`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>
A wave runs to data_at + declared_size, and there is seek magic exactly there
whose little-endian packet count at +12 times 2048 equals the declared size --
7620/7620 disc-wide. That is the decoder-independent boundary and the thing that
proves the declared sizes honest, so it should not be able to regress silently.
The boundary routinely lies outside the entry's own comp_size window, so reading
it needs the flat segment stream rather than the entry slice; PakArchive gains a
small data_at(offset, len) accessor for that.
Test walks a bounded slice to stay fast -- 792 banks in this run, all holding.
8 disc tests pass.
1495 banks carry no RIFF and take a separate path that hardcoded both the
offset and stereo. Across a random 48-bank sample there was NOT ONE where the
old stereo-at-1392 pair beat the best mono offset; median gain 184x, individual
banks going from 0-4816 decoded bytes to 180000-380000. Stereo shows the same
stop-after-one-frame signature already recorded for the leading segment.
With no RIFF the offset cannot be derived, so scan_data_offset picks among the
four disc offsets by XMA1 packet-header plausibility. Validated on the LABELLED
set -- all 7358 banks that do have a RIFF, where the answer is forced: 7330
correct (99.62%), and all 28 misses are ties on the top score, never a wrong
unique winner. Ties fall back to 1392.
The winning offsets also reproduce, by directory, the distribution measured
independently from the RIFF-bearing banks. jpn\etc splits 1468/1600, so path
alone is not sufficient -- which is why this is a scan and not a lookup table.
7 disc tests pass (build-reborn test -p sylpheed-formats --test
slb_leading_segment_disc, SYLPHEED_DISC wired up).
HEADERLESS_DATA_OFFSET is the value the offset takes in <lang>\etc\, not a
property of the format. The leading stream is a whole number of 2048-byte XMA1
packets ending at the first RIFF, so its start is first_riff % XMA1_PACKET.
Disc-wide that takes four values -- 1392, 1468, 1600, 1728 -- varying by
language and subdirectory.
Verified by decoding, not by arithmetic: on a random 140-bank sample with a
non-empty leading region, the derived offset yields more audio in 85, identical
in 54 (the eng\etc controls, where it must and does reproduce the old
behaviour) and less in 1. Median gain among the improved is 70x --
eng\Voice\VOICE_TCAF_592 goes 1506 -> 97152 bytes, jpn 2910 -> 127178.
This withdraws my own claim from earlier today that the Japanese banks were a
different undecoded layout. They are the same format with a different offset;
I had treated a constant derived from one subdirectory as a property of the
format. The same error was hiding the identical defect in 1873 eng\Voice banks.
ixud.rs now has an IdxdObject-shaped reader, IxudObject, and build_caption_text
reads captions as FIELDS instead of pairing them with whatever token follows in
the pool.
build_demo_text token adjacency 134 ids 537 lines
build_caption_text token adjacency 3721 8074
build_caption_text record fields 4085 8800 = all of them
Verified over the whole disc by tests/ixud_records_disc.rs: 1104/1104 objects
parse, 1476/1476 records and 628165/628165 named fields reproduce their
ixud_hash, 48 positional, zero failures. The header word at 0x08 is record 0's
hash, asserted per object -- there is no schema field, exactly as for IDXD. The
module doc described a 12-byte record directory and a "schema/type hash"; both
were wrong and are corrected.
I also have to correct my own number from the previous commit. "1.3% of the
game's text" counted OCCURRENCES: each family lives in 24-45 IXUD blocks and
the same key repeats across them. Distinct text-bearing MSG_* keys number 8800,
not 44579, and every one has the <id>_<page>_<line> shape. So the real coverage
was 537/8800 = 6.1%, and I overstated the gap about fivefold. Direction right,
magnitude wrong.
The DEMO control is the sharpest evidence for the change: token adjacency finds
537 lines there, the field reader 541. It was dropping lines even in the one
family it was written for -- which is why the test now asserts "must not lose
lines" rather than "must be identical".
Same lesson twice in one session: pool adjacency is a consequence of how
records are written, not a rule of the format.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
build_caption_text generalises the key parser from MSG_DEMO_* to all eight
families. The shapes are uniform and each family is 100% consistent with its
own: seven use MSG_<FAM>_<id>_<page>_<line>, and VOICE alone inserts a family
letter before the id.
ids lines
build_demo_text 134 537
build_caption_text 3721 8074
The DEMO family comes out identical through both readers -- 537 lines either
way -- which is the control that generalising changed nothing that already
worked. Pinned by tests/caption_families_disc.rs, along with VOICE ids keeping
their family letter.
But this does NOT close the gap, and the write-up says so: 8074 against the
44579 text-bearing fields the record-level scan counts is about 18%.
The reason is the same lesson this session already learned once.
build_caption_text pairs a value with the key that happens to follow it in the
raw UTF-16 token stream -- the adjacency heuristic that was wrong for IDXD and
is wrong here for the same reason. ixud.rs has no record/field reader at all.
The IXUD record table IS decoded and verified disc-wide (1104/1104 objects,
628165/628165 fields reproducing their key) and was simply never wired into
the crate.
Next step recorded: give ixud.rs an IdxdObject-shaped reader and read captions
as fields rather than adjacent tokens. The decode exists; only the plumbing is
missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
A disc-gated test that fails if the loaders regress to one flat answer per
object. It checks the three structural claims that came with the migration
request, and one of them is overstated:
* ✅ MainMissionBonus IS per difficulty — 18 of the 24 missions pay three
different values, in a strict 1:2:4 easy:normal:hard ratio (all 24); the other
6 pay 0 at every difficulty. The flat reader returned the Easy value.
* ✅ RankScore_S/A/B/C/D repeat identically across the three Score_* records in
24 of 24 missions, so the single flat answer happened to be right. The
difficulty scaling lives in the earnings (CraftScore_Adjustment,
KillBonus_Maximum, …), not in the thresholds.
* 🟡 SpaceSize is *stored* per phase (three Phase_N records), but only **1 of the
24** missions varies it — the one that reads 250000/100000/100000. The other 23
repeat a single value (500000 ×1, 100000 ×18, 50000 ×4). "Phase_1 = 250000,
Phase_2/3 = 100000" is one mission's numbers, not a rule.
Also pinned: the Delta Saber's 63 Turret_NNN mounts at HP 100 each against a
TurretCount of 4; the SD-Battleship's per-component HP (bridge 10000, thruster
20000, shield generator 5000 at PowerRatio 0.25, hatch 100 launching
Squadron_Test2, anti-ship gun 1500) against a hull of 100000, and its 37 records
that define an HP; 418 hardpoints across the 23 capital ships, none missing HP;
131 weapons whose shell id always differs from the launcher id, 40 with a
ShellWake, and Weapon_NULL as the only one without a Shell.Power; and
fcs_range/shield_ratio/cruising_velocity/maximum_velocity/acceleration/
deceleration now set on 89 of 89 units, all of which the pool reader could miss.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
It is a throwaway measurement harness I wrote in this session ("TEMPORARY
measurement scratch — not for commit") that a concurrent commit picked up
along with its own files. It asserts nothing and duplicates work that is now
covered by tests/game_data_disc.rs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
to_xma_riffs now emits the leading headerless segment when it sits at a whole
number of XMA1 packets and carries a non-zero byte. VOICE_D_453 goes from a
0.14 s trailing fragment to a 45116-byte leading sub-wave that dominates it.
I withdrew this exact change earlier for two reasons. Both are now answered
rather than argued away:
* "It recovers no audio" -- it used the STEREO format. At two channels every
bank yields exactly 1792 bytes, one frame, whatever its size. Mono yields up
to 113x more.
* "It matches 1524 of 8021 RIFF-bearing entries" -- the byte-level reach is
still 1524, but the audible reach is not. Across the 84 movie-bound banks
the segment adds >1 s to exactly 7, the hokyu_*_H tankers on D_453/D_454 --
precisely the broken ones -- and <=0.25 s to 66 of the rest. The largest
non-resupply addition is S04A at +0.66 s on a 256 s movie.
The safety oracle is recorded with its limits: 8 of the 84 banks ALREADY
exceed their movie's duration before the change, by hundredths of a second,
so it cannot resolve differences at that scale. It establishes scoping, not
correctness. Callers clamp to the movie length regardless.
VOICE_D_451's all-zero leading region is skipped by the non-zero guard, so
the rule cannot prepend silence to a bank that does not need it. Pinned, as
is the packet arithmetic (n = 8, 1, 7, 22, 29) which has no tunable.
slb_disc, movie_subtitle_disc and movie_manifest_disc all still pass.
NOT verified by ear -- that needs a human.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The corpus recorded that some unit fields the disc leaves defaulted inherit
from a sibling: Size_Y from Size_X, FCSRange from RadarRange, DefencePoint
from AttackVesselPoint. Size_Y was marked the one to trust, on 9/9 support
across 7 independent ships, and it is restated in INDEX.md.
The premise is false. These fields are not defaulted -- they are on disc for
113-114 of 114 unit tables -- and Size_Y DIFFERS from Size_X in 90 of them.
The mechanism, cross-tabulating "legacy reader missed it" against "equal on
disc":
pair seen+differ seen+equal miss+differ miss+equal
Size_Y / Size_X 90 0 0 24
FCSRange / RadarRange 54 0 1 58
DefencePoint / AttackVesselPoint 51 0 1 61
seen+equal is 0 for all three: a value shared with a sibling is ALWAYS
invisible to the string-pool reader, because the pool stores each distinct
string once. And the reader almost never misses a value that differs. So
"the missing value equals the sibling's" was true BY CONSTRUCTION -- the rule
re-derived the very condition that made the field go missing. That is why the
support looked perfect: it could not fail on the cases it was fitted to.
The two miss+differ cells are its real wrong predictions, both named:
UN_e104_ADAN_Carrier DefencePoint is 0.2 (rule says 0.003), and
UN_e011_ADAN_Attacker_B_HF_Wayne FCSRange is 3000.0 (rule says 6000.0).
Retracted in unit-struct-runtime.md (original reasoning kept below the
correction), live-unit-definitions.md and INDEX.md. Pinned by a disc test
that asserts the seen+equal cells stay zero, so the mechanism itself is
guarded, not just the counts. Artifact: examples/sibling_rule_check.rs.
This one was found by my own check after the subagent assigned to it stalled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
movie_manifest::parse now reads BASE_INFO's positional field keys (the game's
own cutscene ids, stage*100 + slot) and follows each to its record, instead of
scraping the string pool. The pool stores each distinct string once, so a
REPEAT reference produced no token and read as "no binding".
That single cause explains every wrong cell: 13 later references to
VOICE_D_450..454, two to SUBTITLE_hokyu_LS_s11A.tbl, and MS01A's share of
pwterop_s01a.prt. All 18 hokyu movies are bound, not five.
Counts, verified independently by me against the disc before recording:
104 cutscene SLOTS binding 101 distinct MOVIES; 99 slots / 96 movies with a
voice track, 99 / 96 with a subtitle, 22 / 22 with a telop. The docs' old
94 / 83 / 21 are exactly the counts of DISTINCT POOL STRINGS -- not wrong
measurements, measurements of the wrong thing. Three denominators were being
conflated; the new test pins all three.
Two assertions in movie_manifest_disc.rs were false and are corrected:
hokyu_DS_s13A binds VOICE_D_452 and resolves to eng\etc\VOICE_D_452.slb. The
in-game verdict that rejected that value tested an INFERENCE from a shared
demo id, on a decoder that discards 85-87% of banks in this class -- see
voice-bank-leading-region.md, committed earlier today.
The ~104 script ids are no longer open: they are literal positional keys,
each naming its record, and all 104 resolve. The old "counts differ by three,
positional pairing does not work" has a concrete cause -- three resupply
movies are bound by TWO slots each.
Also corrected: the naming convention has 3 subtitle exceptions (s24A/s27A
borrow s11A's track) and 18 voice exceptions, not one and five.
The legacy scraper is kept as a fallback for blobs with no record table, so
the synthetic unit fixtures still exercise it.
Artifacts: examples/movie_map_csv.rs regenerates the CSV, now slot-keyed
(104 rows; the movie-keyed version silently dropped one slot of each
duplicate). Disc tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
With the record table decoded there is finally a ground truth to check the
old string-pool reader against. It infers `key -> value` from pool adjacency,
which is a consequence of how records are written, not a rule of the format.
Verified by hand against the disc, with an independent parser:
* `FCSRange` = 500000.0 — the module docs' own canonical example of a field
"left at its default" that "omits the value string".
* `ShieldRatio` = 1.0, where `tests/pak_idxd_disc.rs` asserted None with the
comment "a defaulted/omitted field must be None". That test encoded the
false belief; it now keeps the None as a deliberate characterisation of the
legacy reader, with the true value asserted beside it.
* `get_raw("Model")` on GP_HANGAR_ARSENAL returns the first record's model for
every record — silent corruption, not an absent value. New test pins four
records that disagree with it.
The cause is the flat API having no way to name a record: only 548 of 6325
objects have one. `HP` on the DeltaSaber answers 1000.0, the hull, while 63
Turret_* records each carry their own 100.0 (measured — a first draft said 34,
taken from a report rather than from the disc).
Disc-wide rates are recorded as single-source and labelled as such: get_raw
52% wrong, typed getters 38% miss, but 100% correct on single-record objects.
Also records a negative result: the 504 unnamed field keys were NOT recovered.
A 572464-string dictionary and 73191 variants gave 0/42. The key deltas do
prove the preimage ends with the two decimal digits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The binary region in front of the string pool was the parser's oldest open
note ("Not yet decoded"). It is a uniform 16-byte record array sorted by
name hash, a field count, a 12-byte field array sorted by key, a pool size,
and the pool. The trailing `pool_size == file_len - pool_base` identity makes
the layout self-checking, which is what caught the first wrong version.
Verified over the WHOLE disc with zero failures: 7750/7750 IDXD objects,
190782/190782 records reproducing their stored tag_hash, 1271462/1271462
named fields reproducing their key. IXUD is the same container with
ixud_hash, UTF-16BE and every offset in chars — 1104/1104 objects,
628165/628165 fields, checked with an independent parser.
Field names are stored on disc, so no preimage search is needed: a field's
middle word points at its own name. Only 504 fields disc-wide are hash-keyed
with no name; the other 1485073 nameless fields are positional, keyed by a
literal integer (line slots, movie ids).
Two long-held beliefs are WITHDRAWN:
* The word at 0x08 is not a schema hash. It is record 0's name_hash — the
format has no type field at all, and an object's kind is known only from
the caller that loads it. It survived as "schema" because tables of one
kind share their lowest-hashed record name. Caught by a test asserting
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 field's middle word is not an always-0xFFFFFFFF flags word. It is
0xFFFFFFFF for 54% of fields, enough to look constant in a small sample;
the tell was that it is constant per key ACROSS records, which a per-record
flag cannot be but a per-name pointer must.
`schema_hash` keeps its name rather than churn 33 call sites, with corrected
docs. The first sweep globbed dat/** and missed hidden/DefTables.pak (1425
objects); the test now walks the whole disc root.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
TextIndex in sylpheed-formats::localization already indexes per-phase objective
text under S<NN>_P<n>_Objective_<i>, with hints and lose conditions alongside.
Querying it settles what the wave work spent many iterations circling, and a
disc test now pins it.
Stage 02 phase 1 is "Shoot down all invading enemy fighters while watching out
for attacks on the ACROPOLIS", losing if the ACROPOLIS is sunk. Phase 2 is
"Protect the Admiral's ship the CALIBAN until it has entered the safe zone",
losing if the CALIBAN is sunk. Phase 3 is "Destroy the interstellar cruise
missiles headed for Schlos Base", matching phase 3's roster of exactly nine
ISCMissile and SUBOBJ_013.
That explains the phase advances and shows they are not all the same kind. Phase
1 is a kill-all-marked-fighters gate -- fighters, not the turrets every run
killed almost exclusively. Phase 2 is positional and ends when the CALIBAN
reaches a safe zone, so "what advances a phase" never had a single answer.
It also closes a loop to the first session. The hints say red mission markers
[OB] indicate your targets, so REMAINING OB -- the HUD counter hunted in the
earliest iterations and located at 0xbdb59668 -- is the count of remaining
marked objective targets, which is phase-1 progress itself. The old reframing
that the counter is not a roster was right, and this names what it is. It is
also the correct signal to watch for an advance: not deployed, which only
changes once the next phase deploys, but REMAINING OB reaching zero, which is
the cause.
Method lesson recorded because the reflex will recur: localization.rs has had
objectives, hints and lose_conditions for some time, and several iterations were
spent reconstructing the same information by hand from SUBObjective strings and
the guide script. The reconstruction was not wasted -- it produced the IXUD
UTF-16BE and language\ prefix findings independently -- but the corpus should
have been searched before the disc was, and grep -rl TextIndex crates/ would
have saved the detour.
Minor discrepancy noted, not investigated: ixud.rs documents the string pool as
UTF-16BE and localization.rs as UTF-16LE.
The 16.16 reading of +0x04 rested on twelve bundles at 30.0. They are not twelve
witnesses: they are TWO bundles - pghud_range_main_em/emeff and
pghud_range_nose_em/emeff, both dur 30 - repeated across six language PAKs. A
30-frame flash is equally consistent with 0.5 s at 60 and 1 s at 30, so the
observation that "30.0 bundles are shorter" says almost nothing.
Worse for the clean fixed-point reading: the four bundles at 0x3C0001 would be
60.0000152 fps, which nobody authors. They are py_ranking_jump/py_ranking_next
dialogs, all dur 60. So the better reading is <rate:16>.<flag:16> - a rate-like
number in the high half and a small low field that is 0 on 2843 bundles and 1 on
four, meaning unknown.
Also stated: nothing in this container can settle it by timing, because the
emulator runs on software Vulkan far from real time - a stopwatch would measure
lavapipe rather than the game.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The rate/duration reading of the bundle header came from the values alone, so it
was checked against something the file states independently: the keyframe `time`
fields in the placement region.
Over the 2313 bundles that have both, the largest keyframe time is <= +0x08 in
EVERY one, none exceeds it, and 444 reach it exactly. The refutation attempt is
in the same data: a large unrelated constant would bound every time too, but the
max_time/+0x08 ratio would then pile up near zero - instead it peaks at exactly
1.0 with 520 bundles. Asserted, including the shape of that histogram, so the
bound cannot later be waved through as vacuous.
Stated precisely because the units are a separate claim: what is proven is that
+0x08 is the animation length in the SAME UNIT as a keyframe's time. That the
unit is frames still rests on the values (30/60/120/1200) and on the 16.16
reading of +0x04, which stays amber - the only new evidence for it is that the
twelve 30.0 bundles cap at +0x08 = 30 while the 2843 60.0 ones reach 1440, and
twelve bundles is not a demonstration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The backlog asked what makes a bundle a screen rather than a fragment, and the
obvious suspect was the 32-byte header. Swept over all 2859 composable bundles
with a real declaration table.
The answer to the question is NO, and it is asserted rather than argued: no bit
of the flags word at +0x10 labels a screen. The best any bit manages is bit 13 -
403 bundles, 179 of them carrying a full-screen element, a 44% hit rate against a
12.8% base - and the commonest bit is set on 91% of everything. Enrichment, not a
marker.
The sweep found more than it was asked for, though. The header is not dead space:
+0x18 is 1280 on 2829 bundles and +0x1c is 720 on 2823 - the design resolution at
bundle level, the same pair the parser already reads out of a .rat record, and
asserted here. And +0x04 takes only three values, 0x3C0000 on 2843 and 0x1E0000
on 12, which are exactly 60.0 and 30.0 in 16.16 fixed point, with +0x08 taking
30/1200/120/60 - a frame rate and a duration in frames would fit a format whose
records are keyframe lists. That reading is marked amber: it comes from the
values alone and is not verified against an animation.
Also recorded, since the file will not say: element counts are min 1, median 2,
p75 5, p95 23, max 56, and only 365 bundles carry a full-screen element. The
population is mostly fragments and the separation is shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
Two readings were on record and both were wrong in different directions: the
structure doc called it "normal state -> focused state" from a single example,
and the backlog called it "refuted as focus; unexplained otherwise".
Classified every link reachable from a declaration table: 1467 links, and ALL
1467 resolve to a RATC child of their own bundle, ALL are .rat -> .rat, none
dangle and none self-link. 1076 (73%) match the <stem>f focus pattern; the other
391 are chains between effect records - px_bunk_eff01 -> pjex_eff -> pjex_eff07,
pveff01 -> pjeff02 -> pjeff21 - which also explains why only 227 targets are
themselves declared elements: the middle of a chain is, the end is not.
So `opt ` is a record-to-record reference within the bundle, and focus is its
commonest use rather than its meaning.
Coverage is stated rather than glossed: the bundles hold 18718 raw `opt ` tags
against the 1467 classified, because opt_link reads the first tag of a DECLARED
element's record. Roughly 92% of occurrences sit deeper in the chains (or are
byte coincidences - the scan is unaligned) and are untested. The numbers are
asserted so the answer cannot drift back into an anecdote.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The backlog's cheapest open question about the 60-byte entry: kind is a flags
word (0x10 untextured primitive, 0x4 repeated instance, 0x3002 button record), so
a focus bit would be the obvious answer and the name-pairing in
mark_focused_states would be a convention standing in for a real field.
It is not. Over every screen build on the disc: 54 name-paired focused/base
pairs, all 54 with IDENTICAL kind (all 0x0), NO bit ever set on the focused entry
and clear on its base, and the only words of the entry that ever differ are +48
and +52 - the pivot. The two entries differ in where the sprite sits and in
nothing else.
The test asserts all four numbers rather than just printing them, so the negative
is pinned instead of decaying back into a suspicion. Also noted: these buttons
carry kind = 0x0, so the documented 0x3002 "button record" belongs to the .rat
records and not to the .t32 sprites a menu draws.
Closes the declaration table, not the question - the focused state could still be
marked in the .rat record, the RATC child stream, or only in the game's code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
The three orders the derived rule was built from all live in GP_TITLE.pak, so
they cannot confirm it - the rule was fitted to them. These two are from
GP_SAVE_LOAD.pak, read off the running game now that the Canary threading fix
makes the main menu dependable.
The 9-element slot-list header composites EXACTLY as the sort predicts, on all 6
instances of it, and nothing about this screen was fed into the rule:
measured 7 8 0 1 2 3 4 5 6
derived 7 8 0 1 2 3 4 5 6
including TWO tied groups (0xb102 x2 and 0xb210 x5) that both come out in
declaration order, and the unkeyed pfeff00.prm fade quad last.
The 13-element save/load frame differs in exactly the two open questions and no
new ones: two unkeyed pfbase.tbm backgrounds paint FIRST where the sort puts the
keyless last - the splash's palogo_eff0.prm behaviour in a different file type,
so implied_layer_key now covers it - and the 0xb100 group of four paints
10,11,8,12 where declaration order is 8,10,11,12.
That second point is a SECOND screen with a mis-ordered tie, which is what the
question needed, and it immediately kills a candidate: 10 and 11 are kind=0x2002
while 8 and 12 are 0x0000, so "descending kind then declaration index"
reproduces 10,11,8,12 exactly - and then fails both title groups, where every
element of 0x8083 is kind 0 and where 0x80a0 would predict 2,3,4,5,0,1,7 against
a measured 0,2,4,7,1,3,5. Seven candidates refuted now.
16 disc tests green.
With the layer key and the primitives' implied keys in place, the tie-break -
how the game orders elements sharing a key - is all that is left between the
derived order and ground truth. Three measured screens now constrain it.
On the menu and the splash every tied group comes out in declaration order,
which is what the stable sort already gives. The title is the only screen that
discriminates, and nothing predicts it: 0x8083 x5 paints eff1, eff2, eff5, eff3,
eff4, and 0x80a0 x7 paints logo1 x3, tm, logo2 x3.
Refuted: declaration order; RATC child order; first keyframe time (52, 56, 62,
58, 60 - the measured order is not sorted by them); resting keyframe time;
resting X or Y (938, 938, 64, 788, 447); and T8aD header words +00, +04, +0c and
+10, which are either identical within a group or unsorted.
Child order is worth its own line: a strict improvement over declaration order
(7 misplaced positions on the title instead of 9, and it recovers the logo
grouping) and exactly right on the other two screens. NOT adopted, because on the
only screen that can tell them apart it is still wrong.
Adds a test that measures what the residual costs instead of assuming it. Of the
3 disagreeing pairs of drawn elements across all three screens, all 3 have
overlapping bounding boxes and 2 share opaque pixels: ptlogo_back2eff5 against
eff3 (22568 px) and eff4 (32395 px). The third pair, ptlogo2 vs ptlogo_tm,
overlaps by two columns and shares NO opaque pixel - the wordmark is transparent
there. A bounding-box test called that a defect; reading the alpha says it is
not, which is why the test reads pixels. The set is pinned, so a change that
makes it worse fails.
15 disc tests green.
Two places checked for the key a .prm element sorts by, both empty:
* the declaration entry's four unread words are constant across every element
of all three measured screens (+28=0, +36=0xffffffff, +56=0, and +44 is a
button ordinal 1-5, 0xffffffff elsewhere);
* the bundle carries NO data for a primitive at all - the menu build declares
pteff00.prm, pteff02.prm and pteff05.t32 and has zero RATC children for any
of them, its 34 children being 21 T8aD sprites and 13 .rat records.
So the layer comes from the game code. But it is consistent, which is what makes
a per-name table a measurement rather than a fudge. Bracketing each unkeyed
element between the keys of its measured neighbours: pteff02.prm falls in
(0x8010, 0x8040) on BOTH screens it appears on, pteff00.prm is past the maximum
on both, and palogo_eff0.prm is below the minimum on the splash.
implied_layer_key records exactly those and nothing else; an unlisted primitive
keeps u32::MAX and still sorts last. With it, derived_paint_order produces the
same layer-key sequence as the order read off the running game on all three
measured screens - primitives included - and matches element-for-element on four
of the five bundle instances. The fifth is the title, differing only inside its
tied groups, which is a separate open question.
This does not make include_primitives safe by default: the 36 builds that come
out one colour are wiped by pzeff00.prm and pceff00.prm, never measured, hence
not in the table.
14 disc tests green.
fill_quad composites an untextured primitive as a solid rectangle of the
keyframes fade colour, pivot x 2 in size, placed and scaled exactly as a sprite
is. Behind ComposeOptions::include_primitives and screen render --primitives.
On the title screen, whose paint order is ground truth, it is measurably right:
mean luminance 76.30 -> 63.72 against the captures 64.58, i.e. from +18% to
-1.3%, and mean absolute difference 16.07 -> 13.08. The background was ~40% too
bright; pteff02.prm, a 25% black dim, was what was missing. The wordmark is not
dimmed by it because the measured order paints that quad at slot 4, beneath the
logo. Edge correlation moves 0.9538 -> 0.9480, which is not informative here: a
uniform dim scales gradients uniformly so a normalised edge score barely sees it.
OFF BY DEFAULT, and that is the finding. A primitive has no T8aD header, so no
layer key, and derived_paint_order sorts the keyless to the end. GP_DIALOGs
pzeff00.prm is a single keyframe of opaque black at full screen; painted last it
wipes the build. Of the 125 builds that draw a visible primitive, 36 come out
>99% one colour with the derived order.
No constant default works, because the two screens read off the running game
disagree: the splash paints its primitive FIRST (the black backdrop) while the
title paints one at slot 4 and another LAST (the fade-out). Declaration order
fails the title too. A disc test measures the damage rather than asserting the
feature works, so the number moves when the ordering is solved.
Also records a false alarm worth keeping: a first pass reported 36 GP_DIALOG
builds at "100% black", which was a crude near-black pixel threshold and not a
black screen - those dialogs are dimmed 50% and perfectly legible. The genuinely
wiped builds are a different set.
blit modulated by tint only, which is 0xffffffff on essentially every keyframe,
so the fade word was decoded, stored and then discarded. Applying it as an ARGB
modulate takes the title composite's edge correlation against the framebuffer
capture of the running game from 0.4597 to 0.9538, both at zero shift. The white
wordmark with its blue outline, the trademark, the copyright and the orange
exploding planet all appear -- the planet because a full-screen blue effect that
rests at alpha 0 had been painting over it at full opacity.
ARGB is measured: across a fade-in the high byte walks 0x00 -> 0x80 -> 0xc0 ->
0xe0 -> 0xff while the low three stay ffffff, and the low 24 bits are 0xffffff on
5276 of the disc's 5453 resting keyframes.
A modulate can only remove pixels, so the risk is a blank screen. Measured: no-op
on 4060 of 5200 sprite elements, partial on 453, hides 687 -- transient HUD
indicators that should not be lit at rest -- and NO build is left with nothing
visible. A disc test asserts that, and that the no-op share stays high, which
also guards the resting rule against drifting onto ramp frames.
Both changes are in one commit because the second is invisible without the first.
Applying fade erased the word PAUSE, which the running-game capture plainly
shows: pgptitle.rat has three runs of two identical keyframes -- pre-roll, hold,
exit -- and the 'later run wins' tie-break grabbed the exit. A group carries the
screen's entry animation AND its exit, so a run ending on the last keyframe is
now excluded unless it is the only one. The title correlation is unchanged.
Element::rest() picked the keyframe with the largest gap to the next keyframe's
time. That reads a keyframe as a value held until the next one; it is the start
of a ramp toward it. A long gap after keyframe k means the screen spends that
time arriving at k+1, so the settled pose is at the far end of the gap.
The title wordmark zooms in over five frames and holds at (184,193) at 100% from
t=251 to t=264. The old rule picked the frame before the long gap: (179,186) at
101%, still mid-zoom.
Measured against the framebuffer capture of the running title screen, which is a
1:1 crop so frame coordinates map directly (confirmed: the copyright line lands
on row 669 in the capture and in both composites). Edge-correlated over the
wordmark box:
plateau (landed) best 0.4597 at shift (0,0)
longest dwell (old) best 0.1511 at shift (+3,+8), 0.1268 at (0,0)
The old composite scores 3x lower and only peaks after being moved, by about the
(-5,-7) that picking kf4 instead of kf5 predicts.
It also fixes six title elements the old rule rested at alpha 0x00 where the
capture plainly shows them, and pteff00.prm - the full-screen fade quad painted
last - which rested at opaque black. That was the blocker on .prm compositing.
Adds tools/re-capture/align_to_capture.py, which is how this was scored, and
turns the .prm test that deliberately asserted the old defect into a guard on
the fix.
Not settled and now the next item: compose ignores the keyframe fade alpha
entirely (blit modulates by tint only), which is why choosing the wrong keyframe
was invisible until now.
369 .prm elements exist in the disc's screen builds and every composite is
missing them. Swept statically:
* none of the 369 has a RATC child of its own name — no payload, nothing to
draw, so .prm is a primitive and not a sprite;
* kind & 0x10 and a .prm name agree with ZERO exceptions in either direction
over all 965 builds, so the format marks primitives as a decoded field and a
port need not parse filenames;
* 361 of 369 are exactly 1280x720 at scale 100% in the corner, and their
keyframe 'fade' ARGB is overwhelmingly black at some alpha — these are the
fade-to-black, dim-behind-menu and flash layers, i.e. the PRMD dim-quad the
compositor's backdrop has been standing in for.
Refuted before believing: drawing them at Element::rest() is wrong. The title's
pteff00.prm is opaque -> transparent -> transparent -> opaque, a transition whose
resting pose is the transparent plateau; rest() picks by longest dwell and lands
on the opaque endpoint, which is painted LAST on that screen and would black out
the title. A test asserts that wrong answer deliberately so that fixing the
resting rule fails it and leads to the note.
No compositing change: the resting rule is not .prm-specific and has to be A/B'd
against the title framebuffer capture first.
The developer-logo splash declares its sprites directly and has no .rat layout
child, so is_build rejected it and no screen command could render it — despite
it being one of only two screens whose paint order has been read off the running
game, and the one where the layer key explains the whole permutation.
Adds ui_layout::is_composable (a declaration table plus at least one element
resolving to a T8aD the bundle carries) and an opt-in --all on screen
list/info/render. Measured on the disc: 2859 RATC bundles, 965 pass is_build,
2751 pass is_composable, and 0 pass is_build without passing it — a strict
superset. It is opt-in because the 1786 extra bundles are mostly two-element
fragments (a button and its glow), and because widening the default would
renumber --build for every pak, invalidating the build indices the corpus's
notes cite by number.
The splash now renders 6/7 elements, painting its glows first in the order
measured off the game; a disc test pins that order.
`compose` skips focused-state records by default, so whatever that flag matches
vanishes from every composite. It matched a trailing `f` in the name and nothing
else, which is not the convention — it is a letter.
Measured across the disc: 2458 elements match the suffix and only 54 of them
have the base element they would be the focused version of; all 54 are
`pgmenu_btnNNf.t32`. The other 2404, spread over 864 bundles, are `_eff` glow
layers whose names end in the same letter — `pb_name_eff.t32` alone accounts for
1122 of them, and `palogo_gamearts_eff.t32` is one the draw capture shows the
game painting, before its logo, with its own layer key.
Requiring the pair recovers 587 glows in the composable builds. GP_OPTIONS is
the plainest case: without `pbmwindow_eff.t32` the panel had no window at all,
just two floating brackets (captures/ui-layout/options-with{out,}-its-glow-layer.png).
Two other candidate rules were tried and refuted first. The `opt` link is not a
focus link: 221 elements are opt-targets, only 2 of them suffix-match, and the
targets include `pjnet_bg.rat` and `pv_loading_loop1.rat`. And the suffix alone,
as shown above, is unrelated to anything.
The layer-key order was adopted from two measured screens and then applied to
every build on the disc, so it owed a regression check against the screens the
corpus had already validated against the running game.
Rendered the tutorial PAUSE menu and the title main menu both ways and diffed:
3.8 % and 1.1 % of pixels differ, max delta 45/255 and 34/255, and the two
renders are indistinguishable in layout — the change is confined to blends where
translucent sprites overlap. No regression, but which order is more faithful on
those two screens is unsettled and recorded as such.
Adds a corpus-wide test asserting every composite's draw list is strictly
increasing in (layer key, declaration index), streaming one pak at a time so it
does not OOM alongside the other whole-disc tests. It reports the rule's reach:
341 of 965 builds are reordered, and it fails if that share collapses.
compose now sorts elements by the word at +0x08 of their sprite's T8aD header
instead of painting in declaration order, for every build except the two whose
measured order is hard-coded. That word is non-decreasing in the order the game
actually paints both measured screens, so every screen nobody has captured now
gets its layering from the file rather than from the declaration table, which is
provably not the paint order.
Verified with artifacts and both ways, not by a green build: the disc test
asserts the measured orders never invert the key and that the composite's key
sequence is sorted, and reading the word from +0x0c instead makes it fail; the
title composites identically; and GP_MISSION_SELECT — uncaptured — now composites
cleanly, committed as a capture.
Two things recorded rather than smoothed over: ties keep declaration order
because the game breaks them some other way that is not known, and the
developer-logo splash has no .rat child, so is_build rejects it and the
compositor never sees that bundle at all — its measured order is inert in
practice and screen render cannot draw it.
Checking the blast radius of the previous commit's `kind = 0x4` skip found 174
elements on the disc that are 0x4 with no non-0x4 element of the same sprite —
GP_READY_ROOM pak entry 75 is 56 elements and every one is 0x4, a list of real
icons a blanket skip would erase. So the rule is narrowed: skip an instance only
when its template is present, which is the case the title capture covers. The
title render is byte-identical before and after, so the narrowing changes nothing
the evidence covers.
And the claim that this "caught a regression" is WITHDRAWN, because measuring it
refuted it: none of those 174 elements is in a bundle `is_build` accepts, so none
of them ever reaches the compositor. The blanket skip would have been harmless in
practice. The narrow rule is a precaution, not a fix, and the comment says so.
The test that was going to assert the regression could not find such a build —
correctly, since none is composable. It is replaced by one that pins the fact
which makes the narrow rule safe: no composable build on the disc has a 0x4
element without its template, checked across 500+ builds. If that stops being
true it fails, instead of a screen quietly going empty.
The compositor painted in declaration order, which the draw capture proved wrong
— the title's background is declared ninth and paints first. The order the game
uses is its runtime child list, and no decoded field reproduces it, so rather
than leave the screen composited wrongly this lands the orders that HAVE been
read off the running game and falls back to declaration order everywhere else.
Two builds are covered: GP_TITLE's title build and the GAME ARTS / SETA / studio
anima splash. Keyed by element names, which identify a build across paks and
language variants.
Rendering it exposed a second defect, and the same capture settles it: the
kind = 0x4 elements are motion-trail ghosts, not resting content. The bundle
declares three instances of each wordmark; the capture shows exactly ONE quad at
each wordmark's position. Drawing them at their resting keyframe put three
oversized PROJECT SYLPHEED copies across the composite. They are now skipped.
Verified with an artifact, not a green build: the composite is committed
(captures/title-composited-measured-order.png) and now reads as the title screen
— background, planet, ship, wordmark, TM, copyright, correctly layered.
The test is disc-gated and was checked BOTH ways: it passes as landed, and
disabling the order table makes it fail. It reads one pak rather than every
build on the disc — the first version used the all-builds helper and got the
test process OOM-killed running alongside the other three.
The title screen needs an order that puts element 13 (`ptbase2.t32`, the
full-screen background) behind elements 0-5 (the wordmarks) — the capture shows
the wordmarks on top, so the declaration table is not it. Two other orderings
the bundle carries were the cheap candidates, and both are now dead:
* the **placement region** stores a keyframe group per element with an explicit
element index, so it could be a second ordering. It is not — it equals the
declaration order on every build on the disc. `UiBuild::placement_order`
exposes it and `placement_region_order_is_never_a_second_ordering` pins it, so
the refutation stays checkable instead of remembered.
* the **RATC child order** is the declaration order with the `.prm` elements
absent — strictly less information, and no place to put the background other
than where the table already puts it.
`screen info --geometry` prints both, plus each element's decoded sprite size
beside `pivot*2` and every keyframe's scale/position/time — the numbers a
placement hypothesis has to be tested against, and how the pivot/scale rule in
the previous commit was found.
`title_background_is_full_screen` pins that rule against the disc rather than a
synthetic sprite. `scaled_elements_are_a_small_and_mostly_undiscriminating_minority`
reports the scope honestly: 865 of 5 130 resting placements are scaled at all,
and only 213 of those could tell "about the pivot" from "about the sprite
centre" — which the capture did *not* settle, because `ptbase2`'s pivot is its
centre. It also counts how far `pivot*2` is from the decoded size disc-wide
(2 521 agree, 1 884 are off by more than 16 px), which demotes the "pivot is
exactly half the texture" result to a property of the tutorial bundle.
Two independent lines landed a `.rat` reading and neither was the whole
picture, so this merges them into one module and fixes what the merge exposed.
ui_layout — the screen is the BUNDLE, not the set of .rat records
------------------------------------------------------------------
`feat/ui-layout-preview` parsed `.rat` records; the autopilot stack documented
the RATC header and probed it in `examples/screen_layout.rs` but never landed a
library module. The `.rat`-only reading structurally cannot see an element that
has no record -- the `eff*` frame corners, the `deli*` dividers, `msg` -- which
is exactly what the committed real-vs-rebuilt capture shows missing. Rebuilt
around the header:
* element declaration table at 0x20 (60-byte entries: name, parent index at
+32, kind flags, pivot) = the back-to-front draw list;
* the placement region after it = per-element keyframe groups.
Verified against the disc, each against a fact the docs state independently:
`pgpeff02a` -> parent 3 = `pgpeff02`; `pgp_ttrl_btn10` rests at (546,288); the
pause buttons sit at 268/337/407/478, the documented 70 px pitch; the Arsenal
carries X = -516. The tutorial PAUSE menu now composites 11/11 elements and
matches the real screen more closely than the earlier rebuild did.
Three defects found while validating, none of which any test would have caught:
* the keyframe block is 40 bytes with X/Y/time at +28/+32/+36 and an
alpha-ramping ARGB at +0 -- the fade, previously unread;
* a group's data stops 4 bytes short of its last block's time slot, so that
word is the NEXT group's element index. Reading it produced times like
1869640736 and silently corrupted the max-dwell pick. Last-frame time is
now `None`;
* the `.rat` sprite-name field is not 16 bytes. Capping it there truncated
`pgp_ttrl_title.t32` to `pgp_ttrl_title.t`, which resolved against nothing
and dropped 4 of 11 tutorial elements from the composite.
Max-dwell also needed a tie-break: on equal gaps take the LATER frame, or
`pgpmsg` reports the y=645 fly-through instead of the y=605 it settles at.
savegame -- a Rust port of tools/re-capture/savegame.py
------------------------------------------------------
GDHA container, zlib payload, chunk stream (GDAA / phase / GHAD 122 B / 16x20 B
SHAB / trailer). Every GHAD word carries its own confidence rather than the
block being presented as solved: 6 named, 2 recorded as REFUTED (+36, +56 were
tested as difficulty and as stage and are neither), 7 still unknown.
Tested against the three real saves committed under docs/re/captures -- no disc
and no emulator needed. The load-bearing assertion is the byte-identical
round-trip; the develop differential is asserted as a property (spending 4000 P
moves +24 and not its twin +28, steps the clear ratio, and moves exactly two
blob entries), and the header summary is checked to agree with the payload it
mirrors -- the trap that makes the Details panel a bad oracle.
CLI: `screen list|info|render` and `save info`, so both are checkable headlessly
in the same spirit as `mesh render`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
desc_dump shows each index marker is followed by its OWN element triples: n201_01
declares strides 24, 24, 24 and 28 (the last sub-mesh has a fourth element), which
matches the runtime capture's stride=28 on that draw and the four distinct vertex
shaders. parse_vertex_decl read the first declaration for the whole pool.
all_vertex_decls reads one per marker; anchor_grouped_meshes uses each sub-mesh's
own stride for the pool walk, the pivot validation and the read. debug_grouped_report
follows the same setting so the diagnostic cannot blame the wrong gate — at n201_01's
capture-proven pool start it now reports "pad 0: ACCEPTED" instead of a NaN position.
With XBG7_SUBMESH_DECLS=1: resources that never decode 85 -> 47, resources decoding
in no container 63 -> 30, capture oracles unchanged (93/93 index runs, 42/42 index
counts), consistency unchanged at 96.
Off by default because selection has not caught up: the three n201_0x copies then
settle on one pool (twin_pairs_do_not_share_a_buffer fails), production still picks
a start 4 bytes before sub-mesh #1 rather than the proven one even though the proven
start validates and is unclaimed, and four newly decoded ptc_pack .dat composites
carry degenerate triangles. Format truth is settled; choosing among candidates is
the remaining work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Two follow-ups to the pad-scoring fix, both driven by the same invariant (a
correctly located index run has no degenerate triangles):
- anchor_grouped_meshes picked its pad by first-match too; scoring the pivot run
the same way cleared every remaining ptc_pack composite (f102/f104/e107).
- anchor_pool_mesh now prefers a degenerate-free candidate over an earlier dirty
one. examples/better_home.rs showed the last two resources each had exactly one
degenerate-free, pool-covering block, sitting later in file order than the
lookalike we took. First-match order is kept for every clean hit, and a dirty
block is still used if nothing clean exists, so coverage cannot regress.
degenerate index runs, disc-wide: 582 -> 11 -> 1
captured index runs identical: 93/93 (unchanged)
resources decoded / misses: 6 209 / 85 (unchanged)
index runs changed / anchors moved: 590 / 10 (_rou_f402_dead x8, e201_bdy_03_m x2)
Cross-container minority decodes 89 -> 96, and that is progress: all seven new
rows are _rou_f402_dead, which now has a majority (32x25x8) for the first time, so
its seven wrong copies are named instead of hidden behind "no majority".
The last dirty run (_rou_f402_dead in Stage_S09) is blocked by distinct assignment
— its clean block is claimed by e_rou_f003_Near, both 24-vertex bounding boxes. A
winding-floor escalation for that case was written, measured to fire for nothing,
and reverted; the reasoning is kept as a comment.
Regression threshold tightened to 1. Suite green with --include-ignored apart from
the pre-existing known-failing cross-container consistency test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
Extending the capture comparison from index COUNTS to index VALUES
(`examples/capture_index_bytes.rs`, using the batch offsets the new ib logging
gives) showed 76 of 93 Stage_S02 index runs identical to the GPU's and 17
differing — every difference a shift by exactly one element, on buffers whose
index data sits at pad 2.
`anchor_pool_mesh` returned the FIRST pad that validated, and pad 0 is tried
first with the looser winding gate (0.70 vs 0.85). Read at pad 0, a pad-2 block
yields [true[1], true[2], …, garbage]: every index in range, the pool covered,
the positions right, the winding often just above 0.70 — so it validated, and
every triangle was mis-wired. Nothing count-based could see it.
The signature is decidable without the capture: a shifted run wires arbitrary
vertices, so triangles come out degenerate. 282 of 283 correctly anchored
Stage_S02 blocks have zero degenerate triangles, while the shifted readings carry
1–2 156. So score every validating pad by (degenerate triangles, then winding)
and keep the best. `XBG7_PAD_FIRST_MATCH=1` restores the old behaviour.
captured index runs identical: 76/93 -> 93/93 (2 025 elements)
decoded runs with a degenerate triangle: 579 -> 16 (disc-wide)
sub-meshes whose index run changed: 575 of 8 850
resources decoded / vertex anchors / consistency: unchanged (6 209 / same vb / 89)
Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles.
Suite green with --include-ignored apart from the pre-existing known-failing
cross-container consistency test (the 24-vertex bounding-box class).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
The offset->field map read out of sub_82341A20 is now usable rather than just
documented: data/unit_definition_layout.txt carries the 159 fields with their
provenance and the two conventions (angles are degrees on disc and radians in the
object; a defaulted field keeps the accessor's 0.0 miss value), and
sylpheed_formats::unit_layout exposes fields()/field_at()/offset_of() so a memory
snapshot can be read by name.
tests/unit_layout_disc.rs replays the verification against the checked-in live
dump -- every mapped float of all 11 identified objects must equal its disc value,
angles compared in radians -- asserting 0 disagreements and >=400 agreements. It
needs no emulator. Full suite green: 11 binaries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
debug_grouped_report (why_rejected) shows n206_02's alternative pool is ACCEPTED
at pad 0 under production gates and is in the candidate list -- and the decoder
does take it: n206_02 now anchors at 0x342d984. The 'still collapsed' reading
came from the audit comparing decoded geometry, and that offset holds a direct
(unmirrored) copy, so a separated pair still looked identical. The audit now
requires a SHARED BUFFER to call it a collapse: disc-wide 18 exact mirrors, 16
related, 0 collapses, 0 unrelated -- and the regression test drops its exception.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
twin_mirror_audit applies the capture-derived rule to all 166 containers: of 34
equal-count twin pairs, 18 are exact X-mirrors, 15 related another way, 1
identical, 0 unrelated. Two calibration fixes were needed first (authored halves
need a tolerance, and a mirrored pair may be stored in another vertex order).
The one collapse, n206_01/_02, is a grouped-pool pair -- the path distinct
assignment excludes -- so it names the next target. Added a disc-gated
regression test; refreshed the stale ignore message on the consistency test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
texture_disc.rs and mesh_disc.rs fall back to "/home/fabi/RE Project Sylpheed/..."
when SYLPHEED_ISO / SYLPHEED_RES3D are unset. The directory is "RE - Project
Sylpheed"; the fallbacks were missing the " - ", so stage_models_decode,
stage_models_sweep and stage_models_quality_audit failed with NotFound for anyone
who did not happen to know the env var. They now pass unaided (10/10 in
mesh_disc).
Found while auditing what the suite actually exercises after withdrawing the
anchor change: an ISO-gated test skipping, and an ignored test failing on a typo,
are both "green" in a normal run.
Full state, with SYLPHEED_ISO/DISC/RES3D set and --include-ignored: 82 lib tests
and every disc/ISO suite pass; the single failure is
shared_resources_decode_identically_in_every_container, which is deliberately
written as the target state and #[ignore]d.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The neighbourhood anchor (f18d591) and its refinement (27a0701) took
cross-container inconsistency from 125 to 51 with coverage unchanged, and made
e106 render as a destroyer rather than a slab. Both are reverted.
ship::tests::static_assembly_matches_runtime_capture is gated on SYLPHEED_ISO, so
it SKIPS in an ordinary cargo test -- which is why the regression was invisible
in every suite run so far. With the ISO it fails:
e106_bdy_01: static M row0 [-1.0, 0.0, 0.0] != captured [1.0, 0.0, 0.0]
e106_bdy_01 and _02 are a mirrored pair whose two buffers hold the same geometry
reflected in X, and BOTH resources currently decode to the SAME buffer (identical
counts, span and mean_x). apply_twin_mirrors picks which instance to reflect from
the sign of that mean_x, so which buffer wins flips the decision:
before both twins mean_x = -66.83 -> mirror bdy_02 (matches the capture)
after both twins mean_x = +66.83 -> mirror bdy_01 (contradicts it)
Neither is right -- two resources sharing one decode is itself the bug and the
mirror heuristic has been compensating. The capture is ground truth, so a change
that contradicts it does not ship. The real fix must give each twin its own
buffer first.
Kept from the attempt: this test now also asserts the SET of static placements
against the capture (allow-list {e303_wep_01} for vbase dedup), so extra
placements can finally fail it -- the direction it could never fail in before.
Docs, backlog, INDEX and the ignored test's message all corrected to say
diagnosed-not-fixed rather than fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pass 1's anchor map contains exactly the mistakes the neighbourhood is meant to
correct, so a resource sitting beside a mis-anchored neighbour inherits a bad
reference. Re-anchoring against the improving map and repeating converges
quickly: two rounds, and a third changes nothing (the loop exits early when a
round is a fixpoint).
before decoded 5480/6294 inconsistent 125
neighbourhood anchor decoded 5480/6294 inconsistent 63
+ refining the map decoded 5480/6294 inconsistent 51
Coverage still unchanged. The 51 that remain cluster in _l (LOD) and _dead
variants -- e001_l, e010_bdy_01_l, e106_eng_02_l, _rou_f302_base_dead,
e303_base_dead and friends. A plausible reading is that a variant shares its
base's vertex and index counts, making the two mutually confusable so that
locality cannot separate them; recorded as untested rather than asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anchor_pool_mesh took the FIRST candidate in file order from a container-global
vertex-run scan, so a resource could be handed another resource's block whenever
both shared (stride, vertex count, index count). Both blocks are real geometry and
both pass every quality gate, so only position separates them.
anchor_pool_mesh_near now tries candidates in order of distance from a reference,
and anchor_models_filtered runs two passes: pass 1 anchors first-match to learn
where resources land, pass 2 re-anchors each resource preferring the median anchor
of its +/-2 descriptor neighbours. Too few anchored neighbours -> keep pass 1, so
nothing regresses to guesswork.
before decoded 5480/6294 shared 681 inconsistent 125
after decoded 5480/6294 shared 681 inconsistent 63
Coverage unchanged, inconsistency halved. e303_wep_01 decodes to 49x23x42 in ALL
containers now, and e106 renders as a destroyer instead of a slab -- its two
shared turrets symmetric at X[-203,-154] and X[154,203]. That resolves the
user-reported "capital ships assemble wrong" for this cause.
The filtered path needed care: models_named (what the viewer uses) dropped
non-wanted resources, which would have left filtered decodes with no
neighbourhood and silently kept the old behaviour. Resources are now collected
regardless of the filter, but only the asked-for ones and their +/-2 neighbours
are decoded in pass 1, so a filtered decode stays proportional to what was asked.
63 cases remain; mesh_consistency_disc stays ignored and now records 63, not 125.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each finding today was originally missed by reasoning from a sample, so these
assert DISC-WIDE invariants rather than one hand-picked file.
ui_surfaces_disc.rs (3 passing)
every_t8ad_on_the_disc_decodes -- all ~19216 surfaces; the old
256-grid model looked like 96%
lsta_count_equals_sprites_plus_primitives -- header counts T8aD AND PRMD,
64/64, which is what made the
count look unreliable
ratc_nesting_is_exactly_one_level -- nested records are leaves; zero
grandchildren disc-wide
mesh_consistency_disc.rs (1 ignored, deliberately)
shared_resources_decode_identically_in_every_container
The mesh test is written as the TARGET state, not a snapshot of the bug: a
resource shared by several containers must decode to the same bounds, which today
fails for 125 of 681 shared resources. Fixing the anchor scan makes it pass;
un-ignoring it is then the last step rather than a rewrite. It only compares
decodes that agree on vertex/triangle counts, so "found different geometry" stays
a separate question from "placed the same geometry differently".
All suites green: 81 lib + the disc guards, with 2 ignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.
Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
(fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
Latin-1 accents preserved.
Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.
Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).
Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.
Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revert the mis-guided "triangle STRIP" reading (a937779): XBG7 index
buffers are triangle LISTS (prim=4 in the GPU capture; stored-normal
agreement 1.000 as a list vs ~0.49 as a strip). Reinstate the
winding-consistency gate.
Crack the grouped-pool layout used by the hero ship and ~150 detailed
models. A resource's sub-meshes share one index pool (buffers 4-byte
aligned, in descriptor-marker order) followed by one vertex pool (each
vtx_count*stride, same order); the vertex pool is 4-byte aligned after
the index pool. The whole resource is derived from one anchored pivot:
ib0 = vb0 - span - pad (pad in 0..=3, alignment)
ib[i] = align4(ib[i-1] + idx_count[i-1]*2)
vb[i] = vb[i-1] + vtx_count[i-1]*stride
vb0 is found via the unit-normal vertex-run scan; the alignment is
confirmed by validating the LARGEST sub-mesh (most reliable), after
which the rest are read/validated. A single marker reduces this to the
existing adjacency anchor, so grouped generalises it.
Results (cross-checked against a Canary GPU draw-log capture of
DeltaSaber_T.xpr):
- DeltaSaber_T f001 = body + 7 detail parts = 8650 tris, every sub-mesh
0-degenerate / full-coverage / winding-agreement 1.000.
- All 19 previously-declined weapon models now decode (they hit pad 2
and/or lead with a tiny bracket that broke a markers[0] pivot). Corpus
audit: 0/146 geometry files fail (was 19 -> flat-texture in the viewer).
- Stages unchanged (single-marker path is byte-identical; grouped falls
back to the old anchor on failure). 7/7 disc tests green incl. the
strict stage quality audit; new hero_ship_grouped_pool_decodes test.
Viewer: route by count_xbg7; --only matches an exact model name (so the
neutral pose renders without the mnv*/turn180 animation poses). Fix the
albedo matcher: match a sub-model to its _col map by entity stem
(e007_bdy_01 -> e007_col) instead of a full-name prefix, lifting stage
sub-model texturing from ~25% to ~95% (the rest were flat grey). Colour
correctness (channel order/sRGB) remains a separate dynamic-RE item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage containers (hidden/resource3d/Stage_S*.xpr) are collections of up to
~450 enemy/prop sub-models, not single meshes. Xbg7Model::stage_models decodes
them: each resource is a [12-byte header][index buffer][vertex buffer] block
(index count = descriptor marker, vertex count = u32 32 bytes before it) whose
on-disc offset is NOT stored, so it is located by content — one O(file) pass per
stride finds vertex-buffer starts (unit NORMAL at +12 whose previous slot isn't)
and each resource is pinned to the candidate whose indices validate and produce
non-degenerate, well-connected triangles. A connectivity gate (mean triangle
edge <= 0.28x the bbox diagonal) rejects spiky mis-anchors. ~4993 sub-models
decode across the 22 stages.
The same insight fixes the weapon single-model layout: it is [12-byte header]
[index][vertex] too, not [index][12-byte gap][vertex]. Reading indices from the
block start turned the 12 header bytes into 6 junk indices (2 leading degenerate
triangles — the recurring stray-triangle artifact) and dropped the last 6 real
indices. Skipping the header leaves vertex offsets identical (coverage unchanged
at 36/166) and corrects the triangle list. Verified on wep_00/03/04.
Adds `sylpheed-cli mesh {info,render}` — a headless software rasterizer that
writes a shaded PNG (orthographic, z-buffered, two-sided), so recovered geometry
can be verified without the GUI. Stages render as a normalised thumbnail grid;
--only filters sub-models.
Tests: stage_models_{decode,sweep,quality_audit}; docs/re updated (xbg7-mesh.md
evidence log + INDEX.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dynamic-RE follow-up: capture Canary's GPU vertex-fetch + draw calls and
feed the ground truth back into the static decoder.
The GPU capture confirmed the reverse-engineered layout exactly — meshes
draw as triangle LISTs (prim=4) with pos f32x3 @0, normal f16x4 @0x0C,
uv f16x2 @0x14 — and revealed the XBG7 vertex format is NOT fixed-stride:
models omit elements (stride 20 = pos+normal, no UV; 24 = pos+normal+uv).
- mesh.rs: parse the descriptor's vertex declaration ({offset, format-code,
usage} triples; 0x2A23B9=pos f32x3, 0x1A2360=normal f16x4, 0x2C235F=uv
f16x2) to drive per-model stride + element offsets, instead of assuming
stride 24. Coverage 25 -> 36 fully-validated models (e.g. the Stage_S*
props, which are pos+normal only). Same index-range + unit-normal safety
gates; complex/mismatched layouts still declined.
- Endianness note (documented): the capture's fetch endian=k8in32 describes
the GPU's guest-memory copy, NOT the .xpr file bytes — reading the file
with k8in32 breaks the normals (|n|->1.33); naive big-endian per element
is correct (the game rearranges vertex data on load).
- tests: a coverage-regression test (>=35 models) + the existing weapon /
body-declined disc tests still pass.
Confirmed against a Canary draw-log capture; see docs/re/structures/
xbg7-mesh.md (evidence log + declaration table).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverse-engineer the single-stream XBG7 geometry layout (clean-room: hex
inspection + geometric validation of the retail disc's
hidden/resource3d/*.xpr, no game code copied) and present models as
textured 3D meshes in the explorer.
Format (docs/re/structures/xbg7-mesh.md): XBG7 geometry resources sit
inside XPR2 containers alongside TX2D textures. For ~25 single-stream
models (weapons, simple props) the data section is a sequence of
sub-meshes, each an u16-BE triangle-list index buffer followed (after a
fixed 12-byte header) by a stride-24 vertex buffer whose declaration is
in the descriptor: POSITION f32x3 @0x00, NORMAL f16x4 @0x0C, TEXCOORD
f16x2 @0x14. Sub-mesh (vtx,idx) counts come from descriptor tuples.
The +12 vertex offset is pinned by the recovered normals being exactly
unit-length (align16 lands 4 bytes early and silently corrupts every
field). A safety gate rejects any model whose indices are out of range
or whose mean |normal| is not ~1, declining garbage (Stage_S*
placeholders, the complex multi-stream hero-ship body) rather than
mis-decoding it.
- mesh.rs: Xbg7Model::from_xpr2 -> GameMesh { positions, normals, uvs,
indices }; standalone f16->f32; unit + real-disc tests (weapon decodes
to 215v/364t with unit normals + in-range UVs; DeltaSaber body
declined).
- texture.rs: from_xpr2_index / texture_names so the viewer can pick a
model's _col albedo map.
- viewer: loose .xpr with decodable XBG7 spawns Bevy meshes (real normals,
double-sided) textured with the albedo, framed by the orbit camera; the
central egui panel goes transparent so the 3D scene shows through.
Complex multi-stream body meshes (DeltaSaber f004, other vertex layouts)
remain undecoded and are cleanly declined — next target is dynamic RE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>