diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index ee2eb71..70a10c0 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -564,7 +564,7 @@ fn tfrag_topology_reproduces_the_header_triangle_count() { idx.iter().all(|&v| v < vinfo), "level{n} patch {i}: triangle index outside {vinfo} vertex-info entries" ); - assert!((t.ad_gif as usize) < g.ad_gif_count); + assert!((t.ad_gif as usize) < g.ad_gifs.len()); } patches += 1; triangles += tris.len(); @@ -580,3 +580,127 @@ fn tfrag_topology_reproduces_the_header_triangle_count() { ); eprintln!("reproduced {triangles} triangles across {patches} patches in {levels} levels"); } + +/// Texture bindings: every ad-gif a patch uses must index an entry that +/// actually exists in the level's texture table, and the light array must be +/// one record per vertex. +/// +/// The texture table is the join between tfrags and the texel data, so an +/// out-of-range index here means the level's geometry and its textures have +/// been misread independently of each other. +#[test] +fn tfrag_ad_gifs_index_the_level_texture_table() { + 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; + } + + let mut patches = 0usize; + let mut ad_gifs = 0usize; + let mut textures_seen = 0usize; + let mut textures_total = 0usize; + let mut levels = 0usize; + + for n in 0..27 { + let dir = data.join(format!("level{n}")); + let core = match std::fs::read(dir.join("core_data.bin")) { + Ok(c) => c, + Err(_) => continue, + }; + let index = match std::fs::read(dir.join("core_index.bin")) { + Ok(i) => i, + Err(_) => continue, + }; + let ch = gc_formats::level_core::LevelCoreHeader::parse(&index).expect("core header"); + let textures = gc_formats::level_core::texture_table(&index, ch.tfrag_textures) + .expect("texture table"); + assert!(!textures.is_empty(), "level{n}: no tfrag textures"); + // Indices are per-level, so the pair is what identifies a texture. + let mut level_seen = std::collections::BTreeSet::new(); + + let region = + |off: usize| i32::from_le_bytes(index[off..off + 4].try_into().unwrap()) as usize; + let start = region(0x08); + let end = [0x0c_usize, 0x10, 0x14] + .iter() + .map(|&o| region(o)) + .filter(|&v| v > 0) + .min() + .unwrap_or(core.len()); + let block = &core[start..end]; + + let header = gc_formats::tfrag::TfragsBlock::parse(block).expect("tfrags block header"); + let table = header.table_offset as usize; + for i in 0..header.tfrag_count as usize { + let th = header.header(block, i).expect("tfrag header"); + let data_base = table.wrapping_add(th.data as usize); + if data_base >= block.len() { + continue; + } + let g = gc_formats::tfrag::parse_tfrag(&block[data_base..], &th) + .unwrap_or_else(|e| panic!("level{n} patch {i}: {e}")); + + assert_eq!( + g.lights.len(), + th.vert_count as usize, + "level{n} patch {i}: {} light records for {} vertices", + g.lights.len(), + th.vert_count + ); + assert_eq!( + g.rgba.len(), + th.rgba_size as usize * 4, + "level{n} patch {i}: rgba" + ); + + for a in &g.ad_gifs { + let t = a.texture_index(); + assert!( + t < textures.len(), + "level{n} patch {i}: texture {} outside the {} entry table", + t, + textures.len() + ); + let tex = &textures[t]; + assert!( + tex.width > 0 && tex.height > 0 && tex.width <= 256 && tex.height <= 256, + "level{n} patch {i}: texture {t} is {}x{}", + tex.width, + tex.height + ); + level_seen.insert(t); + ad_gifs += 1; + } + patches += 1; + } + textures_seen += level_seen.len(); + textures_total += textures.len(); + levels += 1; + } + + assert!(levels > 0, "no unpacked levels were found"); + assert!(patches > 18_000, "expected 18k+ patches, got {patches}"); + // Most of each level's texture table should be referenced by some tfrag. A + // handful genuinely go unused; if that share grows, the ad-gif walk has + // started missing primitives rather than the data having spare entries. + let covered = textures_seen as f64 / textures_total as f64; + assert!( + covered > 0.85, + "only {textures_seen} of {textures_total} level textures referenced ({:.1}%)", + covered * 100.0 + ); + eprintln!( + "{ad_gifs} ad-gifs over {patches} patches reference {textures_seen}/{textures_total} level textures" + ); +} diff --git a/crates/gc-formats/src/level_core.rs b/crates/gc-formats/src/level_core.rs new file mode 100644 index 0000000..e2fcbf9 --- /dev/null +++ b/crates/gc-formats/src/level_core.rs @@ -0,0 +1,274 @@ +//! The level core **index**: the uncompressed directory that sits alongside +//! `core_data` and points into it. +//! +//! Spec: `docs/formats/level-core.md`. +//! +//! The index is small (about 31 KB) and mostly a set of count/offset pairs. Two +//! things live here that the compressed data cannot supply: where each block of +//! `core_data` starts, and the per-level tables the geometry refers to by +//! index — most importantly the texture table that tfrag ad-gifs point into. +//! +//! Only the fields this crate actually reads are decoded. The class tables at +//! 0x18..0x30 resisted every field order and stride tried and are deliberately +//! left alone; see `docs/plan/level-assets.md`. + +use crate::buf::{Buf, Error, ErrorKind, Result}; + +/// A count/offset pair naming an array in the index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArrayRange { + pub count: i32, + pub offset: i32, +} + +/// One texture in the level's texture table (16 bytes). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextureEntry { + /// Byte offset of the texel data within the level's texture base. + pub data_offset: i32, + pub width: i16, + pub height: i16, + /// Mip level count (3 or 4 on retail data). + pub ty: i16, + /// CLUT index within the level's palette space. + pub palette: i16, + /// Byte offset of mip level 1. + pub mipmap: i16, + /// Byte offset of the second mip chain. + pub pad: i16, +} + +impl TextureEntry { + pub fn parse(bytes: &[u8]) -> Result { + let mut b = Buf::new(bytes); + Ok(TextureEntry { + data_offset: b.i32("texture data offset")?, + width: b.i16("texture width")?, + height: b.i16("texture height")?, + ty: b.i16("texture ty")?, + palette: b.i16("texture palette")?, + mipmap: b.i16("texture mipmap")?, + pad: b.i16("texture pad")?, + }) + } + + /// `ceil(log2(n))`, the GS TBW/TSB derivation. + /// + /// PS2 texture widths are `width << 6` texels wide, so a 128 px texture is + /// 8192 texels and needs 13. + pub fn log2_size(&self) -> i32 { + let n = (self.width as i32) << 6; + let mut bits = 0; + while (1 << bits) < n.max(1) { + bits += 1; + } + bits + } +} + +/// Parse a texture table. +pub fn texture_table(index: &[u8], range: ArrayRange) -> Result> { + let count = range.count.max(0) as usize; + let offset = range.offset.max(0) as usize; + // Bound the whole table before slicing, so an absurd count reports an error + // instead of panicking partway through. + let need = count + .checked_mul(16) + .and_then(|n| offset.checked_add(n)) + .ok_or(Error { + offset, + context: "texture table range overflows", + kind: ErrorKind::InvalidLength("texture table"), + })?; + if need > index.len() { + return Err(Error { + offset, + context: "texture 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 * 16; + out.push(TextureEntry::parse(&index[at..at + 16])?); + } + Ok(out) +} + +/// The level core index header. +/// +/// Only fields this crate reads are decoded. Offsets are byte offsets into the +/// uncompressed index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LevelCoreHeader { + /// GS packet memory in use. + pub gs_ram: ArrayRange, + /// Byte offset of the tfrags block within `core_data`. + pub tfrags: i32, + /// Byte offset of the occlusion block. + pub occlusion: i32, + /// Byte offset of the sky block. + pub sky: i32, + /// Byte offset of the collision block. + pub collision: i32, + /// The table tfrag ad-gifs index into. + pub tfrag_textures: ArrayRange, + /// Byte offset of texture pixel data within `core_data`. + pub textures_base: i32, +} + +impl LevelCoreHeader { + pub fn parse(bytes: &[u8]) -> Result { + let mut b = Buf::new(bytes); + let pair = |b: &mut Buf, what: &'static str| -> Result { + let count = b.i32(what)?; + let offset = b.i32(what)?; + Ok(ArrayRange { count, offset }) + }; + let gs_ram = pair(&mut b, "gs_ram")?; + let tfrags = b.i32("tfrags")?; + 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 tfrag_textures = pair(&mut b, "tfrag textures")?; + b.skip(0x60 - 0x38, "texture tables")?; + let textures_base = b.i32("textures base")?; + Ok(LevelCoreHeader { + gs_ram, + tfrags, + occlusion, + sky, + collision, + tfrag_textures, + textures_base, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A header as measured on level 0. + fn header() -> Vec { + let mut v = vec![0u8; 0x64]; + v[0x00..0x04].copy_from_slice(&404i32.to_le_bytes()); + v[0x04..0x08].copy_from_slice(&18_704i32.to_le_bytes()); + v[0x08..0x0c].copy_from_slice(&0i32.to_le_bytes()); + v[0x0c..0x10].copy_from_slice(&2_117_888i32.to_le_bytes()); + v[0x10..0x14].copy_from_slice(&2_192_576i32.to_le_bytes()); + v[0x14..0x18].copy_from_slice(&2_615_232i32.to_le_bytes()); + v[0x30..0x34].copy_from_slice(&93i32.to_le_bytes()); + v[0x34..0x38].copy_from_slice(&10_512i32.to_le_bytes()); + v[0x60..0x64].copy_from_slice(&3_518_848i32.to_le_bytes()); + v + } + + #[test] + fn parses_the_measured_header() { + let h = LevelCoreHeader::parse(&header()).unwrap(); + assert_eq!( + h.gs_ram, + ArrayRange { + count: 404, + offset: 18_704 + } + ); + assert_eq!(h.tfrags, 0); + assert_eq!(h.occlusion, 2_117_888); + assert_eq!(h.sky, 2_192_576); + assert_eq!(h.collision, 2_615_232); + assert_eq!( + h.tfrag_textures, + ArrayRange { + count: 93, + offset: 10_512 + } + ); + assert_eq!(h.textures_base, 3_518_848); + } + + #[test] + fn a_short_header_is_an_error() { + assert!(LevelCoreHeader::parse(&[0u8; 0x20]).is_err()); + } + + #[test] + fn parses_a_texture_table() { + let mut index = vec![0u8; 10_512 + 2 * 16]; + let mut put = |at: usize, e: TextureEntry| { + index[at..at + 4].copy_from_slice(&e.data_offset.to_le_bytes()); + index[at + 4..at + 6].copy_from_slice(&e.width.to_le_bytes()); + index[at + 6..at + 8].copy_from_slice(&e.height.to_le_bytes()); + index[at + 8..at + 10].copy_from_slice(&e.ty.to_le_bytes()); + index[at + 10..at + 12].copy_from_slice(&e.palette.to_le_bytes()); + index[at + 12..at + 14].copy_from_slice(&e.mipmap.to_le_bytes()); + }; + put( + 10_512, + TextureEntry { + data_offset: 0, + width: 128, + height: 128, + ty: 3, + palette: 8, + mipmap: 12, + pad: -1, + }, + ); + put( + 10_528, + TextureEntry { + data_offset: 20_480, + width: 64, + height: 64, + ty: 3, + palette: 60, + mipmap: 64, + pad: -1, + }, + ); + + let t = texture_table( + &index, + ArrayRange { + count: 2, + offset: 10_512, + }, + ) + .unwrap(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].width, 128); + assert_eq!(t[1].mipmap, 64); + // Widths are `width << 6` texels, so 128 px needs log2(8192) = 13. + assert_eq!(t[0].log2_size(), 13); + assert_eq!(t[1].log2_size(), 12); + } + + #[test] + fn a_table_running_past_the_index_is_an_error() { + let index = vec![0u8; 64]; + assert!(texture_table( + &index, + ArrayRange { + count: 8, + offset: 60 + } + ) + .is_err()); + assert!(texture_table( + &index, + ArrayRange { + count: -1, + offset: 0 + } + ) + .unwrap() + .is_empty()); + } +} diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs index 6b0e0b9..c8e8da9 100644 --- a/crates/gc-formats/src/lib.rs +++ b/crates/gc-formats/src/lib.rs @@ -1,6 +1,7 @@ pub mod buf; pub mod global_wad; pub mod iso9660; +pub mod level_core; pub mod level_data; pub mod level_wad; pub mod tfrag; diff --git a/crates/gc-formats/src/tfrag.rs b/crates/gc-formats/src/tfrag.rs index 723dfbd..8147e14 100644 --- a/crates/gc-formats/src/tfrag.rs +++ b/crates/gc-formats/src/tfrag.rs @@ -36,8 +36,12 @@ //! Strip indices address the vertex-info array rather than the position array — //! several vertex-info entries can share a position. //! -//! Not yet recovered: the ad-gif texture bindings themselves, and the LOD -//! switch and morph rules in `TfragProc`. See `docs/plan/tfrag.md`. +//! Also recovered: the ad-gif texture primitives (texture index, wrap modes, +//! minification filter, LOD bias), the per-vertex colours and the per-vertex +//! light and normal records. +//! +//! Not yet recovered: the LOD switch and morph rules in `TfragProc`, and the +//! texel data the texture table points into. See `docs/plan/tfrag.md`. use crate::buf::{Buf, Error, ErrorKind, Result}; @@ -442,6 +446,144 @@ pub struct TfragTriangle { pub ad_gif: u16, } +/// One GIF A+D quadword (16 bytes) from an ad-gif. +/// +/// On disc the register values are packed fields, not GS registers; the level +/// load rewrites them in place before drawing. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AdGif { + pub data_lo: i32, + pub data_hi: i32, + /// GS register address: 0x06 TEX0, 0x14 TEX1, 0x08 CLAMP, 0x34/0x36 MIPTBP. + pub address: u8, +} + +/// A texture primitive: five A+D quadwords (0x50 bytes). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TfragAdGifs { + /// TEX0_1. On disc `data_lo` indexes the level's tfrag texture table. + pub tex0: AdGif, + /// TEX1_1. On disc `data_lo` is the LOD K in 1/16 units, `data_hi` the MMIN filter. + pub tex1: AdGif, + /// CLAMP_1. On disc `data_lo` is WMS and `data_hi` is WMT. + pub clamp: AdGif, + pub miptbp1: AdGif, + pub miptbp2: AdGif, +} + +/// GS CLAMP_1 wrap mode. Retail tfrags only use Repeat and Clamp. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GsWrap { + Repeat, + Clamp, + RegionClamp, + RegionRepeat, +} + +impl GsWrap { + fn from_bits(v: i32) -> GsWrap { + match v & 3 { + 0 => GsWrap::Repeat, + 1 => GsWrap::Clamp, + 2 => GsWrap::RegionClamp, + _ => GsWrap::RegionRepeat, + } + } +} + +/// GS TEX1_1 magnification/minification filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GsFilter { + Nearest, + Linear, + NearestMipmapNearest, + NearestMipmapLinear, + LinearMipmapNearest, + LinearMipmapLinear, +} + +impl GsFilter { + fn from_bits(v: i32) -> GsFilter { + match v & 7 { + 0 => GsFilter::Nearest, + 1 => GsFilter::Linear, + 2 => GsFilter::NearestMipmapNearest, + 3 => GsFilter::NearestMipmapLinear, + 4 => GsFilter::LinearMipmapNearest, + _ => GsFilter::LinearMipmapLinear, + } + } +} + +impl TfragAdGifs { + fn parse(bytes: &[u8]) -> TfragAdGifs { + let q = |i: usize| AdGif { + data_lo: i32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()), + data_hi: i32::from_le_bytes(bytes[i + 4..i + 8].try_into().unwrap()), + address: bytes[i + 8], + }; + TfragAdGifs { + tex0: q(0), + tex1: q(16), + clamp: q(32), + miptbp1: q(48), + miptbp2: q(64), + } + } + + /// Index into the level's tfrag texture table. + pub fn texture_index(&self) -> usize { + self.tex0.data_lo.max(0) as usize + } + + /// Horizontal wrap mode. + pub fn wrap_s(&self) -> GsWrap { + GsWrap::from_bits(self.clamp.data_lo) + } + + /// Vertical wrap mode. + pub fn wrap_t(&self) -> GsWrap { + GsWrap::from_bits(self.clamp.data_hi) + } + + /// Minification filter, from the on-disc MMIN field. + pub fn min_filter(&self) -> GsFilter { + GsFilter::from_bits(self.tex1.data_hi) + } + + /// TEX1_1 K field as the signed 12-bit raw value, 1/16 units. + pub fn lod_k_raw(&self) -> i16 { + ((self.tex1.data_lo as u32 & 0xfff) << 4) as u16 as i16 >> 4 + } + + /// TEX1_1 K in mip levels. The GS computes `log2(1 / |Q|) + K`. + pub fn lod_k(&self) -> f32 { + self.lod_k_raw() as f32 / 16.0 + } +} + +/// Per-vertex colour (4 bytes), indexed by position index. 0x80 is 1.0. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TfragRgba { + pub r: u8, + pub g: u8, + pub b: u8, + /// 0x80 is opaque. + pub a: u8, +} + +/// Per-vertex light and normal record (8 bytes), indexed by position index. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TfragLight { + pub unknown_0: i8, + pub intensity: i8, + /// Normal azimuth, pi/128 radians per unit. + pub azimuth: i8, + /// Normal elevation, pi/128 radians per unit. + pub elevation: i8, + pub color: i16, +} + /// Everything one tfrag contributes to a draw call. #[derive(Debug, Clone, Default, PartialEq)] pub struct TfragGeometry { @@ -454,7 +596,12 @@ pub struct TfragGeometry { pub vinfo_tier: [usize; 3], /// Index 0 is highest detail. pub lod: [TfragLod; 3], - pub ad_gif_count: usize, + /// Texture primitives; strips select one by ad-gif index. + pub ad_gifs: Vec, + /// Per-vertex colours, indexed by position index. + pub rgba: Vec, + /// Per-vertex light and normal records, indexed by position index. + pub lights: Vec, } impl TfragGeometry { @@ -547,7 +694,15 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result have_vu = true; } // Ad-gifs: 5 quadwords each. - (3, 0) => g.ad_gif_count = v.count() / 5, + (3, 0) => { + for i in 0..v.count() / 5 { + let o = i * 0x50; + if payload.len() < o + 0x50 { + break; + } + g.ad_gifs.push(TfragAdGifs::parse(&payload[o..o + 0x50])); + } + } (2, 1) => { let tier = if addr == vu.positions_common_addr { 0 @@ -614,6 +769,23 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result kind: ErrorKind::Invalid("tfrag has no VU header"), }); } + // These three arrays sit outside the VIF lists, at fixed offsets in the + // patch's data block. + g.rgba = read_rgba( + block, + header.rgba_ofs as usize, + header.rgba_size as usize * 4, + )?; + g.lights = read_lights( + block, + header.light_ofs as usize + 0x10, + header.vert_count as usize, + )?; + if !have_origin { + // Without an STROW origin the quadword at light_ofs carries it. + g.origin = read_i32x4(block, header.light_ofs as usize).unwrap_or([0; 4]); + } + if header.vert_count != 0 && g.positions.len() != header.vert_count as usize { return Err(Error { offset: 0, @@ -621,10 +793,70 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result kind: ErrorKind::Invalid("tfrag position count does not match its header"), }); } - let _ = have_origin; Ok(g) } +/// Read `count` 4-byte colour records at `at`. +fn read_rgba(block: &[u8], at: usize, count: usize) -> Result> { + let end = at + .checked_add(count * 4) + .filter(|&e| e <= block.len()) + .ok_or(Error { + offset: at, + context: "tfrag rgba array runs past the block", + kind: ErrorKind::OutOfBounds { + needed: count * 4, + available: block.len().saturating_sub(at), + }, + })?; + Ok((at..end) + .step_by(4) + .map(|i| TfragRgba { + r: block[i], + g: block[i + 1], + b: block[i + 2], + a: block[i + 3], + }) + .collect()) +} + +/// Read `count` 8-byte light records at `at`. +fn read_lights(block: &[u8], at: usize, count: usize) -> Result> { + let end = at + .checked_add(count * 8) + .filter(|&e| e <= block.len()) + .ok_or(Error { + offset: at, + context: "tfrag light array runs past the block", + kind: ErrorKind::OutOfBounds { + needed: count * 8, + available: block.len().saturating_sub(at), + }, + })?; + Ok((at..end) + .step_by(8) + .map(|i| TfragLight { + unknown_0: block[i] as i8, + intensity: block[i + 1] as i8, + azimuth: block[i + 2] as i8, + elevation: block[i + 3] as i8, + color: i16::from_le_bytes([block[i + 4], block[i + 5]]), + }) + .collect()) +} + +/// Read four `i32` at `at`, or `None` if out of range. +fn read_i32x4(block: &[u8], at: usize) -> Option<[i32; 4]> { + if at.checked_add(16).is_none_or(|e| e > block.len()) { + return None; + } + let mut out = [0i32; 4]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = i32::from_le_bytes(block[at + i * 4..at + i * 4 + 4].try_into().ok()?); + } + Some(out) +} + /// Read `count` V3_16 elements: three `i16` each, 6 bytes per element. fn read_i16x3(bytes: &[u8], count: usize) -> Result> { if bytes.len() < count * 6 { @@ -712,7 +944,7 @@ pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result= 0 || s.ad_gif_offset >= 0 { let z = s.ad_gif_offset as i32; - if z < 0 || z % 5 != 0 || (z / 5) as usize >= g.ad_gif_count { + if z < 0 || z % 5 != 0 || (z / 5) as usize >= g.ad_gifs.len() { return Err(Error { offset: 0, context: "strip ad-gif offset outside the ad-gif array", @@ -1001,7 +1233,7 @@ mod topology_tests { /// A common list holding only a V3_16 position unpack, plus the LOD-0 /// strips and indices that reference it. - fn fixture() -> (Vec, TfragHeader) { + pub(super) fn fixture() -> (Vec, TfragHeader) { const INDICES: u16 = 6; const STRIPS: u16 = 8; let verts: [[i16; 3]; 6] = [ @@ -1066,6 +1298,21 @@ mod topology_tests { } } + // The tfrag origin, as the STROW row that precedes the positions. + block.extend_from_slice( + &VifCode { + irq: false, + cmd: 0x30, + num: 0, + imm: 0, + } + .to_word() + .to_le_bytes(), + ); + for c in [1024i32, 2048, 4096, 0] { + block.extend_from_slice(&c.to_le_bytes()); + } + // Positions. block.extend_from_slice( &VifCode { @@ -1140,6 +1387,27 @@ mod topology_tests { let lod0_end = block.len().div_ceil(4) * 4; block.resize(lod0_end, 0); + // Past the lists: the origin quadword and the light array. + let lights_at = block.len().div_ceil(16) * 16; + block.resize(lights_at, 0); + for c in [0i32, 0, 0, 0] { + block.extend_from_slice(&c.to_le_bytes()); + } + for i in 0..6u8 { + let v = i as i8; + let colour = (i as u16) * 100; + block.extend_from_slice(&[ + i, + v as u8, + (v * 8) as u8, + (v * 4) as u8, + colour as u8, + (colour >> 8) as u8, + 0, + 0, + ]); + } + let mut h = TfragHeader::parse(&[0u8; TFRAG_HEADER_SIZE]).unwrap(); h.vert_count = 6; h.tri_count = 4; // two runs of 4 vertices @@ -1148,6 +1416,8 @@ mod topology_tests { h.lod_0_ofs = common_end as u16; h.lod_1_size = (common_end / 16) as u8; h.rgba_ofs = lod0_end as u16; + h.light_ofs = lights_at as u16; + h.light_end_ofs = (lights_at + 0x10 + 6 * 8) as u16; (block, h) } @@ -1176,10 +1446,21 @@ mod topology_tests { let g = parse_tfrag(&block, &header).unwrap(); assert_eq!(g.position_index(3), 3); assert_eq!(g.lod_vinfo_count(0), 6); + // Origin from the STROW row, in 1024ths of a world unit. + assert_eq!(g.origin, [1024, 2048, 4096, 0]); assert_eq!( g.world_position(0), - [99.0 / 1024.0, -3795.0 / 1024.0, -5203.0 / 1024.0] + [ + (1024 + 99) as f32 / 1024.0, + (2048 - 3795) as f32 / 1024.0, + (4096 - 5203) as f32 / 1024.0 + ] ); + // Six positions, so six light records. + assert_eq!(g.lights.len(), 6); + assert_eq!(g.lights[3].intensity, 3); + assert_eq!(g.lights[3].azimuth, 24); + assert_eq!(g.lights[3].color, 300); } #[test] @@ -1202,3 +1483,95 @@ mod topology_tests { assert!(tfrag_triangles(&g, 0).is_err()); } } + +#[cfg(test)] +mod ad_gif_tests { + use super::*; + + /// An ad-gif as measured in level 0: texture 12, K = -137/16, clamp, mip 4. + fn measured() -> TfragAdGifs { + TfragAdGifs { + tex0: AdGif { + data_lo: 12, + data_hi: 0, + address: 0x06, + }, + tex1: AdGif { + data_lo: 0xf77, + data_hi: 4, + address: 0x14, + }, + clamp: AdGif { + data_lo: 0, + data_hi: 1, + address: 0x08, + }, + miptbp1: AdGif { + data_lo: 0, + data_hi: 0, + address: 0x34, + }, + miptbp2: AdGif { + data_lo: 0, + data_hi: 0, + address: 0x36, + }, + } + } + + #[test] + fn decodes_the_texture_binding() { + let a = measured(); + assert_eq!(a.texture_index(), 12); + assert_eq!(a.wrap_s(), GsWrap::Repeat); + assert_eq!(a.wrap_t(), GsWrap::Clamp); + assert_eq!(a.min_filter(), GsFilter::LinearMipmapNearest); + // K is a signed 12-bit value in 1/16 units. + assert_eq!(a.lod_k_raw(), -137); + assert_eq!(a.lod_k(), -137.0 / 16.0); + } + + #[test] + fn a_negative_k_survives_the_round_trip() { + // The on-disc field is only 12 bits wide and is sign-extended through the + // shift, so 0xf77 in 1/16 units is -137 rather than +3959. + let a = TfragAdGifs { + tex1: AdGif { + data_lo: 0xf77, + data_hi: 4, + address: 0x14, + }, + ..measured() + }; + assert_eq!(a.lod_k_raw(), -137); + } + + #[test] + fn parses_five_quadwords_into_register_order() { + let mut bytes = Vec::new(); + for (i, addr) in [0x06u8, 0x14, 0x08, 0x34, 0x36].iter().enumerate() { + bytes.extend_from_slice(&((i as i32 + 1) * 10).to_le_bytes()); + bytes.extend_from_slice(&((i as i32 + 1) * 100).to_le_bytes()); + bytes.push(*addr); + bytes.extend_from_slice(&[0u8; 7]); + } + let a = TfragAdGifs::parse(&bytes); + assert_eq!(a.tex0.data_lo, 10); + assert_eq!(a.tex0.address, 0x06); + assert_eq!(a.tex1.data_hi, 200); + assert_eq!(a.clamp.data_lo, 30); + assert_eq!(a.miptbp2.address, 0x36); + } + + #[test] + fn the_fixture_exposes_two_ad_gifs() { + let (block, header) = super::topology_tests::fixture(); + let g = parse_tfrag(&block, &header).unwrap(); + assert_eq!(g.ad_gifs.len(), 2); + assert_eq!(g.ad_gifs[1].texture_index(), 0); + // Strip 1 selects ad-gif 1 via its quadword offset of 5. + let tris = tfrag_triangles(&g, 0).unwrap(); + assert_eq!(tris[0].ad_gif, 0); + assert_eq!(tris[2].ad_gif, 1); + } +} diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index c5835b0..2d25a09 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -357,8 +357,45 @@ element size from the immediate, 2-byte payload padding, ignoring non-unpack payloads, and banking strips by arrival order. Every one produced parseable, plausible geometry; only the header's own triangle count caught them. +### Texture bindings, colours and lights + +Also settled. Adds `level_core.rs` for the index header and the texture table, +which is the join between tfrags and texel data. + +A tfrag's **ad-gifs** are five A+D quadwords (0x50 bytes) holding packed +fields rather than GS registers; the level load rewrites them in place before +drawing. On disc `TEX0_1.data_lo` indexes the level's texture table, +`TEX1_1.data_lo` is the LOD bias K and `data_hi` the minification filter, and +`CLAMP_1` holds the two wrap modes. The texture table is a count/offset pair at +index offset 0x30, entries of 16 bytes. + +The table checks out on its own terms: on level 0 it is 93 entries at 0x2900, +and 0x2900 + 93 x 16 lands exactly on the next range at 0x38. + +Three details worth writing down: + +* **K is a signed 12-bit value in 1/16 units**, sign-extended by shifting the + field up 4 and back down. Retail values are -137..-89, which is -8.56..-5.56 + mip levels. Reading the field as unsigned gives 1911 instead of -137. +* **`data_hi` of TEX1_1 is MMIN, and 4 is `LinearMipmapNearest`**, not + `LinearMipmapLinear` as the bit layout alone suggests. +* **RGBA and the light/normal records are not in the VIF lists.** They sit at + fixed offsets in the patch's data block: `rgba_size * 4` four-byte colours at + `rgba_ofs`, and `vert_count` eight-byte records at `light_ofs + 0x10`. The + origin also lives there — if no STROW row supplied one, the quadword at + `light_ofs` is the origin. + +### Verification + +Every ad-gif must index a texture that exists, or the geometry and the texture +data have been misread independently of each other. Across all 27 levels: +**47,950 ad-gifs over 18,102 patches, all indices in range**, referencing +1429 of the 1595 level texture entries (90%). Each patch's light array is +exactly `vert_count` records and its RGBA array exactly `rgba_size * 4`. + ### Still open -The ad-gif texture bindings (which texture each primitive samples), the RGBA -per-vertex colours and light records, and the LOD switch and morph rules in -`TfragProc`. Positions and topology are drawable but not yet textured. +The LOD switch and morph rules in `TfragProc`, the texel data itself, and the +GS register values the level load builds from the texture entry. A tfrag can +now be turned into positioned, UV-mapped, textured triangles; what is missing +is choosing which LOD to draw and decoding pixels. diff --git a/docs/roadmap.md b/docs/roadmap.md index c596251..8823c7e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -20,7 +20,7 @@ early, while they are still cheap to retire. header, and the `data` extent is parsed as a container of byte ranges. The geometry decompresses: level 0 yields 16,969,296 bytes of level content from a 9.7 MB stream. -- [ ] Next: tfrag texture bindings, then the geometry directory's class tables. +- [ ] Next: tfrag LOD selection and morphing, then the first drawable frame. The disc being the exact build the reference projects pin is the single biggest favourable fact so far: no disc conversion, no regional timing work, @@ -128,8 +128,13 @@ Phase 1 hands over the extents; this phase works out what each one contains. rather than positions. Every patch's strips expand to exactly its own `tri_count`: **18,102 patches, 809,879 triangles**, all indices in range. See `plan/level-assets.md` and `gc-formats::tfrag`. -- [ ] tfrag shading inputs: the ad-gif texture bindings, per-vertex RGBA and - light records, then the LOD switch and morph rules in `TfragProc`. +- [x] **tfrag texture bindings and per-vertex data.** The ad-gifs decode to a + texture index, wrap modes, minification filter and LOD bias; the level + index gained a texture table (`level_core.rs`) for those indices to land + in. Per-vertex RGBA and light/normal records recovered from their fixed + offsets. All 27 levels: 47,950 ad-gifs, every index in range, 90% of the + level texture tables referenced. +- [ ] tfrag LOD selection: the switch and morph rules in `TfragProc`. - [ ] `gameplay` — begins `WAD` + a per-level byte, and does *not* decode with the WAD LZ77 decoder. All 27 levels fail identically, so the input is wrong rather than the decoder. Ruled out: bit order, header length, the