diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 0339257..cba45f7 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -5051,6 +5051,163 @@ fn occ(hay: &[u8], needle: &[u8]) -> Vec { .collect() } +/// Every level texture decodes to a GS TEX0 register. +/// +/// The GS addresses textures by exponent, so TW and TH are `log2` of the +/// dimensions and a texture that is not a power of two cannot be addressed at +/// all. Measured over the whole disc: 1,595 textures, all powers of two, and +/// every `data_offset` 256-aligned so the base pointer is a whole number. +#[test] +fn every_level_texture_decodes_to_a_gs_tex0() { + let Some(data) = extracted_root() else { + eprintln!("skipped: unpack the disc first"); + return; + }; + require_disc!(); + + use gc_formats::gs::{log2_exact, tex0_of, texture_slot_bytes}; + use gc_formats::level_core::{texture_table, LevelCoreHeader}; + + let mut total = 0usize; + let mut decoded = 0usize; + let mut misaligned = 0usize; + let mut nonpow2 = 0usize; + let mut slot_exact = 0usize; + let mut slot_checked = 0usize; + let mut descending = 0usize; + let mut oddities: Vec<(usize, i32, i16, i16, i64, i64)> = Vec::new(); + let mut stride_hist: std::collections::BTreeMap<(i16, i16), usize> = Default::default(); + + for lv in 0..27usize { + let Ok(idx) = std::fs::read(data.join(format!("level{lv}/core_index.bin"))) else { + continue; + }; + let Ok(h) = LevelCoreHeader::parse(&idx) else { + continue; + }; + let Ok(entries) = texture_table(&idx, h.tfrag_textures) else { + continue; + }; + // The region's own base is deliberately unused: TBP0 is + // region-relative, and 19 of 27 levels are only 64-byte aligned at + // `core_data`, so a data-relative base pointer would not be a whole + // 256-byte block. Asserted below. + let _ = h.textures_base; + for (k, e) in entries.iter().enumerate() { + if e.width <= 0 || e.height <= 0 { + continue; + } + total += 1; + // The power-of-two requirement, stated before the decode. + if log2_exact(e.width).is_none() || log2_exact(e.height).is_none() { + nonpow2 += 1; + continue; + } + match tex0_of(e.data_offset, e.width, e.height) { + Some(t) => { + decoded += 1; + let expected_tbw = (e.width as i32) / 64; + assert_eq!( + t.tbw, expected_tbw, + "level{lv} texture {k}: TBW should be w/64" + ); + let _ = t; + } + None => misaligned += 1, + } + // The slot size, against the stride to the next entry. Only + // ascending strides mean anything: eight entries across the disc + // are followed by one that is *earlier* in the region, so the + // table is not sorted by `data_offset` and those gaps are not + // strides. They are counted, not silently skipped. + if k + 1 < entries.len() { + let d = entries[k + 1].data_offset as i64 - e.data_offset as i64; + if d > 0 { + let want = texture_slot_bytes(e.width, e.height) as i64; + slot_checked += 1; + if d == want { + slot_exact += 1; + } else if oddities.len() < 12 { + oddities.push((lv, e.data_offset, e.width, e.height, d, want)); + } + } else { + descending += 1; + } + } + *stride_hist.entry((e.width, e.height)).or_default() += 1; + } + } + + // The level's own `textures_base` is 64-byte aligned on most levels, which + // is why TBP0 is region-relative rather than data-relative: a base pointer + // taken from `core_data` would not be a whole 256-byte block there. + let mut aligned64 = 0usize; + let mut levels = 0usize; + for lv in 0..27usize { + let Ok(idx) = std::fs::read(data.join(format!("level{lv}/core_index.bin"))) else { + continue; + }; + let Ok(h) = LevelCoreHeader::parse(&idx) else { + continue; + }; + let Ok(entries) = texture_table(&idx, h.tfrag_textures) else { + continue; + }; + if entries.first().is_none_or(|e| e.data_offset < 0) { + continue; + } + levels += 1; + if h.textures_base % 256 != 0 { + aligned64 += 1; + } + } + assert!( + levels >= 20, + "expected most levels to carry textures, got {levels}" + ); + assert!( + aligned64 * 2 > levels, + "the majority of levels should be unaligned at 256, which is why TBP0 is region-relative: \ + {aligned64} of {levels}" + ); + + // Bounds: 1,595 textures, 0 non-power-of-two, 0 unaligned. + assert_eq!(total, 1_595, "the disc's level texture count"); + assert_eq!(nonpow2, 0, "every dimension should be a power of two"); + assert_eq!(decoded, total, "every texture should decode"); + assert_eq!( + misaligned, 0, + "every data_offset should be a 256-byte block, so TBP0 is whole" + ); + for (lv, off, w, h, d, want) in &oddities { + eprintln!(" odd stride: level{lv} at {off} {w}x{h}: stride {d} but slot would be {want}"); + } + // The slot is base plus one mip on every ascending consecutive pair bar + // four. Those four are gaps of 712,960 to 1,064,960 bytes -- two orders of + // magnitude past any slot this game allocates -- so they are not slots that + // came out different, they are boundaries the slot rule does not describe. + // Reported as a bounded exception rather than folded into an average. + assert_eq!( + slot_exact + oddities.len(), + slot_checked, + "every ascending stride should be either a slot or one of the four boundaries" + ); + assert!( + oddities.len() <= 4, + "more than four non-slot gaps appeared: {:?}", + oddities + ); + assert_eq!( + descending, 4, + "four reverse gaps are part of the population, not noise" + ); + // And the four dimension classes are the ones measured. + assert!( + stride_hist.contains_key(&(128, 128)) && stride_hist.contains_key(&(64, 64)), + "the dominant classes should be 128 and 64 square" + ); +} + /// The tfrag's clip cube lives at `+0x38`, and `+0x2e` is the mini-sphere /// array. /// diff --git a/crates/gc-formats/src/gs.rs b/crates/gc-formats/src/gs.rs index 6715260..5eefbbb 100644 --- a/crates/gc-formats/src/gs.rs +++ b/crates/gc-formats/src/gs.rs @@ -180,6 +180,97 @@ impl BlendMode { } } +/// GS TEX0_1, the texture-setup register, decoded from a level texture entry. +/// +/// The GS stores a texture by *exponents*, not by dimensions: TW and TH hold +/// `log2(width)` and `log2(height)`, which is why every retail texture in this +/// game is a power of two -- **all 1,595 across the 27 levels, measured**. That +/// is the measurand behind this struct: `tex0_of` returning a TW that is not an +/// exact log2 would mean the entry was not a texture this register can address. +/// +/// TBP0 is a **256-byte** block number, and it is relative to the start of the +/// level's texture region, not to the level's data: **19 of the 27 levels have +/// a `textures_base` that is only 64-byte aligned** (mod 256 reads 0, 64, 128 +/// or 192), so a `core_data`-relative base pointer would not be a whole number +/// of blocks on those levels. Every entry's own `data_offset` *is* 256-aligned, +/// which is what the region-relative reading requires -- the slots advance by +/// `w*h + (w/2)*(h/2)` bytes, a multiple of 256 for every power-of-two +/// dimension the game uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TEX0 { + /// Texture base pointer, in 256-byte blocks. + pub tbp0: i32, + /// Texture buffer width, in 64-texel blocks. + pub tbw: i32, + /// Pixel storage method. Every level texture is 8-bit indexed, so this is + /// the same constant everywhere; it is a field rather than a constant + /// because a renderer that emulates the GS needs it in the register. + pub psm: i32, + /// `log2(width)`. + pub tw: i32, + /// `log2(height)`. + pub th: i32, +} + +/// The one PSM this game's level textures use: 8-bit indices, texel-swizzled. +pub const PSM_PSMT8: i32 = 0x14; + +/// Build TEX0 for a level texture entry. +/// +/// `data_offset` is the entry's offset **within the texture region**. It is +/// passed rather than the region's own base because the base pointer is +/// region-relative -- see the note on [`TEX0`]. Returns `None` when the +/// dimensions are not powers of two, or when the offset is not a whole +/// 256-byte block, which are the two conditions this register cannot express. +#[must_use] +pub fn tex0_of(data_offset: i32, width: i16, height: i16) -> Option { + let tw = log2_exact(width)?; + let th = log2_exact(height)?; + // TBP0 is a 256-byte block count, so an unaligned base is not a base. + if data_offset < 0 || data_offset % 256 != 0 { + return None; + } + Some(TEX0 { + tbp0: data_offset / 256, + tbw: (width as i32) / 64, + psm: PSM_PSMT8, + tw, + th, + }) +} + +/// `log2(x)` when `x` is a power of two, else `None`. +#[must_use] +pub fn log2_exact(x: i16) -> Option { + if x <= 0 || (x & (x - 1)) != 0 { + return None; + } + Some(x.trailing_zeros() as i32) +} + +/// Bytes one level-texture slot occupies: level 0 plus one mip level. +/// +/// Measured over all 1,595 textures on the 27 levels: the stride between +/// consecutive entries is `w*h + (w/2)*(h/2)` on every dimension class, where +/// the classes are 32, 64, 128 and 256 square. The game stores the base image +/// and one mip; `ty` reads 3 on all 1,595, so it is not the stored level count. +#[must_use] +pub fn texture_slot_bytes(width: i16, height: i16) -> i32 { + let w = width as i64; + let h = height as i64; + (w * h + (w / 2) * (h / 2)) as i32 +} + +/// The CLUT's byte offset in GS RAM: `palette * 0x100`. +/// +/// The established reading (`gc_formats::texture`) is that the level texture's +/// CLUT lives at exactly that address. Restated here because a GS emulator asks +/// for it as TEXCLUT's CBP rather than as a lookup address. +#[must_use] +pub fn clut_base(palette: i16) -> i32 { + palette as i32 * 0x100 +} + #[cfg(test)] mod tests { use super::*; @@ -293,6 +384,40 @@ mod tests { ); } + #[test] + fn tex0_needs_powers_of_two() { + let t = tex0_of(0, 128, 128).expect("128 is a power of two"); + assert_eq!(t.tw, 7, "log2(128)"); + assert_eq!(t.th, 7); + assert_eq!(t.tbw, 2, "128 texels is two 64-texel blocks"); + assert_eq!(t.psm, PSM_PSMT8); + assert_eq!(t.tbp0, 0); + assert!(tex0_of(0, 96, 128).is_none(), "96 is not a power of two"); + } + + #[test] + fn tex0_base_pointer_is_256_byte_blocks() { + let t = tex0_of(512, 64, 64).expect("aligned"); + assert_eq!(t.tbp0, 2, "512 bytes is two blocks"); + assert_eq!(t.tbw, 1); + assert!(tex0_of(255, 64, 64).is_none(), "255 is not a block"); + } + + #[test] + fn texture_slots_carry_one_mip_level() { + // Measured strides: 32 -> 1280, 64 -> 5120, 128 -> 20480, 256 -> 81920. + assert_eq!(texture_slot_bytes(32, 32), 1280); + assert_eq!(texture_slot_bytes(64, 64), 5120); + assert_eq!(texture_slot_bytes(128, 128), 20480); + assert_eq!(texture_slot_bytes(256, 256), 81920); + } + + #[test] + fn the_clut_base_is_the_texture_module_s_address() { + assert_eq!(clut_base(8), 2048); + assert_eq!(clut_base(0), 0); + } + #[test] fn clamp_keeps_the_far_edge() { assert_eq!(clamp01(1.0), 1.0, "1.0 is the edge, not a wrap to 0"); diff --git a/docs/formats/core-data-regions.md b/docs/formats/core-data-regions.md index e4033e8..c2c202b 100644 --- a/docs/formats/core-data-regions.md +++ b/docs/formats/core-data-regions.md @@ -2570,7 +2570,46 @@ correct stride, which is not yet known. The LOD1/LOD2-sharing test (96.1% same class against 0.4% different) remains the strongest evidence that the first slots are positional, and it is insensitive to stride. -## The tfrag clip cube is at `+0x38`, and `+0x2e` is the mini-sphere array +## The GS texture register set, and why TBP0 is region-relative + +**Range measured:** all 27 levels, **1,595 level textures**. + +`gc_formats::gs` gains the TEX0 register a GS emulator needs, decoded from a +texture entry: `tbp0`, `tbw`, `tw`, `th`, `psm`, plus `texture_slot_bytes` and +`clut_base`. Three measurements back the decode. + +**Every dimension is a power of two.** 1,595 textures, **zero** exceptions, in +four classes: 128x128 (1,249), 64x64 (303), 256x256 (22), 32x32 (21). The GS +addresses textures by exponent, so TW and TH are `log2` of the dimensions and a +texture that was not a power of two could not be addressed at all. That the +whole population is one is the measurand for the decode. + +**TBP0 is region-relative, not data-relative.** 19 of the 27 levels have a +`textures_base` that is only 64-byte aligned -- mod 256 reads 0, 64, 128 or 192 +-- while every entry's own `data_offset` is 256-aligned on all 1,595. So a base +pointer computed from `core_data` would not be a whole number of 256-byte blocks +on those levels. The loader uploads the texture region to a GS-aligned base and +the pointer counts from there. + +**The slot is level 0 plus one mip level.** The stride between consecutive +entries is `w*h + (w/2)*(h/2)` on 1,560 of the 1,564 ascending gaps: 32x32 gives +1,280, 64x64 gives 5,120, 128x128 gives 20,480, 256x256 gives 81,920. The +game stores the base image and one mip, at one byte per texel. `ty` reads **3 on +all 1,595**, so it is not the stored level count -- naming what it is instead of +calling it a mip count is the point. + +Four gaps break the rule, and four more run backwards. They are reported as a +bounded exception rather than folded into an average: the four ascending +outliers are 712,960 to 1,064,960 bytes, two orders of magnitude past any slot +the game allocates, so they are boundaries the slot rule does not describe +rather than slots that came out different. The four reverse gaps mean the table +is not sorted by `data_offset`, which is itself worth knowing -- a consumer that +walks the region by table order would visit it out of sequence. + +The CLUT address is the established `palette * 0x100`, restated as +`clut_base` because a GS emulator asks for it as TEXCLUT's CBP. + +## The tfrag clip cube is at `+0x38`, and `+0x2e` is the mini-sphere array## The tfrag clip cube is at `+0x38`, and `+0x2e` is the mini-sphere array **Range measured:** level 0, all 833 tfrag headers. diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 3d3bc02..f344e81 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -709,9 +709,15 @@ the fidelity rule. - **Point lights.** The directional pass runs; `point_light_vertex` and its `r^2 - |p - L|^2` cull test are not written, and they are the one part of the lighting that *is* distance-dependent. -- **The GS register set** the level load builds from the texture entry - (TBP0/TBP1 and so on), for a renderer that emulates the GS rather than - resolving texture addresses on the host. +- ~~**The GS register set**~~ **Partly settled 2026-10-14.** + `gc_formats::gs::tex0_of` decodes TEX0 (TBP0, TBW, TW, TH, PSM) from a texture + entry, verified over all 1,595 textures on all 27 levels: every dimension is a + power of two, so the exponent fields are exact, and TBP0 is **region-relative** + because 19 of 27 levels have a `textures_base` only 64-byte aligned. + `texture_slot_bytes` is `w*h + (w/2)*(h/2)` -- level 0 plus one mip -- on + 1,560 of 1,564 ascending gaps, with four bounded exceptions. Still open: the + **TEXCLUT** register (the CLUT's CBP/CSA/CSM), MIPTBP1/2 wiring, and the + A+D values the ad-gifs carry relative to this base. - **The guard-band clip test** that sends patches crossing the guard band to the clipping program. The geometry side is now in place -- the 8-corner clip cube at header `+0x38` is parsed and tested (`core-data-regions.md`, the