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
+102
View File
@@ -28,6 +28,7 @@ use gc_formats::tfrag::{
parse_tfrag, tfrag_lod_link, tfrag_triangles, TfragAdGifs, TfragDrawMode, TfragGeometry,
TfragHeader, TfragsBlock,
};
use gc_formats::tfrag_light::NormalTable;
/// Raw position units per world unit.
pub const UNITS_PER_UNIT: f32 = 1024.0;
@@ -67,6 +68,8 @@ pub struct TriangleBatch {
pub positions: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub colors: Vec<[f32; 4]>,
/// Surface normals, from the patch's own light records.
pub normals: Vec<[f32; 3]>,
/// Three vertex indices per triangle.
pub indices: Vec<u32>,
/// The ad-gif in force per triangle, parallel to `indices`.
@@ -78,6 +81,9 @@ pub struct LevelSource<'a> {
pub core_data: &'a [u8],
pub core_index: &'a [u8],
pub gs_ram: &'a [u8],
/// The level overlay, for the normal table. Optional: without it the level
/// still builds, just without normals.
pub overlay: Option<&'a [u8]>,
}
/// Where a level's terrain sits: the centre of its patch bounding spheres and
@@ -180,6 +186,25 @@ pub struct ResolvedVertex {
pub color: [f32; 4],
}
/// The surface normal of a vertex's position, in Bevy space.
///
/// Taken from the patch's per-vertex light record, which stores an azimuth and
/// elevation index into the overlay's sine table rather than an angle. Morphing
/// does not rotate the normal -- the game lerps positions and leaves lighting
/// to its own pass -- so this is the vertex's own normal regardless of blend.
pub fn vertex_normal(g: &TfragGeometry, vinfo: usize, table: Option<&NormalTable>) -> [f32; 3] {
let Some(table) = table else {
return [0.0, 1.0, 0.0];
};
let i = g.position_index(vinfo);
let Some(light) = g.lights.get(i) else {
return [0.0, 1.0, 0.0];
};
let n = table.normal(light.azimuth_index(), light.elevation_index());
// Same Z-up to Y-up swap as positions, or the lighting comes out sideways.
[n[0], n[2], -n[1]]
}
/// 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);
@@ -276,6 +301,7 @@ 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<LevelGeometry> {
let normals = src.overlay.and_then(|o| NormalTable::from_overlay(o).ok());
let ch = LevelCoreHeader::parse(src.core_index)?;
let table = texture_table(src.core_index, ch.tfrag_textures)?;
@@ -350,6 +376,7 @@ pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result<Le
positions: Vec::new(),
uvs: Vec::new(),
colors: Vec::new(),
normals: Vec::new(),
indices: Vec::new(),
ad_gifs: Vec::new(),
});
@@ -359,6 +386,7 @@ pub fn build_level(src: &LevelSource<'_>, camera: [f32; 3]) -> anyhow::Result<Le
batch.positions.push(r.position);
batch.uvs.push(r.uv);
batch.colors.push(r.color);
batch.normals.push(vertex_normal(&g, v, normals.as_ref()));
}
batch.indices.extend_from_slice(&[base, base + 1, base + 2]);
batch.ad_gifs.push(*ad);
@@ -534,3 +562,77 @@ mod tests {
);
}
}
#[cfg(test)]
mod normal_tests {
use super::*;
use gc_formats::tfrag::TfragLight;
use gc_formats::tfrag_light::NormalTable;
fn table() -> NormalTable {
// 256 entries of (cos, sin) at i * pi / 128, as the overlay holds them.
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 geometry_with_light(azimuth: i16, elevation: i16) -> TfragGeometry {
TfragGeometry {
positions: vec![[0, 0, 0]],
vertex_info: vec![gc_formats::tfrag::TfragVertexInfo {
s: 0,
t: 0,
parent: 0,
vertex: 0,
}],
lights: vec![TfragLight {
unknown_0: 0,
intensity: 0,
azimuth: azimuth as i8,
elevation: elevation as i8,
color: 0,
}],
..TfragGeometry::default()
}
}
#[test]
fn the_light_record_indexes_the_table() {
// Azimuth 0, elevation 0 is -X in the table, which becomes -X in Bevy.
let g = geometry_with_light(0, 0);
let n = vertex_normal(&g, 0, Some(&table()));
assert!((n[0] + 1.0).abs() < 1e-5, "{n:?}");
assert!(n[1].abs() < 1e-5 && n[2].abs() < 1e-5, "{n:?}");
}
#[test]
fn normals_get_the_same_axis_swap_as_positions() {
// Elevation 64 is pi/2, so the table gives a normal along Z, negated.
let g = geometry_with_light(0, 64);
let n = vertex_normal(&g, 0, Some(&table()));
// PS2 (0, 0, -1) must land on Bevy -Y.
assert!(n[0].abs() < 1e-5 && n[2].abs() < 1e-5, "{n:?}");
assert!((n[1] + 1.0).abs() < 1e-5, "{n:?}");
}
#[test]
fn an_unsigned_index_is_used_even_when_the_field_reads_negative() {
// 200 as an i8 is -56, but it indexes table entry 200, not 56.
let g = geometry_with_light(-56, 0);
let from_field = vertex_normal(&g, 0, Some(&table()));
let mut g2 = g.clone();
g2.lights[0].azimuth = -56;
assert_eq!(from_field, vertex_normal(&g2, 0, Some(&table())));
}
#[test]
fn missing_normals_fall_back_rather_than_panicking() {
let g = geometry_with_light(0, 0);
assert_eq!(vertex_normal(&g, 0, None), [0.0, 1.0, 0.0]);
let empty = TfragGeometry::default();
assert_eq!(vertex_normal(&empty, 7, Some(&table())), [0.0, 1.0, 0.0]);
}
}