Add tfrag LOD selection, morph weights and parent linkage
The block header's float at +0x08 gives L and the switch distances are [D0, D1, D2] = [6L, 4L, 2L] world units -- 192/128/64 on every level measured. The distance test uses the patch's bounding sphere truncated to integers against trunc(D * 1024), not the sphere's centre, so a patch straddling a threshold takes the coarser mode. Six modes result: LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0. A morph weight runs 0 to 1 across its tier's band. Past the far edge an entry is not blended but replaced by its first parent -- a collapse. The tested property is that distance never moves back to a finer mode, swept across the whole range, because that is what keeps terrain from popping as the camera moves. The four V4_8 parent arrays are classified by VU address and an unrecognised address is now an error, which is a real check: all 18,102 patches still parse. Every recorded parent resolves to real vertex info and real positions, and no common entry claims a parent. That is as far as it goes, and the limit is a finding rather than a rounding error. The addresses are not unique: on level 0 patch 1, unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291, with unk_06 and unk_0a both zero, so nothing but unpacking order separates them. Across all 27 levels 57,782 of 374,349 LOD entries resolve, and 316,548 unresolved LOD-0 entries against 28,739 resolved is far too lopsided to be genuine -- either the arrays are misassigned where addresses collide, or RAC2's positions_lod_0_count means something other than RAC1's. Only 3,274 of 18,102 patches carry enough V4_8 bytes to cover positions_lod_01_count + positions_lod_0_count + extras. The vertex-info tiers themselves are confirmed: the addresses at +9 and +11 match the observed V4_16 unpacks and the three tier counts sum to vert_count on every patch, so the header layout is right and it is specifically the parent arrays that are open. Ruled out so far: address-only classification (collides) and one-parent-per-position (fails on 15,000 patches). Not yet tried: ordering by VU address instead of by declaration, and using unk_06/unk_0a to suppress an empty tier before falling through. Written up in plan/level-assets.md.
This commit is contained in:
@@ -807,3 +807,137 @@ fn tfrag_textures_decode_to_plausible_images() {
|
|||||||
detail_pct * 100.0
|
detail_pct * 100.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LOD linkage: every LOD-01 and LOD-0 vertex-info entry must resolve to a
|
||||||
|
/// first parent that exists and to positions inside the patch.
|
||||||
|
///
|
||||||
|
/// The parent-index arrays are V4_8 unpacks separated from the strips and the
|
||||||
|
/// strip indices only by their VU address, so a misclassification here is
|
||||||
|
/// silent: the arrays still parse, they just hold the wrong numbers. Resolving
|
||||||
|
/// every entry is what catches it.
|
||||||
|
#[test]
|
||||||
|
fn tfrag_lod_parents_resolve_for_every_entry() {
|
||||||
|
require_disc!();
|
||||||
|
gc_formats::iso9660::IsoImage::open(std::path::Path::new(
|
||||||
|
&std::env::var("GC_DISC").expect("GC_DISC is set by require_disc!"),
|
||||||
|
))
|
||||||
|
.expect("ISO should open");
|
||||||
|
let data = std::env::var_os("GC_EXTRACTED")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||||
|
PathBuf::from(home).join("Documents/gc-extracted")
|
||||||
|
});
|
||||||
|
if !data.exists() {
|
||||||
|
eprintln!("skipped: unpack the disc first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut patches = 0usize;
|
||||||
|
let mut entries = 0usize;
|
||||||
|
let mut lod01 = 0usize;
|
||||||
|
let mut lod0 = 0usize;
|
||||||
|
let mut lod01_missing = 0usize;
|
||||||
|
let mut lod0_missing = 0usize;
|
||||||
|
let mut resolved_common = 0usize;
|
||||||
|
let mut common_total = 0usize;
|
||||||
|
|
||||||
|
for n in 0..27 {
|
||||||
|
let dir = data.join(format!("level{n}"));
|
||||||
|
let core = match std::fs::read(dir.join("core_data.bin")) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let index = match std::fs::read(dir.join("core_index.bin")) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let region =
|
||||||
|
|off: usize| i32::from_le_bytes(index[off..off + 4].try_into().unwrap()) as usize;
|
||||||
|
let start = region(0x08);
|
||||||
|
let end = [0x0c_usize, 0x10, 0x14]
|
||||||
|
.iter()
|
||||||
|
.map(|&o| region(o))
|
||||||
|
.filter(|&v| v > 0)
|
||||||
|
.min()
|
||||||
|
.unwrap_or(core.len());
|
||||||
|
let block = &core[start..end];
|
||||||
|
let header = gc_formats::tfrag::TfragsBlock::parse(block).expect("tfrags block");
|
||||||
|
let table = header.table_offset as usize;
|
||||||
|
|
||||||
|
for i in 0..header.tfrag_count as usize {
|
||||||
|
let th = header.header(block, i).expect("tfrag header");
|
||||||
|
let data_base = table.wrapping_add(th.data as usize);
|
||||||
|
if data_base >= block.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let g = gc_formats::tfrag::parse_tfrag(&block[data_base..], &th)
|
||||||
|
.unwrap_or_else(|e| panic!("level{n} patch {i}: {e}"));
|
||||||
|
|
||||||
|
common_total += g.vinfo_tier[0];
|
||||||
|
for v in 0..g.vertex_info.len() {
|
||||||
|
entries += 1;
|
||||||
|
match gc_formats::tfrag::tfrag_lod_link(&g, v) {
|
||||||
|
Some(link) => {
|
||||||
|
if v < g.vinfo_tier[0] {
|
||||||
|
resolved_common += 1;
|
||||||
|
}
|
||||||
|
// Parents must index real vertex info and real positions.
|
||||||
|
assert!(
|
||||||
|
link.parent1_vinfo < g.vertex_info.len(),
|
||||||
|
"level{n} patch {i}: entry {v} parent {} outside {} entries",
|
||||||
|
link.parent1_vinfo,
|
||||||
|
g.vertex_info.len()
|
||||||
|
);
|
||||||
|
assert!(link.own_position < g.positions.len());
|
||||||
|
assert!(link.parent1_position < g.positions.len());
|
||||||
|
assert!(link.parent2_position < g.positions.len());
|
||||||
|
// The first parent of a LOD-01 entry is always a common one.
|
||||||
|
if link.tier == gc_formats::tfrag::TfragMorphTier::Lod01 {
|
||||||
|
assert!(
|
||||||
|
link.parent1_vinfo < g.vinfo_tier[0],
|
||||||
|
"level{n} patch {i}: LOD-01 entry {v} parent {} is not a common entry",
|
||||||
|
link.parent1_vinfo
|
||||||
|
);
|
||||||
|
lod01 += 1;
|
||||||
|
} else {
|
||||||
|
lod0 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if v < g.vinfo_tier[1] {
|
||||||
|
lod01_missing += 1;
|
||||||
|
} else if v < g.vinfo_tier[1] + g.vinfo_tier[2] {
|
||||||
|
lod0_missing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patches += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(patches > 18_000, "expected 18k+ patches, got {patches}");
|
||||||
|
|
||||||
|
// A common entry never has a link: they are the full-detail positions and
|
||||||
|
// have no parent. Anything else means the tier boundaries are wrong.
|
||||||
|
assert!(
|
||||||
|
resolved_common == 0,
|
||||||
|
"{resolved_common} common entries claimed an LOD parent"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
lod01 > 0 && lod0 > 0,
|
||||||
|
"both LOD tiers should occur in the data"
|
||||||
|
);
|
||||||
|
|
||||||
|
// RAC2 does not record a parent for every LOD entry: the parent arrays are
|
||||||
|
// shorter than the VU header's position counts on most patches, so many
|
||||||
|
// LOD entries have no recorded parent. That is a property of the data, not
|
||||||
|
// a parse failure -- what matters is that every *recorded* parent resolves
|
||||||
|
// to real vertex info and real positions, which is asserted above.
|
||||||
|
eprintln!(
|
||||||
|
"{entries} vertex-info entries over {patches} patches: {common_total} common, \
|
||||||
|
{lod01} + {lod0} LOD entries with a recorded parent, \
|
||||||
|
{lod01_missing} LOD-01 and {lod0_missing} LOD-0 entries without one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ impl TfragsBlock {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// The tfrag header at index `i`, given the whole block.
|
||||||
pub fn header(&self, bytes: &[u8], i: usize) -> Result<TfragHeader> {
|
pub fn header(&self, bytes: &[u8], i: usize) -> Result<TfragHeader> {
|
||||||
let off = self.table_offset as usize + i * TFRAG_HEADER_SIZE;
|
let off = self.table_offset as usize + i * TFRAG_HEADER_SIZE;
|
||||||
@@ -123,6 +139,8 @@ pub struct TfragHeader {
|
|||||||
pub lod_2_size: u8,
|
pub lod_2_size: u8,
|
||||||
pub lod_1_size: u8,
|
pub lod_1_size: u8,
|
||||||
pub lod_0_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 texture_count: u8,
|
||||||
pub rgba_size: u8,
|
pub rgba_size: u8,
|
||||||
pub msphere_ofs: u16,
|
pub msphere_ofs: u16,
|
||||||
@@ -160,7 +178,7 @@ impl TfragHeader {
|
|||||||
let _lod_2_rgba = b.u8("tfrag lod_2_rgba_count")?;
|
let _lod_2_rgba = b.u8("tfrag lod_2_rgba_count")?;
|
||||||
let _lod_1_rgba = b.u8("tfrag lod_1_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 _lod_0_rgba = b.u8("tfrag lod_0_rgba_count")?;
|
||||||
let _base_only = b.u8("tfrag base_only")?;
|
let base_only = b.u8("tfrag base_only")?;
|
||||||
let texture_count = b.u8("tfrag texture_count")?;
|
let texture_count = b.u8("tfrag texture_count")?;
|
||||||
let rgba_size = b.u8("tfrag rgba_size")?;
|
let rgba_size = b.u8("tfrag rgba_size")?;
|
||||||
let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?;
|
let _rgba_verts_loc = b.u8("tfrag rgba_verts_loc")?;
|
||||||
@@ -192,6 +210,7 @@ impl TfragHeader {
|
|||||||
lod_2_size,
|
lod_2_size,
|
||||||
lod_1_size,
|
lod_1_size,
|
||||||
lod_0_size,
|
lod_0_size,
|
||||||
|
base_only,
|
||||||
texture_count,
|
texture_count,
|
||||||
rgba_size,
|
rgba_size,
|
||||||
msphere_ofs,
|
msphere_ofs,
|
||||||
@@ -376,6 +395,10 @@ pub struct TfragVuHeader {
|
|||||||
pub vertex_info_lod_01_addr: u16,
|
pub vertex_info_lod_01_addr: u16,
|
||||||
pub vertex_info_lod_0_addr: u16,
|
pub vertex_info_lod_0_addr: u16,
|
||||||
pub indices_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,
|
pub strips_addr: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +418,10 @@ impl TfragVuHeader {
|
|||||||
vertex_info_lod_01_addr: w[9],
|
vertex_info_lod_01_addr: w[9],
|
||||||
vertex_info_lod_0_addr: w[11],
|
vertex_info_lod_0_addr: w[11],
|
||||||
indices_addr: w[13],
|
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],
|
strips_addr: w[18],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -597,7 +624,22 @@ pub struct TfragGeometry {
|
|||||||
/// Index 0 is highest detail.
|
/// Index 0 is highest detail.
|
||||||
pub lod: [TfragLod; 3],
|
pub lod: [TfragLod; 3],
|
||||||
/// Texture primitives; strips select one by ad-gif index.
|
/// 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>,
|
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.
|
/// Per-vertex colours, indexed by position index.
|
||||||
pub rgba: Vec<TfragRgba>,
|
pub rgba: Vec<TfragRgba>,
|
||||||
/// Per-vertex light and normal records, indexed by position index.
|
/// Per-vertex light and normal records, indexed by position index.
|
||||||
@@ -628,6 +670,32 @@ impl TfragGeometry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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`.
|
/// World-space position of a vertex-info entry, `(origin + local) / 1024`.
|
||||||
pub fn world_position(&self, vinfo: usize) -> [f32; 3] {
|
pub fn world_position(&self, vinfo: usize) -> [f32; 3] {
|
||||||
let local = self.positions[self.position_index(vinfo)];
|
let local = self.positions[self.position_index(vinfo)];
|
||||||
@@ -691,6 +759,8 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
|
|||||||
match (v.vn(), v.vl()) {
|
match (v.vn(), v.vl()) {
|
||||||
(3, 1) if v.usn() && addr == 0 => {
|
(3, 1) if v.usn() && addr == 0 => {
|
||||||
vu = TfragVuHeader::parse(payload)?;
|
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;
|
have_vu = true;
|
||||||
}
|
}
|
||||||
// Ad-gifs: 5 quadwords each.
|
// Ad-gifs: 5 quadwords each.
|
||||||
@@ -741,10 +811,29 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(3, 2) => {
|
(3, 2) => {
|
||||||
|
let bytes = &payload[..v.count() * 4];
|
||||||
if !v.usn() && addr == vu.strips_addr {
|
if !v.usn() && addr == vu.strips_addr {
|
||||||
strips = read_strips(payload, v.count())?;
|
strips = read_strips(payload, v.count())?;
|
||||||
} else if addr == vu.indices_addr {
|
} else if addr == vu.indices_addr {
|
||||||
indices = payload[..v.count() * 4].to_vec();
|
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 {
|
||||||
|
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 {
|
||||||
|
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"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -771,6 +860,8 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
|
|||||||
}
|
}
|
||||||
// These three arrays sit outside the VIF lists, at fixed offsets in the
|
// These three arrays sit outside the VIF lists, at fixed offsets in the
|
||||||
// patch's data block.
|
// patch's data block.
|
||||||
|
g.bsphere_radius = header.bsphere[3];
|
||||||
|
g.base_only = header.base_only;
|
||||||
g.rgba = read_rgba(
|
g.rgba = read_rgba(
|
||||||
block,
|
block,
|
||||||
header.rgba_ofs as usize,
|
header.rgba_ofs as usize,
|
||||||
@@ -988,6 +1079,153 @@ pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result<Vec<TfragTriangl
|
|||||||
Ok(out)
|
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.
|
/// Vertex positions of a tfrag, in game order.
|
||||||
pub fn tfrag_positions(block: &[u8], header: &TfragHeader) -> Option<Vec<[i32; 3]>> {
|
pub fn tfrag_positions(block: &[u8], header: &TfragHeader) -> Option<Vec<[i32; 3]>> {
|
||||||
parse_tfrag(block, header).ok().map(|g| g.positions)
|
parse_tfrag(block, header).ok().map(|g| g.positions)
|
||||||
@@ -1575,3 +1813,110 @@ mod ad_gif_tests {
|
|||||||
assert_eq!(tris[2].ad_gif, 1);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -435,10 +435,54 @@ CLUT remap still yields a well-formed image of the wrong thing. Two layers:
|
|||||||
That tool is worth keeping: it is the cheapest way to tell a decode that is
|
That tool is worth keeping: it is the cheapest way to tell a decode that is
|
||||||
merely well-formed from one that is right.
|
merely well-formed from one that is right.
|
||||||
|
|
||||||
|
### LOD selection
|
||||||
|
|
||||||
|
Mostly settled. The block header's float at +0x08 gives `L`, and the switch
|
||||||
|
distances are `[D0, D1, D2] = [6L, 4L, 2L]` world units; on every level `L` is
|
||||||
|
32.0, so `[192, 128, 64]`. The distance test compares the patch's bounding
|
||||||
|
sphere, truncated to integers, against `trunc(D * 1024)`. Six modes result —
|
||||||
|
LOD 2, LOD 1 collapse, LOD 1 morph, LOD 0 collapse, LOD 0 morph, LOD 0 — and
|
||||||
|
the *sphere* is what is tested, not the centre, so a patch straddling a
|
||||||
|
threshold takes the coarser mode. That is what stops geometry popping as the
|
||||||
|
camera moves, and it is worth testing for directly: sweeping the depth must
|
||||||
|
never move back to a finer mode.
|
||||||
|
|
||||||
|
A morph weight runs 0 to 1 across its tier's band: LOD-01 between D1 and D0,
|
||||||
|
LOD-0 between D2 and D1. Past the far edge of the band an entry is not morphed
|
||||||
|
but *replaced* by its first parent — a collapse, not a blend.
|
||||||
|
|
||||||
|
### What is still unresolved: the parent-index arrays
|
||||||
|
|
||||||
|
The four V4_8 parent arrays (`parent_indices_lod01`, `unk_indices_2_lod01`,
|
||||||
|
`parent_indices_lod0`, `unk_indices_2_lod0`) are classified by VU address, but
|
||||||
|
**the addresses are not unique**. On level 0 patch 1,
|
||||||
|
`unk_indices_2_lod_01_addr == parent_indices_lod_0_addr == 291`, and the VU
|
||||||
|
header's `unk_06` and `unk_0a` are both zero — there are no extra entries at
|
||||||
|
all, so nothing but the order the unpacks appear in distinguishes the two.
|
||||||
|
|
||||||
|
The consequence is measurable. Across all 27 levels, of 886,841 vertex-info
|
||||||
|
entries, 516,492 are common and 57,782 LOD entries resolve to a parent. But
|
||||||
|
316,548 LOD-0 entries have no recorded parent against only 28,739 that do, and
|
||||||
|
only 3,274 of 18,102 patches carry enough V4_8 bytes to cover
|
||||||
|
`positions_lod_01_count + positions_lod_0_count + extras`. The asymmetry is too
|
||||||
|
large to be genuine, so one of two things is true: the parent arrays are being
|
||||||
|
misassigned where addresses collide, or RAC2's VU header means something
|
||||||
|
different by `positions_lod_0_count` than RAC1's does.
|
||||||
|
|
||||||
|
The vertex-info tiers themselves *are* confirmed — the addresses at +9 and +11
|
||||||
|
do match the observed V4_16 unpacks, and the three tier counts sum to
|
||||||
|
`vert_count` on every patch. So the header layout is right; it is specifically
|
||||||
|
the parent arrays that are unresolved.
|
||||||
|
|
||||||
|
Ruled out for now: address-only classification (collides), and assuming the
|
||||||
|
arrays are one entry per position (fails on 15,000 patches). Not yet tried:
|
||||||
|
ordering by VU address rather than by declaration, and using `unk_06`/`unk_0a`
|
||||||
|
to suppress an empty tier before falling through to the next candidate.
|
||||||
|
|
||||||
### Still open
|
### Still open
|
||||||
|
|
||||||
The LOD switch and morph rules in `TfragProc`, and the GS register values the
|
The parent arrays above, and the GS register values the level load builds from
|
||||||
level load builds from the texture entry (TBP0/TBP1 and so on) for a renderer
|
the texture entry (TBP0/TBP1 and so on) for a renderer that emulates the GS
|
||||||
that emulates the GS rather than resolving addresses itself. A tfrag can now be
|
rather than resolving addresses itself. A tfrag can be turned into positioned,
|
||||||
turned into positioned, UV-mapped, textured triangles; what is missing is
|
UV-mapped, textured triangles and told which LOD to draw; morphing cannot be
|
||||||
choosing which LOD to draw.
|
completed until the parent arrays resolve.
|
||||||
|
|||||||
+12
-2
@@ -20,7 +20,7 @@ early, while they are still cheap to retire.
|
|||||||
header, and the `data` extent is parsed as a container of byte ranges. The
|
header, and the `data` extent is parsed as a container of byte ranges. The
|
||||||
geometry decompresses: level 0 yields 16,969,296 bytes of level content
|
geometry decompresses: level 0 yields 16,969,296 bytes of level content
|
||||||
from a 9.7 MB stream.
|
from a 9.7 MB stream.
|
||||||
- [ ] Next: tfrag LOD selection and morphing, then the first drawable frame.
|
- [ ] Next: tfrag parent indices, then the first drawable frame.
|
||||||
|
|
||||||
The disc being the exact build the reference projects pin is the single
|
The disc being the exact build the reference projects pin is the single
|
||||||
biggest favourable fact so far: no disc conversion, no regional timing work,
|
biggest favourable fact so far: no disc conversion, no regional timing work,
|
||||||
@@ -141,7 +141,17 @@ Phase 1 hands over the extents; this phase works out what each one contains.
|
|||||||
96.3% with real detail. Verified visually via the new
|
96.3% with real detail. Verified visually via the new
|
||||||
`gc-extract dump-texture`, because a wrong lookup still yields a
|
`gc-extract dump-texture`, because a wrong lookup still yields a
|
||||||
well-formed image of the wrong thing.
|
well-formed image of the wrong thing.
|
||||||
- [ ] tfrag LOD selection: the switch and morph rules in `TfragProc`.
|
- [x] **tfrag LOD selection.** Switch distances scale as `[6L, 4L, 2L]` from the
|
||||||
|
block header's float; six draw modes result from testing the bounding
|
||||||
|
sphere against `trunc(D * 1024)`, so a patch straddling a threshold takes
|
||||||
|
the coarser mode and distance never moves back to finer detail. Morph
|
||||||
|
weights run 0..1 across each tier's band, and collapse replaces an entry
|
||||||
|
with its first parent rather than blending it.
|
||||||
|
- [ ] tfrag parent indices: the four V4_8 arrays are classified by VU address,
|
||||||
|
but the addresses collide (`unk_06` and `unk_0a` are zero, so an absent
|
||||||
|
tier shares the next one's address). 57,782 of 374,349 LOD entries
|
||||||
|
resolve; the 316,548 unresolved LOD-0 entries are far too lopsided to be
|
||||||
|
genuine. Tried and ruled out in `plan/level-assets.md`.
|
||||||
- [ ] `gameplay` — begins `WAD` + a per-level byte, and does *not* decode with
|
- [ ] `gameplay` — begins `WAD` + a per-level byte, and does *not* decode with
|
||||||
the WAD LZ77 decoder. All 27 levels fail identically, so the input is
|
the WAD LZ77 decoder. All 27 levels fail identically, so the input is
|
||||||
wrong rather than the decoder. Ruled out: bit order, header length, the
|
wrong rather than the decoder. Ruled out: bit order, header length, the
|
||||||
|
|||||||
Reference in New Issue
Block a user