phase7: census duplicate bodies and expose a zero band of false positives

Matching conventions require a duplicate check before registering, because a
shared body is matched once and registered once per address. Phase 6 did that
check by hand and found one 12-byte pair. tools/sf3_dupes now hashes every
derived extent body and groups exact duplicates.

Results: 2284 extents, 65 multi-address groups, 2104 singletons. Only 10 groups
contain code (24 addresses, all exact-graded); 55 are all-zero bodies. The
hand-found pair 0x800262E0/0x800262EC is reproduced as g0002, which is the check
that the census measures what it claims. The largest real groups are 712 bytes
(0x8001084C/0x800189E8) and 436 bytes.

The zero groups are a real finding: 252 extents have all-zero bodies, 245 inside
the zero band 0x80147000..0x80170000. The cause is the inventory's jal grade,
which decodes every word as an instruction -- in a data region a word with
opcode 3 is graded as a call whose target lands in the zero band. The census
flags those groups rather than hiding them, and the worklist must exclude
degenerate bodies.

The census is tracked rather than ignored as the plan said, because it holds
addresses, sizes and grades only (the same class as the tracked inventory and
extents tables) and the worklist must be reproducible from tracked inputs. The
content hash is computed and never written.
This commit is contained in:
Christopher Williams
2026-09-23 22:15:51 -04:00
parent 6988ca96b0
commit 2000cc4101
6 changed files with 761 additions and 2 deletions
+10 -1
View File
@@ -27,12 +27,14 @@ REGIONS := config/regions.tsv
SYMBOLS := config/symbols.tsv
INVENTORY := config/function_inventory.tsv
EXTENTS := config/function_extents.tsv
DUPES := config/duplicate_bodies.tsv
MATCH := tools/sf3_match
EXTENTS_TOOL := tools/sf3_extents
DUPES_TOOL := tools/sf3_dupes
CODE_OUT := build/code
EXPECTED_SHA1 := e173426c157384ebf1b6caf8c6fea18a85a14af9
.PHONY: all validate split assemble link binary code gate extents extents-verify test check clean
.PHONY: all validate split assemble link binary code gate extents extents-verify dupes test check clean
all: binary
@@ -102,6 +104,13 @@ extents-verify: validate
@"$(EXTENTS_TOOL)" verify --exe "$(EXE)" --inventory "$(INVENTORY)" \
--extents "$(EXTENTS)" --regions "$(REGIONS)"
# Duplicate-body census (Phase 7): groups byte-identical bodies so a shared body
# is matched once and registered once per address.
dupes: validate
@test -f "$(DUPES_TOOL)"
@test -f "$(EXTENTS)"
@"$(DUPES_TOOL)" census --exe "$(EXE)" --extents "$(EXTENTS)" --out "$(DUPES)" --force
# Verification gates. `test` is synthetic-only and needs no game input;
# `check` adds the extents check and the full-binary byte gate (which do).
test:
+74
View File
@@ -0,0 +1,74 @@
# Syphon Filter 3 (USA) duplicate-body census.
# Columns: group<TAB>size<TAB>count<TAB>addresses<TAB>grades<TAB>flags.
# Multi-address groups only; addresses, sizes and grades only; no bytes.
# A group is evidence of a shared body, not a claim that every member is
# a real function start: verify a member before registering it.
# flags=zero means the body is all zero bytes -- a zero-filled region the
# walk ran through, not a function. Exclude those from matching.
# Regenerate: ./tools/sf3_dupes census --exe '<exe>' \
# --extents config/function_extents.tsv --out config/duplicate_bodies.tsv --force
g0001 8 4 0x80042088,0x80089C4C,0x80089E74,0x800B5AF0 exact,exact,exact,exact -
g0002 12 2 0x800262E0,0x800262EC exact,exact -
g0003 12 2 0x80147D74,0x801515F4 fallthrough,fallthrough zero
g0004 16 8 0x80147650,0x80149A00,0x8014A170,0x8014A1C0,0x8014AC60,0x8014AC70,0x8014AC80,0x8014AEB0 fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
g0005 20 2 0x80149478,0x80168288 fallthrough,fallthrough zero
g0006 32 3 0x801478A8,0x80147DFC,0x80151660 fallthrough,fallthrough,fallthrough zero
g0007 36 3 0x800F7FB4,0x80103C7C,0x80103F84 exact,exact,exact -
g0008 36 2 0x800F8F9C,0x80109314 exact,exact -
g0009 36 4 0x8014762C,0x80147E1C,0x80169448,0x8016946C fallthrough,fallthrough,fallthrough,fallthrough zero
g0010 40 2 0x801540D4,0x8016E4DC fallthrough,fallthrough zero
g0011 48 2 0x80148210,0x8016C808 fallthrough,fallthrough zero
g0012 52 2 0x80147D40,0x8016CA70 fallthrough,fallthrough zero
g0013 60 2 0x80010810,0x8009C69C exact,exact -
g0014 60 4 0x8014A0F8,0x8014A134,0x801522D0,0x8015F554 fallthrough,fallthrough,fallthrough,fallthrough zero
g0015 64 6 0x801482B0,0x8014A180,0x8014AE70,0x80151294,0x8016ADF0,0x8016B524 fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
g0016 72 4 0x801499B8,0x80164824,0x8016ADA8,0x8016E494 fallthrough,fallthrough,fallthrough,fallthrough zero
g0017 80 2 0x80146D00,0x80169944 fallthrough,fallthrough zero
g0018 84 2 0x801694EC,0x8016E2D8 fallthrough,fallthrough zero
g0019 88 2 0x8016A004,0x8016C47C fallthrough,fallthrough zero
g0020 92 3 0x801498CC,0x80169490,0x80169994 fallthrough,fallthrough,fallthrough zero
g0021 96 4 0x80147CE0,0x80151600,0x8015230C,0x8016C164 fallthrough,fallthrough,fallthrough,fallthrough zero
g0022 104 2 0x80162AA8,0x80162FCC fallthrough,fallthrough zero
g0023 108 3 0x80149A8C,0x801521EC,0x80153868 fallthrough,fallthrough,fallthrough zero
g0024 112 4 0x80146C90,0x80148240,0x8014AE00,0x80151AF4 fallthrough,fallthrough,fallthrough,fallthrough zero
g0025 116 4 0x80147C6C,0x80154060,0x8016D364,0x8016D818 fallthrough,fallthrough,fallthrough,fallthrough zero
g0026 120 3 0x80018CB0,0x8001ADC4,0x8009C6D8 exact,exact,exact -
g0027 120 2 0x80152258,0x8016D090 fallthrough,fallthrough zero
g0028 124 4 0x801471CC,0x80147D80,0x80149A10,0x8016486C fallthrough,fallthrough,fallthrough,fallthrough zero
g0029 128 2 0x800FB13C,0x800FBE04 exact,exact -
g0030 140 2 0x8009E8D0,0x800A45E0 exact,exact -
g0031 144 3 0x80147F30,0x80149928,0x80165FF8 fallthrough,fallthrough,fallthrough zero
g0032 148 6 0x8014804C,0x8014AD6C,0x80151CEC,0x801564B4,0x8016AA20,0x8016C850 fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
g0033 152 4 0x801478C8,0x801520AC,0x80155F64,0x80164AFC fallthrough,fallthrough,fallthrough,fallthrough zero
g0034 160 3 0x80151968,0x8016729C,0x8016C0C4 fallthrough,fallthrough,fallthrough zero
g0035 164 2 0x801648E8,0x8016CD48 fallthrough,fallthrough zero
g0036 168 3 0x80152144,0x8015F4AC,0x8016C27C fallthrough,fallthrough,fallthrough zero
g0037 172 4 0x801480E0,0x80163D78,0x8016A974,0x8016C75C fallthrough,fallthrough,fallthrough,fallthrough zero
g0038 176 2 0x801540FC,0x8016B298 fallthrough,fallthrough zero
g0039 184 2 0x80149AF8,0x8016C1C4 fallthrough,fallthrough zero
g0040 188 3 0x801644AC,0x80164568,0x80166528 fallthrough,fallthrough,fallthrough zero
g0041 212 2 0x80169374,0x8016D9AC fallthrough,fallthrough zero
g0042 216 2 0x80149F48,0x8014A020 fallthrough,fallthrough zero
g0043 220 2 0x8014AC90,0x801512D4 fallthrough,fallthrough zero
g0044 252 5 0x801495A8,0x80161978,0x80161A74,0x801643B0,0x80164F5C fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
g0045 256 2 0x801497CC,0x80151B64 fallthrough,fallthrough zero
g0046 264 3 0x80149E40,0x80164CF4,0x8016E504 fallthrough,fallthrough,fallthrough zero
g0047 268 2 0x80153034,0x8016CF84 fallthrough,fallthrough zero
g0048 296 2 0x801496A4,0x80167174 fallthrough,fallthrough zero
g0049 312 2 0x80147400,0x80168910 fallthrough,fallthrough zero
g0050 352 4 0x80164B94,0x80164DFC,0x801687B0,0x80169540 fallthrough,fallthrough,fallthrough,fallthrough zero
g0051 396 2 0x80156328,0x8016C8E4 fallthrough,fallthrough zero
g0052 416 2 0x80162690,0x801696A0 fallthrough,fallthrough zero
g0053 436 2 0x8001D98C,0x8009C750 exact,exact -
g0054 436 2 0x80152C88,0x801532A4 fallthrough,fallthrough zero
g0055 440 2 0x80147248,0x80166FBC fallthrough,fallthrough zero
g0056 476 2 0x80149BB0,0x8016B348 fallthrough,fallthrough zero
g0057 488 3 0x80150EB0,0x801538D4,0x80159DE8 fallthrough,fallthrough,fallthrough zero
g0058 600 2 0x80148D6C,0x80148FC4 fallthrough,fallthrough zero
g0059 652 2 0x80147960,0x80163E24 fallthrough,fallthrough zero
g0060 656 2 0x80153458,0x80153DD0 fallthrough,fallthrough zero
g0061 712 2 0x8001084C,0x800189E8 exact,exact -
g0062 812 2 0x80151D80,0x80155FFC fallthrough,fallthrough zero
g0063 816 2 0x80169CD4,0x8016E60C fallthrough,fallthrough zero
g0064 1184 2 0x8014F07C,0x80166088 fallthrough,fallthrough zero
g0065 2000 2 0x80165828,0x8016B8F4 fallthrough,fallthrough zero
1 # Syphon Filter 3 (USA) duplicate-body census.
2 # Columns: group<TAB>size<TAB>count<TAB>addresses<TAB>grades<TAB>flags.
3 # Multi-address groups only; addresses, sizes and grades only; no bytes.
4 # A group is evidence of a shared body, not a claim that every member is
5 # a real function start: verify a member before registering it.
6 # flags=zero means the body is all zero bytes -- a zero-filled region the
7 # walk ran through, not a function. Exclude those from matching.
8 # Regenerate: ./tools/sf3_dupes census --exe '<exe>' \
9 # --extents config/function_extents.tsv --out config/duplicate_bodies.tsv --force
10 g0001 8 4 0x80042088,0x80089C4C,0x80089E74,0x800B5AF0 exact,exact,exact,exact -
11 g0002 12 2 0x800262E0,0x800262EC exact,exact -
12 g0003 12 2 0x80147D74,0x801515F4 fallthrough,fallthrough zero
13 g0004 16 8 0x80147650,0x80149A00,0x8014A170,0x8014A1C0,0x8014AC60,0x8014AC70,0x8014AC80,0x8014AEB0 fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
14 g0005 20 2 0x80149478,0x80168288 fallthrough,fallthrough zero
15 g0006 32 3 0x801478A8,0x80147DFC,0x80151660 fallthrough,fallthrough,fallthrough zero
16 g0007 36 3 0x800F7FB4,0x80103C7C,0x80103F84 exact,exact,exact -
17 g0008 36 2 0x800F8F9C,0x80109314 exact,exact -
18 g0009 36 4 0x8014762C,0x80147E1C,0x80169448,0x8016946C fallthrough,fallthrough,fallthrough,fallthrough zero
19 g0010 40 2 0x801540D4,0x8016E4DC fallthrough,fallthrough zero
20 g0011 48 2 0x80148210,0x8016C808 fallthrough,fallthrough zero
21 g0012 52 2 0x80147D40,0x8016CA70 fallthrough,fallthrough zero
22 g0013 60 2 0x80010810,0x8009C69C exact,exact -
23 g0014 60 4 0x8014A0F8,0x8014A134,0x801522D0,0x8015F554 fallthrough,fallthrough,fallthrough,fallthrough zero
24 g0015 64 6 0x801482B0,0x8014A180,0x8014AE70,0x80151294,0x8016ADF0,0x8016B524 fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
25 g0016 72 4 0x801499B8,0x80164824,0x8016ADA8,0x8016E494 fallthrough,fallthrough,fallthrough,fallthrough zero
26 g0017 80 2 0x80146D00,0x80169944 fallthrough,fallthrough zero
27 g0018 84 2 0x801694EC,0x8016E2D8 fallthrough,fallthrough zero
28 g0019 88 2 0x8016A004,0x8016C47C fallthrough,fallthrough zero
29 g0020 92 3 0x801498CC,0x80169490,0x80169994 fallthrough,fallthrough,fallthrough zero
30 g0021 96 4 0x80147CE0,0x80151600,0x8015230C,0x8016C164 fallthrough,fallthrough,fallthrough,fallthrough zero
31 g0022 104 2 0x80162AA8,0x80162FCC fallthrough,fallthrough zero
32 g0023 108 3 0x80149A8C,0x801521EC,0x80153868 fallthrough,fallthrough,fallthrough zero
33 g0024 112 4 0x80146C90,0x80148240,0x8014AE00,0x80151AF4 fallthrough,fallthrough,fallthrough,fallthrough zero
34 g0025 116 4 0x80147C6C,0x80154060,0x8016D364,0x8016D818 fallthrough,fallthrough,fallthrough,fallthrough zero
35 g0026 120 3 0x80018CB0,0x8001ADC4,0x8009C6D8 exact,exact,exact -
36 g0027 120 2 0x80152258,0x8016D090 fallthrough,fallthrough zero
37 g0028 124 4 0x801471CC,0x80147D80,0x80149A10,0x8016486C fallthrough,fallthrough,fallthrough,fallthrough zero
38 g0029 128 2 0x800FB13C,0x800FBE04 exact,exact -
39 g0030 140 2 0x8009E8D0,0x800A45E0 exact,exact -
40 g0031 144 3 0x80147F30,0x80149928,0x80165FF8 fallthrough,fallthrough,fallthrough zero
41 g0032 148 6 0x8014804C,0x8014AD6C,0x80151CEC,0x801564B4,0x8016AA20,0x8016C850 fallthrough,fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
42 g0033 152 4 0x801478C8,0x801520AC,0x80155F64,0x80164AFC fallthrough,fallthrough,fallthrough,fallthrough zero
43 g0034 160 3 0x80151968,0x8016729C,0x8016C0C4 fallthrough,fallthrough,fallthrough zero
44 g0035 164 2 0x801648E8,0x8016CD48 fallthrough,fallthrough zero
45 g0036 168 3 0x80152144,0x8015F4AC,0x8016C27C fallthrough,fallthrough,fallthrough zero
46 g0037 172 4 0x801480E0,0x80163D78,0x8016A974,0x8016C75C fallthrough,fallthrough,fallthrough,fallthrough zero
47 g0038 176 2 0x801540FC,0x8016B298 fallthrough,fallthrough zero
48 g0039 184 2 0x80149AF8,0x8016C1C4 fallthrough,fallthrough zero
49 g0040 188 3 0x801644AC,0x80164568,0x80166528 fallthrough,fallthrough,fallthrough zero
50 g0041 212 2 0x80169374,0x8016D9AC fallthrough,fallthrough zero
51 g0042 216 2 0x80149F48,0x8014A020 fallthrough,fallthrough zero
52 g0043 220 2 0x8014AC90,0x801512D4 fallthrough,fallthrough zero
53 g0044 252 5 0x801495A8,0x80161978,0x80161A74,0x801643B0,0x80164F5C fallthrough,fallthrough,fallthrough,fallthrough,fallthrough zero
54 g0045 256 2 0x801497CC,0x80151B64 fallthrough,fallthrough zero
55 g0046 264 3 0x80149E40,0x80164CF4,0x8016E504 fallthrough,fallthrough,fallthrough zero
56 g0047 268 2 0x80153034,0x8016CF84 fallthrough,fallthrough zero
57 g0048 296 2 0x801496A4,0x80167174 fallthrough,fallthrough zero
58 g0049 312 2 0x80147400,0x80168910 fallthrough,fallthrough zero
59 g0050 352 4 0x80164B94,0x80164DFC,0x801687B0,0x80169540 fallthrough,fallthrough,fallthrough,fallthrough zero
60 g0051 396 2 0x80156328,0x8016C8E4 fallthrough,fallthrough zero
61 g0052 416 2 0x80162690,0x801696A0 fallthrough,fallthrough zero
62 g0053 436 2 0x8001D98C,0x8009C750 exact,exact -
63 g0054 436 2 0x80152C88,0x801532A4 fallthrough,fallthrough zero
64 g0055 440 2 0x80147248,0x80166FBC fallthrough,fallthrough zero
65 g0056 476 2 0x80149BB0,0x8016B348 fallthrough,fallthrough zero
66 g0057 488 3 0x80150EB0,0x801538D4,0x80159DE8 fallthrough,fallthrough,fallthrough zero
67 g0058 600 2 0x80148D6C,0x80148FC4 fallthrough,fallthrough zero
68 g0059 652 2 0x80147960,0x80163E24 fallthrough,fallthrough zero
69 g0060 656 2 0x80153458,0x80153DD0 fallthrough,fallthrough zero
70 g0061 712 2 0x8001084C,0x800189E8 exact,exact -
71 g0062 812 2 0x80151D80,0x80155FFC fallthrough,fallthrough zero
72 g0063 816 2 0x80169CD4,0x8016E60C fallthrough,fallthrough zero
73 g0064 1184 2 0x8014F07C,0x80166088 fallthrough,fallthrough zero
74 g0065 2000 2 0x80165828,0x8016B8F4 fallthrough,fallthrough zero
+112
View File
@@ -0,0 +1,112 @@
# Phase 7 — Duplicate-Body Census
**Scope:** P7-T3 — find every body the executable contains more than once, so a shared body is matched
once and registered once per address, instead of being found by hand.
**Status:** complete. Tracked output: `config/duplicate_bodies.tsv`. Tool: `tools/sf3_dupes`
(20 synthetic tests).
## Why
`docs/MATCHING_CONVENTIONS.md` requires a duplicate check **before** registering a match, because the
registry's N-rows-to-one-source mechanism exists precisely so a shared body is written once. Phase 6
performed that check by hand and found exactly one shared body (`0x800262E0` / `0x800262EC`). There was
no way to know how many others exist, or how large they are.
## Method
Every extent row in `config/function_extents.tsv` is hashed over exactly the bytes
`[address, end)` that `tools/sf3_extents` derived, and rows are grouped by `(size, body)`. Each member
keeps its grade, so a consumer can prefer groups whose members are all `exact`. `--exact-only` and
`--min-size N` restrict the census.
The content hash is computed and **never written**. Grouping is the finding; addresses, sizes and
grades are the only ROM-derived quantities this project tracks — the same rule
`config/function_inventory.tsv` and `config/function_extents.tsv` follow. No instruction bytes appear
in the output.
## Results
| Measure | Count |
|---|---|
| Extents censused | 2,284 |
| Multi-address groups | 65 |
| — of which bodies are entirely zero bytes (`flags=zero`) | 55 |
| — of which bodies contain code | **10** |
| Addresses inside groups | 180 |
| Singleton bodies | 2,104 |
| Largest group | 8 addresses |
Restricting to `exact` extents yields **the same 10 groups**, so every real duplicate group consists of
`exact`-graded members.
### The ten real duplicate groups
| Group | Size | Members | Grades |
|---|---|---|---|
| `g0001` | 8 | `0x80042088`, `0x80089C4C`, `0x80089E74`, `0x800B5AF0` | all `exact` |
| `g0002` | 12 | `0x800262E0`, `0x800262EC` | all `exact` |
| `g0007` | 36 | `0x800F7FB4`, `0x80103C7C`, `0x80103F84` | all `exact` |
| `g0008` | 36 | `0x800F8F9C`, `0x80109314` | all `exact` |
| `g0013` | 60 | `0x80010810`, `0x8009C69C` | all `exact` |
| `g0026` | 120 | `0x80018CB0`, `0x8001ADC4`, `0x8009C6D8` | all `exact` |
| `g0029` | 128 | `0x800FB13C`, `0x800FBE04` | all `exact` |
| `g0030` | 140 | `0x8009E8D0`, `0x800A45E0` | all `exact` |
| `g0053` | 436 | `0x8001D98C`, `0x8009C750` | all `exact` |
| `g0061` | 712 | `0x8001084C`, `0x800189E8` | all `exact` |
`g0002` is the pair Phase 6 found by hand: the census reproduces it, which is the check that the
method is measuring what it claims to measure. `g0061` (712 bytes) and `g0053` (436 bytes) are
substantial shared bodies — matching one of those registers two functions for the price of one, which
is a materially better return than the 12-byte pair.
## The finding this census produced: a zero band of false positives
55 of the 65 groups are bodies consisting **entirely of zero bytes**, and they are concentrated in one
place: 252 extents overall have all-zero bodies, 245 of them inside `0x80147000`–`0x80170000`, a
160 KB zero-filled band. In that band the grades are 245 `fallthrough` and 1 `indirect`.
The cause is a false positive in the **inventory**, not in the extents walk. `sf3_boundaries` grades
`jal` by decoding every word in the payload as an instruction; in a data region a word that happens to
have opcode `3` is graded as a call, and its "target" is an address inside the zero band. The walk then
starts at that address, decodes zeros (which are `nop`s), continues linearly, and reports a
`fallthrough` extent ending at the next such candidate. Two such extents of equal length therefore
contain identical all-zero bytes and group together.
Consequences, recorded rather than fixed in place:
- The inventory's `jal` grade is **not** sufficient on its own in a data region. This is a known limit
of Phase 6's grade definition (`docs/PHASE6_BOUNDARIES.md` §Grades) and is now demonstrated with
addresses, not asserted.
- The census flags these groups (`flags=zero`) instead of hiding them, so the finding stays visible.
- The worklist (P7-T4) must exclude candidates whose body is degenerate; a zero body is not code.
## Limits
- A duplicate group is **evidence of a shared body**, not a claim that every member is a real function
start. A shared *tail* reached by several functions is not a duplicate function, and a member still
has to be verified through the ordinary workflow.
- The census inherits the extents' limits: a group built on `fallthrough`/`indirect`/`escape` extents
is only as reliable as those bounds. In practice every real group here is all-`exact`.
- Two different functions that happen to compile to identical bytes are indistinguishable from a
shared body by this method. That is the correct behaviour for the registry (one source, N rows), but
it is a statement about bytes, not about intent.
- `--min-size` exists because very short bodies collide for boring reasons; the default is 1, so the
tracked census is unfiltered.
## Regenerate
```bash
make dupes
# or, explicitly:
./tools/sf3_dupes census --exe 'extracted/SCUS_946.40;1' \
--extents config/function_extents.tsv --out config/duplicate_bodies.tsv --force
```
## Deviation from the plan
`phase-ends/Phase7_PLAN.md` said the census artifact would be **ignored** and only aggregate counts
tracked. It is tracked instead, with the content hash still never written. The reason: the tracked file
contains addresses, sizes and grades only — the same class of information as the already-tracked
inventory and extents tables — and P7-T4's worklist must be reproducible from tracked inputs alone.
Keeping the grouping in an ignored file would have made the worklist unreproducible for exactly the
reason the plan wanted it reproducible. The plan's intent (no ROM content committed) is preserved.
+42 -1
View File
@@ -11,7 +11,7 @@ function bodies, bringing the project past **thirty** distinct byte-identical fu
- [x] **P7-T1 — Phase control records, baseline revalidation, and open-item triage** (complete)
- [x] **P7-T2 — Evidence-graded function extents** (complete)
- [ ] **P7-T3 — Duplicate-body census**
- [x] **P7-T3 — Duplicate-body census** (complete)
- [ ] **P7-T4 — Candidate triage worklist**
- [ ] **Rules check**
- [ ] **P7-T5 — Symbol rows at scale**
@@ -115,6 +115,47 @@ of a called entry", equally true of library and game code); `standalone` (438) i
15 `escape` cases are clipped conservatively with the crossing instruction recorded but the cause not
determined per case; and nothing here promotes a candidate to a match.
## P7-T3 — Duplicate-body census (2026-09-23)
**Delivered:**
- `tools/sf3_dupes` — hashes the body of every derived extent and groups byte-identical bodies;
20 synthetic tests.
- `config/duplicate_bodies.tsv` — tracked census of multi-address groups,
`group<TAB>size<TAB>count<TAB>addresses<TAB>grades<TAB>flags`. Addresses, sizes and grades only; the
content hash is computed and never written.
- `make dupes`; `docs/PHASE7_DUPES.md`.
**Results:** 2,284 extents → 65 multi-address groups, of which **10 contain code** (24 addresses) and
55 are all-zero bodies; 2,104 singleton bodies. Restricting to `exact` extents yields the same 10
groups. `g0002` is the `0x800262E0`/`0x800262EC` pair Phase 6 found by hand — the census reproduces it.
The largest real groups are `g0061` (712 bytes, `0x8001084C`/`0x800189E8`) and `g0053` (436 bytes).
**Finding:** 252 extents have all-zero bodies, 245 of them inside the zero band
`0x80147000`–`0x80170000` (160 KB), graded `fallthrough`. Cause: the **inventory's** `jal` grade comes
from decoding every word as an instruction, so a data word with opcode `3` is graded as a call and its
"target" lands in the zero band. The walk then decodes zeros as `nop`s and reports a `fallthrough`
extent. Recorded, not hidden: the census flags those groups `flags=zero`, and the P7-T4 worklist must
exclude degenerate bodies. This demonstrates a Phase 6 grade limit with addresses rather than asserting
it.
**Verification:**
| Check | Command | Result |
|---|---|---|
| Known shared body found | `grep 0x800262E0 config/duplicate_bodies.tsv` | `g0002 12 2 0x800262E0,0x800262EC exact,exact -` |
| Synthetic suite | `python3 -m unittest discover -s tools/tests` | 20 new tests pass |
| Firewall | output columns | no body bytes and no hash written |
| Determinism | two `census` runs | identical (group labels ordered by size then first address) |
**Deviation from the plan:** the plan said the census artifact would be ignored and only aggregate
counts tracked. It is tracked instead, hash still never written: the file holds addresses, sizes and
grades only (the same class as the tracked inventory and extents tables), and P7-T4's worklist must be
reproducible from tracked inputs alone. The plan's intent — no ROM content committed — is preserved.
**Limits:** a group is evidence of a shared body, not that every member is a function start; a shared
*tail* is not a duplicate function; and the census inherits the extents' bounds.
## Notes and limits
- The four class items are the plan's P7-T2..T5 work; the single-function items are explicitly out of
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Duplicate-body census for the USA executable.
Matching conventions require checking for duplicates *before* registering a
match, because a body shared by several addresses is matched **once** and
registered once per address (`config/regions.tsv`, N rows to one source). Phase 6
did that check by hand and found one shared body; there was no way to know how
many others exist.
This tool hashes the body of every derived extent in
`config/function_extents.tsv` and groups exact duplicates.
## What is grouped
Every extent row -- any grade -- is hashed over exactly the bytes
`[address, end)` that `tools/sf3_extents` derived. Each member keeps its grade so
a consumer can prefer groups whose members are all `exact`. `--exact-only`
restricts the census to `exact` extents, and `--min-size` drops bodies shorter
than N bytes (very short bodies collide for boring reasons, e.g. `jr ra; nop`).
## What is written
A tracked, sorted TSV of **multi-address groups only**:
group<TAB>size<TAB>count<TAB>addresses<TAB>grades<TAB>flags
* `group` is a stable label (`g0001`, ...) assigned in `(size, first address)`
order, so the file is deterministic;
* `addresses` is a comma-separated list in ascending order;
* `grades` is the comma-separated grade of each address, in the same order;
* `flags` is `zero` when the shared body is nothing but zero bytes, otherwise
`-`. An all-zero "body" is not a function: it is a zero-filled region the
walk ran through, which happens when a data word decodes as a `jal` whose
target lands in the zero band. Those groups are recorded, not hidden, so the
finding stays visible and the worklist can exclude them.
The content hash is computed and **never written**: grouping is the finding, and
addresses, sizes and grades are the only ROM-derived quantities this project
tracks (the same rule `config/function_inventory.tsv` and
`config/function_extents.tsv` follow). No instruction bytes are read into the
output, and the executable is read only from the caller-supplied path.
The census is a measurement, not a match claim: two identical bodies are
evidence of shared code, and nothing more. A shared *tail* is not a duplicate
function, so a group member still has to be verified as a real function start by
the ordinary workflow.
Exit codes: 0 success, 2 usage or environment error.
"""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
import struct
import sys
from typing import Sequence
EXE_MAGIC = b"PS-X EXE"
HEADER_SIZE = 0x800
PAYLOAD_LMA = 0x800
EXTENT_GRADES = frozenset({"exact", "fallthrough", "indirect", "escape"})
HEADER_LINES = (
"# Syphon Filter 3 (USA) duplicate-body census.",
"# Columns: group<TAB>size<TAB>count<TAB>addresses<TAB>grades<TAB>flags.",
"# Multi-address groups only; addresses, sizes and grades only; no bytes.",
"# A group is evidence of a shared body, not a claim that every member is",
"# a real function start: verify a member before registering it.",
"# flags=zero means the body is all zero bytes -- a zero-filled region the",
"# walk ran through, not a function. Exclude those from matching.",
"# Regenerate: ./tools/sf3_dupes census --exe '<exe>' \\",
"# --extents config/function_extents.tsv --out config/duplicate_bodies.tsv --force",
)
class ToolError(Exception):
"""A usage or environment problem; maps to exit code 2."""
def parse_hex(text: str, label: str) -> int:
try:
return int(text, 16)
except ValueError as exc:
raise ToolError(f"{label}: not a hex address: {text!r}") from exc
def require_file(path: Path, label: str) -> Path:
if not path.is_file():
raise ToolError(f"{label} is not a regular file: {path}")
return path
def resolve_output(path: Path, force: bool) -> Path:
if path.exists() or path.is_symlink():
if not force:
raise ToolError(f"output already exists (use --force to overwrite): {path}")
if not path.is_file() or path.is_symlink():
raise ToolError(f"output is not a regular file: {path}")
return path
def parse_psx_exe(header: bytes) -> tuple[int, int, int]:
if len(header) < HEADER_SIZE:
raise ToolError("executable is smaller than a PS-X EXE header")
if header[:8] != EXE_MAGIC:
raise ToolError("executable does not carry the PS-X EXE magic")
entry, _gp, text_address, text_size = struct.unpack_from("<IIII", header, 0x10)
if text_size == 0:
raise ToolError("PS-X EXE header declares an empty payload")
return entry, text_address, text_size
def load_extents(path: Path) -> list[tuple[int, int, str]]:
"""Read a generated extents table; keep rows that carry an extent."""
rows: list[tuple[int, int, str]] = []
for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw.split("#", 1)[0].strip()
if not line:
continue
fields = line.split("\t")
if len(fields) != 7:
raise ToolError(f"extents line {number}: expected seven fields")
address = parse_hex(fields[0], f"extents line {number}")
grade = fields[5]
if fields[1] == "-":
continue
end = parse_hex(fields[1], f"extents line {number}")
if end <= address:
raise ToolError(f"extents line {number}: end is not after address")
if grade not in EXTENT_GRADES:
raise ToolError(f"extents line {number}: {grade!r} carries no extent")
rows.append((address, end, grade))
if not rows:
raise ToolError("extents table contains no rows with an extent")
return rows
class Group:
"""One set of addresses whose bodies are byte-identical."""
__slots__ = ("size", "members", "all_zero")
def __init__(self, size: int, all_zero: bool) -> None:
self.size = size
self.members: list[tuple[int, str]] = []
self.all_zero = all_zero
def census(payload: bytes, text_address: int, rows: Sequence[tuple[int, int, str]],
min_size: int, exact_only: bool) -> tuple[list[Group], int]:
"""Group rows by (size, body bytes). Returns the groups and the singleton count."""
buckets: dict[tuple[int, bytes], Group] = {}
for address, end, grade in rows:
if exact_only and grade != "exact":
continue
size = end - address
if size < min_size:
continue
start = address - text_address
body = payload[start:start + size]
if len(body) != size:
raise ToolError(f"extent 0x{address:08X}..0x{end:08X} is outside the payload")
key = (size, hashlib.sha1(body).digest())
group = buckets.get(key)
if group is None:
group = buckets[key] = Group(size, not any(body))
group.members.append((address, grade))
groups = [group for group in buckets.values() if len(group.members) > 1]
singletons = sum(1 for group in buckets.values() if len(group.members) == 1)
for group in groups:
group.members.sort()
groups.sort(key=lambda group: (group.size, group.members[0][0]))
return groups, singletons
def format_census(groups: Sequence[Group]) -> str:
lines = list(HEADER_LINES)
for index, group in enumerate(groups, 1):
addresses = ",".join(f"0x{address:08X}" for address, _grade in group.members)
grades = ",".join(grade for _address, grade in group.members)
flags = "zero" if group.all_zero else "-"
lines.append("\t".join((
f"g{index:04d}", str(group.size), str(len(group.members)), addresses, grades, flags,
)))
return "\n".join(lines) + "\n"
def command_census(args: argparse.Namespace) -> int:
exe_path = require_file(args.exe, "executable")
extents_path = require_file(args.extents, "function extents")
out = resolve_output(args.out, args.force)
if args.min_size < 0:
raise ToolError("--min-size cannot be negative")
data = exe_path.read_bytes()
_entry, text_address, text_size = parse_psx_exe(data)
payload = data[PAYLOAD_LMA:PAYLOAD_LMA + text_size]
rows = load_extents(extents_path)
groups, singletons = census(payload, text_address, rows, args.min_size, args.exact_only)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(format_census(groups), encoding="ascii")
addresses = sum(len(group.members) for group in groups)
duplicated = sum(group.size * (len(group.members) - 1) for group in groups)
zero_groups = sum(1 for group in groups if group.all_zero)
print(f"extents={len(rows)}")
print(f"groups={len(groups)}")
print(f"groups_zero_body={zero_groups}")
print(f"groups_with_code={len(groups) - zero_groups}")
print(f"addresses_in_groups={addresses}")
print(f"singletons={singletons}")
print(f"duplicated_bytes={duplicated}")
if groups:
print(f"largest_group={max(len(group.members) for group in groups)}")
print(f"output={out}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest="command", required=True)
census_parser = subparsers.add_parser("census", help="group byte-identical bodies")
census_parser.add_argument("--exe", required=True, type=Path)
census_parser.add_argument("--extents", required=True, type=Path,
help="derived function extents (tools/sf3_extents output)")
census_parser.add_argument("--out", required=True, type=Path)
census_parser.add_argument("--force", action="store_true",
help="overwrite an existing output file")
census_parser.add_argument("--min-size", type=int, default=1, dest="min_size",
help="ignore bodies shorter than N bytes (default 1)")
census_parser.add_argument("--exact-only", action="store_true",
help="census only extents graded exact")
census_parser.set_defaults(handler=command_census)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.handler(args)
except ToolError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except OSError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+262
View File
@@ -0,0 +1,262 @@
"""Synthetic-only tests for the duplicate-body census tool.
Fixtures are self-authored bytes in temporary directories. They never read the
local disc or the extracted game executable.
"""
from __future__ import annotations
import contextlib
import importlib.machinery
import importlib.util
import io
from pathlib import Path
import struct
import sys
import tempfile
import unittest
TOOL_PATH = Path(__file__).resolve().parents[1] / "sf3_dupes"
def _load_tool() -> object:
loader = importlib.machinery.SourceFileLoader("sf3_dupes_under_test", str(TOOL_PATH))
spec = importlib.util.spec_from_loader(loader.name, loader)
if spec is None:
raise RuntimeError("could not create an import specification")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
return module
sf3_dupes = _load_tool()
PAYLOAD = 0x80010000
# Payload-relative layout of the fixture.
SHARED_A = 0x00 # 8 bytes of code, duplicated at SHARED_B
SHARED_B = 0x10
UNIQUE = 0x20 # 12 bytes, no twin
ZERO_A = 0x30 # 16 zero bytes, duplicated at ZERO_B
ZERO_B = 0x40
def _payload() -> bytes:
words = [0] * (0x50 // 4)
words[SHARED_A // 4] = 0x03E00008 # jr ra
words[SHARED_B // 4] = 0x03E00008 # jr ra
words[UNIQUE // 4] = 0x24020001
words[UNIQUE // 4 + 1] = 0x24030002
words[UNIQUE // 4 + 2] = 0x03E00008
return b"".join(struct.pack("<I", word) for word in words)
def _synthetic_exe(payload: bytes, entry: int) -> bytes:
header = bytearray(0x800)
header[:8] = b"PS-X EXE"
struct.pack_into("<IIII", header, 0x10, entry, 0, PAYLOAD, len(payload))
return bytes(header) + payload
def _extents_text() -> str:
lines = [
"# synthetic extents",
"# Columns: address<TAB>end<TAB>size<TAB>next<TAB>gap<TAB>grade<TAB>evidence.",
]
def row(offset: int, size: int, grade: str, evidence: str) -> str:
start = PAYLOAD + offset
end = start + size
return f"0x{start:08X}\t0x{end:08X}\t{size}\t-\t-\t{grade}\t{evidence}"
lines.append(row(SHARED_A, 8, "exact", "term=jr_ra"))
lines.append(row(SHARED_B, 8, "exact", "term=jr_ra"))
lines.append(row(UNIQUE, 12, "exact", "term=jr_ra"))
lines.append(row(ZERO_A, 16, "fallthrough", "hit=0x80010040"))
lines.append(row(ZERO_B, 16, "fallthrough", "hit=0x80010050"))
lines.append(f"0x{PAYLOAD + 0x60:08X}\t-\t-\t-\t-\tcontained\tinside=0x80010000")
return "\n".join(lines) + "\n"
def _rows() -> list[tuple[int, int, str]]:
return [
(PAYLOAD + SHARED_A, PAYLOAD + SHARED_A + 8, "exact"),
(PAYLOAD + SHARED_B, PAYLOAD + SHARED_B + 8, "exact"),
(PAYLOAD + UNIQUE, PAYLOAD + UNIQUE + 12, "exact"),
(PAYLOAD + ZERO_A, PAYLOAD + ZERO_A + 16, "fallthrough"),
(PAYLOAD + ZERO_B, PAYLOAD + ZERO_B + 16, "fallthrough"),
]
class ParseTests(unittest.TestCase):
def test_rejects_missing_magic(self) -> None:
with self.assertRaises(sf3_dupes.ToolError):
sf3_dupes.parse_psx_exe(bytes(0x800))
def test_rejects_empty_payload(self) -> None:
header = bytearray(0x800)
header[:8] = b"PS-X EXE"
with self.assertRaises(sf3_dupes.ToolError):
sf3_dupes.parse_psx_exe(bytes(header))
class ExtentLoadingTests(unittest.TestCase):
def test_skips_rows_without_an_extent(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "extents.tsv"
path.write_text(_extents_text(), encoding="ascii")
rows = sf3_dupes.load_extents(path)
self.assertEqual(len(rows), 5)
def test_rejects_a_row_whose_grade_carries_no_extent(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "extents.tsv"
path.write_text(
f"0x{PAYLOAD:08X}\t0x{PAYLOAD + 8:08X}\t8\t-\t-\tcontained\tinside=0x1\n",
encoding="ascii")
with self.assertRaises(sf3_dupes.ToolError):
sf3_dupes.load_extents(path)
def test_rejects_an_empty_extents_table(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "extents.tsv"
path.write_text("# nothing\n", encoding="ascii")
with self.assertRaises(sf3_dupes.ToolError):
sf3_dupes.load_extents(path)
class CensusTests(unittest.TestCase):
def setUp(self) -> None:
self.payload = _payload()
def _groups(self, **kwargs: object) -> list[object]:
options = {"min_size": 1, "exact_only": False}
options.update(kwargs)
groups, _singletons = sf3_dupes.census(
self.payload, PAYLOAD, _rows(), options["min_size"], options["exact_only"])
return groups
def test_identical_bodies_are_grouped(self) -> None:
groups = self._groups()
addresses = [[address for address, _grade in group.members] for group in groups]
self.assertIn([PAYLOAD + SHARED_A, PAYLOAD + SHARED_B], addresses)
self.assertIn([PAYLOAD + ZERO_A, PAYLOAD + ZERO_B], addresses)
def test_unique_bodies_are_not_grouped(self) -> None:
groups = self._groups()
for group in groups:
self.assertNotIn(PAYLOAD + UNIQUE, [address for address, _g in group.members])
def test_a_zero_body_is_flagged(self) -> None:
groups = {group.members[0][0]: group for group in self._groups()}
self.assertTrue(groups[PAYLOAD + ZERO_A].all_zero)
self.assertFalse(groups[PAYLOAD + SHARED_A].all_zero)
def test_min_size_drops_short_bodies(self) -> None:
groups = self._groups(min_size=10)
sizes = {group.size for group in groups}
self.assertEqual(sizes, {16})
def test_exact_only_drops_other_grades(self) -> None:
groups = self._groups(exact_only=True)
self.assertEqual([group.size for group in groups], [8])
for group in groups:
for _address, grade in group.members:
self.assertEqual(grade, "exact")
def test_singletons_are_counted_not_listed(self) -> None:
groups, singletons = sf3_dupes.census(self.payload, PAYLOAD, _rows(), 1, False)
self.assertEqual(singletons, 1)
self.assertEqual(len(groups), 2)
def test_groups_are_ordered_by_size_then_address(self) -> None:
groups = self._groups()
keys = [(group.size, group.members[0][0]) for group in groups]
self.assertEqual(keys, sorted(keys))
def test_an_extent_outside_the_payload_is_rejected(self) -> None:
rows = [(PAYLOAD + 0x1000, PAYLOAD + 0x1008, "exact")]
with self.assertRaises(sf3_dupes.ToolError):
sf3_dupes.census(self.payload, PAYLOAD, rows, 1, False)
class FormatTests(unittest.TestCase):
def test_header_and_column_count(self) -> None:
groups, _ = sf3_dupes.census(_payload(), PAYLOAD, _rows(), 1, False)
text = sf3_dupes.format_census(groups)
body = [line for line in text.splitlines() if not line.startswith("#")]
self.assertTrue(body)
for line in body:
self.assertEqual(len(line.split("\t")), 6)
def test_group_labels_are_stable_and_sorted(self) -> None:
groups, _ = sf3_dupes.census(_payload(), PAYLOAD, _rows(), 1, False)
labels = [line.split("\t")[0] for line in sf3_dupes.format_census(groups).splitlines()
if not line.startswith("#")]
self.assertEqual(labels, ["g0001", "g0002"])
def test_no_body_bytes_are_written(self) -> None:
groups, _ = sf3_dupes.census(_payload(), PAYLOAD, _rows(), 1, False)
text = sf3_dupes.format_census(groups)
self.assertNotIn("03e00008", text.lower())
self.assertNotIn("24020001", text.lower())
class MainTests(unittest.TestCase):
def _write_inputs(self, root: Path) -> tuple[Path, Path]:
exe = root / "synthetic.exe"
exe.write_bytes(_synthetic_exe(_payload(), PAYLOAD))
extents = root / "extents.tsv"
extents.write_text(_extents_text(), encoding="ascii")
return exe, extents
def test_census_writes_the_table(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
exe, extents = self._write_inputs(root)
out = root / "dupes.tsv"
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
rc = sf3_dupes.main(["census", "--exe", str(exe), "--extents", str(extents),
"--out", str(out)])
self.assertEqual(rc, 0)
text = out.read_text(encoding="ascii")
self.assertIn(f"0x{PAYLOAD + SHARED_A:08X},0x{PAYLOAD + SHARED_B:08X}", text)
self.assertIn("groups=2", stdout.getvalue())
self.assertIn("groups_zero_body=1", stdout.getvalue())
def test_census_refuses_an_existing_output(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
exe, extents = self._write_inputs(root)
out = root / "dupes.tsv"
out.write_text("", encoding="ascii")
rc = sf3_dupes.main(["census", "--exe", str(exe), "--extents", str(extents),
"--out", str(out)])
self.assertEqual(rc, 2)
def test_census_force_overwrites(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
exe, extents = self._write_inputs(root)
out = root / "dupes.tsv"
out.write_text("stale\n", encoding="ascii")
with contextlib.redirect_stdout(io.StringIO()):
rc = sf3_dupes.main(["census", "--exe", str(exe), "--extents", str(extents),
"--out", str(out), "--force"])
self.assertEqual(rc, 0)
self.assertNotIn("stale", out.read_text(encoding="ascii"))
def test_rejects_a_negative_min_size(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
exe, extents = self._write_inputs(root)
rc = sf3_dupes.main(["census", "--exe", str(exe), "--extents", str(extents),
"--out", str(root / "dupes.tsv"), "--min-size", "-1"])
self.assertEqual(rc, 2)
if __name__ == "__main__":
unittest.main()