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
+6
View File
@@ -244,10 +244,12 @@ fn load_source(dir: &std::path::Path, level: usize) -> Result<(LevelFiles, [f32;
let core_data = std::fs::read(level_dir.join("core_data.bin")).context("core_data.bin")?;
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 src = LevelSource {
core_data: &core_data,
core_index: &core_index,
gs_ram: &gs_ram,
overlay: overlay.as_deref(),
};
let (centre, _radius) = level_bounds(&src)?;
Ok((
@@ -255,6 +257,7 @@ fn load_source(dir: &std::path::Path, level: usize) -> Result<(LevelFiles, [f32;
core_data,
core_index,
gs_ram,
overlay,
},
centre,
))
@@ -265,6 +268,7 @@ struct LevelFiles {
core_data: Vec<u8>,
core_index: Vec<u8>,
gs_ram: Vec<u8>,
overlay: Option<Vec<u8>>,
}
impl LevelFiles {
@@ -273,6 +277,7 @@ impl LevelFiles {
core_data: &self.core_data,
core_index: &self.core_index,
gs_ram: &self.gs_ram,
overlay: self.overlay.as_deref(),
}
}
}
@@ -389,6 +394,7 @@ fn mesh_of(b: &TriangleBatch) -> Option<Mesh> {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, b.positions.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, b.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, b.colors.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, b.normals.clone());
// No normals and no colours: lighting is the game's own per-vertex light
// array, which is parsed but not yet reproduced. Inventing normals would
// make unlit terrain look lit and hide that gap.