Commit Graph

150 Commits

Author SHA1 Message Date
sim
2f838040ca docs: stop telling people to use the retired xenia-rs
Instructions and generated text that still sent readers to `xenia-rs`, which is
archived and deleted locally. Provenance and dated research records are left as
they are — "lifted from xenia-rs", or a finding stating what database it was
measured against, is still true, and rewriting it would falsify the record.

Changed, because each one tells someone what to do today or writes stale text:

  docs/re/README.md          the RE toolchain guide queried `xenia-rs/sylpheed.db`
                             with `xenia-rs/zq.py`, and its "Dynamic" bullet said
                             to prefer xenia-rs's probe suite OVER Canary. Now:
                             `sylpheed.db` at the repo root (how to build it and
                             re-stamp names), `tools/zq.py`, dynamic = Canary, and
                             `.rdata` reads from the `.pe` (offset = VA - 0x82000000)
                             in place of `--dump-addr`.
  challenge-mission-gate.md, structures/achievements.md
                             reproduction commands `python3 xenia-rs/zq.py dis …`
  crates/sylpheed-xexdb/SCHEMA.md
                             titled after the retired `xenia-analysis` crate,
                             citing `xenia-rs dis`, pointing at a `xenia-analysis`
                             source path
  sylpheed-xexdb/src/formatter.rs
                             WROTE "generated by xenia-rs" into every disassembly
                             it produced (no test pins the banner)
  sylph-xexdb.rs, db.rs      "SQLite" / "DuckDB writer for xenia-rs"
  sylpheed-formats/src/hash.rs
                             pointed at `xenia-rs/RE_SYMBOLS.md`, now at
                             `docs/re/RE_SYMBOLS.md`

Verified: every rewritten reproduction command was extracted from the doc and
run as written against the regenerated database — all 5 exit 0 and return the
disassembly they describe. The README's function count (25 676) is the database's
own. `cargo fmt --all -- --check` clean.

Not changed, deliberately: the README's Oracle bullet says Canary's native Linux
ELF "crashes / does not run". That is about Canary, not xenia-rs, and it conflicts
with a July note that the native build works — unverified either way here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:37:08 +02:00
MechaCat02
c6f7a18e30 fix(lint): the hand-judged clippy sites in library code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:36:43 +02:00
MechaCat02
62376dd4a1 style: rustfmt sweep — 107 files the lint gate never saw
This branch predates CI on `main`. `cargo fmt --all` only; no behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:34:40 +02:00
sylph-decoder
5b850bb565 Merge remote-tracking branch 'origin/main' into auto/frame-blend-draw-path
# Conflicts:
#	crates/sylpheed-cli/src/main.rs
2026-09-11 19:23:13 +00:00
ed54f95d54 style: rustfmt sweep -- 774 hunks across 154 files -> 0
`cargo fmt --all -- --check` has failed on every run in this repository's
history, identically on `main` and on every branch. This is #12.

Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other
extension touched. `cargo check --workspace` exits 0 afterwards, so nothing
changed semantically.

ON THE ORDERING, WHICH WAS THE REAL QUESTION.

HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree
reformat before #7 and #8 return "would put a conflict in every file of 861
commits and make the reviews those items exist to enable unreadable".

That is measurably too pessimistic, and it had been reasoned rather than
tested. Measured here by three-way merging a rustfmt'd `main` against both
unmerged branches, file by file:

  file/branch pairs tested   32
  merges CLEAN               28
  merges CONFLICTING          4   (8 conflict hunks total)

    sylpheed-cli/src/main.rs      1 hunk
    sylpheed-export/src/check.rs  1
    sylpheed-export/src/screen.rs 4
    sylpheed-export/src/video.rs  2

All four are against `auto/frame-blend-draw-path` only;
`auto/port-p6-audio` does not conflict anywhere. The earlier framing --
154 dirty files, 133 that cannot collide, 21 that can, the collision set
carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say
is that most of the 21 still merge cleanly, because rustfmt's edits and the
branches' edits rarely land on the same lines.

So the cost of sweeping now is 4 files and 8 hunks for one branch, against
a check that is otherwise red forever. Deliberately NOT folded into the
WASM PR: 154 reformatted files would make that one unreviewable.

Closes #12
2026-09-08 20:07:01 +02:00
MechaCat02
e0bcef1ec2 fix(formats): collapse the one else { if }, so both toolchains agree
`ascii_runs` in movie_manifest.rs had a nested `else { if .. }`. Clippy's
`collapsible_else_if` flags it — on some toolchains.

WHY IT WAS NEVER CAUGHT, AND WHY THAT IS THE INTERESTING PART:

  clippy::collapsible-else-if   @1.92.0 (2025-12-08)  ->  warn
  clippy::collapsible-else-if   @1.98.1 (2026-09-01)  ->  allow

CI installs `dtolnay/rust-toolchain@stable`, which floats. The runner is on
1.98.1, where this lint is allow-by-default, so the Clippy job passed. A local
run on a nine-month-old stable failed on the same bytes. Neither instrument was
broken; "clippy clean" is simply a statement about a toolchain and a date, and
nothing in the pipeline records which. That is #15, and this commit does not fix
it -- it removes one instance of its consequence.

Fixing it rather than pinning, because `else { if }` is worse code on every
version. A tree whose cleanliness is contingent on a release date is the thing
to avoid; agreeing with both toolchains is cheaper than arguing about which one
is right.

⚠️ I FIRST DIAGNOSED THIS WRONG, and the wrong version is worth recording. From
CI's rustfmt reporting 774 hunks and mine reporting 774, I concluded the
toolchains matched, therefore the clippys matched, therefore CI's green must be
a cached or ungated result -- "the frozen splash again". Every step after the
first was false. rustfmt is deliberately output-stable within a style edition;
clippy explicitly moves lints between groups. Measured here afterwards:

  rustfmt 1.8.0-stable -> 774 hunks
  rustfmt 1.9.0-stable -> 774 hunks     (nine months apart, identical)

So formatting parity carries no information about which clippy ran. It is the
same error as reading protection off a settings page or reachability off a DNS
record: a property inferred from something ADJACENT to it. The peer refuted it
by measurement -- `No cache found` in the run log, and `success()` evaluating
'true' in 207 against 'false' in 203 -- rather than by accepting the framing.

Verified on both: 1.92.0 `clippy --workspace -- -D warnings` rc=0 (was 101);
1.98.1 with the lint forced on, 0 sites. rustfmt still 774, so no debt added to
#12. Tests 207 passed / 0 failed / 14 ignored, unchanged.
2026-09-06 10:17:55 +02:00
sylph-pi
658a93de9e fix: stop the lint pass adding rustfmt debt to #12
The lint work added 8 rustfmt hunks. Run 206's Formatting job reports 782
where run 204 reported 774, and the difference is mine — debt added to the
one issue the lint pass argued should not be disturbed. Measured against a
`4ac5c9f` worktree under the same rustfmt, the tree is back to 774: equal
to baseline, not merely close to it.

Two causes, both interactions rather than mistakes of judgement:

`cargo clippy --fix` writes its replacement on one line. Where the call
sat inside a multi-line method chain that produced

    .as_chunks::<2>().0.iter()

which rustfmt wants split across three lines. Seven sites, in
`formats/{audio,vfs,game_data}.rs` and `cli/main.rs`. An eighth was a
`for` header pushed past the width limit; it reads better as two bindings
than as a six-line chain, so that is what it became.

The last one is subtler and was the only file left over after the other
seven were fixed. `ship_capture.rs` had

    let mut flush = |base: u32,
                     size: u32,

and clippy correctly removed the `mut`. That shortens the line by four
characters, so the closure's six continuation parameters were then
aligned four columns too far right — a formatting change caused by a
change on a different line, in a file whose own hunk count is what
exposed it.

Worth recording because it generalises: `--fix` output is not rustfmt
output, and on a tree that is not rustfmt-clean the difference is
invisible in the diff and only shows up as a hunk count moving. The check
that catches it is a count against a same-toolchain baseline, not an
inspection of the patch.

Re-verified after the reformat, since these edits changed real lines:

    cargo clippy --workspace -- -D warnings   exit 0
    cargo test   --workspace                  exit 0   207 passed, 0 failed
    cargo fmt    --all -- --check             774 hunks == baseline

Refs #12, #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 22:51:18 +02:00
sylph-pi
cbb6c078a6 fix(formats): clear all 43 clippy lints in sylpheed-formats
Run 204 gave this repository its first clippy measurement — 48 errors, 43
of them in `sylpheed-formats`. This clears that 43 to zero under the exact
invocation CI runs, `cargo clippy -p sylpheed-formats -- -D warnings`.

Why this crate first, and why it is safe to touch:

Every one of the 43 sites was checked against the line ranges that
`auto/frame-blend-draw-path` (495 commits) and `auto/port-p6-audio` (366)
actually modify. None of them overlap. Eleven of the fifteen affected
files are byte-identical on both branches, including `mesh.rs` and
`texture.rs`, which carry 28 of the hits between them. The three sites in
`audio.rs`, `ui_layout.rs` and `slb.rs` that live in files those branches
do change fall outside every modified hunk. The collision argument that
defers #12 does not transfer here; it was tested rather than assumed.

It also unblocks a measurement. `-D warnings` turns a lint in this crate
into a hard compile error, so its dependents never build — `sylpheed-cli`
and `sylpheed-viewer` have never been linted at all, and viewer is the
largest crate in the workspace. Both depend only on `sylpheed-formats`
(`sylpheed-export` pins it from a git tag instead), so this commit is what
makes their real counts knowable.

  38  applied by `cargo clippy --fix` — chunks_exact_to_as_chunks,
      manual_div_ceil / is_multiple_of / range_contains, unnecessary_map_or,
      needless_borrow, let_and_return, dead_code, unused_mut/variables.
      Purely local expression rewrites: 38 insertions, 39 deletions.
   2  by hand: a doc continuation that markdown was parsing as a list, and
      `d / frame` behind a `frame > 0` guard becoming `checked_div`.
   3  `#[allow(clippy::too_many_arguments)]` with a stated reason.

On those three allows: 8 parameters against a threshold of 7, in the mesh
anchor path. The real fix is a shared params struct across
`anchor_pool_mesh`, `validate_block` and `validate_block_report` — the
latter two take the same eight arguments and one delegates to the other —
which is a change to the decoder's signatures and belongs to whoever owns
that path, not to a CI-lint pass.

This is not the shape PROTOCOL.md forbids. `continue-on-error` suppresses
everything, present and future, at the job level, and cannot tell "not
yet" from "no longer". A site-local `#[allow]` with a reason is a decision
recorded where it applies: one lint, one function, and any new violation
anywhere else still fails the build.

`sylpheed-export`'s remaining 5 are deliberately untouched — three of them
sit inside hunks both long-lived branches modify, and that crate blocks
nothing. Left for #13.

Refs #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 16:59:57 +02:00
sylph-decoder
fa27509937 formats: teach the reference renderer the additive blend, and re-open 8 claims
The blend bit has been decoded against RB_BLENDCONTROL0 since 2026-08-31,
but ui_layout::blit could not draw it, and said so in a comment citing a
refutation that is <render-vs-capture> -- this renderer disagreeing with
itself while it had a stale keyframe association, no leaf geometry and no
rotation.

The consequence the port raised: verify-screen compares two renderers, so a
renderer that structurally cannot express a declared field makes the check
incapable on every screen that uses it -- 12 of 16 -- and the tolerance
silently excuses all of them. A quiet check is worse than a failing one.

Both equations come off the game's own pixel shader, which premultiplies
(oC0 = rgb*A, A), so only the blend register differs: 0x07010701 gives
rgb*A + dst*(1-A), 0x01010101 gives rgb*A + dst. Additive therefore
saturates rather than wrapping, and a transparent or black source is the
identity -- neither is a choice.

No plumbing needed: t8ad::parse already stores +0x04 as T8adImage::flags.

Four controls, pinned against arithmetic per the rotation precedent. The
fourth is the only one that can fail for the right reason: the first three
pass just as well if blit ignores the flag and draws everything additive,
so the discriminator flips only the blend on one sprite and requires two
different answers, each equal to its own equation. That is the same failure
class as the port's non-inverting latch check and my own backward scan that
resolved every guard to "internal".

120 passed, 0 failed on the full lib suite.

67 sprites over 14 screens were being drawn with the wrong blend, including
10 of 18 on the title and ptbtn00f, the PRESS (A) plate's highlight.

R1: tools/stale-instrument render-vs-capture lists 8 claims that died to
this instrument, including both legs of the rest() pair and "the plate-free
title capture may be too early to be settled", which sits on play-test
finding 3. None is re-derived here; this only records that the instrument
no longer exists in that form.

Also corroborates the port's H5: pgloading_loop5 is an ELEMENT resolving to
sprite pgloading_ring.t32, which is additive. I could not find loop5 as a
sprite in any pak and nearly reported a false contradiction from the
element/sprite name split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
2026-09-01 19:03:53 +00:00
sylph-decoder
958eca6b6b formats: expose the T8aD blend bit on the public API, with a control
`ui-blend-mode-decoded.md` established the field but nothing on `Element`
reached it, so the exporter could only key a blend map by SCREEN NAME --
which asserted-by-omission that the Japanese menus blend differently from
the English ones.

Adds `sprite_header_word_04`, `header_word_04_by_name`,
`sprite_blend_additive` and `blend_additive_by_name`, plus
`examples/blend_api_check.rs`: the accessor the exporter will actually
call, checked against the same 35 oracle rows read out of the guest
command stream.

22/22 agree, 0 mismatched. The control requires entry 6 to report BOTH
values (additive=9, alpha-over=7), so an accessor stuck at one answer
fails rather than scoring 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
2026-09-01 18:18:47 +00:00
sylph-decoder
e5f1b7c2d4 formats: expose loop_length_units -- the port was reading +0x08 itself
sylpheed-port reports that a record's loop length is on no public ref at all
(example, test and docs/re/ only), so their screen.rs parses the four bytes with
its own RATC guard. That is my field to publish.

One function serves both levels, since a nested .rat leaf is itself a RATC bundle
with the same header shape. Returns None for a non-RATC or short slice so callers
need no guard of their own.

Verified against the disc, controls first: rejects a non-RATC slice, rejects one
too short for the field, reads big-endian at +0x08 -- then reproduces every
published value (ptbtn00f 120, ptloop01 600, ptloop02 720) over 65 GP_TITLE
records with 0 violations of +0x08 >= largest keyframe time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 20:54:16 +00:00
sylph-decoder
8d43e16c6f re: two fixed-code-under-unfixed-description hits in the crate the port pins
sylpheed-port named a pattern narrower than "docs go stale": a correct fix
sitting directly beneath a refuted description in the same file, within twenty
lines. Not drift -- editing at the point of failure without re-reading the frame
around it. Applied their grep (the vocabulary the OLD rule needed) to my crate
and found two.

ui_layout.rs:308, in rest_plateau's fallback: `continue; // the last frame
carries no time`. That is the pre-fix rule, on a branch that is now UNREACHABLE
-- measured at 0 untimed of 24 811 keyframes across 965 builds. Kept as a guard
because `time` is still Option<u32> and a malformed group could yield None, but
relabelled: it is no longer a description of the format.

ui_layout.rs:268, on the `lastall` rest override: "This is what the shifted time
reading predicts ... testing it against the captures is an independent check on
that reading." The shifted reading was refuted by the record-layout fix in the
same file. The override survives as a plain "take the last keyframe" diagnostic
alongside the documented `last` and `maxalpha`, and now says so.

Both corrections quote the original sentence so the change is visible rather than
silently overwritten -- the practice the port adopted from me this iteration.

Verified by artifact rather than by "it compiles": a comment-only edit must leave
output byte-identical, and the build-7 render's md5 is unchanged at
141771d8f1a2b3496cfd679c6cd45d1a.

METHOD records the pattern with the two greps that find it: the vocabulary of the
dead rule in code, and a HEDGE around something the current reader states exactly
in prose -- a "~0" marks where the old reader could not see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:57:30 +00:00
sylph-decoder
3dbfa320ae formats: drop the 1.5 MB cap that truncated 17 voice regions' first stream
The cause, and the fix, with a disc-wide check.

resolve_movie_voice_region picks start = the predecessor cue's trailer, then
filtered it with 'end - s < 1_500_000' -- 'only within one bank'. ADV's
predecessor sits 3 618 816 B before end, so the filter rejected it and start fell
back to anchor, which is a TOC offset and not a stream boundary. That explains
the shape of the defect exactly: it strikes regions larger than 1.5 MB, which is
why the three-stream multichannel regions are hit and single-stream ones never
are. 17 of 95 resolving movies took the fallback.

ADV's predecessor trailer at 433 425 776 plus 17 040 B of descriptor and padding
is 433 442 816 -- the -238-packet start measured against the decoder, to the byte.

Dropping the cap: unchanged 78, fixed cleanly 17, changed in any other way ZERO.
In all 17 the only difference is a larger first chunk with every later chunk
byte-identical, which is what a corrected start looks like and what pulling in a
neighbouring asset does not.

Regression test pinned to the RUNNING DECODER's byte_sizes rather than to this
crate's own output. That is the point of it: every internal check passed happily
while a third of a stream was missing, so only an external number could have
caught this class of bug.

sylpheed-formats: 136 tests pass, 0 fail (the one still running at commit time is
an unrelated long mesh test).

Exact clips for the other 16 are not independently verified -- the sweep is
strong but ADV is the only one with a decoder measurement behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:55:47 +00:00
sylph-decoder
159778faf1 formats: test backdrop coverage per instant, and record where the .tbm hunt reached
Two things, neither of which moves a verdict.

The port pointed out that rejecting on DECLARED size replaces one error with its
mirror: an element scaled ABOVE 100% could cover the screen from a smaller
declared size, and my guard would silently exclude it. Checked against the disc
first: across 921 keyless elements, ZERO cover the screen only via scale, so the
mirror case does not occur here. Adopted anyway, because the construction does
not need that to stay true -- coverage is now tested per instant against the
scaled size, alongside the opacity test, since both animate on the same ramp.
80 forced instances before and after, split 42 .prm / 38 .tbm, unchanged.

Second: an attempt to upgrade the 38 .tbm verdicts from inferred to decoded by
finding the texture and measuring its alpha coverage. It cannot be located. Not
in its bundle (no RATC record, no sprite-table entry, for any of the 13 names);
not a file (no .tbm anywhere on the disc); not a pak entry (its archive's hashed
TOC contains none of the name, its uppercase form, its stem, .t32/.tga/.xpr
variants, or ui\\ and tex\\ prefixes, across four archives); and not visible in
our composite, since compose skips an element with no resolvable sprite, so we
draw no pixels for a .tbm at all -- and no committed capture covers a screen
that has one.

So a second reading survives and is recorded rather than excluded: a .tbm may
contribute no pixels, in which case its paint position is INERT rather than
correct. That leaves the 38 harmless instead of right -- a different claim with
the same consequence. Distinguishing them needs a capture of GP_SAVE_LOAD,
GP_BUNK or GP_DEBRIEFING_PILOTLOG, all behind the A fault.

One upgrade: pfbase.tbm's first position is MEASURED, not inferred -- it is
element 0 of the save/load frame and the order read off the running game starts
[0, 1, 2, ...]. Twelve of the thirteen .tbm names still rest on the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 22:04:31 +00:00
sylph-decoder
29c99e1b3e re: primitive blend mode -- undecodable with reach, and no longer a risk
Looked in four places. The bundle has no field: a primitive has no RATC child at
all and the declaration words are constant across every element of three measured
screens -- the two grounds ui-prm-primitives.md already used to refute a
bundle-side LAYER key, and they apply identically to blend.

The colour census: every full-screen *eff00* primitive on the disc carries pure
black at its various alphas, and the only non-black primitive anywhere is
pbafc.prm, RGB 00e8e0 cyan.

The occlusion constraint cannot reach that one. pbafc.prm looked alarming at a
declared 844x600 and alpha ff; it is a small moving glint. It strobes between
alpha 255 and 124 every 2 units, travels from x=178 to x=291, and is scaled
2%x3%, so it draws about 17x18 pixels. At that size it occludes essentially
nothing.

The oracle is unavailable: GP_READY_ROOM is a recorded no-go and gameplay needs
the A press that faults the guest in this container.

But the consequence closes even though the question does not. For a BLACK quad
-- which is every primitive forced_backdrop touches -- the hypotheses differ only
in whether it hides what is beneath. Drawn first it is correct under both; drawn
last it is correct only under additive. So the rule's verdict is robust to the
open question, and the port's original "layerless sorts last" was wrong under
alpha-over and merely pointless under additive. This is explicitly NOT evidence
for alpha-over.

The investigation also found forced_backdrop judging coverage from the pivot
alone, ignoring scale -- pbafc.prm is the disc's own proof that a nominally
844x600 element can draw at 2%. Checked before changing anything: all 80 forced
instances are at scale 100% on every opaque instant, so no verdict moved. The
guard now requires scale >= 100 at the instants it counts as opaque. Defensive,
not a fix. 4 + 13 disc tests green either side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:58:25 +00:00
sylph-decoder
cfcda5501c formats: a keyless primitive that would hide the screen is forced to paint first
Partly closes ui-prm-primitives.md's standing blocker, "where an UNMEASURED
primitive paints". Raised by the port: build_12/build_15 composite to solid
black at every instant of their declared life, because pgloading_eff00.prm -- a
full-screen opaque quad -- sorts last.

The rule is a constraint read off the file, not a preference: an element that
covers the screen and is fully opaque at some instant cannot paint above
anything visible at that instant. Where the elements visible during its opaque
span are ALL of them, its position is forced to first.

pgloading_eff00.prm is opaque for 39 instants and all 9 other elements are
visible inside that span -> forced first, 4/4 instances.

Two controls, both measured orders from the running game, and the rule has to
survive both:

  * palogo_eff0.prm is measured painting FIRST -- opaque 211 instants, forced
    below 6 of 6. It is NAMED like an overlay, so a name-based rule sorts it
    wrong against a measured order. Occlusion gets it right.
  * pteff00.prm is measured painting LAST -- opaque for 2 instants at its
    screen's entry and exit, forced below only 3 of 23, so the constraint
    permits it on top where it belongs.

Disc-wide: 80 instances forced first, 50 constrained but not forced, 0
unconstrained. The split runs almost exactly along the names -- every *base* is
forced, every *eff00* is not -- with three families crossing it, which is
exactly why the name is not the rule.

It also explains 36 builds the corpus had recorded as "coming out one colour"
with no cause: pzeff00.prm is forced first in 32 of 32 instances, so they were
wiped by our own sort rather than by the game.

The rule's real limit was found by its own disc-wide test failing. Applied to
any element it claimed 22 .t32 SPRITES must sort first against their own layer
keys -- pneff01.t32 (key 0xd850, #8 of 13) and pbfriendly.t32 (0x9230, #17 of
49). A sprite's ELEMENT alpha says nothing about whether its TEXTURE covers the
screen, so forced_backdrop is now restricted to untextured primitives, which is
also the only case derived_paint_order consults it for.

Reach stated: assumes straight alpha-over (blend mode is still open, and an
additive quad at alpha 255 would not occlude); it is a lower bound, not an
ordering; and there is no new oracle measurement -- both controls are prior
ones, and a loading screen is not reachable from the title path.

3 new disc tests; the 13 paint-order tests are green, including
the_derived_order_matches_the_measured_ones_up_to_ties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:02:17 +00:00
sylph-decoder
d0735d2c25 formats: a settled screen is one instant, not one hold per element
`Element::rest()` picks each element's last hold keyframe independently of
every other element, so a composite built from it is not the screen at any
moment in time -- it is a per-element maximum. For a transient that is
exactly wrong: a two-frame flash's last hold IS the flash peak, so it burns
forever.

GP_TITLE build 4 is the case. `ptlogo_back2eff1`..`eff5` are five staggered
two-frame flashes -- one light sweep drawn as five frames, all extinguished
by t110 -- that `rest()` draws simultaneously and permanently. Five stacked
white glows saturate the light arc behind the logo.

The disc names the right instant: the midpoint of the longest interval
containing no keyframe of any element. `UiBuild::settle_time()` and
`settle_window()`; `screen render --settle` applies it and prints the window,
whose width is how much the midpoint is worth.

Predicted t=198 from [160,236] BEFORE scoring. Against the console capture,
the arc band goes 33.22 -> 11.79 and pixels at the clipping level 8581 ->
1452, where the console has 1459 -- an unfitted statistic. Whole frame
14.07 -> 12.06. Controls at t=100 and t=358 are far worse, and a hand-picked
visibility list reaches the identical numbers.

`ComposeOptions::at` now poses every element rather than leaves only, which
is why the earlier rotation pose scan was flat: it moved the sweeps and never
touched the top-level flashes. `at = None` is byte-identical (cmp), the
pre-rotation tag renders identically at rest, and the 13 paint-order tests
plus the keyframe/focus/opt-link disc tests are green.

Also fixes the diagnostic that caused a wrong finding to be sent to the port
agent: `not drawn` listed bare names, and a kind-0x4 ghost carries its
template's name, so four ghosts printed as `ptlogo1.t32`/`ptlogo2.t32` and
read as "the logo is missing". It now prints index, name and reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:28:27 +00:00
sylph-decoder
37af2055e3 formats: a scale-0 leaf must not claim the draw and blank its parent
Found by inspection while the disc tests ran. The leaf branch set its
something-was-drawn flag unconditionally after calling blit, but blit returns
early on a zero scale -- collapsed to nothing, not unset. So a scale-0 leaf would
have been counted as drawn, its parent skipped, and the element blanked outright.

pgloading_loop5 s leaf is scale (0,0), so this was live on all four loading
screens, and scale-0 is one of the failures this corpus is already named for.
Fixed by skipping a zero-scale leaf pose before it can claim the draw; the
loading builds render afterwards at 4.0 percent non-black.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:04:03 +00:00
sylph-decoder
81f42f9041 formats: teach the renderer to rotate (Option A) -- and report that it does not close the title
The human chose Option A: teach sylpheed-formats own renderer to draw
rotation_deg so it and the port stay comparable and verify-screen keeps meaning
someone is wrong.

Three pieces, because rotation alone does nothing on the title. blit gains a
rotated path that draws by inverse mapping over the rotated bounding box, turning
about the pivot, whose absolute position is invariant under scale; zero rotation
keeps the original forward-mapped path byte for byte so non-rotating screens
cannot regress. compose draws a nested .rat leaf when the leaf carries geometry
the parent does not, which is the sweeps case, but not as a blanket rule since a
button s leaf duplicates its parent. And --at poses leaves at a keyframe time,
because the sweeps hold off-screen at x=1521 so a resting composite omits them.

A trap found the hard way: posing EVERYTHING at one global time is wrong, because
a top-level group s final keyframes are its exit ramp and rest() deliberately
stops before them. Posing the title at t=358 walked every parent into its exit
and drove the disagreement from 10.92 to 61.74. So at poses leaves only.

Controls: 0 and 360 degrees byte-identical to the unrotated path, 90 degrees
swaps a 10x4 to 4x10, area conserved within 15 percent, centroid stays on the
pivot. 116 lib tests pass, main_menu unchanged at 9.26.

And the verification did not show what it was meant to, which is reported rather
than buried: scanning the pose time against the title capture gives 10.73 to
11.17 against a 10.92 baseline -- flat, no minimum, best 1.7 percent. The
whole-frame mean is dominated by the tone curve, and the renderer still does not
draw ptlogo1/ptlogo2 at all, which is a far larger spatial gap than two
translucent sweeps. So rotation is correct in isolation and no screen regressed,
but whether it closes the port s 1.81 percent is not established here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:59:26 +00:00
sylph-decoder
a98a66190e formats: XMA1 is not a WAVEFORMATEX -- audio info was reading three wrong fields
parse_riff_wave read every fmt chunk as a WAVEFORMATEX. XMA1 (tag 0x0165) is
not one, so audio info reported the disc s movie voices as 16 channels,
4310 Hz, 2-bit: 16 is wBitsPerSample read as a channel count and 4310 is
wEncodeOptions (0x10d6) read as a sample rate. This misled me earlier in the
session and I recorded it as a limitation before finding the cause.

XMA1 carries XMAWAVEFORMAT followed by one XMASTREAMFORMAT per stream. The
reader now branches on the tag and reads bits at +2, PsuedoBytesPerSec at +12,
SampleRate at +16 and Channels at +29. The same three files now report 2
channels, 48000 Hz, 16-bit.

The consequence worth having: this crate has no XMA decoder, and
data_bytes / PsuedoBytesPerSec is the only route to a duration. Checked against
durations decoded independently by the port:

  ADV presentation 1   137.34 s declared   137.324 s decoded   +0.012 percent
  ADV presentation 2   137.33 s declared   137.324 s decoded   +0.004 percent
  S00A presentation 1   93.71 s declared    93.694 s decoded   +0.017 percent

So the corpus can now get XMA1 durations off the disc without a decoder, which
is a capability I had written down as absent. It is a declared rate rather than
a measurement of the samples, and the CLI labels it as such.

Regression test pins the real on-disc header bytes and asserts the duration
against the independently decoded 137.324 s. 115 lib tests and 3 media disc
tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:42:52 +00:00
sylph-decoder
884822ebe3 re: the paint-order tie-break costs the port zero pixels, not 24 pairs
The census bounded WHERE a wrong tie-break could show and said outright that
nobody had measured how many of those pairs change a pixel. Measured.

`compose_with_order` renders a bundle in a caller-supplied order; the new
example renders each screen twice, once derived and once with one tied pair
swapped, and diffs. Same-key elements are contiguous under a stable sort on
(key, i), so a swap paints nothing else in between.

Controlled per entry: swapping an OVERLAPPING pair with DIFFERENT keys moves
36 305 to 771 479 px (max delta 254). Where no such pair is drawn the output
says so rather than reporting an uninterpretable zero.

* EXTRAS (entries 6/9) and the main menu (5/8): 0 px. The tied ptframe pairs
  ink ~3 600 px each and share NONE of them -- the 102x132 rect overlap was an
  artefact of approximating an element as pivot x 2. Blend-independent.
* Across all 31 drawable overlapping tied pairs in GP_TITLE, the largest change
  any of them makes to any channel is 3/255.
* Withdrawn: "a wrong tie-break can be wrong by a whole layer". That rested on
  ptlogo_back2eff5 geometrically containing two other glows. Rendered, the swap
  moves 6 390 px by max delta 2. Containment is not occlusion when the container
  is a near-transparent glow, and nobody had rendered it before asserting it.

Reach: this measures our compositor's sensitivity to order, not the game's. The
zero-shared-ink results hold under any per-pixel blend; the delta<=3 figures
assume ours.

13 disc-gated ui_paint_order_disc tests and 114 lib tests pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 14:47:19 +00:00
sylph-decoder
5744f379b2 formats: a keyframe's time comes before its pose, and none of them was missing
The placement region is `frames` records of `{u32 time; 36-byte pose}` after an
8-byte header, so the time word PRECEDES the pose it belongs to. Our parser's
40-byte window opened at the pose, four bytes into the record, and then read the
word at its `+36` as that pose's time -- which is the NEXT pose's. Every pose
field was right; only the time association slipped by one.

Two things the corpus has carried for weeks are that off-by-one and nothing
else: "a group's data stops 4 bytes short of its final block's time slot", and
"the last keyframe carries no time". The group is not short (8 + frames*40 is
exact) and no time is missing -- the first pose's time is the lead-in word at
`header + 8` that `parse_placements` skipped without asking what it was.

Disc-wide, 33 archives, 13 991 groups, each test with a control:

  A  lead-in prepended to the shifted times is non-decreasing  13991/13991
  B  a non-zero lead-in is strictly below the next time         5058/5058
     control (another group's lead-in, same bundle)            70.9%
  C  multi-segment alpha ramp at a constant rate, corrected    857/1540
     the same, under the old reading                             0/1042

C is the one that cannot be argued with: interpolation between keyframes is
linear, and under the old reading not one multi-keyframe ramp on the disc comes
out at a constant rate.

Adoption is free on every static composite, which is what the corpus previously
declined it over. `SYLPHEED_KF_TIME_SHIFT=1` moved GP_TITLE build 7 by 13.1% of
its pixels because it left pose 0 untimed; with the lead-in restored, all 12
GP_TITLE builds render byte-identically, and across 217 builds in six archives
only two elements pick a different rest pose -- both times between two poses
that are equally invisible.

`SYLPHEED_KF_TIME_SHIFT` is gone; `SYLPHEED_KF_TIME_LEGACY=1` restores the old
reading for A/B work.

ui_header_time_disc needed one line: 546 bundles whose every group is a single
static pose now report max_time = 0 where they previously reported no time at
all. Excluding them, the result it guards strengthened -- the bound holds over
2 859 bundles instead of 2 313, still with zero violations.

Not established: the executable's own parser. Reach is written down.

docs/re/ui-keyframe-record-layout.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
2026-08-29 14:01:39 +00:00
sylph-decoder
f34d24941d formats: a music bank's third sub-wave was its own header
The port hit `sound_bank_riffs("BGM_103.slb")` returning three against a census
that says two, and refused to guess which to drop. It was our reader.

`to_xma_riffs`'s hybrid branch derives a leading packet stream's start as
`first_riff % XMA1_PACKET`. That is right only when the bank header is smaller
than one 2048-byte packet -- true of the voice banks the branch was written for
(1392/1468/1600/1728), false of a music bank, whose header is exactly five
packets. The modulus returned 0 and the whole 10 240-byte header was emitted as
sub-wave 0.

The header states its own length, so the guard needs no threshold: BE u32 0x800
at +0x18 with the bank id repeated at +0x00 and +0x20, header length in blocks at
+0x24. Disc-wide over sound.pak's 9 519 entries, 28 match at offset 0 -- every
music bank, ids 1001-1023 and 1101-1105 -- and on 28/28 the declared header ends
EXACTLY at the first RIFF. Zero have a gap, so a header and a leading packet
stream never coexist here; zero false positives among the other 9 491.

Controlled rather than argued: decoding the emitted region through the same
chain, on the same bank, in the same run gives 0.009 s of PCM where the bank's
real wave 0 gives 87.744 s against a declared 87.75. The region is also 99.1%
zero bytes. And the oracle had already said two -- the XMA probe at the main menu
saw exactly two streams, at BGM_103's two declared wave sizes.

BGM_106-109 are deliberately NOT in the 28: their entries start mid-bank, so they
have no header at offset 0 and their leading region is real audio. The
VOICE_D_453 recovery is untouched and its tests still pass, 10/10 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:30:16 +00:00
Sylpheed RE agent
76653ca41b re(ui): the focus ring SPINS, the game draws it, and the leaf owns the f record
Three things, all from parsing ptbtn0Nf.rat as a build.

1. THE RING SPINS. Its two keyframes differ in exactly one field: rotation_deg
ramps 0 -> 360 with position, scale, alpha and tint all constant. A spin in
place, the same shape as the GP_BUNK example already recorded.

2. THE ORACLE CONFIRMS THE GAME RENDERS IT. In the OPTIONS-focused capture the
ring's bright head sits in a completely different angular position from the
sprite's own -- caught mid-spin. This is a SECOND independent confirmation that
rotation_deg is drawn, now on a different screen and a different element from the
ptloop sweeps, and it raises rotation's priority: it is not a title-only concern
that sits off-screen at rest, it is the main menu's focus marker.
NO ANGLE IS QUOTED. A brightest-region centroid says ~250 deg, but the control
refuses that precision -- rotating the sprite by a known 30/90/180/270 and
re-measuring gives errors up to 19.8 deg. What survives the error bar is that a
<=20 deg error cannot manufacture a ~250 deg displacement.

3. WHICH PLACEMENT WINS -- correcting this page's own earlier caveat, which said
to use the leaf only for elements the parent does not declare. Right for a BASE
record, wrong for an f record: the parent declares NO element for ptbtn0Nf.rat at
all (zero of build 5's 16), so the f record's placement comes from its leaf for
BOTH elements, label included. The label's (-7,-7) is load-bearing -- the f
sprite is 13px larger per axis and -7 keeps them concentric (535+96/2 = 583 vs
542+83/2 = 583.5). Corroborated against the oracle: the focused-minus-unfocused
region is x 505..703, and the leaf predicts a right edge near 707 where the
parent reading predicts 714.

Also exposes UiBuild::records (name -> (offset, size) of a nested .rat leaf).
Nested records were parsed into a PRIVATE map, so a consumer holding a UiBuild
could not locate a leaf's bytes at all -- which is exactly what blocked the port
from reaching the ring.
2026-08-29 08:46:53 +00:00
Sylpheed RE agent
6779d9c807 media: expose se_wave_riff -- the menu's SE cues, assembled where the format lives
The port is forbidden from reimplementing media assembly and Static.slb is
exactly that case: no RIFF, no seek chunk, no XACT container, just a packed run
of whole 2048-byte XMA1 packets, so a wave is defined only by (offset, packet
count) and the header has to be synthesized. That step now happens once, in the
crate that owns the format, instead of in each consumer.

`slb::xma1_wave_riff` wraps raw packets; `media::se_wave_riff` looks the bank up
and reads just the packets asked for. Both reuse the existing synth_xma1_fmt /
build_riff, which are already byte-identical to what tools/re-capture/
slb_extract_wave.py writes -- so this is exposure, not a second implementation.

It reads a TARGETED range rather than the whole bank, and that is load-bearing:
Static.slb is the ONE entry of sound.pak's 9 519 whose declared extent runs past
the end of the extracted segments -- by exactly 616 768 B -- so reading it whole
fails outright on this extraction. Every cue we need is in the first few hundred
KB. Recorded rather than worked around silently.

Verified as an artifact, not a compile: all three cues decode through ffmpeg to
mono 48 kHz PCM at 0.533 / 0.344 / 1.016 s, non-silent (rms 2085 / 2985 / 4327,
peaks 29813 / 16973 / 32767). The refusal path is exercised in the same run --
an impossible packet count is rejected rather than returning a short stream,
because a truncated XMA decodes to plausible-sounding garbage.

Also adds docs/re/captures/ORACLE-CAPTURES.md: an index of the nine canary
framebuffer captures already in this repo, and a plain statement that THEY are
the reference and `screen render` is not.
2026-08-29 08:41:42 +00:00
Sylpheed RE agent
1a82ade187 re(ui): the 60 nameless RATC children are frames, not children -- .tan decoded
Closes the reach caveat the `opt ` name fix left behind: 60 of 18 002 RATC
children carry no `opt ` block, and it was not established whether they lack one
or sit past our 128-byte window.

Neither. They are not children. `examples/ratc_optless_children.rs` re-runs
`ratc::parse`'s own guards over the disc and reports which one fired: all 60 are
"tag beyond the window", none is rejected by length, gap or charset, none is
child #0, and all 60 live in six bundles of one archive. Within a bundle the
distances back to the nearest tag are an exact arithmetic progression, step
60 600 -- ten different records finding the SAME tag, because there is only one.

Reading a bundle directly: children 1..10 are equal-size T8aD blocks under a
single `opt ` name, `pb_f15_eg_anm.tan`. `.tan` is a FRAME SEQUENCE. One block
declares the resource; its payload is a run of T8aD frames.

Disc-wide, over all 18 718 `opt ` names in all 33 paks: a RATC bundle names
exactly six kinds of resource -- `.t32` 14 756, `.rat` 3 311, `.prm` 367,
`.tbm` 224, `.sbo` 54, `.tan` 6. Six `.tan`, ten frames each = 60, the entire
population with nothing left over. The negative is closed, not narrowed.

Consequence recorded but deliberately not fixed: `ratc::parse` over-reports
there, listing a `.tan`'s frames as anonymous children. Nothing in the menu
milestone reads a `.tan` -- it occurs only in GP_READY_ROOM, which S1 ruled
out -- so no screen the port draws changes.

Also a METHOD entry for this container OOM-killing `slb_leading_segment_disc`
under default test parallelism (SIGKILL, no assertion; 8/8 pass with
--test-threads=1).
2026-08-29 07:39:09 +00:00
Sylpheed RE agent
0ed33bcd38 re(ui): a RATC child's name is stated, not inferred -- and it was hiding every menu background
`ratc::parse` named each child by scanning backwards for the last printable run
of bytes before its magic. The format states the name explicitly instead, in an
`opt ` block: `"opt " | BE32 len | name | NUL | 3 bytes | magic` -- the same
block `ui_layout::opt_link` already read for a button's focus link.

The scan agrees with it 17 918 times out of 17 942 and is wrong 24 times, every
one the same failure: the 3 trailing payload bytes are themselves printable and
beat the real name. For `pteff05.t32` those bytes are `38 41 58` = `8AX`, so the
full-resolution background of all five menu screens registered under a name no
element declares, resolved to no sprite, and `compose` dropped it through an
early `continue` that -- unlike the two arms above it -- records nothing. The
screen lost its background and `screen render` still reported "all resolved".

`8AX` was never a name. Docs that treated it as one are corrected here.

Disc-wide, and the control is the 17 918 the scan already got right: the `opt `
reading reproduces every one of them. Effect on the five screens is the
signature of the same art at twice the resolution -- mean brightness unmoved,
high-frequency detail x1.15..x1.30 -- which is what the separately-measured
`ui-8ax-fullres-background` result said the game draws.

Also closes a long-standing dangling reference: `pmbase.t32`, recorded as "on
the disc nowhere", is the `GP_STAGE_CLEAR` child the scan called `8AX`. RATC
sibling references now resolve 10 148 of 10 148.

Verified: 114/114 sylpheed-formats unit tests (including two new ones pinning
the `8AX` case byte for byte and the no-block fallback), and every disc-gated
integration suite in sylpheed-formats/sylpheed-cli.
2026-08-29 07:26:56 +00:00
Sylpheed RE agent
e63efabcfb re(ui): why rest_plateau is right -- and last is right only for a transient
The shifted keyframe-time reading looked like it implied something
simple: the final pose is reached at a definite time and nothing follows,
so rest should just be the last keyframe and the plateau heuristic could
go. Tested by applying it to EVERY element:

  title              +0.9500 -> +0.6819   -0.2681
  main menu          +0.9460 -> +0.6416   -0.3044
  EXTRAS             +0.9440 -> +0.5745   -0.3695
  publisher splash   +0.9600 -> blank (zero variance, corr undefined)
  developer splash   +0.9643 -> blank

Refuted, and the failure supplies the model. A group is entry -> hold ->
exit, and the exit is the screen's DISMISSAL. While a screen is displayed
it has not reached its last keyframe; it is sitting at the hold. So
rest_plateau is the correct primary rule, and the last keyframe is the
post-exit state -- correct only once the screen is gone, which is why
applying it everywhere blanks the splashes.

This does not contradict the shifted reading. That reading says when each
pose is reached; it says nothing about the group being played to
completion while the screen is still up. The step between them was mine.

And it explains why last wins for the two plateau-less elements: an
element with no hold is a transient, it flashes and is over, and at any
settled moment it is gone -- which is its last keyframe. The draw capture
says the same independently: on the developer splash the _eff glows draw
on frames 94-115 and the logos on 116-211, so the glows are already
finished when the logos are up.

Three independent observables -- animation timing, static composites, and
the per-frame draw log -- now agree on one rule: plateau where there is
one, last keyframe where there is not.

METHOD: a blank render is a NaN correlation, not a low score, and that
NaN was the strongest form of the result; and when a model predicts
something the measurement refuses, suspect the step you supplied between
them.
2026-08-29 04:28:19 +00:00
Sylpheed RE agent
7347db24df re(ui): the rest fallback fires on 2 elements, and "last keyframe" wins there
Scored candidate rest-pose rules by rendering and correlating instead of
arguing, and both results correct something I had published.

First, the exposure. The guessing fallback is reached only by an element
that is plateau-less AND multi-keyframe -- a single-keyframe element
short-circuits at `match len { 1 => first }`. Per screen:

  title (4)               24 elements   2 plateau-less   0 reach the fallback
  main menu (5)           16            5                0
  EXTRAS (6)              18            5                0
  publisher splash (10)    3            2                1
  developer splash (11)    7            2                1

So on the three screens the port cares most about, rest() never guesses.
That is why three different rules render builds 4/5/6 to identical
correlations -- the code is unreachable there, which I nearly read as
"the choice does not matter".

Second, where it does fire, the last keyframe is markedly better:

  publisher splash   dwell +0.9600   last +0.9982   maxalpha +0.9600
  developer splash   dwell +0.9643   last +0.9758   maxalpha +0.9643

That refutes my own earlier refutation. I had killed the last-keyframe
rule by arguing it makes palogo_anima_eff invisible while its two
siblings stay lit, which looked like an artefact. The capture says
otherwise: making it invisible is what improves the match. The sibling
symmetry was my expectation, not evidence.

Caveat kept in front: both captures are single frames of a transient
animation, so this fixes which pose matches THOSE frames, not which is
canonically at rest. Default unchanged -- better on both screens where it
fires and identical on the other three, but it would move 2 305 elements
disc-wide on two measurements. Reachable via SYLPHEED_REST_RULE=last.

Also confirmed: all 195 zero-scale rest poses are inside the corrected
2 305 ambiguous population; none is a single-keyframe element.

METHOD: score a rule where it can differ, or you measure nothing; and an
argument from symmetry is a prediction, not a refutation.
2026-08-29 04:20:27 +00:00
Sylpheed RE agent
af43563806 re(ui): measure the paint-order hedge -- exact on 4 of 5, and bound the rest
`compose` claimed the derived paint order "reproduces both measured
orders up to ties". That sentence was never measured and was stale by
one: there are three measured orders, not two. examples/paint_order_audit.rs
checks it.

  main menu (entries 5, 8)      derived == measured   0 inverted pairs
  developer splash (11, 14)     derived == measured   0 inverted pairs
  title (entry 4)               DIFFERS               8, all same-key ties

So the claim holds and the exception is entirely ties -- but two of those
ties are total occlusions, not near-misses. The tied family is the five
ptlogo_back2eff glows (key 32899); back2eff5 is 1133x280 and FULLY
CONTAINS back2eff3 (82,824 px^2 = 100% of the smaller) and back2eff4
(152,047 px^2 = 100%). Derived paints it on top of two glows it entirely
covers; the game paints it underneath. A tie-break by declaration index
can therefore be wrong by a whole layer. The title itself is unaffected --
it has a measured order.

The port's actual exposure, per screen: title, main menu and developer
splash all use MEASURED orders; the publisher splash is derived but has
ZERO ties, so it is fully determined; EXTRAS is derived with 15 tied
pairs of which only 2 OVERLAP. Two element pairs on one screen is the
whole risk, and that is what HANDOFF now says -- not the raw 15, which
would have overstated it 7x.

Reach stated: this compares the derived order against orders measured
from the game, not an independent derivation, so where no measured order
exists only the tie exposure can be checked. Overlap uses pivot*2 as the
element size at its resting placement.

Stale comment in compose corrected. METHOD: a hedge in a code comment is
an unmeasured claim; and count the cases that can bite, not the ones that
match the pattern.
2026-08-29 02:22:01 +00:00
Sylpheed RE agent
d6a24f40f7 re(ui): scale 0 means collapsed, not "unset" -- stop drawing it full size
blit() and fill_quad() both opened with

    let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };

so an element whose pose is collapsed to nothing rendered at FULL SIZE.
I first described this as "a 1-pixel sliver", reading the .max(1) in the
size arithmetic two lines below -- the guard above it meant .max(1) never
saw a zero. Read the whole function, not the lines you went looking for.

Control run before the change: if 0 meant "unset", something on the disc
would use it throughout. Nothing does.

  elements with a keyframe group                    15 493
  at least one keyframe at scale 0                   2 166
  EVERY keyframe at scale 0                              0
  zero is a transient the element grows out of       1 762

ptlogo_eff3.t32 runs 0% -> 200%. An "unset" marker no element ever uses
throughout is not a marker.

Both functions now return without drawing when either scale is 0.

Reach: 24 of 24 renders byte-identical across GP_TITLE (all 16 builds),
GP_PAUSE_MENU and GP_OPTIONS -- additive on every screen the port needs.
195 elements have a guessed rest pose at scale 0 and 126 of those have a
non-zero alpha, so the old code painted them; all 126 are in
GP_READY_ROOM.pak, which S1 already declared a no-go. They are
tactical-map sprites at sx=0/sy=100, mid-horizontal-wipe, that the
coercion drew full-width.

So: a real correctness fix with no visible effect on the menu port.
Suite green, 122 passed / 0 failed across 3 suites at commit time.
2026-08-28 23:38:37 +00:00
Sylpheed RE agent
d18a99a055 re(ui): the keyframe-time shift is favoured 26x by timing, rejected by a render
Follow-up on last iteration's unadopted candidate (+36 holds the NEXT
pose's time, not its own). Two new results, pointing opposite ways, and
both are reported.

FOR, and calibration-free: the observed full-alpha hold : fade-out ratio
on palogo_gamearts is 83 : 13 frames = 6.38. The shifted reading predicts
8.00. The current reading predicts 0.25 -- off by 26x. With the glow's 2
units/frame fixed and nothing else free, the current reading says the
logo holds full alpha for 2.0 frames; the capture holds it for 83. This
is no longer the shape argument the candidate rested on.

Also for: rest()'s plain max-dwell fallback picks a=0 -- a transparent
pose, for a publisher logo -- under the current reading, and the visible
a=255 hold under the shift. Only the rest_plateau special case rescues
the render today, and that is the case the port agent reported a bug in.

AGAINST: rendering every build of six UI paks under both readings, 10 of
11 compared are byte-identical and one changes -- GP_TITLE build 7, the
Japanese twin of build 4, by 13.1% of pixels. Build 4, the one verified
against a live capture, is unchanged either way, so the single build the
shift moves is the one with no capture to adjudicate it. The proxy goes
against the shift: language twins are the same artwork, and build 7 reads
70.94 mean luminance as decoded against build 4's 71.41, but 76.32
shifted. Correlation does not separate them (0.6206 vs 0.6201).

These constrain different things -- timing versus pose selection -- and
rest() is a heuristic layered on the times, so moving the times moves its
tie-breaks. Adopting the shift means revisiting that heuristic in the
same change, with no build-7 capture to verify against.

Default UNCHANGED. Experiment reachable via SYLPHEED_KF_TIME_SHIFT=1.
Shifted-mode suite: 122 passed, 0 failed across 3 suites at commit time.
2026-08-28 23:03:55 +00:00
Sylpheed RE agent
ffd1a30ba7 re(ui): decode keyframe +12 as screen-plane rotation in degrees
The rotated quads on the title screen come from the keyframe block after
all. The earlier negative -- "every GP_TITLE build 4 element has all three
angle words at zero" -- read the right bytes over too small a region: it
walked the top-level declaration table, and the rotated elements are the
nested leaf records ptloop01.rat / ptloop02.rat.

Confirmed against the framebuffer rather than against our own renderer.
The two records declare +12 = 30 and -45; the GPU capture submits their
quads at +30.26 and -45.28 degrees -- magnitude and sign, two different
values. Corroborated by shape in GP_BUNK 117ca14f, where +12 ramps
0 -> 360 with position, scale and alpha constant: a spin in place.

Identifying which draw it was needed edge lengths, not bounding boxes:
400x1076 and 400x1444 against pteff03/pteff03a 399x180 at the elements'
two different declared scales, 600% (1080) and 800% (1440). The same test
names three known-positives in the capture (ptlogo1, ptcopyright,
ptbtn00), so it passes its own control.

Keyframe gains rotation_deg plus unknown_4/unknown_8, carried rather than
dropped. NOT rendered -- ui_layout::blit is axis-aligned only, so the
reference renderer and the port will both draw these upright until a
rotating blit exists.

The census tool ships with the trap that broke its first version: nested
RATC blobs are not 4-byte aligned, so an aligned scan found 0/3 of its
own control blocks and missed 16 341 blocks. Disc-wide +12 is non-zero in
14.50 % of 83 862 blocks.

sylpheed-formats tests, SYLPHEED_DISC set: 131 passed, 0 failed across the
6 suites finished at commit time; the run had not yet completed.
2026-08-28 22:42:00 +00:00
Sylpheed RE agent
e6ad72c7a0 re: three angle fields found in the keyframe -- and they are not the
title's rotation

Looking for where the rotated quads come from, the obvious candidates
were the three keyframe words at +4, +8 and +12 that ui_layout.rs
documents as zero.

They are not zero. Across 72287 keyframe blocks disc-wide they are
non-zero in 4.81, 4.56 and 15.82 percent of blocks, and read as signed
values clustering on 180, -180, 90, -90, 120 and 22 -- degrees. Three of
them, so plausibly rotation about three axes. I have marked that amber
because it is the shape of the numbers and nothing more; no observed
rotation has been tied to a value. The doc comment is corrected
regardless: "0 on every frame seen" was an artefact of the sample.

And they do not explain the screen I was chasing. Every element of
GP_TITLE build 4 has all three at zero, element by element, while the
game demonstrably submits rotated parallelograms there. So the title's
rotation comes from outside the keyframe data and is still unidentified.

One correction to my own last write-up, flagged rather than left: I
stated that the skewed draw IS the swoosh. It is the only skewed geometry
in the capture and the swoosh is the only diagonal element on the screen,
so the inference is reasonable -- but I never confirmed it by matching
the draw's texture or screen position to that element, and I should have
said so the first time.
2026-08-28 22:17:14 +00:00
Sylpheed RE agent
4cee06c07f re: additive blending refuted, the swoosh is not displaced, and the
residual is smaller than I said

Testing the candidate I raised last iteration rather than carrying it.
Blending bit-0x02 sprites additively moves every measure the wrong way --
whole-frame mean diff +0.55 to +1.04, swoosh-band mean +1.83 to +3.98,
band edge-correlation 0.6971 down to 0.5578. So the bit is real and
independent but does not select an additive blend. I reverted the
experiment and kept the word as T8adImage::flags, documented and not
acted on; the render is byte-identical to before.

Second refutation: the swoosh is not displaced. Shifting the band over
plus or minus 80 by 8 pixels peaks sharply at zero, 0.7342, falling to
0.22 at 24 px. So the pivot story is dead twice over -- inert at scale
100, and no displacement to explain anyway.

And I have restated the residual, because earlier sections overstated it.
The +16 to +34 band tiles I quoted were measured WITHOUT --primitives.
With the dim drawn the band's average is nearly right at +1.83; what is
wrong is its structure, tiles running -38.6 then +33.8 and cancelling.
Six candidates eliminated now and none confirmed.

One caveat I owe the port agent about the capture I gave them: it is at
t=4.0s, roughly 174 keyframe units into a screen whose elements have
keyframes out to t=600. I judged "settled" from mean luminance, which
cannot see a thin sprite still moving. It is settled for the bulk of the
screen and not proven settled for every element -- which is a live
alternative explanation for a structural difference in exactly the band
the sweeps cross.

METHOD: cargo build passing does not mean cargo test compiles. Adding the
field built the library in 1.48s and broke two test-only struct literals;
cargo test failed with exit 101.
2026-08-28 22:00:55 +00:00
Sylpheed RE agent
5e6cf0db92 re: rest() fixed -- a trailing keyframe run is the hold when it is
VISIBLE, not when its pose repeats

The port agent's report was right about the defect and about which
elements it hits, and its proposed condition does not survive the case
the exclusion was written for.

Their test -- the final untimed keyframe has the same pose as the last
timed one -- is also true of pgptitle.rat, whose trailing run is two
identical transparent frames. Adopting it would erase the word PAUSE
again, which a committed capture disproves. What separates the two is
alpha: an exit fades the element out so its last keyframe is transparent,
while an element with no exit ends on the pose you can see. So
rest_plateau now accepts a trailing run exactly when it is visible.

Verified against a capture rather than against another renderer, which is
the point their own report made: ptframe1's rest moves from (620,108)
t=16 to (440,108) t=62; the changed pixels are 10082 in a bounding box of
x 440-839, y 108-577, which is exactly the 400x470 at (440,108) they
predicted; and correlation against main-menu-oracle.png over that region
improves from 0.9596 to 0.9748. The PAUSE wordmark is unchanged across
all three pause builds.

This also closes a question ui-paint-order-key.md has carried for a
while, that ptframe1 and ptframe2 rest at alpha 0 while the capture shows
the menu frame plainly.

One trap cost most of this iteration and goes in METHOD: CARGO_TARGET_DIR
is redirected in this container, so ./target/debug/sylpheed-cli is hours
stale and every render I made against it was old code. Byte-identical
before and after reads as "no effect" when it actually means "you ran the
old binary".

The full disc-gated test suite was still executing when this was
committed; the verification above is artifact-based.
2026-08-28 21:19:00 +00:00
Sylpheed RE agent
47f423f914 formats: move media assembly out of the viewer, where it could not be reused
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
a81da8cfcc 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
761e91de96 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
16a19219e8 slb: guard the wave-boundary identity with a test, and expose data_at
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.
2026-08-26 05:33:01 +00:00
Sylpheed RE agent
c3ff6aa0e8 slb: read Channels instead of assuming mono -- and retract the TCAF_608 conclusion
I read the seek chunk's packet count big-endian; it is little-endian at seek+12,
with size == 8 + 4*count. And a seek sits immediately AFTER its own data, so an
entry's first seek usually belongs to the PREVIOUS bank (implied start -25232
for D_452, -145988 for TCAF_608). I was comparing an entry's first seek against
its first data -- different waves by construction, which is why no reading lined
up.

With that fixed, the declared sizes are honest: every RIFF-bearing entry on the
disc has seek magic at exactly data_at + declared_size with count*2048 ==
declared. 7620/7620, zero failures.

VOICE_TCAF_608 is not truncated. Its Channels is 2 and I decoded it as mono;
read as stereo it gives 6520176 bytes = 33.96 s, agreeing with both length
signals in the bank (33.88 s from cumulative samples, 33.97 s from
PsuedoBytesPerSec). 170 of 8021 banks (2.12%) are stereo -- exactly the rate of
my 1-in-60 outlier.

This is the mono/stereo trap already documented on this very page, met from the
other direction: I had written 'at two channels every bank yields one frame' and
then spent several passes blaming missing data for a one-frame decode.

Code fix: to_xma_riffs built the leading segment with a hard-wired mono fmt. It
now reads Channels from the bank's first RIFF. 7 disc tests pass.
2026-08-26 05:27:42 +00:00
Sylpheed RE agent
220e250453 slb: the seek chunk gives the data offset structurally, and breaks the 28 ties
The ties needed a different signal, not a longer scan. Banks carry one: a seek
chunk sitting on a packet boundary, so seek_pos % 2048 IS the data offset. On
the 6033 labelled banks with a seek before their first RIFF, 6031 agree
(99.97%) -- better than the packet scan and structural rather than statistical,
so scan_data_offset now tries it first.

On the scan's 28 ties it resolves 26 correctly and 0 wrongly (2 have no usable
seek). Combined rule scores 7354/7358 = 99.95%, up from 99.62%. 762 of the 1495
RIFF-less banks carry a seek, so the signal exists where it is needed.

Also ruled out, since a wrong offset was this page's whole subject: the header
is not audio being discarded. Adding 0 to the candidate set, it wins 6 of 7358.

7 disc tests pass.
2026-08-26 04:32:23 +00:00
Sylpheed RE agent
b7b29dcb05 slb: the declared data size is an upper bound, not an exact one
Two comments claimed it is 'honest per sub-wave'. Measured: 5296 of 7586 banks
declare more than the entry holds and none declares exactly what it holds, so
the existing .min(slb.len()) clamp is load-bearing rather than defensive.
Comment-only change; 7 disc tests still pass.
2026-08-26 04:12:45 +00:00
Sylpheed RE agent
57a9d98de1 slb: the headerless path was decoding stereo at a fixed offset; both are wrong
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).
2026-08-26 04:03:05 +00:00
Sylpheed RE agent
fde9473c89 slb: derive the leading-stream data offset instead of assuming 1392
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.
2026-08-26 03:56:50 +00:00
Sylpheed RE agent
ff83918572 re: wire the IXUD record table into the crate — captions go 537 to 8800 of 8800
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
2026-08-26 02:55:26 +00:00
Sylpheed RE agent
914e6651cd re: read all eight caption families — 15x more text, and the same lesson twice
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
2026-08-26 02:47:13 +00:00
Sylpheed RE agent
12fe7ceecd re: rebuild game_data on the IDXD record table — 966 misses and 596 flattened reads
Measured first, over GP_MAIN_GAME_E.pak, comparing every named-field read the
six struct loaders performed against the record table: 4435 reads, 2872 agreed,
**966 returned None for a field that has a value**, **596 flattened a field that
several records carry**, 1 was wrong (a weapon whose TargetType is the empty
string read back as the neighbouring token "Skip"). The prior report of
4453/2887/974/591/1 is the same picture; the small differences are definitional
(I count a read as flattened only when the records disagree).

Every read now goes through IdxdObject::record, and the types say where a value
comes from:

* Weapon = the `Weapon` record (launcher) + the `Shell` record (projectile).
  Both carry an ID and a Name and — with `ShellWake` — an `Interval`, which the
  flat reader merged; they are separate fields now. Power/Velocity/ranges/
  LifeTime are Shell fields, which is why 427 weapon reads used to miss.
* CraftUnit/Vessel = `Generic` (hull) + `Maneuver` (flight model) +
  `StructureCount` (counts) + `Shield`, plus a new `hardpoints: Vec<Hardpoint>`
  — one entry per Turret_/Bridge_/Thruster_/Hatch_/ShieldGenerator_ record, each
  with its own HP. A flat HP could only ever be one of them.
* PlayerConfig = `Player`, plus `phases: Vec<PlayerPhase>` (SpaceSize/SupplyRange
  are per Phase_N) and `score: ByDifficulty<ScoreRules>` (MainMissionBonus is per
  Score_<difficulty>; the flat answer was the Easy one).
* Character faces come from the `Faces` record's field names (identical output to
  the old token scrape, 0 of 68 objects differ — now by construction).
* Stage = `StageResource` + `phases: Vec<StagePhase>`, and the packages it names.
* The `fields: BTreeMap` on every struct became `records: RecordSet`, which keeps
  the record boundary; `RecordSet::everywhere(field)` answers "which record".

The token-scraping loaders move too, and this is where the old reader was worst:

* Arsenal: options are the positional fields of the STANDARD_<slot> records. The
  scrape returned 16 nose options of which 8 were field keys and pilot names, and
  47 for arm3 of which 38 were junk, while missing Mine_B2A and No_Equipment.
  Now 8/12/9/9, all real weapons.
* Squadron: one record per squadron, members are Count*4 positional slots
  (unit, message set, n, pilot) — 1160 squadrons with ids and 2295 member tuples,
  against 28 idless squadrons and 47 members before. Agrees exactly with the
  independent Python decode in docs/re/structures/unit-group-table.md.
* DemoMessage: 11775 lines against 10263, every one with a speaker, a portrait,
  a delivery mode and a voice token, from fixed positional slots.
* PilotRoster: assignments are the records the `UNITS` record names, so each one
  now carries its unit id, its loadout and the player marker.
* UnitRoster: the roster is the field *names* of the single `EnumUnit` record.
* load_weapons selects on the records (Weapon + Shell) rather than on token[0],
  whose first byte is often a stray pool byte ("#Weapon", "%Weapon"). Same 131
  objects, no heuristic. GP_HANGAR_ARSENAL.pak holds none of them — the module
  doc's claim that player weapons live there was wrong.

schema:: constants keep their names and values but are documented for what they
are: record 0's name hash (PLAYER = Difficulty_Easy, UNIT = Maneuver, VESSEL =
Bridge_000, MESSAGE = Message_000), not a schema id.

Two things the migration exposes and does not fix, flagged in the docs instead:
load_units' bucket is 43 Type=Craft + 46 Type=Vessel objects (new `unit_type`
field lets a caller separate them), and StructureCount.TurretCount is not the
number of Turret_* records (the player's craft says 4 and has 63).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 01:42:53 +00:00
Sylpheed RE agent
ede9a26150 re: recover the .slb leading segment — mono, and scoped by measurement
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
2026-08-26 00:37:06 +00:00
Sylpheed RE agent
61387d5ceb re: the .slb leading region is 1392+n*2048 — and my fix for it is withdrawn
The structure is exact. In all five resupply banks the first RIFF sits at
HEADERLESS_DATA_OFFSET + n*2048, where 1392 is a constant this crate already
had and 2048 is the XMA1 packet size: n = 8, 1, 7, 22, 29. No free parameter
to tune, and the raw bytes agree -- high entropy from offset 0, then a zero
run immediately before the RIFF. VOICE_D_451 is the control, its single
packet being all zeros.

So I made the obvious fix, emitting that region as a sub-wave, and then
withdrew it on two measurements:

* It does not recover audio. Coverage went 5.4% -> 89.9% for VOICE_D_453, but
  the emitted stream decodes through FFmpeg to 1792 PCM bytes -- silence --
  while the RIFF sub-waves from the same banks decode to 150-270 KB. Byte
  coverage was the wrong success metric and it looked like progress.
* It is not narrow. The rule matches 1524 of the 8021 RIFF-bearing entries in
  sound.pak, including RT* movie banks that decode correctly today. Landing
  it would have risked a wide regression in order to not-fix five banks.

to_xma_riffs is back to its previous behaviour, verified by re-measuring:
coverage is 5.4% / 9.7% again. The refuted attempt is recorded in the code
beside the branch it would have changed, so the next person does not
re-derive the arithmetic and re-make the change.

XMA1_PACKET is kept as a named constant because the blast-radius scan uses
it. Artifacts: examples/voice_bank_shape.rs (structure), voice_bank_dump.rs
(sub-waves for decoding), slb_hybrid_scan.rs (the 1524 count).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-25 23:30:42 +00:00