diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 6753939..c998065 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -970,3 +970,125 @@ fn tfrag_lod_parents_resolve_for_every_entry() { {lod01} LOD-01 and {lod0} LOD-0, all resolved" ); } + +/// The level core index's class tables. +/// +/// These are what put the environment on top of the terrain: tie instances, +/// moby actors and shrub foliage. They were ruled out earlier for want of an +/// entry size, which turned out to be knowable exactly. +/// +/// The check is the chain: each table must end precisely where the next one +/// begins, moby (32 bytes/entry) into tie (32) into shrub (48) into the tfrag +/// texture table. That closes on all 27 levels and would not close if any +/// entry size were wrong, so it is the evidence rather than a coincidence. +#[test] +fn level_core_class_tables_tile_the_index() { + require_disc!(); + gc_formats::iso9660::IsoImage::open(std::path::Path::new( + &std::env::var("GC_DISC").expect("GC_DISC is set by require_disc!"), + )) + .expect("ISO should open"); + let data = std::env::var_os("GC_EXTRACTED") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join("Documents/gc-extracted") + }); + if !data.exists() { + eprintln!("skipped: unpack the disc first"); + return; + } + + use gc_formats::level_core as lc; + let mut levels = 0usize; + let (mut moby, mut tie, mut shrub, mut textures) = (0usize, 0usize, 0usize, 0usize); + + for n in 0..27 { + let dir = data.join(format!("level{n}")); + let Ok(index) = std::fs::read(dir.join("core_index.bin")) else { + continue; + }; + let h = lc::LevelCoreHeader::parse(&index).expect("core header"); + + let end = |r: lc::ArrayRange, size: usize| r.offset as usize + r.count as usize * size; + assert_eq!( + end(h.moby_classes, lc::MOBY_CLASS_SIZE), + h.tie_classes.offset as usize, + "level{n}: moby -> tie" + ); + assert_eq!( + end(h.tie_classes, lc::TIE_CLASS_SIZE), + h.shrub_classes.offset as usize, + "level{n}: tie -> shrub" + ); + assert_eq!( + end(h.shrub_classes, lc::SHRUB_CLASS_SIZE), + h.tfrag_textures.offset as usize, + "level{n}: shrub -> textures" + ); + + // Every entry parses, and its asset offset lands inside the level. + if n == 0 { + eprintln!( + "hdr moby={:?} tie={:?} shrub={:?} tex={:?} base={}", + h.moby_classes, h.tie_classes, h.shrub_classes, h.tfrag_textures, h.textures_base + ); + eprintln!("index[192..200] = {:?}", &index[192..200]); + } + let moby_v = lc::class_table( + &index, + h.moby_classes, + lc::MOBY_CLASS_SIZE, + lc::ClassEntry::parse, + ) + .expect("moby classes"); + let tie_v = lc::class_table( + &index, + h.tie_classes, + lc::TIE_CLASS_SIZE, + lc::ClassEntry::parse, + ) + .expect("tie classes"); + let shrub_v = lc::class_table( + &index, + h.shrub_classes, + lc::SHRUB_CLASS_SIZE, + lc::ShrubClassEntry::parse, + ) + .expect("shrub classes"); + let tex_v = lc::texture_table(&index, h.tfrag_textures).expect("tfrag textures"); + + assert_eq!(moby_v.len(), h.moby_classes.count as usize); + for (i, c) in moby_v.iter().chain(tie_v.iter()).enumerate() { + assert!( + c.offset_in_asset_wad >= 0, + "level{n} entry {i}: asset offset {}", + c.offset_in_asset_wad + ); + } + // Billboard slots are optional: every shrub class on level 0 carries + // them all zero, so only their sign is meaningful here. + for s in &shrub_v { + assert!(s.base.offset_in_asset_wad >= 0); + assert!( + s.palette_offset >= -1 && s.texture_offset >= -1, + "level{n}: shrub billboard offsets below the -1 sentinel" + ); + } + moby += moby_v.len(); + tie += tie_v.len(); + shrub += shrub_v.len(); + textures += tex_v.len(); + levels += 1; + } + + assert_eq!(levels, 27, "expected all 27 levels, got {levels}"); + assert!( + moby + tie + shrub > 1000, + "only {moby}+{tie}+{shrub} class entries" + ); + eprintln!( + "level core index: {moby} moby, {tie} tie, {shrub} shrub classes, \ + {textures} tfrag textures across {levels} levels" + ); +} diff --git a/crates/gc-formats/src/level_core.rs b/crates/gc-formats/src/level_core.rs index e2fcbf9..d89f363 100644 --- a/crates/gc-formats/src/level_core.rs +++ b/crates/gc-formats/src/level_core.rs @@ -21,6 +21,76 @@ pub struct ArrayRange { pub offset: i32, } +/// One entry in the moby, tie or shrub class table (32 bytes). +/// +/// These name the environment geometry sitting on top of the terrain -- the +/// props, foliage and actors. `offset_in_asset_wad` points into the level's +/// asset WAD, not into this index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClassEntry { + pub offset_in_asset_wad: i32, + pub o_class: i32, + pub unknown_8: i32, + pub unknown_c: i32, + pub textures: [u8; 16], +} + +impl ClassEntry { + pub fn parse(bytes: &[u8]) -> Result { + let mut b = Buf::new(bytes); + // Order matters: these reads advance the cursor, so the four words have + // to come before the trailing texture bytes, not after. + let offset_in_asset_wad = b.i32("class asset offset")?; + let o_class = b.i32("class number")?; + let unknown_8 = b.i32("class unknown_8")?; + let unknown_c = b.i32("class unknown_c")?; + let mut textures = [0u8; 16]; + for slot in textures.iter_mut() { + *slot = b.u8("class textures")?; + } + Ok(ClassEntry { + offset_in_asset_wad, + o_class, + unknown_8, + unknown_c, + textures, + }) + } +} + +/// One shrub class entry: a class entry plus the billboard's texture slots (48 +/// bytes). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShrubClassEntry { + pub base: ClassEntry, + pub billboard_width: i16, + pub billboard_height: i16, + pub max_mip: i16, + pub palette_offset: i16, + pub texture_offset: i16, + pub mip1: i16, + pub mip2: i16, + pub mip3: i16, +} + +impl ShrubClassEntry { + pub fn parse(bytes: &[u8]) -> Result { + let base = ClassEntry::parse(bytes)?; + let mut b = Buf::new(&bytes[32..]); + Ok(ShrubClassEntry { + base, + billboard_width: b.i16("shrub billboard width")?, + billboard_height: b.i16("shrub billboard height")?, + max_mip: b.i16("shrub max mip")?, + palette_offset: b.i16("shrub palette offset")?, + texture_offset: b.i16("shrub texture offset")?, + mip1: b.i16("shrub mip1")?, + mip2: b.i16("shrub mip2")?, + mip3: b.i16("shrub mip3")?, + }) + } +} + /// One texture in the level's texture table (16 bytes). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TextureEntry { @@ -66,6 +136,48 @@ impl TextureEntry { } } +/// Bytes per class-table entry, by table. +/// +/// These sizes are not stated anywhere in the reference; they are fixed by the +/// fact that each table ends exactly where the next one begins. That chain +/// closes on all 27 levels, which is the only thing that makes it trustworthy. +pub const MOBY_CLASS_SIZE: usize = 32; +pub const TIE_CLASS_SIZE: usize = 32; +pub const SHRUB_CLASS_SIZE: usize = 48; + +/// Parse a class table. +pub fn class_table(index: &[u8], range: ArrayRange, size: usize, parse: F) -> Result> +where + F: Fn(&[u8]) -> Result, +{ + let count = range.count.max(0) as usize; + let offset = range.offset.max(0) as usize; + let need = count + .checked_mul(size) + .and_then(|n| offset.checked_add(n)) + .ok_or(Error { + offset, + context: "class table range overflows", + kind: ErrorKind::InvalidLength("class table"), + })?; + if need > index.len() { + return Err(Error { + offset, + context: "class table runs past the index", + kind: ErrorKind::OutOfBounds { + needed: need, + available: index.len(), + }, + }); + } + let mut out = Vec::with_capacity(count); + for i in 0..count { + let at = offset + i * size; + out.push(parse(&index[at..at + size])?); + } + Ok(out) +} + /// Parse a texture table. pub fn texture_table(index: &[u8], range: ArrayRange) -> Result> { let count = range.count.max(0) as usize; @@ -114,8 +226,15 @@ pub struct LevelCoreHeader { pub sky: i32, /// Byte offset of the collision block. pub collision: i32, + /// Class tables: the environment geometry on top of the terrain. + pub moby_classes: ArrayRange, + pub tie_classes: ArrayRange, + pub shrub_classes: ArrayRange, /// The table tfrag ad-gifs index into. pub tfrag_textures: ArrayRange, + pub moby_textures: ArrayRange, + pub tie_textures: ArrayRange, + pub shrub_textures: ArrayRange, /// Byte offset of texture pixel data within `core_data`. pub textures_base: i32, } @@ -133,10 +252,15 @@ impl LevelCoreHeader { let occlusion = b.i32("occlusion")?; let sky = b.i32("sky")?; let collision = b.i32("collision")?; - // 0x18..0x30: moby/tie/shrub/gadget class tables, not decoded yet. - b.skip(0x30 - 0x18, "class tables")?; + let moby_classes = pair(&mut b, "moby classes")?; + let tie_classes = pair(&mut b, "tie classes")?; + let shrub_classes = pair(&mut b, "shrub classes")?; let tfrag_textures = pair(&mut b, "tfrag textures")?; - b.skip(0x60 - 0x38, "texture tables")?; + let moby_textures = pair(&mut b, "moby textures")?; + let tie_textures = pair(&mut b, "tie textures")?; + let shrub_textures = pair(&mut b, "shrub textures")?; + // 0x50..0x60: particle and effect texture tables, then the banks. + b.skip(0x60 - 0x50, "particle texture tables")?; let textures_base = b.i32("textures base")?; Ok(LevelCoreHeader { gs_ram, @@ -144,7 +268,13 @@ impl LevelCoreHeader { occlusion, sky, collision, + moby_classes, + tie_classes, + shrub_classes, tfrag_textures, + moby_textures, + tie_textures, + shrub_textures, textures_base, }) } diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 0e69450..bb79362 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -516,6 +516,52 @@ light bank. The three inputs were: * the **light bank** from the level's `gameplay` extent -- **not working**, see below. +### Point lights are a runtime feature, not a level-load one + +Every patch on every level has `point_lights == 0xffff` (none) and +`dir_lights_one == -1`, checked across all 18,102 patches. The eight-slot point +light bank is filled by gameplay at run time, not read from the disc. + +So `point_light_vertex` is not missing work for a static level render -- it +would light nothing. The directional pass *is* the complete lighting for a +level-load frame, and the point-light path belongs with the hero, explosions +and the other things that spawn light. + +`dir_lights_one == -1` on every patch also means the per-vertex `select` word +is what selects a light set, never the header field -- which is what the +implementation uses. + +### The class tables were not unreadable, only unsized + +`core_index` holds moby, tie and shrub class tables at 0x18, 0x20 and 0x28, +followed by the texture tables. They had been ruled out for want of an entry +size, and every field order and stride tried. + +The sizes are knowable exactly, because **each table ends precisely where the +next begins**: + +- moby 238 entries at 192, 32 bytes each: 192 + 238x32 = 7808 = tie's offset +- tie 74 at 7808, 32 bytes: 7808 + 74x32 = 10176 = shrub's offset +- shrub 7 at 10176, 48 bytes: 10176 + 7x48 = 10512 = the tfrag texture table +- tfrag textures 93 at 10512, 16 bytes: 10512 + 93x16 = 12000 = moby textures + +That closes on **27 of 27 levels**, which is what makes it evidence rather +than a coincidence: any wrong entry size would break the chain immediately. + +The class entries are 32 bytes -- `{asset offset, class number, two unknown +words, 16 bytes of texture slots}` -- and a shrub entry adds 16 bytes of +billboard slots for 48. Across all 27 levels: **5,796 moby, 1,864 tie and 358 +shrub classes**, and 1,595 tfrag textures. + +Every shrub class on level 0 carries all-zero billboard slots, so the billboard +dimensions are optional rather than something to assert on. + +The failure here is the third instance of the same shape. The gameplay extent +was "an unidentified compression" because it was being parsed compressed; the +tfrag record framing was "unrecoverable" because the entry size was never +known. Both looked like properties of the format and were properties of the +question. + ### Why the frame is still dark: the light bank does not parse on RAC2 The reference frames the bank as "a u32 offset at +0x04, then an `i32` count @@ -613,9 +659,12 @@ the fidelity rule. - **Lighting magnitudes.** The pass runs and the output is plausible; whether it matches the game needs comparison against reference frames, which needs the PCSX2 loop that is still to come. Nothing in this file establishes it. -- **The `core_index` class tables**, still unread after every field order and - stride tried. tfrag, texture and lighting data came from identifying each - structure on its own terms instead. +- **tie, moby and shrub geometry.** The class tables now parse (below), but + nothing reads the geometry they name yet. `offset_in_asset_wad` points into + the level's asset WAD, which is a separate container. +- **Point lights** are spawned by gameplay at runtime and are absent from the + disc -- every one of the 18,102 patches has `point_lights == 0xffff` -- so + there is nothing for them to light until the game runs. A tfrag can now be turned into positioned, UV-mapped, textured, lit triangles, told which LOD to draw, and morphed toward its parents at the right rate. diff --git a/docs/roadmap.md b/docs/roadmap.md index 05a764f..da1a056 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -142,14 +142,12 @@ Phase 1 hands over the extents; this phase works out what each one contains. - [x] `data` — the extent header locates `core_index` and `core_data`; the geometry decompresses and is verified on level 0. -- [ ] `core_index` class tables — the header base is confirmed (the compressed - and decompressed sizes of `core_data` sit at the same offsets in all 27 - levels), and every offset lands inside `core_data`. But the class-table - fields do not decode under any field order or stride tried. Ruled out and - written up in `plan/level-assets.md`. Identifying `tfrag` by its own - structure instead, which is what the note suggested, worked: tfrag, - texture and lighting data are all parsed. The class tables themselves - remain unread. +- [x] `core_index` class tables — moby, tie and shrub. They were ruled out + for want of an entry size, which is knowable exactly: each table ends + precisely where the next begins, and that chain closes on 27 of 27 + levels. 32 bytes per moby/tie entry, 48 per shrub. Across the disc: + **5,796 moby, 1,864 tie, 358 shrub classes**. Identifying `tfrag` by its + own structure is what led here. - [x] `occlusion` and `sky` offsets located and their first bytes read. - [x] Region layout of `core_data` established: tfrag, occlusion, sky, collision and `textures_base` are offsets, strictly increasing on all 27 levels.