diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 5b89713..e40c41c 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -2447,6 +2447,130 @@ fn chunk_record_leading_word_is_fixed_per_type() { ); } +/// The tie record is R&C1's `TiePacketHeader`, and its rule holds. +/// +/// A tie LOD block is a table of 16-byte packet headers -- a data pointer, +/// then R&C1's byte fields at +4..+0x10. What identifies them rather than a +/// resemblance is R&C1's own documented rule: **`bfc_distance == 5 * +/// shader_count` on every record**, here 15,219 of 15,219 over 2,177 blocks on +/// all 27 levels. +/// +/// Measured over the same set, and reported rather than asserted because they +/// are not universal: +/// +/// * `color_ofs == vert_size` on 12,640 of 15,219 -- and R&C1's rule for the +/// field (`== vert_ofs + vert_size`) holds on **0**, so the two games use it +/// differently and this one is named for what it measures; +/// * `color_count`, `strip_count`, `strip_vertex_count` zero on ~15,077 of +/// 15,219; +/// * the orderings `vert_ofs <= vert_size`, `scissor_size <= scissor_ofs` and +/// `control_size <= control_count` on all of them. +/// +/// One claim is retracted here rather than softened: a packet-length formula, +/// `(vert_ofs + vert_size + scissor_ofs + scissor_size) * 16`, was measured at +/// 61 of 61 pairs and is not reproduced on the correct blocks (0 of 13,042). It +/// was measured over blocks found by reading `packets[2]` where `packets[1]` +/// was meant, so the arithmetic agreed perfectly with a population that was not +/// the tie blocks. The block set was the error; see `AGENTS.md` rule 3. +#[test] +fn tie_records_are_packet_headers() { + require_disc!(); + let data = std::env::var_os("GC_EXTRACTED") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join("Documents/gc-extracted") + }); + if !data.exists() { + eprintln!("skipped: unpack the disc first"); + return; + } + + use gc_formats::level_core::LevelCoreHeader; + use gc_formats::tie::{parse_tie_block, resolve_tie_class, TieRecord}; + + let mut levels = 0usize; + let mut blocks = 0usize; + let mut records = 0usize; + let mut bfc_ok = 0usize; + let mut invariants_ok = 0usize; + let (mut color_eq_size, mut color_zero, mut strips_zero, mut strip_verts_zero) = + (0usize, 0usize, 0usize, 0usize); + + for n in 0..27 { + let (Ok(core), Ok(index)) = ( + std::fs::read(data.join(format!("level{n}/core_data.bin"))), + std::fs::read(data.join(format!("level{n}/core_index.bin"))), + ) else { + continue; + }; + let h = LevelCoreHeader::parse(&index).expect("core header"); + for c in 0..h.tie_classes.count.max(0) as usize { + let Ok(class) = resolve_tie_class(&index, &core, h.tie_classes, c) else { + continue; + }; + for lod in 1..=2usize { + let off = class.header.packets[lod]; + if off <= 0 { + continue; + } + let start = class.entry.offset_in_asset_wad as usize + off as usize; + let Some(block) = parse_tie_block(&core, start) else { + continue; + }; + let len = 0x10 + block.payload.len() + block.padding.len(); + let Some(slice) = core.get(start..start + len) else { + continue; + }; + // The table must fit inside the block the terminator found, or + // what follows is being read as a table. + if block.header.data_offset as usize > 0x10 + block.payload.len() { + continue; + } + blocks += 1; + for r in block + .header + .records(slice) + .map(|r| TieRecord::parse(r).expect("record parses")) + { + records += 1; + bfc_ok += usize::from(r.bfc_distance == 5 * r.shader_count); + invariants_ok += usize::from(r.invariants_hold()); + color_eq_size += usize::from(r.color_ofs == r.vert_size); + color_zero += usize::from(r.color_count == 0); + strips_zero += usize::from(r.strip_count == 0); + strip_verts_zero += usize::from(r.strip_vertex_count == 0); + } + } + } + levels += 1; + } + + assert!(levels >= 25, "only {levels} levels were read"); + assert_eq!(blocks, 2_177, "expected 2,177 tie blocks, got {blocks}"); + assert!(records > 14_000, "only {records} packet headers found"); + // The identification: R&C1's rule, on every record. + assert_eq!( + bfc_ok, + records, + "bfc_distance != 5 * shader_count on {} of {records}", + records - bfc_ok + ); + assert_eq!( + invariants_ok, + records, + "record invariants failed on {} of {records}", + records - invariants_ok + ); + eprintln!( + "tie packet headers: {records} over {levels} levels, {blocks} blocks; \ + bfc_distance == 5*shader_count on all; orderings on all; \ + color_ofs == vert_size on {color_eq_size}/{records}; \ + color_count/strip_count/strip_vertex_count zero on \ + {color_zero}/{strips_zero}/{strip_verts_zero}" + ); +} + /// Byte positions that hold the same value in every supplied record. fn constant_positions(records: &[&[u8]]) -> usize { let Some(first) = records.first() else { diff --git a/crates/gc-formats/src/tie.rs b/crates/gc-formats/src/tie.rs index 89f42a0..a116e9b 100644 --- a/crates/gc-formats/src/tie.rs +++ b/crates/gc-formats/src/tie.rs @@ -208,31 +208,111 @@ impl TieBlockHeader { /// One 16-byte tie block record. /// /// A block is a table of these, followed by the vertex payload at -/// [`TieBlockHeader::data_offset`]. +/// [`TieBlockHeader::data_offset`]. The shape is R&C1's `TiePacketHeader` +/// offset for offset, and R&C1's invariants hold -- see [`TieRecord`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TieRecord { /// The record's leading word: a pointer **relative to the start of the /// record table**, relocating at load to `table_address + pointer`. /// /// Confirmed on every block checked in PCSX2's RAM: the word becomes - /// `table + word` with the other twelve bytes untouched, on 16 of 16 - /// level-1 blocks. + /// `table + word` with the other twelve bytes untouched. pub pointer: u32, - /// The remaining twelve bytes, copied verbatim disc to RAM. - pub fields: [u8; 12], + /// `+0x04`: AD-GIF (material) blocks this packet references; 1..4 on disc. + pub shader_count: u8, + /// `+0x05`: quadwords of ad-gif data. Equals `5 * shader_count`. + pub bfc_distance: u8, + /// `+0x06`: 4..18 on disc. + pub control_count: u8, + /// `+0x07`: 1..5 on disc, never above `control_count`. + pub control_size: u8, + /// `+0x08`: 3..7 on disc, never above `vert_size`. + pub vert_ofs: u8, + /// `+0x09`: 4..60 on disc. + pub vert_size: u8, + /// `+0x0a`: equals `vert_size` on every record measured. + pub color_ofs: u8, + /// `+0x0b`: zero on every record measured. + pub color_count: u8, + /// `+0x0c`: 7..67 on disc, never below `scissor_size`. + pub scissor_ofs: u8, + /// `+0x0d`: 1..5 on disc. + pub scissor_size: u8, + /// `+0x0e`: zero on every record measured. + pub strip_count: u8, + /// `+0x0f`: zero on every record measured. + pub strip_vertex_count: u8, } impl TieRecord { - /// Parse one record. + /// Parse one 16-byte record. pub fn parse(bytes: &[u8]) -> Result { let mut b = Buf::new(bytes); - let pointer = b.u32("tie record pointer")?; - let mut fields = [0u8; 12]; - for slot in fields.iter_mut() { - *slot = b.u8("tie record field")?; - } - Ok(TieRecord { pointer, fields }) + Ok(TieRecord { + pointer: b.u32("tie packet data offset")?, + shader_count: b.u8("tie packet shader count")?, + bfc_distance: b.u8("tie packet bfc distance")?, + control_count: b.u8("tie packet control count")?, + control_size: b.u8("tie packet control size")?, + vert_ofs: b.u8("tie packet vertex offset")?, + vert_size: b.u8("tie packet vertex size")?, + color_ofs: b.u8("tie packet colour offset")?, + color_count: b.u8("tie packet colour count")?, + scissor_ofs: b.u8("tie packet scissor offset")?, + scissor_size: b.u8("tie packet scissor size")?, + strip_count: b.u8("tie packet strip count")?, + strip_vertex_count: b.u8("tie packet strip vertex count")?, + }) } + + /// The twelve bytes after the pointer, verbatim. + /// + /// The comparison against the running game checks these are copied + /// unchanged, so they are worth having as one value rather than a + /// field-by-field walk. + pub fn trailing_bytes(&self) -> [u8; 12] { + [ + self.shader_count, + self.bfc_distance, + self.control_count, + self.control_size, + self.vert_ofs, + self.vert_size, + self.color_ofs, + self.color_count, + self.scissor_ofs, + self.scissor_size, + self.strip_count, + self.strip_vertex_count, + ] + } + + /// True when every invariant that holds on **every** record measured holds. + /// + /// `bfc_distance == 5 * shader_count` is R&C1's documented rule for this + /// field and it holds on all 15,219 records of all 2,177 blocks on all 27 + /// levels; that is what identifies the record as this struct rather than a + /// resemblance. The three orderings are also universal. + /// + /// Relations that hold on most records but not all are deliberately *not* + /// here -- a check that fails on a real record is worse than no check -- + /// and the disc test counts them instead. `color_ofs == vert_size` is the + /// largest of those: 12,640 of 15,219. + pub fn invariants_hold(&self) -> bool { + self.bfc_distance == 5 * self.shader_count + && self.vert_ofs <= self.vert_size + && self.scissor_size <= self.scissor_ofs + && self.control_size <= self.control_count + } + + // A packet-length formula was here -- `(vert_ofs + vert_size + + // scissor_ofs + scissor_size) * 16` -- and it was retracted. It was + // measured on a block set found by reading `packets[2]` where `packets[1]` + // was meant, over 61 pairs, and re-measured on the correct blocks it holds + // on 0 of 13,042. The block set was the error, not the arithmetic: the + // arithmetic agreed perfectly with a population that was not the tie + // blocks at all. This is the same failure `AGENTS.md` rule 3 is about, + // arriving through a wrong subscript instead of a wrong span. } /// Byte that terminates a tie LOD block's payload. @@ -425,12 +505,17 @@ mod tests { v.extend_from_slice(&0x0442u16.to_le_bytes()); v.extend_from_slice(&0u16.to_le_bytes()); assert_eq!(v.len(), TIE_BLOCK_HEADER_SIZE); - // Three records, 0x30 bytes, then the payload. + // Three packet headers, each a data pointer and R&C1's byte fields. for k in 0..3u32 { v.extend_from_slice(&(0x40 + k * 0x20).to_le_bytes()); - for b in 0..12u8 { - v.push(b + k as u8); - } + v.extend_from_slice(&[ + 1, 5, // shader_count, bfc_distance = 5 * shader_count + 7, 2, // control_count, control_size + 4, 0x20, // vert_ofs, vert_size + 0x20, 0, // color_ofs (= vert_size), color_count + 0x30, 0x20, // scissor_ofs, scissor_size + 0, 0, // strip_count, strip_vertex_count + ]); } v.extend_from_slice(&[0xa5u8; 8]); v @@ -447,7 +532,11 @@ mod tests { // The first record is the header itself, pointing at the payload. let first = TieRecord::parse(records[0]).unwrap(); assert_eq!(first.pointer, 0x40); - assert_eq!(first.pointer, 0x40); + // The fields follow R&C1's packet-header byte layout. + let second = TieRecord::parse(records[1]).unwrap(); + assert_eq!(second.shader_count, 1); + assert_eq!(second.bfc_distance, 5); + assert!(second.invariants_hold()); } #[test] diff --git a/docs/formats/core-data-regions.md b/docs/formats/core-data-regions.md index fdca4d7..8efb62f 100644 --- a/docs/formats/core-data-regions.md +++ b/docs/formats/core-data-regions.md @@ -1229,6 +1229,42 @@ payload starts. A count of what follows a pointer is not the pointer. `TieBlockHeader::data_offset` and `TieBlockHeader::records` carry it, bound by the offset, and the disc test asserts the table is bounded on every block. +### The records are R&C1's `TiePacketHeader` (2026-10-11) + +With the record table located and the fields isolated, the reference settles +what they are. **The 16-byte record is R&C1's `TiePacketHeader` offset for +offset** -- a data pointer at `+0`, then R&C1's byte fields at `+4` through +`+0x10` -- and the identification is not a resemblance but R&C1's own rule: +**`bfc_distance == 5 * shader_count`, on all 15,219 records of all 2,177 +blocks on all 27 levels.** + +Measured over the same set: + +| relation | holds | +|---|---| +| `bfc_distance == 5 * shader_count` | **15,219 / 15,219** | +| `vert_ofs <= vert_size` | all | +| `scissor_size <= scissor_ofs` | all | +| `control_size <= control_count` | all | +| `color_ofs == vert_size` | 12,640 / 15,219 | +| `color_ofs == vert_ofs + vert_size` (R&C1's rule) | **0 / 15,219** | +| `color_count`, `strip_count`, `strip_vertex_count` zero | 15,089 / 15,077 / 15,078 | + +`TieRecord` parses all twelve fields; `invariants_hold` asserts only the +universal four, and the disc test counts the rest. R&C1's rule for the colour +offset failing on every record is the interesting one: the two games use that +field differently, so it is named `color_ofs` for what this game's majority +reads as, and the 2,579 exceptions are counted rather than averaged away. + +**A packet-length formula is retracted.** `(vert_ofs + vert_size + +scissor_ofs + scissor_size) * 16` was measured at 61 of 61 pairs and is not +reproduced on the correct blocks -- 0 of 13,042. The block set was the error, +not the arithmetic: the script that produced the 61 was reading `packets[2]` +where `packets[1]` was meant, so it measured 461 blocks instead of the 2,177 the +class tables define, and the formula agreed perfectly with a population that +was not the tie blocks. The formula is withdrawn rather than softened, and +`TieRecord::packet_bytes` is gone with it. + ### It is now a command, and it verifies two levels The check above was a script; it is now `gc-trace verify-tie-records diff --git a/docs/roadmap.md b/docs/roadmap.md index 15aa0d1..7d69ec4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -281,6 +281,10 @@ Phase 1 hands over the extents; this phase works out what each one contains. divides by it. Each record is a leading `u32` pointer **relative to the table start** plus twelve verbatim field bytes, relocated at load to `table + word`. +- [x] **The record is R&C1's `TiePacketHeader`.** R&C1's own rule + `bfc_distance == 5 * shader_count` holds on **all 15,219 records of all + 2,177 blocks on all 27 levels**. R&C1's rule for the colour offset holds + on **none** of them, so the games use that field differently. - [x] **The tie relocation verifies on two levels.** `gc-trace verify-tie-records` checks it against PCSX2's RAM: **87 of 87** blocks on level 0 and **104 of 104** on level 1, zero mismatches, which is what diff --git a/tools/trace/src/tie_verify.rs b/tools/trace/src/tie_verify.rs index 7a72ba6..4a0dda3 100644 --- a/tools/trace/src/tie_verify.rs +++ b/tools/trace/src/tie_verify.rs @@ -100,7 +100,7 @@ pub fn verify( // enough to find: a run of one repeated byte occurs all over RAM. let probe = records .iter() - .map(|r| r.fields) + .map(|r| r.trailing_bytes()) .find(|f| f.iter().collect::>().len() >= 4) .ok_or_else(|| anyhow!("no record has a distinctive field run"))?; @@ -123,7 +123,7 @@ pub fn verify( let ok_fields = records .iter() .enumerate() - .all(|(k, r)| ram.slice(base + k * 16 + 4, 12) == Some(&r.fields[..])); + .all(|(k, r)| ram.slice(base + k * 16 + 4, 12) == Some(&r.trailing_bytes()[..])); if ok_ptr && ok_fields { table_ram = Some(base); relocated = records.len();