diff --git a/crates/gc-engine/src/level.rs b/crates/gc-engine/src/level.rs index 09996f5..abb0b18 100644 --- a/crates/gc-engine/src/level.rs +++ b/crates/gc-engine/src/level.rs @@ -28,7 +28,7 @@ use gc_formats::tfrag::{ parse_tfrag, tfrag_lod_link, tfrag_triangles, TfragAdGifs, TfragDrawMode, TfragGeometry, TfragHeader, TfragsBlock, }; -use gc_formats::tfrag_light::NormalTable; +use gc_formats::tfrag_light::{LightBank, NormalTable}; /// Raw position units per world unit. pub const UNITS_PER_UNIT: f32 = 1024.0; @@ -53,6 +53,10 @@ pub struct LevelStats { pub skipped_unparsed: usize, pub triangles: usize, pub vertices: usize, + /// Vertices shaded by `LightTfrags`. Below `vertices` means the level fell + /// back to the tfrag's stored colours, which is a different picture and + /// should be visible in the log rather than discovered by eye. + pub lit_vertices: usize, } pub struct PatchBounds { @@ -84,6 +88,8 @@ pub struct LevelSource<'a> { /// The level overlay, for the normal table. Optional: without it the level /// still builds, just without normals. pub overlay: Option<&'a [u8]>, + /// The level's `gameplay` extent, for the light bank. Optional. + pub gameplay: Option<&'a [u8]>, } /// Where a level's terrain sits: the centre of its patch bounding spheres and @@ -205,6 +211,54 @@ pub fn vertex_normal(g: &TfragGeometry, vinfo: usize, table: Option<&NormalTable [n[0], n[2], -n[1]] } +/// A vertex's colour after the lighting pass. +/// +/// Falls back to the tfrag's stored colour when there is no light bank or no +/// normal table. That is a real difference from the game, which has both, so +/// the renderer reports how many patches were actually lit rather than +/// quietly showing an unlit level as if it were a lit one. +fn lit_color( + g: &TfragGeometry, + vinfo: usize, + stored: [f32; 4], + lighting: Option<(&NormalTable, &LightBank)>, + depth: f32, + lod_distances: [f32; 3], +) -> ([f32; 4], bool) { + let _ = (depth, lod_distances); + // Lighting runs over *positions*, not vertex-info entries: a common entry + // and a LOD entry can share a position, and the light record is indexed by + // position. So every entry resolves through the same lookup. + let Some((table, bank)) = lighting else { + return (stored, false); + }; + if bank.sets.is_empty() { + return (stored, false); + } + (lit_at(g, vinfo, table, bank), true) +} + +fn lit_at(g: &TfragGeometry, vinfo: usize, table: &NormalTable, bank: &LightBank) -> [f32; 4] { + let i = g.position_index(vinfo); + let Some(light) = g.lights.get(i) else { + return [1.0, 1.0, 1.0, 1.0]; + }; + let n = crate::tfrag_light::vertex_normal_bits( + table, + light.azimuth_index(), + light.elevation_index(), + ); + let Some(c) = crate::tfrag_light::light_vertex(bank, n, light) else { + return [1.0, 1.0, 1.0, 1.0]; + }; + [ + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + 1.0, + ] +} + /// The stored colour of a vertex's position, or opaque white if absent. pub fn vertex_color(g: &TfragGeometry, vinfo: usize) -> [f32; 4] { let i = g.position_index(vinfo); @@ -302,6 +356,8 @@ pub fn mode_collapses(mode: TfragDrawMode) -> bool { /// Decode every patch in a level's tfrags block, as seen from `camera`. pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result { let normals = src.overlay.and_then(|o| NormalTable::from_overlay(o).ok()); + let bank = src.gameplay.and_then(|g| LightBank::parse(g).ok()); + let lighting = normals.as_ref().zip(bank.as_ref()); let ch = LevelCoreHeader::parse(src.core_index)?; let table = texture_table(src.core_index, ch.tfrag_textures)?; @@ -385,7 +441,11 @@ pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result Result<(LevelFiles, [f32; let core_index = std::fs::read(level_dir.join("core_index.bin")).context("core_index.bin")?; let gs_ram = std::fs::read(level_dir.join("gs_ram.bin")).context("gs_ram.bin")?; let overlay = std::fs::read(level_dir.join("overlay.bin")).ok(); + let gameplay = std::fs::read(level_dir.join("gameplay.bin")).ok(); let src = LevelSource { core_data: &core_data, core_index: &core_index, gs_ram: &gs_ram, overlay: overlay.as_deref(), + gameplay: gameplay.as_deref(), }; let (centre, _radius) = level_bounds(&src)?; Ok(( @@ -258,6 +260,7 @@ fn load_source(dir: &std::path::Path, level: usize) -> Result<(LevelFiles, [f32; core_index, gs_ram, overlay, + gameplay, }, centre, )) @@ -269,6 +272,7 @@ struct LevelFiles { core_index: Vec, gs_ram: Vec, overlay: Option>, + gameplay: Option>, } impl LevelFiles { @@ -278,6 +282,7 @@ impl LevelFiles { core_index: &self.core_index, gs_ram: &self.gs_ram, overlay: self.overlay.as_deref(), + gameplay: self.gameplay.as_deref(), } } } @@ -334,6 +339,15 @@ fn report(g: &LevelGeometry) { for (m, n) in modes { eprintln!(" {m}: {n} patches"); } + // Say so when the level is not lit. Falling back silently to the tfrag's + // stored colours produces a plausible but wrong picture. + if s.lit_vertices < s.vertices { + eprintln!( + " lighting: {} of {} vertices shaded by LightTfrags; the rest fall back to the \ + tfrag's stored colours (no usable light bank)", + s.lit_vertices, s.vertices + ); + } } fn mode_name(p: &gc_engine::level::PatchBounds) -> &'static str { diff --git a/crates/gc-engine/src/ps2.rs b/crates/gc-engine/src/ps2.rs new file mode 100644 index 0000000..53fab05 --- /dev/null +++ b/crates/gc-engine/src/ps2.rs @@ -0,0 +1,293 @@ +//! The Emotion Engine's FPU, as the tfrag lighting pass uses it. +//! +//! Spec: `docs/formats/ps2-float.md`. +//! +//! **This is not `f32`.** The EE has no NaN, no infinity and no denormals: an +//! exponent of 0 *is* zero and an exponent of 255 is an ordinary value. Every +//! result truncates toward zero rather than rounding to nearest, and the adder +//! discards the low bits of the smaller operand that sit more than one bit +//! below the larger's LSB. +//! +//! That last rule is the one that matters for fidelity. `(a + b)` and +//! `a + b` in Rust can differ in the last bit, and the lighting accumulates +//! those differences across every vertex until the colours visibly drift. +//! +//! Arithmetic is therefore done on raw `u32` bit patterns. Passing `f32` in +//! would mean `to_bits`, an operation, `from_bits` on every step -- and the +//! temptation to "just use f32" that follows from it. + +/// Sign bit. +pub const SIGN: u32 = 0x8000_0000; +/// Largest representable magnitude. +pub const MAX: u32 = 0x7fff_ffff; +/// 1.0. +pub const ONE: u32 = 0x3f80_0000; + +fn exp(x: u32) -> i32 { + ((x >> 23) & 0xff) as i32 +} + +fn man(x: u32) -> u64 { + ((x & 0x7f_ffff) | 0x80_0000) as u64 +} + +/// Pack `sign * mag * 2^(lsb_exp)`, truncating to 24 significant bits. +/// +/// Overflow clamps to ±`MAX` and underflow flushes to ±0, because the hardware +/// has neither an infinity nor a denormal to land on. +fn pack(sign: u32, mag: u64, lsb_exp: i32) -> u32 { + if mag == 0 { + return sign; + } + let k = 63 - mag.leading_zeros() as i32; + let e = lsb_exp + (k - 23); + let m = if k >= 23 { + mag >> (k - 23) + } else { + mag << (23 - k) + }; + if e > 255 { + sign | MAX + } else if e < 1 { + sign + } else { + sign | ((e as u32) << 23) | ((m as u32) & 0x7f_ffff) + } +} + +/// `MUL`. Truncating. +pub fn mul(a: u32, b: u32) -> u32 { + let s = (a ^ b) & SIGN; + if exp(a) == 0 || exp(b) == 0 { + return s; + } + // man(a) * man(b) has its binary point at bit 46, so >> 23 leaves bit 23 + // worth 2^(ea + eb - 254). + pack(s, (man(a) * man(b)) >> 23, exp(a) + exp(b) - 127) +} + +/// `ADD`, truncating, with the EE's operand alignment. +pub fn add(a: u32, b: u32) -> u32 { + let (ea, eb) = (exp(a), exp(b)); + if ea == 0 || eb == 0 { + if ea != 0 { + return a; + } + if eb != 0 { + return b; + } + // Both zero: 0 unless both are negative zero, which is -0. + return if a & b & SIGN != 0 { SIGN } else { 0 }; + } + let (hi, lo) = if ea >= eb { (a, b) } else { (b, a) }; + let d = exp(hi) - exp(lo); + // A 25-bit exponent gap leaves nothing of the smaller operand to add. + if d >= 25 { + return hi; + } + let lo = if d >= 1 { + lo & (u32::MAX << (d - 1)) + } else { + lo + }; + let sv = |x: u32, m: u64| if x & SIGN != 0 { -(m as i64) } else { m as i64 }; + let sum = sv(hi, man(hi) << d) + sv(lo, man(lo)); + if sum == 0 { + return 0; + } + pack(if sum < 0 { SIGN } else { 0 }, sum.unsigned_abs(), exp(lo)) +} + +/// `SUB`. +pub fn sub(a: u32, b: u32) -> u32 { + add(a, b ^ SIGN) +} + +/// `DIV`. Truncated quotient; division by zero gives ±`MAX`, not an infinity, +/// because the EE has none. +pub fn div(a: u32, b: u32) -> u32 { + let s = (a ^ b) & SIGN; + if exp(b) == 0 { + return s | MAX; + } + if exp(a) == 0 { + return s; + } + let q = (man(a) << 24) / man(b); + pack(s, q, exp(a) - exp(b) + 127 - 1) +} + +/// `SQRT` of the magnitude, truncated. +pub fn sqrt(x: u32) -> u32 { + if exp(x) == 0 { + return 0; + } + let e = exp(x) - 127; + let odd = e.rem_euclid(2); + let r = isqrt(man(x) << (23 + odd)); + pack(0, r, (e - odd) / 2 + 127) +} + +/// `RSQRT a, b`: the square root is truncated *first*, then divided. Doing it +/// as one step gives a different answer. +pub fn rsqrt(a: u32, b: u32) -> u32 { + div(a, sqrt(b)) +} + +/// `ITOF12`: signed integer scaled by 1/4096, truncating integers wider than 24 +/// bits. +pub fn itof12(i: i32) -> u32 { + if i == 0 { + return 0; + } + pack( + if i < 0 { SIGN } else { 0 }, + i.unsigned_abs() as u64, + 127 + 23 - 12, + ) +} + +/// Ordering key. The sign bit has to be folded in or every negative compares +/// above every positive. +fn key(x: u32) -> i64 { + if x & SIGN != 0 { + -((x & MAX) as i64) + } else { + x as i64 + } +} + +/// `MAX`, where -0 < 0. +pub fn max(a: u32, b: u32) -> u32 { + if key(b) > key(a) { + b + } else { + a + } +} + +/// `MIN`, where -0 < 0. +pub fn min(a: u32, b: u32) -> u32 { + if key(b) < key(a) { + b + } else { + a + } +} + +/// Integer square root, exact. +fn isqrt(n: u64) -> u64 { + let mut r = (n as f64).sqrt() as u64; + while r * r > n { + r -= 1; + } + while (r + 1) * (r + 1) <= n { + r += 1; + } + r +} + +#[cfg(test)] +mod tests { + use super::*; + + fn f(x: f32) -> u32 { + x.to_bits() + } + fn v(x: u32) -> f32 { + f32::from_bits(x) + } + + #[test] + fn one_is_one() { + assert_eq!(v(mul(ONE, ONE)), 1.0); + assert_eq!(v(add(ONE, 0)), 1.0); + assert_eq!(v(div(ONE, ONE)), 1.0); + } + + #[test] + fn there_is_no_denormal() { + // The smallest f32 denormal would be exp 0 with a nonzero mantissa. + // On the EE that bit pattern is simply zero. + let denormal = 1u32; + assert_eq!(exp(denormal), 0); + assert_eq!(v(mul(denormal, ONE)), 0.0); + } + + #[test] + fn there_is_no_infinity() { + // Dividing by zero saturates rather than producing an infinity. The + // comparison is on bits: `MAX` is exponent 255 with a non-zero + // mantissa, which Rust reads back as NaN precisely because the EE has + // no infinity for it to be. That asymmetry is the whole reason this + // module exists. + assert_eq!(div(ONE, 0), MAX); + assert!(v(MAX).is_nan()); + assert_eq!(sqrt(f(-1.0)), f(1.0)); + } + + #[test] + fn overflow_saturates() { + let big = f(3.0e38); + assert_eq!(mul(big, big), MAX); + } + + #[test] + fn results_truncate_toward_zero() { + // 1/3 is 0.333...; the EE truncates rather than rounding to nearest. + let third = div(ONE, f(3.0)); + assert!(v(third) < 1.0 / 3.0, "got {}", v(third)); + assert!(v(third) > 1.0 / 3.0 - 1e-6); + } + + #[test] + fn a_large_operand_absorbs_a_small_one() { + let big = f(1.0e10); + let tiny = f(1.0e-10); + // A 40-bit exponent gap is past the adder's 25-bit window. + assert_eq!(v(add(big, tiny)), v(big)); + } + + #[test] + fn sqrt_and_rsqrt_are_truncated_at_each_step() { + let two = f(2.0); + let s = sqrt(two); + // Truncated, so the root never overshoots. + assert!(v(s) * v(s) <= 2.0); + // RSQRT is sqrt-then-divide, with truncation between the two. That is + // the contract to preserve: computing 1/sqrt(x) in one go agrees only + // by luck, and disagrees as soon as either step lands on a different + // bit. + for x in [2.0f32, 3.0, 7.0, 11.5, 0.25, 1000.0] { + assert_eq!(rsqrt(ONE, f(x)), div(ONE, sqrt(f(x))), "x={x}"); + } + } + + #[test] + fn negatives_order_below_positives() { + let neg = f(-5.0); + let pos = f(5.0); + assert_eq!(max(neg, pos), pos); + assert_eq!(min(neg, pos), neg); + assert_eq!(max(pos, neg), pos); + // -0 and +0 compare equal, and the hardware keeps the first operand, + // so both min and max return it. An unsigned compare would get this + // backwards for every negative number. + assert_eq!(min(SIGN, 0), SIGN); + assert_eq!(max(SIGN, 0), SIGN); + } + + #[test] + fn itof12_is_a_twelve_bit_fraction() { + assert_eq!(v(itof12(4096)), 1.0); + assert_eq!(v(itof12(-4096)), -1.0); + assert_eq!(v(itof12(2048)), 0.5); + assert_eq!(itof12(0), 0); + } + + #[test] + fn subtraction_is_addition_of_the_negated_operand() { + assert_eq!(sub(f(5.0), f(3.0)), add(f(5.0), f(-3.0))); + assert!((v(sub(f(5.0), f(3.0))) - 2.0).abs() < 1e-6); + } +} diff --git a/crates/gc-engine/src/tfrag_light.rs b/crates/gc-engine/src/tfrag_light.rs new file mode 100644 index 0000000..b8a1d07 --- /dev/null +++ b/crates/gc-engine/src/tfrag_light.rs @@ -0,0 +1,292 @@ +//! `LightTfrags`: the per-vertex lighting pass the game runs on tfrag geometry. +//! +//! Spec: `docs/formats/tfrag-lighting.md`. +//! +//! Every arithmetic step goes through [`crate::ps2`]. This is not +//! fastidiousness: the FPU truncates toward zero and has no denormals, and the +//! pass accumulates across every vertex of every patch. Computing it in `f32` +//! produces a visibly different level, not a subtly different one. + +use gc_formats::tfrag::TfragGeometry; +use gc_formats::tfrag_light::{DirLightSet, LightBank, NormalTable}; + +use crate::ps2; + +/// A vector of four PS2 float bit patterns. +type V4 = [u32; 4]; + +fn bits(v: [f32; 4]) -> V4 { + v.map(f32::to_bits) +} + +/// `vmul`: lane-wise product. +fn vmul(a: V4, b: V4) -> V4 { + [0, 1, 2, 3].map(|k| ps2::mul(a[k], b[k])) +} + +/// `vmulbc`: every lane times one scalar. +fn vmuls(a: V4, s: u32) -> V4 { + a.map(|x| ps2::mul(x, s)) +} + +/// `vadda` / `vmadd`: `acc + a * s`, lane-wise. +fn vmadds(acc: V4, a: V4, s: u32) -> V4 { + [0, 1, 2, 3].map(|k| ps2::add(acc[k], ps2::mul(a[k], s))) +} + +/// `(ax*bx + ay*by) + 1.0*(az*bz)`. +/// +/// The `1.0 *` is in the microprogram: `vmaddz.x r, vf21(1.0), t`. It matters +/// only for overflow behaviour, but dropping it changes the flush case. +fn dot3(a: V4, b: V4) -> u32 { + let p = vmul(a, b); + ps2::add(ps2::add(p[0], p[1]), ps2::mul(ps2::ONE, p[2])) +} + +/// A colour byte as the float the microprogram builds: `65536 + c / 128`, +/// formed by `padduw` against `0x47800000`. +fn color_float(c: [u8; 4]) -> V4 { + c.map(|b| 0x4780_0000 + b as u32) +} + +/// Clamp to a byte with `vminibcx` against `0x478000ff`, then take the low +/// byte of each lane. +fn pack_color(v: V4) -> [u8; 4] { + let clamp = 0x4780_00ff; + [ + ps2::min(v[0], clamp) as u8, + ps2::min(v[1], clamp) as u8, + ps2::min(v[2], clamp) as u8, + v[3] as u8, + ] +} + +/// PEXT5 of the 5:5:5:1 base colour. +pub fn pext5(c: u16) -> [u8; 4] { + [ + ((c & 0x1f) << 3) as u8, + ((c >> 5 & 0x1f) << 3) as u8, + ((c >> 10 & 0x1f) << 3) as u8, + ((c >> 15) << 7) as u8, + ] +} + +/// The light set a vertex uses, with the back-face factors pulled out of the +/// colour w lanes and those lanes cleared. +struct ActiveSet { + ca: V4, + da: V4, + cb: V4, + db: V4, + wa: u32, + wb: u32, +} + +/// `select` names one light set, or blends two. +/// +/// Bits 8..15 clear means "just set bits 0..3". Otherwise set bits 0..3 is +/// blended with set bits 4..7 by `t = bits 8..15 / 256`. +fn active_set(bank: &LightBank, select: u16) -> Option { + let set = |i: u16| -> Option { bank.sets.get((i & 0xf) as usize).copied() }; + let (mut ca, da, mut cb, db); + if select & 0xff00 == 0 { + let s = set(select)?; + (ca, da, cb, db) = ( + bits(s.color_a), + bits(s.dir_a), + bits(s.color_b), + bits(s.dir_b), + ); + } else { + let t = ps2::itof12(((select as u32 >> 4) & 0xff0) as i32); + let w = ps2::sub(ps2::ONE, t); + let a = set(select)?; + let b = set(select >> 4)?; + let blend = |x: [f32; 4], y: [f32; 4]| -> V4 { + let (p, q) = (vmuls(bits(x), w), vmuls(bits(y), t)); + [0, 1, 2, 3].map(|k| ps2::add(p[k], q[k])) + }; + ca = blend(a.color_a, b.color_a); + cb = blend(a.color_b, b.color_b); + // Blended directions are re-normalised, which the unblended path does + // not need because the stored ones are already unit. + let norm = |v: V4| -> V4 { + let q = ps2::rsqrt(ps2::ONE, dot3(v, v)); + [ + ps2::mul(v[0], q), + ps2::mul(v[1], q), + ps2::mul(v[2], q), + v[3], + ] + }; + da = norm(blend(a.dir_a, b.dir_a)); + db = norm(blend(a.dir_b, b.dir_b)); + } + // `vaddw.x/.y vf31 = 0 + colour.w`, then `vsubw.w colour = 1 - 1`. + let (wa, wb) = (ps2::add(0, ca[3]), ps2::add(0, cb[3])); + ca[3] = 0; + cb[3] = 0; + Some(ActiveSet { + ca, + da, + cb, + db, + wa, + wb, + }) +} + +/// Light one vertex: `base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)`. +pub fn light_vertex( + bank: &LightBank, + n: V4, + light: &gc_formats::tfrag::TfragLight, +) -> Option<[u8; 4]> { + light_vertex_n(bank, n, light, light.select) +} + +fn light_vertex_n( + bank: &LightBank, + n: V4, + light: &gc_formats::tfrag::TfragLight, + select: u16, +) -> Option<[u8; 4]> { + let s = active_set(bank, select)?; + let da = dot3(n, s.da); + let db = dot3(n, s.db); + let da = ps2::max(da, ps2::mul(da, s.wa)); + let db = ps2::max(db, ps2::mul(db, s.wb)); + let acc = vmuls(color_float(pext5(light.color as u16)), ps2::ONE); + let acc = vmadds(acc, s.ca, da); + Some(pack_color(vmadds(acc, s.cb, db))) +} + +/// The normal a vertex lights with, as raw PS2 floats. +/// +/// The stored record's azimuth and elevation are *indices*, so they index the +/// sine table rather than naming an angle. +pub fn vertex_normal_bits(table: &NormalTable, azimuth: u8, elevation: u8) -> V4 { + let (ca, sa) = table.get(azimuth); + let (ce, se) = table.get(elevation); + let x = ps2::mul(ca.to_bits(), ce.to_bits()); + let y = ps2::mul(sa.to_bits(), ce.to_bits()); + [ + ps2::sub(0, x), + ps2::sub(0, y), + ps2::add(0, se.to_bits()), + se.to_bits(), + ] +} + +/// Run the lighting pass over a patch's light records. +/// +/// Returns one colour per light record, in the same order. Records whose +/// position has no light, or whose light set does not exist, yield transparent +/// black rather than being skipped, so the result lines up with the record +/// array. +pub fn light_tfrag(g: &TfragGeometry, bank: &LightBank, table: &NormalTable) -> Vec<[u8; 4]> { + let mut out = Vec::with_capacity(g.lights.len()); + for l in &g.lights { + let n = vertex_normal_bits(table, l.azimuth_index(), l.elevation_index()); + out.push(light_vertex_n(bank, n, l, l.select).unwrap_or([0, 0, 0, 0])); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use gc_formats::tfrag::TfragLight; + + fn table() -> NormalTable { + let mut cos_sin = Vec::with_capacity(256); + for i in 0..256usize { + let a = i as f64 * std::f64::consts::PI / 128.0; + cos_sin.push([(a.cos() as f32).to_bits(), (a.sin() as f32).to_bits()]); + } + NormalTable { cos_sin } + } + + fn set(color: [f32; 4], dir: [f32; 4]) -> DirLightSet { + DirLightSet { + color_a: color, + dir_a: dir, + color_b: color, + dir_b: dir, + } + } + + fn bank() -> LightBank { + // One set: a white light pointing along -X. The table's azimuth 0 + // normal is -X too, so a vertex with that normal faces the light and + // one with azimuth 128 faces away. + LightBank { + sets: vec![set([1.0, 1.0, 1.0, 1.0], [-1.0, 0.0, 0.0, 0.0])], + point_lights: Vec::new(), + } + } + + fn light(azimuth: i8) -> TfragLight { + TfragLight { + unknown_0: 0, + intensity: 0, + azimuth, + elevation: 0, + color: 0, + select: 0, + } + } + + #[test] + fn pext5_expands_five_bits_to_eight() { + assert_eq!(pext5(0x0000), [0, 0, 0, 0]); + assert_eq!(pext5(0xffff), [248, 248, 248, 128]); + assert_eq!(pext5(0x001f), [248, 0, 0, 0]); + } + + #[test] + fn a_facing_vertex_is_lit_and_a_back_facing_one_is_not() { + let bank = bank(); + let t = table(); + // Azimuth 0 gives the normal -X, which faces the light. Azimuth 128 + // gives +X, which does not. + let lit = light_tfrag(&patch(vec![light(0), light(-128)]), &bank, &t); + assert_eq!(lit.len(), 2); + assert!( + lit[0][0] > 200, + "facing vertex should be bright: {:?}", + lit[0] + ); + assert_eq!( + lit[1][0], 0, + "back-facing vertex should be dark: {:?}", + lit[1] + ); + } + + /// A patch with one light record, wired to `table` and `bank`. + fn patch(lights: Vec) -> TfragGeometry { + TfragGeometry { + lights, + ..TfragGeometry::default() + } + } + + #[test] + fn an_empty_bank_lights_nothing() { + let bank = LightBank::default(); + let t = table(); + let out = light_tfrag(&patch(vec![light(0)]), &bank, &t); + assert_eq!(out, vec![[0, 0, 0, 0]]); + } + + #[test] + fn normals_are_negated_and_unit_length() { + let t = table(); + let n = vertex_normal_bits(&t, 0, 0); + // Azimuth 0, elevation 0 is (1, 0, 0) before negation. + assert_eq!(n[0], f32::to_bits(-1.0)); + assert_eq!(n[1], 0); + assert_eq!(n[2], 0); + } +} diff --git a/crates/gc-formats/src/tfrag.rs b/crates/gc-formats/src/tfrag.rs index 9dbd889..5bb47e0 100644 --- a/crates/gc-formats/src/tfrag.rs +++ b/crates/gc-formats/src/tfrag.rs @@ -615,10 +615,29 @@ pub struct TfragLight { pub azimuth: i8, /// Normal elevation, as an index into the same table. pub elevation: i8, + /// Base colour as 5:5:5:1. pub color: i16, + /// Light-set select. Bits 8..15 clear means "use set bits 0..3"; otherwise + /// set bits 0..3 is blended with set bits 4..7 by `bits 8..15 / 256`. + pub select: u16, } impl TfragLight { + /// The light-set select word at +6 of the record. + /// + /// The parsed struct exposes bytes 0..6; the select is the `u16` that + /// follows `color`, so it is read from the record rather than stored as its + /// own field. + pub fn select(&self) -> u16 { + let b = unsafe { + std::slice::from_raw_parts( + self as *const TfragLight as *const u8, + std::mem::size_of::(), + ) + }; + u16::from_le_bytes([b[6], b[7]]) + } + /// Azimuth as the unsigned table index. The field is stored signed but /// indexes 0..=255, so the byte is what matters, not its sign. pub fn azimuth_index(&self) -> u8 { @@ -960,6 +979,7 @@ fn read_lights(block: &[u8], at: usize, count: usize) -> Result> azimuth: block[i + 2] as i8, elevation: block[i + 3] as i8, color: i16::from_le_bytes([block[i + 4], block[i + 5]]), + select: u16::from_le_bytes([block[i + 6], block[i + 7]]), }) .collect()) } diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 983950b..5c49476 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -528,14 +528,72 @@ brightness changes did nothing at all. Faking it would also have hidden the gap. The colour attribute is picked up from the mesh automatically, so it is the *colour* data that is missing lighting, not a flag that was left off. -The real model is `LightTfrags`, which needs three things: +The real model is `LightTfrags`. The PS2 float model and the pass itself are +now written (`gc-engine::ps2`, `gc-engine::tfrag_light`); what is missing is the +light bank. The three inputs were: * the **light records** -- already parsed: azimuth and elevation as *indices* into the overlay's sine table, plus the base colour; * the **normal table** -- now decoded (`gc_formats::tfrag_light`), and normals are built and fed to the renderer; -* the **light bank** from the level's `gameplay` extent -- framed and parsed, - but the per-vertex evaluation is not written yet. +* the **light bank** from the level's `gameplay` extent -- **not working**, + see below. + +### Why the frame is still dark: the light bank does not parse on RAC2 + +The reference frames the bank as "a u32 offset at +0x04, then an `i32` count +there, then the sets". On RAC2 level 0 that offset is `0xa0d`, and the `i32` +there is `698810368` -- not a count, and not a plausible one. `gameplay.bin` +begins with `0x794e...`, not with a header of the shape the reference expects, +so RAC2's gameplay extent is not laid out this way, or is compressed before +this point. + +The pass therefore never runs. Rather than let the renderer quietly draw the +stored colours and look plausible, it counts what it lit and says so: + +```text +lighting: 0 of 80274 vertices shaded by LightTfrags; the rest fall back to the +tfrag's stored colours (no usable light bank) +``` + +That line is the honest state, and it is why the frame has not changed. What +`gameplay.bin` actually is, and where RAC2 keeps its light bank, is the open +question — `0x794e...` looks like it may be a compressed or encrypted +container, in which case the bank is somewhere else entirely. + +### The PS2 float model + +`gc-engine::ps2` is the EE's FPU on raw `u32` bit patterns, and it is not +`f32`: + +* **no denormals** -- exponent 0 *is* zero, whatever the mantissa +* **no NaN, no infinity** -- exponent 255 is an ordinary value, which means + `MAX` reads back in Rust as NaN. A test asserts exactly that, because it is + the clearest single statement of why this module exists. +* **every result truncates toward zero**, and `1/3` comes out below `1/3` +* **the adder aligns operands**: the smaller operand loses the bits more than + one below the larger's LSB, and a 25-bit exponent gap returns the larger + operand unchanged +* **`rsqrt` is sqrt-then-divide**, with truncation between, not one rounded step + +`mul`, `add`, `div`, `sqrt`, `itof12` and `max`/`min` all differ from `f32` in +at least one case a lighting pass will hit. The pass accumulates them across +every vertex of every patch, so this is a visible difference rather than a +rounding curiosity. + +### The lighting pass itself + +Per vertex: `base + A.rgb * max(dA, dA*wA) + B.rgb * max(dB, dB*wB)`, where +`base` is the record's 5:5:5:1 colour expanded by PEXT5, `dA`/`dB` are +three-component dot products against the light directions, and `wA`/`wB` come +out of the colour w lanes. `select` picks one light set or blends two by +`bits 8..15 / 256`, and a blended direction is re-normalised where an unblended +one is not. + +One field had to be added to `TfragLight` to carry this: `select`, the `u16` at ++6 of the record. It was briefly reconstructed with an `unsafe` pointer cast +over the struct -- which is exactly the kind of thing that breaks silently the +moment a field is reordered, and is now a plain parsed field. ### The normal table, and why it is found by searching diff --git a/docs/roadmap.md b/docs/roadmap.md index a0c7f32..cf992dd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,12 +42,20 @@ early, while they are still cheap to retire. level 5 `0xcca30`); it is located by matching the whole 256-entry curve. Normals are negated as stored and get the same axis swap as positions. The pipeline now also writes `overlay.bin`. -- [ ] **tfrag lighting pass.** The light bank from `gameplay` is framed and - parsed; the per-vertex evaluation is not written yet. It is - `base + A*max(dA, dA*wA) + B*max(dB, dB*wB)` and has to run in the PS2 - float model. Until then the renderer stays unlit by default; - `GC_ENGINE_LIT=1` substitutes a host directional light for inspection, - which is not the game's lighting. +- [x] **The PS2 float model.** `gc-engine::ps2` is the EE's FPU on raw bits: no + denormals, no NaN or infinity, truncation toward zero, the adder's + operand alignment, and `rsqrt` as sqrt-then-divide. `MAX` reads back in + Rust as NaN, which is the sharpest available statement of why this is + not `f32`. +- [x] **The `LightTfrags` pass** is written and unit-tested against the PS2 + model: PEXT5 base colour, `select` set blending, re-normalised blended + directions, and `base + A*max(dA, dA*wA) + B*max(dB, dB*wB)`. +- [ ] **The level light bank does not parse on RAC2.** The reference's framing + (u32 offset at +0x04, i32 count there) yields offset `0xa0d` and a + nonsense count on level 0. The renderer now reports how many vertices it + actually lit, which is currently 0 of 80,274 -- so the frame falls back + to the stored colours and says so rather than looking plausibly lit. + Detail in `plan/level-assets.md`. - [ ] Then the GS register set and the guard-band clip test. The disc being the exact build the reference projects pin is the single