Decode tfrag normals from the overlay's sine table

Normals are per-vertex, and the tfrag's light records store them as an
azimuth and elevation *index* into a 256-entry sine table rather than as
angles. This decodes that table and builds normals for the renderer.

The reference reads the table from an ELF at BOOT_NORMAL_TABLE_VADDR
(0x165500) and notes every level overlay carries a copy. The overlay has
no ELF header -- it is a raw loadable image starting with code -- so there
is no program header to map a virtual address through, and the offset is
not fixed either: level 0 has the table at 0xb650c, level 5 at 0xcca30,
level 26 at 0xb6284.

So it is located by content. Every table starts with exactly (1.0, 0.0),
angle zero, and the scan then requires all 256 entries to match
cos(i*pi/128), sin(i*pi/128). Validating the whole curve is what makes the
search safe: a lone (1.0, 0.0) pair of floats occurs by accident in a
2.7 MB executable, and a test plants one as a decoy. The values are read
from the disc rather than recomputed, since those are the numbers the game
indexes.

A normal is (cos(az)cos(el), sin(az)cos(el), sin(el)) *negated* -- the
stored normals point into the surface -- and then given the same Z-up to
Y-up swap as positions, or the lighting comes out sideways. The azimuth
and elevation fields are stored signed but index 0..=255, so entry 200
must be read as 200, not as -56; there is a test for that.

Also frames and parses the level light bank from the gameplay extent, and
the pipeline now writes overlay.bin. The per-vertex lighting evaluation
is not written yet, so the renderer stays unlit by default. GC_ENGINE_LIT=1
substitutes a host directional light for inspection -- that is a stand-in,
not the game's lighting, and it is deliberately not the default.

The hash table is regenerated for the 27 new overlay.bin files: now 5,213
files and 5,762,497,559 bytes.
This commit is contained in:
2026-10-10 03:09:07 +02:00
parent 17c7e8ccc9
commit 90f02890f8
9 changed files with 477 additions and 16 deletions
+1
View File
@@ -6,4 +6,5 @@ pub mod level_data;
pub mod level_wad;
pub mod texture;
pub mod tfrag;
pub mod tfrag_light;
pub mod wad;
+15 -2
View File
@@ -611,13 +611,26 @@ pub struct TfragRgba {
pub struct TfragLight {
pub unknown_0: i8,
pub intensity: i8,
/// Normal azimuth, pi/128 radians per unit.
/// Normal azimuth, as an *index* into the overlay's 256-entry sine table.
pub azimuth: i8,
/// Normal elevation, pi/128 radians per unit.
/// Normal elevation, as an index into the same table.
pub elevation: i8,
pub color: i16,
}
impl TfragLight {
/// 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 {
self.azimuth as u8
}
/// Elevation as the unsigned table index.
pub fn elevation_index(&self) -> u8 {
self.elevation as u8
}
}
/// Everything one tfrag contributes to a draw call.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TfragGeometry {
+271
View File
@@ -0,0 +1,271 @@
//! 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<NormalTable> {
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<DirLightSet>,
pub point_lights: Vec<PointLight>,
}
/// 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<LightBank> {
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<DirLightSet> {
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<u8> {
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());
}
}