Compare commits

..

231 Commits

Author SHA1 Message Date
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
f6e1f12728 re: read the suppressed set -- 7 correction contexts, zero live revivals
Also dedups the suppressed listing: two registered claims can be substrings of one
line, which reported that line twice (8 -> 7).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 20:45:33 +00:00
sylph-decoder
5c97d3819d re: check_refuted.py's clean run was not a pass -- 8 of 8 mentions were suppressed silently
A real revival planted inside a paragraph that merely discussed corrections was
missed: the words 'refuted' and 'withdrawn' in the surrounding prose vouched for it.
Measured reach: 100 % of registered-claim mentions in the corpus are suppressed by
marker language, so the reported 0 was 0 regardless of whether any was live, and I
had been reading it as a pass.

sylpheed-port's token-based hook has the opposite bias -- it over-reports on
well-written corrections, which is the safe direction. Under-reporting is disguised
as success.

Fixed by making the suppression visible rather than removing it: suppressed mentions
are counted and listed with --show-marked as not verified, only vouched for.
Controlled -- the planted revival moves the suppressed count 8 -> 9 and appears in
the listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 20:27:17 +00:00
sylph-decoder
390cdef5db method: the refuted register and a good correction pull against each other
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 20:19:54 +00:00
sylph-decoder
b4c6095c47 handoff: the stem question is narrowed, not settled -- and it does not change what the port does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 20:17:37 +00:00
sylph-decoder
4dd6a3f325 re: a bank's wave 1 is not a filtered copy of wave 0 -- and my own discriminator cannot finish the job
Coherence on BGM_103, the menu's bank, with controls run first: a real linear
filter of wave 0 reads 0.93-0.94 in every band, a different bank reads 0.001, and
wave 0 misaligned by 1 s reads 0.004-0.057. The measurement reads 0.027 at 1-4 kHz,
so the 'wave 1 is wave 0 filtered' model is refuted.

The frequency structure is inverted relative to any mic-pair or reverb model:
coherence rises with frequency (0.169 -> 0.827) while energy falls (71 % -> 0.2 %),
and a rear pair decorrelates fastest at HF. In the midrange the two waves are 13x
further apart than the two channels of one wave.

But the L-R control is what limits the tool and it is recorded as such: within one
wave, genuinely one performance in two channels, coherence is only 0.221-0.497. So
'same performance' does not imply high coherence here, my positive control was the
wrong model of the rear-pair reading, and the 🟡 is NOT settled. The tool tests for
linear filtering and neither surviving reading requires it.

Also corrects MISSION's Q10 row, which still carried the refuted three-sub-wave
premise and had directed work at a dead question for days. Its gate is in fact met.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 20:17:23 +00:00
sylph-decoder
95ceb70cb5 re: the refuted register ignored the deaths I just wrote, and two false positives shared a cause
Three refutations written as prose under ### headings never entered the register:
check_refuted.py parses * "claim" lines, so the count stayed at 188. Registered
them properly (188 -> 192). A register that parses one syntax silently ignores
every other, and it is invisible from the author's side -- ask the register what it
holds, do not re-read what you wrote.

Both standing false positives were bullets under a header that retracts the whole
list, with no marker in the +-4-line window: scope marks them, not proximity. The
scan now includes the nearest preceding header and matches markers
case-insensitively ('An earlier version' was missed by the marker 'an earlier
version'). Controlled by planting a real revival and confirming it is still caught;
register now runs clean at 0.

Also records sylpheed-port's diagnosis of the phase-lock fallout: a number can be
inapplicable rather than wrong, and a tension built on one is manufactured. Plus
their point that some claims are not registrable in a substring register at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 20:02:21 +00:00
sylph-decoder
c5d685ba15 re: the sweep IS drawn on the JP title -- and my gate was phase-locking the shutter
The occlusion hypothesis is refuted: build 7 draws the same three ROT strips at
higher alpha than English, so there was never an absence to explain.

The 0.32-vs-11.9 tension that motivated it was an artefact of my own instrument.
Both JP captures were shuttered on the plate pulse, and the plate's pulse is part
of the animation -- so the gate synchronises the shutter to the animation's phase.
Measured at the shutter instant, the sweep sits 25-26 px apart across two runs in
different locales and different sessions: 1.6 % of a ~1600 px traverse.

So the 0.32 I recorded as between-session capture noise measures my trigger's
repeatability, and I read it as evidence the title is still when it is evidence
the gate works. The era adjudication is unaffected -- margin 16.72 clears even the
un-locked 11.9 -- and unaffected for the reason that file already gave: correlated
noise cancels in a margin.

Refutation attempt on sylpheed-port's positional-mechanism rejection: FAILED, the
claim stands. Its residual sits inside lit logos, and the logo ROI is
byte-identical across five differently-phased frames in two sessions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:52:15 +00:00
sylph-decoder
1ef6896f8e re: the absence half of the gate audit -- 3 flagged, 0 real, and the audit was narrower than its wording
sylpheed-port ran my two-half decomposition on their side and found the thing that
passes every check by being absent -- an authored value with no `why` at all.
Their first pass flagged 35 of 131; ancestor-aware, the real number was 0. The
analogue here is a page citing NO reference data, which my previous gate audit
would score "0 missing" and pass.

42 pages carry a measured/decoded/CONFIRMED status; 3 cite no data/ or captures/
path. INSPECTED BEFORE PUBLISHING, per their rule, and all three are false
positives, each verified rather than waved through:

  slb-bank-header-not-a-wave.md cites tests/slb_leading_segment_disc.rs, and that
  file exists in crates/sylpheed-formats/tests/ -- its evidence is a disc-wide
  check over 9 519 sound.pak entries plus regression tests.

  ui-screen-runtime.md carries 26 rows of inline evidence, live guest-memory reads
  matched field by field against the file.

  five-screens-acceptance.md is a consolidation page; its evidence is the six
  pages it links and the numbers it tabulates.

3 -> 0.

The real finding is about the EARLIER audit. This corpus carries evidence in at
least three forms -- committed data files, inline tables, committed disc tests --
and both checks look for exactly one. "48 citations, 0 missing" is a statement
about the data-file form, not about whether the gates are evidenced. The gates are
evidenced; the audit was narrower than its wording suggested.

METHOD gains their formulation with all four instances -- a first count from a new
detector is a measurement of the detector, and all four were caught by inspecting
the flagged items before publishing the number -- and the corollary that an audit
is narrower than its wording: name the form you checked, not the property you hope
it stands for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:39:25 +00:00
sylph-decoder
b93ff1d974 re: audit MISSION's own gates, both halves -- clean, with the checks stated
sylpheed-port found P0 complete-but-unindexed: the work existed, the artifact
existed, the gate record did not. They named it as the argued-versus-indexed split
one level up from the refutation register, which is a shape worth checking on my
own objective rather than only agreeing with.

MISSION's gate has two halves -- "a written docs/re/ result with the evidence, and
reference data committed alongside it" -- and all ten questions read answered.

Half one: all ten cite a docs/re/ result.

Half two: every data/ and captures/ path those nine pages cite was resolved
against the tree. 48 citations, 0 missing. Spot-checked six for substance rather
than existence, since the gate's PURPOSE is that the port can work without a disc
-- 1.2 KB to 20.7 KB, 15 to 324 numeric lines each. No stubs.

CLEAN, and unlike the port's P0 also indexed: HANDOFF's status table cites the
page and the page cites the data.

Reach stated, because a clean audit is worth exactly its checks. This tests that
CITED files EXIST and carry content. It does not test that the data supports the
claim, and it cannot see data a page should have cited and did not -- a page
citing nothing would have passed as "0 missing". None did, but the check would not
have caught it. Existence and substance, never sufficiency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:36:16 +00:00
sylph-decoder
cf04a14958 re: the gamma ramp write -- direct observation attempted, blocked by the build tree
ui-render-tone-curve.md records the game's gamma-ramp write as "inferred from a
closed chain, not directly observed". The direct observation is a log in Canary's
own DC_LUT write path, which /canary being read-write makes available. Wrote it;
could not build it.

The patch logs each completed 256-entry sweep with samples against the identity
ramp the source documents (i * 0x3FF / 0xFF), so a written ramp is
distinguishable from an unwritten one by reading the log. Recorded in the page in
full so a future iteration with a working build can re-apply it.

BLOCKED: /sylph-home/re/canary-build was configured with -S/work/xenia-canary and
that path does not exist in this container. ninja fails at CMake regeneration
before compiling anything, and reconfiguring against /canary would trigger a
near-full Xenia rebuild -- not something to start on the way to one log line. Per
"do not improvise around a blocker", stopped and wrote it down.

REVERTED the patch and verified /canary byte-identical to its backup. Leaving
instrumented source the running binary does not contain is the
source-and-binary-disagree trap this session has caught three times; a later
reader would find the logging in the tree and conclude it was live.

Also of note for the corpus: the header edit initially failed silently because I
chained it with `||`, which hid the failure -- the "assert every edit" lesson from
four iterations ago, repeated. Caught by grepping for the symbol afterwards rather
than by trusting the command.

The ramp write remains inferred, not observed. What is new is the reach: the
experiment is written and the obstacle is a build-tree path, not anything about
the game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:33:25 +00:00
sylph-decoder
2ea7b7eb3d re: my own MISSION correction was wrong on all three clauses
Two iterations ago I corrected MISSION.md's stale "🔴 Emulator-side questions are
blocked" banner and wrote, in its place, that the two items it named were
"unblocked, not answered ... both need a running menu, both now have one, and
neither has been attempted". I wrote that without reading either page.

Reading them:

  8AX vs ptbase was RESOLVED on 2026-08-29. Its status line says so -- both its
  questions closed, page kept for the evidence.

  The gamma control was attempted and half-answered, and its page records that
  the run "needed the emulator only to BOOT, not to reach a menu ... parked behind
  the title-screen blocker for no reason".

  So neither item ever needed a running menu, and the section's premise was wrong
  independently of whether the menu was reachable.

All three clauses false. The failure is specific and worth naming precisely:
replacing a stale status with an UNCHECKED one, in the same edit that criticised
the document for carrying unchecked status. sylpheed-port wrote a dead instruction
inside the commit fixing dead instructions; this is that shape, one document over.

METHOD: a correction is a new claim and needs the same check as the claim it
replaces. The urge to correct supplies confidence the correction has not earned --
check a replacement as hard as the thing it replaces, and hardest when the edit is
about checking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:29:17 +00:00
sylph-decoder
897be7bfdd re: build the enforcement check REFUTED.md lacked -- 0 real revivals, and why
sylpheed-port's check-claims fails their run when a refuted claim is quoted
without an explicit token, and feeding it four withdrawals flagged three still
asserted unmarked -- each inside a correction they had written. REFUTED.md
publishes deaths without enforcing them, which is the gap I named last iteration
and did not close.

check_refuted.py is the prose equivalent: for each quoted claim in REFUTED.md it
searches docs/ for that text and reports occurrences whose neighbourhood carries
no refutation marker. Controlled first -- a claim planted unmarked in a scratch
file is detected, so a clean run means something.

9 raw hits, ZERO real revivals. All false positives, and the kinds are the
finding: 2 were text explicitly DECLINING to revive a claim; 1 a duplicate report;
4 were BACKLOG.md entries under a 2026-08-12 header, an append-only log recording
what was believed then; 2 were the claim quoted inside its own correction.

The structural limit is worth more than the clean result. A neighbourhood-language
detector cannot separate "asserted now" from "recorded as believed then", because
a dated log entry and a revival read identically. The port's design avoids this by
testing for a token an author must PLACE rather than for language -- theirs fires
correctly inside a correction, which is what caught their three, while mine fires
incorrectly there and would miss a revival reworded.

Stopped tuning at two remaining. Each marker phrase added fits the detector to this
corpus's habits of expression and away from being a test of them; tuning until it
reads zero would be fitting the instrument to the answer. Left over-reporting,
which is the safe direction.

Reach: it matches a claim's exact wording, so "no verbatim revival" is not "no
revival".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:26:00 +00:00
sylph-decoder
28b1a4c33c re: eight refutations from this session had never reached REFUTED.md
sylpheed-port audited the documents their brief names and found two stale
blockers in a table they are instructed to consult, having audited everything
else. Mine names eight documents; I had audited MISSION.md and never PROTOCOL,
REFUTED, INDEX or CONTAINER-NOTES.

REFUTED.md is the dangerous one, because a wrongly-dead entry stops someone
re-investigating something live. Checked the keyframe cluster first for the
opposite failure -- entries refuted USING the stale time association, which would
make their deaths unsound. They are sound: the additive-blend and pivot entries
rest on scale values and capture measurements that the association does not move,
and the one entry that did depend on it is already struck through.

The real gap is the other direction. EIGHT claims died this session -- the
fade-out duration "not in the file", the ~14 units as a black hold, the black
interval as a load, "(B) has no black interval", ptloop01/02 not free-running,
the splash dwells running 8.5 % long, EXTRAS's "structural" n=1, and the gap being
determined by the outgoing screen. Every one was recorded in its own page at the
time. NONE of them reached REFUTED.md, the file the brief says to grep before
proposing anything.

Added as a dated section with the true answer after each arrow, following the
file's stated format, and each carrying what made it wrong rather than only that
it was.

METHOD: a refutation that lives only where it was made is not reachable by the
person about to repeat it. The pages are where a refutation is argued; the index
is where it is found -- the same split as docs versus tool, and only the second
one saves anyone. The check is mechanical: after withdrawing a claim, grep
REFUTED.md for its noun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:21:06 +00:00
sylph-decoder
d7b715d6ee re: try to name the two unidentified destinations -- rejected by my own calibration
sylpheed-port's caveat on the ninth transition: the destination is identified
after the fact by draw signature, which establishes THAT the two screens differ
but not WHICH either is, so the gap is attributed to a pair whose second member is
known only as "not the other one". Worth trying to remove.

Both runs saved a screenshot of the destination. Scored against the archives the
menu's non-EXTRAS buttons plausibly reach:

  m2o   best GP_OPTIONS 43.30, margin 5.88
  m2o2  best GP_SYSTEM  45.74, margin 2.28

REJECTED against this corpus's own calibration. which_title_screen.py's control
puts a true match at RMSE ~18-20 with margin ~10, and a "neither" at ~34 with
margin under 1. These best fits are roughly double a real match. Accepting "m2o is
GP_OPTIONS" on a margin of 5.88 would be the same weak-margin acceptance that a
threshold was added to the navigation search to prevent three iterations ago.

Reach of the negative: one build per archive was rendered -- the default, which is
the largest -- and the screen a button opens need not be the largest build. So
this fails to identify rather than refuting those archives, which is a different
statement. The port's caveat stands and the ninth pair keeps it.

METHOD: a calibrated instrument can reject its own answer, and should. Without the
calibration, "best match, margin 5.88" reads like an identification -- a ranked
list always has a winner, and nothing in the ranking says whether the winner is
good enough. Any nearest-match report needs a known-good score beside it or it
will name something every time it is asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:17:03 +00:00
sylph-decoder
123cfc8561 re: MISSION.md carried three stale headings -- the document nobody audits
I have been reading MISSION.md every iteration as instructed and never once
audited it, while auditing headings, instructions, env vars and fallbacks
everywhere else. It carried three false section headings, each superseded in
HANDOFF.md and nowhere else:

  "🔴 Emulator-side questions are blocked -- the title is not reachable here"
      False. Twelve emulator runs on 2026-08-30 reached the settled title, gated
      on the plate pulse, and drove it into the menu, EXTRAS and out of the
      archive. The two items the section named -- the gamma control and
      separating 8AX from ptbase -- are UNBLOCKED, not answered, and neither has
      been attempted. That distinction is kept.

  "🟡 Needs one more run -- a Japanese-locale capture", describing "one capture we
      cannot take". Taken twice, both committed, via jp_title_session.sh. And both
      questions it blocked are closed, one of them confirmed against that very
      capture. ⚠️ I noticed this section was stale several iterations ago, said so
      in a message, and did not fix it.

  "🔵 Needs a human decision -- rotation". Decided and implemented the day it was
      raised: HANDOFF records "OPTION A IS DONE. The reference renderer rotates",
      and ui_layout.rs carries the rotated blit with a control test pinning 0° and
      360° byte-identical to the unrotated path. The field is now confirmed from
      the ORACLE too -- +30/-45 predicting AABB heights of 1135.3 and 1301.1
      against 1134 and 1303 measured, both under 0.2 %.

All three corrected leading with the correction, originals quoted and demoted
beneath. Only FACTS were changed; the questions and their gates are untouched,
since keeping the document true is maintenance and changing what it asks would be
overstepping.

METHOD: audit the document that defines the objective, because it is the one
nobody audits. A document read only for instructions is never read for review, and
the more central it is the more often it is consulted and the less often checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:15:40 +00:00
sylph-decoder
06aa031980 re: a ninth transition -- the same origin gives gap 0 and 1 by destination
sylpheed-port confirmed "nothing declared predicts the gap" from their export
independently, and deliberately declined to search combinations: four pairs
against many candidate two-screen functions fits by construction. Right call, and
it applies to me unchanged. So this iteration adds a PAIR rather than a fit.

menu -> a second screen outside GP_TITLE, reached by stepping the cursor two
items before arming. The button is not controlled -- there is no focus readout --
so the destination is identified afterwards by its draw signature: incoming
primitive [255] at 7-9 draws/frame, against the first run's [127] at 12-13.
Different screens.

Outgoing quad rises 25, 51, 102, 229, 255 across frames 37-42, then at frame 44
the new screen is already drawing. NO empty frame anywhere. GAP = 0.

So the menu as origin gives four values across four destinations: title 0,
EXTRAS 1, other-1 1, other-2 0. The same origin yields both 0 and 1 depending on
where it goes, while the two repeated pairs stay internally identical (3,3,3 and
2,2). Further evidence for the ordered pair over the origin.

Recorded as an observation with its counter-example rather than fitted: the
incoming screen's own full-screen primitive is [255] where the gap is 0 and [127]
where it is 1, which suggests a screen beginning from opaque black needs no blank
frame. That FAILS on menu -> EXTRAS, which declares a black backdrop and still
gives 1. Nine transitions against many candidate functions is the construction
the port declined to search, and I am not searching it either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:13:23 +00:00
sylph-decoder
5e2dd62732 re: the gap is a property of the ORDERED PAIR -- and the guard verified in a run
Two things in one run, on the screen that motivated both.

First, the effective-config guard I had flagged as "not yet verified" -- leaving a
doubt in my own file, which is the shape sylpheed-port had just caught themselves
in. Verified now:

    arming on       = menu  [screen_id: cannot separate menu/EXTRAS]
    discriminator   = extras  rmse=18.94  (other main_menu 30.09, margin 11.15)

Before the fix this run would have announced "arming on = menu" while armed on
EXTRAS. The ambiguity is visible instead of hidden.

Second, a replicate of the table's weakest cell. Second EXTRAS -> menu: outgoing
quad 229, 255, 255 across frames 29-31, then TWO empty frames at 32 and 33. Gap =
2, identical to the first.

Eight transitions now say something sharper than the outgoing-screen story, which
is superseded a second time:

  title  -> menu    3, 3, 3   n=3   repeats agree
  EXTRAS -> menu    2, 2      n=2   repeats agree
  menu   -> title   0         n=1
  menu   -> EXTRAS  1         n=1
  menu   -> other   1         n=1
  EXTRAS -> other   3         n=1

Every repeated pair is identical -- five replicates, no variation -- and every
differing value comes from a different pair. The same origin gives different
values to different destinations (menu 0 vs 1, EXTRAS 2 vs 3). So the origin
CONSTRAINS the gap and the ORDERED PAIR determines it, reproducibly.

For the port: a constant black_hold_units is excluded and keying on the outgoing
screen is excluded too. Any keyed version must be keyed on the ordered pair, with
a measured value per pair -- six known, two replicated, none predicted by anything
declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 19:02:19 +00:00
sylph-decoder
09af29f1dd re: the symmetric audit -- port-sourced claims in my corpus are attributed
sylpheed-port promoted my unverified "EXTRAS is stuck at n=1, a structural limit"
out of a message into DECISIONS.md as an established fact, while holding the file
that refuted it -- their own authored/flow.json, recording ptbtn11 ->
GP_MISSION_SELECT. Their corollary is sharper than my original entry: distrust
"structural" and "impossible" hardest when SOMEONE ELSE writes them, because they
arrive without the doubt the author would have had.

Swept this side for the same shape. It is clean: port-supplied figures are
attributed in the text ("port reports 866 keyframes ... 0 untimed"), the
ui_layout.rs comment on the unreachable fallback cites MY OWN measurement of 0
untimed of 24 811 across 965 builds rather than their 866, and their quantisation
floor of 0.41 appears in no document of mine at all.

Reach stated: this tests attribution WORDING and the port-supplied figures I could
enumerate, not every reliance. A negative from a naive check is not proof of
absence, and saying so is the point of recording it.

What protected it was a habit rather than vigilance -- writing the source into the
sentence. That is now the third instance of one remedy: state what the number is a
number of; write the index space into the token (e10 rather than "build 10"); write
the source into the claim. Put the qualifier in the text, never in the reader's
memory.

Also fixes the half-guard the port called out. The effective-config block reported
`arming on` from screen_id.py, which cannot separate the main menu from EXTRAS --
so it announced "menu" while the run was armed on EXTRAS, a field the guard could
not resolve for exactly the two screens in question. It now prints both that value
AND the discriminator with its margin, so the ambiguity is visible rather than
hidden. NOT yet verified in a run -- per the port's own --no-hold lesson, parsed
and edited is not working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:52:19 +00:00
sylph-decoder
cd829b6dad re: break the EXTRAS n=1 cap -- which I had wrongly called structural
I recorded EXTRAS as able to supply only one gap measurement because "its sole
exit is (B) to the menu", and called that n=1 STRUCTURAL -- a word that retires a
question. The disc refutes it in one command: build 6 declares three buttons,
ptbtn11/ptbtn12/ptbtn13, all kind 0x3002. The cap was an unverified assertion I
had already written into HANDOFF twice.

Measured EXTRAS -> a screen outside GP_TITLE via (A): outgoing quad rises across
frames 36-40 (4-5 frames, matching build 6's declared 10-unit close), then THREE
empty frames at 42, 43, 44, then a different archive builds (23-28 draws/frame
against GP_TITLE's 11-14). Gap = 3.

So EXTRAS as outgoing gives {2, 3}, and seven transitions now group as:
menu {0,1,1} n=3, EXTRAS {2,3} n=2, title {3,3,3} n=3.

A pairwise control that holds the destination class constant: menu -> another
archive gives 1, EXTRAS -> another archive gives 3. Same kind of destination, gap
differs by outgoing screen. That is the strongest support yet for the
outgoing-screen dependence because it removes the destination as the variable.

But the clean ordering is GONE: EXTRAS {2,3} and title {3,3,3} overlap at 3, so
"menu < EXTRAS < title" no longer separates them. What survives is weaker -- the
outgoing screen constrains the gap to a ~2-wide band and the bands are not
disjoint.

METHOD: a structural limit is a claim and needs checking like any other.
"Structural" and "impossible" are the two words most worth distrusting in your own
notes, because they retire a question rather than answering it and nothing later
re-opens them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:49:29 +00:00
sylph-decoder
e823cfe6fa re: a third menu-outgoing gap, and leaving the archive costs no extra black
sylpheed-port's BLOCKED row asks for a second value on one outgoing screen --
what would make "the gap tracks the outgoing screen" predictive rather than a
restatement of the data.

First, a correction their ask surfaced without needing a run: THE MENU ALREADY
HAD TWO VALUES AND THEY DIFFER -- 0 leaving for the title, 1 leaving for EXTRAS.
So "the outgoing screen determines the gap" was too strong and is withdrawn; what
holds is an ordering, not a determination. Also recorded: their ask is answerable
only from the menu, since the title's sole exit is (A) to the menu and EXTRAS's
sole exit is (B) to the menu.

Then took a third menu-outgoing measurement, to a screen outside GP_TITLE.
Confound named in advance rather than after: that transition leaves the ARCHIVE,
so a pak load could inflate the gap for reasons unrelated to the outgoing screen.

Result: outgoing quad rises across frames 24-28 (4-5 frames, matching build 5's
declared 10-unit close), then ONE empty frame at 30. Gap = 1, identical to
menu -> EXTRAS. So the confound is measured ABSENT -- leaving the archive costs no
extra black, which is worth having on its own.

Six transitions now group as: menu {0, 1, 1} over three different destinations
including another pak, EXTRAS {2}, title {3, 3, 3}. The ordering rests on 3+1+3
measurements and the menu's three agree to within one frame.

Still not predictive: outgoing closes are 5, 5, 4 frames against gaps {0,1}, {2},
{3}, and EXTRAS has n=1 with no way to obtain a second value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:42:51 +00:00
sylph-decoder
36c01ae253 re: the black gap tracks the OUTGOING screen -- direction and button ruled out
Applied my own log-line rule to my own instrument first, which is the "rule
written down is not a rule applied" failure I have on record three times. Every
branch of fade_decompose.sh now announces its EFFECTIVE configuration -- derived
from the variables in force, not from $WHERE, per sylpheed-port's sharpening --
and the run REFUSES if no branch announced itself. That is the guard that would
have caught last iteration's silent fall-through at a glance.

With it in place, took the measurement that failed last time: menu -> EXTRAS, the
reverse of the pair measured at a 2-frame gap. Navigation verified both ways
(extras margin 11.23, back on menu 11.60) and the config block confirmed the right
branch.

Result: outgoing quad rises across 5 frames matching build 5's declared 10-unit
close, then ONE logged empty frame. Quoted as 1 with an upward uncertainty, since
its two neighbours carry no frame header at all.

Five transitions now order by the screen being LEFT:

  menu -> title    (B)     0        outgoing menu
  menu -> EXTRAS   (A)     1        outgoing menu
  EXTRAS -> menu   (B)     2        outgoing EXTRAS
  title -> menu    (A) x3  3,3,3    outgoing title

Direction is ruled out: EXTRAS->menu (2) and menu->EXTRAS (1) are the same pair
both ways and differ. Button is ruled out: (B) gives 0 and 2, (A) gives 1 and 3.
Incoming screen is ruled out: an incoming menu takes 3 from the title and 2 from
EXTRAS.

NOT a rule, and recorded as such. Three outgoing screens with one value each means
"each outgoing screen has its own gap" restates the data rather than predicting,
and nothing declared predicts it -- outgoing closes are 5,5,5,4 frames against
gaps 0/1,2,3, inverted if anything, on three points.

What it changes for the port: a UNIFORM black_hold_units is now positively
excluded rather than merely unsupported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:33:40 +00:00
sylph-decoder
1b3a9e9086 re: a third title->menu replicate, from a run that ran the wrong experiment
Set out to measure menu -> EXTRAS, the reverse of the pair measured at a 2-frame
gap, to test whether the black gap is a property of the screen pair or of the
direction. The run did not do that.

A three-part patch to fade_decompose.sh asserted two of its three replacements
and left the third -- the branch condition -- unchecked. It silently failed, so
WHERE=menu2extras fell through to the `title` branch. The capture is well-formed
and is of a different transition than intended, which is the build-ordinal error's
shape again: right-looking output for the wrong object. What caught it was the log
LACKING the navigation lines the intended branch prints; the data itself looked
entirely fine.

Salvaged, because the accidental transition is one already measured twice:

  run   outgoing ramp             black   incoming decay
  1     67-70: 63,127,191,255     3       73-77
  2     64-67: 63,127,191,255     3       70-74
  3     92-95: 63,127,191,255     3       98-103

Three independent runs, gap = 3 frames every time, outgoing ramp byte-identical
in all three. That takes "the black gap is not a load" from two replicates to
three, and makes the 4-frame outgoing ramp as solid as anything measured here.

menu -> EXTRAS remains open; the condition is fixed (with an assertion this time)
and the run has not been taken.

METHOD gains two entries. Assert every edit, not most of them -- and have each
branch announce itself in the log, so a run that took the wrong path says so
before its numbers are read. And: "appears nowhere in crates/" is a claim about a
TREE. sylpheed-port found SYLPHEED_KF_TIME_SHIFT live at ui_layout.rs:497 on their
branch, which carries the stale era; both statements are true of different trees.
With main 145 commits behind and each agent on a topic branch, any claim about
what the code contains needs its ref attached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:21:20 +00:00
sylph-decoder
e8e036f317 re: the inverse sweep -- 41 env vars read, 22 undocumented, none in the UI path
sylpheed-port inverted my documented->exists sweep into parsed->documented and
found three live undocumented flags, with the framing that a capability existing
only in an 11 000-line record is, to a reader of the interface, a capability that
does not exist. The mirror on my side is env vars the CODE reads, checked against
the docs. Like theirs it enumerates, so it completes rather than samples.

41 read by crates/, 19 documented, 22 not. The 22 split cleanly: 7 are read only
in examples/ (per-example filters and dump paths, reachable only by editing an
example's command line), and 15 are read in src/ -- live capabilities of the
library and CLI. Ten are mesh/3D toggles and five are XPR_* texture-decode
toggles.

FOR THE PORT: none of the 15 is in the UI path. Every env var ui_layout.rs and
the screen commands read is documented -- SYLPHEED_REST_RULE and
SYLPHEED_KF_TIME_LEGACY. The menu lane is clean in this direction. But the five
XPR_* are texture-decode toggles and the port consumes textures, so if a sprite
comparison ever disagrees those are the knobs and they are invisible from the
interface.

LIMIT, stated rather than glossed: I verified NONE of the 15 end to end.
`texture export` takes a loose file and the disc keeps its textures inside paks,
so the check cost more than the answer was worth here. That matters because the
port found --no-hold parsed, documented AND INERT under an interaction with
--time: "parsed and reachable" is not "works". The honest claim is that 15
undocumented env vars are READ, not that 15 capabilities exist.

METHOD: sweep the surface in both directions, and note that both directions
enumerate and therefore complete rather than sample -- rare enough in that file to
be worth preferring when available -- while neither establishes that the thing
works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:12:21 +00:00
sylph-decoder
f9410cf878 re: METHOD -- rank silent instructions above loud ones
The refinement sylpheed-port earned by sweeping their own instruction surface and
finding all of it loud: a wrong path errors out and announces itself, while an
inert environment variable returns a clean, wrong result. Only the silent kind
manufactures evidence.

Records that the silent surface is ENUMERABLE and therefore sweepable rather than
sampleable -- every env var the docs name, checked against the code -- with the
result of doing it, and the proxy warning that absent-from-code also flags
container paths the brief sets and no code reads.

This entry failed to apply in the previous commit (an exact-match assertion on
surrounding text) while the two document fixes it describes did land. Committed
separately rather than amended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:08:33 +00:00
sylph-decoder
70ee001327 re: sweep the SILENT instruction surface -- 5 files still named a dead gate
sylpheed-port refined my ranking: rank silent instructions above loud ones. All
of theirs were loud -- wrong paths that error out and announce themselves --
while mine was silent: an inert env var returning a clean, wrong result. Only the
silent kind manufactures evidence.

The silent surface is enumerable, so this is a sweep rather than a sample: every
environment variable the docs name, checked against the code.

SYLPHEED_KF_TIME_SHIFT was still live in FIVE doc files after I fixed one last
iteration. Two of the five were genuine hits rather than historical quotes:

  ui-resting-pose.md -- a RESULTS TABLE ROW labelled "with
  SYLPHEED_KF_TIME_SHIFT=1". Re-running it sets an inert variable, produces the
  DEFAULT row, and lets a reader conclude the two readings agree. A stale
  instruction inside a results table is the purest form of the evidence-
  manufacturing class.

  HANDOFF.md -- "experiment reachable via SYLPHEED_KF_TIME_SHIFT=1", a live
  instruction in the delivery contract.

And a live gate exists under a DIFFERENT NAME that the docs never pointed at:
SYLPHEED_KF_TIME_LEGACY, verified read at ui_layout.rs:595 -- the parser itself,
not only the tests, so it does reach screen info and screen render. Both hits now
redirect there.

Beware the proxy, which is the trap the port named about their own "parsed"
check: absent-from-code also flags SYLPHEED_DISC, XENIA_SRC and SYLPH_ISO, which
are container paths the brief sets and no code reads. Absent-from-code is
necessary, not sufficient, and I checked each rather than reporting the seven raw
hits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:07:54 +00:00
sylph-decoder
7b505be769 re: a stale INSTRUCTION that no-ops -- worse than any stale description found yet
sylpheed-port generalised the heading rule: an index is an amplifier, since
anything republishing headings multiplies whatever they assert. Checked mine --
INDEX.md's generated table republishes each file's H1 and Status line, a narrower
amplifier than their TOC but the same mechanism -- and then swept headings for the
dead-rule vocabulary.

The strongest hit is not a heading. ui-keyframe-time-unit.md, the Q1 page, told
readers a comparison was "Gated by SYLPHEED_KF_TIME_SHIFT=1, default unchanged"
and referred to "the other reading behind SYLPHEED_KF_TIME_SHIFT=1". That
variable was REMOVED with the record-layout fix and appears nowhere in crates/.

A reader following it sets something inert, gets default behaviour, and concludes
the two readings agree. A stale instruction that no-ops MANUFACTURES A FALSE
CONFIRMATION -- strictly worse than a stale description, and the same shape as
screen-transitions.md telling the port to author a value that is decoded.

Also demoted the section heading "and the shifted reading wins every time": the
shifted reading was itself superseded, the fix having established the same
association by a better route and timed pose 0 as well, which the shifted reading
never did. The evidence stands and is now evidence for the corrected reading.

METHOD gains three things: rank instructions above descriptions when sweeping for
stale text; an index is an amplifier; and the denominator, stated because it is
unflattering -- this corpus has 2 989 headings, 401 of which make a negative or
absolute assertion, and I have audited this session's plus the dead-vocabulary
intersection. That is a sample, not a sweep, and older headings are likelier to be
stale for having had more chances to be overturned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:04:19 +00:00
sylph-decoder
c184f8a783 re: a false HEADING standing 78 lines above its own correction
sylpheed-port diagnosed their four instances of fixed-code-under-unfixed-
description as a habit rather than inattention: corrections are ADDITIVE. They
append a correction block and leave the original standing above it -- right for a
record, wrong for a statement, because a reader takes the first assertion. Their
fix is to keep the quote but demote it grammatically.

Applied their diagnosis here and found a worse instance than theirs.
screen-transitions.md carried the heading "###  The fade-OUT duration is not in
this field", with a section beneath it that is false in every sentence:

  "The fourth block has no time -- a group's last block stops 4 bytes short and
   that word is already the next group's element index. So the disc gives the
   ramp's target (black) and not its length. That duration is measured below, and
   the port is authoring it."

All pre-fix. The record-layout fix times a group's final pose, so block 4 carries
t=80 (menu), 74 (EXTRAS) and 269 (title), and the fade-out ramp is DECODED at
70->80 = 10 units, 64->74 = 10, 261->269 = 8. The section told the port to author
a value that is decoded, and its correction sat 78 lines below.

It also carried the dead rule's exact vocabulary -- "stops 4 bytes short" -- which
is the grep I built for code last iteration and never ran against docs.

Rewritten leading with the correction, the original quoted and demoted beneath it.

METHOD: corrections are additive by default and that is wrong for a statement;
the worst form is a HEADING, which asserts with maximum reach and minimum
context, and a reader scanning headings never reaches the retraction. Audit
headings first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 18:00:38 +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
66c3422dec re: sweep my pre-fix numbers -- one real hit, durations survive, argument corrected
sylpheed-port asked which of my figures predate the keyframe record-layout fix,
noting the sharper form of the hazard: a fix that changes WHICH ROWS EXIST is
harder to sweep for than one that changes values, because the recomputation looks
like a correction rather than a different question.

Located the fix (5744f37, 2026-08-29 14:01) and enumerated docs quoting
keyframe-derived numbers that have not been touched since. One real hit:
structures/ui-group-start-time.md, a page about group START TIMES, written -- in
its own words -- "under the shifted time reading" and referring to the glows'
"untimed first keyframe".

Durations survive. The glow is visible ~0..45 (45 units) and the logo ~15..210
(195 units), exactly what its table already used; the "~0" it hedged with is now
a real timed 0.

The argument did not. It read "every glow declares the same times 15, 30, 45 ...
both families start at 15". Post-fix both start at 0, with times 0,15,30,45 and
0,15,30,190,194,206,210. Corrected in place with the original sentence quoted so
the change is visible.

The conclusion is unchanged and slightly stronger: on one clock the glow is
visible over 0..45 and the logo over 15..210, an overlap of 30 of the glow's 45
units, where the capture shows no overlap at all (glows frames 94-115, logos
116-211). A shared clock origin was contradicted before and is contradicted by a
wider margin now, because both groups demonstrably begin at the same declared
instant.

Of the three figures the port reports having quoted from me, the splash dwell
spans were already corrected today and the black-gap and cold-boot intervals are
capture-derived, so the fix does not reach them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:54:11 +00:00
sylph-decoder
22846bf30e re: finish the --help audit -- a stale percentage and a missing noun, both shipped
The METHOD entry I wrote an hour ago says to read your tool's own --help as if a
stranger wrote it. I had done that for ONE of sixteen leaf commands, which is the
"a rule written down is not a rule applied" failure this corpus already records
twice. Finished it across the whole surface.

One survivor, and it fails in two ways at once. `screen render --settle` said a
narrow window means the bundle never settles, "(42 % of them, mostly loop*
fragments)".

MISSING NOUN: inside `screen render`, "them" reads as the builds you would render.
The 42 % is over composable bundles -- a different and much larger set including
~1 700 two-element fragments a user of that flag never renders. ui-settle-time.md
states its population precisely; the help inherited the number without it.

STALE: recomputed under the corrected reader, the composable figure is 862/2211 =
39 %, not 731/1758 = 42 %. The POPULATION GREW BY 453, which is the keyframe
record-layout fix's signature -- it times a group's final pose, so bundles that
previously showed one timed keyframe now show two and qualify. Third consequence
of that fix not being swept, after fade_quads.py and screen-transitions.md's
0.87-4.08 s fade-in.

And the share a --settle user actually faces is 38 %: 185 of 491 screen builds.

Corrected in the help text with all three numbers and their populations, and in
ui-settle-time.md, whose three-row table is marked pre-fix and superseded rather
than edited in place. Verified by artifact -- the tool's --help output is quoted,
not merely recompiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:45:43 +00:00
sylph-decoder
0d1e0ae8b7 re: my own CLI shipped an unmeasured recommendation -- "Prefer --settle" withdrawn
sylpheed-port reported hedging a predicate in DECISIONS.md while stating the
unhedged version in their tool's header, and named it as the same delivery gap
they had fixed once elsewhere and not generalised. Checked this side for the same
shape and found it.

`sylpheed-cli screen render --at` told every user that the resting pose "is wrong
twice over" and to "Prefer `--settle`". That recommendation was never measured.
What the corpus actually records: scored against a live capture of the JP title,
settle gives RMSE 40.210 and rest 41.690 -- a margin of 1.48 against that
instrument's own noise floor of 1.2, which is not decisive -- and --settle has its
own failure mode, 25.5 % of elements mid-ramp at their screen's settle instant.
So neither is established as better, and the interface has been telling people
otherwise while the hedge lived only in docs/re/.

Corrected in the help text itself, on both flags, with the numbers rather than a
softer adjective. Verified by artifact: the tool's --help output is quoted in the
commit's own test, not merely recompiled.

METHOD: hedging in the write-up does not protect the claim you ship in the tool.
Docs are where a claim is reasoned; the tool is where it is believed. Read your
own --help as if a stranger wrote it and check every confident sentence against
what the corpus establishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:38:42 +00:00
sylph-decoder
82b20bc51d re: test the port's black-backdrop predicate disc-wide -- exact locally, rare globally
sylpheed-port proposed that a screen declaring a full-screen .prm at t=0 with
fade == 0xff000000 is standalone, and one without it is composited: 12/4 across
their sixteen exported screens, every exception independently known to be
composited. They asked for it against archives they do not have. That is my lane.

CONTROL: the predicate reproduces their split exactly. GP_TITLE's sixteen bundles
give 12 with and 4 without, the four without being entries 0, 1, 2, 3 --
build_00, build_01, press_start, press_start_jp -- and the element names match
(pteff00.prm, palogo_eff0.prm, pgloading_eff00.prm). Independent derivation from
the disc, not a re-run of their tool.

DISC-WIDE it is rare: 76 of 965 screen builds, 7.9 %. GP_STAGE_CLEAR 4/4,
GP_SYSTEM 2/2 and GP_TUTORIAL 2/2 are all-yes; GP_HANGAR_ARSENAL is 0 of 390, and
GP_READY_ROOM, GP_OPTIONS, GP_PAUSE_MENU and GP_GAMEOVER are all zero.

So it is not a general standalone/composited test. GP_OPTIONS and GP_PAUSE_MENU
are screens a player plainly sees as screens and declare no backdrop; read as
"composited" the rule would make 92 % of the game's screens composited, which the
archives do not support. What it appears to separate is narrower: screens that
BEGIN FROM BLACK from everything else. A pause menu over gameplay, a hangar over a
3D scene and a plate over a title all lack a backdrop without being the same kind
of thing -- the negative class is heterogeneous, which is what a two-way rule
cannot express.

For the port: exact within GP_TITLE, so --black for those twelve is justified from
the file rather than assumed; do not carry it into the four archives they have yet
to export, where in three of them it classifies every screen alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:36:10 +00:00
sylph-decoder
a1af583725 re: verify the splash's opaque-black backdrop, and the noun amendment
sylpheed-port found the fifth member of our error family on their own side: their
"visible" test counted any element with alpha > 0, which includes palogo_eff0.
Verified from the disc rather than accepted --

  entry 10  [0] palogo_eff0.prm  1 kf  t=0  fade=0xff000000  scale=100x100  pos=(0,0)
  entry 11  same

Alpha 255 over RGB 000000: full-screen opaque black, drawn from t=0 and showing
nothing. So "any element drawn" reports these screens visible from t=0 while the
frame is black -- "visible" read as "drawn".

Worth having on its own: this verifies from the disc the premise behind
`screen render --black`, which its own help states as "what the game composites
over on a screen carrying its own background". On the splash builds that
background is DECLARED, not assumed.

METHOD gains their amendment, which is the sharpest formulation either of us
reached this week: all five instances are a failure of a NOUN, not of a number.
Extent, bounding box, duration, span, visible. The number was always correct FOR
SOMETHING; what went missing was which thing. Every other check in that file
tests whether a number is right, and not one tests whether it is a number of the
thing you think.

Also teaches fade_quads.py to address a PAK ENTRY directly (`e10`) rather than
only a build ordinal. The splashes are entries 10/11 and are not screen builds,
so no ordinal addresses them -- and writing `e10` states which index space is
meant, which is the standing lesson of build-ordinal-vs-entry.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:30:37 +00:00
sylph-decoder
2dfc20bc0f re: WITHDRAW the 8.5 % dwell systematic -- I measured one element, not the screen
sylpheed-port said the port plays the full group (255 and 210 units) rather than
my 240/195, and they are right for a reason sharper than either of us first had.
The _eff elements ramp alpha 0 -> 255 over t=0..15 while the main logo is still
fully transparent:

  palogo_sqex.t32      0:a=0  15:a=0    30:a=255  ... 255:a=0
  palogo_sqex_eff.t32  0:a=0  15:a=255  30:a=212  45:a=0

So the SCREEN is visible from within t=0..15 and its visible span is the full
group. My "240 units visible" was ONE ELEMENT's visible span, computed while
another element of the same build was already on screen -- which is exactly the
error class I was writing up when I made it.

Recomputed against the screen: publisher 1.011/1.083/1.028, developer
1.002/1.001/0.962, mean 1.0146 with one measurement BELOW unity, against my
1.085 with none below. That is not a clock at 54 u/s. The systematic is gone and
Q1 stands unqualified.

The consequence was wrong too: "a port playing 240 units at 60 shows the splash
0.42 s less" -- it plays 255, so the gap is 0.174 s, and on the developer splash
the port runs longer than my mean. No direction to correct in.

What survives weakly: against the full group the publisher runs long in all three
boots while the developer sits at unity. Three boots per screen is thin and it is
not a systematic.

METHOD: state the number, and state what it is a number OF. Four instances of
this family now -- pivot anchor as extent, centre track as bounding box, cycle
length as motion duration, one element's visible span as the screen's -- and two
of the four arose because the PUBLISHER of the number never said what it spanned.
The reader reasoned correctly from the only definition available each time.
Publishing a quantity's extent in the same breath is cheaper than every check in
that file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:28:00 +00:00
sylph-decoder
c354ee615d re: the splash dwells run ~8.5 % long, six for six -- a qualification on Q1
sylpheed-port reported verify-dwell at 4.28 s / 3.58 s and called it agreement
with my three cold boots. Checked the arithmetic instead of the impression, and
it is not agreement with the DISC.

  publisher  240 u = 4.000 s at Q1's 60 u/s   measured 4.297/4.604/4.370  mean 4.424
  developer  195 u = 3.250 s                  measured 3.508/3.503/3.366  mean 3.459

All six ratios exceed 1 -- 1.074, 1.151, 1.093, 1.079, 1.078, 1.036, mean 1.085 --
implying 54.3 and 56.4 units/s. The port's own two numbers imply 56.1 and 54.5.
Four estimates, none at 60.

The obvious explanation fails: a detector triggering early and late would lengthen
the interval, but the declared span IS 15->255 and outside it the alpha is 0, so
there is nothing on screen to trigger on. An 8.5 % overshoot is 20 units, ten
rendered frames, which no threshold can manufacture from a blank screen.

Recorded as an open qualification on Q1 rather than a correction: three boots per
screen is thin, and Q1 was measured on a different quantity. It is also NOT the
same discrepancy as the sweep leaf's, which runs ~50 % slow rather than 8.5 %.

For the port: the dwells remain decoded and should still not be authored, but a
port playing 240 units at exactly 60 u/s shows the publisher splash for 0.42 s
less than the game does.

METHOD gains the reusable half of the (A) result, which the port named: a probe
whose observation window is shorter than the effect reports a CLEAN NEGATIVE.
(A) takes 4-6 s; a script that presses and looks 0.5 s later concludes the press
was dropped, with nothing in its log to say otherwise. Check the window against
the latency before believing a null, and sample repeatedly when the latency is
unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:23:56 +00:00
sylph-decoder
9a80ed6bd2 re: (A) on a settled boot title DOES reach the menu -- the withdrawal explained
Clearing my own debt: I withdrew navigation.md's "boot title accepts a single A"
counter-example as confounded by three concurrent emulators and never re-ran it,
which left the claim unsupported rather than settled.

Clean trial: exactly one emulator verified by count, gated on the plate pulse
(glyph in [500,2500] held 12 samples) so the press lands on the BOOT title rather
than the attract loop's, delivery confirmed at [file-pad] keystroke vk=5800
down/up. Glyph after the press is 0 at +2 s and +4 s -- the transition -- then 327
steady from +6 s through +39 s.

327 is a proxy and reading a proxy is the habit this corpus keeps cataloguing, so
the screen was checked with which_title_screen.py instead: main_menu at RMSE
19.91 and 20.08 with margin ~10, inside the 9.9-11.7 band its control establishes
on four known captures. The before frame gives the "neither" signature at margin
0.10, correctly, since the title is neither main_menu nor extras.

So the count is 3 of 3, the latency is 4-6 s -- which is why a script that
presses and looks 0.5 s later concludes the press was dropped -- and the two
earlier failures were the confound, not the game.

Refutation attempted: sylpheed-port's leaf segment rates. Derived independently
from the disc and they SURVIVE exactly -- pteff03 +4.0000 then +4.0000 then a
hold, pteff03a -4.0667 then -4.0625 then a hold. So their inversion stands: my
linearity gate fails on the leaf whose declared track is perfectly straight.

And records the third structural consequence of main being stale, which they
raised: HANDOFF.md is the delivery contract and it lives on an unmerged branch,
so their checkout contains none of this week's entries. Findings written into the
contract reach them only through messages -- the channel the protocol says does
not count as delivery. Writing it in the contract is necessary and not sufficient
when the contract lives where the other party cannot see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:17:41 +00:00
sylph-decoder
0fdce00273 re: close the sweep-leaf thread -- deliver it to HANDOFF, and record why one
check counted more than the others

An answer not reachable from HANDOFF is not delivered, and this thread lived in
messages and docs/re/. Delivering it: the leaves ARE drawn and free-run on a
settled title, so a settled screen is not a static screen; the leaves are
identical on entries 4, 5 and 7; how the game advances them is undecodable with
reach, four models each refuted by a measurement; and keyframe_units_per_second
should stay put because the port's 1.87x table has an input now known to be wrong
rather than merely unpinned.

METHOD gains the lesson sylpheed-port named, which outlives the thread. Almost
every cross-check here runs disc -> exporter -> export against disc -> our reader:
two paths that agree because they read the same bytes with the same
understanding, and that agree anyway when the understanding is wrong. That is how
this corpus produced a build-ordinal error where everything still validated, a
two-era comparison with the same binary on both sides, and a rate confirmation
that was a prediction 20 % low meeting a measurement 50 % high. The chain that
counts ends at the ORACLE, and the single instance this week was the leaves'
rotation: +30 / -45 from the file predicting AABB heights of 1135.3 and 1301.1
against 1134 and 1303 in the draw stream. Before quoting an agreement, ask where
the two paths diverge -- if it is after the fact in question they are one witness
wearing two coats.

Also records that a refuted model is a result: four refutations, each with the
measurement that closed it, is firmer than any one of them fitting would have
been, because the failure this corpus keeps hitting is a model that fits for the
wrong reason and nobody checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:10:22 +00:00
sylph-decoder
2298abe33e re: the leaf's clock is undecodable with reach -- and the rotation is confirmed
Four models for how the game advances the sweep leaf, four refutations.

Frame-locked predicts px/frame unchanged under --framerate_limit; measured
-4.348 -> -2.032. Wall-clock predicts px/frame LARGER at a lower limit; it got
smaller. sylpheed-port's alternative -- that my samples might be at a fixed
wall-clock rate while guest time slows, which would reproduce the direction --
checked rather than assumed: every capture reports 150 frames spanning 1..149, so
the capture is indexed by guest VdSwap submissions. And per UI-drawing frame,
which matters because at limit 15 only 99 of 150 frames carry draws against 131
at default, gives -5.773 vs -3.687, ratio 1.57, not invariant either.

Three measures of one slowdown -- 3.58x on the boot, 2.14x per submitted frame,
1.57x per appearance -- and no two agree. The clock is none of the four and the
absolute rate stays unpinned. Recorded as undecodable with the reach stated.

Two things the same data does establish.

The rotation is confirmed FROM THE ORACLE. The port's export carries rotation_deg
+30 on pteff03 and -45 on pteff03a, read from the file. The AABB height of a
rotated quad predicts from the declared scale alone: 400x1080 at +30deg -> 1135.3
against 1134 observed (0.12 %), and 400x1440 at -45deg -> 1301.1 against 1303
(0.15 %). Two angles, two scales, both under 0.2 %.

Which makes their inversion real. Height 1134 IS pteff03 -- the leaf whose
declared track is perfectly linear at +4.0000 px/unit across both segments -- and
that is the strip my sign-change gate FAILS. Height 1303 is pteff03a, the
slightly non-uniform one, and it passes. The curvature is in the strip whose
source is exactly straight, so it is not in the disc: it is in the measurement or
in how the game advances the record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:07:16 +00:00
sylph-decoder
1538c3e333 re: the leaf is NOT frame-locked -- and a residual gate disqualifies half my fits
The frame-rate test sylpheed-port and I agreed was the only clean route left.
Same strips, same screen, --framerate_limit=15 against the default. The limit
demonstrably took effect: the title settled at 862 s against 241 s.

First, a gate this work should have had from the start. A slope is only a rate if
its residual is random, so count sign changes in the residual:

  default   1299x1303  -4.348  rms 3.33  43/111  OK
             883x1134  +4.284  rms 3.08  21/76   SYSTEMATIC
             890x1134  +4.284  rms 3.19  15/54   SYSTEMATIC
  limit 15  1299x1303  -2.032  rms 1.51  44/83   OK
             883x1134  +2.003  rms 1.12  36/61   OK
             890x1134  +1.999  rms 0.74  12/37   SYSTEMATIC

So one of the two strips I quoted as "agreeing to three significant figures"
FAILS the linearity gate at default fps: that agreement was between a rate and a
slope through a curve. The port had already caveated the claim for a different
reason; this weakens it further from my own side.

The result, on the one group passing the gate at both settings: -4.348 px/frame
at default against -2.032 at limit 15, a ratio of 2.14.

THE LEAF IS NOT FRAME-LOCKED. A fixed number of units per submitted frame
predicts px/frame unchanged; it changed by 2.14x. Dead.

A simple wall-clock model is dead too, in the other direction: fewer frames per
second means more wall time per frame, so a time-driven leaf should move MORE
px/frame at a lower limit. It moved LESS. Neither model fits and I have no third.

Reach: the effective frame rate was NOT measured. The timing instrument I added
polls for the capture log, which is created when the capture is ARMED rather than
when it finishes, so it reported 0.728 s and is void. The 3.6x boot slowdown says
the limit took effect, not that fps went 28 -> 15. The RATIO is measured; the
absolute rate still is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 17:03:15 +00:00
sylph-decoder
10b0dbe5c4 re: three routes to remove the fps caveat, all closed -- 1.07 stands on one capture
sylpheed-port's caveat on my 1.07 units/frame: two strips agreeing to three
significant figures constrains the strips to each other, not the absolute rate,
because both ratios come from one capture under one fps assumption. They are
right and I could not remove it. Recording the three attempts and why each fails,
since a closed route is worth as much as an open one.

Route 1, compare the leaf to a TOP-LEVEL clock in the same capture so fps cancels:
not available. On a settled title nothing top-level moves -- that is what settled
means -- and every varying quad in the capture is a leaf. The plate looked like a
candidate (538x76, clean ~56-frame pulse) but build 2's ptbtn00 is a one-shot fade
at t=0,214,236,238,244; the repeating pulse comes from its own nested .rat.

Route 2, fit the same strips in the transition captures, which DO carry a
top-level clock (the fade quad, 8 declared units at 2.0 units/frame). The strips
are present but the fits are not measurements: rms residuals of 26.70 and 16.75 px
against 147 px of travel, versus 3.59 px against 627 px in titledraw2. Scatter,
not a line. The apparent disagreement between captures is a NON-measurement, and
quoting 1.94 or 3.57 as a second sample would have repeated the 6-7 px/frame
eyeball error one message after withdrawing it.

Route 3, read fps from the emulator's own log: not printed.

So 1.07 rules out a per-record quirk and does not pin the absolute rate. The test
that would is measuring the same strips at a deliberately different emulator frame
rate -- unchanged px/frame means frame-locked, scaling with 1/fps means wall time.
Not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:39:50 +00:00
sylph-decoder
dd7edd5e38 re: withdraw "the sweep rate matches the disc" -- two errors that cancelled
sylpheed-port corrected the disc figure I compared against: the leaf's final
segment HOLDS, so a cycle length is not a motion duration. Verified from the
disc rather than accepted -- pteff03 moves over t=0..540 of a 600-unit cycle
(4.000 px/unit, not 3.600) and pteff03a over 0..630 of 720 (4.063, not 3.556).

Checking that sent me back to my own measurement, which was worse. "6-7
px/frame" came from eyeballing deltas between consecutive APPEARANCES in a
capture that skips frames, so a delta of 7 often spans two frames. A
least-squares fit of x against frame over all 132/112 points gives +4.287 and
-4.348 px/frame, with rms residuals of 3.6 and 3.3 px.

So the confirmation I reported was a prediction 20 % too low meeting a
measurement 50 % too high. Neither number was right and the agreement was an
artefact of both being wrong -- which is the most dangerous form of agreement in
this corpus, because nothing about it looked suspicious.

The corrected numbers say something larger than the claim they replace. Measured
against declared: 4.287/4.000 = 1.072 units per frame, and 4.348/4.063 = 1.070.
Two independent strips with different cycle lengths and different declared rates
agree to three significant figures. Q1 establishes 2 units per RENDERED frame for
top-level elements. So either a nested leaf record advances at about half the
top-level rate, or Q1's factor does not apply to nested records. Measured, not
explained, and flagged as deserving its own iteration because Q1 is load-bearing.

One untested candidate recorded: 1 unit per 1/30 s of game time against the
~28.5 fps this corpus measures for the idle title gives 1.053, close to 1.07.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:35:09 +00:00
sylph-decoder
d80519e0c3 re: the game DOES draw the sweep leaves at rest -- my own hypothesis refuted
Last iteration I hypothesised that the game might not draw pteff03/pteff03a on a
settled title, which would have explained three things at once: the flat --at
plateau, the 0.32 between-session in-box term, and part of the ~40 residual. The
oracle says no.

A draw capture of the settled EN title -- gated on the plate pulse, fired at
325.3 s, with exactly ONE emulator verified by count -- shows two quads taller
than the 720 px screen present in every one of 132 frames and sweeping in
OPPOSITE directions:

  strip A  h=1134  ROT  x -109 -> +518   step +6..7 px/frame
  strip B  h=1303  ROT  x +486 -> -154   step -6..7 px/frame

The rate matches the disc: the declared x track is -639..1521 = 2160 px over a
600-unit cycle = 3.6 px/unit, and at 2 units per rendered frame that predicts 7.2
px/frame against 6-7 measured. Both quads are flagged ROT, which is why their
axis-aligned bounding boxes are ~885 and ~1300 px wide where the declared quad is
400 -- consistent with rotation living in leaf records and with `screen render`
being axis-aligned only.

So the leaves ARE drawn and DO free-run on a settled title. My hypothesis is
refuted and sylpheed-port's reading of their `title` curve -- sweep present in a
title capture -- is confirmed by the oracle rather than by a render.

What this does NOT settle is the tension that prompted it. Both strips cross the
adjudication box in x and cover it in y, so two JP captures at different phases
should differ there, and they differ by 0.32. Two candidates, neither tested: the
two JP shutters happened to fall at similar phases (a ~1 % coincidence for a
600-unit cycle), or build 7's denser logo stack -- the katakana plus the
crystalline burst that jp-title-at-rest.txt records as absent from the English
title -- occludes the sweep inside that box. A draw capture of the JP title
distinguishes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:31:03 +00:00
sylph-decoder
4c92f54439 re: make the one-emulator rule enforceable instead of remembered
The withdrawal last iteration was caused by `rm -f /tmp/xenia-canary.lock` --
the obvious way past a lock orphaned by kill -9, which also disables the guard
for every later launch. Three instances ended up live at once, sharing
/tmp/xenia_pad.txt and display :98, and silently confounded an input experiment.

Care is not a fix, so this is tooling. ensure_single_emulator.sh counts live
instances, stops them (plain kill, then -9, each with a bounded wait), verifies
ZERO, and only then removes the lock -- refusing loudly if any remain. The lock
is never removed before the condition it guards against is verified absent.

FOUR scripts did the bare `rm -f`, and only two were mine from today:
menu_loop_session.sh, title_draw_capture.sh, poke_control.sh and
resume_reliability.sh. So the footgun was corpus-wide rather than introduced
this session. All four now route through the guard.

The guard is controlled rather than assumed: run against a deliberately started
live instance it reports "1 instance(s) live -- stopping them", ends at 0 with
the lock cleared, and exits 0. A guard that only ever passes on an already-clean
slate would prove nothing.

It also kills by process NAME. `pkill -f xenia_canary` matches the shell running
it -- that has now cost this corpus three commands, one of them a cleanup that
died halfway and left the very instances it was meant to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:23:59 +00:00
sylph-decoder
969b816aad re: WITHDRAW the (A)-press finding -- three emulators were live at once
While chasing the draw-stream question I found THREE xenia instances running
simultaneously (started 15:39, 15:44, 16:12), which violates the "one emulator at
a time" hard rule and confounds the finding I recorded last iteration.

All three read the same /tmp/xenia_pad.txt and share display :98. A press written
to that file is delivered to EVERY instance, while `screenshot` grabs whichever
window is topmost -- not necessarily the one that acted on it. So "(A) was
delivered and the screen did not change" may simply be two different emulators,
and the keystroke-level confirmation proves only that SOME instance received it.
The navigation.md entry claiming the boot title's "2 of 2" is no longer 2 of 2 is
withdrawn as unsupported, pending a clean re-run.

The cause was mine. run-canary's lockfile is the IMPLEMENTATION of the one-at-a-
time rule; a kill -9 orphans it, and the obvious unblock -- rm -f the lock --
also disables the guard for every later launch. I did that more than once today.

METHOD gains two entries. A lockfile is the rule, not an obstacle to it: clear a
stale lock only after confirming zero live instances, and COUNT them rather than
trusting a kill landed, because a plain kill is asynchronous and a -9 on a stuck
process can take seconds. When a guard blocks you, the question is whether the
condition it guards against is present, not how to remove the guard.

And a third instance of pgrep -f matching the shell that runs it -- this time it
killed a cleanup command halfway through, leaving the emulators alive and the
lock in place. Already recorded for wait-loops; promoted to "reach for -C first".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:20:24 +00:00
sylph-decoder
21de7841da re: the crop is not why the box is robust -- and it questions "the leaf free-runs"
sylpheed-port could not transfer the masking rule to their screens and inferred a
precondition: my free-running element is a localised plate I can crop around,
theirs is a wide sweep they cannot. Tested against my own screen, that is wrong.
The JP title carries the SAME sweep -- the leaves are identical on entries 4, 5
and 7, which I established last iteration -- and it crosses the box:

  two renders of build 7 on the settled plateau, t=135 vs t=240
    whole frame  RMSE 12.135   95 791 px
    in the box   RMSE 11.923   57 981 px      <- the sweep IS inside the box
    differences span y 70..674, x 128..1140; the box is y 54..476, x 389..776

So the crop did not exclude the mover, and the in-box between-session term of
0.3215 has no explanation in the crop. Which leaves a tension worth stating:

  two RENDERS one plateau-phase apart differ by 11.9 inside the box;
  two CAPTURES of that screen from different sessions differ by 0.32 there;
  and the --at sweep of renders against a capture is flat to 1.2 across
  t=135..240, despite those renders differing from each other by 11.9.

A metric cannot be insensitive to an 11.9 change unless what changed is largely
absent from what it is compared against.

Hypothesis, recorded as untested: the game may not draw these leaves on the
settled title at all, while our renderer poses them wherever --at says. That
would explain the flat plateau, the tiny between-session term and part of the ~40
residual together. It would also mean the port's "the leaf free-runs in the game
too" is not established by their evidence -- their two minima come from two
DIFFERENT screens, which can differ for reasons other than phase, whereas my two
captures are of the same screen and barely differ where the sweep would be.

Not claiming the leaves are invisible; that needs a draw-stream check for
pteff03/pteff03a on a settled title, which is one run. What is established is
narrower and enough to stop the inference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:09:55 +00:00
sylph-decoder
3da57ee515 re: measure the capture-phase term -- 4.6 whole-frame, 0.32 in-box
sylpheed-port overturned their own phase-0 result using the identical-leaves fact
I gave them: the same leaf minimises at phase 240 against a title capture and 0
against a main_menu capture, so the best-matching phase is a property of when the
shutter fell rather than of the game's rest state. A continuously sweeping
element has no canonical rest phase. They warned that any whole-frame score
against a single capture carries a phase term of ~1.0 RMSE.

Measured on my own two JP sessions, which certainly differ in sweep phase
(44 025 px differ in the band the leaf crosses):

  whole frame                4.566
  sweep band x721..1241      4.088
  the adjudication box       0.3215

Their ~1.0 understates it for this screen: a whole-frame score against one
capture of the JP title carries ~4.6. Theirs is the leaf-phase component isolated
in a renderer; mine is everything that varies between sessions -- the plate pulse
alone contributes ~2.8, measured separately on the EN peak/trough pair -- and
includes theirs.

My margins are unaffected and now for a measured reason rather than an assumed
one. The era margin of 16.72 sits against an in-box term of 0.32, and
settle-vs-rest at 1.48 is 4.6x that term while remaining non-decisive against the
render-axis plateau of 1.2, exactly as stated.

Scoring the 388x423 box rather than the frame drops the between-session term from
4.566 to 0.3215, a factor of 14, because the sweep contributes at x 721..1241 and
the box is mostly clear of it. That was NOT why I cropped -- the crop was to stop
a local difference being diluted across 92 % of an identical frame -- so the
robustness is luck. The rule it earns: score inside a region that excludes the
free-running elements, and measure the residual term there rather than estimating
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:04:12 +00:00
sylph-decoder
2145691586 re: verify the sweep leaves' full extent -- port's table confirmed, plus a new fact
Attempted to refute sylpheed-port's leaf table by measuring it against the disc.
It SURVIVES to the digit: ptloop01 -> pteff03, cycle span 600, x track
-639..1521, scale (100, 600); ptloop02 -> pteff03a, span 720, x -839..1721,
scale (100, 800).

The existing ptloop_leaf_sweep_at.rs samples only t=340..540 -- a window chosen
to compare two competing fits -- so it could never have shown the extent. That
gap is what let my "ptloop01/02 do not free-run" claim stand: measured over the
parent's 200x90 pivot rect, which a leaf travelling -639..1521 is almost never
inside. ptloop_leaf_extent.rs sweeps the whole cycle instead.

New fact neither of us had: the leaves are IDENTICAL on entries 4, 5 AND 7 --
the title, the main menu and the JP title. Same leaf names, spans, x tracks,
scales and parent rest position. So the menu declares exactly the same sweep as
the title, and the still-open menu question is about the game's behaviour rather
than a different declaration.

The quad is 400 px wide at scale_x 100 % -- not widened -- and scale_y 600/800 %
makes it 1080/1440 px tall, taller than the screen. A full-height strip crossing
the frame and going off both sides, which is why a phase-to-phase diff covers the
union of two positions and looks frame-wide.

And my own "centre running x~921->1041" was a 30-unit window of a 600-unit cycle
whose centre spans -439..1721. A sub-range is not an extent -- the same caution
as a pivot not being a bounding box, one level up, and I made both errors within
a day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 16:01:13 +00:00
sylph-decoder
cce20b83ca re: WITHDRAW "ptloop01/02 do not free-run" -- I measured a pivot, not an extent
sylpheed-port noted that build 5's ptloop parent can be static while the leaf
record animates, and asked me to check it against my table. My own corpus
refutes my claim outright.

ptloop-leaf-sweep-positions.txt -- written earlier in this same corpus -- records
ptloop01.rat's nested record at loop length 600, whose leaf pteff03.t32 sweeps a
400 px-wide quad with its centre running x~921->1041 over t=340..370. The
parent's declared rect is (441,270) 200x90. The leaf draws 300 px outside it: the
parent rect is a PIVOT ANCHOR, not the drawn extent.

Checked against the two JP captures: my measured rect differs by 0 px -- and so
does the whole dead region y 270..450 x 480..960 around it -- while the band the
sweep actually occupies (x 721..1241) differs by 44 025 px. The zero was measured
where nothing happens.

So the port's reading is right and now confirmed from the disc: parent static,
leaf animates, and the two nested records cycle at DIFFERENT lengths, 600 and
720. My "single static keyframe" described the parent only. The era adjudication
is unaffected -- its box overlaps the sweep band only at x 721..776, which shows
no between-session differences.

The menu-loop question is still unsettled after a second attempt, and the second
attempt's failure REFUTES my diagnosis of the first. menu_loop_probe.py gated on
the plate pulse (glyph in [500,2500] held 12 samples), fired at t=484.5 s with
glyph 1723 -- a verified settled BOOT title, not the attract one -- pressed A,
and the press was delivered ([file-pad] keystroke vk=5800 down/up, 8 RE-INPUT
lines). Twenty seconds later all five frames still classified as the title
(rmse ~67-70, margins 0.06-0.16, the "neither" signature; screen_id says title).
So "the attract title accepts nothing" does not explain attempt 1, and the
corpus's "the boot title accepts a single A, 2 of 2 runs" is no longer 2 of 2.

METHOD: a declared rect can be an anchor, not an extent -- confirm an element
draws in a region before diffing that region to ask whether it moves.
navigation.md: confirm the screen changed, do not infer it from a delivered
press.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 15:55:31 +00:00
sylph-decoder
5a49511952 re: ptloop01/02 do not free-run on the settled title -- and the menu is not settled
sylpheed-port found ptloop01/02 free-running in their renderer on the menu path,
pinned them, and was explicit that pinning picks one pose rather than the game's:
"a capture question, not a harness one". It is, and it lands in my lane.

On the title it is now answered. Those leaves rest at (441,270) 200x90, INSIDE
the box the ptlogo_eff3 era adjudication uses, and across my two JP captures from
different sessions they are byte-identical: 0 of 18 000 px, max |d| 0, against a
whole-frame contrast of 116 492 px differing. So they are static at rest, and the
in-box between-session noise of 0.32 is not theirs -- the 645 differing pixels
all lie in a 30-row band at y 99..128, nowhere near the loop rect. That also
closes the reach caveat on the EN->JP noise transfer.

The MENU is a different bundle and is not settled. Build 5 declares the same rect
with a single static keyframe, and that is where their row drifted.
menu_loop_rest.sh was written to capture five settled menu frames and diff the
rect; it did not complete. The run reached a title at t=146 s and (A) did not
take across six attempts -- the documented intermittency where the attract loop's
title accepts nothing, unlike the boot title. Recorded rather than re-rolled.

Two committed main-menu captures cannot substitute: they differ across 57 % of
the surface (different geometries and capture paths), so the 88 % differing on
the loop rect measures the mismatch, not the loops. The control fails and the
comparison is void.

navigation.md gains the trap that cost this iteration a run: kill -9 on xenia
orphans /tmp/xenia-canary.lock, the next run-canary refuses to STDERR where a
polling script never looks, and a probe then sampled a dead display for 484 s
reporting `other` every 4 s -- because screen_id.py on an empty screen returns
`other` and "not the title yet" is indistinguishable from "there is no emulator".
Kill plainly so it clears its own lock, and assert the emulator is alive before
entering any wait loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 15:43:29 +00:00
sylph-decoder
281a058aca re: a second JP capture closes the transfer -- and corrects my own noise claim
Last turn I transferred the EN title's capture noise to the JP box and flagged
the gap: build 7 carries ptloop01/02.rat which may animate inside that region
where the EN plate does not, and the era adjudication rests on a single capture.
Took a second, independent capture from a fresh boot in a separate session
(jp_title_session.sh -- sets ja, captures, always restores en; verified back at
language=1).

Within-run stability reproduces: 0 of 138 600 px in the ROI across four
comparisons, with 47k-73k px moving whole-frame as the contrast control.

BETWEEN SESSIONS, inside the box the adjudication uses: 645 of 164 124 px
differ, RMSE 0.3215, against 116 492 px whole-frame -- genuinely different
sessions. And the verdict reproduces to three decimals: stale 58.412 -> 58.413,
fixed 41.690 -> 41.692, margin 16.722 -> 16.721.

The shape is the useful part: capture noise moves both candidates together, so it
nearly cancels in a MARGIN. Absolute scores moved 0.001-0.002 while the margin
moved 0.001 against an in-box noise of 0.32. A margin between two renders scored
on one capture is far more robust than either score is.

CORRECTION to a claim I made earlier today and sent to the port: I said the
settle-vs-rest negative was STRENGTHENED because 1.48 sits below the whole-frame
capture spread of 2.8. Wrong comparison -- the measurement lives in the box, and
in-box between-session noise is 0.32, so 1.48 is well above it. The negative
rests on the render axis alone (1.2, ratio 1.2x), exactly as first stated. I
reached for a number that was to hand rather than the one that applies, which is
the same family as the errors we have both been cataloguing.

METHOD gains: match the noise floor to the quantity, including which noise
applies; and sylpheed-port's point that an instrument which rounds away the thing
being verified cannot verify it (they called a harness reproducible from an RMSE
printed to two decimals when the residual was 0.0565).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 15:24:56 +00:00
sylph-decoder
f16338bc88 re: measure the CAPTURE-axis noise floor -- 0 in-box, and the era result survives
sylpheed-port found their main_menu row drifting 13.25-13.30 across runs on a
free-running spin clock, and made the general point that a margin only means
something against the noise it sits on. My --at plateau measures the RENDER axis;
it says nothing about how much the score moves between two CAPTURES of the same
screen, which is what a single JP grab is exposed to.

Measured from two independent captures of the settled EN title at different
phases of its free-running plate pulse, scored against one render:

  whole frame     peak 31.302  trough 28.463   spread 2.839
  inside the box  peak 21.230  trough 21.230   spread 0.000

The zero carries its control: the two captures differ by 83 496 px whole-frame
(max |d| 174), so they are genuinely different grabs, and by 0 inside the box --
the screen's free-running element is the plate, which lies outside the logo
region the adjudication uses.

Margins re-stated: stale-vs-fixed 16.7 is 14x the render noise and >=5.9x the
whole-frame capture noise, so the era result survives on both axes. And the
settle-vs-rest negative is STRONGER than first stated: 1.5 is not merely inside
the render plateau's 1.2 flatness, it is below the whole-frame capture spread of
2.8 as well.

Reach recorded: this transfers the EN title's capture noise to the JP title's
box, and build 7 carries ptloop01/02.rat which may animate inside that region
where the EN plate does not. A second JP capture would settle it and has not been
taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 15:17:22 +00:00
sylph-decoder
ee5ed2cfd2 re: count the in-range fallbacks -- and the control that failed is the answer
sylpheed-port pointed out that classifying defaults "by inspection" is exactly
the method that cannot see an in-range fallback, and that correction applies to
my own sweep from an hour ago: I waved 64 sites through by reading them.

Counted instead, disc-wide over 965 builds and 24 811 keyframes:
  ui_layout.rs:1681  untimed poses (would fabricate t=0):        0
  ui_layout.rs:1010  pose_at queries 168 264, None (reads a=0):  0

Two zeroes, which is the result this corpus now distrusts most, so the detector
was made to prove it can see a hit: ask pose_at for a time no build declares.
The control FAILED -- 10 906 out-of-range queries, 0 None -- so the detector was
blind and the :1010 zero measured nothing.

The failure is the finding. pose_at is TOTAL: reading the source, its only None
path is an `if ks.is_empty() { return None }` guard, and disc-wide there are 0
elements with zero keyframes out of 5 453. So :1010's unwrap_or(0) is unreachable
BY CONSTRUCTION, which is stronger than "0 in this corpus" -- and it was
established by the control failing rather than by the count passing. Without the
control this corpus would have recorded a true conclusion resting on a
meaningless number.

:1681 stands differently: 0 of 24 811, and time really is Option<u32> with the
stale reader demonstrably producing None (its screen info prints a trailing -),
so the state is representable and a detector would see it. :973 is not a hazard
-- guarded two lines later by `if tmax == 0 { return false; }`, where reading is
sufficient because the guard is the proof.

METHOD gains both: a zero is worth nothing until the detector is shown able to
report non-zero; and the habit under several of this week's errors, which is
reading a PROXY for the thing when the thing itself is one command away -- a line
count for an era, a type name's spelling for its default, an ordinal for an
entry, a fallback's text for its firing rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 15:15:42 +00:00
sylph-decoder
593ce46069 re: sweep my own crates for fallbacks that fabricate a quantity
The mirror of sylpheed-port's sweep after their exit_ramp_units catch, where a
refuted 24.0 survived in a `get(..., 24.0)` fallback because the authored entry
had been deleted as progress and the deletion was a no-op.

112 fallback sites across sylpheed-formats and sylpheed-cli. 64 supply 0, false,
empty or Default -- sentinels asserting nothing. Of the 48 remaining most are
pass-through or an extent. Positive control: the filter found media.rs:314
unwrap_or(anchor), the voice-region start fallback landed earlier this session,
so the detector finds a known case rather than only reporting absence. The
mesh.rs cluster (1.0, 0.85, 0.5, 0.70, 0.45) is env-var tunables with defaults
documented in xbg7-mesh.md.

ui_layout.rs, the crate the port pins, has 8 sites; 6 sentinel or pass-through
and 2 that could fabricate a quantity. Both fabricate a value that is
LEGITIMATE, which is worse than the port's conspicuous 24.0:

  :695  unwrap_or((DESIGN_W, DESIGN_H)) -- 1280x720, which is what every real
        screen states, so no parser output can distinguish read from invented.
        MEASURED: it fires 0 times in 965 builds disc-wide, so design_w/design_h
        is read and the port can rely on it.

  :1681 kf.time.unwrap_or(0) in the serialiser -- 0 is a real keyframe time
        (pose 0's time IS 0). Unreachable today under the corrected record
        layout, the same status as their exit_ramp_units branch, but a
        fabricated 0 would be indistinguishable from a real one.

The measuring instrument failed its own control first: a version reading EVERY
RATC child reported all 965 builds stating a non-standard design size
(GP_TUTORIAL 12x3), where `screen list` prints 1280x720 for every one -- a T8aD
sprite header read at +0x18 is garbage that passes the range test. Filtered to
the .rat records, it reproduces screen list exactly.

METHOD: a fallback default is an authored value no reader can see, and the
dangerous ones are IN-RANGE -- the only way to know is to count how often they
fire, which no parser output reveals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 15:06:15 +00:00
sylph-decoder
c7237edc9b re: an uncontrolled capture instant, and why the ptlogo_eff3 result survives it
sylpheed-port's harness grabbed main_menu at t=9.00 in one session and t=8.00 in
the next. One keyframe unit apart, mid-build-in, is 70 % of the picture, and it
read as "the change broke two screens" -- a real measurement of the wrong thing.
The instant was stable WITHIN a session and drifted BETWEEN them, so every cheap
reproducibility check said deterministic. Their flags are their harness's, not
sylpheed-cli's (checked: `screen` has only list/info/render), so the tool defect
is not in my crate -- but the hazard generalises to every live capture here.

It would void this iteration's ptlogo_eff3 adjudication if the JP capture had
been taken at an arbitrary moment. It was not, and for two independent reasons
recorded rather than assumed: the grab was gated on the plate pulse, the title's
own settled signature, with the gate and a contrast control written beside the
capture in jp-title-at-rest.txt; and the --at sweep shows the capture on a
plateau flat to 1.2 RMSE across 105 units against edges at 78, where a capture
caught mid-build would give a sharp minimum. The sweep was run for a noise scale
and answers this too -- which is luck, so METHOD now names both defences.

METHOD: pin a capture's instant explicitly, and do not infer stability from
repeat runs inside one session. Gate the grab on a settled signal prospectively,
and sweep --at retrospectively -- a broad flat minimum with sharp edges means at
rest, a sharp minimum means the instant is load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 14:57:55 +00:00
sylph-decoder
0c2910636a re: audit my own era comparison against the shared-target-dir trap -- it holds
sylpheed-port found that a shared CARGO_TARGET_DIR across two source trees
serves one binary to both: cargo fingerprints per source path, so building tree B
into a directory last written by tree A prints "Finished" in 0.15 s and changes
nothing. Their harness scored against a decoder from an unnamed tree for hours,
and it HAPPENED to be the correct era -- agreeing by luck, which is worse than
disagreeing because nothing prompts the check.

My own era comparison could have died the same way, so I audited it rather than
assuming it was fine: the two binaries have different md5s (7516bdac vs
8370e0e9), they were built into SEPARATE target dirs, and the era control still
distinguishes them live -- `rest t=70 [12 70 80 -]` against `rest t=12
[0 12 70 80]`. The result in ui-resting-pose.md stands, now with the evidence
that it stands.

Also raises main-is-the-stale-era.md from hygiene to a defect anyone can build.
Two things landed since it was written: the corrected association is now
confirmed against the running game (RMSE 41.69 vs 58.41 on a live title_jp
capture, a margin ~14x the noise floor), and the port confirmed their published
branch carries the stale ui_layout.rs, md5-identical to main's. So every
published ref except this branch ships the decoder the game disagrees with, and
nothing in a build warns you. Merging is a human's call; the page records the
cost of not making it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 14:53:41 +00:00
sylph-decoder
84cf98477f re: the record-layout fix is confirmed against the GAME, and settle-vs-rest is not
The era test left one element responsible for all 74 507 differing pixels on
title_jp -- ptlogo_eff3.t32, the corpus's named plateau-less rest() discriminator
-- with two candidate rest poses, (108,72) stale and (98,42) fixed. There is a
capture of that exact screen, so the oracle can choose.

Scored over the 388x423 box where the two renders differ, so the result is not
diluted by the ~92 % of the frame that is identical:

  stale era  rest (108,72)   RMSE 58.412
  fixed era  rest  (98,42)   RMSE 41.690   <- the game agrees with the fixed era
  fixed era  --settle t=213  RMSE 40.210

Until now the keyframe record-layout fix rested on internal consistency: 0 of
1 042 multi-segment alpha ramps constant-rate under the old reading against 857
of 1 540 under the new. Strong, but not a measurement of the game. It now has
one, on the single screen where the two readings change pixels.

Three controls, all run first. Alignment found by sweeping the vertical offset
rather than assuming it -- 45 gives 32.41 against 56.37 and 53.08 either side, a
sharp minimum at the known game-surface offset. The scoring box discriminates:
the same box against a different screen's capture gives 98-103 against 40-58
here. And --black changes nothing (58.412/41.690 either way) because every pixel
in that box is covered by an element -- recorded because the flag's help says a
framebuffer capture must be compared against a black canvas, and here it happens
not to matter.

Sweeping the screen's own timeline with --at gives the noise scale: the capture
sits on a plateau from t~135 to t~240, flat to 1.2 RMSE across 105 units, rising
sharply outside (78 at t=0 and t=270). So the stale-vs-fixed margin of 16.7 is
~14x that flatness and decisive, while the settle-vs-rest margin of 1.5 is INSIDE
it and is not. This capture separates the eras and cannot separate the policies;
the settle-instant proposal stays unadopted.

Refutation attempted: sylpheed-port's adjudication that their shipped pose is
closer to the game than their reference. It SURVIVES, independently and by a
different metric, in the same direction.

Also concedes that my "your branch is the stale era" reasoning was invalid -- I
inferred era from a line count, which is the error they named -- while recording
that the conclusion holds for the ref I could see: origin/auto/port-p6-audio's
ui_layout.rs is md5-identical to origin/main's.

METHOD: two things that should differ producing identical output is a broken
experiment until proven otherwise, and a zero is its most dangerous form. Four
instances now. Verify the inputs differ before believing the outputs match, and
do not infer that difference from a proxy -- line count is not era.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 14:48:30 +00:00
sylph-decoder
82ce9dbaaa re: the decoder eras DO change pixels -- 7 of 16 bundles, and title_jp is one
The reach test I deferred twice. sylpheed-port tested three screens across the
stale and fixed ui_layout.rs eras, found 0 differing pixels, and concluded the
eras explain nothing. Built both eras from source and rendered EVERY composable
GP_TITLE bundle through each.

7 of 16 differ. Entries 0-6, 8 and 9 are byte-identical -- which includes title
(4), main_menu (5) and extras (6), so the port's result reproduces for the
screens they picked. Entry 7 (title_jp) differs by 74 507 px, RMSE 12.409; the
two loading bundles by 49 771 px, RMSE 10.078; the four splashes by 23-33k px at
RMSE 0.94-1.78.

So "the eras explain nothing" is true for three screens and false for the
archive. It is specifically false for title_jp, which is one of the two rows
their check-all now allows BY NAME with the reason "rest-pose sparkles". Their
measurement of that screen was 0 and mine is 74 507; recorded with exact flags as
a disagreement for them to check, not adjudicated. Noted that their branch's
ui_layout.rs is the stale one (20 ins / 488 del against the pin), so a binary
built from their workspace HEAD is the stale era.

Two controls, both run first. The binaries genuinely embody the eras: build 5's
pteff00.prm reads `rest t=70 [12 70 80 -]` stale against `rest t=12 [0 12 70 80]`
fixed. And the renderer is deterministic: same binary, same flags, twice, 0
differing pixels on entries 7 and 12 -- without which every number is noise.

Mechanism on entry 7 is a single element, ptlogo_eff3.t32, rest (108,72) ->
(98,42). That is the element MISSION.md and ui-resting-pose.md already name as
THE plateau-less rest() discriminator, so the era difference on the JP title is
our existing open question surfacing rather than a new one.

And a trap: entries 10-15 differ by up to 49 771 px with NO rest position change.
The rest selection moves to a keyframe at the same (x,y) with a different scale
and alpha. My first extraction compared only the rest (x,y) column and would have
reported a difference with no cause. A pose is position and scale and alpha.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 14:42:29 +00:00
sylph-decoder
e803930e88 re: the black gap between screens is NOT a load -- measured, three legs
sylpheed-port's BLOCKED ask #2. The gap was the one quantity in the transition
with no rule: measured at 0, 3 and 2 frames across three transitions, and I had
proposed it might be a load, which would make it emulator- and storage-dependent
and unauthorable.

Leg 1, bundle size runs the wrong way. If the gap were the incoming bundle
arriving, the biggest bundle would gap longest. Build 4 is 12 278 666 B and gaps
ZERO frames; build 5 is 6 977 437 B and gaps 3 and 2.

Leg 2, ran title->menu a second time from cold. The outgoing ramp is
byte-identical (63, 127, 191, 255) and the gap is 3 frames in BOTH runs.

Leg 3, and the two runs are not a null comparison -- which is the objection leg 2
invites. The captures refute it themselves: press-to-first-change differs by ~12
frames between them (~25 against ~10). Something in this transition really is
cache-sensitive and moved by 0.4 s, while the gap did not move at all. The
control comes from inside the measurement rather than from an assumption that
conditions differed.

So the gap is deterministic to the frame and not a load. It is also not constant
across transitions (0, 3, 2, 3) and not in the fade group -- the port reports 866
keyframes across 16 screens with 0 untimed. A deterministic game quantity with no
rule found; black_hold_units stays 0, and "not a load" must not become a reason
to author a constant.

The load proposal in screen-transitions.md is marked refuted rather than deleted.

Also fills in docs/game/navigation.md, which the standing brief asks me to keep
and which I had not touched while measuring four transitions: a player-side
section on what a screen change looks like, and three scripting traps -- that
`pkill -f xenia_canary` kills the shell that ran it (cost a launch today, and the
same trap is already in METHOD for pgrep), that screen_id.py reports `menu`
during the attract loop, and that it cannot tell EXTRAS from the main menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 14:28:00 +00:00
sylph-decoder
6afe0c4ab8 re: main is 171 commits behind and carries the STALE keyframe reader
Went to refute sylpheed-port's finding that their check-all allowance ("the pin
is not on main, so this compares two decoder eras") explained nothing, by
extending its reach past three screens. Found the premise is understated.

  git rev-list --left-right --count origin/main...HEAD  ->  0  171

main has nothing we lack; we are 171 commits ahead of it. Its tip is dated
2026-08-29, the pin tag is not an ancestor of it, and its ui_layout.rs still
reads "Keyframe time, or None for the group's last frame" -- the pre-fix
association that takes a pose's time from the NEXT record and leaves a group's
final pose untimed. That is the exact bug that left fade_quads.py stale and made
screen-transitions.md publish a 0.87-4.08 s fade-in for a 0.20-0.27 s ramp.

So "sync with main first" is currently backwards: main is the stale era, and
`git merge origin/main` is a no-op from any topic branch -- it says "Already up
to date" every iteration, which reads as "I am current" and means "main has
nothing". Do not pin to main.

The port's measurement stands: across their three screens the two eras render
identically, which is consistent with the association fix moving TIMES and not
pixels -- same signature as the .tbm and stale-sylpheed-cli findings.

Not settled: whether the eras render identically beyond three screens (the
disc-wide build-and-compare is not done), and why 171 commits are unmerged,
which is a human's call.

Also adds the METHOD entry the port offered: an allowance is a claim and it
decays; allow by NAME, never by COUNT, because a count cannot notice that a
different row started failing while the total held steady.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 14:18:30 +00:00
sylph-decoder
589d23e44a re: (B) from EXTRAS DOES go black -- "(B) has no black interval" refuted
sylpheed-port's BLOCKED.md ask #1. They declined to suppress their uniform
black_hold on the cancel path because (B) menu->title was one transition. The
test says they were right.

EXTRAS -> main menu, also via (B): the outgoing quad ramps frames 34-38 (5
frames, exactly build 6's declared 10 units), then frames 39 AND 40 are
completely empty -- 3 draws, zero textured, a harder black than either earlier
capture -- then the incoming menu's quad decays 41-45. So (B) does not imply a
cross-fade; menu->title is the outlier of three, and the generalisation I was one
step from publishing is false.

The screen was verified, not assumed. screen_id.py cannot separate EXTRAS from
the main menu, so which_title_screen.py checked the armed frame: extras 18.58 vs
main_menu 29.85, margin 11.27, inside the 9.9-11.7 band its control sets on four
known captures.

Three transitions now agree on one thing and disagree on another:

  outgoing ramp = the declared final ramp, THREE FOR THREE, against three
  different declared values (10u/5f, 8u/4f, 10u/5f), and exactly linear where
  nothing overlaps it. Authorable from the file.

  black gap = none / 3 frames / 2 frames. Not a per-button property, not a
  per-direction property, not a constant. black_hold_units should not be
  authored as one.

Build 5's incoming ramp is confirmed at 12 units by its RATE rather than its
count: the count came out 5 against a predicted 6 in both runs -- reproducible,
so not noise -- but capture 3's steps are -21, -42, -43, -42, i.e. 255/6 per
frame after a half-step start. Capture 2's decay does not fit that and is
unexplained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 14:15:58 +00:00
sylph-decoder
2a1ecc1c63 re: a menu/EXTRAS discriminator that failed its control, and the fix
sylpheed-port's BLOCKED.md ask #1 -- does (B) from EXTRAS also show no black
interval, or is "(B) has no black" one screen pair -- needs the harness to know
it is on EXTRAS. screen_id.py cannot tell: both are dark blue GP_TITLE screens
and it reports `menu` for either.

which_title_screen.py correlates a grab against our build 5 / build 6 renders.
First version FAILED its control: it called live-main-menu.png "extras" and
live-extras.png "main_menu", both backwards, margins under 1.1 on RMSE ~33.
Cause: it applied the y=45 game-surface offset unconditionally, but only a full
1280x720 display frame has the menu bar -- a 1279x675 grab IS the surface, and
two of the three reference captures are surface-sized. Offset made conditional;
the control now passes 4/4 with margins 9.9-11.7 against ~18 within-class.

The tool is a navigation aid for driving the emulator and says so: it identifies
a screen by agreeing with our own renders, so nothing measured may rest on it.

Also records the METHOD entry sylpheed-port offered from their own wedged check
script: an absence of output is not a status. They reported "still running, two
lines, both ok" for three iterations while the first attempt had died silently
under its own timeout with block-buffered output -- zero information, reported
as patience. An orphan from an older form of the same script was found running
after 9.5 hours. Line-buffer and bound long jobs, and check the artifact rather
than the exit code: the artifact reached its correct duration while the process
never returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 13:59:47 +00:00
sylph-decoder
aa4ffafbc5 re: the decaying quad is the INCOMING screen -- and (A) and (B) are different shapes
Last iteration left an unidentified full-screen untextured quad decaying
255->15 during a menu->title transition, which build 5's declaration does not
account for. Hypothesis: it is the INCOMING screen's own pteff00, which opens at
a255 and clears. 8 frames matching build 4's declared 16 units is a FIT, so the
test was a transition whose incoming screen declares something else: title->menu
brings in build 5, 0->12 = 12 units = 6 frames. Prediction recorded before the
run.

Measured: menu->title decay 8 frames (incoming build 4, declared 8), title->menu
decay 5 frames (incoming build 5, declared 6). Different incoming screen,
different decay, in the predicted direction. The second is one frame short of
prediction, inside the documented +-1.

The tell that clinches it: a screen contributes TWO primitives, pteff00 at 255
and pteff02 at 64. The settled menu's untextured set is [64]; at frame 34 it
becomes [64, 255, 64] -- build 4's opening pair, which no single element
explains.

Bonus, and it closes the alpha puzzle: in capture 2 the outgoing quad ramps with
no other untextured quad present -- 63, 127, 191, 255, steps of exactly 64, four
frames, against build 4's declared 261->269 = 8 units = 4 frames. Exact and
exactly linear. Capture 1's 102/127/255 was a composite of two overlapping
quads, as sylpheed-port proposed.

The thing neither of us predicted: the two directions are not the same shape.
(A) title->menu is SEQUENTIAL with a real black interval of 5 frames (~10 units,
against the port's authored 9). (B) menu->title is a CROSS-FADE with no black
interval at all -- the incoming title starts drawing at frame 34, before the
outgoing menu's quad begins ramping at 40. Authoring one hold for both directions
inserts black that (B) does not have.

Also fixed: fade_pair.py's automatic rising/decaying classifier worked on capture
1 and produced nonsense on capture 2, where the title has no full-screen
primitive at rest and the heuristic latched onto a transient. It now prints and
does not decide.

Refutation attempted: sylpheed-port's structural prediction of a 6-frame decay
for an incoming menu. Measured 5. Survives as direction, one frame short as
duration; recorded as both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 13:51:48 +00:00
sylph-decoder
d8b71cfb08 re: withdraw "the fade-out overlaps the content" -- it is a gap, and the port was right
sylpheed-port read the lead off the disc independently: content fade-outs start
at ptmsg 58, pteff10/pteff12/ptbtn05 60, against the quad's ramp at 70. That
reproduces my measured six-frame lead exactly (12 units = 6 frames) but has
content FINISHING two units before the quad starts, where I had published
overlap.

Checked which draws I had been watching. The content sprites are TEXTURED: they
fade over frames 34-37 and are gone by 39, and the black quad appears at 40 --
a one-frame gap, which is their two units. What overlaps the quad is a
different, UNTEXTURED full-screen quad decaying 255->...->15 across frames
34-41. It is unidentified: build 5 declares only pteff00.prm and a
single-keyframe pteff02.prm, neither of which is that decay. Recorded as an
open observation, not named from one capture.

So the shape is sequence, not overlap.

Also flagged, against my own interest: their "18 vs 19, one unit apart" compares
different intervals (content-start->black vs ramp-start->next screen), and the
capture's frame axis is not phase-locked to the file's unit axis -- the two
plausible alignments differ by two frames with nothing here to distinguish them.
So that agreement holds at one alignment and is not a confirmation. The quad's
alphas (102/127/255) also do not sit on a linear ramp across t=70->80, which is
unexplained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 13:40:42 +00:00
sylph-decoder
4bcb35cef7 re: the transition is overlap, not ramp-then-hold -- and the fade-in was 5x wrong
screen-transitions.md carried a 14-unit "black hold" that the page itself
flagged as arithmetic rather than measurement. Measured it against the running
game; the guess was wrong, and finding the instrument to measure it turned up a
second, larger error in the same page.

1. fade_quads.py was STALE. It read each pose's time from blk+36 -- the next
record's time word -- the association the keyframe record-layout fix retired in
the crate. sylpheed-cli was rebuilt at the time; the Python helper was never
swept with it. Signature: it cannot time a group's last pose, so it printed a
trailing `t=-`. Fixed, controlled against the rebuilt `screen info` ([0 12 70
80] for build 5's pteff00.prm).

2. Through it, the page labelled the quad's CLEAR-hold as its fade-in and
published 0.87 s / 0.97 s / 4.08 s for a ramp that is 0.20 s / 0.20 s / 0.27 s.
A port pacing its menu fade-in off that would run it 5x too slow.

3. The measurement. fade_decompose.sh boots to the main menu, arms the UI draw
capture there, then presses (B), so one 260-frame window holds the whole screen
change. The fade quad is identified rather than guessed: a .prm carries no
tex[base=] and paints last, so it is the last full-screen untextured quad of a
frame. Control first -- the quad's ramp is decoded at 10 units = 5 frames, and
measures 4 submitted-frame steps with one unlogged frame in the span.

Result: content elements begin fading at frame 34; the black quad first appears
at 40 and is opaque by 43; the menu's last frame is 45; frame 46 has 6 draws
against 12. So the ~14 extra units are the content's own fade-outs OVERLAPPING
the quad's ramp, not a hold after it, and the inter-screen black is one frame.

Refutation attempted: sylpheed-port's entries 13/14 twins. Re-derived off the
disc -- 3.06 / 4.33 / 47.91, identical to two decimals. Recorded as confirming
their addressing and arithmetic, NOT as independent support: same renderer,
same disc, which is their own rule.

Reach: one transition, one run; the frame axis has gaps (232 headers over frames
3..260), so every span is +-1 frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 13:35:56 +00:00
sylph-decoder
c364cde476 re: sweep the disc for the ordinal foot-gun -- GP_TITLE was the mildest case
Last iteration I retracted three claims because `--build 10/11` on GP_TITLE are
entries 12/15, and named the untested remainder in my own report: how much else
in the corpus used a build ordinal as an entry index. This is that sweep.

`screen --build N` indexes a predicate-filtered list, so every rejected entry
shifts every later ordinal. Disc-wide: 21 of 24 build-bearing archives diverge,
18 of them at ordinal 0 -- `--build 0` is entry 108 in each GP_MAIN_GAME_*2D,
24/26 in GP_HANGAR_ARSENAL/GP_READY_ROOM. GP_TITLE is the ONLY archive whose
first ten ordinals are the identity, which is the sole reason 207 of the
corpus's 226 build citations are safe. Second foot-gun: `--all` swaps the
predicate and renumbers 18 archives, so `--build N` and `--build N --all` are
not the same object.

The instrument failed its control first. A version using parse_build as the
predicate reported GP_TITLE as 16 builds, ordinal == entry throughout -- it
would have certified the exact bug it was built to find. The shipped version
uses the same predicates screen_builds() uses and reproduces `screen list` on
GP_TITLE exactly.

Audited all 226 citations. One real defect: a five-row table in
ui-keyframe-time-unit.md headed "declared element (build 11)" spans builds 10
and 11 -- palogo_sqex is in 10. All five placements re-verified and correct, so
the linear-ramp measurement is untouched; only the label was wrong. Fixed with a
per-row bundle column. GP_DIALOG --build 0 and GP_DEBRIEFING_PILOTLOG --build 10
re-run and reproduce.

Refutation attempted: sylpheed-port's corrected mid-ramp test rests on
ptlogo_all_eff holding a=127 from t=112 to t=246. Their quote is exact and it is
a plateau. The refutation fails; their correction stands.

METHOD already carried the rule I broke, and ui-splash-addressing already said
the splashes need --all. The failure was not missing knowledge -- it was
addressing a bundle by index without grepping for the index first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 13:20:41 +00:00
sylph-decoder
f102cf9209 handoff+method: land the ordinal retraction where the claims live
Marks the two void splash rows in the capture-comparison data file, withdraws the
three claims in HANDOFF, and corrects the METHOD entry -- whose 'a gradient across
buckets is not a mechanism' near-miss was itself resolved by a counter-example
taken with the wrong index.

General form recorded: an index that silently means something else produces
well-formed output for the wrong object, and this project has now been bitten
twice from opposite directions with 'everything still validates' both times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:59:58 +00:00
sylph-decoder
3356343cad re: RETRACT the splash settle windows -- --build takes an ORDINAL, not an entry
The port recomputed the publisher splash's widest keyframe-free gap as 190 units
against my 8 and said one reading must be wrong. Mine was, and the library was
never wrong -- only my invocation.

From the file: entry 10's union of times is [0,15,30,45,235,239,251,255], widest
gap 190, and settle_window() returns Some((45,235)). Entry 11 gives 145. Both
match the port exactly.

The cause is that screen render --build N takes a BUILD ORDINAL. screen list says
[10] entry 12 and [11] entry 15; the splashes are entries 10 and 11 and are not
screen builds at all, so my --build 10/11 rendered the LOADING screens. This is
the foot-gun HANDOFF already documents, which the port caught months ago in the
mirror direction.

Three retractions:

1. 'Width does not predict quality' -- withdrawn. It rested entirely on the
   splashes being width 8 while winning 75x. They are the widest of the five, so
   width and mid-ramp are perfectly confounded across every screen either of us
   has measured and the width hypothesis is NOT refuted.

2. 'My filter excluded the splashes' -- withdrawn; at 190 and 145 they were never
   near the 10-unit cutoff. The other half stands: it admitted the 10-19 bucket,
   the worst at 45.1 %.

3. The splash rows of settle-vs-rest-against-captures -- void. They scored
   loading-screen renders against splash captures. I discarded them for a railed
   gamma fit; the real reason is that they were the wrong screens, and the railing
   was that mismatch surfacing where my instrument could report it.

Surviving: the title row (ordinal 4 = entry 4) and the disc-wide censuses, which
iterate pak entries directly and never touch the ordinal path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:59:29 +00:00
sylph-decoder
ca41b81377 method: a borrowed threshold still has to be checked against the cases it decides
The under-10-units filter was documented and reasonable and wrong both ways: it
admitted the 10-19 bucket, the worst at 45.1 % mid-ramp, and excluded the two
splashes at width 8, the strongest evidence for the proposal it was serving. One
--settle invocation per screen would have shown this in seconds.

Plus the near-miss: the bucket gradient made 'narrow window means bad settle
pose' look obvious, and it is refuted by two screens inside the excluded band
that win by 75x and 33x. A gradient across buckets is not a mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:45:59 +00:00
sylph-decoder
9d32852724 re: census the settle pose's own failure mode -- and refute the obvious explanation
The port found ptmsg, the main menu's footer, at alpha 127.5 at that screen's
settle instant. Verified: build 5's window is [44,56] = 12 units and
screen render --settle already prints 'narrow -- this bundle may never settle'.

Disc-wide, elements caught mid-ramp at their screen's settle instant: 25.5 %
overall, 40.9 % on windows under 10 units, 45.1 % on 10-19, falling to 11.7 % and
15.0 % on wide ones.

The obvious reading of that table -- narrow window means the settle pose is bad --
is REFUTED by the screens that motivated the proposal, and I nearly published it.
The two splashes have an 8-unit window, narrower than the main menu's 12, and the
settle pose beats rest() there by 75x and 33x. Width does not predict quality.

The predictor is the port's own statement: the settle pose wins decisively where
rest() lands on a transient's peak, and loses slightly where rest() is already
sound and an element arrives after the window closes.

And my own rest_vs_settle filter was wrong in both directions: dropping bundles
under 10 units admitted the 10-19 bucket, the worst at 45.1 %, and excluded both
splashes at width 8 -- the strongest evidence FOR the proposal. A threshold taken
from a documented rule of thumb and applied without checking which screens it
admitted and which it threw away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:45:33 +00:00
sylph-decoder
54254a86e1 method: correct 'cannot adjudicate against the incumbent' -- it needed a discriminator
The entry said a candidate-vs-incumbent comparison is the wrong shape of
experiment. Too strong. Two iterations later the same comparison adjudicated with
no new oracle, once a structural property was added that says which side is wrong
per disagreement -- does the chosen run contain the settle instant. Control 3072
of 3072; disagreements attributed 1036 of 1036.

Corrected rule: a bare comparison cannot adjudicate because a disagreement is
symmetric; it becomes decisive the moment a property breaks the symmetry per
case. Reach for an oracle when no such property exists, not as the first move.

With the tell that I had one available and did not look: I recorded the residual
as 'ambiguous by construction' and moved on TWICE without asking what would make
it un-ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:38:44 +00:00
sylph-decoder
642341b12b re: rest_plateau() picks the wrong plateau -- and it is the whole residual
rest_plateau() selects the LONGEST run of identical adjacent poses, which need
not be the run covering the screen's settle instant. rest_vs_settle left a 21.9 %
disagreement that I recorded as ambiguous by construction. It is not.

  CONTROL  exactly one plateau, covering the settle instant:
           3 072 / 3 072 agree (100.0 %)
  TEST     more than one plateau, at least one covering:
           1 622 elements, agree on 586 (36.1 %)
           of the 1 036 disagreements, rest() landed on a run NOT covering the
           settle instant: 1 036 -- all of them, no exceptions

Both poses are genuinely held in these cases -- they are plateau cases, not
transients -- so this is rest() returning a pose the screen has ALREADY LEFT by
the time it settles.

This corrects my own METHOD entry of two iterations ago, which said a candidate
cannot be adjudicated against the incumbent it replaces. Too strong. The bare
comparison cannot; the comparison plus a structural property that independently
says which side is wrong in each disagreement can. What I lacked was not an
oracle but a discriminator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:38:18 +00:00
sylph-decoder
b37eb4a5bc handoff: settle_time() itself beats rest() on the one screen that adjudicates
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:30:23 +00:00
sylph-decoder
10f469cbed re: settle_time() itself beats rest() against the game -- on the one screen that adjudicates
Closes the gap the port named: it ran my proposal against captures 3/3 in favour,
but tested ITS OWN settled pose rather than UiBuild::settle_time().

Geometry established first, because my first attempt got it wrong: a 1280x720
render meets a 1279x675 capture by CROP, not scale -- crop rows 0..675 gives RMSE
14.07 against 68.89 resized and 79.61 for the 45-row crop. The 45-row offset
holds for a full display frame; these captures are already the game surface.

Gamma fitted per pose so neither candidate can win on the fit:

  title      settle  g=0.84  RMSE  8.17  15.28 % >8
  title      rest    g=1.04  RMSE 20.92  70.84 % >8

The two splashes DO NOT ADJUDICATE and are not counted: their gamma fit rails at
the edge of the search range, still railing when widened to 0.30..3.00, so the
photometric model is wrong for them -- and with gamma railed their margins
collapse to 1.16x and 1.06x.

title adjudicates at an interior gamma and does so decisively, 4.6x on differing
area and 2.6x on RMSE. So the IMPLEMENTATION and not merely the direction is
supported.

Absolute agreement is poor -- the port's settled title row is 0.21 % where mine
is 15.28 % -- so the ordering is what this table carries, not the values. The
port's three-screen result remains the stronger evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:30:07 +00:00
sylph-decoder
3aa47f77a7 method: a candidate cannot be adjudicated against the incumbent it replaces
Plus the two companions: any statistic keyed on where an element's visibility
ends is near information-free here (87.8 % end at a=0 from the screen's exit
ramp, which bit both agents within an hour), and the port's mirror -- my
threshold-free 'nothing is held on this path' criterion is correct on the
fallback path and fails outright on the plateau path, so a cleaner definition
that fails a control is worse than an ugly one that passes.

HANDOFF carries the proposal with its evidence, its failed control, and an
explicit refusal to change rest() in a crate the port pins on the strength of
something I cannot validate from my own side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:23:10 +00:00
sylph-decoder
0c9224fbbc re: propose settle-instant posing -- and my control cannot validate it
The proposal: pose every element at the SCREEN's settle instant rather than
asking each element for its own resting pose. On the 2 249 fallback elements in
settling bundles the visible-pose rate falls 73.6 % -> 34.7 %.

But the control fails twice. Naive, over every plateau element: 46.6 %. That one
was misspecified and I caught it by asking what the number means physically --
rest() finds *a* held pose and many elements hold one during the build-in then
move on, so it answers a different question and disagreement proves nothing.
Restricted to elements HOLDING ACROSS the settle instant: 78.1 %, still not a
pass.

And the residual is ambiguous by construction: rest_plateau() picks one plateau,
so an element with two whose settle instant falls in the other will disagree --
and there pose_at(settle) is RIGHT. The control cannot separate 'the candidate is
wrong' from 'the incumbent is wrong'.

Recorded as the general point: comparing a candidate to the incumbent cannot
adjudicate when the incumbent is the thing under suspicion. It is the wrong shape
of experiment, not a tuning problem.

What does adjudicate is the oracle and it is the port's measurement, not mine --
publisher splash against a committed capture, settle-instant pose RMSE 2.17 /
0.01 % differing against --pose=rest 9.05 / 0.75 %. My numbers describe the
proposal's effect; they do not establish it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:22:44 +00:00
sylph-decoder
e988f6e090 handoff: correct the fallback number to 1457, and record the failed correction
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:13:57 +00:00
sylph-decoder
c89e4da723 re: my own 1697 audited -- and the first correction failed its control
Applying the port's physical-story rule to my own number. '1 697 fallback fires
return a visible pose' was published as if it were a defect count; it is not, since
an element that genuinely ends visible should rest visible.

The first correction split the 1 697 by whether the element's LAST keyframe is
visible: 347 correct, 1 350 transient peaks. Plausible, arithmetic fine, and
WRONG -- 12 278 of 13 991 elements (87.8 %) end at alpha 0 because a screen's exit
ramp drives everything to zero, so the split carries almost no information. The
1 350 is not published.

What survives needs no such split: the fallback runs only when no two adjacent
poses are equal, i.e. only when no pose is held, so every pose it can return is
un-held by construction -- and 1 457 of the 2 305 times it returns the element's
MAXIMUM alpha, the brightest un-held pose.

I ran that control only because the port had just been bitten by the same exit
ramp, its census calling ptmsg -- the main menu's permanent footer -- 'a 2-unit
flash'. Without its message the 1 350 would have shipped.

METHOD gains the sharpened form: the physical-story test catches confident FALSE
claims, not just nulls. A wrong number usually still has a story, just an absurd
one. Plus the tell that its fix was right -- re-keyed on the screen's span, the
false positives fell out on their own, and a definition that stops needing
hand-maintained exceptions is usually the correct one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 12:13:46 +00:00
sylph-decoder
ef851fec92 method: say what the number means physically and see whether the story survives
From the port, and a better generalisation than mine. I had been filing my
failures under 'an external quantity caught it', which prescribes finding an
anchor; anchors are not always available. The port's title_jp error had none --
every control passed because the metric was fine and the error was which frame it
scored. What caught it was asking why rest produced that light, which exposed a
4-unit sparkle whose rest.t is its own peak.

So: state what the number means physically and see whether the story survives
contact with the data. A wrong frame yields a number with no physical story
behind it, which is detectable from the inside. It subsumes the null-as-result
cases too.

And a control does not test this: a control proves the INSTRUMENT and says
nothing about the SAMPLE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:55:10 +00:00
sylph-decoder
ad9cd948c6 re: the port's two extra elements are PLATEAU cases -- refutation succeeds, and its point gets bigger
The port listed palogo_gamearts_eff and palogo_seta_eff among GP_TITLE's four
visible dwell-fallback fires; this census listed only palogo_sqex_eff and
palogo_anima_eff. Checked, and the census is right: gamearts_eff and seta_eff
hold a=255 at identical x, y and scale from t=15 to t=30, which is a plateau at
pair index 1, so rest_plateau() handles them and t=15 is the CORRECT answer. They
are not fallback cases.

The distinction is not cosmetic -- a plateau is a pose the element genuinely
holds, and only the dwell fallback is the unsound path.

But the refutation makes the port's underlying point STRONGER. Its rest pose for
those two really is the flash's peak, reached by the SOUND path. So 'a rest
render is not a frame to score against a capture' does not follow from the
fallback being unsound: a plateau can itself be the held peak of a transient. The
rule covers both paths, and the fallback census understates the exposure rather
than bounding it.

Also records the port's oracle number for the rule -- publisher splash against
the committed capture, timeline RMSE 2.17 / 0.01 % differing against --pose=rest
9.05 / 0.75 %, 75x the differing area.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:54:43 +00:00
sylph-decoder
28b06f9a31 handoff: rest is not a settled pose -- 1697 of 2305 fallback fires return a visible pose
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:50:25 +00:00
sylph-decoder
cae2f7596e re: the rest() fallback -- its example dissolved, the question got bigger
ui-resting-pose.md built its dwell-fallback section on GP_TITLE build 7's
ptlogo_eff3.t32, listing keyframes [46, 61, 103, -] -- the STALE PARSER's output.
Corrected they are [0, 46, 61, 103], the longest gap moves from 61->103 to 0->46,
and BOTH ends of the new longest gap are a=0. The element no longer selects a
visible pose under either indexing, and build 7 renders byte-identical under the
corrected and legacy readings (0 px differ). MISSION lists this element as the one
case a Japanese capture was needed to discriminate; it is not.

But losing an example is not closing a question, so: disc-wide census. The
fallback fires on 2 305 of 13 991 elements and returns a VISIBLE pose in 1 697 of
them -- 74 %.

GP_TITLE is 5 fires, 4 visible, and all four are on the SPLASH screens:
palogo_sqex_eff and palogo_anima_eff, each [0:a0 15:a255 30:a212 45:a0], a flash
peaking at 15 and dead by 45 where the fallback returns t=30 a=212.

Independently converged on from the other side: the port, working from the JP
capture and knowing nothing of this census, found ptlogo_back2eff1's rest.t at the
peak of its own 4-unit sparkle with six staggered across the logo, so --pose=rest
fires every sparkle at once -- a frame the game never shows.

Consequence recorded as a rule: a render posed at rest is a legitimate common
reference for comparing two DECODERS and is not a frame to score against a
capture of the game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:50:07 +00:00
sylph-decoder
3b52240d83 handoff: the JP title at rest is captured, with its rest demonstrated
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:37:54 +00:00
sylph-decoder
279a33846e re: the Japanese title at rest -- the capture MISSION has wanted since 2026-08-29
Asked for by the port: its title_jp row drifted, localized to a 350x396 block at
(405,74) -- the logo stack -- and with no JP capture in the corpus it could say
the renderers moved apart but not which one moved.

Three earlier attempts failed to reach the interactive title in either locale.
The reason is now known and was never the locale: A at the title needs a
signed-in profile, and no run had one.

Locale set through canary's own persisted XConfig and restored afterwards,
verified back at language=1. INDEPENDENT confirmation it took: the XMA probe
logged a different voice-context set from every English run (ja 1112064 /
1150976 / 1177600 against en 1294336 / 1118208 / 1171456), so the switch reached
the guest rather than being a menu-language cosmetic.

'At rest' is demonstrated rather than assumed. Five frames ~1.5 s apart after the
plate pulse says the screen has settled: the port's ROI is byte-identical across
all of them, max |delta| 0 over 138 600 px, while the WHOLE FRAME moves 39 584 to
71 927 px -- the plate pulse and sweeps. That contrast is the control: the
instrument can see motion and the ROI still shows none.

The capture shows what the English title does not -- the katakana subtitle, and a
crystalline burst behind the wordmark, the ptlogo3a/b/c + ptlogo_back2eff* stack
that this corpus records as transparent at rest in English. Exactly the region
the port's drift is localized to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:37:35 +00:00
sylph-decoder
8ad87012fb handoff: the fade ramp is 10 units decoded, and screen info was stale
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:22:59 +00:00
sylph-decoder
83b7316065 re: the CLI binary was STALE -- screen info's keyframe times were the old parser's
The copy of sylpheed-cli in this container was built 2026-08-29 12:38, before the
keyframe-record-layout fix. The old parser shifted every time by one slot and
could not time a group's final pose, printing a trailing '-':

  stale  pteff00.prm  4 kf  rest t=70  [12:0,0 70:0,0 80:0,0 -:0,0]
  fresh  pteff00.prm  4 kf  rest t=12  [ 0:0,0 12:0,0 70:0,0 80:0,0]

Both outputs are well-formed and neither announces its age.

That refutes the premise of screen-transitions.md's 2026-08-29 section, which
argued from 'there is exactly one untimed keyframe, and every element has it'.
There is no untimed keyframe, so the question it answered -- is 0.4 s the missing
duration of that keyframe -- has lost its subject. The ratio test in the same
section is untouched.

And it decodes the number the port asked about: pteff00.prm's final ramp is
70 -> 80 = 10 units, about 0.167 s, not the ~24 this page authored. I tried to
refute the port's 10 against the bytes and could not.

So the measured ~0.4 s is NOT the ramp alone -- 24 units measured against 10
decoded. That the remaining ~14 units are exactly the black hold is arithmetic
that fits (0.233 s, inside this corpus's own 0.17-0.23 s plateau) and is NOT a
measurement; the decomposition stays open.

CONTAINER-NOTES gains the trap. Renders are byte-identical across the two
binaries (max per-channel difference 0 on GP_TUTORIAL build 0), so element
identity, pivots, keyframe counts and screen render output are unaffected -- it
is the times that move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:22:43 +00:00
sylph-decoder
951b9a4c80 handoff: Q5's two weak clauses are measured -- both stampable now
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:18:42 +00:00
sylph-decoder
1eee838f30 re: three empty evidence cells filled in one run -- auto-repeat, B on a settled title, plate redraw
1. NO AUTO-REPEAT. A 2.0 s held DOWN moves the cursor exactly once. The counter
   passes its control first: a single 0.12 s tap gives exactly 1 spike and the
   hold gives 1, with the move spike at 0.0202-0.0220 against a 0.0003-0.0038
   noise floor. The port had flagged that the hedge 'at the durations tried' was
   carrying the claim, and it was -- nothing recorded a HELD direction.

2. B ON THE SETTLED TITLE DOES NOTHING. Twenty seconds after a
   delivery-confirmed B the screen is still the title with PRESS (A) BUTTON up,
   read off a capture that names itself. This is the run the previous attempt
   could not be: it waited for the plate pulse, the title's own settled
   signature, instead of pressing during the build-in.

3. THE PLATE IS RE-DRAWN after B from the menu -- pressed at 351.2 s, pulse
   detected at 358.5 s. That was the other unevidenced half of the B-on-menu row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:18:18 +00:00
sylph-decoder
7a63501bc1 handoff: split Q5's bundled 'measured' -- a label is as strong as its weakest cell
The port's authored/flow.json stamped title/on_cancel_why = 'MEASURED, HANDOFF
Q5' for a clause whose evidence cell in the source table reads 'none'. It did not
invent that: HANDOFF's Q5 row opened with one **measured** covering six clauses of
different strength, and HANDOFF is the document it authors against.

Split per clause. Measured: initial focus varies; up/down move one item per press
and wrap both ends; left/right do nothing; B on a submenu restores focus 4/4; B on
the main menu goes to the title in <= 0.4 s with no loading screen. NOT measured,
evidence cell empty: 'no auto-repeat at the durations tried', and 'B on the title
-> nothing'. Both marked do-not-stamp.

Also splits the source table's own up/down row, which bundled 'one item per press'
(indirectly but soundly evidenced by the 4-press wrap count) with 'no auto-repeat'
(nothing behind it, and the hedge was carrying the claim).

METHOD entry: the failure is in summarising, not at either endpoint. The source
table was honest and the consumer cited its source; flattening six claims into one
adjective created a provenance nothing supports. A strength label is not
distributive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:11:08 +00:00
sylph-decoder
c3b66c4b91 handoff: on_cancel from the main menu is measured
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:06:32 +00:00
sylph-decoder
78cb1d4e9b re: B on the main menu goes to the TITLE -- measured, filling an empty evidence cell
menu-navigation-semantics.md had this row at yellow with an EMPTY evidence cell,
and it is what the port still authors as on_cancel.

Delivery-confirmed via [RE-INPUT] (B is kXInputPadB = 0x5801), change detected
rather than timed. B delivered at 331.2 s; the glyph leaves 327 by 331.6 and
73.5 % of pixels differ. Both captures name themselves: PROJECT SYLPHEED with the
(C)2006,2007 SQUARE ENIX line.

Three things measured:
  * B on the main menu goes to the title;
  * latency <= 0.4 s at a 4 Hz sample rate, where the corpus previously had this
    as 'not measured (a backlogged probe void)';
  * NO loading screen in between -- the disc carries four pgloading_* bundles and
    none appears on this path.

What the run CANNOT say, recorded in the table rather than glossed: 'B on the
title -> nothing' is still unevidenced. The second B was delivered during the
title's build-in, so the glyph 0 -> 154 change after it is the build-in
completing, not a response. A run that answers that row must wait for the title
to settle before pressing.

The 're-draws PRESS A after a beat' half of the first row is also still
unevidenced -- the run ended with the plate absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 11:06:18 +00:00
sylph-decoder
dc0b84c87f handoff+index: a .tbm draws, and screen render omits it silently
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:53:57 +00:00
sylph-decoder
ddc2a8e36b re: a .tbm DRAWS -- the TUTORIAL screen captured, and the 'inert' reading refuted
Closes the open second reading in ui-forced-backdrop.md: that a .tbm contributes
no pixels, leaving 24 of its 62 deciding verdicts harmless rather than correct.

The TUTORIAL screen was reached and captured. It carries a full-screen blue
circuit/hex background. GP_TUTORIAL build 0's element 0 is pubase.tbm with pivot
(640,360) -- 1280x720, the only full-screen TEXTURED element in the bundle; the
one other full-screen element is pueff00.prm, an untextured primitive the colour
census puts at pure black. Our render of the same build is the identical layout
on pure black, 6.0-6.4 % inked against the game's 99.7 %. The only difference is
the background and the only thing it can be is the .tbm.

So the 24 .tbm verdicts are correct rather than harmless, and they are
load-bearing in the full sense. Reach: one .tbm observed; the class question is
settled, the ten other families are not individually seen.

Also: screen render is wrong on every screen carrying a .tbm -- it drops the
background silently, with no diagnostic.

And the identification is worth its own METHOD entry. Two statistical identifiers
were built. Masked correlation FAILED its control, picking EXTRAS over the known
main menu by 0.004 because the shared background dominates. A high-passed variant
PASSED by 1.28x, which is not a margin that licenses identifying an unknown, so it
was not used. The screen says TUTORIAL across the top. Ask whether the artefact
already states the answer before building a matcher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:53:34 +00:00
sylph-decoder
77e54e95a5 re: a submenu is REACHED -- and correlation cannot identify it
Third attempt at the .tbm question. All three fixes from the previous page were
applied and all three were needed: hold A for 0.5 s, confirm delivery from
[RE-INPUT] rather than from the pad, and detect the screen change instead of
timing it. Title at 288.6 s, both presses delivered on attempt 1, submenu at
303.4 s with 87.1 % of pixels changed.

The capture is 99.7 % inked and uniform top to bottom -- a full-screen
background. Our renderer gives 1.9-3.0 % for all 19 GP_SAVE_LOAD builds,
6.0-6.4 % for GP_TUTORIAL, 78.4 % for GP_SYSTEM 0/1. So two of the three
archives render essentially nothing where the game draws a full screen.

But WHICH screen was captured is not established, and the reason is worth more
than the run: correlation cannot discriminate when the candidate renders are
near-blank. All 19 GP_SAVE_LOAD builds score -0.004..-0.010 -- a ranking with no
information. A matching statistic is useless against a hypothesis that predicts
an empty image, which is exactly the hypothesis under test.

Focus could not be read either: the two labelled menu captures fit at 2.52 and
2.48 mean absolute difference, 1.6 % apart. That is a SECOND statistic failing on
the focus problem after the per-row brightness one, so it is an open item rather
than an oversight.

Kept regardless: the game surface sits at y=45 in the 1280x720 display frame,
fitting the committed 1279x675 captures to 2.5 mean absolute difference. That is
the alignment the earlier cross-geometry comparison got wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:49:48 +00:00
sylph-decoder
ac1b76ee39 re: run 'grep the corpus for the claim' on this corpus -- four still standing
Applying my own METHOD entry one iteration after writing it found four refuted
statements still asserted unmarked where a reader lands:

  * envelope correlation 'has no resolving power' -- in three places including
    HANDOFF. The port controlled the same estimator on a single track and got
    r=1.0000 at zero offset; the saturation needs CONCURRENT streams sharing
    timing. I agreed to this in a message and never landed it.
  * '8 of 10 three-chunk regions' -- still asserted in HANDOFF in a different
    section from its own correction.
  * 'r9 is a wild pointer, never a guest address' -- still asserted inside the
    kept-for-the-record section.
  * the ALSA channel permutation, stated without scope, when a later capture
    measured the identity and labelling from it put the silent channel on the
    wrong name.

All four marked in place, striking the sentence and pointing forward.

Two lessons added: a 'kept for the record' section still asserts, so labelling
the heading is not enough; and naming a refuted claim keeps it greppable, so the
audit returns its own corrections as hits and every hit needs reading.

The first item is the one worth admitting: I acknowledged that correction in a
message, wrote the entry about corrections that never land, and then did not land
my own for a full iteration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:35:27 +00:00
sylph-decoder
e882900f85 method: 'it did nothing' and 'it never happened' look identical from outside
An A press produced no screen change; the obvious reading was that the button did
nothing. The log said the press was never delivered -- two file-pad vk=5800 lines
across the whole run is ONE press -- because it was issued 0.8 s after a screen
appeared, while the guest was loading and not polling.

The pad driver reports what it emitted, so a script checking its own output sees
success either way. Confirm the receiving side.

Generalised: when an action produces no observable effect, the first hypothesis
is that the action did not occur, not that it had no effect. The corpus has paid
for this twice -- here, and in the sign-in swallow where thousands of delivered
keystrokes were discarded inside the emulator and looked exactly like a dead pad.

Companion to the control entries: a control proves the instrument reads
correctly; a delivery check proves the experiment happened at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:31:12 +00:00
sylph-decoder
bbc167425f re: the .tbm submenu is not reached -- and the second tap was never delivered
Two runs, neither answering whether a .tbm draws pixels.

Run 1 TIMED the title->menu transition and was still on the title 8 s later
(glyph 714, the plate's pulse trough), so the second tap did the transition and
the 'submenu' capture is the menu. Void.

Run 2 DETECTED the menu instead -- glyph 327, matching live-main-menu.png
exactly -- tapped 0.8 s later, and 12 s after that was still on the menu. The
log says why: 2 file-pad vk=5800 lines, i.e. ONE press, and one RE-INPUT
delivery. The second tap was never delivered, with zero swallow lines so it is
not the sign-in path. A 0.12 s press issued while the guest is still loading a
screen is missed outright.

So 'the press did nothing' and 'there was no press' look identical from the
screen, and only the log separates them.

Worth more than the run: this is the third time in one iteration that timing was
used where detection was required -- the title->menu wait, the menu->submenu
wait, and the press itself. Each fix is the same substitution, and each was
written only after the timed version had produced a confident wrong answer.

Also records that no focus detector is needed for this question, since every
main-menu destination except EXTRAS carries a .tbm decider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:30:49 +00:00
sylph-decoder
12159be61f re: land the loop-point correction where the claim actually lives
The port found its exporter still shipping 'no loop-point field has been
identified anywhere' in the field manifest.json concatenates, days after the
correction existed in other fields. Auditing this corpus the same way found the
same failure here: the refuted sentence was still standing untouched in
bgm-two-stems.md -- where anyone looking up BGM behaviour arrives -- and in
HANDOFF.md, the one page the port is told to read. My correction had gone into a
NEW page only.

Both fixed in place, each naming the refutation rather than quietly deleting the
old claim, and each carrying the measured window [9.44, 71.31] s at 61.87 s.

METHOD entry: writing a correction down is not landing it. Grep the corpus for
the CLAIM, not for the file you were working in. Plus the port's trap in doing
that audit -- a replacement that quotes the refuted sentence in order to name it
will match a substring search from inside the paragraph saying it is false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:20:04 +00:00
sylph-decoder
e1f2a7f714 re: the menu loop starts at 9.44 s -- measured, and the fix was scheduling
Tailing the log from BEFORE the music starts cut the unsampled backlog from 616
samples spanning offsets 32..2,559,033 to 125 spanning 32..515,239, so the first
pass is sampled like any later cycle. Offsets below loop_start play exactly once,
which is why the previous run could not measure them.

Wraps at 96.46 / 158.33 / 220.21 s, gaps 61.87 / 61.87, both contexts together.

Two derivations, neither converting bits to seconds:
  (a) time to read_offset crossing loop_start, plus a 1.33 s head correction at a
      rate measured on 748 timestamped samples of that same stretch
  (b) first pass (offset 32 -> loop_end) minus the cycle
Both give 9.44 s on both contexts -- four numbers, one value.

So the loop region is [9.44, 71.31] s of an 87.744 s wave, cycling every 61.87 s.
The first 9.44 s is an intro played once; the last 16.4 s, the fade-out
bgm-two-stems.md documents, is never played at all.

The decoder reads ahead of playback, but both endpoints are read_offset events so
the lead cancels in the difference. One boot, one bank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:13:14 +00:00
sylph-decoder
10df64e7d0 method: a control easier than the measurement bounds nothing
My locator validated on slices cut from the wave itself -- found exactly, control
passed perfectly -- and the measurement was still ten seconds wrong, because
matching an exact copy is a different problem from matching a real capture and
music with repeated sections is where a locator aliases. Its clean +5.00 s
stepping showed self-consistency, which is also what an aliased lock looks like.

Paired with the port's mirror image the same day: an awk that read 0 for every
span produced a perfectly structured result agreeing with the hypothesis under
test, and its tell was that the CONFIRMING rows were impossible too.

Both point the same way: the tell is in the rows that agree with you. Checking
the disconfirming half harder is the intuitive discipline and it is the wrong
half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:04:18 +00:00
sylph-decoder
d949a2216f re: the menu loop WATCHED -- three wraps, 61.81 s, and my placement is refuted
Settles the conflict by timing the loop instead of converting it. A tailing probe
stamps read_offset with the wall clock as each log line arrives, so the period
needs no bits-to-time step -- the step already shown to be invalid.

Three wraps, each exactly loop_end -> loop_start, and BOTH CONTEXTS WRAP AT THE
SAME INSTANT all three times. That is the property two stems of one performance
must have and the one the linear conversion could not deliver (62.34 vs 63.29 s
would drift a second per cycle).

Cycle 61.56 and 62.06 s, mean 61.81, against the audio autocorrelation's 61.93 --
0.2 % apart from instruments sharing nothing.

Linearity refuted a second time and internally: the fitted rate over 10..60 s is
341 394 bits/s while the cycle covers 22 034 741 bits in 61.81 s = 356 491
bits/s, 4.4 % apart inside one stream.

My own audio locator's PLACEMENT is refuted. loop_start at 3.6 M bits is 11.6 %
of the stream by any reading, ~10.1 s at the cycle's own mean rate, against the
0.25 s that page reported -- for the reason already suspected, that its control
matched slices cut from the wave itself and never tested the aliasing the real
problem has. The length was right and the span was wrong.

Still not measured: loop_start in seconds. Offsets below it play exactly once and
this trace stamped that whole stretch at t=0.002, swallowing the log backlog in
one read, because it started after the music. The fix is to start the trace
before tapping into the menu -- one line, not done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 10:03:52 +00:00
sylph-decoder
cf6be709ab re: the loop IS a runtime XMA field -- and reading it contradicts my audio measurement
No Canary patch was needed: UpdateLoopStatus already logs loop_start/loop_end,
they just need the Apu category (--log_mask=13 --log_level=3).

Decoded, from the menu, 8734 records all after BGM_103's contexts appear:
  ctx0 (wave 3876864)  loop_start 3605682  loop_end 25640423  loop_count 255
  ctx1 (wave 3930112)  loop_start 3539158  loop_end 26216351  loop_count 255
The movie's three ADV streams log NO loop records -- they do not loop.

Semantics visible in the trajectory: read_offset runs from 32 upward and 20 % of
samples sit below loop_start, so the stream plays from the beginning and
loop_start is where it returns AFTER loop_end. No wrap was observed -- the 45 s
hold ended with read_offset at 17 M against a loop_end of 25.6 M.

Two registered predictions REFUTED. loop_start is not ~0 but 11.6 % in. And a
linear bits-to-seconds conversion is invalid: it gives 62.34 s and 63.29 s for
two stems that must play sample-synchronously, which is impossible, so the data
refutes the assumption on its own.

That leaves a conflict I am not resolving: the field implies a cycle of roughly
[10 s, 72 s]; my audio tracking reported offsets 0.25..57.18 s. Recorded as
contested, with the likely weak link named as mine -- that locator's control used
slices cut from the wave itself, exact copies, which is an easier problem than
matching a real capture, and a control easier than the measurement does not bound
its error.

The port is told to change nothing: its trimmed 61.93 s loop is verified in its
own output, and the length survives better than the placement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 09:52:13 +00:00
sylph-decoder
5f386111ad re: count the voice-region population properly -- 25 three-chunk, and 17 of them were broken
Pays the debt from the truncated audit. The census prints population, coverage
and skips in the same output, and ends with an explicit END line, so a cut-short
run cannot be read as a complete one.

  POPULATION 104 movies; COVERAGE 95 resolved, 9 unresolved, 0 unreadable
  70 one-chunk regions, 25 three-chunk regions

The port's 25 was right; my '8 of 10' was not a count.

Cross-referenced against the fix's own sweep, which also ran to completion
(78 + 17 + 9 = 104): all 17 changed regions are three-chunk, none is one-chunk,
and 8 three-chunk regions were never affected -- which the 1.5 MB cap predicts,
since a region only trips the filter if its span exceeds it.

So 'the defect is specific to the multichannel regions' survives with complete
populations on both sides, while 'all three-chunk regions were broken' does not.
The original 8-of-10 was wrong in its denominator and coincidentally shares a
digit with the 8 that are unaffected, which is the kind of resemblance that
carries a dead number into a later document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 09:46:08 +00:00
sylph-decoder
45a21f0d33 method: a sound instrument, a correct number, and the wrong sentence around it
Named by the port after hitting it twice in a fortnight -- a real 3.4 s seam
written up as the cost of a missing loop point when our own loop produced it, and
a real r=0.998 correlation written up as a duplicate when it was a
start-truncated simultaneous stream.

Distinct from everything else in this file, which is about broken or misspecified
instruments. Controls test whether a measurement is faithful and say nothing
about the clause that follows it. The defences that work are naming the
alternative explanation so it can be rejected, and noticing when a measurement
taken in OUR output is being used to make a claim about THE GAME.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 09:40:39 +00:00
sylph-decoder
fa832fad1e re: the menu BGM loops at 61.93 s, and the game never reaches the fade
240 s parked on the main menu, reached by using the XMA probe log as the screen
oracle instead of video -- the route the previous iteration wrote down. Menu in
26.8 s against never-in-378 s for the video rig, guest at 0.92x, capture at
0.08 % silence against the recipe page's own best of 0.31 %. BGM_103's contexts
verify the screen and no ADV context appears afterwards, so the attract loop
never took over.

Three results, two instruments.

NO SEAM: zero runs >= 0.3 s below median-18 dB in 232 s. The port's 3.4 s
near-silence is a property of its authored loop, not of the game.

NOT THE WAVE LENGTH: autocorrelation r at 87.750 s is -0.009 on four independent
windows; the top lag is 61.909 s with a 2x harmonic. Estimator controls recover
87.750 and 60.000 exactly.

61.93 s, INDEPENDENTLY: locating 30 s slices of the capture inside the decoded
summed waves shows playback advancing exactly +5.00 s per 5 s and wrapping at
61.93, from three wraps. Control: slices cut from the wave itself at 10/45/70 s
are found at 10.00/45.00/70.00. Two points mis-lock where the slice straddles a
wrap and they carry the two lowest scores in the table.

Offsets span 0.25..57.18 s of an 87.744 s wave, so the loop is [~0, 61.93) and
the final ~25.8 s is never played -- exactly where bgm-two-stems.md found the
fade-out and trailing silence. The game loops before the fade, which is why
there is no seam.

Also corrects my own '8 of 10 three-chunk regions start mid-stream'. The port
counts 25 three-chunk regions; it is right that both numbers cannot describe the
same set. My audit run was CUT SHORT -- the committed file ends mid-list with no
summary line -- so that was a ratio over an unknown fraction of the population,
and the claim that the defect is specific to multichannel regions is now
unsupported. The ADV verification and the fix's own sweep are unaffected; that
sweep ran to completion and printed its totals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 09:34:42 +00:00
sylph-decoder
57035a700b handoff: the menu BGM loop is attempted and not captured; the route left
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 09:22:01 +00:00
sylph-decoder
4ce639c63d re: the menu BGM loop is NOT captured -- and the rig that cannot take it
Recorded per 'do not improvise around a blocker'. The question is what the game
does at BGM_103's loop seam, where the corpus has 'not a seamless loop, no
loop-point field found, so the menu loop is authored' and the port measures a
3.4 s near-silent seam.

Audio needs the ALSA tee; detecting the title needs video, so --gpu=null was
unavailable. Measured twice: the guest runs at ~0.20x real time (76.5 s of audio
in 378 s of wall clock) and the title is not reached in 300 s even after tapping
A to skip the movie, with the tee's slave ending in a broken pipe and Xenia in
underrun recovery. Not a crash -- rss 701 MB with 9.5 GB free, and the 'Killed'
line is this harness's own cleanup.

REFUTED along the way: CONTAINER-NOTES says --gpu=null runs here die at ~70 s.
The intro-audio capture ran 148.02 s under --gpu=null and ended on its probe's
timer with the emulator alive and the whole ADV movie decoded. More than twice
the quoted lifetime. That note had been the reason not to use --gpu=null for
anything long, which is exactly what a clean audio capture needs.

The route left, written down rather than attempted: use the XMA probe log as the
screen oracle instead of video. Sitting on the main menu decodes exactly BGM_103's
two waves, so their byte_sizes appearing IS the menu -- which is better
provenance for an audio question than a screenshot anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 09:21:47 +00:00
sylph-decoder
3392982e58 method: over-warning is what makes the next real warning unreadable
From the port, after two rounds of its 'known incomplete' banner firing wrongly
-- once with a message true only for ADV, once claiming a gap over 93.694 s of
exact zeroes. Both errors were in the cautious direction, which feels like the
safe mistake and therefore goes unexamined longer than a false all-clear would;
the cost is deferred onto the one occasion the warning is true. A warning needs
the same scrutiny as an assertion, starting with what it keys on.

Also records S00A's kept count moving 1 -> 2 after the voice-region fix, on an
asset the fix was not derived from and with the prediction preceding the
observation -- at the strength it actually has. A different ASSET is evidence
about generality; a different IMPLEMENTATION is what would make it independent,
and only one of those was present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 09:11:10 +00:00
sylph-decoder
dbb12036c1 method: an internal check cannot catch a bug that makes the input smaller
The voice-region truncation survived every test because there was nothing to
catch: the region parsed, the chunks decoded, the durations were self-consistent.
A missing third of a stream is smaller VALID output, and no check written against
our own output distinguishes that from correct output. What caught it was a
number from outside -- the running decoder's byte_sizes not fitting the region.

So: prefer at least one test per decoder pinned to something we did not produce,
and say so in the test, because the obvious maintenance of such a test is to
re-baseline it against current output, which deletes the only thing it was for.

Plus the port's corollary: it was checkable only because the identifier happened
to be a byte count. Index a handed-over result by a quantity the recipient can
test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:56:14 +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
fb6095ae3a handoff: tell the port its refusal found a decoder defect, and what is safe to use
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:41:38 +00:00
sylph-decoder
3e8235ddbd re: resolve_movie_voice_region starts INSIDE the first stream, 8 of 10 multichannel regions
Found because the port refused to apply my stream assignment and did the
arithmetic instead: the running decoder's three ADV contexts sum to 3 584 000 B
against a resolved region of 3 114 352 -- 15 % too small to hold them. Two spans,
one wrong, and it was the disc side.

The gap is 238 packets exactly (487 424 B), which is what a start offset looks
like; ctx0 declares 632 packets and the resolver's leading chunk has 394.

Verified against the decoder's own byte_sizes, which cannot be fitted to: at
-238 packets to_xma_riffs yields [1294336, 1118208, 1171456], all three exactly.
It is a real boundary and not the end of a sweep -- at -300 the previous asset's
chunks appear while the three ADV sizes stay stable.

Disc-wide: 24 of 24 single-chunk regions start at a boundary; 8 of 10 three-chunk
regions start mid-stream. The defect is specific to the multichannel case.

The audit's per-movie number is an UPPER BOUND, not the clip -- its stopping rule
is the chunk count changing, and to_xma_riffs absorbs a few packets of the
previous asset first (243 reported for ADV against a true 238). Only ADV has
external ground truth.

Consequence: in those 8 movies the leading chunk is a truncated first stream, not
a spurious artefact, and anything measured on it was measured on a fragment --
including this corpus's own chunk-0 level, though the assignment survives because
its ratio test was chosen to be immune to the clipping.

The resolver is NOT patched. Why the predecessor cue's trailer lands 238 packets
into the next asset is unanswered, and a fix guessed from one movie would be
worse than a documented defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:41:22 +00:00
sylph-decoder
8c71520ab8 re: which ADV stream sits where -- settled by level, not by waveform
Completes ask #4. The three chunks were dumped from the resolved voice region
and decoded; the assignment is ctx0 -> FL/FR, ctx1 -> FC with LFE silent,
ctx2 -> BL/BR.

Two instruments failed first and both look like results, so both are recorded.
Envelope correlation with a per-pair lag search returns 0.86-0.95 for EVERY
chunk against EVERY channel, because all six residual channels share the
dialogue's activity timing -- that is an instrument with no resolving power, not
a finding. Sample-level correlation returns about zero, because the chunks do
not start with the movie and the XMA decode's framing offset is unknown.

Level settles it under the same 0.600 gain the bed uses: each stream lands
within 0.5 dB of exactly one residual pair and misses the others by 4-6 dB. The
ratio test is immune to chunk 0 being a clipped tail of ctx0 -- chunk0 - chunk2
is +5.88 dB against FL - BL at +6.18 dB, agreeing to 0.30 dB, where a swap would
be wrong by 11.76 dB.

Structural confirmation: chunk 1 is the only chunk with a digitally silent
channel and LFE is the only output channel with an empty residual (-115.73
dBFS), one to one; and the internal L/R correlations track the residual pairs'
(0.932 vs 0.918, 0.962 vs 0.929).

Worth having on its own: the same 0.600 scales both the movie bed and the voice,
so it is one mixer gain rather than two.

Reach: levels, not waveforms; one boot, one movie; and whether 0.600 is a fixed
constant or a volume setting is still unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:32:14 +00:00
sylph-decoder
59f5bf1b59 method: a rule learned from a burn generalises by resemblance, not by mechanism
From the port agent. This file already carries two divisor bugs of the same
shape -- a silent input in a divisor attenuating real signal -- and the lesson
taken from them was 'be suspicious of dividing by N'. Applied to the intro's
three streams it produced a unity sum that the port's own checker rejected at
+2.62 dBFS.

The precedent did not transfer because a BGM bank's two waves are stems of one
signal while the intro's three streams are positions in a field, whose downmix
weights sum to one whatever the assignment. Nothing in 'several streams, one
output' distinguishes those.

The general point: a rule extracted from a burn is indexed by what the burn
looked like rather than by why it happened, so it fires on the next thing with
the same silhouette while feeling well-earned. State the mechanism a past lesson
turned on and check that mechanism is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:29:01 +00:00
sylph-decoder
232ae0fa9c re: ask #4 answered -- the intro is a 5.1 WMAPro bed at 0.600 plus three streams
ADV.wmv carries ONE audio stream and it is wmapro 5.1, not XMA. Any framing of
the intro's audio as only 'which of three voice streams to ship' was missing the
bed.

Aligned the 148 s capture against that track (envelope r 0.769 against a median
of -0.001, refined to +224 samples, r 0.900) and solved
capture = g x movie + residual per channel.

The gain is 0.600 on every channel -- a uniform -4.44 dB, a mixer setting rather
than a fit artefact. LFE reproduces to -115.73 dBFS, 72 dB down, which is what
rules out codec difference as the explanation for the other residuals. FC is the
exception: the movie explains NOTHING of it (-0.09 dB), and the movie's own FC is
91.6 % silent.

The residual is three signals, not one: a front pair (r 0.918), a rear pair
(r 0.929), and a centre whose partner LFE is empty. The FC residual spans 34 dB
across 100 ms frames -- bursty, not steady noise.

That CONFIRMS the corpus's 5.1 reading, which voice-three-streams-are-concurrent
recorded as not established, and it confirms the specific detail it offered: that
the mono-in-stereo stream is 'a centre paired with a silent LFE'. Measured from
the output with no access to the stream contents.

Also corrects my own census page: it labelled channels with the ALSA permutation
[0,1,4,5,2,3] from the recipe page, which does NOT apply to this capture. The
6x6 matrix was computed assuming no order, every row's max falls on a distinct
movie channel, and the answer is the identity -- so the census's 'BR is 82 %
silent' was really LFE, reconciling with the movie's own 80.64 % silent LFE.

Reach: one boot, one movie; which XMA context is front/centre/rear is not
determined, only that the residual occupies those positions; and whether 0.600 is
a fixed constant or a volume setting is unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:19:03 +00:00
sylph-decoder
ef4ea8fe86 re: the boot intro's audio output -- five live channels, not a stereo mix
Groundwork for the port's ask #4; it does not settle #4.

Captured the game's own output over the boot intro following the ALSA file-tee
recipe exactly -- paced pulse slave, --gpu=null, both mutes off. 148.02 s, 6ch
float32 48 kHz, 0.15-0.16 % silence against the 0.31 % the recipe page records
for its own clean run.

Provenance is the XMA probe rather than a screenshot, which is the right evidence
for an audio question: ADV's three contexts appear byte-exact (1294336 /
1118208 / 1171456), then the documented BGM_102 pair.

Five of the six channels carry distinct content; BR is 82 % silent and 11-15 dB
down. No channel is a copy of another -- the largest pairwise correlation is 0.70
between FL and FR.

That rules out a stereo mix, so 'ship one stream' cannot be right and the port's
held-wrong value stays wrong. It does NOT establish that summing is right, and
the 6-channel count is Xenia's hardcoded kFrameChannelsDefault -- what is
evidence is that five of them differ, which a stereo guest cannot produce.

NOT settled and named as such: the stream-to-channel mapping. The
cross-correlation of each captured channel against each decoded ADV stream has
not been run. One boot, one movie, and --gpu=null means no video cross-check.

Raw is 170 MB and is not committed; sent over share to the port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:13:50 +00:00
sylph-decoder
6f87ecc4db re: the pulse floor gets a second witness, and two METHOD entries
The port reproduced the floor exactly (159) once the predicate was named, and
counted an independent capture from a different session: 753 against this run's
714, a ratio of 4.7x against 4.6x. 'Never goes off' is no longer single-run.

Two METHOD entries.

A detector that can fire on a single frame will fire on the wrong one. The A/B's
first pair was void because the title detector tested one frame against a glyph
threshold and the intro movie throws sub-second green flashes of 1298..5433. The
presses were real and skipped the movie, so both legs returned a clean,
symmetric, meaningless result -- a void test that looks like it ran is worse than
one that errors. Same shape the corpus already recorded for screen_id.py calling
the SQUARE ENIX logo 'title'. Twice paid for. The rule is that a screen detector
matches a signature over time, and a broken run's own series is the cheapest
control for its replacement.

A demand for reproducibility can surface a defect that is not the one demanded.
The literal answer to 'your figures are unverifiable' was 'here is the
predicate', after which they verified exactly -- but writing the method down is
what exposed the cross-geometry floor comparison, which nobody was looking for.
And both sides were wrong at once: the challenger's counts were the wrong
measurement AND the published figure had a real flaw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:07:51 +00:00
sylph-decoder
e0de5c6f22 re: the A-press A/B is run -- signed-in profile, no swallow, menu opens
The debt from two iterations ago. Two boots, same binary and ISO, one A tap
each, fired only after the plate's pulse had been seen for 12 consecutive
samples. ARGV recorded per leg, because the config dump provably cannot say.

  leg A  no profile flag        3811 swallow lines and climbing
  leg B  --logged_profile_...   0 swallow lines, final glyph 327 = MAIN MENU

327 is the documented main-menu glyph count, reproduced by this instrument's own
control, so leg B's press opened the menu. Capture committed.

Leg A demonstrates the SWALLOW, not the crash: I stopped it at ~2.3 M swallowed
calls because kernel tracing at log_level=3 was eating the 300 MB budget the
crash dumps need. The fault itself remains measured once, historically. One run
per leg.

A void pair came first and is recorded, because it is why the detector is what
it is. The first version fired on a single frame over a glyph threshold and hit
the INTRO MOVIE -- green flashes of 1298..5433 lasting under a second -- about
6 s before the title, in both legs. The presses were real (each skipped the rest
of the movie, which is Q9's behaviour) but the pair tested nothing. The fixed
detector requires 12 consecutive in-band samples, and was replayed against the
void runs' own series as its control: it declines the movie flash at 84.8/85.5 s
and fires at 93.9/94.7 s inside the sustained pulse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 08:05:43 +00:00
sylph-decoder
6072d216a8 re: name the region and the threshold -- and the floor was cross-geometry
The port agent could not reproduce this page's 159/714/1520 from the capture it
holds: counting green>150..200 over a plate box it got 3-5x at every threshold.
The page named neither the region nor the predicate.

Stated now: the whole 1280x720 frame, and is_title.py's three-channel predicate
(g>130 & g-r>45 & g-b>45), which is why it counts far fewer pixels than a bare
green>N. That reproduces 1520/714/159 exactly.

Writing the method down exposed a defect the prose had hidden. The 159 floor
came from live-title-build4-no-plate.png at 1279x675 -- the game surface --
while the pulse frames are 1280x720, the whole display. Different crops,
silently compared.

Replaced with a same-run, same-geometry floor that was in the series all along:
154, flat for ~2 s immediately before the plate ramps in. So 'it never goes off'
now rests on one run in one geometry, at 714 against 154, which is where it
should have rested from the start. The port's independent ratio of 1:10.4-10.9
brackets this page's 1:9.6 and is the part robust to how anyone counts.

Two METHOD entries: a pixel figure needs its region and its predicate, and a
comparison between two counts needs them to share a geometry; and the port's
observation that a fix which overshoots leaves no symptom until a third change
needs the part it disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:59:00 +00:00
sylph-decoder
b6f0cf3fb2 re: RETRACT -- a Canary config dump is the FILE, not the run
Refuted my own evidence with a direct test. The A-press fault page cited the
faulting run's dumped logged_profile_slot_0_xuid = "" as proof no profile was
signed in. Xenia prints its config dump BEFORE applying command-line overrides:
in a run launched with --apu=sdl --hid=file --mute=true --log_mask=13, the dump
says apu="any", hid="any", mute=false, log_mask=0. Four for four.

So the dump is a statement about xenia-canary.config.toml and nothing else, and
this page cannot know the faulting run's profile state. Anything in the corpus
citing a config dump as evidence of what a run did is making the same mistake;
to know a run's settings, record its argv.

Survives: the mechanism (swallow -> unbounded pump -> failed allocation ->
fault), which rests on the [RE-INPUT] counter and the crash dump's registers;
and canary-scripted-input-traps.md section 3's measured sign-in-dialog claim,
which has a capture behind it.

Also records the port's base-plus-glow mechanism for the plate, which explains
why the pulse floor is 714 rather than the plate-absent 159 -- ptbtn00's fade at
t=244 is an exit ramp so the base holds at 255 while the screen is held, and
ptbtn00f's 0->80->0 glow draws over it. Marked as agreeing with the measurement,
not confirming it: their renderer is not an oracle. It does rule out a glow-only
plate, which could not produce a non-zero floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:52:14 +00:00
sylph-decoder
3c004845fa re: the PRESS A plate PULSES -- measured, and it keeps an authored entry
Answers the port's ask #1, which it had flagged as the only one of its four
that could delete an authored entry rather than confirm one. It confirms one.

Held at the title with no input, the plate oscillates continuously: two windows
in one boot, 58 s and 57 s, ~23 cycles each, no decay. Periods 2.530 and 2.540 s
by upward mid-crossings -- 0.4 % apart.

It never goes off. The plate-absent floor is 159 green pixels, measured on the
committed live-title-build4-no-plate.png; the pulse bottoms at 714, 4.5x that.
So the port's 'flash and nothing after', reasoned from ptbtn00 expiring at
t=244, is wrong on the boot's end state -- ptbtn00f's 120-unit cycle is what
runs.

Instrument controls were run before it was pointed at anything unknown: the
glyph counter reproduces the documented 753 on live-title-press-a.png and 327 on
live-main-menu.png exactly.

Two estimators, and only one replicates. Mid-crossings agree across the two
windows to 0.4 %; a single-sinusoid least-squares fit does not (2.553 vs 2.413),
because the waveform is fast-rise/slow-decay rather than sinusoidal -- its own r2
of 0.468 and 0.228 is the tell. Both were controlled on synthetic sinusoids at
2.24/2.55/3.10 s laid on the ACTUAL timestamps and recovered every one exactly,
so neither is broken; one is misspecified. Recorded as such.

The wall-clock is 13 % longer than the corpus's earlier 2.24 s mean. Same
declared 120 units, different emulator pacing (x1.27 here against x1.12), so
this corroborates 'author the units' rather than disturbing it.

Reach stated: one boot; does not distinguish the boot title from an attract-loop
title; and the glyph count is a thresholded pixel count, so 714/1520 is not an
alpha ratio and no duty cycle can be read off it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:37:49 +00:00
sylph-decoder
ec06c50bf6 method: the mirror trap -- two records that drift, and a weakened control
Two corrections from the port agent, both of which make earlier claims smaller.

1. Its 'reproduces your published centres to half a pixel' was model against
   model. This corpus's 981/478 are the model's output at t=355, not the
   capture's; the capture measured 992.0/467.2, the 11.5 px residual the page
   declines to fit. So that control shows two implementations of one model
   agreeing, not the model matching the oracle. Neither of us applied the
   correlated-instrument test to that sentence at the time.

   The discriminator survives: it asks whether two captures are the same frame,
   and the model is monotone in t at ~4 px/unit, so a 42-unit gap cannot come
   out of one frame however wrong the absolute times are. Recorded as such.

2. Running my 'grep for the symptom' audit against its own tree, the port found
   the opposite failure: a control recorded in BOTH a tool table and a document,
   drifted to 53.3 % and 53.2 %, with the evidence file gone so neither can be
   re-measured. One hard-to-find record announces itself as missing; two
   disagreeing records announce nothing, which is worse.

   So the rule is not 'write it down twice' -- one record in docs/re/,
   everything else cites it, and any number that must appear twice is generated
   rather than typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:26:27 +00:00
sylph-decoder
8dfa0ebac5 re: the A-press dialog is the SIGN-IN dialog -- and the corpus already knew
Answers the open half of the A-press diagnosis, and most of the answer was
already in the tree.

The faulting runs booted with logged_profile_slot_0_xuid = "" -- their own
config dump -- while a profile existed (Found 1 Profiles). With nobody signed
in, A takes the state-0 branch of sub_821D03A0 and calls XamShowSigninUI(1,1);
Canary raises its Sign In dialog with a no-op close handler and nothing in an
unattended run dismisses it. 85 instructions verified against the image, 0
mismatches; the state-3 branch is XamShowDeviceSelectorUI, already ruled out by
storage_selection_dialog = false.

The correlation runs through the tooling: boot_menu.sh passes the profile flag
and Q4/Q5 pressed all five buttons; frame_clock.sh, which produced the faulting
run, does not.

So there is no blocker -- boot with boot_menu.sh. Flagged as retrodicted rather
than A/B tested, since I have not myself booted both ways and pressed A.

The uncomfortable half: canary-scripted-input-traps.md section 3 already named
the sign-in dialog WITH a committed capture, and boot_menu.sh's header already
carried the mechanism and the 8.4 million figure. The fault page searched for
the cause it had hypothesised and never searched for its own symptom. Added to
METHOD.md, along with the more expensive lesson -- a measurement whose only
record is a script comment is invisible to the document that needs it.

What this session did add is the join: that the known blackout is what drives an
unbounded guest queue into a failed 128 MB allocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:21:18 +00:00
sylph-decoder
15d9037774 re: the sweep discriminator resolves -- 294.9 against a predicted 295
The port ran the check and returned 294.9. Different frames; both measurements
stand. Its renderer also reproduces this page's published t=355 centres to half
a pixel on both quads, which is the control that makes the 295 worth anything,
and it confirms the 600/720 cycles from its own export rather than from the
header word.

Worth recording as method: the observable and the value were specified before
the port computed anything, so it produced the number without knowing whether
295 was the pass or the fail. Neither agent checked its own instrument with its
own instrument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:18:11 +00:00
sylph-decoder
59f3ac6846 re: ask #2 -- t=357.7 came from a GPU draw capture, and a sweep cannot date a frame
The port asked whether the refined sweep fit t=357.7 was measured against
live-title-build4-no-plate.png, because if so one of us is 42 units out.

It was not. 357.7 was solved against title-draw-capture-vertex-colours.log, a
GPU per-draw capture of the submitted vertex buffer -- four observables at once,
two quad centres and two vertex alphas. No framebuffer, no PNG.

The gap is not a fitting error either. Posing the leaves directly, t=400 misses
the captured quads by +169.0 and -172.2 px. Probe control: it reproduces the
page's published t=355 centres, 981 and 478, exactly.

Refutation attempted and FAILED: I expected the port's fit to be minimised by
the quad leaving the screen -- 'best fit' meaning 'draws least', the same shape
as the .tbm control that could not fail. At t=400 quad B is fully on screen and
quad A is 319 of 400 px. Their number is fitting something present and it
survives.

The real reason the two must differ is better than 'different frames'. The
sweeps are nested records on a free-running loop and their cycles differ -- 600
and 720, read from the record header +0x08 -- while the top-level clock stops at
settle. So two captures of the same settled title share a screen time and not a
sweep phase, by construction.

Consequence for the port: a sweep position does not date a frame; it gives a
phase on a 600- or 720-unit loop. And 357.7 is a joint fit over both leaves
while the port's ~400 poses one, so the two are not comparable in kind -- the
phases coincide only every LCM 3 600 units.

Discriminator handed to the port rather than taken: if its ~400 is pteff03 and
the frame is in the first cycle, pteff03a must be at centre 295 in that same
frame. The fit is against its renderer, so it owns the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:15:45 +00:00
sylph-decoder
bec7f02c0a re: not ONE of the 80 forced instances has a file-read key
The port agent pointed out that forced_backdrop_necessity.rs collapsed
sprite_layer_key (a u16 read from the T8aD header, decoded) with
implied_layer_key (this crate's table of positions MEASURED in the running
game), and that 'has its own key' therefore reads as file-backed when it is
not. Splitting them is stronger than either of us stated:

    read from the T8aD header:   0
    implied (measured):         14   10x pfbase.tbm, 4x palogo_eff0.prm
    nothing at all:             66   62 decided, 4 inert

Zero. There is no instance on the disc where a forced element also carries a
file-read layer key, so this rule has never been checked against a decoded
field -- there is no case where both can speak. That is what a keyless-element
fallback necessarily looks like, but it removes a check a reader would assume
exists.

Also corrects something I said to the port and had wrong. 'None of the 18 is
evidence for the rule in any direction' conflated two questions. Whether the
rule changes the composite: no, the sort already had the key. Whether the rule
gets the RIGHT answer: yes, and the 14 implied keys are measured positions, so
this is the rule agreeing with the oracle -- its only external corroboration,
and there are 14 instances of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:11:12 +00:00
sylph-decoder
f8abb56bc4 re: reconcile the two ink counts -- we agree at >1 and not at >0
The port agent produced a genuine second witness for the pixel-cost claim: it
re-checked GP_TITLE entry 12 in Godot, which shares no code with compose,
swapping only paint_order. 59 530 px ink with the rule, exactly 0 without it.
The strong form -- the screen ceasing to exist, not merely changing a lot --
now has two real renderers behind it on that entry.

Its figures did not match ours, so I counted the same composite every way:

    RGB > 0    ours 49 771   Godot 59 530    16 %  apart
    RGB > 1    ours 48 043   Godot 48 368    0.68 % apart

The entire disagreement lives in pixels whose value is exactly 1. That is a
1-LSB sampling artefact between two samplers, not a different set of inked
pixels. So '>0' is not a portable ink convention between renderers on a
mostly-dark frame and '>1' is; any future cross-renderer ink figure should say
which it used.

Also worth recording: our 49 771 was never a threshold figure. It is exact RGBA
inequality between the two paint orders, which over a black backdrop coincides
with ink>0 -- so it belongs against the port's 59 530, not its 48 368. Matching
it to the 48 368 would have made the two renderers look like they agreed for
the wrong reason.

The without-the-rule column is 0 at every threshold here too, matching Godot:
the strong form is not threshold-sensitive in either renderer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:07:21 +00:00
sylph-decoder
aa9b7ef340 re: the forced-backdrop rule's pixel cost -- 38 screens go black without it
Follows the necessity census. 'The order moves' is a property of the sort; the
tie-break work already found reorders costing zero pixels, so the picture
moving is a separate claim. Rendered each of the 62 deciding builds twice and
diffed.

  38 .prm deciders: changed_px == ink_px in ALL 38. Without the rule the
                    primitive sorts last, paints over everything, and the
                    screen composites to pure black. The port's original
                    contradiction argument, measured on 38 builds across seven
                    archives instead of argued on two.
  24 .tbm deciders: zero -- and that is MY INSTRUMENT, not a finding.

The control asked whether the composite had ink; it always does. The question
was whether the reordered ELEMENT has ink, and compose draws no pixels at all
for a .tbm. So those 24 zeros measure our renderer's blindness by construction.
tie_break_pixel_cost.rs already had the per-element ink_mask this needed.
Reported rather than quietly patched: a control that cannot fail is the shape
this corpus keeps paying for.

Also corrects two things the port agent caught:

  - 'Two renderers, same answer' was true of the six GP_TITLE instances and not
    of the other 74. The port's re-run of my probe is my code executed twice;
    its independent leg was removing its own exporter post-pass, which covers
    GP_TITLE only. The disc-wide 62 has one witness and the page now says so.
  - forced_backdrop_necessity.rs defaulted to GP_TITLE with no argument, so a
    bare run printed 6 instances in the same format as 80. It now walks every
    dat/*.pak and reports the archive count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 07:04:05 +00:00
sylph-decoder
304ce9efaa re: forced_backdrop DECIDES 62 of its 80 instances, not 6
The port agent raised that every check this corpus ran on the rule measured
its STABILITY -- that no verdict moved -- and never its NECESSITY. It is
right, and the distinction is load-bearing.

New probe: recompute derived_paint_order with the forced_backdrop fallback
removed and diff the orders, over every dat/*.pak.

  80 forced instances = 62 the rule DECIDES + 18 it merely AGREES with.

The 80 reproduces the page's own census exactly, which is the check that the
probe sees the same set. Every one of the 62 deciders is keyless; no keyed
element is ever moved.

Of the 18 that agree, 14 have their own key -- and that includes the
palogo_eff0.prm 'control', whose implied key is 0x00000000 and would sort it
first regardless. So that agreement is the rule reproducing our crate, not the
game confirming the rule. The port saw this before I did. The remaining 4 are
keyless but inert: every element on those two builds is forced, so the
tie-break gives the same order either way.

Confirms the port's GP_TITLE finding from the other side: entries 10/11/13/14
unchanged without the rule, entries 12/15 decided by it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 06:53:16 +00:00
sylph-decoder
e94e203a71 re: the A-press fault is SOLVED -- Xenia swallows input, the guest pump is unbounded
The 326 MB log from the failing run was still on disk, so this needed no
emulator time at all.

Mechanism: Xenia's XamInputGetKeystrokeEx returns X_ERROR_SUCCESS with a zeroed
keystroke on every call while a XAM dialog is up (xam_input.cc:197, upstream
Canary). The game's keystroke pump -- sub_82457038, read out of the image -- is
an unbounded 'while (GetKeystrokeEx() == SUCCESS) queue.push_back()'. It queued
8 388 608 empty keystrokes, grew its vector to 64 MB, asked for 128 MB, got a
failed allocation back unchecked, and copied off the top of the guest stack.

Two independent instruments agree to within 7: the Canary counter's last report
before the crash says 8 388 601 swallowed calls; the crash dump's r29 says the
vector held 8 388 608. The reporting granularity is 600.

Retracts this page's own 'r9 is a wild pointer above 4 GB'. Xenia prints
si_addr, a host address; the guest is mapped at 0x100000000, so the fault
address is guest 0x701D0000 -- which is exactly r9 in the register dump.

Also refutes nothing of the port's, but answers its ask #3: the two press-a
captures are different frames (40.84 % of the band's pixels differ at the
best alignment, which has a sharp minimum), so its 0.301 % is not an
instrument floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 06:44:49 +00:00
sylph-decoder
6d7bc87b0f re: diagnose the A-press fault -- a wild pointer, and a third failure mode
The blocker on all menu-side dynamic RE in this container, measured over four
runs and traced to an instruction.

A single A press on the title produces a Xenia CRASH DUMP with PC 0x824578A0 and
"Access Violation: write at 0x00000001701D0000", repeating 32 356 times and
writing 326 MB of register dump in about ten seconds. Four A runs faulted; four
no-input runs in the same sessions completed.

REFUTED, my own hypothesis: it is not an unimplemented instruction. The config
carries break_on_unimplemented_instructions = true and Xenia's own message reads
"to skip, disable break_on_unimplemented_instructions", so the flag looked like
the fix. Booting with it false faults identically, and no "Unimplemented instr"
line is ever logged on stdout or stderr in any run -- and since that path emits
its XELOGE BEFORE the guarded DebugBreak, the absence rules the mechanism out
rather than leaving it open. The dump comes from Emulator::ExceptionCallback, a
genuine guest exception.

The instruction, read from the image rather than the database: b0c90000 is
sth r6, 0(r9), the first of four halfword stores at offsets 0/2/4/6 through r9
inside a bne- loop -- code filling an array of 8-byte records with four u16
fields each. So r9 is a wild pointer, and 0x1701D0000 is above 4 GB, outside the
guest's 32-bit address space entirely: not a null dereference and not a small
overrun, but a base that was never a guest address. The database agrees on the
containing function, sub_82457780 at +0x120.

It is a THIRD failure mode. Not the cache-flush crash at 0x82307128, and not the
loader stall documented in canary-scripted-input-traps.md, which logs ZERO crash
dumps. Unlike the stall it reproduced 4 of 4, so that page's "retry whole boots"
does not obviously apply. It does not explain how Q4 and Q5 pressed A
successfully; what differs between those runs and these has not been found.

frame_clock.sh's 300 MB guard killed the run as designed -- the session log's
"EMULATOR GONE at 56s" is the guard, not the crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 22:17:45 +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
1f6e07598e re: the primitive colour census -- and it refutes 38 of my own 80 forced verdicts
A disc-wide census of the ARGB that keyless elements carry.

Every full-screen *eff00* PRIMITIVE is pure black at its various alphas
(ff000000, 7f000000, 40000000, b2000000, cc000000, d4000000, 00000000). Black at
alpha a over content is exactly an alpha-over dim or fade, and an additive black
quad would be a no-op nobody would author -- so this narrows the open blend
question a long way. The only non-black primitive on the disc is pbafc.prm, RGB
00e8e0 cyan at alphas up to ff, and it is 844x600, NOT full-screen, so it sits
outside forced_backdrop's geometry guard. It is now the sole additive candidate.

The census also refutes my own argument for nearly half its verdicts. Of the 80
forced-first instances only 42 are .prm; 38 are .tbm carrying fade ffffffff. A
SOLID white quad at alpha 255 painted first would make the screen white, and no
screen is white -- so a .tbm is a white modulation on a texture, and element
alpha does not establish its coverage.

That is the .t32 error one file extension further out. I guarded that with
el.sprite.is_some(), which fixed the symptom and not the cause: an element's
alpha is not its texture's opacity, and only an untextured primitive makes the
two the same fact.

So 42 verdicts stay decoded and 38 drop to inferred -- still almost certainly
right, since all are named *base*, all are full-screen, and pfbase.tbm's first
position is measured in the running game, but that is a name-and-role argument
which this page elsewhere calls the weaker kind.

The code is deliberately unchanged. Restricting forced_backdrop to .prm would
send eleven screens' backgrounds back to u32::MAX -- last -- which is the
blank-screen bug the rule was written to fix. Downgrading the status is honest;
reverting the position would be wrong. The 42/38 split is pinned by a test so
anyone tightening the rule sees what it costs.

Separately, on the port's black_hold_units ask: four more no-input boots yielded
one usable log, which armed late and missed the publisher splash, so the sample
is still two runs spanning 3 and 4 frames. Their 6.5-9.2 range stands. And a
reason it may not be resolvable this way: the draw log DROPS frame numbers -- in
the 3-frame run, frames 121 and 124 are absent entirely, so "frames with no
sprite" and "span of frame numbers" are different quantities.

Their statistical correction is taken: at n=3 the sample SD (3.893) is the
estimator, not the population SD (3.179), making my run 1.88 sigma from the
corpus mean rather than 2.31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:51:06 +00:00
sylph-decoder
7a5f7b886a re: downgrade -- the drift explains the 4.1% in sign, not magnitude
The port refuted the stronger half of the last claim and was right. I wrote that
the units/frame drift explained the publisher splash's 4.1% error against its
declared dwell. It explains the sign only.

Their test verified exactly here: the publisher/developer dwell ratio is 1.2143
declared, 1.2784 as the corpus's three cold boots measure it, and 1.3678 as this
container's drift predicts -- so the drift's direction is right and real evidence,
but its magnitude is about 2.4x too strong.

One refinement, because the means are being compared more finely than n=3
supports: the corpus's three boots individually give excesses of +0.89%, +8.24%
and +6.79%, a spread of 7.3 percentage points -- WIDER than the 5.30 pp gap under
test -- and boot 1's ratio (1.2251) is essentially the declared 1.2143. This run
sits 2.3 sigma above their mean: suggestive, not established.

Not closable without a frame log from the corpus's instrument, which was
screenshot timing and has none. An attempt to give this side an n of 3 failed on
tooling and is recorded: ARM=early loses its F10 about 40% of the time -- two of
five runs logged "ARMED EARLY" and produced no draw log at all, with nothing in
the session log distinguishing them.

Also fences the 33% drift against a misreading the port flagged: it is
PRESENTATION pacing and cannot reach keyframe_units_per_second = 60, which is the
game's logical rate, decoded under Q1, and which a renderer converts through at
its own frame rate.

And records a cross-check neither side went looking for: the batch counts are 1
and 2 on the publisher against 3 and 6 on the developer, and the port reports a
count restricted to SPRITE-BEARING elements reproduces that exactly from the
export -- so palogo_eff0, the layerless forced backdrop, is not in the batched
draw, confirmed from the file. Two instruments that disagreed about that element
in every previous iteration now agree on which one it is.

New tool splash_boundaries.py carries the corrected counting method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:40:23 +00:00
sylph-decoder
6c8ab5db94 re: withdraw the splash frame boundaries -- count the batch, not the logged quads
The port refuted the boundaries in boot-splash-dwells-are-declared.md by
arithmetic: the two splash spans gave 2.237 and 2.414 units/frame, 7.9% apart on
one boot of one guest, which must be one number. They proposed the spans were
anchored on different elements.

The log says the cause is worse. The developer splash batches SIX quads into one
draw (indices=24) and the log dumps only the first two. While the three glows are
alive they occupy that prefix, so the three wordmarks are invisible to the log
until the glows stop being submitted at t=45. "Developer wordmarks first drawn at
frame 140" was the logging prefix shifting, not the game -- and the same defect
explains why palogo_anima never appeared at all.

The fix costs nothing: indices/4 is how many quads the draw actually holds, and
the 8-vertex cap cannot touch it. Its transitions land exactly where the declared
count of elements with alpha>0 changes, giving free calibration points:

  publisher   1->2 quads at frame 5.5 (t=15), 2->1 at 22.5 (t=45), ends 119.5 (t=255)
  developer   3->6 quads at frame 126.5 (t=15), 6->3 at 139.5 (t=45), ends 209.5 (t=210)

That yields 1.765 and 2.165 units/frame on the publisher, 2.308 and 2.357 on the
developer -- the developer's two independent segments agreeing to 2%, and the rate
rising 33% across the run. One cause for both the port's 7.9% inconsistency and
this page's open 4.1% publisher error, exactly as they predicted: the publisher
splash runs during the first seconds, where the rate is furthest from its later
value. It also means no single units-per-frame figure describes a run here, which
is the dwell-is-emulator-paced conclusion from a third direction.

The declared dwells (255 and 210 units) and the corpus's three-cold-boot
confirmation are untouched -- neither uses this draw log.

METHOD.md gains the general form: when an instrument truncates, the surviving
sample is not random, it is the first N, and what falls in the first N is itself a
moving function of the thing being measured. A truncated view looks like a
complete view of a smaller set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:34:51 +00:00
sylph-decoder
acaacc36e0 re: the boot splash dwells are declared -- and wall clock is the wrong unit
The port asked for two wall-clock timestamps across the boot splashes. Measured,
and the measurement's own result is that timestamps are not the invariant.

The dwells are the bundles' own declared timelines: publisher t=0..255 = 4.250 s
at 60 units/s, developer t=0..210 = 3.500 s. The corpus's independent screenshot
timing over three cold boots gives 4.30/4.60/4.37 and 3.51/3.50/3.37 -- the
developer agreeing to 1.1%, two of its three runs to 0.3%.

A fresh no-input boot with a frame->wall-clock map puts the same two dwells at
5.10-5.61 s and 3.83-4.30 s, 15-20% longer than both the declared values and the
corpus's runs, on the same disc and the same declared timeline. So the
wall-clock dwell is an emulator-pacing artefact that varies run to run, and a
port authoring seconds is authoring one run's pacing.

Boundaries from the draw stream, read per quad: publisher glow frame 1, wordmark
6-119, three frames with NO sprite drawn, developer glows 123, wordmarks
140-209, intro video 216. The 3-frame gap replicates the earlier 4-frame
measurement within the +-1 both are quantised to.

New tool frame_clock.sh, and its limitation found by its own control: it
resolves to one BUFFER FLUSH, not one frame. The capture writes through a C++
ofstream, so tail sees the log in bursts -- 69 of 125 samples showed no advance
and the rest jumped 7-15 frames. Naive interpolation inside a burst made the
apparent rate swing between 0.0164 and 0.0316 s/frame, which is the flush and
not the guest. Frames 119 and 123 fall in one burst, so the inter-splash gap is
not separable by this clock at all. Everything is quoted as brackets and the
point estimates were withdrawn before being reported.

palogo_anima never appears in the log and is NOT reported as undrawn: the
developer bundle batches 7 elements into one draw and only the first two quads
are logged. That is the trap that produced the eff3 false negative, so it is
named rather than claimed.

Also records the port's correction: ptcopyright has 105 instants with alpha >= 1
(t=139..243) against 105.89 units of span; I had quoted the rounded span.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:29:06 +00:00
sylph-decoder
78328c5022 re: the top-level clock freezes at the settle point -- closing the 114-vs-120 gap
Measured in the title draw capture, re-read with the per-quad parser.

GP_TITLE build 4 declares t = 0..269, about 120 presented frames at this run's
pacing. The title dwell lasted ~1100. ptcopyright declares alpha >= 1 for 106
units (t=138..244) and is drawn for 1050 frames; ptlogo1 declares an exit at
t=264 and is drawn for 1095. Both vanish within three frames of the dwell
ending.

So the top-level clock advances through the build-in, stops inside the settle
window [160,236], and holds. The exit ramp is not played on a timer -- it plays
when something makes the screen leave. That is ui-settle-time.md's decode seen
from the other side and observed in the running game rather than inferred from
the file. A nested record keeps looping on its own clock throughout.

This closes the 114-vs-120 gap, and it was my arithmetic rather than a
discrepancy in the decode. The 2.231 units/frame was regressed over BUILD-IN
events -- the only stretch in which the top-level clock advances -- and applied
to a period measured over the settled dwell, where that clock is frozen and
only the plate's own record is running. Two different clocks. The declared 120
was never in doubt from the calibration-free dark-fraction test.

The 51.158-frame period is now confirmed by a second independent estimator:
autocorrelation returns lag 51 with clean harmonics at 102 and 154. Its FIRST
version failed its control, returning 48 for a period known to be 51.158,
because it indexed by sample position where the log's frame numbers have gaps.
Recorded, because the failure is the reason the second version can be trusted.

Not settled: the sweeps' period. The same validated estimator disagrees between
two dwells of one screen -- 515 vs 452 frames for the same family -- and a 14%
disagreement within one screen is not a period. The +0x08 field cannot settle
it either, since ptloop01/ptloop02 have zero slack.

Blocker recorded in CONTAINER-NOTES: a single A press on the title faults the
guest. Three menu-capture attempts, two ending in register dumps of 223 MB and
519 MB, against three no-input runs in the same session that all completed. And
a guest fault writes an UNBOUNDED register dump to stdout on a filesystem at
91%, so any scripted button press needs a size guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:21:27 +00:00
sylph-decoder
cf91ad1dcc re: the forced-backdrop span -- 256 vs 211 is a bundle mismatch, and the hold
decides 55% of verdicts

The port implemented the forced-backdrop rule and reported a discrepancy:
palogo_eff0.prm at 256 opaque instants against this corpus's 211.

There is no discrepancy. palogo_eff0.prm appears on BOTH splashes -- the
publisher (entries 10, 13) runs to t=255, giving 256 instants; the developer
(11, 14) runs to t=210, giving 211. Same definition, different bundle. The page
now names the entries so it cannot recur.

The definition, stated: the span is 0..=max keyframe time over every element in
the build, and an element HOLDS its final pose past its own last keyframe --
which is what pose_at does, and which is decoded rather than assumed (a group
holds at its last keyframe rather than looping; the declared +0x08 never falls
short of the last keyframe, the slack being that hold).

The port's instinct that the hold was load-bearing was right. Over the 130
keyless full-screen primitives with an opaque interval:

  * span = the header's declared +0x08          ->  0 verdicts change
  * span = the primitive's own last keyframe    -> 72 change
  * elements GONE after their last keyframe     -> 72 change

So the hold decides 55% of verdicts -- and dropping it is REFUTED by a measured
order. palogo_eff0.prm is a single keyframe at t=0: without the hold it is
opaque for one instant, no other element is up yet, and the rule calls it free,
against a game measured painting it first. Pinned by a new test that spells out
the counterfactual rather than importing it.

The verdicts that matter are convention-independent: pgloading_eff00.prm is
FIRST under all four conventions and pteff00.prm FREE under all four. And the
header length is interchangeable with the elements' maximum -- zero
disagreements disc-wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 21:14:58 +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
47faeaa7a9 re: RETRACT "the game never draws eff3" -- a batched draw merged two quads
The console draws all five title flashes. My claim that ptlogo_back2eff3 is
never drawn was an instrument artefact, and I had reported it to the port with
three alternative explanations "ruled out".

A GPU draw can batch several quads -- indices=4 is one, indices=8 two,
indices=24 six -- and the UI draw log dumps only the first 8 vertices. Taking
min/max over a line's whole vertex list merges quads into one box.

eff3 is batched with eff4, and because the wipe family is right-aligned, eff3
(788..1196) lies ENTIRELY INSIDE eff4 (447..1196). The union is exactly eff4's
own extent, so the merged box matched eff4 to 1 px, eff3 vanished, and nothing
looked wrong.

Parsed per quad, all five fire in both title entries in the declared stagger:
eff1 130-131, eff2 133, eff3 133-134, eff4 133-135, eff/eff5 134+, back2 136+;
and 5953-5955 / 5955-5957 / 5957-5958 / 5957-5959 / 5958+ / 5962+ in entry 2.
Frames 133 and 134 are t=60.1 and 62.3, inside eff3's declared t in (58,64).

Also retracts "the developer splash is one composited quad" -- the same bug,
which the port refuted by arithmetic first (a 259-tall box cannot contain three
logos spanning y 164..585). It draws three logos and three glows as separate
quads in one indices=24 call; the 525x259 was gamearts_eff merged with
seta_eff. The 9-unit black hold is unaffected: those glows are the developer
splash's first draw.

The three "ruled out" explanations were all aimed at the wrong failure. In
particular the invisible-draw check counted draws with NO geometry line, when
the hiding place was draws with PARTIAL geometry. Refuting three wrong
hypotheses is not evidence for a fourth, and a list of failure modes written by
whoever built the instrument is the least likely to contain its blind spot.
Recorded in METHOD.md, along with the tell that was present and explained away:
a merged box carries the first quad's colour, which made one element's alpha
read 255/127/254 on consecutive frames.

New tool: tools/re-capture/quads_per_frame.py parses vertices in groups of four
and warns when the logged quad count falls short of indices/4.

Also guards a double-A-tap in ui_draw_capture.sh: the movie branch ignored that
TARGET=menu had already tapped, so a run tapped A on the title at t=23s and
again at t=27s on the transition; the guest faulted and Xenia dumped registers
to stdout until the file reached 519 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 20:49:27 +00:00
sylph-decoder
1a2f4fdc2d re: the boot splash black gap is ~9 units, measured in draws not luminance
The port found its boot had no black frame between the publisher and developer
splashes and authored 12 units by analogy with the menus' transition quad. On
the boot path that analogy has nothing behind it -- palogo_eff0.prm is a single
static keyframe, so the splash bundles declare no fade quad.

I had agreed with the dismissal that hid the defect: told the residual was
0.03 s against a bound built from two measured ranges plus jitter slack, I said
it said more about the bound than the game. The real gap was 0.2 s.

Measured in the draw stream, which separates true black from a fade tail where
luminance cannot: palogo_sqex is drawn to frame 125 at alpha 7, frames 126-129
submit NO sprite quad at all, and the developer fades in at frame 130 from
alpha 34. Four presented frames, the only such run in the sequence.

Converted with the disc as its own clock rather than a frame rate -- this run
presented at 13.1 fps against 28 elsewhere -- palogo_sqex declares alpha >= 1
for 239.8 units and is drawn in 105 frames, giving 2.284 units per presented
frame, which the title capture independently corroborates at 2.231. So the gap
is ~9.1 units (0.152 s), against the 12 authored; +-1 frame is 6.9-11.4. And
the true black is SHORTER, since both boundary frames still carry picture.

Second finding: the developer splash is ONE composited 525x259 quad at the
bounding box of its three declared logos, none of whose individual sizes is
ever submitted. That is why an earlier pass reported "developer splash: 0
frames".

Declaration sites ruled out: the splash bundles (no fade quad) and the
top-level +0x08 (a family constant, 300/60, slack 12-226 units). The
executable is NOT looked at and is named as the next place rather than
claimed.

Also answers the port's sweep question: +0x08 canNOT settle it, because
ptloop01/ptloop02 have zero slack and a zero-slack record cannot distinguish
"loops" from "runs once and stops". The oracle settles it for the TITLE -- the
sweep oscillates over its whole range and resets hard to the same start, once
in dwell 1 and twice in dwell 2, so it does not park. The MENU is unmeasured
and stays open.

Tooling: GRACE and NOTAP knobs for ui_draw_capture.sh. The script taps A on
"the screen changed a lot", which is also true of a fading splash -- a first
run tapped through the publisher and the developer never appeared. The
instrument was perturbing what it measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 20:36:15 +00:00
sylph-decoder
69bc4cd1cd re: the game never draws ptlogo_back2eff3 -- reproduced, with three explanations
ruled out

Last iteration I recorded eff3's absence as unexplained after withdrawing a bad
explanation for it. The previous capture survived on disk with 6907 frames, and
the attract loop returns to the title, so it contains a SECOND build-in at
frames 5942..7025. eff3 is absent there too.

Three alternative explanations tested and failed:

  * sampling phase -- eff3 is non-zero for t in (58,64), SIX units, against a
    2.23-unit step. A window wider than the step cannot be missed; frames 133
    (t=60.1) and 134 (t=62.3) sit inside it and draw eff2 and eff4 instead.
  * a draw the log cannot see -- exactly 2 draws per frame carry no geometry,
    on all 932 settled title frames, always the same full-screen-triangle
    shader, and present on frames where no wipe element is active.
  * a bad position guess -- dropping position entirely, ZERO quads anywhere on
    screen in either build-in window have a width within +-30 of 408. The width
    spectrum jumps straight from 262 to 748.

Draw counts across both entries: eff1 4, eff2 3, eff3 0, eff4 6, against ~5
expected each. The four are a right-aligned wipe (938+258, 788+408, 447+749,
64+1133, all ending at x~1196) -- a left-growing reveal in four widths, of
which the game draws three.

Why is NOT established: nothing in eff3's element record differs from its
neighbours. Classified measured, not decoded, and the port is told that
dropping eff3 means authoring a behaviour I cannot derive from the file.

Two further corrections, both to my own earlier claims:

  * "frame 107 is the title composited once" was an over-read. It binds NO
    texture and only 4 of its 27 draws log geometry. The second title entry
    has no such frame.
  * the two build-ins are NOT frame-identical. I had that from a coincidentally
    aligned pair of rows; aligned properly only 4 of 46 frames match. They are
    the same animation at different sampling phases -- which is precisely what
    makes the eff3 result robust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 20:19:04 +00:00
sylph-decoder
311bd16ad8 re: withdraw the eff3 explanation and the flash advice; add a calibration-free
test that refutes 105

Two claims shipped this morning are withdrawn, and the port had already acted
on one of them.

WITHDRAWN 1: "eff3 was never drawn because a 2-unit flash peak is sub-frame."
eff3's alpha is non-zero for t in (58,64), and the capture's frames 133 and 134
sit at t = 60.0 and 62.2 -- squarely inside that window, with eff2 and eff4
both drawn in the same frames. It should have been submitted and was not. The
absence is real and UNEXPLAINED; it is not sampling phase.

WITHDRAWN 2: "a port drawing all five flashes shows more sweep than the
console". No evidence behind it. The port checked against its own renderer and
found it draws them sequentially at their declared times, never more than two
at once -- which is exactly what frames 131-135 show the game doing. The
pile-up worth warning about was the rest() bug, now fixed.

Kept, at the port's request: a frame-by-frame comparison of the build-in WILL
disagree about which flash lands in which frame -- 2 units per submitted frame
against this run's 2.231 units per presented frame -- and neither side is
wrong. Without that stated, the discrepancy reads as a port defect.

Added, and stronger than the argument it replaces: a calibration-free test of
105 vs 120. The glow's draw is omitted when its alpha reaches zero, and the
smallest alpha actually submitted across 807 drawn frames is 1, so the culling
threshold is read off the data rather than assumed. Measured dark fraction
17.7% (173 of 980 settled frames); a 120-unit cycle with its declared 15-unit
hold predicts 14.4%; a 105-unit cycle predicts 2.2%. 105 is out by 8x and
would need a threshold of alpha 11 out of a peak of 80, while the capture
contains submitted draws at alpha 1..12. No frame rate, no pacing factor, no
wall clock.

Also recorded: a regression of five build-in events against their declared
times (residuals <=0.9 frames) recovers t=0 at frame 106.1 when the composite
spike, not in the fit, is frame 107 -- and that same slope makes the glow's
period imply a 114-unit cycle against a declared 120, which is unexplained.
And the vertex-alpha identity holds for the glow but does NOT generalise:
eff4 reads 255/127/254 on consecutive frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 20:03:48 +00:00
sylph-decoder
d98c8214cc re: the title's build-in measured in the guest's draw stream -- the flashes are real
The settle-time decode was confirmed only against a SETTLED frame, which shows
the end state is right and says nothing about whether the five flashes ever
happen. This runs the oracle: a draw capture armed before the title exists,
so the window contains the frames in which the screen is built.

The flashes fire in a six-frame window and are absent from all 155 other
sampled frames. `ptlogo_back2eff1` is drawn in exactly two frames at t = 54.0
against a decoded peak of t54-56; `ptlogo1` first appears at t = 42.2 against
a decoded t42. Units-per-frame was taken from the GLOW's period alone, a
different element, so the timings are not circular. The two holders are
continuous from frame 134.

The plate glow's quad carries a per-vertex colour whose alpha IS the element's
fade alpha, so the ramp is read straight out of the guest: observed range
0..80 against a decoded peak of 80, exact and unfitted; period 51.158
presented frames over 20 cycle starts. Fitting the decoded ramp gives RMS
13.16 alpha levels against 38.18 for the same ramp REVERSED -- if the shape
carried no information those would be equal, so the asymmetry is real and
correctly directed. Further controls: symmetric triangle 15.73, flat 31.13.

`ptlogo_back2eff3` was never drawn, and that is expected rather than a miss: a
2-unit flash peak is 0.85 of a presented frame, so catching one is a matter of
phase. A port drawing all five every time shows more sweep than the console.

METHOD.md gains the trap this cost: a 2D draw's identity is its vertex
geometry, not its bound texture. These sprites sample shared pages, and
matching texture dimensions produced a false negative (no flash is ever drawn)
and a false positive (the intro movie's 640x360 YUV planes read as `ptbase2`)
in the same pass.

Also records the top-level restriction on the settle window, which the port
raised and which is verified here: top-level [160,236] width 76, including the
`ptloop` leaves [269,540] width 271 -- an instant past the end of every
top-level element's timeline.

Evidence committed as a derived per-frame series, not the 7 MB raw log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:56:34 +00:00
sylph-decoder
4c74e579a0 re: 210 focus records pulse, not 2 -- and 8 of them fail in a way that looks right
The port censused focus-record alpha over its own export -- 34 elements, 2
varying, both `ptbtn00f` -- and concluded there is nothing to fix. That is
correct and correctly scoped. This asks the same question of the whole disc.

1 130 focus records, 2 664 timed elements, 210 with a varying alpha. 202 have
`rest()` returning the PEAK, the `ui-settle-time` pathology. By pak:
PILOTLOG 116, MOVIE_THEATER 54, HANGAR_ARSENAL 30, LEADERBOARD 8, GP_TITLE 2.

So the port's 2 is right because GP_TITLE has 2. The scope was load-bearing
and was not stated as a limit -- "only 2 have a varying alpha" reads as a fact
about the format and is a fact about one pak. The pathology is concentrated in
exactly the screens a wider port reaches next.

The 8 LEADERBOARD ones are the worse mode. `py_ranking_btn01f` swings
255->127->255 with no two adjacent keyframes equal, so `rest()` falls through
to its longest-dwell rule and returns 244 -- neither the peak nor the trough.
A glow stuck at its peak is visibly wrong; one stuck at 244 of a 127..255
range looks entirely plausible and nothing reports it.

Verified rather than asserted: two hits dumped keyframe by keyframe, and a
control on `ptbtn01f`, which is genuinely constant across its cycle and is
correctly NOT flagged. `py_ranking_btn01f` also confirms the loop-length
decode independently -- its ramp ends at t=90 inside a declared 120-unit
cycle, holding bright for 30 units.

Reach stated: 210 is a floor. Focus records are matched by the `Xf.rat` name
rule, and elements with constant alpha but varying scale, rotation or
position have the same problem and are not counted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:45:17 +00:00
sylph-decoder
d4a08ad194 re: a nested record's +0x08 is its loop length -- the plate's period is 120, not 105
Answers the question the port agent asked: does the `PRESS A` plate's pulse
group loop from its start, or hold at alpha 0 between cycles? It holds.

A nested record is itself a RATC bundle with its own header, and that header's
`+0x08` is the loop length -- the same field ui_header_time_disc already tests
as an animation length at the top level. Its keyframes need not fill it, and
the slack is a hold at the final pose. `ptbtn00f` is 105 units of ramp inside
a 120-unit cycle, so the glow rests dark for 15 units between pulses. The five
main-menu focus records fill their 120 exactly, which is what shows the slack
belongs to this record rather than to the format.

Disc-wide over 1 781 timed nested records: 92.3% declare exactly their last
keyframe time, 7.7% declare more, and 0 declare less. That last row is the
falsifier -- a cycle cannot restart before its own last pose -- and it never
fires; the 7.7% is what keeps the reading from being an unfalsifiable
relabelling of the keyframes.

Falsification against the running game, using a pacing factor measured
INDEPENDENTLY on the main menu's focus ring (declared 120 units, measured
2.177 s, factor 1.0885): to reach the corpus's four measurements of the plate
pulse (2.12/2.19/2.34/2.31 s), a 105-unit period needs a factor of 1.211-1.337,
which EXCLUDES the ring's; a 120-unit period needs 1.060-1.170, which CONTAINS
it. Predicted 2.177 s against a measured 2.12-2.34. The two elements are in
different bundles and were measured in separate runs; the only thing tying
them together is that both declare 120.

So the port should stop shipping 105. Its 123-vs-129 ambiguity straddled the
right answer without containing it, and 129 only fitted because it was
105 + the exit_ramp_units constant it has since correctly deleted.

Reach is stated: this says where a cycle ends, not which records cycle, and
the TOP-level +0x08 is a different field left untouched -- every GP_TITLE
entry declares 300 while its elements end at 244-269.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:40:23 +00:00
sylph-decoder
7b5a4aa041 re: the paint-order tie-break costs one pixel, on one screen we do not ship
Closes the open half of Q3. `ui-paint-order-derived-check.md` bounded WHERE a
wrong tie-break could show -- overlapping same-key pairs -- and said outright
that nobody had measured how many change a pixel.

At the instant the player sees, the answer is: at most 1 px at max channel
difference 1, on the JAPANESE title only (`ptlogo2` x `ptlogo_tm`, 5 px of
shared ink). Exactly 0 px on all five port screens.

The earlier 24-pair bound was counted at `rest()`, and 10 of the title's 11
overlapping tied pairs are between `ptlogo_back2eff1`..`eff5` -- the five
transient flashes from the settle-time finding, transparent on the settled
screen. A tie between two invisible elements cannot cost a pixel.

Not a knife-edge. Sweeping every keyframe time and every midpoint between
keyframe times, the live-pair count is flat across the ENTIRE settle window:
1 on the EN title, 2 on the JP title, 0 on all four loading bundles -- whose
tie is live only at t17..t33, during the build-in, which matters because
their settle windows are narrow enough to deserve little trust otherwise.

Controls: every entry reporting zero also swaps an overlapping DIFFERENT-key
pair, which must and does move pixels (25 310 / 268 698 / ~765 000 px). Zeros
are explained by shared-ink counts rather than asserted -- the `ptframe` pairs
overlap by bounding box and share 0 px of ink. Entries 0/1/12/15 have NO live
control and their zeros rest on keyframe data rather than a render; recorded
as the weaker claim it is.

Refutation attempt on the corpus's "24 overlapping pairs": it SURVIVES as a
rest-pose count -- an independent recount reproduces entry 7's 16 exactly.
What is overturned is its interpretation as the risk surface.

`tie_break_pixel_cost` gains a settle-time case and an alpha/scale filter on
its rect test; `tie_cost_over_time` is new. Also strips 611 bytes of captured
cargo warnings from the head of the committed tie census.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:35:29 +00:00
sylph-decoder
38b80302b7 re: the title's light arc is five transient flashes, not a tone or rotation error
Records the settle-time decode, and corrects two claims it overturns.

docs/re/structures/ui-settle-time.md is the finding: a settled screen is one
instant every element is posed at, the disc names it (the midpoint of the
longest keyframe-free interval), and the title's arc closes from 33.22 to
11.79 with the clipped-pixel count landing on the console's 1459 within 0.5%.
Includes the disc-wide reach -- only 30% of bundles have a window wide enough
to trust -- and a three-way figure.

Withdrawn in ui-rotation-implemented.md:

  * "Flat. No minimum." was not a property of rotation. `at` posed LEAVES ONLY,
    so the scan moved the light sweeps and never touched the top-level flashes.
  * "our renderer does not draw ptlogo1/ptlogo2 at all" is wrong. Both are
    drawn; the four elements the diagnostic named are kind-0x4 ghosts sharing
    their template's name. Hiding the real ones makes the error WORSE by
    +5.20 and +7.47.
  * Its 10.92 baseline is not reproducible -- 14.07 at its own pre-change tag
    and 14.07 today -- so the "1.7% better" verdict rests on an unrecorded
    recipe. Flagged in title-residual-tone-vs-geometry.md too.

METHOD.md gains two traps: a shared CARGO_TARGET_DIR makes a `git worktree`
build silently replace the binary you run next (it cost three renders here,
caught only because a missing flag was a hard error rather than a wrong
number); and an aggregate computed per-element is not a state of the system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 19:29:12 +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
21a2c649e6 notes: build-reborn points at a source root that does not exist either
Found while checking what "Reborn" names. build-reborn line 15 is
SRC="${PROJECT_DIR:-/work}/Syplheed-Reborn" -- transposed letters -- and no such
directory exists; the workspace is at /work itself. It fails immediately with
cd: /work/Syplheed-Reborn: No such file or directory, so the documented way to
run the disc-gated tests is broken in this container.

Records the direct alternative, setting SYLPHEED_DISC by hand, which is what this
session has actually been doing.

This is the second wrapper here pointing at a source root that does not exist --
build-canary has the same defect and blocks the audio tap. Worth checking a
wrapper s SRC before trusting that a green or a failure came from your own code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:47:35 +00:00
sylph-decoder
ce5505872b handoff: deliver the tone-versus-geometry split on the title
The rotation decision needs a size, not just a direction. A per-level LUT fitted
on a screen is the most general tone model possible, so whatever it cannot close
is by construction spatial. Self-fitted, it closes 70.3 percent on the main menu
-- the positive control, where the port measures 0.06 percent so geometry is
right -- and only 32.0 percent on the title. At most a third of the title s
disagreement is tone; at least two thirds is geometry.

Also warns the port off a global tone correction: the curve does not transfer.
Fitted on the title and applied to the menu it closes 29.7 percent; the other way
round it makes the title 24 percent worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:45:30 +00:00
sylph-decoder
f2a74efe2d re: at least two thirds of the title s disagreement is geometry, not tone
Separates two confounded effects so the pending rotation decision has a number:
how much would drawing the rotation actually buy.

A per-level lookup table fitted on a screen is the most general tone model there
is -- every render level mapped to whatever capture level minimises the error,
no functional form assumed -- so whatever such a LUT cannot close is by
construction not a per-level effect. Fitting on the screen itself therefore gives
an upper bound on the tone share and a lower bound on the geometry share.

Positive control: on the main menu, where the port measures 0.06 percent of
pixels differing so geometry is essentially right, a self-fitted LUT closes
70.3 percent, from 9.26 to 2.75. The instrument can collapse a tone-dominated
residual.

Result: on the title the same self-fitted LUT closes only 32.0 percent, from
10.92 to 7.42. So at most a third of the title s disagreement is tone and at
least two thirds is spatial -- content in the wrong place, which is where the
rotation lives. The fitted LUT is generous to tone, so the geometry share is if
anything larger.

Also refutes the idea of a single transferable tone curve. Fitted on the title
and applied to the menu it closes 29.7 percent; fitted on the menu and applied to
the title it makes things 24 percent WORSE. A curve fitted on a dark flat screen
is unconstrained at the bright end -- the menu s populated range is levels 5 to
204 with few bright pixels -- and extrapolating it onto the title s planet and
wordmark actively harms. That extends the existing refutation of the single
exponent: even a full per-level LUT fails to transfer, so a consumer must not
carry a global tone correction.

Reach: two screens, one capture each, and the title pairing is the same screen
but not the same instant, so the ratio is what is claimed rather than the
absolute level. Our render draws the sweeps parent record only, so the geometry
share includes both the missing rotation and the missing leaf placement -- both
closed by the same decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:45:15 +00:00
sylph-decoder
1f9609c37f re: the keyframe block s +4 and +8 are angles used almost always as a 180 flip
Narrows a standing unexplained pair without claiming to decode it, and states
precisely why it cannot be closed in this container.

Census over every UI pak on the disc -- 2859 builds, 90347 keyframes, parents and
nested leaves. +4 has 12 distinct values and +8 has 11, against 157 for the
decoded rotation at +12. Per sprite-instance across 14241 of them, with +12 as a
control because it is known to hold a real angle: +4 takes more than two distinct
values on 7 instances, +8 on 99, and +12 on 396. So +4 is in practice a two-state
field whose state is 180 -- and for a screen-plane sprite a 180 degree rotation
about an in-plane axis is a mirror.

But they are not booleans. GP_TITLE entry 7 s ptlogo3a runs +4 = -72, -18, -4, -1
against +12 = -14, -4, -1, 0: the two decay to zero together with +4 roughly four
to five times +12 at each keyframe. That is a coupled two-axis settle and the
strongest support the disc offers for the three-axis reading. So the readings
reconcile -- the field is an angle whose overwhelmingly common use is the 180
degree special case.

The reach is the important half. All six non-zero +4/+8 keyframes in GP_TITLE are
in entry 7, the Japanese title, which has no oracle capture and which MISSION
scopes out as localisation beyond English. The five English screens that do have
captures carry +4 = +8 = 0 on every keyframe, so they never exercise the fields.
The paks that use them heavily, GP_READY_ROOM at 4686 and GP_DIALOG at 1058, are
also out of scope and GP_READY_ROOM is a recorded no-go. So this is untestable
against every oracle the project holds rather than merely unfinished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:30:29 +00:00
sylph-decoder
258e1e63f7 method: an insensitive observable fails twice, and the second way sends you hunting
The port checked my pivot claim rather than taking it and found the nuance: the
sprite is odd-width, 399, so its true centre is 199.5 against a declared pivot of
200. Half a pixel, far inside the 0.70 and 0.48 px agreement, so it changes
nothing -- but "the pivot IS the centre" is the kind of sentence somebody leans on
for a sub-pixel claim later, so the page now says it is the centre rounded up.

And records the general form of the two bugs this exchange produced, which share
one cause. First, a leaf-composition rule was checked against alpha, which moves
about 0.3 levels per keyframe unit, so a one-keyframe association error barely
shifted it and the rule looked confirmed -- while the same span moved x by
1560 px. Second, fitting t from that same alpha manufactured an 11.5 px position
residual that did not exist and sent the consumer hunting a pivot rule to explain
it, because one byte of alpha quantisation is worth 1.5 to 1.9 keyframe units.

Solve on the fastest-moving field and check the slow one, never the reverse. The
second failure is the more expensive: failing to falsify leaves you falsely
reassured, but inventing a residual sends you looking for a mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:25:52 +00:00
sylph-decoder
f15c3f516d re: the 11.5 px residual was the alpha-only fit s resolution, and it closes
Left open earlier as do-not-fit. Closed by adding observables rather than tuning
a parameter.

The draw s vertex buffer carries positions and colours at the same instant, so
all four quantities must agree on one t. Solving independently: quad A x gives
357.88, quad B x 357.58, quad A alpha 355.75, quad B alpha 354.09. The alphas are
about 50x less precise per unit of time because alpha is a byte changing by only
0.27 to 0.33 levels per keyframe unit, so a single level of quantisation is worth
1.5 to 1.9 units, which at 4 px per unit is 6 to 8 px of sweep. The 11.5 px was
that.

At the position-derived t = 357.7 every observable lands: centres within 0.70 and
0.48 px, both alphas inside one level, and the parent alpha is 0 throughout. And
there is no pivot correction to find -- the leaf pivot is (200,90) against a
399x180 sprite, so the pivot is the sprite centre and rotation displaces it by
nothing.

The methodological point is the one this exchange started with, inverted. Earlier
a rule looked confirmed because it was checked against alpha, the insensitive
field. Here the same insensitivity manufactured an apparent 11.5 px error. The
insensitive quantity does not merely fail to falsify, it invents residuals. Solve
on the fastest-moving field and check the slow one, never the reverse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:23:28 +00:00
sylph-decoder
a1adb17d35 re: 125 percent is not the only odd scale, and ptlogo_eff2 is a pop not a steady state
Refutes a DECISIONS claim and decodes the element the port withheld on my say-so.

The claim that title_jp s ptlogo_eff2 at 125 percent is the single drawn element
in the whole export at a scale that is not a whole multiple of 100 percent rested
on a census of PARENTS only. Opening the 45 leaves as well finds thirteen
distinct non-whole-multiple scales -- 75, 96, 99, 101, 103, 112, 125, 150, 204x208,
210x220, 250, and the 75x100 / 96x100 / 99x100 pairs -- with 125 among the rarest
at two occurrences. ptlogo1 and ptlogo2 carry 101/103/112 on the ENGLISH title
too, so it is not a Japanese-build peculiarity. The claim s real content was "the
only one the port draws", which is about the export s element set rather than the
disc.

And ptlogo_eff2 is decoded. The 125 percent lasts 57 units, about 0.95 s -- a
scale-0 to 125 to scale-0 flash between t=50 and t=107, a transient rather than a
steady state, which is why it looked anomalous in a census of resting poses. The
leaf draws at 100 percent as two superimposed copies of the same sprite at alpha
160 and 80, each rotating a full 360 degrees over 960 units: a slow double-layered
spin, 16 s per revolution.

This is exactly the case the ptloop rule could not separate. There the parent had
expired so leaf-wins and parent-ignored were indistinguishable; here the parent
carries real geometry including a scale that reaches zero twice. If parent scale
gates the leaf the spin is a 0.95 s flash; if the leaf runs on its own timeline it
spins for 16 s. Nothing on the disc chooses between them, and title_jp has no
oracle capture, so it is undecodable in this container -- the port is right to
withhold it, and the Japanese-locale capture MISSION has parked would settle it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:16:20 +00:00
sylph-decoder
5da1a837fa re: the port s leaf x = -324 is the OLD keyframe association, not a geometry gap
They implemented the leaf rule and reported the leaf top-left at x about -324 at
t=355, off-screen left, against 781 here, and asked how a rotated 600-percent
quad s declared pos relates to its drawn centre. It is not a geometry question.

Their stated pairing is t=150 at x=-639, t=540 at x=-39. On the disc the poses
-639 / -39 / 1521 carry times 0 / 150 / 540. Their pairing gives each pose the
NEXT pose s time -- exactly the association ui-keyframe-record-layout.md refuted
and HANDOFF carries a red banner about. Feeding their pairing into the same
interpolation reproduces -324 to the digit.

With the corrected association t=355 gives top-left 781 and centre 980.5 for the
399-wide sprite, against 992.0 measured off the capture.

So the leaf path still carries the pre-fix association although the top-level
parser was corrected: a leaf is parse_build on a sub-slice, so anything reading
leaves through a separate path can still be shifted.

And the reason it looked confirmed is worth keeping: alpha at t=355 sits inside a
long segment where a one-keyframe shift barely moves it, while x sweeps 1560 px
over the same span. The rule matched on the insensitive quantity and was wrong on
the sensitive one -- check a new interpretation against the fastest-moving field,
not the one that happens to agree.

The residual 11.5 px between 980.5 and 992.0 is left open rather than fitted; a
rotation about a declared pivot rather than the centre would displace by roughly
that much and nothing here measures it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:11:10 +00:00
sylph-decoder
14a7897c8f handoff: deliver the leaf-versus-parent alpha rule
The port asked for the composition rule and declined to guess it, which was the
right call. Delivered on the page they read: draw the leaf on its own timeline,
do not multiply the parent s alpha in, with the refutation stated -- multiplying
predicts zero at the observed time and the sweeps would be invisible.

Keeps the two limits that matter to a consumer. It is not a universal precedence
rule: here the parent is a container with no sprite, while for a button the leaf
duplicates the parent and the parent wins, so the discriminator is which record
carries the geometry. And because every observation has parent alpha zero, leaf
wins is not separated from parent ignored because it draws nothing.

Also flags their title_jp ptlogo_eff2 lead as untested by me, with the reason it
is worth checking: if its two-element leaf carries the geometry the same way, the
125 percent scale may be the parent s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:03:38 +00:00
sylph-decoder
2f1c561d76 re: a nested .rat leaf animates on its own timeline; parent alpha does not multiply
Answers the port s question, which they refused to guess at: they emit both a
parent record and its nested leaf, each with an alpha ramp over a different
span, and would not draw the leaf without the composition rule.

The per-draw capture records vertex colours, and on the title s ptloop draw they
are C3FFFFFF and B6FFFFFF -- alpha 195 and 182, not 255 -- so the composed alpha
is observable.

Fitting ONLY the two alphas against the two leaf ramps gives one consistent time,
t = 355, where leaf A is 194.8 against an observed 195 and leaf B is 182.2
against 182. The parent has expired there: it returns to 0 at t=250 and a group
holds at its last keyframe. So leaf times parent over 255 predicts zero for both
quads and the sweeps would be invisible. They are drawn.

The position check was predicted rather than fitted: nothing about x entered the
fit, and the same t=355 places the quad centres at 981 and 478 against 992.0 and
467.2 measured off the capture -- within about 11 px on 400-px quads travelling
1560 and 1950 px. Four quantities from two differently-shaped ramps agree on one
time.

The rule: a leaf carrying geometry animates on its own timeline and the parent s
alpha does not gate it. For these records the parent is a container with no
sprite.

Reach stated: one draw, one capture, one element pair, and specifically the case
where the parent carries no geometry. The opposite case is already recorded --
for a button a base record s leaf duplicates the parent and the parent wins -- so
the discriminator is which record carries the geometry, not a fixed precedence.
And because every observation here has parent alpha 0, "the leaf wins" is not
separated from "the parent is ignored because it draws nothing"; a capture during
t=100 to 238 would separate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 18:03:19 +00:00
sylph-decoder
5a4428ec67 handoff: the port s title residual is the rotated ptloop quads, not a blend mode
The port asked whether a blend field exists and whether _eff layers draw
additively, naming it as their largest remaining oracle gap. Both answers are no
and the cause is already decoded.

Additive blending is refuted specifically: T8aD +0x04 bit 0x02 as an additive
selector was tested and every measure worsens against the capture. The export
carries no blend field because none has been found -- the per-draw capture
records primitive type, index count, shader hashes, texture bindings and vertex
attribute 0, but no RB_BLENDCONTROL, and reading real blend state needs a Canary
change that is blocked here.

One of their three eliminations is overturned. They ruled out the ptloop sweeps
as 399x180 at (441,270) with keyframes holding position constant -- that is the
unscaled, unrotated geometry. Measured off a GPU draw capture, the live title
submits two rotated quads: ptloop01 at 400x1076 rotated +30.26 degrees centred
(992,359), and ptloop02 at 400x1444 rotated -45.28 degrees centred (467,360),
the scales being 600 and 800 percent. Two quads at x about 467 and 992, leaning
opposite ways, is exactly their signature of darker centre-left and brighter
right nearly cancelling. Our own renderer shows the same residual from the same
cause, tiles running -38.6 then +33.8 across the band, so it is a shared decode
gap rather than a defect in their compositor.

The rotation itself is decoded: keyframe block +12, degrees, clockwise-positive,
confirmed against a framebuffer capture. So their biggest oracle gap and the
rotation question they raised for the human are the same item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:56:16 +00:00
sylph-decoder
3bd1a61ea9 re: S00A is unreachable here -- the new-game path crashes on a cache it cannot build
Drive 4 worked. With focus detected and the detector validated live against a
known transition (NEW GAME, down, LOAD GAME -- CONTROL PASSED), the drive
navigated and pressed through, and every step confirms against a committed
capture: main menu +0.999, newgame-difficulty +0.999, newgame-selectdata-crash
+0.997. Then the guest throws, PC 0x82307128 times 349, and no S00A voice stream
ever decodes.

It is on the new-game path rather than the boot: the log order is ADV attract,
then BGM_103 menu music, then the throw. So it is not the ~100 s boot throw that
title-crash-stl-tree.md documents.

That page attributes the throw to an incomplete on-disc cache and Q4 s note cites
it for this crash. The attribution survives, but not through the container the
page names. aab216c3 is complete here at 7 files. The line immediately before the
exception resolves \1b556564\9\00c8dcd, and 1b556564 holds exactly one file plus
a stray 1b556564900c8dcd.tmp -- precisely the page s run C, partially rebuilt,
which throws. So the new-game path builds a different cache container from the
title path and that one is incomplete.

The page s remedy does not transfer. It restores a previously complete cache, and
no complete 1b556564 has ever existed here: the game crashes while building it,
leaving the .tmp, so the cache cannot complete and re-running does not escape it.
Its own run B shows a fully cold cache throws as well, so deleting does not help.

Consequence recorded: the centre-channel voice result stays resting on ADV alone,
because the corroborating asset is behind a crash that is outside menu-port scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:52:36 +00:00
sylph-decoder
c5fe6a460c re: initial focus is TUTORIAL x3 / NEW GAME x3, and never anything else
Two more data points for the Q5 instability, from today s drives. A run that
pressed A with no d-pad movement ended in a tutorial mission, correlating +0.960
with the committed capture, so that boot opened on TUTORIAL. A later boot read
NEW GAME from a focus detector on the first menu frame.

Six boots on the same harness now: TUTORIAL three times, NEW GAME three times,
and no other item ever observed. The distribution is not uniform over the five
buttons -- only these two occur -- which is a real constraint on whatever selects
initial focus and something an explanation will have to account for.

Also records in METHOD a bug that cost a seven-minute driven boot: a value was
clamped for readability BEFORE the comparison that used it. A focus detector
printed a degenerate margin, so it was capped at 999; the cap ran before the
vote-sorting step, two different votes compared equal, the stable sort kept the
wrong one, and a correct NEW GAME became an out-of-range index and a refusal. The
measurement was right throughout -- a cosmetic fix changed a decision. Clamp at
the point of display, never upstream of a comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:42:03 +00:00
sylph-decoder
8b4dcccb67 handoff: BGM_103 as the menu music now has a runtime leg
The port asked for this to have its own line rather than sitting inside a drive
report, and they are right -- it is a third independent confirmation of a claim
that the port authors from.

The claim rested on GamePart_Title s sub_821C5580 playing cue 1103 (static code)
and on the bank s two declared wave sizes matching what an XMA probe saw (disc
census). On a driven boot, BGM_103 s two waves were handed to the XMA decoder at
the moment the main menu appeared -- observed being decoded on arrival at the
screen, rather than inferred from a table or matched by size afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:33:29 +00:00
sylph-decoder
b7930e6616 re: the S00A drive failed -- initial menu focus was TUTORIAL, not NEW GAME
Reported as a route finding rather than retried silently, because the cause is a
defect in shared harness tooling that will bite the next drive.

Drive 1 sat 396 s reporting other with two spurious menu hits while the guest was
healthy and decoding audio throughout. screen_id.py thresholds on green and only
returns title once the PRESS A plate has faded in, so it cannot see the
plate-less build-4 title this corpus documents. It also calls difficulty-screen a
menu. Both reproduce on committed frames, and newgame_path.sh, nav_probe.sh and
boot_menu.sh all gate on it.

Drive 2, with a classifier controlled 6/6, reached title_plate at t=398 s and
drove on -- into a TUTORIAL MISSION. The screen 60 s after the last A correlates
+0.960 with the committed tutorial-mission-reached-then-crash.png, and no S00A
voice stream ever decoded. newgame_path.sh s header assumes NEW GAME is the first
item so no d-pad movement is needed; that contradicts this corpus s own Q5
result, where four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME.

Incidental corroboration: BGM_103 s two waves decoded on reaching the menu, an
independent runtime confirmation of the menu-music claim that HANDOFF rests on
static code and a disc census.

What is needed is a focus DETECTOR, and wrap-around means counting presses cannot
substitute -- up from the first item goes to the last. I do not have one: a
per-row brightness statistic failed its control, picking NEW GAME on the capture
whose filename says OPTIONS.

Also records a refutation attempt on the port s focus identification that FAILED.
Differencing the two captures and binning by row appeared to show NEW GAME and
EXTRAS changing, contradicting them. That was my error -- I placed row bands as
rest_y plus or minus 24, treating the resting position as a band centre. The
offset-independent check settles it: the changed bands are 254.9 design-y apart
against a button pitch of 80, so the two focused buttons are 3 apart and not 4 --
NEW GAME to OPTIONS. Their identification stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:27:22 +00:00
sylph-decoder
2a7ac9f859 re: the tone curve s single exponent is refuted above render 40
Raised by the port and independently reproduced here before being adopted, since
adopting their claims unchecked has misfired twice this session.

Binning matched pixels by render level rather than fitting a scalar, the implied
exponent falls monotonically and crosses 1.0:

  render          16     23     31     39     47     64
  port  (all)   1.26   1.18   1.10   1.03   0.93   0.85
  mine  (flat)  1.303  1.347  1.128  0.912  0.935  1.003

Below the crossing the capture is darker than the render, which is what the page
measured; above it the capture is brighter. A single exponent cannot express a
curve that crosses unity, so the model is valid only in the darks -- which is
exactly the reach the page already stated. The reach line was not a hedge, it was
the finding.

Where the two disagree is recorded and not resolved: the crossing is about 44 by
their binning and 35 to 40 by mine, and the darks read 1.18-1.26 theirs,
1.30-1.35 mine, 1.49 for the page s original patch fit. Three estimators on three
populations, all agreeing on direction and on gamma above 1 in the darks.

A confound in my own reproduction is stated rather than left implicit: whole-image
correlation is only 0.594 because the committed capture and the default render
differ in focus state, which the port measured as 74.1 percent of differing
pixels. My bins include that mismatch, so they are not a clean second opinion.
And a 1280x720 render against a 1279x675 capture needs a resample, which is why
the fit is restricted to flat-neighbourhood pixels.

METHOD gains the general form: a stated reach is a boundary rather than a hedge,
and the fix was printing the curve instead of a scalar, because a scalar hides
its own domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:20:41 +00:00
sylph-decoder
fbe4ba5526 method: screen_id.py cannot see a plate-less title, and calls DIFFICULTY a menu
Both reproduce on committed reference frames, so this is a defect in a shared
harness tool rather than a one-run anomaly:

  live-title-build4-no-plate.png -> other   (should be title)
  live-title-press-a.png         -> title
  difficulty-screen.png          -> menu    (is not the main menu)

It thresholds on green -- 0.0009 with the PRESS A plate against 0.0002 without --
so it only recognises a title once the plate has faded in. This corpus s own
finding is that the boot title shows build 4 FIRST, plate-less, for about 2.25 s,
which means any harness waiting for `title` from it can sit through a visible
title and report nothing. That is what happened on an S00A drive here: 396 s of
`other` with two spurious `menu` hits, on a run whose audio proved the guest was
healthy throughout. newgame_path.sh, nav_probe.sh and boot_menu.sh all gate on
it.

The zncc-against-committed-frames classifier used for the settle-time screen log
has neither defect, controlling 6/6 with both movie frames and difficulty-screen
as negatives -- but only at a 0.85 threshold. At 0.60 it also called
difficulty-screen a menu at 0.632, so the threshold is doing real work and has to
be controlled rather than chosen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:14:03 +00:00
sylph-decoder
a13dd42c2d re: stripping additive padding is exact -- controlled, and only one side needs it
The page recorded the substituted-versus-additive distinction as unverified, with
the port controlling it. It passes.

A real music and SFX bed of 137.37 s, itself carrying 454 genuine zero runs --
which is what makes it an honest control -- had 1149 holes inserted at 8.37
gaps/s to +9.9 percent length, matching the measured ALSA profile, then stripped:

  original vs itself (ceiling)   r 1.000  lag 0.0 s  margin +0.141
  padded vs original             r 0.436  lag -12.2  margin +0.006
  stripped vs original           r 1.000  lag  0.0   margin +0.142

Two things beyond the yes. It runs the inference forwards: padding at this
profile puts correlation squarely in the known-absent regime on a file whose
contents are controlled, so the earlier captures were unusable for the reason
claimed rather than for some other one -- until now that was reasoning backwards
from a failure to a cause. And only one side needs stripping, since the stripped
capture matches the UNSTRIPPED source at the ceiling, so a capture needs no
preprocessing before being handed over and there is no shared step to get out of
sync on.

The danger is recorded as the part to repeat: stripping removes genuine silence
too and cannot tell the two apart, so it is exact on additive ALSA padding and
vandalism on a PulseAudio monitor capture where the silence replaced real audio.
Running it on the wrong artefact would look like it worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:05:37 +00:00
sylph-decoder
9e66d6a82c handoff: deliver the centre-channel result and the S00A capture that is not taken
Records the port s measurement on the page they read, because it closes the last
open question and my own pages carried the hypothesis it settles. Keeps all
three limits as they stated them, including that streams 2 and 3 are
indistinguishable to the instrument so no selection rule is vindicated, and that
the one-of-three-streams warning stands with its character changed rather than
its colour.

Also names the capture that would strengthen it most and says plainly that I have
not taken it: S00A rather than a longer ADV, why it is structurally different,
and what it costs -- a driven rendered run, so no --gpu=null and the additive
padding comes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:01:44 +00:00
sylph-decoder
5fb64f3d31 re: the voice dialogue is in the CENTRE channel -- measured, not inferred
The 5.1 reading of a voice cue s three concurrent streams was recorded here as a
hypothesis with counter-evidence attached. It is now answered, by the port
fitting the disc s decoded streams against a clean capture of the game s own
6-channel output, with the instrument controlled first (known-present margin
+0.248, known-absent +0.005).

Speech-band margins put streams 2 and 3 at +0.305 and +0.307 on FC, r = 0.989,
above the known-present control, while stream 1 sits in the noise on every
channel. The low band mirrors it exactly: the movie bed at 0.76 to 0.84 on the
four corners against 0.32 on FC. Dialogue in the centre, bed in the corners.

The hypothesis was right for a reason the file could never have supplied.
ChannelMask reads 0x0002 on all three streams, so the header is not merely
unhelpful, it is actively misleading -- refusing to call it 5.1 from the header
was correct, and the oracle answered what the header could not.

Three limits recorded as the measurer stated them: streams 2 and 3 are
indistinguishable to this instrument, so no rule for choosing between them is
vindicated; the one-of-three-streams warning stands, since nothing says what
streams 1 and 3 contribute; and the reach is 59.7 s of a 137 s movie, one run,
one asset.

Also records the capture that would strengthen it most and why it is not taken:
S00A rather than a longer ADV, because its second full-length stream is digital
silence where ADV s is a 0.60x copy, so a structurally different movie would
agree. Reaching it needs a driven rendered run -- S00A starts about 4.5 s after A
on the save slot -- so it cannot use --gpu=null and will carry the additive
padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 17:01:26 +00:00
sylph-decoder
6f25a6a0d0 re: substituted silence destroys information, additive padding does not
The distinction is the port s and it is sharper than the framing this page had.

PulseAudio s monitor SUBSTITUTES: audio that existed is replaced by silence to
keep the wall clock, so information is destroyed and deleting the holes only
compresses time unevenly. Xenia s padding is ADDITIVE: the silence is inserted
between samples the guest emitted, so nothing is lost and every real sample is
present and in order.

So stripping all-channel-zero runs from an ALSA-tee capture is exact rather than
a repair, which means even the 0.70x rendered capture at 9.98 percent padding is
usable for correlation, where none of the PulseAudio-monitor captures ever were
however they were tuned. Recorded as unverified: the port is controlling it by
padding a known source to match and checking the stripped result correlates back.

Consequence for check-capture recorded too: its silence and gap-rate rule was
built when only damage existed and cannot distinguish genuine emulator padding
from capture damage, so a FAIL on an ALSA-tee capture is a statement about the
recording path rather than the file s usability.

Also promotes the runaway guard to a first-class CONTAINER-NOTES entry at the
port s request -- 7.34 GB in 50 seconds at about 250x real time is not a
footnote -- and adds --gpu=null there, which is what takes the guest from 0.70x
to 0.96x and stops the padding, with its two caveats: no video for provenance,
and runs die at about 70 s with PM4_DRAW_INDX failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:56:32 +00:00
sylph-decoder
4f77ab26d7 re: --gpu=null gives a clean audio capture -- 0.31 percent silence, 0.01 gaps/s
The residual padding in the ALSA tee was the guest running at 0.70x real time,
and the dominant load is llvmpipe software rendering -- which an audio capture
does not need at all.

  PulseAudio monitor, xenia default             39.3 percent silence  30.5 gaps/s
  ALSA tee to paced slave, rendered              9.98 percent silence  8.37 gaps/s
  ALSA tee to paced slave, --gpu=null            0.31 percent silence  0.01 gaps/s

One gap in 67.7 s. Six distinct channels, no duplicates, sensible peaks. For
scale the port s genuine music bed control measures 1.1 percent silence at 3.3
gaps/s, so this capture is cleaner than their known-good reference.

Control that the run is still comparable: ADV s three XMA contexts appear in the
--gpu=null log, so the movie s voice is decoding exactly as in a rendered boot.
That is also better provenance for an audio question than screenshots were, since
it evidences the thing being recorded rather than what was on screen.

Records the full working recipe. Caveat: --gpu=null means no video, so
screen-based provenance is unavailable and it is only appropriate when the
question is about audio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:52:51 +00:00
sylph-decoder
eaa578421f notes: point the container page at the working audio-capture route
The entry already warned that run-canary is silent twice over, but stopped
there -- and fixing only the mutes still yields an unfaithful capture, because a
PulseAudio null sink s monitor is wall-clocked and invents silence when the
client is late. Adds the pointer to the ALSA file-tee page, with the two things
a reader needs before starting: the slave must pace or the tee free-runs at about
250x real time, and a size guard is not optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:51:14 +00:00
sylph-decoder
371cd0d610 re: the ALSA tee measured on Canary -- faithful now, but the guest still pads
150 s boot with --apu=alsa --mute=false, tee in front of the paced pulse slave.

  PulseAudio monitor, default 5.3 ms   39.3 percent silence   30.5 gaps/s
  PulseAudio monitor, 200 ms           15.6 percent silence    3.5 gaps/s
  PulseAudio monitor, 500 ms           50.1 percent silence    1.3 gaps/s
  ALSA tee to paced pulse slave         9.98 percent silence   8.37 gaps/s

106.2 s captured over about 151 s of wall clock, i.e. 0.70x real time: the file
is SHORT rather than gap-riddled, which is the intended trade. Six distinct
channels, no duplicates, sensible peaks.

A format trap worth recording: xenia s ALSA driver is SND_PCM_FORMAT_FLOAT_LE and
its log confirms 6 channels, so the raw tee is float32 and not s16. Reading it as
s16 yields a plausible-looking file whose giveaway is peaks alternating exactly
-0.00 / -4.82 across channels -- the two halves of each float landing in
alternate channels. I measured it wrongly that way first.

The residual 10 percent silence is not removed, but its meaning has changed. It
is no longer invented by PulseAudio s monitor; the tee records exactly what Xenia
wrote, and Xenia wrote silence, because its writer thread pads whenever the guest
has not filled the ring. So the capture is faithful -- every sample in it is a
sample the emulator emitted -- while the emulator is still padding, because the
guest runs at about 0.7x real time here. No capture method can remove that.

So this is a 3.9x improvement in silence and a change of attribution, not a clean
capture. At 9.98 percent and 8.37 gaps/s it sits right on the port s fail bar,
and should not be treated as an oracle without saying which side of the line it
fell on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:49:17 +00:00
sylph-decoder
3d5b5bae13 re: capture audio through an ALSA file tee, not a PulseAudio monitor
The human identified that both agents were fighting the wrong subsystem, and
testing it here confirms the diagnosis and finds the limit.

A PulseAudio null sink s MONITOR is sampled on a wall clock: when the client is
late PulseAudio does not wait, it emits silence to keep its own timeline. So the
39.3 percent silence in the take-2 capture was never audio that went missing, it
was silence PulseAudio invented -- which is why PULSE_LATENCY_MSEC gave a
non-monotonic curve and never won. The instrument was wrong, not mistuned.

ALSA s file plugin has no clock; it tees exactly what the client writes, so a
slow producer yields a shorter file rather than a gap-riddled one. Control with
six distinct tones: 12.000 s against a 12.000 s source, 0.00 percent silence,
zero gaps, no duplicate channels. Channel order comes out as ALSA s
FL FR BL BR FC LFE rather than WAV s FL FR FC LFE BL BR -- deterministic and
invertible, not data loss.

Three configuration traps recorded in the order they bite: ALSA_CONFIG_PATH
replaces the whole config so the stock one must be included; but WITH that
include a pcm.!default override silently does not take, in either the inline or
the alias form, so the slave must be declared with an inline plugin type and no
include; and a pipe to head SIGPIPEs the producer before it writes, which looks
exactly like a broken config.

And the limit the proposer honestly flagged, now measured: a bare file tee is not
enough for Xenia, because its ALSA writer thread pads silence whenever the ring
buffer is empty (alsa_audio_driver.cc:359). Against a device that never blocks it
free-ran at about 250x real time -- 7.34 GB, 12746 s of nominal audio, in 50 s of
wall clock, nearly all driver-generated silence. Killed and deleted; it would
have filled the disk.

The configuration that satisfies both constraints is a tee in FRONT of a paced
slave: type file with slave.pcm { type pulse }. The file plugin captures what the
client writes and the slave supplies the clock, so the wall-clock silence
insertion happens downstream of the capture point. Control through that exact
config: 12.000 s, 0.00 percent silence, zero gaps.

Consequences for verification: short file becomes the failure mode, so a capture
check needs an expected-duration test alongside silence and gap rate, and a
runaway guard is not optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:47:31 +00:00
sylph-decoder
e8afbc8632 re: the buffer/dropout relationship is not monotonic, and a gap-rate bar misses it
Extends the latency finding with a third point, and it changes the advice.

  xenia default (~5.3 ms)   347.5 s   39.3 percent silence   30.5 gaps/s   3.94 ms median
  PULSE_LATENCY_MSEC=200     88.0 s   15.6 percent silence    3.5 gaps/s  37.33 ms median
  PULSE_LATENCY_MSEC=500     87.9 s   50.1 percent silence    1.3 gaps/s 346.67 ms median

200 ms is 2.5x better than the default; 500 ms is worse than either. Raising the
buffer keeps cutting the gap RATE while total silence bottoms out at 200 ms and
then doubles, because an over-large buffer starves in a few enormous holes rather
than many small ones.

That is also a warning about the metric. The port s check-capture bar is 20
gaps/s, derived from sound controls -- starved 32.9, genuine music bed 3.3, voice
track 0.03. The 500 ms file scores 1.3 gaps/s, better than a real music bed,
while being 50 percent silence: a gap-rate test alone would pass the worst
capture of the three. It needs a total-silence companion. Same shape as the
defect that made a per-channel level table useless -- one number that cannot see
the failure mode next door.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:35:46 +00:00
sylph-decoder
b56d0e1fb6 re: the capture dropouts are largely a client-buffer size, not the guest running slow
Withdraws my conclusion that the monitor-sink capture route cannot be fixed by
configuration, and with it the claim that only an in-emulator tap would work.

The container has no audio hardware -- no /proc/asound/cards, no /dev/snd, no
asound.conf -- so PulseAudio s stock default.pa module-always-sink supplies a
null sink, whose whole purpose is to exist when there is no device. A null sink
has no hardware clock: it is timer-driven, and anything the client fails to write
in time becomes silence in the monitor. That much was right.

What was wrong was inferring from it that the holes mean the guest runs below
real time. The alternative was never tested: xenia asks SDL for channel_samples_
= 256, which is 5.33 ms at 6 channels, and daemon.conf here is stock with no
fragment tuning. PULSE_LATENCY_MSEC overrides what SDL s PulseAudio backend
requests.

Measured, same title and sink and parec invocation:

  xenia default (~5.3 ms)   347.5 s   39.3 percent silence   30.5 gaps/s
  PULSE_LATENCY_MSEC=200     88.0 s   15.6 percent silence    3.5 gaps/s

An 8.7x reduction from one environment variable. Against the port s controls --
starved 32.9 gaps/s, genuine music bed 3.3, voice track 0.03, bar at 20 -- the
default is squarely starved and 200 ms lands at the level of real content.

Not yet a clean bill of health: the runs are not like-for-like at 88 s against
347 s, and the short one covers the splash logos where silence is real. What is
established is direction and scale. The consequence that matters is that the
capture route should be retried at raised latency before anyone spends a session
on a Canary rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:34:34 +00:00
sylph-decoder
55cdcac61b handoff: tell the port the settle-time numbers carry an unmeasured real-time factor
The pulse-period argument for trusting the settle-time run is withdrawn, and the
port authors from those numbers so it belongs on the page they read. The plate
delay survives because it agrees with three independent prior readings; the menu
build-in and B-to-title are anchored by nothing, so a few per cent of emulator
slowdown sits inside them undetected. That is a second, independent reason to
treat those two as provisional beyond their being one-run figures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:28:23 +00:00
sylph-decoder
02a99bc3e9 re: withdraw the pulse-period argument, and the shared WAV declares itself empty
Two corrections to my own recent work, both prompted by the port checking it.

First, the settle-time page argued the plate delay discrepancy was an instrument
artefact on two legs, and one of them is withdrawn. It said the plate pulse
period acts as an internal clock for presentation rate and measured 2.369 s
against the corpus s 2.3. That estimate rests on ONE interval between two
distinct troughs at a 125 ms sample interval -- uncertainty 0.177 s or 6.7
percent -- and trough-picking on a noisy plateau is fragile enough that
re-running it gives 2.628 s, because an adjacent local minimum had been counted
as a separate trough. Against the corpus s 2.24 that is +17.3 percent, about two
sigma. So the pulse period does not show the run at normal speed; it is too weak
to show anything, and cannot resolve a real-time factor below about 7 percent.

The conclusion survives on the other leg, which is the sound one: the
content-measured 2.247 s agrees with three independent prior readings
(2.13 / 2.132 / 2.138), and both its landmarks are sharp content transitions
rather than a trough on a plateau. A 17 percent slowdown would have put it at
2.49 s.

What that leaves open matters because the port authors from these numbers: the
run carries an unmeasured real-time factor under about 7 percent. The plate delay
is anchored by agreement with prior runs; the menu build-in and B-to-title are
anchored by nothing, so that is a second reason to treat them as provisional.

Second, the shared capture is worse than truncated: parec writes the WAV header
with zero sizes and patches them on clean exit, so the mid-write copy has RIFF
size 8 and data size 0 against 183 MB of actual bytes. Python s wave module
refuses to open it; ffmpeg and ffprobe recover by scanning and report a plausible
duration, which is exactly why it went unnoticed -- the lenient reader hid it.

Also corrects the attribution of the starvation numbers: 39.3 percent and
16680453 frames were measured on the finished local recording, not on the shared
artefact. The port measured the shared copy and got 35.6 percent and 15289876
frames, with burst and gap medians agreeing to 0.1 ms. The diagnosis is
unaffected but a number must say which artefact it came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:28:09 +00:00
sylph-decoder
5938d3b1fc notes: build-canary points at a source root that does not exist here
Recorded as a blocker rather than worked around, because it changes what the
next session can plan.

The faithful-capture route is an internal tap at SDLAudioDriver::SubmitFrame,
which receives exactly frame_size_ bytes of the guest s own frame in guest order
with no wall clock in the loop. A cvar-gated WAV writer there would record what
the guest PRODUCED rather than what a device CONSUMED, so it would be gap-free
however slowly the emulator runs -- which is precisely the defect that made both
ADV captures unusable.

The change is small. The build is not. build-canary builds
${PROJECT_DIR:-/work}/xenia-canary, which does not exist in this container; the
source is at /canary. The warm 235 MB tree at /sylph-home/re/canary-build is
configured with CMAKE_HOME_DIRECTORY=/work/xenia-canary, also missing, and its
build-Release.ninja carries no per-file rules -- it re-runs CMake first, and that
reconfigure fails on the absent root. So any Canary change is a full reconfigure
against /canary plus a full compile, at SYLPH_JOBS=4 on a box sitting at about
700 MB free with a documented history of full-parallel builds OOM-killing the
host.

Not attempted: that is a whole session s risk for one probe, and the next session
should decide with the cost in front of it rather than discover it halfway
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:22:13 +00:00
sylph-decoder
3a5f215179 re: withdraw a wrong explanation of the BGM_001 duration gap
I wrote that BGM_001 s declared 173.821 s disagreed with a decoded 167.663 s,
and explained the gap as declared covering the encoded stream including trailing
silence while decoded is where the audio stops. The port decoded it fully: the
bank yields 173.809 s of PCM. There is no disagreement -- 167.663 s is where the
music fades out, measured from the audio, and the stream continues silent to its
declared end inside that same decode.

So the declared-rate method is better than this page claimed, and is now
cross-checked on three banks against independent decodes: BGM_103 87.750 vs
87.744, BGM_102 37.487 vs 37.482, BGM_001 173.821 vs 173.809 -- agreement 5 to
12 ms.

The conclusion survives unchanged and is the useful half: trust it for lengths,
not for musical boundaries. A declared length includes whatever silence the
encode carries, so it is not a loop point.

Also records in METHOD a defect shape the port hit three times in one pipeline,
each invisible to every check except a level: normalising by how many inputs
there are rather than how many carry signal. A silent chunk in a voice sum, a
silent channel in a mono fold, and a silent sub-wave -- the 10240-byte bank
header wrapped to 10300 B -- counted as a third stem in a music sum, which put
every real stem at 1/3 instead of 1/2 and cost 3.52 dB on all menu music for two
iterations. This corpus s census said two waves and the exporter s divisor said
three; the count that disagrees with a census is the one that is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:20:49 +00:00
sylph-decoder
c05c8e1719 re: take 2 is also unusable -- the sink is starved, 39.3 percent digital silence
The port could not find the movie bed or the cutscene voice in take 2 either,
this time with a correlator they had rebuilt and calibrated in both directions
after retracting the first one. Their negative stands. They named two readings:
the capture path is still losing the guest s mix, or the guest is not emitting
these sources -- and flagged the second as landing on them hard, because if the
game never plays the .wmv s WMA track the port s intro audio has been wrong
since P4.

It is the first, and take 2 says so on its face:

  digital silence on all six channels   6557892 / 16680453 = 39.3 percent
  non-silent runs   10595, median 13.60 ms, longest 1.19 s
  silent runs       10596, median 3.94 ms
  burst+gap period  about 17.5 ms, 57 Hz, duty cycle 60.7 percent

The recording is chopped into 13 ms fragments separated by 4 ms holes, ten
thousand times over -- a starved sink, PulseAudio filling underruns with silence.
That destroys envelope correlation by construction, since the envelope is
dominated by a 57 Hz chop unrelated to the content. The file s strongest
periodicity is 5.2 s rather than BGM_102 s 37.487 s loop; the estimator was
controlled first, recovering a synthetic 37.487 s loop as 37.480 and scoring
non-repeating noise at 0.019.

So the port s alarming hypothesis is NOT supported. Nothing here says the game
fails to play the movie s audio; it says this capture cannot answer either way.

A monitor sink cannot fix it: parec reads a monitor that advances at wall clock
and substitutes silence, so every moment the emulator runs below real time is a
hole and the timebase is warped non-uniformly. The route that would work is an
internal tap at SDLAudioDriver::SubmitFrame, which sees every frame the guest
produces in guest order with no wall clock -- same shape as xma_param_probe,
additive and default-off. Not attempted this iteration.

Also corrects a provenance number I got wrong: I told the port take 2 was 253.3 s
when the shared file is 318.5 and the full recording 349. I read ffprobe while
the recorder was still writing and copied the file before it finished, so the
shared artefact is itself a truncation. Corrected provenance: movie 10-251, title
262-318, back to movie at 329 -- meaning the file includes the title screen,
contrary to what I told them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:19:46 +00:00
sylph-decoder
b07f8984e3 handoff: deliver the BGM_102 identification and the header-derived durations
States the identification, the caution that it does NOT establish which screen
BGM_102 belongs to, and the one thing the port can use today: BGM durations from
the corrected XMA1 PsuedoBytesPerSec with no decoder. Flags that those durations
include trailing silence -- BGM_001 reads 173.821 declared against 167.663
decoded, a gap matching the 6.15 s of silence this page already records -- so a
menu loop point must use the decoded figure.

Also records that a refutation attempt on this page s own BGM_103 wave sizes
failed: both match the disc exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:16:34 +00:00
sylph-decoder
05d278173d re: the two unexplained XMA streams are BGM_102, and BGM_103 s sizes survive
Closes the open question left by the take-2 audio capture, where the probe
logged five streams on one boot when only ADV s three were accounted for.

Both unexplained sizes are whole packet counts, 562 and 620. Searching every
inter-descriptor span of the voice stream and every sound.pak entry large enough
finds zero hits in the voice stream and ONE entry carrying both -- hash
9799c546, which candidate enumeration recovers as BGM_102.slb, two streams of
1150976 and 1269760 B. One entry holding both sizes is the two-stem shape rather
than two coincidental matches. So the boot s five streams were ADV s three voice
streams plus one music bank s two stems, and nothing is unaccounted for.

What it does not establish is which screen it belongs to. The window ran from
launch to t=253 s with the title arriving at 262, so BGM_102 was decoded
somewhere inside a launch-to-just-before-title window -- but the probe fires on
first decode and its lines carry a thread id rather than a timestamp, so a title
BGM decoded moments before the title appears is equally consistent with the
evidence. Cue 1103 is already the main menu, which makes 1102 as the title at
least suggestive. The settling experiment is written down and not done.

Refutation attempt on HANDOFF s BGM_103 wave sizes: exact match on both
(3876864 / 3930112). The claim survives unchanged.

Also a third route to two-stems-of-identical-duration, from the XMA1 header
alone now that PsuedoBytesPerSec is read correctly: BGM_102 37.487/37.487,
BGM_103 87.750/87.749, BGM_001 173.821/173.821. The one apparent disagreement
resolves in the corpus s favour -- BGM_001 reads 173.821 here against the port s
decoded 167.663, a gap of 6.158 s, and HANDOFF already records 6.15 s of trailing
silence after its fade-out. Declared duration covers the encoded stream including
that silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:16:13 +00:00
sylph-decoder
b047788d37 re: the capture recipe that passes, and a five-stream observation it turned up
Take 2 of the ADV audio capture verified with the port s independent
tools/port/check-capture -- six distinct channel MD5s, PASS -- run before
sharing and deliberately using their tool rather than the hand that made the
file.

Records the recipe: sink channel_map set equal to Canary s own stream map and
the same map passed to parec, so PulseAudio does no remapping; both of
run-canary s mutes off; recorder started before the emulator so WAV t=0 precedes
process launch; and a screenshot every ~11 s keyed to the recording s own clock.

That last pair is what makes it self-checking, and both were the port s asks.
Classified against the committed references, this run reads movie/other for
t=10..251 and then title_noplate at t=262 (r=+0.998) and title_plate at 277/289
-- so the 253 s of audio sits wholly inside the movie with the title arriving
just after. A miss is now diagnosable instead of ambiguous, which is the whole
difference from take 1.

Still the full mix: movie WMA bed plus voice, nothing at this boundary separates
them.

Also records something unexplained that the run turned up: the probe logged FIVE
distinct XMA byte_size values, not three -- ADV s 1294336 / 1118208 / 1171456
plus 1150976 and 1269760. The extra pair belongs to some other cue, is not
BGM_103 s two waves, and a pair is the shape bgm-two-stems documents for music
banks. Untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:10:28 +00:00
sylph-decoder
930ab1016d re: a per-channel level check is blind to remap corruption, by construction
The port made this point while building a checker for the channel-map trap, and
it refutes a sentence in my own write-up.

In the known-bad control all six channels report a peak of -18.063656 dB,
identical to six decimals, while the file contains three duplicate pairs. Equal
tone amplitudes make the peak table uniform however the channels are permuted or
duplicated; on real content the peaks simply differ from one another, which looks
equally healthy. The table is uninformative either way.

So "the WAV has plausible per-channel levels" was not weak evidence that a
capture was sound, it was none, and this page implied otherwise. The per-channel
peak table is the natural thing to eyeball after a capture and it cannot see this
failure at all. What detects it is hashing each channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:08:10 +00:00
sylph-decoder
e2c599fa72 re: a 6-channel capture scrambles and duplicates channels unless the maps match
The ADV audio capture I shared with the port is withdrawn as evidence. They
could not match it against anything -- the movie bed, any of the three voice
streams, BGM_103, S00A -- with best-vs-runner-up margins of 0.001 to 0.016
everywhere, and they controlled that three ways before saying so. They also
noticed capture channels 3 and 6 were byte-identical.

That duplicate pair reproduces without the emulator, and it is my capture chain.
Six channels each carrying a different tone, played to the null sink and
recorded from its monitor with the same parec invocation:

  sink map NOT matching the client (the original setup)
    expected  400  800  200 1600 3200 6400
    captured  400 3200  200  800  800  200     ch2 == ch5 byte-identical

  sink map made identical to Canary s stream map, and passed to parec too
    captured  400  800  200 1600 3200 6400     no duplicates -- CONTROL PASSED

PulseAudio remaps when the maps differ, and a 6-channel remap silently drops
channels and duplicates others. No error, no warning; the WAV has the right
length, channel count and plausible per-channel levels.

Withdrawn with it: "all six channels carry signal", and the observation that
non-zero surround and LFE weakly supported the 5.1 reading of a voice cue s
three streams. The port said a duplicated channel is not an independent one and
they were right before this control existed.

Unaffected: the three-XMA-context concurrency result, which is read from the
emulator s own log rather than the audio path, on two independent boots.

The control needed no emulator, no disc and thirty seconds. It was not run, an
artefact was published, and the person who found the defect was the one who
could not see the instrument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:04:52 +00:00
sylph-decoder
105a1f65e4 notes: run-canary is silent TWICE over, and the 6-channel stream is a red herring
Completes an entry committed an hour ago that was incomplete, which is worse
than absent because it looked authoritative. Fixing SDL_AUDIODRIVER alone still
records silence: run-canary also passes --mute=true on its own command line
(line 98). With the driver fixed and the mute left alone, Canary attaches a
healthy 6-channel stream, holds it at 100 percent volume, reports Corked: no,
and emits nothing. Both layers have to go, and "$@" is last so --mute=false on
the caller s side wins.

Also records that parec defaults to stereo/44.1 kHz and will resample a
6-channel monitor without saying so -- the first successful-looking capture came
back 2ch 44100 from a 6ch sink.

And a red herring I nearly published as a finding. pactl shows Canary s stream
as float32le 6ch 48000Hz with a full 5.1 channel map, which reads as the guest
requesting 5.1 and would have been strong support for the hypothesis that a
voice cue s three streams are 5.1 channel pairs. It is not evidence about the
game at all: AudioDriver::kFrameChannelsDefault is a hardcoded 6, and the code
path actually used, SDLAudioSystem::CreateDriver(index, semaphore, &driver),
constructs SDLAudioDriver(semaphore) taking every default. The format is
Xenia s; only the content of those six channels is the guest s.

That is the same failure this corpus recorded in METHOD earlier today -- the
specific observation and the general rule reading identically -- caught this
time before it was written down rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:56:24 +00:00
sylph-decoder
a8a828644a notes: run-canary is silent by default, which records perfect silence
Both of these cost time in one session and both are the kind that look like
success.

run-canary line 82 is SDL_AUDIODRIVER=${SDL_AUDIODRIVER:-dummy}, and its own
header explains why: --apu=nop stalls the guest in the intro movie, so the SDL
driver against a dummy device is what lets the title advance. But the comment s
premise -- "there is no PulseAudio here" -- stopped being true when
tools/audio-capture landed, since that starts a daemon on demand. So a capture
through the null sink records pure silence, of the right length, behind a run
that looks perfectly healthy. The override is
PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio run-canary, and the live check is
pactl list sink-inputs: empty means Canary never attached and the sink sits at
IDLE. audio-capture s own -inf peak warning is the backstop, but it only fires
after the whole run.

Separately, pkill -f and pgrep -f match the caller s OWN command line. Hit twice
here: pkill -9 -f adv_audio_cap.sh killed the shell running it, and an
until ! pgrep -f "probe.py --run" loop never exited because the loop s own
command line contained the pattern -- which looks exactly like the job hanging.
Kill by process name with ps -o pid= -C instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:52:05 +00:00
sylph-decoder
4362ea21b7 handoff: deliver the concurrent-streams refutation to the page the port reads
The finding landed in docs/re/ in the previous commit; an answer not reachable
from HANDOFF is not delivered. States plainly that "take one stream" was mine,
that the port implemented it, and that it is withdrawn -- together with the
caution that summing is not thereby right, because an equal-gain 1/n sum of
channel pairs is not a downmix and the port s measured 6.02 dB loss was real.
Neither rule is established, so the manifest should say the value is authored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:49:32 +00:00
sylph-decoder
062e17bf34 re: the three streams of a voice cue are decoded CONCURRENTLY, not alternatives
Refutes a framing of mine that two documents and the port s exporter were built
on, so it is a new page rather than an edit.

voice-region-leading-chunk.md read a long cue s three streams as three
presentations of one take, and from that came the instruction "take one stream,
do not sum", which the port implemented. The open question was which
presentation the game plays.

It has no answer. Booted with --xma_param_probe=true, the cvar whose own comment
says it is keyed to reveal which sub-wave of a movie s .slb the game actually
decodes. The guest opens three XMA contexts and decodes all three concurrently:

  ctx=0  packets=632  byte_size=1294336  ch=2  48000   ADV stream 1
  ctx=1  packets=546  byte_size=1118208  ch=2  48000   ADV stream 2
  ctx=2  packets=572  byte_size=1171456  ch=2  48000   ADV stream 3

Byte-exact against the three streams payloads taken independently off the disc
(RIFF size minus 60). Only these three contexts appear in the run.

So a consumer that picks one discards two thirds of what the game mixes. Both
"three presentations of one take" and "take one stream" are withdrawn -- and the
previous behaviour is not thereby right either, because an equal-gain 1/n sum of
channel pairs is not a downmix and the port s measured 6.02 dB loss was real.
Neither rule is established; a consumer is authoring.

Three concurrent stereo streams is six channels and N stereo streams is how XMA
carries multichannel on the 360, which would also explain the 1-or-3-never-2
span census. Recorded as a hypothesis, not a result: all three fmt chunks
declare ChannelMask 0x0002 identically, which is not what distinct channel roles
should look like.

Everything byte-level survives: the leading chunk being stream 1 clipped by our
own guard, the 70 + 8 + 17 decomposition, the bank-header discriminator.

Reach: one cue, one boot. That 28 cues are 3-stream is decoded; that all three
decode concurrently is measured on ADV alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:48:35 +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
9f34829776 method: the specific observation and the general rule read identically
Five corrections across two agents in two days share one shape, and it is worth
naming as a family rather than fixing one at a time. None was carelessness about
the measurement -- every underlying observation was true of the asset actually
looked at. The failure is reaching for the general form in the same breath as the
specific one, where the two are indistinguishable on the page and the general one
is what the next reader uses.

Three were the port s and two were mine, and the entry names both sides:

  the two chunks are two stems of one performance -- true of a music bank,
    written as a fact about voice, where one of the two is digital silence
  the extra bytes are a duplicated channel, not fidelity -- true of ADV, and the
    size ratio it implies runs 0.0778 to 2.9163 across the disc
  everything the sequencer paces off rest.t is late -- true of the title, and
    false of the screens actually checked
  a three-stream cue is a movie cue -- mine, and BIRD_224 is neither
  take the highest-rate, highest-gain stream -- mine, and on ADV those two
    criteria select different streams

The counter is the same every time: run the census before writing the rule.
Where the census cannot be run, write the specific sentence and say it is
specific.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:40:11 +00:00
sylph-decoder
b82f75979e re: the dual-mono explanation for the extra stream bytes does not generalise
The port chose a voice presentation on the argument that ADV chunk 1 is
mono-in-stereo and chunk 2 is dual-mono, so chunk 2 s extra bytes encode a
duplicated channel rather than fidelity -- which would explain its higher
declared PsuedoBytesPerSec without appealing to encode quality.

Their ADV channel measurement stands. The generalisation does not. If stream 3
were systematically the same take with its channel duplicated, its size ratio to
stream 2 would be tight across the 28 three-stream cues. Measured:

  min 0.0778 (S00A, the silent one)
  median 1.2565
  max 2.9163 (S06A)
  sd 0.5057
  within 15 percent of 1.0: 12 of 28

A 37x spread is not a duplicated channel, and the declared rates scatter with
them -- S06A is 5661 against 16513 B/s. Whatever distinguishes the three streams
varies per cue rather than being a fixed channel-configuration triple.

This does not touch the port s decision, which is to take the loudest
presentation: that is a per-asset content measurement, not a structural rule, so
a scattering ratio cannot undermine it. It touches the explanation, which should
not harden into a fact about the format.

Two curiosities recorded: S12B s three streams are byte-size identical at 14396
each, and BIRD_224 is 3-stream while being a non-movie cue, so the shape is not
exclusive to cutscenes.

Also narrows the settle-time page s own generalisation. The port measured its
boot the way this corpus measured the game and found the sequencer NOT late --
its 0.6 s discrepancy was arrival-to-arrival timestamps compared against visible
spans, the plate-delay trap in a second place. So what is supported is that
rest.t is the wrong landmark for the TITLE, not that everything paced off it is
late. And the offered re-take of the one-run menu figures is recorded as
declined, with the reason, rather than left looking unfinished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:37:46 +00:00
sylph-decoder
63573d2b50 re: settle_time measured -- the title is drawn at 2 s, not at rest.t s 4.18
The port s boot sequencer paces every screen off rest.t, which is the last hold
keyframe rather than when a screen arrives. Measured on one cold boot: the
container had no Xenia storage root at all, so this is a fresh profile with no
shader cache, the slowest case.

  title build-in (first ink -> art fully drawn)   0.23 s
  title settled -> PRESS A plate on              2.247 s   (disc declares 120 units)
  plate pulse period                            ~2.37 s
  main menu build-in                             0.531 s
  B -> title                                     0.482 s
  A -> menu                                      3.763 s   DO NOT AUTHOR, see below

The title s rest.t is 251 units = 4.183 s and its art is finished at about 2 s,
so a sequencer pacing off rest.t holds it roughly twice as long as the game does.

Instrument controlled before the run: 9/9 on the content classifier including
the movie-frame and difficulty-screen negatives, 4/4 on the plate detector; the
run sampled 7.99 fps against a requested 8 with an independent one-shot grab
cross-checking every 20 s.

Records a refutation attempt of mine that FAILED. The probe s own marks gave a
plate delay of 3.203 s against the corpus s 2.13 s, which on a cold-cache boot
looked like a real effect. It was the instrument: the plate pulse period is an
internal clock for presentation rate and measures 2.369 s here against the
corpus s 2.3, so the run is not slowed, and re-measuring from content gives
2.247 s. The probe s title_static mark fires during the crossfade out of the
attract movie, before the wordmark has drawn -- glyph was still 0 when it fired.

Also a third independent reproduction of the A-path load stall: 13 frames,
1.53 s, surface mean 26.631 against the earlier 14/1.53 and 12/1.39 at 26.626.
This boot had no shader cache, so it is not a warm-cache artefact. Noted that
the earlier pair agreed to six decimals and mine agrees to three.

Reach: one run. The menu build-in and B->title rest on it alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:21:49 +00:00
sylph-decoder
6011512334 method: the plate glyph counter false-positives on the attract movie by 13x
Found while controlling the timing probe before a settle_time run, not by
reasoning about it.

title_timing_probe.py s plate detector thresholds a green-glyph pixel count at
400, and its control checks two committed movie frames that both score 0. A real
boot disagrees: in one 100 s attract window, 17 frames scored at or above 400
and the peak was 5393. The attract movie has green content in the plate region.

The probe is not wrong -- its state machine refuses to look at the glyph until
the content classifier has already labelled the frame title_noplate or
title_plate, so the false positives never reach the drive. But it is safe
because of that gate, not because the threshold discriminates, and the
distinction matters for anyone reusing glyph() on its own.

Recorded with the general form: a two-frame control over a three-and-a-half
minute movie is not a control over that movie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:17:08 +00:00
sylph-decoder
cf3b60180e re: withdraw a stream-selection recommendation that contradicted itself
The port implemented "take the highest-rate stream" and reported that on ADV it
selects the QUIETER presentation -- chunk 2, 1171516 B at -8.3 dBFS, over
chunk 1, 1118268 B at 0.0. They were right to flag it rather than accept it.

My sentence was "the highest-rate, highest-gain one is chunk 1". Those two
criteria do not select the same stream and the sentence should never have joined
them; the parenthetical named chunk 1 while the rule named chunk 2. Withdrawn.

What the header does decode, read off the bytes: the fmt chunk is a 32-byte
XMAWAVEFORMAT, little-endian, and +0x20 is a declared PsuedoBytesPerSec -- 8142
and 8530 on ADV s two presentations, agreeing with the computed rates to 0.02
percent, with 48000 Hz at +0x24. So the rate is decoded rather than inferred.

What it does not decode: wEncodeOptions (0x10d6), channel count and channel mask
are byte-identical across the presentations. Nothing in the header ranks them,
so stream selection stays an authored choice and the port must know it is
authoring. Settleable in one emulator run -- a capture of the intro with the
dialogue audible says which level the game plays -- and not yet done.

Also records that sylpheed-cli audio info is misaligned for XMA1: its "16
channels / 4310 Hz / 2-bit" is wBitsPerSample, wEncodeOptions and the channel
fields read at the wrong offsets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:13:20 +00:00
sylph-decoder
3f945808d6 re: a long voice cue is three presentations of one take, and our guard clips the first
Closes the last open question on the voice regions: why one cue s byte span
decodes to ~2.6x the movie s length.

The port measured, with controls including a cross-movie negative, that a
region s leading chunk is the TAIL of the full-length chunk that follows it --
r = 0.998 at a lag that puts it flush against that chunk s end, residual 16.7 dB
down over 84.5 s. They withdrew their own earlier 0.768, which came from a
search that scored best on the boundary of its own lag range.

Checked it here by an independent route that needs no decoder. If the leading
chunk is the tail of a full-length first stream, the whole leading stream should
be one complete take of chunk 1 s duration. For ADV: 504464 + 808304 = 1312768 B
at chunk 0 s byte rate of 9559.7 B/s is 137.323 s, against chunk 1 s measured
137.324 s. One millisecond over 137 seconds, from byte rates rather than from
envelope correlation.

And the byte structure settles the shape disc-wide. Counting stream starts inside
every inter-descriptor span: 258 hold exactly 1 stream, 28 hold exactly 3, and
nothing holds 2 or any other number. All 20 spans over 1.5 MB are 3-stream. The
95 movie regions decompose 70 + 8 + 17, and the 8 are independently the same 8
the first census found as bank-header-with-3-chunks.

So 359 s = 84.55 + 137.32 + 137.32: three presentations of one take, the first
clipped by resolve_movie_voice_region s own 1.5 MB guard.

Consequences recorded for the port: dropping the leading chunk is removing a
duplicate rather than truncating, so the hedge is lifted; but summing chunk 1
and chunk 2 is wrong, because they are the same take at different gain, not two
stems. Take one stream.

Also flags a coincidence I nearly built on: the 504464 B constant is structural,
not proportional -- ADV s proportional prediction lands within 8 bytes of it and
S00A s is 4305 B out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:09:40 +00:00
sylph-decoder
432fb7450b re: the voice region s leading chunk is the movie s OWN dialogue, and a guard puts it there
My own leading hypothesis -- that the leading chunk is an in-mission VOICE_D_*
line -- is refuted, on the route the port suggested: widen the enumeration past
the 95 manifest-bound movies and the byte-span test settles it without anyone
listening.

Scanning the stream for every trailer descriptor (the (id, 0x11) pair whose id
repeats at +0x800) gives the complete cue partition, mission lines included:
287 descriptors in a 116.2 MB window, all 287 carrying an id the 4280-name
registry names. Every one of the 17 leading spans is bracketed by
desc(N-1)..desc(N) where desc(N) is that movie s OWN cue id. Zero mission lines.

The mechanism is a guard in our own resolver. resolve_movie_voice_region takes
the predecessor trailer as the region start, guards it with
end - start < 1_500_000, and falls back to the .slb TOC anchor when that fails.
Cues with a true span over the guard: 17, of which 17 are stream-opening. Cues
under it: 78, of which 0. Perfect discrimination both ways. The anchor sits a
constant 504464 B after the true predecessor trailer on all 17, which is
unexplained.

Not established, and stated as such: this does NOT mean the export truncates N
seconds. The port s decode already has ADV s region at 359 s against a 137 s
movie, so it over-covers and the byte-to-time mapping is not linear. No XMA1
decoder in this container to check.

Also withdraws a claim this page had adopted from the port -- that chunks 1 and
2 are two stems of one performance. The port refuted its own claim by decoding:
S00A chunk 2 is digital silence, ADV chunk 2 is 0.60x chunk 1 with the residual
26.8 dB down. Equal duration was a shape match and Q10 s music census should not
have been carried across to voice on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 15:03:58 +00:00
sylph-decoder
de2fe4a110 re: the voice-region third chunk is a different structure from the BGM one
The port hit a 2+1 chunk signature on a resolved movie-voice region and asked
whether the bank-header explanation that closed HANDOFF Q10 also covers it,
rather than assuming it. It does not, and the discriminator is mechanical.

Disc-wide over the 95 English movie-voice regions the manifest binds:

  78 open with a bank header -- bank_header_len fires, 10240 B = 5 packets
     exactly, every time. That is the BGM case.
  17 open with a leading headerless stream -- bank_header_len is None, and all
     17 have length congruent to 1392 mod 2048, the disc s own derived data
     offset. No other residue occurs.
   0 begin at a RIFF.

Counting chunks does not discriminate: 8 bank-header regions also yield three
chunks. slb.rs already predicted this in its own doc comment -- the header
signature has "zero false positives on the 7993 mid-bank windows, where the
leading region IS real" -- and a voice region is a mid-bank window by
construction.

Also tested the obvious defence of dropping the leading chunk, that it is the
predecessor cue s audio: 0 of 17 leading spans lie inside any other resolved
region, 0.0 percent on every one. The test finds overlaps where they exist (16
overlapping pairs among the regions, 60 exactly-adjacent boundaries, 73 of 78
bank-header regions starting where another ends), so the zero is not the
instrument.

Left open, with reach: the census covers movie-voice regions only, and the same
stream carries the in-mission VOICE_D_* cues, which are not enumerated -- the
leading bytes plausibly belong to one of those. Could not be settled by
listening: no XMA1 decoder in this container, and sylpheed-cli audio info
reports these chunks as 16 channels / 4310 Hz / 2-bit, which is visibly wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 14:56:17 +00:00
sylph-decoder
3e7a258a9a re: a cross-reference kept recommending a route the cited page had killed
`menu-navigation-semantics.md` advertised "the cheap way to finish this":
read 0x828A690C as a live screen id and 0x828F38AC as the cursor, under
--gpu=null with no screenshots. `menu-state-in-memory.md` withdrew exactly that
identity ON THE SAME DAY it was published -- three back presses send the
"cursor" 36 -> 38 -> 40 -> 41, and a cursor returns when you go back. They are
monotonic counters; the cross-run agreement is the same key sequence producing
the same count.

The recommendation stood for three days after the page it cited had killed it,
and either document would have been believed on its own. Marked withdrawn where
it was recommended, with what the words ARE still good for (did the game react?)
and the consequence: a measured button->GamePart-id binding stays unfinished
because no screen enum has been located.

Also delivers Q4 to HANDOFF in the shape the port asked for -- exactly one
main-menu button opens a GP_TITLE entry (EXTRAS -> entry 6/9); the other four
leave the archive. That was measured on 2026-08-28 and was reachable only from
docs/re/, which the protocol counts as undelivered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 14:51:15 +00:00
sylph-decoder
b40f18eeb6 re: a refutation attempt that failed -- EXTRAS really is a three-button screen
The port reported GP_TITLE entries 6/9 as a three-button submenu. This page and
HANDOFF call 6/9 EXTRAS and never recorded a button count, and 18 elements
looked like too many for three buttons, so I challenged it -- from the count,
without listing the elements.

Wrong. `screen info --all --build 6` shows ptbtn11/12/13 among fifteen frame,
title, background and effect layers, and entry 9 is identical. Both things are
true: 6/9 are EXTRAS (our composite correlates +0.944 whole-frame with the
committed live-extras.png) and EXTRAS is a three-button screen. The port
established the button count; the corpus did not have it.

Recorded per the adversarial duty, which is worth nothing if only the successful
challenges get written down. The retraction has gone to the port as well.

Also checked in the same pass, and it constrains Q2: entries 5 and 8 have
identical element lists and identical button placements, as do 6 and 9. The
EN/JP difference lives in the baked sprite pixels, so no layout field will ever
separate the members of either pair -- that needs the sprite images or a
capture, and the "English is the first half of the data segment" rule stays a
heuristic rather than something a field will replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 14:48:46 +00:00
sylph-decoder
fb0755d190 handoff: say which index space a build number is in -- 10/11 meant two different screens
The port challenged HANDOFF's loading-screen row and was right. The Q2 row said
"0/1 and 10/11 are the LOADING screen"; the dated section above it says
"entries 0, 1, 12, 15". Both are true, in different index spaces, and the page
did not say which.

Verified against the bytes rather than the table:

  screen list       GP_TITLE.pak -> 12 builds, ordinals 0..11
  screen list --all GP_TITLE.pak -> 16 builds, ordinals 0..15

Only under --all does the ordinal equal the pak entry. Without it ordinal 10 is
entry 12 and ordinal 11 is entry 15. `screen info --all --build 10` shows
palogo_sqex; --build 11 shows palogo_gamearts / seta / anima; 12 and 15 show
pgloading_*. So in ENTRY space 10/11 are the publisher and developer splashes,
which is exactly the screen the wrong reading would have renamed.

It would have validated silently: the port's screen_names.json is keyed by
entry. Q2 row corrected to entry space and marked; the trap is in METHOD under
"Mechanics that have bitten", with the rule that a number leaving this
repository says "entry N", never "build N".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 14:47:30 +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
a0eb509cec Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 14:34:10 +00:00
sylph-decoder
6c05b72099 re: the paint-order tie-break costs 24 overlapping pairs, not one element
The port challenged HANDOFF's "costs one element's blend on one screen" with a
census of 105 elements sharing a layer key across 12 of 16 screens. The two
numbers count different things -- elements vs overlapping pairs -- so it is not
the contradiction it looked like, but the objection stands and the line was
wrong.

paint_order_audit already reports overlapping ties per entry, and over all 16
GP_TITLE entries: 5 use a measured order and carry no tie risk; of the 11 that
fall back to the derived order, 7 have overlapping ties, 24 pairs in total. The
Japanese title (entry 7) alone has 16, because it is the twin of the one build
whose measured order exists and has none of its own.

Overlap bounds where a wrong tie-break COULD show, not what it costs; nobody has
measured how many of the 24 change a pixel, and the port is right about that too.

The rule itself is unaffected: the layer key is still decoded and the derived
order still reproduces every measured order exactly except the title's eight
tied pairs.

Census committed at docs/re/data/paint-order-ties-gp_title.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
2026-08-29 14:17:40 +00:00
sylph-decoder
406517cc68 handoff: address the loading bundles by pak entry, not by is_build ordinal
The port caught this before writing a name: over the twelve bundles is_build
accepts, ordinals 10/11 are entries 12/15, while ENTRIES 10/11 are the two
splash bundles. A key written from an ordinal names the publisher wordmark as a
loading screen and still validates. Restated in entry space throughout.
2026-08-29 14:15:50 +00:00
sylph-decoder
fe49c70da1 re: the boot shows the publisher first -- the TSV that says otherwise attached late
The port flagged docs/re/data/boot-timeline-2026-08-29.tsv, whose label column
runs splash_dev before splash_pub, as a possible boot-order bug in its tree.

Three cold boots, t=0 at launch, no pad input, and the frames looked at rather
than only correlated: SQUARE ENIX 3.05-7.34 / 1.18-5.78 / 1.19-5.56 s, then a
~0.25 s black hold, then GAME ARTS/SETA/studio anima. Publisher first, 3/3.

The TSV is not wrong about any frame; its t=0 is ~7.7 s into the guest's boot,
so the publisher splash had been and gone before the stream opened. The tell is
in the file: its first twelve rows are byte-identical to four decimals -- one
held frame sampled twelve times -- and those exact numbers reappear in my run 1
at 8.42-10.94 s.

Second trap, new: ADV.wmv opens with its own SQUARE ENIX card, bloomed and below
centre, scoring 0.59-0.75 against live-splash-publisher.png. The classifier
fires splash_pub twice per boot and the second one is a movie frame. The real
splash holds perfectly still and scores 0.93-0.94.

And the dwells are DECODED, not measured: the publisher declares 240 units
(4.000 s) and the developer 195 (3.250 s), against measured 4.30/4.60/4.37 and
3.51/3.50/3.37. Measured over declared is 1.085 on average across six spans --
a 30 Hz timeline at 27.6 fps, which is the presentation rate this corpus has
measured independently three times. The port authors nothing here.

Instrument control run first: 11/11 content, 4/4 plate, the two splash
references rejecting each other at 0.035.

docs/re/boot-order-and-splash-dwell.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
2026-08-29 14:15:28 +00:00
sylph-decoder
d71a74f938 re: index and cross-references for the record layout and the loading screens 2026-08-29 14:06:10 +00:00
sylph-decoder
2e04e24ce9 handoff: the keyframe record layout, and the loading screens are named 2026-08-29 14:05:19 +00:00
sylph-decoder
3e4864b7fa re: the DELTASABER plates are the loading screen, and the pak is packed EN-then-JP 2026-08-29 14:04:42 +00:00
sylph-decoder
43f61a8d93 re: the splash timing table was already reading the corrected pairing 2026-08-29 14:04:04 +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
b7104c6668 Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 13:24:03 +00:00
sylph-decoder
aafd7c1b6f Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 13:18:09 +00:00
sylph-decoder
14fced07b6 re: I cannot measure this emulator's clock -- and that answers the port's 2%
The port put two of my pages against each other: settle->plate 2.135 s and one
focus-ring revolution 2.177 s, both a declared 120 units during a static hold,
2% apart against a 6 ms run-to-run agreement. Fair challenge.

The resolution is that the question assumes a stable wall clock. Same interval,
same container, same day: 2.138, 2.132, and 2.549 s -- a 19% swing, caused by
adding --log_ui_draws=true. The 2% is a fifth of that. The two pages were never
in conflict about the game; they are three readings of one declared quantity
through a clock that moves. What settles the quantity is the disc.

Wall clock cannot separate the hypotheses, so I tried to measure frames instead.
Both instruments are recorded as failures rather than published as numbers:

  * Canary's own [UI-CAP] counter -- the one that produced the corpus's 28.5 fps
    -- costs a third of the frame rate. 300 frames in 16.567 s = 18.11 fps on a
    screen that gives ~28 without it. That reclassifies 28.5 as a load-dependent
    lower bound; it does not overturn it.
  * A distinct-frame counter over the spinning ring FAILED its decisive control:
    15.88 fps against the game's own 17.59 in the same window, 10% low, so the
    ring does not change on every presented frame. Its static control also read
    2.63 instead of ~0. Dead, not tuneable, per METHOD.md.

The rule that follows, and it applies to everything I hand the port: a measured
interval landing near a round number of declared units almost certainly IS that
number of units. Ship the units.

Also recovered here, because the same question needed it: the static PPC route.
Four tools open /work/xenia-rs/sylpheed.db and nothing in this repository builds
it -- no disassembler, no PPC decoder, and default.xex is encrypted (zero
plaintext "GamePart"). Xenia decompresses the image at load, so dump_image.py
reads it out of guest memory and validates it against the corpus's own landmarks:
the 29-entry GamePart id table at 0x820A1630 and the Xbox 360 D3D runtime
strings. String search and table dumps work again; instruction-level work does
not, and the present interval I wanted is an immediate, not a string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 13:16:02 +00:00
sylph-decoder
f24304248c re: the plate delay was on the disc all along -- my instruction is refuted
The port caught this with arithmetic off the disc, and it was right: build 2 has
a keyframe group of its own, so "when build 4 has settled, wait 2.13 s, then
composite build 2" puts the plate at settle + 2.13 + 3.97 s. Confirmed build 2's
group here independently of their message: `ptbtn00.t32` reaches a=255 at t=238.

The reconciliation needs no free parameter. Both builds run on ONE clock, started
together, and the premise that fails is `rest.t`:

  rest.t is NOT when a screen settles. It is the last HOLD keyframe before the
  exit. ptlogo1 rests at t=251 and stops moving at t=42.

The title's visible build-in ends at t=118, where pteff01, pteff02.prm and
ptlogoall_eff end their ramps together. 238 - 118 = 120 units = 2.000 s, against
a measured 2.138 and 2.132. So the interval the two runs agreed on to 6 ms was a
DECLARED one and I handed over a wall-clock reading of it.

That reading is 6.7% long, and the corpus already knew why: 120 units in 2.135 s
is the game presenting at 28.06 / 28.14 fps against a nominal 30, and the idle
title was independently measured at 28.5 fps before these runs. Corroborated from
inside the same two runs -- first pixels -> settle is 1.643 s and 2.131 s, a 30%
spread, while settle -> plate is 2.138 and 2.132. Frames are dropped during the
build-in, not during the hold, which a change in the game's own timing could not
do.

So the port authors nothing here. What is unchanged: ScreenView still has to draw
two builds at once and the boot's end state is still not plate-free.

Not settled, and said so on the page: which reading of the keyframe times is
right (it moves the plate by 2 units and I cannot separate them from these
traces), and my settle landmark to better than +/-5 units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:43:41 +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
sylph-decoder
5fcc89be55 re: the PRESS-A plate arrives 2.13 s after the title settles, measured twice
The one number the port said decides a structural question on its side: whether
`ScreenView` has to draw two builds at once. It does. The boot title shows
build 4 alone, and 2.13 s after build 4 stops animating it composites build 2
over it. Two independent boots agree to 6 ms (2.138 / 2.132), which is under one
sample interval.

Measure from SETTLED, not from first pixels. "First drawn -> plate" is 3.78 s in
one run and 4.26 s in the other, because the build-in animation itself ran 1.64 s
and 2.13 s -- that spread is the emulator's frame pacing, and it is exactly the
kind of number that looks like a measurement.

Ruled out before believing it: that the plate was pulsing all along, too dim for
a thresholded glyph counter. The counter reads EXACTLY 154 -- the committed
no-plate title's own value -- for every frame of a plateau nearly one full pulse
period long, with zero variation, and the surface mean is flat to +/-0.03 across
it. A cycling overlay moves both.

Also settled, and also not:

  * the black hold between two screens is 0.14-0.30 s, which brackets the port's
    authored 0.17-0.23 s and the file's declared 12 units. Their constant stands.
  * the (A)->menu latency is STILL not available, and now the reason is known.
    Both runs freeze one frame for ~1.4 s at surface mean 26.626 -- agreeing
    between runs to six decimals, and reproduced in run 2 with stream restarts
    disabled, so it is not the capture path. It is a guest load stall: the (B)
    path, which loads nothing, has no freeze at all. Any figure from it would be
    an emulator load time.

Refutation attempt, recorded whether or not it survived: navigation.md's "the
title is not input-ready for about ten seconds, and even then (A) registers
roughly half the time". At 7.29 s and 7.28 s after the title settled, (A) was
accepted first press in both runs, as was (B) on the menu. n=2 only makes "half
the time" unlikely (p ~ 0.25); it contradicts the ten seconds outright.

And the standing red banner is withdrawn: the interactive title IS reachable in
this container, twice, with no pad input, in ~3.5 minutes. Why it changed is NOT
established -- this container came up with no Xenia storage root at all, so run 1
created a profile -- and that is written as a correlation for the next session to
test rather than as a cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:22 +00:00
sylph-decoder
3ee1a25f47 tools: a title timing probe that costs 8.7 ms a frame, not 1503
The four durations withdrawn yesterday were produced by a classifier costing
1503 ms/frame draining an 8 fps x11grab at 0.64 fps -- a backlog, which
preserves ordering and destroys durations. This is the instrument for retaking
them.

What makes it cheap: every committed capture aligns at exactly dy=0 dx=0
(five-screens-acceptance), so the +/-8 px offset search screen_match does at
full resolution is 25 ZNCCs buying nothing on this path. Decimate 4x, do one
ZNCC per reference. Measured 8.7 ms per frame including the glyph count -- 173x.

Controls, run before the measurement and not after it:
  * 9/9 content controls, including the two committed movie frames that are the
    class this oracle exists to reject;
  * 4/4 on the plate detector itself, which is a threshold on the green-glyph
    counter and so needs its own control (no-plate title 159, plate title 753,
    movie frames 0).

And three things learned from run 1, folded back in:
  * do NOT restart the stream once the measurement is under way. Run 1's restart
    landed 0.25 s after the (A) press and its stale frames straddled exactly the
    interval being timed;
  * press INLINE, not through pad.py's subprocess -- an interpreter start plus
    the 0.25 s hold sat between the press and the timestamp;
  * count the longest run of byte-identical surface means and report it. That is
    the freeze signature, and it is how run 2 showed the 26.626 hold is the
    guest rather than the capture path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:06 +00:00
sylph-decoder
dd4f30a79f tools: fade_quads could not run -- two pre-monorepo paths
`screen-transitions.md` cites `fade_quads.py` as the command behind its decoded
fade ramp, and the command had been dead since the monorepo migration: it read
regn_decode.py from /work/Syplheed-Reborn and defaulted its pak to
/work/sylph_extract, neither of which exists. Resolve the helper beside the
script and default the pak under $SYLPHEED_DISC, the way run-canary and
sylpheed-cli already do.

A cited command that no longer runs is a citation nobody can check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:06 +00:00
sylph-decoder
06c32a0fd4 Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 11:53:43 +00:00
sylph-decoder
7a4a74f8d7 re: the main menu has no idle self-return -- and four of my own durations were a backlog
Two results and one retraction, all from the same session.

REFUTED: 'an ~8-10 s idle returns to the title' does not apply to the main menu.
Held untouched it stayed put for >= 60 s, correlation never leaving
0.9245-0.9249. That timer is real but belongs to the TITLE. It was the only
reason 'B leaves the main menu' was classed as authored, so Q5's B rule is
upgraded to measured-ordering: B is delivered (canary logs vk=5801) and is the
only input in >= 100 s before the return.

The PRESS (A) plate: the boot title presents build 4 WITHOUT the plate first --
green-glyph 154, against 159 on the committed no-plate capture and 753/977/1493
on plate titles -- and the plate arrives after. That is the port's third option.

RETRACTED: four durations taken the same day. classify_array costs 1503 ms per
frame; running it per frame against an 8 fps x11grab drained the pipe at
0.64 fps, so every classified frame was stale and increasingly so. It
manufactured 'plate 24.66 s after the title art', 'B->title 15.58 s', 'B->title
25.60 s' and 'A->menu 20.26 s'. The tell: a transition, a press and a fade do
not share a duration, and the two B figures GREW across a longer run.

A backlog preserves ordering and destroys durations, which is why the sequence
results above stand and every timing does not. The ring's period is unaffected
and that was checked, not assumed -- ring_period ran at 15.03 fps against a
requested 15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:46 +00:00
sylph-decoder
724e06b134 re: the main menu's focus ring spins continuously -- period 2.18 s, measured
Answers the port's ask: ptbtneff01 is ANIMATED while a button is focused, not
drawn once and held. The existing page said 'the ring SPINS' from one frame at a
large angle, which is equally consistent with a static draw at a fixed angle.

No angle is quoted anywhere. The 360-bin angular estimator written for this
FAILED its own control -- a synthetic 30 deg came back as 0 deg (peak 0.596)
while 90/180/270 came back exactly -- so it was not used. What settles it needs
no angle: total annulus brightness is conserved to 0.4 % while individual
angular bins swing by 24, i.e. brightness moving AROUND the ring, which excludes
a pulse. The temporal-std map is a clean annulus, falling to ~1 both inside and
outside the stroke, which excludes positional jitter.

Period from the profile's autocorrelation: eight evenly spaced peaks, mean
2.177 s over nine revolutions. Even spacing is the internal check a drifting
instrument cannot pass. That is 120 units = 60 frames = 2.00 s at a true 30 Hz.

Also measured, same run: the ring is the ONLY moving thing on the settled main
menu -- temporal std is exactly 0.000 on every unfocused button, the labels and
the footer. And the ring's centre, located from the std map at game
(520.7, 339.7), matches the declared leaf offset's prediction of (521, 340).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:32 +00:00
sylph-decoder
e2d2dd34f0 tools: a screen oracle that matches CONTENT, with movie frames as its controls
The statistics oracle (green/white/mean) cannot reject the class it exists to
reject. A frame of ADV.wmv with a bright green laser reads green 0.0018 /
white 0.086 / mean (53,67,76) -- the title's numbers -- and a probe built on it
tapped (A) into the movie, then waited 120 s for a menu that was never coming.

screen_match correlates against committed captures instead. Controls run before
it was ever used live: 8/8, and the negatives are COMMITTED movie frames rather
than scratch grabs -- an earlier list pointed at two scratch files and a later
run of the same probe overwrote one, failing the control for the wrong reason.

Two paths, both controlled. The exact path costs 1503 ms/frame, which is fine
offline and catastrophic in a live loop; fast=True decimates 4x for 38-75 ms and
agrees with the exact path to +/-0.005 on all eight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:18 +00:00
sylph-decoder
9f34e6f7b6 re: withdraw the no-disc banner -- the disc is mounted, and both instruments that said otherwise were blind to it
The container was replaced at 11:07:38 UTC, 25 minutes after 3db09a3 wrote
"the decoder container has no disc". /disc is a real read-only bind mount
(device 2050 against /'s 92), 6.2 GB, 74 entries under dat/, and
`sylpheed-cli screen list` returns 12 GP_TITLE builds.

The reusable half is the instruments. `find / -xdev` cannot cross into a bind
mount on another device, so its "no ISO, no default.xex, no GP_TITLE.pak"
is what it returns whether or not the disc is there. `sylph-doctor` only ever
looks under /work and never consults $SYLPHEED_DISC, so it still reports
"no ISO" against a disc that works. Two instruments, one blind spot, read as
corroboration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:12:36 +00:00
sylph-decoder
c88f5e87a9 game: fill in the player's-eye navigation map from the committed oracle frames
Sections 1 to 3 were almost entirely open questions. Everything a capture in
docs/re/captures/ actually shows is now written down from the chair: what is on
each screen, what the cursor does, and what each footer offers.

Boot: the publisher plate is SQUARE ENIX, the developer plate is GAME ARTS /
SETA / studio anima, both still pictures the game draws rather than video, then
the cinematic -- one A skips it, 57 s to the title against 193 s without.

Main menu: the five labels and where each goes, the wrap rule, and the caveat
that initial focus varied across four boots. Carries the footer warning from the
measurement in the same push.

Submenus: NEW GAME's DIFFICULTY and SELECT DATA; LOAD GAME's slot carousel,
Details panel and its five-button footer; the six tutorial lessons in two
groups; OPTIONS' four categories; EXTRAS' three items; and MISSION SELECT with
the locked-list explanation for the cursor that would not move.

What stays open is marked open, and it is now the residue no capture answers
rather than the residue nobody looked at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:43:12 +00:00
sylph-decoder
6f4b4d8b4c re: two menu facts re-measured from the committed captures, no disc needed
Refutation attempt, per the adversarial duty. Target: this page's own row "B on
the main menu goes to the title". Chosen because it is one of only two Q5 rows
with an empty evidence cell, and because it is the only exit from the main menu,
so the port will build on it.

Whole-frame colour test for the pad-glyph discs. The main menu carries ZERO
red-B pixels anywhere in the frame, on two independent captures, while the same
unchanged detector finds 514 on EXTRAS and 518 on DIFFICULTY. The control passes
twice: the A glyph reads 438/438/440/438 across all four screens, so it is one
asset at one size and a B of that family could not have slipped under a
threshold. The main menu's legend is "Select / OK"; every submenu adds "Back".

The claim SURVIVES -- a legend is not behaviour, and an absent glyph cannot
refute an observed press -- but it is downgraded to amber. The observation is
uncited and single, it is now the only Q5 row the game's own text contradicts,
and there is a named confound: the title-side screens auto-return after ~8-10 s
idle, which looks exactly like what was described. Reading 0x828A690C while
pressing B would separate them in one run; that run needs a disc this container
does not have.

Second finding, same method. MISSION SELECT's "sixteen d-pad presses never left
Stage 01" was a LOCKED stage list, not a broken one. The labels have three
brightnesses, not two -- locked 104, unlocked 183, focused 254 -- and the
all-story-unlocked capture is the control that separates the lower two while
holding row 1 at an identical 254. On that save the cursor reaches Stage16 at the
bottom of a scrolled list. The list is 16 long and shows 8 at a time.

Regenerator committed beside the finding; it reads only files already in git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:43:01 +00:00
sylph-decoder
3db09a3806 re: the decoder container has no disc -- root-caused to the volume migration
find / -xdev turns up no ISO, no default.xex and no GP_TITLE.pak; /exchange is
empty; SYLPHEED_DISC is unset; sylph-doctor agrees. Everything else in the
container is healthy -- xenia_canary is built, :98 is up, screenshot works,
Vulkan enumerates. There is simply no game to boot.

The cause is in the launcher. Before c58196b, sylph-agent bind-mounted the
human's working tree at /work, and the ISO and sylph_extract/ live in that tree,
so the disc arrived incidentally with the repository mount. c58196b replaced
that with `-v sylpheed-decoder-repo:/work` -- correct for the collision class it
was written for -- and nothing was added to replace the disc. sylph-decoder
still forwards SYLPH_ISO, but as a bare environment variable naming a host path
that does not exist inside the container. sylph-port mounts the disc explicitly,
so the one container that owns the disc and the oracle is the one without them.

This shuts the oracle, every sylpheed-cli call that names a pak, the disc-gated
tests, and -- because the XEX is on the disc -- the static PPC route too. It
does not touch the committed corpus, which is what this iteration worked from.

A second, smaller casualty of the same migration: no git identity is configured
anywhere, so the first commit in a fresh container fails outright. Both are
recorded with their fixes; neither is worked around, since the launcher runs on
the host and this container cannot restart itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:42:47 +00:00
345 changed files with 52298 additions and 36273 deletions

View File

@@ -10,53 +10,36 @@ env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# ── What this file may assume about where it runs ────────────────────────────
#
# It runs on ONE self-hosted runner: `rpi5-runner`, aarch64, advertising
# ["ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04"]. Nothing else exists.
#
# This file was written for GitHub's hosted fleet — three operating systems and
# x86_64 throughout — and had never once gone green here: 23 runs cancelled, 2
# waiting, zero successes. Two separate reasons, and both are configuration
# describing a world that is not this one:
#
# * `windows-latest` / `macos-latest` match no runner label, so those jobs sit
# in WAITING for ever. The run therefore never reaches a terminal state, and
# a pull request's checks never resolve either way — not red, just never
# finished. That is worse than a failure: a red check tells you something.
# * `--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a
# cross-compile, and `wayland-sys`'s build script dies on it —
# "pkg-config has not been configured to support cross-compilation".
#
# So: one job, on the machine that exists, building for the machine that exists.
# If a second architecture is ever wanted here it needs a second RUNNER, not a
# second matrix row.
jobs:
# ── Native build, on the one runner there is ────────────────────────────────
# ── Native builds: Windows, macOS, Linux ────────────────────────────────────
native:
name: Native — linux
runs-on: ubuntu-latest
name: Native — ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: macos-latest
target: aarch64-apple-darwin
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
# `stable` installs a MINIMAL profile: rustc, cargo, rust-std and no
# more. Components have to be named. Without this line the Clippy step
# below dies on "'cargo-clippy' is not installed for the toolchain
# 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it
# is the step never having run. The `fmt` job below always got this
# right; this one never did.
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
targets: ${{ matrix.target }}
- name: Cache Cargo registry and build
uses: Swatinem/rust-cache@v2
# Linux: install Bevy's system dependencies (X11, Wayland, audio)
- name: Install Linux system dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
@@ -69,30 +52,16 @@ jobs:
pkg-config
- name: Check (fast compile check)
run: cargo check --workspace
run: cargo check --workspace --target ${{ matrix.target }}
- name: Build (debug)
run: cargo build --workspace
run: cargo build --workspace --target ${{ matrix.target }}
- name: Run tests
run: cargo test --workspace
run: cargo test --workspace --target ${{ matrix.target }}
# This step has never once executed on this codebase: the toolchain above
# shipped without the component, so every run died on "not installed"
# before clippy saw a line of source. Its result was never pass or fail,
# only unmeasured. With the component installed it becomes a real check,
# and the first honest thing it will report is that the workspace is not
# clean — the build already emits ~13 plain rustc warnings (unused
# imports, unused variables, needless `mut`, dead fields) that
# `-D warnings` promotes to errors, before clippy's own lints are counted.
#
# Left gating on purpose. A red check that measures something is worth
# more than a green one that measures nothing, and the alternative —
# `continue-on-error`, or dropping `-D warnings` — cannot tell "debt not
# yet paid" from "debt paid", which is the shape PROTOCOL.md forbids.
# The debt is scoped in #13, as the rustfmt debt is in #12.
- name: Clippy
run: cargo clippy --workspace -- -D warnings
run: cargo clippy --workspace --target ${{ matrix.target }} -- -D warnings
# ── WASM / Web build ─────────────────────────────────────────────────────────
wasm:

31
.gitignore vendored
View File

@@ -25,41 +25,10 @@ __pycache__/
# ── The port ────────────────────────────────────────────────────────────────
# Generated from the user's own disc. This repo stays clean-room: code, schemas,
# authored mappings and documentation only -- never game content.
#
# BOTH names are ignored on purpose. `export/` is what the exporter writes and
# what `ExportTree.locate()` reads today; `data/base/` is the name MODDING.md
# gives that same tree. Only one of them existed here, and it was the one
# nothing writes -- so the live output directory was tracked while MISSION §4
# said it was ignored. Ignoring both means renaming the tree to match the docs
# cannot silently start committing the disc.
/export/
/data/base/
#
# ⚠️ Enumerating names is what FAILED. The two rules above were written --
# carefully, with the comment above -- while 850 files and 299 MB of extracted
# sprites, audio and transcoded video sat committed under `export-probe/` and
# `export-probe2/`, a third name nobody had thought to list. So ignore the
# SHAPE, not the instances: any top-level directory whose name starts `export`,
# and game media anywhere it lands.
/export*/
*.ogv
*.ogg
*.wav
*.xpr
*.pak
# Loose capture output at the repo root -- 246 MB of it arrived this way.
/*.tsv
/*.log
# Transient inter-agent files. Deliberately outside history: they are working
# artefacts with provenance in their manifest, not results.
/exchange/
!/exchange/.gitkeep
.godot/
port/.godot/
# A mod is usually an EDITED GAME ASSET, and this repository never holds game
# assets. `data/mods/` is the user's own directory -- the exporter never touches
# it and neither does git, except for the README that explains the rule.
/data/mods/*
!/data/mods/README.md
!/data/mods/.gitkeep

25
Cargo.lock generated
View File

@@ -4611,7 +4611,7 @@ dependencies = [
"colored",
"image",
"indicatif",
"sylpheed-formats 0.1.0",
"sylpheed-formats",
"texpresso",
"tokio",
"tracing",
@@ -4627,7 +4627,7 @@ dependencies = [
"image",
"serde",
"serde_json",
"sylpheed-formats 0.1.0 (git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01)",
"sylpheed-formats",
]
[[package]]
@@ -4648,25 +4648,6 @@ dependencies = [
"xdvdfs",
]
[[package]]
name = "sylpheed-formats"
version = "0.1.0"
source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01#1cd5b8b1cb1f02eefc0865e1a1fe280e44831c9d"
dependencies = [
"anyhow",
"binrw",
"flate2",
"futures",
"rayon",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"ttf-parser 0.24.1",
"xdvdfs",
]
[[package]]
name = "sylpheed-viewer"
version = "0.1.0"
@@ -4678,7 +4659,7 @@ dependencies = [
"image",
"rfd",
"rodio",
"sylpheed-formats 0.1.0",
"sylpheed-formats",
"thiserror 2.0.18",
"tracing",
"tracing-subscriber",

View File

@@ -42,7 +42,6 @@ docs/
re/ the corpus: findings, refutations, method traps
game/ how the game is navigated -- menus, modals, flight
port/ the port's mission, its handoff contract, modding rules
-- and RUNNING.md, which is how you actually start it
agents/ how the agent team works together
tools/ capture harnesses, probes, the share tool
exchange/ transient inter-agent files. NOT in git

View File

@@ -1,384 +0,0 @@
{
"format": "sylpheed.audio/1",
"_": [
"Menu audio. EVERY VALUE IN THIS FILE IS MEASURED OR CHOSEN -- none of it is",
"in a data file the exporter can read, which is why it is here and not in the",
"exporter. `measured` and `chosen` are NOT the same thing and this file keeps",
"them apart: a measurement is deleted when the disc states it, a choice is",
"deleted when somebody measures it.",
"",
"Two different kinds of not-on-the-disc live in this file and they are not",
"interchangeable:",
"",
" * `se` -- MEASURED. `Static.slb` is a delimiter-less run of whole 2048-byte",
" XMA1 packets: no RIFF, no seek chunk, no XACT container. A wave is defined",
" ONLY by (offset, packet_count), and both numbers come from the running",
" game, not from the file. HANDOFF Q8. Delete a row the day a table on the",
" disc states the same thing.",
"",
" * `bgm` -- MEASURED, and only the LOOP POLICY beside it is chosen. HANDOFF",
" Q10's negative is about the TABLES: `SOUNDS`, `FILES` and the bank headers",
" name no screen. The executable does -- cue 1103 = `BGM_103`, corroborated",
" by a byte-for-byte match against what the XMA probe saw at the main menu.",
" An earlier draft read the negative as unbounded, picked a track at random",
" and called it authored. See the `bgm._` block for what that cost.",
"",
"The exporter reads this file and emits `export/audio/**` from it. It holds no",
"cue table of its own: a measured offset compiled into a Rust `const` is a",
"measurement wearing the costume of a decoded field, and MISSION section 3 is",
"explicit that measured values live here."
],
"se": {
"_": [
"MEASURED, HANDOFF Q8, and the RE agent retracted an earlier 'cannot be",
"extracted' to publish these. The waves were located BY PLAYING THEM: Canary",
"with `--xma_param_probe=true` prints a stream's packet count and first 32",
"bytes when it is played, and searching those bytes in the bank gives the",
"offset.",
"",
"WARNING, from the same finding: the file order is NOT cue-id order. These",
"cannot be counted out, and an index here would be a fabrication.",
"",
"`name_match` is the authors' own identifier GUESSED BY NAME. It is carried",
"so the guess is not lost and is never presented as the measurement. Where",
"the RE agent did not separate two candidates, there is no name at all --",
"an absent `name_match` means nobody has claimed one, never that the",
"BINDING is unknown. The binding is the measured part.",
"",
"All three are mono 48 kHz; that is the RE agent's statement in",
"`sylpheed_formats::media::se_wave_riff`, not something re-derived here."
],
"move": {
"bank": "Static.slb",
"offset": "0x1ec0",
"packets": 4,
"channels": 1,
"rate": 48000,
"name_match": "SE_UI_CURSOR",
"why": "HANDOFF Q8, measured: the d-pad move cue, 8 192 B / 0.533 s, reproduced across two boots. Left/right play nothing at all, which is a measurement too and is why there is no `left`/`right` row here rather than a silent file.",
"kind": "measured"
},
"confirm": {
"bank": "Static.slb",
"offset": "0x5d6c0",
"packets": 6,
"channels": 1,
"rate": 48000,
"why": "HANDOFF Q8, measured: the (A) confirm cue, 12 288 B / 1.016 s. NO `name_match`: Q8 is explicit that (A)'s wave was not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`, so naming it would invent the one thing the measurement did not settle.",
"kind": "measured"
},
"back": {
"bank": "Static.slb",
"offset": "0x0ec0",
"packets": 2,
"channels": 1,
"rate": 48000,
"why": "HANDOFF Q8, measured: the (B) back cue, 4 096 B / 0.344 s, reproduced across two boots. No `name_match` for the same reason as `confirm` -- Q8 names no identifier for it.",
"kind": "measured"
}
},
"bgm": {
"_": [
"MEASURED, NOT CHOSEN -- and the port got this wrong for one iteration.",
"",
"`docs/port/BLOCKED.md` carried a row reading 'not on the disc ... the port",
"is choosing a track, and that choice is authored', and the first draft of",
"this file duly picked BGM_001 and labelled it arbitrary. That row was not",
"stale: `BGM_103` is in HANDOFF at `9ca1eb5`, which is the exact commit the",
"row says it was reconciled against. It was WRONG WHEN WRITTEN.",
"",
"What HANDOFF actually says is a negative with a stated reach, and the reach",
"is what the port dropped: the *tables* cannot say which BGM a screen plays",
"-- `SOUNDS`, `FILES` and the bank headers name no screen. The EXECUTABLE",
"can. `GamePart_Title`'s phase handler `sub_821C5580` carries `li r5, 1103`",
"into a sound call, cue 1103 is `BGM_103`, and `BGM_103.slb`'s two declared",
"waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA",
"probe saw decoding at the main menu. Static code, disc census and runtime",
"agree. HANDOFF's own words: 'The port does not have to choose a track.'",
"",
"So this section is a CITATION, not a decision. It lives in `authored/`",
"only because the binding is in the .xex and the exporter reads data files,",
"not code -- and it must be deleted the day something the exporter can read",
"states it. The loop policy below IS still a decision."
],
"main_menu": {
"bank": "BGM_103.slb",
"loop": "restart",
"kind": "measured",
"why": "MEASURED, HANDOFF Q10 -- NOT a port choice. `GamePart_Title`'s phase handler `sub_821C5580` plays cue 1103 = `BGM_103`, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree; see docs/re/menu-audio-cues.md and docs/re/structures/bgm-two-stems.md. The name carries its `.slb` extension because that is what `sound.pak` hashes -- `BGM_103` alone resolves to nothing, which is how the first draft of this file failed. ✅ AUDITED 2026-08-31 -- the THREE legs are three, and that is now measured rather than asserted. Prompted by the Decoder's point that a decorative second support is worse than none, since a conclusion with two supports reads as better evidenced than one and apparent redundancy is itself the misinformation. Read literally, 'disc census' and 'runtime' could be ONE comparison -- declared wave sizes matched byte-for-byte against the probe -- which would make three legs two. It is a real third leg only if the census EXCLUDES alternatives: if another bank carried the same two sizes, the byte match would not distinguish BGM_103. Measured with this port's own reader (`crates/sylpheed-export/examples/bgm_size_census.rs`): of 32 readable BGM_* banks on the disc, EXACTLY ONE carries waves of that size. The census therefore excludes, the static-code leg names the cue independently, and the three legs stand. ✅ AND THE EXCLUSION IS TIGHTER THAN I STATED. The Decoder attempted to refute it from their own census tool rather than this port's reader: of 32 census rows, exactly one bank carries EITHER of those wave sizes -- not merely both together, which is what I measured. A collision would therefore need to reproduce a single size, not a pair, and none does.",
"loop_why": "MEASURED, and this field's own history is why it says so first. The bed loops; the loop is a RUNTIME field -- `loop_start`/`loop_end` in the XMA decoder context, set by `XMASetLoopData` and logged by Xenia -- and the cycle was watched directly: three wraps, both contexts wrapping at the same instant every time, mean 61.81 s against the 61.93 s authored in `loop_end_s`, 0.2 % apart from instruments sharing nothing. The export is TRIMMED to that window, because Godot loops a whole file and a loop region therefore has to BE the file. ⚠️ The window's START is not measured and is authored as 0, which is known to be wrong -- see `loop_start_why`. 🔴 EVERY SENTENCE THAT PRECEDED THIS ONE WAS REFUTED, and the previous text survived in the manifest for two days after the corrections were written. It said the loop would be `AUDIBLY WRONG AT THE SEAM [refuted]`, that `no loop-point field has been identified [refuted] anywhere`, and that trimming `would INVENT a loop point`. All three are false: the field exists, the 3.4 s of near-silence was the PORT'S loop and not the game's, and the trim is now what the measurement says. The corrections went into `loop_end_why` and `loop_start_why`; this field is the one the exporter concatenates into `manifest.json`, so the export went on telling readers the refuted story. A correction that does not reach the artifact a consumer reads has not been made. 📌 CITATIONS ADDED 2026-09-01, and their absence was found by `audit-kinds` the moment this field got a `kind` -- it had 1 400 characters of prose and nothing openable, which is exactly the state the audit exists to catch and could not see while the field was unlabelled. The wrap measurement is docs/re/data/menu-bgm-loop-measured.txt and the start is docs/re/data/menu-bgm-loop-start.txt; the bank's two-stem structure is docs/re/structures/bgm-two-stems.md.",
"loop_kind": "measured",
"stems": "sum",
"stems_why": "MEASURED, HANDOFF Q10: a bank is exactly TWO waves of identical duration (32/32 banks on the disc), sample-synchronous -- transient correlation peaks at lag 0.00 s over +/-5 s and both stop at the same millisecond. Concatenating them plays the piece twice, the second time as a bass-less stem; that was the previous reading and it is refuted. Emitting two files would be wrong for a second reason: MODDING rule 1 is one logical asset, one file, and handing a modder two stems to line up by hand is the reassembly the exporter exists to have already done. WHAT IS SUMMED IS SETTLED; WHAT WAVE 1 IS, IS NOT -- HANDOFF calls it quieter, far more L/R-decorrelated and almost bass-free, so it reads as a surround-rear pair OR a second intensity layer, and `ChannelMask` is 0x0002 on both so the file will not say. A unity sum is right under either reading; a weighting would only be justified once that is settled.",
"stems_kind": "measured",
"loop_start_s": 9.44,
"loop_start_why": [
"MEASURED 2026-08-30 -- 9.44 s. The loop region is [9.44 s, 71.31 s] of an",
"87.744 s wave: the first 9.44 s is an intro played ONCE, and the last 16.4 s",
"is a fade-out never played at all.",
"",
"Two derivations, both stems, and NEITHER converts bits to seconds -- the",
"conversion that refuted itself earlier by giving two sample-synchronous stems",
"62.34 and 63.29 s. (a) time to `read_offset` crossing `loop_start`, plus a",
"1.33 s head correction at a LOCALLY measured rate; (b) first pass minus cycle.",
"9.44 s on both stems either way.",
"",
"⚠️ ONE BOOT, ONE BANK. The decoder reads ahead of playback, but both endpoints",
"are `read_offset` events so the lead cancels in the difference.",
"",
"🔴 THIS FIELD WAS 0.0 AND FLAGGED WRONG FOR ONE ITERATION, deliberately. The",
"value was not guessable -- linear back-extrapolation said 9-13 s and linearity",
"is refuted by a 4.4 % rate variation within one stream. What made the wait",
"cheap was that the field EXISTED and the `-ss`/`-t` ordering had been proved",
"with a stand-in value, so arriving at 9.44 was a one-value edit.",
"",
"📌 CITATION ADDED 2026-09-01 -- found the moment this field got a `kind`. It",
"carried 1 041 characters describing two derivations and cited no file. The",
"numbers are in docs/re/data/menu-bgm-loop-start.txt, and the loop region's",
"wrap timing is in docs/re/data/menu-bgm-loop-measured.txt.",
"",
"⚠️ Second uncited MEASURED field in this one entry, after `loop_why`. Both",
"described their evidence carefully in prose and pointed at nothing. A why that",
"recounts a measurement reads as well-sourced precisely because it is detailed,",
"which is why neither looked wrong.",
"",
"✅ AUDITED 2026-09-01 with the exclusion test: could either derivation have come",
"out differently given the other? YES, and they discriminate different errors --",
"(a) depends on a locally measured RATE and (b) on the CYCLE, so a wrong rate",
"breaks (a) and leaves (b) standing, and a wrong cycle does the reverse. Two legs",
"that fail independently, which is what 'two derivations' was claiming.",
"",
"⚠️ BOUND: they share one trace. A systematic error in the read_offset stream",
"moves both, and the ONE BOOT, ONE BANK caveat above is that limit stated. What",
"they exclude is arithmetic error, not trace error."
],
"loop_start_kind": "measured",
"loop_end_s": 61.87,
"loop_end_why": [
"MEASURED off the running game 2026-08-30, 240 s parked on the menu",
"(docs/re/structures/menu-bgm-loop-measured.md). The bed loops at 61.93 s, NOT",
"at the summed wave's 87.744 s length, and the last ~25.8 s is never played --",
"exactly the fade-out and trailing silence bgm-two-stems.md found. The game",
"loops BEFORE the fade.",
"",
"🔴 THIS CORRECTS AN AUTHORED VALUE THAT WAS WRONG IN BOTH DIRECTIONS. `restart`",
"at the wave's end produced a seam of about 3.4 SECONDS of near-silence, and",
"this port measured that seam off its own Master bus and recorded it as the",
"cost of a missing loop point. It was not the game's seam; it was OURS. Zero",
"runs of >=0.3 s below median-18 dB appear in 232 s of the real menu.",
"",
"Two instruments agree: correlation gives a top lag of 61.909 s and r = -0.009",
"at 87.750 s, and locating 30 s slices inside the decoded waves shows playback",
"advancing exactly +5.00 s per 5 s and wrapping at 61.93 s, three times, with a",
"control that finds slices cut at 10/45/70 s at 10.00/45.00/70.00.",
"",
"⚠️ The loop START is inferred, not measured: [0.0, 61.93) and [0.25, 62.18) are",
"not separated at their resolution. The port takes 0 because a bank's own start",
"is where its data begins, and records that the choice was not measured.",
"",
"⚠️ Godot loops a WHOLE FILE, so the export is TRIMMED to 61.93 s rather than",
"carrying a loop point the runtime could not honour. The trimmed tail is",
"content the game never reaches, so nothing playable is lost -- but a modder",
"replacing this file is replacing the loop region, not the whole bank.",
"",
"🔴 CONFLICT, OPEN AS OF 2026-08-30. The loop IS a runtime field: `loop_start`",
"and `loop_end` live in the XMA decoder context, set by `XMASetLoopData`, and",
"the RE agent read 8734 records off the menu. Converted, they imply a cycle of",
"roughly [10 s, 72 s] against the [0.25, 57.18] their audio tracking reported.",
"BOTH CANNOT BE RIGHT and neither has been withdrawn.",
"",
"They judge the weak link probably theirs: the locator's control matched slices",
"cut from the wave ITSELF -- exact copies -- which is an easier problem than",
"matching a capture that differs by decoder, gain and mix. A control easier than",
"the measurement does not bound the measurement's error, and music with repeated",
"sections is where a locator aliases.",
"",
"⚠️ THE VALUE IS KEPT ON THEIR INSTRUCTION, and because the LENGTH survives",
"better than the PLACEMENT: 61.93 has an autocorrelation behind it that used no",
"wave at all, and the trimmed loop has no seam in this port's own output.",
"",
"This port added one check neither of their instruments ran: whether the trim",
"JOINS SMOOTHLY. Over 126.5 s the wrap at 61.93 s and again at 123.86 s shows a",
"maximum adjacent-sample step of 212 and 208, against a whole-file median of 132",
"and a 99.9th percentile of 3737. So the join is not a click and nothing is",
"audibly broken.",
"",
"⚠️ THAT DOES NOT DISCRIMINATE THE TWO READINGS. A smooth join says the waveform",
"does not jump; it does not say the loop is at the musically right point, and a",
"cut landing near a zero crossing is smooth wherever it falls.",
"",
"🔴 What the conflict would COST if their runtime fields win: under [10 s, 72 s]",
"this export is about 10 SECONDS SHORT -- the content in [61.93, 72] is played",
"by the game and absent here. That is the number to weigh when it resolves, and",
"it is why this entry is not being treated as settled.",
"",
"✅ CONFIRMED 2026-08-30 BY A SECOND INSTRUMENT SHARING NOTHING WITH THE FIRST.",
"The RE agent stopped converting the runtime fields and TIMED them instead --",
"a probe tailing the Apu debug log and stamping `read_offset` on arrival --",
"and watched THREE wraps, each from its own `loop_end` to its own `loop_start`,",
"with both contexts wrapping at the SAME INSTANT every time. Cycle 61.56 and",
"62.06 s, mean 61.81 s: 0.2 % from the 61.93 authored here, measured by wall",
"clock between decoder events against an autocorrelation that never touched",
"the wave. Both contexts wrapping together is the sample-synchrony the linear",
"bit conversion could not produce.",
"",
"So the LENGTH is settled and the WINDOW is not. See `loop_start_why`.",
"",
"✅ 61.87 ADOPTED 2026-08-30, replacing 61.93. Their wrap timing gives 61.87 --",
"wraps at 96.46 / 158.33 / 220.21 s, gaps 61.87 and 61.87 -- against the 61.93",
"this port's autocorrelation gave. 0.1 % apart. The measured value is taken",
"because it is the one with the loop's own endpoints under it; the",
"autocorrelation never touched the wave and agreed to a tenth of a percent,",
"which is what makes both worth having."
],
"loop_end_kind": "measured"
}
},
"voice": {
"_": [
"🔴 KNOWN WRONG, HELD DELIBERATELY. Which of a voice region's streams to",
"export. The premise this entry was built on has been REFUTED BY THE RUNNING",
"GAME and the entry is kept, escalated, rather than swapped for another guess.",
"",
"The premise was: a region carries THREE PRESENTATIONS OF ONE TAKE, so the",
"exporter picks one. The Decoder booted with `--xma_param_probe=true` -- the",
"cvar that reports which sub-wave the game decodes -- and the game decodes",
"ALL THREE, CONCURRENTLY, in three separate XMA contexts, with byte sizes",
"matching the three disc payloads exactly (1294336 / 1118208 / 1171456",
"against RIFF size - 60 of 1294396 / 1118268 / 1171516).",
"",
"SO THERE IS NO 'WHICH ONE' TO ANSWER. `presentation` below discards two of",
"three streams the game plays. It is not a preference between rules any more;",
"it is a known-incomplete export.",
"",
"WHY IT IS NOT CHANGED TODAY. Reverting to the 1/n sum is not obviously less",
"wrong: an equal-gain sum of channel pairs is not a downmix -- MISSION",
"section 6 makes exactly that point when it pins an explicit matrix for the",
"movies' 5.1 fold rather than letting ffmpeg default -- and the 6.02 dB the",
"sum cost S00A was a real defect. Swapping one guess for another on a message",
"is what produced this entry twice already.",
"",
"🟡 HYPOTHESIS, NOT A RESULT, and it is the Decoder's: three concurrent stereo",
"streams is six channels, and N stereo streams is how XMA carries",
"multichannel on the 360, so 5.1 would explain the differing byte rates, the",
"near-silent stream and why cues are 1-stream or 3-stream and never 2. AGAINST",
"IT: all three declare ChannelMask = 0x0002 identically, which is odd for",
"distinct channel roles. Do not build on it.",
"",
"WHAT SETTLES IT: a recording of the game's own output over the intro,",
"through the PulseAudio null sink (AUDIO-VERIFICATION section 3). Candidate",
"combinations of the three decoded streams can then be correlated against",
"what the game actually played. Asked 2026-08-29.",
"",
"🔴 REFUTED FROM THE OUTPUT SIDE, 2026-08-30, not merely suspected.",
"",
"The RE agent recorded 148 s of the game's own output over the boot intro",
"(ALSA tee, --gpu=null, 0.15 % silence -- cleaner than the recipe page's own",
"reference run), with provenance from the XMA probe rather than a screenshot:",
"`ADV`'s three contexts appear byte-exact, then the `BGM_102` pair.",
"",
"FIVE OF SIX CHANNELS CARRY DISTINCT CONTENT. No channel is a copy of another;",
"the largest pairwise correlation is 0.70, between FL and FR, which is what a",
"stereo pair looks like. BR is 82 % silent and 11 dB down.",
"",
"So `presentation: \"loudest\"` -- keeping ONE stream -- cannot be right. That",
"was already labelled known-wrong here on the strength of the game decoding",
"all three concurrently; it is now refuted by what the game PLAYS.",
"",
"⚠️ AND IT IS STILL NOT FIXED, DELIBERATELY, on the RE agent's own instruction.",
"Three limits they state:",
" * it does not make summing right -- the output is multichannel, which says",
" nothing about which stream lands where;",
" * '6 channels' is NOT evidence the game is 5.1 -- that count is Xenia's",
" hardcoded kFrameChannelsDefault. The evidence is that five of them DIFFER,",
" which a stereo guest cannot produce;",
" * 🔴 the stream-to-channel mapping is NOT RUN. Cross-correlating each",
" captured channel against each decoded `ADV` stream is the step that",
" answers this, and it is their next iteration.",
"",
"Changing the mapping now would swap one authored guess for another, which is",
"a worse position than a guess that is labelled. The value stays; the label is",
"upgraded from suspicion to refutation."
],
"presentation": "all",
"presentation_why": [
"`loudest` = the full-length stream whose peak is nearest full scale.",
"",
"🔴 READ THE BLOCK ABOVE FIRST. This selects one of three streams the game",
"decodes concurrently, so whatever it selects, two are missing. The",
"paragraphs below are the history of how the value was arrived at, kept",
"because the reasoning is what makes the error checkable -- NOT because the",
"choice is defensible on its own terms any more.",
"",
"It was `highest_rate`, on a recommendation withdrawn as self-contradictory:",
"'the highest-rate, highest-gain one is chunk 1' selects different streams --",
"ADV stream 2 is 1118268 B at 0.0 dBFS, stream 3 is 1171516 B at -8.3.",
"",
"A structural argument for `loudest` was offered and withdrawn too: ADV",
"stream 2 is mono-in-stereo and stream 3 is dual-mono, so the extra bytes",
"looked like a duplicated channel rather than fidelity. The CHANNEL",
"MEASUREMENT stands and now reads differently -- these are channel pairs, and",
"0.60x with the residual 26.8 dB down is what a correlated pair at a lower",
"level looks like. The GENERALISATION was refuted by census: the stream-3 /",
"stream-2 size ratio over the 28 three-stream cues runs 0.0778 to 2.9163.",
"",
"⚠️ THE FAILURE MODE HERE IS THAT IT SOUNDS FINE. A single stream decodes to",
"clean audible dialogue, so nothing in the output reveals that two streams",
"are missing. That is why the manifest says it in words on every voice entry",
"rather than leaving it to this file.",
"",
"📌 WHERE THE OPEN QUESTION LIVES, added 2026-09-01 under this port's own rule:",
"an `authored` kind must cite the question it stands in for, or an invented",
"value and a placeholder for a measurement read identically. This one stands in",
"for the three-concurrent-streams problem, recorded in docs/port/BLOCKED.md and",
"delivered in docs/port/HANDOFF.md -- the game decodes all three at once, so",
"ANY single selection is missing two, and the export states that per movie",
"rather than choosing quietly.",
"",
"⚠️ 1 402 characters of careful reasoning and nothing openable until now. It is",
"the third uncited field in this file, and all three were detailed rather than",
"sloppy -- the detail is what made them look sourced."
],
"presentation_kind": "authored",
"stream_weights": {
"_": [
"Declared XMA `byte_size` -> the coefficient that stream's position takes in a",
"stereo downmix. MEASURED by the RE agent 2026-08-30",
"(docs/re/structures/intro-audio-decomposed.md): decomposing the game's own",
"6-channel output as capture = 0.600 x movie + residual puts ctx0 at FL/FR,",
"ctx1 at FC with LFE silent, and ctx2 at BL/BR.",
"",
"🔴 KEYED BY BYTE SIZE ON PURPOSE. The assignment is indexed by the decoder's",
"own declared size, so the exporter can CHECK that the stream in front of it is",
"the one the measurement describes rather than assume it. A region whose chunks",
"do not match falls back to the count divisor and says so. That is not defensive",
"programming: on 2026-08-30 this table's sizes did NOT fit the region the",
"resolver returned, which is what exposed `resolve_movie_voice_region` starting",
"238 packets late. Had the weights been applied positionally they would have",
"been applied to the wrong streams silently.",
"",
"⚠️ ONE BOOT, ONE MOVIE. Only `ADV`'s three streams were measured. `S00A`'s",
"sizes match nothing here and it keeps the divisor -- extending this by",
"POSITION would be assuming the ordering generalises, which is exactly the",
"inference the byte-size key exists to avoid.",
"",
"⚠️ The weights are a stereo downmix's, folded to mono. They sum to 1.0, so the",
"total is the movie's own; what they distribute is the balance between three",
"positions. Whether the game's 0.600 mixer gain is a constant or a volume",
"setting is unknown and the port applies no gain of its own."
],
"1294336": {
"position": "FL/FR",
"weight": 0.4142
},
"1118208": {
"position": "FC (LFE silent)",
"weight": 0.2929
},
"1171456": {
"position": "BL/BR",
"weight": 0.2929
}
}
}
}

View File

@@ -1,669 +1,55 @@
{
"format": "sylpheed.flow/1",
"_": [
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
"negative -- the order is in none of the four places it could have been. It is",
"not in config.ini's empty [SYSTEM], not in the movie manifest (which carries",
"assets, not transitions), not in a persistent GamePart field (the requested id",
"lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero",
"xrefs of any kind. A transition is a call with a name argument, chosen by code.",
"",
"So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE",
"agent watched the game do, not what any file on the disc says it does. Nothing",
"here may be presented as decoded."
],
"boot": [
{
"screen": "publisher_logo",
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both. 📌 SOURCE, added 2026-09-01: the boot's screen order and dwells are derived from GP_TITLE's own entries -- see docs/port/FORMAT.md for the export shape and docs/re/ui-title-build-map.md for which entry is which screen. The order here is not authored; it is what the archive declares."
},
{
"screen": "developer_logos",
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
},
{
"video": "ADV",
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
"skippable": true,
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline.",
"skippable_kind": "measured"
},
{
"screen": "title",
"overlay": {
"screen": "press_start",
"clock": "shared",
"why": "MEASURED, 2026-08-29, docs/re/title-plate-delay-measured.md on branch auto/no-disc-and-menu-captures at 5b0a6e6 (NOT on main when this was written). The boot title shows build 4 ALONE and the `PRESS (A) BUTTON` plate -- build 2 -- arrives later. This is the ONE case in the port where two builds are drawn at once.",
"no_constant_why": "THERE IS NO AUTHORED DELAY HERE, AND THERE WAS ONE FOR ONE ITERATION. The first version of this block carried `after_settle_seconds: 2.13`, taken from the RE agent's instruction. The port refuted that instruction with arithmetic off the disc -- build 2 has a group of its own, and starting it at settle put the plate 3.97 s late -- and the corrected answer needs no constant at all: BOTH BUILDS RUN ON ONE CLOCK, STARTED TOGETHER, and the plate arrives at its own declared t=236 (CORRECTED 2026-09-01 from t=238, which is the last opaque frame rather than the arrival). `clock: \"shared\"` is that, spelled out rather than implied by the absence of a delay field. 📌 SOURCES, added 2026-09-01 in the uncited-why backfill: the plate's arrival is docs/re/title-plate-delay-measured.md and its pulse is docs/re/structures/plate-pulse-measured.md. 🔴 AND `clock: \"shared\"` IS AUTHORED FROM OUR OWN ARITHMETIC, NOT MEASURED. Nobody has watched whether build 2's group starts with build 4's; it is the reading that reconciles the oracle's 2.13 s IF the settle anchor is t=118. See docs/port/plate-arrival-halves.md and BLOCKED.md H3.",
"arithmetic_why": "Why one clock reproduces the measurement, checked against this export rather than taken on trust: build 4's effect quads `pteff01`, `pteff02` and `ptlogoall_eff` end their ramps together at t=118; `ptbtn00` reaches alpha 255 at t=236; the difference is 118 units = 1.967 s at 60 units/s. The oracle measured 2.138 s and 2.132 s. The gap is presentation rate: the emulator presents at 28.1 fps against a nominal 30, and the corpus had independently measured the idle title at 28.5 fps before these runs. 🔴 CORRECTED 2026-09-01: this said `ptbtn00` reaches 255 at t=238 and that the difference is 120 units = 2.000 s. It reaches 255 at t=236 and HOLDS to 238, so 238 is the last opaque frame, not the arrival; 236 - 118 = 118. The port printed the contradiction in one sentence on every boot. The correction moves the reconciliation by 0.033 s and overturns nothing -- see docs/port/plate-arrival-halves.md. 🔴 AND THE ANCHOR IS NOW OPEN. The oracle defines \"title settled\" operationally, as its glyph counter first reading the no-plate value 154. This export offers TWO anchors 42 units apart: t=118 (the effect quads) and t=160 (`ptcopyright` at full alpha -- the LAST element to finish building in, and the only one made of glyphs). This line picked 118, while `ScreenView.settle_time()` returns 160 and the boot prints `settles at t=160`, so one binary holds both. Asked in BLOCKED.md H3; not guessed here. 📌 SOURCE: the pulse period and its phase behaviour are in docs/re/structures/plate-pulse-measured.md and docs/re/structures/plate-pulse-phase-lock.md, with the raw series in docs/re/data/plate-pulse-timeseries.txt. ✅ AUDITED 2026-09-01: the corpus's 28.5 fps is a genuinely independent leg -- a different quantity (idle-title presentation rate), measured BEFORE these runs, so it could have come out disagreeing. It agrees to 1.4 %.",
"the_premise_that_failed_why": "The port's own, and it is worth keeping because it will bite again: `rest.t` IS NOT WHEN A SCREEN SETTLES. It is the last hold keyframe before the exit. Reading it as the settle put build 4's arrival at 4.35 s instead of 1.97 s, and every reconciliation computed from it came out wrong by exactly that error. `ScreenView.settle_time()` still uses rest.t -- see docs/port/BLOCKED.md. 🔴 THE EXAMPLE THIS CITED IS GONE, THOUGH THE CONCLUSION IS NOT. It read \"`ptlogo1` has rest.t=251 and stops MOVING at t=42\". In the CURRENT export `ptlogo1.rest.t` is 42 -- equal to when it stops moving. The record-layout fix repaired precisely that element, and the entry was never re-derived under it (REFUTED.md now carries this at 🟡 ⟨our-reader⟩). rest.t is still wrong for transients -- `ptlogo_back2eff1` is a two-frame flash whose rest.t=54 is the flash PEAK -- and for `pteff00`, whose rest.t=16 sits at the end of the fade-FROM-black while a fade-TO-black runs 261..269. Re-derived 2026-09-01: docs/port/plate-arrival-halves.md. 🔴 AND IT IS NOT THIS DEFECT'S CAUSE. The plate's ARRIVAL is a declared keyframe (transparent to t=214, opaque at t=236), not a rest pose; rest.t=236 only chooses where `holding` parks it, and 236 is that ramp's own peak. Confirmed on a filmed boot with rest.t untouched: the onset is bracketed within one frame of 214.",
"scope_why": "Attached to the BOOT STEP, not to the `title` screen, and that is deliberate. What was measured is the boot title. Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured, and putting the overlay on the screen would quietly claim it is. 📌 SOURCE, added 2026-09-01: the plate belongs to the boot's overlay step rather than to the title screen because its arrival is measured against the boot clock -- docs/re/title-plate-delay-measured.md. 🔴 STALE CLAUSE, CORRECTED 2026-09-01: this said \"Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured\". It IS measured now, and has been since 2026-08-30: after (B) from the menu the plate is re-drawn, pressed at 351.2 s with its pulse back at 358.5 s (the Decoder, nav-autorepeat-and-settled-b data). The port re-arms the overlay on arrival at the title by any path, and that is correct. What stayed true is the structural half -- the declaration lives on the boot STEP and is looked up from there, so a screen that gains an overlay gets it on both paths at once. ⚠️ What is STILL not measured is whether the returned plate FADES or appears at once; the 7.3 s between press and pulse is consistent with a transition plus the declared 214->236 fade, but that is consistency, not a measurement of the ramp on this path.",
"no_pulse_why": "The port draws the plate arriving and then holding. It does not pulse it. The RE agent identifies the pulse as the plate's FOCUS RECORD `ptbtn00f` -- a glow ramping 0x00 to 0x50 and back, t=6..105 -- not as a loop of `ptbtn00`'s own group, which was the port's earlier reading and was wrong. Looping that record is a candidate the port has NOT taken: its group is 105 timed units plus an AUTHORED 24-unit exit ramp, and hitting the measured 2.24 s mean requires composing that authored constant with a loop assumption, which is tuning rather than measuring. Filed in BLOCKED.md. 📌 SOURCE, added 2026-09-01: docs/re/structures/plate-pulse-measured.md, and the phase-lock caveat that bounds what a gated capture can show is docs/re/structures/plate-pulse-phase-lock.md."
},
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. This is the LAST step, and a last step is where the sequence stops rather than fading out -- a boot that ends by fading to black looks like a boot that crashed. P5 gave the title somewhere to go, but that is a HANDOVER and not another boot step: `--boot` still stops here, and `--boot --play` hands the same held title to the menu flow, where (A) opens TITLE_MENU. Kept as a stop rather than folded into `screens` because what the boot does is authored from a measured sequence, and what (A) does is a separate measurement."
}
],
"dwell": {
"format": "sylpheed.flow/1",
"_": [
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
"",
"This key has now been wrong in two opposite directions, and the second was",
"mine, so both are recorded.",
"",
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
"🔴 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
"exit is caused by something outside its timeline, so it holds. A splash's exit",
"is caused by nothing, so it plays its declared timeline and leaves. The title",
"is the exception, not the rule, and one screen was never enough to overturn",
"the other two.",
"",
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
"",
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
"",
"The developer agrees to 1.1 %, two of its three runs to 0.3 %. The port emits",
"4.400 s and 3.650 s -- each declared value plus the 9-unit black hold, exactly.",
"So the pacing was right all along and nothing changes in the code.",
"",
"🔴 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
"Decoder's own container came out 15-20 % LONGER than both the declared values",
"and the corpus -- same disc, same timeline -- and three independent readings",
"of that container's rate disagree with each other. A seconds figure records",
"one emulator's pacing on one run. The units are on the disc. If anything ever",
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
"measured to wait beyond its group."
]
},
"navigation": {
"_": [
"MEASURED off the running game, HANDOFF Q5 -- none of it is on the disc.",
"It lives here rather than in GDScript so that a reader can see it is a",
"measurement and delete it the day a field on the disc states it."
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
"negative -- the order is in none of the four places it could have been. It is",
"not in config.ini's empty [SYSTEM], not in the movie manifest (which carries",
"assets, not transitions), not in a persistent GamePart field (the requested id",
"lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero",
"xrefs of any kind. A transition is a call with a name argument, chosen by code.",
"",
"So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE",
"agent watched the game do, not what any file on the disc says it does. Nothing",
"here may be presented as decoded."
],
"wrap": true,
"wrap_why": "HANDOFF Q5: up/down move one item and WRAP at both ends. Measured on the 5-item main menu AND the 3-item EXTRAS, so it is a menu rule and not a per-screen one (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26).",
"wrap_kind": "measured",
"left_right": "nothing",
"left_right_why": "HANDOFF Q5: left/right do nothing. Measured. Implemented as an explicit no-op rather than by omission, so that 'we never wired it' and 'the game ignores it' are distinguishable in the code.",
"left_right_kind": "measured",
"input_during_transition": "ignored",
"input_during_transition_why": "AUTHORED, and NOT measured -- nobody has watched what the game does with a button pressed mid-fade. Ignoring is the choice that invents the least: it cannot queue a press the game might have dropped. Ask the RE agent before relying on it. 📌 WHERE THE ASK LIVES, added 2026-09-01: docs/port/BLOCKED.md carries it, and until now this why said \"ask the RE agent\" without naming where the question is recorded -- a pointer with no destination. An `authored` kind still needs a citation, because the thing to cite is the OPEN QUESTION the choice stands in for; without it, an invented value and a placeholder for a measurement read the same.",
"input_during_transition_kind": "authored",
"auto_repeat": false,
"auto_repeat_why": "MEASURED 2026-08-30, Decoder daf8f47: a 2.0 s held (down) moves the cursor EXACTLY ONCE. Their counter passes its own control first -- a single 0.12 s tap gives exactly 1 spike, the hold gives 1, move spike 0.0202-0.0220 against a 0.0003-0.0038 floor. The port's edge-triggered _input already behaved this way; what changed is that it is now a MEASUREMENT rather than an unexamined consequence of how the handler was written. HANDOFF Q5's 'up / down' row is split at the source: one-item-per-press (evidenced by the 4-press wrap count) from no-auto-repeat (which had nothing until this run).",
"auto_repeat_kind": "measured"
},
"screens": {
"_": [
"What each button does. The NAVIGATION ORDER is not here -- it is derived,",
"in each screen file's `buttons` (button-role elements sorted by resting Y).",
"Only the destinations, the initial focus and the cancel target are",
"authored, because only those are measurements or decisions.",
"",
"`goto` is an EXPORTED SCREEN NAME or null. `goto_name` is the game's own",
"screen vocabulary from the decoded transition lookup -- carried so the",
"binding is not lost, and marked below as the NAME MATCH it is, never as a",
"measurement (HANDOFF: the strings are what the call sites reference, not",
"proven arguments, and the same list mixes in TEXT_FONT and GAMMA_RGB).",
"",
"`goto: null` with a `blocked` note means the destination screen is real and",
"measured but is NOT IN THIS EXPORT -- it lives in another archive. That is a",
"milestone boundary, not an unknown."
"boot": [
{
"screen": "publisher_logo",
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both."
},
{
"screen": "developer_logos",
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
},
{
"video": "ADV",
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
"skippable": true,
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline."
},
{
"screen": "title",
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go."
}
],
"title": {
"on_accept": {
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF: (A) on the title opens the main menu, with (A) on the boot title as the control in the same run."
},
"on_cancel": null,
"on_cancel_why": "MEASURED 2026-08-30, Decoder daf8f47, docs/re/data/nav-autorepeat-and-settled-b.txt: twenty seconds after a delivery-confirmed B the screen is still the title with PRESS (A) BUTTON up. The run waited for the PLATE PULSE -- the title's own settled signature -- before pressing, which is exactly what the earlier confounded attempt did not. This cell briefly said 'MEASURED, HANDOFF Q5' on no evidence, then said AUTHORED once that was caught; it is now measured for real. Value unchanged throughout: null.",
"on_cancel_kind": "measured"
"dwell": {
"_": [
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer",
"logos at t=190 (3.17 s), both read from the disc. Holding beyond that would",
"be a number nobody has measured, so the sequencer holds for zero extra time",
"and the pacing is the disc's own.",
"",
"When a capture times the real boot, the extra hold per screen goes here."
]
},
"main_menu": {
"initial_focus": "ptbtn01",
"initial_focus_kind": "measured",
"focus_persists": true,
"focus_persists_kind": "measured",
"focus_persists_why": [
"MEASURED 2026-08-30, Decoder: the main menu REMEMBERS ITS CURSOR across a",
"round trip through the title. (B) out and (A) back returns to the item you",
"left, not to a default. Their control passed first -- two delivery-confirmed",
"DOWNs moved the cursor exactly two items before the round trip, so the",
"cursor demonstrably was not where it started.",
"",
"The port reset to `initial_focus` on every entry, so this was a real defect",
"and not a refinement: a player who moved to EXTRAS, pressed (B), then (A),",
"landed back on NEW GAME.",
"",
"🔴 SCOPED TO THIS SCREEN ON PURPOSE, and the scope is the authored part.",
"The measurement is of the MAIN MENU. Making it a menu-wide rule would be",
"n=1 wearing a rule's clothes -- and here it would actively contradict a",
"measurement, because `extras` opens on MISSION SELECT as a MEASURED initial",
"focus, and a remembered cursor would override it on re-entry. `wrap` is a",
"menu rule because it was measured on two screens; this was measured on one.",
"",
"⚠️ WHAT IS NOT KNOWN: whether the memory survives a return to the BOOT",
"(as opposed to the title), and whether any other screen has it. Ask before",
"widening this.",
"",
"🔴 CORRECTED 2026-08-30, SAME DAY, by the Decoder: the paragraph above argued",
"the scope from `extras` having a MEASURED initial focus that a remembered",
"cursor would override. That is a good reason to be CAUTIOUS and NOT a finding",
"that `extras` resets. Nothing has measured what a submenu's own cursor does on",
"re-entry: the corpus has EXTRAS' opening item from ONE entry, and (B) restoring",
"the PARENT's focus 4/4, and neither answers it.",
"",
"So `focus_persists: false` everywhere else is THE PORT'S DEFAULT, not the",
"game's behaviour. It invents the least and it preserves the one measurement",
"there is. `tools/port/contract-check` asserts only the main-menu half against",
"the contract and reports the scope as a GUARD, because for one iteration it",
"asserted non-persistence as though it had been measured -- which would have",
"held the port to the wrong behaviour and passed while doing it.",
"",
"❔ The Decoder is measuring EXTRAS re-entry now. Do not build on the",
"non-persistence half until it returns.",
"",
"📌 SOURCE, added 2026-09-01 in the uncited-why backfill: docs/re/data/focus-persists-across-title.txt carries the round trip, and docs/re/data/extras-focus-resets.txt carries the contrasting submenu result that keeps this scoped to one screen."
],
"initial_focus_why": [
"MEASURED 2026-08-30 (later) -- `NEW GAME` on a fresh boot, 2/2 fresh boots,",
"both the FIRST menu entry. Decoder, HANDOFF `bf9e07f`, section \"correcting",
"today's focus delivery\"; ring row y=225.5 against a measured 79.25 px step,",
"data in docs/re/data/menu-focus-reader-offset.txt.",
"",
"🔴 THIS FIELD WAS `authored` UNTIL NOW AND THE UPGRADE IS NOT BECAUSE IT",
"AGREES WITH ME. The value did not change; its standing did. The confirmation",
"is a direct reading of a fresh boot's first menu entry, independent of the",
"reasoning that chose NEW GAME here -- and the Decoder had said explicitly that",
"my agreeing with their records was no evidence, which was correct at the time.",
"",
"✅ AND IT SURVIVES A REBOOT -- MEASURED 2026-08-31. Six fresh boots all",
"opened on NEW GAME, and THREE of them followed a session that ended with the",
"cursor on EXTRAS or OPTIONS. That is what makes it a test of persistence",
"rather than six repetitions of the same start.",
"",
"⚠️ REACH, and it is the Decoder's own caveat rather than mine: every one of",
"those sessions ended with the emulator KILLED, not shut down cleanly. A game",
"that writes menu state on a clean exit never gets the chance, so this",
"measures 'does not survive a KILLED session'. If a real console remembers a",
"cursor across a power cycle, that does not contradict this.",
"",
"⚠️ WHY 'FIRST ENTRY' IS LOAD-BEARING: the menu REMEMBERS ITS CURSOR (see",
"`focus_persists`), so any reading not taken on a fresh boot's first entry is",
"measuring HISTORY, not what the screen opens on. That objection is what",
"invalidated the earlier TUTORIAL/NEW GAME disagreement, and this measurement",
"is the one that is immune to it.",
"",
"The superseded reasoning is kept below, because it is what made the wait cheap:",
"the field existed and was labelled honestly, so arriving at a measurement was a",
"label change and not an archaeology problem.",
"",
" (was) AUTHORED, standing in for HANDOFF Q5, which measured that initial focus is NOT STABLE: four boots of the same harness opened on TUTORIAL, TUTORIAL, NEW GAME, NEW GAME. A port has to open on something. ptbtn01 (NEW GAME) is picked because it is one of the two states actually observed and it is the top item, so a reader can predict it. It is a CHOICE. Delete this the day the RE agent finds what selects it. CORROBORATED 2026-08-29, and still not decoded: the committed capture live-main-menu.png has NEW GAME focused. Identified by rendering all five focus states and taking the minimum difference -- 531 differing pixels against 6080-7094 for the others, an 11.5x margin -- with the method controlled on live-main-menu-options-focused.png, whose answer is in its filename and which it picks by 4.7x. That means the port's choice matches the state of one committed frame. It does NOT make focus stable: Q5's four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME, and this identifies one frame rather than a rule. Delete this entry the day something says what SELECTS it. TIGHTENED 2026-08-29: Q5 now has SIX boots, and the shape is sharper than 'unstable' -- TUTORIAL x3, NEW GAME x3, and NO OTHER ITEM EVER OBSERVED. So it is not uniform over five buttons; whatever selects it has to explain a two-way split. That does not change this choice (NEW GAME remains one of exactly two observed states, and it is the state of the committed capture) but it does change what would REFUTE it: a boot opening on LOAD GAME, OPTIONS or EXTRAS would break the two-way shape, and a rule that predicts the split would delete this entry outright.",
" (was) ",
" (was) ✅ CONSISTENT WITH THE ONE CAPTURE, measured 2026-08-30. Rendering each of the",
" (was) five buttons focused against `live-main-menu.png` gives 0.0705 % for ptbtn01",
" (was) and 0.72-0.84 % for the other four -- a 10x discrimination. So that capture",
" (was) shows NEW GAME focused, and the authored choice matches it.",
" (was) ",
" (was) ⚠️ THIS DOES NOT OVERTURN Q5. Q5 measured initial focus as UNSTABLE across",
" (was) four boots; one capture showing ptbtn01 is consistent with that and does not",
" (was) contradict it. What the measurement establishes is narrower and still worth",
" (was) having: the port's focus rendering is distinctive enough that a capture",
" (was) identifies which button is focused, and this authored value is not at odds",
" (was) with the only frame we can check it against. It stays AUTHORED."
],
"on_cancel": {
"goto": "title",
"goto_name": "TITLE_SCREEN",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_SCREEN` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"kind": "measured",
"why": "MEASURED 2026-08-30, delivery-confirmed (B = 0x5801), 73.5 % of pixels changed, and both captures name themselves. Latency <= 0.4 s and NO loading screen in between, which matters because the disc carries four pgloading_* screens. This entry previously read 'likely but UNPROVEN': it had been seen once without a capture, and the title ALSO returns on its own after ~8-10 s idle, so an observer could not tell a response from a timeout. The <= 0.4 s latency is what kills that confound -- it is twenty times faster than the idle return. Decoder 86a8ce7, menu-navigation-semantics.md row 'B on the main menu', docs/re/data/b-on-main-menu.txt."
},
"buttons": {
"ptbtn01": {
"label": "NEW GAME",
"goto": null,
"goto_name": "DLG_SELECT_DIFFICULTY",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"✅ CORRECTED 2026-08-31: this read `DIFFICULTY`, and the destination is a",
"DIALOG rather than a GamePart -- `DLG_SELECT_DIFFICULTY`, `GP_DIALOG.pak`",
"entries 2/3 [see the withdrawal below]. Decoder, TWO arguments [corrected below]; the geometry one is",
"re-derived here with this port's own reader: entries 2 and 3 are the ONLY",
"builds in that archive carrying `pcbtn00`-`pcbtn03`, at design rows",
"259/329/399/469, spacing exactly 70. See",
"`crates/sylpheed-export/examples/dialog_rows.rs`.",
"",
"🔴 SO THE FOUR EXTERNAL DESTINATIONS ARE NOT UNIFORM: three open GameParts",
"and this one opens a dialog. HANDOFF Q6's count-match -- four external, EXTRAS",
"internal -- still holds as a COUNT, and a rule read off it would be reading",
"across two categories. The Decoder sent that count with disc support",
"yesterday and weakened it themselves today; recorded at the weaker strength.",
"",
"✅ THE REACH IS NOW BOUNDED -- 2026-08-31, and both agents scanned for it.",
"",
"It read: \"another four-button dialog with the same rows would be",
"indistinguishable by this evidence\". The Decoder searched every build in",
"every pak for four buttons within 6 px of those rows and found ZERO rivals.",
"Re-run here with this port's reader and a BROADER filter -- any element",
"whose name contains `btn`, not only `pcbtn`, so a rival under a different",
"naming convention would still be caught: 2 859 builds across 33 paks,",
"EXACTLY 2 matches, entries 2 and 3. The run carries its own known positive:",
"fewer than 2 would mean the reader cannot see the incumbents and its zero",
"would mean nothing.",
"",
"✅ And the name is now backed by a TABLE ENTRY rather than an inference",
"from a string list: every `DLG_` name in the image sits in a 12-byte record",
"(id, name pointer, handler [corrected]) spanning 0x820A0A2C-0x820A0D68 -- 70 names,",
"70 records, none unmatched. `DLG_SELECT_DIFFICULTY` is **id 2000**.",
"",
"🔴 \"THREE INDEPENDENT ROUTES\" CORRECTED TO TWO -- 2026-08-31, by the Decoder,",
"and I had relayed the count unchecked for the second time from one delivery.",
"",
"The image leg says DIFFICULTY is a dialog and names no entry, so alone it",
"identifies nothing. The disc and oracle legs are ONE COMPOUND ARGUMENT: the",
"capture is compared against the disc's rows. What makes that discriminating is",
"the EXCLUSION SCAN -- zero rivals within 6 px anywhere on the disc -- and that",
"is what the word \"three\" was taking credit for. The conclusion is unchanged;",
"the evidence is two arguments, one of them compound, and was never three.",
"",
"📌 The test that falls out of it, theirs: ask of an n-routes claim not whether",
"the routes are correct but whether ANY COULD HAVE COME OUT DIFFERENTLY GIVEN",
"THE OTHERS. That is an exclusion argument, and it is usually absent.",
"",
"🔴 WITHDRAWN 2026-08-31 -- \"AN EN/JP PAIR\", AND I RELAYED IT.",
"",
"The Decoder stated entries 2/3 as a language pair in the same HANDOFF row that",
"identifies DIFFICULTY, as a fact, and has withdrawn it: nothing established the",
"pairing. I copied it into this `why` -- twice -- in the SAME SENTENCE where I",
"was careful to say my re-derivation confirms the geometry and does not name the",
"screen. The unchecked half rode along inside the clause I had checked.",
"",
"What the scan actually shows is that adjacent GP_DIALOG entries are UNRELATED",
"DIALOGS: 26 of 65 adjacent pairs differ in BUTTON COUNT, which no language pair",
"can. Identical element sets is the language signature in GP_TITLE; here it is",
"equally consistent with a duplicate. So `2/3` are two builds with the same four",
"buttons at the same rows, and calling them EN and JP is an assumption.",
"",
"⚠️ THE IDENTIFICATION DOES NOT REST ON IT -- unique four-button geometry with",
"zero rivals disc-wide, plus the oracle capture. The pairing was decoration on a",
"conclusion that stands without it, which is exactly why it travelled unchecked.",
"",
"✅ RESTORED 2026-08-31, ON A MEASUREMENT RATHER THAN A RELAY. The Decoder took",
"the `ja` capture of DIFFICULTY that was missing and 2/3 ARE English/Japanese:",
"EN vs JP differ in 1.82 % of pixels in FOUR BANDS AND NOWHERE ELSE -- the",
"heading (DIFFICULTY -> the JP heading), the ring by 2 px, the BACK label, and",
"the footer. EASY/NORMAL/HARD are NOT in the differing set: the Japanese release",
"leaves the three difficulty names in Latin script, which is why the disc figure",
"is only 2.77 % of bytes against 1.82 % of pixels.",
"",
"📌 MY OBJECTION WAS NOT WRONG AND IS NOT WITHDRAWN. It was that IDENTICAL",
"ELEMENT SETS DO NOT IMPLY A LANGUAGE PAIR -- 26 of 65 adjacent pairs differ in",
"button count, so adjacency proves nothing. That argument still holds; what has",
"changed is that the conclusion now rests on a direct locale capture instead of",
"on that inference. A bad argument for a true claim is still a bad argument, and",
"the claim was correctly out of this file until somebody went and looked.",
"",
"⚠️ REACH, THEIRS: one JP boot, one screen, does not generalise. GP_TITLE 4/7 is",
"known to differ by MORE than text -- entry 7 carries nine sprites entry 4 lacks.",
"Nothing in the port keys off locale today; this is recorded, not consumed.",
"",
"🔴 RECORD LAYOUT CORRECTED 2026-09-01, and I had copied the wrong one. I wrote",
"\"(handler, id, name pointer)\"; it is {id, name_ptr, handler} -- the same three",
"fields shifted one word, so every record was being credited with the PREVIOUS",
"record's handler. The Decoder caught it with a control dump: under the old",
"alignment record 0 had a handler of 0x10000000, which is not a code address.",
"ids and names are unaffected and DLG_SELECT_DIFFICULTY is still 2000, so",
"nothing here moves except the sentence.",
"",
"📌 FOURTH aside of theirs relayed into this file. The first three were an EN/JP",
"pairing, a leg count and an independence claim -- all decorative. This one is a",
"STRUCTURE, which is worse: a wrong field order is the kind of thing a later",
"reader builds on, and it carried no weight here only by luck.",
"",
"❔ AND THE JOIN IS NOT REACHABLE BY THAT ROUTE -- their negative, with their",
"reach. All three handlers load the same global at 0x828E2B14 and take addresses",
"at 0x828E45E0/4640/467C, every one inside a 364 601-byte contiguous zero run:",
"BSS, populated only at runtime. Controlled, because an all-zero read is also",
"what a wrong address gives, and the dialog table itself reads non-zero through",
"the same arithmetic.",
"",
"⚠️ That closes the DIALOG HANDLERS, not the image. The archive loader and any",
"id-keyed table elsewhere are unexamined, so \"not in the image\" is NOT",
"established. Recorded as a route rather than an answer, which is how they sent",
"it.",
"",
"❔ STILL UNBOUND, and it is what would make this airtight: nothing connects",
"id 2000 to a pak entry. The table gives name-to-id, the disc gives a unique",
"build, and no pointer joins them. The tie is UNIQUENESS PLUS THE ORACLE",
"CAPTURE, not a binding -- so if a rival build ever appeared, this",
"identification would go with it.",
"",
"button count and geometry, NOT by a binding from the `DLG_` name to a pak",
"entry. No such binding was found. Another four-button dialog with the same",
"rows would be indistinguishable by this evidence -- my re-derivation",
"confirms the geometry and does not name the screen.",
"",
" (was) NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
" (was) screens are measured; the ids are a name match onto the executable's class",
" (was) names.\" So `DIFFICULTY` is a string that exists in the executable and plausibly",
" (was) denotes this screen -- nothing observed binds it to this transition.",
" (was) ",
" (was) It is carried so a reader can search for it and so the port never has to",
" (was) invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
" (was) a screen file, and this field is documentation.",
" (was) ",
" (was) 🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
" (was) labels rested on a sibling `why` that argues the DESTINATION -- a different",
" (was) claim from where the NAME came from. `tools/port/audit-kinds` reports that",
" (was) as BORROWED rather than ok, because a label resting on a neighbour's",
" (was) argument reads as evidenced and is not."
],
"blocked": "DIFFICULTY is not in this export. MEASURED destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA) but it is not a GP_TITLE build, so there is no screen file to go to yet.",
"skipped_chain": [
"DIFFICULTY",
"SELECT DATA"
],
"skipped_chain_why": "THE PORT SKIPS TWO MEASURED SCREENS HERE, AND IT SAYS SO OUT LOUD RATHER THAN PRETENDING. The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot -> ~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations (HANDOFF Q4) but neither is a GP_TITLE build, so there is no screen file to go to. The port jumps from NEW GAME to the one thing in that chain it has, and the runtime prints what it skipped on every run. This is a GAP, not a sequence: nobody may read the port's behaviour here as what the game does.",
"skipped_chain_kind": "measured",
"then_video": "S00A",
"then_video_why": "P7. HANDOFF Q9, DECODED from the movie manifest: MS00A -> S00A.wmv is the new-game intro, 93.9 s. Its POSITION is measured as well -- the movie starts ~4.5 s after (A) on the save slot, matched off the running game at 0.96-1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples.",
"then_video_kind": "decoded",
"unobserved_why": "WHAT FILLS THE ~4.5 s between the save slot and the movie is NOT KNOWN. The oracle run that would have shown it hit the already-documented sub_823070B0 cache crash after SELECT DATA. GP_TITLE does carry a LOADING screen -- entries 0/1 and 12/15, whose elements are every one of them named pgloading_* -- and LOADING is in the game's own screen vocabulary, but nobody has watched it appear here and the port does NOT put it in the chain on that basis. 📌 WHERE THE OPEN QUESTION LIVES, added 2026-09-01: docs/port/BLOCKED.md carries the row -- 'what fills the 4.5 s before S00A'. An explicit unknown still needs a citation, or it cannot be distinguished from an unexamined one.",
"skippable": true,
"skippable_why": "HANDOFF Q9, MEASURED: one (A) press skips a movie -- the title was reached at 57 s against a 193 s baseline. Same rule the boot intro already uses.",
"skippable_kind": "measured",
"after_video": {
"goto": "title",
"kind": "authored",
"why": "AUTHORED, and it has to be: the game goes into MISSION 1, and gameplay is out of scope (PORT-MISSION section 7). P7's gate asks for 'plays, then returns to a defined state' -- this is that state. The title is chosen over the main menu because the boot's own end state is the title, so a run that finishes the new-game intro lands somewhere a player can start again from. Nothing measured says the game does this."
}
},
"ptbtn02": {
"label": "LOAD GAME",
"goto": null,
"goto_name": null,
"blocked": "The save-slot list is GP_SAVE_LOAD, not in this export. Destination MEASURED."
},
"ptbtn03": {
"label": "TUTORIAL",
"goto": null,
"goto_name": "TUTORIAL_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TUTORIAL_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"blocked": "The lesson list is not a GP_TITLE build. Destination MEASURED."
},
"ptbtn04": {
"label": "OPTIONS",
"goto": null,
"goto_name": null,
"blocked": "The settings menu is GP_OPTIONS, not in this export. Destination MEASURED."
},
"ptbtn05": {
"label": "EXTRAS",
"goto": "extras",
"goto_name": "EXTRA_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `EXTRA_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF Q4: EXTRAS opens GP_TITLE build 6. It is the ONLY main-menu destination inside this archive, and therefore the only (A)-into-a-submenu the P5 gate can actually walk."
}
},
"labels_why": "The five labels are read off live-main-menu.png, a capture of the running game (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26). They are carried for logs and for a human reading this file; nothing draws them -- the button sprite already has its own text."
},
"extras": {
"initial_focus": "ptbtn11",
"initial_focus_kind": "measured",
"focus_persists": false,
"focus_persists_kind": "measured",
"focus_persists_why": [
"MEASURED 2026-08-30 -- EXTRAS RESETS. HANDOFF `4ed75e6`: ring back to",
"y=347.5 on re-entry after a confirmed DOWN, frame 0.0 % different from the",
"first entry, and the screen confirmed by eye as EXTRAS because an earlier",
"run was fooled about which screen it was on.",
"",
"📌 WRITTEN EXPLICITLY, THOUGH THE PORT'S DEFAULT IS ALREADY false. The",
"absent key and the measured false behave identically and mean completely",
"different things: one is 'nobody looked', the other is 'the game was",
"watched doing it'. `tools/port/audit-kinds` can see the second and not the",
"first, which is the whole reason for spending a key on it.",
"",
"🔴 AND THIS IS NOT A VINDICATION OF HOW IT GOT HERE. For one iteration the",
"port ASSERTED non-persistence for EXTRAS in `contract-check` while nothing",
"had measured it; the Decoder flagged that, and it turned out right. Being",
"right by luck does not retroactively make it evidence -- declining to",
"generalise the memory was the correct move, and encoding 'not measured",
"here' as a positive claim was a different and wrong one that happened to",
"land. The measurement is what makes it true; the assertion never did.",
"",
"⚠️ Do NOT generalise in either direction: main_menu persists, EXTRAS resets,",
"and OPTIONS / LOAD GAME / TUTORIAL are untouched."
],
"initial_focus_why": [
"MEASURED, unlike the main menu's: EXTRAS opens focused on MISSION SELECT (live-extras.png). It is authored here only because there is nowhere else to put a measurement -- it is not a choice.",
"",
"",
"✅ CAVEAT LIFTED 2026-08-30 -- MEASURED, not a single-entry reading any more.",
"HANDOFF `4ed75e6`, docs/re/data/extras-focus-resets.txt: EXTRAS opens at ring",
"y=347.5 on MISSION SELECT, moves to 427.5 after one delivery-confirmed DOWN,",
"and returns to 347.5 on re-entry with the frame 0.0 % different from the first",
"entry. Because this screen RESETS, a single-entry reading of it is not",
"measuring history -- which is precisely what made the caveat necessary while",
"persistence here was unknown.",
"",
"✅ THE AMBIGUITY IS RESOLVED -- MEASURED 2026-08-31, and it went the way",
"that makes `ptbtn11` right for a REASON rather than by coincidence.",
"",
"A submenu resets to ITS OWN OPENING ITEM, and that item is a per-screen",
"default which need NOT be the first. Decoder, docs/re/data/",
"difficulty-resets-to-named-item.txt: DIFFICULTY opens on NORMAL (second of",
"four); after one confirmed DOWN to HARD, (B) out and (A) back returns to",
"NORMAL -- in-cursor 1.0 from where it opened against 93.9 from where it was",
"left. Reproduced on a FRESH BOOT and confirmed by eye, not read off the",
"2026-08-29 capture.",
"",
"So the port's `initial_focus` is the reset target, and `buttons[0]` in",
"`MenuFlow.initial_focus` is a REPAIR rather than a default -- which is how",
"it was already documented, and is now measured rather than principled.",
"",
"❔ STILL OPEN, and not leaned on: whether the reset target MOVES once a",
"difficulty has actually been confirmed. A game that remembered your last",
"choice would behave differently, and the probe never confirms one -- the",
"same SELECT DATA crash that constrains the run prevents testing it.",
"",
"",
"🔴 CORRECTED 2026-08-31. This read \"it matters IF another screen is ever",
"authored\" whose opening item is not its first. Such a screen exists and is",
"recorded IN THIS FILE: DIFFICULTY, under `main_menu/buttons/ptbtn01`, is",
"EASY/NORMAL/HARD/BACK and opens on NORMAL -- the SECOND of four. Measured:",
"driven with no d-pad, unchanged for 90 s, matching the committed capture at",
"r=+0.999 (Decoder, docs/re/captures/newgame-path/newgame-difficulty.png).",
"",
"So \"a screen opens on its first item\" is REFUTED as a general description of",
"this game. On EXTRAS, TUTORIAL and OPTIONS the named item and the top item",
"coincide BY ACCIDENT. A top-item rule would be wrong on DIFFICULTY.",
"",
"nobody can separate \"resets to MISSION SELECT\" from \"resets to the TOP ITEM\".",
"They coincide here -- ptbtn11 is both. The port's value is correct under either",
"reading, and the REASON is not established.",
"",
"The superseded caveat is kept below.",
" (was) ⚠️ WEAKENED 2026-08-30 -- the OBSERVATION stands, its reading as an INITIAL",
" (was) focus does not. It was taken on a single entry. Now that the main menu is known",
" (was) to remember its cursor across a round trip, a one-entry reading of any screen",
" (was) may be measuring HISTORY rather than what the screen opens on -- the same",
" (was) objection that reframed the main menu's TUTORIAL/NEW GAME disagreement.",
" (was) ",
" (was) Kept as `measured` because the frame really does show MISSION SELECT focused,",
" (was) and kept as the port's opening item because it is the only reading there is.",
" (was) 🔴 If EXTRAS turns out to persist, this becomes history and the kind must",
" (was) change with it.",
"",
"🔴 CHECKED AGAINST THE BYTES 2026-08-31 by both agents -- and NOT independently.",
"Settled by fact, not by my inference: the Decoder's 282/362/442 came from",
"`crates/sylpheed-formats/examples/extras_button_order.rs`, which calls",
"`ui_layout::parse_build` -- THE SAME CRATE this port's export uses. The",
"Python RATC parsers in their tree exist and did not produce that number.",
"So the two legs are ONE READER USED TWICE, and the agreement carries no",
"information about the reader being right; it carries information only about",
"two callers of it agreeing, which they could not fail to do.",
"",
"⚠️ The VALUE is unaffected -- `ptbtn11` is decided by the DIFFICULTY",
"measurement and by the reset finding. What died is a word I used about the",
"evidence, which is the third such word in three iterations.",
"",
"is WEAKENED, by my own audit rather than by theirs.",
"",
"Applying their test to my own sentence: could my reading have come out",
"differently given theirs? Only if the implementations differ. Mine is",
"`sylpheed_formats::ui_layout::parse_build` via this port's export. Their tree",
"does carry separate Python RATC parsers (`kf_record_census.py` and others),",
"so a second implementation EXISTS -- but which reader produced their",
"282/362/442 is not established by me, and if they used the same crate the",
"two legs are one reader used twice.",
"",
"So: the values agreeing is still evidence, and calling it INDEPENDENT was a",
"claim about their tooling that I did not check. Recorded at the strength I",
"can support. ⚠️ Nothing rests on it -- the row order is also decided by the",
"DIFFICULTY measurement -- which is exactly why it went unexamined.",
"",
"Decoder attempted to refute this value and it survives: `ptbtn11` is the TOP",
"button on this screen -- y 282 against 362 and 442 -- so the port is right",
"whichever reading of the reset target applies. Confirmed from THIS port's own",
"export, a different reader of the same disc: extras 282/362/442, and the main",
"menu as a control at 162/242/322/401/482.",
"",
"🔴 WHICH ALSO MEANS EXTRAS CANNOT SEPARATE the two readings -- named item and",
"top item coincide here. It was DIFFICULTY, opening on its second of four, that",
"settled it."
],
"on_cancel": {
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you came from. EXTRAS advertises (B) in its own footer -- the red glyph is in ptmsg2.png and absent from the main menu's ptmsg.png."
},
"buttons": {
"ptbtn11": {
"label": "MISSION SELECT",
"goto": null,
"goto_name": null,
"blocked": "The stage list is GP_MISSION_SELECT, not in this export. Destination MEASURED."
},
"ptbtn12": {
"label": "MOVIE THEATER",
"goto": null,
"goto_name": null,
"blocked": "NEVER OPENED. docs/game/navigation.md marks this one unknown -- not merely unexported. Do not assume it opens GP_MOVIE_THEATER; that would be a name match dressed as a destination."
},
"ptbtn13": {
"label": "BACK",
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"same_as_cancel": true,
"why": "MEASURED: EXTRAS' third item is BACK (live-extras.png). Treated as (B): it pops the stack, so focus is restored on the main menu exactly as (B) does. Whether the game distinguishes them is untested and there is no reason here to invent a difference."
}
}
"screens": {
"_": [
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",
"destination screens and the RE agent later decoded that a transition is a",
"lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,",
"LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`",
"targets when this is written, marked as the name match they are."
]
}
}
}

View File

@@ -1,172 +0,0 @@
{
"format": "sylpheed.rendering/1",
"_": [
"WHICH decoded rules the runtime applies where. AUTHORED because it is a",
"choice about the REACH of somebody else's decode, not about the disc.",
"Delete an entry the day the decode covers the case outright.",
"",
"The exporter flags `leaf_carries_geometry` on 15 elements -- those whose",
"nested `.rat` leaf declares a scale or rotation the parent does not. That",
"flag is a CENSUS FACT and it is emitted for all 15. What is DECODED is",
"narrower: the Decoder fitted the game's own composed alpha (per-draw vertex",
"colours C3FFFFFF / B6FFFFFF = 195 and 182) against the ptloop leaves and got",
"one consistent time, then PREDICTED the quad centres to ~11 px. That covers",
"`ptloop01` and `ptloop02` and nothing else."
],
"draw_leaf_for": [
"ptloop01",
"ptloop02"
],
"draw_leaf_why": [
"The two the decode covers. `docs/re/structures/ui-leaf-vs-parent-alpha.md`.",
"",
"NOT DRAWN, though the exporter flags them and ships their data:",
"",
" `title_jp/ptlogo_eff2` -- OUT OF SCOPE, which is a better reason than",
" the caution this entry first gave. MISSION section 7 scopes out",
" 'localisation beyond English', and this element exists only on the",
" Japanese title. So it is not a thing the menu port has to answer, and the",
" parked Japanese-locale capture does not need reviving on its account --",
" that is the human's call and not something either agent widens quietly.",
"",
" It is ALSO undecidable here even if it were in scope. Its 125% is a POP,",
" not a steady scale: scale-0 -> 125% -> scale-0 between t=50 and t=107,",
" about 0.95 s. The leaf draws at 100%, as two superimposed copies at alpha",
" 160 and 80, each rotating 360 degrees over 960 units -- 16 s a turn. If",
" parent scale gates the leaf it is a 0.95 s flash; if the leaf runs free it",
" spins for 16 s. Nothing on the disc chooses and title_jp has no oracle",
" capture.",
" `build_12,15/pgloading_loop5` -- STILL NOT DRAWN, but the reason given here",
" was WRONG and is replaced. It read \"leaf scale (0,0). A zero scale is one of",
" the three historical failures this corpus names\" -- which describes t=0 and",
" t=30 and nothing after them.",
"",
" What the leaf actually holds, read out of the export: ONE element,",
" `pgloading_ring`, with a sprite, whose scale ramps 0 -> 250 -> 800 -> 1000",
" while its alpha rises to full at t=55 and falls to nothing by t=130. An",
" expanding, fading ring -- a loading pulse, not a degenerate record.",
"",
" 🔴 And it is VISIBLE at the instant this port poses. `build_12`'s settle",
" window is [40, 48], so the pose lands near t=44, where the ring interpolates",
" to scale 140 at alpha 143. So withholding it is not declining to draw",
" nothing; it is declining to draw something, and the old reason hid that.",
"",
" It stays withheld on the reason below, which is the one that always applied:",
" there is no way to adjudicate it here. The loading screens have no oracle",
" capture -- the RE agent records them as not reachable from the title path --",
" and `verify-screen` compares against a renderer that draws no leaves at all.",
" Drawing it would put unadjudicable content on a screen, which is the same",
" test `ptlogo_eff2` fails.",
"",
"AND THERE IS NO WAY TO ADJUDICATE EITHER HERE. `title_jp` has no oracle",
"capture, and `verify-screen` compares against `sylpheed-cli`, which does not",
"draw leaves at all -- so ANY leaf drawing increases that divergence whether",
"it is right or wrong. Its max went 155 -> 232 when they were drawn, and that",
"number is not evidence in either direction.",
"",
"What deletes this list: a decode covering those cases, or an oracle capture",
"of title_jp."
],
"draw_leaf_kind": "decoded",
"loop_leaf_on_screens": [
"title"
],
"loop_leaf_why": [
"WHICH screens replay a leaf's group instead of letting it run once and park.",
"MEASURED on the title, UNRESOLVED on the menus, so it is scoped to the title.",
"",
"The disc gives one pass: ptloop01's leaf runs t=0..600 and ptloop02's t=0..720,",
"each ending parked off-screen at x=1521 / -839. The port ran them once.",
"",
"THE ORACLE SAYS THEY LOOP ON THE TITLE. Across two title dwells the sweep quad",
"oscillates over its whole x range and resets hard to the same start value --",
"one reset inside the first dwell, two inside the second. A run-once-and-park",
"shows one traverse and then a constant x.",
"",
"🔴 THE LOOP-LENGTH FIELD CANNOT SETTLE THIS, and I had hoped it would.",
"`ptloop01` declares 600 with keyframes to exactly 600; `ptloop02` declares 720",
"to 720. SLACK ZERO -- and 'loops at 600' and 'runs once for 600 and stops'",
"write the identical header. 92.3% of records on the disc are in that state, so",
"the field discriminates loop length only where there IS slack, as the plate's",
"105-in-120 had.",
"",
"⚠️ THE MENUS ARE NOT COVERED, on purpose. Both declare the same 600/720, so",
"nothing on the disc distinguishes them -- but the oracle measurement is of the",
"title, and my own weak evidence points the other way for the menu: sweeping the",
"phase against live-main-menu.png, the port matches best with the sweeps",
"OFF-SCREEN (0.061%) and three times worse mid-screen (0.183%). If they looped",
"with a 600-unit period the sweep is on screen for roughly 73% of the cycle, so",
"a capture showing none is not nothing -- but it is one capture, and 'best",
"match' is a weak instrument for an absence. Two weak signals in opposite",
"directions is a reason to scope, not to pick.",
"",
"What settles the menu: a direct capture of it, which the Decoder has offered.",
"",
"🔴 RE-MEASURED 2026-08-31, BECAUSE THE EVIDENCE ABOVE WAS TAKEN WITH THE WRONG",
"BLEND. The phase sweep that produced '0.061 % off-screen, 0.183 % mid-screen'",
"drew the sweeps ALPHA-OVER. They are additive -- measured off the running game",
"the same day (`additive_elements`) -- so an on-screen sweep composited the wrong",
"way was being scored against the capture, and 'mid-screen is worse' could have",
"been an artefact of my own compositing rather than of the sweeps being absent.",
"",
"Re-run with additive sweeps and looping switched on for the menu, against",
"`live-main-menu.png`:",
"",
" phase 0 0.0208 % sweeps paint 0 px -- off screen",
" phase 150 0.0851 % sweeps paint 58 027 px, bbox 884x720",
" phase 300 0.0205 % sweeps paint 0 px -- off screen",
" phase 75 / 225 / 375 / 450 / 525: 0.086..0.122 %",
" run-once-and-park, which is what the port ships: 0.0208 %",
"",
"✅ THE CONCLUSION HELD AND GOT STRONGER. The ratio was 3x with the wrong blend",
"and is 4-6x with the right one, and the absolute numbers improved everywhere.",
"The capture still matches best with the sweeps NOT VISIBLE. So this entry stays",
"scoped to the title, and the correction is recorded rather than the scoping",
"changed.",
"",
"⚠️ It is still one capture and 'best match' is still a weak instrument for an",
"absence -- that caveat is not repaired by fixing the blend, only cleared of one",
"confound.",
"",
"📌 AND THE NEW DRAW LOG DOES NOT SETTLE IT EITHER, though it looks like it",
"should. `docs/re/captures/ui-draws/blend-main-menu-2026-08-31.log` shows both",
"sweep strips SUBMITTED on the main menu, in every frame group. That is not",
"evidence they animate there: a quad parked off-screen at x=1521 is still a draw",
"call. A DRAW IS NOT A VISIBLE ELEMENT, and reading that log as 'the sweeps run",
"on the menu' would have contradicted the pixels for no reason."
],
"loop_leaf_kind": "measured",
"additive_elements_deleted_why": [
"✅ DELETED 2026-09-01, and the deletion is the point.",
"",
"This held `additive_elements`, a per-screen list of element ids transcribed",
"from the Decoder's per-draw RB_BLENDCONTROL0 log. PORT-MISSION section 3: 'When",
"the RE agent later decodes something you had authored, delete the authored",
"entry and let the exporter emit it. That deletion is the measure of progress.'",
"",
"The blend is now DECODED -- `T8aD +0x04` bit 0x02, docs/re/structures/",
"ui-blend-mode-decoded.md -- and reachable since formats-pin-2026-09-01 exposed",
"`ui_layout::sprite_blend_additive` and `blend_additive_by_name`. The exporter",
"emits `blend_additive` per element and per nested focus/leaf element, and",
"ScreenView reads it there.",
"",
"🔴 CHECKED BEFORE THE SWAP, and the map turned out to be a SUBSET rather than",
"the answer. Over main_menu, extras, press_start and title:",
"",
" 15 the map called additive AND the disc agrees",
" 0 the map called additive and the disc does not <- no contradictions",
" 17 the disc calls additive and the map did not",
"",
"So nothing transcribed was wrong; it was incomplete, and was being read as",
"complete. The 17 include `pteff03`/`pteff03a` -- the sweep LEAVES, which are",
"what `draw_leaf_for` actually puts on screen while the map listed their parents",
"`ptloop01`/`ptloop02` -- and TWELVE on `title`, where this map was deliberately",
"empty and the port therefore drew every title effect alpha-over.",
"",
"A name-keyed map can only answer for a screen somebody drove the game to. That",
"is what made the Japanese menus an open question (BLOCKED.md H6): the port drew",
"main_menu additive and main_menu_jp alpha-over, asserting by omission that the",
"JP build blends differently. The bit is on the disc for every screen at once, so",
"that asymmetry is now answered statically and H6 needs no capture."
]
}

View File

@@ -1,94 +1,94 @@
{
"format": "sylpheed.screen_names/1",
"_": [
"Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its",
"builds, so every name here is a decision. The identifications come from",
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
"captures of the running game; the exporter stamps the name into the screen",
"file with name_source: \"authored\" so a reader can tell a recovered name from",
"an invented one.",
"",
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
"a name that moves when the enumeration rule changes is not a name. The entry",
"was always described here as the stronger locator -- now it is the only",
"stable one.",
"",
"Delete an entry here the day the RE agent decodes a name field."
],
"archives": {
"dat/GP_TITLE.pak": {
"2": {
"name": "press_start",
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"3": {
"name": "press_start_jp",
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"4": {
"name": "title",
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"5": {
"name": "main_menu",
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.) 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"6": {
"name": "extras",
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"7": {
"name": "title_jp",
"why": "HANDOFF Q2: the Japanese twin of build 4. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"8": {
"name": "main_menu_jp",
"why": "HANDOFF Q2: the Japanese twin of build 5. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"9": {
"name": "extras_jp",
"why": "HANDOFF Q2: the Japanese twin of build 6. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"10": {
"name": "publisher_logo",
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM). 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"13": {
"name": "publisher_logo_r",
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"11": {
"name": "developer_logos",
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"14": {
"name": "developer_logos_r",
"why": "The region twin of entry 11, as 13 is to 10. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
}
"format": "sylpheed.screen_names/1",
"_": [
"Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its",
"builds, so every name here is a decision. The identifications come from",
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
"captures of the running game; the exporter stamps the name into the screen",
"file with name_source: \"authored\" so a reader can tell a recovered name from",
"an invented one.",
"",
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
"a name that moves when the enumeration rule changes is not a name. The entry",
"was always described here as the stronger locator -- now it is the only",
"stable one.",
"",
"Delete an entry here the day the RE agent decodes a name field."
],
"archives": {
"dat/GP_TITLE.pak": {
"2": {
"name": "press_start",
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
},
"3": {
"name": "press_start_jp",
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need."
},
"4": {
"name": "title",
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
},
"5": {
"name": "main_menu",
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
},
"6": {
"name": "extras",
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
},
"7": {
"name": "title_jp",
"why": "HANDOFF Q2: the Japanese twin of build 4."
},
"8": {
"name": "main_menu_jp",
"why": "HANDOFF Q2: the Japanese twin of build 5."
},
"9": {
"name": "extras_jp",
"why": "HANDOFF Q2: the Japanese twin of build 6."
},
"10": {
"name": "publisher_logo",
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)."
},
"13": {
"name": "publisher_logo_r",
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs."
},
"11": {
"name": "developer_logos",
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements."
},
"14": {
"name": "developer_logos_r",
"why": "The region twin of entry 11, as 13 is to 10."
}
}
},
"unnamed": {
"dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing."
},
"also_export": {
"dat/GP_TITLE.pak": {
"10": {
"name": "publisher_logo",
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments."
},
"11": {
"name": "developer_logos",
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn."
},
"13": {
"name": "publisher_logo_r",
"why": "As entry 10, region twin."
},
"14": {
"name": "developer_logos_r",
"why": "As entry 11, region twin."
}
}
}
},
"unnamed": {
"dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing."
},
"also_export": {
"dat/GP_TITLE.pak": {
"10": {
"name": "publisher_logo",
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"11": {
"name": "developer_logos",
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"13": {
"name": "publisher_logo_r",
"why": "As entry 10, region twin. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"14": {
"name": "developer_logos_r",
"why": "As entry 11, region twin. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
}
}
}
}

View File

@@ -1,604 +1,59 @@
{
"format": "sylpheed.timing/1",
"keyframe_units_per_second": 60,
"why": [
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
"transition quad is declared black for 12 units, and a capture measured the",
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
"",
"Expressed as units-per-second rather than seconds-per-unit so the value is",
"exact rather than a repeating decimal a reader has to recognise.",
"",
"DELETE THIS FILE when a field on the disc is found that states the unit.",
"Nothing here is on the disc.",
"",
"🔴 DO NOT 'CORRECT' THIS AGAINST AN EMULATOR FRAME RATE. A draw-stream",
"measurement on 2026-08-29 found the presented units-per-frame rising 33 % over",
"a single boot (1.765 early, 2.357 late) and three independent readings of one",
"container's rate disagreeing with each other. That is the EMULATOR's",
"presentation pacing drifting, and no single units-per-frame figure describes a",
"run there.",
"",
"60 is a different quantity: the GAME's logical unit rate, measured off the",
"running game as HANDOFF Q1 (a declared t=30 landing on the linear value at",
"every one of seven sampled frames). The port renders at its own frame rate and",
"converts through this constant, so guest pacing cannot reach it. The two",
"numbers are not comparable and one is not evidence about the other.",
"",
"🔴 2026-09-01 — THE FIRST LEG ABOVE IS RETIRED. THE VALUE IS NOT.",
"",
"'2 units per rendered frame ... a 30 Hz game, giving 60 units per second' is a",
"FRAME-COUNT derivation, and the Decoder retired that mechanism the same day",
"(docs/re/units-per-second-measured.md): the same animation takes 21 frame",
"labels in one capture and 33 in another, and one splash logo steps +136,+34 in",
"one run and +17,+51,+34,+34,+17,+17 in the other. A fixed per-frame increment",
"cannot do that. The clock is TIME-INTEGRATED, not frame-counted, so `units =",
"2 x frames` computes an emulator artefact. The 2 was that run's frame pacing.",
"",
"✅ The port's RUNTIME was already right: `boot.gd` advances",
"`time_units += delta * units_per_second`, off delta time. Nothing in this port",
"derives a unit from a frame count. Audited 2026-09-01, and it is why the",
"retirement cost a justification and not a behaviour.",
"",
"✅ AND THE SECOND LEG NEVER TOUCHED A FRAME COUNT, which is why 60 survives:",
"the transition quad is declared black for 12 units and the capture bracketed",
"the pure-black plateau at 0.14-0.30 s (title-plate-delay-measured.md, at a",
"0.125 s sampling resolution). 12 units in 0.14-0.30 s is 40-86 units/s. That",
"is a declared unit count against a wall-clock duration, with no frames in the",
"chain -- and it EXCLUDES 120 units/s, which would need 0.10 s.",
"",
"✅ MEASURED DIRECTLY 2026-09-01: 56.8 units per guest second, control passing",
"at 1.15 %, from two elements agreeing at one clock (`ptbtn00` 657.9 alpha/s,",
"`ptcopyright` 650.4 alpha/s, which puts ptcopyright's segment at T = 22.25 --",
"a rate agreement AND a round declared length). 30 and 120 are both excluded.",
"",
"60 IS KEPT. 56.8 is 5.6 % away against a ~5 % quantisation resolution, so it",
"does not refute 60, and the Decoder explicitly did not ask for a change. The",
"reach is the TITLE: the splashes are a different GamePart and nothing yet shows",
"they tick at the same rate.",
"",
"⚠️ If anyone re-fits this from alpha: DROP THE LAST STEP of a ramp. It clamps",
"at 255 and reports more elapsed time than it consumed -- worth 4 % on the plate.",
"",
"🔴 2026-09-01 (later) — A PER-SCREEN RATE WAS PROPOSED AND NOT ADOPTED.",
"",
"docs/re/splash-declared-vs-captured.md proposes ~57 units/s for the title and",
"~35-40 for the splashes, i.e. that one constant cannot be right and that a",
"splash at 60 runs 1.5-1.7x too fast. THE PORT DID NOT MOVE, and the reason is",
"arithmetic on a measurement already cited in this file:",
"",
" the 160-unit hold is the DEVELOPER splash's a=255 plateau, t=30..190, and it",
" is measured at 4.514 s. The 210-unit group CONTAINING it is measured at",
" 3.37/3.50/3.51 s over three cold boots (the dwell_why block below). A",
" sub-interval cannot outlast the interval containing it.",
"",
"The same three boots put the splashes at 57.7 and 60.7 units/s -- corroborating",
"60 on exactly the two screens the new figure puts at 35-39. At 35.4 the declared",
"groups would run 5.93 s and 7.20 s against corpus dwells of 3.37-3.51 and",
"4.30-4.60, i.e. each splash ~70 % longer than measured.",
"",
"⚠️ DO NOT ADOPT EITHER NUMBER UNTIL THAT IS RESOLVED, and do not split the",
"difference -- averaging two measurements that cannot both be true is not a",
"third measurement. docs/port/splash-rate-contradiction.md, asked as BLOCKED H7.",
"",
"⚠️ AND THE STRUCTURAL CLAIM MAY STILL BE RIGHT. 'One rate cannot cover every",
"screen' is a claim about the format, and the title's 56.8 does sit ~5 % off the",
"splashes' 58-61. If a per-screen rate is real this file should carry a MECHANISM",
"-- a field or a GamePart constant -- not two authored numbers. The Decoder has",
"'where the per-GamePart rate comes from' as its next item.",
"",
"🔴 2026-09-01 (later still) — RECLASSIFIED measured -> authored. THE VALUE DOES",
"NOT MOVE; THE LABEL WAS FALSE.",
"",
"The Decoder withdrew their guest-frame-rate finding the same day they published",
"it. ⚠️ THAT DOCUMENT IS NOT IN THIS CHECKOUT -- it is `guest-frame-rate-WITHDRAWN.md`",
"on their branch, named here in prose deliberately rather than in `source`:",
"`audit-kinds` flagged the first version of this entry DANGLING because I cited",
"a file I cannot read, which is exactly the check doing its job. The reading",
"below is from their message and is labelled as such.",
"It read the guest's presentation as 30 fps from a movie-frame ruler and",
"concluded 2 x 30 = 60. This file carried `kind: measured` on that strength.",
"`kind: measured` on the strength of it. It cannot any more.",
"",
"Three routes now disagree and at most one can be right:",
"",
" withdrawn movie cadence 60 units/s",
" vblank cadence (Xenia, 60 Hz) ~120",
" title-plate-delay, 120 units ~56 -- two runs agreeing to 6 ms",
"",
"⚠️ 60 IS KEPT ANYWAY, and it is not a coin toss between the three. The one leg",
"of this file's own reasoning that never touched a frame count still stands and",
"still brackets it: the transition quad is declared black for 12 units and the",
"capture measured the plateau at 0.14-0.30 s, i.e. 40-86 units/s. 60 sits inside",
"that; 120 does not. And ~56 is 7 % from 60, inside the same bracket.",
"",
"So the honest statement is: 60 is AUTHORED, bracketed by one surviving",
"frame-free measurement, and consistent with the nearest of the three live",
"routes. It is no longer 'measured', and anything that cited it as measured is",
"citing a withdrawal.",
"",
"📌 THE METHOD NOTE IS WORTH MORE THAN THE NUMBER, and it is the Decoder's: their",
"pre-registration named three ways the ruler could lie and guarded two. The third",
"occurred, and a PERFECT 1.0000 is exactly what it produces -- a triple buffer",
"rotating once per present gives run-length 1 at any frame rate. Both guards",
"tested how the buffer was READ, neither tested whether a change meant a decode.",
"",
" A clean result on an instrument whose key assumption is unguarded is not",
" confirmation. The cleanness may be the failure mode's own signature.",
"",
"Same family as this port's non-inverting latch check, which passed for the wrong",
"reason until its control failed.",
"",
"🔴 2026-09-01 — THE 12-UNIT BRACKET ABOVE IS WITHDRAWN. IT EXCLUDES NOTHING.",
"",
"I kept 60 on the ground that '12 declared units measured at 0.14-0.30 s gives",
"40-86 units/s, so 120 is excluded'. The Decoder refuted it and the refutation",
"holds on arithmetic I checked myself:",
"",
" the source doc says of that number, in its own words, 'at a sampling",
" resolution (0.125 s) that cannot do better'. 120 units/s predicts 12 units in",
" 0.100 s -- BELOW one sample interval. A 0.125 s sampler cannot resolve it and",
" reports about one sample, ~0.125-0.14 s. The 0.14 s low end is the",
" INSTRUMENT'S FLOOR, and 12/0.14 = 85.7 is an upper bound produced by dividing",
" by a floored duration. It is the value 120 predicts once the sampler is",
" accounted for.",
"",
"🔴 AND THE DEEPER ERROR IS MINE, NOT THE ARITHMETIC. I argued the leg survived",
"because it 'never touched a frame count'. True, and INSUFFICIENT: every",
"wall-clock duration off Canary is true/speed_factor, so apparent units/s =",
"true x speed -- and the speed factor is precisely what makes the three routes",
"disagree. I checked the leg for the WRONG CONTAMINANT. Frame-free is not",
"clock-free, and on this emulator clock-free is the property that matters.",
"",
"What actually survives from that leg, and it is the half I did not lead with:",
"the declared 12 units are independently confirmed as SIX FRAMES by",
"screen-transitions.md's 255/6-per-frame ramp. No wall clock in it at all. That",
"is evidence about units per FRAME -- which was never in dispute -- and silent",
"about units per second.",
"",
"SO 60 HAS NO SURVIVING BRACKET. It stays because nothing supports 120 either and",
"moving a shipped timeline on no evidence is worse than leaving it. That is a",
"default, not a derivation, and this entry now says so. `kind` is already",
"`authored`, which is the honest label for a default.",
"",
"🟡 2026-09-01 — 120 units/s IS NOW MEASURED, AND THIS PORT HAS NOT MOVED.",
"",
"The Decoder's content-hash experiment gives 120 (2 units/present x 60",
"presents/s), with the controls the withdrawn version lacked -- a static texture",
"hashing constant, 1 change in 403 samples, and movie luma not constant, 102",
"distinct hashes. Pre-registered bands, and the observed 0.5739 falls inside",
"them. It is a better experiment than either of the two it replaces.",
"",
"IT IS ALSO THEIR THIRD POSITION ON THIS NUMBER IN ONE DAY, reach is one boot,",
"and they said themselves that a second independent boot before a timeline is",
"rewritten is the defensible call. Agreed. 60 stays for now.",
"",
"⚠️ 60 IS NOT DEFENDED EITHER -- its bracket was withdrawn this morning. Both",
"numbers are undefended; the port keeps the one it ships because switching on a",
"single capture is a worse failure than holding on none. That is the whole",
"reasoning and it is not evidence about the game.",
"",
"✅ AUDITED, SO THE SWITCH IS CHEAP WHEN IT COMES: no seconds are baked into the",
"timeline anywhere. Every second this port prints or acts on is computed as",
"units / keyframe_units_per_second at the point of use. audio.json's loop_start_s",
"and loop_end_s ARE seconds and correctly do NOT follow this constant -- they are",
"positions in an audio file with no keyframe unit in them.",
"",
"🔴 One exception found and fixed: tools/port/verify-dwell read black_hold_units",
"from this file 'so it cannot drift again' and then divided by a literal 60.0.",
"The value could not drift; the conversion could.",
"",
"📌 THE FALSIFIER IS PRE-REGISTERED in docs/port/units-per-second-switch-readiness.md:",
"at 120 the publisher splash runs 2.13 s and the developer 1.75 s, against three",
"cold boots measuring 4.30/4.60/4.37 and 3.51/3.50/3.37. 120 and the dwell corpus",
"cannot both be right in wall-clock seconds -- the same collision that killed the",
"35 units/s proposal from the other direction.",
"",
"✅ 2026-09-01 (final position of the day) — 120 IS WITHDRAWN BY ITS AUTHOR AND 60",
"IS POSITIVELY SUPPORTED. The port never moved, so nothing has to be undone.",
"",
"The mechanism is worth more than the number: `units per present` HALVED when the",
"present rate doubled (Δα +34 at 27.2 presents/s, +17 at 51.4) while units per",
"second did not move (54.4 vs 51.4). The UI clock advances by elapsed TIME, not",
"by frame count -- so '2 units per frame' was never a property of the game, only",
"of a capture that happened to run at 27 fps. The 120 was 2 units/present x 60",
"presents/s, and the first factor is not a constant, so the product was not a",
"rate.",
"",
"Their write-up is `units-per-frame-is-not-a-constant.md`, under docs/re/ on",
"their branch. 🔴 NOT IN THIS CHECKOUT, so it is named WITHOUT a resolvable",
"path -- `tools/port/check-citations` flagged the first version of this very",
"paragraph as DANGLING, in the entry where I was recording the lesson about",
"dangling citations. The check does not care about a disclaimer, which is",
"correct: a path that does not resolve does not resolve.",
"",
"✅ 2026-09-01 (settled) — THE GAME'S CLOCK IS FRAME-BASED, 1 UNIT PER PRESENT.",
"Measured by the Decoder with a DESIGNED experiment rather than an inference:",
"`--framerate_limit=30` halved units/second to 30.2, doubled the publisher dwell",
"to 8.450 s, and left the modal alpha step at 17 where a time-based clock",
"predicts 34. Both controls passed first -- the limiter demonstrably took effect,",
"and all 8 splash quad rects were identical, so nothing but the frame rate",
"differed. `255 x 1 / 15 = 17` at 28.4, 51.4 and 54.8 presents/s alike.",
"",
"⚠️ THIS CHANGES WHAT 60 MEANS HERE, AND MAKES IT MORE FALSIFIABLE. If the game",
"advances 1 unit per present, its units/second IS its present rate. So",
"`keyframe_units_per_second = 60` is now equivalent to the claim:",
"",
" the game presented these screens at 60 Hz on the console.",
"",
"That is a sharper statement than 'the unit is 1/60 s' and it is checkable.",
"",
"✅ AND IT IS SUPPORTED, which the constant has not been until now. Canary",
"unlimited presents at 51-55 Hz and the splash dwell is 4.30/4.60/4.37 s over",
"three cold boots. A natively 30 Hz game would present at ~30 in Canary too --",
"which the framerate_limit run confirms, since forcing 30 made the same splash",
"take 8.45 s. It does not take 8.45 s unforced. So the game asks for ~60, not 30.",
"",
"🔴 AND THAT CLOSES THE CONSTANT AS A CAUSE OF 'THE PLATE IS LATE', for a NEW",
"reason and in the direction that matters. Under the frame-based model the only",
"alternative console rate is 30 Hz, which puts the plate at 236/30 = 7.87 s --",
"LATER than the 3.93 s the port ships, not earlier. There is no console present",
"rate that makes the plate arrive sooner than it already does here.",
"",
"⚠️ KEPT AS `authored`, NOT PROMOTED TO `measured`. The chain is inference over",
"three measurements (frame-based clock; Canary's unlimited present rate; the",
"dwell corpus) rather than a measurement of units per second. It becomes",
"`measured` the day someone reads the console's present rate for these screens",
"directly.",
"",
"📌 AND THE PORT'S OWN DESIGN IS DELIBERATELY NOT THE GAME'S, which is worth",
"stating so nobody 'fixes' it. The game is frame-based; this port is time-based",
"(`time_units += delta * units_per_second`). They agree at 60 fps, which is the",
"only rate the console ever asked the game to be right at. A time-based port",
"reproduces a 60 Hz console on hardware that is not 60 Hz; a frame-based port",
"would drift on every machine that is not -- and this port has measured itself at",
"9.7 to 69.4 fps depending on the renderer. DO NOT make the port frame-based to",
"match the game.",
"",
"✅ 2026-09-02 — 60 NOW STANDS ON A THIRD INDEPENDENT ROUTE, and the REASON",
"changed again while the value did not.",
"",
"The Decoder reconciled three of their own pages that held incompatible",
"positions -- 2 units per guest frame, time-integrated at 56.8, and 1 unit per",
"present -- with one mechanism: THE CLOCK ADVANCES ONE UNIT PER VBLANK, and",
"presents may be dropped without the clock caring. That explains steps that are",
"always multiples of 17 (1, 2 or 3 vblanks between two logged presents), and the",
"same animation spanning 21 labels in one capture and 33 in another, which a",
"strict per-present clock cannot produce.",
"",
"Their rate result is a MANIPULATION rather than an observation: 255 declared",
"units take 4.263/4.162 s at a 60 Hz vblank and 8.450 s at --framerate_limit=30",
"-- 59.8/61.3 against 30.2 units/s. So the vblank rate sets the unit rate, and a",
"console vblanks at 60.",
"",
"So the justification for 60 has now been: '2 units per rendered frame' (retired),",
"'the game presents at 60 Hz' (superseded), and now 'one unit per 60 Hz vblank'.",
"THE NUMBER HAS NEVER MOVED. That is worth noticing rather than celebrating -- a",
"value whose reason changes three times while it survives is either robust or",
"under-constrained, and the honest label is still `authored`.",
"",
"📌 THIS PORT INSTANTIATES THEIR NULL MODEL, which is the one thing this side can",
"contribute to that argument. Their reasoning turns on 'a time-integrated clock",
"predicts 4.25 s in BOTH conditions'. This port IS a working time-integrated",
"clock at 60 units/s, and its splash dwell across a 4.0x change in its own",
"rendering rate is 4.28 / 4.26 / 4.27 / 4.26 s -- flat to 0.5 %. So their",
"counterfactual is demonstrated rather than assumed. ⚠️ It is evidence about the",
"NULL, not about the game; it says what a time-integrated clock does, not what",
"the game's clock is.",
"",
"🟡 PER-VBLANK VS PER-PRESENT IS STILL OPEN, and they name the discriminating",
"experiment (log Xenia's vblank counter beside each present). ⚠️ IT IS",
"IMMATERIAL TO THIS PORT AND THEY SHOULD NOT RUN IT ON THE PORT'S ACCOUNT. The",
"two models differ only when the console DROPS a present: per-vblank keeps",
"real-time pace through a drop, per-present slows. This port is time-based, so",
"it matches per-vblank exactly and would run marginally ahead of per-present",
"during drops only. On a console presenting every vblank the two coincide, and",
"the screens in question are a handful of quads.",
"",
"🔴 2026-09-02 (later) — 'A THIRD INDEPENDENT ROUTE' IS WITHDRAWN BY ITS AUTHOR.",
"The paragraph above says 60 now stands three ways. It does not, and I recorded",
"the claim before challenging it hard enough.",
"",
"I raised that three routes to one number are weaker than they look if they share",
"an upstream assumption -- vblank rate, present rate and declared dwell are not",
"obviously independent. The Decoder audited it and agreed: route B needs 'the",
"guest presents 60x/s', which comes from the vblank histogram UNDER XENIA'S 60 Hz",
"LIMITER; route C needs 'the vblank is 60 Hz', which is that limiter's cvar; route",
"D is a wall-clock duration that lands on 60 only BECAUSE the vblank is 60 Hz.",
"All three reduce to one upstream fact: the display refreshes 60 times a second",
"on that emulator. One witness in three coats.",
"",
"✅ WHAT SURVIVES IS CONDITIONAL AND BETTER, and it is established by MANIPULATION",
"rather than agreement -- forcing 30 Hz gave 30.2 units/s, 60 Hz gives 59.8/61.3:",
"",
" units per second = THE DISPLAY REFRESH RATE.",
"",
"It becomes '60' only through a fact this corpus has never measured: an Xbox 360",
"outputs 60 Hz. That is a hardware specification. It is solid, and it belongs",
"CITED as a spec rather than folded in as a third measurement.",
"",
"📌 And the conditional form is the one that justifies this port's construction",
"rather than excusing it. 'units/s = refresh rate' says what to do on hardware",
"that is NOT 60 Hz, which is exactly why a time-based clock at a fixed 60 units/s",
"is right and a frame-based one would drift. `kind` stays `authored`: nothing",
"here promotes it, and the reason it is not `measured` is now sharper -- the",
"measurement is of a RELATIONSHIP, and the constant that closes it comes from a",
"datasheet."
],
"kind": "authored",
"source": "docs/re/ui-keyframe-time-unit.md, docs/port/HANDOFF.md",
"ramp": "linear",
"ramp_why": [
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
"linear value at every one of the seven sampled frames, so there is no ease."
],
"ramp_kind": "measured",
"dwell_seconds": null,
"dwell_why": [
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
"",
"This key has now been wrong in two opposite directions, and the second was",
"mine, so both are recorded.",
"",
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
"🔴 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
"exit is caused by something outside its timeline, so it holds. A splash's exit",
"is caused by nothing, so it plays its declared timeline and leaves. The title",
"is the exception, not the rule, and one screen was never enough to overturn",
"the other two.",
"",
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
"",
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
"",
"The developer agrees to 1.1 %, two of its three runs to 0.3 %.",
"",
"🔴 CORRECTED 2026-09-01. This said: 'The port emits 4.400 s and 3.650 s -- each",
"declared value plus the 9-unit black hold, exactly. So the pacing was right all",
"along and nothing changes in the code.' THE PORT DOES NOT DO THAT, and this",
"file is what stops it: `black_hold_units` is 0, set deliberately (see",
"black_hold_why -- a uniform value is positively excluded and only an",
"ordered-pair key survives). There is no 9-unit hold to add, so the sentence",
"described a behaviour asserted three keys above it and refused one key below.",
"",
"MEASURED off the shipping boot, three runs, 2026-09-01:",
"",
" publisher declared 255 units = 4.250 s 4.28 / 4.26 / 4.27 mean 4.270 s",
" developer declared 210 units = 3.500 s 3.50 / 3.57 / 3.51 mean 3.527 s",
"",
"Residuals +1.2 and +1.6 units -- frame granularity on the exit check, not a",
"hold. The claimed 4.400 and 3.650 are each ~0.13 s longer than what has been",
"shipping since P3. Against the corpus (4.42 and 3.46 means) neither the claimed",
"nor the measured figure dominates: the port is 3.4 % short on the publisher and",
"2.0 % long on the developer, the claim would be 0.5 % short and 5.5 % long. So",
"this corrects a false statement about our own behaviour; it does not settle",
"whether a hold belongs there. That is still black_hold_why's ordered-pair ask.",
"",
"🔴 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
"Decoder's own container came out 15-20 % LONGER than both the declared values",
"and the corpus -- same disc, same timeline -- and three independent readings",
"of that container's rate disagree with each other. A seconds figure records",
"one emulator's pacing on one run. The units are on the disc. If anything ever",
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
"measured to wait beyond its group.",
"",
"🔴 2026-09-01 (later) — 'So the pacing was right all along and nothing changes in",
"the code' IS CONDITIONAL, AND MAY BE A COINCIDENCE OF TWO CANCELLING ERRORS.",
"",
"That sentence rests on the port's total screen time matching the corpus dwells.",
"It does: 4.270 s against 4.30/4.60/4.37 and 3.527 s against 3.51/3.50/3.37.",
"",
"But a TOTAL cannot see two errors of opposite sign inside it. Measured:",
"",
" the port's screen time IS its animation time. publisher 4.270 s against a",
" 4.250 s animation -- a hold of +0.020 s, i.e. none. The port does not hold",
" after a splash timeline at all.",
"",
" the GAME does: the Decoder counts the publisher on screen for 219 presents and",
" animating for ~128 of them, about 42 % hold.",
"",
"So IF keyframe_units_per_second is 120 rather than 60, this port animates every",
"splash 2x too slow AND omits the hold entirely, and the two sum to almost exactly",
"the right total. The agreement above would then be evidence of nothing.",
"",
"⚠️ THE HOLD AND THE CONSTANT ARE COUPLED. At 60 the port must NOT gain a hold --",
"the animation already fills the screen time and a hold would overshoot by ~40 %.",
"The missing hold is a defect only if 120 is right. They stand or fall together,",
"which is another reason not to move on one capture.",
"",
"📌 And when it does move it is TWO changes, not one: the constant, and a hold",
"measured as (screen presents - animation presents). It must NOT be inferred from",
"the total, because the total is precisely the quantity that cannot distinguish",
"the two errors. docs/port/units-per-second-switch-readiness.md.",
"",
"✅ 2026-09-01 (later still) — THE PARAGRAPH ABOVE IS WITHDRAWN. 'The pacing was",
"right all along' WAS right all along.",
"",
"I claimed the dwell agreement might be a coincidence of two cancelling errors --",
"a 2x-slow animation plus a missing hold. There is no missing hold. I misread a",
"presents split from the Decoder's instrument ('219 on screen, ~128 animating')",
"as a hold OUTSIDE the declared timeline. It is a split WITHIN it: the publisher",
"ramps 0-30, HOLDS 30-235 (205 units, 80.4 % of the screen) and fades 235-255,",
"and this port plays all three.",
"",
"Measured rather than read -- frozen samples of the logo region across the",
"publisher splash: 0.405488 at t=60, 120, 180 and 228 units, identical to six",
"decimals across 168 units, with 0.391 at t=15 (mid-ramp) and 0.038 at t=252",
"(in the exit fade). The hold is there and it is played.",
"",
"⚠️ The failure was not a mis-measurement. I took a two-part split from someone",
"else's instrument and assumed its boundary sat where my own model put it.",
"Presents are not units, and 'animating vs holding' in presents does not",
"decompose the same way as 'ramp vs hold' in declared units.",
"",
"AND THE DWELL FIGURES HERE ARE NOW POSITIVE EVIDENCE, not merely survivors. A",
"time-based clock is immune to dropped frames, so a dwell measured in seconds is",
"stable across runs at different frame rates. This port's own splash dwell across",
"a 4.0x change in its rendering rate: 4.28 s at 17.3 fps, 4.26 at 19.6, 4.27 at",
"25.0, 4.26 at 69.4 -- a 0.5 % spread, putting 255 units at 59.6-59.9 units/s",
"every time. That establishes these dwells are frame-rate-independent",
"MEASUREMENTS rather than artefacts of whatever rate a run hit, which is the",
"property the Decoder's argument needs of them.",
"",
"✅ 2026-09-02 — THE +1.2 / +1.6 UNIT RESIDUAL WAS FRAME GRANULARITY, and that is",
"now measured rather than inferred.",
"",
"The dwells were recorded as 4.270 s and 3.527 s against declared 4.250 and 3.500",
"-- residuals of +1.2 and +1.6 units -- and I attributed them to the granularity",
"of the exit check without testing it. A hardware GPU makes that testable: same",
"boot, same declared groups, three runs at 65-66 fps instead of 17-25.",
"",
"Pre-registered: if the residual is frame granularity it should shrink roughly",
"with the frame rate, so <= 0.5 units at 65 fps. Measured:",
"",
" publisher 4.27 / 4.26 / 4.27 mean 4.253 s residual +0.20 units",
" developer 3.50 / 3.52 / 3.50 mean 3.500 s residual +0.00 units",
"",
"From +1.2 and +1.6 down to +0.20 and +0.00. The prediction held and the",
"attribution is no longer an assumption. ⚠️ It also means the figures quoted",
"elsewhere in this corpus as 4.270 / 3.527 carry a rendering-rate term; the",
"declared values are what the port actually targets and 4.250 / 3.500 is what it",
"hits when the renderer keeps up."
],
"dwell_kind": "measured",
"looping_focus_records": {
"_": [
"WHICH focus records the port draws, unconditionally and on a loop, OVER the",
"element's own sprite rather than instead of it.",
"",
"RESTORED 2026-08-30 on a MEASUREMENT, having been deleted on 2026-08-29 for",
"a real defect that was in the RENDERER, not in this table. The old entry made",
"`_draw` substitute the glow for the plate's own bright sprite, so the plate",
"was invisible at every instant (max 0 against max 252.5). `ScreenView` now",
"draws the base and the record over it, and the entry comes back."
"format": "sylpheed.timing/1",
"keyframe_units_per_second": 60,
"why": [
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
"transition quad is declared black for 12 units, and a capture measured the",
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
"",
"Expressed as units-per-second rather than seconds-per-unit so the value is",
"exact rather than a repeating decimal a reader has to recognise.",
"",
"DELETE THIS FILE when a field on the disc is found that states the unit.",
"Nothing here is on the disc."
],
"press_start/ptbtn00": {
"record_element": "ptbtn00f",
"period_units": 120,
"kind": "measured",
"source": "docs/re/structures/plate-pulse-measured.md, RE agent 2026-08-30",
"why": [
"MEASURED off the running game, held at the title with NO INPUT: the plate",
"oscillates continuously -- two windows in one boot of 58 s and 57 s, about",
"23 cycles each, with no decay and no settling.",
"kind": "measured",
"source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md",
"ramp": "linear",
"ramp_why": [
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
"linear value at every one of the seven sampled frames, so there is no ease."
],
"exit_ramp_seconds": 0.4,
"exit_ramp_why": [
"HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.",
"",
"🔴 IT NEVER GOES OFF. The plate-absent floor is 159 thresholded green",
"pixels -- the title art's own, measured on live-title-build4-no-plate.png --",
"and the pulse bottoms at 714, four and a half times that. So `ptbtn00`",
"going transparent at t=244 is not the end of the plate; that is its EXIT",
"ramp, which plays when the screen leaves. While the screen is held the base",
"sits at its own hold (alpha 255 at t=238) and `ptbtn00f`'s cycle runs over",
"it. Base-only and base-plus-glow are what the 714 and the 1520 are.",
"Every element of a screen ends on exactly ONE untimed keyframe, so there is",
"exactly one unknown duration per screen -- the ramp INTO that final keyframe.",
"This is that duration. ~0.4 s, which is 24 units at 60 units/s.",
"",
"⚠️ 120 UNITS, NOT SECONDS, and that is the RE agent's own instruction. Their",
"run measured 2.530 and 2.540 s; an earlier corpus run measured 2.24 s. Same",
"declared number, different emulator pacing -- x1.27 and x1.12 against a",
"nominal 2.000 s, which IS 120 units at 60 units/s. Hardcoding 2.5 s would",
"author one loaded container's clock."
],
"limits": [
"ONE BOOT. Two windows inside it are not two boots.",
"It does NOT distinguish the boot title from an attract-loop title: run 1",
"opens at t~255 s against Q9's ~193 s no-input baseline, so it may already",
"be the attract title. Both are 'the title, held, no input' -- which is what",
"was asked -- but it is not proof about the first appearance.",
"🔴 714/1520 IS NOT AN ALPHA RATIO. The counter is thresholded pixels, so dim",
"pixels drop out first. No duty cycle and no ramp shape may be read off it;",
"the port draws the record's own declared alpha ramp and infers nothing."
]
}
},
"exit_ramp_deleted_why": [
"DELETED 2026-08-29, and the deletion is the point.",
"",
"`exit_ramp_seconds` (~0.4 s) and `exit_ramp_units` (24) were AUTHORED because",
"the disc had no time slot on a group's final keyframe, so the ramp into it was",
"the one unknown duration per screen. Under the corrected record layout",
"(formats-pin-2026-08-29c onward) there IS no untimed keyframe -- a group is an",
"8-byte header then frames x {u32 time; 36-byte pose}, so every pose is timed",
"including the last. The unknown the constant stood in for does not exist.",
"",
"MISSION section 3: 'When the RE agent later decodes something you had",
"authored, delete the authored entry and let the exporter emit it. That",
"deletion is the measure of progress.'",
"",
"VERIFIED DEAD BEFORE DELETING, not assumed: setting it to 9999 (166 seconds)",
"changed the boot's transitions by 0.04 s -- wall-clock jitter, not a 166 s",
"ramp. Both of its uses in ScreenView were gated on `not last_frame.has('t')`,",
"which no longer fires on any of the export's 866 keyframes.",
"",
"The measurement it recorded is not lost: HANDOFF Q7's ~0.4 s fade-out and the",
"0.17-0.23 s black hold are still measured facts, and the hold is still used --",
"`tools/port/verify-dwell` compares a transition INTERVAL against the oracle's",
"visible SPAN plus that hold. What is deleted is the port's need to invent a",
"duration the disc now states."
],
"black_hold_units": 0,
"black_hold_why": [
"0 = NOT MODELLED. The escalation is resolved: a uniform value is positively",
"EXCLUDED, so 0 is no longer one option among several -- it is the only honest",
"uniform choice, because it is the one that does not claim a constant exists.",
"",
"UPDATE: TWO candidate models are now excluded, not one. The Decoder has five",
"replicates with NO variation -- title->menu 3,3,3 and EXTRAS->menu 2,2 -- and",
"every differing value comes from a different ORDERED PAIR. The same origin",
"gives different values to different destinations (menu 0 vs 1, EXTRAS 2 vs 3).",
"So a constant is excluded AND keying on the outgoing screen is excluded; only",
"an ordered-pair key survives, with a measured value needed per pair.",
"",
"I checked independently whether anything DECLARED predicts it, from the",
"quantities in my export. None does: outgoing close (15,10,10,10), incoming",
"clear (12,12,16,12), outgoing span (269,74,80,80) and incoming span",
"(80,80,269,74) each have two rows sharing a value with different gaps.",
"",
"I did NOT search combinations of them. Four intra-archive pairs against many",
"candidate two-screen functions fits by construction -- that is the error this",
"corpus has catalogued five times, and finding a formula here would be",
"indistinguishable from finding one in noise.",
"",
"The Decoder ordered the gaps by the screen being LEFT (frames): menu 0 and 1,",
"EXTRAS 2, title 3. Three hypotheses are positively ruled out, not merely",
"unsupported. DIRECTION: EXTRAS->menu (2) and menu->EXTRAS (1) are the same",
"pair both ways and differ. BUTTON: (B) gives 0 and 2, (A) gives 1 and 3.",
"INCOMING SCREEN: an incoming menu takes 3 from the title and 2 from EXTRAS.",
"",
"So the quantity varies 0-3 frames by outgoing screen, and any uniform non-zero",
"value is wrong as a MODEL rather than merely off in magnitude. 0 models the",
"gap as absent; 6 would model it as constant, which the data excludes.",
"",
"MY OWN RULE IS REFUTED, not just unadopted. It was gap + the incoming",
"screen's opening black-clear = a constant, holding at 16/16/18 on three",
"transitions. Their fourth gives 16, 14, 16, 18 -- and decisively, the two",
"transitions with the SAME incoming screen (main_menu) have different gaps,",
"so the incoming screen cannot determine it. A fourth point did to a",
"three-point fit exactly what it should.",
"",
"DO NOT key this per outgoing screen yet. Three outgoing screens with one",
"value each restates the data rather than predicting it -- the same objection",
"I raised against my own 16/16/18. Key it when a screen has more than one",
"measured value, and key it on the screen being LEFT.",
"",
"📌 CITATION ADDED 2026-09-01, and its absence propagated from the delivery.",
"This why carried over a thousand characters and NOTHING OPENABLE. The Decoder",
"sent the `(B)`-from-EXTRAS leg as an inline frame table with no file cited,",
"while docs/re/data/fade-four-transitions.txt -- which carries that leg and",
"eight others -- had been committed the whole time. They found it in their own",
"audit and cited it; it had already landed here uncited.",
"",
"⚠️ An uncited measurement propagates as an uncited value. The receiving end",
"cannot tell a summarised measurement from a recalled one, and both read as",
"prose.",
"",
"✅ AUDITED 2026-09-01 and this one needed nothing: it was already an EXCLUSION argument rather than a count. It excludes a constant, excludes keying on the outgoing screen, and excludes every declared quantity in the export as a predictor -- four of them named, each shown not to separate the pairs. That is the form the week's other claims were found to be missing."
],
"black_hold_kind": "measured"
"The alternative readings were tested and refuted. It is not a black quad laid",
"over a frozen screen: under that model a black rect scales every region by the",
"same 1-alpha, so the button-region / background-region brightness RATIO would",
"be constant through the fade. Measured on the RE agent's filmstrip it falls",
"6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen",
"itself plays out: pteff00.prm ramps to opaque black while the button labels,",
"ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.",
"",
"REACH, quoted from the RE agent rather than smoothed over: the filmstrip is",
"downsampled and the button region contains some background, so this pins the",
"DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the",
"number as approximate and the model as established."
],
"exit_ramp_units": 24,
"dwell_seconds": null,
"dwell_why": [
"NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the",
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos",
"at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would",
"be inventing a number nobody measured, so the sequencer holds for zero extra",
"time and the pacing you see is the disc's own.",
"",
"If a capture ever times the real boot, this is where that number goes."
]
}

View File

@@ -195,6 +195,36 @@ enum ScreenCommands {
/// `--build`**, which is why it is a flag and not the default.
#[arg(long)]
all: bool,
/// Pose every element at this KEYFRAME TIME instead of at its resting
/// pose (60 units = 1 second). The resting pose is each element's last
/// *hold* keyframe, picked independently of every other element, so it is
/// not the screen at any one moment: it omits anything still moving (the
/// title's light sweeps hold off the right edge) and freezes a transient
/// at its PEAK (the title's five two-frame flashes burn forever).
/// ⚠️ This help used to end "Prefer `--settle`". That is WITHDRAWN and was
/// never measured: scored against a live capture of the JP title, settle
/// gives RMSE 40.210 and rest 41.690 — a margin of 1.48 against that
/// instrument's own noise floor of 1.2, which is NOT decisive. `--settle`
/// also has its own failure mode (25.5 % of elements are mid-ramp at their
/// screen's settle instant). Neither is established as better; pick by what
/// you are measuring. See `docs/re/structures/ui-resting-pose.md`.
#[arg(long, conflicts_with = "settle")]
at: Option<u32>,
/// Pose every element at the instant the screen is SETTLED, derived from
/// the disc: the midpoint of the longest interval containing no keyframe
/// of any element. Prints the window it used, whose width is how much the
/// midpoint is worth — a narrow one means the bundle never settles.
/// ⚠️ That is **38 % of the screen builds this command renders** (185 of
/// 491 carrying two or more keyframe times) and 39 % of the wider set
/// `--all` admits (862 of 2 211), mostly `loop*` fragments. This help used
/// to say "42 % of them" without saying of WHAT: 42 % was 731/1 758 over
/// composable bundles, computed before the keyframe record-layout fix,
/// which times a group's final pose and so admits ~450 bundles that
/// previously had only one timed keyframe. ⚠️ NOT established as better
/// than the resting pose — see the note on `--at`. See
/// `docs/re/structures/ui-settle-time.md`.
#[arg(long)]
settle: bool,
},
}
@@ -353,8 +383,10 @@ async fn main() -> Result<()> {
black,
all,
primitives,
at,
settle,
} => cmd_screen_render(
&pak, &output, build, focus, animated, black, all, primitives,
&pak, &output, build, focus, animated, black, all, primitives, at, settle,
),
},
Commands::Save { cmd } => match cmd {
@@ -581,12 +613,40 @@ fn cmd_screen_render(
black: bool,
all: bool,
primitives: bool,
at: Option<u32>,
settle: bool,
) -> Result<()> {
use sylpheed_formats::ui_layout::{self, ComposeOptions};
let builds = screen_builds(pak, all)?;
let idx = pick_build(&builds, want)?;
let bytes = &builds[idx].1;
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
let at = if settle {
match (b.settle_window(), b.settle_time()) {
(Some((lo, hi)), Some(t)) => {
// Report the width, not just the answer. A 4-unit window and a
// 190-unit one give the same kind of number and mean entirely
// different things.
println!(
"settle window [{lo}, {hi}] = {} units ({:.2} s) -> posing at t={t}{}",
hi - lo,
(hi - lo) as f64 / 60.0,
if hi - lo < 30 {
" ⚠️ narrow — this bundle may never settle"
} else {
""
}
);
Some(t)
}
_ => {
println!("no settle window (fewer than two distinct keyframe times) — using rest()");
None
}
}
} else {
at
};
let screen = ui_layout::compose(
&b,
bytes,
@@ -599,6 +659,7 @@ fn cmd_screen_render(
ComposeOptions::default().backdrop
},
include_primitives: primitives,
at,
},
None,
);
@@ -621,14 +682,35 @@ fn cmd_screen_render(
if !screen.missing.is_empty() {
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
}
let undrawn: Vec<&str> = b
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4`
// ghost instance carries its template's name, so a bare name list shows
// `ptlogo1.t32` twice and reads as "the logo is missing" when what is
// skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That
// misreading cost this project a wrong finding sent to another agent.
let undrawn: Vec<String> = b
.elements
.iter()
.filter(|e| !screen.drawn.contains(&e.index))
.map(|e| e.name.as_str())
.map(|e| {
let why = if e.name.ends_with(".prm") {
"untextured primitive, needs --primitives"
} else if e.name.ends_with(".rat") {
"animation, needs --animated"
} else if e.kind == 0x4 {
"kind 0x4 ghost instance"
} else if e.rest().map(|k| k.fade >> 24) == Some(0) {
"transparent at its pose"
} else {
"no reason established"
};
format!("[{}] {} ({why})", e.index, e.name)
})
.collect();
if !undrawn.is_empty() {
println!(" not drawn ({}): {undrawn:?}", undrawn.len());
println!(" not drawn ({}):", undrawn.len());
for u in &undrawn {
println!(" {u}");
}
}
Ok(())
}
@@ -744,8 +826,16 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
if let Some(b) = info.avg_bytes_per_sec {
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
}
if let Some(d) = info.duration_secs {
println!(" Duration : {d:.2} s");
let how = if info.codec == sylpheed_formats::AudioCodec::Xma {
" (from the declared byte rate, not decoded)"
} else {
""
};
println!(" Duration : {d:.2} s{how}");
}
if let Some(p) = info.xma_packets {
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());

View File

@@ -55,33 +55,7 @@ license.workspace = true
# a squash-merge can orphan, and no way for the exporter to be built against a
# decoder it was never tested with. A decoder change and the exporter change it
# requires now land in the same commit or not at all.
# PINNED BY TAG, which is what MISSION section 2 prescribes and what the tagging
# rule exists for: "the RE agent tags when it lands something you need and tells
# you over the message channel -- that is how you stay current without floating."
# That is exactly what happened here.
#
# The tag carries the CORRECTED keyframe association: a placement group is an
# 8-byte header then `frames` x {u32 time; 36-byte pose}, so pose 0's time is the
# group's lead-in word and EVERY POSE IS TIMED, including the last. The working
# tree's copy still has the retired `SYLPHEED_KF_TIME_SHIFT` knob -- a superseded
# partial fix that got the association right but left pose 0 untimed, which is
# why testing it moved the untimed frame from last to first instead of removing
# it. The old reading is behind `SYLPHEED_KF_TIME_LEGACY=1` here.
#
# 🔴 THE COST, STATED: `sylpheed-cli` builds from the WORKSPACE crate, so until
# this lands on `main` the exporter and the reference renderer read DIFFERENT
# decoders and `tools/port/verify-screen` is comparing two eras rather than
# detecting drift. `tools/port/verify-capture` is unaffected -- it compares the
# port against oracle CAPTURES and never touches the CLI -- and it is the check
# that matters. Revert to the path dependency the day the tag is an ancestor of
# `main`.
# Bumped c -> d 2026-08-29. What I wanted from the new state: `d` carries parser
# and `audio.rs` changes on top of `c`. ⚠️ Its headline change -- Reborn's
# renderer drawing `rotation_deg`, and `compose` drawing a leaf that carries
# geometry -- does NOT reach this port from here: `sylpheed-cli` builds from the
# WORKSPACE crate, so the reference renderer stays unrotated until the tag lands
# on `main`. This bump is for the parser, not for the renderer.
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Sylpheed.git", tag = "formats-pin-2026-09-01" }
sylpheed-formats = { path = "../sylpheed-formats" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -1,45 +0,0 @@
//! Throwaway probe: what are a music bank's sub-waves, decoded and timed?
//!
//! `export_bgm` sums every sub-wave `media` returns and scales by 1/n. If one of
//! them is not music, the divisor is wrong and every real stem is attenuated for
//! nothing -- the same defect already found and fixed in `export_voice`.
use std::process::Command;
use sylpheed_formats::media;
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&disc);
for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] {
match media::sound_bank_riffs(&src, bank) {
Ok(riffs) => {
println!("{bank}: {} sub-wave(s)", riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = std::env::temp_dir().join(format!("bk_{i}.xma.wav"));
std::fs::write(&p, r).unwrap();
let w = std::env::temp_dir().join(format!("bk_{i}.wav"));
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&p).arg(&w).output();
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "info", "-i"])
.arg(&w)
.args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"])
.output().unwrap();
let t = String::from_utf8_lossy(&out.stderr).into_owned();
let get = |k: &str| t.lines().find_map(|l| l.split_once(k).map(|x| x.1.trim().to_string()))
.unwrap_or_else(|| "?".into());
let dur = Command::new("ffprobe")
.args(["-v","error","-show_entries","format=duration","-of","csv=p=0"])
.arg(&w).output().ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
println!(" sub-wave {i}: {:>9} B -> {:>10} s peak {:>10} rms {}",
r.len(), dur, get("Peak level dB:"), get("RMS level dB:"));
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(&w);
}
}
Err(e) => println!("{bank}: {e}"),
}
}
}

View File

@@ -1,47 +0,0 @@
//! Is `BGM_103` the ONLY bank with those two wave sizes?
//!
//! `authored/audio.json` says *"Static code, disc census and runtime all agree"*
//! — three legs. Reading the sentence beneath it, legs two and three are **one**
//! comparison: the disc's declared wave sizes matched byte-for-byte against what
//! the XMA probe saw at the menu. That is a disc-to-runtime match, not two
//! independent confirmations.
//!
//! It is a third leg only if the census independently EXCLUDES alternatives — if
//! some other bank carried the same two sizes, the byte match would not
//! distinguish it. So the sizes are counted across every `BGM_*` bank on the
//! disc.
//!
//! Prompted by the Decoder's point that a decorative second support is worse
//! than none: **a conclusion with two supports reads as better evidenced than
//! one with a single support, so apparent redundancy is itself the
//! misinformation.**
use sylpheed_formats::media;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&root);
const WANT: [usize; 2] = [3_876_864, 3_930_112];
let (mut found, mut matches) = (0usize, Vec::new());
for n in 0..=199u32 {
let name = format!("BGM_{n:03}.slb");
let Ok(riffs) = media::sound_bank_riffs(&src, &name) else { continue };
if riffs.is_empty() { continue }
found += 1;
let sizes: Vec<usize> = riffs.iter().map(|r| r.len()).collect();
// Compare on the DATA payload the port sums, not on the RIFF wrapper:
// a wrapper differs by header bytes and would hide a real collision.
let near = sizes.iter().any(|s| WANT.iter().any(|w| s.abs_diff(*w) < 4096));
if near {
matches.push((name.clone(), sizes.clone()));
}
}
println!(" {found} BGM_* bank(s) readable on this disc");
for (n, s) in &matches {
println!(" {n:<14} wave sizes {s:?}");
}
println!("\n {} bank(s) carry a wave within 4 KiB of {WANT:?}", matches.len());
println!(" Exactly 1 means the census EXCLUDES alternatives and is a real third");
println!(" leg. More than 1 means the byte match does not distinguish BGM_103,");
println!(" and \"three legs\" is two. Zero means this reader cannot see the");
println!(" incumbent and its answer means nothing.");
}

View File

@@ -1,87 +0,0 @@
//! Test the Decoder's UNTESTED reading of a residual they recorded as odd.
//!
//! `GP_DIALOG` holds 140 entries against a 70-record dialog table — a 2:1 ratio
//! that would make the id→entry join an ordering question. It does not hold:
//! adjacent pairing gives identical element-name sets on **2 of 65** pairs,
//! halves pairing on **0**. In `GP_TITLE` a language pair shares its element set
//! exactly, so identical sets are the signature there and almost nothing matches
//! here.
//!
//! The residual: the only two adjacent pairs that DO match are entries `0/1` and
//! `2/3` — and `2/3` is the DIFFICULTY build. Their plausible reading is that
//! dialog text is baked into language-specific sprites, so EN/JP entries differ
//! by construction. ⚠️ **They flagged it as untested and did not assert it**, and
//! it has a hole they named themselves: it would explain the 63 that differ and
//! leave the 2 that match needing their own explanation.
//!
//! This prints what the differences actually look like, so the reading is judged
//! against the names rather than accepted as plausible.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeSet;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = pak::PakArchive::open(format!("{root}/dat/GP_DIALOG.pak")).expect("GP_DIALOG.pak");
let sets: Vec<Option<BTreeSet<String>>> = ar.entries().iter().map(|e| {
let by = ar.read(e).ok()?;
if !ratc::is_ratc(&by) { return None }
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|el| el.name.clone()).collect())
}).collect();
let (mut same, mut diff, mut pairs) = (0usize, 0usize, 0usize);
let mut shown = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
let (Some(a), Some(b)) = (&sets[i], &sets[i + 1]) else { continue };
pairs += 1;
if a == b {
same += 1;
println!(" entries {i:>3}/{:<3} IDENTICAL sets, {} element(s)", i + 1, a.len());
continue;
}
diff += 1;
// The stage-dialog pairs, checked by name and by SPRITE COUNT. A
// translation of one dialog carries the same amount of text; a
// different stage does not. This is the Decoder's closing evidence for
// the 37 pairs that differ WITHOUT a button-count mismatch, re-derived
// here because it settles a bound I had recorded as unlikely to be
// tested -- and saying so is what got it tested.
if (10..=15).contains(&i) {
let sp = |x: &BTreeSet<String>| x.iter().filter(|n| n.ends_with(".t32")).count();
let stage = |x: &BTreeSet<String>| -> Vec<String> {
let mut v: Vec<String> = x.iter().filter_map(|n| n.strip_prefix("pzstg")
.and_then(|r| r.get(..2)).map(|s| s.to_string())).collect();
v.sort(); v.dedup(); v
};
println!(" entries {i:>3}/{:<3} stages {:?} vs {:?} sprites {} vs {}",
i + 1, stage(a), stage(b), sp(a), sp(b));
}
if shown < 3 {
shown += 1;
let only_a: Vec<_> = a.difference(b).cloned().collect();
let only_b: Vec<_> = b.difference(a).cloned().collect();
println!(" entries {i:>3}/{:<3} differ: {} only-in-first, {} only-in-second",
i + 1, only_a.len(), only_b.len());
println!(" first : {:?}", &only_a[..only_a.len().min(4)]);
println!(" second : {:?}", &only_b[..only_b.len().min(4)]);
}
}
// 🔴 THE DECISIVE DETAIL, not the impressionistic one. Two languages of one
// dialog cannot differ in BUTTON COUNT. If adjacent entries do, they are
// different dialogs and the whole adjacent-pairing premise is wrong -- which
// is a stronger statement than "the language reading is untested".
let btns = |s: &Option<BTreeSet<String>>| -> usize {
s.as_ref().map_or(0, |x| x.iter().filter(|n| n.contains("btn")).count())
};
let mut mismatched = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
if sets[i].is_none() || sets[i + 1].is_none() { continue }
if btns(&sets[i]) != btns(&sets[i + 1]) { mismatched += 1 }
}
println!("\n adjacent pairs whose BUTTON COUNTS differ: {mismatched}");
println!(" A language pair cannot. Every one of these is two different dialogs.");
println!("\n {pairs} adjacent pair(s): {same} identical, {diff} differing");
println!(" Their reading -- text baked into language-specific sprites -- predicts");
println!(" the differing names look SYSTEMATIC (a locale suffix, a parallel set).");
println!(" Judge it against the names above rather than against its plausibility.");
}

View File

@@ -1,67 +0,0 @@
//! Independent check of "DIFFICULTY is a dialog: GP_DIALOG entries 2/3".
//!
//! The Decoder identified `DLG_SELECT_DIFFICULTY` as `GP_DIALOG.pak` entries 2/3
//! by TWO arguments, one of them compound — corrected from "three routes", which
//! was taking credit for the exclusion scan. The image leg names no entry, and
//! the disc and oracle legs are one argument, since the capture is compared
//! against the disc's rows. One of them is button count and geometry. That half is
//! readable from the disc with this port's own reader, so it is checked here
//! rather than taken on their word — the same form as re-deriving `ptbtn11`'s
//! row order from my export when they offered it.
//!
//! ⚠️ What this CANNOT check is their binding claim, and they flagged it first:
//! entries 2/3 are identified by button count and geometry, **not** by a binding
//! from the `DLG_` name to a pak entry. Another four-button dialog with the same
//! rows would be indistinguishable by this evidence. Reproducing the geometry
//! confirms the geometry; it does not name the screen.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
// 🔴 WIDENED 2026-08-31 to every pak, to check the Decoder's rival search
// independently. They report zero four-button builds within 6 px of
// 259/329/399/469 anywhere on the disc, which turns "another dialog with
// these rows would be indistinguishable" from a standing reach into a
// bounded one. A disc-wide negative is exactly the claim worth re-running
// with a different reader, because its whole content is an absence.
const WANT: [i32; 4] = [259, 329, 399, 469];
const TOL: i32 = 6;
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut hits, mut scanned) = (0usize, 0usize);
for path in &paks {
let Ok(ar) = pak::PakArchive::open(path) else { continue };
let arch = path.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
scanned += 1;
// Any button-shaped record, not just `pcbtn`: a rival need not share the
// naming convention, and restricting by name would answer a narrower
// question than the one asked.
let mut rows: Vec<(String, i32)> = b.elements.iter()
.filter(|el| el.name.contains("btn"))
.filter_map(|el| el.rest().map(|r| (el.name.clone(), r.y)))
.collect();
if rows.is_empty() { continue }
rows.sort_by(|a, b| a.1.cmp(&b.1));
let ys: Vec<i32> = rows.iter().map(|r| r.1).collect();
let gaps: Vec<i32> = ys.windows(2).map(|w| w[1] - w[0]).collect();
if rows.len() == 4 && ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= TOL) {
hits += 1;
println!(" {arch} entry {i:>2} {} record(s): {}", rows.len(),
rows.iter().map(|r| r.0.as_str()).collect::<Vec<_>>().join(" "));
println!(" rows {ys:?} gaps {gaps:?}");
}
}
}
println!("\n {scanned} build(s) scanned across {} pak(s); {hits} match the",
paks.len());
println!(" DIFFICULTY row signature within +/-{TOL} px.");
println!(" Expected: exactly 2 -- the EN/JP pair. More means a RIVAL exists and");
println!(" the geometric identification is not unique; fewer means this reader");
println!(" cannot see the incumbents and its zero would mean nothing.");
}

View File

@@ -1,49 +0,0 @@
//! Probe: does a `.rat` leaf record carry geometry the parent element does not?
//!
//! The GPU capture says the title submits `ptloop01`/`ptloop02` scaled 600 %/800 %
//! and rotated +30.26°/45.28°, while the export writes scale 100 % and rotation
//! 0 for both. `ui_layout`'s own note says the rotated quads come from the
//! **nested `.rat` leaf records**, which is where `export_screen` already looks
//! for focus records and nowhere else.
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open");
let e = &ar.entries()[4]; // entry 4 = the English title
let bundle = ar.read(e).expect("read");
let b = ui_layout::parse_build(&bundle).expect("parse");
println!("build has {} elements, {} records", b.elements.len(), b.records.len());
let mut names: Vec<&String> = b.records.keys().collect();
names.sort();
println!("records: {names:?}");
for el in &b.elements {
if !el.name.starts_with("ptloop") { continue; }
let r = el.rest();
println!("\nPARENT {} sprite={:?} -> rest scale {:?} rot {:?}", el.name, el.sprite,
r.map(|r| (r.scale_x, r.scale_y)), r.map(|r| r.rotation_deg));
if let Some(&(off, size)) = b.records.get(&el.name) {
match ui_layout::parse_build(&bundle[off..off + size]) {
Some(leaf) => {
println!(" LEAF {} parses: {} element(s)", el.name, leaf.elements.len());
for le in &leaf.elements {
let lr = le.rest();
println!(" {:<20} rest scale {:?} rot {:?} pos {:?}",
le.name,
lr.map(|r| (r.scale_x, r.scale_y)),
lr.map(|r| r.rotation_deg),
lr.map(|r| (r.x, r.y)));
for k in &le.keyframes {
println!(" t={:?} scale=({},{}) rot={} pos=({},{}) fade={:#010x} u4={} u8={}",
k.time, k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y,
k.fade, k.unknown_4, k.unknown_8);
}
}
}
None => println!(" LEAF {} does NOT parse as a build", el.name),
}
} else {
println!(" no record named {}", el.name);
}
}
}

View File

@@ -1,130 +0,0 @@
//! Run the Decoder's own falsifier for "a nested record's `+0x08` is its loop
//! length" against the bundles THIS PORT SHIPS, before shipping 120 for 105.
//!
//! HANDOFF (`27938aa`, delivered at `07e93ce`) says the plate's glow cycles over
//! **120** units while its keyframes end at 105, and instructs the port to stop
//! shipping 105. The port's `ScreenView` derives a looping record's period from
//! the element's largest keyframe time, so it does ship 105 — and the field that
//! would fix it is decoded in an *example* and a *test* on the Decoder's branch
//! and **exposed in `sylpheed_formats`' public API on no ref at all**.
//!
//! ✅ **Since then the crate exposes it** — `ui_layout::loop_length_units`, taken
//! at `formats-pin-2026-08-30b` — and `screen.rs` has deleted its local copy.
//!
//! 🔴 **This file deliberately did NOT follow it.** The read below is still the
//! raw four bytes, because the moment a control calls the API it is meant to
//! check, it stops being a control and becomes the API tested against itself. It
//! is the independent reading that makes the falsifier mean anything.
//!
//! So this re-runs both of their controls:
//!
//! * **the falsifier** — `+0x08 < max keyframe time` must never occur; an
//! animation cannot restart before its own last pose;
//! * **non-triviality** — if every record had `+0x08 == max t` the field would
//! carry nothing and the name would be a relabelling of the keyframes.
//!
//! and adds the one they could not run: the same two, restricted to the records
//! **this port actually animates**. A disc-wide 0.00 % violation rate says
//! nothing about my six screens if all six sit in the exceptional tail.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
/// The records the port animates: the plate glow, the five menu focus records,
/// and the title's two sweeps. Named rather than pattern-matched, because the
/// point is to check the ones that are shipped, not the ones that match a glob.
const SHIPPED: &[&str] = &[
"ptbtn00f", "ptbtn01f", "ptbtn02f", "ptbtn03f", "ptbtn04f", "ptbtn05f",
"ptloop01", "ptloop02",
];
/// Which header word to read as the loop length. `0x08` is the decoded one;
/// `--offset=N` re-runs the same falsifier at a neighbour, which is the only way
/// to learn whether the falsifier is evidence for the offset or just for the
/// disc.
static mut OFFSET: usize = 8;
fn main() {
let off: usize = std::env::args().find_map(|a| a.strip_prefix("--offset=")
.and_then(|v| v.parse().ok())).unwrap_or(8);
unsafe { OFFSET = off };
println!(" reading the loop length at header +0x{off:02x}");
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
let mut slack_hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut shipped: BTreeMap<String, (i64, i64)> = BTreeMap::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
if o + off + 4 > by.len() || o + s > by.len() { continue }
if &by[o..o + 4] != b"RATC" { continue }
// 🔴 THE FALSIFIER IS RUN AT NEIGHBOURING OFFSETS TOO. The
// Decoder's struct-layout control showed that a homogeneous
// repeated table type-checks at every field boundary, so an
// interior test carries no information about phase -- 69 of 70
// records passed under BOTH shifted alignments of their dialog
// table. My falsifier (`+0x08 >= max keyframe time`) is an
// interior test of exactly that kind, and I re-ran it as
// "confirmation" without asking whether it discriminates the
// OFFSET or merely the file.
let len = u32::from_be_bytes(by[o + off..o + off + 4].try_into().unwrap()) as i64;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0) as i64;
if maxt == 0 { continue } // static: declares no cycle at all
total += 1;
let slack = len - maxt;
*slack_hist.entry(slack).or_default() += 1;
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else { violations += 1 }
let stem = rn.trim_end_matches(".rat");
if SHIPPED.contains(&stem) {
shipped.entry(stem.to_string()).or_insert((len, maxt));
}
}
}
}
println!("disc-wide, records with timed keyframes: {total}");
println!(" +08 == max t (exact) : {exact:5} {:5.1} %", pc(exact, total));
println!(" +08 > max t (a hold) : {holds:5} {:5.1} %", pc(holds, total));
println!(" +08 < max t <- FALSIFIER : {violations:5} {:5.2} %", pc(violations, total));
println!("\nslack distribution, most common first:");
let mut h: Vec<_> = slack_hist.iter().collect();
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
for (k, n) in h.iter().take(8) { println!(" slack {k:>6} : {n}"); }
println!("\nthe records THIS PORT animates:");
println!(" {:<12} {:>6} {:>7} {:>7}", "record", "+0x08", "max t", "slack");
let (mut ship_exact, mut ship_hold, mut ship_bad) = (0, 0, 0);
for (n, (len, maxt)) in &shipped {
let slack = len - maxt;
match slack { 0 => ship_exact += 1, s if s > 0 => ship_hold += 1, _ => ship_bad += 1 }
println!(" {n:<12} {len:>6} {maxt:>7} {slack:>7}{}",
if slack < 0 { " 🔴 FALSIFIED" } else { "" });
}
println!("\n shipped: {ship_exact} exact, {ship_hold} hold, {ship_bad} falsified");
if shipped.len() < SHIPPED.len() {
let missing: Vec<_> = SHIPPED.iter().filter(|s| !shipped.contains_key(**s)).collect();
println!(" ⚠️ not found on the disc: {missing:?} -- a name the port ships and");
println!(" this control never checked is worse than a violation it found.");
}
println!("\n verdict: {}", if ship_bad > 0 {
"🔴 the reading fails on a record the port animates -- do NOT adopt"
} else if ship_hold == 0 {
"⚠️ every shipped record is exact, so this port cannot tell loop length\n from max keyframe time -- adopting 120 would change nothing here"
} else {
"✅ falsifier clean and the field is non-trivial ON THE SHIPPED SET"
});
}
fn pc(n: usize, d: usize) -> f64 { if d == 0 { 0.0 } else { 100.0 * n as f64 / d as f64 } }

View File

@@ -1,65 +0,0 @@
//! Why do two "every pak, every timed record" scans disagree by 86 %?
//!
//! This port counts 1 781 timed nested records and reports `+0x08 == max t` at
//! 92.3 %. The Decoder counts 3 311 and reports 49.6 %. Both scans are described
//! the same way, so at least one of them is narrower than its own description --
//! and the exactness figure this port has quoted repeatedly is a property of
//! whichever subset it actually walks.
//!
//! Counts the survivors at each filter, so the gap is located rather than
//! guessed at.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut records, mut in_bounds, mut magic, mut parsed, mut timed) = (0, 0, 0, 0, 0);
let (mut untimed, mut all_at_zero) = (0usize, 0usize);
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (_, &(o, s)) in &b.records {
records += 1;
if o + 12 > by.len() || o + s > by.len() { continue }
in_bounds += 1;
if &by[o..o + 4] != b"RATC" { continue }
magic += 1;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
parsed += 1;
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
// 🔴 `maxt == 0` merges two different populations, and the
// Decoder's cause -- `.max()` returning `Some(0)` -- is only one
// of them. A record with NO timed keyframe has no largest
// keyframe time; a record whose keyframes all sit at t=0 has
// one, and it is 0. Only the first is a question without
// content. Both of us called all 1 530 "the question has no
// meaning"; that is true of one group and an assumption about
// the other.
let any_timed = lb.elements.iter()
.any(|el| el.keyframes.iter().any(|k| k.time.is_some()));
if maxt == 0 {
if any_timed { all_at_zero += 1 } else { untimed += 1 }
continue;
}
timed += 1;
}
}
}
println!(" records declared by parse_build : {records}");
println!(" within the entry's bounds : {in_bounds}");
println!(" carrying the RATC magic : {magic} <- {} dropped here",
in_bounds - magic);
println!(" parsing as a nested build : {parsed}");
println!(" with a largest keyframe time > 0: {timed}");
println!(" of the {} excluded:", untimed + all_at_zero);
println!(" NO timed keyframe at all : {untimed} <- the question has no content");
println!(" timed, but every pose at t=0 : {all_at_zero} <- a largest time EXISTS, and it is 0");
}

View File

@@ -1,46 +0,0 @@
//! Do any screens THIS PORT SHIPS carry a record that declares a cycle while all
//! its poses sit at t = 0?
//!
//! The substantive finding from the denominator thread: 1 530 nested records
//! disc-wide are timed with every pose at t = 0 and still declare a nonzero
//! `+0x08`. A static record that declares a cycle length is a real thing, not a
//! counting artefact — so the question for the port is whether it holds one of
//! those still while the disc says it cycles.
//!
//! Scoped to `GP_TITLE`, because that is the archive the port exports.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = pak::PakArchive::open(format!("{root}/dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut total, mut hits, mut multipose) = (0usize, 0usize, 0usize);
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (name, &(o, s)) in &b.records {
if o + 12 > by.len() || o + s > by.len() || &by[o..o + 4] != b"RATC" { continue }
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
let len = ui_layout::loop_length_units(&by[o..o + s]).unwrap_or(0);
total += 1;
if maxt == 0 && len > 0 {
hits += 1;
// A cycle can only produce motion if there is more than one pose
// to move between. All-at-t=0 with a single keyframe per element
// is visually inert however it is played.
let kf: usize = lb.elements.iter().map(|el| el.keyframes.len()).sum();
let multi = lb.elements.iter().filter(|el| el.keyframes.len() > 1).count();
if multi > 0 { multipose += 1 }
println!(" entry {i:>2} {name:<16} {len}-unit cycle, {kf} keyframe(s) \
across {} element(s), {multi} with >1 pose", lb.elements.len());
}
}
}
println!("\n {total} nested record(s) in GP_TITLE; {hits} declare a cycle while static.");
println!(" Of those, {multipose} have an element with MORE THAN ONE pose -- the only");
println!(" ones where looping could differ visibly from holding. A record whose");
println!(" elements each carry a single pose renders identically either way, so a");
println!(" declared cycle there is inert rather than a defect.");
}

View File

@@ -1,48 +0,0 @@
//! Throwaway probe: how long is each region chunk of a movie's voice?
//!
//! The question it answers is whether the chunks of a resolved voice region are
//! CONSECUTIVE SEGMENTS (concatenate them) or ALTERNATE TAKES (chunk 0 is the
//! whole track). Getting that backwards plays the dialogue three times over.
use std::process::Command;
use sylpheed_formats::{media, slb::VoiceLang};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&disc);
for movie in ["ADV", "S00A", "RT01A"] {
let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
else {
println!("{movie}: no region");
continue;
};
let riffs = media::voice_region_riffs(&src, s, e).expect("riffs");
println!("{movie}: region [{s}, {e}) = {} bytes, {} chunk(s)", e - s, riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = std::env::temp_dir().join(format!("vc_{movie}_{i}.xma.wav"));
std::fs::write(&p, r).unwrap();
// XMA declares no duration, so DECODE it and measure the result.
let w = std::env::temp_dir().join(format!("vc_{movie}_{i}.wav"));
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&p)
.arg(&w)
.output();
let out = Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
.arg(&w)
.output()
.unwrap();
let dur = String::from_utf8_lossy(&out.stdout).trim().to_string();
if std::env::var("KEEP_WAV").is_ok() {
let keep = std::path::Path::new(&std::env::var("KEEP_WAV").unwrap())
.join(format!("{movie}_chunk{i}.wav"));
let _ = std::fs::rename(&w, &keep);
println!(" kept -> {}", keep.display());
} else {
let _ = std::fs::remove_file(&w);
}
println!(" chunk {i}: {} bytes -> {dur} s", r.len());
let _ = std::fs::remove_file(&p);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,7 @@
//! * a `buttons` entry naming an element that is not a button, or out of
//! resting-Y order;
//! * a sprite path that does not exist, or a PNG that does not decode;
//! * a name presented as recovered when it was authored;
//! * an audio file that is silent or clips -- the two audio failures that pass
//! every check that is not looking for them.
//! * a name presented as recovered when it was authored.
//!
//! It deliberately does **not** check that the export matches the disc. That is
//! what `sylpheed-cli screen render` is for.
@@ -186,26 +184,10 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
for (k, kf) in kfs.iter().enumerate() {
check_pose(&mut c, &format!("{at} keyframe {k}"), kf);
}
// 🔴 INVERTED 2026-08-29, and the old rule is the more interesting
// half. It read: "the last keyframe of a group carries no time slot
// on the disc, and an invented one is exactly the kind of value this
// format refuses." That was true of the OLD keyframe association,
// where a group's data stopped four bytes short of its final block's
// time slot.
//
// Under the corrected layout (`formats-pin-2026-08-29c` onward) a
// group is an 8-byte header then `frames` x {u32 time; 36-byte
// pose}, so **pose 0's time is the group's lead-in word and EVERY
// POSE IS TIMED, including the last.** The rule now says the
// opposite, and an untimed keyframe is the thing to refuse.
//
// ⚠️ This fired 150 times on a re-export and I had not run `check`
// between pinning the tag and measuring against the oracle -- the
// pixel harness was green while the format validator was failing on
// every screen with a multi-keyframe group. A correctness harness
// does not replace a format one; they fail at different layers.
if kfs.len() > 1 && kfs.iter().any(|k| k.get("t").is_none()) {
c.err(format!("{at}: a keyframe has no `t`; every pose is timed under the corrected record layout"));
// The last keyframe of a group carries no time slot on the disc, and
// an invented one is exactly the kind of value this format refuses.
if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) {
c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there"));
}
}
}
@@ -285,8 +267,6 @@ pub fn run(root: &Path) -> Result<usize> {
check_screen(root, file, &mut errors)?;
}
check_audio(root, &m, &mut errors);
if !errors.is_empty() {
for e in &errors {
eprintln!("{e}");
@@ -295,88 +275,3 @@ pub fn run(root: &Path) -> Result<usize> {
}
Ok(screens.len())
}
/// The `audio` array, checked the way a consumer would have to.
///
/// Two of these are content checks rather than schema checks, and they are here
/// on purpose. `docs/port/AUDIO-VERIFICATION.md` names silence as "the failure
/// that looks like success": a file of exactly the right duration, the right
/// channel count and the right size, full of zeroes, because something opened
/// the wrong thing. Every structural check passes it. So does clipping, which
/// the BGM can produce because it is a **sum of two stems** at unity gain.
///
/// The exporter measures both at export time and writes them here; this refuses
/// the tree if what it wrote is a file nobody would want to play. Neither is a
/// judgement about whether the audio is the RIGHT audio — nothing in this
/// binary can know that, and `docs/port/BLOCKED.md` says which parts are still
/// authored guesses.
fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
let Some(audio) = m.get("audio").and_then(Value::as_array) else {
// Absent is correct for every export taken before P6.
return;
};
for a in audio {
let name = a.get("name").and_then(Value::as_str).unwrap_or("?");
let kind = a.get("kind").and_then(Value::as_str).unwrap_or("");
if !matches!(kind, "se" | "bgm" | "voice") {
errors.push(format!(
"manifest.json: audio `{name}` has kind {kind:?}, which a consumer cannot dispatch on"
));
}
for key in ["file", "command", "why"] {
if a.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
errors.push(format!("manifest.json: audio `{name}` has no `{key}`"));
}
}
let Some(file) = a.get("file").and_then(Value::as_str) else { continue };
if !root.join(file).exists() {
errors.push(format!("manifest.json: lists audio {file}, which does not exist"));
continue;
}
match a.get("peak_dbfs").and_then(Value::as_f64) {
None => errors.push(format!(
"manifest.json: audio `{name}` carries no `peak_dbfs` -- it was not measured, \
and silence is the audio failure that passes every check that is not looking \
for it"
)),
Some(p) if p <= -90.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- this file is silent"
)),
// The bound differs by kind, and the difference is the point. A
// `bgm` is something WE combined -- a sum of stems -- so a peak at
// or above full scale is our arithmetic and is refused outright. An
// `se` is a single wave off the disc: it is mastered near full
// scale, and a lossy decode of a near-full-scale signal overshoots
// by a fraction of a dB (`confirm` lands at +0.18). Refusing that
// would be refusing the disc's own mastering, and "fixing" it would
// mean attenuating a game asset to make a number smaller.
//
// 🟡 +1.0 dB is a JUDGEMENT, not a measurement: a few tenths is
// reconstruction overshoot, a whole dB is not. Nobody has measured
// the overshoot distribution across a corpus of cues, and if a cue
// ever trips this the right response is that measurement, not a
// looser bound.
// `voice` was on the strict side of this bound while it was a SUM of a
// region's chunks. It no longer is: a region carries three
// presentations of one take, so the exporter keeps ONE stream and
// performs no arithmetic on it. That puts `voice` with `se` -- a
// single wave off the disc, mastered near full scale, whose lossy
// decode overshoots by a fraction of a dB. `ADV`'s louder
// presentation measures +0.0003 dBFS at source; refusing that would
// be refusing the disc's own mastering.
Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- a SUM we produced clips"
)),
Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot"
)),
Some(_) => {}
}
match a.get("duration_s").and_then(Value::as_f64) {
Some(d) if d > 0.0 => {}
_ => errors.push(format!(
"{file}: no positive `duration_s` -- a zero-length asset plays as silence"
)),
}
}
}

View File

@@ -12,7 +12,6 @@
//!
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod audio;
mod check;
mod video;
mod screen;
@@ -21,7 +20,7 @@ use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use std::path::{Path, PathBuf};
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
@@ -77,48 +76,6 @@ struct ManifestVideo {
/// dislikes the quality re-runs one line rather than reverse-engineering it.
command: String,
why: &'static str,
/// What the runtime should have played, so it can report what it did.
/// See `video::Transcoded::duration_s` — the port measured its player
/// presenting 2847 % of a stream's frames, and seconds alone hide that.
duration_s: f64,
fps: f64,
}
/// One exported audio file. Carries the same provenance a video does, plus the
/// measured peak and duration: silence and clipping are the two audio failures
/// that pass every check that is not looking for them.
#[derive(Serialize)]
struct ManifestAudio {
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
/// than a prefix on `name` that a consumer would have to parse.
kind: &'static str,
name: String,
file: String,
command: String,
why: String,
#[serde(skip_serializing_if = "Option::is_none")]
peak_dbfs: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_s: Option<f32>,
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
/// runtime to announce. Absent means nothing is known to be missing --
/// never that the asset was checked and is complete.
///
/// It exists because the export could already say this and the RUNTIME
/// could not. `why` carries the full account, but it is a paragraph aimed
/// at a reader of the manifest; a player hears clean dialogue and has no
/// way to learn that a stream is absent from it. This port already
/// announces the two measured screens NEW GAME jumps over, on the principle
/// that a gap is announced before it is opened. Audio had no equivalent.
#[serde(skip_serializing_if = "Option::is_none")]
incomplete: Option<String>,
/// The game's own cue identifier where one is a NAME MATCH. Absent means
/// nobody has claimed one -- never that the binding is unknown.
#[serde(skip_serializing_if = "Option::is_none")]
name_match: Option<String>,
/// What the runtime does at the end of the file, where that was authored.
#[serde(skip_serializing_if = "Option::is_none")]
loop_mode: Option<String>,
}
#[derive(Serialize)]
@@ -131,8 +88,6 @@ struct Manifest {
screens: Vec<ManifestScreen>,
#[serde(skip_serializing_if = "Vec::is_empty")]
videos: Vec<ManifestVideo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
audio: Vec<ManifestAudio>,
warnings: Vec<String>,
}
@@ -240,53 +195,11 @@ fn main() -> Result<()> {
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
let names = load_names(authored_dir)?;
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
// has to know about; it is not an error, and it is not a log line, because
// the person who needs it reads `manifest.json` and never sees stdout.
let mut warnings: Vec<String> = vec![
"GP_TITLE screen builds only. No other archive, and only the two movies \
MISSION section 6 puts in scope."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
];
// Derived output is regenerated wholesale: clear it, so a screen that stops
// being exported stops existing rather than lingering as a stale file that
// still validates.
//
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
// `video::transcode` has always carried a cache -- it writes a `.cmd`
// sidecar with the exact command, the source size and the channel count, and
// skips the encode when all three still match. Its own doc comment says
// "without it every re-export pays ~4 minutes to produce a byte-identical
// file". **This wipe deleted the sidecar and the output immediately before
// the check, so the cache had never hit once.** Six exports in one session
// paid ~48 minutes of Theora to produce five byte-identical files, and
// nothing reported it: the cache is silent when it works and silent when it
// does not.
//
// The wholesale guarantee is kept rather than weakened -- everything else is
// still cleared outright, and `prune_videos` below deletes any file in
// `video/` that this run did not claim, so a movie that stops being exported
// still stops existing.
if out.exists() {
for entry in std::fs::read_dir(&out).context("clear the output tree")? {
let entry = entry?;
if entry.file_name() == "video" {
continue;
}
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(entry.path())
} else {
std::fs::remove_file(entry.path())
}
.with_context(|| format!("clear {}", entry.path().display()))?;
}
std::fs::remove_dir_all(&out).context("clear the output tree")?;
}
std::fs::create_dir_all(&out)?;
@@ -348,153 +261,20 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// MISSION §6: the boot intro and the one new-game intro only.
let mut videos = Vec::new();
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
// 🔴 The export deviates from a HUMAN decision, and until this warning
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
// justified for one of the two movies and over-broad for the other, and
// which of the three options to take is not the exporter's call -- so it is
// reported on every run rather than left in a doc comment nobody opens.
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
warnings.push(
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
.to_string(),
);
}
for m in video::MOVIES {
match video::transcode(disc, out, m)? {
Some(t) => {
println!(" video {} -> {}", m.src, t.file);
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
videos.push(ManifestVideo {
name: t.name,
file: t.file,
command: t.command,
why: t.why,
duration_s: t.duration_s,
fps: t.fps,
});
}
None => println!(" video {} not on this disc -- skipped", m.src),
}
}
prune_videos(out, &videos)?;
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
// because they were measured off the running game and are on the disc in no
// findable form, the BGM choice because HANDOFF Q10 is a negative and
// nothing states which track a menu plays. See `authored/audio.json`.
let mut audio = Vec::new();
let audio_cfg = audio::load(authored_dir)?;
match &audio_cfg {
None => println!(" no authored/audio.json -- no audio exported"),
Some(cfg) => {
let source = media::DirectorySource::new(disc);
for a in audio::export_cues(&source, out, &cfg.se)? {
println!(
" se {:<8} -> {} ({})",
a.name,
a.file,
describe(&a)
);
audio.push(ManifestAudio::from(a));
}
for (role, spec) in &cfg.bgm {
match audio::export_bgm(&source, out, role, spec)? {
Some(a) => {
println!(
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
a.name,
a.file,
describe(&a),
spec.bank,
a.sub_waves
);
// HANDOFF Q10's census is "exactly two waves of
// identical duration, 32/32 banks on the disc". When
// `media` hands back a different number, SAY SO -- the
// port does not get to decide that one of them is not a
// stem, and silently summing an extra region into the
// music is precisely the media-assembly mistake MISSION
// section 2 names. The decoder's answer is what ships;
// the disagreement is what gets reported.
if a.sub_waves != 2 {
warnings.push(format!(
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
says a music bank is EXACTLY TWO waves of identical duration \
(32/32 banks). All {} are summed, because choosing which to drop \
is a decoding question and this exporter does not answer those. \
See docs/port/BLOCKED.md.",
a.sub_waves, spec.bank, a.sub_waves
));
}
audio.push(ManifestAudio::from(a));
}
// Not an error: the authored bank may simply not be on this
// disc, and the export of everything else is still good.
None => warnings.push(format!(
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
this disc's sound.pak -- no BGM exported for that role.",
spec.bank
)),
}
}
}
}
// The cutscene voices are DERIVED, not authored, so this runs outside the
// `authored/audio.json` block above: the binding comes off the disc (the
// movie manifest in `tables.pak`), and an export with no authored audio
// should still carry the dialogue for the movies it ships.
//
// A movie that resolves to no region is genuinely unvoiced and gets a
// warning rather than a substitute -- for both movies in scope this port
// expects a region, so a warning here is a real signal and not noise.
{
let source = media::DirectorySource::new(disc);
for (stem, len) in &movie_lengths {
// The presentation choice is AUTHORED and this block runs even when
// there is no `authored/audio.json` -- the voice binding is decoded,
// so the dialogue exports either way and only the choice defaults.
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
Some(a) => {
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
// export is known to be missing audio the game plays, and
// the failure sounds like success: one stream decodes to
// clean dialogue, so nobody listening finds out.
if a.kept_waves < a.content_waves {
warnings.push(format!(
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
three XMA contexts, byte sizes matching the disc payloads exactly). \
The export carries ONE. Nothing in the audio reveals this -- a \
single stream is clean audible dialogue. Held rather than summed \
because an equal-gain sum of channel pairs is not a downmix and \
would be a second guess, not a fix. See authored/audio.json voice \
and docs/port/BLOCKED.md.",
a.file, a.sub_waves
));
}
println!(
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
a.name,
a.file,
describe(&a),
a.kept_waves,
a.sub_waves,
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
);
audio.push(ManifestAudio::from(a));
}
None => warnings.push(format!(
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
)),
}
}
}
let manifest = Manifest {
format: "sylpheed.manifest/1",
@@ -503,8 +283,16 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
disc: disc.display().to_string(),
screens,
videos,
audio,
warnings,
warnings: vec![
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
],
};
std::fs::write(
out.join("manifest.json"),
@@ -513,81 +301,3 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
println!("wrote {}/manifest.json", out.display());
Ok(())
}
impl From<audio::Exported> for ManifestAudio {
fn from(a: audio::Exported) -> Self {
ManifestAudio {
kind: a.kind,
name: a.name,
file: a.file,
command: a.command,
why: a.why,
peak_dbfs: a.peak_dbfs,
duration_s: a.duration_s,
incomplete: (a.kept_waves < a.content_waves).then(|| {
format!(
"{} of {} streams. The running game decodes all {} concurrently. \
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
WHICH streams are dropped and why differs per asset; the manifest \
entry's `why` says, and it is not the same story twice.",
a.kept_waves, a.sub_waves, a.sub_waves
)
}),
name_match: a.name_match,
loop_mode: a.loop_mode,
}
}
}
/// The two numbers worth reading on an audio line, in the console.
///
/// Printed rather than left to the manifest because the failure this catches is
/// a SILENT file: the right duration, the right channel count, the right size,
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
/// person notices without being told to look.
fn describe(a: &audio::Exported) -> String {
let peak = match a.peak_dbfs {
Some(p) => format!("peak {p:.1} dBFS"),
None => "peak unmeasured".into(),
};
match a.duration_s {
Some(d) => format!("{d:.3} s, {peak}"),
None => peak,
}
}
/// Delete anything in `video/` this run did not produce.
///
/// `video/` is the one directory the wholesale wipe spares, so that the
/// transcode cache survives to be consulted. This restores the guarantee the
/// wipe exists for: a movie that stops being exported stops existing, rather
/// than lingering as a file the manifest no longer lists.
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
let dir = out.join("video");
if !dir.exists() {
return Ok(());
}
let mut keep: Vec<String> = Vec::new();
for v in kept {
if let Some(name) = Path::new(&v.file).file_name() {
let name = name.to_string_lossy().into_owned();
keep.push(name.clone());
// The cache sidecar goes with the file it stamps.
if let Some(stem) = Path::new(&name).file_stem() {
keep.push(format!("{}.cmd", stem.to_string_lossy()));
}
}
}
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if keep.contains(&name) {
continue;
}
println!(" video {name} is no longer exported -- removed");
let _ = std::fs::remove_file(entry.path());
}
Ok(())
}

View File

@@ -103,14 +103,6 @@ pub struct FocusElement {
pub id: String,
pub declared: String,
pub sprite: Option<String>,
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
///
/// A leaf's sprite may live in the leaf's own table or in the parent
/// bundle's, so the bit is looked up in the same two places, in the same
/// order, that the PNG is written from.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub pivot: [u32; 2],
pub rest: Rest,
pub keyframes: Vec<Keyframe>,
@@ -120,34 +112,6 @@ pub struct FocusElement {
pub struct Focus {
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
pub record: String,
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
/// units — which is not the same thing as the last keyframe's time.
///
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
/// the period from the largest keyframe time — what the port did until now —
/// runs it 14 % fast and deletes the dark rest entirely.
///
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
/// bare until 2026-09-01, which is a population-scoped statistic reported
/// without its population):
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
/// their own last pose**; on the eight records this port animates, seven
/// exact and `ptbtn00f` the one hold.
///
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
/// held its own guard and byte read, because the field was decoded in an
/// example and a test and exposed in no public API on any ref. It is now
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
/// the local copy is deleted — the doc comment that promised that deletion
/// is the only reason it did not quietly become permanent.
pub loop_length_units: Option<u32>,
/// Back-to-front, in the leaf's own declaration order.
pub elements: Vec<FocusElement>,
}
@@ -177,59 +141,6 @@ pub struct Element {
/// convention and a consumer may still want the bare highlight texture.
#[serde(skip_serializing_if = "Option::is_none")]
pub focus: Option<Focus>,
/// This element's own `.rat` leaf, when its declared name is itself a
/// record in the bundle.
///
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
/// the title declare scale 100 % and rotation 0 at the parent, and their
/// leaves declare **(100, 600) at +30°** and **(100, 800) at 45°** — and
/// the leaves *move*, x from 639 → 1521 and 1721 → 839. `ui_layout`'s own
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
/// records, which the census never opened."* Neither did this exporter: it
/// opened a leaf only for a FOCUS record, via `highlight_name`.
///
/// That omission is measurable. It is the whole of the title's 1.82 %
/// disagreement with the oracle — the port draws two 400 px sprites upright
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
/// quads across the frame at opposite leans.
///
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
/// ramp on a different span — parent 0→255 over t=70…238, leaf
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
/// question and not the port's to answer. The data is exported so it stops
/// being invisible; `ScreenView` ignores it until the composition rule is
/// known.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf: Option<Focus>,
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
/// what the game draws.
///
/// Decided here rather than in the runtime because it is disc knowledge.
/// The Decoder's rule: *"the discriminator is which record carries the
/// geometry, not a fixed order"* — and the census over this export splits
/// cleanly, with no ambiguous middle:
///
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
/// exactly. That is the BASE-record case `screen.rs` already handled: the
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
/// y=402) and the parent wins. Flag is false; nothing changes.
/// * **16 differ**, and all of them differ in scale or rotation, not by a
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
/// and (100,800) at 45° against an identity parent), two
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
///
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
/// measured. The other two are the same shape and are **not** separately
/// confirmed; they are flagged so the harness can adjudicate them rather
/// than being asserted.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub leaf_carries_geometry: bool,
/// The raw `opt ` link inside this element's `.rat` record.
///
/// ⚠️ **This is not a focus link.** It was read as one, and that was
@@ -250,23 +161,6 @@ pub struct Element {
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
/// `"implied"` = **measured off the running game**, for elements that carry
/// no header. `"none"` = neither; sorts last.
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
/// `0x02`.
///
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
/// A name-keyed map cannot answer for a screen nobody drove the game to,
/// which is why the port was drawing the English menus additive and the
/// Japanese ones alpha-over — asserting by omission that the JP build
/// blends differently. The bit is on the disc for every screen at once.
///
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
/// 0 of 14 additive elements set it and 9 non-additive ones do.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub layer_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
@@ -307,35 +201,6 @@ pub struct Screen {
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
/// and not to be trusted for anything else.
pub buttons: Vec<String>,
/// The instant every element of this screen is settled at, and the width of
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
/// units, absent when the screen has fewer than two keyframe times.
///
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
/// each element at its own `rest()` is right for anything that ends the
/// screen settled and **exactly wrong for a transient**: the title's
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t5456, 0
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
/// it burning forever. There are five of these, and `rest()` draws all five
/// at once, saturating the light arc.
///
/// The window is the **longest interval containing no keyframe time**, over
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
/// that exclusion is what reproduces the Decoder's independently computed
/// `[160, 236]` for the title: including the `ptloop` leaves gives
/// `[269, 540]` instead.
///
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
/// export the widths split with nothing in between — `press_start` 214,
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
#[serde(skip_serializing_if = "Option::is_none")]
pub settle_window: Option<[i64; 3]>,
/// What this file does not answer. A consumer needing one of these must get
/// it from `authored/`.
pub unresolved: Vec<&'static str>,
@@ -451,76 +316,6 @@ pub fn export_build(
// Contrast with a BASE record, where the leaf duplicates the parent's
// placement and the two can differ by a unit (ptbtn04: parent y=401,
// leaf y=402). There the parent wins. Here there is no parent.
// Reads one record in the bundle as a nested build and returns its
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
// element whose OWN declared name is a record (`ptloop01.rat`). One
// implementation, because the second case was missing for eight
// milestones and a second copy is how it would go missing again.
let read_leaf = |rec: &str,
written: &mut std::collections::BTreeMap<String, ()>,
missing: &mut Vec<String>|
-> Result<Option<Focus>> {
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
return Ok(None);
};
let mut fes = Vec::new();
for fe in &leaf.elements {
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None;
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
{
fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") {
missing.push(sp.to_string());
}
let Some(r) = fe.rest() else { continue };
fes.push(FocusElement {
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
scale: [r.scale_x, r.scale_y],
tint_rgba: hex32(r.tint),
fade_argb: hex32(r.fade),
rotation_deg: r.rotation_deg,
t: r.time,
},
keyframes: fe
.keyframes
.iter()
.map(|k| Keyframe {
t: k.time,
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
rotation_deg: k.rotation_deg,
})
.collect(),
});
}
Ok(if fes.is_empty() {
None
} else {
Some(Focus {
record: rec.to_string(),
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
})
})
};
// An element whose own declared name is a record in this bundle carries
// its geometry THERE, not in its parent entry. See `Element::leaf`.
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
let mut focus = None;
if let Some(rec) = highlight_name(&el.name) {
if let Some(&(off, size)) = b.records.get(&rec) {
@@ -546,9 +341,6 @@ pub fn export_build(
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
@@ -573,11 +365,7 @@ pub fn export_build(
});
}
if !fes.is_empty() {
focus = Some(Focus {
record: rec,
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
});
focus = Some(Focus { record: rec, elements: fes });
}
}
}
@@ -609,21 +397,10 @@ pub fn export_build(
sprite: sprite_out,
focus_sprite,
focus,
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
let p = el.rest();
l.elements.iter().any(|le| {
p.is_none_or(|p| {
le.rest.scale != [p.scale_x, p.scale_y]
|| le.rest.rotation_deg != p.rotation_deg
})
})
}),
leaf,
opt_link: el.focus_link.clone(),
pivot: [el.pivot_x, el.pivot_y],
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
parent: el.parent,
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
layer_source,
layer,
focused: el.focused,
@@ -650,12 +427,6 @@ pub fn export_build(
.collect();
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let window = settle_window(&elements);
let order = forced_backdrop_first(
ui_layout::derived_paint_order(&b, bundle),
&elements,
[b.design_w, b.design_h],
);
let screen = Screen {
format: "sylpheed.screen/3",
exporter: exporter.to_string(),
@@ -670,9 +441,8 @@ pub fn export_build(
name_why,
design: [b.design_w, b.design_h],
elements,
paint_order: order,
paint_order: ui_layout::derived_paint_order(&b, bundle),
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
settle_window: window,
unresolved: vec![
// The time unit is measured off the running game, not on the disc.
"keyframe_time_unit",
@@ -703,234 +473,3 @@ pub fn export_build(
missing,
})
}
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
///
/// See [`Screen::settle_window`] for why this is the settled instant and why
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
let mut times: Vec<i64> = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
.collect();
times.sort_unstable();
times.dedup();
if times.len() < 2 {
return None;
}
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
//
// The widest keyframe-free interval is only a settled state if the screen is
// actually PRESENTING something across it. `press_start` is the case that
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
// and the runtime then answered every question about that screen at t=107.
// The result was that the PRESS (A) plate could not be drawn at any instant
// at all, including the boot's own end state, whose entire purpose is to
// show it.
//
// The fix is not a tuned threshold: it is that the heuristic was reading an
// interval where the screen is BLANK as the interval where it has arrived.
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
// under the runtime's 30-unit bar, so it falls back to each element's own
// hold -- which is the plate, opaque, exactly as the disc declares it.
//
// ⚠️ This does not disturb the windows the settle instant was measured on.
// `title` keeps [160, 236]: elements are visible across it, and the
// Decoder's draw stream independently found the game's clock freezing in
// that same interval.
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
let (a, b) = times
.windows(2)
.map(|w| (w[0], w[1]))
.filter(|(a, b)| visible_at((a + b) / 2))
.max_by_key(|(a, b)| b - a)?;
Some([a, b, (a + b) / 2])
}
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
fn alpha_at(e: &Element, t: i64) -> u8 {
let ks = &e.keyframes;
let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
.unwrap_or(0) >> 24) as i64;
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return 0;
}
if t <= timed[0].t.unwrap() as i64 {
return a(timed[0]) as u8;
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return a(w[0]) as u8;
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
}
}
a(timed[timed.len() - 1]) as u8
}
/// Scale of one element at instant `t`, in percent per axis, under the same
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
/// that animates passes through every value between its keyframes.
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return [100.0, 100.0];
}
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
if t <= timed[0].t.unwrap() as i64 {
return [g(timed[0], 0), g(timed[0], 1)];
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return [g(w[0], 0), g(w[0], 1)];
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return [
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
];
}
}
let l = timed[timed.len() - 1];
[g(l, 0), g(l, 1)]
}
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
/// file forces it there.
///
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
/// *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.*
///
/// It was found because `build_12`/`build_15` are **black at every instant** of
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
/// 39 instants while all 9 other elements live and die inside that span. A
/// screen that is black for its whole life is impossible on its face, which is
/// the only kind of check that survives two renderers sharing an assumption:
/// `sylpheed-cli` agreed with the port here because it agreed about
/// `implied_layer_key`.
///
/// Two measured controls, both prior orders off the running game:
///
/// | primitive | measured | opaque instants | forced below | |
/// |---|---|---|---|---|
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
///
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
/// matches 77 of 80 and fails on exactly the three families that cross it —
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
/// an overlay* and is measured painting first. The name is not the rule.
///
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
/// `.t32` textures must sort first *against their own layer keys*. **An
/// element's alpha says nothing about whether its texture covers the screen** —
/// most of a sprite may be transparent.
///
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
/// ordering — it says nothing about elements that are constrained but not
/// forced. Delete this when a pinned `sylpheed-formats` does it.
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
let screen_end: i64 = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
.map(i64::from)
.max()
.unwrap_or(0);
let forced: Vec<usize> = elements
.iter()
.enumerate()
.filter(|(_, e)| {
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
// sprite". Those coincide in GP_TITLE and the distinction is still
// the whole point, because the negative test guards a SYMPTOM.
//
// The rule needs the element's alpha to BE its pixels' alpha. That
// is true of a `.prm` solid quad and of nothing else. The Decoder
// found this the expensive way twice: first `.t32` sprites (an
// element's alpha says nothing about a texture that is mostly
// transparent), guarded with "no sprite" -- and then `.tbm`, which
// is 38 of their 80 forced-first verdicts and declares fade
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
// make the screen white; no screen is white, so a `.tbm`'s white is
// a modulation ON a texture and its element alpha proves nothing
// about coverage either.
//
// "No sprite" would keep admitting a `.tbm` that this exporter
// happens not to emit a sprite for. `role == "primitive"` cannot.
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
// full-screen element here is `.prm` and pure black, checked -- so
// this changes no verdict today and is a guard against a corpus
// that grows.
// Cheap prefilter only -- the binding coverage test is per-instant,
// in `covers` below. An element scaled ABOVE 100 could cover the
// screen from a smaller declared size, so this deliberately does
// not reject on size.
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
})
.filter(|(i, e)| {
let span: Vec<i64> = e
.keyframes
.iter()
.filter_map(|k| k.t)
.map(i64::from)
.collect();
let Some(&lo) = span.first() else { return false };
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
// Declared size alone is not what the element draws: scale is a
// percent per axis and it animates. `pbafc.prm` is the disc's own
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
// about 17x18 px, a moving glint rather than a wash. A rule that
// read its declared size would call it screen-covering.
//
// Nothing in GP_TITLE needs this: every layerless full-screen
// element here is at scale 100 on every keyframe, so no verdict
// moves. It is in because the data that would break it exists on
// this disc, which is a better reason than a failure would have been.
let covers = |t: i64| {
let sc = scale_at(e, t);
e.size.is_some_and(|s| {
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
})
};
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
// that shows why: it declares ONE keyframe, opaque black full-screen
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
// a single-instant event instead of the thing that is on screen for
// the whole splash. So the span runs to the SCREEN's last keyframe.
let hi = screen_end.max(*span.last().unwrap());
let opaque: Vec<i64> = (lo..=hi)
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
.collect();
if opaque.is_empty() {
return false;
}
// Every OTHER element must be visible somewhere inside that span.
elements.iter().enumerate().all(|(j, o)| {
j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
})
})
.map(|(i, _)| i)
.collect();
if forced.is_empty() {
return order;
}
let mut out = forced.clone();
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
out
}

View File

@@ -63,34 +63,8 @@ pub const MOVIES: &[Movie] = &[
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
/// default *is* this matrix; the point is that the manifest now says so.
///
/// # 🔴 This is NOT the matrix MISSION §6 pins, and that was never said out loud
///
/// MISSION §6 records a **human decision of 2026-08-29** fixing the fold at
/// `FL = 1.0·FL + 0.707·FC + 0.707·BL` (plus 7.1 terms a 5.1 source does not
/// have). This constant is that matrix scaled by 0.4142 — the same relative
/// weighting, **7.65 dB quieter** — and until now nothing in the code, the
/// manifest or the docs said so. Recording the command you ran does not disclose
/// that it is not the command you were given.
///
/// The original justification for the deviation was *"the unnormalised form
/// clips: peak 0.0 dBFS"*, and that is a peak reading — the instrument
/// `docs/port/BLOCKED.md` records this port declaring unfit for the clipping
/// question, because one sample at full scale and two seconds of square wave
/// give the same number. Re-measured properly (float decode, whole file, count
/// the samples that would clamp):
///
/// | | peak | ≥ full scale | > +1 dB over | longest run |
/// |---|---|---|---|---|
/// | `ADV`, MISSION §6 | **+4.26 dBFS** | 4 406 / 13 187 900 | 1 874 | 0.333 ms |
/// | `S00A`, MISSION §6 | 1.34 dBFS | **0** | 0 | — |
///
/// So the pin really does overload `ADV` — and this constant is over-broad,
/// because `S00A` never needed it. The smallest single scalar under which
/// neither clamps is `1/1.6339 = 0.612`, +3.39 dB on today.
///
/// **Not changed here.** The level of a mix is what §6 reserves to a human
/// (*"adjust it deliberately, as a commit"*), so the export carries a warning
/// with these numbers instead. See `docs/port/DECISIONS.md`.
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
/// why the normalisation is here rather than the textbook coefficients.
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
/// How many audio channels the source declares.
@@ -106,34 +80,6 @@ fn channels(src: &Path) -> Result<u32> {
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
}
/// Duration and frame rate of a finished transcode, straight from the file.
///
/// Probed from the OUTPUT, not the source: what the runtime will play is this
/// file, and the two differ — `ADV` is 137.44 s against a 137.71 s source.
/// Returns zeros rather than failing, because a missing number should make the
/// runtime say "unknown", not stop an export that otherwise succeeded.
fn probe_timebase(out: &Path) -> (f64, f64) {
let probe = |entries: &str, stream: bool| -> String {
let mut c = Command::new("ffprobe");
c.args(["-v", "error"]);
if stream {
c.args(["-select_streams", "v:0"]);
}
c.args(["-show_entries", entries, "-of", "csv=p=0"]).arg(out);
c.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
};
let secs = probe("format=duration", false).parse().unwrap_or(0.0);
// `r_frame_rate` is a rational, "30/1".
let rate = probe("stream=r_frame_rate", true);
let fps = match rate.split_once('/') {
Some((n, d)) => n.parse::<f64>().unwrap_or(0.0) / d.parse::<f64>().unwrap_or(1.0),
None => rate.parse().unwrap_or(0.0),
};
(secs, fps)
}
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
let mut v: Vec<String> = [
"-hide_banner", "-loglevel", "error", "-y",
@@ -162,30 +108,6 @@ pub struct Transcoded {
pub file: String,
pub command: String,
pub why: &'static str,
/// The transcode's own duration and frame rate, probed from the file that
/// was just written.
///
/// Recorded so the RUNTIME can say what it actually presented.
///
/// 🔴 CORRECTED 2026-09-01. This read: *"Godot's video player drops frames to
/// hold its schedule, and it drops a lot of them here — measured at 28 % of [refuted]
/// `S00A`'s frames presented and 47 % of `ADV`'s"*. **Both numbers are
/// retracted.** They came from CONTENDED runs, and the counter is an upper
/// bound on ENGINE frames that is vacuous once the engine outruns the stream
/// — quiet, `ADV` draws 6 480 frames across a 4 123-frame video. On a quiet
/// box the bound is 8890 % for `S00A`, and playback runs **+6.7 %…+6.9 %**
/// long for both films. What survives is that elapsed seconds hide whatever
/// the player does, which is why the count is in the manifest. Without a frame count in the manifest a run can only
/// report elapsed seconds, and elapsed seconds are exactly what stays
/// plausible while three frames in four go missing.
///
/// 🔴 This field exists because the port asserted the opposite. The claim was
/// *"a player that runs long decoded everything"*, argued from the absence of
/// an overrun rather than measured; the measurement was four lines and
/// refuted it. **The instrument is now permanent so the argument cannot be
/// made again from a run that never counted.**
pub duration_s: f64,
pub fps: f64,
}
/// Transcode one movie, skipping the encode when the output already exists and
@@ -209,35 +131,10 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
let argv = args(&src, &ogv, ch);
let command = format!("ffmpeg {}", argv.join(" "));
let size = std::fs::metadata(&src)?.len();
// The sidecar SAYS WHAT IT IS. It sits in the modder-facing asset tree next
// to the `.ogv`, and MODDING rule 2's principle is that a generated file
// should be tellable from a hand-made one by reading it -- a bare ffmpeg
// line beside a video looks like something a modder should edit or delete.
//
// The header is NOT part of the cache key: `fresh` compares only the lines
// that describe the encode. Otherwise rewording this comment would re-encode
// four minutes of video to no purpose, which is a cache that punishes
// documentation.
let key = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let want = format!(
"# Generated by sylpheed-export. NOT an asset and not hand-editable: this\n\
# records how {}.ogv beside it was encoded, so a re-export can skip the\n\
# encode when the source and the command are both unchanged. Deleting it\n\
# only forces one re-encode. To change the video, override the .ogv under\n\
# data/mods/ (MODDING rule 4) -- editing this file changes nothing.\n{key}",
m.stem
);
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let cache_key = |s: &str| -> String {
s.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
};
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp)
.map(|s| cache_key(&s) == cache_key(&want))
.unwrap_or(false);
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
if !fresh {
// Encode to a temp name and rename on success. A reader that catches
// this mid-write sees no file at all rather than a valid-looking one
@@ -258,26 +155,12 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
bail!("ffmpeg failed on {}", m.src);
}
std::fs::rename(&partial, &ogv)?;
}
// Refresh the sidecar whenever its TEXT differs, encode or no encode.
//
// It used to be written only inside the `!fresh` branch, which is right for
// the cache and wrong for the file: a change to the header alone -- the part
// deliberately excluded from the key -- would then never reach an existing
// export, because nothing that reads the header can trigger the write that
// updates it. The explanation would be correct in the source and absent on
// disc, which is the same shape as every other documented-but-unexercised
// thing this port has had to find the hard way.
if std::fs::read_to_string(&stamp).map(|s| s != want).unwrap_or(true) {
std::fs::write(&stamp, &want)?;
}
let (duration_s, fps) = probe_timebase(&ogv);
Ok(Some(Transcoded {
name: m.stem.to_string(),
file: format!("video/{}.ogv", m.stem),
command,
why: m.why,
duration_s,
fps,
}))
}

View File

@@ -0,0 +1,38 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
println!("entry {i}: {} elements, {} records, {} sprites",
b.elements.len(), b.records.len(), b.sprites.len());
let mut rk:Vec<&String>=b.records.keys().collect(); rk.sort();
println!(" records: {:?}", rk);
for (rn,&(o,sz)) in &b.records {
if o+sz>by.len() { continue }
let Some(lb)=ui_layout::parse_build(&by[o..o+sz]) else { continue };
println!(" RECORD {rn}: {} elements", lb.elements.len());
for le in &lb.elements {
let lts:Vec<String>=le.keyframes.iter()
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
println!(" [{}] {:<22} kind=0x{:<6x} {}", le.index, le.name, le.kind, lts.join(" "));
}
}
for e in &b.elements {
let ts:Vec<String>=e.keyframes.iter()
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
println!(" [{}] {:<24} kind=0x{:<6x} {}", e.index, e.name, e.kind, ts.join(" "));
if let Some(&(o,s))=b.records.get(&e.name) {
if o+s<=by.len() {
if let Some(lb)=ui_layout::parse_build(&by[o..o+s]) {
for le in &lb.elements {
let lts:Vec<String>=le.keyframes.iter()
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
println!(" leaf {:<20} kind=0x{:<6x} {}", le.name, le.kind, lts.join(" "));
}
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
for t in a {
let i:usize=t.parse().unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
println!("=== entry {i} ===");
let order=ui_layout::derived_paint_order(&b,&by);
for e in &b.elements {
let k=ui_layout::sprite_layer_key(&b,&by,e);
let pos=order.iter().position(|&x|x==e.index);
println!(" [{}] {:<24} kind=0x{:<5x} sprite={:<24} key={:<12} paint#{:?}",
e.index, e.name, e.kind,
e.sprite.clone().unwrap_or_else(||"<none>".into()),
k.map(|v|format!("0x{v:08x}")).unwrap_or_else(||"NONE".into()), pos);
}
}
}

View File

@@ -0,0 +1,39 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main(){
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
let ar=pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap();
for (i,e) in ar.entries().iter().enumerate() {
let Ok(by)=ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b)=ui_layout::parse_build(&by) else { continue };
let Some(p)=b.elements.iter().find(|el|el.name=="pbafc.prm") else { continue };
println!("=== entry {i}: {} elements ===", b.elements.len());
println!(" pbafc.prm pivot=({},{}) -> {}x{}", p.pivot_x,p.pivot_y,p.pivot_x*2,p.pivot_y*2);
for k in &p.keyframes {
println!(" t={:<5} fade={:08x} a={:<4} xy=({},{}) s={}/{}",
k.time.map(|v|v as i64).unwrap_or(-1), k.fade, k.fade>>24, k.x,k.y,k.scale_x,k.scale_y);
}
// what does it cover, and is anything visible while it is opaque?
let rest=p.rest().unwrap();
let (px,py,pw,ph)=(rest.x, rest.y, (p.pivot_x*2) as i32, (p.pivot_y*2) as i32);
println!(" its rect at rest: ({px},{py}) {pw}x{ph}");
let tmax=b.elements.iter().flat_map(|e|e.keyframes.iter().filter_map(|k|k.time)).max().unwrap_or(0);
let op:Vec<u32>=(0..=tmax).filter(|&t|p.pose_at(t).map(|k|k.fade>>24)==Some(255)).collect();
println!(" opaque at {} instants (t={:?}..{:?}) of 0..{tmax}", op.len(), op.first(), op.last());
let mut cov=0; let mut vis=0;
for o in &b.elements {
if o.index==p.index { continue }
let Some(ok)=o.rest() else { continue };
let (ow,oh)=((o.pivot_x*2) as i32,(o.pivot_y*2) as i32);
let overlap=(px+pw).min(ok.x+ow)-px.max(ok.x)>0 && (py+ph).min(ok.y+oh)-py.max(ok.y)>0;
if !overlap { continue }
cov+=1;
if op.iter().any(|&t| o.pose_at(t).map(|k|k.fade>>24).unwrap_or(0)>0) {
vis+=1;
if vis<=6 { println!(" covered AND visible while opaque: {}", o.name); }
}
}
println!(" elements its rect covers: {cov}; visible while it is opaque: {vis}");
break;
}
}

View File

@@ -0,0 +1,27 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
for t in a {
let i:usize=t.parse().unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let Some(b)=ui_layout::parse_build(&by) else { continue };
let top=u32::from_be_bytes(by[8..12].try_into().unwrap());
println!("entry {i:2} TOP +08 = {top}");
let mut ks:Vec<&String>=b.records.keys().collect(); ks.sort();
for rn in ks {
let &(o,s)=b.records.get(rn).unwrap();
if o+16>by.len() { continue }
let magic=&by[o..o+4];
let h4=u32::from_be_bytes(by[o+4..o+8].try_into().unwrap());
let h8=u32::from_be_bytes(by[o+8..o+12].try_into().unwrap());
let maxt=ui_layout::parse_build(&by[o..o+s])
.map(|lb|lb.elements.iter().flat_map(|e|e.keyframes.iter()
.filter_map(|k|k.time)).max().unwrap_or(0)).unwrap_or(0);
println!(" {rn:<24} magic={:?} +04={:08x}({:.1}) +08={h8:<6} max keyframe t={maxt} ratio={:.4}",
String::from_utf8_lossy(magic), h4, h4 as f64/65536.0,
if maxt>0 {h8 as f64/maxt as f64} else {0.0});
}
}
}

View File

@@ -0,0 +1,53 @@
//! Does `resolve_movie_voice_region` start LATE, and by exactly how much?
//!
//! The port agent's arithmetic: the running decoder's three `ADV` XMA contexts sum
//! to **3 584 000** payload bytes, but the resolved voice region is **3 114 352** —
//! 15 % too small to hold them. One of the two spans is not what the other thinks
//! it is, and the disc side is this crate's.
//!
//! The gap is exact. `ctx0` declares **632** packets (1 294 336 B); the leading
//! chunk the resolver yields has **394** (806 912 B). The difference is **238
//! packets = 487 424 B**, a whole number of packets — which is what a start offset
//! looks like, not corruption.
//!
//! So: walk the region start backwards and report where `to_xma_riffs` first
//! reproduces the decoder's own three sizes. The probe's byte_sizes are the
//! control — this is not free to fit, it either lands on them or it does not.
//!
//! cargo run -p sylpheed-formats --example adv_region_extend
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
/// What the running decoder reported (docs/re/structures/voice-three-streams-are-concurrent.md).
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let (start, end) =
media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English).expect("region");
println!("resolver says {start}..{end} ({} B)", end - start);
println!("decoder wants {:?} = {} B payload\n", WANT, WANT.iter().sum::<usize>());
for back_packets in [0usize, 100, 200, 237, 238, 239, 300, 400] {
let back = (back_packets * 2048) as u64;
if back > start {
continue;
}
let s = start - back;
let Ok(bytes) = src.read_segment_range("dat/sound", s, (end - s) as usize) else {
println!("-{back_packets:4} packets: unreadable");
continue;
};
let riffs = slb::to_xma_riffs(&bytes);
let sizes: Vec<usize> = riffs.iter().map(|r| r.len() - 60).collect();
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
println!(
"-{back_packets:4} packets (start {s}): {} chunk(s) {:?}{}",
riffs.len(),
sizes,
if hit { " <== MATCHES THE DECODER" } else { "" }
);
}
}

View File

@@ -0,0 +1,41 @@
//! Dump `ADV`'s voice chunks as RIFF/XMA, so each can be decoded and identified.
//!
//! `intro-audio-decomposed.md` measured that the intro's output is the movie's own
//! WMA Pro 5.1 track at 0.600 **plus** three streams occupying a front pair, a
//! centre (with a silent partner) and a rear pair. What it could **not** say is
//! *which* stream sits where — the assignment there is by position, not content.
//! The port needs that to weight a positional downmix.
//!
//! This writes the chunks out so they can be decoded (ffmpeg has `xma2`) and
//! correlated against the per-channel residuals.
//!
//! cargo run -p sylpheed-formats --example adv_voice_dump -- OUTDIR [MOVIE]
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
fn main() {
let out = std::env::args().nth(1).expect("OUTDIR");
let movie = std::env::args().nth(2).unwrap_or_else(|| "ADV".into());
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
std::fs::create_dir_all(&out).expect("outdir");
let (start, end) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
.expect("voice region");
let bytes = src
.read_segment_range("dat/sound", start, (end - start) as usize)
.expect("region");
println!("{movie}: region {start}..{end} = {} B", end - start);
let riffs = slb::to_xma_riffs(&bytes);
println!("{} RIFF chunk(s)", riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = format!("{out}/{movie}_{i}.xma");
std::fs::write(&p, r).expect("write");
// the probe reports `byte_size` = RIFF total - 60; print both so the
// dump can be tied to a specific XMA context by its own number
println!(" chunk {i}: {} B byte_size-equivalent {} -> {p}",
r.len(), r.len() as i64 - 60);
}
}

View File

@@ -0,0 +1,40 @@
//! List a sound bank's streams and their declared rates.
//!
//! cargo run -p sylpheed-formats --example bank_streams -- <disc> BGM_102.slb …
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::{hash::name_hash, slb};
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().expect("usage: bank_streams <disc> NAME.slb…");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
for name in a {
let h = name_hash(&name);
match media::read_sound_bank(&src, h) {
Ok(bytes) => {
let riffs = slb::to_xma_riffs(&bytes);
println!(
"{name} (hash {h:08x}, {} B on disc) header {:?} -> {} stream(s)",
bytes.len(),
slb::bank_header_len(&bytes),
riffs.len()
);
for (i, r) in riffs.iter().enumerate() {
let rate = if r.len() >= 0x28 {
u32::from_le_bytes(r[0x20..0x24].try_into().unwrap())
} else {
0
};
let payload = r.len() - 60;
println!(
" stream {i}: payload {payload} B ({} packets) declared {rate} B/s \
=> {:.3} s",
payload / 2048,
if rate > 0 { payload as f64 / rate as f64 } else { 0.0 }
);
}
}
Err(e) => println!("{name}: {e}"),
}
}
}

View File

@@ -0,0 +1,22 @@
//! Dump one BGM bank's waves as RIFF/XMA so they can be decoded and compared
//! against a capture of the running game.
//!
//! cargo run -p sylpheed-formats --example bgm_dump -- BGM_103.slb OUTDIR
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::slb;
fn main() {
let name = std::env::args().nth(1).unwrap_or_else(|| "BGM_103.slb".into());
let out = std::env::args().nth(2).expect("OUTDIR");
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
std::fs::create_dir_all(&out).expect("outdir");
let h = sylpheed_formats::hash::name_hash(&name);
let bytes = media::read_sound_bank(&src, h).expect("bank");
println!("{name}: {} B", bytes.len());
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
let p = format!("{out}/{}_{i}.xma", name.trim_end_matches(".slb"));
std::fs::write(&p, r).expect("write");
println!(" wave {i}: {} B (byte_size {}) -> {p}", r.len(), r.len() - 60);
}
}

View File

@@ -0,0 +1,78 @@
//! Does a screen declare its own OPAQUE BLACK backdrop? Disc-wide.
//!
//! `sylpheed-port` observed that the splash builds declare `palogo_eff0.prm` as a
//! full-screen primitive at t=0 with `fade_argb 0xff000000` -- alpha 255 over RGB
//! 000000 -- and turned it into a candidate predicate: a declared opaque-black
//! backdrop separates STANDALONE screens from COMPOSITED ones. On their sixteen
//! exported screens it splits 12 / 4, with all four exceptions independently known
//! to be composited (the two `press_start` plates, and two loading builds that
//! carry the `pgloading_*` set without its backdrop).
//!
//! That matters because the corpus previously told them "no content rule exists,
//! take the entry index" -- correct for the question asked (recognise the splash),
//! but this is a content rule for a different and useful question. They asked for
//! it to be tested against an archive they do not have. This is that test.
//!
//! CONTROL: it must reproduce the 12/4 split on GP_TITLE's sixteen composable
//! bundles before its disc-wide numbers mean anything.
//!
//! cargo run -p sylpheed-formats --example black_backdrop_predicate
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
/// A screen declares its own backdrop if some `.prm` primitive holds
/// `fade == 0xff000000` at t = 0: full alpha over black.
fn has_black_backdrop(b: &ui_layout::UiBuild) -> Option<String> {
for el in &b.elements {
if !el.name.ends_with(".prm") { continue }
if let Some(k) = el.keyframes.iter().find(|k| k.time == Some(0)) {
if k.fade == 0xff00_0000 { return Some(el.name.clone()) }
}
}
None
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
println!("== CONTROL: GP_TITLE's 16 composable bundles (port reports 12 with, 4 without)");
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut y, mut n) = (0, 0);
for e in 0..16usize {
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
match has_black_backdrop(&b) {
Some(nm) => { y += 1; println!(" entry {e:>2} YES {nm}") }
None => { n += 1; println!(" entry {e:>2} no") }
}
}
println!(" -> {y} with, {n} without\n");
println!("== DISC-WIDE, over every screen build");
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut tot, mut with) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut t, mut w) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
t += 1;
if has_black_backdrop(&b).is_some() { w += 1 }
}
if t > 0 {
println!("{name:30} {w:4} / {t:<4} declare a black backdrop");
std::io::stdout().flush().ok();
}
tot += t; with += w;
}
println!("\n{with} of {tot} screen builds disc-wide declare an opaque-black backdrop \
({:.1} %)", 100.0 * with as f64 / tot as f64);
println!("--- END ---");
}

View File

@@ -0,0 +1,63 @@
//! How often is a screen's design size READ, and how often is it FABRICATED?
//!
//! `ui_layout.rs` scans the `.rat` records for a `(w,h)` at `+0x18`/`+0x1c` and,
//! finding none, falls back to `(DESIGN_W, DESIGN_H)` = 1280x720. Its own comment
//! says "every screen seen is 1280x720, **which is also the fallback**" -- which
//! is precisely the problem: the fabricated value equals the expected one, so no
//! output of the parser can distinguish a read design size from an invented one.
//! The port sizes its screens off this number.
//!
//! This replicates the scan through the public RATC API and counts.
//!
//! cargo run -p sylpheed-formats --example design_size_fallback
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
use std::io::Write;
use std::path::PathBuf;
fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() { return 0 }
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut read, mut fell_back, mut nonstd) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut r, mut f) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(kids) = ratc::parse(&by) else { continue };
// the same predicate ui_layout uses, over the same records
// ⚠️ A first version took EVERY RATC child and failed its control:
// it reported all 965 builds stating a non-1280x720 size, where
// `screen list` prints 1280x720 for every one. `records` in
// ui_layout is the `.rat` children only; a T8aD sprite header read
// at +0x18 is garbage that passes the range test.
let found = kids.iter().filter(|k| k.kind == "RATC" || k.name.ends_with(".rat")).find_map(|k| {
let rec = &by[k.offset..(k.offset + k.size).min(by.len())];
let (w, h) = (be32(rec, 0x18), be32(rec, 0x1c));
(w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h))
});
match found {
Some((w, h)) => { r += 1; if (w, h) != (1280, 720) { nonstd += 1;
println!(" {name} : a build states a NON-standard design size {w}x{h}"); } }
None => f += 1,
}
}
if r + f > 0 {
println!("{name:30} {r:5} read {f:5} FABRICATED");
std::io::stdout().flush().ok();
}
read += r; fell_back += f;
}
println!("\n{read} builds state a design size, {fell_back} get the 1280x720 FALLBACK");
println!("{nonstd} builds state something other than 1280x720");
println!("--- END ---");
}

View File

@@ -0,0 +1,123 @@
//! Which cue owns an XMA stream of a given payload size?
//!
//! A boot with `--xma_param_probe` logs each decoded stream's `byte_size`. Three
//! of the five on the take-2 `ADV` boot are that movie's own streams; two —
//! 1 150 976 and 1 269 760 B — belong to something unidentified. The probe gives
//! a size and nothing else, so the disc has to be asked which cue has a stream
//! that long.
//!
//! Searches every inter-descriptor span of the continuous voice stream, and
//! every `sound.pak` entry, for a stream whose payload matches.
//!
//! cargo run -p sylpheed-formats --example find_stream_by_size -- <disc> <bytes>…
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::{slb, PakArchive};
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
fn main() {
let mut args = std::env::args().skip(1);
let disc = args.next().expect("usage: find_stream_by_size <disc> <bytes>…");
let wanted: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
assert!(!wanted.is_empty(), "give at least one payload size");
// A `to_xma_riffs` chunk is the payload plus a 60-byte RIFF wrapper.
let want_riff: Vec<usize> = wanted.iter().map(|w| w + 60).collect();
println!("looking for payloads {wanted:?} (riff sizes {want_riff:?})\n");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// --- 1. the continuous movie-voice stream, span by span
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
let win_start: u64 = 421_739_888 & !3;
let buf = src
.read_segment_range("dat/sound", win_start, 116_300_000)
.expect("window");
let descs = all_descriptors(&buf);
println!("voice stream: {} descriptors", descs.len());
let mut hits = 0;
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
for (i, r) in slb::to_xma_riffs(&buf[a..b]).iter().enumerate() {
if want_riff.contains(&r.len()) {
let name = name_of
.get(&w[1].1)
.cloned()
.unwrap_or_else(|| format!("id{}", w[1].1));
println!(
" ✅ cue {name} (id {}) stream {i}: payload {} B",
w[1].1,
r.len() - 60
);
hits += 1;
}
}
}
println!(" {hits} hit(s) in the voice stream\n");
// --- 2. every sound.pak entry
let stoc = src.read_file("dat/sound.pak").expect("sound.pak toc");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
println!("sound.pak: {} entries", entries.len());
let mut phits = 0;
let mut scanned = 0usize;
for e in &entries {
// Only entries big enough to hold the target.
let need = wanted.iter().copied().min().unwrap_or(0) as u32;
if e.comp_size < need {
continue;
}
let Ok(bytes) = src.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize)
else {
continue;
};
scanned += 1;
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
if want_riff.contains(&r.len()) {
println!(
" ✅ sound.pak entry hash {:08x} offset {} size {} — stream {i}: payload {} B",
e.name_hash,
e.offset,
e.comp_size,
r.len() - 60
);
phits += 1;
}
}
}
println!(" scanned {scanned} entries large enough; {phits} hit(s)");
}

View File

@@ -0,0 +1,65 @@
//! Which focus records have a VARYING alpha — disc-wide, not export-wide?
//!
//! `rest()` returns an element's last hold keyframe. For a constant-alpha
//! element that is harmless. For one that pulses it returns the PEAK, which is
//! the `ui-settle-time.md` pathology: the plate's `ptbtn00f` ramps 0→80→0 and
//! `rest()` reports 80, its maximum.
//!
//! The port censused this over its own export (34 records, 2 varying) and
//! concluded there is nothing to fix. That conclusion is only as wide as the
//! export. This asks the same question of the whole disc.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut n_rec, mut n_elem, mut varying) = (0usize, 0usize, 0usize);
let (mut at_peak, mut mid_ramp) = (0usize, 0usize);
let mut by_pak: std::collections::BTreeMap<String, usize> = Default::default();
let mut hits: Vec<String> = Vec::new();
for p in &paks {
let pn = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
// A focus record is one whose name is another record's plus `f`.
let Some(stem) = rn.strip_suffix("f.rat") else { continue };
if !b.records.contains_key(&format!("{stem}.rat")) { continue }
if o + s > by.len() { continue }
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
n_rec += 1;
for el in &lb.elements {
if el.keyframes.is_empty() { continue }
n_elem += 1;
let a: Vec<u32> = el.keyframes.iter().map(|k| k.fade >> 24).collect();
let (lo, hi) = (*a.iter().min().unwrap(), *a.iter().max().unwrap());
if lo == hi { continue }
varying += 1;
let rest = el.rest().map(|k| k.fade >> 24).unwrap_or(0);
if rest == hi { at_peak += 1 } else { mid_ramp += 1 }
*by_pak.entry(pn.clone()).or_default() += 1;
hits.push(format!(
"{pn} [{ei}] {rn}::{} alpha {lo}..{hi} rest()={rest}{}",
el.name, if rest == hi { " 🔴 == PEAK" } else { "" }));
}
}
}
}
println!("focus records disc-wide : {n_rec}");
println!(" their timed elements : {n_elem}");
println!(" with a VARYING alpha : {varying}");
println!(" of which rest() == the PEAK : {at_peak} <- burns bright forever");
println!(" of which rest() is MID-RAMP : {mid_ramp} <- neither extreme; looks plausible");
println!("\nby pak:");
for (k, v) in &by_pak { println!(" {k:<34} {v}") }
println!("\nevery varying one:");
hits.sort(); hits.dedup();
for h in &hits { println!(" {h}") }
println!("\n({} distinct)", hits.len());
}

View File

@@ -0,0 +1,90 @@
//! Reconcile two ink counts for one screen that were never counting the same pixels.
//!
//! The port agent double-witnessed the pixel-cost claim in Godot — a renderer
//! sharing no code with `compose` — and got `GP_TITLE` entry 12 at **59 530 px**
//! ink above threshold 0 and **48 368** above 1. This crate reported **49 771**.
//! Neither is wrong; the question is which convention each was using, and on a
//! mostly-dark frame the answer moves thousands of pixels.
//!
//! So: count the same composite every way, and print the family. Whichever row
//! the port's numbers land in is the convention, and then the two renderers can be
//! compared on purpose rather than by coincidence.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
use ui_layout::ComposeOptions;
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn counts(rgba: &[u8], t: u8) -> (usize, usize) {
let rgb = rgba
.chunks_exact(4)
.filter(|p| p[0] > t || p[1] > t || p[2] > t)
.count();
let alpha = rgba.chunks_exact(4).filter(|p| p[3] > t).count();
(rgb, alpha)
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [12usize, 15] {
let by = ar.read(&ar.entries()[entry]).expect("entry");
let b = ui_layout::parse_build(&by).expect("parse");
for (label, opts) in [
(
"primitives on (what the cost run used)",
ComposeOptions {
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
),
(
"primitives+focus+animated",
ComposeOptions {
include_primitives: true,
include_focus: true,
include_animated: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
),
] {
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
println!("\n== GP_TITLE entry {entry}{label} ({}x{})", a.width, a.height);
println!(" threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT");
for t in [0u8, 1, 2, 4, 8, 16] {
let (r1, a1) = counts(&a.rgba, t);
let (r0, a0) = counts(&c.rgba, t);
println!(" >{t:<8} | {r1:>16} | {a1:>14} | {r0:>13} | {a0:>11}");
}
let changed = a
.rgba
.chunks_exact(4)
.zip(c.rgba.chunks_exact(4))
.filter(|(x, y)| x != y)
.count();
println!(" exact-RGBA changed pixels between the two orders: {changed}");
}
}
}

View File

@@ -0,0 +1,71 @@
//! Of the forced instances the rule merely CONFIRMS, how many have a key READ
//! FROM THE FILE, and how many an IMPLIED key that is itself a measurement?
//!
//! `forced_backdrop_necessity.rs` asked only whether an element had *a* key,
//! collapsing `sprite_layer_key` (a `u16` read out of the `T8aD` header — decoded)
//! with `implied_layer_key` (this crate's per-name table of positions **measured
//! in the running game**). For counting whether the rule moves anything that is
//! the right question. For describing what a confirmation is *made of*, it is not:
//! "the file already settles it" and "another measurement already settles it" are
//! different claims, and a reader who sees "own key" will take the first.
//!
//! Raised by the port agent 2026-08-30.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_key_source
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut read, mut implied, mut none) = (0usize, 0usize, 0usize);
println!("# archive entry element key_source key");
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
if !ui_layout::forced_backdrop(&b, el) {
continue;
}
let (src, key) = match ui_layout::sprite_layer_key(&b, &by, el) {
Some(k) => {
read += 1;
("read_T8aD", Some(k))
}
None => match ui_layout::implied_layer_key(&el.name) {
Some(k) => {
implied += 1;
("implied_MEASURED", Some(k))
}
None => {
none += 1;
("none", None)
}
},
};
println!(
" {name} {i} {} {src} {}",
el.name,
key.map(|k| format!("0x{k:08X}")).unwrap_or("-".into())
);
}
}
}
println!("\n# forced instances by key source:");
println!("# read from the T8aD header (decoded): {read}");
println!("# implied — this crate's MEASURED name table: {implied}");
println!("# none — only forced_backdrop can speak: {none}");
}

View File

@@ -0,0 +1,114 @@
//! Which screens does `forced_backdrop` DECIDE, and which does it merely agree with?
//!
//! Every check this corpus has run on the rule measured its **stability** — that
//! no verdict moved when something else changed. That is a different property
//! from **necessity**: an element whose position is already fixed by a read or an
//! implied key is confirmed by the rule, not decided by it.
//!
//! So: compute `derived_paint_order` with the rule, and again with the
//! `forced_backdrop` fallback removed, and report every entry whose order moves.
//! Where nothing moves, the rule is decorative on that screen; where it moves,
//! the rule is the only thing holding the order up.
//!
//! Raised by the port agent 2026-08-30. Reach note in
//! `docs/re/structures/ui-forced-backdrop.md`.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_necessity -- [pak...]
//!
//! 🔴 With no argument this used to default to `GP_TITLE` alone, so a bare run
//! reported **6 instances, not 80** — a thirteenth of the census, printed in the
//! same format and reading like the whole thing. The port agent hit it and nearly
//! filed the discrepancy back at me. It now walks every `dat/*.pak` by default and
//! says on stderr how many archives it opened, because "I ran your instrument" has
//! to mean the same thing to both of us.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let (mut total_decides, mut total_agrees) = (0usize, 0usize);
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paks.is_empty() {
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
all.sort();
paks = all;
}
eprintln!("# scanning {} archive(s)", paks.len());
for path in &paks {
let ar = PakArchive::open(path).expect("pak");
println!("# {}", path.display());
println!("# entry forced decides elements note");
let mut decides = Vec::new();
let mut agrees = Vec::new();
#[allow(unused)]
let _ = (&decides, &agrees);
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
// Which elements does the rule fire on, and of those, which have no key
// of their own to fall back on?
let mut forced = Vec::new();
let mut keyless = Vec::new();
for el in &b.elements {
if !ui_layout::forced_backdrop(&b, el) {
continue;
}
forced.push(el.name.clone());
let own = ui_layout::sprite_layer_key(&b, &by, el)
.or_else(|| ui_layout::implied_layer_key(&el.name));
if own.is_none() {
keyless.push(el.name.clone());
}
}
if forced.is_empty() {
continue;
}
let moved = with != without;
if moved {
decides.push(i);
} else {
agrees.push(i);
}
println!(
" {i:>5} {:>6} {:>7} {:>8} forced=[{}] keyless=[{}]",
forced.len(),
if moved { "YES" } else { "no" },
b.elements.len(),
forced.join(","),
keyless.join(","),
);
}
println!("# rule DECIDES the order on entries {decides:?}");
println!("# rule merely AGREES on entries {agrees:?}\n");
total_decides += decides.len();
total_agrees += agrees.len();
}
println!("# TOTAL over {} archive(s): {total_decides} deciding entries, \
{total_agrees} agreeing", paks.len());
}

View File

@@ -0,0 +1,119 @@
//! What does `forced_backdrop` cost IN PIXELS on the screens it decides?
//!
//! `forced_backdrop_necessity.rs` answers "does the derived ORDER move", which is
//! a property of the sort. The port agent then pointed out — correctly — that its
//! re-run of that probe was **my code executed twice**, not a second witness, so
//! the disc-wide 62 has one measurement behind it and only `GP_TITLE` has two.
//!
//! This does not fix that (it is still this crate), but it moves the question to a
//! **different layer**: render each deciding build twice, once in the order
//! `compose` derives and once with the `forced_backdrop` fallback removed, and
//! count the pixels that differ. "The order moved" and "the picture moved" are not
//! the same claim, and the second is the one anybody cares about — the tie-break
//! work already found overlapping reorders that cost exactly zero pixels.
//!
//! Each entry carries its own CONTROL: the pixel count of the composite itself.
//! If a build renders empty, its zero means the instrument saw nothing, not that
//! the rule is free.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost -- [pak...]
//!
//! With no argument it walks **every `dat/*.pak`** — the necessity probe defaulted
//! to `GP_TITLE`, which made a bare run report a thirteenth of the census and read
//! like the whole thing.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
use ui_layout::ComposeOptions;
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paks.is_empty() {
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
all.sort();
paks = all;
}
eprintln!("# scanning {} archive(s)", paks.len());
let opts = ComposeOptions {
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
};
println!("# archive entry element changed_px total_px ink_px(control) pct");
let (mut decided, mut zero_cost, mut blind) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
if with == without {
continue;
}
let forced: Vec<&str> = b
.elements
.iter()
.filter(|el| ui_layout::forced_backdrop(&b, el))
.map(|el| el.name.as_str())
.collect();
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
let c = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&without));
let n = a
.rgba
.chunks_exact(4)
.zip(c.rgba.chunks_exact(4))
.filter(|(x, y)| x != y)
.count();
// Control: does this build put any ink down at all, against the bare
// backdrop? A build that renders to nothing cannot show a reorder.
let ink = a
.rgba
.chunks_exact(4)
.filter(|p| p[..3] != [0, 0, 0])
.count();
let total = a.rgba.len() / 4;
decided += 1;
if ink == 0 {
blind += 1;
} else if n == 0 {
zero_cost += 1;
}
println!(
" {name} {i} {} {n} {total} {ink} {:.2}%",
forced.join(","),
100.0 * n as f64 / total as f64
);
}
}
println!("\n# builds whose ORDER the rule decides: {decided}");
println!("# of those, costing ZERO pixels: {zero_cost}");
println!("# of those, BLIND (build renders no ink, control fails): {blind}");
}

View File

@@ -0,0 +1,86 @@
//! Do `ui_layout`'s two IN-RANGE fallbacks ever fire? Counted, disc-wide.
//!
//! An in-range fallback supplies a value that is legitimate, so no output can
//! distinguish it from the real thing and inspection cannot settle it. The only
//! question that has an answer is *how often does it fire*.
//!
//! ui_layout.rs:1681 kf.time.unwrap_or(0) -- 0 is a real keyframe time
//! (pose 0's time IS 0), so a fabricated one is invisible.
//! ui_layout.rs:1010 pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0
//! -- alpha 0 is legitimate, and it makes "no pose here"
//! read as "fully transparent", biasing an occlusion test
//! toward NOT occluded.
//!
//! (ui_layout.rs:973's `unwrap_or(0)` is NOT counted: it is guarded two lines
//! later by `if tmax == 0 { return false; }`, so 0 is handled, not assumed.)
//!
//! cargo run -p sylpheed-formats --example inrange_fallback_count
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut kf, mut untimed, mut builds) = (0u64, 0u64, 0u64);
let (mut queries, mut none_at) = (0u64, 0u64);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
builds += 1;
// (a) :1681 -- how many poses carry no time?
for el in &b.elements {
for k in &el.keyframes {
kf += 1;
if k.time.is_none() { untimed += 1 }
}
}
// (b) :1010 -- ask every element for a pose at every time that any
// element declares, which is the set the occlusion test draws from.
let mut times: Vec<u32> = b.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
times.sort_unstable(); times.dedup();
for el in &b.elements {
for &t in &times {
queries += 1;
if el.pose_at(t).is_none() { none_at += 1 }
}
}
}
print!("."); std::io::stdout().flush().ok();
}
println!();
println!("{builds} builds, {kf} keyframes");
println!(":1681 untimed poses (the fallback would fabricate t=0): {untimed}");
println!(":1010 pose_at queries {queries}, of which None (fallback reads a=0): {none_at}");
// NEGATIVE CONTROL. Both counters above report 0, and a zero is the result
// this corpus has learned to distrust most -- it reads clean rather than
// suspicious. So prove the detector CAN see a hit: ask every element for a
// pose at a time no build declares. If pose_at is total, `none_out` is 0 too
// and the 0 above means nothing.
let mut out_queries = 0u64;
let mut none_out = 0u64;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
for &t in &[u32::MAX, 1_000_000u32] {
out_queries += 1;
if el.pose_at(t).is_none() { none_out += 1 }
}
}
}
}
println!("CONTROL pose_at at an undeclared time: {out_queries} queries, {none_out} None");
println!(" (if this is 0 the detector is blind and the 0 above is meaningless)");
println!("--- END ---");
}

View File

@@ -0,0 +1,72 @@
//! Do `+4` / `+8` = 180 mean MIRROR?
//!
//! The disc-wide census shows `+4` and `+8` are dominated by 180 and ±90, while
//! `+12` (the decoded screen-plane rotation) takes 157 distinct values including
//! odd ones. That shape says flips rather than free rotation.
//!
//! Structural test, no renderer involved: if 180 means "mirror", then the same
//! sprite should appear both with the field 0 and with it 180 **within one
//! build** -- a mirrored pair. Free-rotation semantics predicts no such pairing.
//!
//! CONTROL: the same search run on `+12`, which is decoded as a real rotation
//! and should NOT show a 0/180 pairing pattern of the same strength.
use sylpheed_formats::{pak, ui_layout};
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let dir = std::env::args().nth(1).expect("<disc>/dat");
let mut paks: Vec<_> = std::fs::read_dir(&dir).expect("dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)).collect();
paks.sort();
// field -> (pak, entry, sprite) -> set of values seen
let mut seen: [BTreeMap<(String, usize, String), BTreeSet<i32>>; 3] =
[BTreeMap::new(), BTreeMap::new(), BTreeMap::new()];
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
let pn = p.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> =
b.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
for el in &b.elements {
if let Some(&(off, size)) = b.records.get(&el.name) {
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
for le in &lb.elements {
groups.push((le.name.clone(), le.keyframes.clone()));
}
}
}
}
for (nm, ks) in groups {
for k in &ks {
let key = (pn.clone(), i, nm.clone());
seen[0].entry(key.clone()).or_default().insert(k.unknown_4);
seen[1].entry(key.clone()).or_default().insert(k.unknown_8);
seen[2].entry(key).or_default().insert(k.rotation_deg);
}
}
}
}
for (idx, label) in [(0, "+4"), (1, "+8"), (2, "+12 (rotation, CONTROL)")] {
let m = &seen[idx];
let mut pair_0_180 = 0usize; // a sprite seen at BOTH 0 and 180 in one build
let mut only_180 = 0usize;
let mut multi = 0usize; // more than two distinct values
for v in m.values() {
if v.len() > 2 { multi += 1; }
let has0 = v.contains(&0);
let has180 = v.contains(&180) || v.contains(&-180);
if has0 && has180 { pair_0_180 += 1; }
else if has180 && !has0 { only_180 += 1; }
}
println!("{label}: {} sprite-instances", m.len());
println!(" both 0 and ±180 in one build : {pair_0_180}");
println!(" ±180 without any 0 : {only_180}");
println!(" more than 2 distinct values : {multi}");
}
}

View File

@@ -0,0 +1,19 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap(); let bi:usize=a.next().unwrap().parse().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[bi]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
for t in a {
let i:usize=t.parse().unwrap();
let e=&b.elements[i];
println!("[{i}] {} kind=0x{:x} parent={:?} pivot=({},{}) kfs={}",
e.name, e.kind, e.parent, e.pivot_x, e.pivot_y, e.keyframes.len());
for k in &e.keyframes {
println!(" t={:<5} a={:<4} xy=({},{}) s={}/{} rot={} u4={} u8={} tint={:08x}",
k.time.map(|v|v as i64).unwrap_or(-1), k.fade>>24, k.x,k.y,
k.scale_x,k.scale_y,k.rotation_deg,k.unknown_4,k.unknown_8,k.tint);
}
}
}

View File

@@ -0,0 +1,83 @@
//! What are the keyframe block's `+4` and `+8`?
//!
//! `+12` is decoded as a screen-plane rotation in degrees. `+4` and `+8` sit
//! immediately before it and are carried but unexplained; one standing 🟡
//! reading is that the three together are rotations about three axes, "not tied
//! to an observed rotation". This censuses them across every UI pak on the disc
//! so the reading can be argued with rather than assumed.
use sylpheed_formats::{pak, ui_layout};
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("usage: <disc>/dat");
let mut paks: Vec<_> = std::fs::read_dir(&dir)
.expect("dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
let mut h4: BTreeMap<i32, usize> = BTreeMap::new();
let mut h8: BTreeMap<i32, usize> = BTreeMap::new();
let mut h12: BTreeMap<i32, usize> = BTreeMap::new();
let mut both_nz: Vec<String> = Vec::new();
let (mut kfs, mut builds) = (0usize, 0usize);
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
let name = p.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
builds += 1;
// parents and leaves alike
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> = b
.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
for el in &b.elements {
if let Some(&(off, size)) = b.records.get(&el.name) {
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
for le in &lb.elements {
groups.push((format!("{}->{}", el.name, le.name), le.keyframes.clone()));
}
}
}
}
for (nm, ks) in groups {
for k in &ks {
kfs += 1;
*h4.entry(k.unknown_4).or_default() += 1;
*h8.entry(k.unknown_8).or_default() += 1;
*h12.entry(k.rotation_deg).or_default() += 1;
if k.unknown_4 != 0 || k.unknown_8 != 0 {
both_nz.push(format!("{name} e{i} {nm} +4={} +8={} +12={}",
k.unknown_4, k.unknown_8, k.rotation_deg));
}
}
}
}
}
println!("{builds} builds, {kfs} keyframes (parents + leaves)\n");
for (nm, h) in [("+4", &h4), ("+8", &h8), ("+12 (rotation)", &h12)] {
let nz: usize = h.iter().filter(|(k, _)| **k != 0).map(|(_, v)| *v).sum();
println!("{nm}: {} distinct values, {} non-zero keyframes ({:.4}%)",
h.len(), nz, 100.0 * nz as f64 / kfs as f64);
let mut top: Vec<_> = h.iter().filter(|(k, _)| **k != 0).collect();
top.sort_by_key(|(_, v)| std::cmp::Reverse(**v));
for (k, v) in top.iter().take(6) { println!(" {k:>8} x{v}"); }
}
println!("\nkeyframes with a non-zero +4 or +8: {}", both_nz.len());
// Per-pak, so a reader can ask whether a pak they have a CAPTURE of is
// among them -- which decides whether the field is testable at all.
let mut per: BTreeMap<String, usize> = BTreeMap::new();
for l in &both_nz {
let pak = l.split_whitespace().next().unwrap_or("?").to_string();
*per.entry(pak).or_default() += 1;
}
println!(" by pak:");
for (k, v) in &per { println!(" {k:<28} {v}"); }
println!(" paks with NONE: (any UI pak not listed above)");
if let Ok(f) = std::env::var("KF_SHOW") {
println!("\n all lines for {f}:");
for l in both_nz.iter().filter(|l| l.starts_with(&f)) { println!(" {l}"); }
}
}

View File

@@ -0,0 +1,56 @@
//! How do a parent record's alpha ramp and its nested `.rat` leaf's compose?
//!
//! The port emits both but will not draw the leaf without knowing the rule --
//! rightly, since drawing on a guess trades a visible 1.82 % error for an
//! invisible wrong one. There is an oracle for this: the GPU draw capture
//! records **vertex colours**, and on the title's `ptloop` draw they are
//! `C3FFFFFF` and `B6FFFFFF` -- alpha **195** and **182**, not 255. So the game's
//! composed alpha is observable, and a candidate rule either predicts those two
//! numbers or does not.
//!
//! cargo run -p sylpheed-formats --example leaf_alpha_compose -- <GP_TITLE.pak>
use sylpheed_formats::{pak, ui_layout};
fn alpha_of(kf: &ui_layout::Keyframe) -> u32 {
// The keyframe block's +0 is an ARGB fade colour; alpha is its high byte.
kf.fade >> 24
}
fn main() {
let path = std::env::args().nth(1).expect("usage: <pak>");
let ar = pak::PakArchive::open(&path).expect("open");
let e = &ar.entries()[4]; // GP_TITLE entry 4 = the English title
let bytes = ar.read(e).expect("read");
let build = ui_layout::parse_build(&bytes).expect("build");
for name in ["ptloop01.rat", "ptloop02.rat"] {
println!("\n=== {name} ===");
if let Some(el) = build.elements.iter().find(|x| x.name == name) {
println!(" PARENT keyframes (t, alpha, scale, rot, x,y):");
for k in &el.keyframes {
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y);
}
}
match build.records.get(name) {
Some(&(off, size)) => {
let leaf = &bytes[off..off + size];
match ui_layout::parse_build(leaf) {
Some(lb) => {
for le in &lb.elements {
println!(" LEAF element {:?}:", le.name);
for k in &le.keyframes {
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y,
k.rotation_deg, k.x, k.y);
}
}
}
None => println!(" leaf did not parse"),
}
}
None => println!(" no leaf record"),
}
}
println!("\nOBSERVED in the draw capture: quad A alpha 195 (0xC3), quad B alpha 182 (0xB6)");
}

View File

@@ -0,0 +1,42 @@
//! Dump a record's parent keyframes and its nested `.rat` leaf's, for any entry.
//!
//! cargo run -p sylpheed-formats --example leaf_dump -- <pak> <entry> <name>
use sylpheed_formats::{pak, ui_layout};
fn a(k: &ui_layout::Keyframe) -> u32 { k.fade >> 24 }
fn t(k: &ui_layout::Keyframe) -> i64 { k.time.map(|v| v as i64).unwrap_or(-1) }
fn show(tag: &str, els: &[ui_layout::Element]) {
for e in els {
println!(" {tag} {:?} pivot=({},{}) kind={:#x}", e.name, e.pivot_x, e.pivot_y, e.kind);
for k in &e.keyframes {
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} pos=({},{}) tint={:#010x}",
t(k), a(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y, k.tint);
}
}
}
fn main() {
let mut it = std::env::args().skip(1);
let path = it.next().expect("pak");
let entry: usize = it.next().expect("entry").parse().expect("entry");
let want = it.next().expect("name");
let ar = pak::PakArchive::open(&path).expect("open");
let bytes = ar.read(&ar.entries()[entry]).expect("read");
let b = ui_layout::parse_build(&bytes).expect("build");
println!("entry {entry}: {} elements, design {}x{}", b.elements.len(), b.design_w, b.design_h);
let els: Vec<_> = b.elements.iter().filter(|e| e.name.contains(&want)).cloned().collect();
show("PARENT", &els);
for e in &els {
match b.records.get(&e.name) {
Some(&(off, size)) => {
println!(" -- leaf of {:?}: {size} B at {off}", e.name);
match ui_layout::parse_build(&bytes[off..off + size]) {
Some(lb) => show(" LEAF", &lb.elements),
None => println!(" leaf did not parse"),
}
}
None => println!(" -- {:?} has NO leaf record", e.name),
}
}
}

View File

@@ -0,0 +1,20 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
let by=ar.read(&ar.entries()[4]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
for n in ["ptloop01.rat","ptloop02.rat"] {
let el=b.elements.iter().find(|e| e.name==n).unwrap();
println!("{n}: kind={:#x} animated={} rec={:?}", el.kind, el.animated,
b.records.get(n).map(|&(o,s)|(o,s)));
if let Some(&(o,s))=b.records.get(n) {
match ui_layout::parse_build(&by[o..o+s]) {
Some(lb)=>for le in &lb.elements {
println!(" leaf {:?} sprite={:?} in_sprites={}", le.name, le.sprite,
le.sprite.as_ref().map(|x| b.sprites.contains_key(x)).unwrap_or(false));
},
None=>println!(" leaf parse FAILED"),
}
}
}
}

View File

@@ -0,0 +1,26 @@
// Leave-one-out over the elements that touch a band, dumping raw RGBA each time.
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let bi:usize=a.next().unwrap().parse().unwrap();
let dir=a.next().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[bi]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
let o=ui_layout::ComposeOptions{ backdrop:[0,0,0,255], ..Default::default() };
let n=b.elements.len();
let base=ui_layout::compose(&b,&by,o.clone(),None);
std::fs::write(format!("{dir}/base.raw"),&base.rgba).unwrap();
println!("canvas {}x{} elements {n}", base.width, base.height);
for i in 0..n {
let e=&b.elements[i];
let Some(kf)=e.rest() else { continue };
// only bother with elements whose rest pose can touch the band
let mut v=vec![true;n]; v[i]=false;
let c=ui_layout::compose(&b,&by,o.clone(),Some(&v));
std::fs::write(format!("{dir}/wo-{i}.raw"),&c.rgba).unwrap();
println!("{i}\t{}\t{}\t({},{})\ta={}", e.name,
e.sprite.clone().unwrap_or_default(), kf.x, kf.y, kf.fade>>24);
}
}

View File

@@ -0,0 +1,46 @@
//! Recover a `sound.pak` TOC name from its hash by generating candidates.
//!
//! The hash is a Barrett-reduction over the uppercased path, so it cannot be
//! inverted — but the naming is regular enough to enumerate. Tries the shapes
//! this disc actually uses for sound entries.
//!
//! cargo run -p sylpheed-formats --example name_from_hash -- <hex-hash>…
use sylpheed_formats::hash::name_hash;
fn main() {
let wanted: Vec<u32> = std::env::args()
.skip(1)
.filter_map(|a| u32::from_str_radix(a.trim_start_matches("0x"), 16).ok())
.collect();
assert!(!wanted.is_empty(), "give hashes in hex");
let langs = ["eng", "jpn", ""];
let dirs = ["", "Movie", "etc", "Voice", "Sound", "BGM", "bgm", "se", "SE"];
let mut tried = 0usize;
let mut check = |name: String, tried: &mut usize| {
*tried += 1;
let h = name_hash(&name);
if wanted.contains(&h) {
println!("{h:08x} {name}");
}
};
for l in langs {
for d in dirs {
let pre = match (l.is_empty(), d.is_empty()) {
(true, true) => String::new(),
(true, false) => format!("{d}\\"),
(false, true) => format!("{l}\\"),
(false, false) => format!("{l}\\{d}\\"),
};
for stem in ["BGM", "bgm", "JNGL", "jngl", "SE", "Static", "VOICE"] {
for n in 0..1200u32 {
check(format!("{pre}{stem}_{n:03}.slb"), &mut tried);
check(format!("{pre}{stem}{n:03}.slb"), &mut tried);
}
}
for bare in ["Static.slb", "static.slb", "SE.slb", "BGM.slb"] {
check(format!("{pre}{bare}"), &mut tried);
}
}
}
println!("tried {tried} candidate names");
}

View File

@@ -0,0 +1,63 @@
//! Where does `screen --build N`'s ORDINAL diverge from the pak ENTRY index?
//!
//! `screen render --build N` takes an ordinal into the filtered build list, not
//! a pak entry. On `GP_TITLE` `[10]` is entry 12, which is how I rendered two
//! loading screens while believing they were the splashes — and every downstream
//! number validated. This enumerates the divergence across the disc so any
//! `--build N` in `docs/` can be checked instead of trusted.
//!
//! Two lists, because `screen list --all` swaps the predicate (`is_composable`
//! for `is_build`) and therefore RENUMBERS: `--build 4` and `--build 4 --all`
//! are not necessarily the same object.
//!
//! cargo run -p sylpheed-formats --example ordinal_entry_map
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
/// One decompression pass per entry, both predicates applied to it: reading the
/// archive twice doubled the cost on `GP_READY_ROOM` (902 entries) for nothing.
fn maps(ar: &PakArchive) -> (Vec<usize>, Vec<usize>) {
let (mut d, mut a) = (Vec::new(), Vec::new());
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if ui_layout::is_build(&by) { d.push(i) }
if ui_layout::is_composable(&by) { a.push(i) }
}
(d, a)
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut clean, mut div, mut allshift) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (d, a) = maps(&ar);
if d.is_empty() && a.is_empty() { continue }
let bad = d.iter().enumerate().find(|(o, &e)| *o != e).map(|(o, _)| o);
// does `--all` renumber? compare the entry each ordinal resolves to
let shift = (0..d.len().min(a.len())).find(|&o| d[o] != a[o]);
let tag = match bad {
None => { clean += 1; format!("{:4} builds ordinal == entry throughout", d.len()) }
Some(o) => { div += 1;
let t: Vec<String> = d.iter().enumerate().skip(o).take(5)
.map(|(x, &y)| format!("[{x}]->{y}")).collect();
format!("{:4} builds 🔴 diverges at ordinal {o}: {}", d.len(), t.join(" ")) }
};
let s = match shift {
Some(o) => { allshift += 1;
format!(" ⚠️ --all renumbers from [{o}]: entry {} -> {}", d[o], a[o]) }
None if a.len() != d.len() => format!(" (--all appends {} more)", a.len() - d.len()),
None => String::new(),
};
println!("{name:30} {tag}{s}");
std::io::stdout().flush().ok();
}
println!("\n{clean} archives ordinal==entry, {div} diverge, {allshift} renumbered by --all");
println!("--- END ---");
}

View File

@@ -0,0 +1,40 @@
//! Are `palogo_gamearts_eff` / `palogo_seta_eff` dwell-FALLBACK cases, or PLATEAU
//! cases? The port agent lists them among `GP_TITLE`'s four visible fallback
//! fires; this census listed only `palogo_sqex_eff` and `palogo_anima_eff`.
//!
//! It matters because the two are different defects. A plateau is a pose the
//! element genuinely HOLDS, and `rest_plateau()` returning it is correct. Only the
//! fallback is the unsound path.
//! cargo run -p sylpheed-formats --example palogo_eff_check
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if !el.name.starts_with("palogo") || !el.name.contains("eff") { continue }
// A single-keyframe element has no gap to maximise, so neither path
// applies and `rest()` trivially returns the only pose. Excluding it
// here matches the census, which filters `len < 2`.
if el.keyframes.len() < 2 { continue }
let plateau = el.keyframes.windows(2).position(|w| {
w[0].x == w[1].x && w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade
});
let ks: Vec<String> = el.keyframes.iter().map(|k| format!(
"{}:a{} {},{} {}%",
k.time.map(|v| v.to_string()).unwrap_or("-".into()),
(k.fade >> 24) & 0xff, k.x, k.y, k.scale_x)).collect();
let r = el.rest();
println!("e{i:<3} {:24} kf=[{}]", el.name, ks.join(" "));
println!(" plateau at pair {:?} -> path: {} rest a={} t={:?}",
plateau,
if plateau.is_some() { "PLATEAU (sound: the pose is held)" } else { "DWELL FALLBACK (unsound)" },
r.map(|k| (k.fade >> 24) & 0xff).unwrap_or(0),
r.and_then(|k| k.time));
}
}
}

View File

@@ -0,0 +1,90 @@
//! When an element has MORE THAN ONE plateau, does `rest_plateau()` pick the
//! wrong one — and is that the 21.9 % residual?
//!
//! `rest_vs_settle` found that among elements holding a pose ACROSS the screen's
//! settle instant, `pose_at(settle)` and `rest()` still disagree 21.9 % of the
//! time. I hypothesised that `rest_plateau()` picks the **longest** run (it does —
//! `len >= any_len`), which need not be the run covering the settle instant.
//!
//! ⚠️ **Control**: on elements with exactly ONE plateau that covers the settle
//! instant, the two MUST agree. If they do not, the hypothesis is not the
//! explanation and something else is wrong.
//!
//! cargo run -p sylpheed-formats --example plateau_choice
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut one_cov, mut one_agree) = (0usize, 0usize); // control
let (mut multi_cov, mut multi_agree, mut multi_wrongrun) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
if hi - lo < 10 { continue }
let st = lo + (hi - lo) / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 { continue }
let same = |a: &ui_layout::Keyframe, c: &ui_layout::Keyframe| {
a.fade == c.fade && a.scale_x == c.scale_x && a.scale_y == c.scale_y
&& a.tint == c.tint && a.x == c.x && a.y == c.y
};
// enumerate maximal runs of length >= 2, with their time spans
let mut runs: Vec<(usize, usize)> = Vec::new();
let mut i = 0usize;
while i < k.len() {
let mut j = i;
while j + 1 < k.len() && same(&k[j], &k[j + 1]) { j += 1 }
if j - i + 1 >= 2 { runs.push((i, j)) }
i = j + 1;
}
if runs.is_empty() { continue }
let covers = |&(a, c): &(usize, usize)| match (k[a].time, k[c].time) {
(Some(t0), Some(t1)) => t0 <= st && st <= t1,
_ => false,
};
let covering: Vec<_> = runs.iter().filter(|r| covers(r)).collect();
if covering.is_empty() { continue }
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
let agree = r.fade == s.fade && r.x == s.x && r.y == s.y
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y;
if runs.len() == 1 {
one_cov += 1;
if agree { one_agree += 1 }
} else {
multi_cov += 1;
if agree { multi_agree += 1 }
else {
// did rest() land on a run that does NOT cover settle?
let on_covering = covering.iter().any(|&&(a, c)| {
(a..=c).any(|idx| {
let kk = &k[idx];
kk.fade == r.fade && kk.x == r.x && kk.y == r.y
&& kk.scale_x == r.scale_x && kk.scale_y == r.scale_y
})
});
if !on_covering { multi_wrongrun += 1 }
}
}
}
}
}
println!("CONTROL — exactly ONE plateau, and it covers the settle instant:");
println!(" {one_cov} elements, rest() and pose_at(settle) agree on {one_agree} ({:.1} %)",
100.0 * one_agree as f64 / one_cov.max(1) as f64);
println!("\nTEST — MORE THAN ONE plateau, at least one covering the settle instant:");
println!(" {multi_cov} elements, agree on {multi_agree} ({:.1} %)",
100.0 * multi_agree as f64 / multi_cov.max(1) as f64);
println!(" of the {} disagreements, rest() landed on a run that does NOT cover",
multi_cov - multi_agree);
println!(" the settle instant: {multi_wrongrun}");
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,50 @@
//! Does a primitive's alpha AT t=0 predict the layer it paints on?
//!
//! `implied_layer_key` is a measured per-name table. The four entries in it, read
//! against their own keyframes, suggest a rule derived from the file instead:
//!
//! * `palogo_eff0.prm` measured FIRST (0x0000) -- alpha at t=0 = ?
//! * `pfbase.tbm` measured FIRST (0x0000) -- alpha at t=0 = ?
//! * `pteff02.prm` measured MIDDLE (0x8030) -- alpha at t=0 = ?
//! * `pteff00.prm` measured LAST -- alpha at t=0 = ?
//!
//! ⚠️ The rule was invented AFTER seeing three of those answers, so it is fitted
//! on them and only `pfbase.tbm` is out of sample. This prints all four plus a
//! disc-wide census, so the fit and its reach are visible together.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
// name -> (count, set of t=0 alphas, set of "starts at max" flags)
let mut byname: BTreeMap<String, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
// primitives and the keyless: anything with no layer key
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
let Some(k0) = el.keyframes.first() else { continue };
let a0 = k0.fade >> 24;
let ent = byname.entry(el.name.clone()).or_default();
ent.0 += 1;
*ent.1.entry(a0).or_default() += 1;
}
}
}
println!("{:>28} {:>7} alpha at t=0 (count)", "keyless element", "n");
let known = ["palogo_eff0.prm", "pfbase.tbm", "pteff02.prm", "pteff00.prm"];
for (n, (c, a)) in &byname {
let tag = if known.contains(&n.as_str()) { " <- IN THE MEASURED TABLE" } else { "" };
if *c < 4 && tag.is_empty() { continue }
let al: Vec<String> = a.iter().map(|(k, v)| format!("{k}x{v}")).collect();
println!(" {n:>26} {c:>7} {}{tag}", al.join(" "));
}
}

View File

@@ -0,0 +1,37 @@
//! What COLOUR is a primitive, and does any of them only make sense additively?
//!
//! `ui-prm-primitives.md` leaves blend mode open, and `forced_backdrop` assumes
//! straight alpha-over. A quad whose ARGB would tint the whole screen a colour no
//! screen shows is evidence against alpha-over for that quad.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
fn main(){
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
let mut paks:Vec<_>=std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e|e.path())
.filter(|p|p.extension().and_then(|s|s.to_str())==Some("pak")).collect();
paks.sort();
let mut m:BTreeMap<String,BTreeMap<String,usize>>=BTreeMap::new();
for p in &paks {
let Ok(ar)=pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by)=ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b)=ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.sprite.is_some() { continue }
for k in &el.keyframes {
*m.entry(el.name.clone()).or_default()
.entry(format!("{:08x}", k.fade)).or_default()+=1;
}
}
}
}
println!("{:>26} fade ARGB values (count)", "keyless element");
for (n,v) in &m {
let tot:usize=v.values().sum();
if tot<4 { continue }
let s:Vec<String>=v.iter().map(|(k,c)|format!("{k}x{c}")).collect();
println!(" {n:>24} {}", s.join(" "));
}
}

View File

@@ -0,0 +1,58 @@
//! Which keyless primitives have their paint position FORCED by occlusion?
//!
//! An opaque full-screen quad must sort below every element visible at any
//! instant it is opaque. Where that set is *every* other element, its position is
//! forced to first — derived from the file, not analogised from a neighbour.
//!
//! Controls, both measured in the running game and both reproduced here:
//! * `palogo_eff0.prm` is measured painting FIRST — and comes out forced first.
//! * `pteff00.prm` is measured painting LAST — and is forced below only a
//! handful, so the constraint permits it on top.
//!
//! ⚠️ Assumes straight alpha-over blending. Blend mode is ❔ in
//! `ui-prm-primitives.md`; an additive quad at alpha 255 would not occlude.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut forced, mut partial, mut free) = (0usize, 0usize, 0usize);
let mut names: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
if tmax == 0 { continue }
for el in &b.elements {
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
// full-screen only: a quad that does not cover cannot occlude
if el.pivot_x * 2 < 1280 || el.pivot_y * 2 < 720 { continue }
let op: Vec<u32> = (0..=tmax)
.filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)).collect();
if op.is_empty() { continue }
let others: Vec<&ui_layout::Element> =
b.elements.iter().filter(|o| o.index != el.index).collect();
if others.is_empty() { continue }
let below = others.iter().filter(|o|
op.iter().any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)).count();
let ent = names.entry(el.name.clone()).or_default();
ent.1 += 1;
if below == others.len() { forced += 1; ent.0 += 1 }
else if below > 0 { partial += 1 } else { free += 1 }
}
}
}
println!("keyless FULL-SCREEN primitives with an opaque interval:");
println!(" position FORCED FIRST (below every other element) : {forced}");
println!(" forced below SOME but not all : {partial}");
println!(" occludes nothing : {free}");
println!("\nby name — instances forced first / total:");
for (n, (f, t)) in &names { println!(" {n:>24} {f:>4} / {t}"); }
}

View File

@@ -0,0 +1,73 @@
//! An OPAQUE full-screen primitive cannot paint on top of elements that are
//! visible at the same time — the screen would be blank.
//!
//! That is a constraint read off the file, not a preference. For each keyless
//! primitive this computes the interval over which it is opaque, and the interval
//! over which any OTHER element is visible, and reports the overlap.
//!
//! The falsifier: `pteff00.prm` is MEASURED painting last on the title and the
//! main menu. If any of its instances is opaque while content is up, the
//! constraint is wrong and this whole line is dead.
use sylpheed_formats::{pak, ratc, ui_layout};
/// Interval(s) where alpha >= `thr`, sampled at every half unit.
fn opaque_span(el: &ui_layout::Element, thr: u32, tmax: u32) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut cur: Option<f64> = None;
let mut t = 0.0;
while t <= tmax as f64 {
let a = el.pose_at(t as u32).map(|k| k.fade >> 24).unwrap_or(0);
if a >= thr {
if cur.is_none() { cur = Some(t) }
} else if let Some(s) = cur.take() {
out.push((s, t));
}
t += 0.5;
}
if let Some(s) = cur { out.push((s, tmax as f64)) }
out
}
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let want = ["pteff00.prm", "pgloading_eff00.prm", "palogo_eff0.prm",
"pfbase.tbm", "pteff02.prm", "pzeff00.prm", "pceff00.prm", "pdeff00.prm"];
println!("{:>22} {:>5} {:>16} {:>18} overlap", "primitive", "entry", "opaque while", "content visible");
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
if tmax == 0 { continue }
for el in &b.elements {
if !want.contains(&el.name.as_str()) { continue }
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
let op = opaque_span(el, 255, tmax);
if op.is_empty() { continue }
// when is any OTHER element visible?
let mut cmin = f64::MAX; let mut cmax = f64::MIN;
for o in &b.elements {
if o.index == el.index { continue }
for (s, t) in opaque_span(o, 1, tmax) { cmin = cmin.min(s); cmax = cmax.max(t) }
}
if cmin > cmax { continue }
// overlap of the primitive's opaque span with the content span
let ov: f64 = op.iter()
.map(|&(s, t)| (t.min(cmax) - s.max(cmin)).max(0.0)).sum();
let opd: String = op.iter().map(|&(s,t)| format!("{s:.0}-{t:.0}")).collect::<Vec<_>>().join(",");
let flag = if ov > 2.0 { " 🔴 CANNOT BE ON TOP" } else { "" };
println!("{:>22} {:>5} {:>16} {:>18} {ov:6.1}{flag}",
el.name, format!("{}:{}", p.file_name().unwrap().to_string_lossy()
.trim_end_matches(".pak").trim_start_matches("GP_"), ei),
opd, format!("{cmin:.0}-{cmax:.0}"));
}
}
}
}

View File

@@ -0,0 +1,82 @@
//! Does `forced_backdrop`'s verdict depend on how the screen's timeline ENDS?
//!
//! The rule quantifies over "every instant the primitive is opaque" and "every
//! element visible then", so both halves depend on where the timeline stops and
//! on what an element does after its own last keyframe. The port asked, and it is
//! the right question: a verdict that flips with the convention is not a decode.
//!
//! Four conventions, all applied to the same disc:
//! A span = max keyframe time over all elements; elements HOLD their last pose
//! (what `forced_backdrop` does, and what the port implements)
//! B span = the primitive's OWN last keyframe time; elements hold
//! C span = the bundle header `+0x08` (the declared length); elements hold
//! D span = max keyframe time; an element is GONE after its own last keyframe
//!
//! D is the one worth the most: it is the assumption the port flagged as "doing
//! real work", and it strictly shrinks the visible set, so it can only turn
//! `forced` into `not forced`.
use sylpheed_formats::{pak, ratc, ui_layout};
fn last_t(el: &ui_layout::Element) -> u32 {
el.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0)
}
fn forced(b: &ui_layout::UiBuild, el: &ui_layout::Element, tmax: u32, hold: bool) -> Option<bool> {
if el.sprite.is_some() { return None }
if (el.pivot_x * 2) < b.design_w as u32 || (el.pivot_y * 2) < b.design_h as u32 { return None }
if tmax == 0 { return None }
let alpha = |e: &ui_layout::Element, t: u32| -> u32 {
if !hold && t > last_t(e) { return 0 }
e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0)
};
let op: Vec<u32> = (0..=tmax).filter(|&t| alpha(el, t) == 255).collect();
if op.is_empty() { return None }
let others: Vec<&ui_layout::Element> = b.elements.iter().filter(|o| o.index != el.index).collect();
if others.is_empty() { return None }
let below = others.iter().filter(|o| op.iter().any(|&t| alpha(o, t) > 0)).count();
Some(below == others.len())
}
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut n, mut a_true) = (0usize, 0usize);
let mut flips = [0usize; 3];
let mut examples: Vec<String> = Vec::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let tall = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
let hdr = if by.len() >= 12 { u32::from_be_bytes(by[8..12].try_into().unwrap()) } else { 0 };
for el in &b.elements {
let Some(va) = forced(&b, el, tall, true) else { continue };
n += 1; if va { a_true += 1 }
for (k, vb) in [forced(&b, el, last_t(el), true),
forced(&b, el, hdr, true),
forced(&b, el, tall, false)].into_iter().enumerate() {
if vb != Some(va) {
flips[k] += 1;
if k == 2 && examples.len() < 6 {
examples.push(format!("{}:{} {} A={va} D={vb:?}",
p.file_name().unwrap().to_string_lossy(), ei, el.name));
}
}
}
}
}
}
println!("keyless full-screen primitives with an opaque interval: {n}");
println!(" convention A (span = all elements' max, hold) -> forced first: {a_true}\n");
println!(" verdicts that CHANGE under:");
println!(" B span = the primitive's own last keyframe : {}", flips[0]);
println!(" C span = the header's declared length +0x08 : {}", flips[1]);
println!(" D elements GONE after their last keyframe : {}", flips[2]);
for e in &examples { println!(" {e}") }
}

View File

@@ -0,0 +1,65 @@
//! The FULL extent of the two title sweep leaves, across their whole cycle.
//!
//! `ptloop_leaf_sweep_at.rs` samples t=340..540 — a window chosen to compare two
//! competing fits — so it never showed how far the leaves travel. That gap let a
//! claim stand that `ptloop01/02` "do not free-run", measured over the PARENT's
//! 200x90 rect, which is a pivot anchor the leaf spends almost no time inside.
//! `sylpheed-port` reports x tracks of -639..1521 and -839..1721 from their
//! export; this checks that against the disc.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_extent
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [4usize, 5, 7] {
let Ok(by) = ar.read(&ar.entries()[entry]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
println!("\n######## GP_TITLE entry {entry} ########");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
println!(" {parent}: not present in this build"); continue };
let Some(&(off, size)) = b.records.get(&el.name) else {
println!(" {}: no nested record", el.name); continue };
let span = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
println!(" {}: leaf will not parse", el.name); continue };
println!(" {} parent rest ({},{}) nested cycle span {span}",
el.name, el.keyframes.last().map(|k| k.x).unwrap_or(0),
el.keyframes.last().map(|k| k.y).unwrap_or(0));
for le in &lb.elements {
let (mut lo, mut hi) = (i64::MAX, i64::MIN);
let (mut sxs, mut sys) = (Vec::new(), Vec::new());
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
lo = lo.min(k.x as i64); hi = hi.max(k.x as i64);
if !sxs.contains(&k.scale_x) { sxs.push(k.scale_x) }
if !sys.contains(&k.scale_y) { sys.push(k.scale_y) }
}
}
// A CYCLE LENGTH IS NOT A MOTION DURATION. Find the last t at
// which x still changes: sylpheed-port reports the final segment
// HOLDS, which would make px/unit larger than cycle-based maths.
let mut last_move = 0u32;
let mut prev = None;
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
if prev.map_or(false, |p| p != k.x) { last_move = t }
prev = Some(k.x);
}
}
let w = (le.pivot_x * 2) as i64;
println!(" leaf {:<12} pivot {}x{} quad w={w} x track {lo} .. {hi} \
(centre {} .. {}) scale_x {:?} scale_y {:?}",
le.name, le.pivot_x, le.pivot_y,
lo + le.pivot_x as i64, hi + le.pivot_x as i64, sxs, sys);
println!(" motion ends at t={last_move} of a {span}-unit cycle -> {:.3} px/unit over the MOVING span (vs {:.3} over the cycle)",
(hi - lo) as f64 / last_move.max(1) as f64,
(hi - lo) as f64 / span as f64);
}
}
}
println!("--- END ---");
}

View File

@@ -0,0 +1,37 @@
//! The sweep leaves' RAW keyframes, so a segment rate can be checked not assumed.
//!
//! `sylpheed-port` reports `pteff03` as +4.0000 px/unit over t 0..150 and +4.0000
//! again over 150..540 -- perfectly linear -- against `pteff03a` at -4.0667 then
//! -4.0625. That asymmetry is what makes their "inversion" observation sharp: my
//! linearity gate fails on the leaf whose source is exactly straight. It is their
//! number from their export, so it is worth deriving independently.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_keyframes
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let by = ar.read(&ar.entries()[4]).expect("entry 4");
let b = ui_layout::parse_build(&by).expect("parse");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else { continue };
let Some(&(off, size)) = b.records.get(&el.name) else { continue };
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else { continue };
for le in &lb.elements {
println!("\n== {} -> leaf {}", el.name, le.name);
let ks: Vec<_> = le.keyframes.iter().collect();
for w in ks.windows(2) {
let (a, c) = (w[0], w[1]);
match (a.time, c.time) {
(Some(t0), Some(t1)) if t1 > t0 => println!(
" t {t0:>4} -> {t1:<4} x {:>6} -> {:<6} = {:+.4} px/unit",
a.x, c.x, (c.x - a.x) as f64 / (t1 - t0) as f64),
_ => println!(" t {:?} -> {:?} x {} -> {} (no rate)", a.time, c.time, a.x, c.x),
}
}
}
}
println!("--- END ---");
}

View File

@@ -0,0 +1,91 @@
//! Where are the title's two light-sweep quads at a given time — and is a
//! best-fit against a framebuffer PNG even measuring their position?
//!
//! `ui-leaf-vs-parent-alpha.md` solves the sweep instant as **t = 357.7** from a
//! GPU per-draw capture: quad centre x measured off the submitted vertex buffer,
//! which is a position measurement at 4 px/unit. The port agent separately
//! best-fits the same leaf against `live-title-build4-no-plate.png` and gets
//! **~400 units**, and asked whether the two used the same capture.
//!
//! Before comparing the numbers, check whether the second method can see what it
//! claims to measure. A fit that is minimised by the quad being OFF-SCREEN is
//! minimised by absence, and would return "best" at whatever time draws least —
//! the same shape as the `.tbm` control that could not fail.
//!
//! So: print the leaves' own x, alpha, and on-screen overlap across the window.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_sweep_at
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const SCREEN_W: i64 = 1280;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let by = ar.read(&ar.entries()[4]).expect("entry 4");
let b = ui_layout::parse_build(&by).expect("parse");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
eprintln!("no element {parent}");
continue;
};
let Some(&(off, size)) = b.records.get(&el.name) else {
eprintln!("{}: no nested record", el.name);
continue;
};
// `ui-record-loop-length.md`: a nested record's header `+0x08` is its
// CYCLE LENGTH, and its keyframes need not fill it. This is why two
// captures of the same settled title do not share a sweep phase.
let loop_len = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
println!("\n-- {} nested record: loop length (+0x08) = {loop_len}", el.name);
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
eprintln!("{}: leaf will not parse", el.name);
continue;
};
for le in &lb.elements {
let w = (le.pivot_x * 2) as i64;
println!(
"\n== {} -> leaf {} (sprite {:?}, pivot {}x{}, {} keyframes, last t={:?})",
el.name,
le.name,
le.sprite,
le.pivot_x,
le.pivot_y,
le.keyframes.len(),
le.keyframes.last().map(|k| k.time)
);
// ⚠️ A keyframe's `x` is the quad's LEFT edge, not its centre. The
// draw-capture fit is quoted in CENTRES, so compare `centre`, which is
// `x + pivot_x`. Printing `x` under a "centre" heading is how a
// 200-px offset gets into a comparison unnoticed.
println!(" t | x | centre | a | on-screen px of a {w}px-wide quad");
for t in [
340u32, 350, 355, 357, 358, 360, 370, 380, 390, 395, 400, 405, 410, 420, 440, 480,
540,
] {
let Some(k) = le.pose_at(t) else {
println!(" {t:>4} | (no pose)");
continue;
};
let x = k.x as i64;
let a = k.fade >> 24;
let l = x;
let r = l + w;
let vis = (r.min(SCREEN_W) - l.max(0)).max(0);
println!(
" {t:>4} | {x:>4} | {:>6} | {a:>3} | {vis:>5} px {}",
x + le.pivot_x as i64,
if vis == 0 {
"*** ENTIRELY OFF SCREEN ***"
} else {
""
}
);
}
}
}
}

View File

@@ -0,0 +1,69 @@
//! Is a nested record's header `+0x08` its LOOP LENGTH — and do its keyframes
//! have to fill it?
//!
//! A `*f` focus record animates forever while its button is focused, so
//! something must say where the cycle restarts. The keyframes cannot: the plate's
//! `ptbtn00f` runs 0→80→0 over 105 units, and looping at 105 gives a period 15 %
//! short of every measurement of the real thing.
//!
//! Each record is itself a RATC bundle with its own header, and `+0x08` is a
//! frame count. If it is the loop length then it must never be LESS than the
//! record's largest keyframe time — an animation cannot restart before its own
//! last pose — and it may be more, which is a hold at the final pose.
//!
//! Two controls, both of which a wrong reading fails:
//! * `+0x08 < max keyframe time` must never happen. That is the falsifier.
//! * The distribution must not be trivial: if every record had exactly
//! `+0x08 == max t`, the field would carry nothing and "loop length" would be
//! an unfalsifiable relabelling of the keyframes.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
let mut hold_hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut worst: Vec<(i64, String)> = Vec::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
if o + 12 > by.len() || o + s > by.len() { continue }
if &by[o..o + 4] != b"RATC" { continue }
let len = u32::from_be_bytes(by[o + 8..o + 12].try_into().unwrap()) as i64;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0) as i64;
if maxt == 0 { continue } // a static record declares no cycle
total += 1;
let slack = len - maxt;
*hold_hist.entry(slack).or_default() += 1;
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else {
violations += 1;
if worst.len() < 12 {
worst.push((slack, format!("{}:{rn} len={len} maxt={maxt}",
p.file_name().unwrap().to_string_lossy())));
}
}
}
}
}
println!("nested records with timed keyframes : {total}");
println!(" +08 == max keyframe time (exact) : {exact} ({:.1}%)", 100.0*exact as f64/total as f64);
println!(" +08 > max keyframe time (a hold) : {holds} ({:.1}%)", 100.0*holds as f64/total as f64);
println!(" +08 < max keyframe time 🔴 : {violations} ({:.2}%) <- the falsifier",
100.0*violations as f64/total as f64);
println!("\nslack (+08 - max t) distribution, most common first:");
let mut h: Vec<_> = hold_hist.iter().collect();
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
for (k, n) in h.iter().take(14) { println!(" slack {k:>6} : {n}"); }
if !worst.is_empty() { println!("\nviolations:"); for (s, w) in &worst { println!(" {s:>6} {w}"); } }
}

View File

@@ -0,0 +1,63 @@
//! Verify the newly-public `ui_layout::loop_length_units` against the disc.
//!
//! `sylpheed-port` reads a record's `+0x08` itself, guarded on the RATC magic,
//! because the field was exposed on no public ref at all — example, test and
//! `docs/re/` only. This checks the public function reproduces the numbers the
//! finding was written from before the port depends on it.
//!
//! CONTROL FIRST: the function must return `None` for a non-RATC slice and for a
//! slice too short to hold the field. An accessor that returns a number for
//! anything cannot be trusted to return the right one.
//!
//! cargo run -p sylpheed-formats --example record_loop_length_api
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
// ---- controls -------------------------------------------------------
assert_eq!(ui_layout::loop_length_units(b"NOTR\x00\x00\x00\x00\x00\x00\x00\x78"), None,
"control FAILED: accepted a non-RATC slice");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00"), None,
"control FAILED: accepted a slice too short for +0x08");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00\x00\x00\x00\x00\x00\x78"), Some(120),
"control FAILED: did not read +0x08 big-endian");
println!("controls pass: rejects non-RATC, rejects short, reads BE at +0x08");
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
// The records the finding names, with their published values.
let expect: &[(&str, u32)] = &[("ptbtn00f.rat", 120), ("ptloop01.rat", 600),
("ptloop02.rat", 720)];
let mut seen = 0usize;
let (mut recs, mut viol) = (0usize, 0usize);
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (name, (off, size)) in &b.records {
let rec = &by[*off..(*off + *size).min(by.len())];
let Some(len) = ui_layout::loop_length_units(rec) else { continue };
recs += 1;
// the disc-wide invariant the finding rests on
let largest = ui_layout::parse_build(rec)
.map(|l| l.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0))
.unwrap_or(0);
if len < largest { viol += 1; }
for (want_name, want) in expect {
if name == want_name && seen < 16 {
seen += 1;
let ok = if len == *want { "OK" } else { "MISMATCH" };
println!(" entry {ei:2} {name:16} +0x08 = {len:4} \
(published {want}) largest kf {largest:4} {ok}");
assert_eq!(len, *want, "{name} disagrees with the published value");
}
}
}
}
println!("\n{recs} records read through the public fn; \
{viol} violate +0x08 >= largest keyframe time");
assert!(seen > 0, "found none of the named records — the check proved nothing");
}

View File

@@ -0,0 +1,68 @@
//! Does "1 697 fallback fires return a visible pose" survive being said out loud?
//!
//! `rest-fallback-census.txt` reports that of 2 305 elements where the dwell
//! fallback decides, 1 697 rest at `alpha > 0`. It was written as if that number
//! were the defect. **It is only a defect where the element is a transient.** An
//! element that genuinely ends visible and stays visible SHOULD rest visible, and
//! the fallback happening to be the path that got there is not an error.
//!
//! The port agent hit the mirror image of this: it counted a screen's own exit
//! ramp as the end of an element's visibility, so `ptmsg` — the main menu's
//! permanent footer — came out as "a 2-unit flash". The story collapsed when
//! said aloud. This asks the same question of my number.
//!
//! Split the 1 697 by what the element's LAST keyframe does:
//! * last alpha > 0 -> the element ends visible; resting visible is right
//! * last alpha == 0 -> it fades out; a visible rest is a transient's peak
//!
//! cargo run -p sylpheed-formats --example rest_fallback_audit
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut fires, mut vis, mut ends_visible, mut ends_zero, mut at_peak) = (0, 0, 0, 0, 0);
// ⚠️ The port agent's exit-ramp finding applies to THIS split too: if a
// screen's exit ramp drives every element to a=0, then "last keyframe a=0"
// says nothing about the element being a transient. Measure it on ALL
// elements before using it on the 1 697.
let (mut all_el, mut all_end_zero) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
all_el += 1;
if (el.keyframes.last().unwrap().fade >> 24) & 0xff == 0 { all_end_zero += 1 }
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade) { continue }
fires += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a == 0 { continue }
vis += 1;
let last = (el.keyframes.last().unwrap().fade >> 24) & 0xff;
if last > 0 { ends_visible += 1 } else { ends_zero += 1 }
let peak = el.keyframes.iter().map(|k| (k.fade >> 24) & 0xff).max().unwrap_or(0);
if a == peak { at_peak += 1 }
}
}
}
println!("fallback fires {fires}");
println!(" of those, rest alpha > 0 {vis}");
println!(" element's LAST keyframe alpha > 0 {ends_visible} <- ends visible; resting visible is CORRECT");
println!(" element's LAST keyframe alpha = 0 {ends_zero} <- fades out; a visible rest is a transient's peak");
println!(" rest alpha == the element's MAX {at_peak}");
println!("\nCONTROL on the split itself — is 'ends at a=0' near-universal?");
println!(" all elements with >= 2 keyframes {all_el}");
println!(" of those, last keyframe alpha = 0 {all_end_zero} ({:.1} %)",
100.0 * all_end_zero as f64 / all_el as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,77 @@
//! When the resting-pose DWELL FALLBACK actually runs, does it pick a visible pose?
//!
//! `ui-resting-pose.md` argues the fallback is structurally unsound — the gap it
//! maximises is time spent *interpolating*, so neither endpoint is held. Its one
//! worked example, `GP_TITLE` build 7's `ptlogo_eff3.t32`, **no longer
//! discriminates**: under the corrected keyframe-record layout the longest gap
//! moved from `61→103` to `0→46`, and both ends of that are `a = 0`. The page's
//! listing still shows the stale parser's trailing `-`.
//!
//! Losing the example is not the same as closing the question, so: disc-wide, how
//! often does the fallback fire, and when it does, does it return something the
//! player would see? An element resting at `a = 0` is harmless whichever end the
//! rule lands on.
//!
//! cargo run -p sylpheed-formats --example rest_fallback_census
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut elements, mut plateau, mut fallback, mut fb_visible) = (0usize, 0, 0, 0);
let mut worst: Vec<(u32, String, String)> = Vec::new();
let mut per_pak: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
elements += 1;
// a plateau is two ADJACENT poses that are equal — the same test
// the plateau path makes before the fallback can run
let has_plateau = el.keyframes.windows(2).any(|w| {
w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
});
if has_plateau { plateau += 1; continue }
fallback += 1;
per_pak.entry(name.clone()).or_default().0 += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a > 0 {
fb_visible += 1;
per_pak.entry(name.clone()).or_default().1 += 1;
worst.push((a, name.clone(), format!("e{i}/{}", el.name)));
}
}
}
}
println!("POPULATION: {elements} elements with >= 2 keyframes, over {} archives", paks.len());
println!("COVERAGE: {plateau} have a plateau (fallback never runs)");
println!(" {fallback} have NONE -> the dwell fallback decides");
println!(" {fb_visible} of those rest at alpha > 0 -- i.e. VISIBLE\n");
println!("PER ARCHIVE — fallback fires / of those, rests VISIBLE:");
let mut rows: Vec<_> = per_pak.into_iter().collect();
rows.sort_by(|a, b| b.1.1.cmp(&a.1.1));
for (pak, (fires, vis)) in &rows {
println!(" {pak:34} {fires:5} fires {vis:5} visible");
}
println!();
worst.sort_by(|a, b| b.0.cmp(&a.0));
for (a, pak, el) in worst.iter().take(6) {
println!(" a={a:3} {pak} {el}");
}
println!("\n--- END OF CENSUS (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,27 @@
//! Which `GP_TITLE` elements does the resting-pose dwell fallback decide, and does
//! it hand back a visible pose? The disc-wide census says 5 fires / 4 visible here.
//! cargo run -p sylpheed-formats --example rest_fallback_title
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade) { continue }
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
let ks: Vec<String> = el.keyframes.iter()
.map(|k| format!("{}:a{}", k.time.map(|v| v.to_string()).unwrap_or("-".into()), (k.fade >> 24) & 0xff))
.collect();
println!("entry {i:2} {:24} rest a={a:3} t={:?} [{}]{}",
el.name, r.time, ks.join(" "),
if a > 0 { " <== VISIBLE" } else { "" });
}
}
}

View File

@@ -0,0 +1,97 @@
//! Should the settled pose come from each element's `rest()`, or from the
//! **screen's** settle instant?
//!
//! Three iterations have measured how badly `rest()`'s dwell fallback behaves —
//! 2 305 elements where it decides, 1 457 of them handed the element's *maximum*
//! alpha, and by construction none of those poses is held. What has been missing
//! is a proposal.
//!
//! `UiBuild::settle_time()` already exists: the midpoint of the longest
//! keyframe-free interval **across the whole build**. That is the port agent's
//! "re-key on the screen's span rather than the element's", and its shipped path
//! poses `pose_at(hold)` and agrees with every capture it holds at 0.01 %.
//!
//! ⚠️ **Control first.** On elements where `rest()` is already sound — the plateau
//! path, a pose the element genuinely holds — `pose_at(settle)` must AGREE. If it
//! disagrees there, it is not a better rule, it is a different one.
//!
//! ⚠️ `ui-settle-time.md` records that 42 % of bundles have a settle window under
//! 10 units and never settle at all. Bundles are split on that here rather than
//! averaged over.
//!
//! cargo run -p sylpheed-formats --example rest_vs_settle
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// control population (plateau) and test population (fallback), each split by
// whether the bundle settles at all
let (mut ctl_n, mut ctl_cov, mut ctl_agree) = (0usize, 0usize, 0usize);
let (mut fb_n, mut fb_rest_vis, mut fb_settle_vis) = (0usize, 0usize, 0usize);
let mut narrow = 0usize;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
if hi - lo < 10 { narrow += 1; continue } // this bundle never settles
let st = lo + (hi - lo) / 2;
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
let plateau = el.keyframes.windows(2).any(|w| w[0].x == w[1].x
&& w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade);
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
let (ra, sa) = ((r.fade >> 24) & 0xff, (s.fade >> 24) & 0xff);
if plateau {
// ⚠️ The first version of this control compared EVERY plateau
// element and got 46.6 % agreement — then I asked what that
// means physically. `rest()` finds *a* held pose; many
// elements hold one during the build-in and then move on.
// `pose_at(settle)` asks what is on screen WHEN THE SCREEN HAS
// SETTLED. Those are different questions, so disagreement
// proves nothing. The fair control is the subset where the
// held interval actually CONTAINS the settle instant.
ctl_n += 1;
let covers = el.keyframes.windows(2).any(|w| {
let held = w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade;
match (w[0].time, w[1].time) {
(Some(a), Some(bb)) => held && a <= st && st <= bb,
_ => false,
}
});
if covers {
ctl_cov += 1;
if ra == sa && r.x == s.x && r.y == s.y
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y { ctl_agree += 1 }
}
} else {
fb_n += 1;
if ra > 0 { fb_rest_vis += 1 }
if sa > 0 { fb_settle_vis += 1 }
}
}
}
}
println!("bundles skipped as never-settling (window < 10 units): {narrow}\n");
println!("CONTROL — elements where rest() takes the SOUND plateau path:");
println!(" {ctl_n} plateau elements in settling bundles");
println!(" {ctl_cov} of them HOLD ACROSS the settle instant — the fair control");
println!(" pose_at(settle) agrees with rest() on {ctl_agree} of those ({:.1} %)",
100.0 * ctl_agree as f64 / ctl_cov.max(1) as f64);
println!("\nTEST — elements where the unsound dwell fallback decides:");
println!(" {fb_n} elements");
println!(" rest() returns a VISIBLE pose on {fb_rest_vis} ({:.1} %)",
100.0 * fb_rest_vis as f64 / fb_n.max(1) as f64);
println!(" pose_at(settle) returns a VISIBLE pose on {fb_settle_vis} ({:.1} %)",
100.0 * fb_settle_vis as f64 / fb_n.max(1) as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,48 @@
//! Every scale value on the disc's UI, parents AND nested leaves.
//!
//! `DECISIONS.md` records `ptlogo_eff2` at 125 % as "the single drawn element in
//! the whole export at a scale that is not a whole multiple of 100 %". That
//! census was over parents only -- leaves were never opened. This opens them.
use sylpheed_formats::{pak, ui_layout};
use std::collections::BTreeMap;
fn main() {
let path = std::env::args().nth(1).expect("pak");
let ar = pak::PakArchive::open(&path).expect("open");
let mut hist: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
let mut leaves_opened = 0usize;
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
for el in &b.elements {
for k in &el.keyframes {
hist.entry((k.scale_x, k.scale_y))
.or_default()
.push(format!("e{i}/{}", el.name));
}
if let Some(&(off, size)) = b.records.get(&el.name) {
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
leaves_opened += 1;
for le in &lb.elements {
for k in &le.keyframes {
hist.entry((k.scale_x, k.scale_y))
.or_default()
.push(format!("e{i}/{}->LEAF/{}", el.name, le.name));
}
}
}
}
}
}
println!("{leaves_opened} leaves opened\n");
println!("{:>12} {:>7} examples", "scale", "count");
for (k, v) in &hist {
let mut ex: Vec<&String> = v.iter().collect();
ex.sort(); ex.dedup();
let odd = k.0 % 100 != 0 || k.1 % 100 != 0;
println!("{}{:>5},{:<5} {:>7} {}", if odd { "* " } else { " " },
k.0, k.1, v.len(),
ex.iter().take(3).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
}
println!("\n* = not a whole multiple of 100%");
}

View File

@@ -0,0 +1,75 @@
//! How often does posing at the SCREEN's settle instant catch an element
//! mid-ramp? The adversarial census of my own proposal.
//!
//! The port agent found `ptmsg` — the main menu's footer — at alpha **127.5 of
//! 255** at that screen's settle instant, because the longest keyframe-free
//! interval ends exactly as the footer starts to arrive. `screen render --settle`
//! already prints "⚠️ narrow — this bundle may never settle" there: the window is
//! **12 units**.
//!
//! ⚠️ **And my `rest_vs_settle` filter was too permissive**: it dropped bundles
//! with a window under 10 units, so a 12-unit window passed while the tool itself
//! was flagging it. This splits by width instead of picking one cutoff.
//!
//! "Mid-ramp" = at the settle instant the element sits strictly inside an interval
//! whose two endpoint poses DIFFER — it is interpolating, not held.
//!
//! cargo run -p sylpheed-formats --example settle_midramp_census
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// buckets by settle-window width
let edges = [0u32, 10, 20, 30, 60, u32::MAX];
let names = ["< 10", "1019", "2029", "3059", ">= 60"];
let mut els = [0usize; 5];
let mut mid = [0usize; 5];
let mut bundles = [0usize; 5];
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
let w = hi - lo;
let bi = edges.windows(2).position(|p| w >= p[0] && w < p[1]).unwrap_or(4);
bundles[bi] += 1;
let st = lo + w / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 { continue }
els[bi] += 1;
// the interval containing the settle instant
let mut interpolating = false;
for pair in k.windows(2) {
if let (Some(t0), Some(t1)) = (pair[0].time, pair[1].time) {
if t0 <= st && st <= t1 && t0 != t1 {
let same = pair[0].fade == pair[1].fade
&& pair[0].x == pair[1].x && pair[0].y == pair[1].y
&& pair[0].scale_x == pair[1].scale_x
&& pair[0].scale_y == pair[1].scale_y;
if !same && st != t0 && st != t1 { interpolating = true }
break;
}
}
}
if interpolating { mid[bi] += 1 }
}
}
}
println!("{:8}{:>10}{:>10}{:>12}{:>10}", "window", "bundles", "elements", "mid-ramp", "share");
for i in 0..5 {
if els[i] == 0 { continue }
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", names[i], bundles[i], els[i], mid[i],
100.0 * mid[i] as f64 / els[i] as f64);
}
let te: usize = els.iter().sum(); let tm: usize = mid.iter().sum();
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", "ALL", bundles.iter().sum::<usize>(), te, tm,
100.0 * tm as f64 / te as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,49 @@
//! What share of bundles have a NARROW settle window -- and of WHICH bundles?
//!
//! `screen render --settle`'s help says "a narrow one means the bundle never
//! settles (42 % of them, mostly `loop*` fragments)". The 42 % is correct and is
//! stated precisely in ui-settle-time.md: 731 of **1 758 composable bundles
//! carrying two or more keyframe times**. But inside `screen render`, "them"
//! reads as the bundles you would render -- the SCREEN BUILDS -- which is a
//! different and much smaller population. This computes both.
//!
//! cargo run -p sylpheed-formats --example settle_narrow_rate
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// (with >=2 keyframe times, narrow) for each population
let (mut b2, mut bn) = (0usize, 0usize); // screen builds (is_build)
let (mut c2, mut cn) = (0usize, 0usize); // composable (is_composable)
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let is_b = ui_layout::is_build(&by);
let is_c = ui_layout::is_composable(&by);
if !is_b && !is_c { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let mut ts: Vec<u32> = b.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
if ts.len() < 2 { continue }
let narrow = match b.settle_window() { Some((lo, hi)) => hi - lo < 10, None => true };
if is_b { b2 += 1; if narrow { bn += 1 } }
if is_c { c2 += 1; if narrow { cn += 1 } }
}
}
println!("population n narrow (<10 u) share");
println!("SCREEN BUILDS (is_build, what `screen render` renders by default)");
println!(" {b2:5} {bn:9} {:.0} %",
100.0 * bn as f64 / b2.max(1) as f64);
println!("COMPOSABLE bundles (is_composable, what --all admits)");
println!(" {c2:5} {cn:9} {:.0} %",
100.0 * cn as f64 / c2.max(1) as f64);
println!("\nui-settle-time.md quotes 731 / 1758 = 42 % over composable bundles.");
println!("--- END ---");
}

View File

@@ -0,0 +1,31 @@
// The settled screen, computed from the keyframe times alone.
//
// `rest()` picks each element's last HOLD keyframe independently, which is right
// for an element that ends settled and wrong for a transient: a 2-frame flash
// holds at its PEAK, so `rest()` leaves it burning forever. The settled screen is
// instead one INSTANT that every element is posed at, and the instant to pick is
// inside the longest interval during which no element has a keyframe at all.
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let ar=pak::PakArchive::open(&pk).unwrap();
let only:Option<usize>=a.next().and_then(|s|s.parse().ok());
for (i,e) in ar.entries().iter().enumerate() {
if let Some(o)=only { if o!=i { continue } }
let Ok(by)=ar.read(e) else { continue };
let Some(b)=ui_layout::parse_build(&by) else { continue };
if b.elements.len()<2 { continue }
let mut ts:Vec<u32>=b.elements.iter().flat_map(|el|
el.keyframes.iter().filter_map(|k|k.time)).collect();
if ts.len()<2 { continue }
ts.sort_unstable(); ts.dedup();
// longest gap between consecutive keyframe times
let (mut best,mut lo,mut hi)=(0u32,0u32,0u32);
for w in ts.windows(2) {
if w[1]-w[0] > best { best=w[1]-w[0]; lo=w[0]; hi=w[1]; }
}
println!("{:>4} {:<28} times {:>3} span {:>4} settle window [{lo},{hi}] = {best} units ({:.2}s) -> t={}",
i, format!("{:08x}",e.name_hash), ts.len(), ts.last().unwrap(), best as f64/60.0, lo+best/2);
}
}

View File

@@ -0,0 +1,27 @@
//! Where does `settle_window()`'s answer come from? The port agent recomputes the
//! publisher splash's widest keyframe-free gap as 190 units; `--settle` reports 8.
//! One of the two readings is wrong and the file settles it.
//! cargo run -p sylpheed-formats --example settle_window_check -- <build>
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let b: usize = std::env::args().nth(1).unwrap_or("10".into()).parse().unwrap();
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let by = ar.read(&ar.entries()[b]).expect("entry");
let build = ui_layout::parse_build(&by).expect("parse");
println!("entry {b}: {} elements", build.elements.len());
for el in &build.elements {
let ts: Vec<String> = el.keyframes.iter()
.map(|k| k.time.map(|v| v.to_string()).unwrap_or("-".into())).collect();
println!(" {:26} [{}]", el.name, ts.join(" "));
}
let mut ts: Vec<u32> = build.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
println!("\nunion of all element keyframe times: {ts:?}");
let gaps: Vec<(u32,u32,u32)> = ts.windows(2).map(|w| (w[1]-w[0], w[0], w[1])).collect();
let mut g = gaps.clone(); g.sort_by(|a,b| b.0.cmp(&a.0));
println!("widest gaps: {:?}", &g[..g.len().min(4)]);
println!("settle_window() reports {:?}", build.settle_window());
}

View File

@@ -0,0 +1,12 @@
use sylpheed_formats::{pak, ui_layout, t8ad};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
let mut v:Vec<(String,u32,u32)>=b.sprites.iter().filter_map(|(n,&(o,s))|{
let im=t8ad::parse(&by[o..o+s])?; Some((n.clone(),im.width,im.height))}).collect();
v.sort();
for (n,w,h) in v { println!(" {w:>5} x {h:<5} {n}"); }
}

View File

@@ -0,0 +1,9 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
let b=ui_layout::parse_build(&ar.read(&ar.entries()[4]).unwrap()).unwrap();
for el in b.elements.iter().filter(|e| e.name.contains("ptloop")||e.name.contains("ptbtn")) {
println!("{:<18} sprite={:?} has_leaf={}", el.name, el.sprite,
b.records.contains_key(&el.name));
}
}

View File

@@ -0,0 +1,332 @@
//! What does the unknown paint-order TIE-BREAK actually cost, in pixels?
//!
//! `ui-paint-order-derived-check.md` bounds *where* a wrong tie-break could
//! show — 24 overlapping tied pairs across `GP_TITLE` — and says outright that
//! nobody has measured how many of them change a pixel. Overlap is an upper
//! bound: two elements can overlap and still composite identically in either
//! order, if either is transparent where they meet.
//!
//! This renders each screen twice — once in the order `compose` derives, once
//! with one tied pair swapped — and counts the pixels that differ. Same-key
//! elements are contiguous in the derived order (a stable sort by `(key, i)`),
//! so swapping two of them paints nothing else in between: the diff is the
//! tie-break's cost and nothing else.
//!
//! Every entry also runs a CONTROL: a swap of two OVERLAPPING elements with
//! DIFFERENT keys, i.e. a pair whose order the game is known to care about. If
//! the control diff is zero the instrument cannot see a reorder on this screen
//! and its zeros mean nothing.
//!
//! cargo run -p sylpheed-formats --example tie_break_pixel_cost -- <GP_TITLE.pak>
use sylpheed_formats::{pak, ui_layout};
use ui_layout::{ComposeOptions, UiBuild};
/// Pixels that differ, and the largest per-channel difference.
fn diff(a: &[u8], b: &[u8]) -> (usize, u8) {
let (mut n, mut worst) = (0usize, 0u8);
for (pa, pb) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
if pa != pb {
n += 1;
for k in 0..4 {
worst = worst.max(pa[k].abs_diff(pb[k]));
}
}
}
(n, worst)
}
/// Where does element `ei` actually put ink? Render with it and without it;
/// the pixels that move are the ones it paints. This is what turns a bare
/// "0 px differ" into an explained one: bounding boxes can overlap while the
/// sprites inside them never touch the same pixel.
fn ink_mask(
build: &UiBuild,
bundle: &[u8],
opts: ComposeOptions,
order: &[usize],
base: &[u8],
ei: usize,
) -> Vec<bool> {
let n_el = build.elements.iter().map(|e| e.index).max().unwrap_or(0) + 1;
let mut vis = vec![true; n_el.max(build.elements.len())];
vis[build.elements[ei].index] = false;
let without = ui_layout::compose_with_order(build, bundle, opts, Some(&vis), Some(order));
base.chunks_exact(4)
.zip(without.rgba.chunks_exact(4))
.map(|(x, y)| x != y)
.collect()
}
fn swapped(order: &[usize], a: usize, b: usize) -> Vec<usize> {
let mut o = order.to_vec();
let (pa, pb) = (
o.iter().position(|&e| e == a).unwrap(),
o.iter().position(|&e| e == b).unwrap(),
);
o.swap(pa, pb);
o
}
/// The element's on-screen rect, the same approximation the tie census uses:
/// the declared pivot doubled, placed at the keyframe. `at` selects the pose —
/// `None` is `rest()`, which is where the original census was computed.
///
/// 🔴 An element that is TRANSPARENT at the chosen pose gets no rect at all. A
/// tie involving something invisible cannot cost a pixel, and counting it as an
/// overlap is what made the original census an upper bound rather than a cost.
fn rect(e: &ui_layout::Element, at: Option<u32>) -> Option<(i32, i32, i32, i32)> {
let kf = match at {
Some(t) => e.pose_at(t)?,
None => e.rest()?.clone(),
};
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
return None;
}
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 {
return None;
}
Some((kf.x, kf.y, w, h))
}
fn overlaps(a: &ui_layout::Element, b: &ui_layout::Element, at: Option<u32>) -> bool {
let (Some(ra), Some(rb)) = (rect(a, at), rect(b, at)) else {
return false;
};
(ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0
}
struct Case {
name: &'static str,
opts: ComposeOptions,
}
fn cases() -> Vec<Case> {
vec![
Case {
name: "default (what `screen render` draws)",
opts: ComposeOptions {
backdrop: [0, 0, 0, 255],
..Default::default()
},
},
Case {
name: "everything on (focus+animated+primitives)",
opts: ComposeOptions {
include_focus: true,
include_animated: true,
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
},
// 🔴 The two cases above pose at `rest()`, which is each element's last
// hold picked independently — so they draw transients that the settled
// screen does not have (`docs/re/structures/ui-settle-time.md`). A tie
// between two elements that are transparent at the settle time cannot
// cost a pixel on the screen the player sees, however much their rects
// overlap at rest. `at` is filled in per entry.
Case {
name: "AT THE SETTLE TIME (what the player sees)",
opts: ComposeOptions {
backdrop: [0, 0, 0, 255],
at: Some(0), // replaced per entry
..Default::default()
},
},
]
}
fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Vec<(usize, usize, u32)> {
let keys: Vec<u32> = build
.elements
.iter()
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
.collect();
let mut out = Vec::new();
for a in 0..keys.len() {
for b in (a + 1)..keys.len() {
if keys[a] != keys[b] || keys[a] == u32::MAX {
continue;
}
if overlaps(&build.elements[a], &build.elements[b], at) {
out.push((a, b, keys[a]));
}
}
}
out
}
/// A pair the game's own order DOES separate: overlapping, different keys.
/// Used as the control — swapping it must move pixels.
fn control_pair(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Option<(usize, usize)> {
let keys: Vec<u32> = build
.elements
.iter()
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
.collect();
let mut best: Option<(i64, usize, usize)> = None;
for a in 0..keys.len() {
for b in (a + 1)..keys.len() {
if keys[a] == keys[b] || keys[a] == u32::MAX || keys[b] == u32::MAX {
continue;
}
if !overlaps(&build.elements[a], &build.elements[b], at) {
continue;
}
let (ra, rb) = (rect(&build.elements[a], at)?, rect(&build.elements[b], at)?);
let ox = ((ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0)) as i64;
let oy = ((ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1)) as i64;
let area = ox * oy;
if best.map_or(true, |(x, _, _)| area > x) {
best = Some((area, a, b));
}
}
}
best.map(|(_, a, b)| (a, b))
}
fn main() {
let path = std::env::args()
.nth(1)
.expect("usage: tie_break_pixel_cost <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
let entries: Vec<_> = ar.entries().to_vec();
println!("# tie-break pixel cost — {path}\n");
let mut totals = (0usize, 0usize, 0usize); // pairs, changed-a-pixel, controls-dead
for (i, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else {
continue;
};
// The census pairs are still computed at rest, so the report can say
// how many of THOSE survive posing at the settle time.
let pairs_at_rest = tied_overlapping_pairs(&build, &bytes, None);
if pairs_at_rest.is_empty() {
continue;
}
let settle = build.settle_time();
println!(
"entry {i:2} {} elements {} overlapping tied pair(s) at rest{}",
build.elements.len(),
pairs_at_rest.len(),
match (settle, build.settle_window()) {
(Some(t), Some((lo, hi))) => format!(" settle t={t} (window {} units)", hi - lo),
_ => " NO SETTLE WINDOW".to_string(),
}
);
let derived = ui_layout::derived_paint_order(&build, &bytes);
for mut c in cases() {
// The settle case is a no-op on a bundle that never settles.
if c.opts.at.is_some() {
match settle {
Some(t) => c.opts.at = Some(t),
None => {
println!(" [{}] SKIPPED: no settle window", c.name);
continue;
}
}
}
let pairs = tied_overlapping_pairs(&build, &bytes, c.opts.at);
if pairs.len() != pairs_at_rest.len() {
println!(
" [{}] 🔴 {} of the {} tied pairs are GONE at this pose (an element is \
transparent or collapsed there) — they cannot cost a pixel",
c.name,
pairs_at_rest.len() - pairs.len(),
pairs_at_rest.len()
);
}
let base = ui_layout::compose_with_order(&build, &bytes, c.opts, None, Some(&derived));
let drawn: std::collections::HashSet<usize> = base.drawn.iter().copied().collect();
// Control first. An instrument that cannot see a reorder it is
// supposed to see makes every zero below meaningless.
let ctrl = match control_pair(&build, &bytes, c.opts.at) {
Some((a, b)) if drawn.contains(&a) && drawn.contains(&b) => {
let alt = ui_layout::compose_with_order(
&build,
&bytes,
c.opts,
None,
Some(&swapped(&derived, a, b)),
);
let (n, w) = diff(&base.rgba, &alt.rgba);
Some((a, b, n, w))
}
_ => None,
};
match ctrl {
Some((a, b, n, w)) if n > 0 => println!(
" [{}] CONTROL ok: swapping [{a}] {} x [{b}] {} moves {n} px (max Δ {w})",
c.name, build.elements[a].name, build.elements[b].name
),
Some((a, b, _, _)) => {
totals.2 += 1;
println!(
" [{}] CONTROL DEAD: swapping [{a}] {} x [{b}] {} changes NOTHING — \
zeros below are uninterpretable",
c.name, build.elements[a].name, build.elements[b].name
)
}
None => {
totals.2 += 1;
println!(
" [{}] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn",
c.name
)
}
}
for &(a, b, key) in &pairs {
let both_drawn = drawn.contains(&a) && drawn.contains(&b);
if !both_drawn {
println!(
" [{}] [{a}] {} x [{b}] {} (key {key}): NOT BOTH DRAWN — unreachable here",
c.name, build.elements[a].name, build.elements[b].name
);
continue;
}
let alt = ui_layout::compose_with_order(
&build,
&bytes,
c.opts,
None,
Some(&swapped(&derived, a, b)),
);
let (n, w) = diff(&base.rgba, &alt.rgba);
let total = (base.width as usize) * (base.height as usize);
// Explain the number: how many pixels do the two BOTH paint on?
// A zero with a large shared-ink count is a real "order does
// not matter here"; a zero with no shared ink means the
// bounding boxes overlapped and the sprites did not.
let ma = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, a);
let mb = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, b);
let shared = ma.iter().zip(&mb).filter(|(x, y)| **x && **y).count();
let (ia, ib) = (
ma.iter().filter(|x| **x).count(),
mb.iter().filter(|x| **x).count(),
);
println!(
" [{}] [{a}] {} x [{b}] {} (key {key}): {n} px differ ({:.4}% of frame), \
max Δ {w} | ink {ia} / {ib} px, shared {shared} px",
c.name,
build.elements[a].name,
build.elements[b].name,
100.0 * n as f64 / total as f64
);
if c.name.starts_with("default") {
totals.0 += 1;
if n > 0 {
totals.1 += 1;
}
}
}
}
println!();
}
println!(
"default-options summary: {} of {} overlapping tied pairs change at least one pixel; \
{} dead/unavailable controls",
totals.1, totals.0, totals.2
);
}

View File

@@ -0,0 +1,76 @@
//! How many tied pairs can cost a pixel, as a function of TIME?
//!
//! `tie_break_pixel_cost` answers "at one pose". That leaves the answer looking
//! like it might be a knife-edge: pick a different instant and the count could
//! jump. This sweeps every keyframe time in the bundle and reports, per entry,
//! how many same-key pairs are simultaneously **opaque, non-collapsed and
//! overlapping** — the pairs whose order could possibly matter at that instant.
//!
//! The instrument's control is built in: the count at t=0 (nothing has faded in)
//! and the count at rest must bracket it, and an entry whose count is flat at
//! zero for the whole sweep would be suspicious rather than reassuring — so the
//! peak is printed too.
use sylpheed_formats::{pak, ui_layout};
use ui_layout::UiBuild;
fn rect(e: &ui_layout::Element, t: u32) -> Option<(i32, i32, i32, i32)> {
let kf = e.pose_at(t)?;
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
return None;
}
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 { return None; }
Some((kf.x, kf.y, w, h))
}
fn live_pairs(b: &UiBuild, bytes: &[u8], keys: &[u32], t: u32) -> usize {
let mut n = 0;
for a in 0..keys.len() {
for c in (a + 1)..keys.len() {
if keys[a] != keys[c] || keys[a] == u32::MAX { continue }
let (Some(ra), Some(rb)) = (rect(&b.elements[a], t), rect(&b.elements[c], t)) else { continue };
if (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0 { n += 1 }
}
}
n
}
fn main() {
let path = std::env::args().nth(1).expect("usage: tie_cost_over_time <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
println!("# tied pairs that could cost a pixel, over time — {path}\n");
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let keys: Vec<u32> = b.elements.iter()
.map(|el| ui_layout::sprite_layer_key(&b, &by, el).unwrap_or(u32::MAX)).collect();
let mut ts: Vec<u32> = b.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
if ts.len() < 2 { continue }
let last = *ts.last().unwrap();
// sample every keyframe time AND every midpoint between them
let mut samples: Vec<u32> = ts.clone();
for w in ts.windows(2) { samples.push(w[0] + (w[1] - w[0]) / 2); }
samples.sort_unstable(); samples.dedup();
let counts: Vec<(u32, usize)> =
samples.iter().map(|&t| (t, live_pairs(&b, &by, &keys, t))).collect();
let peak = counts.iter().map(|&(_, n)| n).max().unwrap_or(0);
if peak == 0 { continue }
let Some((lo, hi)) = b.settle_window() else { continue };
let st = b.settle_time().unwrap();
let at_settle = live_pairs(&b, &by, &keys, st);
// the whole plateau, not just its midpoint
let plateau: Vec<usize> =
(lo..=hi).step_by(((hi - lo).max(1) / 8).max(1) as usize)
.map(|t| live_pairs(&b, &by, &keys, t)).collect();
let pmax = plateau.iter().copied().max().unwrap_or(0);
println!("entry {i:2} peak {peak} live pair(s) over t=0..{last} \
settle window [{lo},{hi}] at t={st}: {at_settle} ACROSS THE WHOLE WINDOW: max {pmax}");
let busy: Vec<String> = counts.iter().filter(|&&(_, n)| n > 0)
.map(|&(t, n)| format!("t{t}:{n}")).collect();
if busy.len() <= 24 { println!(" live only at {}", busy.join(" ")); }
else { println!(" live at {} of {} sampled instants", busy.len(), counts.len()); }
}
}

View File

@@ -0,0 +1,71 @@
//! Is raising the start filter's 1.5 MB cap safe, disc-wide?
//!
//! `voice_region_fix_test.rs` shows that for `ADV` the predecessor start recovers
//! the decoder's own three byte_sizes exactly. But the cap exists to protect a
//! case: the code says *"only within one bank (~1.5 MB), else this is the first cue
//! in its block and the audio starts at the anchor itself"*. Raising it blindly
//! could pull a **previous asset's** streams into the region.
//!
//! So compare, per movie: the chunk list the resolver gives today against the one
//! the predecessor start gives. A safe change makes the FIRST chunk bigger and
//! leaves the rest identical. An unsafe one adds leading chunks.
//!
//! cargo run -p sylpheed-formats --example voice_region_cap_sweep
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::pak::PakArchive;
use sylpheed_formats::slb::{self, VoiceLang};
use sylpheed_formats::{movie_manifest, movie_voice};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let code = VoiceLang::English.code_pub();
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak.entries().iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
let registry = tpak.entries().iter().find_map(|e| {
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
}).expect("registry");
let ids = movie_voice::registry_voice_ids(&registry);
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
let (mut same, mut grew, mut extra, mut skip) = (0, 0, 0, 0);
for m in movie_manifest::parse(&manifest) {
let movie = m.movie;
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { skip += 1; continue };
let Some(&id) = ids.get(&token) else { skip += 1; continue };
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
}) else { skip += 1; continue };
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { skip += 1; continue };
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { skip += 1; continue };
let end = win_start + end_local as u64;
let cand = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
.map(|o| win_start + o as u64)
.filter(|&s| s < end);
let today = cand.filter(|&s| end - s < 1_500_000).unwrap_or(anchor);
let Some(proposed) = cand else { skip += 1; continue };
if today == proposed { same += 1; continue }
let sizes = |s: u64| -> Vec<usize> {
src.read_segment_range("dat/sound", s, (end - s) as usize)
.map(|b| slb::to_xma_riffs(&b).iter().map(|r| r.len() - 60).collect())
.unwrap_or_default()
};
let (a, b) = (sizes(today), sizes(proposed));
let tail_same = a.len() == b.len() && a.iter().skip(1).eq(b.iter().skip(1));
let verdict = if a.len() == b.len() && tail_same && b[0] > a[0] {
grew += 1; "first chunk GREW, tail identical"
} else if b.len() > a.len() { extra += 1; "EXTRA leading chunks" }
else { extra += 1; "changed otherwise" };
println!("{movie:10} today {a:?}\n{:10} prop {b:?} {verdict}", "");
}
println!("\nunchanged {same} fixed-cleanly {grew} would-break {extra} skipped {skip}");
}

View File

@@ -0,0 +1,50 @@
//! How many voice regions hold three chunks? A COUNT, stated with its population.
//!
//! `voice-region-starts-late.md` published "8 of 10 three-chunk regions start
//! mid-stream". The port agent counts **25** three-chunk regions. Mine was not a
//! count: the audit that produced it was cut short and I read a partial file as a
//! complete one — it ends mid-list with no summary line.
//!
//! This does the cheap half properly. It does not step backwards looking for the
//! clip; it resolves each region once and counts its chunks, and it prints the
//! population, the coverage and the skips **in the same output** so a truncated run
//! cannot be mistaken for a complete one.
//!
//! cargo run -p sylpheed-formats --example voice_region_chunk_census
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::movie_manifest;
use sylpheed_formats::slb::{self, VoiceLang};
use std::collections::BTreeMap;
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries().iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let movies: Vec<String> = movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect();
let total = movies.len();
let mut hist: BTreeMap<usize, Vec<String>> = BTreeMap::new();
let (mut resolved, mut unresolved, mut unreadable) = (0, 0, 0);
for movie in movies {
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
else { unresolved += 1; continue };
let Ok(b) = src.read_segment_range("dat/sound", start, (end - start) as usize)
else { unreadable += 1; continue };
resolved += 1;
hist.entry(slb::to_xma_riffs(&b).len()).or_default().push(movie);
}
println!("POPULATION: {total} movies in the manifest");
println!("COVERAGE: {resolved} resolved and read, {unresolved} unresolved, {unreadable} unreadable");
println!(" {} accounted for\n", resolved + unresolved + unreadable);
for (n, ms) in &hist {
println!(" {n} chunk(s): {:>3} region(s) {}", ms.len(),
ms.iter().cloned().collect::<Vec<_>>().join(" "));
}
println!("\nTHREE-CHUNK REGIONS: {}", hist.get(&3).map(|v| v.len()).unwrap_or(0));
println!("--- END OF CENSUS (if this line is missing, the run did not finish) ---");
}

View File

@@ -0,0 +1,97 @@
//! What ARE the chunks a movie-voice region decodes to?
//!
//! The port reports a resolved voice region decoding to **three** chunks — two
//! of equal duration each spanning the whole movie, and a leading one that
//! "matches nothing" — and notes that this is the same 2+1 signature the BGM
//! banks showed before `bank_header_len` attributed the extra to a bank header.
//! Two different asset kinds with one signature is worth checking, because if
//! the same explanation applies then `bank_header_len` is incomplete, and if it
//! does not then the leading chunk is something we are discarding.
//!
//! `slb.rs`'s own doc comment already predicts the answer and disagrees with
//! "drop it": the header signature fires on 28 entries, all music banks, with
//! "zero false positives on the 7 993 mid-bank windows, WHERE THE LEADING
//! REGION IS REAL". A voice region is a mid-bank window by construction —
//! `resolve_movie_voice_region` starts it at the PREDECESSOR cue's trailer.
//!
//! cargo run -p sylpheed-formats --example voice_region_chunks -- <disc-dir>
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
fn main() {
let disc = std::env::args()
.nth(1)
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// Disc-wide, not the four movies that motivated the question: every movie
// the manifest binds a voice to.
let movies: Vec<String> = {
use sylpheed_formats::movie_manifest;
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("movie manifest");
movie_manifest::parse(&manifest)
.into_iter()
.map(|m| m.movie)
.collect()
};
println!("{} movies in the manifest\n", movies.len());
let mut census: std::collections::BTreeMap<usize, usize> = Default::default();
let (mut with_header, mut with_leading, mut no_leading) = (0, 0, 0);
for movie in movies.iter().map(|s| s.as_str()) {
let Some((start, end)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
else {
continue;
};
let len = end - start;
let Ok(bytes) = src.read_segment_range("dat/sound", start, len as usize) else {
println!("{movie:8} region {start}..{end} unreadable");
continue;
};
// Where does the first RIFF sit? Everything before it is the leading
// headerless packet region.
let first_riff = bytes
.windows(4)
.position(|w| w == b"RIFF")
.map(|p| p as i64)
.unwrap_or(-1);
let hdr = slb::bank_header_len(&bytes);
let riffs = slb::to_xma_riffs(&bytes);
let kind = if first_riff <= 0 {
no_leading += 1;
"no leading region".to_string()
} else if hdr == Some(first_riff as usize) {
with_header += 1;
format!("BANK HEADER ({first_riff} B = {} packets exactly)", first_riff / 2048)
} else {
with_leading += 1;
*census.entry(first_riff as usize % 2048).or_default() += 1;
format!(
"leading STREAM ({first_riff} B = {} packets + {} B)",
first_riff / 2048,
first_riff % 2048
)
};
println!(
"{movie:10} {start:12}..{end:12} {len:9} B chunks {} {kind}",
riffs.len()
);
if let Ok(dir) = std::env::var("VOICE_CHUNK_DUMP") {
for (i, r) in riffs.iter().enumerate() {
let _ = std::fs::write(format!("{dir}/{movie}-chunk{i}.wav"), r);
}
}
}
println!(
"\n{with_header} region(s) open with a BANK HEADER (bank_header_len fires)\n\
{with_leading} open with a leading STREAM\n{no_leading} start at a RIFF"
);
println!("leading-stream length mod 2048, i.e. the derived data offset:");
for (rem, n) in &census {
println!(" {rem:5} B x{n}");
}
}

View File

@@ -0,0 +1,40 @@
//! Would keeping the predecessor (instead of falling back to `anchor`) recover
//! the streams the running decoder actually decodes?
//!
//! `voice_region_start_why.rs` shows the failing branch: the start filter
//! `end - s < 1_500_000` rejects `ADV`'s predecessor because its span is 3.6 MB,
//! so `start` falls back to `anchor` — a TOC offset, not a stream boundary.
//!
//! This does NOT patch the resolver. It asks the one question that decides whether
//! raising that cap is the fix: **from the predecessor, does `to_xma_riffs` return
//! the decoder's own byte_sizes?** For `ADV` those are known, so this is a test and
//! not a fit.
//!
//! cargo run -p sylpheed-formats --example voice_region_fix_test
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::slb;
const ADV_PRED: u64 = 433_425_776;
const ADV_ANCHOR: u64 = 433_930_240;
const ADV_END: u64 = 437_044_592;
/// What the running decoder reported.
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
for (label, start) in [("anchor (today)", ADV_ANCHOR), ("predecessor (proposed)", ADV_PRED)] {
let bytes = src
.read_segment_range("dat/sound", start, (ADV_END - start) as usize)
.expect("region");
let sizes: Vec<usize> = slb::to_xma_riffs(&bytes).iter().map(|r| r.len() - 60).collect();
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
println!(
"{label:24} start {start} span {:>9} -> {:?}{}",
ADV_END - start,
sizes,
if hit { " <== MATCHES THE DECODER" } else { "" }
);
}
}

View File

@@ -0,0 +1,63 @@
//! Does `resolve_movie_voice_region` start inside the first stream, disc-wide?
//!
//! Verified on `ADV`: the resolver starts **238 packets (487 424 B) late**, and
//! extending the span by exactly that reproduces the running decoder's three
//! byte_sizes to the byte. The decoder is the ground truth there, but it exists
//! for one movie only — so this asks a structural question instead.
//!
//! **If the region began at a stream boundary, stepping the start backwards would
//! immediately expose the PREVIOUS asset's chunks.** If it began mid-stream, the
//! first chunk instead *grows*, packet for packet, until the real boundary. The
//! number of packets it grows for is the clip.
//!
//! cargo run -p sylpheed-formats --example voice_region_start_audit
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::movie_manifest;
use sylpheed_formats::slb::{self, VoiceLang};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let movies: Vec<String> = movie_manifest::parse(&manifest)
.into_iter()
.map(|m| m.movie)
.collect();
println!("{:10} {:>8} {:>12} {:>10} {}", "movie", "chunks", "first chunk", "clip pkts", "verdict");
let (mut clipped, mut clean, mut skipped) = (0, 0, 0);
for movie in movies {
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
else { skipped += 1; continue };
// ONE read of the region plus a lead-in, then slide inside it. Re-reading
// several MB per step made this too slow to finish at all.
const LEAD: u64 = 600 * 2048;
let lead = LEAD.min(start);
let buf = match src.read_segment_range("dat/sound", start - lead, (end - (start - lead)) as usize) {
Ok(b) => b, Err(_) => { skipped += 1; continue }
};
let at = |back: usize| -> Vec<Vec<u8>> {
let off = lead as usize - back * 2048;
slb::to_xma_riffs(&buf[off..])
};
let base_riffs = at(0);
if base_riffs.is_empty() { skipped += 1; continue }
let n0 = base_riffs.len();
let first0 = base_riffs[0].len() - 60;
let mut clip = 0usize;
for k in 1..=(lead as usize / 2048) {
if at(k).len() != n0 { break }
clip = k;
}
let verdict = if clip == 0 { clean += 1; "starts at a boundary" }
else { clipped += 1; "STARTS MID-STREAM" };
println!("{movie:10} {n0:>8} {first0:>12} {clip:>10} {verdict}");
}
println!("\nclipped {clipped} clean {clean} skipped {skipped}");
}

View File

@@ -0,0 +1,70 @@
//! WHY does `resolve_movie_voice_region` start inside the first stream?
//!
//! [`voice-region-starts-late.md`] establishes that it does — 238 packets late for
//! `ADV`, 8 of 10 multichannel regions disc-wide — but not why, and a fix guessed
//! from one movie would be worse than a documented defect. This reproduces the
//! resolver's own steps and prints each candidate, so the failing branch is visible
//! rather than inferred.
//!
//! The suspicion the code itself raises: the start is filtered by
//! `end - s < 1_500_000` — "only within one bank" — and `ADV`'s region has to span
//! **3.6 MB**. If that filter rejects the real predecessor, `start` silently falls
//! back to `anchor`, which is a TOC offset and not a stream boundary at all.
//!
//! cargo run -p sylpheed-formats --example voice_region_start_why
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::pak::PakArchive;
use sylpheed_formats::slb::VoiceLang;
use sylpheed_formats::{movie_manifest, movie_voice};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let code = VoiceLang::English.code_pub();
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries().iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
let registry = tpak.entries().iter().find_map(|e| {
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
}).expect("registry");
let ids = movie_voice::registry_voice_ids(&registry);
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
println!("{:10} {:>6} {:>12} {:>12} {:>12} {:>10} {:>9} {}",
"movie","id","anchor","pred(id-1)","pred(before)","span","chosen","note");
for m in movie_manifest::parse(&manifest) {
let movie = m.movie;
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { continue };
let Some(&id) = ids.get(&token) else { continue };
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
}) else { continue };
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { continue };
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { continue };
let end = win_start + end_local as u64;
let p1 = movie_voice::find_descriptor(&window, id.wrapping_sub(1)).map(|o| win_start + o as u64);
let pb = movie_voice::find_descriptor_before(&window, end_local).map(|o| win_start + o as u64);
let cand = p1.or(pb);
// the resolver's own filter
let kept = cand.filter(|&s| s < end && end - s < 1_500_000);
let chosen = kept.unwrap_or(anchor);
let span = cand.map(|s| end.saturating_sub(s)).unwrap_or(0);
let note = match (cand, kept) {
(Some(_), None) => "REJECTED by the 1.5 MB filter -> fell back to anchor",
(Some(_), Some(_)) => "predecessor kept",
(None, _) => "no predecessor found -> anchor",
};
println!("{movie:10} {id:>6} {anchor:>12} {:>12} {:>12} {span:>10} {:>9} {note}",
p1.map(|v| v.to_string()).unwrap_or("-".into()),
pb.map(|v| v.to_string()).unwrap_or("-".into()),
if chosen == anchor { "anchor" } else { "pred" });
}
}

View File

@@ -0,0 +1,205 @@
//! Who owns the bytes in front of a movie-voice region's first `RIFF`?
//!
//! [`voice-region-leading-chunk.md`] left one thing open: the leading chunk of
//! the 17 stream-opening regions is real XMA audio that **no other movie-voice
//! region claims** — but the census only enumerated the 95 movie cues, while the
//! same continuous stream also carries the in-mission `VOICE_D_*` lines. The
//! leading hypothesis was that the bytes belong to one of those, and the port
//! pointed out that the byte-span test already written settles it *without
//! anyone listening* if the enumeration is widened.
//!
//! So this widens it the whole way: rather than resolving cues one at a time
//! through the manifest, scan the stream itself for **every** trailer descriptor
//! — the `(id: u32be, 0x11, …)` pair whose id repeats at `+0x800`, which
//! `movie_voice` documents as the end of a cue's audio. Cue N's audio is
//! `[descriptor(N-1) .. descriptor(N)]`, so the full descriptor list IS the
//! complete cue partition of the stream, movie and mission alike.
//!
//! cargo run -p sylpheed-formats --example voice_stream_cue_map -- <disc-dir>
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::VoiceLang;
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
/// Every trailer descriptor in `buf`, as `(offset, id)`.
///
/// Same predicate `movie_voice::find_descriptor` uses — the id-repeat at +0x800
/// is what makes a false match inside XMA audio ~2^-64.
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
fn main() {
let disc = std::env::args()
.nth(1)
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// The cue-name -> id registry, so a descriptor id can be named.
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("voice registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
println!("registry: {} cue names, {} distinct ids", ids.len(), name_of.len());
// The 17 regions that open with a headerless stream, from the manifest.
let movies: Vec<String> = {
use sylpheed_formats::movie_manifest;
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect()
};
let mut regions = Vec::new();
for m in &movies {
if let Some((s, e)) = media::resolve_movie_voice_region(&src, m, VoiceLang::English) {
regions.push((m.clone(), s, e));
}
}
let lo = regions.iter().map(|r| r.1).min().unwrap();
let hi = regions.iter().map(|r| r.2).max().unwrap();
// Scan a window covering every region, with margin for cues either side.
let win_start = lo.saturating_sub(8 * 1024 * 1024) & !3;
let win_len = (hi - win_start + 8 * 1024 * 1024) as usize;
println!("scanning dat/sound {win_start}..{} ({:.1} MB)", win_start + win_len as u64,
win_len as f64 / 1e6);
let buf = src
.read_segment_range("dat/sound", win_start, win_len)
.expect("stream window");
let descs = all_descriptors(&buf);
println!("{} trailer descriptors found\n", descs.len());
let named = descs.iter().filter(|(_, id)| name_of.contains_key(id)).count();
println!(" of those, {named} carry an id the registry names, {} do not\n",
descs.len() - named);
// For each stream-opening region, name the cue that OWNS the leading span:
// the cue whose [prev_desc .. desc] interval contains it.
println!("leading span -> owning cue\n");
let mut verdicts: std::collections::BTreeMap<&str, usize> = Default::default();
for (m, s, e) in &regions {
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
let Some(fr) = bytes.windows(4).position(|w| w == b"RIFF") else { continue };
if fr == 0 || sylpheed_formats::slb::bank_header_len(&bytes) == Some(fr) {
continue; // bank-header case, not ours
}
let (a, b) = (*s, *s + fr as u64); // the leading span, global offsets
// Descriptors bracketing the MIDDLE of the leading span.
let mid = (a + b) / 2;
let mid_local = (mid - win_start) as usize;
let before = descs.iter().rev().find(|(o, _)| (*o as u64) < mid_local as u64);
let after = descs.iter().find(|(o, _)| *o >= mid_local);
let owner = after.map(|(_, id)| *id);
let owner_name = owner
.and_then(|id| name_of.get(&id).cloned())
.unwrap_or_else(|| owner.map(|i| format!("<unnamed id {i}>")).unwrap_or("<none>".into()));
let kind = if owner_name.starts_with("VOICE_D_") {
"MISSION line"
} else if owner_name.starts_with("VOICE_") {
"movie cue"
} else {
"unknown"
};
*verdicts.entry(kind).or_default() += 1;
println!(
" {m:8} lead {:8} B bracketed by desc@{:?} .. desc@{:?} owner {owner_name} [{kind}]",
b - a,
before.map(|(o, i)| (*o as u64 + win_start, *i)),
after.map(|(o, i)| (*o as u64 + win_start, *i)),
);
}
println!("\nverdicts: {verdicts:?}");
// WHY do exactly these 17 open mid-cue? `resolve_movie_voice_region` takes
// the predecessor trailer as the region start, but guards it with
// `end - start < 1_500_000` and falls back to the .slb TOC anchor when that
// fails. If the guard is the cause, then the stream-opening regions are
// exactly the cues whose true span exceeds the guard.
println!("\ncue span vs the 1.5 MB guard, and what the region actually starts at:\n");
let (mut over, mut under, mut over_is_stream, mut under_is_stream) = (0, 0, 0, 0);
for (m, s, e) in &regions {
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
let fr = bytes.windows(4).position(|w| w == b"RIFF");
let is_stream = matches!(fr, Some(f) if f > 0
&& sylpheed_formats::slb::bank_header_len(&bytes) != Some(f));
// The true predecessor trailer for this cue, from the full descriptor list.
let end_local = (*e - win_start) as usize;
let prev = descs.iter().rev().find(|(o, _)| *o < end_local).map(|(o, _)| *o as u64 + win_start);
let Some(prev) = prev else { continue };
let span = e - prev;
let guarded = span >= 1_500_000;
if guarded { over += 1; if is_stream { over_is_stream += 1 } }
else { under += 1; if is_stream { under_is_stream += 1 } }
if is_stream {
println!(
" {m:8} true cue span {span:8} B (> guard: {guarded}) region starts at {s}, \
true start {prev} -> {} B of the cue's own audio is OUTSIDE the region",
s.saturating_sub(prev)
);
}
}
println!("\ncues over the 1.5 MB guard: {over}, of which stream-opening: {over_is_stream}");
println!("cues under the guard: {under}, of which stream-opening: {under_is_stream}");
// How many streams is ONE cue stored as? The port measured that a region's
// leading chunk is the TAIL of its first full-length chunk, i.e. the cue is
// re-presented. Structurally that predicts a fixed number of stream starts
// inside a cue's TRUE span [desc(N-1) .. desc(N)] -- which is measurable
// from the bytes alone, with no decoder.
println!("\nstream starts inside each cue's TRUE span (desc(N-1)..desc(N)):\n");
let mut hist: std::collections::BTreeMap<usize, usize> = Default::default();
let mut hist_long: std::collections::BTreeMap<usize, usize> = Default::default();
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
let span = &buf[a..b];
// A stream start is a RIFF; plus the run before the first one, when it
// is not a bank header, is itself a stream.
let riffs = span
.windows(4)
.enumerate()
.filter(|(_, w)| *w == b"RIFF")
.count();
let lead_is_stream = match span.windows(4).position(|w| w == b"RIFF") {
Some(f) if f > 0 => sylpheed_formats::slb::bank_header_len(span) != Some(f),
_ => false,
};
let streams = riffs + usize::from(lead_is_stream);
*hist.entry(streams).or_default() += 1;
if b - a >= 1_500_000 {
*hist_long.entry(streams).or_default() += 1;
}
}
println!(" all inter-descriptor spans: {hist:?}");
println!(" spans >= 1.5 MB (the long cues): {hist_long:?}");
}

View File

@@ -0,0 +1,122 @@
//! Are the three streams of a voice cue in a CONSISTENT size relationship?
//!
//! The port selected a voice presentation on this argument: `ADV` chunk 1 is
//! mono-in-stereo (channel 2 digitally silent) and chunk 2 is dual-mono (both
//! channels identical), so chunk 2's extra bytes encode a duplicate channel
//! rather than fidelity — which would explain its higher declared
//! `PsuedoBytesPerSec` without appealing to encode quality.
//!
//! That is a claim about the *encoding*, and it makes a structural prediction:
//! if stream 3 is always "the same take with its channel duplicated", it should
//! sit in a consistent size ratio to stream 2 across every 3-stream cue on the
//! disc. If the ratio scatters — or if some third streams are tiny — then the
//! observation is about `ADV`, not about the format.
//!
//! cargo run -p sylpheed-formats --example voice_three_stream_sizes -- <disc>
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::slb;
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
/// The declared `PsuedoBytesPerSec` at `fmt +0x20` of a RIFF chunk, if present.
fn declared_rate(riff: &[u8]) -> Option<u32> {
if riff.len() < 0x28 || &riff[0..4] != b"RIFF" {
return None;
}
Some(u32::from_le_bytes(riff[0x20..0x24].try_into().ok()?))
}
fn main() {
let disc = std::env::args()
.nth(1)
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc"));
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
// Same window the cue map uses.
let win_start: u64 = 421_739_888 & !3;
let win_len: usize = 116_300_000;
let buf = src
.read_segment_range("dat/sound", win_start, win_len)
.expect("window");
let descs = all_descriptors(&buf);
println!("{:<14} {:>10} {:>10} {:>10} {:>7} {:>9} {:>9}",
"cue", "stream1", "stream2", "stream3", "s3/s2", "rate2", "rate3");
let mut ratios: Vec<f64> = Vec::new();
let mut tiny = 0;
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
let span = &buf[a..b];
let riffs = slb::to_xma_riffs(span);
if riffs.len() != 3 {
continue;
}
let name = name_of
.get(&w[1].1)
.cloned()
.unwrap_or_else(|| format!("id{}", w[1].1));
let (s1, s2, s3) = (riffs[0].len(), riffs[1].len(), riffs[2].len());
let r = s3 as f64 / s2 as f64;
ratios.push(r);
if r < 0.5 {
tiny += 1;
}
println!(
"{:<14} {s1:>10} {s2:>10} {s3:>10} {r:>7.4} {:>9} {:>9}",
name.trim_start_matches("VOICE_"),
declared_rate(&riffs[1]).map(|v| v.to_string()).unwrap_or("-".into()),
declared_rate(&riffs[2]).map(|v| v.to_string()).unwrap_or("-".into()),
);
}
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = ratios.len();
println!("\n{n} three-stream cues");
if n > 0 {
let mean = ratios.iter().sum::<f64>() / n as f64;
let var = ratios.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
println!(
" stream3/stream2 ratio: min {:.4} median {:.4} max {:.4} mean {:.4} sd {:.4}",
ratios[0], ratios[n / 2], ratios[n - 1], mean, var.sqrt()
);
println!(" cues where stream 3 is less than HALF of stream 2: {tiny}");
let near = ratios.iter().filter(|r| (**r - 1.0).abs() < 0.15).count();
println!(" cues where stream 3 is within 15% of stream 2: {near} of {n}");
}
}

View File

@@ -97,6 +97,14 @@ pub struct AudioInfo {
pub size_bytes: usize,
/// 2048-byte XMA packet count, for XMA/raw-XMA streams.
pub xma_packets: Option<u32>,
/// The stream's **declared** average bytes per second.
///
/// For XMA1 this is `XMASTREAMFORMAT::PsuedoBytesPerSec`. It is what makes a
/// duration available for a codec we cannot decode: `data_bytes / this`
/// agreed with an independently decoded duration to **0.01 %** on the two
/// movie voices it was checked against
/// (`docs/re/structures/voice-region-leading-chunk.md`).
pub avg_bytes_per_sec: Option<u32>,
}
impl AudioInfo {
@@ -110,6 +118,7 @@ impl AudioInfo {
duration_secs: None,
size_bytes: size,
xma_packets: None,
avg_bytes_per_sec: None,
}
}
@@ -181,6 +190,7 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
let mut pos = 12;
let (mut tag, mut channels, mut rate, mut bits) = (0u16, 0u16, 0u32, 0u16);
let mut avg_bps = 0u32;
let mut data_bytes: Option<u64> = None;
let mut have_fmt = false;
@@ -191,13 +201,37 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
match id {
b"fmt " if body + 16 <= bytes.len() => {
tag = le16(body);
channels = le16(body + 2);
rate = le32(body + 4);
bits = le16(body + 14);
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's first
// two bytes, right after cbSize (+2) → +24 from the fmt body.
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
tag = le16(body + 24);
// 🔴 XMA1 is NOT a WAVEFORMATEX. Reading it as one is where
// `audio info` got "16 channels, 4310 Hz, 2-bit" from the
// movie voices: 16 is `wBitsPerSample` read as channels, and
// 4310 is `wEncodeOptions` (0x10d6) read as a sample rate.
//
// XMA1 carries `XMAWAVEFORMAT`, then one `XMASTREAMFORMAT` per
// stream (xenia-canary `src/xenia/apu/xma_context.h`,
// cross-checked against the disc's own movie-voice headers):
//
// +0 wFormatTag +2 wBitsPerSample +4 wEncodeOptions
// +6 wLargestSkip +8 wNumStreams +10 bLoopCount (u8)
// +11 bStreamCount (u8)
// +12 PsuedoBytesPerSec +16 SampleRate +20 LoopStart
// +24 LoopEnd +28 SubframeData (u8) +29 Channels (u8)
// +30 ChannelMask
if tag == WAVE_FORMAT_XMA && body + 32 <= bytes.len() {
bits = le16(body + 2);
avg_bps = le32(body + 12);
rate = le32(body + 16);
channels = bytes[body + 29] as u16;
} else {
channels = le16(body + 2);
rate = le32(body + 4);
bits = le16(body + 14);
avg_bps = le32(body + 8);
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's
// first two bytes, right after cbSize (+2) → +24 from the
// fmt body.
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
tag = le16(body + 24);
}
}
have_fmt = true;
}
@@ -222,6 +256,15 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
info.channels = Some(channels).filter(|&c| c > 0);
info.sample_rate = Some(rate).filter(|&r| r > 0);
info.bits_per_sample = Some(bits).filter(|&b| b > 0);
info.avg_bytes_per_sec = Some(avg_bps).filter(|&b| b > 0);
// A declared byte rate gives a duration for a codec we cannot decode. Only
// for XMA1, where the field is `PsuedoBytesPerSec` and means exactly this.
if codec == AudioCodec::Xma && avg_bps > 0 {
if let Some(d) = data_bytes {
info.duration_secs = Some(d as f32 / avg_bps as f32);
}
}
match codec {
AudioCodec::Pcm | AudioCodec::PcmFloat => {
@@ -356,6 +399,58 @@ mod tests {
assert!((audio.samples[2] - 0.99997).abs() < 1e-3); // 32767/32768
}
/// XMA1 is not a `WAVEFORMATEX`, and reading it as one produced nonsense.
///
/// The bytes here are the real `fmt ` chunk of `ADV`'s first movie-voice
/// presentation, copied off the disc. Read as a `WAVEFORMATEX` it reports
/// **16 channels, 4310 Hz, 2-bit** — 16 is `wBitsPerSample`, 4310 is
/// `wEncodeOptions` (`0x10d6`). Read as an `XMAWAVEFORMAT` it reports 2
/// channels, 48 kHz, 16-bit, 8142 B/s.
///
/// The duration is the part worth guarding: this crate has no XMA decoder,
/// and `data_bytes / PsuedoBytesPerSec` is the only route to one. It agrees
/// with an independently decoded 137.324 s to **0.02 %**.
#[test]
fn xma1_fmt_is_not_a_waveformatex() {
let mut v = Vec::new();
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(b"WAVE");
v.extend_from_slice(b"fmt ");
v.extend_from_slice(&32u32.to_le_bytes());
// XMAWAVEFORMAT, exactly as it appears on the disc.
v.extend_from_slice(&[
0x65, 0x01, // wFormatTag = 0x0165 (XMA1)
0x10, 0x00, // wBitsPerSample = 16
0xd6, 0x10, // wEncodeOptions = 0x10d6 <- was misread as the rate
0x00, 0x00, // wLargestSkip
0x01, 0x00, // wNumStreams
0x00, // bLoopCount
0x02, // bStreamCount
0xce, 0x1f, 0x00, 0x00, // PsuedoBytesPerSec = 8142
0x80, 0xbb, 0x00, 0x00, // SampleRate = 48000
0x00, 0x00, 0x00, 0x00, // LoopStart
0x00, 0x00, 0x00, 0x00, // LoopEnd
0x00, // SubframeData
0x02, // Channels = 2 <- was read from +2 as 16
0x02, 0x00, // ChannelMask
]);
v.extend_from_slice(b"data");
v.extend_from_slice(&1_118_208u32.to_le_bytes());
let info = AudioInfo::probe(&v);
assert_eq!(info.codec, AudioCodec::Xma);
assert_eq!(info.channels, Some(2), "channels came from wBitsPerSample");
assert_eq!(info.sample_rate, Some(48_000), "rate came from wEncodeOptions");
assert_eq!(info.bits_per_sample, Some(16));
assert_eq!(info.avg_bytes_per_sec, Some(8142));
let d = info.duration_secs.expect("duration from the declared byte rate");
assert!(
(d - 137.324).abs() < 0.05,
"declared-rate duration {d} should match the decoded 137.324 s"
);
}
#[test]
fn probe_xma2_riff_reports_metadata_not_decode() {
// Minimal RIFF/WAVE with an XMA2 fmt tag.

View File

@@ -288,12 +288,29 @@ pub fn resolve_movie_voice_region<S: DiscSource + ?Sized>(
// Start = the predecessor trailer. Prefer the exact `id-1`; where the id
// sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer
// below — but only within one bank (~1.5 MB), else this is the first cue in
// its block and the audio starts at the anchor itself.
// below.
//
// 🔴 There used to be a second condition here — `end - s < 1_500_000`, "only
// within one bank, else this is the first cue in its block and the audio
// starts at the anchor itself". **It was wrong, and it silently truncated the
// first stream of every region larger than 1.5 MB.** `anchor` is a TOC offset,
// not a stream boundary, so the fallback started mid-packet-run: `ADV` began
// **238 packets (487 424 B) into its own first stream**, and a consumer then
// saw a leading chunk that "matched nothing" and dropped 62 % of a real stream.
//
// Ground truth is the running decoder, which reports `ADV`'s three contexts as
// 1 294 336 / 1 118 208 / 1 171 456 (`--xma_param_probe`). With the cap gone the
// region reproduces all three exactly; with it, the first is 806 912.
//
// Disc-wide over the 95 manifest movies that resolve: **17 regions fixed, 78
// unchanged, 0 changed in any other way** — in every one of the 17 the first
// chunk grows and the remaining chunks are byte-identical, which is what a
// corrected start looks like and what pulling in a neighbouring asset does not.
// `docs/re/structures/voice-region-starts-late.md`.
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
.map(|o| win_start + o as u64)
.filter(|&s| s < end && end - s < 1_500_000)
.filter(|&s| s < end)
.unwrap_or(anchor);
Some((start, end))
}

View File

@@ -377,6 +377,39 @@ pub fn leading_data_offset(first_riff: usize) -> usize {
first_riff % XMA1_PACKET
}
/// Length of the **bank header** when an entry begins with one, in bytes.
///
/// A music bank opens with a header the header itself sizes: big-endian, the
/// 2048-byte block size sits at `+0x18`, the bank id is repeated at `+0x00` and
/// `+0x20`, and `+0x24` is the header's length **in blocks** (5, i.e. 10 240 B,
/// on every music bank on this disc).
///
/// This exists because [`leading_data_offset`] derives a leading packet stream's
/// start as `first_riff % XMA1_PACKET`, which is only correct when the header is
/// SMALLER than one packet. A music bank's header is exactly five packets, so
/// the modulus returns 0 and the whole header was being emitted as a sub-wave —
/// a third "stem" on a bank the corpus documents as two
/// (`docs/re/structures/bgm-two-stems.md`).
///
/// Disc-wide over `sound.pak`'s 9 519 entries the signature fires on **28**, all
/// of them music banks (ids 10011023, 11011105), and on every one of the 28
/// the declared header ends **exactly** at the first `RIFF` — so no bank on this
/// disc has both a header at offset 0 and a leading packet stream. Zero false
/// positives on the 7 993 mid-bank windows, where the leading region IS real.
pub fn bank_header_len(slb: &[u8]) -> Option<usize> {
if slb.len() < 0x38 {
return None;
}
if slb[0x18..0x1c] != [0x00, 0x00, 0x08, 0x00] {
return None;
}
if slb[0x00..0x04] != slb[0x20..0x24] {
return None;
}
let blocks = u32::from_be_bytes(slb[0x24..0x28].try_into().ok()?) as usize;
blocks.checked_mul(XMA1_PACKET)
}
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let first_riff = find(slb, b"RIFF", 0);
@@ -422,7 +455,15 @@ pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
// bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and
// ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway.
if let Some(ri) = first_riff {
let start = leading_data_offset(ri);
// A bank that carries its OWN header at offset 0 states how long it is,
// and on this disc that header always runs right up to the first `RIFF`
// — so there is no leading packet stream at all. Without this the
// modulus below returns 0 for a 5-packet header and the header itself is
// emitted as a sub-wave: `BGM_103.slb` came back as THREE waves against a
// census, an executable reference and a runtime XMA probe that all say
// two. It decodes to 0.009 s of PCM (the same chain returns 87.744 s for
// the bank's real wave 0), and it is 99.1 % zero bytes.
let start = bank_header_len(slb).unwrap_or_else(|| leading_data_offset(ri));
if ri > start {
if let Some(data) = slb.get(start..ri) {
if data.iter().any(|b| *b != 0) {

View File

@@ -111,12 +111,22 @@ pub struct Keyframe {
/// starts are negative.
pub x: i32,
pub y: i32,
/// Keyframe time, or `None` for the group's **last** frame.
/// The time at which this pose is reached.
///
/// A group's data stops 4 bytes short of its final block's time slot — that
/// word is already the next group's element index. Reading it anyway is
/// where a stray `time = 1869640736` comes from, and it silently corrupts
/// the max-dwell pick in [`Element::rest`].
/// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header
/// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time
/// word **precedes** the pose it belongs to. Our block window starts at the
/// pose, so pose `k`'s time is the previous stride's `+36` word, and pose
/// 0's is the group's lead-in word at `header + 8`.
///
/// ⚠️ The old reading took `+36` as *this* pose's time. That left the final
/// pose — the end of every fade-out — untimed, and it is where the "a
/// group's data stops 4 bytes short of its final block's time slot" note and
/// the stray `time = 1869640736` both came from. There is no short group and
/// no missing word; the association was off by one.
/// See `docs/re/ui-keyframe-record-layout.md`.
///
/// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch.
pub time: Option<u32>,
}
@@ -182,13 +192,90 @@ impl Element {
///
/// Falls back to the longest-dwell rule when no two adjacent keyframes
/// agree — a group that ramps through every frame and never holds.
/// The element's pose at keyframe time `t`, linearly interpolated.
///
/// `rest()` returns the last HOLD keyframe **of one element, chosen
/// independently of every other element**. That is the wrong pose twice over:
///
/// * for anything still moving — the title's light sweeps hold at `x = 1521`,
/// off the right edge, so a resting composite deletes them rather than
/// settling them;
/// * 🔴 and for anything **transient**. `ptlogo_back2eff1` is a two-frame
/// flash (`a=0` until t52, `255` at t5456, `0` again by t58); its last
/// hold *is* the flash peak, so `rest()` leaves it burning forever. Five
/// such flashes stack on the title and blow the light arc out to pure
/// white — see `docs/re/structures/ui-settle-time.md`.
///
/// A settled screen is one INSTANT that every element is posed at, which is
/// what [`UiBuild::settle_time`] computes and `ComposeOptions::at` applies.
///
/// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group
/// **holds** at its last keyframe rather than looping, so `t` past the end
/// clamps.
pub fn pose_at(&self, t: u32) -> Option<Keyframe> {
let ks = &self.keyframes;
if ks.is_empty() {
return None;
}
let timed: Vec<(u32, &Keyframe)> =
ks.iter().filter_map(|k| k.time.map(|tt| (tt, k))).collect();
if timed.is_empty() {
return Some(ks[ks.len() - 1].clone());
}
if t <= timed[0].0 {
return Some(timed[0].1.clone());
}
if t >= timed[timed.len() - 1].0 {
return Some(timed[timed.len() - 1].1.clone());
}
for w in timed.windows(2) {
let ((t0, a), (t1, b)) = (w[0], w[1]);
if t >= t0 && t <= t1 {
if t1 == t0 {
return Some(b.clone());
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
let li = |x: i32, y: i32| x + ((y - x) as f64 * f).round() as i32;
let lu = |x: u32, y: u32| (x as f64 + (y as f64 - x as f64) * f).round() as u32;
// ARGB / RGBA words interpolate per BYTE, not as integers.
let lc = |x: u32, y: u32| {
let mut o = 0u32;
for sh in [24, 16, 8, 0] {
let (cx, cy) = ((x >> sh) & 0xff, (y >> sh) & 0xff);
o |= (lu(cx, cy) & 0xff) << sh;
}
o
};
return Some(Keyframe {
fade: lc(a.fade, b.fade),
rotation_deg: li(a.rotation_deg, b.rotation_deg),
unknown_4: li(a.unknown_4, b.unknown_4),
unknown_8: li(a.unknown_8, b.unknown_8),
scale_x: lu(a.scale_x, b.scale_x),
scale_y: lu(a.scale_y, b.scale_y),
tint: lc(a.tint, b.tint),
x: li(a.x, b.x),
y: li(a.y, b.y),
time: Some(t),
});
}
}
Some(timed[timed.len() - 1].1.clone())
}
pub fn rest(&self) -> Option<&Keyframe> {
// `lastall`: the LAST keyframe for every element, bypassing the plateau
// rule entirely. This is what the shifted time reading predicts — under
// it the final pose is reached at a definite time and nothing follows,
// so "rest" needs no heuristic. Testing it against the captures is an
// independent check on that reading, from static composites rather than
// from animation timing.
// rule entirely.
//
// ⚠️ ITS STATED PURPOSE IS RETIRED (corrected 2026-08-30). This comment
// read: "This is what the shifted time reading predicts — under it the
// final pose is reached at a definite time and nothing follows, so
// 'rest' needs no heuristic. Testing it against the captures is an
// independent check on that reading." The shifted reading was **refuted**
// by the record-layout fix above, so this override no longer checks
// anything about it. It survives only as a plain "take the last
// keyframe" diagnostic, alongside the documented `last` and `maxalpha`
// (see `docs/re/structures/ui-resting-pose.md`).
if std::env::var("SYLPHEED_REST_RULE").as_deref() == Ok("lastall") {
return self.keyframes.last();
}
@@ -224,7 +311,16 @@ impl Element {
let (Some(t0), Some(t1)) =
(self.keyframes[k].time, self.keyframes[k + 1].time)
else {
continue; // the last frame carries no time
// ⚠️ PRE-FIX COMMENT, corrected 2026-08-30. This read
// "the last frame carries no time", which was the rule
// BEFORE the record-layout fix directly above. Post-fix
// every pose is timed — measured at **0 untimed of
// 24 811 keyframes** across 965 builds — so this branch
// is unreachable on this disc. Kept as a guard because
// `time` is still `Option<u32>` and a malformed group
// could produce `None`; it is no longer a description of
// the format.
continue;
};
let dwell = t1.saturating_sub(t0);
// `>=`, not `>`: on a tie take the LATER frame. A group is
@@ -402,6 +498,30 @@ fn opt_link(rec: &[u8]) -> Option<String> {
(!s.is_empty()).then_some(s)
}
/// A RATC record's animation **loop length** in keyframe units — its `+0x08`.
///
/// Works at either level: a nested `.rat` leaf is itself a RATC bundle with the
/// same header shape as the one containing it, so this reads a whole screen
/// build's length and a single record's length through one path.
///
/// **Why it is public.** The loop length is not the largest keyframe time —
/// `ptbtn00f.rat`, the `PRESS Ⓐ` plate glow, declares **120** while its last
/// keyframe is at **105**, and that 15-unit slack is the plate holding dark
/// between cycles. A consumer that infers the period from the keyframes gets
/// 105 (1.750 s) against a real pulse measured four times at 2.122.34 s.
/// Decoded disc-wide: 1 781 records, **0** violations of
/// `+0x08 >= largest keyframe time` — see
/// `docs/re/structures/ui-record-loop-length.md`.
///
/// Returns `None` for anything that is not a RATC record, so it is safe to call
/// on an arbitrary slice; callers do not need their own magic guard.
pub fn loop_length_units(rec: &[u8]) -> Option<u32> {
if rec.len() < 0x0c || rec[0..4] != *b"RATC" {
return None;
}
Some(be32(rec, 0x08))
}
/// The sprite a `.rat` record places: a NUL-terminated name at `0x20`.
///
/// The field is **not** 16 bytes. Capping it there truncates every longer name —
@@ -493,8 +613,10 @@ fn mark_focused_states(elements: &mut [Element]) {
/// Read the placement region that follows the declaration table, filling in each
/// element's keyframe group.
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
// Experiment gate, default off; see the `time` field below.
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
// Escape hatch for the pre-2026-08-29 reading, which mis-associated every
// keyframe time by one slot and could not time a group's final pose at all.
// See `docs/re/ui-keyframe-record-layout.md`.
let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1");
let count = elements.len();
let mut order = Vec::with_capacity(count);
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
@@ -507,7 +629,13 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
if idx >= count || frames == 0 || frames > 4096 {
break;
}
// Group header is (index, count) then one lead-in word; blocks follow.
// The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte
// pose }`, after an 8-byte header — so the word at `pos + 8` is the
// FIRST pose's time, and each 40-byte stride's `+36` word is the time of
// the pose that follows it. Our block window is offset 4 bytes into the
// record (it starts at the pose), which is why the pose field offsets
// below are right while the times were off by one.
let first_time = be32(bundle, pos + 8);
let first = pos + 12;
// The region is packed so that the next group's header sits 4 bytes
// inside the last block — i.e. the group owns `frames * 40 - 4` bytes of
@@ -529,16 +657,17 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
tint: be32(bundle, blk + 24),
x: be32(bundle, blk + 28) as i32,
y: be32(bundle, blk + 32) as i32,
// Only a block wholly inside the group carries a time.
//
// ⚠️ Which block a time word BELONGS TO is under test — see
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
// to read `W[k-1]` as block `k`'s time ("the word is the time the
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
time: if shift_times {
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
} else {
// Pose `k`'s time is the word that PRECEDES it: the group's
// lead-in word for `k == 0`, and the previous stride's `+36`
// otherwise. Every pose is timed; nothing is missing and nothing
// is special-cased. Checked disc-wide — see
// `docs/re/ui-keyframe-record-layout.md`.
time: if legacy_times {
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
} else if k == 0 {
Some(first_time)
} else {
Some(be32(bundle, blk - KEYFRAME + 36))
},
});
}
@@ -724,6 +853,12 @@ pub struct ComposeOptions {
/// Left on with the derived order, an opaque black quad sorts last and wipes
/// 32 of `GP_DIALOG`'s builds.
pub include_primitives: bool,
/// Pose every element at this keyframe time instead of at its resting pose.
///
/// `None` keeps the settled composite, which is what every existing caller
/// wants. A capture taken mid-animation needs the render posed at the same
/// instant — see [`Element::pose_at`].
pub at: Option<u32>,
}
impl Default for ComposeOptions {
@@ -733,6 +868,7 @@ impl Default for ComposeOptions {
include_animated: false,
backdrop: [14, 14, 20, 255],
include_primitives: false,
at: None,
}
}
}
@@ -825,6 +961,99 @@ pub fn implied_layer_key(name: &str) -> Option<u32> {
/// they land does not affect a composite. Ties keep declaration order — the game
/// breaks them some other way, which is unexplained and looks harmless because
/// tied elements are same-layer.
/// Is this element an opaque full-screen quad that **must** sort below everything?
///
/// A keyless primitive has no layer key and the game's own code decides where it
/// paints ([`implied_layer_key`] records the names measured in the running game).
/// For one whole class of them the file settles it without a measurement: an
/// element that covers the screen and is **fully opaque** at some instant cannot
/// paint above anything visible at that instant, or the screen would be blank.
/// Where the elements visible during its opaque span are *all* of them, its
/// position is forced to first.
///
/// Two controls, both measured in the running game and both reproduced by this
/// rule rather than assumed by it:
///
/// * `palogo_eff0.prm` is measured painting **first** — and comes out forced
/// first (opaque for 211 instants, below 6 of 6). A rule keyed on the *name*
/// would get this wrong: it is named like an overlay.
/// * `pteff00.prm` is measured painting **last** — and is forced below only 3 of
/// 23 elements on the title, because it is opaque for 2 instants at the screen's
/// entry and exit, so the rule permits it on top.
///
/// Disc-wide: 80 instances forced first, 50 constrained but not forced, 0
/// unconstrained. It also explains the 36 dialog builds that composite to one
/// colour — `pzeff00.prm` is forced first in 32 of 32 instances.
///
/// ⚠️ Assumes straight alpha-over blending. Blend mode is an open question in
/// `docs/re/structures/ui-prm-primitives.md`; an *additive* quad at alpha 255
/// would not occlude, and this rule would then be placing it wrongly.
pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool {
// 🔴 Untextured primitives only. A `.t32` sprite's ELEMENT alpha being 255
// says nothing about whether its texture covers the screen — most of it may
// be transparent, so it occludes nothing. Applied without this guard the
// rule claims 22 textured sprites must sort first, against their own layer
// keys: `pneff01.t32` (key 0xd850, paints #8 of 13) and `pbfriendly.t32`
// (key 0x9230, #17 of 49). Those disagreements are the rule being wrong,
// not the keys.
if el.sprite.is_some() {
return false;
}
// The element must have a declared size at all; coverage itself is tested
// per instant below, against the SCALED size.
if el.pivot_x == 0 || el.pivot_y == 0 {
return false;
}
let tmax = build
.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0);
if tmax == 0 {
return false;
}
// An instant counts only where the element is BOTH fully opaque AND actually
// covering — tested together, because both animate on the same ramp.
//
// ⚠️ Coverage is the SCALED size, not the declared one, and the test must be
// two-sided. A quad scaled down does not cover what its pivot suggests —
// `pbafc.prm` declares 844x600 and draws ~17x18 at 2 %/3 %. And a quad scaled
// *up* can cover from a smaller declared size, so rejecting on the declared
// size would replace one error with its mirror. Checked before adopting:
// across 921 keyless elements, **0** cover the screen only via scale, so the
// mirror case does not occur on this disc — the per-instant test is in
// because it does not need that to stay true.
let (dw, dh) = ((el.pivot_x * 2) as u64, (el.pivot_y * 2) as u64);
let opaque: Vec<u32> = (0..=tmax)
.filter(|&t| {
el.pose_at(t).map_or(false, |k| {
k.fade >> 24 == 255
&& dw * k.scale_x as u64 / 100 >= build.design_w as u64
&& dh * k.scale_y as u64 / 100 >= build.design_h as u64
})
})
.collect();
if opaque.is_empty() {
return false;
}
let mut others = 0usize;
let mut occluded = 0usize;
for o in &build.elements {
if o.index == el.index {
continue;
}
others += 1;
if opaque
.iter()
.any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)
{
occluded += 1;
}
}
others > 0 && occluded == others
}
pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
@@ -832,6 +1061,9 @@ pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
(
sprite_layer_key(build, bundle, el)
.or_else(|| implied_layer_key(&el.name))
// An opaque full-screen quad that covers every other element
// while it is opaque cannot be on top — see `forced_backdrop`.
.or_else(|| forced_backdrop(build, el).then_some(0))
.unwrap_or(u32::MAX),
i,
)
@@ -900,11 +1132,73 @@ fn measured_paint_order(build: &UiBuild) -> Option<Vec<usize>> {
None
}
impl UiBuild {
/// The instant at which this screen is *settled*, in keyframe time units.
///
/// A screen is not settled when each element sits at its own last hold —
/// that is what [`Element::rest`] gives, and it is wrong for a transient
/// (see the note there). It is settled at one shared instant, and the disc
/// says which: gather every keyframe time in the build, and take the
/// **longest interval containing none of them**. Inside that gap nothing has
/// an inflection, so every element is either holding or on a long linear
/// ramp — which is exactly what "the screen has stopped changing" means.
///
/// Returns the midpoint of that gap, or `None` when the build has fewer than
/// two distinct keyframe times.
///
/// ⚠️ **Not every bundle has a settled instant.** Disc-wide over the 1 758
/// composable bundles carrying two or more keyframe times, 30 % have a gap of
/// at least half a second and 42 % have one under 10 units — the latter are
/// mostly `loop*` animation fragments, which are *meant* to be in motion and
/// have no settled pose to find. Check the gap width before trusting the
/// midpoint; `settle_window` returns it.
pub fn settle_time(&self) -> Option<u32> {
self.settle_window().map(|(lo, hi)| lo + (hi - lo) / 2)
}
/// The `[start, end]` of the longest keyframe-free interval — see
/// [`UiBuild::settle_time`]. The width `end - start` is how much confidence
/// the midpoint deserves.
pub fn settle_window(&self) -> Option<(u32, u32)> {
let mut ts: Vec<u32> = self
.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.collect();
ts.sort_unstable();
ts.dedup();
if ts.len() < 2 {
return None;
}
ts.windows(2)
.map(|w| (w[1] - w[0], w[0], w[1]))
.max_by_key(|&(d, _, _)| d)
.map(|(_, lo, hi)| (lo, hi))
}
}
pub fn compose(
build: &UiBuild,
bundle: &[u8],
opts: ComposeOptions,
visible: Option<&[bool]>,
) -> ComposedScreen {
compose_with_order(build, bundle, opts, visible, None)
}
/// `compose`, with the paint order supplied by the caller.
///
/// The only reason this exists is to *measure* what a paint order costs: render
/// a screen twice, once with the order `compose` would pick and once with two
/// elements swapped, and diff the pixels. `order` is a permutation of element
/// indices, first painted first; `None` means "whatever `compose` would use".
/// Nothing in the normal render path passes anything but `None`.
pub fn compose_with_order(
build: &UiBuild,
bundle: &[u8],
opts: ComposeOptions,
visible: Option<&[bool]>,
order_override: Option<&[usize]>,
) -> ComposedScreen {
let (w, h) = (build.design_w, build.design_h);
// A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind
@@ -928,8 +1222,10 @@ pub fn compose(
// same-layer-key ties, two being total occlusions. Of the port's five
// screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs,
// 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md.
let order: Vec<usize> =
measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle));
let order: Vec<usize> = match order_override {
Some(o) => o.to_vec(),
None => measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle)),
};
for &ei in &order {
let Some(el) = build.elements.get(ei) else {
continue;
@@ -967,7 +1263,23 @@ pub fn compose(
{
continue;
}
let Some(kf) = el.rest() else { continue };
// 🔴 `at` poses LEAVES ONLY, never the top-level elements.
//
// Posing everything at one global time was tried and is wrong: a
// top-level group's final keyframes are its **exit ramp** — the fade-out
// played when the screen leaves — and `rest()` deliberately stops at the
// last *hold* keyframe before it. Posing the title at t=358 walked every
// parent into its exit and drove the render's disagreement with the
// capture from 10.92 to 61.74. The leaf is the thing still animating at
// that instant, and it runs on its own timeline
// (`docs/re/structures/ui-leaf-vs-parent-alpha.md`).
let Some(kf) = (match opts.at {
Some(t) => el.pose_at(t),
None => el.rest().cloned(),
}) else {
continue;
};
let kf = &kf;
// An untextured primitive: a solid quad of the keyframe's `fade` colour,
// sized by the declared pivot. `kind & 0x10` marks these exactly — see
// `docs/re/structures/ui-prm-primitives.md`. They are the screen's
@@ -999,6 +1311,65 @@ pub fn compose(
missing.push(sprite.clone());
continue;
};
// A nested `.rat` leaf sometimes carries the geometry while the parent
// carries none — the title's light sweeps are the case: the parent sits
// fixed at (441,270) scale 100 %, and the leaf holds the 600 %/800 %
// scale, the +30°/45° rotation and the whole sweep. Drawing the parent
// put the sprite upright in the middle of the screen.
//
// ⚠️ NOT a blanket rule. A button's base record has a leaf that
// DUPLICATES it, and there the parent wins
// (`docs/re/structures/ui-button-focus-record.md`). The discriminator is
// which record actually carries geometry, so the leaf is used only when
// its pose genuinely differs — see `ui-leaf-vs-parent-alpha.md`.
let leaf = build.records.get(&el.name).and_then(|&(lo, ls)| {
if lo + ls > bundle.len() {
return None;
}
let lb = parse_build(&bundle[lo..lo + ls])?;
let pose = |e: &Element| match opts.at {
Some(t) => e.pose_at(t),
None => e.rest().cloned(),
};
let differs = lb.elements.iter().any(|le| {
pose(le).map_or(false, |lk| {
lk.rotation_deg != 0 || lk.scale_x != kf.scale_x || lk.scale_y != kf.scale_y
})
});
if differs { Some(lb) } else { None }
});
if let Some(lb) = leaf {
let mut any = false;
for le in &lb.elements {
let lk = match opts.at {
Some(t) => le.pose_at(t),
None => le.rest().cloned(),
};
let Some(lk) = lk else { continue };
// A leaf element resolves no sprite of its own: sprite names are
// resolved against the bundle a build was parsed from, and a leaf
// is parsed from its own slice. Its NAME is the sprite name, and
// the sprite itself lives in the PARENT bundle's table.
let lsp = le.sprite.clone().unwrap_or_else(|| le.name.clone());
let Some(&(so, ss)) = build.sprites.get(&lsp) else { continue };
let Some(limg) = t8ad::parse(&bundle[so..so + ss]) else { continue };
// 🔴 Only count it as drawn if it CAN draw. `blit` returns early
// on a zero scale — "collapsed to nothing", not "unset" — so
// setting the flag unconditionally would let a scale-0 leaf
// suppress its parent and blank the element outright.
// `pgloading_loop5`'s leaf is scale (0,0), and scale-0 is one of
// the failures this corpus is already named for.
if lk.scale_x == 0 || lk.scale_y == 0 {
continue;
}
blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y);
any = true;
}
if any {
drawn.push(el.index);
continue;
}
}
blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y);
drawn.push(el.index);
}
@@ -1117,6 +1488,11 @@ fn blit(
// Keep the pivot point fixed as the element scales.
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100;
// The pivot's ABSOLUTE position is invariant under scale, which is the whole
// point of the two lines above: at 100 % `ox = kf.x` so the pivot sits at
// `kf.x + pivot_x`; at 200 % `ox = kf.x - pivot_x` and the pivot sits at
// `ox + 2·pivot_x`, the same place. So rotation turns about it.
let (pax, pay) = (kf.x + pivot_x as i32, kf.y + pivot_y as i32);
// Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on
// essentially every keyframe seen) and `fade` (**ARGB** — the high byte is
// the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff`
@@ -1135,6 +1511,73 @@ fn blit(
((kf.tint >> 8) & 0xff) * fb / 255,
(kf.tint & 0xff) * fa / 255,
);
// ---- rotated path -------------------------------------------------------
// `+12` is a screen-plane rotation in DEGREES, clockwise-positive with Y
// down (`docs/re/structures/ui-keyframe-rotation.md`). The game submits
// rotated quads for it; this used to draw them axis-aligned, which put the
// title's two light sweeps upright instead of at +30° / 45° and left at
// least two thirds of that screen's disagreement with the capture
// (`title-residual-tone-vs-geometry.md`).
//
// Zero rotation keeps the original forward-mapped path byte for byte, so
// the screens that do not rotate cannot regress. A rotated element is drawn
// by INVERSE mapping instead: forward-mapping a rotation leaves gaps.
let rot = ((kf.rotation_deg % 360) + 360) % 360;
if rot != 0 {
let th = (rot as f64).to_radians();
let (cs, sn) = (th.cos(), th.sin());
// Axis-aligned bounds of the rotated destination rect.
let corners = [
(ox, oy),
(ox + dw as i32, oy),
(ox + dw as i32, oy + dh as i32),
(ox, oy + dh as i32),
];
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for (cx, cy) in corners {
let (rx, ry) = ((cx - pax) as f64, (cy - pay) as f64);
let px = pax as f64 + rx * cs - ry * sn;
let py = pay as f64 + rx * sn + ry * cs;
x0 = x0.min(px.floor() as i32);
y0 = y0.min(py.floor() as i32);
x1 = x1.max(px.ceil() as i32);
y1 = y1.max(py.ceil() as i32);
}
for ty in y0.max(0)..=y1.min(ch as i32 - 1) {
for tx in x0.max(0)..=x1.min(cw as i32 - 1) {
// Rotate the destination pixel BACK to find its source pixel.
let (rx, ry) = ((tx - pax) as f64 + 0.5, (ty - pay) as f64 + 0.5);
let ux = rx * cs + ry * sn;
let uy = -rx * sn + ry * cs;
let dx = ux + (pax - ox) as f64;
let dy = uy + (pay - oy) as f64;
if dx < 0.0 || dy < 0.0 || dx >= dw as f64 || dy >= dh as f64 {
continue;
}
let sxi = ((dx as u32) * sw / dw).min(sw - 1);
let syi = ((dy as u32) * sh / dh).min(sh - 1);
let si = ((syi * sw + sxi) * 4) as usize;
if si + 3 >= img.rgba.len() {
continue;
}
let sr = img.rgba[si] as u32 * tr / 255;
let sg = img.rgba[si + 1] as u32 * tg / 255;
let sb = img.rgba[si + 2] as u32 * tb / 255;
let sa = img.rgba[si + 3] as u32 * ta / 255;
if sa == 0 {
continue;
}
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
let dc = canvas[di + k] as u32;
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
}
canvas[di + 3] = 255;
}
}
return;
}
// ---- unrotated path (unchanged) -----------------------------------------
for row in 0..dh {
let ty = oy + row as i32;
if ty < 0 {
@@ -1182,6 +1625,70 @@ fn blit(
mod tests {
use super::*;
/// A solid opaque rectangle sprite, for exercising `blit` geometry.
fn solid(w: u32, h: u32) -> t8ad::T8adImage {
t8ad::T8adImage { width: w, height: h, rgba: vec![255u8; (w * h * 4) as usize],
flags: 0 }
}
fn kf_at(x: i32, y: i32, rot: i32) -> Keyframe {
Keyframe { fade: 0xff_ff_ff_ff, rotation_deg: rot, unknown_4: 0, unknown_8: 0,
scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(0) }
}
fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec<u8> {
let mut c = vec![0u8; 64 * 64 * 4];
blit(&mut c, 64, 64, img, kf, px, py);
c
}
fn covered(c: &[u8]) -> Vec<(i32, i32)> {
let mut v = Vec::new();
for y in 0..64 { for x in 0..64 {
if c[((y * 64 + x) * 4 + 3) as usize] != 0 { v.push((x as i32, y as i32)); }
}}
v
}
/// 🔴 CONTROL for the rotated path. An estimator that is wrong on a known
/// angle cannot be trusted on an unknown one, so the rotated blit is pinned
/// against angles whose answer is arithmetic rather than measured.
#[test]
fn rotation_control_known_angles() {
let img = solid(10, 4);
// pivot at the sprite's centre, so rotation turns in place
let (px, py) = (5u32, 2u32);
let base = draw(&img, &kf_at(20, 30, 0), px, py);
// 0° and 360° must be identical to the unrotated path, byte for byte:
// the fast path must be exactly the old behaviour.
assert_eq!(base, draw(&img, &kf_at(20, 30, 360), px, py),
"360 degrees must equal the unrotated path exactly");
// 90° must turn a 10x4 into a 4x10 about the same centre.
let r90 = covered(&draw(&img, &kf_at(20, 30, 90), px, py));
let b = covered(&base);
let bw = b.iter().map(|p| p.0).max().unwrap() - b.iter().map(|p| p.0).min().unwrap();
let bh = b.iter().map(|p| p.1).max().unwrap() - b.iter().map(|p| p.1).min().unwrap();
let rw = r90.iter().map(|p| p.0).max().unwrap() - r90.iter().map(|p| p.0).min().unwrap();
let rh = r90.iter().map(|p| p.1).max().unwrap() - r90.iter().map(|p| p.1).min().unwrap();
assert_eq!((bw, bh), (9, 3), "unrotated extent");
assert_eq!((rw, rh), (3, 9), "90 degrees must swap the extents");
// The covered area must be conserved to a few percent -- a rotation that
// loses or invents pixels is the forward-mapping bug this path avoids.
let (a0, a90) = (b.len() as f64, r90.len() as f64);
assert!((a0 - a90).abs() / a0 < 0.15,
"area changed too much under rotation: {a0} -> {a90}");
// And the centroid must stay on the pivot.
let cen = |v: &Vec<(i32, i32)>| {
let n = v.len() as f64;
(v.iter().map(|p| p.0 as f64).sum::<f64>() / n,
v.iter().map(|p| p.1 as f64).sum::<f64>() / n)
};
let (c0, c9) = (cen(&b), cen(&r90));
assert!((c0.0 - c9.0).abs() < 1.0 && (c0.1 - c9.1).abs() < 1.0,
"rotation moved the centroid: {c0:?} -> {c9:?}");
}
/// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration
/// table at 0x20, then a placement region.
fn synth_build(decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec<Keyframe>)]) -> Vec<u8> {

View File

@@ -86,3 +86,39 @@ fn manifest_binding_is_the_only_route() {
None
);
}
/// The resolved `ADV` voice region must contain **all three** streams the running
/// decoder decodes — not a truncated first one.
///
/// Ground truth is the emulator, not this crate: booting with `--xma_param_probe`
/// reports three XMA contexts with `byte_size` 1 294 336 / 1 118 208 / 1 171 456
/// (`docs/re/structures/voice-three-streams-are-concurrent.md`). Until 2026-08-30
/// the resolver's start filter capped a region at 1.5 MB, `ADV`'s span is 3.6 MB,
/// so the start fell back to `anchor` — a TOC offset, 238 packets into the first
/// stream — and this returned 806 912 for the first chunk.
///
/// This is a regression test against an EXTERNAL measurement, which is the only
/// kind that can catch the class of bug it was written for: every internal check
/// passed happily while a third of a stream was missing.
#[test]
fn adv_voice_region_holds_all_three_decoded_streams() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let (start, end) = media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English)
.expect("ADV voice region");
let bytes = src
.read_segment_range("dat/sound", start, (end - start) as usize)
.expect("region bytes");
let sizes: Vec<usize> = sylpheed_formats::slb::to_xma_riffs(&bytes)
.iter()
.map(|r| r.len() - 60)
.collect();
assert_eq!(
sizes,
vec![1_294_336, 1_118_208, 1_171_456],
"the region must reproduce the RUNNING DECODER's byte_sizes; \
a first chunk of 806912 means the start filter has come back"
);
}

View File

@@ -269,3 +269,62 @@ fn a_waves_declared_size_is_confirmed_by_the_next_seek() {
assert!(checked >= 30, "expected banks to check, got {checked}");
eprintln!("wave-boundary identity held for {checked} banks");
}
/// A **music** bank has no leading segment — the bytes before its first `RIFF`
/// are the bank header, and emitting them made `BGM_103` look like three stems.
///
/// The header sizes itself (`+0x24`, in 2048-byte blocks), and on every bank on
/// this disc that size lands exactly on the first `RIFF`. So the guard is not a
/// heuristic and has no threshold: if a bank states a header, believe it.
#[test]
fn a_bank_that_states_its_own_header_has_no_leading_segment() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let mut with_header = 0usize;
let mut mid_bank = 0usize;
// Peek at the 56-byte header through the archive's flat data rather than
// decompressing 9 519 entries: `sound.pak` stores them uncompressed, and a
// full read of all of them is several GB (it OOM-killed the test runner).
for entry in snd.entries() {
let Some(head) = snd.data_at(entry.offset as usize, 0x38) else { continue };
match slb::bank_header_len(head) {
Some(h) => {
let b = snd.read(entry).expect("read a bank that states a header");
let ri = b.windows(4).position(|w| w == b"RIFF").expect("has a RIFF");
// Declared header ends exactly at the first RIFF: no gap, so
// nothing before it can be a packet stream.
assert_eq!(h, ri, "a bank header that does not end at its first RIFF");
with_header += 1;
}
None => mid_bank += 1,
}
}
// 28 music banks (ids 1001-1023, 1101-1105); the rest are mid-bank windows,
// where the leading region IS real and must keep being emitted.
assert_eq!(with_header, 28, "banks stating their own header at offset 0");
assert!(mid_bank > 9000, "mid-bank windows, got {mid_bank}");
eprintln!("{with_header} banks state a header; {mid_bank} mid-bank windows");
}
/// The regression itself: the menu's music bank is **two** sub-waves, and they
/// are the two the corpus names — matching the executable's `BGM_103` and the
/// two streams the runtime XMA probe saw at the main menu.
#[test]
fn the_menu_music_bank_is_exactly_two_sub_waves() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
for (name, sizes) in [
("BGM_103.slb", [3_876_864usize, 3_930_112]),
("BGM_001.slb", [4_466_688, 4_673_536]),
] {
let entry = snd.find_by_name(name).expect("bank present");
let b = snd.read(entry).expect("read");
let riffs = slb::to_xma_riffs(&b);
assert_eq!(riffs.len(), 2, "{name}: sub-wave count");
for (r, want) in riffs.iter().zip(sizes) {
let di = r.windows(4).position(|w| w == b"data").expect("data chunk");
let got = u32::from_le_bytes(r[di + 4..di + 8].try_into().unwrap()) as usize;
assert_eq!(got, want, "{name}: sub-wave payload size");
}
}
}

View File

@@ -0,0 +1,197 @@
//! An opaque full-screen primitive cannot paint above what it would hide.
//!
//! A keyless primitive has no layer key, and `implied_layer_key` records the
//! handful whose position was measured in the running game. For one class the
//! file settles it without a measurement: an element covering the screen and
//! fully opaque at some instant cannot paint above anything visible then, or the
//! screen is blank. Where that set is *every* other element, the position is
//! forced to first.
//!
//! The port found this by contradiction on `build_12`/`build_15`, which its
//! renderer composited to solid black at every instant of their declared life.
//!
//! Argument, census and reach: `docs/re/structures/ui-forced-backdrop.md`.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
fn build(ar: &PakArchive, i: usize) -> (Vec<u8>, ui_layout::UiBuild) {
let by = ar.read(&ar.entries()[i]).expect("entry");
let b = ui_layout::parse_build(&by).expect("parse");
(by, b)
}
fn el<'a>(b: &'a ui_layout::UiBuild, name: &str) -> &'a ui_layout::Element {
b.elements.iter().find(|e| e.name == name).expect(name)
}
/// The two controls are measured orders from the running game. The rule has to
/// reproduce one and permit the other, or it is not measuring occlusion.
#[test]
fn the_rule_reproduces_both_measured_primitives() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
// `palogo_eff0.prm` is MEASURED painting first. Named like an overlay, so a
// name-based rule gets it wrong; occlusion gets it right.
let (_, splash) = build(&ar, 11);
assert!(
ui_layout::forced_backdrop(&splash, el(&splash, "palogo_eff0.prm")),
"the developer splash's backdrop is measured FIRST and must come out forced"
);
// `pteff00.prm` is MEASURED painting last. It is opaque only at its screen's
// entry and exit, so the rule must NOT force it down.
for entry in [4usize, 5] {
let (_, b) = build(&ar, entry);
assert!(
!ui_layout::forced_backdrop(&b, el(&b, "pteff00.prm")),
"entry {entry}: pteff00.prm is measured painting LAST and must stay permitted on top"
);
}
}
/// The case that prompted it: the loading screens.
#[test]
fn the_loading_screens_backdrop_sorts_first() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [12usize, 15] {
let (by, b) = build(&ar, entry);
let prim = el(&b, "pgloading_eff00.prm");
assert!(ui_layout::forced_backdrop(&b, prim), "entry {entry}");
let order = ui_layout::derived_paint_order(&b, &by);
assert_eq!(
order.first().copied(),
Some(prim.index),
"entry {entry}: the backdrop must be painted first, not last"
);
}
}
/// 🔴 The rule quantifies over "every instant the primitive is opaque" and "every
/// element visible then", so both halves depend on where the timeline ends and on
/// what an element does after its own last keyframe. **The hold is not a
/// convenience: a measured order requires it.**
///
/// `palogo_eff0.prm` is a SINGLE keyframe at t=0. If an element counted as *gone*
/// after its last keyframe, the splash's backdrop would exist for one instant, no
/// other element would be up yet, and the rule would call it free — against the
/// order measured in the running game, which paints it first.
///
/// Disc-wide the choice decides **72 of 130** verdicts, so this is the load-bearing
/// half of the rule. (Using the header's declared `+0x08` as the span instead of
/// the elements' maximum changes **0**.)
#[test]
fn the_hold_after_a_final_keyframe_is_required_by_a_measured_order() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [10usize, 11] {
let (_, b) = build(&ar, entry);
let prim = el(&b, "palogo_eff0.prm");
assert_eq!(prim.keyframes.len(), 1, "entry {entry}: the case rests on it being static");
// With the hold — what `pose_at` does, and what the game does.
assert!(
ui_layout::forced_backdrop(&b, prim),
"entry {entry}: measured painting FIRST, so the rule must force it"
);
// Without it, spelled out here rather than imported, so the test states
// the counterfactual it is pinning.
let tmax = b.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
let last = |e: &ui_layout::Element| e.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0);
let alpha_no_hold = |e: &ui_layout::Element, t: u32| -> u32 {
if t > last(e) { 0 } else { e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) }
};
let opaque: Vec<u32> = (0..=tmax).filter(|&t| alpha_no_hold(prim, t) == 255).collect();
let others: Vec<_> = b.elements.iter().filter(|o| o.index != prim.index).collect();
let below = others.iter()
.filter(|o| opaque.iter().any(|&t| alpha_no_hold(o, t) > 0)).count();
assert_ne!(
below, others.len(),
"entry {entry}: without the hold this element would come out FREE — which is \
why the hold is load-bearing rather than incidental"
);
}
}
/// Disc-wide: the rule must fire on a real population and never on something it
/// cannot occlude.
#[test]
fn forced_backdrops_are_full_screen_and_plentiful() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut forced, mut prm, mut tbm) = (0usize, 0usize, 0usize);
for p in &paks {
let Ok(ar) = PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else { continue };
for element in &b.elements {
if !ui_layout::forced_backdrop(&b, element) {
continue;
}
forced += 1;
if element.name.ends_with(".prm") { prm += 1 }
else if element.name.ends_with(".tbm") { tbm += 1 }
// 🔴 Untextured only. This assertion caught the rule's real
// limit: applied to `.t32` sprites it claimed 22 of them must
// sort first, against their own layer keys — a sprite's element
// alpha says nothing about its texture's coverage.
assert!(
element.sprite.is_none(),
"{}: a textured sprite cannot be judged to occlude by element alpha",
element.name
);
assert!(
element.pivot_x * 2 >= b.design_w as u32
&& element.pivot_y * 2 >= b.design_h as u32,
"{}: a quad that does not cover the screen cannot occlude it",
element.name
);
}
}
}
assert!(forced > 50, "expected a real population, got {forced}");
eprintln!("{forced} keyless primitives have their position forced to first");
// 🔴 Pin the split, so anyone tightening this rule sees what it would cost.
// Only the `.prm` half is DECODED: a solid colour quad's fade IS its pixel, so
// opacity and coverage are the same fact. Every `.tbm` in the set carries fade
// `ffffffff` — a white SOLID quad painted first would make the screen white, so
// they are textured, and element alpha does not establish their coverage.
// Their verdicts are kept because restricting to `.prm` would send eleven
// screens' backgrounds back to last, which is the bug this rule fixed.
assert!(prm >= 40 && tbm >= 30,
"expected roughly 42 .prm / 38 .tbm forced instances, got {prm} / {tbm} — \
if this moved, re-read the self-refutation section of ui-forced-backdrop.md");
}

View File

@@ -120,8 +120,17 @@ fn header_0x08_against_the_keyframe_times() {
worst.push(format!("{pak}: max keyframe {max_time} > header {dur}"));
}
}
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
*ratio.entry(r.min(30)).or_default() += 1;
// ⚠️ Bundles whose every group is a single static pose contribute
// `max_time == 0` and say nothing about whether `+0x08` is a length.
// Before 2026-08-29 they were invisible here, because the old keyframe
// time reading left a one-frame group's only pose untimed; the corrected
// record layout (`docs/re/ui-keyframe-record-layout.md`) gives it the
// group's lead-in time, which is 0. They are excluded rather than
// allowed to swamp the histogram's zero bucket — 546 of them do.
if max_time > 0 {
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
*ratio.entry(r.min(30)).or_default() += 1;
}
});
eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}");
@@ -142,11 +151,18 @@ fn header_0x08_against_the_keyframe_times() {
assert!(animated > 0, "no animated bundles — the sweep is broken");
// MEASURED 2026-08-24. +0x08 bounds the keyframe times in EVERY one of the
// 2313 bundles that have both, and 444 of them reach it exactly. The
// MEASURED 2026-08-24, re-measured 2026-08-29 under the corrected keyframe
// record layout. +0x08 bounds the keyframe times in EVERY one of the 2 859
// bundles that have both (2 313 before the correction, which could not time
// a group's final pose at all), and 444 of them reach it exactly. The
// spread-out ratio histogram is what rules out the boring explanation: a
// large unrelated constant would bound everything too, but then the ratios
// would pile up near zero instead of peaking at 1.0.
//
// ✅ The correction STRENGTHENS this result rather than weakening it: 546
// more bundles now carry a readable last-pose time, and `over` is still 0 —
// i.e. the newly-visible times, which are the LATEST in every group, still
// do not run past the header's.
assert_eq!(over, 0, "a keyframe time runs past the header's +0x08");
assert!(exact > 400, "the bound is never attained — it may be unrelated");
let near_one = ratio.get(&10).copied().unwrap_or(0);

View File

@@ -0,0 +1,204 @@
//! A placement group's time word **precedes** the pose it belongs to.
//!
//! A group is an 8-byte header `{u32 element_index, u32 frame_count}` followed
//! by `frame_count` records of 40 bytes, each `{u32 time; 36-byte pose}`. The
//! parser's block window starts at the *pose*, four bytes into the record, so
//! the word at a block's `+36` is the time of the pose that FOLLOWS it, and the
//! first pose's time is the group's lead-in word at `header + 8`.
//!
//! The old reading took `+36` as the block's own time. That is off by one, and
//! it is where two long-standing oddities came from: the group looked four bytes
//! short, and the final pose — the end of every fade-out — carried no time.
//!
//! Full argument and disc-wide census: `docs/re/ui-keyframe-record-layout.md`.
//!
//! Two checks here, both disc-wide:
//!
//! 1. **Every pose is timed, and the times are non-decreasing.** Under the old
//! reading the last pose has no time at all, so this cannot even be asked.
//! 2. **A monotone alpha ramp of three or more segments runs at a constant
//! rate.** Interpolation between keyframes is linear
//! (`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment makes
//! multi-keyframe ramps come out at a constant d(alpha)/d(time). The old
//! reading achieves this on **zero** ramps on the whole disc.
use std::path::{Path, PathBuf};
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
let default = Path::new(
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
);
if default.join("dat").is_dir() {
return Some(default.to_path_buf());
}
None
}
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
for p in &paks {
let name = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(arc) = PakArchive::open(p) else { continue };
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
if ratc::is_ratc(&bytes) {
f(&name, &bytes);
}
}
}
}
/// Maximal runs of strictly monotone alpha with at least `min_seg` segments.
fn monotone_ramps(alphas: &[i32], min_seg: usize) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let (mut i, n) = (0usize, alphas.len());
while i + 1 < n {
if alphas[i] == alphas[i + 1] {
i += 1;
continue;
}
let up = alphas[i + 1] > alphas[i];
let mut j = i + 1;
while j + 1 < n && ((alphas[j + 1] > alphas[j]) == up) && alphas[j + 1] != alphas[j] {
j += 1;
}
if j - i >= min_seg {
out.push((i, j));
}
i = j;
}
out
}
fn constant_rate(times: &[u32], alphas: &[i32], a: usize, b: usize) -> Option<bool> {
let mut rates = Vec::new();
for k in a..b {
let dt = times[k + 1].checked_sub(times[k])?;
if dt == 0 {
return None;
}
rates.push((alphas[k + 1] - alphas[k]).unsigned_abs() as f64 / dt as f64);
}
let mean = rates.iter().sum::<f64>() / rates.len() as f64;
if mean == 0.0 {
return None;
}
let worst = rates.iter().map(|r| (r - mean).abs()).fold(0.0, f64::max);
Some(worst / mean <= 0.06)
}
#[test]
fn every_pose_is_timed_and_the_times_are_ordered() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
eprintln!("SKIP: the legacy time reading is selected");
return;
}
let (mut groups, mut untimed, mut out_of_order) = (0usize, 0usize, 0usize);
let mut examples: Vec<String> = Vec::new();
for_each_build(&root, |pak, bytes| {
let Some(build) = ui_layout::parse_build(bytes) else {
return;
};
if build.from_fallback {
return;
}
for el in &build.elements {
if el.keyframes.is_empty() {
continue;
}
groups += 1;
if el.keyframes.iter().any(|k| k.time.is_none()) {
untimed += 1;
if examples.len() < 8 {
examples.push(format!("{pak}: {} has an untimed pose", el.name));
}
continue;
}
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
if t.windows(2).any(|w| w[1] < w[0]) {
out_of_order += 1;
if examples.len() < 8 {
examples.push(format!("{pak}: {} times {t:?} descend", el.name));
}
}
}
});
eprintln!("placement groups: {groups}; untimed: {untimed}; out of order: {out_of_order}");
for e in &examples {
eprintln!(" {e}");
}
assert!(groups > 10_000, "expected the whole disc, saw {groups} groups");
assert_eq!(untimed, 0, "every pose must carry a time");
assert_eq!(out_of_order, 0, "keyframe times must not descend");
}
#[test]
fn multi_segment_alpha_ramps_run_at_a_constant_rate() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
eprintln!("SKIP: the legacy time reading is selected");
return;
}
let (mut ramps, mut constant) = (0usize, 0usize);
for_each_build(&root, |_pak, bytes| {
let Some(build) = ui_layout::parse_build(bytes) else {
return;
};
if build.from_fallback {
return;
}
for el in &build.elements {
if el.keyframes.iter().any(|k| k.time.is_none()) {
continue;
}
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
let a: Vec<i32> = el
.keyframes
.iter()
.map(|k| ((k.fade >> 24) & 0xff) as i32)
.collect();
for (lo, hi) in monotone_ramps(&a, 3) {
if let Some(ok) = constant_rate(&t, &a, lo, hi) {
ramps += 1;
constant += usize::from(ok);
}
}
}
});
let share = 100.0 * constant as f64 / ramps as f64;
eprintln!("multi-segment alpha ramps: {constant}/{ramps} at a constant rate ({share:.1}%)");
assert!(ramps > 500, "expected the whole disc, saw {ramps} ramps");
// The old reading scores 0 of 1042. Half is a floor, not a target: the rest
// are genuinely shaped ramps, authored with keyframes that are not evenly
// spaced. Anything near zero means the time assignment has slipped again.
assert!(
share > 45.0,
"only {share:.1}% of ramps run at a constant rate — the time \
association has probably slipped (the old off-by-one scored 0%)"
);
}

View File

@@ -0,0 +1,121 @@
//! A nested record's header `+0x08` is its LOOP LENGTH, and its keyframes need
//! not fill it.
//!
//! A `*f` focus record animates for as long as its button is focused, so
//! something has to say where the cycle restarts. The keyframes cannot: the
//! `PRESS Ⓐ` plate's `ptbtn00f` ramps 0→80→0 over **105** units, and a 105-unit
//! period is 15 % short of every measurement of the real thing.
//!
//! Each record is itself a RATC bundle with its own header. `+0x08` is a frame
//! count, and if it is the loop length it must never be **less** than the
//! record's largest keyframe time — an animation cannot restart before its own
//! last pose. Disc-wide that holds 1 781 times out of 1 781, and 7.7 % of records
//! declare *more*, which is a hold at the final pose before the cycle repeats.
//!
//! `ptbtn00f` is one of those: 105 units of ramp inside a **120**-unit cycle, so
//! it rests dark for 15 units between pulses.
//!
//! Argument, the falsification test against the measured period, and the census:
//! `docs/re/structures/ui-record-loop-length.md`.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
/// Read a nested record's declared length and its largest keyframe time.
fn record_len_and_maxt(bundle: &[u8], off: usize, size: usize) -> Option<(i64, i64)> {
if off + 12 > bundle.len() || off + size > bundle.len() || &bundle[off..off + 4] != b"RATC" {
return None;
}
let len = u32::from_be_bytes(bundle[off + 8..off + 12].try_into().ok()?) as i64;
let lb = ui_layout::parse_build(&bundle[off..off + size])?;
let maxt = lb
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max()? as i64;
(maxt > 0).then_some((len, maxt))
}
/// The falsifier: a loop cannot restart before its own last keyframe.
#[test]
fn a_records_declared_length_is_never_shorter_than_its_keyframes() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut total, mut exact, mut holds) = (0usize, 0usize, 0usize);
for p in &paks {
let Ok(ar) = PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
let Some((len, maxt)) = record_len_and_maxt(&by, o, s) else { continue };
total += 1;
assert!(
len >= maxt,
"{}:{rn} declares a {len}-unit cycle but has a keyframe at t={maxt} — \
a loop cannot restart before its own last pose",
p.file_name().unwrap().to_string_lossy()
);
if len == maxt { exact += 1 } else { holds += 1 }
}
}
}
assert!(total > 1500, "expected >1500 timed nested records, got {total}");
// The field must carry information. If every record declared exactly its own
// last keyframe time, "loop length" would be an unfalsifiable relabelling.
assert!(
holds > 50,
"only {holds} of {total} records declare a hold — the field would be carrying nothing"
);
eprintln!("{total} records: {exact} exact, {holds} with a hold before the cycle repeats");
}
/// The case that motivated it, pinned by name.
#[test]
fn the_press_a_plate_glow_holds_dark_for_fifteen_units() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let by = ar.read(&ar.entries()[2]).expect("entry 2 — the PRESS A plate");
let b = ui_layout::parse_build(&by).expect("parse");
let &(o, s) = b.records.get("ptbtn00f.rat").expect("the focus record");
let (len, maxt) = record_len_and_maxt(&by, o, s).expect("a timed record");
assert_eq!(maxt, 105, "the glow's ramp ends at t=105");
assert_eq!(len, 120, "but the cycle is 120 units");
assert_eq!(len - maxt, 15, "so it holds dark for 15 units between pulses");
// The five main-menu focus records fill their cycle exactly — the contrast
// that shows the slack is a property of this record, not of the format.
let menu = ar.read(&ar.entries()[5]).expect("entry 5 — the main menu");
let mb = ui_layout::parse_build(&menu).expect("parse");
let mut checked = 0;
for n in 1..=5 {
let name = format!("ptbtn0{n}f.rat");
let Some(&(mo, ms)) = mb.records.get(&name) else { continue };
let (l, m) = record_len_and_maxt(&menu, mo, ms).expect("timed");
assert_eq!((l, m), (120, 120), "{name} should fill its 120-unit cycle exactly");
checked += 1;
}
assert_eq!(checked, 5, "expected five main-menu focus records");
}

View File

@@ -0,0 +1,142 @@
//! A settled screen is one INSTANT, not one hold per element.
//!
//! `Element::rest()` returns an element's last *hold* keyframe, picked for that
//! element alone. For anything that ends the screen settled that is right. For a
//! **transient** it is exactly wrong: a two-frame flash's last hold is the flash
//! *peak*, so `rest()` leaves it burning for the whole screen.
//!
//! `GP_TITLE` build 4 is the case that found this. `ptlogo_back2eff1` … `eff5`
//! are five staggered flashes — `a=0` until t52, `255` for two frames, `0` again
//! two frames later — that sweep left to right across the logo once and are gone
//! by t110. Two elements, `ptlogo_back2eff` (t66238) and `ptlogo_back2`
//! (t80243), then hold for the rest of the screen. `rest()` draws all seven at
//! `a=255` simultaneously, and stacking five extra white glows blows the light
//! arc out to saturation: against the console capture the arc's mean error is
//! 33.22 and 8 581 pixels sit at the clipping level, where the console has 1 459.
//!
//! `UiBuild::settle_time()` recovers the right instant from the disc alone — the
//! midpoint of the longest keyframe-free interval — with no reference to any
//! capture. For this build that is t=198, and posing there takes the arc error to
//! 11.79 and the clipped count to 1 452 against the console's 1 459.
//!
//! Argument, controls and the disc-wide census: `docs/re/structures/ui-settle-time.md`.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
None
}
/// The case that found the bug, asserted end to end.
#[test]
fn a_flash_is_transparent_at_the_settle_time_and_opaque_at_rest() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let arc = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let bytes = arc.read(&arc.entries()[4]).expect("build 4");
let b = ui_layout::parse_build(&bytes).expect("parse");
let (lo, hi) = b.settle_window().expect("a settle window");
let t = b.settle_time().expect("a settle time");
assert!(hi - lo >= 60, "title's settle window should be a second or more, got {lo}..{hi}");
assert!(lo < t && t < hi, "settle time {t} must lie inside {lo}..{hi}");
let alpha = |k: &ui_layout::Keyframe| k.fade >> 24;
let mut flashes = 0;
for el in &b.elements {
let Some(name) = el.name.strip_prefix("ptlogo_back2eff") else { continue };
// `ptlogo_back2eff.t32` itself holds; only the numbered ones flash.
if !name.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
flashes += 1;
let rest = el.rest().expect("a rest pose");
let posed = el.pose_at(t).expect("a posed keyframe");
assert_eq!(
alpha(rest),
255,
"{}: rest() is expected to report the FLASH PEAK — that is the bug",
el.name
);
assert_eq!(
alpha(&posed),
0,
"{} flashes once before t110 and must be gone at the settle time {t}",
el.name
);
}
assert_eq!(flashes, 5, "GP_TITLE build 4 has five numbered back2 flashes");
// …while the two that genuinely hold are still opaque there.
for want in ["ptlogo_back2eff.t32", "ptlogo_back2.t32"] {
let el = b.elements.iter().find(|e| e.name == want).expect(want);
assert_eq!(
alpha(&el.pose_at(t).expect("posed")),
255,
"{want} holds across the settle time and must stay opaque"
);
}
}
/// The window is a property of the data, so it must be computable disc-wide
/// without panicking, and must be self-consistent wherever it exists.
#[test]
fn settle_windows_are_self_consistent_disc_wide() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut with, mut wide) = (0usize, 0usize);
for p in &paks {
let Ok(a) = PakArchive::open(p) else { continue };
for e in a.entries() {
let Ok(by) = a.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
with += 1;
assert!(lo < hi, "an empty window is not a window: {lo}..{hi}");
let t = b.settle_time().expect("a window implies a time");
assert!((lo..=hi).contains(&t), "settle time {t} outside {lo}..{hi}");
// No element may have a keyframe strictly inside the window — that
// is the whole definition, so it is worth asserting rather than
// trusting.
for el in &b.elements {
for k in &el.keyframes {
if let Some(kt) = k.time {
assert!(
kt <= lo || kt >= hi,
"{}: keyframe t={kt} lies inside the settle window {lo}..{hi}",
el.name
);
}
}
}
if hi - lo >= 30 {
wide += 1;
}
}
}
assert!(with > 1000, "expected >1000 bundles with a settle window, got {with}");
eprintln!("{with} bundles have a settle window; {wide} are at least 30 units wide");
}

View File

@@ -1,57 +0,0 @@
# Your mods go here
A mod **replaces a file by shadowing its path**. There is no manifest, no
registration and no load order: if a file exists here at the same relative path
it has in the export tree, the game reads yours instead.
```
export/sprites/title/main_menu/ptbtn01.png <- what the exporter wrote
data/mods/sprites/title/main_menu/ptbtn01.png <- what the game will use
```
That works for **every** asset kind the port reads — a screen's JSON, a sprite
PNG, a sound cue, the music bed, a movie — because every read goes through one
resolver (`port/scripts/export_tree.gd`, `ExportTree.resolve`).
Nothing under `export/` is ever touched, so **re-exporting from your disc is
always safe**, and *"did I break it?"* is answered by moving your file out of
this directory.
Point the game somewhere else with `SYLPHEED_MODS=/path/to/tree`.
## The game tells you what you changed
Every file a mod replaces is printed the first time it is read:
```
mod: sprites/title/main_menu/ptbtn01.png <- /work/data/mods/sprites/title/main_menu/ptbtn01.png
```
A modded run that looked identical to an unmodded one in the log would leave you
with exactly one debugging tool — delete the mod and try again.
## Try it in ten seconds
Replace the `NEW GAME` label with a magenta block. The size is the original's,
`203x43`, and nothing here is derived from the disc:
```bash
mkdir -p data/mods/sprites/title/main_menu
ffmpeg -f lavfi -i "color=c=0xff00c8:s=203x43" -frames:v 1 -pix_fmt rgba \
data/mods/sprites/title/main_menu/ptbtn01.png
godot --path port -- --menu
```
Delete the file to put it back.
## Nothing in here is committed
`.gitignore` excludes everything in this directory except this README. That is
deliberate: a mod is usually an *edited game asset*, and this repository never
holds game assets — not in `export/`, and not here either.
## One tree, not a stack
Several mods layering over each other would need a load order, and a load order
needs a rule nobody has asked for yet. Today there is one override tree. If you
want more, say so rather than assuming the port has an answer.

View File

@@ -82,24 +82,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
# ── gitea-mcp ────────────────────────────────────────────────────────────────
# The agent's hands on issues, pull requests and notifications — Gitea's own MCP
# server, so there is no second store of truth to drift out of sync with the
# first.
#
# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is
# handed a token that can write to the repository. The checksum is the one
# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset.
ARG GITEA_MCP_VERSION=1.7.0
ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d
RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \
"https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \
&& echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \
&& chmod +x /usr/local/bin/gitea-mcp \
&& rm -f /tmp/gitea-mcp.tar.gz \
&& gitea-mcp --version
# ── The agent user ───────────────────────────────────────────────────────────
# NOT root, and not negotiable: Claude Code refuses --dangerously-skip-permissions
# when it has root privileges. uid/gid 1000 matches the host account so files

View File

@@ -39,117 +39,34 @@ set answered_trust 0
set answered_bypass 0
spawn -noecho claude --dangerously-skip-permissions {*}$argv
set child_pid [exp_pid]
# 🔴 THIS WRAPPER USED TO SWALLOW BOTH THE SIGNAL AND THE EXIT STATUS, and those
# two omissions caused most of this project's multi-hour outages. Found
# 2026-09-03 by tracing the signal path, after a tooling review predicted exactly
# this from the symptoms.
#
# The path is: tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
#
# `spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
# is the process Docker signals, and everything below it depends on expect
# passing things along. It did not.
#
# 1. NO SIGNAL FORWARDING. `docker stop` sent SIGTERM to expect, which died and
# took the pty with it. Claude Code never received a SIGTERM, so it never ran
# its `SessionEnd` hooks and never wrote `lastSessionId`/`history` to
# `~/.claude.json` -- which are written only at a GRACEFUL shutdown. That is
# the whole reason `claude --continue` answered "No conversation found to
# continue" with 33 MB of transcripts sitting in the volume beside it, and why
# we resume by scraping a session id off a transcript filename instead.
#
# 2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
# ZERO. So when the kernel OOM-killer took the child, expect saw EOF and
# reported a clean exit -- `OOMKilled: true` with `ExitCode 0`, which is not
# Docker being odd, it is this line. It also meant `--restart on-failure`
# would have treated a memory kill as success, which is why the policy had to
# be `unless-stopped`.
#
# Both are fixed here. Signals are forwarded to the child and its real status is
# propagated, so a kill reads as 137, a clean stop lets Claude Code shut down
# properly, and the exit code means what it says.
proc forward {sig} {
global child_pid
catch { exec kill -$sig $child_pid }
}
trap { forward TERM } SIGTERM
trap { forward INT } SIGINT
trap { forward HUP } SIGHUP
# 🔴 THIS BLOCK TYPED INTO A LIVE SESSION, and the single-word patterns were why.
#
# 2026-09-04: both agents stopped, and the decoder said so itself --
#
# "I received '2' and '1' but I don't have a pending question those would
# answer -- I was in the middle of setting up the /loop cron job."
#
# The patterns were the bare substrings `Choose`, `trust` and `accept`. The
# /loop PROMPT is echoed into the terminal, and that day's brief contained
# "H3, the plate delay, is ACCEPTED" and "Do not choose what jump means". So
# expect matched the agent's own instructions and sent `2\r` and `1\r` into a
# running session, which then sat waiting for a human to explain them.
#
# The original comment argued that a multi-word pattern "never matches" because
# the gate text wraps. That is true of a LITERAL multi-word string and false of a
# whitespace-tolerant regex, which is what these now are: `\s+` spans the wrap.
# The terminal is also 200 columns wide (set above), so these lines rarely wrap
# at all.
#
# Two defences, because one is not enough for something that can type:
# 1. patterns specific enough that ordinary prose cannot match them
# 2. gates are skipped ENTIRELY when resuming -- a resumed session cannot show
# a first-run gate, so there is nothing to answer and everything to lose
if {[info exists env(SYLPH_SKIP_GATES)] && $env(SYLPH_SKIP_GATES) ne "0"} {
send_user "\[claude-autonomous] resuming: first-run gates cannot appear, not watching for them\n"
} else {
# Shorter than the old 90 s. The gates appear immediately or not at all, and
# every extra second is a second in which this can type into a live session.
set timeout 25
expect {
-re {Choose\s+the\s+text\s+style} {
if {!$answered_theme} { set answered_theme 1; send "\r" }
exp_continue
}
-re {Do\s+you\s+trust\s+the\s+files} {
if {!$answered_trust} {
set answered_trust 1
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
send "1\r"
}
exp_continue
}
-re {Yes,\s*I\s+accept} {
if {!$answered_bypass} {
set answered_bypass 1
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
send "2\r"
}
exp_continue
}
timeout {
# No gate appeared. Stop matching so nothing later in the run can be
# answered by accident -- which is exactly what used to happen.
}
eof { exit }
expect {
-re {Choose} {
if {!$answered_theme} { set answered_theme 1; send "\r" }
exp_continue
}
-re {trust} {
if {!$answered_trust} {
set answered_trust 1
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
send "1\r"
}
exp_continue
}
-re {accept} {
if {!$answered_bypass} {
set answered_bypass 1
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
send "2\r"
}
exp_continue
}
timeout {
# No new gate for a while: the session is up (or never had one). Stop
# matching so nothing later in the run can be answered by accident.
}
eof { exit }
}
# Hand the terminal over for the rest of the run.
interact
# Propagate the child's REAL exit status. `interact` returns when the child is
# gone; `wait` then yields {pid spawnid os_error status}. Without this the script
# simply ran off the end and returned 0 -- see the note at `spawn` above for what
# that cost.
catch wait result
set status 0
if {[info exists result] && [llength $result] >= 4} {
# os_error_flag (index 2) is -1 for a normal exit; anything else means the
# wait itself failed and the status field is not a status.
if {[lindex $result 2] == 0} {
set status [lindex $result 3]
}
}
exit $status

View File

@@ -84,26 +84,11 @@ if [ -r /sys/fs/cgroup/memory.max ]; then
[ "$_m" != max ] && mem_gib=$(( _m / 1073741824 ))
fi
[ "${mem_gib:-0}" -lt 1 ] && mem_gib=1
# 🔴 THIS CONSTANT WAS WRONG, AND IT COST A RUN. `mem_gib * 2 / 3` assumes
# ~1.5 GB per job. On 2026-09-01 a raw `cargo test --release -p sylpheed-formats`
# ran 4 jobs in a 6 GB container and was OOM-killed mid-task. Release-mode rustc
# on this workspace needs closer to 2 GB, so the divisor is 2, not 3/2.
#
# ⚠️ And the kill reported `OOMKilled: true` with **ExitCode 0**, so it read as a
# clean exit — which is why the restart policy is `unless-stopped` rather than
# `on-failure`.
by_mem=$(( mem_gib / 2 ))
by_mem=$(( mem_gib * 2 / 3 ))
[ "$by_mem" -lt 1 ] && by_mem=1
jobs=$(( cpus < by_mem ? cpus : by_mem ))
# An EXPLICIT cap from the launcher wins. Without this the launcher's
# `-e CARGO_BUILD_JOBS=...` was computed, exported over, and silently discarded
# — the guardrail was set and then removed three lines later.
if [ -n "${CARGO_BUILD_JOBS:-}" ] && [ "${CARGO_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then
jobs="$CARGO_BUILD_JOBS"
_why=" (explicit, from the launcher)"
fi
export SYLPH_JOBS="$jobs" CARGO_BUILD_JOBS="$jobs" CMAKE_BUILD_PARALLEL_LEVEL="$jobs"
log "build parallelism: $jobs${_why:-} (cpus=$cpus, mem=${mem_gib}GiB avail)"
log "build parallelism: $jobs (cpus=$cpus, mem=${mem_gib}GiB avail)"
mkdir -p "$HOME/shots" "$HOME/logs"
@@ -156,22 +141,7 @@ mkdir -p /exchange/files 2>/dev/null || true
#
# Newer-wins rather than always-copy, because the container refreshes its own
# token during a run and that copy may legitimately be the fresher one.
# 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT.
#
# With CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file
# in would re-create the exact collision the token exists to remove: three
# clients on one rotating refresh token, the losers of a rotation race getting
# their stored tokens CLEARED to empty strings and parking at "Login expired".
# Measured 2026-09-04 -- see the launcher.
# 🔴 PER-AGENT LOGIN: never seed. Set SYLPH_OWN_LOGIN=1 once this container
# has run `claude auth login` itself. Its grant is its OWN -- copying the
# host's over it re-creates the rotation collision that empties credentials
# and parks the session, which is the whole reason per-agent logins exist.
if [ -n "${SYLPH_OWN_LOGIN:-}" ] && [ "${SYLPH_OWN_LOGIN}" != "0" ]; then
log "auth: this agent has its own login; not seeding from the host"
elif [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
log "auth: using the long-lived token from the environment; not seeding OAuth"
elif [ -d "$HOME/.claude.seed" ] && \
if [ -d "$HOME/.claude.seed" ] && \
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
mkdir -p "$HOME/.claude"
@@ -190,10 +160,6 @@ fi
if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then
cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true
fi
# Nothing is restored into `~/.claude.json` on purpose. Resuming is done by
# SESSION ID off the transcript instead — see the resume block below for why
# the index is useless for this.
# `credential.helper=store` rewrites this file by rename-over-target, which
# fails with EBUSY on a bind mount -- reported as `fatal: unable to write
# credential store`, while the push itself succeeds. A fatal line that is
@@ -208,54 +174,6 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER"
"$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true
chmod 600 "$HOME/.claude.json" 2>/dev/null || true
# ── The Gitea MCP server ─────────────────────────────────────────────────────
# Registered at USER scope rather than from a committed `.mcp.json`: the token
# differs per agent and none of it belongs in git.
#
# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat
# …)` would write the secret in cleartext into ~/.claude.json, where it is read
# by every session in this container and lands in any copy of that file.
# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its
# read-only mount and lets the server read it itself.
#
# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name
# that already exists, and ~/.claude.json is re-seeded above — neither ordering
# survives alone.
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}"
GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}"
# Which tools this agent gets. Deliberately not all of them:
#
# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one.
# Gitea will not let an author approve its own pull request — but the moment
# the two agents are separate people, nothing stops them approving each
# OTHER's and satisfying `required_approvals` between themselves with no
# human involved. Separate identities open that hole; withholding the tool
# closes it here, and the approvals whitelist on `main` closes it there.
# * the file / branch / repo WRITE tools are absent: a change reaches `main`
# as a reviewable commit through git, or it does not reach it.
#
# `pull_request_write` bundles `merge` into one tool and cannot be split, so
# merging stays blocked where the agent cannot reach it — the merge whitelist in
# branch protection. This list is defence in depth BEHIND that, never instead.
GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-get_me,notification_read,notification_write,list_issues,issue_read,issue_write,attachment_read,search_issues,label_read,milestone_read,list_pull_requests,pull_request_read,pull_request_write}"
if [ ! -s "$GITEA_TOKEN_FILE" ]; then
echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered."
echo "[entrypoint] This agent cannot read its notifications or open a pull"
echo "[entrypoint] request, which is most of what its brief asks of it."
elif ! command -v gitea-mcp >/dev/null 2>&1; then
echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2
else
claude mcp remove gitea -s user >/dev/null 2>&1 || true
if claude mcp add -s user gitea \
-e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
-- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then
echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL"
else
echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2
echo "[entrypoint] no pull requests and no notifications." >&2
fi
fi
# ── Claude Code ──────────────────────────────────────────────────────────────
if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
# Drop the image's default CMD first, or `claude` is handed the literal string
@@ -263,81 +181,6 @@ if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then
set --
fi
# ── Resume across a restart ────────────────────────────────────────────────
#
# The container restarts automatically now, and a restart that opens a BLANK
# session throws away everything the agent knew. That is not hypothetical: on
# 2026-09-01 an OOM kill ended a run mid-task with a 2.7 MB transcript and two
# files uncommitted in the volume.
#
# 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured 2026-09-01:
#
# `--continue` resolves through `~/.claude.json`'s per-project `history` and
# `lastSessionId`. Those are written at a GRACEFUL SHUTDOWN — mid-session the
# live file has `history: None`, `lastSessionId: None`. A container that is
# OOM-killed or `docker rm -f`ed never writes them, which is exactly the case
# this feature exists for. So `--continue` answered "No conversation found to
# continue" with 33 MB of perfectly good transcripts in the volume beside it,
# and persisting `.claude.json` did not help because the fields were never
# populated in the first place.
#
# The TRANSCRIPTS are durable and are named by session id, so read the id off
# the newest one for this working directory. Claude Code has not started yet
# at this point, so the newest is the previous run's.
#
# The /loop prompt is still passed, so the loop is RE-ARMED rather than merely
# restored — a resumed conversation with no wake-up scheduled answers once and
# stops, which looks like resuming and is not.
SYLPH_STAMP="$HOME/.claude/.sylph-last-start"
SYLPH_RESUME=0
SYLPH_SESSION=""
SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')"
if [ -d "$SYLPH_PROJ" ]; then
_newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1)
if [ -n "$_newest" ]; then
SYLPH_SESSION=$(basename "$_newest" .jsonl)
SYLPH_RESUME=1
fi
fi
# 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. If the last start was under
# two minutes ago we are already in a restart loop, and continuing back into
# whatever killed us is the one thing guaranteed not to help. Start fresh and
# say so, rather than burning tokens on the same death repeatedly.
if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then
_last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0)
_now=$(date +%s)
if [ $((_now - _last)) -lt 120 ]; then
SYLPH_RESUME=0
log "restarted <120s after the last start — restart loop suspected;"
log " starting a FRESH session rather than continuing into the same death"
fi
fi
mkdir -p "$HOME/.claude" 2>/dev/null || true
date +%s > "$SYLPH_STAMP" 2>/dev/null || true
if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then
set -- "$1
⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact,
but the process that was running when it died is gone. Before anything else:
1. \`git -C /work status\`. Whatever you had in progress is still in the tree,
UNCOMMITTED. Commit it and \`push-work\` before starting anything new.
2. Any build, test or capture you had running did NOT finish. Do not read its
absence as a result.
3. The likeliest cause is an OOM kill — this container is capped at 6 GB.
\`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it,
and prefer \`build-reborn test\` over a raw \`cargo test --release\`, which
bypasses the wrapper's job cap. That is exactly what killed the run on
2026-09-01."
log "resuming session ${SYLPH_SESSION%%-*}… with a restart notice"
fi
# Tell the gate-answering wrapper to stand down: a resumed session cannot
# show a first-run gate, and on 2026-09-04 its single-word patterns matched
# the /loop prompt itself and typed "2" and "1" into a live session.
[ "$SYLPH_RESUME" = "1" ] && export SYLPH_SKIP_GATES=1
[ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@"
# The flag the user asked for. It is refused under root, which is why this
# image runs as `agent`.
# Remote Control registers the session with your account so you can chat with

View File

@@ -23,8 +23,6 @@
# SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent)
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
# (default: $HOME/.sylph-git-credentials)
# SYLPH_GITEA_TOKEN this agent's own Gitea token file
# (default: $HOME/.sylph-gitea-token-decoder)
# SYLPH_LOOP_INTERVAL fixed loop cadence, e.g. 30m (default: 45m)
# SYLPH_CPUS / SYLPH_MEM_GB override the computed half
set -euo pipefail
@@ -132,19 +130,6 @@ docker_args() {
-e "PROJECT_DIR=/work"
-e "SYLPH_EXCHANGE=/exchange"
-e "SYLPH_AGENT=decoder"
# 🔴 THE JOB CAP LIVES IN THE ENVIRONMENT, NOT IN THE WRAPPER.
#
# `build-reborn` has always exported CARGO_BUILD_JOBS, and on 2026-09-01
# that was not enough: the agent ran a RAW `cargo test --release -p
# sylpheed-formats`, which never touches the wrapper, got one rustc per
# granted CPU, and the container was OOM-killed at its 6 GB cap mid-task.
# Docker reported ExitCode 0 with OOMKilled true, so it read as a clean
# exit and cost a diagnosis.
#
# A guardrail reachable only through a wrapper protects the calls that use
# the wrapper. This one is inherited by every process in the container, so
# bypassing it takes an explicit override rather than forgetting.
-e "CARGO_BUILD_JOBS=${SYLPH_JOBS:-2}"
-e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git"
-e "XENIA_SRC=/canary"
# ── claude ──
@@ -187,39 +172,6 @@ docker_args() {
# Read-only, and only ever used by `push-work`, which refuses anything but an
# auto/* branch and never force-pushes. Without this the agent's work only
# exists inside the container and dies with it.
# ── Claude auth ──
#
# 🔴 THE ROTATING OAUTH FILE IS WHY THIS AGENT KEPT PARKING, and a long-lived
# token removes the failure by construction rather than recovering from it.
#
# Measured 2026-09-04: `~/.claude/.credentials.json` holds a REFRESH TOKEN THAT
# ROTATES ON USE. Seeding both containers from the host's copy left three
# clients holding one token; the first to refresh invalidated the other two,
# and on the failed refresh **Claude Code CLEARS the stored tokens** -- it
# writes empty strings, keeps the metadata, and parks at "Login expired". The
# decoder's file was caught emptied at 13:04:28 with its last work at 13:04:29.
# A hollow file passes every "does it exist" check, which is why three separate
# diagnoses missed it.
#
# `claude setup-token` issues a LONG-LIVED token against the same Claude
# subscription (not Console/API billing -- `claude auth login` defaults to
# `--claudeai`, and `--console` is the billed one). Passed as an environment
# variable it cannot be rotated out from under a peer and there is no file for
# Claude Code to empty, so both halves of the failure are gone.
#
# Inert until the file exists: without it the OAuth path below is unchanged.
# Pass through: set SYLPH_OWN_LOGIN=1 when this container has run
# `claude auth login` itself, so the entrypoint never copies the host's
# rotating credentials over its own grant. Remote Control needs a real
# login -- the long-lived token does not carry the sessions scope.
[ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN")
CLAUDETOK="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
if [ -f "$CLAUDETOK" ]; then
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$CLAUDETOK")")
echo "==> auth: long-lived token from $CLAUDETOK (no rotating credential file)" >&2
fi
GITCRED="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
if [ -f "$GITCRED" ]; then
_out+=(-v "$GITCRED:/sylph-home/re/.git-credentials.host:ro")
@@ -231,29 +183,6 @@ docker_args() {
echo " or point SYLPH_GIT_CREDENTIALS elsewhere." >&2
fi
# ── Gitea ──
# This agent's OWN token, for its OWN Gitea account — not the push credential
# and not the human's. Three reasons it is separate: `~/.sylph-git-credentials`
# is scoped `write:repository` and every issue endpoint REFUSES it; a pull
# request the agent authored is one a human can approve, which is the entire
# review gate; and revoking one agent then touches neither the other nor you.
#
# Mounted read-only and passed to the MCP server BY PATH — see the entrypoint
# for why the value must not go through the environment.
# Inert until the file exists: the container still runs, with no issues.
GITEATOK="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-decoder}"
if [ -f "$GITEATOK" ]; then
_out+=(
-v "$GITEATOK:/sylph-home/re/.sylph-gitea-token:ro"
-e "GITEA_TOKEN_FILE=/sylph-home/re/.sylph-gitea-token"
)
else
echo "==> NOTE: no Gitea token at $GITEATOK — this agent cannot read its" >&2
echo " notifications, open an issue or open a pull request. Generate one" >&2
echo " while logged in AS sylph-decoder: Settings -> Applications, scopes" >&2
echo " write:repository, write:issue, write:notification, read:user." >&2
fi
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
[ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN")
[ -n "${SYLPH_REMOTE:-}" ] && _out+=(-e "SYLPH_REMOTE=$SYLPH_REMOTE")
@@ -340,17 +269,7 @@ case "${1:-}" in
echo "==> repo: own clone in volume sylpheed-decoder-repo -> /work"
echo "==> pacing: ${INTERVAL:-self-paced}"
docker rm -f "$NAME" >/dev/null 2>&1 || true
# 🔴 `unless-stopped`, NOT `on-failure` -- and the reason is a trap worth
# keeping. When this container was OOM-killed on 2026-09-01, Docker reported
# `OOMKilled: true` with **ExitCode 0**. `on-failure` keys off the exit code,
# so it would have treated a memory kill as a clean finish and left the agent
# down. `unless-stopped` restarts regardless, and still honours an explicit
# `./sylph-agent stop`.
#
# Restarting into the same death is handled at the other end: the entrypoint
# refuses to `--continue` if the last start was under two minutes ago.
docker run -d -i -t --restart unless-stopped "${ARGS[@]}" "$IMAGE" \
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
docker run -d -i -t "${ARGS[@]}" "$IMAGE" "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
echo
echo " running detached as '$NAME'."
echo " ./sylph-agent remote link to chat with it from anywhere"

Some files were not shown because too many files have changed in this diff Show More