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");