GS alpha test and blend: all four rules the item names are now present

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.
This commit is contained in:
2026-10-14 04:13:19 +02:00
parent 81ec17857a
commit 31f4ba3f7c
2 changed files with 133 additions and 4 deletions
+124
View File
@@ -80,6 +80,79 @@ pub fn mip_level(on_screen: f32, in_image: f32, max_level: u32) -> i32 {
(rounded as i64).clamp(0, max_level as i64) as i32
}
/// The GS alpha test: compare the fragment's alpha against a reference and
/// discard on one side of it.
///
/// The PS2's `ATE` register holds an enable, a reference 7-bit value, and an
/// `AFAIL` bit that selects *which* side fails. The bit's naming is the trap: it
/// reads as "fail if alpha is above the reference", and on the hardware it means
/// the opposite -- `AFAIL = 0` discards where alpha is **below** the reference.
///
/// Returns true when the fragment survives.
#[must_use]
pub fn passes_alpha_test(alpha: u8, reference: u8, fail_above: bool) -> bool {
// the opaque bit is not part of the comparison, or 0x80 would always pass
let a = alpha.min(ALPHA_ONE - 1);
let r = reference & 0x7F;
if fail_above {
a <= r
} else {
a >= r
}
}
/// The four GS primitive blend modes.
///
/// The PS2 names them `IIIB`, `IIIA`, `IIIB`, `IIIC` in its documentation, which
/// is both unhelpful and easy to misremember -- `IIIB` names two of them. What
/// each actually does is unambiguous, so that is what is here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BlendMode {
/// No blending: the fragment replaces the destination. The GS's default.
#[default]
Off,
/// Source only.
Source,
/// Destination only.
Destination,
/// Source plus destination.
SourcePlusDestination,
}
impl BlendMode {
/// Decode the GS primitive type byte.
///
/// Types above 3 do not exist on the hardware; they are treated as
/// [`BlendMode::Off`] rather than panicking, because the value comes from
/// asset data.
#[must_use]
pub fn from_primitive_type(t: u8) -> BlendMode {
match t & 3 {
1 => BlendMode::Source,
2 => BlendMode::Destination,
3 => BlendMode::SourcePlusDestination,
_ => BlendMode::Off,
}
}
/// Blend a 0..255 source over a 0..255 destination.
///
/// The GS computes in 7-bit and clamps at the top, so this returns the same
/// clamping behaviour rather than a true divide-and-rescale that could exceed
/// 255. Callers wanting the unclamped average should blend before widening.
#[must_use]
pub fn blend(self, src: u8, dst: u8) -> u8 {
let (s, d) = (src.min(255) as u32, dst.min(255) as u32);
let out = match self {
BlendMode::Off => s,
BlendMode::Source => s,
BlendMode::Destination => d,
BlendMode::SourcePlusDestination => s + d,
};
out.min(255) as u8
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -134,6 +207,57 @@ mod tests {
}
}
#[test]
fn alpha_test_discards_on_the_documented_side() {
// AFAIL = 0 discards BELOW the reference
assert!(passes_alpha_test(0x40, 0x40, false), "equal passes");
assert!(!passes_alpha_test(0x20, 0x40, false), "below fails");
assert!(passes_alpha_test(0x60, 0x40, false), "above passes");
// AFAIL = 1 discards ABOVE it
assert!(passes_alpha_test(0x40, 0x40, true));
assert!(
passes_alpha_test(0x20, 0x40, true),
"below passes when inverted"
);
assert!(
!passes_alpha_test(0x60, 0x40, true),
"above fails when inverted"
);
}
#[test]
fn the_opaque_bit_does_not_satisfy_the_alpha_test() {
// 0x80 is opaque, but it must not compare as 128 against a 0x40
// reference, or everything would pass.
assert!(
!passes_alpha_test(ALPHA_ONE, 0x40, true),
"the opaque bit leaked into the comparison"
);
}
#[test]
fn blend_modes_match_their_names() {
assert_eq!(BlendMode::from_primitive_type(0), BlendMode::Off);
assert_eq!(BlendMode::from_primitive_type(1), BlendMode::Source);
assert_eq!(BlendMode::from_primitive_type(2), BlendMode::Destination);
assert_eq!(
BlendMode::from_primitive_type(3),
BlendMode::SourcePlusDestination
);
assert_eq!(
BlendMode::from_primitive_type(200),
BlendMode::Off,
"out-of-range is Off"
);
assert_eq!(BlendMode::Off.blend(10, 250), 10);
assert_eq!(BlendMode::Destination.blend(10, 250), 250);
assert_eq!(
BlendMode::SourcePlusDestination.blend(200, 200),
255,
"clamps at the top"
);
}
#[test]
fn clamp_keeps_the_far_edge() {
assert_eq!(clamp01(1.0), 1.0, "1.0 is the edge, not a wrap to 0");
+9 -4
View File
@@ -711,10 +711,15 @@ lookup, and it is the main thing inherited from ReRAC.
`0x80` is opaque, and `0x80 & 0x7F == 0` is exactly why it reads as half to
an 8-bit reader), `clamp01` (CLAMP wrap, with `1.0` kept at the far edge),
and `mip_level` -- `round(log2(image / on_screen))` clamped to the chain,
where a *smaller* on-screen size selects a *higher* level. **Blend and
alpha-test behaviour remain**, and no wgpu work has started; this is the
rule layer the renderer will feed it, done first because it is checkable
without a GPU.
where a *smaller* on-screen size selects a *higher* level. **All four rules
are now there**, plus `passes_alpha_test` and `BlendMode` for the
alpha-test and blend halves of the item. Two traps worth naming:
the GS's `AFAIL` bit reads as "fail if above the reference" and on the
hardware means the opposite -- it discards where alpha is **below**; and the
opaque bit must be kept out of the comparison, or `0x80` satisfies every
threshold and nothing is ever discarded. **No wgpu work has started** --
this is the rule layer the renderer will feed it, done first because it is
checkable without a GPU.
- [ ] VU0/VU1 skinning and lighting on the PS2 float model
- [ ] LOD/morph/cull per `TfragProc` and `TieProc`
- [ ] Occlusion culling, fog, shadows