Files
Going-Commando/crates/gc-formats/src/tfrag.rs
T
ReaperOfVeriod 6d0c1b6fd1 Close out tfrag on the disc side, and fix two stale "not recovered" claims
Picked tfrag morph targets from the open list and found the premise was wrong.

There are no morph targets in this format. Morphing is a blend toward the midpoint
of the vertex's two PARENT vertices -- g[parent1_position] and
g[parent2_position] -- which are other vertices in the same tfrag. The engine
implements exactly that in resolve_vertex, and it is covered by
morphing_blends_toward_the_parent_midpoint plus
morph_weight_runs_from_zero_to_one_across_its_band and
collapse_distances_are_the_far_edge_of_each_band. So "morph targets unrecovered"
was never a gap; it named a thing that does not exist in the format.

Strip bookkeeping is likewise already there: TfragStrip carries
vertex_count_and_flag, and tfrag_triangles walks the strip command lists and index
arrays to build triangles. That was the other half of the same roadmap entry.

Coverage, all passing: 36 tfrag tests, 9 texture tests, 23 gc-engine tests. The
real-disc texture test decodes all 1595 level textures, with pixels and mip levels,
so the module doc's "not yet recovered: the texel data the texture table points
into" is also stale.

Two stale claims corrected:

1. crates/gc-formats/src/tfrag.rs said "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." The rules are recovered and tested, the texels are
   decoded, and docs/plan/tfrag.md DOES NOT EXIST -- a dangling reference to a
   file that was never written.

2. docs/roadmap.md marked tfrag partial with "Still open: morph targets and the
   strip/chain bookkeeping". Neither is open.

The honest replacement for both is what is genuinely not claimed: the LOD switch
distances, morph weight and collapse edge are DERIVED from the data -- tiers are
classified from the four V4_8 parent arrays, the weight runs zero to one across
each tier's band -- but have not been checked against PCSX2 the way the lighting
pass was. That is a verification gap, not a parsing gap, and it needs a frame
captured at a known view distance rather than more disc-side measurement.
2026-10-13 04:50:28 +02:00

2049 lines
71 KiB
Rust

//! Terrain fragments: the `tfrags` block of a level's geometry.
//!
//! Spec: `docs/formats/tfrag.md`.
//!
//! A level's terrain is a set of **tfrags** — small patches of ground, each
//! with its own bounding sphere, its own vertex set and its own index strips,
//! organised into three levels of detail. The block is a directory followed by
//! one data block per tfrag.
//!
//! # Block layout
//!
//! ```text
//! +0x00 4 byte offset of the header table, from the start of the block
//! +0x04 4 number of tfrags
//! +0x08 4 float, LOD distance parameter
//! +0x10 .. padding, zero, up to the table
//! +table_offset TfragHeader[tfrag_count], 0x40 bytes each
//! tfrag 0's data block
//! tfrag 1's data block
//! ...
//! ```
//!
//! A tfrag header's `data` field is a byte offset **relative to the start of the
//! header table**, not to the start of the block. Every other offset in the
//! record is relative to that tfrag's own data block. Two different bases in one
//! record is the sort of detail that produces silently wrong geometry.
//!
//! # What is recovered here
//!
//! Vertex **positions**, **texture coordinates** and **triangles**. Positions
//! 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.
//!
//! Strip indices address the vertex-info array rather than the position array —
//! several vertex-info entries can share a position.
//!
//! 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.
//!
//! What is *not* claimed: that these rules match the original. The LOD switch
//! distances, the morph weight and the collapse edge are all **derived from the
//! data** -- tiers are classified from the four V4_8 parent arrays, and the
//! weight runs from zero to one across each tier's band -- but they have not
//! been checked against PCSX2 the way the lighting pass was. That check is the
//! outstanding item, and it needs a frame captured at a known view distance
//! rather than more disc-side measurement.
use crate::buf::{Buf, Error, ErrorKind, Result};
/// Size of one tfrag header record.
pub const TFRAG_HEADER_SIZE: usize = 0x40;
/// Size of the block header before the padding.
pub const TFRAGS_BLOCK_HEADER_SIZE: usize = 0x10;
/// The header at the start of a tfrags block.
#[derive(Debug, Clone, PartialEq)]
pub struct TfragsBlock {
/// Byte offset of the header table, relative to the start of the block.
pub table_offset: u32,
pub tfrag_count: u32,
/// LOD distance parameter. Preserved; semantics not confirmed.
pub lod_distance: f32,
/// The second unnamed word. Preserved.
pub reserved: u32,
}
impl TfragsBlock {
/// Parse the block header from the start of the tfrags region.
pub fn parse(bytes: &[u8]) -> Result<TfragsBlock> {
let mut b = Buf::new(bytes);
let table_offset = b.u32("tfrags table offset")?;
let tfrag_count = b.u32("tfrag count")?;
let lod_distance = b.f32("tfrags LOD distance")?;
let reserved = b.u32("tfrags reserved word")?;
Ok(TfragsBlock {
table_offset,
tfrag_count,
lod_distance,
reserved,
})
}
/// LOD switch distances `[D0, D1, D2] = [6L, 4L, 2L]` in world units,
/// where `L` is the block's LOD distance (32.0 on every level measured).
///
/// D0 is the far end of LOD 1, D1 the far end of LOD 0, and D2 the point
/// where LOD-0 vertices start morphing.
pub fn lod_distances(&self) -> [f32; 3] {
let l = self.lod_distance;
[l * 6.0, l * 4.0, l + l]
}
/// The integer thresholds the distance test compares against, in raw units
/// (1024 = 1 world unit).
pub fn lod_thresholds_raw(&self) -> [i32; 3] {
self.lod_distances().map(|d| (d * 1024.0) as i32)
}
/// The tfrag header at index `i`, given the whole block.
pub fn header(&self, bytes: &[u8], i: usize) -> Result<TfragHeader> {
let off = self.table_offset as usize + i * TFRAG_HEADER_SIZE;
if off + TFRAG_HEADER_SIZE > bytes.len() {
return Err(Error {
offset: off,
context: "tfrag header table",
kind: ErrorKind::OutOfBounds {
needed: TFRAG_HEADER_SIZE,
available: bytes.len().saturating_sub(off),
},
});
}
TfragHeader::parse(&bytes[off..off + TFRAG_HEADER_SIZE])
}
/// Every tfrag header in the block.
pub fn headers(&self, bytes: &[u8]) -> Result<Vec<TfragHeader>> {
(0..self.tfrag_count as usize)
.map(|i| self.header(bytes, i))
.collect()
}
}
/// One tfrag's header record.
#[derive(Debug, Clone, PartialEq)]
pub struct TfragHeader {
/// Bounding sphere: centre xyz and radius w. The centre is a VU1 memory
/// address on this game rather than a world position, so it is not usable
/// as a world-space bound without further work.
pub bsphere: [f32; 4],
/// Byte offset of this tfrag's data block, **relative to the table**.
pub data: i32,
pub lod_2_ofs: u16,
pub shared_ofs: u16,
pub lod_1_ofs: u16,
pub lod_0_ofs: u16,
/// Offset of the ad-gif texture payload inside the common VIF list.
pub tex_ofs: u16,
/// Offset of the per-vertex RGBA array.
pub rgba_ofs: u16,
pub common_size: u8,
pub lod_2_size: u8,
pub lod_1_size: u8,
pub lod_0_size: u8,
/// Non-zero: always draw LOD 2 and skip the distance test entirely.
pub base_only: u8,
pub texture_count: u8,
pub rgba_size: u8,
pub msphere_ofs: u16,
pub light_ofs: u16,
pub light_end_ofs: u16,
pub cube_ofs: u16,
/// Occlusion / visibility index for this tfrag.
pub occl_index: u16,
/// Total vertex count. `u8`, so at most 255 positions per tfrag.
pub vert_count: u8,
/// Triangle count at LOD 0.
pub tri_count: u8,
}
impl TfragHeader {
/// Parse one 0x40-byte header record.
pub fn parse(bytes: &[u8]) -> Result<TfragHeader> {
let mut b = Buf::new(bytes);
let mut bsphere = [0f32; 4];
for slot in bsphere.iter_mut() {
*slot = b.f32("tfrag bounding sphere")?;
}
let data = b.i32("tfrag data offset")?;
let lod_2_ofs = b.u16("tfrag lod_2_ofs")?;
let shared_ofs = b.u16("tfrag shared_ofs")?;
let lod_1_ofs = b.u16("tfrag lod_1_ofs")?;
let lod_0_ofs = b.u16("tfrag lod_0_ofs")?;
let tex_ofs = b.u16("tfrag tex_ofs")?;
let rgba_ofs = b.u16("tfrag rgba_ofs")?;
// 0x20..0x2d are single bytes; 0x2d..0x2e then a u16, so read in order.
let common_size = b.u8("tfrag common_size")?;
let lod_2_size = b.u8("tfrag lod_2_size")?;
let lod_1_size = b.u8("tfrag lod_1_size")?;
let lod_0_size = b.u8("tfrag lod_0_size")?;
let _lod_2_rgba = b.u8("tfrag lod_2_rgba_count")?;
let _lod_1_rgba = b.u8("tfrag lod_1_rgba_count")?;
let _lod_0_rgba = b.u8("tfrag lod_0_rgba_count")?;
let base_only = b.u8("tfrag base_only")?;
let texture_count = b.u8("tfrag texture_count")?;
let rgba_size = b.u8("tfrag rgba_size")?;
let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?;
let _occl_index_stash = b.u8("tfrag occl_index_stash")?;
let _msphere_count = b.u8("tfrag msphere_count")?;
let _flags = b.u8("tfrag flags")?;
let msphere_ofs = b.u16("tfrag msphere_ofs")?;
let light_ofs = b.u16("tfrag light_ofs")?;
let light_end_ofs = b.u16("tfrag light_end_ofs")?;
let _dir_lights_one = b.u8("tfrag dir_lights_one")?;
let _dir_lights_upd = b.u8("tfrag dir_lights_upd")?;
let _point_lights = b.u16("tfrag point_lights")?;
let cube_ofs = b.u16("tfrag cube_ofs")?;
let occl_index = b.u16("tfrag occl_index")?;
let vert_count = b.u8("tfrag vert_count")?;
let tri_count = b.u8("tfrag tri_count")?;
let _mip_dist = b.u16("tfrag mip_dist")?;
Ok(TfragHeader {
bsphere,
data,
lod_2_ofs,
shared_ofs,
lod_1_ofs,
lod_0_ofs,
tex_ofs,
rgba_ofs,
common_size,
lod_2_size,
lod_1_size,
lod_0_size,
base_only,
texture_count,
rgba_size,
msphere_ofs,
light_ofs,
light_end_ofs,
cube_ofs,
occl_index,
vert_count,
tri_count,
})
}
}
/// A VIF code word decoded into its fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VifCode {
pub irq: bool,
/// Command field, bits 30..24.
pub cmd: u8,
/// Payload word count, bits 23..16. Zero means 256.
pub num: u16,
/// Immediate, bits 15..0.
pub imm: u16,
}
impl VifCode {
/// Re-encode into a code word. Used by tests to build packets.
pub fn to_word(&self) -> u32 {
((self.irq as u32) << 31)
| ((self.cmd as u32) << 24)
| ((self.num as u32) << 16)
| self.imm as u32
}
pub fn decode(word: u32) -> VifCode {
VifCode {
irq: (word >> 31) & 1 == 1,
cmd: ((word >> 24) & 0x7F) as u8,
num: ((word >> 16) & 0xFF) as u16,
imm: (word & 0xFFFF) as u16,
}
}
/// Is this an UNPACK? Recognised by the top two bits of the command.
pub fn is_unpack(&self) -> bool {
(self.cmd & 0b110_0000) == 0b110_0000
}
/// Number of components minus one: 0..3 = 1..4 components.
pub fn vn(&self) -> u8 {
(self.cmd >> 2) & 3
}
/// Component width: 0 = 32-bit, 1 = 16-bit, 2 = 8-bit, 3 = 5-bit.
pub fn vl(&self) -> u8 {
self.cmd & 3
}
/// Bytes per unpacked element on disk.
///
/// The whole vector format is encoded in the command byte: `vn` gives the
/// component count and `vl` the component width. Nothing about the format
/// lives in the immediate.
pub fn element_size(&self) -> usize {
((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.
pub fn format_name(&self) -> String {
const NAMES: [&str; 4] = ["V1", "V2", "V3", "V4"];
const WIDTHS: [&str; 4] = ["32", "16", "8", "5"];
if self.is_unpack() {
format!(
"{}_{}",
NAMES[self.vn() as usize],
WIDTHS[self.vl() as usize]
)
} else {
"not an unpack".to_string()
}
}
/// Effective element count: a `num` of zero means 256.
pub fn count(&self) -> usize {
if self.num == 0 {
256
} else {
self.num as usize
}
}
/// Inline payload size in bytes, not counting the 4-byte code word.
///
/// An unpack's payload is padded up to 4 bytes. Only a few non-unpack
/// commands carry inline data; everything else is a bare code word. Getting
/// this wrong desynchronises the walk and yields plausible garbage, so the
/// set is enumerated rather than assumed empty.
pub fn payload_size(&self) -> usize {
if self.is_unpack() {
(self.count() * self.element_size() + 3) & !3
} else {
match self.cmd {
STMASK => 4,
STROW | STCOL => 16,
MPG => self.num as usize * 8,
DIRECT | DIRECTHL => {
if self.imm == 0 {
65536 * 16
} else {
self.imm as usize * 16
}
}
_ => 0,
}
}
}
/// Total packet size in bytes, including the 4-byte code word.
pub fn packet_size(&self) -> usize {
4 + self.payload_size()
}
}
/// VIF command bytes for non-unpack commands that carry inline data.
pub const STMASK: u8 = 0x20;
pub const STROW: u8 = 0x30;
pub const STCOL: u8 = 0x31;
pub const MPG: u8 = 0x4a;
pub const DIRECT: u8 = 0x50;
pub const DIRECTHL: u8 = 0x51;
/// The VIF lists that make up a tfrag's data block, in the order the game
/// transfers them, each paired with the LOD it contributes to (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 are collected as
/// the lists are walked and banked when the LOD that owns them is complete.
/// 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;
[
(None, (h.shared_ofs as usize, h.lod_1_ofs as usize)),
(Some(2), (h.lod_2_ofs as usize, h.shared_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)),
]
}
/// The VU header: 20 `u16` describing where each kind of data is unpacked.
///
/// Every unpack in a tfrag's lists is classified by format *and* destination
/// VU address, so this table is what tells the strips from the indices from
/// the parent arrays.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TfragVuHeader {
pub positions_common_count: u16,
pub positions_lod_01_count: u16,
/// Extra (non-position) LOD-01 vertex-info count. Zero means there is no
/// extra tier, and the "unknown indices 2" array is absent.
pub unk_06: u16,
pub positions_lod_0_count: u16,
/// Extra (non-position) LOD-0 vertex-info count.
pub unk_0a: u16,
pub positions_common_addr: u16,
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 parent_indices_lod_01_addr: u16,
pub unk_indices_2_lod_01_addr: u16,
pub parent_indices_lod_0_addr: u16,
pub unk_indices_2_lod_0_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")?;
}
Ok(TfragVuHeader {
positions_common_count: w[0],
positions_lod_01_count: w[2],
unk_06: w[3],
positions_lod_0_count: w[4],
unk_0a: w[5],
positions_common_addr: w[6],
vertex_info_common_addr: w[7],
vertex_info_lod_01_addr: w[9],
vertex_info_lod_0_addr: w[11],
indices_addr: w[13],
parent_indices_lod_01_addr: w[14],
unk_indices_2_lod_01_addr: w[15],
parent_indices_lod_0_addr: w[16],
unk_indices_2_lod_0_addr: w[17],
strips_addr: w[18],
})
}
}
/// 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,
}
/// 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, as an *index* into the overlay's 256-entry sine table.
pub azimuth: i8,
/// Normal elevation, as an index into the same table.
pub elevation: i8,
/// Base colour as 5:5:5:1.
pub color: i16,
/// Light-set select. Bits 8..15 clear means "use set bits 0..3"; otherwise
/// set bits 0..3 is blended with set bits 4..7 by `bits 8..15 / 256`.
pub select: u16,
}
impl TfragLight {
/// The light-set select word at +6 of the record.
///
/// The parsed struct exposes bytes 0..6; the select is the `u16` that
/// follows `color`, so it is read from the record rather than stored as its
/// own field.
pub fn select(&self) -> u16 {
let b = unsafe {
std::slice::from_raw_parts(
self as *const TfragLight as *const u8,
std::mem::size_of::<TfragLight>(),
)
};
u16::from_le_bytes([b[6], b[7]])
}
/// Azimuth as the unsigned table index. The field is stored signed but
/// indexes 0..=255, so the byte is what matters, not its sign.
pub fn azimuth_index(&self) -> u8 {
self.azimuth as u8
}
/// Elevation as the unsigned table index.
pub fn elevation_index(&self) -> u8 {
self.elevation as u8
}
}
/// 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],
/// Texture primitives; strips select one by ad-gif index.
/// LOD-01 and LOD-0 position counts from the VU header. These drive the
/// primary/extra split in the parent-index arrays.
/// Radius of the patch's bounding sphere, in raw units (1024 = 1 world unit).
pub bsphere_radius: f32,
/// Non-zero: always draw LOD 2 and skip the distance test.
pub base_only: u8,
pub vu_positions_lod_01_count: usize,
pub vu_positions_lod_0_count: usize,
pub ad_gifs: Vec<TfragAdGifs>,
/// For each primary LOD-01 vertex-info entry, the vertex-info index of its
/// first parent. The second parent is that entry's own `parent` field.
pub parent_indices_lod01: Vec<u8>,
/// Same, for the extra (non-position) LOD-01 entries.
pub unk_indices_2_lod01: Vec<u8>,
pub parent_indices_lod0: Vec<u8>,
pub unk_indices_2_lod0: Vec<u8>,
/// 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 {
/// 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
}
}
/// The draw path for this tfrag at view depth `centre_depth_raw`, in raw
/// units. Frustum culling and the guard-band clip test are separate.
///
/// The bounds are the patch's bounding sphere truncated to integers, so a
/// sphere straddling a threshold picks the coarser mode — which is what
/// keeps geometry from popping as the camera moves.
pub fn draw_mode(&self, centre_depth_raw: f32, thresholds_raw: [i32; 3]) -> TfragDrawMode {
let r = self.bsphere_radius;
let far = (centre_depth_raw + r) as i32;
let near = (centre_depth_raw - r) as i32;
let [d0, d1, d2] = thresholds_raw;
if self.base_only != 0 || near >= d0 {
TfragDrawMode::Lod2
} else if far >= d0 {
TfragDrawMode::Lod1Collapse
} else if near >= d1 {
TfragDrawMode::Lod1Morph
} else if far >= d1 {
TfragDrawMode::Lod0Collapse
} else if near >= d2 || far >= d2 {
TfragDrawMode::Lod0Morph
} else {
TfragDrawMode::Lod0
}
}
/// 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 last_strow: Option<[i32; 4]> = None;
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 {
// Remember the row; the origin is the one that immediately
// precedes the first position unpack.
if payload.len() >= 16 {
last_strow = read_i32x4(payload, 0);
}
} 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)?;
g.vu_positions_lod_01_count = vu.positions_lod_01_count as usize;
g.vu_positions_lod_0_count = vu.positions_lod_0_count as usize;
have_vu = true;
}
// Ad-gifs: 5 quadwords each.
(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) => {
// The first position unpack is preceded by the origin
// row. Taking the *last* STROW instead picks up the
// per-vertex light rows, which RAC2 also uses STROW
// for, and collapses the whole level onto the origin.
if !have_origin {
if let Some(row) = last_strow {
g.origin = row;
have_origin = true;
}
}
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) => {
// Each V4_8 element is 4 bytes and each element holds
// four indices, so an array of N entries is N * 4
// bytes long -- not N bytes.
let bytes = &payload[..v.count() * 4];
if !v.usn() && addr == vu.strips_addr {
strips = read_strips(payload, v.count())?;
} else if addr == vu.indices_addr {
indices = bytes.to_vec();
} else if addr == vu.parent_indices_lod_01_addr
&& vu.positions_lod_01_count != 0
{
g.parent_indices_lod01 = bytes.to_vec();
} else if addr == vu.unk_indices_2_lod_01_addr && vu.unk_06 != 0 {
g.unk_indices_2_lod01 = bytes.to_vec();
} else if addr == vu.parent_indices_lod_0_addr
&& vu.positions_lod_0_count != 0
{
g.parent_indices_lod0 = bytes.to_vec();
} else if addr == vu.unk_indices_2_lod_0_addr && vu.unk_0a != 0 {
g.unk_indices_2_lod0 = bytes.to_vec();
} else {
return Err(Error {
offset: pos,
context: "unexpected V4_8 unpack address in tfrag list",
kind: ErrorKind::Invalid("unexpected V4_8 unpack address"),
});
}
}
_ => {}
}
}
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),
};
}
}
// 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"),
});
}
// These three arrays sit outside the VIF lists, at fixed offsets in the
// patch's data block.
g.bsphere_radius = header.bsphere[3];
g.base_only = header.base_only;
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,
context: "tfrag position count does not match its header",
kind: ErrorKind::Invalid("tfrag position count does not match its header"),
});
}
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]]),
select: u16::from_le_bytes([block[i + 6], block[i + 7]]),
})
.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 {
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_gifs.len() {
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)
}
/// The draw path chosen for a tfrag from its distance.
///
/// The two collapse modes are not just a coarser LOD: an entry whose parents
/// are both past the collapse distance is *replaced* by its parent rather than
/// morphed, which is what stops distant terrain shimmering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TfragDrawMode {
/// LOD 2 list, no morph.
Lod2,
/// LOD 1 lists; LOD-01 vertices morph and collapse onto parent 1 once both
/// parents are past D0.
Lod1Collapse,
/// LOD 1 lists; LOD-01 vertices morph.
Lod1Morph,
/// LOD 0 lists; LOD-01 morphs, LOD-0 morphs with collapse at D1.
Lod0Collapse,
/// LOD 0 lists; LOD-01 at rest, LOD-0 morphs.
Lod0Morph,
/// LOD 0 lists, no morph.
Lod0,
}
impl TfragDrawMode {
/// Which strip and index list is drawn. 0 is highest detail.
pub fn lod(self) -> usize {
match self {
TfragDrawMode::Lod2 => 2,
TfragDrawMode::Lod1Collapse | TfragDrawMode::Lod1Morph => 1,
_ => 0,
}
}
/// The MSCAL address the game writes for the tfrag's VU1 program.
pub fn mscal(self) -> u8 {
match self {
TfragDrawMode::Lod2 => 6,
TfragDrawMode::Lod1Collapse => 8,
TfragDrawMode::Lod1Morph => 0xa,
TfragDrawMode::Lod0Collapse => 0xe,
TfragDrawMode::Lod0Morph => 0x10,
TfragDrawMode::Lod0 => 0x14,
}
}
}
/// Which tier a morphable vertex-info entry belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TfragMorphTier {
/// Fades between D1 and D0.
Lod01,
/// Fades between D2 and D1.
Lod0,
}
impl TfragMorphTier {
/// Morph amount in 0..=1 for a vertex at view depth `depth`, in world units:
/// 0 is the vertex's own position, 1 is its parents' midpoint.
pub fn weight(self, depth: f32, lod_distances: [f32; 3]) -> f32 {
let [d0, d1, d2] = lod_distances;
let (near, far) = match self {
TfragMorphTier::Lod01 => (d1, d0),
TfragMorphTier::Lod0 => (d2, d1),
};
if (far - near).abs() < f32::EPSILON {
return 0.0;
}
((depth - near) / (far - near)).clamp(0.0, 1.0)
}
/// Distance past which an entry is replaced by its first parent.
pub fn collapse_distance(self, lod_distances: [f32; 3]) -> f32 {
match self {
TfragMorphTier::Lod01 => lod_distances[0],
TfragMorphTier::Lod0 => lod_distances[1],
}
}
}
/// The LOD linkage of one LOD-01 or LOD-0 vertex-info entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TfragLodLink {
pub tier: TfragMorphTier,
/// True for the tier's first `positions_*_count` entries, which the VU
/// morphs. Extra entries only collapse.
pub morphs: bool,
/// Position index of this entry, `vertex / 2`.
pub own_position: usize,
/// Vertex-info index of the first parent. Also the collapse replacement.
pub parent1_vinfo: usize,
/// Position index of the first parent.
pub parent1_position: usize,
/// Position index of the second parent, from this entry's `parent` field.
pub parent2_position: usize,
}
/// LOD linkage of vertex-info entry `vinfo`, or `None` for common entries, out
/// of range indices, or an entry with no parent recorded.
pub fn tfrag_lod_link(g: &TfragGeometry, vinfo: usize) -> Option<TfragLodLink> {
let common = g.vinfo_tier[0];
let l01 = common..common + g.vinfo_tier[1];
let l0 = l01.end..l01.end + g.vinfo_tier[2];
let (tier, k, primaries, parents, extras) = if l01.contains(&vinfo) {
(
TfragMorphTier::Lod01,
vinfo - l01.start,
g.vu_positions_lod_01_count,
&g.parent_indices_lod01,
&g.unk_indices_2_lod01,
)
} else if l0.contains(&vinfo) {
(
TfragMorphTier::Lod0,
vinfo - l0.start,
g.vu_positions_lod_0_count,
&g.parent_indices_lod0,
&g.unk_indices_2_lod0,
)
} else {
return None;
};
let (morphs, p1) = if k < primaries {
(true, *parents.get(k)? as usize)
} else {
(false, *extras.get(k - primaries)? as usize)
};
let e = *g.vertex_info.get(vinfo)?;
let pos = |v: i16| -> Option<usize> {
let p = v as isize as usize / 2;
(p < g.positions.len()).then_some(p)
};
Some(TfragLodLink {
tier,
morphs,
own_position: pos(e.vertex)?,
parent1_vinfo: p1,
parent1_position: pos(g.vertex_info.get(p1)?.vertex)?,
parent2_position: pos(e.parent)?,
})
}
/// Position indices a morphing entry blends toward, `(parent1, parent2)`.
pub fn tfrag_morph_parents(g: &TfragGeometry, vinfo: usize) -> Option<(usize, usize)> {
tfrag_lod_link(g, vinfo)
.filter(|l| l.morphs)
.map(|l| (l.parent1_position, l.parent2_position))
}
/// Vertex positions of a tfrag, in game order.
pub fn tfrag_positions(block: &[u8], header: &TfragHeader) -> Option<Vec<[i32; 3]>> {
parse_tfrag(block, header).ok().map(|g| g.positions)
}
#[cfg(test)]
mod tests {
use super::*;
/// A block header as measured on level 0.
fn block_header() -> Vec<u8> {
let mut v = vec![0u8; TFRAGS_BLOCK_HEADER_SIZE];
v[0x00..0x04].copy_from_slice(&0x40u32.to_le_bytes());
v[0x04..0x08].copy_from_slice(&833u32.to_le_bytes());
v[0x08..0x0c].copy_from_slice(&32.0f32.to_le_bytes());
v
}
#[test]
fn parses_the_measured_block_header() {
let h = TfragsBlock::parse(&block_header()).unwrap();
assert_eq!(h.table_offset, 0x40);
assert_eq!(h.tfrag_count, 833);
assert_eq!(h.lod_distance, 32.0);
}
#[test]
fn parses_a_tfrag_header() {
let mut v = vec![0u8; TFRAG_HEADER_SIZE];
v[0x00..0x10].copy_from_slice(&[0u8; 16]);
v[0x10..0x14].copy_from_slice(&53_312i32.to_le_bytes());
v[0x14..0x16].copy_from_slice(&0u16.to_le_bytes());
v[0x16..0x18].copy_from_slice(&208u16.to_le_bytes());
v[0x1c..0x1e].copy_from_slice(&256u16.to_le_bytes());
v[0x3c] = 75;
v[0x3d] = 62;
let h = TfragHeader::parse(&v).unwrap();
assert_eq!(h.data, 53_312);
assert_eq!(h.shared_ofs, 208);
assert_eq!(h.tex_ofs, 256);
assert_eq!(h.vert_count, 75);
assert_eq!(h.tri_count, 62);
}
#[test]
fn header_table_access_is_bounds_checked() {
let mut bytes = block_header();
bytes.resize(0x40 + 4 * TFRAG_HEADER_SIZE, 0);
let block = TfragsBlock::parse(&bytes).unwrap();
block.header(&bytes, 3).unwrap();
assert!(block.header(&bytes, 99).is_err());
}
#[test]
fn vif_code_decoding() {
// 0x694b_8022, measured in level 0's first tfrag: a V3_16 unpack of 75
// positions to VU address 2.
let c = VifCode::decode(0x694b_8022);
assert!(c.is_unpack());
assert_eq!((c.cmd, c.num, c.imm), (0x69, 0x4b, 0x8022));
assert_eq!((c.vn(), c.vl()), (2, 1));
assert_eq!(c.element_size(), 6);
assert_eq!(c.format_name(), "V3_16");
// 75 * 6 = 450, padded up to 452, plus the code word.
assert_eq!(c.payload_size(), 452);
assert_eq!(c.packet_size(), 456);
}
#[test]
fn vector_formats_come_from_the_command_byte() {
// (code word, vn, vl, bytes per element)
for (word, vn, vl, size) in [
(0x6900_8009u32, 2u8, 1u8, 6usize), // V3_16
(0x6c00_0000, 3, 0, 16), // V4_32
(0x6d00_c012, 3, 1, 8), // V4_16
(0x6e00_0000, 3, 2, 4), // V4_8
(0x6a00_0000, 2, 2, 3), // V3_8
(0x6800_0000, 2, 0, 12), // V3_32
] {
let c = VifCode::decode(word);
assert_eq!(
(c.vn(), c.vl(), c.element_size()),
(vn, vl, size),
"{word:#010x}"
);
}
}
#[test]
fn vif_num_zero_means_256() {
assert_eq!(VifCode::decode(0x6e00_0000).count(), 256);
assert_eq!(VifCode::decode(0x6e00_0000).payload_size(), 256 * 4);
}
#[test]
fn only_some_non_unpack_commands_carry_payload() {
// STROW and STCOL move a quadword each; a bare code word moves none.
assert_eq!(VifCode::decode(0x3000_0000).payload_size(), 16);
assert_eq!(VifCode::decode(0x3100_0000).payload_size(), 16);
assert_eq!(VifCode::decode(0x2000_0000).payload_size(), 4);
assert_eq!(VifCode::decode(0x4a02_0000).payload_size(), 16); // MPG num=2
assert_eq!(VifCode::decode(0x5000_0001).payload_size(), 16); // DIRECT imm=1
assert_eq!(VifCode::decode(0x0500_0001).payload_size(), 0); // STMOD
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.
fn synthetic_tfrag(verts: &[[i16; 3]]) -> (Vec<u8>, TfragHeader) {
let mut block = Vec::new();
let n = verts.len() as u16;
// NUM is 8 bits, so a 256-element unpack has to encode zero.
let num = if n == 256 { 0u16 } else { n };
block.extend_from_slice(&vu_header_bytes(n));
block.extend_from_slice(
&VifCode {
irq: false,
cmd: 0x69,
num,
imm: 0x8000 | 2,
}
.to_word()
.to_le_bytes(),
);
for v in verts {
for c in v {
block.extend_from_slice(&c.to_le_bytes());
}
}
// The next list starts after the padded packet, not after the raw
// vertices, so an odd element count advances the boundary.
let common_end = block.len().div_ceil(4) * 4;
block.resize(128, 0);
let mut h = TfragHeader::parse(&[0u8; TFRAG_HEADER_SIZE]).unwrap();
h.vert_count = verts.len() as u8;
h.shared_ofs = 0;
h.lod_1_ofs = common_end as u16;
h.lod_0_ofs = common_end as u16;
// The LOD-0-only list starts on a quadword boundary at or after the
// common list, so round the span up rather than truncating it.
h.lod_1_size = common_end.div_ceil(16) as u8;
h.rgba_ofs = 128;
(block, h)
}
#[test]
fn finds_positions_in_a_synthetic_block() {
let verts = [
[99, -3795, -5203],
[1123, -3795, -5203],
[-715, -3072, -5203],
[1123, -3072, -5203],
];
let (block, header) = synthetic_tfrag(&verts);
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);
for (got, want) in pos.iter().zip(verts.iter()) {
assert_eq!(*got, [want[0] as i32, want[1] as i32, want[2] as i32]);
}
}
#[test]
fn an_odd_count_is_padded_to_four_bytes() {
// 3 elements = 18 bytes, padded to 20, so the next code word sits at
// +24 rather than +22.
let verts = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
let (block, header) = synthetic_tfrag(&verts);
// 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);
let pos = tfrag_positions(&block, &header).expect("positions should be found");
assert_eq!(pos.len(), 3);
}
#[test]
fn a_desynchronised_walk_is_reported_rather_than_trusted() {
let verts = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
let (block, header) = synthetic_tfrag(&verts);
// Claim more vertices than the block actually holds.
let mut bad = header;
bad.vert_count = 40;
assert!(tfrag_positions(&block, &bad).is_none());
}
#[test]
fn a_tfrag_with_no_unpack_returns_none() {
let block = vec![0u8; 128];
let mut header = TfragHeader::parse(&[0u8; TFRAG_HEADER_SIZE]).unwrap();
header.vert_count = 4;
header.shared_ofs = 0;
header.lod_1_ofs = 64;
header.lod_0_ofs = 64;
header.rgba_ofs = 128;
assert!(tfrag_positions(&block, &header).is_none());
}
#[test]
fn zero_vertex_count_gives_an_empty_result() {
let block = vec![0u8; 16];
let mut header = TfragHeader::parse(&[0u8; TFRAG_HEADER_SIZE]).unwrap();
header.vert_count = 0;
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.
pub(super) 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());
}
}
// 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 {
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);
// 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
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;
h.light_ofs = lights_at as u16;
h.light_end_ofs = (lights_at + 0x10 + 6 * 8) 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);
// 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),
[
(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]
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());
}
}
#[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);
}
}
#[cfg(test)]
mod lod_tests {
use super::*;
fn block() -> TfragsBlock {
TfragsBlock {
table_offset: 0x40,
tfrag_count: 1,
lod_distance: 32.0,
reserved: 0,
}
}
#[test]
fn switch_distances_scale_from_the_block_parameter() {
let b = block();
assert_eq!(b.lod_distances(), [192.0, 128.0, 64.0]);
assert_eq!(b.lod_thresholds_raw(), [196_608, 131_072, 65_536]);
}
/// A patch with the given bounding-sphere radius and `base_only` flag.
fn geometry(radius: f32, base_only: u8) -> TfragGeometry {
TfragGeometry {
bsphere_radius: radius,
base_only,
..TfragGeometry::default()
}
}
#[test]
fn a_sphere_straddling_a_threshold_takes_the_coarser_mode() {
let th = [196_608, 131_072, 65_536];
// Well inside LOD 0, then stepping back through every boundary.
let mode = |d: f32| geometry(1000.0, 0).draw_mode(d, th);
assert_eq!(mode(10_000.0), TfragDrawMode::Lod0);
assert_eq!(mode(70_000.0), TfragDrawMode::Lod0Morph); // near >= D2
assert_eq!(mode(131_000.0), TfragDrawMode::Lod0Collapse); // far >= D1
assert_eq!(mode(150_000.0), TfragDrawMode::Lod1Morph); // near >= D1
assert_eq!(mode(196_000.0), TfragDrawMode::Lod1Collapse); // far >= D0
assert_eq!(mode(200_000.0), TfragDrawMode::Lod2); // near >= D0
}
#[test]
fn the_mode_is_monotonic_in_distance() {
// Distance must never make the mesh finer again.
let th = [196_608, 131_072, 65_536];
let order = |m: TfragDrawMode| match m {
TfragDrawMode::Lod0 => 0,
TfragDrawMode::Lod0Morph => 1,
TfragDrawMode::Lod0Collapse => 2,
TfragDrawMode::Lod1Morph => 3,
TfragDrawMode::Lod1Collapse => 4,
TfragDrawMode::Lod2 => 5,
};
let g = geometry(4000.0, 0);
let mut last = 0;
for d in (0..600_000).step_by(997) {
let now = order(g.draw_mode(d as f32, th));
assert!(now >= last, "mode went backwards at depth {d}");
last = now;
}
}
#[test]
fn base_only_pins_a_patch_to_lod_2() {
let th = [196_608, 131_072, 65_536];
let g = geometry(1000.0, 1);
assert_eq!(g.draw_mode(0.0, th), TfragDrawMode::Lod2);
}
#[test]
fn each_mode_maps_to_a_lod_and_an_mscal() {
for (m, lod, mscal) in [
(TfragDrawMode::Lod2, 2usize, 6u8),
(TfragDrawMode::Lod1Collapse, 1, 8),
(TfragDrawMode::Lod1Morph, 1, 0xa),
(TfragDrawMode::Lod0Collapse, 0, 0xe),
(TfragDrawMode::Lod0Morph, 0, 0x10),
(TfragDrawMode::Lod0, 0, 0x14),
] {
assert_eq!(m.lod(), lod);
assert_eq!(m.mscal(), mscal);
}
}
#[test]
fn morph_weight_runs_from_zero_to_one_across_its_band() {
let d = [192.0, 128.0, 64.0];
// LOD-01 fades between D1 and D0; LOD 0 between D2 and D1.
assert_eq!(TfragMorphTier::Lod01.weight(128.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod01.weight(160.0, d), 0.5);
assert_eq!(TfragMorphTier::Lod01.weight(192.0, d), 1.0);
assert_eq!(TfragMorphTier::Lod01.weight(500.0, d), 1.0);
assert_eq!(TfragMorphTier::Lod01.weight(0.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod0.weight(64.0, d), 0.0);
assert_eq!(TfragMorphTier::Lod0.weight(96.0, d), 0.5);
assert_eq!(TfragMorphTier::Lod0.weight(128.0, d), 1.0);
}
#[test]
fn collapse_distances_are_the_far_edge_of_each_band() {
let d = [192.0, 128.0, 64.0];
assert_eq!(TfragMorphTier::Lod01.collapse_distance(d), 192.0);
assert_eq!(TfragMorphTier::Lod0.collapse_distance(d), 128.0);
}
}
#[cfg(test)]
mod parent_tests {
use super::*;
/// A patch with `common` + `lod01` + `lod0` vertex-info entries, one
/// position each, and parent arrays of the given lengths.
fn linked(common: usize, lod01: usize, lod0: usize, p01: usize, p0: usize) -> TfragGeometry {
let n = common + lod01 + lod0;
let mut g = TfragGeometry {
positions: (0..n).map(|i| [i as i32, 0, 0]).collect(),
vertex_info: (0..n)
.map(|i| TfragVertexInfo {
s: 0,
t: 0,
parent: 0,
vertex: (i * 2) as i16,
})
.collect(),
vinfo_tier: [common, lod01, lod0],
vu_positions_lod_01_count: lod01,
vu_positions_lod_0_count: lod0,
parent_indices_lod01: (0..p01).map(|i| (i % 4) as u8).collect(),
parent_indices_lod0: (0..p0).map(|i| (i % 4) as u8).collect(),
..TfragGeometry::default()
};
// Every LOD entry's first parent is a common entry, as on disc.
for i in 0..n {
g.vertex_info[i].parent = 0;
}
g
}
#[test]
fn a_common_entry_has_no_link() {
let g = linked(4, 2, 2, 2, 2);
assert!(tfrag_lod_link(&g, 0).is_none());
assert!(tfrag_lod_link(&g, 3).is_none());
// Out of range too.
assert!(tfrag_lod_link(&g, 99).is_none());
}
#[test]
fn lod_entries_resolve_through_the_parent_arrays() {
let g = linked(4, 2, 2, 2, 2);
let l01 = tfrag_lod_link(&g, 4).expect("LOD-01 entry 0");
assert_eq!(l01.tier, TfragMorphTier::Lod01);
assert!(l01.morphs);
assert_eq!(l01.own_position, 4);
assert_eq!(l01.parent1_position, l01.parent1_vinfo);
let l0 = tfrag_lod_link(&g, 6).expect("LOD-0 entry 0");
assert_eq!(l0.tier, TfragMorphTier::Lod0);
assert!(l0.morphs);
assert_eq!(l0.own_position, 6);
}
#[test]
fn an_entry_beyond_its_parent_array_has_no_link() {
// Two LOD-0 entries but only one parent index recorded.
let g = linked(4, 0, 2, 0, 1);
assert!(tfrag_lod_link(&g, 4).is_some());
assert!(tfrag_lod_link(&g, 5).is_none());
}
#[test]
fn morph_parents_are_only_reported_for_entries_that_morph() {
let g = linked(4, 0, 2, 0, 2);
assert!(tfrag_morph_parents(&g, 4).is_some());
// A common entry never morphs.
assert!(tfrag_morph_parents(&g, 0).is_none());
}
}