28 Commits

Author SHA1 Message Date
Sylpheed RE agent
8b6dbcfead formats: move media assembly out of the viewer, where it could not be reused
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 8m42s
CI / WASM — Web (push) Failing after 7m33s
CI / Formatting (push) Failing after 1m15s
CI / Native — windows-latest (push) Has been cancelled
CI / Native — macos-latest (push) Has been cancelled
The trickiest reading on the disc lived in the Bevy viewer: resolving a
cutscene's voice to a continuous byte REGION of the sound stream, because the
movie voices are one XMA stream chunked into VOICE_*.slb entries whose
boundaries do not match the cues -- a cue routinely spans two chunks, so a .slb
need not hold the track its name claims.

That put the logic most likely to be re-derived incorrectly in the crate least
likely to be reused. The Godot port's exporter needs the same answers, and there
must be one implementation of them.

New `sylpheed_formats::media` owns every case where the bytes of one playable
thing are not one archive entry: segment-spanning reads, multi-sub-wave banks,
and the voice-region resolution. Callers supply bytes through a `DiscSource`
trait, so the viewer keeps its ISO/directory abstraction and a headless consumer
gets `DirectorySource` for free.

The seam is deliberate: this module returns XMA RIFFs, not PCM. Decoding means
shelling out to FFmpeg, which is native-only and a policy decision for the
consumer -- everything up to "here are the bytes that belong together" is disc
knowledge, everything after it is a codec choice.

The four moved functions were previously untested; `tests/media_disc.rs` now
pins them, including the negative the corpus paid for -- an unbound movie must
stay unvoiced rather than borrow a neighbour's clip, which was tried and played
the WRONG recording.

The algorithm is unchanged, moved verbatim (same window sizes, same fallbacks).
The new disc tests pass; the broader audio suite was not re-run in this pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:49:33 +02:00
Sylpheed RE agent
69291a315f port: add Q10 -- what a music bank's sub-waves actually are
Some checks failed
CI / Native — windows-latest (push) Has been cancelled
CI / WASM — Web (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Native — macos-latest (push) Has been cancelled
CI / Native — ubuntu-latest (push) Has been cancelled
BGM_001.slb is three sub-waves (10 KB, 4.47 MB, 4.67 MB) and the decoder
concatenates them into one 347 s track. That is a default nobody chose, not a
decision: two near-equal halves could be intro + loop, two variations, or two
halves of one piece, and a menu that loops its music needs to know which.

Found while wiring the Audio Library up to the shared banks. Recorded in
HANDOFF.md as a trap too, so the port does not build looping on top of the
concatenated track before the question is answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:43:25 +02:00
Sylpheed RE agent
4734797e50 agent: stop the launcher claiming it cannot push when it can
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 8m23s
CI / WASM — Web (push) Failing after 7m9s
CI / Formatting (push) Failing after 1m12s
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
The `loose` footer announced "cannot push -- no git credentials are mounted"
unconditionally. It was written before the credential mount existed and then
went stale, so with credentials correctly mounted it told the operator their
work would be lost -- the exact failure the mount was added to prevent.

It now reports what is actually true, and keeps the real warning for the case
where the file genuinely is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:22:02 +02:00
Sylpheed RE agent
ecbd70f69a port: the container agent answers the questions, it does not build the port
Some checks failed
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
CI / WASM — Web (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Native — ubuntu-latest (push) Has been cancelled
Corrects the split I got wrong in b5a1938. The container is for reverse
engineering, now focused on the menu port; a SEPARATE agent builds the port from
its investigation results. My previous version had the container agent writing
the exporter and the Godot project, which is not the intent.

This lands on the research/engineering line that was already in the estimate:
the container agent takes the research half, the port agent the engineering half.

docs/port/MISSION.md is now a list of open QUESTIONS (Q1-Q9) rather than build
milestones, ordered by what blocks the port earliest -- the keyframe time unit,
which build is which screen state, paint order for the six screens, button ->
GamePart, navigation semantics, the boot sequence driver, transitions, menu
audio bindings, and video binding. Each is done when a written result with
evidence exists, not when something compiles. S1, the Ready Room probe, stays
gated at one iteration and a go/no-go.

Most of these are BEHAVIOUR questions -- timing, transitions, what a d-pad press
does at the end of a list -- so the mission and the loop prompt both push hard
on measuring the oracle rather than reasoning from the file.

docs/port/FORMAT.md is deleted. The export schema is the port agent's design and
was not mine to specify. It is replaced by docs/port/HANDOFF.md, the single page
the port agent reads: a status table, what is already settled and can be relied
on today, and the facts that will trip the port up (the WMV3/WMA Pro intro, the
Static.slb size over-declaration, the voice-vs-music downmix, JNGL_001).

The derived/authored idea survives as the thing it always was -- a finding, not
a design. Every answer must be classified DECODED, MEASURED or UNDECODABLE-with-
reach, and never a fourth thing, because measured and undecodable both mean the
port agent is authoring that value and has to know it. Labelling a guess as a
decode would put it into the port wearing the badge of a measurement.

Reverts the Godot install from the RE container, its AGENT.md section, and the
export/ gitignore entry -- none of that belongs on this side of the wall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:16:47 +02:00
Sylpheed RE agent
b5a193839c port: make the Godot menu shell the agent's primary objective
Sets a new mission: boot the real disc through developer splash -> intro video
-> title -> main menu -> submenus in Godot 4, interactively, with no gameplay,
no 3D and no emulator.

docs/port/MISSION.md defines it -- eight gated milestones, each finished by an
ARTIFACT rather than by compiling, plus the Ready Room as an explicitly gated
stretch goal with a one-iteration probe that decides go/no-go. GP_READY_ROOM is
1106 entries with 6 recoverable names and is ISL-scripted, so it is either a
week or a quarter, and the agent must not start it on its own authority.

Architecture, per the user's decision: the Godot project is INDEPENDENT of the
Rust viewer and never reads a disc format. An offline Rust exporter converts the
disc into open formats; Godot reads only those. No GDExtension, no Rust in the
Godot project, and sylpheed-viewer is off limits -- it stays the human's
verification tool with its static-data rule intact.

docs/port/FORMAT.md specifies the open format, versioned, because modding is the
port's second goal and that makes the layout a deliverable rather than a temp
directory: JSON over XML (Godot parses JSON natively; its XMLParser is SAX),
names never hashes, provenance in every generated file, and unknowns listed
rather than guessed.

The discipline the whole thing rests on is the derived/authored split. `export/`
is regenerated wholesale and never hand-edited; `authored/` is hand-written and
survives a re-export. Three things this milestone needs are NOT on the disc in
any decoded form -- which button does what, paint order, and menu sound cues --
so they live in `authored/` with a stated `why`. Deleting an authored entry
because the exporter can now emit it IS the measure of progress.

`export/` is gitignored: it is generated from the user's own disc and this stays
a clean-room repo.

Container: adds a pinned Godot 4 (windowed under Xvfb for screenshots, plus a
headless wrapper). ffmpeg already carries libtheora, which is the video target --
Godot 4 plays only Ogg Theora natively and the disc's ADV.wmv is WMV3/WMA Pro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:11:35 +02:00
Sylpheed RE agent
5cdff5e515 docs/re: the disc atlas -- one map of how the assets reference each other
A reference page covering the four layers (disc media, IPFB archives, container
formats, domain chains) and the three hashes that join them: name_hash for
archive TOC keys (case-insensitive), tag_hash for IDXD records and fields
(case-sensitive), ixud_hash for localised text.

Counts were measured on the retail extract for this page rather than copied
forward from the corpus -- 41 archives, 26443 entries, 166 xpr, 97 wmv -- and
where they overlap with docs/re they reproduce it independently (104 cutscene
slots / 101 movies / 99 / 99 / 22).

It gives the dangling references their own section, because they are properties
of the shipped disc and a port has to survive them: pmbase.t32, SUBTITLE_S12B,
the four boot logos, GP_TEST, the six 2D paks at exactly 0% named, and the 42
provably unrecoverable ISL symbol keys.

Also published as an artifact for reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:51:45 +02:00
Sylpheed RE agent
77cd58202b viewer: a Cutscenes browser -- the manifest was invisible plumbing
`movie_manifest` has been parsed since the movie-voice work and rendered
nowhere: it resolved a voice bank and that was all. So the only way to find a
cutscene was to hunt `.wmv` files in the ISO tree, where nothing tells you which
mission a file belongs to, whether it has subtitles, or what is said in it.

View ▸ Cutscenes lists all 104 manifest slots with mission/phase, kind, movie,
subtitle track, voice token and telop, and -- the part that needed no new
parsing, only a route -- resolves the captions to a readable TRANSCRIPT with a
language selector. Subtitles were previously burned into the video during
playback and reachable no other way.

Three negatives are shown rather than smoothed over:

* 5 manifest-bound movies have no `.wmv` (logo1-4 and an encoder test clip).
  They are marked and get no Play button instead of one that would fail.
* 9 of 101 movies resolve no English transcript.
* the `.prt` telop overlay is named by the manifest and we have no parser, so
  the reference is shown labelled "not decoded" rather than omitted.

`cutscene_catalog_binds_movies_and_transcripts` pins all of it against the disc
-- 104/101/99/99/22, the exact absent-movie list, 92 transcripts -- because a
browser that quietly dropped these would look complete and be wrong. The counts
independently reproduce docs/re/movie-subtitle-link.md.

Play routes through the normal FileSelected path, so the existing video player
handles it exactly as it would from the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:47:46 +02:00
Sylpheed RE agent
306a8a5661 viewer: open the whole sound bank, not just the voice half
The library enumerator kept only names containing VOICE or \Briefing\, and read
eng\sounds.tbl unconditionally. So the Explorer could reach 4382 of the 9519
banks in sound.pak: no music, no jingles, no sound effects, and no Japanese
voice at all -- roughly half the disc's audio had no route to the UI.

`slb::list_audio_entries` now returns every named bank with the category its
path implies (Music / Jingles / Sound effects / Radio / Dialogue / Movie voice /
Briefing). `list_voice_clips` is that, restricted to the spoken categories, so
its existing test still guards the old behaviour. The 36 root banks carry no
language component and appear whichever table is read; the window gets an
English/Japanese switch that re-reads the other sounds.tbl, since the table name
IS the selector.

Two defects the decode found, both recorded in
docs/re/structures/sound-pak-contents.md:

* `Static.slb` -- the SFX bank -- declares 616768 bytes more than sound.p04
  holds. Not our extraction: p04 matches the ISO's own directory record, and a
  sweep of every pak on the disc finds this one entry over-running and no other.
  It is the highest-offset entry, so its comp_size is an allocation size. A
  short read is now allowed for the tail entry ONLY; any other overrun stays an
  error, because clamping it would hide real damage behind a half-decoded asset.
  The bank went from unreadable to 514 s of audio.

* the left-channel downmix was applied to everything. Right for voice (mono
  content however stored), wrong for music (a real stereo mix, half of it
  discarded). The caller now decides from the category.

35 of the 36 shared banks decode; JNGL_001 does not, and says so in the player
instead of the panel silently closing. Its payload is not a whole number of XMA1
packets from any known data offset, so it is likely not a plain headerless
stream -- written up rather than papered over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:28:20 +02:00
Sylpheed RE agent
fb70511242 viewer: make the two screen presenters agree, and expose the compose options
The UI Screens window enumerated with `is_build` while the PAK browser's
inline preview composes anything `parse_build` accepts. So the browser drew
screens this window flatly refused to list -- most visibly `palogo`, the
publisher splash, which declares its sprites directly and has no `.rat`
layout child.

`compose_screen` now gates on `is_composable`, the documented superset (every
`is_build` bundle passes it), so anything the list offers is drawable and the
two presenters share one rule. A "Fragments" toggle widens the enumeration to
it as well, off by default: the extra ~1786 bundles are mostly two-element
fragments (a button beside its glow) that would bury the real screens. It
re-scans, which renumbers the display ordinal -- harmless, because the pak
ENTRY index is the locator and the stale catalog is cleared.

The toggle sets a `rescan` flag rather than self-sending RequestScreenCatalog:
a system that both reads and writes one event type is a B0002 panic at
startup, which is how the Save browser broke.

Also plumbs the last two ComposeOptions the CLI had and the UI did not --
`black backdrop` (what a framebuffer capture must be compared against) and
`primitives` (decoded, but paint order unsolved; hover text says so).

Verified: `screen render --all --build 13 GP_TITLE.pak` draws the SQUARE ENIX
splash, 2/3 elements, the third being the .prm quad. Workspace builds, viewer
reaches steady state, disc-gated suite 20/20 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:14:23 +02:00
Sylpheed RE agent
47e4e43310 viewer: stop the UI Screens browser loading forever
Three compounding causes, found by tracing every place a `loading` flag is set
against every place it is cleared.

THE COST. `compose_screen` inflated EVERY entry in the pak and held every build
in memory at once, purely to index into the result -- then did it again on every
checkbox click, re-opening the ISO from scratch each time. But the catalog
already records each build's pak ENTRY INDEX, so the locator was there all
along: compose now reads exactly one entry. `RequestScreenCompose` carries
`entry` (the locator) beside `build` (the display ordinal).

`build_screen_catalog` had no budget at all, while the pak browser has capped
exactly this work since it was written. It now skips oversized entries and stops
at a ceiling.

That ceiling is 384 MB, not the pak browser's 64 MB, and the difference is the
point: GP_HANGAR_ARSENAL inflates past 160 MB and holds ~390 builds, so a 64 MB
cap would have quietly hidden most of them -- trading a hang for a wrong answer.
When the ceiling IS hit the pak is marked `truncated` and the UI says so.

THE LATCH. `poll_loader_channel` treated a disconnected channel exactly like an
empty one, so if a worker died every in-flight spinner stayed up for the life of
the process -- and the `if loading { return }` guard at the top of each handler
then refused every retry. Disconnect now clears the flags and reports it.

Verified: the workspace builds, the viewer reaches steady state, and one
composite still draws 11/11 elements of the tutorial pause menu.
2026-08-28 15:59:00 +02:00
Sylpheed RE agent
1054d43676 docs/re: finish the INDEX merge I left unresolved
Commit 03ece95 ("Merge branch 'auto/idxd-unnamed-keys'") shipped INDEX.md with
live conflict markers. My check was worthless in a way worth naming: I ran

  git add docs/re/INDEX.md ; git status --short | grep -E '^(UU|AA)'

in that order. Staging a conflicted file is exactly what marks it resolved, so
the grep found nothing and reported "no unmerged" -- the control passed BECAUSE
of the action it was meant to gate. Both halves of the hunk then went into the
tree verbatim, leaving two competing IDXD rows.

Resolved as a union, which is what it always was:
  * HEAD's "IDXD object/table" row -- the 2026-08-25 record/index-region decode
    -- supersedes the branch's older one-line version of the same row;
  * the branch's "IDXD nameless field keys" row is new and is kept.
2026-08-28 15:55:18 +02:00
Sylpheed RE agent
e1dcc689bc agent: move the loop prompt's memory into the corpus, and let it push
The autonomous agent's loop prompt had grown to ~9 000 words of accumulated
findings, refutations and traps. That is a symptom, not a style: it was doing
the job the corpus should do, in the one place that dies with the container.
Three concrete failures followed from it, and each gets a structural fix rather
than a louder instruction.

1. IT REPEATED WORK IT HAD ALREADY DONE.
   The "do not revive" list and the method traps existed ONLY in the prompt, so
   nothing pointed a fresh iteration at them. Extracted verbatim into two
   tracked files:

     docs/re/REFUTED.md  105 claims tested and dead, grouped by subject so a
                         grep for your noun finds the neighbourhood
     docs/re/METHOD.md   the traps already paid for -- controls, inference,
                         searching, reading data, runtime

   Both are linked from INDEX.md, and the loop prompt now opens by requiring
   them to be read. This is the fix for "re-derived something already known":
   the knowledge is now where the next iteration looks, not in a context window.

2. IT FORGOT TO ARM THE NEXT WAKEUP.
   The prompt tried to solve this by shouting at itself in the first line. The
   real fix is to stop asking: `loose` now defaults to a FIXED interval (45m),
   so the harness owns the cadence and a forgotten ScheduleWakeup cannot end the
   run. SYLPH_LOOP_INTERVAL= (empty) restores self-pacing.

3. IT COULD NOT PUBLISH, SO THE WORK ONLY EXISTED IN THE CONTAINER.
   New `push-work`, plus a read-only credentials mount
   (SYLPH_GIT_CREDENTIALS, default ~/.sylph-git-credentials). It pushes the
   CURRENT branch only, refuses anything that is not auto/*, and never
   force-pushes -- so the consolidated line stays a human's decision and a
   confused iteration cannot rewrite history. The loop prompt now requires a
   push on every iteration that commits, rather than at the end of some longer
   arc, which is exactly when a container dies.

The prompt itself drops from ~9 000 words to 85 lines and, more importantly,
stops accumulating: findings go in the corpus, and the prompt points at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:46:59 +02:00
Sylpheed RE agent
03ece95c06 Merge branch 'auto/idxd-unnamed-keys'
# Conflicts:
#	docs/re/INDEX.md
2026-08-28 15:27:51 +02:00
Sylpheed RE agent
1fce3f9c71 Merge branch 'auto/slb-loader'
# Conflicts:
#	docs/re/INDEX.md
#	docs/re/structures/slb-data-offset.md
2026-08-28 15:27:46 +02:00
Sylpheed RE agent
8124b85fc7 Merge branch 'auto/regn-reader'
# Conflicts:
#	docs/re/BACKLOG.md
#	docs/re/INDEX.md
#	docs/re/structures/regn-map-grid.md
#	tools/re-capture/regn_decode.py
2026-08-28 15:26:35 +02:00
Sylpheed RE agent
245b73243b Merge branch 'auto/isl-builtins-26-28-29'
# Conflicts:
#	docs/re/INDEX.md
#	tools/re-capture/isl.py
2026-08-28 15:24:55 +02:00
Sylpheed RE agent
0cbef2023f re: withdraw "everything was read 16 bytes early" — the planes were right
The coordinator refuted the broad form of my claim and was correct to. The
older reading put section 2 at chunk+offset with the normal at intra-record
+16, which addresses exactly the same bytes as chunk+0x10+offset with the
normal at +0: on 3506e972 both put face record 0's normal at 0x1c700. Its
unit-normal and n·p+d results were measuring real planes and stand.

What is actually wrong is the record BOUNDARY, and it decides which record the
four integer words belong to — the face adjacency, i.e. the field the whole
correlation search was looking for. Settled: requiring own==f and
tet[A].faces[i]==f gives 100.000% with the block from record f and 0.000% from
record f+1, on all 11 objects.

Also audits my own numbers for the same defect the coordinator found in the
point-in-bbox test: most of them are content tests that address identical bytes
under either convention and have no power over the origin. The four that do
have power are listed, including that POF0 at base+0 would relocate the u16
counts and leave the section pointers unrelocated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 07:58:45 +00:00
Sylpheed RE agent
50625b5a9e re: REGN is a tetrahedral navigation mesh — the cell→geometry link, decoded
Found the reader. It is the deserialiser, not a consumer, and it answers the
question twice: the fixup base is chunk+0x10 (82465198 addi r3,r31,16), so
every offset previously recorded was read 16 bytes early — which is why twenty
correlation tests sat at chance — and the POF0 table names every pointer word
in the file.

Six sections, not four. cell {count,item*} → item {n@+0x10, refs*@+0x14} →
array of pointers into section 1 → a 96-byte tetrahedron. Section 2 is a face:
plane, its three vertices, the two tetrahedra either side (0xFFFF = hull) and
their face slots.

All 11 objects: face passes through exactly 3 of its tet's 4 vertices in
253 722/253 722 (random control 0.07–2.2 %); portal cost == face-centroid
distance in 380 460/380 460; sphere reaches its cell 98.7–100 % vs 18–28 %
with transposed axes.

Refuted and kept: 'REGN'/'MCOL' are never built as constants in the executable
(0x474E occurs zero times in 1.87 M instructions), so no magic-dispatch site
exists; and "zero portal-pair float marks a hull edge" shows no lift at all.
Still open: the runtime consumer of the grid, the second portal float, and the
four flag bytes at tetrahedron +0x54.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 07:53:13 +00:00
Sylpheed RE agent
e5c6c27e6a re: regn_decode.py — read a REGN object through its own POF0 fixup table
The retail deserialiser (sub_82465110 / sub_82465138 / sub_82465200) relocates
a chunk with a fixup base of chunk+0x10, and the POF0 table it walks is an
exact list of which words are pointers. Decoding that table gives the pointer
graph with no guessing.

`verify` reproduces every number quoted in the doc, each against a control:
pointer-slot shape, reference-array packing, face/vertex incidence vs a random
face, cell agreement vs transposed axes, and the portal-cost identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 07:53:13 +00:00
Claude
a7d5a3bd12 re: the .slb data offset is the .pNN segment's 2048 grid phase
Closes the open question at the bottom of slb-data-offset.md.

  X = (cumulative start of the .pNN segment holding the wave) mod 2048

The XMA packet grid is 2048-aligned inside each individual segment file,
but the .pak TOC addresses entries in the flat concatenation at offsets
that are themselves multiples of 2048. The segment files are not multiples
of 2048 long, so each join shifts the grid by size % 2048 — and the four
disc-wide values are exactly the running sums:

  1392 = |p00| % 2048;  1468 = +76;  1600 = +132;  1728 = +128

Exact for 7620/7620 banks with a RIFF and 1163/1163 RIFF-less ones via
their seek chunk, 0 mismatches. Supersedes both heuristics (the 99.62 %
packet scan and the 99.97 % seek-residue rule) and dissolves the 28 ties.
The refutation test — an entry straddling a segment join must show two
phases in one file — passes on all 3 straddlers.

The leading bytes are the previous bank's audio, not a header: byte
diversity per offset is indistinguishable from a known packet (101.06 vs
101.90, no fixed field anywhere), the seek packet counts chain exactly
across consecutive entries, and the inter-entry bytes no TOC entry claims
are 1903/1928 non-zero.

Also recorded: the real bank header layout (id, block size 0x800, header
size in blocks, XMAWAVEFORMAT), and the loader search — a null result.
None of the four values exists as an immediate, a table or a float
anywhere in default.xex, which is what a pack-time artifact predicts.
Sound subsystem addresses mapped for the next pass.

Withdraws the 🟡 "most banks declare more data than they store" finding:
declared data sizes are exact (260/260), the bytes are just outside the
TOC window. Also withdraws my own "the header is unique, so nothing is
shared" inference — the windows tile, they do not overlap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 05:01:26 +00:00
Claude
1f4524f9fe tools: slb_segment_phase.py — derive the .slb data offset exactly
Static, disc-only probe for why a .slb bank's XMA1 stream starts at
1392 / 1468 / 1600 / 1728 bytes in. Subcommands:

  phases  the sound.p00..p04 size table and the running sums mod 2048
  verify  predicted vs measured offset over every bank on the disc
  bank    structural dump of one entry (bank headers, waves, seek chunks)
  chain   the tiling arithmetic across consecutive entries

Reads nothing but dat/sound.pak, dat/sound.p00..p04 and tables.pak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 05:01:26 +00:00
Sylpheed RE agent
3b67a451c2 tools(isl): apply the three new built-in names, with 28 flagged as probable
26 damage_unit -> set_unit_hp_pct, 29 -> set_unit_damage_taken_pct,
101 -> all_units_invulnerable, and 28 -> set_unit_damage_dealt_pct with an
inline note that its label rests on a single consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 03:43:56 +00:00
Sylpheed RE agent
fa3818c58c re: ISL built-ins 26 / 29 / 101 named end to end; 28's field pinned, label 🟡
The three unnamed built-ins are one family, and the chain from the dispatch
table to the field write is now followed for all of them:

  builtin -> ScriptPhase vtable slot -> interpreter command word 0xAB<op>BA
          -> command-table thunk -> opcode handler -> unit message 0xED08nnDE
          -> the GROUP pump sub_8232C4C0, which rebroadcasts to each child as
             0xED09nnDE
          -> the entity base handler sub_82398CC0, which writes the field.

  26 -> [unit+532]  = min(max(def.HP * pct, 0), def.HP)   = set_unit_hp_pct
  29 -> [unit+676]  = pct, a multiplier on damage TAKEN   = set_unit_damage_taken_pct
  28 -> [unit+672]  = pct, default 1.0                    = 🟡 damage DEALT
 101 -> the same message as 29 with a hard-wired 0.0, broadcast to every unit
        = all_units_invulnerable

`damage_unit` is WITHDRAWN for 26. The handler sets an absolute value rather
than subtracting one, and 100 heals to full -- which no damage primitive does.
It is pinned three ways: [unit+496] is the unit definition (the constructor
sub_82393868 fills it from the same std::map::find built-in 15 uses), [def+84]
is HP in unit_definition_layout.txt and the constructor seeds [unit+532] from
it, and crossing zero loads [def+584] = Delay and raises a flag, i.e. the
destruction sequence. So 0 destroys, with the datasheet's own death delay.

29 is the strongest of the three: [unit+676] has three independent readers
(sub_8237B020, sub_823800A8, sub_82398CC0) and every one multiplies a damage
amount immediately before it reduces [unit+532].

28 is deliberately left 🟡. The write and the 1.0 default are certain, but the
field has exactly ONE reader in the whole image -- the craft update's projectile
spawn, where it ends up as a multiplicative term in the damage message. That is
the mirror of 29 and it is tidy, which is exactly the shape that produced the
wrong names this file has already had to withdraw. What is not established is
that it reaches every weapon; the sibling damage sender sub_82388FF8 has no
+672 term at all.

Three usage tests, all measured over the 28 stages:
  * operand ceilings -- 26 is 97/97 inside [0,100] and 29 is 164/164, while 28
    (identical signature, identical x0.01 conversion) reaches 2000;
  * the craft cross-tab -- 26 splits cleanly into disposable props at 0,
    warships at 30-80 and the tutorial player craft at 100; 29 lands on the
    player, the tutorial boxes and the escorted TCAF hulls; 28 orders
    boss > ace > elite > line > prop;
  * the setup idiom -- activate_unit, then 15/29/28 as a speed/toughness/
    firepower trio, with 26 added wherever a unit must arrive pre-damaged.

Refutations attempted are recorded, including the two that turned into
confirmations (Stage 28 makes each tutorial box invulnerable with 29 and then
removes it with 26) and the offset-search trap that produced three false
readers, because projectiles have their own fields at 672 and 676.

Also corrected: the state guards. 26 rejects states 3 and 4; 28 and 29 reject
1, 3 and 4. This file said otherwise for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 03:43:56 +00:00
Sylpheed RE agent
1ba16da9a4 re(data): every call site of built-ins 26, 28, 29 and 101, with craft types
804 rows across all 28 stages, each joined to its squadron's craft through
`UnitGroup_S<NN>.tbl` -- 671/671 of the three unit-taking built-ins resolve,
none unknown. Regenerable from the committed tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 03:43:31 +00:00
Sylpheed RE agent
e9c06e2e87 tools: an ISL call-site dumper that carries the operands and the neighbours
`isl.py --calls` scans on the encoding, so it can see that a call happened but
not what it was called with -- the arguments live in the `local[]` staging of
the preceding instructions, not in the call. Naming a built-in needs those, and
it needs the calls either side, because the fixed idioms are what identify a
primitive (`116 -> 101 -> 100 -> 124 -> 93` is a phase teardown, and that is how
101 was named).

So this walks each stage's whole code region linearly, tracking both staging
forms, and prints stage/offset/id/operands plus the neighbouring call ids.
The linear walk is safe rather than assumed: it lands on every call site the
independent encoding scan finds, 2846/2846 in Stage 02.

`--craft` cross-tabs the operand against the squadron's craft type through
`unitgroup.py --all`, which is the test that separated built-in 15's classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 03:43:31 +00:00
Sylpheed RE agent
eb75b62f82 re: INDEX row for the nameless IDXD field keys
Records the census, the 504-entries-not-504-names correction, and the null
result on cracking with the arithmetic that makes it a null result rather
than a shrug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 03:37:05 +00:00
Sylpheed RE agent
e1e4f7bc1d 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
2026-08-26 03:36:40 +00:00
Sylpheed RE agent
8af6a3f5d5 re: measure the nameless IDXD field keys — 504 is 504 entries, not 504 names
Walks every IDXD object on the disc (7 750 objects, 190 782 records,
2 757 039 field entries, zero parse failures) and separates the field keys
that carry no name in the pool from those that do.

The headline correction: the "504 fields disc-wide that are hash-keyed with
no name" is a count of field ENTRIES. It is 42 distinct keys x 6 language
copies of one object x 2 records (FILE and OFFSET) = 504. The brute-force
target was never 504 names.

7 094 distinct keys are never named anywhere. 7 052 of them are not hashes
at all: they are author-assigned element ids, equal to the field's own index
in 1 404 924 of 1 485 577 cases and hand-numbered with gaps elsewhere.
Refuted directly rather than assumed -- tag_hash("BGM_001") is 0xC662435B
while the key of the field valued "BGM_001.slb" is 0x000003E9.

The split is measured, not stipulated: every key that DOES carry a name and
has a zero checksum byte sits at 0x0002677C or above, the ordinal band tops
out at 0x2198, and not one key falls in the gap between.

tag_hash reproduces 1 271 462 / 1 271 462 named field keys disc-wide
(`idxd_unnamed_keys.py selftest`), which is the gate everything else rests on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 03:36:23 +00:00
31 changed files with 13767 additions and 541 deletions

View File

@@ -67,6 +67,10 @@ pub mod movie_manifest;
pub mod movie_voice; pub mod movie_voice;
/// Assembling media whose bytes are not one archive entry — segment-spanning
/// reads, multi-sub-wave banks, and the continuous cutscene-voice stream.
pub mod media;
pub mod game_data; pub mod game_data;
pub mod localization; pub mod localization;

View File

@@ -0,0 +1,305 @@
//! Assembling media that does **not** sit in one place on the disc.
//!
//! Most assets are one archive entry and are read with [`crate::pak`] alone.
//! Audio is not, and this module owns every case where the bytes of one playable
//! thing have to be gathered from somewhere other than a single entry:
//!
//! * **An entry spans segment files.** A `.pak` TOC offset addresses the
//! *concatenated* `.p00….pNN` stream, so one entry routinely straddles two
//! files on disc. [`DiscSource::read_segment_range`] is the seam for that.
//! * **A bank holds several sub-waves.** A `.slb` is an XACT bank; its sub-waves
//! are either alternate takes or sequential segments of one line, and only
//! concatenating them all and clamping to the known length gets both right.
//! * **A cutscene voice is not in its own bank.** The movie voices are one
//! continuous XMA stream chunked into `VOICE_*.slb` TOC entries whose
//! boundaries do **not** match the cutscene cues. A cue routinely spans two
//! chunks, so *a `.slb` need not hold the track its name claims*.
//! [`resolve_movie_voice_region`] resolves a movie to a byte region of the
//! stream instead, which is the only reading that produces the right audio.
//!
//! ## Why this lives in `sylpheed-formats` and not in a viewer
//!
//! It used to live in the Bevy viewer, which meant the one piece of logic most
//! likely to be re-derived incorrectly was in the crate least likely to be
//! reused. Anything that reads the disc — the viewer, a CLI, an asset exporter
//! for a port — needs the same answers, and there must be one implementation of
//! them.
//!
//! ## What deliberately stays out
//!
//! Decoding. This module returns **XMA `RIFF`s**, not PCM: turning XMA into
//! samples means shelling out to FFmpeg, which is a native-only dependency and
//! a policy decision for the consumer. The seam is "here are the bytes that
//! belong together" — everything up to that point is disc knowledge, everything
//! after it is a codec choice.
use crate::pak::PakArchive;
use crate::slb::VoiceLang;
/// Where disc bytes come from. Implemented over an extracted directory, an ISO,
/// or anything else that can serve the same three questions.
///
/// It is a trait rather than a concrete type because the callers differ in ways
/// this module should not know about: a viewer reads from whichever source the
/// user opened, a headless exporter reads from a fixed extract, and a test reads
/// from a fixture.
pub trait DiscSource {
/// Read a whole file by disc-relative path, e.g. `dat/sound.pak`.
fn read_file(&self, path: &str) -> Result<Vec<u8>, String>;
/// Open an IPFB archive by disc-relative path, with its `.pNN` segments.
fn open_pak(&self, path: &str) -> Result<PakArchive, String>;
/// Read `len` bytes at `offset` into the concatenated `<stem>.p00….pNN`
/// stream, where `stem` is a disc-relative path without extension
/// (`dat/sound`). The range may cross a segment boundary; that is the point.
fn read_segment_range(&self, stem: &str, offset: u64, len: usize) -> Result<Vec<u8>, String>;
}
/// Read one `sound.pak` bank by name-hash, taking only its byte range from the
/// segments rather than inflating the 1.07 GB archive.
pub fn read_sound_bank<S: DiscSource + ?Sized>(
source: &S,
name_hash: u32,
) -> Result<Vec<u8>, String> {
let toc = source.read_file("dat/sound.pak")?;
let entries = PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?;
let idx = entries
.binary_search_by_key(&name_hash, |e| e.name_hash)
.map_err(|_| "not present in sound.pak".to_string())?;
let e = &entries[idx];
source.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize)
}
/// The XMA `RIFF`s of one named bank, in the order they must be concatenated.
///
/// Every sub-wave is returned, not just the first. The two bank shapes need
/// this for opposite reasons: a **segment** bank (`VOICE_RT07A` = 24 s + 14 s +
/// 11 s ≈ the 50 s movie) is only complete when all of them are joined, and an
/// **alternate-take** bank (`VOICE_S00A`, whose sub-wave 0 already spans the
/// whole movie) is trimmed by the caller's length clamp. Taking sub-wave 0 alone
/// dropped two thirds of the dialogue on segment banks — that was a real bug.
pub fn sound_bank_riffs<S: DiscSource + ?Sized>(
source: &S,
clip_name: &str,
) -> Result<Vec<Vec<u8>>, String> {
let bytes = read_sound_bank(source, crate::hash::name_hash(clip_name))?;
Ok(riffs_of(&bytes))
}
/// The XMA `RIFF`s of a continuous byte region of the voice stream, as returned
/// by [`resolve_movie_voice_region`].
pub fn voice_region_riffs<S: DiscSource + ?Sized>(
source: &S,
start: u64,
end: u64,
) -> Result<Vec<Vec<u8>>, String> {
let bytes = source.read_segment_range("dat/sound", start, (end - start) as usize)?;
Ok(riffs_of(&bytes))
}
/// Sub-wave `RIFF`s of a bank's bytes, with the single-stream fallback.
///
/// Some banks — the data-before-header `\etc\` radio clips — defeat the
/// multi-sub-wave scanner, and the robust single-stream reader handles them. An
/// empty result here means genuinely undecodable, not "scanner confused".
fn riffs_of(bytes: &[u8]) -> Vec<Vec<u8>> {
let riffs = crate::slb::to_xma_riffs(bytes);
if riffs.is_empty() {
crate::slb::to_xma_riff_best(bytes).into_iter().collect()
} else {
riffs
}
}
/// Resolve a movie's voice bank **name** through the manifest in `tables.pak`.
///
/// Only the manifest's DIRECT bindings are trusted. Extending this to unbound
/// resupply movies by shared demo line was tried and verified WRONG — it played
/// the wrong recording — so an unbound movie stays unvoiced rather than play a
/// guess. `None` therefore means "this cutscene has no voice-over", which is a
/// real answer for most `hokyu_*` movies.
pub fn resolve_movie_voice_clip<S: DiscSource + ?Sized>(
source: &S,
movie: &str,
lang: VoiceLang,
) -> Option<String> {
let pak = source.open_pak("dat/tables.pak").ok()?;
let manifest = find_manifest(&pak)?;
let sounds = pak
.read_by_name(&format!("{}\\sounds.tbl", lang.code_pub()))?
.ok()?;
crate::movie_manifest::resolve_voice_entry(&manifest, &sounds, movie, lang)
}
/// The manifest has no stable name, so it is found by shape among the entries.
fn find_manifest(pak: &PakArchive) -> Option<Vec<u8>> {
pak.entries().iter().find_map(|e| {
pak.read(e)
.ok()
.filter(|b| crate::movie_manifest::is_manifest(b))
})
}
/// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound.
///
/// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse
/// those recordings. The selector is the cutscene's **demo id** (from its
/// subtitle track), NOT the ship category: `hokyu_LS_s02A` and `hokyu_LS_s11A`
/// are both LS/carrier but use demos 600 vs 601, whose lines differ. So the map
/// is derived from the 5 bound hokyu — each of which has both a subtitle demo id
/// and a `VOICETRACK` — and the target movie's demo id is looked up in it.
pub fn hokyu_voice_token<S: DiscSource + ?Sized>(
source: &S,
movie: &str,
lang: VoiceLang,
manifest: &[u8],
) -> Option<String> {
use crate::movie_subtitle as ms;
if !movie.starts_with("hokyu_") {
return None;
}
let lang_pak = source
.open_pak(&format!("dat/movie/{}.pak", lang.code_pub()))
.ok()?;
let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?;
crate::movie_manifest::parse(manifest)
.into_iter()
.find_map(|e| {
let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?;
ms::track_voice_cues(&lang_pak, &e.movie)
.iter()
.any(|&(d, _)| d == want)
.then_some(tok)
})
}
/// Resolve a movie's cutscene voice to a continuous `[start, end)` byte region
/// of the voice stream — **the reading that produces the right audio**.
///
/// The chain is movie → cue token (manifest) → sound id (master registry) →
/// region (scan the stream for two trailers). Each cue ends at an inline
/// `(sound_id, 0x11, …)` trailer, so cue *N* is the bytes between trailer *N-1*
/// and trailer *N*.
///
/// Returns `None` for movies whose voice is not a `\Movie\` bank — the hokyu
/// `\etc\` clips — which the caller then resolves the per-clip way via
/// [`resolve_movie_voice_clip`].
pub fn resolve_movie_voice_region<S: DiscSource + ?Sized>(
source: &S,
movie: &str,
lang: VoiceLang,
) -> Option<(u64, u64)> {
use crate::{hash::name_hash, movie_manifest, movie_voice};
let code = lang.code_pub();
let tpak = source.open_pak("dat/tables.pak").ok()?;
let manifest = find_manifest(&tpak)?;
let token = movie_manifest::voice_token(&manifest, movie)
.or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?;
// token → sound id, via the large per-language IDXD entry carrying the
// `<lang>\Movie\VOICE_*.slb` paths. Located by content, like the manifest.
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
let registry = tpak.entries().iter().find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})?;
let id = *movie_voice::registry_voice_ids(&registry).get(&token)?;
// Physical anchor: the TOC offset of this token's own `.slb` chunk. That is a
// start point NEAR the cue's trailers, not the cue itself — the cue may sit
// before or after it, which is the whole reason a region is needed. The
// token's subdirectory varies by kind.
let stoc = source.read_file("dat/sound.pak").ok()?;
let entries = PakArchive::parse_toc(&stoc).ok()?;
let anchor = ["Movie", "etc", "Voice"].iter().find_map(|dir| {
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
entries
.binary_search_by_key(&h, |e| e.name_hash)
.ok()
.map(|i| entries[i].offset as u64)
})?;
// Scan both directions from the anchor. The window must span the largest
// bank (ADV ≈ 3.6 MB) or the predecessor trailer falls outside it.
let win_start = anchor.saturating_sub(2 * 1024 * 1024) & !3;
let window = source
.read_segment_range("dat/sound", win_start, 8 * 1024 * 1024)
.ok()?;
let end_local = movie_voice::find_descriptor(&window, id)?;
let end = win_start + end_local as u64;
// Start = the predecessor trailer. Prefer the exact `id-1`; where the id
// sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer
// below — but only within one bank (~1.5 MB), else this is the first cue in
// its block and the audio starts at the anchor itself.
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
.map(|o| win_start + o as u64)
.filter(|&s| s < end && end - s < 1_500_000)
.unwrap_or(anchor);
Some((start, end))
}
/// A [`DiscSource`] over an **extracted** disc directory.
///
/// Provided here rather than left to each caller because every headless
/// consumer — the CLI, the disc tests, an asset exporter for a port — wants
/// exactly this and would otherwise re-derive the segment-spanning read, which
/// is the part that is easy to get subtly wrong.
#[cfg(not(target_arch = "wasm32"))]
pub struct DirectorySource {
root: std::path::PathBuf,
}
#[cfg(not(target_arch = "wasm32"))]
impl DirectorySource {
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self { root: root.into() }
}
}
#[cfg(not(target_arch = "wasm32"))]
impl DiscSource for DirectorySource {
fn read_file(&self, path: &str) -> Result<Vec<u8>, String> {
std::fs::read(self.root.join(path)).map_err(|e| format!("{path}: {e}"))
}
fn open_pak(&self, path: &str) -> Result<PakArchive, String> {
PakArchive::open(self.root.join(path)).map_err(|e| format!("{path}: {e}"))
}
/// Walks `<stem>.p00`, `.p01`, … skipping whole segments until the offset is
/// inside one, then reads across as many as the length needs. A range that
/// straddles a boundary is the normal case, not an edge case.
fn read_segment_range(&self, stem: &str, offset: u64, len: usize) -> Result<Vec<u8>, String> {
use std::io::{Read, Seek, SeekFrom};
let mut out = Vec::with_capacity(len);
let (mut skip, mut need) = (offset, len);
for i in 0..100u32 {
if need == 0 {
break;
}
let path = self.root.join(format!("{stem}.p{i:02}"));
let Ok(meta) = std::fs::metadata(&path) else { break };
let seg_len = meta.len();
if skip >= seg_len {
skip -= seg_len;
continue;
}
let mut f = std::fs::File::open(&path).map_err(|e| e.to_string())?;
f.seek(SeekFrom::Start(skip)).map_err(|e| e.to_string())?;
let take = need.min((seg_len - skip) as usize);
let start = out.len();
out.resize(start + take, 0);
f.read_exact(&mut out[start..]).map_err(|e| e.to_string())?;
need -= take;
skip = 0;
}
if need != 0 {
return Err(format!("segment range short by {need} bytes"));
}
Ok(out)
}
}

View File

@@ -25,8 +25,10 @@ use crate::pak::PakArchive;
/// Subtitle language. `pak_code` selects `dat/movie/<code>.pak`; `game_code` /// Subtitle language. `pak_code` selects `dat/movie/<code>.pak`; `game_code`
/// selects `dat/GP_MAIN_GAME_<code>.pak` (the caption text). /// selects `dat/GP_MAIN_GAME_<code>.pak` (the caption text).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SubLang { pub enum SubLang {
/// Default only because the disc's own default is English.
#[default]
English, English,
Japanese, Japanese,
German, German,

View File

@@ -225,16 +225,34 @@ impl PakArchive {
} }
/// The raw stored bytes for an entry (still `"Z1"`-wrapped / compressed). /// The raw stored bytes for an entry (still `"Z1"`-wrapped / compressed).
///
/// One entry on the retail disc declares more bytes than the segments hold:
/// `sound.pak`'s `Static.slb` (the SFX bank) claims 8 970 240 bytes at the
/// highest offset in the archive, 616 768 past the end of `sound.p04`. It is
/// not corruption and it is not our extraction — `sound.p04` is byte-for-byte
/// the size the ISO's own directory record gives, and a sweep of **every**
/// `.pak` on the disc finds this one entry and no other. So the last entry's
/// `comp_size` is an allocation size, not a stored size.
///
/// A short read is therefore allowed **only** for the highest-offset entry,
/// which is the shape the evidence supports. Any other overrun is still an
/// error: that would be real damage, and clamping it would hide the damage
/// behind a half-decoded asset.
pub fn stored_bytes(&self, entry: &PakEntry) -> Result<&[u8], PakError> { pub fn stored_bytes(&self, entry: &PakEntry) -> Result<&[u8], PakError> {
let start = entry.offset as usize; let start = entry.offset as usize;
let end = start + entry.comp_size as usize; let end = start + entry.comp_size as usize;
self.data if let Some(b) = self.data.get(start..end) {
.get(start..end) return Ok(b);
.ok_or(PakError::OffsetOutOfRange { }
offset: entry.offset, let is_tail = self.entries.iter().all(|e| e.offset <= entry.offset);
size: entry.comp_size, if is_tail && start < self.data.len() {
data_len: self.data.len(), return Ok(&self.data[start..]);
}) }
Err(PakError::OffsetOutOfRange {
offset: entry.offset,
size: entry.comp_size,
data_len: self.data.len(),
})
} }
/// Decompress an entry to its raw payload bytes. Handles the `"Z1"` container /// Decompress an entry to its raw payload bytes. Handles the `"Z1"` container

View File

@@ -29,13 +29,25 @@ pub const XMA1_PACKET: usize = 2048;
/// Voice language for cutscene audio. Only English and Japanese voice exist on /// Voice language for cutscene audio. Only English and Japanese voice exist on
/// the disc (subtitles cover more languages, voice does not). /// the disc (subtitles cover more languages, voice does not).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VoiceLang { pub enum VoiceLang {
/// The default only because the disc's own default audio track is English;
/// nothing else about the code should assume it.
#[default]
English, English,
Japanese, Japanese,
} }
impl VoiceLang { impl VoiceLang {
pub const ALL: [VoiceLang; 2] = [VoiceLang::English, VoiceLang::Japanese];
pub fn label(self) -> &'static str {
match self {
VoiceLang::English => "English",
VoiceLang::Japanese => "Japanese",
}
}
fn code(self) -> &'static str { fn code(self) -> &'static str {
match self { match self {
VoiceLang::English => "eng", VoiceLang::English => "eng",
@@ -67,14 +79,122 @@ pub struct VoiceClip {
pub display: String, pub display: String,
} }
/// Enumerate the voice/dialog clips named in a decompressed `sounds.tbl` (the /// What kind of audio a `sounds.tbl` entry names.
/// IDXD in `tables.pak`). Extracts every `<lang>\{Voice,etc,Movie,Briefing}\…` ///
/// path ending in `.slb` for `lang`, parsed into `(name, speaker, display)`. /// The split is the on-disc path shape, not a guess: the 36 language-independent
pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> { /// banks sit at the table root (`BGM_###.slb`, `JNGL_00#.slb`, `Static.slb`),
/// while everything else is under `<lang>\<dir>\`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AudioCategory {
/// `BGM_###.slb` — 32 music tracks, language-independent.
Music,
/// `JNGL_00#.slb` — 3 short jingles (mission clear / fail stings).
Jingle,
/// `Static.slb` — the sound-effect bank, one 9 MB multi-wave bank.
Sfx,
/// `<lang>\Voice\` — in-mission radio chatter, by speaker.
Radio,
/// `<lang>\etc\` — the other spoken lines (cutscene dialogue, system).
Dialogue,
/// `<lang>\Movie\VOICE_<movie>.slb` — a cutscene's continuous voice track.
MovieVoice,
/// `<lang>\Briefing\BR<NN>_<MM>.slb` — mission briefing lines.
Briefing,
/// A `.slb` whose path matched no known shape.
Other,
}
impl AudioCategory {
pub const ALL: [AudioCategory; 8] = [
AudioCategory::Music,
AudioCategory::Jingle,
AudioCategory::Sfx,
AudioCategory::Radio,
AudioCategory::Dialogue,
AudioCategory::MovieVoice,
AudioCategory::Briefing,
AudioCategory::Other,
];
pub fn label(self) -> &'static str {
match self {
AudioCategory::Music => "Music",
AudioCategory::Jingle => "Jingles",
AudioCategory::Sfx => "Sound effects",
AudioCategory::Radio => "Radio",
AudioCategory::Dialogue => "Dialogue",
AudioCategory::MovieVoice => "Movie voice",
AudioCategory::Briefing => "Briefing",
AudioCategory::Other => "Other",
}
}
/// True for the categories that are spoken lines — the set
/// [`list_voice_clips`] returns.
pub fn is_voice(self) -> bool {
matches!(
self,
AudioCategory::Radio
| AudioCategory::Dialogue
| AudioCategory::MovieVoice
| AudioCategory::Briefing
)
}
/// True when the bank is language-independent, so it appears whichever
/// `<lang>\sounds.tbl` is read.
pub fn is_shared(self) -> bool {
matches!(
self,
AudioCategory::Music | AudioCategory::Jingle | AudioCategory::Sfx
)
}
fn classify(name: &str) -> AudioCategory {
let leaf = name.rsplit('\\').next().unwrap_or(name);
if !name.contains('\\') {
return if leaf.starts_with("BGM_") {
AudioCategory::Music
} else if leaf.starts_with("JNGL_") {
AudioCategory::Jingle
} else if leaf.eq_ignore_ascii_case("Static.slb") {
AudioCategory::Sfx
} else {
AudioCategory::Other
};
}
match name.rsplit('\\').nth(1) {
Some("Voice") => AudioCategory::Radio,
Some("etc") => AudioCategory::Dialogue,
Some("Movie") => AudioCategory::MovieVoice,
Some("Briefing") => AudioCategory::Briefing,
_ => AudioCategory::Other,
}
}
}
/// One playable bank named in `sounds.tbl`, with the category its path implies.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioEntry {
pub clip: VoiceClip,
pub category: AudioCategory,
}
/// Enumerate **every** `.slb` bank named in a decompressed `sounds.tbl` (the
/// IDXD in `tables.pak`): the language-independent music/jingle/SFX banks at
/// the table root, plus every `<lang>\…` spoken line.
///
/// Measured on the retail disc: `eng\sounds.tbl` names 4 418 banks (36 shared +
/// 2 382 Radio + 1 821 Dialogue + 101 Briefing + 78 Movie voice) and
/// `jpn\sounds.tbl` names 5 136 (the same 36 shared + 5 100 Japanese lines).
/// Every one of the 36 shared names resolves to a `sound.pak` TOC entry under
/// [`crate::hash::name_hash`], which is the check that they are real banks and
/// not stale table text.
pub fn list_audio_entries(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<AudioEntry> {
let prefix = format!("{}\\", lang.code()); let prefix = format!("{}\\", lang.code());
let mut seen = std::collections::BTreeSet::new(); let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new(); let mut out = Vec::new();
// Scan for printable-ASCII runs; keep those that look like a voice path. // Scan for printable-ASCII runs; keep those that name a `.slb`.
let mut i = 0; let mut i = 0;
while i < sounds_tbl.len() { while i < sounds_tbl.len() {
let start = i; let start = i;
@@ -83,15 +203,14 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
} }
if i - start >= 6 { if i - start >= 6 {
if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) { if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) {
// Every spoken-line category, so the standalone player covers them // Take this language's entries plus the root (shared) banks; a
// all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices // path under the OTHER language would be a table artefact.
// (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in let mine = s.starts_with(&prefix) || !s.contains('\\');
// `\Briefing\` as `BR<NN>_<MM>.slb` (no `VOICE` in the name). if mine && s.ends_with(".slb") && seen.insert(s.to_string()) {
let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\"); out.push(AudioEntry {
if s.starts_with(&prefix) && s.ends_with(".slb") && is_voice { category: AudioCategory::classify(s),
if seen.insert(s.to_string()) { clip: parse_voice_clip(s),
out.push(parse_voice_clip(s)); });
}
} }
} }
} }
@@ -100,6 +219,21 @@ pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
out out
} }
/// Enumerate just the spoken-line clips — [`list_audio_entries`] restricted to
/// [`AudioCategory::is_voice`].
///
/// In-mission radio (`\Voice\`, `\etc\`) and bound movie voices (`\Movie\`) all
/// carry `VOICE_`; mission-briefing lines live in `\Briefing\` as
/// `BR<NN>_<MM>.slb` and carry no `VOICE` at all, which is why the category —
/// i.e. the directory — decides this and not the filename.
pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
list_audio_entries(sounds_tbl, lang)
.into_iter()
.filter(|e| e.category.is_voice())
.map(|e| e.clip)
.collect()
}
fn parse_voice_clip(name: &str) -> VoiceClip { fn parse_voice_clip(name: &str) -> VoiceClip {
// `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.slb`. // `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.slb`.
let stem = name let stem = name
@@ -525,6 +659,56 @@ mod tests {
); );
} }
#[test]
fn list_audio_entries_categorises_root_banks_and_keeps_them_language_shared() {
// The three root banks carry no language component, so BOTH sounds.tbl
// files name them; a language filter that only accepted `<lang>\` would
// silently drop all the music, which is what it used to do.
let mut tbl = Vec::new();
for s in [
"BGM_001.slb",
"JNGL_002.slb",
"Static.slb",
"eng\\Voice\\VOICE_ADAN_010.slb",
"eng\\etc\\VOICE_D_450.slb",
"eng\\Movie\\VOICE_S13A.slb",
"eng\\Briefing\\BR01_01.slb",
] {
tbl.extend_from_slice(s.as_bytes());
tbl.push(0);
}
let by = |lang| {
list_audio_entries(&tbl, lang)
.into_iter()
.map(|e| (e.clip.name, e.category))
.collect::<Vec<_>>()
};
let eng = by(VoiceLang::English);
let want = [
("BGM_001.slb", AudioCategory::Music),
("JNGL_002.slb", AudioCategory::Jingle),
("Static.slb", AudioCategory::Sfx),
("eng\\Voice\\VOICE_ADAN_010.slb", AudioCategory::Radio),
("eng\\etc\\VOICE_D_450.slb", AudioCategory::Dialogue),
("eng\\Movie\\VOICE_S13A.slb", AudioCategory::MovieVoice),
("eng\\Briefing\\BR01_01.slb", AudioCategory::Briefing),
];
assert_eq!(eng.len(), want.len());
for (n, c) in want {
assert!(
eng.iter().any(|(en, ec)| en == n && *ec == c),
"{n} not categorised as {c:?}"
);
}
// Reading the Japanese table yields the shared banks and none of the
// English lines.
let jpn = by(VoiceLang::Japanese);
assert_eq!(jpn.len(), 3, "only the shared banks: {jpn:?}");
assert!(jpn.iter().all(|(_, c)| c.is_shared()));
// And the voice view is exactly the non-shared half.
assert_eq!(list_voice_clips(&tbl, VoiceLang::English).len(), 4);
}
#[test] #[test]
fn rebuilds_riff_from_headerless() { fn rebuilds_riff_from_headerless() {
let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET]; let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET];

View File

@@ -0,0 +1,88 @@
//! Real-disc tests for media assembly. Skipped without `SYLPHEED_DISC`.
//!
//! This logic used to live in the Bevy viewer, where it had no test at all. It
//! is the trickiest reading on the disc — a cutscene's voice is a byte region of
//! a continuous stream, not the bank its name points at — so it gets pinned here
//! before anything else is built on top of it.
use std::path::PathBuf;
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::VoiceLang;
fn disc() -> Option<DirectorySource> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then(|| DirectorySource::new(p))
}
/// A segment-spanning read returns the same bytes as slicing the whole archive.
///
/// The control that matters: `sound.pak`'s data is five segments, so a TOC
/// offset late in the archive addresses a position no single file has. If the
/// walk were off by a segment this would return plausible-looking wrong bytes
/// rather than fail, which is exactly why it is asserted against the archive's
/// own read rather than against a length.
#[test]
fn segment_range_matches_the_archive_read() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let name = "BGM_020.slb";
let hash = sylpheed_formats::hash::name_hash(name);
let via_range = media::read_sound_bank(&src, hash).expect("segment range read");
let toc = src.read_file("dat/sound.pak").unwrap();
let entries = sylpheed_formats::PakArchive::parse_toc(&toc).unwrap();
let e = entries
.iter()
.find(|e| e.name_hash == hash)
.expect("BGM_020 in the TOC");
assert_eq!(via_range.len(), e.comp_size as usize);
// And it decodes, which a misaligned read would not do.
let riffs = media::sound_bank_riffs(&src, name).expect("riffs");
assert!(!riffs.is_empty(), "no sub-waves recovered");
}
/// A movie's voice resolves to a byte region, and the region is sane.
///
/// `RT01A` is one of the cutscenes whose voice spans more than one `.slb`
/// chunk — the case that motivated regions over per-bank reads in the first
/// place. The assertions are deliberately about *shape* (ordered, non-empty,
/// smaller than one bank) rather than exact offsets, because the offsets are
/// disc facts we have no independent oracle for here; a regression that
/// reversed or emptied the region would still be caught.
#[test]
fn movie_voice_resolves_to_a_region_that_decodes() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let (start, end) = media::resolve_movie_voice_region(&src, "RT01A", VoiceLang::English)
.expect("RT01A has a bound voice track");
assert!(start < end, "region is inverted: {start}..{end}");
assert!(end - start > 4096, "region is implausibly small");
assert!(end - start < 1_500_000, "region spans more than one bank");
let riffs = media::voice_region_riffs(&src, start, end).expect("region riffs");
assert!(!riffs.is_empty(), "region decoded to no audio");
}
/// An unbound movie stays unvoiced rather than borrowing a neighbour's clip.
///
/// This is a *negative* the corpus paid for: extending resolution to unbound
/// resupply movies by shared demo line played the WRONG recording. The guard
/// keeps that door shut.
#[test]
fn manifest_binding_is_the_only_route() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
// A movie the manifest does not bind must resolve to nothing, not to a guess.
assert_eq!(
media::resolve_movie_voice_clip(&src, "no_such_movie_xyz", VoiceLang::English),
None
);
}

View File

@@ -167,3 +167,62 @@ fn manifest_slot_and_movie_counts() {
// Every id names a real record, so nothing dangles. // Every id names a real record, so nothing dangles.
assert!(entries.iter().all(|e| !e.slot.is_empty() && !e.movie.is_empty())); assert!(entries.iter().all(|e| !e.slot.is_empty() && !e.movie.is_empty()));
} }
/// Everything the Cutscenes browser shows, asserted against the disc.
///
/// The window's value is that it answers "which cutscenes exist, which mission
/// is each one in, and can I read it without playing it" — so the test checks
/// exactly those three, including the **negative**: five manifest-bound movies
/// have no `.wmv`, and nine more have no English transcript. A browser that
/// quietly omitted them would look complete and be wrong, so the counts are
/// pinned here rather than left to the eye.
#[test]
fn cutscene_catalog_binds_movies_and_transcripts() {
let Some(root) = disc_root() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
use sylpheed_formats::movie_subtitle as ms;
let (manifest, _) = load_manifest_and_sounds(&root);
let rows = movie_manifest::parse(&manifest);
assert_eq!(rows.len(), 104, "manifest slots");
let movies: std::collections::BTreeSet<&str> =
rows.iter().map(|r| r.movie.as_str()).collect();
assert_eq!(movies.len(), 101, "distinct movies");
assert_eq!(rows.iter().filter(|r| r.subtitle.is_some()).count(), 99);
assert_eq!(rows.iter().filter(|r| r.voice_token.is_some()).count(), 99);
assert_eq!(rows.iter().filter(|r| r.telop.is_some()).count(), 22);
// Bound-but-absent: the four boot logos and one encoder test clip are named
// by the manifest and are not on the disc. The browser marks these in red
// rather than offering a Play button that would fail.
let on_disc: std::collections::BTreeSet<String> = std::fs::read_dir(root.join("dat/movie"))
.unwrap()
.filter_map(|e| {
let p = e.ok()?.path();
(p.extension()?.to_str()? == "wmv")
.then(|| p.file_stem()?.to_str().map(|s| s.to_ascii_lowercase()))?
})
.collect();
let mut absent: Vec<&str> = movies
.iter()
.copied()
.filter(|m| !on_disc.contains(&m.to_ascii_lowercase()))
.collect();
absent.sort_unstable();
assert_eq!(
absent,
["SYLPH_HD720p_8M-CBR_2ch", "logo1", "logo2", "logo3", "logo4"],
"manifest-bound movies with no .wmv on the disc"
);
// Transcripts, which is what makes a cutscene readable without playback.
let lang_pak = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
let text_pak = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap();
let resolved = movies
.iter()
.filter(|m| !ms::load(m, &lang_pak, &text_pak).is_empty())
.count();
assert_eq!(resolved, 92, "movies with an English transcript");
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,12 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts}; use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{ use crate::iso_loader::{
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview, AudioLibrary, AudioPreview, CutsceneBrowser, FileInfo, FileSelected, GameCategory, GameData,
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir, ImageRgba, IsoLoaderSystemSet, IsoState, ModelPreview, MovieSubtitles, MovieVoice, PakContent,
RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, PakView, RequestAudio, RequestAudioLibrary, RequestCutscenes, RequestGameData, RequestOpenDir,
RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary, SaveBrowser, RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, RequestShipCatalog,
ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview, RequestShipRender, RequestSubtitles, SaveBrowser, ScreenBrowser, ShipBrowser, SkyboxPreview,
VideoPreview, VoiceLibrary, IsoLoaderSystemSet, TextPreview, TexturePreview, VideoPreview,
}; };
use crate::ViewerState; use crate::ViewerState;
use sylpheed_formats::SubLang; use sylpheed_formats::SubLang;
@@ -37,6 +37,7 @@ impl Plugin for ViewerUiPlugin {
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet)); app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_screens_ui.after(IsoLoaderSystemSet)); app.add_systems(Update, draw_screens_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_save_ui.after(IsoLoaderSystemSet)); app.add_systems(Update, draw_save_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_cutscenes_ui.after(IsoLoaderSystemSet));
} }
} }
} }
@@ -148,11 +149,12 @@ struct UiEvents<'w> {
file_selected: EventWriter<'w, FileSelected>, file_selected: EventWriter<'w, FileSelected>,
subtitles: EventWriter<'w, RequestSubtitles>, subtitles: EventWriter<'w, RequestSubtitles>,
audio: EventWriter<'w, RequestAudio>, audio: EventWriter<'w, RequestAudio>,
voice_lib: EventWriter<'w, RequestVoiceLibrary>, audio_lib: EventWriter<'w, RequestAudioLibrary>,
game_data: EventWriter<'w, RequestGameData>, game_data: EventWriter<'w, RequestGameData>,
ships: EventWriter<'w, RequestShipCatalog>, ships: EventWriter<'w, RequestShipCatalog>,
screens: EventWriter<'w, RequestScreenCatalog>, screens: EventWriter<'w, RequestScreenCatalog>,
save: EventWriter<'w, RequestSaveOpen>, save: EventWriter<'w, RequestSaveOpen>,
cutscenes: EventWriter<'w, RequestCutscenes>,
} }
fn draw_viewer_ui( fn draw_viewer_ui(
@@ -170,7 +172,7 @@ fn draw_viewer_ui(
mut subtitles: ResMut<MovieSubtitles>, mut subtitles: ResMut<MovieSubtitles>,
mut movie_voice: ResMut<MovieVoice>, mut movie_voice: ResMut<MovieVoice>,
mut audio: ResMut<AudioPreview>, mut audio: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>, mut audio_lib: ResMut<AudioLibrary>,
mut events: UiEvents, mut events: UiEvents,
) { ) {
let ctx = contexts.ctx_mut(); let ctx = contexts.ctx_mut();
@@ -202,11 +204,11 @@ fn draw_viewer_ui(
}); });
ui.menu_button("View", |ui| { ui.menu_button("View", |ui| {
if ui.button("🎙 Voice Lines").clicked() { if ui.button("🔊 Audio Library").clicked() {
voice_lib.open = true; audio_lib.open = true;
if !voice_lib.loaded && !voice_lib.loading { if !audio_lib.loaded && !audio_lib.loading {
voice_lib.loading = true; audio_lib.loading = true;
events.voice_lib.send_default(); events.audio_lib.send_default();
} }
ui.close_menu(); ui.close_menu();
} }
@@ -228,6 +230,12 @@ fn draw_viewer_ui(
events.screens.send_default(); events.screens.send_default();
ui.close_menu(); ui.close_menu();
} }
if ui.button("🎬 Cutscenes…").clicked() {
// The manifest binds every cutscene slot to its movie,
// subtitle track, voice token and telop overlay.
events.cutscenes.send_default();
ui.close_menu();
}
if ui.button("💾 Save File…").clicked() { if ui.button("💾 Save File…").clicked() {
// A save is not on the disc — it lives in the emulator's // A save is not on the disc — it lives in the emulator's
// content tree, so this opens a file dialog. // content tree, so this opens a file dialog.
@@ -248,98 +256,134 @@ fn draw_viewer_ui(
}); });
// ── Standalone voice-line browser (floating window) ─────────────────── // ── Standalone voice-line browser (floating window) ───────────────────
if voice_lib.open { if audio_lib.open {
let mut open = true; let mut open = true;
egui::Window::new("🎙 Voice Lines") egui::Window::new("🔊 Audio Library")
.default_width(360.0) .default_width(400.0)
.default_height(480.0) .default_height(520.0)
.open(&mut open) .open(&mut open)
.show(ctx, |ui| { .show(ctx, |ui| {
if voice_lib.loading { if audio_lib.loading {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spinner(); ui.spinner();
ui.label("Reading sounds.tbl…"); ui.label("Reading sounds.tbl…");
}); });
ctx.request_repaint(); ctx.request_repaint();
} else if !voice_lib.loaded { } else if !audio_lib.loaded {
ui.label("Open a game source first."); ui.label("Open a game source first.");
} else { } else {
use sylpheed_formats::slb::VoiceLang;
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Voice:");
// The table name IS the selector: there is no language
// field inside sounds.tbl, so switching re-reads the
// other file. Music/jingles/SFX are shared and stay.
for lang in VoiceLang::ALL {
if ui
.selectable_label(audio_lib.lang == lang, lang.label())
.clicked()
&& audio_lib.lang != lang
{
audio_lib.lang = lang;
audio_lib.loaded = false;
audio_lib.entries.clear();
audio_lib.reload = true;
}
}
ui.separator();
ui.label("Filter:"); ui.label("Filter:");
ui.text_edit_singleline(&mut voice_lib.filter); ui.text_edit_singleline(&mut audio_lib.filter);
}); });
let f = voice_lib.filter.to_lowercase(); let f = audio_lib.filter.to_lowercase();
// Group the thousands of entries as directory → speaker so the // Group category → speaker. The category comes from the path
// list is navigable (e.g. browse `Voice` by character to find a // shape, so the root banks (music/jingles/SFX) get real
// cutscene's radio line). `name` is `<lang>\<dir>\<file>.slb`. // headings instead of the "?" a directory split gave them.
use std::collections::BTreeMap; use std::collections::BTreeMap;
let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> = type Group<'a> = BTreeMap<&'a str, Vec<&'a sylpheed_formats::slb::AudioEntry>>;
BTreeMap::new(); let mut groups: BTreeMap<
for c in &voice_lib.clips { sylpheed_formats::slb::AudioCategory,
if !f.is_empty() && !c.name.to_lowercase().contains(&f) { Group<'_>,
> = BTreeMap::new();
for e in &audio_lib.entries {
if !f.is_empty() && !e.clip.name.to_lowercase().contains(&f) {
continue; continue;
} }
let dir = c.name.rsplit('\\').nth(1).unwrap_or("?");
groups groups
.entry(dir) .entry(e.category)
.or_default() .or_default()
.entry(c.speaker.as_str()) .entry(e.clip.speaker.as_str())
.or_default() .or_default()
.push(c); .push(e);
} }
let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum(); let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
ui.label( ui.label(
egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len())) egui::RichText::new(format!(
.weak() "{shown} / {} banks",
.small(), audio_lib.entries.len()
))
.weak()
.small(),
); );
ui.separator(); ui.separator();
let filtering = !f.is_empty(); let filtering = !f.is_empty();
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {
for (dir, speakers) in &groups { for (cat, speakers) in &groups {
let dtotal: usize = speakers.values().map(Vec::len).sum(); let ctotal: usize = speakers.values().map(Vec::len).sum();
egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})")) egui::CollapsingHeader::new(format!(
.id_salt(("vdir", *dir)) "{} ({ctotal})",
.default_open(filtering) cat.label()
.show(ui, |ui| { ))
for (speaker, clips) in speakers { .id_salt(("acat", *cat))
.default_open(filtering || ctotal <= 40)
.show(ui, |ui| {
for (speaker, entries) in speakers {
// A single-bank group (Static.slb) would be a
// pointless nested header.
let flat = speakers.len() == 1 || entries.len() == 1;
let mut row = |ui: &mut egui::Ui| {
for e in entries {
ui.horizontal(|ui| {
if ui
.button("")
.on_hover_text(&e.clip.name)
.clicked()
{
audio.generation =
audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true;
audio.error = None;
audio.name = e.clip.display.clone();
events.audio.send(RequestAudio {
clip: e.clip.name.clone(),
display: e.clip.display.clone(),
movie: None,
mono: e.category.is_voice(),
generation: audio.generation,
});
}
ui.label(&e.clip.display);
});
}
};
if flat {
row(ui);
} else {
egui::CollapsingHeader::new(format!( egui::CollapsingHeader::new(format!(
"{speaker} ({})", "{speaker} ({})",
clips.len() entries.len()
)) ))
.id_salt(("vspk", *dir, *speaker)) .id_salt(("aspk", *cat, *speaker))
.default_open(filtering || clips.len() <= 6) .default_open(filtering || entries.len() <= 6)
.show(ui, |ui| { .show(ui, &mut row);
for c in clips {
ui.horizontal(|ui| {
if ui
.button("")
.on_hover_text(&c.name)
.clicked()
{
audio.generation =
audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true;
audio.name = c.display.clone();
events.audio.send(RequestAudio {
clip: c.name.clone(),
display: c.display.clone(),
movie: None,
generation: audio.generation,
});
}
ui.label(&c.display);
});
}
});
} }
}); }
});
} }
}); });
} }
}); });
voice_lib.open = open; audio_lib.open = open;
} }
// ── Left panel: file browser ────────────────────────────────────────── // ── Left panel: file browser ──────────────────────────────────────────
@@ -481,11 +525,13 @@ fn draw_viewer_ui(
audio.generation = audio.generation.wrapping_add(1); audio.generation = audio.generation.wrapping_add(1);
audio.loading = true; audio.loading = true;
audio.active = true; // show the panel immediately (spinner) audio.active = true; // show the panel immediately (spinner)
audio.error = None;
audio.name = format!("VOICE_{movie}"); audio.name = format!("VOICE_{movie}");
events.audio.send(RequestAudio { events.audio.send(RequestAudio {
clip: String::new(), clip: String::new(),
display: format!("VOICE_{movie}"), display: format!("VOICE_{movie}"),
movie: Some((movie.clone(), movie_voice.lang)), movie: Some((movie.clone(), movie_voice.lang)),
mono: true, // a cutscene voice track
generation: audio.generation, generation: audio.generation,
}); });
} }
@@ -1348,8 +1394,10 @@ fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
if audio.loading { if audio.loading {
ui.spinner(); ui.spinner();
ui.label("decoding…"); ui.label("decoding…");
} else if let Some(err) = &audio.error {
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), format!("{err}"));
} else { } else {
ui.label("voice track"); ui.label("sound bank");
} }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ Close").clicked() { if ui.button("✖ Close").clicked() {
@@ -1876,6 +1924,21 @@ fn draw_screens_ui(
if !screens.filter.is_empty() && ui.small_button("").clicked() { if !screens.filter.is_empty() && ui.small_button("").clicked() {
screens.filter.clear(); screens.filter.clear();
} }
ui.separator();
// Widens the enumeration to everything `compose` can draw — the
// same rule the pak browser's inline preview uses. Costs a
// re-scan, so it is a deliberate click rather than the default.
if ui
.checkbox(&mut screens.include_fragments, "Fragments")
.on_hover_text(
"Also list RATC bundles with no .rat layout child: the developer-logo \
splash, and ~1 786 two-element fragments (a button beside its glow). \
These are what the PAK browser preview draws but this list omits.",
)
.changed()
{
screens.rescan = true;
}
}); });
ui.separator(); ui.separator();
@@ -1895,6 +1958,12 @@ fn draw_screens_ui(
egui::CollapsingHeader::new(&pak.label) egui::CollapsingHeader::new(&pak.label)
.default_open(pak.builds.len() == 1) .default_open(pak.builds.len() == 1)
.show(ui, |ui| { .show(ui, |ui| {
if pak.truncated {
ui.colored_label(
egui::Color32::from_rgb(224, 168, 86),
"⚠ list truncated (decode budget)",
);
}
for (bi, (entry, size)) in pak.builds.iter().enumerate() { for (bi, (entry, size)) in pak.builds.iter().enumerate() {
let selected = screens.selected == Some(pi) let selected = screens.selected == Some(pi)
&& screens.build == bi; && screens.build == bi;
@@ -1922,6 +1991,32 @@ fn draw_screens_ui(
{ {
recompose = true; recompose = true;
} }
if ui
.checkbox(&mut screens.black_backdrop, "Black backdrop")
.on_hover_text(
"Composite over black instead of the default dim slate. The slate \
stands in for the PRMD dim quad plus the live 3D scene behind an \
in-mission screen; black is what the game composites over on a \
screen carrying its own background — and what a framebuffer \
capture must be compared against.",
)
.changed()
{
recompose = true;
}
if ui
.checkbox(&mut screens.show_primitives, "Primitives")
.on_hover_text(
"Draw the untextured .prm fade/dim/flash quads. Decoded, but their \
paint order is unsolved: a primitive has no T8aD header and so no \
layer key, and the derived order forces keyless elements last. \
That is measurably wrong — expect an opaque quad to wipe some \
screens (32 of GP_DIALOG's builds).",
)
.changed()
{
recompose = true;
}
if screens.composing { if screens.composing {
ui.spinner(); ui.spinner();
ctx.request_repaint(); ctx.request_repaint();
@@ -2081,8 +2176,17 @@ fn draw_screens_ui(
compose.send(RequestScreenCompose { compose.send(RequestScreenCompose {
pak: pak.path.clone(), pak: pak.path.clone(),
build: screens.build, build: screens.build,
// The catalog stored (pak entry index, size) per build; the
// entry index is what lets the worker read one entry.
entry: pak
.builds
.get(screens.build)
.map(|(e, _)| *e)
.unwrap_or(0),
focus: screens.show_focus, focus: screens.show_focus,
animated: screens.show_animated, animated: screens.show_animated,
black_backdrop: screens.black_backdrop,
primitives: screens.show_primitives,
hidden: screens.hidden.clone(), hidden: screens.hidden.clone(),
}); });
} }
@@ -2295,3 +2399,236 @@ fn draw_save_ui(
} }
saves.open &= open; saves.open &= open;
} }
// ── Cutscene browser (View ▸ Cutscenes) ──────────────────────────────────────
/// The cutscene catalog: every slot the manifest binds, with its movie,
/// subtitle track, voice token and telop overlay — and a transcript pane that
/// resolves the captions to text **without playing the video**.
///
/// Before this the manifest was invisible plumbing: it resolved a voice bank and
/// was never rendered, so a cutscene could only be found by hunting `.wmv` in
/// the ISO tree, where nothing says which mission a file belongs to.
#[cfg(not(target_arch = "wasm32"))]
fn draw_cutscenes_ui(
mut contexts: EguiContexts,
mut cut: ResMut<CutsceneBrowser>,
mut requests: EventReader<RequestCutscenes>,
mut file_selected: EventWriter<FileSelected>,
mut browser: ResMut<FileBrowserState>,
) {
if requests.read().next().is_some() {
cut.open = true;
}
if !cut.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
let mut pick: Option<usize> = None;
let mut play: Option<String> = None;
egui::Window::new("🎬 Cutscenes")
.default_width(880.0)
.default_height(560.0)
.open(&mut open)
.show(&ctx, |ui| {
if cut.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading the cutscene manifest…");
});
ctx.request_repaint();
return;
}
if !cut.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(&mut cut.filter);
if !cut.filter.is_empty() && ui.small_button("").clicked() {
cut.filter.clear();
}
ui.separator();
ui.label("Subtitles:");
let before = cut.lang;
egui::ComboBox::from_id_salt("cutscene_lang")
.selected_text(cut.lang.label())
.show_ui(ui, |ui| {
for l in sylpheed_formats::movie_subtitle::SubLang::ALL {
ui.selectable_value(&mut cut.lang, l, l.label());
}
});
if cut.lang != before && cut.selected.is_some() {
cut.want_cues = true;
}
});
let missing = cut.rows.iter().filter(|r| !r.present).count();
ui.label(
egui::RichText::new(format!(
"{} slots · {} distinct movies{}",
cut.rows.len(),
cut.rows
.iter()
.map(|r| r.movie.as_str())
.collect::<std::collections::BTreeSet<_>>()
.len(),
if missing > 0 {
format!(" · ⚠ {missing} bound to a movie not on the disc")
} else {
String::new()
}
))
.weak()
.small(),
);
ui.separator();
let filter = cut.filter.to_lowercase();
egui::SidePanel::left("cutscene_list")
.resizable(true)
.default_width(330.0)
.show_inside(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("cutscene_grid")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
for (i, r) in cut.rows.iter().enumerate() {
let hay = format!("{} {} {}", r.slot, r.movie, r.kind)
.to_lowercase();
if !filter.is_empty() && !hay.contains(&filter) {
continue;
}
let label = match (r.mission, r.phase) {
(Some(m), Some(p)) => format!("S{m:02} ph{p} {}", r.movie),
(Some(m), None) => format!("S{m:02} {}", r.movie),
_ => format!(" {}", r.movie),
};
let mut text = egui::RichText::new(label).monospace();
if !r.present {
text = text.color(egui::Color32::from_rgb(224, 86, 122));
}
if ui
.selectable_label(cut.selected == Some(i), text)
.on_hover_text(&r.slot)
.clicked()
{
pick = Some(i);
}
ui.label(
egui::RichText::new(r.kind).weak().small(),
);
ui.end_row();
}
});
});
});
egui::CentralPanel::default().show_inside(ui, |ui| {
let Some(row) = cut.selected.and_then(|i| cut.rows.get(i)) else {
ui.label("Pick a cutscene on the left.");
return;
};
ui.horizontal(|ui| {
ui.heading(&row.slot);
if row.present {
if ui.button("▶ Play").clicked() {
play = Some(format!("dat/movie/{}.wmv", row.movie));
}
} else {
ui.colored_label(
egui::Color32::from_rgb(224, 86, 122),
"⚠ not on the disc",
);
}
});
egui::Grid::new("cutscene_detail")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
let mut kv = |k: &str, v: String| {
ui.label(egui::RichText::new(k).weak().small());
ui.label(egui::RichText::new(v).monospace());
ui.end_row();
};
kv("kind", row.kind.to_string());
kv("movie", format!("dat/movie/{}.wmv", row.movie));
if let (Some(m), Some(p)) = (row.mission, row.phase) {
kv("mission", format!("S{m:02}, phase {p}"));
} else if let Some(m) = row.mission {
kv("mission", format!("S{m:02}"));
}
kv(
"subtitle",
row.subtitle.clone().unwrap_or_else(|| "".into()),
);
kv(
"voice track",
row.voice_token.clone().unwrap_or_else(|| "".into()),
);
// The .prt overlay is bound here but we have no parser,
// so name the reference and say the content is not read.
kv(
"telop (.prt)",
match &row.telop {
Some(t) => format!("{t} (not decoded)"),
None => "".into(),
},
);
});
ui.separator();
ui.label(egui::RichText::new("Transcript").strong());
if cut.cues_loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("resolving captions…");
});
ctx.request_repaint();
} else if cut.cues.is_empty() {
ui.label(
egui::RichText::new(
"no captions resolved for this movie in this language",
)
.weak(),
);
} else {
egui::ScrollArea::vertical()
.id_salt("cue_scroll")
.show(ui, |ui| {
egui::Grid::new("cue_grid").num_columns(2).striped(true).show(
ui,
|ui| {
for c in &cut.cues {
ui.label(
egui::RichText::new(fmt_time(c.start))
.monospace()
.weak()
.small(),
);
ui.label(&c.text);
ui.end_row();
}
},
);
});
}
});
});
if let Some(i) = pick {
cut.selected = Some(i);
cut.want_cues = true;
}
if let Some(path) = play {
// Route through the normal file-open path, so the existing video player
// handles it exactly as it would from the tree.
browser.loading = true;
browser.selected = browser.files.iter().position(|f| f.eq_ignore_ascii_case(&path));
file_selected.send(FileSelected(path));
}
cut.open = open;
}

67
docker/agent/bin/push-work Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Push the current topic branch to origin — the ONLY sanctioned way out of the
# container.
#
# Why a wrapper instead of plain `git push`:
#
# * **`main` and shared branches are refused.** The agent commits to
# `auto/<topic>`; a human merges. A token that can push anywhere is one
# confused iteration away from rewriting the consolidated line.
# * **Force-push is refused**, always. Nothing here needs it, and history
# rewriting is the one mistake that cannot be undone by merging.
# * It pushes the CURRENT branch only, by name, so a stray `--all` cannot
# publish another agent's worktree branch mid-experiment.
#
# Credentials come from a file mounted read-only at ~/.git-credentials (see
# `sylph-agent`). They are never printed, never logged, and never passed on a
# command line.
#
# push-work push the current branch
# push-work --dry-run say what it would do
set -euo pipefail
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "push-work: not inside a git repository" >&2; exit 1; }
cd "$repo_root"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = "HEAD" ]; then
echo "push-work: detached HEAD — check out a branch first" >&2; exit 1
fi
case "$branch" in
auto/*) ;;
*)
echo "push-work: refusing to push '$branch'." >&2
echo " Only auto/* topic branches may leave the container; a human merges" >&2
echo " them into main. Move your work: git switch -c auto/<topic>" >&2
exit 1 ;;
esac
if [ ! -s "$HOME/.git-credentials" ]; then
echo "push-work: no credentials mounted at ~/.git-credentials." >&2
echo " The host must start the container with SYLPH_GIT_CREDENTIALS pointing" >&2
echo " at a file containing one line:" >&2
echo " https://<user>:<token>@git.mc02.dev" >&2
exit 1
fi
# `store` reads the file we mounted; nothing is written back (it is read-only).
git config --local credential.helper "store --file=$HOME/.git-credentials"
ahead=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || git rev-list --count HEAD)
echo "push-work: $branch — $ahead commit(s) to publish"
if [ "$DRY" = 1 ]; then
echo "push-work: --dry-run, stopping here"
exit 0
fi
# --force-with-lease is deliberately NOT offered. If this is rejected as
# non-fast-forward, someone else moved the branch: fetch and merge, do not
# overwrite.
git push --set-upstream origin "$branch"
echo "push-work: pushed $branch"

View File

@@ -1,48 +1,122 @@
Work the Project Sylpheed reverse-engineering backlog, one item at a time. Answer the open questions the Godot menu port is blocked on, one at a time.
Read `Syplheed-Reborn/docker/agent/AGENT.md` first — it has the container's ## Your objective
tooling and, more importantly, the method rules this corpus is built on.
`Syplheed-Reborn/docs/port/MISSION.md` — read it every iteration. It lists the
open questions Q1…Q9 plus a gated probe S1, and the gate each one must pass.
**You do not build the port.** A separate agent does that, from what you produce.
Your deliverable is decoded, verified, written-down answers with the evidence.
If you find yourself designing an export schema or writing GDScript, you have
crossed the line — go back to the question you were answering.
This is still reverse engineering. What changed is what earns attention: an item
is worth doing when the menu port is blocked on it.
## Read these first, every iteration
Short on purpose, and the reason this prompt is short:
1. `docs/port/MISSION.md` — the open questions, their gates, what is out of scope.
2. `docs/port/HANDOFF.md` — what the port agent has been told so far. **Update it
when you answer something.** An answer not reachable from that page has not
been delivered.
3. `docs/re/REFUTED.md` — claims already tested and dead. Grep it for your nouns
before designing anything.
4. `docs/re/METHOD.md` — the traps this corpus has already paid for.
5. `docs/re/INDEX.md` — what is already decoded. Re-deriving a ✅ row is not a
finding; asking whether its values *resolve* is.
6. `docker/agent/AGENT.md` — the container's tooling.
`docs/re/disc-atlas.html` maps how the assets reference each other — useful when
you need to find what feeds what.
**These files are the memory.** A finding that lives only in your context is lost
when the container dies.
## Each iteration ## Each iteration
1. **Pick one item.** Take the next open entry from 1. **Pick one question** from MISSION.md, preferring the one that blocks the port
`Syplheed-Reborn/docs/re/BACKLOG.md`, preferring the one whose "first step" earliest and whose first step is cheapest. If you are mid-question, continue it
is cheapest and most decisive. If you are mid-item from a previous rather than starting another.
iteration, continue it rather than starting another. 2. **Do the smallest experiment that could settle it**, and try to *refute* your
2. **Do the smallest experiment that could settle it**, and try to *refute* hypothesis before believing it. Run the known-positive through any new filter
your hypothesis before you believe it. first; a filter that fails its own control is dead, not tuneable.
3. **Write the result down** in `docs/re/` under the ✅/🟡/❔ convention, with 3. **Classify the answer honestly.** Every answer is exactly one of:
the evidence. A withdrawn or refuted result is a real result — record it, * **decoded** — the field, plus a disc-wide check;
with the reasoning, rather than deleting it. * **measured** — not on the disc in any form you found, but here is what the
4. **Commit** to a topic branch (below), one logical change per commit. running game does, and here is the capture;
5. **Say plainly what you did not settle**, and stop the iteration. * **undecodable, with reach** — you looked here, here and here, and this is
why it is not there.
Never a fourth thing. *Measured* and *undecodable* mean the port agent will
author that value by hand, and it must know it is authoring rather than
transcribing. Labelling a guess as a decode puts it into the port wearing the
badge of a measurement.
4. **Write it down** in `docs/re/` under the ✅/🟡/❔ convention, with the evidence
and the *reach* of any negative, then update the row in `docs/port/HANDOFF.md`.
* Refuted something → a line in `REFUTED.md`.
* Bitten by a general trap → a line in `METHOD.md`.
* Closed a format → update its `INDEX.md` row.
5. **Commit** to `auto/<topic>`, one logical change per commit.
6. **Publish**: `push-work`. Every iteration that produced a commit.
7. **Say plainly what you did not settle**, and stop.
## Hard rules ## Hard rules
* **Never commit to `main`.** Work on `auto/<topic>` in whichever repo you are * **Do not build the port.** No Godot project, no GDScript, no asset pipeline, no
touching, branched from the current `main`. Create it if it does not exist. export schema, no transcoding. Those belong to the port agent.
* **Never push.** No push credentials are mounted, and that is deliberate — a * **Do not touch `crates/sylpheed-viewer`.** The Explorer is the human's tool for
human reviews before anything leaves the box. exploring and verifying the RE work; it keeps its static-data-only rule and the
* **One emulator at a time.** `run-canary` enforces this with a lockfile; do not port does not depend on it.
work around it. * **Never commit to `main`**, never rebase a shared branch, never delete a branch,
* **Do not edit `main`'s history**, do not rebase shared branches, and do not never rewrite history.
delete branches. * **Do not touch another agent's worktree.** `git worktree list` first; branches
* **Measure the oracle; never infer it.** An iteration that reasons about the marked `+` are checked out elsewhere.
game without running it is a red flag unless it is a pure static-format task. * **One emulator at a time** — `run-canary` enforces it with a lockfile.
* **Verify with an artifact**, not with "it compiles": `build-reborn test` (it * **Measure the oracle; never infer it.** Most of the open questions are about
wires up `SYLPHEED_DISC` — without it the disc tests silently self-skip and a *behaviour* — timing, transitions, what a button does, what a d-pad press does
green run means almost nothing), `sylpheed-cli mesh render`, `screen render`, at the end of a list. Those cannot be answered from the file. An iteration that
`save info`, a screenshot. reasons about the game without running it is a red flag unless the question is
a pure static-format one.
* **Do not improvise around a blocker.** If a question needs a decision only the
user can make, or the container cannot do it, write what you found, note it in
MISSION.md, and move to the next question you can actually finish.
## When you are blocked ## The S1 probe
If an item needs something the container cannot do — hardware Vulkan for a One iteration, then **stop and write the go/no-go**. Do not start Ready Room work
rendering question, a push, a decision only the user can make — **do not on your own authority — MISSION.md §S1 says why.
improvise around it**. Write what you found, note the blocker in `BACKLOG.md`,
and move to the next item. ## Verifying
* `build-reborn test` wires up `SYLPHEED_DISC`; without it the disc tests
self-skip and a green run means almost nothing.
* Verify with an **artifact**, not with "it compiles": `sylpheed-cli screen
info` / `screen render` / `mesh render` / `save info`, a capture, a screenshot.
* Commit the reference data beside the finding, so the port can be built without
a disc in the loop during development.
* A regenerated artifact that comes out byte-identical is strong evidence a change
was additive. When it does change, check that every diff line pairs exactly.
## Publishing
`push-work` pushes the current branch to origin. It refuses anything that is not
`auto/*` and never force-pushes, so the consolidated line stays a human's
decision. Run it **every iteration that produced a commit** — not at the end of
some longer arc, which is exactly when a container dies.
If it reports no credentials, say so in your reply and continue working. Do not
improvise another route out: no remote rewrite, no credential helper of your own,
no alternate transport. A push that is blocked is a blocked push.
## Pacing ## Pacing
Self-pace. A useful iteration is one experiment plus its write-up, not a One experiment plus its write-up is a good iteration; a marathon is not. Stop with
marathon; stopping with a clean commit and an honest "here is what is still a clean commit, a push, and an honest list of what is still open.
open" is the goal every time.
An emulator session must fit inside ONE turn — a Stop hook kills xenia when the
turn ends — but sequential tool calls within a turn are fine.
The loop runs on a fixed interval set by the harness, so you do **not** need to
arm the next wakeup yourself. Spend that attention on the write-up instead.

View File

@@ -21,6 +21,9 @@
# SYLPH_VULKAN=sw force software Vulkan (lavapipe) even if /dev/dri exists # SYLPH_VULKAN=sw force software Vulkan (lavapipe) even if /dev/dri exists
# SYLPH_REMOTE=0 do NOT enable Remote Control (default: enabled for `loose`) # SYLPH_REMOTE=0 do NOT enable Remote Control (default: enabled for `loose`)
# SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent) # SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent)
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
# (default: $HOME/.sylph-git-credentials)
# SYLPH_LOOP_INTERVAL fixed loop cadence, e.g. 30m (default: 45m)
# SYLPH_CPUS / SYLPH_MEM_GB override the computed half # SYLPH_CPUS / SYLPH_MEM_GB override the computed half
set -euo pipefail set -euo pipefail
@@ -125,6 +128,21 @@ docker_args() {
echo " packaged SPIRV-Tools is too old. Running is unaffected." >&2 echo " packaged SPIRV-Tools is too old. Running is unaffected." >&2
fi fi
# ── git push ──
# Read-only, and only ever used by `push-work`, which refuses anything but an
# auto/* branch and never force-pushes. Without this the agent's work only
# exists inside the container and dies with it.
GITCRED="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
if [ -f "$GITCRED" ]; then
_out+=(-v "$GITCRED:/sylph-home/re/.git-credentials:ro")
else
echo "==> NOTE: no git credentials at $GITCRED — the agent cannot push," >&2
echo " so its work will be lost if the container is destroyed. Create it" >&2
echo " with a single line and chmod 600:" >&2
echo " https://<user>:<token>@git.mc02.dev" >&2
echo " or point SYLPH_GIT_CREDENTIALS elsewhere." >&2
fi
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY") [ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
[ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN") [ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN")
[ -n "${SYLPH_REMOTE:-}" ] && _out+=(-e "SYLPH_REMOTE=$SYLPH_REMOTE") [ -n "${SYLPH_REMOTE:-}" ] && _out+=(-e "SYLPH_REMOTE=$SYLPH_REMOTE")
@@ -191,7 +209,12 @@ case "${1:-}" in
TASK="Work the RE backlog in Syplheed-Reborn/docs/re/BACKLOG.md." TASK="Work the RE backlog in Syplheed-Reborn/docs/re/BACKLOG.md."
fi fi
fi fi
INTERVAL="${SYLPH_LOOP_INTERVAL:-}" # empty = let the model self-pace # A FIXED interval by default, not self-pacing. Self-pacing requires the
# agent to call ScheduleWakeup itself at the end of every turn, and the one
# thing an agent deep in an experiment reliably forgets is the bookkeeping
# after it. With an interval the harness owns the cadence and a forgotten
# wakeup cannot end the run. Set SYLPH_LOOP_INTERVAL= (empty) to self-pace.
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
declare -a ARGS; docker_args ARGS declare -a ARGS; docker_args ARGS
ARGS+=(-e SYLPH_AUTONOMOUS=1 -w "$PROJECT") ARGS+=(-e SYLPH_AUTONOMOUS=1 -w "$PROJECT")
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g shm=${SHM_GB}g" echo "==> loose | cpus=$CPUS mem=${MEM_GB}g shm=${SHM_GB}g"
@@ -206,8 +229,19 @@ case "${1:-}" in
echo " ./sylph-agent attach chat with it locally (Ctrl-P Ctrl-Q to leave it running)" echo " ./sylph-agent attach chat with it locally (Ctrl-P Ctrl-Q to leave it running)"
echo " ./sylph-agent stop stop it" echo " ./sylph-agent stop stop it"
echo echo
echo " It commits to auto/* branches and cannot push — no git credentials" # Report what is actually true. This line used to claim unconditionally that
echo " are mounted, so review its work with: git -C '$PROJECT/Syplheed-Reborn' log --oneline auto/..." # the agent could not push, which was written before credentials were
# supported and then went stale — telling the operator their work was at risk
# when it was not, which is the exact failure the credential mount fixes.
if [ -f "${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}" ]; then
echo " It commits to auto/* branches and publishes them with push-work,"
echo " which refuses any other branch and never force-pushes. Review with:"
echo " git -C '$PROJECT/Syplheed-Reborn' fetch origin && git log --oneline origin/auto/..."
else
echo " It commits to auto/* branches and CANNOT PUSH — no git credentials"
echo " are mounted, so its work dies with the container. Review it with:"
echo " git -C '$PROJECT/Syplheed-Reborn' log --oneline auto/..."
fi
;; ;;
logs) shift; exec docker logs "$@" "$NAME" ;; logs) shift; exec docker logs "$@" "$NAME" ;;

99
docs/port/HANDOFF.md Normal file
View File

@@ -0,0 +1,99 @@
# Handoff — what the menu port needs, and where it stands
The single page the **port agent** reads. Everything here is produced by the
container agent's reverse engineering; nothing here is a design decision about
the port itself.
Keep it current. It is a summary with links into `docs/re/`, not a second copy of
the findings — but an answer that is not reachable from this page has not been
delivered.
## How to read an answer
Every row below is one of exactly three things, and the distinction is the point:
| | meaning | what the port should do |
|---|---|---|
| **decoded** | a field on the disc, with a disc-wide check | read it from the data |
| **measured** | not on the disc in any form we found, but the running game does *this* | hardcode it, and cite this page |
| **undecodable** | we looked in these places, it is not there, here is the reach of the negative | author it by hand, knowingly |
There is no fourth kind. If a row says *measured* or *undecodable*, the port is
**authoring** that value, not transcribing it — and it should be kept somewhere a
human can see it is a human decision, so that when it is later decoded the
authored version can be deleted.
## Status
| | Question | State | Answer / link |
|---|---|---|---|
| Q1 | keyframe time unit + ramp shape | ❔ open | blocks all animation |
| Q2 | which build is which screen state | 🟡 partial | build 4 title, 5 main menu, 6/8/9 submenus, `palogo` splash — unconfirmed against captures |
| Q3 | paint order for the six screens | ❔ open | runtime-solved only; declaration table is refuted |
| Q4 | button → GamePart | ❔ open | labels are baked into sprites |
| Q5 | navigation semantics | ❔ open | |
| Q6 | boot sequence + what drives it | 🟡 partial | order observed; the driver is not decoded |
| Q7 | transitions | ❔ open | |
| Q8 | menu audio bindings | ❔ open | cue table complete, event binding is not |
| Q9 | video binding + playback rules | 🟡 partial | `ADV.wmv` is the boot intro; new-game intro unidentified |
| Q10 | music-bank sub-wave roles (intro+loop?) | ❔ open | we concatenate blindly today |
| S1 | Ready Room go/no-go | ❔ open | probe not run |
## Already settled — the port can rely on these today
* **`GP_TITLE.pak` is the whole title-side tree.** Build 4 is the title with the
animating wordmarks, build 5 the five-button main menu, builds 6/8/9 are
submenus, and the developer splash is the `palogo` bundle in the same archive.
✅ decoded (enumeration), 🟡 the state labels are not yet capture-confirmed.
* **Buttons are identifiable as data.** Element kind `0x3002` = button, `0x0` =
decoration, `0x10` = primitive. ✅ decoded.
* **Menu order is geometric.** Buttons sorted top-to-bottom by resting Y. This is
✅ correct for a vertical menu and is **not** a decoded neighbour graph — the
disc's real navigation structure is unknown, and `opt ` is *not* a focus link
(measured and refuted, see
[`ui-focus-and-effect-elements.md`](../re/structures/ui-focus-and-effect-elements.md)).
* **Highlighted states pair by name** — `ptbtn01.rat``ptbtn01f.rat`. 🟡 a
naming convention that holds for all 54 real pairs, not a decoded field.
* **The resting pose is the hold**, not the first, last or longest-dwell keyframe;
a keyframe is the **start of a ramp**.
[`ui-resting-pose.md`](../re/structures/ui-resting-pose.md). ✅
* **The GamePart id table** — 29 entries at `.rdata 0x820A1630`, confirmed by the
executable's own registration strings. ✅ This is the screen vocabulary; which
button reaches which entry is Q4 and is *not* part of it.
* **The logo splash is a screen, not a video.** `logo1``logo4` are
manifest-bound with no `.wmv` on the disc. ✅
* **Sprites carry their own labels.** No font rendering or localisation is needed
for this milestone. ✅
## Facts the port will trip over
* **`ADV.wmv` is WMV3 video + WMA Pro audio**, 1280×720 at 30 fps, 137 s. Godot 4
plays only Ogg Theora natively. How to handle that is the port's decision, not
ours — but it is not optional.
* **The disc holds 3.3 GB of video.** Only the boot intro and the one new-game
intro are in scope.
* **`Static.slb` over-declares its size** by 616 768 bytes — it is the
highest-offset entry in `sound.pak` and its size field is an allocation size. A
reader must allow a short read there and only there.
* **Voice downmixes to mono, music does not.** The left-channel downmix is correct
for spoken lines and discards half a music mix.
* **A music bank has several sub-waves and we glue them together.** `BGM_001`
is 10 KB + 4.47 MB + 4.67 MB, concatenated into one 347 s track. Nobody has
established whether those are intro + loop, two variations, or two halves —
see Q10. Do not build menu looping on the concatenated track until it is
answered.
* **`JNGL_001.slb` does not decode.** One bank in 9 519; its payload is not a whole
number of XMA1 packets from any known data offset.
## Reference data
Committed alongside the findings, so the port can be built without a disc in the
loop during development:
* `sylpheed-cli screen info --build <n> GP_TITLE.pak` — the element table, per
build, with pivots, kinds, focus links, keyframes and resting poses.
* `sylpheed-cli screen render` — the reference composite. When the port draws a
screen, this is what it should be diffed against; where they disagree, one of
them is wrong and the disagreement is worth reporting back.
* `docs/re/captures/` — framebuffer captures of the real screens, for anything
that has to be checked against the game rather than against our renderer.

132
docs/port/MISSION.md Normal file
View File

@@ -0,0 +1,132 @@
# Primary objective — answer everything the menu port needs
**Status:** active, set 2026-08-28. This replaces "work the RE backlog" as the
agent's primary objective. It does not change what the agent *does* — it is still
reverse engineering — it changes what earns attention: an item is worth doing
when the menu port is blocked on it.
## Who builds what
**You do not build the port.** A separate agent will build it, from what you
produce. Your deliverable is decoded, verified, written-down answers plus the
reference data that proves them.
| | container agent (you) | port agent |
|---|---|---|
| decodes the disc | ✅ | ❌ — consumes your answers |
| measures the running game | ✅ | ❌ — no emulator |
| writes `docs/re/` and `docs/port/HANDOFF.md` | ✅ | reads them |
| Godot project, asset pipeline, transcoding | ❌ | ✅ |
If you find yourself designing an export schema or writing GDScript, you have
crossed the line. Stop and go back to the question you were answering.
`crates/sylpheed-viewer` is also **not yours to change** for this objective. The
Explorer is the human's tool for exploring and verifying the RE work, it keeps its
static-data-only rule, and the port does not depend on it.
## The target
Someone else has to build this, from your answers alone:
```
developer logo splash → intro video → title / PRESS Ⓐ → main menu → submenus
```
No gameplay, no 3D, no HUD, no missions. If an answer is not needed to put those
five screens on a display and let a person move through them with a d-pad and Ⓐ,
it is not in this objective.
## What is already answered
Do not re-derive these. They are in `docs/re/` and the
[disc atlas](../re/disc-atlas.html):
* **The screen archive.** `GP_TITLE.pak` holds the whole title-side tree — build 4
the title with animating wordmarks, build 5 the five-button main menu, builds
6/8/9 submenus, and the developer splash as the `palogo` bundle in the same pak.
* **Buttons are identifiable as data.** Element kind `0x3002` is a button, `0x0`
decoration, `0x10` a primitive; buttons sort top-to-bottom by resting Y; each
pairs with an `f`-suffixed highlighted variant.
* **The screen vocabulary.** The GamePart id table, 29 entries at `.rdata
0x820A1630`, confirmed by the executable's own factory-registration strings.
* **The resting pose rule** — the hold, not the longest dwell — and that a
keyframe is the *start of a ramp*.
* **Screen composition**, pixel-accurate for the tutorial pause menu and the title
main menu, via `sylpheed-cli screen render`.
* **The logo splash is not a video.** `logo1``logo4` are manifest-bound with no
`.wmv` on the disc; the splash is the RATC screen, which already renders.
## The open questions — these are the objective
Ordered by what blocks the port earliest. Each is done when its **gate** exists:
a written `docs/re/` result with the evidence, and reference data committed
alongside it.
| | Question | Gate |
|---|---|---|
| **Q1** | **What is a keyframe time?** Values run 16…269. 60 Hz frames would make the title intro ~4.5 s — plausible and untested. Also: is the ramp linear, or eased? | A measured answer against the running game, not an inference. Everything animated downstream depends on this number |
| **Q2** | **Which build is which screen state?** Confirm build↔state for splash, title/PRESS Ⓐ, main menu and each submenu | A table, each row confirmed against a capture of the real screen |
| **Q3** | **Paint order for these six screens.** Solved at runtime, unsolved from the file — the declaration table is provably not it | Either a rule derived from the bundle, or six measured orders and a clear statement that no file-side rule was found |
| **Q4** | **What does each button do?** Labels are baked into the sprites; no decoded field says which GamePart a button opens | Button → GamePart id, from code or from driving the game. Say which |
| **Q5** | **Navigation semantics.** Initial focus, wrap-around at the ends, whether left/right does anything, what B does on each screen | Observed behaviour, per screen |
| **Q6** | **The boot sequence, and what drives it.** Order is observable; the *data or code* that sequences it is not decoded. Include the attract loop and what returns to the title | The sequence, plus whatever the game reads to decide it |
| **Q7** | **Transitions.** What happens visually between screens — the `pteff00.prm` quads, a fade, a cut — and its timing | Described and timed against a capture |
| **Q8** | **Menu audio.** Which BGM per screen; which cue on move / confirm / back / error. The cue table is complete; the event binding is not | Cue names bound to events, with how you established each |
| **Q9** | **Video binding.** Which movie is the boot intro vs the new-game intro; whether playback is skippable and what ends it | Named movies plus the playback rules |
| **Q10** | **What are a music bank's sub-waves?** `BGM_001.slb` is three sub-waves — 10 KB, 4.47 MB, 4.67 MB — and we currently **concatenate them blindly** into one 347 s track. Two near-equal halves could be intro + loop, or two variations, or two halves of one piece. A menu that loops its music needs to know which | The role of each sub-wave, established for at least the menu BGM. "Concatenate" is a decision, not a default — right now it is a default nobody chose |
| **S1** | **Ready Room probe.** *Gated* — one iteration, then stop | A written go/no-go (see below) |
## Known unknowns — say so, do not fill them in
Some of these may turn out to be undecodable. That is a valid, useful answer, and
it is better than a guess, because the port agent will otherwise have to author
the mapping by hand and needs to know it is authoring rather than transcribing.
For each question, the answer is one of:
* **decoded** — here is the field, here is the disc-wide check;
* **measured** — not on the disc in any form we found, but here is what the
running game does, and here is the capture;
* **undecodable, with reach** — we looked here, here and here, and this is why it
is not there.
Never a fourth thing. In particular: if Q4 ends as "read the labels off the sprite
images by eye", say exactly that — it is then an authored mapping on the port
side, not a disc fact, and mislabelling it would put a guess into the port wearing
the badge of a measurement.
## The Ready Room probe (S1) — one iteration, then stop
`GP_READY_ROOM.pak` is the largest UI archive on the disc, 1 106 entries, and only
**6 of its names resolve**. It is also ISL-scripted. That is either a week or a
quarter, and one cheap test tells you which.
Our screen catalog enumerates bundles by **content**, not by name — `is_build` /
`is_composable` read the bytes — so unrecoverable *paths* do not necessarily mean
unrenderable *screens*.
```bash
sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_READY_ROOM.pak"
sylpheed-cli screen render --build <n> "$SYLPHEED_DISC/dat/GP_READY_ROOM.pak" /tmp/rr.png
```
Report how many builds it finds, whether any composite looks like a Ready Room,
and **whether the room is 2D at all** or 3D with a UI overlay — if it is 3D the
answer is no-go by definition, not "try harder".
**Then stop and write the go/no-go.** Do not start Ready Room work on your own
authority.
## Handing it over
[`HANDOFF.md`](HANDOFF.md) is the single page the port agent reads. Keep it
current as you answer questions: it is a summary with links into `docs/re/`, not a
second copy of the findings. An answer that is not reachable from HANDOFF.md has
not been delivered.
## Out of scope
3D, gameplay, HUD, missions, save/load, localisation beyond English, the Godot
project itself, any asset pipeline, and any archive outside `GP_TITLE`,
`tables.pak`, `sound.pak` and `dat/movie/` — except for the S1 probe.

View File

@@ -2,6 +2,11 @@
Confidence: ✅ `CONFIRMED` · 🟡 `PROBABLE` · ❔ `HYPOTHESIS`. See [README](README.md). Confidence: ✅ `CONFIRMED` · 🟡 `PROBABLE` · ❔ `HYPOTHESIS`. See [README](README.md).
Also durable, and worth reading before proposing anything:
[`REFUTED.md`](REFUTED.md) — what has already been tested and died ·
[`METHOD.md`](METHOD.md) — the traps this corpus has already paid for ·
[`BACKLOG.md`](BACKLOG.md) — what is still open.
Formats we've already reversed are, for now, **documented by their parser + disc round-trip Formats we've already reversed are, for now, **documented by their parser + disc round-trip
tests** (the executable spec) rather than a prose file — the "Spec" column points there. tests** (the executable spec) rather than a prose file — the "Spec" column points there.
Promote to a prose `structures/…md` file when a format needs behavioural notes beyond layout. Promote to a prose `structures/…md` file when a format needs behavioural notes beyond layout.
@@ -13,6 +18,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads | | IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads |
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths | | name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` + `tests/idxd_records_disc.rs` ([container](structures/idxd-container.md)) | **The binary record/index region in front of the string pool is DECODED** (2026-08-25), closing the parser's long-standing "not yet decoded" note. Uniform 16-byte records `{name_hash, name_off, field_begin, field_end}` sorted by hash and binary-searched, then a field count, 12-byte fields `{key, name_off, value_off}` sorted by key, a pool size, and the string pool; the trailing `pool_size == file_len - pool_base` identity makes the layout self-checking. Verified over the **whole disc** with **zero** failures: 7 750/7 750 objects, 190 782/190 782 records reproducing their stored `tag_hash`, 1 271 462/1 271 462 named fields reproducing their key — and `IXUD` is the same container with `ixud_hash`, UTF-16BE and all offsets in **chars** (1 104/1 104 objects, 628 165/628 165 fields). **Field names are stored on disc** — a field's middle word points at its own name — so nothing needs preimage search except the **504** field entries disc-wide that are hash-keyed with no name — which are only **42 distinct keys**, each in 12 places (the page for these was never written — the finding is in this row), and are provably unrecoverable from the hash alone; the other 1 485 073 nameless fields are *positional*, keyed by a literal integer (line slots, movie ids). ⚠️ **Two long-held beliefs WITHDRAWN**: the word at `0x08` is **not a schema hash**, it is record 0's `name_hash` (7 750/7 750) — the header has no type field at all, so an object's kind is known only from the caller that loads it; and the field's middle word is **not** an always-`0xFFFFFFFF` flags word. The first was caught by a test asserting that every movie id names a real record: `1005 -> STAGE10_PHASE01` failed because `tag_hash("STAGE10_PHASE01")` **is** `0x067025B9`, that table's supposed schema id. 🟡 the legacy value-before-key string-pool reader is now known to be an *approximation* of the real table, and every number derived from it is re-checkable but not yet re-checked | | IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` + `tests/idxd_records_disc.rs` ([container](structures/idxd-container.md)) | **The binary record/index region in front of the string pool is DECODED** (2026-08-25), closing the parser's long-standing "not yet decoded" note. Uniform 16-byte records `{name_hash, name_off, field_begin, field_end}` sorted by hash and binary-searched, then a field count, 12-byte fields `{key, name_off, value_off}` sorted by key, a pool size, and the string pool; the trailing `pool_size == file_len - pool_base` identity makes the layout self-checking. Verified over the **whole disc** with **zero** failures: 7 750/7 750 objects, 190 782/190 782 records reproducing their stored `tag_hash`, 1 271 462/1 271 462 named fields reproducing their key — and `IXUD` is the same container with `ixud_hash`, UTF-16BE and all offsets in **chars** (1 104/1 104 objects, 628 165/628 165 fields). **Field names are stored on disc** — a field's middle word points at its own name — so nothing needs preimage search except the **504** field entries disc-wide that are hash-keyed with no name — which are only **42 distinct keys**, each in 12 places (the page for these was never written — the finding is in this row), and are provably unrecoverable from the hash alone; the other 1 485 073 nameless fields are *positional*, keyed by a literal integer (line slots, movie ids). ⚠️ **Two long-held beliefs WITHDRAWN**: the word at `0x08` is **not a schema hash**, it is record 0's `name_hash` (7 750/7 750) — the header has no type field at all, so an object's kind is known only from the caller that loads it; and the field's middle word is **not** an always-`0xFFFFFFFF` flags word. The first was caught by a test asserting that every movie id names a real record: `1005 -> STAGE10_PHASE01` failed because `tag_hash("STAGE10_PHASE01")` **is** `0x067025B9`, that table's supposed schema id. 🟡 the legacy value-before-key string-pool reader is now known to be an *approximation* of the real table, and every number derived from it is re-checkable but not yet re-checked |
| IDXD nameless field keys | ✅/❌ | [idxd-unnamed-keys](structures/idxd-unnamed-keys.md) + [`tools/re-capture/idxd_unnamed_keys.py`](../../tools/re-capture/idxd_unnamed_keys.py) | Census of every field entry whose `name_off` is `0xFFFFFFFF`, disc-wide: **7 750 objects, 2 757 039 field entries, 0 parse failures**, `tag_hash` reproducing **1 271 462/1 271 462** named keys. **7 094 distinct keys are never named — and 7 052 of them are not hashes at all**, but author-assigned element ids (equal to the field's own index in 1 404 924 of 1 485 577 cases; `tag_hash("BGM_001")` is `0xC662435B` while the key valued `BGM_001.slb` is `0x000003E9`). ⚠️ **The "504 hash-keyed nameless fields" figure is 504 ENTRIES, not 504 names** — 42 distinct keys × 6 language copies × 2 records. All 42 are **ISL script-symbol hashes** in `<lang>\script\ID.tbl` (GP_READY_ROOM.pak), the link map built by `PrepareScript`'s "isl script prescanning"; 41 of 42 appear as little-endian call targets inside the `.isb` bytecode, forming a coherent launcher/helper call graph. The hash's own algebra pins the **trailing digits of 30 of the 42 names** (deltas of exactly `+0x01000001` across `stage01..09`, `stage10..16`, `challenge01..06`; `+0x01010000` across `tutorial0101..0601`). ❌ **No name was cracked, and the negative is quantified**: seven attacks up to a 3.5×10⁸ composition space found nothing above the noise floor; exhaustive preimage search recovers `"Stage01"` from its own hash but returns nothing for the real targets at ≤6 characters, and at 7 characters one target already has **1 176** preimages — a 24-bit modulus cannot name an 8+ character identifier uniquely |
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ | | XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
| T8aD 2D texture | ✅ | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) | | T8aD 2D texture | ✅ | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) |
| RATC bundle | ✅ | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat``pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** | | RATC bundle | ✅ | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat``pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** |
@@ -94,7 +100,7 @@ files, which is how the same ground got covered twice.
| [`idxd-legacy-reader-audit.md`](idxd-legacy-reader-audit.md) | The legacy IDXD string-pool reader vs the real field table — what the old numbers got wrong | 🟡 shape CONFIRMED by hand (`FCSRange`, `ShieldRatio`, hangar `Model`); disc-wide rates are single-source | | [`idxd-legacy-reader-audit.md`](idxd-legacy-reader-audit.md) | The legacy IDXD string-pool reader vs the real field table — what the old numbers got wrong | 🟡 shape CONFIRMED by hand (`FCSRange`, `ShieldRatio`, hangar `Model`); disc-wide rates are single-source |
| [`structures/idxd-container.md`](structures/idxd-container.md) | The IDXD/IXUD container — record/field table, and the two beliefs it withdraws | ✅ CONFIRMED disc-wide, 7 750/7 750 objects and 1 271 462/1 271 462 named fields, zero failures | | [`structures/idxd-container.md`](structures/idxd-container.md) | The IDXD/IXUD container — record/field table, and the two beliefs it withdraws | ✅ CONFIRMED disc-wide, 7 750/7 750 objects and 1 271 462/1 271 462 named fields, zero failures |
| [`structures/hud-glyph-quad.md`](structures/hud-glyph-quad.md) | The HUD's glyph quad — vtable `0x820B2A64` | ✅ CONFIRMED for the object layout and the atlas size, read live off | | [`structures/hud-glyph-quad.md`](structures/hud-glyph-quad.md) | The HUD's glyph quad — vtable `0x820B2A64` | ✅ CONFIRMED for the object layout and the atlas size, read live off |
| [`structures/slb-data-offset.md`](structures/slb-data-offset.md) | `.slb` leading-stream offset is `first_riff % 2048`, not the constant 1392 | ✅ CONFIRMED by decoding — 85 of 140 sampled banks yield more audio (median 70×), 54 identical controls. ⚠️ The *cause* is a segment-packing phase, not a header: `X = (cumulative .pNN start) mod 2048`. Wave boundaries are exact — `seek` magic at `data_at + declared_size`, **7 620/7 620** — and `Channels` must be read from `RIFF+49` (2.12 % are stereo) | | [`structures/slb-data-offset.md`](structures/slb-data-offset.md) | `.slb` data offset = `(cumulative start of the `.pNN` segment) mod 2048`; the leading bytes are the previous bank's audio, not a header | ✅ CONFIRMED — exact for 8 783/8 783 banks, 0 mismatches; the four values (1392/1468/1600/1728) are the running sums of the five segment sizes mod 2048, which supersedes the earlier `first_riff % 2048` scan heuristic. Decoding at the right offset yields more audio in 85 of 140 sampled banks (median 70×), 54 identical controls. Wave boundaries are exact — `seek` magic at `data_at + declared_size`, **7 620/7 620** — and `Channels` must be read from `RIFF+49` (2.12 % are stereo) |
| [`structures/sound-pak-contents.md`](structures/sound-pak-contents.md) | Census of `sound.pak`, and the limit of the leading-region rule | ✅ CONFIRMED, 5 135/5 135 names hash into the TOC, **9 519/9 519** entries accounted for, and a full 4 114-bank manifest (408.3 min of audio) computed from PsuedoBytesPerSec without decoding; ⚠️ leading-region rule holds for 1 571/4 382 eng and 0/5 100 jpn | | [`structures/sound-pak-contents.md`](structures/sound-pak-contents.md) | Census of `sound.pak`, and the limit of the leading-region rule | ✅ CONFIRMED, 5 135/5 135 names hash into the TOC, **9 519/9 519** entries accounted for, and a full 4 114-bank manifest (408.3 min of audio) computed from PsuedoBytesPerSec without decoding; ⚠️ leading-region rule holds for 1 571/4 382 eng and 0/5 100 jpn |
| [`structures/sound-cue-table.md`](structures/sound-cue-table.md) | The cue index in `tables.pak` — message id -> cue -> sound id -> `.slb` bank | ✅ CONFIRMED, 1 326/1 338 script message ids bind to a bank; SOUNDS and FILES agree on the same 12 absentees, 0 orphan files | | [`structures/sound-cue-table.md`](structures/sound-cue-table.md) | The cue index in `tables.pak` — message id -> cue -> sound id -> `.slb` bank | ✅ CONFIRMED, 1 326/1 338 script message ids bind to a bank; SOUNDS and FILES agree on the same 12 absentees, 0 orphan files |
| [`structures/cutscene-message-table.md`](structures/cutscene-message-table.md) | Cutscene dialogue — speaker, portrait, on-screen seconds, audio cue per page | ✅ CONFIRMED, field count = 9·PageCount+2 for all 7 PageCounts, 1 252/1 252 caption keys match, 138 ids close both ways | | [`structures/cutscene-message-table.md`](structures/cutscene-message-table.md) | Cutscene dialogue — speaker, portrait, on-screen seconds, audio cue per page | ✅ CONFIRMED, field count = 9·PageCount+2 for all 7 PageCounts, 1 252/1 252 caption keys match, 138 ids close both ways |
@@ -116,12 +122,13 @@ files, which is how the same ground got covered twice.
| [`structures/unit-datasheet-static.md`](structures/unit-datasheet-static.md) | The static unit datasheet and AI flight model — `Generic`, `Maneuver`, `Effect` | ✅ CONFIRMED — 394 Generic + 114 Maneuver + 114 Effect records read; AA_/AV_ are one interleaved block (AV at X, AA at X+8), selection 🔴 BLOCKED for static RE (4 routes, all controlled) | | [`structures/unit-datasheet-static.md`](structures/unit-datasheet-static.md) | The static unit datasheet and AI flight model — `Generic`, `Maneuver`, `Effect` | ✅ CONFIRMED — 394 Generic + 114 Maneuver + 114 Effect records read; AA_/AV_ are one interleaved block (AV at X, AA at X+8), selection 🔴 BLOCKED for static RE (4 routes, all controlled) |
| [`structures/weapon-datasheet-static.md`](structures/weapon-datasheet-static.md) | The static weapon datasheet — `Weapon`, `Shell`, `AssortMissileParam` | ✅ CONFIRMED — 131 Weapon + 131 Shell + 9 AssortMissileParam records read | | [`structures/weapon-datasheet-static.md`](structures/weapon-datasheet-static.md) | The static weapon datasheet — `Weapon`, `Shell`, `AssortMissileParam` | ✅ CONFIRMED — 131 Weapon + 131 Shell + 9 AssortMissileParam records read |
| [`structures/isl-timers.md`](structures/isl-timers.md) | A ScriptPhase owns 32 stopwatches, and they count seconds | ✅ CONFIRMED — the advance is read from `sub_822710D0`, the unit from | | [`structures/isl-timers.md`](structures/isl-timers.md) | A ScriptPhase owns 32 stopwatches, and they count seconds | ✅ CONFIRMED — the advance is read from `sub_822710D0`, the unit from |
| [`structures/isl-builtins.md`](structures/isl-builtins.md) | The 147 ISL built-ins — dispatch table, calling convention, and which one does what | ✅ for the table/ABI and ~135 handlers; ✅ 26 `set_unit_hp_pct`, 29 `set_unit_damage_taken_pct`, 101 `all_units_invulnerable`; 🟡 28 |
| [`structures/isl-message-dialogue-link.md`](structures/isl-message-dialogue-link.md) | Mission scripts as dialogue — built-in 64 -> message id -> caption text | ✅ CONFIRMED total, 2 683/2 683 call sites across all 28 stages resolve, no residue | | [`structures/isl-message-dialogue-link.md`](structures/isl-message-dialogue-link.md) | Mission scripts as dialogue — built-in 64 -> message id -> caption text | ✅ CONFIRMED total, 2 683/2 683 call sites across all 28 stages resolve, no residue |
| [`structures/mission-objective-counter.md`](structures/mission-objective-counter.md) | `REMAINING OB` — the mission's own objective counter, in RAM | ✅ CONFIRMED for one Stage 02 run: a big-endian u32 whose value | | [`structures/mission-objective-counter.md`](structures/mission-objective-counter.md) | `REMAINING OB` — the mission's own objective counter, in RAM | ✅ CONFIRMED for one Stage 02 run: a big-endian u32 whose value |
| [`structures/movie-subtitles.md`](structures/movie-subtitles.md) | Movie subtitles & the movie ↔ mission ↔ text chain | — | | [`structures/movie-subtitles.md`](structures/movie-subtitles.md) | Movie subtitles & the movie ↔ mission ↔ text chain | — |
| [`structures/asteroid-fields.md`](structures/asteroid-fields.md) | `AsteroidGroup_00N` — the asteroid-field tables a `Phase_N.AsteroidDefinition` names | ✅ 10 objects/pack, 384 group records, 3 fields each (`AsteroidModelName`, `AsteroidFrameName`, `EnumAsteroid` = **the frame record's `FrameCount`, 57/57**, and the frame holds **`index, quaternion(x,y,z,w), position(x,y,z)`** per asteroid — 37 518 items, unit norm 37 518/37 518); `Enum<Thing>` counts while `Enumerate<Thing>` names a table (54/60); the join resolves as `name_hash("stage\" + name)` 9/9; 🔑 a tenth `Frame_Alpha_S01_*` object nothing references — it miscounts AND its 7 frames have no placement records; 🔑 **S28.Phase_1 borrows `S14_p2_asteroid.tbl`** — the per-phase join shows 9 tables ↔ 9 volumes with one table shared | | [`structures/asteroid-fields.md`](structures/asteroid-fields.md) | `AsteroidGroup_00N` — the asteroid-field tables a `Phase_N.AsteroidDefinition` names | ✅ 10 objects/pack, 384 group records, 3 fields each (`AsteroidModelName`, `AsteroidFrameName`, `EnumAsteroid` = **the frame record's `FrameCount`, 57/57**, and the frame holds **`index, quaternion(x,y,z,w), position(x,y,z)`** per asteroid — 37 518 items, unit norm 37 518/37 518); `Enum<Thing>` counts while `Enumerate<Thing>` names a table (54/60); the join resolves as `name_hash("stage\" + name)` 9/9; 🔑 a tenth `Frame_Alpha_S01_*` object nothing references — it miscounts AND its 7 frames have no placement records; 🔑 **S28.Phase_1 borrows `S14_p2_asteroid.tbl`** — the per-phase join shows 9 tables ↔ 9 volumes with one table shared |
| [`structures/mcol-collision.md`](structures/mcol-collision.md) | `MCOL` — same container and map parameters as `REGN` | 🟡 OPENED — `POF0` at `data_size+16` 11/11, bbox pad words 11/11, `extent == maxmin` 11/11, and bbox + cell-size distributions identical to `REGN` (2/6/3 and 2/9); everything past `0x40` ❔ | | [`structures/mcol-collision.md`](structures/mcol-collision.md) | `MCOL` — same container and map parameters as `REGN` | 🟡 OPENED — `POF0` at `data_size+16` 11/11, bbox pad words 11/11, `extent == maxmin` 11/11, and bbox + cell-size distributions identical to `REGN` (2/6/3 and 2/9); everything past `0x40` ❔ |
| [`structures/regn-map-grid.md`](structures/regn-map-grid.md) | `REGN` — a per-map spatial grid (and `MCOL` beside it) | ✅ CONFIRMED for the header, which self-checks on all 11 objects on | | [`structures/regn-map-grid.md`](structures/regn-map-grid.md) | `REGN` — a stage's tetrahedral navigation mesh (and `MCOL` beside it) | ✅ CONFIRMED — tet mesh + face adjacency + portal costs + uniform grid; decoded from the file's own `POF0` fixup table |
| [`structures/savegame-format.md`](structures/savegame-format.md) | Save file (`savedata`) — container ✅ exact, 3 fields named ✅, rest ❔ (2026-08-11) | ✅ CONFIRMED for the container and the chunk layout — parsed off the | | [`structures/savegame-format.md`](structures/savegame-format.md) | Save file (`savedata`) — container ✅ exact, 3 fields named ✅, rest ❔ (2026-08-11) | ✅ CONFIRMED for the container and the chunk layout — parsed off the |
| [`structures/sound-slb.md`](structures/sound-slb.md) | Sound bank audio — `sound.pak` / `.slb` / XMA1 | — | | [`structures/sound-slb.md`](structures/sound-slb.md) | Sound bank audio — `sound.pak` / `.slb` / XMA1 | — |
| [`structures/stage-definition-table.md`](structures/stage-definition-table.md) | Stage definition table and the squadron (`UnitGroup`) roster | ✅ for the record vocabulary and the stage→table wiring; | | [`structures/stage-definition-table.md`](structures/stage-definition-table.md) | Stage definition table and the squadron (`UnitGroup`) roster | ✅ for the record vocabulary and the stage→table wiring; |

105
docs/re/METHOD.md Normal file
View File

@@ -0,0 +1,105 @@
# Method traps already paid for
Each line cost an iteration at least once. They are general — they are not about
Sylpheed, they are about how this kind of measurement goes wrong.
Like [`REFUTED.md`](REFUTED.md), this list had been living in the autonomous
agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
✅/🟡/❔ confidence convention itself.
## Controls
* **Every result needs a control. A control that fails kills the instrument.**
* **Run the known-positive through a new filter FIRST.** Three filters have been
killed by their own control. When one fails, **read the known-good's
disassembly** before assuming a shape.
* **A measured negative is a result** — but a negative is only as strong as the
route you ran, so **state its reach**.
* **A null result needs its cause shown to have happened.**
* **A result with NO unknowns is suspicious.**
* **Census the whole set; always run the other population as the control.**
**Zero partials is stronger than a majority.**
* **A 2×2 partition is the sharpest general tool** — both off-diagonals empty is
a law.
* **Re-derive a doc's own numbers as the control.**
## Inference
* **Never conclude from ONE sample.**
* **A law proved on one population is a hypothesis on the next.**
* **Finding one exception does not imply a family.**
* **Consistency is not proof. A suggestive coincidence is a coincidence until
measured.** **An analogy is not a measurement.**
* **Same layout ≠ same instance.** **Same record-name set ≠ same object.**
* **A marker is only proven by what it leaves out.**
* **A high-confidence SCORE is not a high-confidence MECHANISM.**
* **Knowing HOW MANY is not knowing WHICH.**
* **Round numbers matching is weak evidence — unless you read the constant.**
* **My own last-turn result is a hypothesis too.**
* **A global partition can understate a per-owner one.**
* **A residual is measured against a population — name it.**
## Searching and tooling
* **A search that returns thousands has no power; state the reach.**
* **A substring match is not a hit.** **A regex miss looks like a null result —
print one raw sample before believing a zero.**
* **A derived table can be a cross product — measure its shape first.**
* **After refuting an instrument, sweep everything that depended on it.**
* **Before measuring how wrong a tool is, read what the tool actually does.**
* **The instrument must pass its own control.**
* **Classify a bulk before mining it. The residual is the prize.**
* **Rank by similarity — the cliff is the finding.** But **read the values
before trusting the rank.**
* **Grep the nouns before designing the experiment — and believe it.**
* **Grep gives you a file list — read *every* file on it.**
* **The answer is often already in the doc that owns the subject — read it end
to end.** A 🟡 often names its own route.
* **Before re-trying a blocked idea, check whether the blocker's own doc already
tried it.**
* **Ship a regenerator with every artefact.** An artefact that moves by a pure
reorder is a tool bug.
* **Never print per-entry lines from a disc-wide sweep — aggregate.**
## Reading the data
* **Read what a loader NAMES, not where it stores.**
* **A field the disc never values still gets named by the loader.**
* **An indexed read beats a deduped-pool adjacency read.**
* **Check the whole string set, not the one matching word.**
* **A dict keyed by record name across a multi-entry pak is a lie.**
* **A set-difference over names hides reuse — join per USER.**
* **A self-index names records, not files.**
* **Case-insensitive hashing means two spellings can be one entry.**
* **An "unresolved" name may be the wrong kind, namespace or prefix — or part of
a cut asset.**
* **A garbled value may be a real string in another encoding.**
* **Two of my own counts disagreeing is a grammar clue.**
* **A game's own typo is a join key.**
* **A bias constant in the code is a join key.**
* **A prefix trap: enumerate maximal `[A-Za-z0-9_]` runs, not `startswith`.**
* **Re-deriving a format is not a finding — asking whether its values *resolve*
is.**
## Mechanics that have bitten
* **Never hand-convert a decimal VA — print `hex()`.**
* **`grep -c` counts LINES** — use `grep -o | wc -l`.
* **`Counter.most_common()` tie-breaks by insertion order — use `sorted()`.**
* **Raw grep cannot see inside compressed pak entries.**
* **Commit messages go in a file** (`git commit -F`); a literal `|` in a table
cell needs escaping; `git log --all -- <path>` can hang.
## Runtime / emulator
* **Look at the PNG** — and check its dimensions.
* **"Animating" is not "still in a mission".**
* **Dedup entity enumerations by position value.**
* **Do not diagnose timing or liveness under gdb.** `ps %cpu` is cumulative.
* **Classify screens by whole-image statistics, not named pixels** — a named
pixel is only valid while the image sits at a known place, and nothing errors
when it moves.
* **Do not poll faster than the guest updates** — it manufactures a clean curve
out of noise.
* **A probe that never performs the action will "prove" the action does not
exist.**

185
docs/re/REFUTED.md Normal file
View File

@@ -0,0 +1,185 @@
# Refuted — claims that were tested and died
**Read this before proposing a hypothesis.** Every line below was believed at
some point, measured, and found false. Reviving one costs a whole iteration and
produces nothing.
This file exists because the list had been living in the autonomous agent's
*loop prompt* — the only copy, lost the moment the container was. A refuted
result is a real result; it belongs in the corpus like any other.
**Format:** each entry is the claim as it was believed. Where the true answer is
known it follows after `→`. Grouped by subject so a grep for your noun finds the
neighbourhood, not just the line.
---
## Offsets, structs and the progress singleton
* `position = instance 0x12c` → refuted.
* `+0x29d0` → refuted.
* "an offset intersection can find a struct's consumer" → **only for LARGE or
unusual offsets.** Small ones have no power (`+184`: 301/351/115 hits).
* "a `+1956` store means a progress write" → writes go through the COPY, not
direct stores. 9 direct stores, none of them a progress write.
* "the singleton-global filter can find progress writers" → it fails its own
control.
* "the progress copy destination is an `r1`-relative stack local" → it is a
**frame register**. The `r1` assumption returned 0 for all 21 candidates
*including the known-good* — the filter was killed by its own control.
* "word B's writer also stores the Time/Points record" → it does not.
* "the debriefing records the metric with the clear bit" → 44 calls, exactly two
strings (`DEBRIEFING`, `BASE_INFO`), no `Time`, no `Points`.
* "`0x820AF030` holds live state" → all 384 words constant; it is a
spawned-entity record, not live state.
## Screens, classes and RTTI
* "the RTTI route can name the anonymous classes" → 1 150 vtables: 1 150
`ANON_`, 0 `rtti_present`, 0 base classes.
* "the sibling vtable methods name the class" → they cannot.
* "`xrefs` can name the callers of a vtable method" → no.
* "the `ind_call` refutation voids existing corpus claims" → damage bounded,
4/4 caller claims verify. But **`xrefs.ind_call` is a CROSS PRODUCT** — always
filter `kind='call'`.
* "`BASE_INFO` marks the 5-slot screen family" → it discriminates
screen-config from table-read, 9/9 vs 10/10.
* "a high key count means a rich screen" → `sub_82297550` / `sub_822A2F00`'s 27
"keys" are coordinate pairs, i.e. a layout table.
* "`EX_` = the CHALLENGE-mission debriefing" → `EX_` is **EXTRA**, mission-kind
3.
* "the `EX_` selection has not been shown" → it has: `[[obj+4]+184] == 3`.
## Stages, missions and the challenge set
* "the disc's stages are numbered 1..28" → S01S16 story, S17 **cut**, S18S23
tutorials, S24S29 challenge.
* "S24S29 are story missions" → they are the challenge missions.
* "the challenge missions have their own maps" → they reuse
`GP_MAIN_GAME_E.pak`'s stage records.
* "the challenge `REQUIREMENT` values are 16,25,26,27,29" → the chain is
16→24→25→26→27→28.
* "the `Extra0n` family shares one leaderboard metric" → `RECORD_TYPE` is
per-stage: 3 Time / 3 Points.
* "stage = filled SHAB count + 1" → refuted.
* "the first TRIGGER is always the point of no return" → refuted.
* "`EnumUnit_S14.tbl` might be missing" / "S14's 13 are a manifest omission" /
"asteroids are exempt from the manifest" → S14's 13 are **dangling
deployments**. NEEDS-HUMAN: fly S14.
* "`StageMessageSet_S02.tbl` is not in the pak" → it is.
* "`S28_p1` has an asteroid volume with no definition" → refuted.
* "`test_s8p1_asteroid.tbl` is test-only" → refuted.
* "the settings family has 28 or 29 objects" → 24.
## ISL / mission scripting
* "the bytecode is in the `.embsec_` sections" → refuted.
* "only 31 built-ins take a unit" → refuted.
* "`sub_8230C398` is the message pump" → refuted.
* "`bus+8216` is the subscriber registry" → refuted.
* "the ScriptPhase vtable is ≥200 slots" → 113.
## IDXD, paks and naming
* "IDXD record keys are `name_hash`" → record keys are **`tag_hash`**
(case-SENSITIVE); `name_hash` is case-INSENSITIVE and used for pak keys.
* "pak TOC order is stage order" / "TOC order is semantic order" → it is not.
* "the executable holds the asset names" → the image names **no data value at
all**; that route is powerless.
* "the image might name a data VALUE" → powerless.
* "the XPR2 manifest names hash to the DefTables tables" → refuted.
* "the `DefTables` model names are unreachable" → reachable via the `Enumerate`
declaration tables (1 413/1 425, 99.2 %).
* "the `GP_MAIN_GAME_*` unnamed block is undiscovered data" → refuted.
* "each `GP_MAIN_GAME_*` `Enumerate` object declares something" → refuted.
* "`GP_HANGAR_ARSENAL` is missing data tables" → refuted.
* "the `Enumeration` self-index can name objects" → a self-index names
**records, not files**.
* "a per-pak prefix might close the 2D blocker" → no.
* "the `+` paths might name the 2D or `GP_READY_ROOM` keys" → the `+`-dictionary
route is exhausted, 0 of 1 817.
* "the `game:\` paths are unresolved" → refuted.
* "a set-difference over file names can see reuse" → it cannot; **join per
USER**. Per-pak copies are ×6.
## Units, weapons, effects and assets
* "`Generic` (394) is the unit datasheet" → refuted.
* "a loadout's `Arm1` names an item" → it names a **hardpoint slot**
(`Turret_NNN`), 59/59.
* "`EnumUnit` and the unit datasheet share a vocabulary" → they do not.
* "the roster is the `Generic.Model` set" → roster 40, `Generic.Model` 46,
`GameResourceID` 480 — three vocabularies.
* "every unit ID is `UN_<l>###_<FACTION>_<name>`" → the grammar is
`UN_<letter>###_[<subkind>_]<FACTION>_<name>`.
* "`_EXn` is the `Extra0n` index" → three different `EX` vocabularies exist.
* "the only two `_EX5` names on the disc are the AA gun and the DeltaSaber" →
refuted.
* "running the tutorial will instantiate the `_Ttrl` weapons" → refuted.
* "the disc has exactly three `EnumWeapon` tables" → four.
* "the `wep_NN` package gaps are unshipped weapons" / "`wep_85` is the tip of a
family" → `wep_85` is the **only** declared-but-unshipped asset (59/0/1/26).
* "nothing is deployed without being declared" → refuted.
* "effects are one namespace" → refuted.
* "the 58 undeclared effect names are missing assets" → refuted.
* "all five orphan effects are unshipped" → refuted.
* "`eff_f0002` ships in `Base.xpr`" → refuted.
* "`Base.xpr` holds more bound effects than `ptc_pack`" → refuted.
* "the 34 unlocated are a scatter" → refuted.
* "the 9 unlocated might be under another prefix" → refuted.
* "`ptc_pack` has 532 names" → 727.
* "the `_e`/`_f` law is effect-FIELD-specific" → it is the **faction law**,
564/564.
* "a disc-wide `.xpr` byte search can show an effect is ABSENT" → it cannot.
* "`rot_n001` is on the disc" → refuted.
* "`rou_f004`'s mesh is in `Stage_S28.xpr`" → it is in `DeltaSaber_A.xpr`.
* "`parent` + `_all` + `_child` is the composite-model convention" → refuted.
`_hangar` **is** real (59 of 166, 59/59 with a bare twin); `_all`/`_child` is
not.
* "`Motion_guard_start` has no damage variants" → refuted.
* "`CoverArea` bits 2 and 3 are mutually exclusive" → refuted.
* "the 27 unresolved `NamePlate` values are missing objects" → refuted.
## LOD, background and misc tables
* "`EnumLODSet_*` is a per-stage family" → `EnumLODSet_test.tbl` serves 17
stages; 17+5+1 = 23.
* "there are 8 orphan LOD tables" → 6.
* "the orphans are stale copies of `_test`" → refuted.
* "S25 is absent from the `DefTables` LOD families" → refuted.
* "`BackGroundID` has no referent anywhere" → it is an **identity**.
* "`BackGroundPackage == BG_<id>.xpr`" → refuted.
* "`<X>ID` + `<X>Package` is a convention" → refuted.
* "`Placement_*` / `RouteTest_*` are unattached" → refuted.
* "the `AsteroidDefinition` join does not reproduce by hash" → it does.
* "the 8-value frame is a new finding" → it was already in the corpus.
## Loaders, config and tuning
* "the config reader is XML" → INI.
* "`sub_822F9498` is the unit-definition loader" → it is `PlayerParams`'s.
* "`sub_822AE628` reads the main-game `Tweak`" → refuted.
* "`sub_8230D1F8` is a rank table" → it is the stage-settings loader.
* "`sub_82286BC8`'s key list is new" → refuted.
* "`sub_825F2CF0` / `sub_825F2F88` read a post-processing table" → refuted.
* "`Booster` is a new schema" / "`Booster` is the player craft's flight
envelope" → refuted; nothing selects `Booster`.
* "the `AnalogRevice`/`Tweak` block is unreachable" → reachable
(`sub_821A6CF0`, base `0x820A1630`).
* "a 0-xref string block has no reader" → refuted.
* "the AI table was NEEDS-HUMAN" → refuted.
* "the `PG*` HUD names are undocumented" → they are documented.
* "a base-solver row identifies a FUNCTION" → it does not.
* "a 64K-boundary base is low confidence" → **inverted**; it is high
confidence.
* "the 0x820B0000 cluster is a false positive" → refuted.
* "a pointer to a function in the image implies a registry" → refuted.
`.pdata` is not a registry.
* "the 13 player-facing chatter tables are the WINGMAN tables" → refuted.
* "the 8 undeclared chatter tables are tutorial chatter" → refuted.
* "other datasheets ship a schema too" → refuted.
## Encoding and text
* "every IDXD string value is ASCII" → 6 non-ASCII values of 99 328.
* "`文字列` is a dev placeholder" → they are Shift-JIS **type words**.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,909 @@
# Every call site of ISL built-ins 26, 28, 29 and 101 across all 28 stage scripts.
#
# Regenerate with:
# python3 tools/re-capture/isl_builtin_sites.py 26,28,29,101 \
# --ssb <dir with StageNN.ssb> --craft <unitgroup.py --all output>
#
# Columns: stage offset builtin slot8 slot4-name slot4-raw symtype before after
# slot8 is the double operand; the handler multiplies it by 0.01 before sending it.
#
Stage01 0x0009C8 29 0 TCN001 0x1B type2 [35,118,12] [12,29,15]
Stage01 0x000A44 29 0 TCN002 0x1E type2 [12,29,12] [15,12,30]
Stage01 0x001A5C 29 100 TCN001 0x1B type2 [127,123,8] [35,11,58]
Stage01 0x0028A8 101 - ? None typeNone [11,95,116] [100,124,93]
Stage01 0x002CF8 101 - ? None typeNone [20,20,116] [100,124,93]
Stage01 0x003210 101 - ? None typeNone [11,9,116] [100,124,93]
Stage01 0x007370 28 10 TCN002 0x1E type2 [1,1,1] [29,30,64]
Stage01 0x0073B0 29 0 TCN002 0x1E type2 [1,1,28] [30,64,64]
Stage01 0x00EDAC 29 0 TCN001 0x1B type2 [35,118,12] [12,15,29]
Stage01 0x00EE80 29 0 TCN002 0x1E type2 [29,12,15] [12,15,29]
Stage01 0x00EF54 29 0 TCN003 0x20 type2 [29,12,15] [12,15,30]
Stage01 0x00F094 28 100 ADT201 0x3 type2 [12,15,30] [12,15,30]
Stage01 0x00F1D4 28 100 ADT202 0x6 type2 [12,15,30] [12,3,15]
Stage01 0x00F348 28 100 ADS221 0x7 type2 [12,3,15] [12,3,15]
Stage01 0x00F4BC 28 200 ADS222 0xB type2 [12,3,15] [12,15,28]
Stage01 0x00F590 28 100 ADS223 0xE type2 [28,12,15] [12,15,12]
Stage01 0x0100B0 29 100 TCN001 0x1B type2 [127,123,8] [35,103,1]
Stage01 0x010398 28 10 TCN002 0x1E type2 [1,1,1] [29,30,11]
Stage01 0x0103D8 29 0 TCN002 0x1E type2 [1,1,28] [30,11,58]
Stage01 0x01060C 28 100 ADT203 0xA type2 [15,30,47] [103,69,63]
Stage01 0x0108B4 28 100 ADT204 0xD type2 [58,15,30] [47,1,1]
Stage01 0x010B40 28 100 ADS224 0x11 type2 [58,3,15] [1,11,58]
Stage01 0x010CE8 28 100 ADS225 0x13 type2 [58,3,15] [1,11,58]
Stage01 0x010DF0 28 100 ADT205 0x10 type2 [11,58,15] [30,47,20]
Stage01 0x01115C 28 200 ADS227 0x17 type2 [57,3,15] [58,15,28]
Stage01 0x011230 28 100 ADS226 0x15 type2 [28,58,15] [48,92,1]
Stage01 0x0115CC 28 100 ADS228 0x19 type2 [58,3,15] [1,11,58]
Stage01 0x011774 28 100 ADS229 0x1A type2 [58,3,15] [1,11,95]
Stage01 0x01184C 101 - ? None typeNone [11,95,116] [100,124,93]
Stage01 0x011D58 101 - ? None typeNone [20,20,116] [100,124,93]
Stage01 0x0127AC 101 - ? None typeNone [11,9,116] [100,124,93]
Stage02 0x000E6C 29 0 TCN001 0x4B type2 [35,118,12] [12,15,12]
Stage02 0x002D14 26 0 TCN131 0x57 type2 [14,77,11] [11,116,127]
Stage02 0x002E0C 29 100 TCN001 0x4B type2 [127,123,8] [35,103,30]
Stage02 0x003220 29 0 TCN002 0x4E type2 [1,1,1] [11,58,15]
Stage02 0x004DA8 101 - ? None typeNone [11,95,116] [100,124,93]
Stage02 0x005274 101 - ? None typeNone [11,20,116] [100,124,93]
Stage02 0x0059BC 101 - ? None typeNone [20,20,116] [100,124,93]
Stage02 0x0060A8 101 - ? None typeNone [11,9,116] [100,124,93]
Stage02 0x01586C 29 0 TCN001 0x4B type2 [35,118,12] [12,15,12]
Stage02 0x018544 29 100 TCN001 0x4B type2 [123,123,8] [35,103,30]
Stage02 0x0186DC 29 0 TCN002 0x4E type2 [1,1,1] [11,58,3]
Stage02 0x019204 101 - ? None typeNone [11,95,116] [100,124,93]
Stage02 0x0196D0 101 - ? None typeNone [11,20,116] [100,124,93]
Stage02 0x01A21C 101 - ? None typeNone [20,20,116] [100,124,93]
Stage02 0x01E114 26 0 ADN207 0x2A type2 [79,7,70] [26,11,47]
Stage02 0x01E154 26 0 TCN208 0x68 type2 [7,70,26] [11,47,7]
Stage02 0x01E2C4 26 0 ADN207 0x2A type2 [47,7,70] [26,11,47]
Stage02 0x01E304 26 0 TCN208 0x68 type2 [7,70,26] [11,47,20]
Stage02 0x01E58C 26 0 ADN207 0x2A type2 [80,7,70] [26,11,47]
Stage02 0x01E5CC 26 0 TCN208 0x68 type2 [7,70,26] [11,47,20]
Stage02 0x01E854 26 0 ADN207 0x2A type2 [80,7,70] [26,11,47]
Stage02 0x01E894 26 0 TCN208 0x68 type2 [7,70,26] [11,47,18]
Stage02 0x025B14 29 0 TCN001 0x4B type2 [35,118,12] [12,15,12]
Stage02 0x025FCC 26 60 TCN324 0x67 type2 [15,12,15] [12,15,26]
Stage02 0x0260A0 26 80 TCN325 0x6A type2 [26,12,15] [12,15,26]
Stage02 0x026174 26 60 TCN326 0x6D type2 [26,12,15] [12,15,12]
Stage02 0x0262DC 26 80 TCN328 0x74 type2 [15,12,15] [12,15,12]
Stage02 0x026AD8 26 80 ADN322 0x1F type2 [15,12,15] [12,15,26]
Stage02 0x026BAC 26 60 ADN323 0x25 type2 [26,12,15] [12,15,26]
Stage02 0x026C80 26 80 ADN324 0x2C type2 [26,12,15] [12,15,26]
Stage02 0x026D54 26 30 ADN325 0x32 type2 [26,12,15] [12,15,26]
Stage02 0x026E28 26 80 ADN326 0x38 type2 [26,12,15] [12,15,26]
Stage02 0x026EFC 26 30 ADN327 0x3D type2 [26,12,15] [12,15,26]
Stage02 0x026FD0 26 60 ADN328 0x42 type2 [26,12,15] [12,15,26]
Stage02 0x0270A4 26 80 ADN329 0x45 type2 [26,12,15] [12,15,12]
Stage02 0x02720C 26 80 ADN331 0x20 type2 [15,12,15] [12,15,26]
Stage02 0x0272E0 26 60 ADN332 0x26 type2 [26,12,15] [47,47,47]
Stage02 0x028B5C 29 100 TCN001 0x4B type2 [123,123,8] [35,103,30]
Stage02 0x028E14 29 0 TCN002 0x4E type2 [1,1,1] [11,26,26]
Stage02 0x028E64 26 0 ADN313 0x1E type2 [1,29,11] [26,11,57]
Stage02 0x028EA4 26 0 TCN334 0x6B type2 [29,11,26] [11,57,30]
Stage02 0x02907C 29 100 ADT301 0x31 type2 [30,15,19] [57,30,15]
Stage02 0x029244 29 100 ADT302 0x37 type2 [30,15,19] [48,47,47]
Stage02 0x029B84 29 100 ADT303 0x3C type2 [30,15,19] [1,64,70]
Stage02 0x029ED0 29 100 ADT304 0x41 type2 [30,15,19] [57,30,15]
Stage02 0x02A098 29 100 ADT305 0x44 type2 [30,15,19] [58,15,48]
Stage02 0x02AAE8 29 100 ADT306 0x47 type2 [30,15,19] [57,30,15]
Stage02 0x02ACB0 29 100 ADT307 0x48 type2 [30,15,19] [57,30,15]
Stage02 0x02AE78 29 100 ADT308 0x49 type2 [30,15,19] [58,15,48]
Stage02 0x02B530 101 - ? None typeNone [11,95,116] [100,124,93]
Stage02 0x02B9FC 101 - ? None typeNone [11,20,116] [100,124,93]
Stage02 0x02C000 101 - ? None typeNone [6,11,116] [100,124,93]
Stage02 0x02C524 101 - ? None typeNone [20,20,116] [100,124,93]
Stage02 0x02D018 101 - ? None typeNone [11,9,116] [100,124,93]
Stage02 0x02D2DC 28 150 TCN004 0x56 type2 [20,105,115] [11,26,64]
Stage02 0x02D350 26 0 ADT301 0x31 type2 [115,28,11] [64,90,11]
Stage02 0x02D4D4 26 0 ADT302 0x37 type2 [64,90,11] [64,90,11]
Stage02 0x02D658 26 0 ADT303 0x3C type2 [64,90,11] [64,90,11]
Stage02 0x02D7DC 26 0 ADT304 0x41 type2 [64,90,11] [64,90,11]
Stage02 0x02D960 26 0 ADT305 0x44 type2 [64,90,11] [64,90,11]
Stage02 0x02DAF0 26 0 ADT306 0x47 type2 [64,90,11] [64,90,11]
Stage02 0x02DC74 26 0 ADT307 0x48 type2 [64,90,11] [64,90,11]
Stage02 0x02DDF8 26 0 ADT308 0x49 type2 [64,90,11] [64,90,11]
Stage03 0x0009F0 29 0 TCN001 0x21 type2 [35,118,12] [12,15,12]
Stage03 0x002514 26 0 TCN132 0x30 type2 [14,77,11] [11,116,127]
Stage03 0x00260C 29 100 TCN001 0x21 type2 [127,123,8] [35,103,1]
Stage03 0x002810 29 0 TCN002 0x24 type2 [30,91,47] [11,58,19]
Stage03 0x0034E8 101 - ? None typeNone [11,95,116] [100,124,93]
Stage03 0x0039B4 101 - ? None typeNone [11,20,116] [100,124,93]
Stage03 0x00424C 101 - ? None typeNone [20,20,116] [100,124,93]
Stage03 0x004720 101 - ? None typeNone [11,9,116] [100,124,93]
Stage03 0x00ABB4 29 0 TCN001 0x21 type2 [35,118,12] [12,15,12]
Stage03 0x00B0CC 29 0 TCT207 0x42 type2 [15,19,30] [12,15,19]
Stage03 0x00C720 29 100 TCN001 0x21 type2 [116,123,8] [35,11,58]
Stage03 0x00DD7C 101 - ? None typeNone [11,95,116] [100,124,93]
Stage03 0x00E248 101 - ? None typeNone [11,20,116] [100,124,93]
Stage03 0x00E84C 101 - ? None typeNone [6,11,116] [100,124,93]
Stage03 0x00EFF8 101 - ? None typeNone [20,20,116] [100,124,93]
Stage03 0x00F844 29 0 TCT207 0x42 type2 [64,19,11] [5,11,20]
Stage03 0x00FA58 29 0 TCT208 0x44 type2 [19,19,11] [5,11,20]
Stage03 0x00FC6C 29 0 TCT209 0x46 type2 [19,19,11] [5,11,20]
Stage03 0x00FE34 29 100 TCT207 0x42 type2 [11,20,20] [11,20,20]
Stage03 0x00FF50 29 100 TCT207 0x42 type2 [11,20,20] [11,20,105]
Stage03 0x01584C 29 0 TCN001 0x21 type2 [35,118,12] [12,15,12]
Stage03 0x0164CC 28 500 ADN301 0x4 type2 [117,12,15] [48,48,48]
Stage03 0x016FF8 29 100 TCN001 0x21 type2 [116,123,8] [35,11,58]
Stage03 0x01713C 28 500 ADN304 0xB type2 [58,15,91] [58,15,30]
Stage03 0x01727C 28 1000 ADT302 0x19 type2 [58,15,30] [91,103,4]
Stage03 0x017C6C 28 500 ADN306 0x11 type2 [11,58,15] [91,69,47]
Stage03 0x0182F4 28 500 ADN308 0x18 type2 [11,58,15] [91,69,47]
Stage03 0x018A4C 28 2000 ADT303 0x1D type2 [15,30,91] [69,47,69]
Stage03 0x0191FC 101 - ? None typeNone [11,95,116] [100,124,93]
Stage03 0x0196C8 101 - ? None typeNone [11,20,116] [100,124,93]
Stage03 0x019CCC 101 - ? None typeNone [6,11,116] [100,124,93]
Stage03 0x019ED4 101 - ? None typeNone [6,11,116] [100,124,93]
Stage03 0x01A744 101 - ? None typeNone [11,9,116] [100,124,93]
Stage03 0x01BC10 28 2000 ADT303 0x1D type2 [15,30,91] [69,47,69]
Stage03 0x01C358 28 150 TCN309 0x41 type2 [9,115,69] [64,11,64]
Stage04 0x000A38 29 0 TCN001 0x15 type2 [35,118,12] [12,15,12]
Stage04 0x002228 29 100 TCN001 0x15 type2 [127,123,8] [35,11,69]
Stage04 0x0026D0 101 - ? None typeNone [11,95,116] [100,124,93]
Stage04 0x002BCC 101 - ? None typeNone [11,20,116] [100,124,93]
Stage04 0x003390 101 - ? None typeNone [20,20,116] [100,124,93]
Stage04 0x003834 101 - ? None typeNone [11,9,116] [100,124,93]
Stage04 0x005ED0 29 0 TCN001 0x15 type2 [35,118,12] [12,15,12]
Stage04 0x006260 28 500 ADN201 0x0 type2 [15,12,15] [12,30,15]
Stage04 0x0063A0 28 1000 ADT202 0x9 type2 [12,30,15] [12,15,12]
Stage04 0x0074CC 29 100 TCN001 0x15 type2 [127,8,123] [35,11,58]
Stage04 0x007674 28 500 ADN206 0x4 type2 [58,15,3] [92,91,64]
Stage04 0x007990 28 500 ADN207 0x6 type2 [58,3,15] [47,1,11]
Stage04 0x007B8C 28 500 ADN208 0x8 type2 [58,15,3] [92,91,64]
Stage04 0x007FAC 28 2000 ADT203 0xD type2 [30,47,30] [64,64,8]
Stage04 0x008324 28 500 ADN209 0xC type2 [58,15,3] [92,91,1]
Stage04 0x0084AC 101 - ? None typeNone [11,95,116] [100,124,93]
Stage04 0x0089A8 101 - ? None typeNone [11,20,116] [100,124,93]
Stage04 0x009030 101 - ? None typeNone [11,20,116] [100,124,93]
Stage04 0x0099DC 101 - ? None typeNone [11,9,116] [100,124,93]
Stage04 0x009D40 28 150 TCN205 0x25 type2 [9,115,69] [64,11,20]
Stage04 0x00A004 28 2000 ADT203 0xD type2 [15,30,47] [64,64,64]
Stage05 0x000898 29 0 TCN001 0x13 type2 [35,118,12] [15,12,15]
Stage05 0x00181C 29 100 TCN001 0x13 type2 [8,127,123] [35,119,11]
Stage05 0x002E6C 101 - ? None typeNone [20,9,116] [100,93,124]
Stage05 0x003BAC 101 - ? None typeNone [20,20,116] [100,93,124]
Stage05 0x003F28 28 150 TCN003 0x19 type2 [9,115,70] [70,48,11]
Stage05 0x007F00 29 0 TCN001 0x13 type2 [35,118,12] [15,12,15]
Stage05 0x0081C0 26 80 TCN004 0x1E type2 [12,15,30] [12,15,30]
Stage05 0x008300 28 150 ADT201 0x4 type2 [12,15,30] [12,19,15]
Stage05 0x0084C8 28 150 ADT202 0x6 type2 [19,15,30] [12,3,15]
Stage05 0x008FF8 29 100 TCN001 0x13 type2 [8,127,123] [35,119,11]
Stage05 0x009734 101 - ? None typeNone [20,9,116] [100,93,124]
Stage05 0x00A228 101 - ? None typeNone [20,20,116] [100,93,124]
Stage05 0x00B1A0 28 150 TCN001 0x13 type2 [64,9,115] [28,28,64]
Stage05 0x00B1E0 28 150 TCN002 0x16 type2 [9,115,28] [28,64,64]
Stage05 0x00B220 28 150 TCN003 0x19 type2 [115,28,28] [64,64,11]
Stage06 0x000924 29 0 TCN001 0x26 type2 [118,12,15] [12,15,12]
Stage06 0x001460 29 100 TCN001 0x26 type2 [8,127,123] [35,119,11]
Stage06 0x00330C 101 - ? None typeNone [11,95,116] [100,93,124]
Stage06 0x003810 101 - ? None typeNone [11,20,116] [100,93,124]
Stage06 0x003E94 101 - ? None typeNone [11,9,116] [100,93,124]
Stage06 0x0040D4 101 - ? None typeNone [6,11,116] [100,93,124]
Stage06 0x009B50 29 0 TCN001 0x26 type2 [118,12,15] [12,15,12]
Stage06 0x00BEC4 29 100 TCN001 0x26 type2 [8,127,123] [35,119,11]
Stage06 0x00C168 101 - ? None typeNone [11,95,116] [100,93,124]
Stage06 0x00C66C 101 - ? None typeNone [11,20,116] [100,93,124]
Stage06 0x00CCF0 101 - ? None typeNone [11,9,116] [100,93,124]
Stage06 0x00D274 101 - ? None typeNone [20,20,116] [100,93,124]
Stage06 0x0119AC 29 0 TCN001 0x26 type2 [118,12,15] [12,15,29]
Stage06 0x011A80 29 0 TCN002 0x29 type2 [29,12,15] [28,12,15]
Stage06 0x011AC0 28 50 TCN002 0x29 type2 [12,15,29] [12,15,12]
Stage06 0x011EFC 26 50 TCN306 0x3D type2 [12,15,3] [12,15,3]
Stage06 0x013A7C 29 100 TCN001 0x26 type2 [8,127,123] [35,119,11]
Stage06 0x013B60 101 - ? None typeNone [11,95,116] [100,93,124]
Stage06 0x01405C 101 - ? None typeNone [11,20,116] [100,93,124]
Stage06 0x014724 101 - ? None typeNone [20,20,116] [100,93,124]
Stage06 0x014F64 101 - ? None typeNone [11,9,116] [100,93,124]
Stage06 0x01845C 26 0 TCN350 0x3A type2 [64,11,64] [1,1,1]
Stage06 0x01A7B0 26 80 TCN002 0x29 type2 [69,11,9] [64,64,1]
Stage06 0x01AAA0 26 50 TCN002 0x29 type2 [69,11,9] [11,64,1]
Stage06 0x01ACEC 26 30 TCN002 0x29 type2 [69,11,9] [11,64,11]
Stage07 0x000D3C 29 0 TCN001 0x1C type2 [12,12,12] [15,15,15]
Stage07 0x0026A4 29 100 TCN001 0x1C type2 [8,127,123] [35,119,11]
Stage07 0x002788 101 - ? None typeNone [11,95,116] [100,93,124]
Stage07 0x002BD0 101 - ? None typeNone [11,20,116] [100,93,124]
Stage07 0x00324C 101 - ? None typeNone [11,20,116] [100,93,124]
Stage07 0x0034D4 101 - ? None typeNone [11,9,116] [100,93,124]
Stage07 0x003714 101 - ? None typeNone [6,11,116] [100,93,124]
Stage07 0x00AB0C 29 0 TCN001 0x1C type2 [12,12,12] [15,15,15]
Stage07 0x00CCD4 29 100 TCN001 0x1C type2 [116,8,123] [35,119,11]
Stage07 0x00CDB8 101 - ? None typeNone [11,95,116] [100,93,124]
Stage07 0x00D200 101 - ? None typeNone [11,20,116] [100,93,124]
Stage07 0x00D87C 101 - ? None typeNone [11,20,116] [100,93,124]
Stage07 0x00E4A8 101 - ? None typeNone [20,69,116] [100,93,124]
Stage07 0x00ED78 101 - ? None typeNone [11,9,116] [100,93,124]
Stage08 0x001F44 29 0 TCN001 0x1E type2 [15,12,15] [15,15,15]
Stage08 0x0021C4 28 0.1 TCN104 0x30 type2 [92,15,30] [15,28,15]
Stage08 0x00225C 28 0.1 TCN105 0x35 type2 [30,28,15] [15,28,15]
Stage08 0x0022F4 28 0.1 TCN106 0x36 type2 [15,28,15] [15,30,28]
Stage08 0x0023F8 28 0.1 TCN012 0x29 type2 [28,15,30] [15,28,15]
Stage08 0x002490 28 0.1 TCN113 0x31 type2 [30,28,15] [15,92,30]
Stage08 0x003350 26 0 ADN126 0xF type2 [4,14,77] [11,26,11]
Stage08 0x0033A0 26 0 ADN126b 0x3D type2 [77,26,11] [11,26,11]
Stage08 0x0033F0 26 0 ADN126c 0x3F type2 [11,26,11] [11,116,8]
Stage08 0x0034E8 29 100 TCN001 0x1E type2 [8,127,123] [35,119,11]
Stage08 0x0036D0 101 - ? None typeNone [20,9,116] [100,93,124]
Stage08 0x004294 101 - ? None typeNone [6,11,116] [100,93,124]
Stage08 0x006C54 28 1000 ADS151a 0x45 type2 [15,47,30] [29,58,15]
Stage08 0x006C94 29 40 ADS151a 0x45 type2 [47,30,28] [58,15,47]
Stage08 0x006E28 28 1000 ADS151b 0x4B type2 [15,47,30] [29,64,8]
Stage08 0x006E68 29 40 ADS151b 0x4B type2 [47,30,28] [64,8,1]
Stage08 0x00BD3C 28 150 TCN001 0x1E type2 [105,9,115] [28,28,64]
Stage08 0x00BD7C 28 150 TCN002 0x21 type2 [9,115,28] [28,64,11]
Stage08 0x00BDBC 28 150 TCN003 0x24 type2 [115,28,28] [64,11,20]
Stage08 0x00D918 29 0 TCN001 0x1E type2 [12,12,12] [15,15,15]
Stage08 0x00E190 29 0 ADT201 0x11 type2 [15,92,15] [117,15,92]
Stage08 0x00EE0C 29 100 TCN001 0x1E type2 [8,127,123] [35,119,11]
Stage08 0x00EFF4 101 - ? None typeNone [20,9,116] [100,93,124]
Stage08 0x00FD4C 101 - ? None typeNone [20,20,116] [100,93,124]
Stage09 0x000974 29 0 TCN001 0x28 type2 [35,118,12] [12,15,12]
Stage09 0x002958 29 100 TCN001 0x28 type2 [127,123,8] [35,11,58]
Stage09 0x003610 101 - ? None typeNone [11,95,116] [100,124,93]
Stage09 0x003A20 101 - ? None typeNone [11,20,116] [100,124,93]
Stage09 0x003C68 101 - ? None typeNone [11,20,116] [100,124,93]
Stage09 0x00426C 101 - ? None typeNone [6,11,116] [100,124,93]
Stage09 0x004A44 101 - ? None typeNone [11,9,116] [100,124,93]
Stage09 0x00851C 29 0 TCN001 0x28 type2 [35,118,12] [12,15,12]
Stage09 0x009E64 29 100 TCN001 0x28 type2 [116,123,8] [35,11,58]
Stage09 0x00A1C0 28 1000 ADS251a 0x44 type2 [12,15,30] [29,12,15]
Stage09 0x00A200 29 40 ADS251a 0x44 type2 [15,30,28] [12,15,30]
Stage09 0x00A340 28 1000 ADS251b 0x46 type2 [12,15,30] [29,47,47]
Stage09 0x00A380 29 40 ADS251b 0x46 type2 [15,30,28] [47,47,1]
Stage09 0x00A6D0 101 - ? None typeNone [11,95,116] [100,124,93]
Stage09 0x00AAE0 101 - ? None typeNone [11,20,116] [100,124,93]
Stage09 0x00ACE8 101 - ? None typeNone [6,11,116] [100,124,93]
Stage09 0x00B2D0 101 - ? None typeNone [7,9,116] [100,124,93]
Stage09 0x00C38C 28 150 TCN001 0x28 type2 [115,9,115] [28,11,64]
Stage09 0x00C3CC 28 150 TCN002 0x2B type2 [9,115,28] [11,64,64]
Stage09 0x00FDC4 29 0 TCN001 0x28 type2 [114,114,12] [12,15,12]
Stage09 0x010684 28 500 ADT301 0x14 type2 [15,12,15] [12,15,12]
Stage09 0x011790 29 100 TCN001 0x28 type2 [116,123,8] [35,11,58]
Stage09 0x011E84 101 - ? None typeNone [11,95,116] [100,124,93]
Stage09 0x012294 101 - ? None typeNone [11,20,116] [100,124,93]
Stage09 0x012B60 101 - ? None typeNone [69,69,116] [100,124,93]
Stage09 0x0131AC 101 - ? None typeNone [11,9,116] [100,124,93]
Stage09 0x017BE4 28 500 ADT302 0x18 type2 [3,15,47] [64,1,1]
Stage10 0x0006DC 29 0 TCN001 0xE type2 [118,75,12] [12,3,15]
Stage10 0x000910 28 2000 ADT001 0x2 type2 [15,30,47] [12,15,47]
Stage10 0x000A38 28 500 ADS002 0x1 type2 [12,15,47] [12,15,47]
Stage10 0x000B60 28 500 ADS003 0x5 type2 [12,15,47] [1,1,1]
Stage10 0x000F38 29 100 TCN001 0xE type2 [8,127,123] [35,119,11]
Stage10 0x00101C 101 - ? None typeNone [11,95,116] [100,93,124]
Stage10 0x0013A8 101 - ? None typeNone [11,9,116] [100,93,124]
Stage10 0x001620 101 - ? None typeNone [11,20,116] [100,93,124]
Stage10 0x002394 28 500 ADS004 0x7 type2 [64,128,47] [1,11,20]
Stage10 0x002BA0 28 500 ADS005 0x9 type2 [64,128,47] [1,11,20]
Stage10 0x0033AC 28 500 ADS006 0xA type2 [64,128,47] [1,11,20]
Stage10 0x003BB8 28 500 ADS007 0xB type2 [64,128,47] [1,11,20]
Stage10 0x0043C4 28 500 ADS008 0xC type2 [64,128,47] [1,11,20]
Stage10 0x004BD0 28 500 ADS009 0xD type2 [64,128,47] [1,11,20]
Stage10 0x0053DC 28 500 ADS010 0x0 type2 [64,128,47] [1,11,20]
Stage10 0x005BE8 28 500 ADS011 0x4 type2 [64,128,47] [1,11,20]
Stage10 0x006554 28 500 ADS013 0x8 type2 [64,128,47] [1,11,20]
Stage11 0x000744 29 0 TCN001 0x18 type2 [35,118,12] [12,15,12]
Stage11 0x001610 29 100 TCN001 0x18 type2 [8,127,123] [35,11,58]
Stage11 0x0017D8 28 1000 ADS151a 0x27 type2 [15,47,30] [29,58,15]
Stage11 0x001818 29 40 ADS151a 0x27 type2 [47,30,28] [58,15,47]
Stage11 0x0019AC 28 1000 ADS151b 0x29 type2 [15,47,30] [29,1,1]
Stage11 0x0019EC 29 40 ADS151b 0x29 type2 [47,30,28] [1,1,1]
Stage11 0x001B0C 101 - ? None typeNone [11,95,116] [100,124,93]
Stage11 0x001F54 101 - ? None typeNone [11,20,116] [100,124,93]
Stage11 0x0025D8 101 - ? None typeNone [11,9,116] [100,124,93]
Stage11 0x002818 101 - ? None typeNone [6,11,116] [100,124,93]
Stage11 0x00B070 29 0 TCN001 0x18 type2 [35,118,12] [12,15,12]
Stage11 0x00BFB8 29 100 TCN001 0x18 type2 [8,127,123] [35,1,1]
Stage11 0x00C0D8 101 - ? None typeNone [11,95,116] [100,124,93]
Stage11 0x00C520 101 - ? None typeNone [11,20,116] [100,124,93]
Stage11 0x00CBA4 101 - ? None typeNone [11,9,116] [100,124,93]
Stage11 0x00CDE4 101 - ? None typeNone [6,11,116] [100,124,93]
Stage12 0x0012D0 29 0 TCN001 0x20 type2 [12,12,12] [75,8,11]
Stage12 0x001564 29 100 TCN001 0x20 type2 [8,127,123] [35,119,1]
Stage12 0x0033CC 101 - ? None typeNone [20,9,116] [100,93,124]
Stage12 0x00403C 101 - ? None typeNone [6,11,116] [100,93,124]
Stage13 0x0012C4 29 0 TCN001 0x16 type2 [12,12,12] [15,15,15]
Stage13 0x001D74 26 50 TCN013 0x25 type2 [3,15,1] [3,15,1]
Stage13 0x0027DC 28 150 ADN004 0x8 type2 [47,92,3] [15,3,28]
Stage13 0x002914 28 150 ADN005 0xB type2 [28,15,3] [15,47,28]
Stage13 0x002A00 28 150 ADN012 0x6 type2 [28,15,47] [15,47,47]
Stage13 0x0031BC 29 100 TCN001 0x16 type2 [116,8,123] [35,119,11]
Stage13 0x003348 101 - ? None typeNone [20,20,116] [100,93,124]
Stage13 0x004EC4 101 - ? None typeNone [20,69,116] [100,93,124]
Stage13 0x0098A0 28 150 ADN002 0x3 type2 [20,9,57] [15,47,47]
Stage13 0x00A68C 28 500 ADS051a 0x34 type2 [64,11,58] [15,47,92]
Stage13 0x00A894 28 500 ADS051b 0x36 type2 [92,30,58] [15,47,92]
Stage13 0x00AA9C 28 500 ADS051c 0x37 type2 [92,30,58] [15,47,92]
Stage13 0x00BA00 28 150 ADN015 0xE type2 [64,11,57] [15,92,64]
Stage13 0x00BE08 28 150 ADN017 0x12 type2 [64,11,57] [15,47,92]
Stage14 0x001230 29 0 TCN001 0x18 type2 [12,12,12] [15,15,92]
Stage14 0x001394 26 80 TCN003 0x24 type2 [15,15,92] [25,15,30]
Stage14 0x001548 28 500 ADT102 0x3 type2 [15,30,15] [30,47,92]
Stage14 0x001714 28 500 ADT103 0x6 type2 [47,92,15] [30,47,92]
Stage14 0x0018E0 28 500 ADT104 0x8 type2 [47,92,15] [30,47,92]
Stage14 0x00242C 29 100 TCN001 0x18 type2 [8,127,123] [35,119,11]
Stage14 0x002510 101 - ? None typeNone [11,95,116] [100,93,124]
Stage14 0x002960 101 - ? None typeNone [11,9,116] [100,93,124]
Stage14 0x002D84 101 - ? None typeNone [20,20,116] [100,93,124]
Stage14 0x003784 28 500 ADT105 0xA type2 [64,58,15] [30,47,47]
Stage14 0x003E90 28 500 ADT108 0x10 type2 [64,58,15] [30,47,1]
Stage14 0x004548 28 500 ADT114 0xB type2 [64,58,15] [30,47,47]
Stage14 0x007334 29 0 TCN201 0x29 type2 [12,12,12] [15,30,47]
Stage14 0x00748C 28 500 ADT201 0x5 type2 [15,30,47] [15,15,15]
Stage14 0x007F18 29 100 TCN201 0x29 type2 [8,127,123] [35,119,11]
Stage14 0x007FFC 101 - ? None typeNone [11,95,116] [100,93,124]
Stage14 0x0083C4 101 - ? None typeNone [11,9,116] [100,93,124]
Stage14 0x008644 101 - ? None typeNone [11,20,116] [100,93,124]
Stage15 0x0014F8 29 0 TCN001 0x3A type2 [12,12,12] [15,15,15]
Stage15 0x002390 29 0 TCN008 0x59 type2 [15,47,30] [1,15,48]
Stage15 0x002C68 28 150 ADN101 0x1 type2 [15,48,92] [15,28,15]
Stage15 0x002D00 28 150 ADN102 0x2 type2 [92,28,15] [15,3,1]
Stage15 0x002FC8 28 150 ADN103 0x5 type2 [15,48,92] [15,3,15]
Stage15 0x0032C0 28 150 ADN104 0xB type2 [48,47,92] [15,3,15]
Stage15 0x003564 28 150 ADN106 0x18 type2 [15,48,92] [15,28,15]
Stage15 0x0035FC 28 150 ADN109 0x2C type2 [92,28,15] [15,15,47]
Stage15 0x003F50 29 100 TCN001 0x3A type2 [116,8,123] [35,119,11]
Stage15 0x0040DC 101 - ? None typeNone [20,20,116] [100,93,124]
Stage15 0x00563C 101 - ? None typeNone [20,69,116] [100,93,124]
Stage15 0x00DE90 29 0 TCN001 0x3A type2 [12,12,12] [15,15,15]
Stage15 0x00E970 29 0 TCN008 0x59 type2 [47,92,30] [15,48,92]
Stage15 0x00F198 28 150 ADT201 0x28 type2 [47,92,30] [15,1,30]
Stage15 0x00F2C0 28 150 ADT202 0x30 type2 [15,1,30] [15,1,28]
Stage15 0x00F37C 28 150 ADN203 0xF type2 [28,15,1] [15,28,15]
Stage15 0x00F414 28 150 ADN204 0x15 type2 [1,28,15] [15,28,15]
Stage15 0x00F4AC 28 150 ADN205 0x1C type2 [15,28,15] [15,28,15]
Stage15 0x00F544 28 150 ADN206 0x21 type2 [15,28,15] [15,28,15]
Stage15 0x00F5DC 28 150 ADN207 0x27 type2 [15,28,15] [15,28,15]
Stage15 0x00F674 28 150 ADN208 0x2F type2 [15,28,15] [15,28,15]
Stage15 0x00F70C 28 150 ADN209 0x36 type2 [15,28,15] [15,15,28]
Stage15 0x00F7FC 28 500 ADN210 0x4 type2 [28,15,15] [47,92,1]
Stage15 0x00F980 28 500 ADN211 0x9 type2 [92,1,15] [47,92,1]
Stage15 0x00FB04 28 500 ADN212 0x10 type2 [92,1,15] [47,47,92]
Stage15 0x00FCDC 28 500 ADN213 0x16 type2 [92,1,15] [47,47,92]
Stage15 0x00FEB4 28 500 ADN214 0x1D type2 [92,1,15] [47,47,92]
Stage15 0x0102DC 29 100 TCN001 0x3A type2 [8,127,123] [35,119,11]
Stage15 0x0104C4 101 - ? None typeNone [20,9,116] [100,93,124]
Stage15 0x0110CC 101 - ? None typeNone [20,20,116] [100,93,124]
Stage15 0x013368 28 500 ADN215 0x22 type2 [20,58,15] [47,92,1]
Stage15 0x01358C 28 500 ADN216 0x2A type2 [20,58,15] [47,92,11]
Stage15 0x01378C 28 500 ADN217 0x32 type2 [20,58,15] [47,47,92]
Stage15 0x0139E0 28 500 ADN218 0x37 type2 [20,58,15] [47,47,92]
Stage15 0x013C34 28 500 ADN219 0x39 type2 [20,58,15] [47,47,92]
Stage15 0x013E88 28 500 ADN220 0xA type2 [20,58,15] [47,92,11]
Stage15 0x014088 28 500 ADN221 0x11 type2 [20,58,15] [47,92,11]
Stage15 0x014924 28 150 ADN222 0x17 type2 [64,11,57] [15,64,64]
Stage15 0x014D28 28 500 ADN223 0x1E type2 [11,58,15] [47,47,92]
Stage15 0x015170 28 150 ADN224 0x23 type2 [64,11,57] [15,64,64]
Stage15 0x01553C 28 500 ADN225 0x2B type2 [11,58,15] [47,92,64]
Stage15 0x0158E8 28 500 ADN226 0x33 type2 [11,58,15] [47,92,58]
Stage15 0x015A84 28 500 ADN227 0x38 type2 [92,58,15] [47,92,64]
Stage16 0x0004F4 29 100 TCN001 0x1 type2 [8,127,123] [35,119,11]
Stage16 0x0005D8 101 - ? None typeNone [11,95,116] [100,93,124]
Stage16 0x000A28 101 - ? None typeNone [11,9,116] [100,93,124]
Stage16 0x000E4C 101 - ? None typeNone [140,140,116] [100,93,124]
Stage18 0x000488 28 0 TCN001 0xB type2 [35,8,12] [52,1,11]
Stage18 0x0009D4 26 0 ADT101 0x0 type2 [1,11,18] [13,0,97]
Stage18 0x000D2C 26 0 ADT102 0x2 type2 [1,11,18] [13,0,11]
Stage18 0x000E30 26 0 ADT103 0x4 type2 [0,11,18] [13,0,11]
Stage18 0x000F34 26 0 ADT104 0x5 type2 [0,11,18] [13,0,11]
Stage18 0x0013E4 26 0 ADT105 0x6 type2 [1,11,18] [13,0,11]
Stage18 0x0014E8 26 0 ADT106 0x7 type2 [0,11,18] [13,0,11]
Stage18 0x0015EC 26 0 ADT107 0x8 type2 [0,11,18] [13,0,11]
Stage18 0x001AB0 26 0 ADT108 0x9 type2 [1,11,18] [13,0,97]
Stage18 0x001E2C 28 100 TCN001 0xB type2 [4,1,11] [12,3,15]
Stage18 0x0029FC 26 100 TCN001 0xB type2 [6,11,20] []
Stage19 0x0003D4 28 0 TCN001 0x7 type2 [35,8,12] [52,1,11]
Stage19 0x000CD4 28 100 TCN001 0x7 type2 [11,104,4] [1,11,69]
Stage19 0x000F0C 26 0 ADS101 0x0 type2 [16,0,12] [26,84,4]
Stage19 0x000F4C 26 0 ADS102 0x1 type2 [0,12,26] [84,4,97]
Stage19 0x001010 28 0 TCN001 0x7 type2 [4,97,4] [12,3,15]
Stage19 0x0012F8 26 0 ADS104 0x2 type2 [4,16,0] [84,4,97]
Stage19 0x001394 28 100 TCN001 0x7 type2 [84,4,97] [12,3,15]
Stage19 0x0019F4 26 100 TCN001 0x7 type2 [6,11,20] []
Stage20 0x0026C0 26 100 TCN001 0xB type2 [6,11,20] []
Stage21 0x0002A8 28 0 TCN001 0x2 type2 [35,8,12] [26,52,1]
Stage21 0x0002E8 26 40 TCN001 0x2 type2 [8,12,28] [52,1,12]
Stage21 0x000B58 28 100 TCN001 0x2 type2 [0,84,4] [97,4,12]
Stage21 0x0012F4 26 100 TCN001 0x2 type2 [6,11,20] []
Stage22 0x00098C 26 0 ADT101 0x0 type2 [1,11,18] [0,11,69]
Stage22 0x000AD0 26 0 ADT102 0x2 type2 [69,11,18] [0,11,69]
Stage22 0x001384 26 0 ADS103 0x1 type2 [11,69,0] [97,1,11]
Stage22 0x001470 26 0 ADT104 0x3 type2 [1,11,18] [0,11,69]
Stage22 0x001868 26 0 ADT106 0x5 type2 [1,11,18] [0,11,69]
Stage22 0x001F1C 26 0 ADT107 0x6 type2 [1,11,18] [0,11,69]
Stage22 0x002130 26 100 TCN001 0x7 type2 [6,11,20] []
Stage23 0x0002A8 28 0 TCN001 0x3 type2 [35,8,12] [52,1,11]
Stage23 0x000898 26 0 ADS105 0x2 type2 [4,16,0] [84,4,97]
Stage23 0x000B98 28 100 TCN001 0x3 type2 [11,104,4] [1,11,69]
Stage23 0x0012AC 26 100 TCN001 0x3 type2 [6,11,20] []
Stage24 0x001274 29 0 ADT108 0x1E type2 [12,30,117] [12,12,12]
Stage24 0x001EE8 29 100 TCN001 0x2C type2 [127,123,8] [35,119,1]
Stage24 0x0029B0 29 100 ADT108 0x1E type2 [15,47,64] [20,64,11]
Stage24 0x0045F8 29 100 ADT109 0x22 type2 [15,48,47] [64,1,7]
Stage24 0x004A44 29 0 ADT109 0x22 type2 [58,30,117] [64,7,58]
Stage24 0x0051B8 29 100 ADT103 0x8 type2 [30,15,47] [64,64,1]
Stage24 0x0056B8 29 0 ADT103 0x8 type2 [58,30,117] [64,20,4]
Stage24 0x007094 101 - ? None typeNone [20,20,116] [100,124,93]
Stage24 0x007304 101 - ? None typeNone [11,95,116] [100,124,93]
Stage24 0x007710 101 - ? None typeNone [11,20,116] [100,124,93]
Stage24 0x007DBC 101 - ? None typeNone [11,9,116] [100,124,93]
Stage25 0x001790 28 100 ADN355 0x21 type2 [15,12,15] [12,15,28]
Stage25 0x001864 28 100 ADN350 0xF type2 [28,12,15] [12,15,28]
Stage25 0x001938 28 100 ADN351 0x13 type2 [28,12,15] [12,15,28]
Stage25 0x001A0C 28 100 ADN352 0x17 type2 [28,12,15] [12,15,28]
Stage25 0x001AE0 28 100 ADN353 0x1B type2 [28,12,15] [12,15,28]
Stage25 0x001BB4 28 100 ADN354 0x1E type2 [28,12,15] [12,15,28]
Stage25 0x001C88 28 200 ADN310 0x1 type2 [28,12,15] [12,15,117]
Stage25 0x004218 29 100 TCN001 0x27 type2 [127,123,8] [35,119,1]
Stage25 0x00440C 28 300 ADN312 0x5 type2 [11,57,16] [1,11,57]
Stage25 0x0044F8 28 300 ADN313 0x9 type2 [11,57,16] [1,11,58]
Stage25 0x004600 28 600 ADN314 0xD type2 [11,58,15] [47,47,1]
Stage25 0x004964 28 600 ADN315 0x11 type2 [11,58,15] [47,47,1]
Stage25 0x005348 28 100 ADN350 0xF type2 [143,57,15] [145,47,47]
Stage25 0x005814 28 200 ADN356 0x23 type2 [5,57,15] [145,47,47]
Stage25 0x005BAC 28 200 ADN356 0x23 type2 [143,57,15] [145,47,47]
Stage25 0x00709C 28 100 ADN351 0x13 type2 [143,57,15] [145,47,47]
Stage25 0x0074C8 28 200 ADN357 0x24 type2 [5,57,15] [145,47,47]
Stage25 0x007860 28 200 ADN357 0x24 type2 [143,57,15] [145,47,47]
Stage25 0x0088E8 28 100 ADN352 0x17 type2 [143,57,15] [145,47,47]
Stage25 0x008D14 28 200 ADN358 0x25 type2 [5,57,15] [145,47,47]
Stage25 0x0090AC 28 200 ADN358 0x25 type2 [143,57,15] [145,47,47]
Stage25 0x00A59C 28 100 ADN353 0x1B type2 [143,57,15] [145,47,47]
Stage25 0x00A9C8 28 200 ADN359 0x26 type2 [5,57,15] [145,47,47]
Stage25 0x00AD60 28 200 ADN359 0x26 type2 [143,57,15] [145,47,47]
Stage25 0x00BC70 28 100 ADN354 0x1E type2 [143,57,15] [145,47,47]
Stage25 0x00C09C 28 200 ADN360 0x14 type2 [5,57,15] [145,47,47]
Stage25 0x00C434 28 200 ADN360 0x14 type2 [143,57,15] [145,47,47]
Stage25 0x00D924 28 100 ADN355 0x21 type2 [143,57,15] [145,47,47]
Stage25 0x00DD50 28 200 ADN361 0x18 type2 [5,57,15] [145,47,47]
Stage25 0x00E0E8 28 200 ADN361 0x18 type2 [143,57,15] [145,47,47]
Stage25 0x00ECD4 28 200 ADN310 0x1 type2 [143,57,15] [19,64,20]
Stage25 0x00F390 28 300 ADN311 0x2 type2 [5,57,15] [19,1,64]
Stage25 0x00F704 28 300 ADN311 0x2 type2 [143,57,15] [19,64,8]
Stage25 0x010EFC 28 300 ADN312 0x5 type2 [9,143,57] [16,64,69]
Stage25 0x0111A8 26 0 ADN320 0x3 type2 [69,20,18] [69,8,69]
Stage25 0x011450 28 300 ADN320 0x3 type2 [9,128,15] [47,47,1]
Stage25 0x011C0C 28 300 ADN320 0x3 type2 [143,128,15] [47,47,1]
Stage25 0x011FFC 28 300 ADN320 0x3 type2 [143,128,15] [47,47,1]
Stage25 0x012240 26 0 ADN321 0x7 type2 [69,20,18] [69,8,69]
Stage25 0x0124E8 28 300 ADN321 0x7 type2 [9,128,15] [47,47,1]
Stage25 0x012CA4 28 300 ADN321 0x7 type2 [143,128,15] [47,47,1]
Stage25 0x013094 28 300 ADN321 0x7 type2 [143,128,15] [47,47,1]
Stage25 0x0134C0 28 300 ADN328 0x20 type2 [9,128,15] [47,1,69]
Stage25 0x013BE8 28 300 ADN328 0x20 type2 [143,128,15] [47,1,69]
Stage25 0x0142CC 28 300 ADN313 0x9 type2 [9,143,57] [16,64,69]
Stage25 0x0146F4 28 300 ADN325 0x16 type2 [9,128,15] [47,1,69]
Stage25 0x014E1C 28 300 ADN325 0x16 type2 [143,128,15] [47,1,18]
Stage25 0x0151F4 28 300 ADN329 0x22 type2 [9,128,15] [47,1,69]
Stage25 0x01591C 28 300 ADN329 0x22 type2 [143,128,15] [47,1,69]
Stage25 0x016058 28 600 ADN314 0xD type2 [143,57,15] [47,47,145]
Stage25 0x016B84 28 600 ADN315 0x11 type2 [143,57,15] [47,47,145]
Stage25 0x017094 101 - ? None typeNone [64,95,116] [100,124,93]
Stage25 0x01748C 101 - ? None typeNone [11,20,116] [100,124,93]
Stage25 0x017AE8 101 - ? None typeNone [11,9,116] [100,124,93]
Stage26 0x000EF0 29 0 TCN001 0x16 type2 [118,146,12] [15,12,15]
Stage26 0x001614 29 50 TCT012 0x28 type2 [15,3,30] [12,15,3]
Stage26 0x0017F4 29 50 TCT013 0x2A type2 [15,3,30] [12,15,3]
Stage26 0x0019D4 29 50 TCT014 0x2C type2 [15,3,30] [12,15,3]
Stage26 0x001BB4 29 50 TCT015 0x2E type2 [15,3,30] [12,15,3]
Stage26 0x001D94 29 50 TCT016 0x30 type2 [15,3,30] [12,15,3]
Stage26 0x001F74 29 50 TCT017 0x32 type2 [15,3,30] [12,15,3]
Stage26 0x002154 29 50 TCT018 0x34 type2 [15,3,30] [12,15,3]
Stage26 0x002334 29 50 TCT019 0x36 type2 [15,3,30] [12,15,3]
Stage26 0x003928 29 100 TCN001 0x16 type2 [127,123,8] [35,11,95]
Stage26 0x003A00 101 - ? None typeNone [11,95,116] [100,124,93]
Stage26 0x003E10 101 - ? None typeNone [11,20,116] [100,124,93]
Stage26 0x004414 101 - ? None typeNone [6,11,116] [100,124,93]
Stage26 0x004664 101 - ? None typeNone [11,9,116] [100,124,93]
Stage26 0x008DD0 29 20 ADN128 0x14 type2 [47,11,57] [15,3,46]
Stage26 0x00AD0C 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00AD4C 26 0 TCT012 0x28 type2 [5,5,26] [4,64,11]
Stage26 0x00AF4C 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00AF8C 26 0 TCT013 0x2A type2 [5,5,26] [4,64,11]
Stage26 0x00B18C 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00B1CC 26 0 TCT014 0x2C type2 [5,5,26] [4,64,11]
Stage26 0x00B3CC 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00B40C 26 0 TCT015 0x2E type2 [5,5,26] [4,64,11]
Stage26 0x00B60C 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00B64C 26 0 TCT016 0x30 type2 [5,5,26] [4,64,11]
Stage26 0x00B84C 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00B88C 26 0 TCT017 0x32 type2 [5,5,26] [4,64,11]
Stage26 0x00BA8C 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00BACC 26 0 TCT018 0x34 type2 [5,5,26] [4,64,11]
Stage26 0x00BCCC 26 0 ADN128 0x14 type2 [18,5,5] [26,4,64]
Stage26 0x00BD0C 26 0 TCT019 0x36 type2 [5,5,26] [4,64,11]
Stage27 0x000AAC 29 0 TCN001 0x12 type2 [118,146,12] [15,12,15]
Stage27 0x002088 29 100 TCN001 0x12 type2 [127,123,8] [35,11,95]
Stage27 0x002160 101 - ? None typeNone [11,95,116] [100,124,93]
Stage27 0x002570 101 - ? None typeNone [11,20,116] [100,124,93]
Stage27 0x002BBC 101 - ? None typeNone [11,9,116] [100,124,93]
Stage27 0x00339C 101 - ? None typeNone [20,20,116] [100,124,93]
Stage28 0x00101C 29 0 TCN001 0x13 type2 [118,146,12] [15,12,30]
Stage28 0x0020EC 29 100 TCN001 0x13 type2 [127,123,8] [35,11,95]
Stage28 0x0021C4 101 - ? None typeNone [11,95,116] [100,124,93]
Stage28 0x00256C 101 - ? None typeNone [20,20,116] [100,124,93]
Stage28 0x002958 101 - ? None typeNone [11,9,116] [100,124,93]
Stage28 0x002E4C 29 0 ADT102 0x2 type2 [12,12,12] [29,29,136]
Stage28 0x002E8C 29 0 ADT103 0x4 type2 [12,12,29] [29,136,13]
Stage28 0x002ECC 29 0 ADT104 0x6 type2 [12,29,29] [136,13,1]
Stage28 0x003BE8 29 0 ADT107 0xB type2 [12,12,12] [29,29,136]
Stage28 0x003C28 29 0 ADT108 0xC type2 [12,12,29] [29,136,13]
Stage28 0x003C68 29 0 ADT109 0xD type2 [12,29,29] [136,13,64]
Stage28 0x004848 29 0 ADT113 0x7 type2 [12,12,12] [29,29,136]
Stage28 0x004888 29 0 ADT114 0x8 type2 [12,12,29] [29,136,13]
Stage28 0x0048C8 29 0 ADT115 0xA type2 [12,29,29] [136,13,1]
Stage28 0x0050F8 26 0 ADT102 0x2 type2 [11,70,18] [13,70,136]
Stage28 0x005330 26 0 ADT103 0x4 type2 [11,70,18] [13,70,11]
Stage28 0x005520 26 0 ADT104 0x6 type2 [11,70,18] [13,11,70]
Stage28 0x005638 26 0 ADT107 0xB type2 [11,70,18] [13,70,136]
Stage28 0x005870 26 0 ADT108 0xC type2 [11,70,18] [13,70,11]
Stage28 0x005A60 26 0 ADT109 0xD type2 [11,70,18] [13,11,70]
Stage28 0x005B78 26 0 ADT113 0x7 type2 [11,70,18] [13,70,136]
Stage28 0x005DB0 26 0 ADT114 0x8 type2 [11,70,18] [13,70,11]
Stage28 0x005FA0 26 0 ADT115 0xA type2 [11,70,18] [13,11,64]
Stage29 0x0083A0 29 100 TCN001 0x11 type2 [12,12,12] [15,29,15]
Stage29 0x008438 29 100 TCN002 0x14 type2 [12,29,15] [15,29,15]
Stage29 0x0084D0 29 50 TCN003 0x18 type2 [15,29,15] [15,92,15]
Stage29 0x008740 29 75 TCN004 0x1F type2 [15,30,3] [15,3,29]
Stage29 0x008878 29 50 TCN005 0x21 type2 [29,15,3] [15,30,19]
Stage29 0x008AA4 29 75 TCT006 0x2C type2 [30,19,3] [15,3,3]
Stage29 0x008C7C 29 50 TCN007 0x25 type2 [15,3,3] [15,3,3]
Stage29 0x008E54 29 50 TCN110 0x17 type2 [15,3,3] [15,3,3]
Stage29 0x00902C 29 50 TCN111 0x1D type2 [15,3,3] [15,3,3]
Stage29 0x009204 29 50 TCN113 0x22 type2 [15,3,3] [15,30,29]
Stage29 0x009308 29 0 TCN008 0x26 type2 [29,15,30] [92,15,48]
Stage29 0x0094B4 29 50 TCN009 0x29 type2 [92,15,48] [15,92,29]
Stage29 0x0095C0 29 50 TCN114 0x24 type2 [29,15,92] [1,15,92]
Stage29 0x0096F0 29 50 TCN116 0x28 type2 [1,15,92] [1,15,48]
Stage29 0x00984C 29 50 TCN119 0x2B type2 [1,15,48] [1,1,75]
Stage29 0x00CCF0 29 100 TCN001 0x11 type2 [127,123,8] [35,119,11]
Stage29 0x010580 101 - ? None typeNone [69,95,116] [100,93,124]
Stage29 0x0109A8 101 - ? None typeNone [11,20,116] [100,124,93]
Stage29 0x011004 101 - ? None typeNone [11,20,116] [100,124,93]
Stage29 0x01128C 101 - ? None typeNone [11,9,116] [100,93,124]
Stage29 0x01158C 101 - ? None typeNone [6,11,116] [100,93,124]
Stage29 0x011D74 29 50 TCN120 0x1E type2 [128,15,92] [11,20,11]
Stage29 0x011FD0 29 50 TCN121 0x20 type2 [128,15,92] [11,69,57]
Stage29 0x012D0C 28 200 ADN101ea1 0x0 type2 [145,128,15] [145,47,47]
Stage29 0x013294 28 200 ADN101ea2 0x1 type2 [1,128,15] [29,145,47]
Stage29 0x0132D4 29 50 ADN101ea2 0x1 type2 [128,15,28] [145,47,47]
Stage29 0x01381C 28 200 ADN102ea1 0x2 type2 [145,128,15] [145,47,47]
Stage29 0x013DA4 28 200 ADN102ea2 0x3 type2 [1,128,15] [29,145,47]
Stage29 0x013DE4 29 50 ADN102ea2 0x3 type2 [128,15,28] [145,47,47]
Stage29 0x01432C 28 200 ADN103ea1 0x4 type2 [145,128,15] [145,47,47]
Stage29 0x0148B4 28 200 ADN103ea2 0x6 type2 [1,128,15] [29,145,47]
Stage29 0x0148F4 29 50 ADN103ea2 0x6 type2 [128,15,28] [145,47,47]
Stage29 0x014E3C 28 200 ADN104ea1 0x7 type2 [145,128,15] [145,47,47]
Stage29 0x0153C4 28 200 ADN104ea2 0xA type2 [1,128,15] [29,145,47]
Stage29 0x015404 29 50 ADN104ea2 0xA type2 [128,15,28] [145,47,47]
Stage29 0x01594C 28 200 ADN121ea1 0x5 type2 [145,128,15] [145,47,47]
Stage29 0x015EB0 28 200 ADN121ea2 0x8 type2 [92,128,15] [29,145,47]
Stage29 0x015EF0 29 50 ADN121ea2 0x8 type2 [128,15,28] [145,47,47]
Stage29 0x016338 28 200 ADN122ea1 0x9 type2 [145,128,15] [145,47,47]
Stage29 0x01689C 28 200 ADN122ea2 0xB type2 [92,128,15] [29,145,47]
Stage29 0x0168DC 29 50 ADN122ea2 0xB type2 [128,15,28] [145,47,47]
Stage29 0x016D24 28 200 ADN123ea1 0xC type2 [145,128,15] [145,47,47]
Stage29 0x017288 28 200 ADN123ea2 0xD type2 [92,128,15] [29,145,47]
Stage29 0x0172C8 29 50 ADN123ea2 0xD type2 [128,15,28] [145,47,47]
Stage29 0x017710 28 200 ADN124ea1 0xE type2 [145,128,15] [145,47,47]
Stage29 0x017C74 28 200 ADN124ea2 0xF type2 [92,128,15] [29,145,47]
Stage29 0x017CB4 29 50 ADN124ea2 0xF type2 [128,15,28] [145,47,47]
Stage29 0x01C5FC 28 200 ADN101b 0x2F type2 [64,57,15] [1,4,57]
Stage29 0x01C71C 28 120 ADN101a 0x2E type2 [4,57,15] [1,4,57]
Stage29 0x01C83C 28 120 ADN102a 0x30 type2 [4,57,15] [1,4,57]
Stage29 0x01C95C 28 200 ADN102b 0x33 type2 [4,57,15] [1,4,57]
Stage29 0x01CA7C 28 120 ADN103a 0x34 type2 [4,57,15] [1,4,57]
Stage29 0x01CB9C 28 200 ADN103b 0x39 type2 [4,57,15] [1,4,57]
Stage29 0x01CCBC 28 120 ADN104a 0x3A type2 [4,57,15] [1,4,57]
Stage29 0x01CDDC 28 300 ADN101e 0x40 type2 [4,57,15] [1,4,57]
Stage29 0x01CEFC 28 300 ADN102e 0x4B type2 [4,57,15] [1,4,57]
Stage29 0x01D01C 28 300 ADN103e 0x54 type2 [4,57,15] [1,4,57]
Stage29 0x01D13C 28 300 ADN104e 0x5C type2 [4,57,15] [1,4,57]
Stage29 0x01D25C 28 120 ADN105a 0x43 type2 [4,57,15] [1,4,57]
Stage29 0x01D37C 28 120 ADN106a 0x4D type2 [4,57,15] [1,4,57]
Stage29 0x01D49C 28 120 ADN107a 0x56 type2 [4,57,15] [1,4,57]
Stage29 0x01D5C4 28 200 ADN101c 0x32 type2 [4,57,15] [1,4,57]
Stage29 0x01D6E4 28 300 ADN101e 0x40 type2 [4,57,15] [1,4,57]
Stage29 0x01D804 28 120 ADN101a 0x2E type2 [4,57,15] [1,4,57]
Stage29 0x01D924 28 200 ADN101b 0x2F type2 [4,57,15] [1,4,57]
Stage29 0x01DA44 28 120 ADN102a 0x30 type2 [4,57,15] [1,4,57]
Stage29 0x01DB64 28 200 ADN102b 0x33 type2 [4,57,15] [1,4,57]
Stage29 0x01DC84 28 120 ADN103a 0x34 type2 [4,57,15] [1,4,57]
Stage29 0x01DDA4 28 200 ADN103b 0x39 type2 [4,57,15] [1,4,57]
Stage29 0x01DEC4 28 120 ADN104a 0x3A type2 [4,57,15] [1,4,57]
Stage29 0x01DFE4 28 120 ADN105a 0x43 type2 [4,57,15] [1,4,57]
Stage29 0x01E104 28 200 ADN102c 0x38 type2 [4,57,15] [1,4,57]
Stage29 0x01E224 28 300 ADN102e 0x4B type2 [4,57,15] [1,4,57]
Stage29 0x01E344 28 120 ADN106a 0x4D type2 [4,57,15] [1,4,57]
Stage29 0x01E464 28 200 ADN104b 0x42 type2 [4,57,15] [1,4,57]
Stage29 0x01E58C 28 200 ADN101c 0x32 type2 [4,57,15] [1,4,57]
Stage29 0x01E6AC 28 200 ADN102c 0x38 type2 [4,57,15] [1,4,57]
Stage29 0x01E7CC 28 120 ADN101a 0x2E type2 [4,57,15] [1,4,57]
Stage29 0x01E8EC 28 300 ADN101e 0x40 type2 [4,57,15] [1,4,57]
Stage29 0x01EA0C 28 120 ADN102a 0x30 type2 [4,57,15] [1,4,57]
Stage29 0x01EB2C 28 120 ADN103a 0x34 type2 [4,57,15] [1,4,57]
Stage29 0x01EC4C 28 120 ADN104a 0x3A type2 [4,57,15] [1,4,57]
Stage29 0x01ED6C 28 300 ADN102e 0x4B type2 [4,57,15] [1,4,57]
Stage29 0x01EE8C 28 200 ADN101b 0x2F type2 [4,57,15] [1,4,57]
Stage29 0x01EFAC 28 200 ADN102b 0x33 type2 [4,57,15] [1,4,57]
Stage29 0x01F0CC 28 120 ADN105a 0x43 type2 [4,57,15] [1,4,57]
Stage29 0x01F1EC 28 200 ADN103b 0x39 type2 [4,57,15] [1,4,57]
Stage29 0x01F30C 28 300 ADN103e 0x54 type2 [4,57,15] [1,4,57]
Stage29 0x01F42C 28 300 ADN104e 0x5C type2 [4,57,15] [1,57,15]
Stage29 0x01F52C 28 200 ADN101c 0x32 type2 [1,57,15] [1,4,57]
Stage29 0x01F64C 28 200 ADN101b 0x2F type2 [4,57,15] [1,4,57]
Stage29 0x01F76C 28 120 ADN101a 0x2E type2 [4,57,15] [1,4,57]
Stage29 0x01F88C 28 120 ADN102a 0x30 type2 [4,57,15] [1,4,57]
Stage29 0x01F9AC 28 300 ADN101e 0x40 type2 [4,57,15] [1,4,57]
Stage29 0x01FACC 28 200 ADN102b 0x33 type2 [4,57,15] [1,4,57]
Stage29 0x01FBEC 28 120 ADN103a 0x34 type2 [4,57,15] [1,4,57]
Stage29 0x01FD0C 28 120 ADN104a 0x3A type2 [4,57,15] [1,4,57]
Stage29 0x01FE2C 28 300 ADN102e 0x4B type2 [4,57,15] [1,4,57]
Stage29 0x01FF4C 28 200 ADN103b 0x39 type2 [4,57,15] [1,4,57]
Stage29 0x02006C 28 300 ADN101d 0x37 type2 [4,57,15] [1,4,57]
Stage29 0x02018C 28 200 ADN102c 0x38 type2 [4,57,15] [1,4,57]
Stage29 0x0202AC 28 300 ADN103e 0x54 type2 [4,57,15] [1,4,57]
Stage29 0x0203CC 28 300 ADN104e 0x5C type2 [4,57,15] [1,4,57]
Stage29 0x0204F4 28 300 ADN101d 0x37 type2 [4,57,15] [1,4,57]
Stage29 0x020614 28 200 ADN101c 0x32 type2 [4,57,15] [1,4,57]
Stage29 0x020734 28 200 ADN101b 0x2F type2 [4,57,15] [1,4,57]
Stage29 0x020854 28 200 ADN102c 0x38 type2 [4,57,15] [1,4,57]
Stage29 0x020974 28 120 ADN101a 0x2E type2 [4,57,15] [1,4,57]
Stage29 0x020A94 28 200 ADN102b 0x33 type2 [4,57,15] [1,4,57]
Stage29 0x020BB4 28 300 ADN101e 0x40 type2 [4,57,15] [1,4,57]
Stage29 0x020CD4 28 300 ADN102e 0x4B type2 [4,57,15] [1,4,57]
Stage29 0x020DF4 28 200 ADN103b 0x39 type2 [4,57,15] [1,4,57]
Stage29 0x020F14 28 120 ADN102a 0x30 type2 [4,57,15] [1,4,57]
Stage29 0x021034 28 120 ADN103a 0x34 type2 [4,57,15] [1,4,57]
Stage29 0x021154 28 120 ADN104a 0x3A type2 [4,57,15] [1,4,57]
Stage29 0x021274 28 300 ADN103e 0x54 type2 [4,57,15] [1,4,57]
Stage29 0x021394 28 300 ADN104e 0x5C type2 [4,57,15] [1,4,57]
Stage29 0x0214BC 28 200 ADN101b 0x2F type2 [4,57,15] [1,4,57]
Stage29 0x0215DC 28 300 ADN101e 0x40 type2 [4,57,15] [1,4,57]
Stage29 0x0216FC 28 120 ADN101a 0x2E type2 [4,57,15] [1,4,57]
Stage29 0x02181C 28 300 ADN102e 0x4B type2 [4,57,15] [1,4,57]
Stage29 0x02193C 28 120 ADN102a 0x30 type2 [4,57,15] [1,4,57]
Stage29 0x021A5C 28 300 ADN103e 0x54 type2 [4,57,15] [1,4,57]
Stage29 0x021B7C 28 120 ADN103a 0x34 type2 [4,57,15] [1,4,57]
Stage29 0x021C9C 28 120 ADN104a 0x3A type2 [4,57,15] [1,4,57]
Stage29 0x021DBC 28 120 ADN105a 0x43 type2 [4,57,15] [1,4,57]
Stage29 0x021EDC 28 120 ADN106a 0x4D type2 [4,57,15] [1,4,57]
Stage29 0x021FFC 28 200 ADN101c 0x32 type2 [4,57,15] [1,57,15]
Stage29 0x0220F4 28 200 ADN102b 0x33 type2 [1,57,15] [1,4,57]
Stage29 0x022214 28 200 ADN102c 0x38 type2 [4,57,15] [1,4,57]
Stage29 0x022334 28 300 ADN104e 0x5C type2 [4,57,15] [1,57,15]
Stage29 0x022434 28 300 ADN101e 0x40 type2 [1,57,15] [1,4,57]
Stage29 0x022554 28 200 ADN101b 0x2F type2 [4,57,15] [1,4,57]
Stage29 0x022674 28 300 ADN102e 0x4B type2 [4,57,15] [1,4,57]
Stage29 0x022794 28 120 ADN101a 0x2E type2 [4,57,15] [1,4,57]
Stage29 0x0228B4 28 200 ADN101c 0x32 type2 [4,57,15] [1,4,57]
Stage29 0x0229D4 28 300 ADN101d 0x37 type2 [4,57,15] [1,4,57]
Stage29 0x022AF4 28 120 ADN102a 0x30 type2 [4,57,15] [1,4,57]
Stage29 0x022C14 28 120 ADN103a 0x34 type2 [4,57,15] [1,4,57]
Stage29 0x022D34 28 120 ADN104a 0x3A type2 [4,57,15] [1,4,57]
Stage29 0x022E54 28 200 ADN102b 0x33 type2 [4,57,15] [1,4,57]
Stage29 0x022F74 28 300 ADN103e 0x54 type2 [4,57,15] [1,4,57]
Stage29 0x023094 28 120 ADN105a 0x43 type2 [4,57,15] [1,4,57]
Stage29 0x0231B4 28 200 ADN102c 0x38 type2 [4,57,15] [1,4,57]
Stage29 0x0232D4 28 120 ADN106a 0x4D type2 [4,57,15] [1,4,11]
Stage29 0x023764 28 200 ADN141a 0x3E type2 [16,16,16] [145,47,47]
Stage29 0x023EFC 28 200 ADN141b 0x48 type2 [16,16,16] [29,145,47]
Stage29 0x023F3C 29 50 ADN141b 0x48 type2 [16,16,28] [145,47,47]
Stage29 0x024420 28 200 ADN142a 0x49 type2 [16,16,16] [145,47,47]
Stage29 0x024BB8 28 200 ADN142b 0x52 type2 [16,16,16] [29,145,47]
Stage29 0x024BF8 29 50 ADN142b 0x52 type2 [16,16,28] [145,47,47]
Stage29 0x02517C 28 200 ADN143a 0x53 type2 [16,16,16] [145,47,47]
Stage29 0x025914 28 200 ADN143b 0x5A type2 [16,16,16] [29,145,47]
Stage29 0x025954 29 50 ADN143b 0x5A type2 [16,16,28] [145,47,47]
Stage29 0x025F78 28 200 ADN144a 0x5B type2 [16,16,16] [145,47,47]
Stage29 0x026710 28 200 ADN144b 0x61 type2 [16,16,16] [29,145,47]
Stage29 0x026750 29 50 ADN144b 0x61 type2 [16,16,28] [145,47,47]
Stage29 0x026CD4 28 200 ADN145a 0x62 type2 [16,16,16] [145,47,47]
Stage29 0x02746C 28 200 ADN145b 0x65 type2 [16,16,16] [29,145,47]
Stage29 0x0274AC 29 50 ADN145b 0x65 type2 [16,16,28] [145,47,47]
Stage29 0x02783C 28 200 ADN121b 0x35 type2 [64,57,15] [1,4,57]
Stage29 0x02795C 28 120 ADN121a 0x31 type2 [4,57,15] [1,4,57]
Stage29 0x027A7C 28 120 ADN122a 0x36 type2 [4,57,15] [1,4,57]
Stage29 0x027B9C 28 200 ADN122b 0x3C type2 [4,57,15] [1,4,57]
Stage29 0x027CBC 28 120 ADN123a 0x3D type2 [4,57,15] [1,4,57]
Stage29 0x027DDC 28 200 ADN123b 0x46 type2 [4,57,15] [1,4,57]
Stage29 0x027EFC 28 120 ADN124a 0x47 type2 [4,57,15] [1,4,57]
Stage29 0x02801C 28 300 ADN121e 0x4E type2 [4,57,15] [1,4,57]
Stage29 0x02813C 28 300 ADN122e 0x57 type2 [4,57,15] [1,4,57]
Stage29 0x02825C 28 300 ADN123e 0x5E type2 [4,57,15] [1,4,57]
Stage29 0x02837C 28 300 ADN124e 0x63 type2 [4,57,15] [1,4,57]
Stage29 0x02849C 28 120 ADN125a 0x51 type2 [4,57,15] [1,4,57]
Stage29 0x0285BC 28 120 ADN126a 0x59 type2 [4,57,15] [1,4,57]
Stage29 0x0286DC 28 120 ADN127a 0x60 type2 [4,57,15] [1,4,64]
Stage29 0x0288A4 28 200 ADN121c 0x3B type2 [64,57,15] [1,4,57]
Stage29 0x0289C4 28 300 ADN121e 0x4E type2 [4,57,15] [1,4,57]
Stage29 0x028AE4 28 120 ADN121a 0x31 type2 [4,57,15] [1,4,57]
Stage29 0x028C04 28 200 ADN121b 0x35 type2 [4,57,15] [1,4,57]
Stage29 0x028D24 28 120 ADN122a 0x36 type2 [4,57,15] [1,4,57]
Stage29 0x028E44 28 200 ADN122b 0x3C type2 [4,57,15] [1,4,57]
Stage29 0x028F64 28 120 ADN123a 0x3D type2 [4,57,15] [1,4,57]
Stage29 0x029084 28 200 ADN123b 0x46 type2 [4,57,15] [1,4,57]
Stage29 0x0291A4 28 120 ADN124a 0x47 type2 [4,57,15] [1,4,57]
Stage29 0x0292C4 28 120 ADN125a 0x51 type2 [4,57,15] [1,4,57]
Stage29 0x0293E4 28 200 ADN122c 0x45 type2 [4,57,15] [1,4,57]
Stage29 0x029504 28 300 ADN122e 0x57 type2 [4,57,15] [1,4,57]
Stage29 0x029624 28 120 ADN126a 0x59 type2 [4,57,15] [1,4,57]
Stage29 0x029744 28 200 ADN124b 0x50 type2 [4,57,15] [1,4,64]
Stage29 0x0299AC 28 200 ADN121c 0x3B type2 [64,57,15] [1,4,57]
Stage29 0x029ACC 28 200 ADN122c 0x45 type2 [4,57,15] [1,4,57]
Stage29 0x029BEC 28 120 ADN121a 0x31 type2 [4,57,15] [1,4,57]
Stage29 0x029D0C 28 300 ADN121e 0x4E type2 [4,57,15] [1,4,57]
Stage29 0x029E2C 28 120 ADN122a 0x36 type2 [4,57,15] [1,4,57]
Stage29 0x029F4C 28 120 ADN123a 0x3D type2 [4,57,15] [1,4,57]
Stage29 0x02A06C 28 120 ADN124a 0x47 type2 [4,57,15] [1,4,57]
Stage29 0x02A18C 28 300 ADN122e 0x57 type2 [4,57,15] [1,4,57]
Stage29 0x02A2AC 28 200 ADN121b 0x35 type2 [4,57,15] [1,4,57]
Stage29 0x02A3CC 28 200 ADN122b 0x3C type2 [4,57,15] [1,4,57]
Stage29 0x02A4EC 28 120 ADN125a 0x51 type2 [4,57,15] [1,4,57]
Stage29 0x02A60C 28 200 ADN123b 0x46 type2 [4,57,15] [1,4,57]
Stage29 0x02A72C 28 300 ADN123e 0x5E type2 [4,57,15] [1,4,57]
Stage29 0x02A84C 28 300 ADN124e 0x63 type2 [4,57,15] [1,64,64]
Stage29 0x02AA8C 28 200 ADN121c 0x3B type2 [64,57,15] [1,4,57]
Stage29 0x02ABAC 28 200 ADN121b 0x35 type2 [4,57,15] [1,4,57]
Stage29 0x02ACCC 28 120 ADN121a 0x31 type2 [4,57,15] [1,4,57]
Stage29 0x02ADEC 28 120 ADN122a 0x36 type2 [4,57,15] [1,4,57]
Stage29 0x02AF0C 28 300 ADN121e 0x4E type2 [4,57,15] [1,4,57]
Stage29 0x02B02C 28 200 ADN122b 0x3C type2 [4,57,15] [1,4,57]
Stage29 0x02B14C 28 120 ADN123a 0x3D type2 [4,57,15] [1,4,57]
Stage29 0x02B26C 28 120 ADN124a 0x47 type2 [4,57,15] [1,4,57]
Stage29 0x02B38C 28 300 ADN122e 0x57 type2 [4,57,15] [1,4,57]
Stage29 0x02B4AC 28 200 ADN123b 0x46 type2 [4,57,15] [1,4,57]
Stage29 0x02B5CC 28 300 ADN121d 0x44 type2 [4,57,15] [1,4,57]
Stage29 0x02B6EC 28 200 ADN122c 0x45 type2 [4,57,15] [1,4,57]
Stage29 0x02B80C 28 300 ADN123e 0x5E type2 [4,57,15] [1,4,57]
Stage29 0x02B92C 28 300 ADN124e 0x63 type2 [4,57,15] [1,4,64]
Stage29 0x02BB94 28 300 ADN121d 0x44 type2 [64,57,15] [1,4,57]
Stage29 0x02BCB4 28 200 ADN121c 0x3B type2 [4,57,15] [1,4,57]
Stage29 0x02BDD4 28 200 ADN121b 0x35 type2 [4,57,15] [1,4,57]
Stage29 0x02BEF4 28 200 ADN122c 0x45 type2 [4,57,15] [1,4,57]
Stage29 0x02C014 28 120 ADN121a 0x31 type2 [4,57,15] [1,4,57]
Stage29 0x02C134 28 200 ADN122b 0x3C type2 [4,57,15] [1,4,57]
Stage29 0x02C254 28 300 ADN121e 0x4E type2 [4,57,15] [1,4,57]
Stage29 0x02C374 28 300 ADN122e 0x57 type2 [4,57,15] [1,4,57]
Stage29 0x02C494 28 200 ADN123b 0x46 type2 [4,57,15] [1,4,57]
Stage29 0x02C5B4 28 120 ADN122a 0x36 type2 [4,57,15] [1,4,57]
Stage29 0x02C6D4 28 120 ADN123a 0x3D type2 [4,57,15] [1,4,57]
Stage29 0x02C7F4 28 120 ADN124a 0x47 type2 [4,57,15] [1,4,57]
Stage29 0x02C914 28 300 ADN123e 0x5E type2 [4,57,15] [1,4,57]
Stage29 0x02CA34 28 300 ADN124e 0x63 type2 [4,57,15] [1,4,64]
Stage29 0x02CC9C 28 200 ADN121b 0x35 type2 [64,57,15] [1,4,57]
Stage29 0x02CDBC 28 300 ADN121e 0x4E type2 [4,57,15] [1,4,57]
Stage29 0x02CEDC 28 120 ADN121a 0x31 type2 [4,57,15] [1,4,57]
Stage29 0x02CFFC 28 300 ADN122e 0x57 type2 [4,57,15] [1,4,57]
Stage29 0x02D11C 28 120 ADN122a 0x36 type2 [4,57,15] [1,4,57]
Stage29 0x02D23C 28 300 ADN123e 0x5E type2 [4,57,15] [1,4,57]
Stage29 0x02D35C 28 120 ADN123a 0x3D type2 [4,57,15] [1,4,57]
Stage29 0x02D47C 28 120 ADN124a 0x47 type2 [4,57,15] [1,4,57]
Stage29 0x02D59C 28 120 ADN125a 0x51 type2 [4,57,15] [1,4,57]
Stage29 0x02D6BC 28 120 ADN126a 0x59 type2 [4,57,15] [1,4,57]
Stage29 0x02D7DC 28 200 ADN121c 0x3B type2 [4,57,15] [1,4,57]
Stage29 0x02D8FC 28 200 ADN122b 0x3C type2 [4,57,15] [1,4,57]
Stage29 0x02DA1C 28 200 ADN122c 0x45 type2 [4,57,15] [1,4,57]
Stage29 0x02DB3C 28 300 ADN124e 0x63 type2 [4,57,15] [1,64,64]
Stage29 0x02DD7C 28 300 ADN121e 0x4E type2 [64,57,15] [1,4,57]
Stage29 0x02DE9C 28 200 ADN121b 0x35 type2 [4,57,15] [1,4,57]
Stage29 0x02DFBC 28 300 ADN122e 0x57 type2 [4,57,15] [1,4,57]
Stage29 0x02E0DC 28 120 ADN121a 0x31 type2 [4,57,15] [1,4,57]
Stage29 0x02E1FC 28 200 ADN121c 0x3B type2 [4,57,15] [1,4,57]
Stage29 0x02E31C 28 300 ADN121d 0x44 type2 [4,57,15] [1,4,57]
Stage29 0x02E43C 28 120 ADN122a 0x36 type2 [4,57,15] [1,4,57]
Stage29 0x02E55C 28 120 ADN123a 0x3D type2 [4,57,15] [1,4,57]
Stage29 0x02E67C 28 120 ADN124a 0x47 type2 [4,57,15] [1,4,57]
Stage29 0x02E79C 28 200 ADN122b 0x3C type2 [4,57,15] [1,4,57]
Stage29 0x02E8BC 28 300 ADN123e 0x5E type2 [4,57,15] [1,4,57]
Stage29 0x02E9DC 28 120 ADN125a 0x51 type2 [4,57,15] [1,4,57]
Stage29 0x02EAFC 28 200 ADN122c 0x45 type2 [4,57,15] [1,4,57]
Stage29 0x02EC1C 28 120 ADN126a 0x59 type2 [4,57,15] [1,4,64]
Stage29 0x02F1EC 28 200 ADN146a 0x66 type2 [16,16,16] [145,47,47]
Stage29 0x02F960 28 200 ADN146b 0x67 type2 [16,16,16] [29,145,47]
Stage29 0x02F9A0 29 50 ADN146b 0x67 type2 [16,16,28] [145,47,47]
Stage29 0x02FF24 28 200 ADN147a 0x68 type2 [16,16,16] [145,47,47]
Stage29 0x030698 28 200 ADN147b 0x69 type2 [16,16,16] [29,145,47]
Stage29 0x0306D8 29 50 ADN147b 0x69 type2 [16,16,28] [145,47,47]
Stage29 0x030C5C 28 200 ADN148a 0x6A type2 [16,16,16] [145,47,47]
Stage29 0x0313D0 28 200 ADN148b 0x6B type2 [16,16,16] [29,145,47]
Stage29 0x031410 29 50 ADN148b 0x6B type2 [16,16,28] [145,47,47]
Stage29 0x031994 28 200 ADN149a 0x6C type2 [16,16,16] [145,47,47]
Stage29 0x032108 28 200 ADN149b 0x6D type2 [16,16,16] [29,145,47]
Stage29 0x032148 29 50 ADN149b 0x6D type2 [16,16,28] [145,47,47]
Stage29 0x0326CC 28 200 ADN150a 0x3F type2 [16,16,16] [145,47,47]
Stage29 0x032E40 28 200 ADN150b 0x4A type2 [16,16,16] [29,145,47]
Stage29 0x032E80 29 50 ADN150b 0x4A type2 [16,16,28] [145,47,47]
# builtin 26: 97 sites, 7 distinct values
# values: 0 x69, 80 x10, 100 x6, 60 x5, 30 x3, 50 x3, 40 x1
# units: 66 distinct, top: ADN128 x8, TCN001 x7, ADN207 x4, TCN208 x4, TCN002 x3, ADT102 x3, ADT104 x3, ADT107 x3, ADT101 x2, ADT103 x2
# craft cross-tab (0 unresolved):
# UN_e201_ADAN_ISCMissile 17 0x17
# UN_n001_TTRL_Box 16 0x16
# UN_f202_TCAF_Cargo 9 0x9
# UN_e106_ADAN_Destroyer 8 0x4, 80x2, 30x1, 60x1
# UN_f106_TCAF_Destroyer 8 0x4, 60x2, 80x1, 50x1
# UN_f001_TCAF_DeltaSaber_T_Player_Ttrl1 6 100x5, 40x1
# UN_f105_TCAF_Cruiser 5 0x3, 80x1, 50x1
# UN_e015_ADAN_Puppy_2 5 0x5
# UN_e015_ADAN_Puppy 5 0x5
# UN_e108_ADAN_ASFrigate 4 80x2, 60x1, 30x1
# UN_f001_TCAF_DeltaSaber_T 3 80x1, 50x1, 30x1
# UN_mn500_ADAN_FloatingMine 3 0x3
# UN_e105_ADAN_Cruiser 2 80x1, 60x1
# UN_e011_ADAN_Attacker_B 2 0x2
# UN_f101_TCAF_Acropolis 1 80x1
# UN_f002_TCAF_DeltaSaber_W 1 80x1
# UN_n001_TTRL_Box_move 1 0x1
# UN_f001_TCAF_DeltaSaber_T_Player_Ttrl2 1 100x1
# UN_f001_TCAF_DeltaSaber_T_Ttrl 1 100x1
# builtin 28: 410 sites, 13 distinct values
# values: 200 x116, 120 x76, 300 x74, 500 x48, 150 x37, 100 x29, 1000 x8, 2000 x5, 0.1 x5, 0 x5, 600 x4, 10 x2, 50 x1
# units: 190 distinct, top: TCN001 x13, ADN101b x7, ADN101a x7, ADN102a x7, ADN102b x7, ADN103a x7, ADN104a x7, ADN101e x7, ADN102e x7, ADN121b x7
# craft cross-tab (0 unresolved):
# UN_e106_ADAN_Destroyer 105 120x76, 150x15, 100x12, 200x2
# UN_e104_ADAN_Carrier 55 300x54, 150x1
# UN_e105_ADAN_CruiserEX 52 200x52
# UN_e102_ADAN_BattleshipEX 26 200x26
# UN_e001_ADAN_Elan 24 200x18, 300x6
# UN_e010_ADAN_Attacker_S 21 200x18, 100x3
# UN_e004_ADAN_ElanPlus_N 19 500x19
# UN_e009_ADAN_Phantom 16 500x16
# UN_f001_TCAF_DeltaSaber_T_Player_Ttrl1 10 0x5, 100x5
# UN_e007_ADAN_Turret 9 100x9
# UN_e002_ADAN_Elan_N 9 500x9
# UN_e105_ADAN_Cruiser 8 150x8
# UN_e101_ADAN_SDBattleshipEX 8 300x8
# UN_f001_TCAF_DeltaSaber_T 7 150x4, 10x2, 50x1
# UN_e011_ADAN_Attacker_B 6 300x6
# UN_f002_TCAF_DeltaSaber_W 5 150x5
# UN_e003_ADAN_ElanPlus_Margras 4 2000x4
# UN_e108_ADAN_ASFrigateEX 4 600x4
# UN_f106_TCAF_Destroyer_Inv 3 0.1x2, 150x1
# UN_e001_ADAN_Elan_GR_Violeta 3 1000x3
# UN_e001_ADAN_Elan_GR 3 1000x3
# UN_e101_ADAN_SDBattleship 3 150x3
# UN_f101_TCAF_Acropolis 2 150x1, 0.1x1
# UN_e013_ADAN_ElanPlus_Taskent 2 1000x2
# UN_f002_TCAF_DeltaSaber_W_Player 2 150x2
# UN_e011_ADAN_Attacker_B_HF 2 500x2
# UN_e102_ADAN_Battleship 2 150x2
# UN_f106_TCAF_Destroyer 1 150x1
# UN_f001_TCAF_DeltaSaber_T_Player 1 150x1
# UN_f105_TCAF_Cruiser 1 0.1x1
# UN_f102_TCAF_LightCarrier_Inv 1 0.1x1
# UN_e005_ADAN_ElanTypeQ_Margras 1 2000x1
# UN_e108_ADAN_ASFrigate 1 150x1
# UN_e011_ADAN_Attacker_B_HF_Wayne 1 500x1
# UN_e006_ADAN_Vindicator_Margras 1 500x1
# builtin 29: 164 sites, 6 distinct values
# values: 0 x64, 100 x53, 50 x38, 40 x6, 75 x2, 20 x1
# units: 70 distinct, top: TCN001 x71, TCN002 x10, TCT207 x4, TCN008 x3, ADT108 x3, ADT109 x3, ADT103 x3, TCN003 x2, ADS151a x2, ADS151b x2
# craft cross-tab (0 unresolved):
# UN_f002_TCAF_DeltaSaber_W_Player 41 100x23, 0x18
# UN_f001_TCAF_DeltaSaber_T 40 0x25, 100x15
# UN_f002_TCAF_DeltaSaber_W 38 100x21, 0x16, 50x1
# UN_f001_TCAF_DeltaSaber_T_Player 30 0x15, 100x15
# UN_e010_ADAN_Attacker_S 18 50x18
# UN_f202_TCAF_Cargo 14 50x8, 0x4, 100x2
# UN_e201_ADAN_ISCMissile 9 100x8, 20x1
# UN_n001_TTRL_Box 9 0x9
# UN_e104_ADAN_Carrier 6 0x3, 100x3
# UN_f003_TCAF_ArrowHead 5 50x5
# UN_e001_ADAN_Elan_GR_Violeta 3 40x3
# UN_e001_ADAN_Elan_GR 3 40x3
# UN_e006_ADAN_Vindicator_MargrasF 3 0x3
# UN_f004_TCAF_DeltaSaber_A_Player 2 0x1, 100x1
# UN_f001_TCAF_DeltaSaber_T_EX5_el 2 0x1, 100x1
# UN_f106_TCAF_Destroyer 2 50x2
# UN_f105_TCAF_Cruiser 2 50x2
# UN_be005_ADAN_SpaceFortress 1 0x1
# UN_f101_TCAF_Acropolis 1 75x1
# UN_f104_TCAF_Battleship 1 75x1
# UN_f102_TCAF_LightCarrier 1 50x1
# UN_e004_ADAN_ElanPlus_NF 1 50x1
# builtin 101: 133 sites, 1 distinct values
# values: - x133
# units: 1 distinct, top: ? x133
# craft cross-tab (133 unresolved):

921
docs/re/disc-atlas.html Normal file
View File

@@ -0,0 +1,921 @@
<title>Sylpheed Disc Atlas</title>
<style>
/* ───────────────────────────────────────────────────────────────────────────
Palette. Three of these hex values are literal constants in the codebase:
--ground is ComposeOptions::default().backdrop = [14,14,20], the colour the
UI compositor paints behind every reassembled screen; --warn and --refuted
are the viewer's own egui warning/error colours, rgb(224,168,86) and
rgb(224,86,122). --confirm is the cyan glow measured off the intro video.
Neutrals are biased blue to sit with the ground rather than against it.
─────────────────────────────────────────────────────────────────────────── */
:root {
--ground: #0e0e14;
--confirm: #3fb6c6;
--warn: #b8792c;
--refuted: #c23f61;
--accent: #c23f61;
--bg: #eceef3;
--surface: #f6f7fa;
--surface-2: #e2e5ee;
--text: #16171f;
--text-muted: #5c6076;
--text-faint: #878ca3;
--rule: #c9cddb;
--rule-soft: #dbdfe9;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--confirm: #4fd0e0;
--warn: #e0a856;
--refuted: #e0567a;
--accent: #e0567a;
--bg: #0e0e14;
--surface: #16171f;
--surface-2: #1e2029;
--text: #e8e9ef;
--text-muted: #9296ab;
--text-faint: #6d7186;
--rule: #2b2e3a;
--rule-soft: #22242e;
}
}
:root[data-theme="dark"] {
--confirm: #4fd0e0;
--warn: #e0a856;
--refuted: #e0567a;
--accent: #e0567a;
--bg: #0e0e14;
--surface: #16171f;
--surface-2: #1e2029;
--text: #e8e9ef;
--text-muted: #9296ab;
--text-faint: #6d7186;
--rule: #2b2e3a;
--rule-soft: #22242e;
}
/* Type roles: mono for every label and datum (this subject is hex and paths),
serif for prose so the two never blur into each other. */
:root {
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo,
Consolas, "Liberation Mono", monospace;
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua",
Georgia, serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--serif);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 0 24px 120px;
}
/* Prose stays near 66ch; panels break out to the full width. */
.col {
max-width: 66ch;
display: flex;
flex-direction: column;
gap: 1.1em;
}
/* ── Masthead ─────────────────────────────────────────────────────────────── */
header.mast {
border-bottom: 1px solid var(--rule);
padding: 72px 0 34px;
margin-bottom: 44px;
}
.eyebrow {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-faint);
margin: 0 0 20px;
}
h1 {
font-family: var(--mono);
font-size: clamp(30px, 5.4vw, 52px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.04;
margin: 0 0 22px;
text-wrap: balance;
}
h1 .dim { color: var(--text-faint); font-weight: 400; }
.standfirst {
font-size: 20px;
line-height: 1.5;
color: var(--text-muted);
max-width: 60ch;
margin: 0;
text-wrap: pretty;
}
/* ── Stat strip ───────────────────────────────────────────────────────────── */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 1px;
background: var(--rule-soft);
border: 1px solid var(--rule-soft);
margin: 40px 0 0;
}
.stat { background: var(--bg); padding: 16px 18px; }
.stat .n {
font-family: var(--mono);
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
display: block;
line-height: 1.1;
}
.stat .l {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--text-faint);
margin-top: 7px;
display: block;
line-height: 1.4;
}
/* ── Sections ─────────────────────────────────────────────────────────────── */
section { margin: 78px 0 0; }
h2 {
font-family: var(--mono);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text);
margin: 0 0 6px;
padding-bottom: 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: baseline;
gap: 14px;
}
h2 .idx { color: var(--accent); font-weight: 400; }
h2 .rest { color: var(--text-faint); font-weight: 400; letter-spacing: 0.1em; }
h3 {
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.03em;
margin: 42px 0 4px;
}
p { margin: 0; }
.col > p + p { margin-top: 0; }
a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:focus-visible, button:focus-visible { outline: 2px solid var(--confirm); outline-offset: 3px; }
code, .m {
font-family: var(--mono);
font-size: 0.86em;
background: var(--surface-2);
padding: 1px 5px;
border-radius: 2px;
word-break: break-word;
}
strong { font-weight: 700; }
em { font-style: italic; color: var(--text); }
/* ── Confidence chips ─────────────────────────────────────────────────────── */
.chip {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
padding: 2px 7px;
border: 1px solid currentColor;
border-radius: 2px;
white-space: nowrap;
display: inline-block;
line-height: 1.5;
}
.c-ok { color: var(--confirm); }
.c-part { color: var(--warn); }
.c-no { color: var(--refuted); }
.c-none { color: var(--text-faint); }
/* ── Panels (full-width breakouts) ────────────────────────────────────────── */
.panel {
margin: 30px 0 0;
border: 1px solid var(--rule);
background: var(--surface);
}
.panel-head {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--text-faint);
padding: 11px 16px;
border-bottom: 1px solid var(--rule-soft);
}
.panel-body { padding: 4px 0; overflow-x: auto; }
/* The schematic is a screen, not a page element: it keeps the game's own
ground in both themes, the way an instrument panel does. */
.screen {
background: var(--ground);
border: 1px solid var(--rule);
margin: 30px 0 0;
overflow-x: auto;
}
.screen svg { display: block; min-width: 760px; width: 100%; height: auto; }
figcaption {
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.65;
color: var(--text-faint);
margin-top: 12px;
max-width: 76ch;
}
figure { margin: 0; }
/* ── Tables ───────────────────────────────────────────────────────────────── */
.tbl-scroll { overflow-x: auto; margin: 26px 0 0; }
table {
border-collapse: collapse;
width: 100%;
font-family: var(--mono);
font-size: 12.5px;
min-width: 620px;
}
th {
text-align: left;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
font-weight: 700;
padding: 0 14px 9px 0;
border-bottom: 1px solid var(--rule);
white-space: nowrap;
}
td {
padding: 8px 14px 8px 0;
border-bottom: 1px solid var(--rule-soft);
vertical-align: top;
}
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; padding-right: 22px; }
tbody tr:hover { background: var(--surface); }
td .note { color: var(--text-muted); font-family: var(--serif); font-size: 13.5px; }
/* ── Mermaid ──────────────────────────────────────────────────────────────── */
.dia {
margin: 24px 0 0;
padding: 18px 16px;
background: var(--surface);
border: 1px solid var(--rule-soft);
overflow-x: auto;
}
.dia pre.mermaid { margin: 0; text-align: left; }
/* ── Callout ──────────────────────────────────────────────────────────────── */
.note-box {
border-left: 2px solid var(--accent);
padding: 2px 0 2px 18px;
margin: 26px 0 0;
color: var(--text-muted);
max-width: 64ch;
}
.note-box .lbl {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--accent);
display: block;
margin-bottom: 6px;
}
ul.plain { margin: 0; padding-left: 1.15em; display: flex; flex-direction: column; gap: 0.55em; }
ul.plain li::marker { color: var(--text-faint); }
/* ── Chain (numbered hops — the numbering is the hop order, which is real) ─── */
.hops { display: flex; flex-direction: column; gap: 0; margin: 24px 0 0; }
.hop {
display: grid;
grid-template-columns: 34px 1fr;
gap: 14px;
padding: 13px 0;
border-bottom: 1px solid var(--rule-soft);
align-items: baseline;
}
.hop:first-child { border-top: 1px solid var(--rule); }
.hop .n {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.hop .b { font-family: var(--mono); font-size: 12.5px; line-height: 1.7; }
.hop .b .d { color: var(--text-muted); font-family: var(--serif); font-size: 14px; display: block; margin-top: 3px; }
footer {
margin-top: 96px;
padding-top: 26px;
border-top: 1px solid var(--rule);
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-faint);
line-height: 1.8;
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
</style>
<div class="wrap">
<header class="mast">
<p class="eyebrow">Project Sylpheed · Arc of Deception — reverse-engineering reference</p>
<h1>Sylpheed Disc Atlas</h1>
<p class="standfirst">
What is on the disc, and how every piece of it points at every other piece.
Four layers deep: the media, the archives, the container formats, and the
domain chains a port has to walk.
</p>
<div class="stats">
<div class="stat"><span class="n">41</span><span class="l">IPFB archives</span></div>
<div class="stat"><span class="n">26 443</span><span class="l">archive entries</span></div>
<div class="stat"><span class="n">166</span><span class="l">.xpr packages</span></div>
<div class="stat"><span class="n">97</span><span class="l">.wmv cutscenes</span></div>
<div class="stat"><span class="n">9 519</span><span class="l">sound banks</span></div>
<div class="stat"><span class="n">3</span><span class="l">hash functions</span></div>
</div>
</header>
<div class="col">
<p>
Nothing on this disc is found by path at runtime. Every lookup is a
<em>hash</em>, and the whole disc is joined together by three of them with
different rules. Read that first, or the rest of the map reads as a pile of
unrelated tables.
</p>
<p>
Counts here were measured on the retail extract for this page, not copied
forward. Where a claim is contested or partial, the chip says so — and the
things that <em>do not</em> resolve get their own section, because a port
has to survive them.
</p>
</div>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<section>
<h2><span class="idx">01</span> <span>The join algebra</span> <span class="rest">three hashes, three rules</span></h2>
<div class="col">
<p>
These are not interchangeable, and mixing them up is the single most
productive mistake this project has made. They differ in what they hash,
and crucially in <strong>case sensitivity</strong>.
</p>
</div>
<div class="tbl-scroll">
<table>
<thead>
<tr><th>Hash</th><th>Keys</th><th>Case</th><th>Verified</th><th></th></tr>
</thead>
<tbody>
<tr>
<td><strong>name_hash</strong></td>
<td>IPFB archive TOC entries — a file path inside a <span class="m">.pak</span></td>
<td>insensitive</td>
<td class="num">recovers paths</td>
<td><span class="chip c-ok">confirmed</span></td>
</tr>
<tr>
<td><strong>tag_hash</strong></td>
<td>IDXD record names and field keys</td>
<td><em>sensitive</em></td>
<td class="num">1 271 462 / 1 271 462</td>
<td><span class="chip c-ok">confirmed</span></td>
</tr>
<tr>
<td><strong>ixud_hash</strong></td>
<td>IXUD localised-text record and field keys</td>
<td><em>sensitive</em></td>
<td class="num">628 165 / 628 165</td>
<td><span class="chip c-ok">confirmed</span></td>
</tr>
</tbody>
</table>
</div>
<div class="note-box">
<span class="lbl">The trap that cost the most</span>
A 24-bit modulus cannot uniquely name an identifier of eight characters or
more. Exhaustive preimage search recovers <span class="m">"Stage01"</span>
from its own hash, but at seven characters one real target already has
<strong>1 176</strong> preimages. Forty-two field keys on this disc are
hash-only with no stored name, and they are <em>provably</em> unrecoverable
— not merely unrecovered. Everything else carries its name inline: an IDXD
field's middle word points at its own name string, which is why the
containers are self-describing and the hash almost never has to be inverted.
</div>
</section>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<section>
<h2><span class="idx">02</span> <span>The four layers</span> <span class="rest">disc → archive → container → domain</span></h2>
<div class="col">
<p>
Reading up from the bottom: a domain chain names an asset by string, that
string hashes into an archive TOC, the TOC yields a compressed blob, and
the blob's first four bytes say which container format it is. Every arrow
in this diagram is a hash lookup or a magic-byte test — there are no
directory scans.
</p>
</div>
<figure>
<div class="screen">
<svg viewBox="0 0 1000 640" role="img" aria-label="Four-layer schematic of the Project Sylpheed disc: media, archives, container formats and domain chains, joined by three hash functions.">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4f5468"/>
</marker>
<style>
.bl { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 10px; letter-spacing: .16em; fill: #6d7186; text-transform: uppercase; }
.bx { fill: #16171f; stroke: #2b2e3a; stroke-width: 1; }
.bxa { fill: #16171f; stroke: #4fd0e0; stroke-width: 1; }
.tt { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; fill: #e8e9ef; }
.ts { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 10px; fill: #6d7186; }
.tn { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; fill: #4fd0e0; }
.jn { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 10.5px; fill: #e0a856; letter-spacing: .06em; }
.ln { stroke: #4f5468; stroke-width: 1; }
.rl { stroke: #22242e; stroke-width: 1; }
</style>
</defs>
<!-- band rules -->
<line class="rl" x1="0" y1="118" x2="1000" y2="118"/>
<line class="rl" x1="0" y1="268" x2="1000" y2="268"/>
<line class="rl" x1="0" y1="418" x2="1000" y2="418"/>
<!-- ── BAND 1 · disc media ── -->
<text class="bl" x="24" y="40">Disc</text>
<g>
<rect class="bx" x="130" y="30" width="160" height="58" rx="2"/>
<text class="tt" x="145" y="54">default.xex</text>
<text class="ts" x="145" y="70">executable + PE</text>
<rect class="bx" x="302" y="30" width="160" height="58" rx="2"/>
<text class="tt" x="317" y="54">dat/</text>
<text class="ts" x="317" y="70">37 archives</text>
<rect class="bx" x="474" y="30" width="160" height="58" rx="2"/>
<text class="tt" x="489" y="54">hidden/</text>
<text class="ts" x="489" y="70">DefTables · MiscBin</text>
<rect class="bx" x="646" y="30" width="160" height="58" rx="2"/>
<text class="tt" x="661" y="54">dat/movie/</text>
<text class="ts" x="661" y="70">97 wmv · 6 paks</text>
<rect class="bx" x="818" y="30" width="160" height="58" rx="2"/>
<text class="tt" x="833" y="54">resource3d/</text>
<text class="ts" x="833" y="70">166 xpr · 1.4 GB</text>
</g>
<!-- join 1 -->
<line class="ln" x1="382" y1="88" x2="382" y2="176" marker-end="url(#ar)"/>
<line class="ln" x1="554" y1="88" x2="554" y2="176" marker-end="url(#ar)"/>
<line class="ln" x1="726" y1="88" x2="726" y2="176" marker-end="url(#ar)"/>
<line class="ln" x1="898" y1="88" x2="898" y2="220" marker-end="url(#ar)"/>
<text class="jn" x="130" y="112">name_hash keys the TOC — case-insensitive</text>
<!-- ── BAND 2 · archives ── -->
<text class="bl" x="24" y="190">Archive</text>
<g>
<rect class="bxa" x="130" y="176" width="560" height="72" rx="2"/>
<text class="tt" x="146" y="200">IPFB · .pak index + .p00….pNN data segments</text>
<text class="ts" x="146" y="218">12-byte TOC entry { name_hash, offset, comp_size } · Z1/zlib payloads</text>
<text class="tn" x="146" y="236">41 archives · 26 443 entries</text>
<rect class="bx" x="706" y="176" width="272" height="72" rx="2"/>
<text class="tt" x="722" y="200">raw files (not archived)</text>
<text class="ts" x="722" y="218">.wmv video · .xpr resource packages</text>
<text class="tn" x="722" y="236">263 files</text>
</g>
<!-- join 2 -->
<line class="ln" x1="200" y1="248" x2="200" y2="292" marker-end="url(#ar)"/>
<line class="ln" x1="410" y1="248" x2="410" y2="292" marker-end="url(#ar)"/>
<line class="ln" x1="620" y1="248" x2="620" y2="292" marker-end="url(#ar)"/>
<line class="ln" x1="842" y1="248" x2="842" y2="292" marker-end="url(#ar)"/>
<text class="jn" x="130" y="264">payload magic decides the format — nothing declares a type</text>
<!-- ── BAND 3 · containers ── -->
<text class="bl" x="24" y="330">Container</text>
<g>
<rect class="bxa" x="130" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="140" y="316">IDXD</text><text class="ts" x="140" y="332">tables</text><text class="tn" x="140" y="347">6325</text>
<rect class="bxa" x="215" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="225" y="316">T8aD</text><text class="ts" x="225" y="332">2D tex</text><text class="tn" x="225" y="347">4525</text>
<rect class="bxa" x="300" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="310" y="316">RATC</text><text class="ts" x="310" y="332">UI bundle</text><text class="tn" x="310" y="347">2985</text>
<rect class="bxa" x="385" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="395" y="316">IXUD</text><text class="ts" x="395" y="332">text</text><text class="tn" x="395" y="347">1104</text>
<rect class="bxa" x="470" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="480" y="316">LSTA</text><text class="ts" x="480" y="332">sprites</text><text class="tn" x="480" y="347">64</text>
<rect class="bxa" x="555" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="565" y="316">SLB</text><text class="ts" x="565" y="332">XMA1</text><text class="tn" x="565" y="347">9519</text>
<rect class="bxa" x="640" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="650" y="316">XBG7</text><text class="ts" x="650" y="332">mesh</text><text class="tn" x="650" y="347">6294</text>
<rect class="bxa" x="725" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="735" y="316">XPR2</text><text class="ts" x="735" y="332">tex pkg</text><text class="tn" x="735" y="347">166</text>
<rect class="bxa" x="810" y="292" width="77" height="62" rx="2"/>
<text class="tt" x="820" y="316">TTF</text><text class="ts" x="820" y="332">fonts</text><text class="tn" x="820" y="347">54</text>
<rect class="bx" x="895" y="292" width="77" height="62" rx="2" stroke="#e0567a"/>
<text class="tt" x="905" y="316">ISB</text><text class="ts" x="905" y="332">PRT · BIN</text>
<text class="ts" x="905" y="347" fill="#e0567a">no parser</text>
</g>
<!-- join 3 -->
<line class="ln" x1="200" y1="354" x2="200" y2="442" marker-end="url(#ar)"/>
<line class="ln" x1="440" y1="354" x2="440" y2="442" marker-end="url(#ar)"/>
<line class="ln" x1="680" y1="354" x2="680" y2="442" marker-end="url(#ar)"/>
<line class="ln" x1="900" y1="354" x2="900" y2="442" marker-end="url(#ar)"/>
<text class="jn" x="130" y="410">tag_hash / ixud_hash key records + fields — values are asset NAMES, hashed back up</text>
<!-- ── BAND 4 · domains ── -->
<text class="bl" x="24" y="480">Domain</text>
<g>
<rect class="bx" x="130" y="442" width="112" height="66" rx="2"/>
<text class="tt" x="141" y="466">Stage</text><text class="ts" x="141" y="482">28 missions</text><text class="ts" x="141" y="496">10 sub-tables</text>
<rect class="bx" x="252" y="442" width="112" height="66" rx="2"/>
<text class="tt" x="263" y="466">Unit</text><text class="ts" x="263" y="482">159 fields</text><text class="ts" x="263" y="496">→ turret slot</text>
<rect class="bx" x="374" y="442" width="112" height="66" rx="2"/>
<text class="tt" x="385" y="466">Arsenal</text><text class="ts" x="385" y="482">59 items</text><text class="ts" x="385" y="496">4-hop chain</text>
<rect class="bx" x="496" y="442" width="112" height="66" rx="2"/>
<text class="tt" x="507" y="466">Audio</text><text class="ts" x="507" y="482">5798 cues</text><text class="ts" x="507" y="496">5 families</text>
<rect class="bx" x="618" y="442" width="112" height="66" rx="2"/>
<text class="tt" x="629" y="466">Cutscene</text><text class="ts" x="629" y="482">104 slots</text><text class="ts" x="629" y="496">4 bindings</text>
<rect class="bx" x="740" y="442" width="112" height="66" rx="2"/>
<text class="tt" x="751" y="466">UI screen</text><text class="ts" x="751" y="482">965 builds</text><text class="ts" x="751" y="496">decl + place</text>
<rect class="bx" x="862" y="442" width="112" height="66" rx="2"/>
<text class="tt" x="873" y="466">Save</text><text class="ts" x="873" y="482">545 bytes</text><text class="ts" x="873" y="496">GDHA + zlib</text>
</g>
<!-- the cross-domain edge: the save indexes the arsenal's string order -->
<path class="ln" d="M 918 508 L 918 560 L 430 560 L 430 508" fill="none" stroke-dasharray="3 3" marker-end="url(#ar)"/>
<text class="jn" x="560" y="578">the save's 54-byte blob is indexed by strings.tbl order — not weapon.tbl</text>
<text class="ts" x="130" y="614" fill="#4f5468">Counts measured on the retail extract. Container counts exclude sound.pak and entries over 8 MB.</text>
</svg>
</div>
<figcaption>
The three amber lines are the joins. Each is a hash lookup, and each is a
place a port can silently take the wrong branch: an archive TOC miss
returns nothing, and a field the disc never values reads as
<span class="m">0.0</span> rather than as an error.
</figcaption>
</figure>
</section>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<section>
<h2><span class="idx">03</span> <span>Archives</span> <span class="rest">where the mass actually sits</span></h2>
<div class="col">
<p>
Two archives hold most of the disc. <span class="m">sound.pak</span> is
1.07 GB across five data segments — more than every other archive
combined — and <span class="m">GP_HANGAR_ARSENAL.pak</span> is large for a
menu because it is <em>stage-scoped</em>: 168 of its objects are 28
missions × 6 languages, each a complete Hangar configuration.
</p>
</div>
<div class="tbl-scroll">
<table>
<thead>
<tr><th>Archive</th><th class="num">Entries</th><th class="num">Stored</th><th>What it is</th></tr>
</thead>
<tbody>
<tr><td>sound.pak</td><td class="num">9 519</td><td class="num">1.07 GB</td><td><span class="note">every XMA1 bank — music, jingles, SFX, both voice languages</span></td></tr>
<tr><td>GP_HANGAR_ARSENAL.pak</td><td class="num">1 538</td><td class="num">67.2 MB</td><td><span class="note">28 stages × 6 languages of Hangar config, plus item text</span></td></tr>
<tr><td>GP_MAIN_GAME_{D,E,F,I,J,S}</td><td class="num">1 119 ea.</td><td class="num">4.6 MB ea.</td><td><span class="note">the mission data set — stages, units, routes, dialogue</span></td></tr>
<tr><td>GP_READY_ROOM.pak</td><td class="num">1 106</td><td class="num">67.6 MB</td><td><span class="note">largest UI pak; ISL script bytecode + link map</span></td></tr>
<tr><td>GP_MAIN_GAME_*2D <span class="chip c-no">unnamed</span></td><td class="num">711 ea.</td><td class="num">15.2 MB ea.</td><td><span class="note">six paks, 0 % of names recoverable — see §06</span></td></tr>
<tr><td>DefTables.pak <span class="m">hidden/</span></td><td class="num">1 465</td><td class="num"></td><td><span class="note">definition tables; 804 of 1 465 names resolved</span></td></tr>
<tr><td>movie/{eng,jpn,deu,esp,fra,ita}</td><td class="num">117 ea.</td><td class="num">1.8 MB ea.</td><td><span class="note">subtitles, telop overlays and fonts — <em>not</em> voice</span></td></tr>
<tr><td>tables.pak</td><td class="num">79</td><td class="num">330 KB</td><td><span class="note">the registries: sound cues, the cutscene manifest</span></td></tr>
<tr><td>MiscBin.pak <span class="m">hidden/</span></td><td class="num">40</td><td class="num"></td><td><span class="note">0 names resolved</span></td></tr>
<tr><td>fonts.pak</td><td class="num">3</td><td class="num">2.4 MB</td><td><span class="note">the three shipped typefaces</span></td></tr>
</tbody>
</table>
</div>
<div class="note-box">
<span class="lbl">One entry lies about its size</span>
<span class="m">Static.slb</span>, the sound-effect bank, sits at the highest
offset in <span class="m">sound.pak</span> and declares
<strong>616 768 bytes more than the disc holds</strong>. This is not a bad
extract — <span class="m">sound.p04</span> matches the ISO's own directory
record — and a sweep of every archive finds this entry over-running and no
other. The last entry's size field is an allocation size. A reader must
allow a short read there, and <em>only</em> there.
</div>
</section>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<section>
<h2><span class="idx">04</span> <span>Container formats</span> <span class="rest">what each blob turns into</span></h2>
<div class="tbl-scroll">
<table>
<thead>
<tr><th>Magic</th><th>Content</th><th>State</th><th>Reach</th></tr>
</thead>
<tbody>
<tr><td><strong>IDXD</strong></td><td><span class="note">self-describing record/field table — the game's whole data layer</span></td><td><span class="chip c-ok">confirmed</span></td><td class="num">7 750 / 7 750 objects</td></tr>
<tr><td><strong>IXUD</strong></td><td><span class="note">the same container, UTF-16BE, offsets in <em>chars</em></span></td><td><span class="chip c-ok">confirmed</span></td><td class="num">1 104 / 1 104 objects</td></tr>
<tr><td><strong>T8aD</strong></td><td><span class="note">2D texture — a list of arbitrary sub-rectangles, not a tile grid</span></td><td><span class="chip c-ok">confirmed</span></td><td class="num">19 216 / 19 216</td></tr>
<tr><td><strong>RATC</strong></td><td><span class="note">UI bundle; children are sprites, layout records and primitives</span></td><td><span class="chip c-ok">confirmed</span></td><td class="num">10 144 / 10 148 refs</td></tr>
<tr><td><strong>LSTA</strong></td><td><span class="note">sprite display list — count covers T8aD <em>and</em> PRMD</span></td><td><span class="chip c-ok">confirmed</span></td><td class="num">64 / 64</td></tr>
<tr><td><strong>XBG7</strong></td><td><span class="note">mesh; index pool then vertex pool, layout declared per sub-mesh</span></td><td><span class="chip c-part">99.25 %</span></td><td class="num">6 247 / 6 294</td></tr>
<tr><td><strong>XPR2</strong></td><td><span class="note">texture package — de-tile, A8R8G8B8 and DXT1</span></td><td><span class="chip c-part">partial</span></td><td class="num">channel order confirmed</td></tr>
<tr><td><strong>SLB</strong></td><td><span class="note">XACT bank of XMA1 sub-waves; two layouts, one headerless</span></td><td><span class="chip c-part">35/36 shared</span></td><td class="num"><span class="m">JNGL_001</span> fails</td></tr>
<tr><td><strong>GDHA</strong></td><td><span class="note">save file — zlib payload, chunk stream, round-trips byte-identically</span></td><td><span class="chip c-part">~11 ❔ fields</span></td><td class="num">545 bytes</td></tr>
<tr><td>TTF / OTF</td><td><span class="note">stock OpenType</span></td><td><span class="chip c-ok">confirmed</span></td><td class="num">54</td></tr>
<tr><td><strong>ISB</strong></td><td><span class="note">ISL mission-script bytecode</span></td><td><span class="chip c-part">partial</span></td><td class="num">builtins documented</td></tr>
<tr><td><strong>PRT</strong></td><td><span class="note">telop — on-screen text overlay for cutscenes</span></td><td><span class="chip c-none">no parser</span></td><td class="num">22 bound</td></tr>
<tr><td><strong>BIN</strong></td><td><span class="note">collision meshes, <span class="m">CollisionSet_S&lt;NN&gt;.bin</span></span></td><td><span class="chip c-none">no parser</span></td><td class="num">documented only</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<section>
<h2><span class="idx">05</span> <span>The domain chains</span> <span class="rest">what references what</span></h2>
<div class="col">
<p>
Each of these starts from something the player can see and ends at bytes on
the disc. Where a chain has a hop that looks like it should be direct and
is not, that hop is called out — those are where a naive port breaks.
</p>
</div>
<h3>Mission</h3>
<div class="col">
<p>
One IDXD object per stage in every language pak. Its
<span class="m">StageResource</span> record is the hub: nineteen fields
naming the 3D packages, the enemy roster, the routes, the collision set
and the localised objective strings.
</p>
</div>
<div class="dia">
<pre class="mermaid">
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#1e2029','primaryTextColor':'#e8e9ef','primaryBorderColor':'#4fd0e0','lineColor':'#8a8fa3','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px','clusterBkg':'#16171f','clusterBorder':'#2b2e3a'}}}%%
flowchart LR
A["GP_MAIN_GAME_&lt;lang&gt;.pak"] --> B["Stage_S&lt;NN&gt; record"]
B --> C["StageResource"]
C --> D["Stage_S&lt;NN&gt;.xpr<br/>BG_&lt;place&gt;.xpr"]
C --> E["EnumUnit_S&lt;NN&gt;.tbl"]
C --> F["UnitGroup_S&lt;NN&gt;.tbl<br/>squadron roster"]
C --> G["Route_S&lt;NN&gt;.tbl<br/>FormationSet_S&lt;NN&gt;.tbl"]
C --> H["CollisionSet_S&lt;NN&gt;.bin"]
C --> I["EnumLocalString_S&lt;NN&gt;.tbl"]
B --> J["Stage\script.tbl"]
J --> K["StageNN.ssb<br/>ISL bytecode"]
</pre>
</div>
<h3>Arsenal — the four-hop trap</h3>
<div class="col">
<p>
The Hangar lists 59 weapons; the disc's <span class="m">Weapon</span> table
has 131, and the two name sets overlap in <strong>zero</strong> values.
That is not a mismatch to be reconciled — it is the wrong join. An arsenal
item names a <em>hardpoint slot</em>, and the slot carries the weapon.
</p>
</div>
<div class="hops">
<div class="hop"><span class="n">01</span><span class="b">loadout record &nbsp;<span style="color:var(--text-faint)">— flight position × pilot, e.g. Rhino2-Ellen</span><span class="d">Names <code>Arm1</code> / <code>Arm2</code> / <code>Arm3</code> / <code>Nose</code> — which are not items either.</span></span></div>
<div class="hop"><span class="n">02</span><span class="b">per-slot allow-list record<span class="d">Its only <em>named</em> field is <code>Type</code>; the candidate items live in its positional, unnamed fields, in order.</span></span></div>
<div class="hop"><span class="n">03</span><span class="b">arsenal item &nbsp;<span style="color:var(--text-faint)">→ .PlayerWeapon</span><span class="d">Resolves to <code>Turret_050</code> — a slot on the player craft's own unit table. 59 of 59 do; 0 of 59 name a weapon.</span></span></div>
<div class="hop"><span class="n">04</span><span class="b">turret slot &nbsp;<span style="color:var(--text-faint)">→ .WeaponID</span><span class="d">And <em>this</em> is the <code>Weapon</code> record.</span></span></div>
</div>
<h3>Audio</h3>
<div class="col">
<p>
One IDXD object in <span class="m">tables.pak</span> is the whole cue
index. Its <span class="m">SOUNDS</span> record is the only record found on
the disc that is entirely named fields — 5 798 of them — and the names are
the join key. Cue ids are partitioned by family with no overlap.
</p>
</div>
<div class="dia">
<pre class="mermaid">
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#1e2029','primaryTextColor':'#e8e9ef','primaryBorderColor':'#4fd0e0','lineColor':'#8a8fa3','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
flowchart LR
M["script message id"] --> S["SOUNDS<br/>cue name → sound id"]
S --> F["FILES<br/>5 135 bank paths"]
F --> H{{"name_hash"}}
H --> P["sound.pak<br/>9 519 entries"]
P --> X["XMA1 sub-waves"]
S -.-> C1["VOICE 15007331"]
S -.-> C2["SE 1901"]
S -.-> C3["DEMO 80008408"]
S -.-> C4["BR 85008600"]
S -.-> C5["BGM 10011109"]
</pre>
</div>
<div class="note-box">
<span class="lbl">Two things the paths do not tell you</span>
The 36 music, jingle and SFX banks sit at the table <em>root</em> with no
language component, so both <span class="m">sounds.tbl</span> files name
them. And a <span class="m">.slb</span> need not hold the track its name
claims: the movie voices are one continuous stream chunked into TOC entries
whose boundaries do not line up with the cues.
</div>
<h3>Cutscene</h3>
<div class="col">
<p>
One manifest record binds four assets per slot. Measured for this page:
104 slots over 101 distinct movies, 99 with a subtitle track, 99 with a
voice track, 22 with a telop overlay.
</p>
</div>
<div class="dia">
<pre class="mermaid">
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#1e2029','primaryTextColor':'#e8e9ef','primaryBorderColor':'#4fd0e0','lineColor':'#8a8fa3','fontFamily':'ui-monospace, Menlo, monospace','fontSize':'13px'}}}%%
flowchart LR
MF["cutscene manifest<br/>tables.pak"] --> MV["MOVIE<br/>dat/movie/&lt;name&gt;.wmv"]
MF --> TL["TELOP<br/>&lt;lang&gt;.pak+*.prt"]
MF --> SB["SUBTITLE<br/>&lt;lang&gt;.pak+SUBTITLE_*.tbl"]
MF --> VT["VOICETRACK<br/>cue name"]
SB --> IX["IXUD caption keys"]
IX --> TX["GP_MAIN_GAME_&lt;lang&gt;<br/>8 800 text keys"]
VT --> SC["sound cue → .slb region"]
</pre>
</div>
<h3>UI screen</h3>
<div class="col">
<p>
One archive per screen; each RATC bundle is one context × language build.
A bundle's declaration table lists every element with its parent and pivot;
the placement region right after it gives each element a keyframe group.
Both the tutorial pause menu and the title main menu rebuild
pixel-accurately from the disc alone.
</p>
<p>
Two things are <em>not</em> what they look like. The resting position is
neither the first nor the last keyframe — it is the plateau. And the
declaration table is <strong>not</strong> the paint order: a per-draw
capture of the running title screen paints element 13 first and elements
0 and 1 late. The real order is a second, reordered child array the screen
object keeps at runtime — deriving it from the bundle is still open.
</p>
</div>
<h3>Save</h3>
<div class="col">
<p>
545 bytes: a <span class="m">GDHA</span> container, a zlib payload, then a
chunk stream. The 54-byte develop blob has the alphabet <span class="m">0
locked / 2 developable / 4 developed</span>, and is indexed by
<span class="m">strings.tbl</span> order — the display order <em>plus</em>
the cut items only the localisation file lists. <span class="m">weapon.tbl</span>'s
id list is not the index space; that it is also 54 long is a coincidence,
and the two agree only to index 32.
</p>
</div>
<div class="note-box">
<span class="lbl">The stale-summary trap</span>
The in-game Details panel reads a <em>summary copy</em> held in the container
header, not the payload. Edit the payload alone and the panel keeps showing
the old values — which reads exactly like a failed parse.
</div>
</section>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<section>
<h2><span class="idx">06</span> <span>References that go nowhere</span> <span class="rest">a port has to survive these</span></h2>
<div class="col">
<p>
These are not gaps in the reverse engineering. They are properties of the
shipped disc, and each one was found by a check that expected the opposite.
</p>
</div>
<div class="tbl-scroll">
<table>
<thead><tr><th>Reference</th><th>Where</th><th>Status</th></tr></thead>
<tbody>
<tr><td><span class="m">pmbase.t32</span></td><td><span class="note">named by <span class="m">pmbase.rat</span> in all four <span class="m">GP_STAGE_CLEAR</span> language builds</span></td><td><span class="chip c-no">nowhere on disc</span></td></tr>
<tr><td><span class="m">SUBTITLE_S12B.tbl</span></td><td><span class="note">bound by the manifest, in all six languages</span></td><td><span class="chip c-no">resolves in none</span></td></tr>
<tr><td><span class="m">logo1…logo4</span>, <span class="m">SYLPH_HD720p_8M-CBR_2ch</span></td><td><span class="note">manifest-bound movies</span></td><td><span class="chip c-no">no .wmv</span></td></tr>
<tr><td><span class="m">dat\GP_TEST\</span></td><td><span class="note">a debug archive the script manifest points at</span></td><td><span class="chip c-no">not shipped</span></td></tr>
<tr><td>6 × <span class="m">GP_MAIN_GAME_*2D</span></td><td><span class="note">711 entries each; eleven other paks are at 100 % by the same method</span></td><td><span class="chip c-no">0 % named</span></td></tr>
<tr><td><span class="m">GP_READY_ROOM.pak</span></td><td><span class="note">the largest UI pak on the disc</span></td><td><span class="chip c-no">6 of 1 106 named</span></td></tr>
<tr><td>42 ISL script-symbol keys</td><td><span class="note">hash-only field keys in <span class="m">&lt;lang&gt;\script\ID.tbl</span></span></td><td><span class="chip c-no">provably unrecoverable</span></td></tr>
<tr><td>9 of 101 cutscenes</td><td><span class="note">no English transcript resolves</span></td><td><span class="chip c-part">unexplained</span></td></tr>
<tr><td><span class="m">JNGL_001.slb</span></td><td><span class="note">headerless bank; payload is not a whole number of XMA1 packets</span></td><td><span class="chip c-part">does not decode</span></td></tr>
</tbody>
</table>
</div>
<div class="note-box">
<span class="lbl">Why the 2D paks matter more than they look</span>
Six archives at <em>exactly</em> 0.0 %, all six, 711 entries each — while
eleven menu paks hit 100 % in the same run. That control is what makes it a
finding rather than a failed guess: the naming method works, and these
archives are outside it. Whatever names their contents is not a path hashed
the way every other archive's is.
</div>
</section>
<!-- ══════════════════════════════════════════════════════════════════════ -->
<section>
<h2><span class="idx">07</span> <span>Still open</span></h2>
<div class="col">
<ul class="plain">
<li><strong>UI paint order from the bundle.</strong> Solved at runtime, not statically — and static is what a port needs.</li>
<li><strong>The <span class="m">.prt</span> telop format.</strong> 22 cutscenes bind one; no parser exists.</li>
<li><strong>Collision meshes.</strong> <span class="m">CollisionSet_S&lt;NN&gt;.bin</span> is documented and unparsed.</li>
<li><strong>47 XBG7 resources</strong> still miss — mostly pose/proxy composites and damage variants, not a threshold away.</li>
<li><strong>~11 GHAD save fields</strong> unnamed, and difficulty-versus-stage undecided: three fields hold the value 2.</li>
<li><strong>The naming of the 2D and Ready Room archives</strong>, which is the largest single block of unreachable content on the disc.</li>
</ul>
</div>
</section>
<footer>
Counts measured on the retail extract (USA/Europe, En/Ja). Container-format
counts exclude <span class="m">sound.pak</span> and entries over 8 MB.<br/>
Confidence follows the corpus convention — confirmed · partial · refuted —
and is per-claim, never per-document.
</footer>
</div>

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.

View File

@@ -3,6 +3,9 @@
Status: ✅ table encoding, calling convention and the `ScriptPhase` state layout; Status: ✅ table encoding, calling convention and the `ScriptPhase` state layout;
✅ ~135 of 147 handlers characterised from the disassembly; 🟡 three resolved ✅ ~135 of 147 handlers characterised from the disassembly; 🟡 three resolved
only partially; ❔ the interpreter-command table is only partly recovered. only partially; ❔ the interpreter-command table is only partly recovered.
✅ built-ins **26 / 29 / 101** are now named end to end (HP %, damage-taken %,
broadcast invulnerability); 🟡 **28** writes the matching damage-**dealt** field
but its one consumer is a single weapon path — see below.
Companion to [isl-bytecode](isl-bytecode.md) (the instruction encoding) and Companion to [isl-bytecode](isl-bytecode.md) (the instruction encoding) and
[mission-phase-advance](../mission-phase-advance.md) (why phases hinge on these). [mission-phase-advance](../mission-phase-advance.md) (why phases hinge on these).
@@ -168,56 +171,269 @@ words**: a descriptor offset and the message id.
That fixes the id format as `0xED08 nn DE`, and the ids already known from other That fixes the id format as `0xED08 nn DE`, and the ids already known from other
work fit it: opcode 514 → `00DE`, 803 → `07DE`, 999 → `0FDE`. work fit it: opcode 514 → `00DE`, 803 → `07DE`, 999 → `0FDE`.
🟡 The pump's arm for `0xED0802DE` does **not** apply an effect — it walks the **The pump's arm for `0xED0802DE` applies no effect itself** — it walks the
unit's child list at `[unit+320]`/`[unit+324]` and **rebroadcasts** to each child unit's child list at `[unit+320]`/`[unit+324]` and **rebroadcasts** to each child
as `0xED0902DE`. So `0xED08…` is the to-unit family and `0xED09…` the to-child as `0xED0902DE`. So `0xED08…` is the to-group family and `0xED09…` the
one, and the terminal effect is one link further on. ❌ Not followed; 26/28/29 to-individual one. That link is now followed all the way to the effect — see
remain unnamed. below.
### 🟡 Built-ins 26 / 28 / 29 are one family — and `damage_unit` looks mis-named ### Built-ins 26 / 28 / 29 RESOLVED — HP, damage dealt, damage taken
Method-diffing put the structure beyond doubt but did not reach the semantics. The chain was followed end to end for all three, from the dispatch table to the
field write. Nothing here is inferred from shape; every hop was read.
| built-in | vtable slot | method | opcode | sites | | built-in | vtable slot | method | opcode | cmd word | interp thunk | opcode handler | to-group msg | to-child msg | arm | writes |
|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|
| 26 `damage_unit` | 76 | `sub_8226ACD0` (67) | **800** | 97 | | **26** | 76 | `sub_8226ACD0` | 800 | `0xAB0320BA` | `0x822FF118` | `sub_823008C8` | `0xED0802DE` | `0xED0902DE` | `0x82398E8C` | `[unit+532]` = **HP** |
| **28** | 84 | `sub_82268F98` (69) | **801** | 410 | | **28** | 84 | `sub_82268F98` | 801 | `0xAB0321BA` | `0x822FF120` | `sub_823009B8` | `0xED0803DE` | `0xED0903DE` | `0x8239926C` | `[unit+672]` |
| **29** | 88 | `sub_822690B0` (69) | **802** | 164 | | **29** | 88 | `sub_822690B0` | 802 | `0xAB0322BA` | `0x822FF128` | `sub_82300AA8` | `0xED0804DE` | `0xED0904DE` | `0x823994C0` | `[unit+676]` |
| 101 | 276 | `sub_822691C8` (78) | 802 (broadcast) | 133 |
**28 and 29 differ in two words only** — the opcode (`0x21BA` vs `0x22BA`) and a The cmd word decodes as `0xAB` `opcode:16` `0xBA`, which is the same encoding
descriptor pointer 8 bytes apart. Otherwise instruction-identical. All three take already recorded for 256 (`0xAB0100BA`), 513 (`0xAB0201BA`) and 996
`(unit, double)`. (`0xAB03E4BA`) — `0x320/0x321/0x322` = 800/801/802.
26 differs from both by one guard: it rejects only state 3, while **28 and 29 Two links that the previous attempt did not have:
reject states 1 and 3** (2 = active, 1/3/4 = gone/dead/invalid).
#### The operand distributions separate them * the **to-group pump is `sub_8232C4C0`** (vtable `0x820AF1FC` slot 6), whose
arms at `0x8232C824` / `0x8232CB48` / `0x8232CC40` each walk the child list and
re-post with the `0xED09…` id, copying the float from `[msg+36]` unchanged;
* the **to-individual handler is `sub_82398CC0`** (vtable `0x820B192C` slot 6),
the entity base class. Its `0xED0902DE` arm is reached by a `sub.`/`cmplwi 0x8200`
pair rather than a `lis`+`ori` compare, which is why a constant scan for
`0xED0902DE` finds only the *sender*. That is worth remembering: **a
subtract-based compare hides the constant from any scan that pairs
`lis` with `ori`.**
| built-in | n | distinct | range | most common | #### ✅ 26 is `set_unit_hp_pct` — NOT `damage_unit`
|---|---|---|---|---|
| 26 | 97 | 7 | **[0, 100]** | **0 ×69**, 80 ×10, 100 ×6 |
| 28 | 410 | 13 | **[0, 2000]** | 200 ×116, 120 ×76, 300 ×74 |
| 29 | 164 | 6 | **[0, 100]** | **0 ×64**, 100 ×53, 50 ×38 |
26 and 29 are percentage-shaped; 28 is an absolute quantity an order of magnitude `sub_8226ACD0` multiplies the double operand by the constant at `0x820C5688`,
larger. which is **0.01** — so the operand is a percentage and the message carries a
fraction. `0x82398E8C` then does, in its own terms:
#### 🟡 `damage_unit` (26) is doubtful ```
new = min( max(def.HP * frac, 0.0), def.HP ) ; def = [unit+496]
[unit+532] = new
if new < FLT_EPSILON and old > 0: ; crossed to zero
[unit+688] = 0.0 ; [unit+1340] = 1 ; [unit+692] = def.Delay
```
**69 of its 97 calls pass 0.** Dealing zero damage is a no-op, so 71 % of the Three independent facts pin the field:
call sites would do nothing. *Setting* a percentage-valued property to 0 is a
perfectly natural thing to do 69 times, and 29 has the same shape (0 ×64 of 164).
The existing name predates this session and is not withdrawn, but it should not
be relied on.
#### ❌ Where this stopped * `[unit+496]` is the **unit definition** — the entity constructor
`sub_82393868` sets it from `sub_82348830` (the per-member definition
`std::map::find` already identified for built-in 15) at `0x82393BE4`;
* `[def+84]` is **`HP`** in
`crates/sylpheed-formats/data/unit_definition_layout.txt`, and the same
constructor immediately does `[unit+532] = [def+84]` at `0x82393BF0` — so
`+532` *is* current HP, seeded from the datasheet maximum;
* the crossing branch loads **`[def+584]` = `Delay`** into `[unit+692]` and sets
a flag — the destruction sequence. So a percentage of **0 destroys the unit**,
with the definition's own death delay.
The three commands' descriptors sit at `0x820A8D10` / `+8` / `+16`. Following So the operand is *percent of maximum HP*, the write is absolute (a **set**, not
them lands on data pointing into `0x8210E5xx`, which is **below the disassembly a subtract), and `damage_unit` is withdrawn: 100 heals to full, which no
DB's range** (it starts at `0x82150000`) and contains no code — so that route "damage" primitive does.
does not reach an execute method. Reaching opcodes 800802's semantics needs the
interpreter's command table, not the command objects. #### ✅ 29 is `set_unit_damage_taken_pct` — `[unit+676]` scales incoming damage
`[unit+672]` and `[unit+676]` are **both initialised to 1.0** by the entity
constructor (`0x8239395C`/`0x82393964`, from the float at `0x8208583C`), so both
are multipliers whose neutral value is 1.0 and which built-ins 28/29 set to
`operand/100`.
`[unit+676]` has **three independent readers**, and every one of them multiplies
a damage amount immediately before it is subtracted from `[unit+532]`:
| reader | what it computes |
|---|---|
| `0x8237B20C` in `sub_8237B020` (craft subclass `0x820B0A3C` slot 6, the `0xED0200DE` damage arm) | `dmg = [msg+36] × def.ResistanceToPlayer × [unit+676]` |
| `0x823803A4` in `sub_823800A8` (another subclass's damage arm) | the same product |
| `0x82399FFC` in `sub_82398CC0` (the base class's continuous-damage arm) | `dmg = def.HP × globalRate × t × [unit+676]`, then `[unit+532] -= dmg` |
`def+128` is `ResistanceToPlayer` in the layout file, which is the second
corroboration that the object is the entity and the quantity is damage.
#### 🟡 28 sets `[unit+672]`, and its one consumer scales damage DEALT
The write is certain and the field's default (1.0) is certain. The **label** is
one link weaker than 29's, and this is the honest state:
`[unit+672]` is read in **exactly one place** in the image —
`0x8237A44C`, inside `sub_823785A0`, which is vtable `0x820B0A3C` **slot 1**,
i.e. the craft subclass's per-frame update. There it is copied into a 240-byte
projectile spawn record at `+100`; `sub_8238F4D0` turns that record into a
projectile with `[proj+300] = [rec+100]`; and `sub_8238FE10` builds the
`0xED0200DE` damage message with **`[msg+36] = [proj+300] × charge × globalScale`**
— the same `[msg+36]` that the readers above multiply by the *target's* `+676`.
So the two fields are the two ends of one damage product: **`+672` on the
shooter, `+676` on the target.** That is a tidy story and it is exactly the
shape that has produced wrong names in this file before, so it is 🟡 and not ✅.
**What is not established**: that `+672` reaches *every* weapon. The one read
site is on a charged-shot path (`[unit+2244]` is a charge accumulator compared
against thresholds, `[unit+2268]` the resulting power), and the other damage
sender in the same family, `sub_82388FF8`, computes
`dmg = ammo.base × [proj+268] × k` with **no `+672` term at all**. Either the
craft update funnels all firing through this one spawn, or `+672` scales only
some weapons. Not settled.
#### ✅ The operand-range test discriminates the three
All three built-ins have the identical signature `(unit, double)` and the
identical `× 0.01` conversion, so an operand ceiling is a real measurement rather
than a coincidence:
| built-in | n | distinct | min | max | inside [0, 100] |
|---|---|---|---|---|---|
| 26 | 97 | 7 | 0 | **100** | **97 / 97** |
| 29 | 164 | 6 | 0 | **100** | **164 / 164** |
| 28 | 410 | 13 | 0.1 | **2000** | 42 / 410 |
26 and 29 respect a hard 100 ceiling — they are percentages of something with a
natural maximum (HP; and "no more damage than normal"). 28 does not: it is a
free multiplier that reaches 20×. 🟡 Note that 29 therefore **only ever makes a
unit tougher**, never more fragile.
#### ✅ The craft cross-tab — the same test that settled built-in 15
Joining every call site to its squadron's craft through `UnitGroup_S<NN>.tbl`
resolves **671 of 671** sites (26 + 28 + 29), none unknown. Full dump in
[`data/isl-builtins-26-28-29-sites.txt`](../data/isl-builtins-26-28-29-sites.txt).
**26** splits into three groups with nothing in between:
| group | craft (sites) | values |
|---|---|---|
| disposable objects | `e201_ISCMissile` 17, `n001_TTRL_Box` 16, `f202_Cargo` 9, `e015_Puppy(_2)` 10, `mn500_FloatingMine` 3, `e011_Attacker_B` 2 | **0, and only 0** |
| warships | `e106_Destroyer` 8, `f106_Destroyer` 8, `f105_Cruiser` 5, `e108_ASFrigate` 4, `e105_Cruiser` 2, `f101_Acropolis` 1 | 30 / 50 / 60 / 80 |
| the player's craft in a tutorial | `..._Player_Ttrl1/2`, `..._T_Ttrl` (8) | **100** ×7, 40 ×1 |
Scripted removal of props, pre-damaged capital-ship spawns, and healing the
player to full at the start of a tutorial section — one field, three uses, and
no other reading covers all three.
**29** is dominated by the ships the mission must protect:
| craft (sites) | values |
|---|---|
| player + wingman `DeltaSaber` variants (`_T`, `_W`, `_A`, `_Player`) 113 | **0** and **100**, alternating |
| `n001_TTRL_Box` 9 | **0** only |
| escorted TCAF hulls — `f202_Cargo` 14, `f106_Destroyer` 2, `f105_Cruiser` 2, `f104_Battleship` 1, `f102_LightCarrier` 1, `f101_Acropolis` 1 | 50 / 75 |
| named ADAN aces — `e001_Elan_GR`, `_GR_Violeta` 6; `e010_Attacker_S` 18 | 40 / 50 |
**28** stratifies by combat class and is applied almost only to ADAN craft:
| class | craft (sites) | value |
|---|---|---|
| named bosses | `e003_ElanPlus_Margras` 4, `e005_ElanTypeQ_Margras` 1 | **2000** |
| named aces | `e001_Elan_GR(_Violeta)` 6, `e013_ElanPlus_Taskent` 2 | **1000** |
| elite fighters | `e004_ElanPlus_N` 19, `e009_Phantom` 16, `e002_Elan_N` 9, `e011_Attacker_B_HF` 3 | **500600** |
| line fighters | `e001_Elan` 24, `e010_Attacker_S` 21, `e011_Attacker_B` 6 | 200300 |
| capital hulls | `e106_Destroyer` 105, `e104_Carrier` 55, `e105_CruiserEX` 52, `e102_BattleshipEX` 26 | 100300 |
| background props | `f106_Destroyer_Inv`, `f102_LightCarrier_Inv`, `f101_Acropolis`, `f105_Cruiser` (5) | **0.1** |
A monotone boss > ace > elite > line > prop ordering is what a firepower knob
looks like. 🟡 It is also what several other knobs would look like, which is why
the name stays PROBABLE and rests on the disassembly link above rather than on
this table.
#### ✅ The idiom that ties all three together
The per-unit setup block that follows every deployment reads, e.g. Stage 06
phase 3 at `0x0119AC` (from
[`data/isl-builtins-26-28-29-sites.txt`](../data/isl-builtins-26-28-29-sites.txt)):
```
activate_unit(TCN001) set_group_speed(TCN001, 400) 29(TCN001, 0)
activate_unit(TCN002) set_group_speed(TCN002, 400) 29(TCN002, 0) 28(TCN002, 50)
activate_unit(TCN003) set_group_speed(TCN003, 400)
... 26(TCN306, 50)
```
`15 / 29 / 28` is a **speed / toughness / firepower trio**, each a percentage
override of the craft's datasheet, applied immediately after `activate_unit`;
`26` joins it wherever a unit should arrive pre-damaged.
And the scripted-drama use is unambiguous. Stage 06 makes the wingman flight
TCN002 invulnerable at phase start (`29(TCN002, 0)`), then walks its HP down as
the voice lines fire:
```
0x01A7B0 26(TCN002, 80) -> request_script_message(MSG_VOICE_D_065)
0x01AAA0 26(TCN002, 50) -> request_script_message(MSG_VOICE_D_069)
0x01ACEC 26(TCN002, 30)
```
You would only combine those two built-ins that way if they meant exactly "this
unit cannot be hurt by combat" and "set this unit's HP to N %".
#### ✅ Refutation attempts, and what they found
Recorded because two of them turned into confirmations.
* **"26 kills a unit the mission still needs."** Over all 28 stages there are
200 (stage, unit) pairs where a predicate on `u` follows a `26(u, 0)` in file
order. Every one inspected is a `unit_alive` / `unit_state` / `hp_pct_test`
**poll waiting for that death** — which is what a scripted kill implies, not a
contradiction. Not a counterexample, but the count is recorded rather than
hidden.
* **"29 makes a unit invulnerable that the player is required to destroy."**
The nine `n001_TTRL_Box` targets in Stage 28 do get `29(box, 0)`. They are
then destroyed by **`26(box, 0)`** later in the same file — nine boxes, nine
pairs, invulnerability first (offsets 0x2E4C0x48C8) and the scripted removal
second (0x50F80x5FA0). The tutorial target cannot be shot down; the script
removes it when the lesson ends. The counterexample became a confirmation.
* **"28 is applied to something with no weapons."** Of the 35 craft classes it
touches, none is an unarmed prop — no asteroid, box, cargo or mine ever
receives 28, while 26 and 29 both do. The only near-miss is the four
`…_Inv` / background hulls at 0.1, i.e. deliberately harmless.
* **"`+672`/`+676` have another consumer that contradicts damage."** Searched
every `lfs`/`stfs`/`addi`/`lfsx` reference to offsets 672 and 676 across the
whole `.text`, then filtered by base register. **No further entity-side reader
of either exists.** ⚠️ The search also produced the trap this file keeps
warning about: `sub_8238E0F0` writes `676(r3)` and `sub_82389558` reads
`672(r31)`, but in both `r3`/`r31` is a **projectile**, which has its own
fields at those offsets. Three of the five "extra readers" the raw offset
search returned were that mistake.
* **What I could not refute and could not confirm**: that `+672` reaches every
weapon (see the ❔ above).
#### ✅ Bonus: built-in 101 is `all_units_invulnerable`
`sub_822691C8` is 29's **broadcast** twin — same descriptor `0x820A8D20`, same
opcode 802 — but it takes **no operand**: the float it sends is the constant at
`0x8209FD28`, which is **0.0**, and it loops over the phase's whole unit array
posting `0xED0804DE` to each live entry.
The usage confirms it. All **133** sites sit in one fixed phase-teardown idiom,
with no exceptions:
```
builtin116(0) -> builtin101 -> reset_phase_threads -> timer_stop -> clear_flag(-1) -> builtin118
```
133/133 preceded by 116 and 133/133 followed by 100. Freezing all damage is
exactly the first step of tearing a phase down, and no other reading of "post
0.0 to every unit" fits a teardown.
#### ❌ Two corrections to this file's earlier entries
* **The state guards were mis-stated.** Read directly: **26 rejects states 3 and
4**; **28 and 29 reject 1, 3 and 4**. This file said 26 rejects "only state 3"
and 28/29 "states 1 and 3". So 26 alone will still act on a state-1 unit.
* **"28 is an absolute quantity, 26 and 29 are percentage-shaped"** was half
right for the wrong reason. All three are percentages of *something*; only 26
and 29 are percentages of a thing with a ceiling.
#### 🗒️ The route that failed, kept
The three commands' descriptors at `0x820A8D10` / `+8` / `+16` are **vtables**,
not data: slot 0 of each is `0x82301C20` and slot 1 points into `0x8210E5xx`,
below the disassembly DB's range. Chasing them still leads nowhere. The route
that works is the one this file already recommended — the **interpreter command
table** → `0x822FF118/120/128` → the three opcode handlers — and from there the
**unit-message pump**, which is the part that was missing.
### ✅ Built-in 108 is `deploy_squadron_ex` — `deploy_squadron` plus a `1 << n` selector ### ✅ Built-in 108 is `deploy_squadron_ex` — `deploy_squadron` plus a `1 << n` selector

View File

@@ -1,8 +1,16 @@
# `REGN` — a per-map spatial grid (and `MCOL` beside it) # `REGN` — a stage's tetrahedral navigation mesh (and `MCOL` beside it)
**Status: ✅ `CONFIRMED` for the header**, which self-checks on all 11 objects on **Status: ✅ `CONFIRMED`.** A `REGN` object is a **tetrahedral navigation mesh
the disc. ❔ the four data sections are undecoded. **New to this corpus** — no of the whole play volume** — vertices, tetrahedra, faces with full adjacency,
document mentioned `REGN`, `MCOL` or `hidden/MiscBin.pak` before 2026-08-24. per-tetrahedron portal costs — plus a uniform grid for point location. The
decode is complete except for two small fields; see
[the 2026-08-26 section](#-2026-08-26--the-reader-found-regn-is-a-tetrahedral-navigation-mesh),
which supersedes the offsets in the older sections below (they were read
**16 bytes early** — the loader's fixup base is `chunk + 0x10`). The older
sections are kept because their refutations are still instructive.
**New to this corpus** — no document mentioned `REGN`, `MCOL` or
`hidden/MiscBin.pak` before 2026-08-24.
## Where it is ## Where it is
@@ -131,23 +139,6 @@ thing is indexed *by*.
partition and no more; the sections are unread. Treat this as the location of the partition and no more; the sections are unread. Treat this as the location of the
world's spatial data, not as the wave table. world's spatial data, not as the wave table.
> ❌ **SETTLED 2026-08-26 — `REGN` is not the wave scheduler, and cannot be.**
> It is a **tetrahedral navigation mesh**: vertices, faces with plane equations
> and adjacency, tetrahedra with portal costs between their face pairs, and a
> grid that indexes which tetrahedra fall in each cell. Every section is
> accounted for by that structure. There is nowhere in it for "what spawns, when,
> on what trigger" to live — no time field, no unit reference, no trigger.
>
> The hedge above was right to keep the reading provisional, but the reasoning
> it hedged ("a grid is what a scheduler would be indexed by") was a **guess from
> shape**, and the shape belonged to pathfinding. Worth noting because that guess
> is what pointed this whole investigation at `REGN` in the first place.
>
> The wave-scheduler search should now treat `REGN` as **excluded**, not as
> unread. The arrival timetable already found in `Route_S<NN>.tbl` — keyframed
> `(time, quat, pos)` per squadron per phase, with `t` in seconds — remains the
> only located part of that mechanism.
## ✅ Update: `REGN` is a stage's **MapPath** ## ✅ Update: `REGN` is a stage's **MapPath**
The per-stage definition record (see The per-stage definition record (see
@@ -391,119 +382,338 @@ object in the executable and watch which fields it dereferences. That is static
PE work (`/work/*.pe`, offset = VA 0x82000000) of the same kind that cracked PE work (`/work/*.pe`, offset = VA 0x82000000) of the same kind that cracked
the `.slb` packing phase, and it is the honest next step rather than a the `.slb` packing phase, and it is the honest next step rather than a
twenty-first correlation. twenty-first correlation.
---
## ⚠️ Open conflict with `auto/regn-reader` over the `+0x10` base # ✅ 2026-08-26 — the reader found: `REGN` is a **tetrahedral navigation mesh**
**2026-08-26.** Branch `auto/regn-reader` decodes `REGN` as a **tetrahedral The previous section closed with "find what reads a `REGN` object in the
navigation mesh** and reports the cell→geometry link solved, with strong checks executable and watch which fields it dereferences". That worked, and it did not
(faces passing through 3 of 4 tet vertices, 253 722/253 722; portal cost equal to need the consumer: the **deserialiser** answers the question, because the file
the distance between face centroids, 380 460/380 460). Its load-bearing structural carries its own pointer map and the deserialiser tells you how to read it.
claim is that the `POF0` fixup base is **`chunk + 0x10`**, and therefore that
"every offset previously recorded on that page was read 16 bytes early" — offered
as the reason the ~20 correlation tests on this page returned chance.
**I could not reproduce that as stated, on the one thing here that is ## ✅ The reader — `sub_82465110` / `sub_82465138` / `sub_82465200`
independently checkable.** Re-reading the plane list at both bases:
| | records | unit normals | Three functions, all in the resource module around `0x82460000`:
|---|---|---|
| section at `chunk + offset` (what this page used) | 133 573 | **133 573 (100.00 %)** |
| section at `chunk + 16 + offset` | 133 573 | **0 (0.00 %)** |
And the plane identity `n·p + d = 0` holds to float round-off at the **unshifted** ```
base. A 16-byte shift destroys it completely. So for *this* record the unshifted sub_82465110 find_pof0(chunk)
reading is right, and the blanket statement is not. 82465110 lwz r11, 4(r3) ; datasize
82465118 add r11, r11, r3
82465120 addi r3, r11, 16 ; -> chunk + 16 + datasize
82465124 lwz r11, 0(r3)
82465114 lis r10, 0x504F / 8246511c ori r10, r10, 0x4630 ; 'POF0'
82465128 cmplw cr6, r11, r10
8246512c beqlr cr6 ; else return 0
```
🟡 **The likely reconciliation is bookkeeping, not disagreement.** The other ```
branch describes a 48-byte **face** record whose plane fields sit at a different sub_82465138 relocate_chunk_chain(chunk)
intra-record offset; `chunk+16` with the plane at `+0` addresses the same bytes as 82465164 lbz r11, 8(r31) / clrlwi r11,r11,31 ; already-relocated bit
`chunk+0` with the plane at `+16`, which is exactly what this page uses. If so 82465178 bl 0x82465110 ; find the POF0 chunk
both readings are correct and only the origin convention differs — but that is a 82465194 addi r4, r11, 16 ; POF0 payload
guess, and I am not adopting either page's wording until it is checked. 82465198 lwz r5, 4(r11) ; POF0 payload size
8246519c addi r3, r31, 16 ; ← THE FIXUP BASE = chunk + 0x10
824651a0 bl 0x82465200
824651a8 ori r11, r11, 0x1 / stb r11, 8(r31) ; set the bit
```
**Also confirmed here, incidentally**: my section-0 point test passes at **100 % ```
at both bases**, so it never had the power to distinguish them. That test could sub_82465200 apply_pof0(base=r3, table=r4, size=r5)
not have caught a 16-byte error and should not be cited as if it validated the 82465224 clrrwi r8, r10, 6 ; top two bits of the lead byte select
offsets. 82465228 cmplwi cr6, r8, 0x40 ; 0x40 → 6-bit delta, 1 byte
82465230 cmplwi cr6, r8, 0x80 ; 0x80 → 14-bit delta, 2 bytes
82465238 cmplwi cr6, r8, 0xC0 ; 0xC0 → 22-bit delta, 3 bytes
82465258 add r11, r10, r11 ; running WORD index, never reset
8246525c slwi r8, r11, 2
82465260 lwzx r10, r8, r3 ; slot = base[word]
82465264 cmplwi cr6, r10, 0x0
8246526c add r10, r10, r3 ; *slot += base (skipped when *slot == 0)
82465270 stwx r10, r8, r3
```
❔ Until the conflict is resolved, treat the plane/face layout on this page as Two things follow, and both are load-bearing:
verified by its own arithmetic and the tetrahedral decode on `auto/regn-reader`
as verified by its own — and do not merge the two offset conventions without
re-running one check from each.
## ✅ RESOLVED — the base IS `chunk + 0x10`, and my section-0 offsets were wrong 1. **The fixup base is `chunk + 0x10`, not `chunk + 0`** (`82465198`:
`addi r3, r31, 16`). Every stored "offset" in a `REGN` file is relative to
file offset `0x10`, and every **record origin** on this page above is
therefore 16 bytes early.
2. **The `POF0` table is an exact list of which words are pointers.** It is not
a heuristic — it is the data the retail loader itself walks. Decoding it
gives the pointer graph directly, with no guessing.
**2026-08-26, same day.** The conflict above is settled, and it settles against ### 🔴 A correction to how I first stated point 1
me on the boundary while leaving the plane arithmetic intact.
**The plane fields were never in dispute.** For object `3506e972`, the other The first version of this section said "everything on this page above was read
branch's face record 0 begins at `0x1c700`, and its plane normal begins at the 16 bytes early", which is **too broad and I withdraw it**. For the plane fields
same address; my `chunk + offset_at_0x78 + 16` gives `0x1c700` too. **Same bytes, it is *false*: the older reading put the section at `chunk + offset` and the
different bookkeeping** — exactly the reconciliation guessed above. The normal at intra-record `+16`, which addresses **exactly the same bytes** as
`n·p + d = 0` result stands unchanged. `chunk + 0x10 + offset` with the normal at `+0`. Its unit-normal and
`n·p + d = 0` results were measuring real planes and stand unchanged. On
`3506e972`, both conventions put face record 0's normal at file offset
**`0x1c700` (116 480)** = `0x10 + 0x1c6f0`, where the header word at `0x78` is
`0x1c6f0`.
**The base is `chunk + 0x10`**, on evidence that has power where my tests did What is genuinely wrong is the **record boundary**, and it matters for one
not: reason: it decides which 48-byte record the four integer words belong to — and
those integer words are the face adjacency, i.e. precisely the thing the
correlation search was hunting for. It also mis-resolves every stored pointer
by 16 bytes, twice over along the cell → item → ref → tetrahedron chain, which
is the mechanical reason that chain never landed on anything.
* the loader does it: `8246519c addi r3, r31, 16`; ### ✅ Which tests actually decide the origin — and which have no power
* at `+0x10` the six `POF0`-relocated slots land exactly on `0x70``0x84`, the six
section pointers. At `+0` they would land on `0x60``0x74` — relocating the
`u16` **counts** and leaving two section pointers unrelocated. Not merely
wrong, non-functional;
* section-0 record 0 reads as a bbox corner at `+0x10` and as garbage at `+0`.
### ❌ And my point-list evidence was vacuous Most of the numbers on this page are **content** tests and address the same
bytes under either convention, so they cannot settle the origin. Stated
plainly, because the same trap caught the point-in-bbox test:
I reported "13 467/13 467 section-0 points lie inside the bbox" as the check that | test | discriminates the base? |
made section 0 a decode. Read at the correct base, record 0 of four objects is:
base +0 (1.4e-41, 5.3e-41, 0) (2.2e-40, 2.7e-40, 0)
base +16 (-50000,-50000,-50000) (-250000, 250000, -250000)
So my offsets were 16 bytes early throughout. **Only 11 of 13 467 points read as
denormal at the wrong base** — the rest were still plausible coordinates inside
the box, because a 16-byte shift within a packed array of `f32` triples yields
*other floats from the same array*. That is the general form of the failure and
it is worth stating plainly:
> **A containment test cannot detect a shift inside a homogeneous array.** The
> shifted values are drawn from the same distribution as the correct ones, so the
> test passes at 100 % either way. It is not a weak check — for this class of
> error it is no check at all.
The conclusion "section 0 is a point list" happens to be right. The evidence I
gave for it was not evidence.
**Adopt `chunk + 0x10`.** The plane *fields* need no change; the record
*boundary* moves, and the four integer words I described as "four zeros" move
with it into the record they describe — which is where the face adjacency, and
the answer to the cell→geometry question, was hiding. See branch
`auto/regn-reader` for the tetrahedral decode.
## ✅ Independent confirmation of the face decode
**2026-08-26.** I re-derived the other branch's central check with my own code,
my own reading of the record, and my own control — rather than accepting the
number.
Reading at base `chunk + 0x10`, taking the plane at intra-record `+0` and the
**three `u16` vertex indices at `+32`**, and asking whether each named vertex
satisfies its own face's plane equation:
| | |
|---|---| |---|---|
| faces where **all three** named vertices lie on the plane | **133 573 / 133 573 (100.00 %)** | | section-5 targets on 96-byte boundaries; ref-array packing | ❌ no — `target start` cancels the base |
| a **random** vertex lying on that plane (control) | 2 782 / 400 719 = **0.69 %** | | face passes through 3 of 4 vertices; portal-cost identity | ❌ no — same absolute bytes either way |
| sphere reaches its cell (98.7 % vs 1828 %) | ❌ no for the base; it discriminates the **axis order** |
| points inside the bbox (the older section) | ❌ no — a denormal reads as ≈ 0, which is inside a ±250 km box |
| **the loader's own `addi r3, r31, 16`** | ✅ direct |
| **where `POF0` says the header pointers are** | ✅ at `+0x10` the six relocated slots are exactly `0x70``0x84`, the six section pointers. At `+0` they would be `0x60``0x74`: the loader would relocate the `u16` **counts** and would leave the pointers at `0x78``0x84` unrelocated. Not merely wrong — non-functional. |
| **what section-0 record 0 contains** | ✅ at `+0x10` a real vertex (`-250000, 250000, -250000` on `d4c89536`); at `+0` the two remaining header pointer words read as floats — `2.2e-40, 2.7e-40, 0` |
| **the section-2 record boundary** | ✅ see below, 100.000 % against 0.000 % |
A 100 % against a 0.69 % baseline is not a fit — the face record genuinely names ### ✅ The record boundary, settled
the vertices of its own plane, and the `+0x10` base together with this layout is
right.
That also settles the record-boundary question from my side: the `u16`s that A tetrahedron names face `f`; the plane at record `f` is independently verified
describe a plane sit **after** it in the same 48-byte record. My earlier reading (three of the tet's four vertices lie on it, 100 %). The question is whether the
of "four zeros at the start of each record" was those integers seen 16 bytes out adjacency block that says *"I am face `f`, my tetrahedra are A and B, at their
of position, one record late. face slots i and j"* sits in the **same** 48-byte record as that plane.
This is worth having in the corpus independently of the other branch: two Requiring `own == f` **and** `tet[A].faces[i] == f`:
different implementations, two different guesses at the intra-record layout, the
same 100 %. adjacency block taken from record f (base + 0x10) : 100.000 %
adjacency block taken from record f + 1 (base + 0) : 0.000 %
11 of 11 objects, every face, both sides. The plane and the four integer words
that describe it are one record, and that record starts at `chunk + 0x10 +
offset + 48·f`.
`sub_82465138` is reached from exactly two callers, `sub_82461018` (vtable slot
9 of the class at `0x820af8bc`, the pak/resource file class) and
`sub_82461DE8`; both store `chunk + 16` as the object's data pointer, which
confirms the same `+0x10` base from the other side.
### 🔴 …and the magic is never compared
Worth recording because it is what sent the search to the fixup table:
**`'REGN'` and `'MCOL'` are not constructed anywhere in the executable.** The
title builds its four-character tags as `lis`/`ori` pairs, and a sweep of every
such pair recovers 156 tags — `RATC`, `T8aD`, `XBG7`, `IPFB`, `IDXD`, `LSTA`,
`POF0`, `PRMD`, `TBMD`, `WMV3` … — but neither of these. Nor does either half
appear as an immediate anywhere: `0x474E` (`'GN'`) occurs **zero** times in
1 865 751 instructions, and the flat PE image contains the byte string `REGN`
**zero** times. So no magic-dispatch site exists to find; `.rgn` objects are
handed to the map code by the stage record, not identified by their tag.
## ✅ The header is 0x70 bytes at `chunk + 0x10`, with **six** sections
Corrected, and re-derived from the `POF0` table, which relocates exactly six
header words (`0x70`, `0x74`, `0x78`, `0x7c`, `0x80`, `0x84`) on 11 of 11:
```
chunk +0x00 char[4] 'REGN'
+0x04 u32 data size (POF0 chunk at +0x04-value + 0x10)
+0x08 u8 flags; bit0 = "already relocated" (set by sub_82465138)
data = chunk + 0x10:
+0x00 f32[4] bbox min (w = 1.0)
+0x10 f32[4] bbox max
+0x20 f32[4] extent
+0x30 f32[4] cell size
+0x40 u32[4] grid dims
+0x50 u16[6] record counts, one per section
+0x60 ptr[6] section pointers
```
The old page had four sections because it read the last two pointers as data.
There are six, their strides are `12, 96, 48, 8, 32, 4`, and each section's
span divided by its stride is its `counts[]` entry exactly — 11 of 11, with the
only slack being 16-byte alignment padding and, for the face list, **two
all-zero sentinel records** (which is the unexplained "constant 96-byte tail"
from the section above: 2 × 48).
| # | contents | stride | count |
|---|---|---|---|
| 0 | vertices | 12 | `counts[0]` |
| 1 | **tetrahedra** | 96 | `counts[1]` |
| 2 | **faces** (plane + adjacency) | 48 | `counts[2]` |
| 3 | cell index, one per cell | 8 | `counts[3]` = cells |
| 4 | cell items, one per **occupied** cell | 32 | `counts[4]` |
| 5 | tetrahedron references | 4 | `counts[5]` |
## ✅ How a cell reaches its geometry — the question, answered
The `POF0` table places every pointer in the file, and there are only four
kinds. Verified on all 11 objects by `tools/re-capture/regn_decode.py verify`:
* the six header words, and nothing else in the header;
* **one pointer per occupied cell**, at section-3 record offset **`+4`**
— so a cell is `{ u32 count; item* }` and the *count* word is not a pointer;
* **exactly one pointer per section-4 record, at offset `+0x14`** — the 32-byte
cell item is `{ …, u32 n @+0x10, tetref* @+0x14, … }`;
* **every word of section 5**, all `counts[5]` of them.
So the chain is:
```
position ─▶ cell (x,y,z) index = (z·dimY + y)·dimX + x
─▶ sec3[index] {count, item*}
─▶ item {…, n @+0x10, refs* @+0x14, …}
─▶ refs[0 .. n) each a pointer into section 1
─▶ tetrahedron
```
Four independent checks, all 11 of 11 objects:
* **section-5 targets land on section-1 record boundaries** — every one of
**261 000** pointers is `sec1 + 96·k` with zero remainder;
* **the reference arrays are packed contiguously in cell order** —
`item[i].refs == sec5 + 4·Σ item[j<i].n`, exact, and the total equals
`counts[5]`;
* `counts[4]` equals the number of relocated section-3 pointers equals the
number of occupied cells;
* **the cell ordering is x-fastest, then y, then z.** A tetrahedron's bounding
sphere reaches the box of a cell that lists it in **98.7 100 %** of all
261 000 references; transposing the axes — the natural control — drops that
to **18 28 %**.
The list is a strict subset of "every cell the bounding sphere touches"
(Jaccard ≈ 0.4 1.0), i.e. the build used a tighter test than sphere-vs-box.
That is expected and is not claimed as decoded.
## ✅ Section 1 is a **tetrahedron** — 96 bytes
```
+0x00 f32[3] bounding-sphere centre
+0x0c f32 bounding-sphere radius
+0x10 u16[4] vertex indices → section 0
+0x18 u16[4] face indices → section 2
+0x20 6 × { f32 cost; f32 ❔ } one pair per face pair, in the order
(0,1) (0,2) (0,3) (1,2) (1,3) (2,3)
+0x50 u32 the record's own index
+0x54 u8[4] flags, values 0/1
```
**The check that makes this a decode, not a reading**: each of a tetrahedron's
four faces must pass through exactly three of its four vertices. Over
**253 722** (tetrahedron, face) pairs on the disc, the count of vertices lying
on the plane is **3 in 100.00 % of cases**. The control — the same test with a
randomly chosen face of the same object — gives 3 in **0.07 2.2 %**, and
touches no vertex at all 96 % of the time.
Two more, both 11 of 11: the bounding sphere encloses all four vertices in
100 % of the 63 410 records (with ~0.1 % padding), and the word at `+0x50` is
the record's own index in 100 %.
### ✅ The six floats at `+0x20` are the portal-graph edge costs
For face `i` of a tetrahedron, let `omit(i)` be the one vertex not on it. Then
cost[k] == | V[omit(i)] V[omit(j)] | / 3 for the k-th pair (i<j)
which is exactly the **distance between the centroids of faces `i` and `j`**
the A\* step cost of crossing the tetrahedron from one portal to another. It
holds to `1e-4` relative in **380 460 of 380 460** values, all 11 objects.
❔ The second float of each pair is unread. It is a genuine varied float, zero
in 8 26 % of slots. **🔴 Refuted**: "zero marks a hull edge" — `P(zero)` is
the same for edges on the hull as for interior ones (e.g. 2.9 % / 16.4 %
against 16.7 % / 64.0 % — no lift at all). A clearance width is the obvious
reading and is *not* established.
## ✅ Section 2 is a **face** — a plane plus the adjacency graph
```
+0x00 f32[3] unit normal
+0x0c f32 d
+0x10 f32[3] a point on the plane
+0x1c f32 1.0
+0x20 u16[3] the face's three vertex indices → section 0
+0x26 u16 the face's own index
+0x28 u16[2] the tetrahedra on either side; 0xFFFF = hull (no neighbour)
+0x2c u16[2] which of that tetrahedron's four face slots this is
```
The old page's plane reading was the same 16 bytes early, which is why it
reported "four zeros" at the front: those were the *previous* record's four
integer words, read as floats.
Verified over every face of every object (11 of 11, 100.00 % each):
* the word at `+0x26` is the record's own index;
* for each non-`0xFFFF` side, `tet[side].faces[slot[side]] == this face`;
* the face's three vertices are a subset of that tetrahedron's four.
And the Euler relation closes: `4·T == 2·(F B) + B` exactly on every object
(e.g. `4 × 1172 = 4688`, `F = 2584`, `B = 480` hull halves).
## ✅ What a `REGN` object is
A stage's `MapPath` is a **tetrahedral navigation mesh of the whole play
volume**, plus a uniform grid for point location:
* vertices, tetrahedra, and faces form a conforming tet mesh with full
face adjacency and hull markers;
* each tetrahedron carries the six face-to-face traversal costs, so A\* over
the portal graph needs no geometry at query time;
* the uniform grid answers "which tetrahedra could contain this point" in O(1),
which is what a flight game needs to localise a ship into the mesh each frame.
The 100 km-cube smoke-test map (`e993b93e`, `counts = [8, 6, 18, …]`) is the
textbook **six-tetrahedron decomposition of a cube** — 8 corners, 6 tets, 18
distinct faces (24 faces less the 6 shared internally) — which is as strong a
confirmation of the reading as the statistics are.
## 🔴 Corrections to the sections above, kept rather than deleted
Every one of these was a real observation; each fails for the same single
reason, and that is worth having on the page.
| earlier claim | status |
|---|---|
| "four section offsets at `0x70`" | 🔴 there are **six**, `0x70``0x84` |
| "the first section offset is always `0x80`" | 🔴 the *value* is `0x80`; the section is at `0x90` — the base is `+0x10` |
| "`counts[4]` = occupied cells **+ 2**, unexplained" | 🔴 artefact of the base error; `counts[4]` = occupied cells exactly |
| "the constant 96-byte tail after the plane list" | ✅ explained: two all-zero sentinel face records |
| "record `+0x1c` is a bounding-sphere radius (ratio 1.001 on one object, 0.130.27 on the rest)" | 🟡 **half right**`+0x0c` *is* a bounding-sphere radius, of the *tetrahedron*; the 1.001 was a coincidence of that map, where every tet shares the cube's circumsphere |
| "`(count, offset)` pairs point at leaf arrays of `count × 4` bytes" | 🟡 **right mechanism, wrong record** — it is the cell *item* at `+0x10`/`+0x14` pointing into section 5, not the cell index |
| "slots 811 are integers (denormal as float)" | ✅ correct, and they are the four vertex + four face `u16` indices |
| "section 2 records are 48 bytes, unit normal, `n·p + d = 0` in 133 573/133 573" | ✅ **stands** — the same bytes under either origin convention; only the record boundary moves, see the correction above |
| "section 0 points are inside the bbox, 13 467/13 467" | ⚠️ true but **uninformative** — it passes at either origin, because a denormal reads as ≈ 0 and 0 is inside the box |
| "slot 7 is a local scale, ~14× smaller than the inter-node distance" | ✅ correct as measured — it is a *tetrahedron's* bounding radius, which is exactly that small |
| "the BVH reading is refuted, no node sphere contains another" | ✅ stands, and is now explained: these are sibling tetrahedra, not a tree |
| "the cell payload holds section-1 indices" | ✅ stands — it holds a **pointer to an array of pointers**, and no index anywhere |
| "the static-correlation avenue is exhausted" | ✅ stands for correlations; the file's own fixup table was never a correlation |
## ❔ Still open
* the second float of each portal pair at tetrahedron `+0x24`, `+0x2c`, … ;
* the four flag bytes at tetrahedron `+0x54` (values 0/1);
* the exact predicate the tools used to decide cell membership (tighter than
sphere-vs-box);
* **the runtime consumer.** The deserialiser is found and proven; the code that
*queries* the grid is not. Searches that came up empty, so they are not
repeated: functions loading the header field groups off one base register
(only stack frames match); `vctsxs`/`vctuxs` float→int conversion (16
functions, only one outside the XDK ranges); and the magic-comparison route,
which cannot exist (above). The likely reason the field-offset search fails
is that a `float4`-aligned header is read with VMX loads, which carry no
useful displacement signature.
## Tooling
`tools/re-capture/regn_decode.py` — standalone, stdlib only.
```
regn_decode.py list hidden/MiscBin.pak
regn_decode.py dump hidden/MiscBin.pak [name_hash]
regn_decode.py verify hidden/MiscBin.pak # every check quoted above
```
`verify` prints, per object: the `POF0` pointer-slot shape, the reference-array
packing, the face/vertex incidence with its random control, the sphere/cell
agreement with its transposed-axis control, and the portal-cost identity.

View File

@@ -149,3 +149,63 @@ into those five shapes.
Two incidental facts fall out: **4 banks run at 44 100 Hz** where everything else Two incidental facts fall out: **4 banks run at 44 100 Hz** where everything else
is 48 000, and the `BGM_*` tracks are the stereo ones. is 48 000, and the `BGM_*` tracks are the stereo ones.
## The 36 shared banks, decoded end-to-end (2026-08-28)
The census above is arithmetic — durations from the `seek` table, no decoding.
This section is the decode itself, for the 36 language-independent banks
(32 `BGM_*` + 3 `JNGL_*` + `Static.slb`), because they were the ones no viewer
had ever played: the library enumerator kept only names containing `VOICE` or
`\Briefing\`, so every one of them was filtered out before it could be tried.
**35 of 36 decode to plausible audio**, and the shapes agree with the census:
the 32 `BGM_*` come out 75555 s and **stereo**, matching "the `BGM_*` tracks
are the stereo ones"; `JNGL_002` decodes to 33.89 s against the manifest's
predicted 33.97 s.
Two things the census could not have caught, both found by decoding:
### `Static.slb`'s TOC entry over-declares its size
The SFX bank sits at the **highest offset in the archive** and claims
8 970 240 bytes — **616 768 past the end of `sound.p04`**. It is not our
extraction: `sound.p04` is byte-for-byte the size the ISO's own directory record
gives. A sweep of **every `.pak` on the disc** finds this one entry over-running
and no other, so the last entry's `comp_size` is an allocation size rather than a
stored size.
`PakArchive::stored_bytes` therefore allows a short read **only** for the
highest-offset entry. Any other overrun is still an error — that would be real
damage, and clamping it would hide the damage behind a half-decoded asset. With
the short read, `Static.slb` decodes to **514 s of mono**; before it, the SFX
bank could not be read at all.
### `JNGL_001.slb` does not decode — and that is a real gap, not a filter
It is one of the headerless banks (no `RIFF`), so it was already outside the
4 114-bank manifest above. Decoding it yields **0.01 s** — one frame, the
signature this corpus already records for a wrong channel count. But the usual
fixes do not apply:
* it is not a channel-count error the `RIFF+49` rule can repair, because there is
no `RIFF` to read the count from;
* its payload is **not a whole number of 2048-byte XMA1 packets** from any of the
four `DATA_OFFSET_CANDIDATES`, which a headerless XMA1 stream must be.
So `JNGL_001` is probably not a plain headerless XMA1 stream at all. The other
headerless root bank, `Static.slb`, decodes fine at 514 s, so the headerless path
is not broken in general — this is one bank in 9 519. It is listed in the viewer
and reports that it did not decode, rather than being hidden.
⚠️ Note on the offsets: `to_xma_riffs` still *scans* for the headerless data
offset, while [slb-data-offset.md](slb-data-offset.md) establishes the exact rule
(the cumulative `.pNN` segment start mod 2048, 8 783/8 783). Wiring the exact
rule into the decoder is open, and is the first thing to try on `JNGL_001`.
### Downmix is a per-category decision, not a constant
The decoder took the left channel unconditionally. That is right for **voice**,
whose content is mono however it is stored (some clips put the signal in the left
channel alone, others duplicate L=R) — and wrong for **music**, where the two
channels are a real stereo mix and taking one throws half of it away. The caller
now decides from the bank's category.

View File

@@ -0,0 +1,536 @@
#!/usr/bin/env python3
r"""Census -- and preimage attack -- for IDXD field keys that carry no name.
Pure static work: reads the extracted disc, runs no emulator.
python3 tools/re-capture/idxd_unnamed_keys.py census > docs/re/data/idxd-unnamed-field-keys.txt
python3 tools/re-capture/idxd_unnamed_keys.py crack # corpus + brute-force attack
python3 tools/re-capture/idxd_unnamed_keys.py idtbl # dump the 42 hash-shaped keys in context
An IDXD field entry is `(key, name_off, value_off)`. When `name_off` is
0xFFFFFFFF the field has no name in the pool, and the reader must know what the
`key` means from somewhere else. This tool answers "how many such keys are
there, what do they look like, and can the names be recovered from the hash?"
The headline measurement (see docs/re/structures/idxd-unnamed-keys.md):
2 757 039 field entries in 7 750 IDXD objects
1 271 462 named, 1 485 577 unnamed
7 094 distinct keys that are never named ANYWHERE on the disc
7 052 of them are < 0x10000 -- author-assigned ordinals / element ids
42 of them are >= 0x01000000 -- genuine tag_hash values
`SYLPHEED_DISC` (default /work/sylph_extract) points at the extracted disc.
"""
import argparse, collections, glob, os, re, struct, sys, zlib
DISC = os.environ.get('SYLPHEED_DISC', '/work/sylph_extract')
# --------------------------------------------------------------------------- hashes
TAG_MODULUS = 0x00FFFFDF # 2^24 - 33, prime
TAG_MAGIC = 0x2101 # floor(2^56/M)+1, the guest's divide magic
def tag_hash(s):
"""IDXD record key / field tag -- `sub_82447DF0`.
Transcribed in docs/re/structures/idxd-tag-hash.md and implemented three
times in this tree (here, tools/re-capture/unitgroup.py, and
sylpheed_formats::hash::tag_hash). Case-SENSITIVE, bytes sign-extended.
"""
a = b = 0
for byte in s.encode('latin-1', 'replace'):
c = (byte - 256) if byte > 127 else byte # extsb
a = ((a << 8) & 0xFFFFFFFF)
a = (a + c) & 0xFFFFFFFF
b = (b + c) & 0xFFFFFFFF
hi = ((a * TAG_MAGIC) >> 32) & 0xFFFFFFFF # mulhwu
q = ((hi + (((a - hi) & 0xFFFFFFFF) >> 1)) & 0xFFFFFFFF) >> 23
a = (a - q * TAG_MODULUS) & 0xFFFFFFFF
return (((b << 24) & 0xFF000000) | (a & 0x00FFFFFF)) & 0xFFFFFFFF
NAME_MODULUS, NAME_RECIP = 0x00FFF9D7, 0x80031493
def _rotl(v, n):
return ((v << n) | (v >> (32 - n))) & 0xFFFFFFFF
def name_hash(s):
"""IPFB TOC path hash -- lowercased, a different modulus. `sub_82455C78`."""
bs = bytearray(s.encode('latin-1', 'replace'))
for i, x in enumerate(bs):
if 65 <= x <= 90:
bs[i] = x + 0x20
a = b = 0
for byte in bs:
c = ((byte - 256) if byte > 127 else byte) & 0xFFFFFFFF
a = ((_rotl(a, 8) & 0xFFFFFF00) + c) & 0xFFFFFFFF
b = (b + c) & 0xFFFFFFFF
q = _rotl(((a * NAME_RECIP) >> 32) & 0xFFFFFFFF, 9) & 0x1FF
a = (a - (q * NAME_MODULUS)) & 0xFFFFFFFF
return (((b << 24) & 0xFF000000) | (a & 0x00FFFFFF)) & 0xFFFFFFFF
# --------------------------------------------------------------------------- pak / IDXD
def pak_entries(pak):
"""yield (toc_key, payload) for every entry of an IPFB pak + its .pNN segments."""
idx = open(pak, 'rb').read()
data = b''.join(open(p, 'rb').read()
for p in sorted(glob.glob(pak[:-4] + '.p[0-9][0-9]')))
for i in range(struct.unpack_from('>I', idx, 4)[0]):
k, off, csize = struct.unpack_from('>III', idx, 16 + i * 12)
s = data[off:off + csize]
if s[:2] == b'Z1':
try:
s = zlib.decompress(s[10:])
except zlib.error:
continue
yield k, s
def parse_idxd(b):
"""-> [(record_key, record_name, [(tag, name|None, value, field_index)])]"""
u = lambda o: struct.unpack_from('>I', b, o)[0]
nrec = u(0x04)
npool_off = 0x08 + nrec * 16
npool = u(npool_off)
pool = npool_off + 4
strsize_off = pool + npool * 12
STR = strsize_off + 4
strsize = u(strsize_off)
if STR + strsize != len(b):
raise ValueError('string-pool trailer mismatch')
def s(o):
if o >= strsize:
raise ValueError('string offset out of pool')
return b[STR + o:b.index(b'\x00', STR + o)].decode('latin-1')
out = []
for i in range(nrec):
key, nm, lo, hi = (u(0x08 + i * 16 + j * 4) for j in range(4))
fields = []
for j in range(lo, hi):
o = pool + j * 12
tag, noff, voff = u(o), u(o + 4), u(o + 8)
fields.append((tag, s(noff) if noff != 0xFFFFFFFF else None, s(voff), j - lo))
out.append((key, s(nm), fields))
return out
def all_paks():
return sorted(glob.glob(DISC + '/dat/*.pak') + glob.glob(DISC + '/hidden/*.pak'))
def walk(counter=None):
"""yield (pak_basename, obj_toc_key, record_key, record_name, fields)."""
for pak in all_paks():
base = os.path.basename(pak)
for h, payload in pak_entries(pak):
if payload[:4] != b'IDXD':
continue
try:
recs = parse_idxd(payload)
except Exception as exc: # noqa: BLE001
print('PARSE FAIL %s %08x %s' % (base, h, exc), file=sys.stderr)
continue
if counter is not None:
counter['objects'] += 1
schema = recs[0][1] if recs else ''
for key, rname, fields in recs:
yield base, h, schema, key, rname, fields
# --------------------------------------------------------------------------- value profile
_FLOAT = re.compile(r'^-?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$')
_INT = re.compile(r'^-?\d+$')
_HEX = re.compile(r'^(0[xX])?[0-9a-fA-F]+$')
_FILE = re.compile(r'^[\w\\/. -]+\.[A-Za-z0-9]{2,4}$')
def classify(values):
"""A coarse type label for a bag of field values."""
if not values:
return 'empty'
kinds = set()
for v in values:
if v == '':
kinds.add('empty')
elif _INT.match(v):
kinds.add('int')
elif _FLOAT.match(v):
kinds.add('float')
elif _FILE.match(v):
kinds.add('file')
elif v in ('True', 'False', 'Yes', 'No', 'ON', 'OFF'):
kinds.add('bool')
elif _HEX.match(v) and len(v) >= 6:
kinds.add('hex')
else:
kinds.add('id')
order = ['file', 'id', 'hex', 'bool', 'float', 'int', 'empty']
return '+'.join(k for k in order if k in kinds)
# --------------------------------------------------------------------------- census
def census():
named = collections.defaultdict(collections.Counter)
unnamed = collections.Counter()
key_objs = collections.defaultdict(set)
key_recs = collections.defaultdict(collections.Counter)
key_idx = collections.defaultdict(lambda: [1 << 30, -1])
key_vals = collections.defaultdict(list)
key_nval = collections.Counter()
# per (object-schema, record-name) family, for the ordinal population
fam = collections.defaultdict(lambda: {'keys': set(), 'n': 0, 'vals': [], 'recs': 0})
tot = collections.Counter()
schema_of = {}
for base, h, schema, rkey, rname, fields in walk(tot):
tot['records'] += 1
# the object's schema hash is the key of its first record; use record name
fam_id = None
for tag, name, val, fidx in fields:
tot['fields'] += 1
if name is not None:
tot['named'] += 1
named[tag][name] += 1
else:
tot['unnamed'] += 1
unnamed[tag] += 1
key_objs[tag].add((base, h))
key_recs[tag][rname] += 1
lohi = key_idx[tag]
lohi[0] = min(lohi[0], fidx)
lohi[1] = max(lohi[1], fidx)
key_nval[tag] += 1
if len(key_vals[tag]) < 40:
key_vals[tag].append(val)
if fam_id is None:
fam_id = (base, schema)
f = fam[fam_id]
f['keys'].add(tag)
f['n'] += 1
if len(f['vals']) < 60:
f['vals'].append(val)
if fam_id is not None:
fam[fam_id]['recs'] += 1
return dict(named=named, unnamed=unnamed, key_objs=key_objs, key_recs=key_recs,
key_idx=key_idx, key_vals=key_vals, key_nval=key_nval, fam=fam, tot=tot)
def report(c, out=sys.stdout):
named, unnamed = c['named'], c['unnamed']
never = sorted(set(unnamed) - set(named))
hashy = [k for k in never if k >= 0x01000000]
ordinal = [k for k in never if k < 0x01000000]
both = sorted(set(unnamed) & set(named))
w = out.write
w('# IDXD field keys that carry no name\n')
w('#\n')
w('# Generated by tools/re-capture/idxd_unnamed_keys.py census\n')
w('# Disc: %s (all dat/*.pak + hidden/*.pak, .pNN segments joined, Z1 inflated)\n' % DISC)
w('# Companion write-up: docs/re/structures/idxd-unnamed-keys.md\n')
w('#\n')
w('# A field entry is (key, name_off, value_off). name_off == 0xFFFFFFFF means the\n')
w('# pool holds no name for it, so the reader must already know what `key` means.\n')
w('\n')
w('== TOTALS ==\n')
w('IDXD objects walked : %d\n' % c['tot']['objects'])
w('IDXD records walked : %d\n' % c['tot']['records'])
w('field entries : %d\n' % c['tot']['fields'])
w(' named (name_off != 0xFFFFFFFF) : %d\n' % c['tot']['named'])
w(' unnamed (name_off == 0xFFFFFFFF) : %d\n' % c['tot']['unnamed'])
w('distinct keys seen named somewhere : %d\n' % len(named))
w('distinct keys seen unnamed somewhere : %d\n' % len(unnamed))
w('distinct keys NEVER named anywhere on disc : %d\n' % len(never))
w(' of those, < 0x00010000 (ordinal-shaped) : %d\n' % len([k for k in never if k < 0x10000]))
w(' of those, in [0x10000, 0x1000000) : %d\n' % len([k for k in never if 0x10000 <= k < 0x1000000]))
w(' of those, >= 0x01000000 (hash-shaped) : %d\n' % len(hashy))
w('keys that are named in one place and unnamed in another: %d %s\n'
% (len(both), ['%08x' % k for k in both]))
w('unnamed FIELD ENTRIES whose key is hash-shaped : %d\n'
% sum(c['key_nval'][k] for k in hashy))
w('unnamed FIELD ENTRIES whose key is an ordinal : %d\n'
% sum(c['key_nval'][k] for k in ordinal))
w('unnamed FIELD ENTRIES with key 0x00000000 : %d (ordinal 0; the key is\n'
% sum(c['key_nval'][k] for k in both))
w('# "named" elsewhere only because tag_hash("") == 0, so it is counted apart)\n')
w('# The first of those two is the "504 hash-keyed fields disc-wide" already in\n')
w('# INDEX.md. It is 504 ENTRIES, not 504 distinct keys: %d keys x 6 language\n' % len(hashy))
w('# copies of the same object x 2 records (FILE and OFFSET) = %d.\n'
% sum(c['key_nval'][k] for k in hashy))
w('\n')
w('# Why the split at 0x01000000 is not arbitrary: a tag_hash puts the byte-sum\n')
w('# checksum in the top byte, so a hash lands below 0x01000000 only when the name\n')
w('# byte-sum is 0 mod 256 (1/256 of names) AND its low 24 bits are also tiny. Of\n')
zerotop = sorted(k for k in named if (k >> 24) == 0 and k != 0)
w('# the %d keys that DO carry a name, %d have a zero top byte, and the smallest of\n'
% (len(named), len(zerotop)))
w('# those is 0x%06x -- far above the ordinal band, which tops out at 0x%x.\n'
% (zerotop[0], max(ordinal)))
w('\n')
w('== PART 1: the %d HASH-SHAPED never-named keys ==\n' % len(hashy))
w('# key n objects record field value\n')
for k in hashy:
recs = ','.join(sorted(c['key_recs'][k]))
lo, hi = c['key_idx'][k]
vals = sorted(set(c['key_vals'][k]))
w('%08x %3d %2d %-13s %2d-%-2d %s\n'
% (k, c['key_nval'][k], len(c['key_objs'][k]), recs, lo, hi, '|'.join(vals)))
w('\n')
w('# All %d live in the six copies of <lang>\\script\\ID.tbl inside\n' % len(hashy))
w('# dat/GP_READY_ROOM.pak (eng jpn fra deu ita esp). Each copy has two records,\n')
w('# FILE and OFFSET, with the SAME 42 keys in the same order -- i.e. the object is\n')
w('# a column store: key -> (which .isb file, what offset inside it).\n')
w('\n')
w('== PART 2: the ordinal population, by (pak, object schema) ==\n')
w('# The schema is the name of the object\'s FIRST record -- the string whose\n')
w('# tag_hash is the object header word. `recs` counts records with >=1 unnamed field.\n')
w('# pak schema recs fields keys keymin-keymax valuetype sample\n')
rows = []
for (base, rname), f in c['fam'].items():
ks = f['keys']
rows.append((base, rname, f['recs'], f['n'], len(ks), min(ks), max(ks),
classify(f['vals']), f['vals'][0] if f['vals'] else ''))
for r in sorted(rows, key=lambda r: (-r[3], r[0], r[1])):
w('%-28s %-17s %5d %7d %5d %6x-%-6x %-10s %s\n'
% (r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8][:40]))
w('\n')
w('== PART 3: every never-named ordinal key ==\n')
w('# key occurrences distinct-records fieldidx-lo-hi valuetype sample-value\n')
for k in ordinal:
lo, hi = c['key_idx'][k]
w('%08x %8d %6d %4d-%-4d %-10s %s\n'
% (k, c['key_nval'][k], len(c['key_recs'][k]), lo, hi,
classify(c['key_vals'][k]), (c['key_vals'][k] or [''])[0][:40]))
w('\n')
w('== PART 4: the ID.tbl script-symbol call graph ==\n')
try:
idtbl(out)
except Exception as exc: # noqa: BLE001
w('# unavailable: %s\n' % exc)
# --------------------------------------------------------------------------- ID.tbl
READY_ROOM = DISC + '/dat/GP_READY_ROOM.pak'
LANGS = ('eng', 'jpn', 'fra', 'deu', 'ita', 'esp')
_RR_CACHE = {}
def readyroom_file(path):
"""Read one file out of GP_READY_ROOM.pak by its (lowercased) path."""
if not _RR_CACHE:
_RR_CACHE.update(dict(pak_entries(READY_ROOM)))
return _RR_CACHE.get(name_hash(path))
def idtbl(out=sys.stdout):
"""The 42 keys with their FILE/OFFSET columns, plus the .isb cross-check."""
payload = readyroom_file('eng\\script\\ID.tbl')
recs = {r[1]: r[2] for r in parse_idxd(payload)}
files = recs['FILE']
offs = recs['OFFSET']
out.write("# eng\\script\\ID.tbl -- key -> (defining .isb file, offset), plus every\n")
out.write('# .isb whose bytecode contains the key as a little-endian word (= a call site).\n')
out.write('# idx key defined-in offset referenced-from\n')
isb = {}
for name in sorted({v for _, _, v, _ in files}):
b = readyroom_file('eng\\script\\' + name)
if b:
isb[name] = {struct.unpack_from('<I', b, o)[0] for o in range(0, len(b) - 3)}
for i, (tag, _n, val, _f) in enumerate(files):
seen = sorted(f for f, s in isb.items() if tag in s)
out.write('%3d %08x %-24s %-6s %s\n'
% (i, tag, val, offs[i][2], ','.join(seen) or '-'))
# --------------------------------------------------------------------------- attacks
def _poly(s):
a = 0
for ch in s.encode('latin-1', 'replace'):
a = (a * 256 + ch) % TAG_MODULUS
return a
def corpus_attack(targets, words):
"""Straight dictionary attack. Returns {tag: [names]} and the corpus size."""
hits = collections.defaultdict(list)
n = 0
for w in words:
n += 1
h = tag_hash(w)
if h in targets:
hits[h].append(w)
return hits, n
def compose_attack(targets, tokens, suffixes=('',), seps=('', '_', '-')):
"""Two-token composition attack: name = t1 + sep + t2 + suffix.
Returns (hits, effective search space). The space is what matters: with a
32-bit tag, expected FALSE hits per target is space / 2**32.
"""
right = collections.defaultdict(list)
for t in tokens:
for sep in seps:
for suf in suffixes:
r = sep + t + suf
right[(len(r), _poly(r))].append(r)
lens = sorted({k[0] for k in right})
p256 = {l: pow(256, l, TAG_MODULUS) for l in lens}
hits = collections.defaultdict(list)
for left in tokens:
pl = _poly(left)
for l in lens:
for tag in targets:
want = ((tag & 0xFFFFFF) - pl * p256[l]) % TAG_MODULUS
for r in right.get((l, want), ()):
cand = left + r
if tag_hash(cand) == tag:
hits[tag].append(cand)
space = len(tokens) * sum(len(v) for v in right.values())
return hits, space
def brute_force(target, suffix='', maxlen=6, alphabet=None):
"""Meet-in-the-middle preimage search: all P over `alphabet` with
tag_hash(P + suffix) == target and len(P) <= maxlen.
Needs numpy. The honest limit is maxlen 6: the expected number of FALSE
preimages is |alphabet|**maxlen / 2**32, which is ~15 at 63**6 and ~58 000
at 63**8, so anything past 6 returns noise, not names.
"""
import numpy as np
if alphabet is None:
alphabet = ([chr(c) for c in range(48, 58)] + [chr(c) for c in range(65, 91)]
+ [chr(c) for c in range(97, 123)] + ['_'])
A = len(alphabet)
codes = np.array([ord(c) for c in alphabet], dtype=np.int64)
tabs = []
polys = np.zeros(1, dtype=np.int64)
sums = np.zeros(1, dtype=np.int64)
tabs.append((polys, sums))
for _ in range(maxlen // 2 + maxlen % 2):
polys = ((polys[:, None] * 256 + codes[None, :]) % TAG_MODULUS).ravel()
sums = (sums[:, None] + codes[None, :]).ravel()
tabs.append((polys, sums))
def decode(idx, k):
s = ''
for _ in range(k):
s = alphabet[idx % A] + s
idx //= A
return s
ps = _poly(suffix)
inv = pow(pow(256, len(suffix), TAG_MODULUS), -1, TAG_MODULUS)
need_sum = ((target >> 24) - sum(suffix.encode('latin-1'))) & 0xFF
out = []
for L in range(maxlen + 1):
v = L // 2
u = L - v
if u >= len(tabs) or v >= len(tabs):
continue
pu, su = tabs[u]
pv, sv = tabs[v]
order = np.argsort(pv, kind='stable')
pv_s = pv[order]
need = (((target & 0xFFFFFF) - ps) * inv) % TAG_MODULUS
want = (need - (pu * pow(256, v, TAG_MODULUS)) % TAG_MODULUS) % TAG_MODULUS
lo = np.searchsorted(pv_s, want, side='left')
hi = np.searchsorted(pv_s, want, side='right')
for i in np.nonzero(hi > lo)[0]:
for j in range(lo[i], hi[i]):
vi = order[j]
if ((su[i] + sv[vi]) & 0xFF) != need_sum:
continue
cand = decode(int(i), u) + decode(int(vi), v) + suffix
if tag_hash(cand) == target:
out.append(cand)
return out
# --------------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('mode', choices=('census', 'idtbl', 'crack', 'selftest'))
ap.add_argument('--maxlen', type=int, default=6)
ap.add_argument('--pe', default=os.environ.get('SYLPHEED_PE'),
help='flat VA dump of default.xex; its strings widen the corpus')
args = ap.parse_args()
if args.mode == 'census':
report(census())
elif args.mode == 'idtbl':
idtbl()
elif args.mode == 'selftest':
# tag_hash must reproduce the key of every NAMED field on the disc.
ok = bad = 0
for _b, _h, _sc, _rk, _rn, fields in walk():
for tag, name, _v, _i in fields:
if name is None:
continue
if tag_hash(name) == tag:
ok += 1
else:
bad += 1
if bad < 5:
print('MISMATCH %r %08x != %08x' % (name, tag, tag_hash(name)))
print('tag_hash reproduces %d / %d named field keys (%d wrong)' % (ok, ok + bad, bad))
else:
c = census()
targets = {k for k in set(c['unnamed']) - set(c['named']) if k >= 0x01000000}
print('targets: %d hash-shaped never-named keys' % len(targets))
words = set()
for b, h, _sc, _rk, rname, fields in walk():
words.add(rname)
for _t, name, val, _i in fields:
if name:
words.add(name)
words.add(val)
if args.pe and os.path.exists(args.pe):
blob = open(args.pe, 'rb').read()
words |= {m.decode('latin-1') for m in re.findall(rb'[ -~]{3,}', blob)}
hits, n = corpus_attack(targets, words)
print('disc-string corpus : %d strings, %d hits, E[false]=%.4f'
% (n, len(hits), n * len(targets) / 2 ** 32))
for t, v in hits.items():
print(' %08x %s' % (t, v[:6]))
toks = collections.Counter()
for w in words:
for part in re.split(r'[^A-Za-z0-9]+', w):
for p in re.findall(r'[A-Z]+(?![a-z])|[A-Z][a-z]+|[a-z]+', part):
if 2 <= len(p) <= 14:
toks[p] += 1
tokens = [t for t, _ in toks.most_common()]
hits, space = compose_attack(targets, tokens)
print('2-token composition: space=%d, %d hits, E[false]=%.3f'
% (space, len(hits), space * len(targets) / 2 ** 32))
for t, v in hits.items():
print(' %08x %s' % (t, v[:6]))
for t in sorted(targets)[:3]:
r = brute_force(t, '', args.maxlen)
print('brute force <=%d chars %08x: %d candidates %s'
% (args.maxlen, t, len(r), r[:6]))
if __name__ == '__main__':
main()

View File

@@ -92,7 +92,11 @@ BUILTIN = {
13: 'play_se', 14: 'play_bgm', 13: 'play_se', 14: 'play_bgm',
15: 'set_group_speed', 15: 'set_group_speed',
17: 'wait_frames', 18: 'dist_lt', 20: 'hp_pct_test', 24: 'squad_survival_pct', 17: 'wait_frames', 18: 'dist_lt', 20: 'hp_pct_test', 24: 'squad_survival_pct',
26: 'damage_unit', 30: 'objective_marker', 31: 'objective_marker_at_route', 26: 'set_unit_hp_pct',
# 28's field and default (1.0) are certain; the LABEL rests on a single
# consumer, so it is PROBABLE, not confirmed. See isl-builtins.md.
28: 'set_unit_damage_dealt_pct', 29: 'set_unit_damage_taken_pct',
30: 'objective_marker', 31: 'objective_marker_at_route',
33: 'global_counter0', 34: 'global_counter1', 36: 'screen_fade', 33: 'global_counter0', 34: 'global_counter1', 36: 'screen_fade',
39: 'MARK_LAST_PHASE', 40: 'mark_not_last', 43: 'play_voice', 39: 'MARK_LAST_PHASE', 40: 'mark_not_last', 43: 'play_voice',
45: 'play_voice_vol', 46: 'squadron_trace', 47: 'squadron_attack', 45: 'play_voice_vol', 46: 'squadron_trace', 47: 'squadron_attack',
@@ -109,8 +113,13 @@ BUILTIN = {
77: 'banner_mission_start', 78: 'banner_mission_complete', 77: 'banner_mission_start', 78: 'banner_mission_complete',
81: 'banner_objective_update', 82: 'banner_mission_failed', 81: 'banner_objective_update', 82: 'banner_mission_failed',
135: 'banner_mission_restart', 135: 'banner_mission_restart',
# 93 is `stopwatch_stop`, not the older `clear_flag`: 8/9/93 were renamed to
# stopwatch_start/_elapsed/_stop once the reading landed (isl-timers.md says
# of the old trio "the names predate the reading"). The branch that still
# called it `clear_flag` simply forked before that rename.
93: 'stopwatch_stop', 94: 'is_engaged', 95: 'unit_hp_pct', 100: 'reset_phase_threads', 93: 'stopwatch_stop', 94: 'is_engaged', 95: 'unit_hp_pct', 100: 'reset_phase_threads',
102: 'prompt_yes_no', 104: 'request_next', 108: 'deploy_squadron_ex', 101: 'all_units_invulnerable', 102: 'prompt_yes_no', 104: 'request_next',
108: 'deploy_squadron_ex',
109: 'set_unit_flags', 115: 'named_event', 109: 'set_unit_flags', 115: 'named_event',
120: 'wait_cmds_drained', 123: 'timer_resume', 124: 'timer_stop', 120: 'wait_cmds_drained', 123: 'timer_resume', 124: 'timer_stop',
125: 'timer_reset', 126: 'timer_elapsed', 127: 'timer_set', 125: 'timer_reset', 126: 'timer_elapsed', 127: 'timer_set',

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Every call site of one or more ISL built-ins, across all `StageNN.ssb`.
Pure static work — reads the extracted scripts, runs no emulator.
python3 tools/re-capture/isl_builtin_sites.py 26,28,29 --ssb <dir>
python3 tools/re-capture/isl_builtin_sites.py 101 --ctx 3 --ssb <dir>
python3 tools/re-capture/isl_builtin_sites.py 28 --craft <unitgroup-dump>
Why this exists rather than `isl.py --calls`: naming a built-in needs the
OPERANDS and the NEIGHBOURS, not just a histogram of ids. `isl.py --calls`
scans on the encoding and so cannot see the `local[]` staging that carries a
call's arguments, and `isl.py dis` has to be re-synchronised by hand for each
site.
The walk here is a straight linear decode of the whole code region, from the
header's code offset (`+0x08`) to the symbol-table-1 offset (`+0x0C`, which is
where the code ends). That is safe: **measured on all 28 stages, the linear walk
lands on every call site the independent encoding scan finds** (Stage02:
2846/2846), so no call is skipped and no false instruction boundary is invented.
Columns are tab-separated:
stage offset builtin slot8 slot4-name slot4-raw symtype before after
`slot4` is the unit operand (a symbol-table-2 index; see isl.py for why the unit
sits at slot 4 and slot 0 is a tag word) and `slot8` is the second operand,
which is a bare double for the built-ins this was written for. `before`/`after`
are the ids of the neighbouring calls, which is what makes a fixed idiom —
`116 -> 101 -> 100 -> 124 -> 93` — visible.
With `--craft <file>` (the output of `unitgroup.py --all`) it also cross-tabs
value against the squadron's craft type, which is the test that separated
built-in 15's classes and separates these three.
"""
import argparse
import collections
import glob
import os
import re
import struct
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import isl # noqa: E402
def walk(b):
"""Yield `(offset, builtin_id, staged)` for every call in the code region.
`staged` is the `local[]` slot -> value map as it stood when the call
executed, i.e. the call's arguments. Both staging forms matter:
`local[i] = special[0]` after an immediate, and the far commoner
`local[i] = immediate` written straight in.
"""
code_base = struct.unpack_from('>I', b, 0x08)[0]
code_end = struct.unpack_from('>I', b, 0x0C)[0]
off, staged, pending = code_base, {}, None
while off + 4 <= code_end:
w = struct.unpack_from('>I', b, off)[0]
op, ln = w & 0xFF, (w >> 8) & 0xFF
k1, k0 = (w >> 24) & 0xFF, (w >> 16) & 0xFF
if ln == 0 or ln % 4:
break
words = [struct.unpack_from('>I', b, off + i)[0]
for i in range(4, max(ln, 4), 4) if off + i + 4 <= len(b)]
if op in (0, 1) and len(words) >= 2:
if k0 == 2 and k1 == 1:
pending = _imm(op, words)
elif k0 == 3 and k1 == 2 and pending is not None:
staged[words[0]] = pending
elif k0 == 3 and k1 == 1:
staged[words[0]] = _imm(op, words)
if op == 19 and words:
yield off, words[0], dict(staged)
staged = {}
off += ln
def _imm(op, words):
"""An `op 1` immediate is a DOUBLE carried as two words, not a float."""
if op == 1:
lo = words[2] if len(words) > 2 else 0
return struct.unpack('>d', struct.pack('>II', words[1], lo))[0]
return words[1]
def _fmt(v):
if v is None:
return '-'
return ('%g' % v) if isinstance(v, float) else '0x%X' % v
def load_craft(path):
"""{stage: {squadron: [craft, ...]}} from `unitgroup.py --all` output."""
out, stage, squad = collections.defaultdict(dict), None, None
for line in open(path):
m = re.match(r'^(S\d\d):', line)
if m:
stage = m.group(1)
continue
m = re.match(r'^ (\S+)\s+\S+\s+Count=', line)
if m:
squad = m.group(1)
out[stage][squad] = []
continue
m = re.match(r'^ unit=(\S+)', line)
if m and squad:
out[stage][squad].append(m.group(1))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('ids', help='comma-separated built-in ids')
ap.add_argument('--ssb', default='.', help='directory holding StageNN.ssb')
ap.add_argument('--ctx', type=int, default=2, help='neighbour calls to show')
ap.add_argument('--craft', help='unitgroup.py --all output, for the cross-tab')
a = ap.parse_args()
ids = {int(x) for x in a.ids.split(',')}
craft = load_craft(a.craft) if a.craft else None
vals = collections.defaultdict(collections.Counter)
units = collections.defaultdict(collections.Counter)
cross = collections.defaultdict(lambda: collections.defaultdict(collections.Counter))
unresolved = collections.Counter()
for path in sorted(glob.glob(os.path.join(a.ssb, 'Stage*.ssb'))):
stage = os.path.basename(path)[:-4]
b = isl.load(path)
sym2 = isl.symbols(b, 2)
calls = list(walk(b))
for i, (off, bid, staged) in enumerate(calls):
if bid not in ids:
continue
uidx = staged.get(4)
typ, name = sym2.get(uidx, (None, '?')) if isinstance(uidx, int) \
else (None, '?')
val = _fmt(staged.get(8))
print('%s\t0x%06X\t%d\t%s\t%s\t%s\ttype%s\t[%s]\t[%s]' % (
stage, off, bid, val, name,
('0x%X' % uidx) if isinstance(uidx, int) else uidx, typ,
','.join(str(calls[j][1]) for j in range(max(0, i - a.ctx), i)),
','.join(str(calls[j][1])
for j in range(i + 1, min(len(calls), i + 1 + a.ctx)))))
vals[bid][val] += 1
units[bid][name] += 1
if craft is not None:
cs = craft.get('S' + stage[-2:], {}).get(name)
if cs:
for c in set(cs):
cross[bid][c][val] += 1
else:
unresolved[bid] += 1
for bid in sorted(ids):
n = sum(vals[bid].values())
print('\n# builtin %d: %d sites, %d distinct values' % (bid, n, len(vals[bid])))
print('# values: ' + ', '.join('%s x%d' % kv for kv in vals[bid].most_common()))
print('# units: %d distinct, top: %s' % (
len(units[bid]), ', '.join('%s x%d' % kv for kv in units[bid].most_common(10))))
if craft is not None:
print('# craft cross-tab (%d unresolved):' % unresolved[bid])
for c in sorted(cross[bid], key=lambda c: -sum(cross[bid][c].values())):
print('# %-42s %4d %s' % (
c, sum(cross[bid][c].values()),
', '.join('%sx%d' % kv for kv in cross[bid][c].most_common())))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Per-frame quad geometry and vertex colour from a `log_ui_draws` capture.
`ui_draw_order.py` answers "in what order were these painted"; this answers
"what did this quad look like on frame N, and on N+1". That is what a keyframe
TIME unit has to be measured against: the bundle says an element ramps from
t=31 to t=34, and the only way to learn what a `t` is worth is to count the
rendered frames the same ramp takes in the running game.
kf_time_probe.py <capture.log> [--csv out.csv]
Emits one row per quad per frame: frame, draw index, pixel rect, whether the
quad is axis-aligned, and the per-vertex colour word (whose alpha byte is the
element's fade). Frame numbers are the emulator's VdSwap count, so they are
SUBMITTED FRAMES, not wall-clock — which is the point: an emulator that runs
at half speed does not move them.
"""
import re
import sys
W, H = 1280, 720
VERT = re.compile(r"\[(-?\d+\.\d+),(-?\d+\.\d+),z=(-?\d+\.\d+)(?:,col=([0-9A-F]{8}))?\]")
def quads(log):
"""Yield (frame, draw, x0, y0, w, h, rot, col) for every quad in the log."""
frame, pending = None, None
for line in open(log):
if line.startswith("# every draw"):
frame = int(line.rstrip().split()[-1].split("..")[0])
continue
if line.startswith("--- frame"):
frame = int(line.split()[2])
continue
m = re.match(r"\s*(\d+) (prim=.*)", line)
if m:
pending = (int(m.group(1)), m.group(2))
continue
if "vb=0x" in line and pending:
hits = VERT.findall(line)
verts = [(float(a), float(b), c) for a, b, _z, c in hits]
if verts:
# Two conventions in one log: the UI sprite shader emits NDC,
# the full-screen pass emits pixels already.
if max(abs(v) for x, y, _c in verts for v in (x, y)) > 4.0:
pts = [(x, y, c) for x, y, c in verts]
else:
pts = [((x + 1) / 2 * W, (1 - y) / 2 * H, c) for x, y, c in verts]
per = 4 if "prim=13" in pending[1] else len(pts)
for q in range(0, len(pts), per):
chunk = pts[q:q + per]
if not chunk:
continue
xs = [p[0] for p in chunk]
ys = [p[1] for p in chunk]
x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys)
rot = all(
(abs(x - x0) < 1.0 or abs(x - x1) < 1.0)
and (abs(y - y0) < 1.0 or abs(y - y1) < 1.0)
for x, y in zip(xs, ys)
)
cols = {p[2] for p in chunk if p[2]}
col = sorted(cols)[0] if len(cols) == 1 else (
"/".join(sorted(cols)) if cols else "")
yield (frame, pending[0], round(x0), round(y0),
round(x1 - x0), round(y1 - y0), "" if rot else "ROT",
col)
pending = None
def main():
log = sys.argv[1]
out = None
if "--csv" in sys.argv:
out = open(sys.argv[sys.argv.index("--csv") + 1], "w")
out.write("frame,draw,x,y,w,h,rot,col\n")
for row in quads(log):
line = "%d,%d,%d,%d,%d,%d,%s,%s" % row
if out:
out.write(line + "\n")
else:
print(line)
if out:
out.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

0
tools/re-capture/regn_decode.py Normal file → Executable file
View File

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Catch a screen being BUILT, not sitting still.
#
# `log_ui_draws` armed at the title only ever sees the steady state, and the
# steady state is the one thing an animation question cannot be asked of. The
# build — the wordmarks zooming in, the effect sprites wiping across, the black
# fade lifting — happens in the seconds BEFORE the screen classifier can call it
# a title.
#
# So arm repeatedly and keep every log: each F10 opens a new numbered file and
# CLOSES the previous one (which stays on disk, complete). Re-arming every few
# seconds through the boot therefore tiles the whole approach to the title, and
# whichever file happens to straddle the build contains it. Stop re-arming the
# instant the title is classified, so a press cannot land mid-build and abandon
# the one file that matters.
#
# NO pad input at all. Ⓐ during the boot has ended a run on a permanent black
# screen (docs/re/canary-scripted-input-traps.md), and nothing here needs it.
#
# Usage: screen_build_capture.sh [out_dir] [timeout_s]
# FRAMES= frames per capture window (default 1200 ~ 20 s at 60 Hz)
# MAXDRAWS= hard stop per capture (default 80000)
# REARM= seconds between F10 presses (default 10)
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
SD="$(cd "$(dirname "$0")" && pwd)"
OUT="${1:-/sylph-home/re/buildcap}"
TIMEOUT="${2:-600}"
mkdir -p "$OUT"; rm -f "$OUT"/xenia_re_ui_draws_*.log
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
shot(){ screenshot "$1" >/dev/null 2>&1; }
wide(){ [ "$(identify -format '%w' "$1" 2>/dev/null || echo 0)" -gt 1000 ]; }
( cd "$OUT" && nohup run-canary \
--ui_draw_capture_frames="${FRAMES:-1200}" \
--ui_draw_capture_max="${MAXDRAWS:-80000}" \
--create_profile_if_none="${SYLPH_TAG:-SylphRE}" \
--logged_profile_slot_0_xuid="${SYLPH_XUID:-B13EBABEBABEBABE}" \
>"$OUT/canary.stdout" 2>"$OUT/canary.stderr" & )
sleep "${LAUNCH_WAIT:-8}"
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do
[ -n "$(alive)" ] || { echo "EMULATOR GONE before the window appeared"; exit 4; }
sleep 1
done
win="$(xdotool search --name "Xenia-canary" | tail -1)"
echo "WINDOW=$win"
arm(){
xdotool windowactivate "$win" 2>/dev/null
xdotool key --window "$win" F10 2>/dev/null
xdotool key F10 2>/dev/null
}
last_arm=-999
deadline=$(( SECONDS + TIMEOUT ))
s=none
while [ $SECONDS -lt $deadline ]; do
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
shot /tmp/sbc.png
if [ -s /tmp/sbc.png ]; then
wide /tmp/sbc.png || { echo "GRAB IS NOT THE GAME SURFACE at ${SECONDS}s"; exit 6; }
s="$(python3 "$SD/screen_id.py" /tmp/sbc.png | awk '{print $1}')"
fi
if [ "$s" = "${WANT:-title}" ]; then
echo "t=${SECONDS}s $s <- STOP re-arming"
cp /tmp/sbc.png "$OUT/reached.png"
break
fi
if [ $(( SECONDS - last_arm )) -ge "${REARM:-10}" ]; then
arm; last_arm=$SECONDS
echo "t=${SECONDS}s $s (armed)"
else
echo "t=${SECONDS}s $s"
fi
sleep 1
done
# Let the straddling window run itself out rather than cutting it short.
echo "waiting for the in-flight capture to close..."
for _ in $(seq 1 40); do
grep -q "UI-CAP. done" "$OUT/canary.stdout" 2>/dev/null && break
sleep 1
done
grep -i "UI-CAP" "$OUT/canary.stdout" | tail -6
ls -l "$OUT"/xenia_re_ui_draws_*.log 2>/dev/null || echo "NO CAPTURE LOG"
echo "SCREEN BUILD CAPTURE DONE (screen=$s, emulator left running)"

View File

@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Why a `.slb` bank's XMA data starts at 1392 / 1468 / 1600 / 1728.
Static, disc-only. No emulator, no decoder, no run-generated input: everything
here is read straight out of `dat/sound.pak` + `dat/sound.p00..p04`.
The answer (see docs/re/structures/slb-data-offset.md): the XMA packet grid is
2048-byte aligned *inside each individual `.pNN` segment file*, but the `.pak`
TOC addresses entries in the flat CONCATENATION of those files, at offsets that
are themselves multiples of 2048. The segment files are not multiples of 2048
long, so the grid phase seen inside an entry is
X = (cumulative start of the .pNN segment holding the wave) mod 2048
and the four disc-wide values are just the running sums of the segment sizes.
Subcommands
phases the segment table and where 1392/1468/1600/1728 come from
verify predicted vs measured X over every bank on the disc
bank <name> full structural dump of one bank entry
chain <name> [n] the tiling arithmetic across n consecutive entries
Usage
SYLPHEED_DISC=/work/sylph_extract python3 slb_segment_phase.py verify
"""
import bisect
import os
import re
import struct
import sys
import zlib
BLOCK = 2048 # XMA1 packet size and the archive's data-offset alignment unit
WAVE_HDR = 4096 # RIFF + 32-byte `fmt ` + `Dmmy` pad, always exactly two blocks
# --- IPFB name hash (crate `sylpheed_formats::hash`, from `sub_82455C78`) -----
MODULUS = 0x00FF_F9D7
RECIP = 0x8003_1493
def _rotl32(x, n):
x &= 0xFFFFFFFF
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
def name_hash(name):
a = b = 0
for byte in name.lower().encode("latin-1"):
c = (byte - 256) & 0xFFFFFFFF if byte >= 0x80 else byte
a = ((_rotl32(a, 8) & 0xFFFFFF00) + c) & 0xFFFFFFFF
b = (b + c) & 0xFFFFFFFF
q = _rotl32(((a * RECIP) >> 32) & 0xFFFFFFFF, 9) & 0x1FF
a = (a - q * MODULUS) & 0xFFFFFFFF
return ((b & 0xFF) << 24) | (a & 0xFFFFFF)
class Pak:
"""Minimal IPFB reader: TOC plus raw access to the concatenated segments."""
def __init__(self, path):
data = open(path, "rb").read()
assert data[:4] == b"IPFB", data[:4]
count, self.block, self.flags = struct.unpack_from(">III", data, 4)
self.toc = [struct.unpack_from(">III", data, 0x10 + 12 * i) for i in range(count)]
base, pos, self.segs = path[:-4], 0, []
i = 0
while os.path.exists("%s.p%02d" % (base, i)):
seg = "%s.p%02d" % (base, i)
n = os.path.getsize(seg)
self.segs.append((pos, pos + n, seg))
pos, i = pos + n, i + 1
self.seg_starts = [s for s, _, _ in self.segs]
def raw(self, off, size):
"""Bytes [off, off+size) of the flat concatenation of the .pNN files."""
out = b""
for s, e, path in self.segs:
if off < e and off + size > s:
lo, hi = max(off, s), min(off + size, e)
with open(path, "rb") as f:
f.seek(lo - s)
out += f.read(hi - lo)
return out
def read(self, idx):
_, off, csz = self.toc[idx]
raw = self.raw(off, csz)
return zlib.decompress(raw[10:]) if raw[:2] == b"Z1" else raw
def find(self, name):
h = name_hash(name)
for i, (hh, _, _) in enumerate(self.toc):
if hh == h:
return i
return None
def phase_at(self, concat_off):
"""The 2048-grid phase in force at a flat-concatenation offset."""
k = bisect.bisect_right(self.seg_starts, concat_off) - 1
return self.seg_starts[k] % BLOCK
def entry_names(disc):
"""Every `.slb` path named in any `<lang>\\sounds.tbl` inside tables.pak."""
tbl = Pak(os.path.join(disc, "dat", "tables.pak"))
names = set()
for lang in ("eng", "jpn", "deu", "fra", "esp", "ita"):
i = tbl.find(lang + "\\sounds.tbl")
if i is None:
continue
blob = tbl.read(i)
for run in re.finditer(rb"[\x20-\x7e]{6,}", blob):
for m in re.finditer(r"[A-Za-z0-9_\\.]+\.slb", run.group().decode("latin-1")):
names.add(m.group())
return names
# --- structure walkers --------------------------------------------------------
def bank_headers(data, phase):
"""Bank headers inside an entry. They sit on the grid, so only `phase` is scanned."""
out = []
for off in range(phase, max(0, len(data) - 56), BLOCK):
if data[off + 0x18 : off + 0x1C] != b"\x00\x00\x08\x00":
continue
if data[off : off + 4] != data[off + 0x20 : off + 0x24]:
continue
f = struct.unpack(">14I", data[off : off + 56])
out.append(
dict(at=off, id=f[0], data_size=f[7], hdr_blocks=f[9],
bits=f[10] >> 16, channels=f[10] & 0xFFFF)
)
return out
def waves(data):
out = []
for m in re.finditer(b"RIFF", data):
r = m.start()
if data[r + 8 : r + 12] != b"WAVE":
continue
dp = data.find(b"data", r)
if dp < 0:
continue
out.append(dict(at=r, data_at=dp + 8,
data_size=struct.unpack_from("<I", data, dp + 4)[0]))
return out
def seeks(data):
"""`seek` (XMA dpds) chunks: one u32 LE per emitted packet, so the entry count
is the wave's packet count and the chunk sits immediately after its data."""
out = []
for m in re.finditer(b"seek", data):
s = m.start()
if s + 16 > len(data):
continue
size, streams, n = struct.unpack_from("<III", data, s + 4)
if streams == 1 and size == 8 + 4 * n and n:
out.append(dict(at=s, packets=n, data_start=s - n * BLOCK))
return out
# --- subcommands --------------------------------------------------------------
def cmd_phases(pak):
print("segment size size%2048 cum_start PHASE (= cum_start%2048)")
running = 0
for s, e, path in pak.segs:
n = e - s
print(" %-10s %11d %8d %12d %d" % (os.path.basename(path), n, n % BLOCK, s, s % BLOCK))
running = s % BLOCK
print()
print("The four disc-wide values are the running sums of `size % 2048`:")
acc = 0
for s, e, path in pak.segs:
print(" %-10s phase %4d" % (os.path.basename(path), s % BLOCK))
acc = (acc + (e - s)) % BLOCK
_ = running
def cmd_verify(pak, disc):
names = entry_names(disc)
ok = bad = 0
seek_ok = seek_bad = 0
misses = []
for name in sorted(names):
i = pak.find(name)
if i is None:
continue
_, off, _ = pak.toc[i]
data = pak.read(i)
w = waves(data)
if w:
pred = pak.phase_at(off + w[0]["at"])
meas = w[0]["at"] % BLOCK
if pred == meas:
ok += 1
else:
bad += 1
if len(misses) < 10:
misses.append((name, pred, meas))
else:
# RIFF-less entry: the prediction is still exact, cross-check on `seek`.
sk = seeks(data)
if sk:
pred = pak.phase_at(off + sk[0]["at"])
if sk[0]["at"] % BLOCK == pred:
seek_ok += 1
else:
seek_bad += 1
print("banks with a RIFF : predicted == measured %d, mismatches %d" % (ok, bad))
print("RIFF-less, via seek : agree %d, disagree %d" % (seek_ok, seek_bad))
for m in misses:
print(" MISMATCH", m)
return 1 if (bad or seek_bad) else 0
def cmd_bank(pak, name):
i = pak.find(name)
if i is None:
sys.exit("no such entry: " + name)
_, off, csz = pak.toc[i]
data = pak.read(i)
phase = pak.phase_at(off)
print("%s\n pak offset %d (%%2048=%d) stored %d bytes phase %d"
% (name, off, off % BLOCK, csz, phase))
events = []
for b in bank_headers(data, phase):
events.append((b["at"], "BANK id=%d data_size=%d hdr=%d blocks (%d B) %dbit/%dch -> wave at %d"
% (b["id"], b["data_size"], b["hdr_blocks"], b["hdr_blocks"] * BLOCK,
b["bits"], b["channels"], b["at"] + b["hdr_blocks"] * BLOCK)))
for w in waves(data):
over = w["data_at"] + w["data_size"] - len(data)
events.append((w["at"], "RIFF data@%d size=%d (%d packets)%s"
% (w["data_at"], w["data_size"], w["data_size"] // BLOCK,
" OVERRUNS window by %d" % over if over > 0 else "")))
for s in seeks(data):
events.append((s["at"], "seek %d packets -> its data began at %d%s"
% (s["packets"], s["data_start"],
" (BEFORE this window)" if s["data_start"] < 0 else "")))
for at, text in sorted(events):
print(" %8d %%2048=%-5d %s" % (at, at % BLOCK, text))
def cmd_chain(pak, name, count):
"""Show that entry K's overrunning wave lands exactly on entry K+1's leading `seek`."""
i = pak.find(name)
if i is None:
sys.exit("no such entry: " + name)
order = sorted(range(len(pak.toc)), key=lambda k: pak.toc[k][1])
start = order.index(i)
prev = None
for k in order[start : start + count]:
_, off, csz = pak.toc[k]
data = pak.read(k)
padded = (len(data) + BLOCK - 1) // BLOCK * BLOCK
sk = seeks(data)
lead = sk[0] if sk and sk[0]["data_start"] < 0 else None
w = waves(data)
tail = w[-1] if w and w[-1]["data_at"] + w[-1]["data_size"] > len(data) else None
line = "off=%-11d len=%-7d padded=%-7d" % (off, len(data), padded)
if lead:
line += " leading seek@%-6d (%d packets)" % (lead["at"], lead["packets"])
if tail:
line += " tail wave ends at %d" % (tail["data_at"] + tail["data_size"])
print(line)
if prev and lead:
predicted = prev[0] - prev[1] # tail end minus previous padded length
mark = "OK " if predicted == lead["at"] else "!! "
print(" %s predecessor's wave ends %d past its padded window; leading seek is at %d"
% (mark, predicted, lead["at"]))
prev = ((tail["data_at"] + tail["data_size"]) if tail else None, padded)
if prev[0] is None:
prev = None
def main():
disc = os.environ.get("SYLPHEED_DISC")
if not disc:
sys.exit("set SYLPHEED_DISC to the extracted disc root")
pak = Pak(os.path.join(disc, "dat", "sound.pak"))
argv = sys.argv[1:] or ["phases"]
cmd = argv[0]
if cmd == "phases":
cmd_phases(pak)
elif cmd == "verify":
sys.exit(cmd_verify(pak, disc))
elif cmd == "bank":
cmd_bank(pak, argv[1])
elif cmd == "chain":
cmd_chain(pak, argv[1], int(argv[2]) if len(argv) > 2 else 4)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()