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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user