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