diff --git a/crates/gc-formats/src/gs.rs b/crates/gc-formats/src/gs.rs index 7cf5b20..6ad2c87 100644 --- a/crates/gc-formats/src/gs.rs +++ b/crates/gc-formats/src/gs.rs @@ -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"); diff --git a/docs/roadmap.md b/docs/roadmap.md index 5dd1e6c..ca21780 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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