Files
Sylpheed/docs/re/structures/idxd-container.md
Sylpheed RE agent bfd0c9c9a4 re: the Enumeration self-index is not a naming route, and the residual is two _Test variants
The tempting move - 360 objects state their own contents, so use that to name
them - does not work, and it is now measured rather than assumed:

  objects carrying an Enumeration record                       360
     key already named by archive-naming routes 1 + 2          348
     still unnamed                                              12
     newly named by name_hash(prefix + own record name + ext)    0

Sixteen prefixes x seven extensions x every record name of every unnamed
object: zero gained. A self-index names an object's RECORDS; a TOC key is
name_hash(path + file name). The two vocabularies never meet.

The residual is exactly two objects x six packs, each byte-identical across the
packs and each an unnamed VARIANT of a named _Test table, not a duplicate:

  2390212806 (1493 B)  Placement_00..02 + RouteTest_00..01 - a trimmed
                       Stage\Null_Test.tbl (3121167452, 27545 B, same five
                       records plus four Frame_Test_Asteroid_*)
  2719765792 (14124 B) Formation_100, Formation_Fleet_01, Formation_Fleet_02 -
                       the same three record names as Formation_Test.tbl
                       (3463590559, 14025 B) but a different payload

That answers the previous iteration's question about the six-record
Placement/RouteTest sibling: it is Null_Test.tbl minus the asteroid frames.

Independent confirmation for stage-mission-tables: name_hash(prefix +
"Formation_Test.tbl") -> key 3463590559, whose records are exactly those three,
so the two frame-ratio exceptions do live in the file it names.

Dangling link recorded, not fixed: idxd-container.md references
idxd-unnamed-keys.md twice and that file does not exist on this branch - it
belongs to auto/idxd-unnamed-keys.

One control missed unexplained: EnumSquadron_Test.tbl matches no pak key under
any of the sixteen prefixes. n=1, not chased.

All fifteen artefacts byte-identical.
2026-08-28 02:57:20 +00:00

12 KiB
Raw Blame History

The IDXD container — record/field table

Status: CONFIRMED, decoded in full and verified over the whole disc (not just dat/hidden/DefTables.pak holds another 1425 objects, which the first version of this sweep missed): 7750 / 7750 objects parse, 190,782 / 190,782 records reproduce their stored name hash, 1,271,462 / 1,271,462 named fields reproduce their key from their stored name. Zero failures of any kind.

This closes the "binary node/index region — not yet decoded" note that stood in crates/sylpheed-formats/src/idxd.rs for the whole life of the parser, and it demotes two beliefs the corpus was built on (see Two corrections below).

Tests: crates/sylpheed-formats/tests/idxd_records_disc.rsrecords_roundtrip_disc, first_header_word_is_record0_hash, field_names_are_stored_disc, movie_table_ids_are_literal_field_keys.

The layout

All fields big-endian.

Offset   Size    Field
0x00     4       "IDXD"
0x04     4       record_count  n
0x08     16*n    records      { u32 name_hash, u32 name_off, u32 field_begin, u32 field_end }
..       4       field_count  m       (equals max(field_end))
..       12*m    fields       { u32 key, u32 name_off, u32 value_off }
..       4       pool_size            (equals file_len - pool_base)
..       ..      string pool          (every *_off above is a byte offset from here)
  • Records are sorted ascending by name_hash and the guest binary-searches them with a 16-byte stride (sub_82448AA0). Verified: sorted in every object.
  • name_hash is tag_hash of the record's own name — not the pak TOC hash (different modulus, and not lowercased).
  • A record owns the half-open field range [field_begin, field_end). Ranges are not contiguous in record order — records are in hash order while their fields sit in name order, so the field array is shared, not partitioned by position.
  • Fields are sorted ascending by key and lower-bounded (sub_8244E338).
  • field_count and pool_size are what make the layout self-checking: a wrong record stride lands on a pool_size that does not equal the remaining bytes. That identity is what caught the first wrong version of this decode.

Field names are on the disc

A field's middle word is its own name's pool offset, and then key == tag_hash(name). Field names never have to be recovered from their hash.

name_off == 0xFFFF_FFFF means the field has no name; its key is then a literal positional integer — a line-slot index (0,1,2,3), a movie id (105, 1303). So a field is positional exactly when it stores no name; you do not have to guess from the key's magnitude.

Disc-wide census of all 2,757,039 fields:

kind count
named, key == tag_hash(name) 1,271,462
unnamed, literal key (< 0x10000) 1,485,073
unnamed, hash-shaped key 504

Those 504 are the entire remaining preimage problem on the disc — but the count is easy to misread, so state it precisely: 504 is a count of field entries, not of distinct names. They are 42 distinct keys, each appearing in 12 places (six byte-identical language copies of one object × two records). The preimage target was never 504 names; it is 42.

Their names are unrecovered, and idxd-unnamed-keys.md shows why that is not a matter of more compute: the 42 belong to <lang>\script\ID.tbl, and at 7 characters one of them already has 1 176 preimages under a 24-bit modulus. 30 of the 42 have their trailing digits pinned algebraically; the prefixes cannot be recovered from the hash alone.

Two corrections

WITHDRAWN — "the word at 0x08 is a schema hash"

The corpus (and IdxdObject::schema_hash, and every pak list line printing schema 067025b9) read the third header word as an object-type id whose preimage was unknown. It is not a schema id. It is simply record 0's name_hash: the record array is uniform 16-byte entries starting at 0x08, and the header has no type field at all.

Evidence: tag_hash(records[0].name) == word@0x08 for all 7750 objects on the disc, zero exceptions (first_header_word_is_record0_hash).

How it was caught, which is the useful part: a test asserted that every movie id in BASE_INFO names a real record, and one — 1005 -> "STAGE10_PHASE01" — did not resolve. The reason was that tag_hash("STAGE10_PHASE01") is 0x067025B9, the movie table's supposed schema id. A "coincidence" at 1-in-2^32 is not a coincidence; the record was being eaten by the header.

It still works as a type discriminator, because tables of one kind share their lowest-hashed record name — which is exactly why it went unquestioned for so long. schema_hash is therefore kept under its established name, with its docs corrected, rather than renamed across 33 call sites. Nothing on disc names an object's type.

WITHDRAWN — "the field's middle word is an aux/flags word, always 0xFFFFFFFF"

It is 0xFFFFFFFF for 54% of fields, which is enough to look constant in a small sample. It is a name offset (above). The tell was that the word is constant per key across records (key=0x6c43a78d always carried 1743) — a per-record flag cannot do that, but a per-name pointer must.

Worked example — the movie table

dat/tables.pak, the object whose record 0 is STAGE10_PHASE01 (105 records, 433 fields). Its BASE_INFO record mixes both field kinds:

named:       PATH = "dat\movie\"   VERSION = "0x060329"   SUBTITLE_FONT …
positional:  105 -> "STAGE01_PHASE01"   205 -> "STAGE02_PHASE01"   1005 -> "STAGE10_PHASE01"

Each positional value names another record in the same object, which carries the real files:

STAGE02_PHASE01:  MOVIE = "RT02A.wmv"   VOICETRACK = "VOICE_RT02A"
                  SUBTITLE = "…+SUBTITLE_RT02A.tbl"   TELOP = "…+pwrt02.prt"

All 104 positional ids resolve to a real record — verified, no dangling entries. This is the id space the mission script's cutscene request uses; see movie-subtitle-link.

IXUD is the same container

The wide-string sibling has an identical shape, with three substitutions: the hash is ixud_hash, strings are UTF-16BE, and every offset — record name, field name and value alike — is in 16-bit chars, so pool_base + 2*off. pool_size is likewise a char count, which is the same identity as the already-known STR + 2*strsize == filesize (ixud-localised-text).

Verified independently over all 1104 IXUD objects on the disc: the header word at 0x08 is record 0's ixud_hash (1104/1104), and key == ixud_hash(field name) for 628,165 / 628,165 named fields, zero mismatches. Only 48 fields are unnamed — 6 objects × 8, all in a CATEGORY_DESC record with keys 0..7, a positional array of weapon-category descriptions.

One extra rule shows up in IXUD's comparator (sub_82447F38) and is worth assuming for IDXD too: name_off == 0xFFFF_FFFF is the primary sort key, so a record's field slice is unnamed fields first, then named fields, each key-ascending (1476/1476 slices). That is why there are two getters — lookup-by-integer searches only the unnamed run, lookup-by-name only the named run.

🟡 The loader sub_82448D00 also accepts legacy magics IIDX, IDX2, IDX3, IDXC and rejects IDXD on that path with "Old virsion binary table. Not supported." [sic]. None of them occur on this disc (magic census over every pak entry: IDXD 7750, T8aD 4525, RATC 2985, IXUD 1104, LSTA 64, and zero of the four legacy magics), so that path is read-only knowledge, untestable here.

What this does not settle

  • The names of the 42 unnamed hash-keyed fields (504 entries — see idxd-unnamed-keys.md; shown to be unrecoverable from the hash alone).
  • How much of the existing corpus the old string-pool reader got wrong. The legacy get_f32/get_raw path infers a field's value from pool adjacency (<value>\0<key>\0). That adjacency is a consequence of the field table, not a rule of the format, and it cannot represent a field whose value string is shared or reordered. Every number in docs/re/ that came from it is now re-checkable against the true table, and has not yet been re-checked.
  • Whether IXUD, the wide-string sibling, uses the same shape. Its offsets are in 16-bit chars and its hash is different (ixud-localised-text).
  • The type-identification question is now open rather than closed: with no schema field, an object's kind is known only from the caller that loads it.

Enumeration — a record that indexes its own object (2026-08-28)

360 of the disc's 7 750 IDXD objects carry a record named Enumeration. Its fields are all valueless ((tag, name, "")), and:

its field names == the object's other record names, EXACTLY   360 / 360
   a strict subset of them                                      0
   naming something not in the object                           0

The matching field/record counts run from 2 up past 25 — (3,3) ×36, (9,9) ×24, (13,13) ×24, (2,2) ×24, (25,25) ×18, (16,16) ×12 — and every one is on the diagonal. A field list that merely overlapped would not do that at 360/360 across that range of sizes.

So Enumeration is a table of contents for the object it lives in. The kind question above ("an object's kind is known only from the caller that loads it") gets a partial answer for these 360: the object states its own contents.

⚠️ Do not confuse it with Enumerate, one letter shorter, which archive-naming uses as the route-2 seed: an object whose single record is named Enumerate and whose fields name other objects (resolving as name_hash("<name>.tbl")). Same idea, opposite scope — self-index versus declaration table — and the spelling is the only thing that distinguishes them.

mission-wave-arrivals counted 28 Enumeration_* records while filtering non-route records out of a speed measurement; what it does not say is what they are.

The self-index is not a naming route — measured, 0 gained

Tempting next step: 360 objects state their own contents, so can that name the objects themselves? No.

objects carrying an `Enumeration` record                       360
   key already named by archive-naming routes 1 + 2            348
   still unnamed                                                12
   newly named by name_hash(prefix + own record name + ext)      0

Sixteen path prefixes × seven extensions × every record name of every unnamed object: zero. A self-index names an object's records; a TOC key is name_hash(path + file name). The two vocabularies never meet, and no amount of record names reaches a file name. A measured negative.

The residual is exactly two objects, each repeated once per language pack, and each is an unnamed variant of a named _Test table — byte-identical across all six packs, and not a duplicate of its named counterpart:

key size records its named counterpart
2390212806 1 493 B Placement_00…02, RouteTest_00…01 Stage\Null_Test.tbl = 3121167452, 27 545 B — the same five records plus four Frame_Test_Asteroid_*
2719765792 14 124 B Formation_100, Formation_Fleet_01, Formation_Fleet_02 Formation_Test.tbl = 3463590559, 14 025 B — the same three record names, a different payload

So one is a trimmed Null_Test, the other a second Formation_Test-shaped file. Neither name resolves under any of the sixteen prefixes with the eight candidate spellings tried.

This also confirms stage-mission-tables's attribution independently: name_hash(prefix + "Formation_Test.tbl") → key 3463590559, whose records are exactly Formation_100, Formation_Fleet_01, Formation_Fleet_02 — the two frame-ratio exceptions really do live in the file it names.

One control threw an unexplained miss: EnumSquadron_Test.tbl, named by the _Test stage record, matches no pak key under any of the sixteen prefixes. n = 1, not chased.