Parse tfrag topology: vertex info, strips, indices and triangles

Turns tfrag positions into drawable geometry. Every unpack in a patch's
VIF lists is now classified by format *and* destination VU address, using
the VU header at the head of the common list. That table is what tells
the strips from the indices from the parent arrays: they are all V4_8
and only the address distinguishes them.

Strip indices address the vertex-info array, not the position array.
Several vertex-info entries can share a position, so an index resolves
through vertex_info[i].vertex / 2 to reach a position. Vertex-info also
carries UVs and the LOD parent link.

A strip run of n vertices is a GS triangle strip of n - 2 triangles with
alternating winding. The vertex count is biased by -128 when negative,
and the first record of a list is always biased even when positive --
real patches open with -116 or -124, never a small positive number.

tri_count lives in the patch header and cannot be derived from the lists
without walking them, so reproducing it is a strong check: all 27 levels,
18,102 patches, 809,879 triangles, every patch matching its header, every
triangle index inside its LOD's vertex-info range.

Four wrong turns got there, each of which failed quietly rather than
loudly: reading the element size from the immediate instead of the
command, 2-byte payload padding instead of 4, ignoring the inline
payload of STROW/STCOL/STMASK/MPG/DIRECT, and banking strips by arrival
order instead of by LOD. Every one produced parseable, plausible geometry.
Only the header's own triangle count caught them.

Still open: ad-gif texture bindings, per-vertex RGBA and light records,
and the LOD switch and morph rules.
This commit is contained in:
2026-10-10 02:15:32 +02:00
parent 36641b4cf8
commit 9459f15329
4 changed files with 830 additions and 70 deletions
+93
View File
@@ -487,3 +487,96 @@ fn tfrag_vertices_decode_for_every_level() {
); );
eprintln!("decoded {total_verts} vertices from {total_tfrags} tfrags across {levels} levels"); eprintln!("decoded {total_verts} vertices from {total_tfrags} tfrags across {levels} levels");
} }
/// Terrain topology: strips and index arrays must expand to exactly the
/// triangle count each patch's header claims.
///
/// `tri_count` is stored in the patch header but is not derivable from the
/// lists directly, so reproducing it on all 18,102 patches is a strong check
/// that the strip walk, the index bank and the LOD assignment are all right.
/// Triangle indices must also stay inside the vertex-info range for the LOD.
#[test]
fn tfrag_topology_reproduces_the_header_triangle_count() {
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 triangles = 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 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}"));
let tris = gc_formats::tfrag::tfrag_triangles(&g, 0)
.unwrap_or_else(|e| panic!("level{n} patch {i}: {e}"));
assert_eq!(
tris.len(),
th.tri_count as usize,
"level{n} patch {i}: {} triangles, header says {}",
tris.len(),
th.tri_count
);
let vinfo = g.lod_vinfo_count(0);
for t in &tris {
let idx = [t.a as usize, t.b as usize, t.c as usize];
assert!(
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);
}
patches += 1;
triangles += tris.len();
}
levels += 1;
}
assert!(levels > 0, "no unpacked levels were found");
assert!(patches > 18_000, "expected 18k+ patches, got {patches}");
assert!(
triangles > 700_000,
"expected 700k+ triangles, got {triangles}"
);
eprintln!("reproduced {triangles} triangles across {patches} patches in {levels} levels");
}
+681 -65
View File
@@ -27,12 +27,17 @@
//! //!
//! # What is recovered here //! # What is recovered here
//! //!
//! Vertex **positions**: a V3_16 signed unpack inside the tfrag's VIF command //! Vertex **positions**, **texture coordinates** and **triangles**. Positions
//! list, giving 6 bytes per vertex as three `s16` world coordinates. //! are a V3_16 signed unpack; texture coordinates and LOD parenting are a V4_16
//! unpack; triangles come from the strip command lists and index arrays. All of
//! it is classified by unpack format *and* destination VU address, using the VU
//! header at the head of the common list.
//! //!
//! Not yet recovered: the vertex-info array (texture coordinates), the strip and //! Strip indices address the vertex-info array rather than the position array —
//! index arrays that define the triangles, and the ad-gif texture bindings. See //! several vertex-info entries can share a position.
//! `docs/plan/tfrag.md`. //!
//! Not yet recovered: the ad-gif texture bindings themselves, and the LOD
//! switch and morph rules in `TfragProc`. See `docs/plan/tfrag.md`.
use crate::buf::{Buf, Error, ErrorKind, Result}; use crate::buf::{Buf, Error, ErrorKind, Result};
@@ -250,6 +255,16 @@ impl VifCode {
((32usize >> self.vl()) * (self.vn() as usize + 1)) / 8 ((32usize >> self.vl()) * (self.vn() as usize + 1)) / 8
} }
/// Destination quadword address in VU memory, bits 9..0 of the immediate.
pub fn vu_address(&self) -> u16 {
self.imm & 0x3ff
}
/// Unpack USN bit: 1 = unsigned.
pub fn usn(&self) -> bool {
(self.imm >> 14) & 1 != 0
}
/// The PS2 vector format name, for diagnostics. /// The PS2 vector format name, for diagnostics.
pub fn format_name(&self) -> String { pub fn format_name(&self) -> String {
const NAMES: [&str; 4] = ["V1", "V2", "V3", "V4"]; const NAMES: [&str; 4] = ["V1", "V2", "V3", "V4"];
@@ -315,77 +330,435 @@ pub const DIRECT: u8 = 0x50;
pub const DIRECTHL: u8 = 0x51; pub const DIRECTHL: u8 = 0x51;
/// The VIF lists that make up a tfrag's data block, in the order the game /// The VIF lists that make up a tfrag's data block, in the order the game
/// transfers them. /// transfers them, each paired with the LOD it contributes to (or `None`).
/// ///
/// The four offsets do not run in ascending order: the LOD-2 list sits *before* /// The offsets do not run in ascending order: the LOD-2 list sits *before* the
/// the common list. Each is a byte range from the start of the tfrag's data /// common list. The LOD-0-only list has no offset of its own, starting instead
/// block. Positions appear in the common list and the two LOD-0 lists only. /// at `shared_ofs + lod_1_size * 16`. Strips and index arrays are collected as
fn position_lists(h: &TfragHeader) -> [(usize, usize); 3] { /// the lists are walked and banked when the LOD that owns them is complete.
// The LOD-0-only list starts here rather than at an offset of its own. /// The byte range of one VIF list within a tfrag's data block.
type ListRange = (usize, usize);
/// The VIF lists that make up a tfrag's data block, in the order the game
/// transfers them, each paired with the LOD it completes (or `None`).
///
/// The offsets do not run in ascending order: the LOD-2 list sits *before* the
/// common list. The LOD-0-only list has no offset of its own, starting instead
/// at `shared_ofs + lod_1_size * 16`. Strips and index arrays accumulate as
/// the lists are walked and are banked when their LOD completes.
fn list_ranges(h: &TfragHeader) -> [(Option<usize>, ListRange); 5] {
let lod0_start = h.shared_ofs as usize + h.lod_1_size as usize * 0x10; let lod0_start = h.shared_ofs as usize + h.lod_1_size as usize * 0x10;
[ [
(h.shared_ofs as usize, h.lod_1_ofs as usize), (None, (h.shared_ofs as usize, h.lod_1_ofs as usize)),
(h.lod_0_ofs as usize, lod0_start), (Some(2), (h.lod_2_ofs as usize, h.shared_ofs as usize)),
(lod0_start, h.rgba_ofs as usize), (Some(1), (h.lod_1_ofs as usize, h.lod_0_ofs as usize)),
(None, (h.lod_0_ofs as usize, lod0_start)),
(Some(0), (lod0_start, h.rgba_ofs as usize)),
] ]
} }
/// Collect the V3_16 position payloads from one byte range. /// The VU header: 20 `u16` describing where each kind of data is unpacked.
fn positions_in_list(block: &[u8], start: usize, end: usize, out: &mut Vec<[i32; 3]>) -> bool { ///
if start >= end || end > block.len() { /// Every unpack in a tfrag's lists is classified by format *and* destination
// Empty lists and inverted ranges (a level with no LOD 2) are normal. /// VU address, so this table is what tells the strips from the indices from
return start >= end || end > block.len(); /// the parent arrays.
} #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
let mut pos = start; pub struct TfragVuHeader {
while pos + 4 <= end { pub positions_common_count: u16,
let code = u32::from_le_bytes([block[pos], block[pos + 1], block[pos + 2], block[pos + 3]]); pub positions_lod_01_count: u16,
let packet = VifCode::decode(code); pub positions_lod_0_count: u16,
if pos + packet.packet_size() > end { pub positions_common_addr: u16,
return false; pub vertex_info_common_addr: u16,
pub vertex_info_lod_01_addr: u16,
pub vertex_info_lod_0_addr: u16,
pub indices_addr: u16,
pub strips_addr: u16,
}
impl TfragVuHeader {
fn parse(bytes: &[u8]) -> Result<TfragVuHeader> {
let mut b = Buf::new(bytes);
let mut w = [0u16; 20];
for slot in w.iter_mut() {
*slot = b.u16("tfrag VU header")?;
} }
if packet.is_unpack() && packet.vl() == 1 && packet.vn() == 2 { Ok(TfragVuHeader {
let base = pos + 4; positions_common_count: w[0],
for v in 0..packet.count() { positions_lod_01_count: w[2],
let o = base + v * 6; positions_lod_0_count: w[4],
if o + 6 > end { positions_common_addr: w[6],
return false; vertex_info_common_addr: w[7],
} vertex_info_lod_01_addr: w[9],
out.push([ vertex_info_lod_0_addr: w[11],
i16::from_le_bytes([block[o], block[o + 1]]) as i32, indices_addr: w[13],
i16::from_le_bytes([block[o + 2], block[o + 3]]) as i32, strips_addr: w[18],
i16::from_le_bytes([block[o + 4], block[o + 5]]) as i32, })
]); }
}
/// Per-vertex texture and LOD data (V4_16 signed, 8 bytes).
///
/// Strip indices address *this* array, not the position array: several
/// vertex-info entries can share one position.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TfragVertexInfo {
/// Texture S, signed 1/4096 fixed point.
pub s: i16,
/// Texture T, signed 1/4096 fixed point.
pub t: i16,
/// VU quadword offset of this entry's LOD parent's position.
pub parent: i16,
/// VU quadword offset of this entry's own position; index = `vertex / 2`.
pub vertex: i16,
}
/// One strip command record (V4_8 signed, 4 bytes).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TfragStrip {
/// Vertex count, biased by -128 when the record also carries an action.
/// Zero ends the list.
pub vertex_count_and_flag: i8,
/// >= 0 loads an ad-gif; negative is an XGKICK ending the GS packet.
pub end_of_packet_flag: i8,
/// Quadword offset into the ad-gif array (ad-gif index = `z / 5`); on a
/// kick record, -1 keeps the current texture.
pub ad_gif_offset: i8,
}
/// Strips and index bytes of one level of detail.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TfragLod {
pub strips: Vec<TfragStrip>,
/// Indices into [`TfragGeometry::vertex_info`].
pub indices: Vec<u8>,
}
/// One triangle, as vertex-info indices plus the texture primitive in force.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TfragTriangle {
pub a: u16,
pub b: u16,
pub c: u16,
pub ad_gif: u16,
}
/// Everything one tfrag contributes to a draw call.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TfragGeometry {
pub positions: Vec<[i32; 3]>,
pub vertex_info: Vec<TfragVertexInfo>,
pub origin: [i32; 4],
/// Positions per tier, concatenated common || lod01 || lod0.
pub positions_tier: [usize; 3],
/// Vertex-info entries per tier, same order.
pub vinfo_tier: [usize; 3],
/// Index 0 is highest detail.
pub lod: [TfragLod; 3],
pub ad_gif_count: usize,
}
impl TfragGeometry {
/// Vertex-info entries a LOD draws from: LOD 2 = common, LOD 1 adds LOD 0+1,
/// LOD 0 adds LOD 0.
pub fn lod_vinfo_count(&self, lod: usize) -> usize {
match lod {
0 => self.vinfo_tier[0] + self.vinfo_tier[1] + self.vinfo_tier[2],
1 => self.vinfo_tier[0] + self.vinfo_tier[1],
_ => self.vinfo_tier[0],
}
}
/// Position index a vertex-info entry uses, as the engine resolves it.
pub fn position_index(&self, vinfo: usize) -> usize {
let idx = self
.vertex_info
.get(vinfo)
.map_or(0, |v| v.vertex as usize / 2);
if idx >= self.positions.len() {
0
} else {
idx
}
}
/// World-space position of a vertex-info entry, `(origin + local) / 1024`.
pub fn world_position(&self, vinfo: usize) -> [f32; 3] {
let local = self.positions[self.position_index(vinfo)];
[
(self.origin[0] + local[0]) as f32 / 1024.0,
(self.origin[1] + local[1]) as f32 / 1024.0,
(self.origin[2] + local[2]) as f32 / 1024.0,
]
}
}
/// Parse one tfrag's data block.
///
/// `block` starts at the tfrag's own `data` offset; every offset in `header`
/// is relative to that.
pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry> {
let mut g = TfragGeometry::default();
let mut vu = TfragVuHeader::default();
let mut have_vu = false;
let mut have_origin = false;
let mut strips: Vec<TfragStrip> = Vec::new();
let mut indices: Vec<u8> = Vec::new();
for (lod, (start, end)) in list_ranges(header) {
// Inverted ranges are normal: RAC2 leaves the LOD-2 list empty, and a
// level may have no LOD 0+1 geometry.
if start >= end || end > block.len() {
continue;
}
let mut pos = start;
while pos + 4 <= end {
let code =
u32::from_le_bytes([block[pos], block[pos + 1], block[pos + 2], block[pos + 3]]);
let v = VifCode::decode(code);
if pos + v.packet_size() > end {
return Err(Error {
offset: 0,
context: "tfrag VIF packet runs past its list",
kind: ErrorKind::OutOfBounds {
needed: 0,
available: 0,
},
});
} }
let payload = &block[pos + 4..pos + v.packet_size()];
if v.cmd == STROW {
// The origin row is the last STROW that is not the vertex-info
// row, and it precedes every position unpack.
if payload.len() >= 16
&& u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]])
!= 0x4500_0000
{
for (i, slot) in g.origin.iter_mut().enumerate() {
*slot = i32::from_le_bytes(payload[i * 4..i * 4 + 4].try_into().unwrap());
}
have_origin = true;
}
} else if v.is_unpack() {
let addr = v.vu_address();
match (v.vn(), v.vl()) {
(3, 1) if v.usn() && addr == 0 => {
vu = TfragVuHeader::parse(payload)?;
have_vu = true;
}
// Ad-gifs: 5 quadwords each.
(3, 0) => g.ad_gif_count = v.count() / 5,
(2, 1) => {
let tier = if addr == vu.positions_common_addr {
0
} else if g.positions_tier[1] == 0
&& g.positions_tier[2] == 0
&& addr
== vu
.positions_common_addr
.wrapping_add(2 * g.positions_tier[0] as u16)
{
1
} else {
2
};
g.positions_tier[tier] = v.count();
for p3 in read_i16x3(payload, v.count())? {
g.positions.push([p3[0] as i32, p3[1] as i32, p3[2] as i32]);
}
}
(3, 1) => {
let tier = if addr == vu.vertex_info_common_addr {
0
} else if addr == vu.vertex_info_lod_01_addr {
1
} else {
2
};
g.vinfo_tier[tier] = v.count();
for w in read_i16(payload, v.count())? {
g.vertex_info.push(TfragVertexInfo {
s: w[0],
t: w[1],
parent: w[2],
vertex: w[3],
});
}
}
(3, 2) => {
if !v.usn() && addr == vu.strips_addr {
strips = read_strips(payload, v.count())?;
} else if addr == vu.indices_addr {
indices = payload[..v.count() * 4].to_vec();
}
}
_ => {}
}
}
pos += v.packet_size();
}
if let Some(lod) = lod {
g.lod[lod] = TfragLod {
strips: std::mem::take(&mut strips),
indices: std::mem::take(&mut indices),
};
} }
pos += packet.packet_size();
} }
true
// An empty tfrag legitimately has no VU header, so this only bites when
// there is geometry that needed classifying.
if !have_vu && header.vert_count != 0 {
return Err(Error {
offset: 0,
context: "tfrag has no VU header",
kind: ErrorKind::Invalid("tfrag has no VU header"),
});
}
if header.vert_count != 0 && g.positions.len() != header.vert_count as usize {
return Err(Error {
offset: 0,
context: "tfrag position count does not match its header",
kind: ErrorKind::Invalid("tfrag position count does not match its header"),
});
}
let _ = have_origin;
Ok(g)
}
/// 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 {
return Err(Error {
offset: 0,
context: "unpack payload smaller than element count",
kind: ErrorKind::OutOfBounds {
needed: count * 6,
available: bytes.len(),
},
});
}
Ok((0..count)
.map(|i| {
let o = i * 6;
[
i16::from_le_bytes([bytes[o], bytes[o + 1]]),
i16::from_le_bytes([bytes[o + 2], bytes[o + 3]]),
i16::from_le_bytes([bytes[o + 4], bytes[o + 5]]),
]
})
.collect())
}
/// Read `count` V4_16 elements: four `i16` each, 8 bytes per element.
fn read_i16(bytes: &[u8], count: usize) -> Result<Vec<[i16; 4]>> {
if bytes.len() < count * 8 {
return Err(Error {
offset: 0,
context: "unpack payload smaller than element count",
kind: ErrorKind::OutOfBounds {
needed: count * 8,
available: bytes.len(),
},
});
}
Ok((0..count)
.map(|i| {
let o = i * 8;
[
i16::from_le_bytes([bytes[o], bytes[o + 1]]),
i16::from_le_bytes([bytes[o + 2], bytes[o + 3]]),
i16::from_le_bytes([bytes[o + 4], bytes[o + 5]]),
i16::from_le_bytes([bytes[o + 6], bytes[o + 7]]),
]
})
.collect())
}
fn read_strips(bytes: &[u8], count: usize) -> Result<Vec<TfragStrip>> {
if bytes.len() < count * 4 {
return Err(Error {
offset: 0,
context: "strip array shorter than its count",
kind: ErrorKind::OutOfBounds {
needed: count * 4,
available: bytes.len(),
},
});
}
Ok((0..count)
.map(|i| TfragStrip {
vertex_count_and_flag: bytes[i * 4] as i8,
end_of_packet_flag: bytes[i * 4 + 1] as i8,
ad_gif_offset: bytes[i * 4 + 2] as i8,
})
.collect())
}
/// Expand a LOD's strips and indices into triangles.
///
/// Runs are GS triangle strips of `n` vertices, so each contributes `n - 2`
/// triangles with alternating winding. Reproduces the header's `tri_count` on
/// every retail tfrag, which is the check that the strip walk is correct.
pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result<Vec<TfragTriangle>> {
let mut out = Vec::new();
let l = &g.lod[lod];
let mut cursor = 0usize;
let mut ad_gif = 0u16;
for (k, s) in l.strips.iter().enumerate() {
let mut n = s.vertex_count_and_flag as i32;
if k == 0 || n <= 0 {
if k != 0 && n == 0 {
break;
}
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 {
return Err(Error {
offset: 0,
context: "strip ad-gif offset outside the ad-gif array",
kind: ErrorKind::Invalid("strip ad-gif offset outside the ad-gif array"),
});
}
ad_gif = (z / 5) as u16;
}
n += 128;
}
let n = n as usize;
if cursor + n > l.indices.len() {
return Err(Error {
offset: 0,
context: "strip runs past the index array",
kind: ErrorKind::OutOfBounds {
needed: 0,
available: 0,
},
});
}
for i in 2..n {
let (a, b, c) = (
l.indices[cursor + i - 2] as u16,
l.indices[cursor + i - 1] as u16,
l.indices[cursor + i] as u16,
);
out.push(if i & 1 != 0 {
TfragTriangle {
a: b,
b: a,
c,
ad_gif,
}
} else {
TfragTriangle { a, b, c, ad_gif }
});
}
cursor += n;
}
Ok(out)
} }
/// Vertex positions of a tfrag, in game order. /// Vertex positions of a tfrag, in game order.
///
/// `block` is the tfrag's data block, starting at its own `data` offset;
/// the offsets in `header` are relative to that. `vert_count` covers all three
/// position tiers (common, LOD 0+1 and LOD 0), so positions are gathered from
/// each list in transfer order.
///
/// Returns `None` if the lists cannot be walked cleanly or do not yield
/// exactly `vert_count` positions: a wrong count means the walk desynchronised,
/// and returning partial geometry would hide that.
pub fn tfrag_positions(block: &[u8], header: &TfragHeader) -> Option<Vec<[i32; 3]>> { pub fn tfrag_positions(block: &[u8], header: &TfragHeader) -> Option<Vec<[i32; 3]>> {
if header.vert_count == 0 { parse_tfrag(block, header).ok().map(|g| g.positions)
return Some(Vec::new());
}
let mut out = Vec::with_capacity(header.vert_count as usize);
for (start, end) in position_lists(header) {
if !positions_in_list(block, start, end, &mut out) {
return None;
}
}
if out.len() != header.vert_count as usize {
return None;
}
Some(out)
} }
#[cfg(test)] #[cfg(test)]
@@ -490,18 +863,43 @@ mod tests {
assert_eq!(VifCode::decode(0x1400_0000).payload_size(), 0); // MSCAL assert_eq!(VifCode::decode(0x1400_0000).payload_size(), 0); // MSCAL
} }
/// A 20-word VU header placing the positions at VU address 2.
fn vu_header_bytes(positions: u16) -> Vec<u8> {
let mut w = [0u16; 20];
w[0] = positions;
w[6] = 2; // positions_common_addr
w[7] = 4; // vertex_info_common_addr
let mut out = Vec::new();
// 5 V4_16 vectors = 20 u16, unsigned (bit 14) and FLG (bit 15), to address 0.
out.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x6d,
num: 5,
imm: 0xc000,
}
.to_word()
.to_le_bytes(),
);
for x in w {
out.extend_from_slice(&x.to_le_bytes());
}
out
}
/// Build a data block holding a single V3_16 unpack in the common list. /// Build a data block holding a single V3_16 unpack in the common list.
fn synthetic_tfrag(verts: &[[i16; 3]]) -> (Vec<u8>, TfragHeader) { fn synthetic_tfrag(verts: &[[i16; 3]]) -> (Vec<u8>, TfragHeader) {
let mut block = Vec::new(); let mut block = Vec::new();
let n = verts.len() as u16; let n = verts.len() as u16;
// NUM is 8 bits, so a 256-element unpack has to encode zero. // NUM is 8 bits, so a 256-element unpack has to encode zero.
let num = if n == 256 { 0u16 } else { n }; let num = if n == 256 { 0u16 } else { n };
block.extend_from_slice(&vu_header_bytes(n));
block.extend_from_slice( block.extend_from_slice(
&VifCode { &VifCode {
irq: false, irq: false,
cmd: 0x69, cmd: 0x69,
num, num,
imm: 0x8009, imm: 0x8000 | 2,
} }
.to_word() .to_word()
.to_le_bytes(), .to_le_bytes(),
@@ -537,7 +935,11 @@ mod tests {
[1123, -3072, -5203], [1123, -3072, -5203],
]; ];
let (block, header) = synthetic_tfrag(&verts); let (block, header) = synthetic_tfrag(&verts);
let pos = tfrag_positions(&block, &header).expect("positions should be found"); let g = match parse_tfrag(&block, &header) {
Ok(g) => g,
Err(e) => panic!("parse error: {e:?}"),
};
let pos = g.positions.clone();
assert_eq!(pos.len(), 4); assert_eq!(pos.len(), 4);
for (got, want) in pos.iter().zip(verts.iter()) { for (got, want) in pos.iter().zip(verts.iter()) {
assert_eq!(*got, [want[0] as i32, want[1] as i32, want[2] as i32]); assert_eq!(*got, [want[0] as i32, want[1] as i32, want[2] as i32]);
@@ -550,7 +952,13 @@ mod tests {
// +24 rather than +22. // +24 rather than +22.
let verts = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; let verts = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
let (block, header) = synthetic_tfrag(&verts); let (block, header) = synthetic_tfrag(&verts);
let c = VifCode::decode(u32::from_le_bytes(block[0..4].try_into().unwrap())); // The VU header now leads the list, so find the V3_16 word rather than
// assuming it is first.
let c = block
.chunks_exact(4)
.map(|w| VifCode::decode(u32::from_le_bytes(w.try_into().unwrap())))
.find(|c| c.cmd == 0x69)
.expect("fixture should hold a V3_16 unpack");
assert_eq!(c.payload_size(), 20); assert_eq!(c.payload_size(), 20);
let pos = tfrag_positions(&block, &header).expect("positions should be found"); let pos = tfrag_positions(&block, &header).expect("positions should be found");
assert_eq!(pos.len(), 3); assert_eq!(pos.len(), 3);
@@ -586,3 +994,211 @@ mod tests {
assert_eq!(tfrag_positions(&block, &header), Some(Vec::new())); assert_eq!(tfrag_positions(&block, &header), Some(Vec::new()));
} }
} }
#[cfg(test)]
mod topology_tests {
use super::*;
/// 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) {
const INDICES: u16 = 6;
const STRIPS: u16 = 8;
let verts: [[i16; 3]; 6] = [
[99, -3795, -5203],
[1123, -3795, -5203],
[-715, -3072, -5203],
[1123, -3072, -5203],
[99, -3072, -2601],
[1123, -3072, -2601],
];
let mut block = Vec::new();
// The VU header: positions at 2, common vertex info at 4, indices at 6,
// strips at 8. Everything else is classified against these addresses.
let mut w = [0u16; 20];
w[0] = 6; // positions_common_count
w[6] = 2; // positions_common_addr
w[7] = 4; // vertex_info_common_addr
w[13] = INDICES;
w[18] = STRIPS;
block.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x6d,
num: 5,
imm: 0xc000,
}
.to_word()
.to_le_bytes(),
);
for x in w {
block.extend_from_slice(&x.to_le_bytes());
}
// Two ad-gifs (5 quadwords each), so strip offsets 0 and 5 are valid.
block.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x6c,
num: 10,
imm: 0x8000,
}
.to_word()
.to_le_bytes(),
);
block.resize(block.len() + 160, 0);
// Common vertex info: 6 entries, each pointing at its own position.
block.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x6d,
num: 6,
imm: 0x8000 | 4,
}
.to_word()
.to_le_bytes(),
);
for i in 0..6u16 {
for c in [i * 8, i * 8, 0, i * 2] {
block.extend_from_slice(&c.to_le_bytes());
}
}
// Positions.
block.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x69,
num: 6,
imm: 0x8000 | 2,
}
.to_word()
.to_le_bytes(),
);
for v in verts {
for c in v {
block.extend_from_slice(&c.to_le_bytes());
}
}
// Pad the common list to a quadword boundary so the LOD-0 ranges are
// well formed, as they are on disc.
let common_end = block.len().div_ceil(16) * 16;
block.resize(common_end, 0);
// LOD-0 list: strips then indices.
block.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x6e,
num: 3,
imm: STRIPS,
}
.to_word()
.to_le_bytes(),
);
for s in [
// The first record always carries the -128 bias, as on disc.
TfragStrip {
vertex_count_and_flag: -124,
end_of_packet_flag: 0,
ad_gif_offset: 0,
},
TfragStrip {
vertex_count_and_flag: -124,
end_of_packet_flag: 0,
ad_gif_offset: 5,
},
TfragStrip {
vertex_count_and_flag: 0,
end_of_packet_flag: -1,
ad_gif_offset: -1,
},
] {
block.extend_from_slice(&[
s.vertex_count_and_flag as u8,
s.end_of_packet_flag as u8,
s.ad_gif_offset as u8,
0,
]);
}
let indices_at = block.len();
block.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x6e,
num: 8,
imm: 0x4000 | INDICES,
}
.to_word()
.to_le_bytes(),
);
block.extend_from_slice(&[0u8, 1, 2, 3, 4, 5, 4, 3]);
// 8 V4_8 elements is a 32-byte payload; the rest is padding.
block.resize(indices_at + 4 + 32, 0);
let lod0_end = block.len().div_ceil(4) * 4;
block.resize(lod0_end, 0);
let mut h = TfragHeader::parse(&[0u8; TFRAG_HEADER_SIZE]).unwrap();
h.vert_count = 6;
h.tri_count = 4; // two runs of 4 vertices
h.shared_ofs = 0;
h.lod_1_ofs = common_end as u16;
h.lod_0_ofs = common_end as u16;
h.lod_1_size = (common_end / 16) as u8;
h.rgba_ofs = lod0_end as u16;
(block, h)
}
#[test]
fn expands_strips_into_triangles() {
let (block, header) = fixture();
let g = match parse_tfrag(&block, &header) {
Ok(g) => g,
Err(e) => panic!("parse error: {e:?}"),
};
assert_eq!(g.positions.len(), 6);
assert_eq!(g.positions[0], [99, -3795, -5203]);
assert_eq!(g.vertex_info.len(), 6);
assert_eq!(g.vertex_info[3].vertex, 6);
let tris = tfrag_triangles(&g, 0).expect("triangles should expand");
// 4-vertex runs give 2 triangles each; the -124 record is 4 after bias.
assert_eq!(tris.len(), 4);
assert_eq!((tris[0].a, tris[0].b, tris[0].c), (0, 1, 2));
assert_eq!((tris[1].a, tris[1].b, tris[1].c), (2, 1, 3));
assert_eq!((tris[2].a, tris[2].b, tris[2].c), (4, 5, 4));
}
#[test]
fn a_vertex_info_entry_resolves_to_a_position() {
let (block, header) = fixture();
let g = parse_tfrag(&block, &header).unwrap();
assert_eq!(g.position_index(3), 3);
assert_eq!(g.lod_vinfo_count(0), 6);
assert_eq!(
g.world_position(0),
[99.0 / 1024.0, -3795.0 / 1024.0, -5203.0 / 1024.0]
);
}
#[test]
fn a_truncated_list_is_an_error() {
let (mut block, header) = fixture();
// Claim the common list runs past the block.
let mut bad = header;
bad.lod_1_ofs = 0x7ff0;
assert!(parse_tfrag(&block, &bad).is_err());
block.clear();
assert!(parse_tfrag(&block, &bad).is_err());
}
#[test]
fn a_strip_past_its_index_array_is_an_error() {
let (block, header) = fixture();
let mut g = parse_tfrag(&block, &header).unwrap();
// Strip declares more vertices than the index array holds.
g.lod[0].indices.truncate(2);
assert!(tfrag_triangles(&g, 0).is_err());
}
}
+47 -2
View File
@@ -313,7 +313,52 @@ mismatch means the walk desynchronised, so `tfrag_positions` returns `None`
rather than partial geometry. Across all 27 levels: 18,102 patches, rather than partial geometry. Across all 27 levels: 18,102 patches,
821,439 vertices, zero mismatches. 821,439 vertices, zero mismatches.
### Topology: strips, indices and triangles
Also settled. `parse_tfrag` walks each list and classifies every unpack by
format *and* destination VU address, using the VU header at the head of the
common list (5 V4_16 vectors, unsigned, to address 0). That table is what
separates the strips from the indices from the parent arrays — they are all
V4_8, and only the address tells them apart.
| `(vn, vl)` | Meaning |
|---|---|
| (3, 1), unsigned, addr 0 | the VU header itself |
| (3, 0) | ad-gifs, 5 quadwords each |
| (2, 1) | positions, V3_16 |
| (3, 1) | vertex info: UV, LOD parent, own position |
| (3, 2) | strips (signed) and indices (unsigned) |
**Strip indices address the vertex-info array, not the position array.** Several
vertex-info entries can share a position, so a strip index resolves through
`vertex_info[i].vertex / 2` to reach a position.
A strip record is four bytes: a vertex count, a packet flag and an ad-gif
offset. A negative count is biased by −128, and *the first record of a list is
always biased* even when its count is positive — a real patch opens with −116 or
−124, never a small positive number. A zero count ends the list. A run of `n`
vertices is a GS triangle strip of `n − 2` triangles with alternating winding.
**Bank the LODs by index, not by arrival.** The lists are walked in transfer
order but the strip and index arrays have to be stored by *which LOD they
belong to*. Appending them as they are found fills the arrays LOD-2-first,
which looks fine until the triangle counts come out low.
### Verification
`tri_count` is stored in the patch header and cannot be derived from the lists
without walking them, so reproducing it is a strong check. All 27 levels:
**18,102 patches, 809,879 triangles, every patch matching its header**, with
every triangle index inside that LOD's vertex-info range and every ad-gif index
inside the ad-gif array.
Getting there took four wrong turns, each of which failed quietly: reading the
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.
### Still open ### Still open
Strips and triangle indices, the RGBA vertex-info arrays, texture binding, and The ad-gif texture bindings (which texture each primitive samples), the RGBA
the LOD/morph/cull rules in `TfragProc`. Positions alone are not drawable. per-vertex colours and light records, and the LOD switch and morph rules in
`TfragProc`. Positions and topology are drawable but not yet textured.
+9 -3
View File
@@ -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 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 geometry decompresses: level 0 yields 16,969,296 bytes of level content
from a 9.7 MB stream. from a 9.7 MB stream.
- [ ] Next: tfrag triangle topology, then the geometry directory's class tables. - [ ] Next: tfrag texture bindings, then the geometry directory's class tables.
The disc being the exact build the reference projects pin is the single 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, biggest favourable fact so far: no disc conversion, no regional timing work,
@@ -122,8 +122,14 @@ Phase 1 hands over the extents; this phase works out what each one contains.
command byte and every element size follows from it. Verified on all 27 command byte and every element size follows from it. Verified on all 27
levels: **18,102 patches, 821,439 vertices**, each patch yielding exactly levels: **18,102 patches, 821,439 vertices**, each patch yielding exactly
its own `vert_count`. See `plan/level-assets.md` and `gc-formats::tfrag`. its own `vert_count`. See `plan/level-assets.md` and `gc-formats::tfrag`.
- [ ] tfrag topology: strips, triangle indices and the RGBA vertex-info arrays, - [x] **tfrag topology: recovered.** Vertex-info (UVs plus LOD parenting), the
which is what turns positions into drawable geometry. strip command lists and the index arrays, classified by unpack format and
destination VU address. Strip indices address the vertex-info array
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`.
- [ ] `gameplay` — begins `WAD` + a per-level byte, and does *not* decode with - [ ] `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 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 wrong rather than the decoder. Ruled out: bit order, header length, the