From 282e633a7c90e2852005c8cf77790539fcfcb8b0 Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Tue, 13 Oct 2026 07:10:17 +0200 Subject: [PATCH] Withdraw the collision anchor: it was a byte-order bug in my own search Last commit claimed exactly one GP-relative occurrence of the collision assert string's $lo half, at 0x354cc8, and called it an unambiguous anchor. That was wrong, and building the disassembler is what proved it. The search compared word bytes 1..3 against the 16-bit immediate. The PS2's MIPS is little-endian, so an I-type immediate sits in bits 15..0 -- word bytes 0 and 1. Bytes 1..3 are the opcode, $rs and $rt fields. The match was on those. The instruction at the phantom hit is 0x24a89948: addiu $t0, $a1, -0x66b8 whose real immediate is 0x9948 -- ten bytes from the 0x99a8 being searched for. -0x66b8 merely looks like a plausible $gp offset, which is what made it convincing. With the byte order corrected the count is ZERO, in 2,751,268 bytes. So the anchor is not established and the assertion is withdrawn. What survives is still worth having: the functions are named (Camera_CollPrimTest takes (prim, grid)), $gp = 0x1AEFF0, .lit loads at 0x1A7C80, and the assert string is a unique copy at overlay+0xd18. Probably the code reaching that string does not address it through $gp, or reaches a copy in another section. The decomp's assert-message-names.tsv gives 0x002db350 for level 0 -- inside .lit, but it disassembles to a function epilogue (jr $ra / addiu $sp, $sp, 0x70), so those are code locations, not string addresses, and they are not traced to the assert either. tools/overlay-anchors now does the search with the correct byte order and carries a comment on exactly the part that was wrong, because that is the part that will be re-derived otherwise. It also disassembles around an address: cargo run -q -p gc-overlay-anchors -- --disasm 0x2db350 0 64 The useful general lesson, which the roadmap already says and this is a second demonstration of: a probe that returns a small, clean, narratively satisfying number is the most dangerous kind of wrong result. "Exactly one hit in 2.75 MB" read as conclusive. It was an artifact of comparing the wrong two bytes, and it survived a commit because it fit the story. --- Cargo.lock | 20 ++++++++++ docs/formats/core-data-regions.md | 51 ++++++++++++++++++++------ tools/overlay-anchors/Cargo.toml | 1 + tools/overlay-anchors/src/main.rs | 61 +++++++++++++++++++++++++++++-- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c2d920..2cd60a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1668,6 +1668,25 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "capstone" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f442ae0f2f3f1b923334b4a5386c95c69c1cfa072bafa23d6fae6d9682eb1dd4" +dependencies = [ + "capstone-sys", + "static_assertions", +] + +[[package]] +name = "capstone-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e8087cab6731295f5a2a2bd82989ba4f41d3a428aab2e7c98d8f4db38aac05" +dependencies = [ + "cc", +] + [[package]] name = "cc" version = "1.6.0" @@ -2366,6 +2385,7 @@ name = "gc-overlay-anchors" version = "0.1.0" dependencies = [ "anyhow", + "capstone", ] [[package]] diff --git a/docs/formats/core-data-regions.md b/docs/formats/core-data-regions.md index 994b3e3..d70259a 100644 --- a/docs/formats/core-data-regions.md +++ b/docs/formats/core-data-regions.md @@ -1903,25 +1903,54 @@ What it does supply is `$gp`, and that is enough to start. delta -0x6658 ($lo half 0x99a8) MIPS materialises a GP-relative address as a `%hi`/`%lo` pair, and the `$lo` half -is a 16-bit immediate sitting in bytes 1..3 of a 4-byte instruction. Searching -the whole overlay for that half, instruction-aligned, gives: +is a 16-bit immediate in the instruction stream. So the anchor search is well +defined -- and it returns **nothing**: - 0x354cc8 (overlay+0x1ad048) -- exactly ONE occurrence + $lo half 0x99a8 (low byte 0xa8, high byte 0x99) + occurrences 0, instruction-aligned, in 2,751,268 bytes -One occurrence in 2.75 MB of code is an unambiguous anchor: `0x354cc8` is the -instruction that materialises the assert string, and the function containing it -is `Camera_CollPrimTest`. That is the route in, and it needs no symbols. +**An earlier version of this file claimed exactly one hit, at `0x354cc8`, and +called it an unambiguous anchor. That was wrong, and the mistake was worth +recording.** The search compared word bytes 1..3 against the immediate. The PS2's +MIPS is little-endian, so an I-type immediate is in bits 15..0, which is word +bytes **0** and **1** -- bytes 1..3 are the opcode, `$rs` and `$rt` fields. The +match was on those, not on an address. -`tools/overlay-anchors` does this step and is dependency-free; it prints strings, -GP-relative deltas and occurrence counts. Asserting the uniqueness: +The instruction at the phantom hit is `0x24a89948`: + + addiu $t0, $a1, -0x66b8 + +whose real immediate is `0x9948`, ten bytes away from the `0x99a8` being +searched for, and whose `-0x66b8` merely looks like a plausible `$gp` offset. +With the byte order corrected, the count is zero. + +So the collision anchor is **not established**, and the assertion that it was has +been withdrawn. What survives is weaker and still useful: + + * `Camera_CollPrimTest` and `MB_CheckCollPill` are named, with + `Camera_CollPrimTest(void *prim, void *grid)`. + * `$gp = 0x1AEFF0`, and `.lit` loads at `0x1A7C80`. + * The assert string is at `overlay+0xd18`, a unique copy. + +The likely reason the search comes back empty is that the code reaching that +string does not go through `$gp` -- or reaches a copy in another section. The +decomp's `assert-message-names.tsv` gives `0x002db350` for level 0, which is +inside `.lit` but disassembles to a function epilogue (`jr $ra` / +`addiu $sp, $sp, 0x70`), so those are code locations, not string addresses, and +they are not yet traced to the assert either. + +`tools/overlay-anchors` does the anchor search and is dependency-free. Its +correctly-ordered result for the collision string is zero, and it now carries a +comment explaining the byte order, since that is precisely the part that was +wrong: cargo run -q -p gc-overlay-anchors -- \ /home/reaper/Documents/gc-extracted/level0/overlay.bin \ "Camera_CollPrimTest WARNING!" -Note the decomp's `docs/assert-message-names.tsv` holds **code** addresses, not -string addresses (level 0's `0x002db350` disassembles to instructions). They are -function-level anchors and are a second, independent way in. +Next step, if collision is still worth it: find how the assert string is actually +addressed -- a `%hi`/`%lo` split that does not straddle zero, a pointer table, or +simply the right section -- before assuming a `$gp` relative load. ### What this project did *not* give up diff --git a/tools/overlay-anchors/Cargo.toml b/tools/overlay-anchors/Cargo.toml index f3c0b56..bdafaf4 100644 --- a/tools/overlay-anchors/Cargo.toml +++ b/tools/overlay-anchors/Cargo.toml @@ -8,3 +8,4 @@ publish = false [dependencies] anyhow = "1" +capstone = { version = "0.14", features = ["arch_mips"] } diff --git a/tools/overlay-anchors/src/main.rs b/tools/overlay-anchors/src/main.rs index 29e12ab..def00f3 100644 --- a/tools/overlay-anchors/src/main.rs +++ b/tools/overlay-anchors/src/main.rs @@ -21,6 +21,13 @@ fn main() -> Result<()> { .context("usage: gc-overlay-anchors [needle] [--level N]")?; let overlay = std::fs::read(path).with_context(|| format!("read {path}"))?; + if args.get(1).map(String::as_str) == Some("--disasm") { + let ram = parse_ram(args.get(2).context("--disasm needs a RAM address")?)?; + let before: usize = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(64); + let after: usize = args.get(4).and_then(|v| v.parse().ok()).unwrap_or(64); + disasm(&overlay, ram, before, after)?; + return Ok(()); + } match args.get(1) { None => { list_strings(&overlay); @@ -54,11 +61,18 @@ fn report(overlay: &[u8], needle: &[u8], off: usize) { u16::from_be_bytes(lo) ); - // Count instruction-aligned occurrences of the $lo immediate. MIPS puts it in - // bytes 1..3 of a 4-byte instruction, big-endian regardless of file order. + // Count instruction-aligned occurrences of the $lo immediate. + // + // The PS2's MIPS is little-endian, and an I-type immediate occupies bits + // 15..0 of the word, so the low byte of the immediate is word BYTE 0 and the + // high byte is word BYTE 1. Reading bytes 1..3 instead -- which an earlier + // version of this file did -- matches the opcode and $rs fields instead and + // manufactures hits out of nothing. + let lo_byte = (delta as u16) as u8; + let hi_byte = ((delta as u16) >> 8) as u8; let mut hits = BTreeSet::new(); for i in (0..overlay.len().saturating_sub(3)).step_by(4) { - if overlay[i + 1] == lo[0] && overlay[i + 2] == lo[1] { + if overlay[i] == lo_byte && overlay[i + 1] == hi_byte { hits.insert(i); } } @@ -73,6 +87,47 @@ fn report(overlay: &[u8], needle: &[u8], off: usize) { } } +fn parse_ram(s: &str) -> Result { + let t = s.trim_start_matches("0x").replace('_', ""); + u64::from_str_radix(&t, 16).with_context(|| format!("parse address {s}")) +} + +/// Disassemble `before` bytes before and `after` bytes after `ram`. +fn disasm(overlay: &[u8], ram: u64, before: usize, after: usize) -> Result<()> { + use capstone::prelude::*; + use capstone::Endian; + let off = usize::try_from( + ram.checked_sub(LIT_BASE) + .context("address below .lit base")?, + )?; + if off + after > overlay.len() { + anyhow::bail!("{ram:#x}+{after} runs past the end of the section"); + } + let cs = capstone::Capstone::new() + .mips() + .mode(capstone::arch::mips::ArchMode::Mips32) + .endian(Endian::Little) + .detail(true) + .build() + .context("capstone mips32")?; + + let start = off.saturating_sub(before); + let chunk = &overlay[start..off + after]; + let insns = cs + .disasm_all(chunk, LIT_BASE + start as u64) + .context("disassemble")?; + for i in insns.iter() { + let at = start + usize::try_from(i.address() - LIT_BASE).unwrap(); + eprintln!( + "{} {:#010x} {}", + if at == off { ">>" } else { " " }, + i.address(), + i + ); + } + Ok(()) +} + fn find_all(h: &[u8], needle: &[u8]) -> Vec { if needle.is_empty() || needle.len() > h.len() { return Vec::new();