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
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn header(&self, bytes: &[u8], i: usize) -> Result<TfragHeader> {
|
||||
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_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,
|
||||
@@ -160,7 +178,7 @@ impl TfragHeader {
|
||||
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 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")?;
|
||||
@@ -192,6 +210,7 @@ impl TfragHeader {
|
||||
lod_2_size,
|
||||
lod_1_size,
|
||||
lod_0_size,
|
||||
base_only,
|
||||
texture_count,
|
||||
rgba_size,
|
||||
msphere_ofs,
|
||||
@@ -376,6 +395,10 @@ pub struct TfragVuHeader {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -395,6 +418,10 @@ impl TfragVuHeader {
|
||||
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],
|
||||
})
|
||||
}
|
||||
@@ -597,7 +624,22 @@ pub struct TfragGeometry {
|
||||
/// 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.
|
||||
@@ -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`.
|
||||
pub fn world_position(&self, vinfo: usize) -> [f32; 3] {
|
||||
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()) {
|
||||
(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.
|
||||
@@ -741,10 +811,29 @@ pub fn parse_tfrag(block: &[u8], header: &TfragHeader) -> Result<TfragGeometry>
|
||||
}
|
||||
}
|
||||
(3, 2) => {
|
||||
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 = 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
|
||||
// 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,
|
||||
@@ -988,6 +1079,153 @@ pub fn tfrag_triangles(g: &TfragGeometry, lod: usize) -> Result<Vec<TfragTriangl
|
||||
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)
|
||||
@@ -1575,3 +1813,110 @@ mod ad_gif_tests {
|
||||
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
|
||||
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
|
||||
|
||||
The LOD switch and morph rules in `TfragProc`, and the GS register values the
|
||||
level load builds from the texture entry (TBP0/TBP1 and so on) for a renderer
|
||||
that emulates the GS rather than resolving addresses itself. A tfrag can now be
|
||||
turned into positioned, UV-mapped, textured triangles; what is missing is
|
||||
choosing which LOD to draw.
|
||||
The parent arrays above, and the GS register values the level load builds from
|
||||
the texture entry (TBP0/TBP1 and so on) for a renderer that emulates the GS
|
||||
rather than resolving addresses itself. A tfrag can be turned into positioned,
|
||||
UV-mapped, textured triangles and told which LOD to draw; morphing cannot be
|
||||
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
|
||||
geometry decompresses: level 0 yields 16,969,296 bytes of level content
|
||||
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
|
||||
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
|
||||
`gc-extract dump-texture`, because a wrong lookup still yields a
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user