re: the 42 hash-keyed nameless fields are an ISL script link map — names not cracked

All 42 live in six byte-identical copies of <lang>\script\ID.tbl inside
GP_READY_ROOM.pak, the artefact of the pass the executable announces as
"PrepareScript - isl script prescanning start". Two records, FILE and
OFFSET, carry the same 42 keys: symbol hash -> (.isb file, offset).

Confirmed independently, not inferred: 41 of the 42 keys occur verbatim as
little-endian words inside the .isb bytecode, and where they occur is a
coherent call graph -- main.isb calls the 30 stage/challenge/tutorial
symbols, those call the five helpers in function.isb, the tutorials call the
six in function_tutorial.isb, and main.isb's own symbol is called by nothing.
Ordinals do not appear as call targets in code; hashes do.

30 of the 42 names have their last characters PINNED by the hash's own
algebra. Bumping the character k places from the end moves the low 24 bits
by 256^k mod M and the top byte by 1, and the measured deltas are exactly
+0x01000001 across stage01..09, stage10..16 and challenge01..06, and
+0x01010000 across tutorial0101..0601. So those names end in the same digits
as their .isb filename. The prefixes are not recovered.

NULL RESULT on the names, and quantified rather than asserted. Seven attacks
(1.2M disc + executable strings, printf substitution, a hand guess list,
two-token composition over a mined vocabulary at S = 3.5e8, exhaustive
meet-in-the-middle preimage search) produced zero hits above the noise floor.
The search is not broken -- fed tag_hash("Stage01") it returns "Stage01" plus
three collisions, as 63^6 / 2^32 predicts. It returns nothing for the real
targets at <=6 characters, so the prefixes are longer, and at 7 characters
one target already has 1 176 measured preimages. A 24-bit modulus cannot
name an 8+ character identifier uniquely; only a corpus holding the actual
string can, and the disc does not hold it.

Refutations kept rather than deleted: the filename-stem hypothesis (direct),
the shared-prefix-plus-stem hypothesis (algebraic -- A's byte sum is pinned
twice and the two values disagree, 9 pairs, 9 contradictions), names inside
the .isb payloads (there are no ASCII names there at all), the hash constants
sitting in the executable (absent in both endiannesses), and IXUD holding
more of the same (8 unnamed tags, all ordinals 0..7).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
This commit is contained in:
Sylpheed RE agent
2026-08-26 03:36:40 +00:00
parent 25412ad570
commit ab5a86fb9f

View File

@@ -0,0 +1,253 @@
# IDXD field keys that carry no name
- **Confidence:** ✅ the census and the identification of the hash-keyed set ·
✅ the last characters of 30 of the 42 names · ❌ **no name was recovered**
- **Tool:** `tools/re-capture/idxd_unnamed_keys.py` (`census` / `idtbl` / `crack` / `selftest`)
- **Data:** [`docs/re/data/idxd-unnamed-field-keys.txt`](../data/idxd-unnamed-field-keys.txt)
- **Seen in:** every `dat/*.pak` + `hidden/*.pak` on the disc; the interesting 42
are in `dat/GP_READY_ROOM.pak`, file `<lang>\script\ID.tbl`
- **Depends on:** the IDXD record/field table and `tag_hash`, which live on branch
`auto/re-idxd-container`**not on `main`**, so this note transcribes what it
needs and the tool carries its own copy of `tag_hash` rather than importing one.
An IDXD field entry is `(key, name_off, value_off)`. Usually `name_off` points at
the field's own name in the string pool and `key == tag_hash(name)`. When
`name_off` is `0xFFFFFFFF` the pool holds no name and the reader has to know what
`key` means from somewhere else. This note answers: how many such keys are there,
what are they, and can the names be inverted out of the hash?
**Short answer: 7 094 distinct keys, of which 7 052 are not hashes at all and 42
are. None of the 42 names was recovered — but they are now identified, their call
graph is reconstructed, and 30 of them have their last two-to-four characters
pinned algebraically.**
## ✅ The census
Measured by walking all 33 `dat/*.pak` plus `hidden/DefTables.pak` and
`hidden/MiscBin.pak` (`.pNN` segments joined, `Z1` payloads inflated):
| | |
|---|---|
| IDXD objects / records / field entries | 7 750 / 190 782 / **2 757 039** |
| named field entries | 1 271 462 |
| unnamed field entries (`name_off == 0xFFFFFFFF`) | 1 485 577 |
| distinct keys named *somewhere* on disc | 12 174 |
| **distinct keys never named anywhere** | **7 094** |
| … below `0x00010000` | 7 052 |
| … in `[0x10000, 0x01000000)` | **0** |
| … at or above `0x01000000` | **42** |
Parse failures: 0. `tag_hash` reproduces the key of **1 271 462 / 1 271 462**
named field entries disc-wide (`idxd_unnamed_keys.py selftest`), which is the
validation gate for everything below — far past the 200-pair bar this
investigation was asked to clear.
### ✅ "504 hash-keyed fields disc-wide" is 504 **entries**, not 504 keys
`INDEX.md` (on `auto/re-idxd-container`) records *504 fields disc-wide that are
hash-keyed with no name*. That number is exactly reproduced here — and it counts
**field entries**, not distinct keys:
```
42 distinct keys x 6 language copies of the same object x 2 records = 504
```
The three unnamed populations add up:
`504` (hash-keyed) `+ 1 380 324` (ordinal) `+ 104 749` (key `0x00000000`, i.e.
ordinal 0, held apart only because `tag_hash("") == 0` makes it look "named"
somewhere) `= 1 485 577`. So the brute-force target was never 504 names; it was
**42**.
### ✅ The gap at `[0x10000, 0x01000000)` is what separates hashes from ordinals
`tag_hash` puts the byte-sum checksum of the name in the **top byte**. A real
hash therefore falls below `0x01000000` only when the name's byte sum is `0 mod
256` — about 1 name in 256 — *and* its low 24 bits are also tiny. Of the 12 174
keys that do carry a name, 75 have a zero top byte, and the smallest of those is
`0x0002677C`; the never-named ordinal band tops out at `0x2198`. There is a clean
three-order-of-magnitude gap and **not one key in it**. The split is measured,
not assumed.
## ✅ The 7 052 ordinals are not hashes and never were
They are author-assigned element ids written straight into the tag slot. For
1 404 924 of the 1 485 577 unnamed entries the key **equals the field's index
within its record**; a further 42 750 are index + 1. The rest are hand-numbered
with deliberate gaps, e.g. the hangar `WEAPONS` record runs
`…0x30 0x31 … 0x35`, jumps to `0x50 … 0x55`, then `0x61` — an id space, not a
counter. `tables.pak`'s `FILES` record numbers 5 135 resources from `0x3E9`
(1001) upwards.
**Do not feed these to a reverse lookup.** Confirmed by the strongest possible
test: `tag_hash("BGM_001") = 0xC662435B`, while the key of the field whose value
is `BGM_001.slb` is `0x000003E9`. Zero of 5 135 `FILES` keys match a hash of
their own value under any stem/case/extension variant.
Full per-key listing: Part 3 of the data file. Per-schema value profiles: Part 2.
## ✅ What the 42 hash-shaped keys are
All 42 live in **six byte-identical copies** of one object — `eng jpn fra deu ita
esp \script\ID.tbl` inside `dat/GP_READY_ROOM.pak` (resolved through the IPFB TOC
by `name_hash("eng\\script\\ID.tbl") = 0xC40BC120`, and pointed at by the
`SCRIPT_PATH` field of `tables.pak`'s `BASE_INFO`, whose value is
`dat\GP_READY_ROOM.pak+eng\script\`).
The object has exactly two records, `FILE` and `OFFSET`, carrying **the same 42
keys in the same order** — it is a column store:
```
key(tag_hash of an ISL script symbol) -> FILE = the .isb it is defined in
OFFSET = where in that file
```
It is the artefact of the pass the executable announces as
`silph::GamePart_ReadyRoom::Impl::PrepareScript - isl script prescanning start.`
— a link map from symbol hash to definition site. The 34 `.isb` files it names
are all present in the same pak (`eng\script\stage01.isb` etc.).
### ✅ Independent confirmation: the keys appear in the bytecode
41 of the 42 keys occur verbatim as **little-endian 32-bit words** inside the
`.isb` bytecode, and where they occur reconstructs a coherent call graph
(Part 4 of the data file):
| defined in | referenced from |
|---|---|
| `stage01..16.isb`, `challenge01..06.isb`, `tutorial0N01.isb` (30 symbols) | `main.isb` only |
| `function.isb` (5 symbols) | the stage and challenge scripts |
| `function_tutorial.isb` (6 symbols) | the six tutorial scripts |
| `main.isb` (1 symbol) | **nothing** — the root entry point |
That is exactly the shape a launcher + shared-helper-library arrangement should
have, and it is the reason these keys can be called hashes with confidence rather
than "large ordinals": ordinals are not stored as call targets in code.
🟡 `OFFSET` is **base 36**. Every value uses only `[0-9a-z]`, and read as base 36
they are small ascending numbers per file: `function.isb` → 1, 9, 19, 35, 58;
`function_tutorial.isb` → 1, 12, 23, 34, 45, 50; `tutorial0101.isb` → 1, 61, 191.
The first symbol of every file is 1. Whether the unit is a statement index, a
line, or a word offset is ❔ — not settled here.
## ✅ 30 of the 42 names end in the digits of their own filename
This is *forced* by the hash, not guessed. `tag_hash`'s low 24 bits are a
base-256 polynomial mod `0x00FFFFDF`, so bumping the character `k` places from
the end changes the low bits by `256^k mod M` (1, `0x100`, `0x10000`, `0x21`, …)
and the top byte by 1. Measured:
| family | observed key deltas | forced conclusion |
|---|---|---|
| `stage01..09` | `+0x01000001` × 8 | last character increments by 1 |
| `stage10..16` | `+0x01000001` × 6 | ditto |
| `stage09 → stage10` | `0x8FA349EF → 0x87A34AE6` | low bits `+0xF7 = 0x100 9`, top `8`: the last two characters go `"09" → "10"` |
| `challenge01..06` | `+0x01000001` × 5 | last character increments by 1 |
| `tutorial0101 → 0201 … 0601` | `+0x01010000` × 5 | the character **two** from the end increments |
| `tutorial0101`'s two siblings | `+0x01000001`, `+0x02000002` | last character `+1`, `+2` |
So the sixteen stage symbols end `"01" … "16"`, the six challenge symbols end
`"01" … "06"`, and the eight tutorial symbols end `"0101" "0102" "0103" "0201"
"0301" "0401" "0501" "0601"` — matching their `.isb` filenames exactly. The
remaining 12 (`function.isb` × 5, `function_tutorial.isb` × 6, `main.isb` × 1) are
unconstrained.
That is the whole of the recovered name information. **The prefixes are not
recovered.**
## ❌ Cracking: a null result, and the arithmetic that says why
`tag_hash` maps onto `256 × 0x00FFFFDF ≈ 2^32` values, so a search over `S`
candidates yields on average `S / 2^32` **false** preimages *per target*. That is
the number every attempt below is judged against.
| attack | candidates (space `S`) | E[false] per target | hits |
|---|---|---|---|
| every distinct disc string (IDXD names, record names, field values) | 105 393 | 0.000025 | **0** |
| every string in the executable (`.pe`) | 75 912 | 0.000018 | **0** |
| every ASCII run ≥3 in every pak payload except `sound.pak` (incl. `.ssb` symbol tables, XML, XPR) | 1 023 671 | 0.00024 | **0** |
| all three combined, hashed against all 42 | 1 204 976 | 0.00028 | **0** |
| `printf`-style substitution: 49 139 format strings × 80 numeric substitutions | 161 200 | 0.00004 | **0** |
| hand-built guess list (`main`/`Stage`/`Mission`/`ReadyRoom`/… × prefixes × decorations × numbers) | 506 520 | 0.00012 | **0** |
| two-token composition over a vocabulary mined from disc + exe identifiers (`t1 + sep + t2`, sep ∈ `"" _ -`) — this is what `idxd_unnamed_keys.py crack --pe …` runs | 3.48 × 10⁸ | 0.081 | **0** |
| the same, with each family's forced digit suffix appended | 1.18 × 10¹⁰ across the sweep | 2.8 total | 1 (`"Cj-format 01"` — noise, and the same string for all six challenge keys, i.e. **one** false prefix) |
| meet-in-the-middle exhaustive preimage, `[A-Za-z0-9_]`, ≤ 6 chars before the known suffix | 6.3 × 10¹⁰ | ~15 | noise only |
The last row is the important one. Exhaustive search **succeeds** — it is not
that the search is broken. Fed `tag_hash("Stage01")` it returns
`['F4D_kl01', 'Stage01', 'be_zT01', 'cd_Yu01']`: the right answer plus three
collisions, which is what `63^5 / 2^32 ≈ 0.2``63^6 / 2^32 ≈ 15` predicts. Fed
the real target `0x87A349E7` with suffix `"01"` it returns **nothing**, so the
prefix is **longer than 6 characters** over that alphabet.
And that is where it stops being useful. Measured, not extrapolated: at 7
characters target `0xA40C6DF7` has **1 176** preimages (`'1T1E1B6'`,
`'24A7O0G'`, `'4quqkkC'`, …). At 8 the expectation is ≈ 58 000. A hash with a
24-bit modulus simply does not have enough entropy to name an 8+ character
identifier uniquely, so **no amount of compute recovers these names from the hash
alone** — only a corpus containing the actual string can, and the disc does not
contain it.
### 🔴 Refuted along the way (kept, not deleted)
* **"The symbol is the filename stem, in some case/decoration."** Refuted
directly: `tag_hash` of `stage01`, `Stage01`, `STAGE01`, `stage_01`,
`mission01`, and 30-odd relatives is not `0x87A349E7`.
* **"The symbol is `<common prefix> + <filename stem> + <number>`."** Refuted
*algebraically*, without searching. If `name = A + "stage01"` and
`name = A + "challenge01"` share one `A`, then `A`'s byte sum is pinned twice
and the two values must agree. They do not, for lowercase, Capitalised or
UPPER stems, with or without a `_`/`-` separator — 9 pairs tested, 9
contradictions.
* **"The name is somewhere in the `.isb` files."** Refuted: the `.isb` payloads
contain no ASCII symbol names at all. They reference symbols purely by hash —
which is *why* the prescan link map exists.
* **"The hash constants might be in the executable next to a name table."**
Refuted: none of the 42 values occurs in the `.pe` image in either endianness.
* **"IXUD might hold more hash-keyed nameless fields."** Refuted by measurement:
534 IXUD objects, 624 488 field entries, **8** distinct unnamed tags — all of
them ordinals `0…7` (the weapon `CATEGORY_DESC` list). Zero hash-shaped.
* **⚠️ "There are ~504 keys to crack."** Withdrawn — 504 is the *entry* count.
See above.
## Coverage / limits
- The census covers every IDXD object the disc has (7 750). `sound.pak` holds no
IDXD objects and `dat/movie/*.wmv` and `hidden/resource3d/*.xpr` are not
containers; strings from `resource3d` were nevertheless folded into the corpus
attack.
- The 42 targets are fully characterised as *references*; their *spelling* is
unrecovered and, per the arithmetic above, unrecoverable from the disc.
## Evidence log (append-only; newest last)
- `2026-08-26` — Disc-wide walk of 7 750 IDXD objects / 2 757 039 field entries
with `tag_hash` self-check at 1 271 462/1 271 462. Census → confidence
`CONFIRMED` for the counts and for the ordinal/hash split.
- `2026-08-26` — 42 hash-shaped keys localised to six copies of
`<lang>\script\ID.tbl`; cross-checked against the `.isb` bytecode, where 41 of
42 appear as little-endian call targets forming a consistent call graph →
`CONFIRMED` that they are ISL script-symbol hashes.
- `2026-08-26` — Key-delta arithmetic pins the trailing digits of 30 of the 42
names → `CONFIRMED` (forced by the hash's polynomial structure, three
independent families agreeing).
- `2026-08-26` — Seven corpus/composition/brute-force attacks, none producing a
hit above the noise floor; exhaustive search validated on a known name and
shown to return nothing at ≤ 6 characters → the names are **not recovered**,
and the negative result is quantified rather than asserted.
## Open questions / what would raise confidence
- **The 12 unconstrained names** (`function.isb` ×5, `function_tutorial.isb` ×6,
`main.isb` ×1) have no digit structure to exploit at all.
- **What would actually work:** a build artefact containing ISL source or a symbol
list (none on the retail disc); or observing the game *construct* one of these
strings at runtime — `PrepareScript` builds the table from `.isb` contents, but
whatever `main.isb`'s root symbol is called, the name existed only in the
compiler's input. A `--mem-watch` on the ReadyRoom lookup path would show
whether any caller ever passes a literal name rather than a precomputed hash;
that is the one remaining honest route and it was **not** attempted here (this
was a static-only investigation).
- **`OFFSET`'s unit** — base-36 is settled, what it counts is not.
- The `.isb` instruction encoding is only sketched here (a length byte at
`byte[2]`, call payloads leading with the target hash). It is not the `.ssb`
ISL bytecode documented elsewhere and deserves its own note.