//! Tfrag lighting inputs: the normal table and the level light bank. //! //! Spec: `docs/formats/tfrag.md`, `docs/formats/tfrag-lighting.md`. //! //! A tfrag's own light records store an azimuth and an elevation per vertex, //! as *indices* into a 256-entry sine table rather than as angles. The table //! lives in the level's overlay executable, and it is not at a fixed offset: //! level 0 has it at `0xb650c`, level 5 at `0xcca30`. The overlay is also a //! raw loadable image with **no ELF header**, so there is no program header to //! map a virtual address through. The table is therefore located by its own //! contents and checked before use — see [`NormalTable::from_overlay`]. //! //! Nothing here reproduces the lighting *arithmetic*. That runs in the PS2's //! float model, which the engine owns; this module only decodes the inputs //! exactly. use crate::buf::{Buf, Error, ErrorKind, Result}; /// 256 pairs of `(cos, sin)` as raw PS2 float bits. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NormalTable { /// `[angle][0]` is cos, `[angle][1]` is sin, for `angle * pi / 128`. pub cos_sin: Vec<[u32; 2]>, } /// How far a candidate entry may differ from `cos(i * pi / 128)` and still be /// accepted. The disc's values are f32 and the check is f64, so exact equality /// is not available; the tolerance is far below anything the table could /// legitimately differ by. const TABLE_EPSILON: f64 = 1e-3; impl NormalTable { /// Find the sine table inside a level overlay. /// /// The overlay carries no ELF header, so the table cannot be reached /// through a program header the way the boot executable's copy can. Every /// table begins with exactly `(1.0, 0.0)` — angle 0 — so the scan starts /// there and then requires all 256 entries to match the expected sine /// curve. Validating the whole table rather than just the first entry is /// what makes the search safe: a lone `1.0, 0.0` pair occurs by accident. pub fn from_overlay(overlay: &[u8]) -> Result { if overlay.len() < 256 * 8 { return Err(Error { offset: 0, context: "overlay is too small to hold a normal table", kind: ErrorKind::OutOfBounds { needed: 256 * 8, available: overlay.len(), }, }); } let mut expected = [0f64; 512]; for i in 0..256usize { let a = i as f64 * std::f64::consts::PI / 128.0; expected[i * 2] = a.cos(); expected[i * 2 + 1] = a.sin(); } // The first entry must be exactly (1, 0) before the table is even a // candidate; that is what makes the scan cheap. let mut at = 0usize; while at + 256 * 8 <= overlay.len() { let (c, s) = read_pair(overlay, at); if c == 1.0 && s == 0.0 && matches_curve(overlay, at, &expected) { let mut cos_sin = Vec::with_capacity(256); for i in 0..256usize { let (c, s) = read_pair(overlay, at + i * 8); cos_sin.push([c.to_bits(), s.to_bits()]); } return Ok(NormalTable { cos_sin }); } at += 4; } Err(Error { offset: 0, context: "no normal table found in the overlay", kind: ErrorKind::Invalid("normal table not found"), }) } /// Cosine and sine for an angle index, as the PS2 floats the game uses. pub fn get(&self, index: u8) -> (f32, f32) { let e = self.cos_sin[index as usize]; (f32::from_bits(e[0]), f32::from_bits(e[1])) } /// A surface normal from an azimuth and elevation index. /// /// `decode_normal` in the reference builds `(cos(a)cos(e), sin(a)cos(e), /// sin(e))` and then negates all three, so the stored normals point /// *into* the surface. Preserved rather than corrected: fidelity beats /// what looks right. pub fn normal(&self, azimuth: u8, elevation: u8) -> [f32; 3] { let (ca, sa) = self.get(azimuth); let (ce, se) = self.get(elevation); [-(ca * ce), -(sa * ce), -se] } } fn read_pair(bytes: &[u8], at: usize) -> (f32, f32) { let c = f32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()); let s = f32::from_le_bytes(bytes[at + 4..at + 8].try_into().unwrap()); (c, s) } fn matches_curve(overlay: &[u8], at: usize, expected: &[f64; 512]) -> bool { for i in 0..256usize { let (c, s) = read_pair(overlay, at + i * 8); if !c.is_finite() || !s.is_finite() { return false; } if (c as f64 - expected[i * 2]).abs() > TABLE_EPSILON { return false; } if (s as f64 - expected[i * 2 + 1]).abs() > TABLE_EPSILON { return false; } } true } /// The level's directional and point light sets, from the `gameplay` extent. /// /// Only the framing is decoded here. The lighting pass itself needs the PS2 /// float model and lives in the engine. #[derive(Debug, Clone, Default, PartialEq)] pub struct LightBank { /// Directional light sets, in the order the microcode indexes them. pub sets: Vec, pub point_lights: Vec, } /// One directional light pair: a colour and a direction for each of A and B. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct DirLightSet { pub color_a: [f32; 4], pub color_b: [f32; 4], pub dir_a: [f32; 4], pub dir_b: [f32; 4], } /// A point light. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct PointLight { pub color: [f32; 4], pub pos: [f32; 4], } /// Cap the light counts so a corrupt header cannot ask for a huge allocation. const MAX_LIGHTS: usize = 64; impl LightBank { /// Parse the bank from a level's `gameplay` extent. /// /// The header at +0x04 is an offset to a count followed by the sets; a /// negative count means the level has no lights. pub fn parse(gameplay: &[u8]) -> Result { let mut b = Buf::new(gameplay); let ofs = b.u32("light bank offset")? as usize; let count = b.i32("directional light count")?; if count < 0 { return Ok(LightBank::default()); } let n = (count as usize).min(MAX_LIGHTS); let mut sets = Vec::with_capacity(n); for _ in 0..n { sets.push(read_set(gameplay, ofs + 0x10 + sets.len() * 0x40)?); } Ok(LightBank { sets, point_lights: Vec::new(), }) } } /// One 0x40-byte directional light set: four V4 lanes each for colour A, /// direction A, colour B and direction B. fn read_set(bytes: &[u8], at: usize) -> Result { at.checked_add(0x40) .filter(|&e| e <= bytes.len()) .ok_or(Error { offset: at, context: "directional light set runs past the gameplay extent", kind: ErrorKind::OutOfBounds { needed: 0x40, available: bytes.len().saturating_sub(at), }, })?; let v4 = |o: usize| -> [f32; 4] { let mut v = [0f32; 4]; for (i, slot) in v.iter_mut().enumerate() { *slot = f32::from_bits(u32::from_le_bytes( bytes[o + i * 4..o + i * 4 + 4].try_into().unwrap(), )); } v }; Ok(DirLightSet { color_a: v4(at), dir_a: v4(at + 0x10), color_b: v4(at + 0x20), dir_b: v4(at + 0x30), }) } #[cfg(test)] mod tests { use super::*; /// A synthetic overlay holding one correct table plus noise around it. fn overlay_with_table() -> Vec { let mut v = vec![0u8; 0x40]; // A decoy that starts with (1, 0) but then leaves the curve. v[0x10..0x14].copy_from_slice(&1.0f32.to_le_bytes()); v[0x14..0x18].copy_from_slice(&0.0f32.to_le_bytes()); v[0x18..0x1c].copy_from_slice(&9.0f32.to_le_bytes()); let at = 0x400usize; v.resize(at + 256 * 8, 0); for i in 0..256usize { let a = i as f64 * std::f64::consts::PI / 128.0; v[at + i * 8..at + i * 8 + 4].copy_from_slice(&(a.cos() as f32).to_le_bytes()); v[at + i * 8 + 4..at + i * 8 + 8].copy_from_slice(&(a.sin() as f32).to_le_bytes()); } v } #[test] fn finds_the_table_and_skips_decoys() { let o = overlay_with_table(); let t = NormalTable::from_overlay(&o).expect("table should be found"); assert_eq!(t.cos_sin.len(), 256); assert_eq!(t.get(0), (1.0, 0.0)); assert!(t.get(64).0.abs() < 1e-6); assert!((t.get(64).1 - 1.0).abs() < 1e-5); assert!((t.get(128).0 + 1.0).abs() < 1e-5); } #[test] fn a_short_overlay_is_an_error_not_a_panic() { assert!(NormalTable::from_overlay(&[0u8; 16]).is_err()); assert!(NormalTable::from_overlay(&[]).is_err()); assert!(NormalTable::from_overlay(&vec![0u8; 4096]).is_err()); } #[test] fn normals_point_into_the_surface() { let t = NormalTable::from_overlay(&overlay_with_table()).unwrap(); // Azimuth 0, elevation 0 is (1, 0, 0) before negation. assert_eq!(t.normal(0, 0), [-1.0, 0.0, 0.0]); // Elevation 64 is pi/2, so z is +/-1. let n = t.normal(0, 64); assert!(n[0].abs() < 1e-5 && n[1].abs() < 1e-5); assert!((n[2] + 1.0).abs() < 1e-5); // Every normal is a unit vector. for a in [0u8, 32, 96, 200] { for e in [0u8, 32, 64, 96] { let n = t.normal(a, e); let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); assert!((len - 1.0).abs() < 1e-4, "a={a} e={e} len={len}"); } } } #[test] fn a_level_with_no_lights_parses_as_empty() { let mut g = vec![0u8; 32]; g[0x04..0x08].copy_from_slice(&(-1i32).to_le_bytes()); let b = LightBank::parse(&g).expect("should parse"); assert!(b.sets.is_empty()); } }