Decode tfrag texture bindings, vertex colours and light records

Adds level_core.rs (the uncompressed core index header and texture table)
and extends tfrag.rs with the data a draw call needs beyond geometry.

An ad-gif is five A+D quadwords 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 both
wrap modes.

Three things are easy to get wrong here:

* 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 mip-scaled;
  reading it unsigned gives 1911 instead of -137.
* TEX1_1.data_hi is MMIN, and 4 decodes as LinearMipmapNearest, not
  LinearMipmapLinear as the raw bit layout suggests.
* RGBA and the light/normal records are not in the VIF lists at all. They
  sit at fixed offsets in the patch data block: rgba_size*4 colours at
  rgba_ofs, vert_count light records at light_ofs+0x10. The tfrag origin
  lives there too -- if no STROW row supplied one, the quadword at
  light_ofs is it.

The texture table checks out on its own terms: 93 entries at index offset
0x2900 on level 0, and 0x2900 + 93*16 lands exactly on the next range.

Verification: an ad-gif index outside the level's table would mean the
geometry and the textures were misread independently, so every one is
range-checked against real entries. All 27 levels: 47,950 ad-gifs over
18,102 patches, all in range, referencing 1429 of 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: LOD selection and morphing, and the texel data itself.
This commit is contained in:
2026-10-10 02:22:51 +02:00
parent 9459f15329
commit 48d9506cf1
6 changed files with 829 additions and 15 deletions
+381 -8
View File
@@ -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<TfragAdGifs>,
/// Per-vertex colours, indexed by position index.
pub rgba: Vec<TfragRgba>,
/// Per-vertex light and normal records, indexed by position index.
pub lights: Vec<TfragLight>,
}
impl TfragGeometry {
@@ -547,7 +694,15 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
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<TfragGeometry>
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<TfragGeometry>
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<Vec<TfragRgba>> {
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<Vec<TfragLight>> {
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<Vec<[i16; 3]>> {
if bytes.len() < count * 6 {
@@ -712,7 +944,7 @@ pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result<Vec<TfragTriangl
}
if k == 0 || s.end_of_packet_flag >= 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<u8>, TfragHeader) {
pub(super) fn fixture() -> (Vec<u8>, 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);
}
}