//! 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. //! //! Not yet recovered: the LOD switch and morph rules in `TfragProc`, and the //! texel data the texture table points into. See `docs/plan/tfrag.md`. use crate::buf::{Buf, Error, ErrorKind, Result}; /// 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 { 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 { 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> { (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 { 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, 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 { 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, /// Indices into [`TfragGeometry::vertex_info`]. pub indices: Vec, } /// 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::(), ) }; 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, 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, /// 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, /// Same, for the extra (non-position) LOD-01 entries. pub unk_indices_2_lod01: Vec, pub parent_indices_lod0: Vec, pub unk_indices_2_lod0: Vec, /// Per-vertex colours, indexed by position index. pub rgba: Vec, /// Per-vertex light and normal records, indexed by position index. pub lights: Vec, } 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 { 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 = Vec::new(); let mut indices: Vec = 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> { 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> { 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> { 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> { 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> { 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> { 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 { 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 { 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> { 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 { 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 { 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, 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, 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()); } }