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.
2049 lines
71 KiB
Rust
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());
|
|
}
|
|
}
|