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 -- <overlay.bin> --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.
This commit is contained in:
2026-10-13 07:10:17 +02:00
parent 1f903efe23
commit 282e633a7c
4 changed files with 119 additions and 14 deletions
Generated
+20
View File
@@ -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]]
+40 -11
View File
@@ -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
+1
View File
@@ -8,3 +8,4 @@ publish = false
[dependencies]
anyhow = "1"
capstone = { version = "0.14", features = ["arch_mips"] }
+58 -3
View File
@@ -21,6 +21,13 @@ fn main() -> Result<()> {
.context("usage: gc-overlay-anchors <overlay.bin> [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<u64> {
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<usize> {
if needle.is_empty() || needle.len() > h.len() {
return Vec::new();