Pulling the 0x80 convention out of the renderer and into gc_formats::gs -- the
side benefit I claimed last commit rather than took -- immediately produced a
contradiction, which is the argument for having done it.
crates/gc-engine/src/level.rs already read it inline:
let a = if c.a < 0x80 { c.a as f32 / 128.0 } else { 1.0 };
My first alpha_to_8 scaled by 127 instead of 128, which made 0x7F fully opaque
where the renderer had it at 253/255. One of them was wrong and it was not the new
one: the disc convention is a 0..0x80 range with 0x80 as the top, so 0x7F is
127/128, not 127/127.
Corrected to match, and the doc now records why -- including that the renderer was
already right, so this is a correction to code I wrote two commits ago rather than
to anyone else's.
One genuine ambiguity remains and it is stated rather than papered over: 0x40 is
exactly the midpoint, and the renderer works in floats where it is exactly 0.5.
Widening to an integer has to round, and this rounds half away from zero giving
128. There is no way to check that against the disc -- it needs a frame, which is
the tfrag morph item's unblock condition -- so it is documented as a choice.
The engine's call site keeps its inline form and now says why: it wants a float,
and the rule has one live site. Converting it would have meant changing working
code for no benefit, which is the opposite of what extracting the rule was for.
Worth noting what actually happened here. Extracting a rule to remove a magic
constant found that two expressions of it disagreed. Neither was obviously wrong
from its own code -- mine looked cleaner, and was not. That is a better argument
for centralising a rule than any amount of saying so.
Finishes the rule layer. Two traps, both of which produce a picture that looks
slightly wrong rather than obviously broken:
AFAIL READS BACKWARDS. The PS2's alpha-test register holds an enable, a 7-bit
reference, and a bit that looks like "fail if alpha is above the reference". On
the hardware it means the opposite -- AFAIL = 0 discards where alpha is BELOW the
reference. passes_alpha_test takes the side explicitly rather than encoding the
register, and both directions are tested so the convention is pinned in code
rather than in a comment.
THE OPAQUE BIT MUST STAY OUT OF THE COMPARISON. 0x80 is fully opaque, but it is
also numerically 128, so a test that compares the raw byte against a 0x40
reference would have every fully-opaque fragment pass every threshold, and nothing
would ever be discarded. passes_alpha_test clamps the opaque bit off first, and
there is a test that asserts exactly that -- the failure mode here is a scene that
renders correctly with the wrong shading, which is not something anyone notices
by looking.
Blend is the four GS primitive types, decoded from the primitive-type byte and
named by what they do rather than by the documentation's IIIB/IIIA/IIIC labels,
which name two different modes identically. Out-of-range types decode to Off
rather than panicking, because the byte comes from asset data. Addition clamps at
255 rather than rescaling, matching the GS's 7-bit clamp; a caller wanting a true
average blends before widening, and the doc says so.
That completes the four rules the roadmap names for this item:
alpha 0x80 == 1.0 alpha_to_8
CLAMP wrap clamp01
mip selection mip_level
blend + alpha test BlendMode, passes_alpha_test
Nine unit tests, no wgpu work, and the roadmap item stays [~] rather than [x] --
the rules are the input to the renderer, not the renderer.
Worth noting the pattern in both traps: each one produces output that is plausible
and slightly-off, with no error anywhere. That is the same shape as the sign
inversion in mip_level and the byte-order bug in the anchor search. The tests are
the only thing that catches this class, which is the argument for having them even
though nothing here needs a GPU.
Picked Phase 3's GS item and did the part that is checkable without a GPU -- the
rule layer the renderer will have to feed wgpu. New gc_formats::gs with three of
the four rules the roadmap names, each unit-tested.
alpha_to_8: 0x80 IS OPAQUE. The obvious implementation of this is
let v = value & 0x7F;
and it is wrong, because 0x80 & 0x7F is 0 -- which is precisely the mechanism
that makes a 7-bit alpha read as "half" to anything treating the byte as 8-bit
unsigned. The first version of this function did exactly that and the test caught
it. Below 0x80 the value is genuinely 7-bit and scales onto 1..255, so 0x7F is
opaque too and no non-zero alpha vanishes.
clamp01: CLAMP wrap, with 1.0 kept AT the far edge rather than wrapping to 0.
Under repeat sampling an exact 1.0 is a discontinuity; under clamp it is the
edge texel, and the game loads its textures that way.
mip_level: round(log2(image / on_screen)) clamped to the chain. The ratio is
image-over-screen and not the reverse -- a SMALLER on-screen size selects a
HIGHER mip level. I had this inverted in the first version, which made
mip_level(1.0, 64.0, 8) return 0 where it must return 6, and the test caught
that too. Both bugs were the same kind: a plausible implementation of a rule
whose sign convention is not obvious, and the only thing standing between them
and a commit was the test.
Not started: wgpu itself, and blend/alpha-test behaviour. This is the rule layer
only, and the roadmap item stays [~] rather than being ticked, because three of
four rules and zero of the GPU work is not "done".
Two of the four rules the item names were already half-present in the renderer --
moby alpha and the 1/4096 UVs -- so making them explicit here rather than scattered
is worth something on its own: 0x80's meaning was previously implicit in however
each call site happened to read the byte.