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