Compare commits

...

122 Commits

Author SHA1 Message Date
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
160 changed files with 41914 additions and 111 deletions

View File

@@ -195,6 +195,23 @@ 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, and it is wrong twice over: it
/// omits anything still moving (the title's light sweeps hold off the
/// right edge), and it freezes a transient at its PEAK (the title's five
/// two-frame flashes burn forever). Prefer `--settle`.
#[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 (42 %
/// of them, mostly `loop*` fragments). See
/// `docs/re/structures/ui-settle-time.md`.
#[arg(long)]
settle: bool,
},
}
@@ -353,8 +370,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 +600,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 +646,7 @@ fn cmd_screen_render(
ComposeOptions::default().backdrop
},
include_primitives: primitives,
at,
},
None,
);
@@ -621,14 +669,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 +813,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

@@ -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,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,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,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,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,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,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,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,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,6 +192,77 @@ 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
@@ -493,8 +574,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 +590,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 +618,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 +814,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 +829,7 @@ impl Default for ComposeOptions {
include_animated: false,
backdrop: [14, 14, 20, 255],
include_primitives: false,
at: None,
}
}
}
@@ -825,6 +922,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 +1022,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 +1093,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 +1183,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 +1224,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 +1272,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 +1449,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 +1472,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 +1586,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

@@ -12,6 +12,17 @@ already went wrong once.
* **The toolchain is real.** `tools/re-capture/rebuild_canary.sh` exists because
the old box had no cmake/ninja/clang and only runtime sonames, so it hand-
relinked object files. **Do not use it here.** Use `build-canary`.
🔴 **But `build-canary` does not work in this container as it stands
(2026-08-29).** It builds `${PROJECT_DIR:-/work}/xenia-canary`, which **does
not exist here** — the Canary source is at **`/canary`** (`$XENIA_SRC`). 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 wants to re-run CMake
first, which would fail on the absent source root. **So any Canary change is a
full reconfigure against `/canary` plus a full compile**, not an incremental
one. Budget for that before starting: `SYLPH_JOBS=4`, and this box has been
sitting at **~700 MB free** with a documented history of full-parallel builds
OOM-killing the host.
* **numpy and Pillow are installed.** `entities2.py`, `flight_probe.py` and the
image oracles work. Their absence used to look like a logic bug.
@@ -40,6 +51,64 @@ python3 tools/re-capture/gmem.py find hex:820af844 400
```
* **One emulator at a time.** `run-canary` enforces it with a lockfile.
* 🔴 **`run-canary` is SILENT TWICE OVER, and that defeats `audio-capture`.**
Line 82 is `export SDL_AUDIODRIVER="${SDL_AUDIODRIVER:-dummy}"`, and its
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. The
comment's premise — "there is no PulseAudio here" — **stopped being true when
`tools/audio-capture` landed**, and it starts a daemon on demand.
So a capture through the null sink records **pure silence**, at the right
length, with a perfectly healthy-looking run behind it. To actually record the
game:
⚠️ **And that is only the first of TWO layers.** `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 to the sink,
holds it at 100 % volume for the whole run — and emits silence. Both have to go:
```bash
audio-capture start # or load a null sink yourself
PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio \
run-canary --mute=false … # `"$@"` is last, so this wins
```
Record at the monitor's real format, too — `parec` defaults to stereo/44.1 kHz
and will silently resample a 6-channel monitor:
`parec -d cap.monitor --channels=6 --rate=48000 --format=s16le`.
🔴 **And even with both mutes off, a PulseAudio-monitor capture is not
faithful — use the ALSA tee instead.** A null sink's *monitor* is sampled on a
wall clock and **invents silence** whenever the client is late, so a capture
through it is 39 % holes that the game never emitted. `PULSE_LATENCY_MSEC`
only trades gap count against gap size and never wins.
✅ **The working route is `--apu=alsa` with an ALSA `file` tee in front of a
paced slave** — full recipe, controls and three configuration traps in
[`audio-capture-alsa-file-tee.md`](../re/audio-capture-alsa-file-tee.md).
* 🔴 **A BARE ALSA `file` tee WILL FILL THE DISK — always run a size guard.**
Xenia's ALSA writer thread pads silence whenever its ring is empty
(`alsa_audio_driver.cc:359`), so against a device that never blocks it
free-runs: measured at **~250× real time, 7.34 GB in 50 seconds**. The slave
must pace — `slave.pcm { type pulse }` — and the capture loop should abort
above ~3× real time. The next person to try a bare tee hits this in the first
minute.
* ✅ **And use `--gpu=null` for an audio capture.** It is what takes the guest
from 0.70× to **0.96×** real time, which stops Xenia padding at all: 0.31 %
silence and 0.01 gaps/s, against 9.98 % / 8.37 rendered. ⚠️ No video, so
screen-based provenance is unavailable (use the XMA probe), and `--gpu=null`
runs here die at ~70 s with `PM4_DRAW_INDX: Failed in backend`.
⚠️ **Do not read Canary's 6-channel PulseAudio stream as evidence the GAME is
5.1.** `pactl` will show `float32le 6ch 48000Hz`, channel-mapped to a full 5.1
layout, on any title. That is `AudioDriver::kFrameChannelsDefault = 6`, a
hardcoded constant — the code path actually used
(`SDLAudioSystem::CreateDriver(index, semaphore, &driver)`) constructs
`SDLAudioDriver(semaphore)` and takes every default. The *format* is Xenia's;
only the *content* of those six channels is the guest's.
⚠️ **Check `pactl list sink-inputs` before trusting a recording.** If it is
empty, Canary never attached and you are recording zeroes; the sink also sits
at `IDLE`. `audio-capture run` warns on a `-inf` peak afterwards, which is the
backstop — but a live check fails in seconds instead of after the whole run.
* Boot is slow cold, ~25 s once the shader/code caches are warm — so a
launch-and-dump fits in a single call.
* **Screens: classify by whole-image statistics** (`screen_id.py`), not named
@@ -49,7 +118,19 @@ python3 tools/re-capture/gmem.py find hex:820af844 400
## Verifying your own work
* Reborn's disc-gated tests **self-skip** without `SYLPHEED_DISC`. A green run
with it unset means almost nothing. `build-reborn test` wires it up for you.
with it unset means almost nothing.
🔴 **But `build-reborn` does not work in this container (2026-08-29).** Line 15
is `SRC="${PROJECT_DIR:-/work}/Syplheed-Reborn"` — note the 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.
✅ **Run them directly instead**, setting the variable yourself:
```bash
SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test <name>
```
⚠️ This is the **second** wrapper in this container pointing at a source root
that does not exist — `build-canary` has the same defect. Check a wrapper's
`SRC` before trusting that a green or a failure came from your code.
* Prefer a headless self-verify over "it compiles": `sylpheed-cli mesh render`,
`screen render`, `save info` all produce checkable artifacts.
* A Bevy system-parameter conflict is invisible to the type checker and panics
@@ -125,3 +206,41 @@ XEX decrypt + LZX decompress, and the disassembly-to-database step — belongs i
`crates/sylpheed-formats`.** Until then, every static finding rests on an
artefact this project cannot rebuild, and that is a real gap in the corpus rather
than a convenience.
## 🔴 Pressing Ⓐ on the title faults the guest — and the fault fills the disk
Three attempts to capture the main menu on 2026-08-29 ended the same way. Every
run that tapped Ⓐ **on the title** faulted; every run that tapped nothing there
completed and produced its capture.
| run | input on the title | outcome |
|---|---|---|
| 1 | Ⓐ, then Ⓐ again on the transition | guest fault, **519 MB** of register dump |
| 2 | one Ⓐ | drifted to a `flight` classification, 97 MB |
| 3 | one Ⓐ | guest fault, **223 MB** of register dump |
| 46 | none (`NOTAP=1`) | all completed normally |
This is the crash `ui_draw_capture.sh`'s own header records from 2026-08-18 — "a
stray A there sends the guest into the save-data probe". ⚠️ The corpus's existing
menu measurements (Q4, Q5, the focus ring) were taken by some route that survived
this; what differs has not been found. **Menu-side dynamic RE is blocked until it
is.**
⚠️ **A guest fault writes an UNBOUNDED register dump to stdout.** Xenia runs with
`break_on_unimplemented_instructions = true`, and the dump is `vN = [...]` / `rN =
...` lines at roughly 100 MB per 30 s. The filesystem here sits at **91 %**. Any
scripted run that presses a button must watch `canary.stdout` and kill on growth —
`ls -la` on it before trusting a long run.
📌 Two knobs added to `ui_draw_capture.sh` for boot-side work: `GRACE=1` (the fixed
8 s wait before arming means an `ARM=early` capture otherwise misses both splashes,
which run at ~1.29.5 s of guest time) and `NOTAP=1` (no input at all — the movie
tap fires on "the screen changed a lot", which is also true of a fading splash).
### ⚠️ `ARM=early` loses its F10 about 40 % of the time
Five `ui_draw_capture.sh ARM=early` runs on 2026-08-29: **two logged `ARMED EARLY`
and produced no `xenia_re_ui_draws_NN.log` at all.** The keypress goes to the
window and is silently lost — nothing in the session log distinguishes a run that
armed from one that did not, so **check the log file exists before spending the
run**, and treat a repeat measurement as needing more attempts than samples.

View File

@@ -7,8 +7,9 @@ and wants to reach a mission — or who needs to script that journey.
Internal names (`ptbtn03`, `GP_LOAD`, build numbers) appear only as footnotes,
because they are how *we* find things, not what the game shows anyone.
**Status:** skeleton. Most of it is ❔ and is *meant* to be — this page exists to
be filled in by playing, not to look finished.
**Status:** filling in. §1§4 now carry what the committed oracle frames actually
show; what is still ❔ is what no capture answers. This page exists to be filled
in by playing, not to look finished.
> ## ⚠️ Fill this in from the real game
>
@@ -28,10 +29,29 @@ Confidence: ✅ seen in a capture · 🟡 inferred · ❔ unknown.
| # | What you see | What you do | What happens |
|---|---|---|---|
| 1 | Publisher and developer logos on black | nothing | plays through 🟡 |
| 2 | The opening cinematic | ❔ can it be skipped, and with which button? | ends into the title 🟡 |
| 3 | **Title screen** — the wordmark animates in, then a prompt | press **Ⓐ** | goes to the main menu ✅ |
| 4 | **Main menu** | — | see §2 |
| 1 | **SQUARE ENIX** in white on black, the two dots in red, `™` after it ✅ | nothing | fades on to the next logo |
| 2 | **GAME ARTS**, **SETA** and **studio anima** stacked on black ✅ | nothing | fades on into the cinematic |
| 3 | The opening cinematic | **Ⓐ** skips it ✅ | ends into the title |
| 4 | **Title screen** — the wordmark appears **first, with no prompt**; `PRESS Ⓐ BUTTON` fades in **2.13 s** later, above the 2006/2007 Square Enix copyright line, and then pulses about every 2.2 s ✅ | press **Ⓐ** | goes to the main menu ✅ |
| 5 | **Main menu** | — | see §2 |
✅ **The order is publisher then developer, confirmed in three cold boots
(2026-08-29)** — `SQUARE ENIX` for ~4.3 s, a ~0.25 s black hold, then
`GAME ARTS` / `SETA` / `studio anima` for ~3.5 s, and both dwells are declared on
the disc (240 and 195 keyframe units). ⚠️ There is a **third** SQUARE ENIX
wordmark about ten seconds in — bloomed, below centre — and it is the opening
card of the intro movie, not a splash.
[the three frames side by side](../re/captures/boot-order/splash-order-two-runs.png) ·
[`boot-order-and-splash-dwell.md`](../re/boot-order-and-splash-dwell.md)
Both logo screens are **still pictures the game draws**, not video — neither is a
`.wmv` on the disc. Captures:
[publisher](../re/captures/title-builds/live-splash-publisher.png) ·
[developer](../re/captures/title-builds/live-splash-developer.png) ·
[title](../re/captures/title-builds/live-title-press-a.png).
⚠️ **One Ⓐ skips the cinematic**, and it is worth a lot of time: the title
arrived at **57 s** with the skip against **193 s** without it ✅.
⚠️ **The title screen has two states that look identical.** The one that ends
the boot accepts Ⓐ. The one the attract loop returns to, after the game has sat
@@ -43,6 +63,21 @@ boot.
⚠️ **The title is not input-ready for about ten seconds** after it appears ✅.
And even then Ⓐ registers roughly half the time, with nothing yet found that
predicts which ✅ — budget retries.
🔴 **Refutation attempt, 2026-08-29 — both halves of that came out wrong on the
runs I could test.** Two boots, Ⓐ pressed **7.29 s** and **7.28 s** after the
title art settled (5.15 s and 5.15 s after the prompt appeared): **accepted both
times, first press, no retry**, and each went straight on to the main menu. Ⓑ on
the menu was then also accepted first press, both runs.
⚠️ Reach: **n = 2**, so "half the time" is only made unlikely (2/2 has p ≈ 0.25
under it), not excluded — but *"not input-ready for about ten seconds"* is
contradicted outright, because 7.3 s worked twice. Keep the retry budget; drop
the ten-second wait. Evidence:
[run 1](../re/data/plate-timing-run1.tsv) · [run 2](../re/data/plate-timing-run2.tsv) ·
[`title-plate-delay-measured.md`](../re/title-plate-delay-measured.md).
⚠️ **The prompt takes 2.13 s to arrive, measured twice (2.138 s / 2.132 s).**
Timed from the moment the wordmark stops animating, not from the moment it first
appears — the build-in itself varies by half a second between runs.
---
@@ -51,18 +86,57 @@ predicts which ✅ — budget retries.
Five options in a vertical stack, roughly centred, with a highlighted state on
the focused one.
> ✅ **The focused option carries a small ring to the left of its label, and the
> ring turns — continuously, about once every 2.2 s.** It has a bright head, so
> you can see it go round. It is the **only** thing moving on this screen once it
> has settled: the labels, the bracket and the footer are all completely still
> (temporal std exactly 0.000 over 20 s). Ⓑ
> [five frames, 4 s apart](../re/captures/focus-ring/ring-single-frames-4s-apart.png) ·
> [the measurement](../re/focus-ring-spin-measured.md)
| position | label | what it opens |
|---|---|---|
| 1 | ❔ | ❔ |
| 2 | ❔ | ❔ |
| 3 | ❔ | ❔ |
| 4 | ❔ | ❔ |
| 5 | ❔ | ❔ |
| 1 | **NEW GAME** | a **DIFFICULTY** prompt, then **SELECT DATA** |
| 2 | **LOAD GAME** | the save-slot list ✅ |
| 3 | **TUTORIAL** | the lesson list ✅ |
| 4 | **OPTIONS** | the settings menu ✅ |
| 5 | **EXTRAS** | a three-item submenu ✅ |
**To fill in, by looking:** read the five labels off the screen and say what each
one leads to. ❔ Which item is focused when the menu opens · ❔ does the cursor
wrap from the last item back to the first · ❔ does left/right do anything ·
❔ what B does here — back to the title, or nothing.
Read off [`live-main-menu.png`](../re/captures/title-builds/live-main-menu.png);
destinations off
[`q4-destinations.png`](../re/captures/menu-nav/q4-destinations.png) and
[`newgame-difficulty.png`](../re/captures/newgame-path/newgame-difficulty.png).
The screen is the title art gone dim, with the wordmark ghosted behind the list
and a bracket of glowing rule-lines drawn around it. The focused item is bright
white with a **spinning ring** to its left; the others are dim blue. Every item
carries a small dot-in-circle at the left end of its underline — that is on all
five all the time and is *not* the cursor.
**Moving around ✅**
| you press | what happens |
|---|---|
| ⬆ / ⬇ | one item, and it **wraps** at both ends |
| ⬅ / ➡ | nothing |
| Ⓐ | opens the focused item |
| Ⓑ | 🟡 back to the title — see the warning below |
**Which item is focused when the menu opens is not fixed.** Four boots of the
same harness opened on `TUTORIAL`, `TUTORIAL`, `NEW GAME`, `NEW GAME`. Do not
assume the top item, and do not assume the middle one either.
> ⚠️ **The main menu is the one screen whose footer does not offer Ⓑ.** It reads
> `⊙ : Select Ⓐ : OK` — every submenu adds `Ⓑ : Back`. Measured: **zero**
> red-Ⓑ glyph pixels anywhere in the frame, on two captures, with the same
> detector finding the glyph on `EXTRAS` and `DIFFICULTY` ✅.
> ✅ **But Ⓑ does leave it, and the objection that stood here is refuted
> (2026-08-29).** This page used to say the title "returns on its own after
> ~810 s idle", so an observer could not tell Ⓑ from the timer. That timer
> belongs to the **title**, not to this screen: the main menu was held untouched
> for **≥ 60 s** and never moved. Ⓑ is delivered and is the only input in ≥ 100 s
> before the return, so the ordering is measured — the *latency* is not
> ([the measurement](../re/menu-idle-and-b-2026-08-29.md)).
*Internals: `GP_TITLE.pak` build 5; buttons `ptbtn01``ptbtn05` top to bottom.*
@@ -73,26 +147,82 @@ wrap from the last item back to the first · ❔ does left/right do anything ·
One section each, in the shape of §2: what is on screen, what the cursor does,
what each choice leads to, and what a wrong choice shows you.
### Continue / Load ❔
❔ How saves are listed · ❔ what an empty slot looks like · ❔ the confirmation
prompt and where the cursor starts.
### New game ✅
Ⓐ on `NEW GAME` does **not** start a mission. It opens **DIFFICULTY**
`EASY` / `NORMAL` / `HARD` / `BACK`, opening focused on **NORMAL** ✅ — and Ⓐ
there opens **SELECT DATA**, a save-slot picker headed
`Current Storage: Dummy HDD` that asks you to choose a file for the auto-save.
Pick one and a movie plays ✅.
[DIFFICULTY](../re/captures/difficulty-screen.png)
### Load game ✅
A vertical list of numbered slots, **8 rows visible**, scrolling as a carousel —
one capture shows the order `19, 20, 01, 02, 03, 04` with `01` focused, so the
list runs past the end and back round to the start ✅. Each row shows
`Difficulty`, `Flight Time` and `Clear Ratio`; a **Details** panel to the right
gives `STAGE`, `Game Status`, `Points` and `Times Cleared`, and an empty slot
leaves every one of those blank ✅. `Current Storage: Dummy HDD` sits along the
top.
Its footer offers more than the other menus:
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓧ : Delete Ⓨ : Select Storage` ✅.
[capture](../re/captures/menu-nav/q4-destinations.png) (left panel)
❔ Still open: the overwrite / delete confirmation, and where its cursor starts.
Known: `title → LOAD GAME → slot 01 → YES → READY ROOM → TAKE OFF` reaches
flight ✅.
### Options ❔
❔ Which settings exist, what each ranges over, how a change is applied and
whether it needs confirming.
### Tutorial ✅
A list of lessons in two headed groups, with a one-line description shown on the
left for whichever is focused ✅ — e.g. `BASIC CONTROLS` reads
*"Learn how to move and attack"*. Opens focused on the first entry.
### Extras ❔
❔ What is in it — a movie theatre, a gallery, records? ❔ what is locked at the
start and what unlocks it.
| group | lessons |
|---|---|
| **Level 1** | `BASIC CONTROLS`, `HEADS-UP DISPLAY`, `RADAR` |
| **Level 2** | `SUPPLY AND SPECIAL MOVES`, `RADIO ORDERS`, `ADVANCED CONTROLS` |
| — | `BACK` |
### Mission select ❔
⚠️ **Stage select would not move**: sixteen d-pad presses never left Stage 01 ✅.
Whether that is because only one stage was unlocked, or because the list is
driven some other way, is unknown — worth settling early, since a scripted run
has to get past it.
[capture](../re/captures/menu-nav/q4-destinations.png) (middle panel)
### Options ✅ (one level in)
`GAME SETTINGS` · `CONTROL SETTINGS` · `SOUND SETTINGS` · `SCREEN SETTINGS` ·
`BACK`, opening focused on the first ✅.
[capture](../re/captures/menu-nav/q4-destinations.png) (right panel)
❔ Still open: what is inside each of the four, what each setting ranges over, and
whether a change needs confirming.
### Extras ✅
Three items: `MISSION SELECT` · `MOVIE THEATER` · `BACK`, opening focused on
`MISSION SELECT` ✅. The cursor wraps here too — it is a menu rule, not a
per-screen one ✅.
[capture](../re/captures/title-builds/live-extras.png)
`MOVIE THEATER` has never been opened.
### Mission select ✅ — and the "stuck cursor" is explained
The stage list on the left (**8 rows visible of 16**, with a scrollbar), a detail
panel showing the stage's name, a picture, `High Score` and `Best Time`, and a
**Wide Area Space Map** on the right with the named systems on it. The chosen
difficulty is printed top-right. Footer:
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓨ : Difficulty` ✅.
⚠️ **"Stage select would not move" — sixteen d-pad presses never left Stage 01 —
is now explained: the other fifteen stages were LOCKED** ✅. A locked row is
drawn *dimmer than an unfocused one*: measured, the labels sit at three distinct
brightnesses — focused **254**, unlocked **183**, locked **104** — and on a save
with the story unlocked the same rows read 183, with the cursor able to reach
**Stage16** at the bottom of the scrolled list.
[the measurement](../re/menu-navigation-semantics.md#-mission-select-the-cursor-was-stuck-because-the-stages-were-locked) ·
[locked](../re/captures/mission-select-stage01-only.png) ·
[unlocked](../re/captures/mission-select-all-story-unlocked.png) ·
[at Stage16](../re/captures/mission-select-ends-at-stage16.png)
So: if you are scripting a run, **check what the save has unlocked** before
concluding the list is broken. ❔ Whether the list wraps past Stage16, and
whether a locked row is skipped or simply unreachable, is not settled.
### Briefing and Ready Room ❔
❔ What you read, what you choose, and what finally launches the mission.

File diff suppressed because it is too large Load Diff

View File

@@ -136,6 +136,7 @@ files, which is how the same ground got covered twice.
| [`structures/stage-mission-tables.md`](structures/stage-mission-tables.md) | The stage table set — phases, routes, sub-objectives and AI parameters | ✅ the table set and how the stage record reaches it, validated across; **`AIParams` disc-wide: 23 objects, one shared 34-profile roster (782 records), loader `sub_8233C368`; `Type`→field-count holds except the two `_Test` templates** |
| [`structures/texture-color-k8888.md`](structures/texture-color-k8888.md) | Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures) | — |
| [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) | What a keyframe time is worth, and what shape the ramp has | ✅ CONFIRMED from the running game's own draw stream — the ramp is **linear** (a declared 15-unit fade lands on `round(255·k/15)` for all seven samples) and the animation clock advances **2 time units per submitted frame**. 🟡 the seconds conversion (`1 unit = 1/60 s`) rests on a measured 27.6 present-frames/second |
| [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md) | A keyframe's time word comes **before** its pose — the placement record, decoded | ✅ CONFIRMED, **decoded**. A group is an 8-byte header then `frames` records of `{u32 time; 36-byte pose}`, so the time precedes the pose; the group's lead-in word at `header+8` is pose 0's time and **every** pose is timed. Disc-wide over 13 991 groups in 33 archives, each test with a control: lead-in prepended is non-decreasing **13 991/13 991**; a non-zero lead-in is strictly below the next time **5 058/5 058** (control 70.9 %); a multi-segment alpha ramp runs at a constant `dα/dt` **857/1 540** against **0/1 042** under the old reading. 🔴 Retires two long-standing corpus claims — *"a group's data stops 4 bytes short of its final block's time slot"* and *"the last keyframe carries no time"* — both of which were this off-by-one. Adoption is free: all 12 `GP_TITLE` builds render byte-identically, and over 217 builds only two elements pick a different `rest()` pose, both between equally invisible ones. ❔ the executable's own parser was **not** found (the 40/60 stride query is weak, not negative) |
| [`structures/ui-composable-bundles.md`](structures/ui-composable-bundles.md) | A screen build is not the only thing `compose` can draw | ✅ CONFIRMED by measurement over the disc, with the artifact to |
| [`structures/ui-focus-and-effect-elements.md`](structures/ui-focus-and-effect-elements.md) | `_eff` glow layers are not focused-state records | ✅ CONFIRMED by measurement over all 965 screen builds on the disc, |
| [`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md) | The paint order comes from a layer key in the T8aD sprite header | ✅ CONFIRMED on both screens whose paint order has been measured — |
@@ -147,17 +148,36 @@ files, which is how the same ground got covered twice.
| [`structures/unit-struct-runtime.md`](structures/unit-struct-runtime.md) | Runtime `Unit` struct (craft / vessel definitions) — read from live guest memory | — |
| [`structures/weapon-struct-runtime.md`](structures/weapon-struct-runtime.md) | Runtime `Weapon` / `Shell` structs — read from live guest memory | — |
| [`structures/xbg7-mesh.md`](structures/xbg7-mesh.md) | XBG7 — mesh geometry (inside XPR2 model containers) | — |
| [`capture-harness-status.md`](capture-harness-status.md) | Why the harness stops reaching the title — and the two instruments that could not see the disc | ✅ **the disc is BACK** (2026-08-29, container replaced at 11:07:38): `/disc` is a real 6.2 GB read-only mount and `screen list` returns 12 builds. The "no disc" section is withdrawn — and its two instruments were blind either way: `find / -xdev` cannot cross into a bind mount on another device, and `sylph-doctor` only ever looks under `/work`. Earlier sections: `screenshot` costs 10.8 s under xenia (92×), and `trace_gpu_stream` is a no-op in the Release build |
| [`title-crash-stl-tree.md`](title-crash-stl-tree.md) | The title-screen crash is an STL `map`/`set` erase on a bad iterator | ✅ CONFIRMED — the guest throws std::out_of_range from an STL |
| [`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md) | A third measured paint order — tool built and validated, screen not reached | ✅ the reader works and is CONFIRMED against both previously |
| [`ui-quad-class-foothold.md`](ui-quad-class-foothold.md) | The guest's UI quad class — a foothold found from the capture's vertex layout | 🟡 PROBABLE for the identification below (it is a static read, but |
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; 4 of 5 destinations driven. 🟡 GamePart id is a name match, ❔ `NEW GAME` untested |
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; all 5 destinations driven. 🟡 GamePart id is a name match. 🟡 **Ⓑ leaving the MAIN menu downgraded 2026-08-29** — uncited, and the main menu is the only screen whose footer omits Ⓑ (0 glyph px in frame vs 514/518 elsewhere). ✅ **MISSION SELECT's stuck cursor was a LOCKED stage list** — labels have three brightnesses, locked 104 / unfocused 183 / focused 254 |
| [`screen-transitions.md`](screen-transitions.md) | Between two screens — a fade through black, and where its timing lives | ✅ the fade quad's keyframe group is decoded (disc-wide: per-pak all-or-nothing; `GP_TITLE` = the 6 screens, not the 6 overlays); the ~0.4 s fade-OUT is measured, not on the disc |
| [`menu-audio-cues.md`](menu-audio-cues.md) | Menu audio — the event vocabulary is on the disc, the binding is not | ✅ `SE_UI_*` cue names/ids decoded and `BANK_SE``Static.slb` (0/322 in FILES); 🟡 event binding is a name match; ❔ `Static.slb` has no wave boundaries, so SE audio is not extractable |
| [`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md) | What the game reads at boot — `config.ini`, and which GameParts exist | ✅ `config.ini` selects the language (the disc's only config); ❔ its `[SYSTEM]` is empty so the boot order is not in config; 🟡 24/29 ids bind to a class, `GP_ADVERTISE_DEMO` is never registered |
| [`movie-binding.md`](movie-binding.md) | Which movie plays where — boot intro, attract loop, new-game intro | ✅ decoded from the movie manifest (`ADVERTISE_MOVIE``ADV.wmv`, `MS00A``S00A.wmv`); attract identity confirmed independently by frame matching; 🟡 skippability unsettled |
| [`ready-room-probe.md`](ready-room-probe.md) | S1 — the Ready Room probe: no-go, and not for the reason expected | ✅ it is 2D and enumerates (60 builds), but the pak is briefing/tactical-map content; and `kind == 0x3002` finds 0 buttons there |
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn |
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn**2026-08-29: the two "unidentified `DELTASABER` plates" are the LOADING screen** — builds 0/1 the plain variant, 10/11 the dressed one, decoded from their `pgloading_*` element names, and the executable (`sub_821C4EB0`, bytes checked in the image) names exactly five title-side screens: `TITLE_SCREEN`, `BUTTON`, `TITLE_MENU`, `LOADING`, `LOADING2`. 🟡 which loading bundle takes which of the two names is undecided. 🟡 the English member of a pair is the one in the first half of `GP_TITLE.p00` — 8/8 structurally, 3/3 where a capture can check it. |
| [`ui-title-paint-order-capture.md`](ui-title-paint-order-capture.md) | The title screen's paint order, measured from the guest's draw submissions | ✅ CONFIRMED — the order in which the running game paints the title |
| [`upstream-baseline.md`](upstream-baseline.md) | A stock-upstream baseline runs Stage 02 crash-free | ✅ CONFIRMED — upstream canary_experimental + only the pad |
| [`weapon-datasheet-runtime.md`](weapon-datasheet-runtime.md) | Weapon DATA SHEET — runtime capture (Route B) | 🟡 first dynamic capture, 2026-07-28. The Arsenal's Gallery Mode panel is a |
| [`xpr2-colour-check.md`](xpr2-colour-check.md) | XPR2 colours: channel order ✅ confirmed against the running game | — |
| [`focus-ring-spin-measured.md`](focus-ring-spin-measured.md) | The main menu's focus ring spins continuously — and how fast | ✅ **measured**: period **2.177 s** over 9 revolutions (8 evenly spaced autocorrelation peaks) = 120 units = 60 frames = 2.00 s at 30 Hz. A pulse is excluded — annulus total conserved to 0.4 % while per-bin brightness swings by 24. ✅ the ring is the **only** moving thing on the settled main menu (std exactly 0.000 elsewhere). 🔴 no angle is quoted: the angular estimator FAILED its own control (30° → 0°) |
| [`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md) | Why a music bank read as THREE sub-waves when the census says two | ✅ **decoded**: the third is the **bank header**, emitted by our own reader. `to_xma_riffs`'s hybrid branch derives a leading packet stream's start as `first_riff % 2048`, which is right only for a header shorter than one packet; a music bank's header is exactly **5 packets (10 240 B)**, so the modulus gave 0 and the whole header came back as sub-wave 0. The header states its own length at `+0x24` in blocks. Disc-wide over 9 519 `sound.pak` entries: **28** match the header signature at offset 0 (ids 10011023, 11011105), **28/28** end exactly at the first `RIFF`, **0** have a gap, **0** false positives — so a header at offset 0 and a leading packet stream never coexist. Decode control, same chain, same bank: the emitted region gives **0.009 s** against **87.744 s** for the real wave 0. Corroborated by the runtime XMA probe, which saw exactly two streams at the main menu. Fixed + 2 regression tests; the `VOICE_D_453` recovery is untouched (10/10 green) |
| [`title-plate-delay-measured.md`](title-plate-delay-measured.md) | How long the boot title shows build 4 before the `PRESS Ⓐ` plate | ✅ **decoded after a refutation**: build 2 and build 4 run on **one clock started together**, and the plate's own `ptbtn00` reaches `a=255` at `t=238`; the last build-in ramp ends at `t=118`, so the interval is a declared **120 units = 2.000 s**. 🔴 The instruction that shipped first — "wait 2.13 s after build 4 settles" — was **refuted by the port** with disc arithmetic and is corrected in place; 🔴 `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 wall-clock 2.13 s is 6.7 % long because Canary presents at **28.06 / 28.14 fps** against a nominal 30, matching the corpus's independent **28.5 fps**; author the 120 units. ✅ **measured**, two independent boots: **2.138 s** and **2.132 s** from the frame build 4 settles (glyph = its no-plate 154, motion → 0). Agreeing to **6 ms**. So the boot title's end state is **not** plate-free and a compositor must draw **two builds at once**. ⚠️ Measure from *settled*, not from first pixels — "first drawn → plate" is 3.78 s vs 4.26 s across the same two runs, because the build-in animation's own duration varies with emulator frame pacing. Plate pulse re-measured at 2.12/2.19/2.34/2.31 s (mean 2.24), replicating the corpus's ≈2.3 s. ✅ black hold between screens bracketed at **0.140.30 s**, consistent with the declared 12 units. 🔴 the Ⓐ→menu latency is still **not** available: both runs freeze one frame for ~1.4 s at surface mean **26.626** — agreeing between runs to 1e-6, and reproduced with stream restarts disabled — which is a guest **load stall**, not the capture path. Probe: 8.7 ms/frame, 7.97/7.98 fps against a requested 8, controls 9/9 + 4/4 |
| [`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md) | The main menu does not idle back to the title — and four durations that were a pipeline | ✅ **refuted**: no self-return in **≥ 60 s** untouched; the ~810 s idle belongs to the **title**. 🟡 Ⓑ→title ordering measured, latency not. 🔴 `classify_array` at **1503 ms/frame** drained an 8 fps stream at 0.64 fps and manufactured four latencies (24.66 s / 15.58 s / 25.60 s / 20.26 s) — all withdrawn; a backlog preserves ordering and destroys durations |
| [`structures/ui-settle-time.md`](structures/ui-settle-time.md) | Which instant a "settled screen" composite depicts | ✅ **decoded**: a settled screen is **one instant every element is posed at**, and the disc names it — the midpoint of the **longest keyframe-free interval** in the build (`UiBuild::settle_time` / `settle_window`). 🔴 `rest()` is *not* that: it picks each element's last hold **independently**, so a two-frame flash holds at its **peak** and burns forever. `GP_TITLE` build 4 has five staggered flashes (`ptlogo_back2eff1``eff5`, all extinguished by t110) that `rest()` draws simultaneously and permanently, saturating the light arc. Predicted t=198 from `[160,236]` **before scoring**: arc band **33.22 → 11.79**, clipped pixels **8 581 → 1 452** against the console's **1 459** (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 12.06/11.79/1 452. Controls: `at=None` byte-identical (`cmp`), pre- and post-rotation tags both 14.07, 13 paint-order tests green. ⚠️ **Reach**: of 1 758 bundles with ≥2 keyframe times only **30 %** have a window ≥ 30 units and **42 %** under 10 — mostly `loop*` fragments that never settle; check the width. 🔴 Withdraws two claims in [`ui-rotation-implemented.md`](structures/ui-rotation-implemented.md) — its "Flat. No minimum." (`at` posed **leaves only**) and its "Reborn does not draw `ptlogo1`/`ptlogo2`" (both **are** drawn; only kind-`0x4` ghosts are skipped, and hiding the real ones makes the error *worse* by +5.20/+7.47). ❔ its **10.92** baseline is unreproducible — 14.07 at both tags |
| [`structures/ui-tie-break-cost-at-settle.md`](structures/ui-tie-break-cost-at-settle.md) | What the unknown paint-order tie-break costs, in pixels | ✅ **decoded**, closing the open half of Q3: at the settled instant the tie-break costs **at most 1 px at Δ1**, on the **Japanese title only** (`ptlogo2`×`ptlogo_tm`, 5 px shared ink); **exactly 0 px on all five port screens**. The earlier 24-pair bound was a `rest()` count — and 10 of the title's 11 tied pairs are between `ptlogo_back2eff1``eff5`, five transient flashes that are **transparent** on the settled screen ([`ui-settle-time.md`](structures/ui-settle-time.md)). Live pairs at settle: entry 4 → **1**, entry 7 → **2**, the four loading bundles → **0**. ✅ Not a knife-edge — sweeping every keyframe time and midpoint, the count is **flat across the whole settle window**, and the loading bundles' tie is live only at t17t33. ✅ Controls: an overlapping *different*-key swap moves 25 310 / 268 698 / ~765 000 px on the entries reporting zero; zeros are explained by shared-ink counts (the `ptframe` pairs share **0 px** of ink). ⚠️ Entries 0/1/12/15 have **no live control** — their zeros rest on keyframe data, not a render. 🟡 Refutation attempt on the corpus's "24 pairs": **survives** as a rest-pose bound, 16/16 on entry 7. ❔ *Why* ties order as they do is still unknown — and now worth one pixel |
| [`structures/ui-record-loop-length.md`](structures/ui-record-loop-length.md) | Where a looping record's cycle restarts — and the `PRESS Ⓐ` plate's real period | ✅ **decoded**: a nested record is itself a RATC bundle and its header **`+0x08` is the loop length**; its keyframes need not fill it, and the slack is a hold at the final pose. Disc-wide over **1 781** timed nested records: 92.3 % declare exactly their last keyframe time, **7.7 % declare more**, and **0 declare less** — the falsifier (a cycle cannot restart before its own last pose) never fires. 🔴 **The plate's `ptbtn00f` is 105 units of ramp inside a 120-unit cycle, so it holds dark for 15 units** — the port was shipping **105**, and the answer is **120**. ✅ Falsification test against the running game, using a pacing factor measured *independently* on the focus ring (declared 120 → **2.177 s**, factor **1.0885**): to reach the corpus's measured 2.122.34 s, 105 units needs a factor of **1.2111.337** (🔴 excludes the ring's) while 120 needs **1.0601.170** (✅ contains it). Different elements, different bundles, separate runs — tied only by both declaring 120. ⚠️ Says where a cycle *ends*, not which records cycle. ❔ the **top-level** `+0x08` (300 on every `GP_TITLE` entry, elements ending at 244269) is a different question, untouched |
| [`structures/ui-focus-record-pulse-census.md`](structures/ui-focus-record-pulse-census.md) | Every focus record whose glow pulses, and where `rest()` puts it | ✅ **decoded**, disc-wide: **1 130** focus records, **2 664** timed elements, **210 with a varying alpha** — of which **202** have `rest()` == the **peak** (burns bright forever) and **8** land **mid-ramp**. By pak: `PILOTLOG` 116, `MOVIE_THEATER` 54, `HANGAR_ARSENAL` 30, `LEADERBOARD` 8, **`GP_TITLE` 2**. 🟡 Bounds rather than refutes the port's "34 in the export, 2 varying, nothing to fix" — correct, and correct *because* `GP_TITLE` has 2; the pathology sits in the screens a wider port needs next. 🔴 The 8 mid-ramp ones are the worse mode: `py_ranking_btn01f` swings 255→127→255 and `rest()` returns **244**, neither extreme, which looks entirely plausible and nothing reports it. ✅ Control: `ptbtn01f` is genuinely constant (255 throughout) and is **not** flagged; two hits verified keyframe by keyframe. ⚠️ A pulsing element has no resting pose — the question is malformed, not mis-answered; `pose_at(t)` inside the record's declared cycle ([`ui-record-loop-length.md`](structures/ui-record-loop-length.md)) is the only well-formed query. ⚠️ 210 is a **floor**: focus records are matched by the `Xf.rat` name rule, and varying scale/rotation/position is not counted |
| [`structures/ui-title-buildin-measured.md`](structures/ui-title-buildin-measured.md) | The title's build-in and the plate glow, read out of the guest's own draw stream | ✅ **measured** (Canary, `ARM=early` draw capture): the decoded *mechanism* is observed, not just its end state. **The five flashes fire in a six-frame window and are absent from all 155 other sampled frames**; `ptlogo_back2eff1` is drawn in exactly 2 frames at **t = 54.0** against a decoded peak of **t5456**, and `ptlogo1` first appears at **t = 42.2** against a decoded **t42** — with units/frame taken from the **glow's period alone**, a different element. The two holders (`ptlogo_back2eff`, `ptlogo_back2`) are continuous from frame 134. ✅ The glow's per-vertex colour alpha IS its fade alpha: **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** against **38.18 reversed** (2.9×), so the asymmetry is real and correctly directed. Structure: the settled title is 1011 draws naming no sprite — which is why arming at the title sees nothing. ⚠️ Frame **107** is a 27-draw spike between the movie's last frame and the title's first; calling it "the composite" 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 entries are the same animation at **different sampling phases** (only 4 of 46 aligned frames match), which is what makes the `eff3` result robust. 🔴🔴 **RETRACTED — the game DOES draw `ptlogo_back2eff3`, and all five flashes fire in both entries in the declared stagger** (`eff3` at frames 133134 / 59575958, i.e. t=60.1 and 62.3, inside its declared t∈(58,64)). The absence was an **instrument artefact**: a draw batches several quads (`indices=8` is two) and the log dumps only the first 8 vertices, so min/max over a line **merges** them — and because the wipe is right-aligned, `eff3` (788…1196) lies entirely inside `eff4` (447…1196), making the union *exactly* `eff4`'s extent. The merged box matched `eff4` to 1 px. 🔴 Three explanations had been "ruled out" and all three were aimed at the wrong failure — notably the invisible-draw check counted draws with **no** geometry, where the hiding place was **partial** geometry. Superseded text follows: ~~three alternative explanations tested and failed: *phase* (its window is **6 units** against a **2.23-unit** step, so it cannot be missed — frames 133/134 sit at t=60.1/62.3 inside it and draw `eff2` and `eff4` instead), *an unlogged draw* (exactly 2 blind draws/frame, always the same full-screen-triangle shader, present when no wipe is active), and *a bad position guess* (dropping position entirely, **zero** quads anywhere have a width within ±30 of 408; the spectrum jumps 262 → 748). Draw counts across both entries: eff1 **4**, eff2 **3**, eff3 **0**, eff4 **6**.~~ (all from the merged-box parse, and wrong) 🔴 **The port draws `eff3` at t=6062 and the console does not.** ❔ Why is not established — nothing in its element record differs from its neighbours. ⚠️ An earlier "sub-frame phase" explanation and the advice that drawing all five "shows more sweep than the console" are both **withdrawn**. ⚠️ What a frame-by-frame build-in comparison *will* show is disagreement about which flash lands in which frame — 2 units/submitted frame against this run's 2.231 units/presented frame — and neither side is wrong. 🔴 **Trap:** matching a bound texture's dimensions to a sprite fails both ways — it missed every flash *and* read the intro movie's 640×360 YUV planes as `ptbase2`. ✅ A regression of five events' observed frames against their declared times (residuals ≤0.9 frames) recovers the intercept at frame **106.1** when the composite spike, not in the fit, is frame **107**. ⚠️ Per-vertex alpha = fade alpha holds for the **glow** and does not generalise — `eff4` reads 255/127/254 on consecutive frames. ❔ Frame rate not recorded, so nothing is in seconds; the glow's period implies a **114**-unit cycle against a declared 120, unexplained; `eff5` vs `ptlogo_back2eff` not separated |
| [`structures/boot-splash-gap-measured.md`](structures/boot-splash-gap-measured.md) | The black gap between the two boot splashes | ✅ **measured** in the guest's **draw stream**, which separates true black from a fade tail where luminance cannot: the publisher's last sprite is frame 125 (alpha 7), then **frames 126129 submit NO sprite quad at all**, then the developer fades in at alpha 34. **The gap is 4 presented frames.** Converted with the disc as its own clock — `palogo_sqex` declares alpha≥1 for **239.8 units** and is drawn in **105** frames → **2.284 units/frame** (the title capture independently gave 2.231) — that is **~9.1 units ≈ 0.152 s**, against the **12** the port authored; ⚠️ and the true black is *shorter*, since both boundary frames still carry picture. 🔴 **RETRACTED**: "the developer splash is ONE composited 525×259 quad" — the same batching artefact. It draws three logos and three glows as separate quads in one `indices=24` call; the 525×259 was `gamearts_eff` merged with `seta_eff`. The port refuted it with arithmetic (a 259-tall box cannot hold logos spanning y 164…585) before I checked. ⚠️ The gap measurement is unaffected — those glows are the developer splash's first draw. ❌ Not declared on the disc: `palogo_eff0.prm` is a single static keyframe, and the top-level `+0x08` is a **family constant** (300 / 60) whose slack ranges 12226 units. ❔ The executable is **not** looked at — named, not claimed. 🔴 The instrument was perturbing the measurement: the capture script taps Ⓐ on "screen changed a lot", which is also true of a fading splash — it tapped through the publisher and the developer never appeared. `GRACE=1` and `NOTAP=1` knobs added |
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) | Where a keyless primitive paints, when the file forces it | ✅ **decoded**, partly closing `ui-prm-primitives.md`'s standing blocker: **an element covering the screen and fully opaque at some instant cannot paint above anything visible then**, and where that set is *every* other element its position is **forced first**. Disc-wide **80** instances forced, 50 constrained but not forced, 0 unconstrained. ✅ **Two controls, both measured orders from the running game**: it reproduces `palogo_eff0.prm` = FIRST (opaque 211 instants, below 6/6) — which a **name**-based rule gets wrong, since it is named like an overlay — and permits `pteff00.prm` on top (opaque 2 instants, below 3/23), which is where it is measured. ✅ Answers the port's `build_12`/`build_15` blank-screen contradiction: `pgloading_eff00.prm` is forced first, 4/4. ✅ Explains 36 builds the corpus recorded as "one colour" with no cause — `pzeff00.prm` forced first 32/32, so **our own sort wiped them**. 🔴 The rule's limit was found by its own test failing: applied to `.t32` sprites it claimed 22 must sort first against their own keys (`pneff01` 0xd850 at #8/13, `pbfriendly` 0x9230 at #17/49) — a sprite's *element* alpha says nothing about its *texture*'s coverage, so it is now restricted to untextured primitives. ⚠️ Assumes straight alpha-over; blend mode is still ❔. ⚠️ A lower bound, not an ordering. ⚠️ No new oracle run — the controls are prior measurements |
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(span sensitivity)* | How much of the forced-backdrop rule rests on the timeline convention | ✅ **decoded**: the span is `0..=max keyframe time over every element`, and an element **holds** its final pose — decoded, not assumed ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md), [`ui-record-loop-length.md`](structures/ui-record-loop-length.md)). Sensitivity over the 130 keyless full-screen primitives with an opaque interval: using the header's declared **`+0x08`** instead changes **0** verdicts (interchangeable); using the primitive's **own** last keyframe changes **72**; counting elements **gone** after their last keyframe changes **72**. 🔴 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, so without the hold it is opaque for one instant, nothing else is up, and the rule calls it *free* against a game measured painting it first. ✅ The verdicts that matter are convention-independent — `pgloading_eff00.prm` is FIRST under all four, `pteff00.prm` FREE under all four. ⚠️ The port's **256 vs 211** was a **bundle mismatch, not a definitional one**: `palogo_eff0.prm` runs to t=255 on the publisher splash (entries 10/13) and t=210 on the developer (11/14) |
| [`structures/ui-clock-freezes-at-settle.md`](structures/ui-clock-freezes-at-settle.md) | The top-level clock stops at the settle point — observed in the running game | ✅ **measured**: `GP_TITLE` build 4 declares `t = 0…269`, about 120 presented frames at this run's pacing, and the dwell lasted **~1 100**. `ptcopyright` declares alpha≥1 for **106 units** (t=138…244) and is **drawn for 1 050 frames**; `ptlogo1` declares an exit at t=264 and is drawn for 1 095. Both vanish within three frames of the dwell ending. **The clock advances through the build-in, stops inside the settle window `[160,236]`, and holds; the exit ramp plays when the screen leaves, not on a timer** — [`ui-settle-time.md`](structures/ui-settle-time.md)'s decode observed from the other side. A nested record keeps looping on its own clock throughout. 🔴 **This closes the 114-vs-120 gap, and it was my arithmetic**: 2.231 units/frame was regressed over *build-in* events (the only stretch the top-level clock advances) and applied to a period measured during the freeze — 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, lag 51 with harmonics at 102/154) — ⚠️ whose first version **failed its control**, returning 48, because it indexed by sample position where the log's frame numbers have gaps. ❔ The **sweeps'** period stays unmeasured: the same validated estimator disagrees between two dwells of one screen (515 vs 452 frames). 🔴 **Blocker: a single Ⓐ on the title faults the guest** — 3 attempts, 2 register dumps of 223 MB and 519 MB, against 3 no-input runs that all completed; bounds menu-side dynamic RE here, and any scripted button press needs a `canary.stdout` size guard |
| [`structures/boot-splash-dwells-are-declared.md`](structures/boot-splash-dwells-are-declared.md) | How long each boot splash is shown | ✅ **decoded**: the dwells are the bundles' own declared timelines — publisher **t=0…255 = 4.250 s**, developer **t=0…210 = 3.500 s** at 60 units/s. The corpus's independent screenshot timing over 3 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 %. 🔴 **Wall clock is the wrong unit to author**: a fresh no-input boot measured the same two dwells at **5.105.61 s** and 3.834.30 s, 1520 % longer than both the declared values and the corpus's runs, on the same disc — so a seconds figure is one run's emulator pacing. Boundaries from the draw stream: publisher wordmark frames 6119, **3 frames with no sprite drawn**, developer glows 123, wordmarks 140209, intro video 216. 🔴 **The frame→wall-clock instrument resolves to one BUFFER FLUSH, not one frame** — 69 of 125 samples showed no advance and the rest jumped 715 frames, making the apparent rate swing 0.01640.0316 s/frame; frames 119 and 123 fall in one burst, so the inter-splash gap is **not separable** by it. Quoted as brackets; sub-flush estimates withdrawn before reporting. ⚠️ `palogo_anima` never appears — almost certainly the 8-vertex cap (7 elements batched, 2 logged), the same trap as the `eff3` false negative, so it is named not reported. ❔ the publisher's 4.1 % error vs the developer's 1.1 % is unexplained |
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(colour census + self-refutation)* | What colour a keyless element is, and which forced verdicts the argument actually supports | ✅ **decoded, disc-wide**: every full-screen `*eff00*` **primitive** is **pure black** at its various alphas (`ff000000`, `7f000000`, `40000000`, `b2000000`, `cc000000`, `d4000000`, `00000000`) — exactly an alpha-over dim or fade, and an *additive* black quad would be a no-op nobody would author. The **only** non-black primitive on the disc is `pbafc.prm`, RGB `00e8e0` cyan at alphas to `ff`, and it is **844×600, not full-screen**, so outside the backdrop rule's geometry guard — ❔ it is now the sole additive candidate. 🔴 **Self-refutation: of the 80 forced-first instances only 42 are `.prm`; 38 are `.tbm` carrying fade `ffffffff`.** A *solid* white quad 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 extension further out: I had fixed the symptom (`el.sprite.is_some()`) 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**, 38 drop to 🟡 (still almost certainly right — all named `*base*`, full-screen, and `pfbase.tbm`'s first position is *measured* — but on a name-and-role argument this page elsewhere calls the weaker kind). ⚠️ Code deliberately unchanged: restricting to `.prm` would send eleven screens' backgrounds back to last, the blank-screen bug the rule fixed. Split pinned by a test |
| [`structures/ui-prm-blend-mode.md`](structures/ui-prm-blend-mode.md) | Whether a primitive blends additively or alpha-over | ❔ **undecodable, with reach** — but the consequence is closed. Looked in **the bundle** (no field: the declaration words are constant and a primitive has no RATC child at all), **the colour census** (every full-screen `*eff00*` primitive is **pure black**; the only non-black primitive on the disc is `pbafc.prm`, cyan `00e8e0`), **the occlusion constraint** (inapplicable — `pbafc.prm` strobes 255/124 every 2 units, travels, and is scaled **2 %×3 %**, so it draws ~**17×18 px**, not its declared 844×600), and **the oracle** (`GP_READY_ROOM` is a recorded no-go and gameplay needs the Ⓐ that faults the guest). ✅ **Why it stopped mattering:** for a *black* quad the hypotheses differ only in whether it hides what is beneath — drawn **first** it is correct under **both**, drawn **last** only under additive. So `forced_backdrop`'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 not evidence *for* alpha-over. 🔴 The investigation found `forced_backdrop` judged coverage from the **pivot alone**, ignoring scale; checked first, **all 80 forced instances are at 100 %**, so no verdict moved and the added guard is defensive |
| [`structures/title-a-press-fault.md`](structures/title-a-press-fault.md) | Why a single Ⓐ on the title faults the guest — the blocker on all menu-side dynamic RE | ✅ **SOLVED 2026-08-30, and it is the emulator, not the game.** Xenia returns `X_ERROR_SUCCESS` with a *zeroed* keystroke on every `XamInputGetKeystrokeEx` while a XAM dialog is up (`xam_input.cc:197`, upstream); the game's pump is an **unbounded** `while (GetKeystrokeEx()==SUCCESS) queue.push_back()`, so 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 thread stack. ✅ **The number is the argument**: the Canary counter reports **8 388 601** swallowed calls at the last report before the crash, the dump's `r29` says the vector held **8 388 608** — two independent instruments, 7 apart, inside the 600-call reporting granularity. No new boot: the failing run's 326 MB log was still on disk. 🔴 **RETRACTED — "`r9` is a wild pointer above 4 GB"**. Xenia prints `si_addr`, a *host* address, and the guest is mapped at `0x100000000`: `0x1701D0000 0x100000000 = 0x701D0000`, which **is** `r9` in the dump — an ordinary guest heap address on an uncommitted page. Subtract `0x100000000` from every `Access Violation … at 0x1________` before reading it. ✅ **Decoded code path**, image-checked with **0 mismatches** over 586 instructions: `sub_824574C0` the input-manager singleton at `0x828F3888`, `sub_82457038` the keystroke pump, `sub_82457780` its `vector<X_INPUT_KEYSTROKE>` insert-with-grow. ✅ **It explains the earlier successes**: whether a XAM dialog is up is *emulator* state, so "reproduced 4/4" and "Q4/Q5 pressed Ⓐ fine" were both always true. 🟡 **Which** dialog is still open — `XamShowDeviceSelectorUI` is ruled out (`storage_selection_dialog = false` takes the headless path), `XamShowSigninUI` / `XamShowMessageBoxUIEx` are not; the settling experiment is one log line per `is_xam_dialog_present_.store(true)` site, not another blind boot. 🟡 Three untried routes out: dismiss the dialog, `--headless`, or return `X_ERROR_EMPTY` from the swallow. ✅ `frame_clock.sh`'s 300 MB guard killed the run as designed — keep it |
| [`structures/plate-pulse-measured.md`](structures/plate-pulse-measured.md) | Does the `PRESS Ⓐ` plate stay up, pulse, or blink once? | ✅ **measured** — it **PULSES**, continuously and without decay, on a title held with **no input**: two windows in one boot, 58 s and 57 s, ~23 cycles each, periods **2.530 / 2.540 s** agreeing to 0.4 %. ⚠️ **It never goes off** — the plate-absent floor is **159** green pixels (the title art's own, from `live-title-build4-no-plate.png`) and the pulse bottoms at **714**, 4.5× that. So the port's "flash and nothing after", reasoned from `ptbtn00` expiring at t=244, is wrong; `ptbtn00f`'s 120-unit cycle is what runs. 🔴 **Two estimators, one misspecified**: mid-crossings replicate to 0.4 %, a single-sinusoid fit does not (2.553 vs 2.413) because the waveform is fast-rise/slow-decay — and its own r² of 0.468/0.228 is the tell. Both were controlled on synthetics at 2.24/2.55/3.10 s laid on the real timestamps and recovered all three exactly. 🟡 wall-clock is **13 % longer** than the corpus's earlier 2.24 s mean — same declared 120 units, different pacing (×1.27 vs ×1.12), so **author the units**. ⚠️ Reach: one boot; does not distinguish the boot title from an attract-loop title; the glyph count is a thresholded pixel count and **not** an alpha, so no duty cycle can be read off it |

View File

@@ -26,6 +26,26 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
## Inference
* **Never conclude from ONE sample.**
* **⚠️ The specific observation and the general rule read identically on the
page — and the general one is what the next reader uses.** This cost five
corrections across two agents in two days, and none of them 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:
* "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 cheap and it is always the same one: run the census before
writing the rule.** A ratio that is tight over 28 assets is a format fact; a
ratio that scatters 37× was one asset wearing a rule's clothing. Where the
census cannot be run, write the specific sentence and *say* it is specific.
* **A law proved on one population is a hypothesis on the next.**
* **Finding one exception does not imply a family.**
* **Consistency is not proof. A suggestive coincidence is a coincidence until
@@ -38,6 +58,39 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
* **My own last-turn result is a hypothesis too.**
* **A global partition can understate a per-owner one.**
* **A residual is measured against a population — name it.**
* 🔴 **An insensitive observable fails TWICE, and the second way is worse.**
Two bugs in one exchange, one cause:
1. A leaf-composition rule was checked against **alpha**, which moves ~0.3
levels per keyframe unit — so a one-keyframe association error barely
shifted it and the rule **looked confirmed**. The same span moved `x` by
**1 560 px**.
2. Fitting `t` from that same alpha then **manufactured an 11.5 px position
residual that did not exist**, and sent the consumer hunting a
pivot/rotation mechanism to explain it. One byte of alpha quantisation is
worth 1.51.9 keyframe units, i.e. 68 px of sweep.
**Solve on the fastest-moving field; check the slow one. Never the reverse.**
⚠️ The second failure is the more expensive: not-falsifying leaves you falsely
reassured, but **inventing a residual sends you looking for a mechanism**.
* ⚠️ **A stated reach is a boundary, not a hedge — do not extrapolate past it.**
`ui-render-tone-curve.md` fitted γ ≈ 1.341.49 on **dark flat patches** and
wrote "nothing constrains midtones or highlights". Used above that range the
model is simply wrong: binned by level, the exponent falls monotonically and
**crosses 1.0 near render ≈ 40**, so above it the capture is *brighter* than
the render and no single exponent can express the curve. The page had already
said where it stopped being true; the error was reading past the sentence.
✅ **The fix was not a better fit — it was printing the curve instead of a
scalar**, so it can be argued with. A scalar hides its own domain.
* ⚠️ **Normalising? Divide by how many inputs CARRY SIGNAL, not how many there
are.** The port hit this three times in one pipeline, each invisible to every
check except a level measurement, and each the same mistake:
a digitally silent *chunk* counted in a voice sum; a digitally silent
*channel* counted in a mono fold (5.94 dB); a digitally silent *sub-wave*
the 10 240-byte bank header, wrapped to 10 300 B — counted as a third stem in
a music sum, putting every real stem at 1/3 instead of 1/2 (**3.52 dB on all
menu music, shipping for two iterations**). This corpus's own census said
those banks hold **two** waves; the exporter's divisor said three. **A count
that disagrees with a census is the count that is wrong**, and the symptom is
never a crash — it is everything being quietly a few dB down.
## Searching and tooling
@@ -89,6 +142,32 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
* **Raw grep cannot see inside compressed pak entries.**
* **Commit messages go in a file** (`git commit -F`); a literal `|` in a table
cell needs escaping; `git log --all -- <path>` can hang.
* 🔴 **Never clamp a value before something compares it.** A focus detector
printed a degenerate `margin=12359888888.89`, so it was capped at 999 to keep
the output readable. That cap ran *before* the vote-sorting step, so 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 — which aborted a
seven-minute driven boot. The measurement was right the whole time; a cosmetic
fix changed a decision. **Clamp at the point of display, never upstream of a
comparison that depends on the value.**
* **`pkill -f PATTERN` / `pgrep -f PATTERN` match YOUR OWN command line.** Hit
twice in one session: `pkill -9 -f adv_audio_cap.sh` killed the shell that ran
it, and an `until ! pgrep -f "probe.py --run"` loop never exited because the
loop's own command line contained the pattern. Kill by process name
(`ps -o pid= -C xenia_canary`) or exclude self; a wait-loop that greps for its
own text waits forever and looks like the job hanging.
* **"Build 10" of a pak is ambiguous — always say which index space.**
`sylpheed-cli screen list GP_TITLE.pak` reports **12** builds and numbers them
011; `screen list --all` reports **16** and numbers them 015. Only under
`--all` does the ordinal equal the pak entry. Without it, ordinal 10 is pak
entry **12** and ordinal 11 is entry **15** — so "builds 10/11 are the loading
screen" and "entries 12/15 are the loading screen" are the same true statement,
while "**entries** 10/11 are the loading screen" is false: those are the
publisher (`palogo_sqex`) and developer (`palogo_gamearts`/`seta`/`anima`)
splashes. This cost a wrong line in HANDOFF that the port caught, and it would
have validated silently because the port's `screen_names.json` is keyed by
entry. **Write `entry N`, not `build N`, whenever the number leaves this
repository.**
## Runtime / emulator
@@ -96,6 +175,37 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
* **"Animating" is not "still in a mission".**
* **Dedup entity enumerations by position value.**
* **Do not diagnose timing or liveness under gdb.** `ps %cpu` is cumulative.
* **The `PRESS Ⓐ` glyph counter false-positives on the attract movie by 13×.**
`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 ≥ 400
and the peak was 5 393** — the movie has green content in the plate region.
The probe is safe *because its state machine will not look at the glyph until
the content classifier has already said `title_*`*, not because the threshold
discriminates. ⚠️ **`glyph()` alone is not a plate detector**; a two-frame
control over a 3½-minute movie is not a control over that movie.
* 🔴 **`screen_id.py` cannot see a plate-less title, and calls DIFFICULTY a
menu.** Both reproduce on committed reference frames:
| frame | `screen_id.py` says | should be |
|---|---|---|
| `live-title-build4-no-plate.png` | **`other`** | title |
| `live-title-press-a.png` | `title` | title |
| `difficulty-screen.png` | **`menu`** | not the main menu |
It thresholds on **green** (0.0009 with the plate vs 0.0002 without), so it
recognises a title only once `PRESS Ⓐ` has faded in — and this corpus's own
finding is that **the boot title shows build 4 FIRST, plate-less**, for ~2.25 s.
⚠️ **Any harness that waits 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 this.
✅ The zncc-against-committed-frames classifier used for the settle-time screen
log does not have either defect: 6/6 including both movie frames and
`difficulty-screen` as negatives, at a 0.85 threshold. ⚠️ At 0.60 it *also*
called `difficulty-screen` a menu (0.632) — the threshold is doing real work
and must be controlled, not chosen.
* **Classify screens by whole-image statistics, not named pixels** — a named
pixel is only valid while the image sits at a known place, and nothing errors
when it moves.
@@ -114,6 +224,25 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
about how a screen is built or animated has to be armed *before* it exists.
Re-arming every few seconds and keeping every log tiles the approach: each F10
opens a new numbered file and closes the previous one complete.
* **A capture stream that opens N seconds after launch will report the boot in
the wrong order, and nothing errors.** `data/boot-timeline-2026-08-29.tsv`
opens on the *developer* splash and labels the publisher one 6 s later, which
reads as `dev → pub` and is the opposite of the boot. The stream had attached
~7.7 s in and missed the publisher entirely. **The tell was in the file**:
its first twelve rows are byte-identical to four decimals — one held frame
sampled twelve times, i.e. the probe joined a screen already in progress rather
than watching it arrive. If `t = 0` is not the launch, say so in the file; if
the first rows do not *change*, you did not see the beginning.
([`boot-order-and-splash-dwell.md`](boot-order-and-splash-dwell.md))
* **`ADV.wmv` opens with its own SQUARE ENIX card, and it scores 0.75 against the
publisher splash.** A correlation classifier keyed on
`live-splash-publisher.png` therefore fires **twice** per boot, ~10 s apart,
and the second one is a movie frame. Discriminators that work: the real splash
is *perfectly still* (identical frame statistics for seconds) and scores
0.930.94; the movie card drifts continuously and never passes 0.76 — and its
wordmark is bloomed and below centre where the splash's is sharp and centred.
**A threshold that both a screen and a movie frame clear is not a classifier**;
look at the frame.
* **Measure animation in submitted frames, not in seconds.** `VdSwap` counts are
the guest's own frames, so an emulator at 80 % of real time does not move them;
a stopwatch reading does, silently and by an unknown factor.
@@ -896,3 +1025,293 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
test needs cross-correlation to align first and an agreed downmix, and only then
is a pass mark like ">40 dB down" meaningful. Reporting the 9 dB as a result
would have been a confident wrong number.
## A shared `CARGO_TARGET_DIR` makes a worktree build replace the binary you run
`CARGO_TARGET_DIR=/sylph-home/re/target-container` is set for the whole container,
so **every checkout shares one target directory**. Build anything in a
`git worktree` — the obvious way to render from an old tag as a control — and the
binary at `$CARGO_TARGET_DIR/release/` is now the *other* checkout's. Cargo then
considers your main tree fresh and does not rebuild it.
It cost three renders here that silently used a CLI with no `--at` flag, and the
only reason it was caught is that the missing flag was a hard error. **A stale
binary that merely produces slightly different numbers would have been believed.**
After any worktree build, `touch` a source file and rebuild before measuring
anything — and prefer building the control's binary to an explicit
`--target-dir` of its own.
## `rest()` is one element's last hold, not the settled screen
`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 this is exactly wrong: a
two-frame flash's last hold is the flash **peak**, so `rest()` leaves it burning
forever.
Five such flashes stack on the title and saturate the light arc; the band's error
against the console was 33.22, and 8 581 pixels sat at the clipping level where
the console has 1 459. Posing every element at one shared instant instead — the
midpoint of the longest keyframe-free interval — takes those to 11.79 and 1 452.
The general trap: **an aggregate computed per-element is not a state of the
system.** Ask what instant a composite claims to depict, and check that every
element was asked the same question. See
[`structures/ui-settle-time.md`](structures/ui-settle-time.md).
## A 2D draw's identity is its geometry, not its bound texture
The title's sprites **sample large shared texture pages**, so the texture bound to
a draw identifies a page and not an element. Matching a bound texture's dimensions
against a decoded sprite's fails silently in both directions, and one pass here
did both at once:
* **false negative** — "none of the five flash sprites is ever drawn". They are
drawn; they simply never appear as their own texture.
* **false positive** — "`ptbase2` (640×360) and `pteff04` (1280×720) are drawn in
frames 75105". Those frames are the **intro movie**, whose YUV planes and
target happen to be 640×360 and 1280×720.
Re-run against the **quad's vertex rect** in design space and every element
appears where the disc says it should. Canary's own capture code already carries
this warning in a comment, and the corpus had already recorded that the settled
title binds only 1280×768 pages — both were there to be read first.
The general shape: **a coincidence of size is not an identification.** Before
matching on one attribute, ask what else in the frame shares it.
## A batched draw merges quads, and the merge can be invisible
A GPU draw can carry 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 log line's whole vertex list therefore silently *merges* quads into
one bounding box.
This produced two wrong findings in one session, one of them reported to another
agent with three alternative explanations "ruled out":
* **`ptlogo_back2eff3` "is never drawn by the game".** It is batched with
`ptlogo_back2eff4`, and because the wipe family is right-aligned, `eff3`
(788…1196) lies **entirely inside** `eff4` (447…1196). The union is *exactly*
`eff4`'s extent — so the merged box matched `eff4` to 1 px, `eff3` vanished, and
nothing looked wrong.
* **"the developer splash is one composited quad."** `gamearts_eff` and
`seta_eff` merged into a box that was read as the bounding box of three logos —
which it could not have been, since it was 259 px tall and they span 421.
**Why the checks failed.** Three hypotheses were tested and refuted — sampling
phase, a draw with no geometry logged, a bad position guess. All three were aimed
at the wrong failure. In particular the "invisible draw" check counted draws with
**no** geometry line; the hiding place was draws with **partial** geometry, which
was never looked for.
> 🔴 **Refuting three wrong hypotheses is not evidence for a fourth.** The
> confidence gained from "I ruled out everything I could think of" is worth
> exactly as much as the list was complete, and a list of failure modes assembled
> by the person who built the instrument is the least likely to contain that
> instrument's own blind spot.
Parse vertices in groups of four, one per quad, and **compare the logged quad
count against `indices / 4`** — `tools/re-capture/quads_per_frame.py` does both and
warns on the shortfall.
⚠️ A related tell that was present and ignored: a merged box carries the *first*
quad's vertex colour, which made one element's alpha read 255 / 127 / 254 on
consecutive frames. That non-monotonicity was noticed, written down as "the
vertex-alpha identity does not generalise", and not chased. **An anomaly you
explain away is cheaper to chase than to re-derive later.**
## Count the batch, not the quads the log happened to print
The UI draw log caps its vertex dump at **8 vertices — two quads** — while a draw
may batch many more (`indices=24` is six). Two consequences, and the second is the
one that bites:
* a bounding box taken across a line's vertices **merges** quads (already recorded
above, the `eff3` false negative);
* **which** elements appear in the log is the *first two in the batch*, and that
set changes as elements fade. On the boot's developer splash the three glows
occupy the prefix until t=45; the three wordmarks are invisible to the log until
the glows stop being submitted. Read naively this says "the wordmarks are first
drawn at frame 140", which is the logging prefix shifting and not the game.
That produced two splash spans 7.9 % apart on one boot of one guest — a quantity
that must be one number. **The fix costs nothing: `indices / 4` is how many quads
the draw actually holds, and the cap cannot touch it.** Its transitions land
exactly where the declared count of elements with alpha > 0 changes, which makes
them free calibration points.
> 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 you are measuring.** A truncated view looks like a
> complete view of a smaller set.
## Before calling a failure unexplained, grep the corpus for its *symptom*
`title-a-press-fault.md` spent a session recording that a single Ⓐ faults the guest
4/4, and closed with *"it does not explain how Q4/Q5 pressed Ⓐ successfully; what
differs is unfound."*
**It was found, and written down twice, before that page existed.**
* [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) §3: *"With no
profile, Ⓐ **is** handled: the guest calls `XamShowSigninUI` and Xenia pops its
Sign In dialog"* — with a committed capture.
* `tools/re-capture/boot_menu.sh`'s header, which explains the swallow **and quotes
the 8.4 million figure**, and is why that launcher passes
`--logged_profile_slot_0_xuid`.
The fault page searched for the *cause* it had hypothesised — an unimplemented
instruction, then a wild pointer — and never searched for its own *symptom*, which
would have hit both immediately.
⚠️ **Two lessons, and the second is the expensive one:**
1. **Grep for the symptom, not the theory.** "Ⓐ", "signin", "IsUIActive" were all in
the tree.
2. 🔴 **Knowledge in a script header is invisible to the document that needs it.**
`boot_menu.sh` had the mechanism and the magnitude, and no `docs/re/` page linked
to it. A tool comment is a fine place to explain a flag and a **bad** place to be
the only record of a finding. If a script comment is carrying a measurement, that
measurement belongs in `docs/re/` with the script pointing at it.
What the later session did add was the **join** — that this known input blackout is
what drives the guest's unbounded keystroke queue into a failed 128 MB allocation —
plus the guest code path and a host-vs-guest address retraction. A join between two
recorded facts is a real finding; but it is much cheaper when neither fact has to be
rediscovered.
## …and its mirror: a finding with TWO records and nothing keeping them equal
The section above is about a measurement whose only record was a script comment, so
the document that needed it could not see it. The port agent ran the same audit
against its own tree and found the **opposite** failure, which is worth pairing here
because the fix for one is the cause of the other.
Its voice-verification control was recorded in **two** places — a tool's control
table and a prose document — and they had drifted: **53.3 %** in the tool, **53.2 %**
in the doc, twice each. The control file was transient and is gone, so neither copy
can be re-measured and there is no way to tell which is right.
⚠️ **Both copies look authoritative.** That is the whole problem: a single record
that is hard to find announces itself as missing the moment you look; two records
that disagree announce nothing at all, and a reader takes whichever they opened.
**So the rule is not "write it down twice".** It is:
* **one record, in `docs/re/`**, for anything that is a measurement;
* **everything else cites it** — a tool comment says *why the flag is there* and
links to the page, and never restates the number;
* if a number must appear in two places, one of them has to be **generated** from
the other, not typed.
The port fixed its case by deleting the duplicate rather than picking a winner,
which is right: with the evidence gone, choosing between 53.2 and 53.3 would have
been authoring a measurement.
## A pixel figure without its region and its threshold is not checkable
`plate-pulse-measured.md` published 159 / 714 / 1520 as the plate-absent floor and
the pulse's two levels. The port agent holds the same capture, tried to reproduce
the floor, and got 35× at every threshold it tried — because the page named
neither the **region** (whole 1280×720 frame, not a plate crop) nor the
**predicate** (`(g>130) & (gr>45) & (gb>45)`, a three-channel test, not
`green > N`).
⚠️ **This is worse than an obviously incomplete number.** A figure with no stated
method reads as checkable, so a reader spends real effort failing to reproduce it
and then has to decide whether the disagreement is theirs or yours.
And writing the method down immediately exposed a defect the prose had hidden: the
floor came from a **1279×675** capture while the pulse came from **1280×720**
frames — different crops, silently compared. The fix was a same-run, same-geometry
floor that was in the series all along.
**So:** every pixel count states its region and its predicate, and a comparison
between two counts states that they share a geometry. If they do not, that is a
finding about the comparison, not a detail.
## A fix that overshoots leaves no symptom until something else needs the part it disabled
From the port agent, and it generalises past its own case. Its static-overlay path
was **frozen at the overlay's arrival** — a fix for a different bug that reached too
far and stopped the overlay's clock entirely. Nothing noticed for a week, because
nothing needed that clock to advance. The plate pulse is what finally gave it
something to be wrong about.
⚠️ **An over-broad fix does not fail; it goes quiet.** The class of bug to look for
is not "this is broken" but "this has been correct-by-inactivity since the day
somebody disabled it". When a fix works by *stopping* something rather than
correcting it, that is the moment to write down what has been stopped.
## A detector that can fire on a single frame will fire on the wrong one
The Ⓐ A/B's first pair was **void**, and the reason is worth more than the result.
The "wait for the title" step tested one frame against a glyph threshold. The intro
movie throws green flashes of **1 298…5 433** lasting under a second, which clears
any threshold the title also clears — so both legs pressed Ⓐ into the movie, about
**6 s before the title appeared**.
🔴 **What makes this dangerous is that it looked like it ran.** The presses were
real and had a real effect: each skipped the rest of the movie, which is exactly
what the corpus documents Ⓐ doing to a movie. Both legs then reported zero swallow
and zero crashes — a clean, symmetric, entirely meaningless result. **A void test
that appears to have run is worse than one that errors**, because nothing prompts
you to look.
It is the same shape `is_title.py` already records for `screen_id.py`, which called
the SQUARE ENIX logo "title" 151 s into a boot and spent `skip_intro`'s one press
there. The corpus has now paid for this twice.
**The rule: a screen detector matches a *signature over time*, never a single
frame.** The fixed version requires 12 consecutive samples inside a band the movie
overshoots — and, crucially, it was **replayed against the void runs' own recorded
series as its control**, where it declines the flash at 84.8 / 85.5 s and fires at
93.9 / 94.7 s. A broken run's data is the cheapest possible control for its
replacement; keep the series.
## A demand for reproducibility can surface a defect that is not the one demanded
The port agent challenged this corpus's pulse figures as unverifiable — it had the
capture and could not reproduce the numbers. The literal answer was small: name the
predicate, and its counts then matched **exactly**.
But writing the method down is what exposed the actual defect: the floor came from a
**1279×675** capture and the pulse from **1280×720** frames, silently compared
across geometries. Nobody was looking for that.
⚠️ **And both sides were wrong at once.** The challenger's counts were the wrong
measurement (single-channel, plate-crop) *and* the published figure had a real flaw.
"One of us must be right" was never the shape of it — which is worth remembering
before spending a round arguing about which.
## A rule learned from a burn generalises to cases that LOOK like the burn, not to cases that share its mechanism
Contributed by the port agent, and it is the sharpest thing either of us has put in
this file.
This file already carries **two** divisor bugs, both the same shape: a silent input
sitting in a divisor and attenuating real signal. The lesson taken from them was
roughly *"be suspicious of dividing by N"*. So when the intro's three streams had to
be combined, the port summed at **unity** — and its own checker rejected the tree at
**+2.62 dBFS**.
🔴 **The precedent did not transfer, and the surface shape is why it looked like it
would.** Both cases are "several streams, one output". But:
* a BGM bank's two waves are **stems of one signal** — parts that were split apart
and must be added back;
* the intro's three streams are **positions in a field** — a stereo downmix weights
them 0.4142 / 0.2929 / 0.2929, which **sum to one whatever the assignment**, so
the total is fixed even when the placement is unknown.
Divide-by-N is neither right nor wrong in itself. It depends on whether the inputs
are parts of one *signal* or parts of one *field*, and nothing in the phrase
"several streams, one output" distinguishes those.
⚠️ **The general failure**: a rule extracted from a specific burn tends to be indexed
by *what the burn looked like* rather than by *why it happened*. It then fires on the
next thing with the same silhouette — and, worse, feels well-earned while doing it.
When reaching for a past lesson, state the mechanism it turned on and check that
mechanism is present, not the resemblance.

View File

@@ -617,3 +617,58 @@ neighbourhood, not just the line.
`10 144 of 10 148 references resolve`) → **withdrawn; it is on the disc.** It is
the `GP_STAGE_CLEAR` child the same scan named `8AX`. With the name decoded the
count is **10 148 of 10 148**. [`ratc-child-names.md`](structures/ratc-child-names.md)
## UI timing (2026-08-29)
* "a screen has SETTLED at its `rest.t`" → **refuted.** `rest.t` is the last
*hold* keyframe before the exit, not the end of motion. Build 4's `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. Believing `rest.t` put a port's plate 3.97 s late —
[`title-plate-delay-measured.md`](title-plate-delay-measured.md).
* "the `PRESS Ⓐ` plate is composited a measured 2.13 s after the title settles,
and the port should author that" → **the measurement stands, the instruction
was refuted by the port.** Build 2 has a keyframe group of its own; both builds
run on **one clock started together** and the plate's declared `t=238` supplies
the timing, so nothing is authored. `238 118 = 120 units = 2.000 s`, of which
2.13 s was a wall-clock reading stretched by Canary presenting at ~28.1 fps.
⚠️ General lesson: **a wall-clock duration off this emulator is ~6 % long**, so
a measured interval that lands near a round number of units probably *is* that
number of units.
* "a music bank has three sub-waves" → **refuted; it was our reader.** The third
is the bank header, emitted because `to_xma_riffs` derived a leading packet
stream's start as `first_riff % 2048` — valid only for a header shorter than
one packet. 28/28 disc-wide —
[`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md).
## The oracle harness and the container (2026-08-29)
* "the decoder container has no disc" → **refuted the same day.** The container
was replaced and `/disc` is a real 6.2 GB read-only mount. Worse, both
instruments behind the claim were blind to the answer either way:
`find / -xdev` **cannot cross** into a bind mount on another device, and
`sylph-doctor` only checks `/work` and never `$SYLPHEED_DISC`. "sylph-doctor
agrees" was two instruments sharing one blind spot.
→ To test for the disc, ask the variable that names it:
`sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`.
* "the main menu returns to the title on its own after ~810 s idle" → **refuted.**
The menu sat untouched for **≥ 60 s** without moving (correlation never leaving
0.92450.9249). The ~810 s idle is real but belongs to the **title**. This was
the only reason "Ⓑ leaves the main menu" was classed as authored.
* "whole-image statistics (green / white / mean) can tell the title from the
attract movie" → **refuted.** 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. A
probe built on it tapped Ⓐ into the movie and waited 120 s for a menu that was
never coming. → Correlate against a committed capture instead, and keep movie
frames as the negative controls.
* "a 360-bin angular cross-correlation can measure the focus ring's rotation
angle" → **refuted by its own control**: a synthetic **30°** rotation of a live
frame came back as **0°** (peak 0.596), while 90/180/270° came back exactly
(peak 1.000) — it only resolves exact pixel permutations. No angle was quoted;
the spin was established from brightness conservation instead.
* "a latency read off a classified `x11grab` stream is a duration" → **refuted.**
At 1503 ms per classification against an 8 fps stream the consumer ran at
0.64 fps, so frames were stale and increasingly so. Four "durations" died with
it. The tell was that a screen transition, a button press and a plate fade all
came out at ~2025 s. → A backlog **preserves ordering and destroys
durations**; check consumed-fps against requested-fps before quoting a time.

View File

@@ -0,0 +1,230 @@
# ✅ Capturing the emulator's audio: the ALSA `file` tee, and why PulseAudio's monitor cannot do it
**Classification: measured**, on the capture chain. Supersedes the tuning advice
in [`audio-capture-channel-map-trap.md`](audio-capture-channel-map-trap.md),
which chased the wrong subsystem.
## The root cause of every bad capture so far
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 % silence measured in the take-2 capture was never audio
that went missing; it was silence PulseAudio *invented*.
That is why `PULSE_LATENCY_MSEC` produced a non-monotonic curve (39.3 % → 15.6 %
→ 50.1 % silence at 5.3 / 200 / 500 ms) and never won: **the buffer size trades
gap count against gap size, and no setting escapes a clock the capture point
does not share.** The instrument was wrong, not mistuned.
**ALSA's `file` plugin has no clock at all.** It tees exactly what the client
writes. A slow producer yields a *shorter file*, not a gap-riddled one — turning
a data-loss problem into a time-base problem, which is the right trade when the
question is "is the correct audio playing".
## ✅ Control — 6 distinct tones, byte-exact
| | |
|---|---|
| source | 12.000 s, 6 channels at 400 / 800 / 200 / 1600 / 3200 / 6400 Hz |
| captured | **12.000 s**, **0.00 % silence**, **0 gaps**, no duplicate channels |
⚠️ **Channel order is ALSA's, not WAV's.** Captured channel *i* holds source
channel `[0,1,4,5,2,3]` — i.e. `FL FR BL BR FC LFE` where the WAV file had
`FL FR FC LFE BL BR`. Deterministic, invertible, and **not** data loss; do not
mistake it for the remap corruption documented in the companion page.
## The three configuration traps, in the order they bite
1. **`ALSA_CONFIG_PATH` REPLACES the entire ALSA config.** Without
`</usr/share/alsa/alsa.conf>` the named `null` device is undefined and the
client fails with `Input/output error`.
2. 🔴 **But with that include, overriding `pcm.!default` silently does not
take** — no error, no file, the client runs happily to completion. Both the
full inline form and the `pcm.!default "name"` alias failed this way.
**The fix is to drop the include** and declare the slave with an *inline
plugin type* (`{ type null }`, `{ type pulse }`), which needs no named
reference. Xenia hardcodes `snd_pcm_open(..., "default", ...)`
(`alsa_audio_driver.cc:150`), so `default` **must** be the tee — a named
device is not reachable.
3. **`| head -N` kills the producer.** An `ffmpeg … | head -3` SIGPIPEs ffmpeg
before it writes, and the symptom is "no file" with no error — indisting-
uishable from a broken config.
## 🔴 And the reason a bare `file` tee is NOT enough for Xenia
Xenia's ALSA driver runs a writer thread that **pads silence whenever its ring
buffer is empty** (`alsa_audio_driver.cc:359`). Against a device that never
blocks, `snd_pcm_avail_update` always reports space, so the thread spins:
> **Measured: ~250× real time — 7.34 GB, 12 746 s of nominal audio, in ~50 s of
> wall clock**, nearly all of it driver-generated silence. It was killed and the
> file deleted; it would have filled the disk.
⚠️ This is exactly the limit the route's proposer flagged — it had been verified
with `ffmpeg` as the client, which is self-paced, and **not** with Canary, which
pads.
## ✅ The configuration that satisfies both constraints
**Tee in front of a paced slave.** The file plugin captures what the client
writes; the slave supplies the clock that stops the driver free-running. The
wall-clock silence-insertion then happens *downstream of the capture point*
rather than inside it.
```
# NO include: `type pulse` is an inline plugin type, and the include is what
# makes a pcm.!default override fail to take.
pcm.!default {
type file
slave.pcm { type pulse }
file "/path/to/capture.raw"
format raw
}
```
```bash
ALSA_CONFIG_PATH=…/asound-tee-pulse.conf PULSE_SINK=cap \
run-canary --apu=alsa --mute=false# "$@" is last, so both win
ffmpeg -f s16le -ar 48000 -ac 6 -i capture.raw out.wav
```
✅ Control through this exact config: **12.000 s, 0.00 % silence, 0 gaps.**
⚠️ **`--apu=alsa` is a third option neither agent had tried.** The note that
`--apu=nop` stalls the guest in the intro movie still stands and is why
`--mute=true` was there; it says nothing about the ALSA backend.
## ✅ Measured on Canary — and the residual silence changes meaning
150 s boot, `--apu=alsa --mute=false`, tee in front of the paced pulse slave.
⚠️ **Xenia's ALSA driver is `SND_PCM_FORMAT_FLOAT_LE`** (`alsa_audio_driver.cc:173`)
and its log confirms `ALSA initialized: 48000 Hz, 6 channels (output: 6),
period: 512, buffer: 2048`. The raw tee is therefore **float32, 6 channels**
reading it as `s16` produces a plausible-looking file with a giveaway signature:
peaks alternating exactly `0.00 / 4.82 / 0.00 / 4.82 / 0.00 / 4.82`, which
is the two halves of each float landing in alternate "channels".
| capture route | silence | gaps/s | notes |
|---|---|---|---|
| PulseAudio monitor, xenia default (~5.3 ms) | 39.3 % | 30.5 | |
| PulseAudio monitor, `PULSE_LATENCY_MSEC=200` | 15.6 % | 3.5 | |
| PulseAudio monitor, `PULSE_LATENCY_MSEC=500` | 50.1 % | 1.3 | |
| **ALSA tee → paced pulse slave** | **9.98 %** | 8.37 | 106.2 s captured over ~151 s wall = **0.70× real time** |
**The file is short rather than gap-riddled, which is the intended trade** — and
six distinct channels, no duplicates, sensible peaks (4.41 / 3.96 / 4.41 /
11.65 / 8.09 / 6.53 dBFS).
🔴 **But the residual ~10 % silence is NOT removed, and 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 (`alsa_audio_driver.cc:359`). So:
* ✅ the capture is now **faithful** — every sample in it is a sample the
emulator emitted;
* ❔ the emulator is still emitting padding, because the guest runs at ~0.7× real
time here, and **no capture method can remove that**. Fixing it needs the guest
to keep up, or a change to the driver's padding behaviour.
⚠️ **So this is a 3.9× improvement in silence and a change of attribution, not a
clean capture.** At 9.98 % / 8.37 gaps/s it sits right on the port's "≥10 %
silent *and* ≥1 gap/s" fail bar. **Do not treat it as an oracle without saying
which side of that line it fell on.**
## ✅ SOLVED — `--gpu=null` removes the residual, and the capture is clean
The residual padding was the guest running at **0.70× real time**, and the
dominant load is **llvmpipe software rendering** — which an *audio* capture does
not need at all.
| configuration | silence | gaps/s |
|---|---|---|
| PulseAudio monitor, xenia default | 39.3 % | 30.5 |
| ALSA tee → paced slave, rendered (llvmpipe) | 9.98 % | 8.37 |
| **ALSA tee → paced slave, `--gpu=null`** | **0.31 %** | **0.01** |
**One gap in 67.7 s.** Six distinct channels, no duplicates, peaks 5.15 / 4.55
/ 4.47 / 11.65 / 6.73 / 6.40 dBFS. For scale, the port's *genuine music bed*
control measures 1.1 % silence at 3.3 gaps/s — **this capture is cleaner than
their known-good reference.**
**Control that the run is still comparable:** `ADV`'s three XMA contexts
(1 294 336 / 1 118 208 / 1 171 456) 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 — it evidences the thing
being recorded rather than what was on screen.
### The full working recipe
```bash
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
# asound.conf: NO include; tee in front of a PACED slave
# pcm.!default { type file slave.pcm { type pulse } file "…" format raw }
ALSA_CONFIG_PATH=…/asound.conf PULSE_SINK=cap \
run-canary --apu=alsa --mute=false --gpu=null …
ffmpeg -f f32le -ar 48000 -ac 6 -i capture.raw out.wav # float32, not s16
```
⚠️ `--gpu=null` means **no video**, so screen-based provenance is unavailable —
use the XMA probe instead. And it is only appropriate when the question is about
audio; it changes what the guest is doing.
## ✅ The distinction that makes even an imperfect tee capture usable
Contributed by the port, 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. Information is destroyed, and deleting the
holes cannot recover it — it only compresses time unevenly.
* **Xenia's padding is ADDITIVE.** The silence is *inserted between* samples the
guest emitted. **Nothing is lost.** Every real sample is present and in order.
So **stripping all-channel-zero runs from an ALSA-tee capture is exact, not a
repair** — it returns a contiguous stream of everything the guest produced. That
means even the 0.70×-real-time rendered capture (9.98 % padded) is usable for
correlation, where none of the PulseAudio-monitor captures ever were, however
they were tuned.
**VERIFIED 2026-08-29.** The port controlled it rather than relying on the
reasoning: a real music+SFX bed (137.37 s, carrying **454 genuine zero runs of
its own**, which is what makes it an honest control) had **1 149 holes inserted
at 8.37 gaps/s to +9.9 % length** — matching the measured ALSA profile — then
stripped:
| | r | lag | margin |
|---|---|---|---|
| original vs itself (ceiling) | 1.000 | 0.0 s | +0.141 |
| **padded** vs original | **0.436** | 12.2 s | **+0.006** |
| **stripped** vs original | **1.000** | **0.0 s** | **+0.142** |
Two things beyond the yes:
***It runs the inference forwards.** Padding at this profile puts correlation
squarely in the known-absent regime (margin +0.006) 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.
***Only ONE side needs stripping.** The stripped capture matches the
**unstripped** source at the ceiling, so a capture needs no preprocessing at
all before being handed over — no shared step for two parties to get out of
sync on.
🔴 **And the danger, which is the part to repeat:** stripping removes *genuine*
silence too and cannot tell the two apart. It is **exact on additive ALSA
padding and vandalism on a PulseAudio monitor capture**, where the silence
replaced real audio. On mostly-silent material the genuine runs would cost
something measurable — here they totalled 0.71 s in 137 s and cost nothing.
⚠️ **Running it on the wrong artefact would look like it worked.**
⚠️ **Consequence for `check-capture`:** its silence/gap-rate rule was built when
only *damage* existed, and **cannot distinguish genuine emulator padding from
capture damage.** A FAIL on an ALSA-tee capture is a statement about the
recording path, not about the file's usability.
## Consequences for verification
🔴 **Short file becomes the failure mode**, so a capture check needs an
**expected-duration** test alongside silence fraction and gap rate. And a
**runaway guard** is not optional: abort if the file exceeds ~3× real time, or
one misconfiguration writes 7 GB before anyone looks.

View File

@@ -0,0 +1,308 @@
# 🔴 A 6-channel PulseAudio capture SCRAMBLES AND DUPLICATES channels unless the maps match
**Classification: measured**, on the capture chain itself rather than on the
game. Recorded because a capture taken with this defect was handed to the port
as evidence, cost them a full controlled analysis, and the negative they
correctly reported was **my instrument, not the guest**.
## What happened
`adv-game-output-6ch.wav` was captured from Canary through a PulseAudio null
sink and shared as "what the game emits during `ADV`". The port could not match
it against **anything** — the `ADV` bed, any of the three XMA voice streams,
`BGM_103`, `S00A` — with best-vs-runner-up margins of 0.0010.016 everywhere,
i.e. plateaux rather than peaks. They controlled that three ways (their
instrument finds `bed` vs `bed` at r=1.000 margin +0.115; their `.ogv` reference
matches the disc's `.wmv` at r=1.000 margin +0.114; and drift was excluded by
windowed lags scattering across the movie). They also observed that **capture
channels 3 and 6 were byte-identical, same MD5**.
That duplicate pair is the tell, and it is reproducible without the emulator.
## The control I should have run first
Six channels, each a **different** tone, so any reorder, drop or duplication
shows up as a wrong frequency. Played to the sink with `paplay`, recorded from
its monitor with the same `parec` invocation the `ADV` capture used.
**Sink map `FL,FR,RL,RR,FC,LFE`, i.e. NOT the stream's map — the original setup:**
| channel | expected | captured | |
|---|---|---|---|
| 0 | 400 Hz | 400 Hz | ok |
| 1 | 800 Hz | **3200 Hz** | wrong |
| 2 | 200 Hz | 200 Hz | ok |
| 3 | 1600 Hz | **800 Hz** | wrong |
| 4 | 3200 Hz | **800 Hz** | wrong |
| 5 | 6400 Hz | **200 Hz** | wrong |
`ch2 == ch5`, **byte-identical** — the same artefact the port found. The 6400 Hz
and 1600 Hz channels are **gone entirely**, replaced by duplicates.
**Sink map made identical to Canary's own stream map**
(`front-left,front-right,front-center,lfe,rear-left,rear-right`), and the same
map given to `parec` explicitly:
| channel | expected | captured |
|---|---|---|
| 05 | 400 / 800 / 200 / 1600 / 3200 / 6400 | **400 / 800 / 200 / 1600 / 3200 / 6400** |
No duplicates. **CONTROL PASSED.**
## 🔴 And a LEVEL CHECK cannot see this failure — by construction
The port made this point while building a checker for it, and it refutes
something written above.
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 no matter how the channels are
permuted or duplicated — and on *real* content the peaks simply differ from each
other, which looks equally healthy. Either way the table is uninformative.
⚠️ So "the WAV has plausible per-channel levels" was not weak evidence that the
capture was sound; it was **no** evidence, and this page said otherwise. The
per-channel peak table is the natural thing to eyeball after a capture and it is
**blind to remap corruption**. What detects it is hashing each channel and
comparing — the port's `tools/port/check-capture`, controlled in both directions
(six distinct tones → PASS; this page's known-bad pattern → FAIL naming all four
pairs; the withdrawn capture → FAIL on `ch2 == ch5`).
## The rule
⚠️ **A null sink whose `channel_map` differs from the client's makes PulseAudio
remap, and a 6-channel remap silently loses channels and duplicates others.**
There is no error, no warning, and the WAV has the right length, the right
channel count and plausible per-channel levels. Set the sink's map to the
client's, and pass the same map to `parec`:
```bash
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
parec -d cap.monitor --channels=6 --rate=48000 --format=s16le --channel-map=$MAP
```
## 🔴 What this withdraws
* **`adv-game-output-6ch.wav` is withdrawn as evidence.** Its channels are
scrambled and one pair is a duplicate. Nothing should be concluded from it,
in either direction — it is not evidence that the game emits something
unexpected, and the port's inability to match it is fully explained.
* **"All six channels carry signal"** — withdrawn. One of the six was a copy of
another.
* **"The surround and LFE channels are not zero, which a stereo guest padded
into a 6-channel frame would give"** — withdrawn. It was offered as weak
support for the 5.1 reading of a voice cue's three streams
([`voice-three-streams-are-concurrent.md`](structures/voice-three-streams-are-concurrent.md)),
and it is worth nothing. 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. That is read from
the emulator's own log, not from the audio path, and it reproduced on two
independent boots.
## The lesson, in the form it should have been applied
The corpus's own rule is *run your instrument through a control first*. Here the
control needed no emulator, no disc and 30 seconds: **play a known signal through
the capture chain and check that it comes back.** It was not run, an artefact was
published, and the person who found the defect was the one who could not see the
instrument. ⚠️ **A capture is an instrument, not just an output** — the same
scrutiny a parser or an estimator gets.
---
## ✅ The capture that passes — recipe, and how it proves itself
Take 2, 2026-08-29. Verified with the port's independent
`tools/port/check-capture` (six distinct channel MD5s → PASS) **before** being
shared, deliberately using their tool rather than the hand that made the file.
```bash
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right # Canary's own
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
parec -d cap.monitor --channels=6 --rate=48000 --format=s16le \
--channel-map=$MAP --file-format=wav out.wav & # recorder FIRST
PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio \
run-canary --mute=false# both mutes off
```
Two properties make it self-checking, and both were the port's asks:
* **The recorder starts before the emulator**, so WAV `t=0` precedes process
launch and the movie cannot fall outside the window by accident.
* **A screenshot every ~11 s, keyed to the recording's own clock.** Classified
against the committed references afterwards, this run reads `movie/other` for
**t = 10 … 251** and then `title_noplate` at **t = 262** (r = +0.998),
`title_plate` at 277/289. So the 253 s of audio sits wholly inside the movie,
with the title arriving just after it ends. **A miss would now be diagnosable
instead of ambiguous** — which is the whole difference from take 1.
⚠️ It is still the **full mix** — the movie's own WMA bed plus the voice streams.
Nothing at this boundary separates them.
## ❔ New, unexplained: this run decoded FIVE XMA streams, not three
`--xma_param_probe` on the take-2 boot logs five distinct `byte_size` values:
`ADV`'s three (**1 294 336 / 1 118 208 / 1 171 456**) plus **1 150 976** and
**1 269 760**. The extra pair belongs to some other cue and is unidentified —
they are not `BGM_103`'s two waves (3 876 864 / 3 930 112). A *pair* is the shape
[`bgm-two-stems`](structures/bgm-two-stems.md) documents for music banks, so a
second bank is the first guess and it is untested.
---
## 🔴 TAKE 2 IS ALSO UNUSABLE — the sink is being STARVED, 39.3 % digital silence
Take 2 passed the duplicate-channel check and carried a verified screen log, and
the port still could not find either the movie's WMA bed or the cutscene voice in
it — this time with a **calibrated** correlator (they had retracted their first
one: it scored 0.415 hunting a bed inside a synthetic mix that certainly
contained it, so it could not have found the target even when present). Their
rebuilt instrument passes both directions, and their negative on take 2 stands.
They named the two readings: *the capture path is still losing the guest's mix*,
or *the guest is not emitting these sources*. ⚠️ They flagged the second as
landing on them hard — 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 the capture says so on its face.** Take 2, measured
directly:
| | |
|---|---|
| frames that are digital silence on **all six** channels | **6 557 892 / 16 680 453 = 39.3 %** |
| non-silent runs | **10 595**, median **13.60 ms**, longest 1.19 s |
| silent runs | **10 596**, median **3.94 ms** |
| burst + gap period | **≈17.5 ms → 57 Hz**, duty cycle **60.7 %** |
The recording is chopped into ~13 ms fragments separated by ~4 ms holes, ten
thousand times over. That is a **starved sink** — PulseAudio filling underruns
with silence because the guest is not keeping the driver fed — and it destroys
envelope correlation *by construction*: the envelope is dominated by a 57 Hz chop
that has nothing to do with the content.
**So the port's alarming hypothesis is NOT supported by this capture.** Nothing
here says the game fails to play the movie's audio track. What it says is that
**this capture cannot answer the question either way**, and the earlier
autocorrelation hint pointed the same way — the file's strongest periodicity is
at **5.2 s** (r = 0.449), not at `BGM_102`'s 37.487 s loop, and 5.2 s is a beat of
the dropout schedule rather than anything musical. (Estimator controlled: it
recovers a synthetic 37.487 s loop as **37.480 s**, and scores non-repeating noise
at r = 0.019.)
### Why the sink starves — and 🔴 why "it cannot be fixed by configuration" was WRONG
`parec` reads a sink **monitor**, which advances at wall-clock rate and
substitutes silence whenever nothing is written. **And every sink in this
container is a null sink**, because there is no audio hardware at all — no
`/proc/asound/cards`, no `/dev/snd`, no `/etc/asound.conf` — so PulseAudio's
stock `default.pa:109` `module-always-sink` supplies one, whose stated purpose is
to "make sure we always have a sink around, **even if it is a null sink**". A
null sink has no hardware clock: it is driven on a timer, and anything the client
fails to write in time becomes silence in the monitor.
From that I concluded the route "cannot be fixed by configuration" and that only
an in-emulator tap would work. **That was wrong, and it was wrong because I
assumed the holes meant the guest was running below real time without testing the
alternative** — that the *client buffer* is simply too small. Xenia asks SDL for
`channel_samples_ = 256`, i.e. **5.33 ms** at 6 channels, and `daemon.conf` here
is stock with no fragment tuning at all.
`PULSE_LATENCY_MSEC` overrides what SDL's PulseAudio backend requests. Measured,
same title, same sink, same `parec` invocation:
| client buffer | duration | **silence** | gaps/s | median gap |
|---|---|---|---|---|
| xenia default (~5.3 ms) | 347.5 s | 39.3 % | 30.5 | 3.94 ms |
| **`PULSE_LATENCY_MSEC=200`** (114.7 ms reported) | 88.0 s | **15.6 %** | 3.5 | 37.33 ms |
| `PULSE_LATENCY_MSEC=500` | 87.9 s | **50.1 %** | **1.3** | 346.67 ms |
**200 ms is 2.5× better than the default. 500 ms is worse than either.** The
relationship is **not monotonic**: raising the buffer keeps cutting the gap
*rate* (30.5 → 3.5 → 1.3) while the *total silence* bottoms out at 200 ms and
then doubles, because an over-large buffer starves in a few enormous holes
instead of many small ones — a 346 ms median gap at 500 ms against 37 ms at 200.
🔴 **And that is a warning about the metric, not just the setting.** The port's
`check-capture` bar is **20 gaps/s**, derived from good 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 % silence.** A gap-*rate* test
alone would pass the worst capture of the three. It needs a **total-silence**
companion, and this is the same shape as the defect that made a level table
useless: one number that cannot see the failure mode next door.
⚠️ **Not yet a clean bill of health.** The two runs are not like-for-like: 88 s
against 347 s, and the short one covers the splash logos, where silence between
cards is real. What is established is the *direction and scale* — the dropouts
were substantially a **client-buffer** problem, not proof that the guest runs
below real time.
**Consequence: the in-emulator tap may not be needed.** The capture route is
worth retrying at a raised latency before anyone spends a session on a Canary
rebuild.
**The route that would work is an internal tap**, and it does not exist yet.
`SDLAudioDriver::SubmitFrame(float* frame)`
(`/canary/src/xenia/apu/sdl/sdl_audio_driver.cc`) receives exactly `frame_size_`
bytes — `sizeof(float) × frame_channels_ × channel_samples_` — of the guest's own
frame, in guest order, with **no wall clock in the loop**. A cvar-gated WAV
writer there is the same shape as `xma_param_probe`: additive, default-off,
read-only. It would produce a gap-free recording however slowly the emulator
runs, because it records what the guest *produced* rather than what a device
*consumed*.
🔴 **Blocked on the build, not on the change — and this is a container fact
worth knowing before anyone plans around it.** `build-canary` builds
`${PROJECT_DIR:-/work}/xenia-canary`, which does not exist here; 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` has no per-file rules, so it re-runs CMake first and that
reconfigure fails on the absent root. **Any Canary change is therefore a full
reconfigure plus a full compile**, at `SYLPH_JOBS=4` on a box sitting at ~700 MB
free with a documented history of parallel builds OOM-killing the host.
**Not attempted, deliberately** — that is a whole session's risk for one probe,
and the rule here is not to improvise around a blocker. Recorded so the next
session can decide with the cost in front of it rather than discovering it
halfway through a build.
## 🔴 And a provenance number I got wrong
I told the port take 2 was **253.3 s**. The file is **318.5 s**, and the full
recording on disk is 349 s. I read `ffprobe` *while the recorder was still
writing*, quoted the partial length, and copied the file before it finished — so
the shared artefact is itself a truncation of the run.
The corrected provenance, from the same screen log: movie/other **t = 10 … 251**,
`title_noplate` at **262**, `title_plate` **277 … 318**, back to movie/other at
**329** (the documented title idle timeout). ⚠️ So the shared file **includes the
title screen**, which contradicts what I told them — I had said it sat wholly
inside the movie window.
**A length in a provenance claim must be read from the finished artefact.**
Measuring a file that is still being written is the same class of error as
reading a level table that cannot see the defect.
🔴 **And it is worse than "truncated" — the shared file declares itself EMPTY.**
`parec` writes the WAV header with zero sizes and only patches them on a clean
exit, so a copy taken mid-recording has:
| field | shared artefact | the finished local recording |
|---|---|---|
| `RIFF` size | **8** | 200 165 472 |
| `data` size | **0** | 200 165 436 |
| actual bytes | 183 478 556 | 200 165 480 |
Python's `wave` module **refuses to open it** (`fmt chunk and/or data chunk
missing`). `ffmpeg` and `ffprobe` recover by scanning, report a plausible
duration, and that is exactly why the defect went unnoticed — **the lenient
reader hid it from me and the strict one would have caught it instantly.**
⚠️ **Attribution of the starvation numbers, corrected.** The 39.3 % / 16 680 453
frames / 10 595 runs above were measured on the **finished local recording**
(347.5 s), not on the artefact that was shared (318.5 s). The port measured the
shared copy independently and got **35.6 % / 15 289 876 frames / 10 482 runs**;
median burst 13.5 ms vs 13.6, gap 3.9 vs 3.9, period 17.4 ms vs ≈17.5. The
diagnosis is unaffected — both files are starved — but **a number must say which
artefact it came from**, and these did not.

View File

@@ -0,0 +1,104 @@
# ✅ The two unexplained XMA streams are `BGM_102.slb` — and the corpus's `BGM_103` sizes survive a check
**Classification: decoded** for the identification (the bank, plus a disc-wide
search); **measured** for the fact that it was decoded during a boot.
Closes the ❔ left by the take-2 audio capture, where
[`audio-capture-channel-map-trap.md`](audio-capture-channel-map-trap.md) recorded
that `--xma_param_probe` logged **five** distinct streams on one boot when only
`ADV`'s three were accounted for.
## The identification
The probe gives a `byte_size` and nothing else, so the disc was asked which cue
owns a stream that long. Both unexplained sizes are whole packet counts —
1 150 976 = 562 packets, 1 269 760 = 620 — and
`--example find_stream_by_size` searched every inter-descriptor span of the
continuous voice stream **and** every `sound.pak` entry large enough:
| | |
|---|---|
| hits in the movie-voice stream | **0** |
| hits in `sound.pak` | one entry carrying **both**: hash `9799c546` |
One entry holding both sizes is the two-stem shape, not a coincidence of two
separate matches. The hash recovers by candidate enumeration
(`--example name_from_hash`) to **`BGM_102.slb`**.
```
BGM_102.slb 2 445 760 B on disc, header 10 240 -> 2 streams
stream 0: 1 150 976 B (562 packets) declared 30 703 B/s => 37.487 s
stream 1: 1 269 760 B (620 packets) declared 33 872 B/s => 37.487 s
```
✅ 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: which screen it belongs to
The capture window ran from process launch to **t = 253 s**, and its screen log
reads movie/attract throughout, with the title arriving at t = 262 s — *after*
the recording ended. So `BGM_102` was decoded somewhere inside a
launch-to-just-before-title window.
⚠️ **That is not enough to call it the attract music.** The probe fires on *first
decode* and its log lines carry a thread id, not a timestamp, so nothing here
says *when* in those 253 s it started — and a title BGM being decoded moments
before the title appears is exactly as consistent. The numbering makes that a
live hypothesis rather than a remote one: the corpus already has the **main
menu** on cue **1103**`BGM_103`, so **1102** sitting one below it is at least
suggestive of the title.
**The experiment that would settle it** is cheap and is not done: put a
wall-clock timestamp on the probe line (or bound the run so it stops before the
title) and compare against the screen log the capture already produces.
## 🟢 Refutation attempt — HANDOFF's `BGM_103` wave sizes. It SURVIVED.
HANDOFF asserts the menu's music is `BGM_103` partly on *"`BGM_103.slb`'s two
declared waves (3 876 864 / 3 930 112 B)"*. Read off the disc:
```
BGM_103.slb 7 841 292 B, header 10 240 -> 2 streams
stream 0: 3 876 864 B declared 44 181 B/s => 87.750 s
stream 1: 3 930 112 B declared 44 788 B/s => 87.749 s
```
**Exact, both.** The claim stands unchanged.
## ✅ And a third route to "two stems of identical duration"
[`bgm-two-stems`](structures/bgm-two-stems.md) established equal duration by
decoding. The XMA1 `PsuedoBytesPerSec` fix
([`voice-region-leading-chunk.md`](structures/voice-region-leading-chunk.md))
gives the same answer from the header alone, on three banks:
| bank | stem 0 | stem 1 |
|---|---|---|
| `BGM_102` | 37.487 s | 37.487 s |
| `BGM_103` | 87.750 s | 87.749 s |
| `BGM_001` | 173.821 s | 173.821 s |
🔴 **An explanation I gave here was wrong and is withdrawn (2026-08-29).** It
said `BGM_001`'s declared **173.821 s** disagreed with a decoded **167.663 s**,
and that "declared covers the encoded stream including its trailing silence; the
decoded figure is where the audio stops". **There is no disagreement to explain.**
A full decode of `BGM_001` yields **173.809 s of PCM** — the 167.663 s is where
the music *fades out*, measured from the audio, and the stream continues silent
to its declared end **inside** that decode. Declared and decoded agree.
✅ **The declared-rate method is now cross-checked on three banks against
independent decodes**, and it is better than the first version of this page
claimed:
| bank | declared | decoded | agreement |
|---|---|---|---|
| `BGM_103` | 87.750 / 87.749 s | **87.744 s** | 56 ms |
| `BGM_102` | 37.487 s | **37.482 s** | 5 ms |
| `BGM_001` | 173.821 s | **173.809 s** | 12 ms |
⚠️ The conclusion that survives unchanged is the useful one: **trust it for
lengths, not for musical boundaries.** A bank's declared length includes whatever
silence the encode carries, so it is not a loop point — that has to be measured
from the audio, and for `BGM_001` that is 167.663 s, 6.1 s before the stream
ends.

View File

@@ -0,0 +1,125 @@
# The boot's first ten seconds: publisher, then developer — and the movie has a logo card too
**Status:**`CONFIRMED`. The **order** is measured in three independent cold
boots and confirmed **by eye**, not only by a correlation. The **dwells** are
**decoded** — they are on the disc, and the running game reproduces them to
within the emulator's own frame pacing.
Raised by the port agent against `data/boot-timeline-2026-08-29.tsv`, whose
`label` column runs `splash_dev` *before* `splash_pub` — the opposite of what
`authored/flow.json` carries. If that ordering were real it would be a boot-order
bug in the port. **It is not real, and this page says why.**
## The order: publisher first
![the two splashes and the movie's logo card](captures/boot-order/splash-order-two-runs.png)
*Brightened ×6 — these frames have a surface mean of ≈5/255. Top-left and
top-right are the two splashes; the bottom row is what comes next and is NOT a
splash.*
| run | `SQUARE ENIX` (publisher) | `GAME ARTS`/`SETA`/`studio anima` (developer) |
|---|---|---|
| 1 | 3.046 → 7.343 s | 7.683 → 11.191 s |
| 2 | 1.180 → 5.784 s | 6.051 → 9.554 s |
| 3 | 1.192 → 5.562 s | 5.929 → 9.295 s |
Three cold boots, `t = 0` at process launch, a ~0.20.3 s black hold between
them. **Publisher first, every time**, and the top row of the contact sheet is
what the two segments actually show. `docs/game/navigation.md` §1 and
[`ui-title-build-map.md`](ui-title-build-map.md) stand.
## Why the committed TSV says otherwise — the probe attached late
`data/boot-timeline-2026-08-29.tsv` opens at `t = 0.692` with twelve
**byte-identical** rows: mean `5.642`, `splash_dev` `+0.8707`, `splash_pub`
`+0.0294`, to four decimals. Twelve identical samples over 1.27 s are one
observation of a held screen, not twelve.
Those exact numbers appear in my run 1 at **8.42 10.94 s** — same mean, same
two correlations, same four decimals. So that capture's `t = 0` is roughly
**7.7 s into the guest's boot**: the publisher splash had already been and gone
before the stream opened, and the developer splash was simply the first thing the
probe ever saw.
**The label column is right about what each frame is. It is wrong about what came
before the file starts.** Nothing in the TSV is retracted; its *reach* is.
## The trap that makes this worse: `ADV.wmv` opens with a SQUARE ENIX card
After the developer splash there is a black hold and then a screen that scores
**0.59 0.75** against `live-splash-publisher.png` — above the classifier's
threshold, so it is labelled `splash_pub` a second time. In all three runs:
| run | second "splash_pub" |
|---|---|
| 1 | 17.793 → 20.301 s |
| 2 | 16.930 → 20.664 s |
| 3 | 14.669 → 17.308 s |
The bottom row of the contact sheet is that screen. It is a **blurred, bloomed
SQUARE ENIX wordmark, lower in the frame** — the intro movie's own opening title
card, i.e. `ADV.wmv` ([`movie-binding.md`](movie-binding.md)) already playing.
The real splash's wordmark is sharp and centred; the movie's is soft and sits
below centre.
⚠️ **So `splash_pub` is not a safe label once the movie has started.** A boot
classifier keyed on `live-splash-publisher.png` will fire twice per boot. The
discriminators that do work: the real splash holds *perfectly still* (identical
frame statistics to four decimals for seconds at a time) and scores **0.930.94**;
the movie card drifts continuously and never exceeds **0.76**.
## The dwells are on the disc — do not author them
Both splash bundles declare their whole life. Read with the corrected keyframe
record layout ([`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md))
and Q1's `1 unit = 1/60 s`:
| | declared | visible span | measured (run 1 / 2 / 3) |
|---|---|---|---|
| publisher, `palogo_sqex.t32` | α `0@15 → 255@30 → 255@235 → 232@239 → 32@251 → 0@255` | `15 → 255` = 240 u = **4.000 s** | 4.297 / 4.604 / 4.370 s |
| developer, `palogo_gamearts.t32` (`seta`, `anima` identical) | α `0@15 → 255@30 → 255@190 → 232@194 → 32@206 → 0@210` | `15 → 210` = 195 u = **3.250 s** | 3.508 / 3.503 / 3.366 s |
Measured ÷ declared, over all six spans: **1.074, 1.151, 1.093, 1.079, 1.078,
1.036** — mean **1.085**. A 30 Hz timeline stretched by 8.5 % is the game
presenting at **27.6 fps**, which is the rate this corpus has measured
independently three times (27.6 on the boot splash, 28.3 and 28.8 on the idle
title — [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)).
**Classified decoded.** The port reads **240 units** for the publisher and
**195 units** for the developer and authors nothing.
⚠️ **Reach of the ±:** a correlation crossing a threshold during a fade is not a
sharp edge, so an individual span is good to roughly ±0.2 s. The developer's
first two runs agree to **5 ms**, which is the instrument at its best; run 2's
publisher span (4.604 s) is the outlier and its onset at 1.180 s is early enough
to be a stream still filling. The *order* does not depend on any of this.
## Method
`tools/re-capture/boot_timeline_probe.py`, whose `--control` was run first and
passed **11/11** on the content classifier and **4/4** on the plate detector,
including `live-splash-publisher.png → splash_pub` and
`live-splash-developer.png → splash_dev` with each rejecting the other at
**0.035**. So a 0.87 reading against the developer reference is a real match and
not an artefact of two dark images: the two references are both dark and they do
not correlate with each other.
Both splash references are themselves committed captures whose identity is
independent of any correlation — `live-splash-publisher.png` was matched to
`GP_TITLE` entry 10, whose elements are literally named `palogo_sqex`, and
`live-splash-developer.png` to entry 11, `palogo_gamearts` / `palogo_seta` /
`palogo_anima`.
Boots were cold: `/dev/shm/xenia_*` cleared, no pad input at any point.
## What this does not say
* Nothing here is about the **movie's** length or the attract loop; the runs were
cut at 2645 s. That half is in `data/boot-timeline-2026-08-29.tsv`, whose
*intervals* are unaffected by the attach offset even though its absolute `t` is.
* The 0.20.3 s black hold between the two splashes was not separately timed
against the declared `palogo_eff0.prm` backdrop; it is consistent with the
0.140.30 s black hold already measured between screens
([`title-plate-delay-measured.md`](title-plate-delay-measured.md)) but that is
a consistency note, not a measurement of this particular gap.

View File

@@ -0,0 +1,168 @@
# ✅ `settle_time()` — when each boot screen actually arrives, measured
**Classification: measured.** None of this is on the disc as a settle time. The
disc declares *keyframes*; when a screen visibly arrives is a property of the
running game, and the port authors these numbers from this page.
Answers the port's standing ask: its boot sequencer paces every screen off
`rest.t`, which is the **last hold keyframe** and not when a screen arrives — it
holds the title for 4.350 s where the build-in is over at about 2 s.
**Run:** one cold boot, 2026-08-29. Container had **no Xenia storage root at
all**, so this is a fresh profile (`--create_profile_if_none`) with no shader
cache — the slowest case, deliberately.
Frame log committed at [`data/boot-settle-run1.tsv`](data/boot-settle-run1.tsv);
analysis `tools/re-capture/settle_analyse.py`.
## The instrument was controlled first, and then caught being wrong
`title_timing_probe.py --control` passed **9/9** on the content classifier —
including the movie-frame and `difficulty-screen` negatives — and **4/4** on the
plate detector, before the run. The run itself sampled **2 107 frames in 263.7 s
= 7.99 fps against a requested 8**, with an independent one-shot grab every 20 s
agreeing with the stream to under 1 grey level. So this is not the backlog
failure mode that cost this corpus four withdrawn durations.
🔴 **And the probe's own `title_static` mark is still biased early — do not use
it for a duration.** It fires when the classifier first labels a frame
`title_*` *and* motion is low, which happens **during the crossfade out of the
attract movie, before the wordmark has drawn**. In this run it fired at
`t=242.655` while the green-glyph count was still **0**; the title art does not
reach its steady state until `243.595`. Any `title_static → plate` figure from
this probe is therefore inflated by about a second.
## The landmarks, taken from content rather than from the probe's marks
The robust landmark is the **glyph plateau**: the title art alone scores a steady
`glyph = 154` (the corpus's `live-title-build4-no-plate.png` scores 159), and the
plate takes it past 700.
| landmark | t (s) | how |
|---|---|---|
| attract movie ends, first title ink | 243.362 | glyph leaves 0 |
| **title art fully drawn** | **243.595** | glyph reaches its steady 154 |
| **`PRESS Ⓐ` plate on** | **245.842** | glyph crosses 400 → 771 |
| Ⓐ pressed | 250.983 | |
| ⚠️ guest load stall | 251.601 253.135 | 13 byte-identical frames |
| **main menu arrives** | **254.707** | classifier |
| **main menu settled** | **255.238** | motion below a run-calibrated floor |
| Ⓑ pressed | 262.864 | |
| **title back** | **263.346** | |
### What the port should author
| | measured | ⚠️ |
|---|---|---|
| title build-in (first ink → fully drawn) | **0.23 s** | from first ink; **1.63 s** from the first frame the classifier calls `title_*`, which is where the crossfade starts |
| **title settled → plate on** | **2.247 s** | matches the disc's declared **120 units** |
| plate pulse period | **≈2.37 s** | trough-to-trough, 248.098 → 250.467 |
| **main menu build-in** | **0.531 s** | |
| **Ⓑ → title** | **0.482 s** | |
| Ⓐ → menu | 3.763 s | 🔴 **do not author** — contains a 1.53 s emulator load stall, below |
🔴 **`rest.t` is confirmed to be the wrong landmark.** The title's `rest.t` is 251
units = 4.183 s; its art is finished at ~2 s and the plate is on at 2.25 s. A
sequencer pacing off `rest.t` holds the title roughly twice as long as the game
does.
## 🟢 A refutation attempt that FAILED — the corpus's 2.13 s plate delay survives
The probe's own marks gave a plate delay of **3.203 s** (`title_static` 242.655 →
`plate` 245.858), against the corpus's two committed runs at **2.138 / 2.132 s**
and a declared 120 units ≈ 2.0 s. A 50 % disagreement, from a cold-cache boot, is
exactly where you would expect the corpus to be wrong.
**It is not. The instrument was** — but 🔴 **one of the two arguments I gave for
that is withdrawn (2026-08-29).**
* 🔴 **WITHDRAWN — "the presentation rate is not depressed in this run".** This
said the plate **pulse period** acts as an internal clock and measured
**2.369 s** against the corpus's ≈2.3 s. Re-examined, that estimate rests on
**one interval between two distinct troughs**, at a 125 ms sample interval, so
its uncertainty is **±0.177 s (±6.7 %)** — and trough-picking on a noisy
plateau is fragile enough that re-running it gave **2.628 s** rather than
2.369, because an adjacent local minimum had been taken as a separate trough.
Against the corpus's ≈2.24 s that is **+17.3 %**, about 2σ. So the pulse
period does **not** show the run running at normal speed; it is simply too
weak to show anything, and **it cannot resolve a real-time factor below ~7 %
at all**. It should never have carried the argument.
***The conclusion survives on the other leg, which is the sound one.**
Re-measured from content rather than from the probe's mark, steady title art
(`243.595`) → plate on (`245.842`) is **2.247 s**, and that agrees with the
corpus's three independent readings — 2.13 / 2.132 / 2.138 — to within the
±0.125 s sample interval. Both of its landmarks are sharp content transitions
(a glyph plateau, and a glyph crossing), unlike a trough on a noisy plateau.
A 17 % slowdown would have put this at 2.49 s; it did not.
⚠️ **What that leaves open, and the port authors from these numbers:** this run
carries an unmeasured real-time factor somewhere under ~7 %, because nothing in
it was precise enough to pin one. The plate delay is anchored by agreement with
three prior runs; **the menu build-in (0.531 s) and Ⓑ→title (0.482 s) are not
anchored by anything**, and a few per cent of emulator slowdown sits inside them
undetected. They were already flagged as one-run figures; this is the second
reason to treat them as provisional.
✅ So the 2.13 s stands, the 120-unit reading stands, and the 3.203 s is
`title_static` firing during a crossfade. Recorded because a failed refutation is
worth as much as a successful one, and because the next person to read the
probe's `title_static` will otherwise repeat it.
## 🟢 And the load stall reproduces — third independent run, cold cache
[`title-plate-delay-measured.md`](title-plate-delay-measured.md) records a frozen
frame on the Ⓐ path in two runs: **14 frames (1.53 s)** and **12 frames
(1.39 s)**, both at surface mean **26.626**, and concludes any Ⓐ→menu figure from
this harness is an emulator load time rather than a game constant.
This run is a third: **13 frames, 251.601 253.135 = 1.53 s, surface mean
26.631**, labelled `title_plate` throughout.
✅ The claim survives, and is now stronger in a way the earlier runs could not
show: this boot had **no shader cache at all**, so the stall is not a warm-cache
artefact. ⚠️ One honest qualification — the earlier note says its two runs agreed
"to six decimals"; mine agrees only to three (26.631 vs 26.626), so the mean is
reproducible but not byte-identical across all three.
## Reach
* **One run.** The durations above are one cold boot. The two that are
cross-checked against independent evidence — the plate delay, against the
corpus's two runs and the disc's 120 units; the load stall, against two prior
runs — are the ones to lean on. The menu build-in (0.531 s) and Ⓑ→title
(0.482 s) rest on **this run alone**.
🟢 **The re-take was offered and declined, 2026-08-29**: the port authors
neither number, is already within ~0.1 s of both from the disc's own
keyframes, and asked that no emulator time be spent on its account. Authoring a
one-run measurement over a decoded value would gain nothing measurable. Left
provisional deliberately rather than for want of a run.
* **Sampling is 8 fps**, so every landmark carries ±0.125 s, and the guest's own
presentation rate cannot be measured from it — 8 fps is far below the ~28 fps
the game presents at, so every sample is a distinct guest frame and repeats
only appear when the guest itself stalls.
* The splash dwells are **not** re-measured here; they are already in
[`boot-order-and-splash-dwell.md`](boot-order-and-splash-dwell.md).
---
## 🟢 The consumer's own red flag was larger than this measurement supports
Recorded because it is the outcome of the measurement and it went the other way
from what the port expected.
The port's standing 🔴 read: *"`rest.t` is the wrong landmark, therefore
everything the sequencer paces off it is late."* **The premise is confirmed here
and the consequence is not.** Measuring the port the same way this page measures
the game — visible span, per-frame greyscale mean — its publisher wordmark runs
4.25 s against three cold boots at 4.297 / 4.604 / 4.370, and its developer logos
3.50 s against 3.508 / 3.503 / 3.366.
⚠️ **The discrepancy it was about to chase was the plate-delay trap in a second
place.** It had been comparing *arrival-to-arrival* transition timestamps against
*visible spans*; those differ by the exit ramp plus the black hold, about 0.6 s,
which was the whole of it — the same shape as timing the title from where it
stops animating rather than from where it first appears.
✅ So the generalisation this page supports is narrower than "the sequencer is
late": **`rest.t` is the wrong landmark for the title specifically**, where it
overstates by 4.183 s against ~2 s. Whether any *other* screen is mis-paced does
not follow from it and was not measured here.

View File

@@ -1,3 +1,62 @@
# ✅ WITHDRAWN 2026-08-29 (later the same day) — the interactive title IS reachable here, twice, with no pad input
**This banner supersedes everything below it about the title being unreachable,
and it supersedes the 🔴 "Emulator-side questions are blocked" section of
[MISSION](../port/MISSION.md).** Everything below is kept because the harness
defects it diagnoses were real and the fixes are in use; what it concluded about
the *game* is now refuted by measurement.
**Two consecutive boots reached the interactive title, with the `PRESS Ⓐ BUTTON`
plate, without a single pad press before it:**
| | run 1 | run 2 |
|---|---|---|
| plate on screen at | **205.4 s** into the probe | **218.4 s** |
| pad input before that | **none** | **none** |
| Ⓐ then reached the main menu | ✅ | ✅ |
| Ⓑ then returned to the title | ✅ | ✅ |
Full per-frame traces, 8 fps, 1783 and 1886 frames:
[`data/plate-timing-run1.tsv`](data/plate-timing-run1.tsv) ·
[`data/plate-timing-run2.tsv`](data/plate-timing-run2.tsv). The measurement they
were taken for is [`title-plate-delay-measured.md`](title-plate-delay-measured.md).
So the standing negative — "three runs, two locales, two launch paths, ~35
minutes of emulator time, no interactive title" — does not hold in this
container today. **The attract loop is simply passed through in ~3.5 minutes and
the title follows.**
## ❔ What changed is NOT established, and I am not going to guess it
What is different about this container, stated as facts rather than as a cause:
* it came up with **no Xenia storage root at all** — no
`~/.local/share/Xenia`, so no profile, no `xconfig.settings`, and no shader
cache. The earlier runs signed in a profile that already existed.
* run 1 therefore had to create one, with canary's own
`--create_profile_if_none=Decoder`. Run 2 signed in the profile run 1 made
(`B13EBABEBABEBABE`).
* the launch was otherwise `boot_menu.sh`'s, minus `skip_intro.sh` — this
measurement had to leave the title untouched, so nothing tapped Ⓐ at all.
⚠️ **A cold profile is a correlation across two runs, not a cause.** It is
written down so the next session can test it directly (delete the storage root,
boot, compare) instead of re-deriving that the title is reachable.
## 🔵 What this unblocks
* the **Japanese-locale capture** that MISSION parks as "🟡 needs one more run":
the mechanism (`set_console_language.py ja`, `user.language` at file offset
`0x912`) is in place, and the reason it was parked — *the title never
appears* — is gone. ⚠️ Note the storage root is new, so `xconfig.settings` has
been recreated and the byte offset should be re-located by its three landmarks
rather than assumed.
* the two items MISSION lists as emulator-blocked: the gamma control behind
[tone curve](structures/ui-render-tone-curve.md), and separating `8AX` from
`ptbase` in [8AX](structures/ui-8ax-fullres-background.md).
---
# 🔴 Why the boot harness stopped reaching the title — `screenshot` costs 10.8 s
**Status:****diagnosed, with a control.** Four consecutive runs on
@@ -362,3 +421,173 @@ The gamma run's flags plainly took effect — that run is where
`VdGetCurrentDisplayGamma` was captured — while its dump showed the file's
values. So the dump reflects the config file and cannot confirm or refute a
command-line override.
---
# ✅ 2026-08-29 (later) — the disc is back, and the section below is withdrawn as CURRENT status
Kept for its history, not as a live claim. The container was replaced: PID 1
here started at **11:07:38 UTC**, 25 minutes after commit `b9aca6a` wrote the
section below at 10:42, and the replacement has the disc mounted.
| check | result |
|---|---|
| `/proc/mounts` | `/dev/sda2 /disc ext4 ro,relatime` — a real bind mount |
| device | `/disc` is device **2050**; `/` is device **92** |
| size | 6.2 GB, 74 entries under `dat/`, `default.xex` = 3 497 984 B |
| ISO | `/iso/game.iso`, 7 835 492 352 B |
| end to end | `sylpheed-cli screen list /disc/dat/GP_TITLE.pak` → 12 builds, element/sprite counts matching the committed build map |
⚠️ **Two instruments would have said "no disc" either way, and both are still
in place.** This is the reusable lesson, and it is worth more than the
resolved incident:
* **`find / -xdev` cannot see `/disc`.** `-xdev` refuses to cross a filesystem
boundary; `/disc` is on a different device from `/`. The withdrawn section's
headline measurement — "no ISO, no `default.xex`, no `GP_TITLE.pak` anywhere"
— is what that command returns **whether or not the disc is mounted**. It had
no reach over the question it was used to answer.
* **`sylph-doctor` never checks `$SYLPHEED_DISC`.** Its two disc lines are
`find /work -maxdepth 2 -iname '*.iso'` and `[ -d /work/sylph_extract/dat ]`
(lines 7982). With the disc at `/disc` it reports "no ISO under /work" and
"no extracted disc — Reborn disc tests will SKIP" — as it does right now,
against a working disc. "`sylph-doctor` agrees" was two instruments sharing
one blind spot, not corroboration.
**To check for the disc, ask the variable that names it**: `ls "$SYLPHEED_DISC/dat"`,
or `sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`, which fails
loudly and cheaply.
# 🔴 2026-08-29 — the disc is not in the decoder container at all *(WITHDRAWN — see the section immediately above)*
**Status:****diagnosed, root-caused in the launcher.** This supersedes every
"the emulator did not reach the title" entry above as the *current* reason the
oracle is unavailable: there is no game to run.
## The measurement
| looked for | result |
|---|---|
| `find / -xdev -iname '*.iso'` | **0** |
| `find / -xdev -iname 'default.xex'` | **0** |
| `find / -xdev -iname 'GP_TITLE.pak'` | **0** |
| `$SYLPHEED_DISC` | **empty** |
| `/work/sylph_extract` | does not exist |
| `/exchange/files` | **empty** |
`sylph-doctor` agrees and says so in its own words:
```
── project ──
✖ /work/xenia-canary not mounted
✖ /work/Syplheed-Reborn not mounted
! no ISO under /work — run-canary needs SYLPH_ISO
! no extracted disc — Reborn disc tests will SKIP
```
Everything else is healthy: `xenia_canary` is built and present, display `:98`
is up, `screenshot` works, Vulkan (llvmpipe) enumerates, cargo and the python
stack are fine. **The emulator has no disc to boot.**
## The cause — the volume migration, and a mount nobody replaced
Before [`06676d3`](#) the launcher bind-mounted the human's working tree:
```
-v "$PROJECT:$PROJECT"
-v "$PROJECT:/work"
```
The ISO and `sylph_extract/` live in that tree, so the disc arrived **incidentally
with the repository mount**, and `run-canary`'s `find "$PROJECT_DIR" -maxdepth 2
-iname '*.iso'` found it.
`06676d3` replaced that with the agent's own clone in a named volume —
```
-v "sylpheed-decoder-repo:/work"
```
— which is the right fix for the collision class it was written for, and it
removed the disc along with the working tree. **Nothing was added to replace
it.** The launcher still forwards
```
[ -n "${SYLPH_ISO:-}" ] && _out+=(-e "SYLPH_ISO=$SYLPH_ISO")
```
but that is an **environment variable with no bind mount behind it** — it names a
host path that does not exist inside the container, so it cannot help.
**The port container does not have this bug.** `docker/port/sylph-port` mounts
the disc explicitly:
```
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
```
So the one container that *owns* the disc and the oracle is the one container
without them.
## Reach of the negative
Whole-filesystem, single pass, `-xdev` per mount, three independent names (the
ISO, the executable, a pak the corpus names constantly). The exchange volume is
empty, so the disc is not arriving by `share` either. This is not "I looked in
the usual place".
## What it blocks — everything disc-side and everything dynamic
* the **oracle** — no boot, no capture, no `run-canary`;
* every `sylpheed-cli` invocation that names a pak — `screen list`, `screen info`,
`screen render`, `pak textures`;
* `build-reborn test` — the disc-gated tests self-skip, and per MISSION a green
run then means almost nothing. (`build-reborn` is *also* pointing at
`/work/Syplheed-Reborn`, a path the monorepo no longer has.)
* **static RE of the executable** — the XEX is on the disc, so the whole
PPC-disassembly route is shut too, not just the dynamic one.
## What it does not block
The committed corpus. `docs/re/captures/` is 99 MB of oracle frames and
`docs/re/data/` 2.5 MB of extracted tables, both in git — enough to re-measure
against captures, which is what this iteration did instead.
## 🔵 For the human — the one-line fix
Add a disc mount to `docker/decoder/sylph-decoder`, the way `sylph-port` already
has one:
```bash
[ -d "$DISC" ] && _out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
[ -f "$SYLPH_ISO" ] && _out+=(-v "$SYLPH_ISO:/disc.iso:ro" -e "SYLPH_ISO=/disc.iso")
```
Recorded rather than worked around, per *do not improvise around a blocker*
and **not attempted**, because the launcher runs on the host and this container
cannot restart itself.
⚠️ `sylph-doctor` reports the missing ISO as `!` (a warning) rather than `✖`. For
the decoder that is not a warning: it is the difference between having an oracle
and not having one.
### A second, smaller consequence of the same migration — no git identity
`git commit` in a fresh decoder container fails with *"Author identity
unknown"*: nothing in the image, the entrypoint or `sylph-decoder` sets
`user.name` / `user.email`, and the old bind mount used to bring the human's
`.git/config` along with the tree.
Set locally, per iteration if the volume is recreated:
```bash
git config --local user.name "sylph-decoder"
git config --local user.email "fabian@diekaulbachs.de"
```
⚠️ `push-work`'s header warns at length against `git config --local`, because
the credential helper it wrote there leaked a container-only path onto the host.
**That warning no longer applies to identity**: `/work` is a private named
volume now, not a shared bind mount, so nothing written to its `.git/config`
can reach a host checkout. The credential helper is still applied per-invocation
with `-c`, and should stay that way.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
# Extract from the Ⓐ-press fault run's xenia.log (2026-08-29)
#
# Source: /sylph-home/re/canary-build/bin/Linux/Release/xenia.log, 326 921 343 bytes,
# mtime 2026-08-29 22:15. 32 356 '==== CRASH DUMP ====' blocks. This file is the
# part that carries the diagnosis; the whole log is not committed (326 MB).
#
# ---- 1. the three real Ⓐ keystrokes, then the swallow begins (log lines 1180-1262)
i> 0100000C [UI-CAP] writing xenia_re_ui_draws_01.log (from frame 0)
!> 0001278F MEM-WATCH rss=590MB (peak 590MB) vsz=19185MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=590MB (peak 590MB) vsz=19189MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40
i> F8000008 [file-pad] #3 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
i> F8000008 [file-pad] keystroke vk=5800 down
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003)
i> F8000008 [file-pad] #4 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
i> F8000008 [file-pad] keystroke vk=5800 up
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003)
!> 0001278F MEM-WATCH rss=594MB (peak 594MB) vsz=19189MB malloc_inuse=314MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=595MB (peak 595MB) vsz=19189MB malloc_inuse=313MB mmap=90MB cache_deque=40 cache_list=40
i> F8000008 XThreadF80000B8 (1A) Stack: 70880000-70900000
K> F80000B8 XThread::Execute thid 26 (handle=F80000B8, 'XThread76FFE6C0 (F80000B8)', native=76FFE6C0)
F> F80000B8 HostPathDevice::ResolvePath(\aab216c3\5\c10eae6)
F> F80000B8 HostPathDevice::ResolvePath(\aab216c3\5)
F> F8000084 DiscImageDevice::ResolvePath(\dat)
F> F8000008 DiscImageDevice::ResolvePath(\dat\movie)
F> F8000008 DiscImageDevice::ResolvePath(\dat\movie)
i> F8000008 XThreadF8000154 (1B) Stack: 70880000-70890000
i> F8000008 XThreadF8000158 (1C) Stack: 708B0000-708C0000
i> F8000008 XThreadF800015C (1D) Stack: 708E0000-708F0000
i> F8000008 XThreadF8000160 (1E) Stack: 70910000-70920000
K> F8000158 XThread::Execute thid 28 (handle=F8000158, 'XThread6FFFF6C0 (F8000158)', native=6FFFF6C0)
K> F8000154 XThread::Execute thid 27 (handle=F8000154, 'XThread75FFD6C0 (F8000154)', native=75FFD6C0)
K> F800015C XThread::Execute thid 29 (handle=F800015C, 'XThread6EFFE6C0 (F800015C)', native=6EFFE6C0)
!> 0001278F MEM-WATCH rss=660MB (peak 660MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=661MB (peak 661MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=661MB (peak 661MB) vsz=19473MB malloc_inuse=324MB mmap=90MB cache_deque=40 cache_list=40
i> F8000008 [file-pad] #5 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
i> F8000008 [file-pad] keystroke vk=5800 down
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003)
i> F8000008 [file-pad] #6 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
i> F8000008 [file-pad] keystroke vk=5800 up
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003)
w> F8000008 XThread::Resume: host resume was refused for thread F8000154
w> F8000008 XThread::Resume: host resume was refused for thread F8000158
w> F8000008 XThread::Resume: host resume was refused for thread F800015C
K> F8000160 XThread::Execute thid 30 (handle=F8000160, 'XThread6DFFD6C0 (F8000160)', native=6DFFD6C0)
w> F8000008 XThread::Resume: host resume was refused for thread F8000154
w> F8000008 XThread::Resume: host resume was refused for thread F8000158
w> F8000008 XThread::Resume: host resume was refused for thread F800015C
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
w> F8000008 XThread::Resume: host resume was refused for thread F8000160
!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1E4B0E00 heap_base=00000000 page=124080 owning_region_start=1C220000 region_page_count=14976 state=03
!> F8000008 PhysicalHeap::Release failed due to parent heap failure
!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1E7A8F00 heap_base=00000000 page=124840 owning_region_start=1C220000 region_page_count=14976 state=03
!> F8000008 PhysicalHeap::Release failed due to parent heap failure
F> F8000084 DiscImageDevice::ResolvePath(\dat)
!> 0001278F MEM-WATCH rss=830MB (peak 830MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=831MB (peak 831MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=832MB (peak 832MB) vsz=19501MB malloc_inuse=348MB mmap=90MB cache_deque=40 cache_list=40
!> 0001278F MEM-WATCH rss=832MB (peak 832MB) vsz=19501MB malloc_inuse=348MB mmap=90MB cache_deque=40 cache_list=40
i> F8000008 [file-pad] #7 buttons=1000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
i> F8000008 [file-pad] keystroke vk=5800 down
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0001 (call flags 00000003)
i> F8000008 XThreadF80000D4 (1F) Stack: 70880000-70900000
K> F80000D4 XThread::Execute thid 31 (handle=F80000D4, 'XThread9BFFF6C0 (F80000D4)', native=9BFFF6C0)
F> F80000D4 HostPathDevice::ResolvePath(\aab216c3\a\c7e701e)
F> F80000D4 HostPathDevice::ResolvePath(\aab216c3\a)
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\e\b80b1a0)
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\e)
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\c\6dea48b)
F> F80000D4 HostPathDevice::ResolvePath(\d5faa9db\c)
i> F8000008 [file-pad] #8 buttons=0000 lt=0 rt=0 lx=0 ly=0 rx=0 ry=0
i> F8000008 [file-pad] keystroke vk=5800 up
i> F8000008 [RE-INPUT] XamInputGetKeystrokeEx -> user=0 vk=5800 flags=0002 (call flags 00000003)
!> 0001278F MEM-WATCH rss=833MB (peak 833MB) vsz=19501MB malloc_inuse=349MB mmap=90MB cache_deque=40 cache_list=41
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1 so far)
!> F8000008 BaseHeap::Release failed because address is not a region start: addr=1DA98C80 heap_base=00000000 page=121496 owning_region_start=1C220000 region_page_count=14976 state=03
!> F8000008 PhysicalHeap::Release failed due to parent heap failure
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 601 so far)
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1201 so far)
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 1801 so far)
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 2401 so far)
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 3001 so far)
w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 3601 so far)
# ---- 2. the last swallow report before the first crash dump (log line <15243)
15236:w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 8388001 so far)
15237:w> F8000008 [RE-INPUT] XamInputGetKeystrokeEx swallowed by IsUIActive (ui_active=true, 8388601 so far)
# total 'swallowed' report lines before the first crash dump:
13982
# they are emitted every 600th call, so swallowed calls ~= 600 x that count
# ---- 3. the first crash dump's GPRs (log line 15243+)
!> F8000008 ==== CRASH DUMP ====
Thread ID (Host: 0xEEFFE6C0 / Guest: 0x00000006)
Thread Handle: 0xF8000008
PC: 0x824578A0
Access Violation: write at 0x00000001701D0000
Registers:
r0 = 0000000000000000
r1 = 00000000701CF7B0
r2 = 0000000020000000
r3 = 00000000701CF5F0
r4 = 0000000000000000
r5 = 0000000000000000
r6 = 0000000000000000
r7 = 00000000A3AC0000
r8 = 00000000701D0008
r9 = 00000000701D0000
r10 = 0000000000000000
r11 = 00000000A3AC0A18
r12 = 0000000082457864
r13 = 000000003001E000
r14 = 0000000000000000
r15 = 0000000000000000
r16 = 0000000000000000
r17 = 0000000000000000
r18 = 00000000BCE24BFC
r19 = 0000000000000001
r20 = 0000000000000000
r21 = FFFFFFFFFFFFFFFF
r22 = 00000000BC65D540
r23 = FFFFFFFF828F3844
r24 = 000000000000052F
r25 = FFFFFFFF828E0000
r26 = 0000000000800001
r27 = 0000000001000000
r28 = 00000000701CF898
r29 = 0000000000800000
r30 = FFFFFFFF828F38CC
r31 = 00000000A7AC0000

View File

@@ -0,0 +1,46 @@
# Which ADV voice stream sits where -- the assignment, and how it was settled.
#
# 2026-08-30. Chunks dumped by examples/adv_voice_dump.rs from the resolved
# movie voice region (dat/sound 433930240..437044592), decoded with ffmpeg's
# xma decoder to f32le 48 kHz stereo.
#
# chunk bytes byte_size probe ctx dur L rms R rms L/R r R silent
# 0 806972 806912 ctx0 TAIL 84.55s -24.79 -24.81 +0.932 53.1%
# 1 1118268 1118208 ctx1 137.32s -20.33 -inf +0.000 100.0%
# 2 1171516 1171456 ctx2 137.32s -30.67 -30.68 +0.962 53.6%
#
# ctx0's full byte_size is 1294336; the region resolver starts at the
# predecessor cue's trailer, so chunk 0 is its clipped tail (62 %).
#
# 🔴 WHAT DID NOT WORK: envelope correlation cannot discriminate.
# Every residual channel shares the dialogue's activity timing, so a
# per-pair lag search returns 0.86-0.95 for EVERY chunk against EVERY
# channel. Recorded because it looks like a strong result and is not.
#
# 🔴 Sample-level correlation also fails: the chunks do not start with the
# movie and the XMA decode's framing offset is unknown, so r ~ 0.
#
# ✅ WHAT SETTLES IT: level, under the SAME 0.600 gain the bed uses.
#
# chunk level x0.600 nearest residuals (|error| dB)
# 0L -24.79 -29.23 FL 0.05 FR 0.06 FC 3.96
# 0R -24.81 -29.25 FL 0.03 FR 0.04 FC 3.98
# 1L -20.33 -24.77 FC 0.50 FL 4.51 FR 4.52
# 2L -30.67 -35.11 BL 0.35 BR 0.38 FR 5.82
# 2R -30.68 -35.12 BL 0.34 BR 0.37 FR 5.83
#
# Ratio test, immune to any worry about chunk 0 being clipped:
# chunk0L - chunk2L = +5.88 dB ; FL - BL = +6.18 dB -> agree to 0.30 dB
# swapped, the ratio would be wrong by 11.76 dB
#
# Structural confirmation: chunk 1 is the ONLY chunk with a digitally silent
# channel (R, 100 %), and LFE is the ONLY output channel with an empty
# residual (-115.73 dBFS). One-to-one.
#
# Internal L/R correlation also tracks:
# chunk0 +0.932 <-> FL/FR residual +0.918
# chunk2 +0.962 <-> BL/BR residual +0.929
#
# ==> ctx0 (1294336) -> FL, FR
# ==> ctx1 (1118208) -> FC, LFE silent
# ==> ctx2 (1171456) -> BL, BR

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
# Every sprite quad submitted during the boot splashes, from the guest's
# own draw stream. Capture: ui_draw_capture.sh GRACE=1 NOTAP=1 ARM=early,
# 2026-08-29. Full-screen quads (w>=1250) are omitted.
# frames 126..129 carry NO sprite quad at all -- that is the black gap.
frame,x,y,w,h,vertex_alpha
2,301,317,685,90,119
3,301,317,685,90,153
4,301,317,685,90,187
6,301,317,685,90,246
7,301,317,685,90,240
9,301,317,685,90,229
10,301,317,685,90,223
11,301,317,685,90,220
12,301,317,685,90,214
13,301,317,685,90,211
14,301,317,685,90,183
15,301,317,685,90,155
16,301,317,685,90,127
17,301,317,685,90,98
18,301,317,685,90,70
19,301,317,685,90,56
20,301,317,685,90,28
21,307,331,666,65,255
22,307,331,666,65,255
23,307,331,666,65,255
24,307,331,666,65,255
25,307,331,666,65,255
26,307,331,666,65,255
27,307,331,666,65,255
28,307,331,666,65,255
29,307,331,666,65,255
30,307,331,666,65,255
31,307,331,666,65,255
32,307,331,666,65,255
34,307,331,666,65,255
35,307,331,666,65,255
37,307,331,666,65,255
38,307,331,666,65,255
39,307,331,666,65,255
40,307,331,666,65,255
41,307,331,666,65,255
42,307,331,666,65,255
43,307,331,666,65,255
44,307,331,666,65,255
45,307,331,666,65,255
46,307,331,666,65,255
47,307,331,666,65,255
48,307,331,666,65,255
49,307,331,666,65,255
50,307,331,666,65,255
51,307,331,666,65,255
52,307,331,666,65,255
53,307,331,666,65,255
54,307,331,666,65,255
55,307,331,666,65,255
56,307,331,666,65,255
57,307,331,666,65,255
58,307,331,666,65,255
59,307,331,666,65,255
60,307,331,666,65,255
61,307,331,666,65,255
62,307,331,666,65,255
63,307,331,666,65,255
64,307,331,666,65,255
65,307,331,666,65,255
66,307,331,666,65,255
67,307,331,666,65,255
68,307,331,666,65,255
69,307,331,666,65,255
70,307,331,666,65,255
71,307,331,666,65,255
72,307,331,666,65,255
73,307,331,666,65,255
74,307,331,666,65,255
75,307,331,666,65,255
76,307,331,666,65,255
77,307,331,666,65,255
78,307,331,666,65,255
79,307,331,666,65,255
80,307,331,666,65,255
81,307,331,666,65,255
82,307,331,666,65,255
83,307,331,666,65,255
84,307,331,666,65,255
85,307,331,666,65,255
86,307,331,666,65,255
87,307,331,666,65,255
88,307,331,666,65,255
89,307,331,666,65,255
90,307,331,666,65,255
91,307,331,666,65,255
92,307,331,666,65,255
93,307,331,666,65,255
94,307,331,666,65,255
95,307,331,666,65,255
96,307,331,666,65,255
97,307,331,666,65,255
98,307,331,666,65,255
99,307,331,666,65,255
100,307,331,666,65,255
102,307,331,666,65,255
103,307,331,666,65,255
105,307,331,666,65,255
106,307,331,666,65,255
107,307,331,666,65,255
108,307,331,666,65,255
109,307,331,666,65,255
110,307,331,666,65,255
111,307,331,666,65,255
112,307,331,666,65,255
113,307,331,666,65,249
114,307,331,666,65,243
115,307,331,666,65,231
116,307,331,666,65,198
117,307,331,666,65,181
118,307,331,666,65,165
119,307,331,666,65,148
120,307,331,666,65,115
121,307,331,666,65,81
122,307,331,666,65,65
123,307,331,666,65,31
124,307,331,666,65,15
125,307,331,666,65,7
130,378,155,525,259,34
131,378,155,525,259,51
132,378,155,525,259,85
133,378,155,525,259,119
134,378,155,525,259,136
135,378,155,525,259,153
136,378,155,525,259,170
137,378,155,525,259,221
138,378,155,525,259,255
139,378,155,525,259,255
140,378,155,525,259,255
141,378,155,525,259,255
142,378,155,525,259,255
143,378,155,525,259,255
144,378,155,525,259,255
145,378,155,525,259,255
146,378,155,525,259,254
147,378,155,525,259,220
148,378,155,525,259,186
149,378,155,525,259,152
151,378,155,525,259,84
152,378,155,525,259,50
153,378,155,525,259,33
154,390,162,499,241,255
155,390,162,499,241,255
156,390,162,499,241,255
157,390,162,499,241,255
158,390,162,499,241,255
159,390,162,499,241,255
160,390,162,499,241,255
161,390,162,499,241,255
162,390,162,499,241,255
163,390,162,499,241,255
164,390,162,499,241,255
165,390,162,499,241,255
166,390,162,499,241,255
167,390,162,499,241,255
168,390,162,499,241,255
169,390,162,499,241,255
170,390,162,499,241,255
171,390,162,499,241,255
172,390,162,499,241,255
173,390,162,499,241,255
174,390,162,499,241,255
176,390,162,499,241,255
177,390,162,499,241,255
179,390,162,499,241,255
180,390,162,499,241,255
181,390,162,499,241,255
183,390,162,499,241,255
184,390,162,499,241,255
185,390,162,499,241,255
186,390,162,499,241,255
187,390,162,499,241,255
188,390,162,499,241,255
189,390,162,499,241,255
190,390,162,499,241,255
191,390,162,499,241,255
192,390,162,499,241,255
193,390,162,499,241,255
194,390,162,499,241,255
195,390,162,499,241,255
196,390,162,499,241,255
197,390,162,499,241,255
198,390,162,499,241,255
199,390,162,499,241,255
200,390,162,499,241,255
201,390,162,499,241,255
202,390,162,499,241,255
203,390,162,499,241,255
204,390,162,499,241,255
205,390,162,499,241,255
206,390,162,499,241,255
207,390,162,499,241,255
208,390,162,499,241,255
209,390,162,499,241,255
210,390,162,499,241,255
211,390,162,499,241,255
212,390,162,499,241,255
213,390,162,499,241,255
214,390,162,499,241,255
215,390,162,499,241,255
216,390,162,499,241,255
217,390,162,499,241,255
218,390,162,499,241,255
219,390,162,499,241,255
221,390,162,499,241,255
223,390,162,499,241,255
224,390,162,499,241,255
225,390,162,499,241,255
226,390,162,499,241,255
227,390,162,499,241,255
228,390,162,499,241,255
229,390,162,499,241,255
230,390,162,499,241,255
231,390,162,499,241,255
232,390,162,499,241,255
233,390,162,499,241,255
234,390,162,499,241,255
235,390,162,499,241,255
236,390,162,499,241,254
237,390,162,499,241,243
238,390,162,499,241,231
239,390,162,499,241,198
240,390,162,499,241,165
241,390,162,499,241,131
242,390,162,499,241,115
243,390,162,499,241,81
244,390,162,499,241,48
245,390,162,499,241,23
246,390,162,499,241,7
1 # Every sprite quad submitted during the boot splashes, from the guest's
2 # own draw stream. Capture: ui_draw_capture.sh GRACE=1 NOTAP=1 ARM=early,
3 # 2026-08-29. Full-screen quads (w>=1250) are omitted.
4 # frames 126..129 carry NO sprite quad at all -- that is the black gap.
5 frame,x,y,w,h,vertex_alpha
6 2,301,317,685,90,119
7 3,301,317,685,90,153
8 4,301,317,685,90,187
9 6,301,317,685,90,246
10 7,301,317,685,90,240
11 9,301,317,685,90,229
12 10,301,317,685,90,223
13 11,301,317,685,90,220
14 12,301,317,685,90,214
15 13,301,317,685,90,211
16 14,301,317,685,90,183
17 15,301,317,685,90,155
18 16,301,317,685,90,127
19 17,301,317,685,90,98
20 18,301,317,685,90,70
21 19,301,317,685,90,56
22 20,301,317,685,90,28
23 21,307,331,666,65,255
24 22,307,331,666,65,255
25 23,307,331,666,65,255
26 24,307,331,666,65,255
27 25,307,331,666,65,255
28 26,307,331,666,65,255
29 27,307,331,666,65,255
30 28,307,331,666,65,255
31 29,307,331,666,65,255
32 30,307,331,666,65,255
33 31,307,331,666,65,255
34 32,307,331,666,65,255
35 34,307,331,666,65,255
36 35,307,331,666,65,255
37 37,307,331,666,65,255
38 38,307,331,666,65,255
39 39,307,331,666,65,255
40 40,307,331,666,65,255
41 41,307,331,666,65,255
42 42,307,331,666,65,255
43 43,307,331,666,65,255
44 44,307,331,666,65,255
45 45,307,331,666,65,255
46 46,307,331,666,65,255
47 47,307,331,666,65,255
48 48,307,331,666,65,255
49 49,307,331,666,65,255
50 50,307,331,666,65,255
51 51,307,331,666,65,255
52 52,307,331,666,65,255
53 53,307,331,666,65,255
54 54,307,331,666,65,255
55 55,307,331,666,65,255
56 56,307,331,666,65,255
57 57,307,331,666,65,255
58 58,307,331,666,65,255
59 59,307,331,666,65,255
60 60,307,331,666,65,255
61 61,307,331,666,65,255
62 62,307,331,666,65,255
63 63,307,331,666,65,255
64 64,307,331,666,65,255
65 65,307,331,666,65,255
66 66,307,331,666,65,255
67 67,307,331,666,65,255
68 68,307,331,666,65,255
69 69,307,331,666,65,255
70 70,307,331,666,65,255
71 71,307,331,666,65,255
72 72,307,331,666,65,255
73 73,307,331,666,65,255
74 74,307,331,666,65,255
75 75,307,331,666,65,255
76 76,307,331,666,65,255
77 77,307,331,666,65,255
78 78,307,331,666,65,255
79 79,307,331,666,65,255
80 80,307,331,666,65,255
81 81,307,331,666,65,255
82 82,307,331,666,65,255
83 83,307,331,666,65,255
84 84,307,331,666,65,255
85 85,307,331,666,65,255
86 86,307,331,666,65,255
87 87,307,331,666,65,255
88 88,307,331,666,65,255
89 89,307,331,666,65,255
90 90,307,331,666,65,255
91 91,307,331,666,65,255
92 92,307,331,666,65,255
93 93,307,331,666,65,255
94 94,307,331,666,65,255
95 95,307,331,666,65,255
96 96,307,331,666,65,255
97 97,307,331,666,65,255
98 98,307,331,666,65,255
99 99,307,331,666,65,255
100 100,307,331,666,65,255
101 102,307,331,666,65,255
102 103,307,331,666,65,255
103 105,307,331,666,65,255
104 106,307,331,666,65,255
105 107,307,331,666,65,255
106 108,307,331,666,65,255
107 109,307,331,666,65,255
108 110,307,331,666,65,255
109 111,307,331,666,65,255
110 112,307,331,666,65,255
111 113,307,331,666,65,249
112 114,307,331,666,65,243
113 115,307,331,666,65,231
114 116,307,331,666,65,198
115 117,307,331,666,65,181
116 118,307,331,666,65,165
117 119,307,331,666,65,148
118 120,307,331,666,65,115
119 121,307,331,666,65,81
120 122,307,331,666,65,65
121 123,307,331,666,65,31
122 124,307,331,666,65,15
123 125,307,331,666,65,7
124 130,378,155,525,259,34
125 131,378,155,525,259,51
126 132,378,155,525,259,85
127 133,378,155,525,259,119
128 134,378,155,525,259,136
129 135,378,155,525,259,153
130 136,378,155,525,259,170
131 137,378,155,525,259,221
132 138,378,155,525,259,255
133 139,378,155,525,259,255
134 140,378,155,525,259,255
135 141,378,155,525,259,255
136 142,378,155,525,259,255
137 143,378,155,525,259,255
138 144,378,155,525,259,255
139 145,378,155,525,259,255
140 146,378,155,525,259,254
141 147,378,155,525,259,220
142 148,378,155,525,259,186
143 149,378,155,525,259,152
144 151,378,155,525,259,84
145 152,378,155,525,259,50
146 153,378,155,525,259,33
147 154,390,162,499,241,255
148 155,390,162,499,241,255
149 156,390,162,499,241,255
150 157,390,162,499,241,255
151 158,390,162,499,241,255
152 159,390,162,499,241,255
153 160,390,162,499,241,255
154 161,390,162,499,241,255
155 162,390,162,499,241,255
156 163,390,162,499,241,255
157 164,390,162,499,241,255
158 165,390,162,499,241,255
159 166,390,162,499,241,255
160 167,390,162,499,241,255
161 168,390,162,499,241,255
162 169,390,162,499,241,255
163 170,390,162,499,241,255
164 171,390,162,499,241,255
165 172,390,162,499,241,255
166 173,390,162,499,241,255
167 174,390,162,499,241,255
168 176,390,162,499,241,255
169 177,390,162,499,241,255
170 179,390,162,499,241,255
171 180,390,162,499,241,255
172 181,390,162,499,241,255
173 183,390,162,499,241,255
174 184,390,162,499,241,255
175 185,390,162,499,241,255
176 186,390,162,499,241,255
177 187,390,162,499,241,255
178 188,390,162,499,241,255
179 189,390,162,499,241,255
180 190,390,162,499,241,255
181 191,390,162,499,241,255
182 192,390,162,499,241,255
183 193,390,162,499,241,255
184 194,390,162,499,241,255
185 195,390,162,499,241,255
186 196,390,162,499,241,255
187 197,390,162,499,241,255
188 198,390,162,499,241,255
189 199,390,162,499,241,255
190 200,390,162,499,241,255
191 201,390,162,499,241,255
192 202,390,162,499,241,255
193 203,390,162,499,241,255
194 204,390,162,499,241,255
195 205,390,162,499,241,255
196 206,390,162,499,241,255
197 207,390,162,499,241,255
198 208,390,162,499,241,255
199 209,390,162,499,241,255
200 210,390,162,499,241,255
201 211,390,162,499,241,255
202 212,390,162,499,241,255
203 213,390,162,499,241,255
204 214,390,162,499,241,255
205 215,390,162,499,241,255
206 216,390,162,499,241,255
207 217,390,162,499,241,255
208 218,390,162,499,241,255
209 219,390,162,499,241,255
210 221,390,162,499,241,255
211 223,390,162,499,241,255
212 224,390,162,499,241,255
213 225,390,162,499,241,255
214 226,390,162,499,241,255
215 227,390,162,499,241,255
216 228,390,162,499,241,255
217 229,390,162,499,241,255
218 230,390,162,499,241,255
219 231,390,162,499,241,255
220 232,390,162,499,241,255
221 233,390,162,499,241,255
222 234,390,162,499,241,255
223 235,390,162,499,241,255
224 236,390,162,499,241,254
225 237,390,162,499,241,243
226 238,390,162,499,241,231
227 239,390,162,499,241,198
228 240,390,162,499,241,165
229 241,390,162,499,241,131
230 242,390,162,499,241,115
231 243,390,162,499,241,81
232 244,390,162,499,241,48
233 245,390,162,499,241,23
234 246,390,162,499,241,7

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
focus records disc-wide : 1130
their timed elements : 2664
with a VARYING alpha : 210
of which rest() == the PEAK : 202 <- burns bright forever
of which rest() is MID-RAMP : 8 <- neither extreme; looks plausible
by pak:
GP_DEBRIEFING_PILOTLOG.pak 116
GP_HANGAR_ARSENAL.pak 30
GP_LEADERBOARD.pak 8
GP_MOVIE_THEATER.pak 54
GP_TITLE.pak 2
every varying one:
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [10] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [11] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [136] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [166] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [22] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [24] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [24] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [3] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn2f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [43] pl_main_btn3f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [53] pl_main_btn0f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [53] pl_main_btn1f.rat::pl_main_eff06.t32 alpha 0..128 rest()=128 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn01f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn02f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn03f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn04f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn05f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn06f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn07f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn08f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn09f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn10f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn11f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn12f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn13f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn14f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn15f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn16f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn17f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn18f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn19f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn20f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn21f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn22f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn23f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_DEBRIEFING_PILOTLOG.pak [5] pp_order_btn24f.rat::pp_order_btn_f.t32 alpha 0..212 rest()=212 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [129] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [129] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [129] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [129] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [167] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [167] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [167] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [167] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [239] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [239] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [239] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [239] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [24] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [253] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [253] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [253] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [253] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [31] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [42] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [43] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [455] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [455] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [455] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [455] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [458] psbtn2f.rat::pspylon_nose.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [458] psbtn3f.rat::pspylon_main2.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [458] psbtn4f.rat::pspylon_main3.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [458] psbtn5f.rat::pspylon_main1.t32 alpha 64..192 rest()=192 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [60] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_HANGAR_ARSENAL.pak [61] psselect_slotf.rat::psselect_slotf_eff1.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_LEADERBOARD.pak [11] py_ranking_btn01f.rat::py_ranking_btn01f.t32 alpha 127..255 rest()=244
GP_LEADERBOARD.pak [11] py_ranking_btn02f.rat::py_ranking_btn02f.t32 alpha 127..255 rest()=244
GP_LEADERBOARD.pak [11] py_ranking_btn03f.rat::py_ranking_btn03f.t32 alpha 127..255 rest()=244
GP_LEADERBOARD.pak [11] py_ranking_btn04f.rat::py_ranking_btn04f.t32 alpha 127..255 rest()=244
GP_LEADERBOARD.pak [6] py_ranking_btn01f.rat::py_ranking_btn01f.t32 alpha 127..255 rest()=244
GP_LEADERBOARD.pak [6] py_ranking_btn02f.rat::py_ranking_btn02f.t32 alpha 127..255 rest()=244
GP_LEADERBOARD.pak [6] py_ranking_btn03f.rat::py_ranking_btn03f.t32 alpha 127..255 rest()=244
GP_LEADERBOARD.pak [6] py_ranking_btn04f.rat::py_ranking_btn04f.t32 alpha 127..255 rest()=244
GP_MOVIE_THEATER.pak [10] px_movie_tn030f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [11] px_movie_tn130f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [12] px_movie_tn040f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [13] px_movie_tn022f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [14] px_movie_tn121f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [15] px_movie_tn131f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [16] px_movie_tn041f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [17] px_movie_tn140f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [18] px_movie_tn050f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [19] px_movie_tn122f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [20] px_movie_tn150f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [21] px_movie_tn060f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [22] px_movie_tn151f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [23] px_movie_tn061f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [24] px_movie_tn160f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [25] px_movie_tn070f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [26] px_movie_tn152f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [27] px_movie_tn071f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [28] px_movie_tn090f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [29] px_movie_tn000f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [2] px_movie_tn000f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [30] px_movie_tn100f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [31] px_movie_tn010f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [32] px_movie_tn110f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [33] px_movie_tn020f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [34] px_movie_tn111f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [35] px_movie_tn021f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [36] px_movie_tn120f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [37] px_movie_tn030f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [38] px_movie_tn130f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [39] px_movie_tn040f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [3] px_movie_tn100f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [40] px_movie_tn022f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [41] px_movie_tn121f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [42] px_movie_tn131f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [43] px_movie_tn041f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [44] px_movie_tn140f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [45] px_movie_tn050f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [46] px_movie_tn122f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [47] px_movie_tn150f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [48] px_movie_tn060f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [49] px_movie_tn151f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [4] px_movie_tn010f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [50] px_movie_tn061f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [51] px_movie_tn160f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [52] px_movie_tn070f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [53] px_movie_tn152f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [54] px_movie_tn071f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [55] px_movie_tn090f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [5] px_movie_tn110f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [6] px_movie_tn020f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [7] px_movie_tn111f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [8] px_movie_tn021f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_MOVIE_THEATER.pak [9] px_movie_tn120f.rat::px_movie_btnf.t32 alpha 64..255 rest()=255 🔴 == PEAK
GP_TITLE.pak [2] ptbtn00f.rat::ptbtn00f.t32 alpha 0..80 rest()=80 🔴 == PEAK
GP_TITLE.pak [3] ptbtn00f.rat::ptbtn00f.t32 alpha 0..80 rest()=80 🔴 == PEAK
(210 distinct)

Binary file not shown.

View File

@@ -0,0 +1,61 @@
# Reconciling two ink counts for GP_TITLE entries 12/15 that were never
# counting the same pixels.
#
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
# 2026-08-30, SYLPHEED_DISC=/disc, 1280x720, black backdrop.
#
# The port agent's Godot second witness for entry 12 (build_12, --pose=rest):
# >0 59 530 px >1 48 368 px without the rule: 0 at both
#
# This crate, same entry, primitives-on (the convention the cost run used):
# >0 49 771 px >1 48 043 px without the rule: 0 at every threshold
#
# CONCLUSION: the >1 counts agree to 0.68 % (325 px). The >0 counts differ by
# 16 %. So the disagreement lives entirely in pixels whose value is exactly 1 --
# a 1-LSB artefact of a different sampler, not a different set of inked pixels.
# >0 is NOT portable between these two renderers on a mostly-dark frame; >1 is.
#
# Note also: our reported 49 771 was never an ink 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.
#
== GP_TITLE entry 12 — primitives on (what the cost run used) (1280x720)
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
>0 | 49771 | 921600 | 0 | 921600
>1 | 48043 | 921600 | 0 | 921600
>2 | 44884 | 921600 | 0 | 921600
>4 | 41946 | 921600 | 0 | 921600
>8 | 38409 | 921600 | 0 | 921600
>16 | 32760 | 921600 | 0 | 921600
exact-RGBA changed pixels between the two orders: 49771
== GP_TITLE entry 12 — primitives+focus+animated (1280x720)
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
>0 | 54968 | 921600 | 0 | 921600
>1 | 52058 | 921600 | 0 | 921600
>2 | 48171 | 921600 | 0 | 921600
>4 | 44396 | 921600 | 0 | 921600
>8 | 40064 | 921600 | 0 | 921600
>16 | 33791 | 921600 | 0 | 921600
exact-RGBA changed pixels between the two orders: 54968
== GP_TITLE entry 15 — primitives on (what the cost run used) (1280x720)
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
>0 | 49771 | 921600 | 0 | 921600
>1 | 48043 | 921600 | 0 | 921600
>2 | 44884 | 921600 | 0 | 921600
>4 | 41946 | 921600 | 0 | 921600
>8 | 38409 | 921600 | 0 | 921600
>16 | 32760 | 921600 | 0 | 921600
exact-RGBA changed pixels between the two orders: 49771
== GP_TITLE entry 15 — primitives+focus+animated (1280x720)
threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT
>0 | 54968 | 921600 | 0 | 921600
>1 | 52058 | 921600 | 0 | 921600
>2 | 48171 | 921600 | 0 | 921600
>4 | 44396 | 921600 | 0 | 921600
>8 | 40064 | 921600 | 0 | 921600
>16 | 33791 | 921600 | 0 | 921600
exact-RGBA changed pixels between the two orders: 54968

View File

@@ -0,0 +1,100 @@
# What kind of key does each of the 80 forced instances have?
#
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_key_source
# 2026-08-30, SYLPHEED_DISC=/disc, every dat/*.pak.
#
# forced_backdrop_necessity.rs collapsed sprite_layer_key (a u16 READ from the
# T8aD header -- decoded) with implied_layer_key (this crate's per-name table of
# positions MEASURED in the running game). Raised by the port agent; splitting
# them gives a stronger result than either of us stated.
#
# read from the T8aD header: 0 <-- NOT ONE, anywhere on the disc
# implied (measured): 14 10x pfbase.tbm, 4x palogo_eff0.prm
# nothing at all: 66 62 the rule decides, 4 inert
#
# archive entry element key_source key
GP_BUNK.pak 0 px_bunk_base.tbm none -
GP_BUNK.pak 2 px_bunk_base.tbm none -
GP_BUNK.pak 4 pvbase.tbm none -
GP_BUNK.pak 6 pvbase.tbm none -
GP_DEBRIEFING_PILOTLOG.pak 118 px_deb_base.tbm none -
GP_DEBRIEFING_PILOTLOG.pak 130 px_deb_base.tbm none -
GP_DEBRIEFING_PILOTLOG.pak 131 pjbgbase2.tbm none -
GP_DEBRIEFING_PILOTLOG.pak 134 px_deb_base.tbm none -
GP_DEBRIEFING_PILOTLOG.pak 150 pjbgbase2.tbm none -
GP_DEBRIEFING_PILOTLOG.pak 165 px_deb_base.tbm none -
GP_DIALOG.pak 2 pcbase.tbm none -
GP_DIALOG.pak 3 pcbase.tbm none -
GP_DIALOG.pak 9 pzeff00.prm none -
GP_DIALOG.pak 10 pzeff00.prm none -
GP_DIALOG.pak 11 pzeff00.prm none -
GP_DIALOG.pak 12 pzeff00.prm none -
GP_DIALOG.pak 13 pzeff00.prm none -
GP_DIALOG.pak 14 pzeff00.prm none -
GP_DIALOG.pak 15 pzeff00.prm none -
GP_DIALOG.pak 16 pzeff00.prm none -
GP_DIALOG.pak 17 pzeff00.prm none -
GP_DIALOG.pak 18 pzeff00.prm none -
GP_DIALOG.pak 19 pzeff00.prm none -
GP_DIALOG.pak 20 pzeff00.prm none -
GP_DIALOG.pak 21 pzeff00.prm none -
GP_DIALOG.pak 22 pzeff00.prm none -
GP_DIALOG.pak 23 pzeff00.prm none -
GP_DIALOG.pak 24 pzeff00.prm none -
GP_DIALOG.pak 25 pzeff00.prm none -
GP_DIALOG.pak 26 pzeff00.prm none -
GP_DIALOG.pak 28 pzeff00.prm none -
GP_DIALOG.pak 29 pzeff00.prm none -
GP_DIALOG.pak 30 pzeff00.prm none -
GP_DIALOG.pak 31 pzeff00.prm none -
GP_DIALOG.pak 32 pzeff00.prm none -
GP_DIALOG.pak 33 pzeff00.prm none -
GP_DIALOG.pak 34 pzeff00.prm none -
GP_DIALOG.pak 35 pzeff00.prm none -
GP_DIALOG.pak 36 pzeff00.prm none -
GP_DIALOG.pak 37 pzeff00.prm none -
GP_DIALOG.pak 38 pzeff00.prm none -
GP_DIALOG.pak 39 pzeff00.prm none -
GP_DIALOG.pak 40 pzeff00.prm none -
GP_DIALOG.pak 41 pzeff00.prm none -
GP_DIALOG.pak 86 esrb_base.prm none -
GP_DIALOG.pak 130 esrb_base.prm none -
GP_GAMEOVER.pak 4 pnbase.tbm none -
GP_GAMEOVER.pak 7 pnbase.tbm none -
GP_MISSION_SELECT.pak 3 px_mission_base.tbm none -
GP_MISSION_SELECT.pak 5 px_mission_base.tbm none -
GP_MOVIE_THEATER.pak 0 px_movie_base.tbm none -
GP_MOVIE_THEATER.pak 1 px_movie_base.tbm none -
GP_OPTIONS.pak 0 po_menu_base.tbm none -
GP_OPTIONS.pak 0 po_menu_base.tbm none -
GP_OPTIONS.pak 2 po_menu_base.tbm none -
GP_OPTIONS.pak 2 po_menu_base.tbm none -
GP_SAVE_LOAD.pak 37 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 37 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 40 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 40 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 46 pgloading_eff00.prm none -
GP_SAVE_LOAD.pak 58 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 69 pgloading_eff00.prm none -
GP_SAVE_LOAD.pak 78 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 89 px_replay_base.tbm none -
GP_SAVE_LOAD.pak 93 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 93 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 98 px_replay_base.tbm none -
GP_SAVE_LOAD.pak 99 pfbase.tbm implied_MEASURED 0x00000000
GP_SAVE_LOAD.pak 99 pfbase.tbm implied_MEASURED 0x00000000
GP_SYSTEM.pak 0 pqbase.tbm none -
GP_SYSTEM.pak 1 pqbase.tbm none -
GP_TITLE.pak 10 palogo_eff0.prm implied_MEASURED 0x00000000
GP_TITLE.pak 11 palogo_eff0.prm implied_MEASURED 0x00000000
GP_TITLE.pak 12 pgloading_eff00.prm none -
GP_TITLE.pak 13 palogo_eff0.prm implied_MEASURED 0x00000000
GP_TITLE.pak 14 palogo_eff0.prm implied_MEASURED 0x00000000
GP_TITLE.pak 15 pgloading_eff00.prm none -
GP_TUTORIAL.pak 0 pubase.tbm none -
GP_TUTORIAL.pak 1 pubase.tbm none -
# forced instances by key source:
# read from the T8aD header (decoded): 0
# implied — this crate's MEASURED name table: 14
# none — only forced_backdrop can speak: 66

View File

@@ -0,0 +1,253 @@
# Does forced_backdrop DECIDE a screen's order, or merely AGREE with it?
#
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_necessity -- <pak>
# over every dat/*.pak, 2026-08-30. SYLPHEED_DISC=/disc.
#
# 'decides' = derived_paint_order() differs from the same sort with the
# forced_backdrop fallback removed. 'no' = the element's own read or implied
# key already puts it there, OR every element on the screen is forced so the
# declaration-index tie-break gives the same order either way.
#
# TOTALS: 80 forced instances = 62 decides + 18 agrees.
# The 80 reproduces the census in ui-forced-backdrop.md exactly.
# Every one of the 62 deciders is keyless; no keyed element is ever moved.
#
# /disc/dat/GP_BUNK.pak
# entry forced decides elements note
0 1 YES 21 forced=[px_bunk_base.tbm] keyless=[px_bunk_base.tbm]
2 1 YES 21 forced=[px_bunk_base.tbm] keyless=[px_bunk_base.tbm]
4 1 YES 19 forced=[pvbase.tbm] keyless=[pvbase.tbm]
6 1 YES 19 forced=[pvbase.tbm] keyless=[pvbase.tbm]
# rule DECIDES the order on entries [0, 2, 4, 6]
# rule merely AGREES on entries []
# /disc/dat/GP_CHALLENGE.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_DEBRIEFING_PILOTLOG.pak
# entry forced decides elements note
118 1 YES 6 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
130 1 YES 6 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
131 1 YES 13 forced=[pjbgbase2.tbm] keyless=[pjbgbase2.tbm]
134 1 YES 10 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
150 1 YES 13 forced=[pjbgbase2.tbm] keyless=[pjbgbase2.tbm]
165 1 YES 10 forced=[px_deb_base.tbm] keyless=[px_deb_base.tbm]
# rule DECIDES the order on entries [118, 130, 131, 134, 150, 165]
# rule merely AGREES on entries []
# /disc/dat/GP_DIALOG.pak
# entry forced decides elements note
2 1 YES 15 forced=[pcbase.tbm] keyless=[pcbase.tbm]
3 1 YES 15 forced=[pcbase.tbm] keyless=[pcbase.tbm]
9 1 YES 34 forced=[pzeff00.prm] keyless=[pzeff00.prm]
10 1 YES 46 forced=[pzeff00.prm] keyless=[pzeff00.prm]
11 1 YES 38 forced=[pzeff00.prm] keyless=[pzeff00.prm]
12 1 YES 32 forced=[pzeff00.prm] keyless=[pzeff00.prm]
13 1 YES 32 forced=[pzeff00.prm] keyless=[pzeff00.prm]
14 1 YES 34 forced=[pzeff00.prm] keyless=[pzeff00.prm]
15 1 YES 26 forced=[pzeff00.prm] keyless=[pzeff00.prm]
16 1 YES 20 forced=[pzeff00.prm] keyless=[pzeff00.prm]
17 1 YES 30 forced=[pzeff00.prm] keyless=[pzeff00.prm]
18 1 YES 38 forced=[pzeff00.prm] keyless=[pzeff00.prm]
19 1 YES 30 forced=[pzeff00.prm] keyless=[pzeff00.prm]
20 1 YES 36 forced=[pzeff00.prm] keyless=[pzeff00.prm]
21 1 YES 38 forced=[pzeff00.prm] keyless=[pzeff00.prm]
22 1 YES 46 forced=[pzeff00.prm] keyless=[pzeff00.prm]
23 1 YES 34 forced=[pzeff00.prm] keyless=[pzeff00.prm]
24 1 YES 36 forced=[pzeff00.prm] keyless=[pzeff00.prm]
25 1 YES 14 forced=[pzeff00.prm] keyless=[pzeff00.prm]
26 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
28 1 YES 14 forced=[pzeff00.prm] keyless=[pzeff00.prm]
29 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
30 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
31 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
32 1 YES 10 forced=[pzeff00.prm] keyless=[pzeff00.prm]
33 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
34 1 YES 14 forced=[pzeff00.prm] keyless=[pzeff00.prm]
35 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
36 1 YES 16 forced=[pzeff00.prm] keyless=[pzeff00.prm]
37 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
38 1 YES 26 forced=[pzeff00.prm] keyless=[pzeff00.prm]
39 1 YES 20 forced=[pzeff00.prm] keyless=[pzeff00.prm]
40 1 YES 18 forced=[pzeff00.prm] keyless=[pzeff00.prm]
41 1 YES 20 forced=[pzeff00.prm] keyless=[pzeff00.prm]
86 1 YES 2 forced=[esrb_base.prm] keyless=[esrb_base.prm]
130 1 YES 2 forced=[esrb_base.prm] keyless=[esrb_base.prm]
# rule DECIDES the order on entries [2, 3, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 86, 130]
# rule merely AGREES on entries []
# /disc/dat/GP_GAMEOVER.pak
# entry forced decides elements note
4 1 YES 13 forced=[pnbase.tbm] keyless=[pnbase.tbm]
7 1 YES 13 forced=[pnbase.tbm] keyless=[pnbase.tbm]
# rule DECIDES the order on entries [4, 7]
# rule merely AGREES on entries []
# /disc/dat/GP_HANGAR_ARSENAL.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_LEADERBOARD.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_D.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_D2D.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_E.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_E2D.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_F.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_F2D.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_I.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_I2D.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_J.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_J2D.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_S.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MAIN_GAME_S2D.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MISSION_LOG.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_MISSION_SELECT.pak
# entry forced decides elements note
3 1 YES 15 forced=[px_mission_base.tbm] keyless=[px_mission_base.tbm]
5 1 YES 15 forced=[px_mission_base.tbm] keyless=[px_mission_base.tbm]
# rule DECIDES the order on entries [3, 5]
# rule merely AGREES on entries []
# /disc/dat/GP_MOVIE_THEATER.pak
# entry forced decides elements note
0 1 YES 12 forced=[px_movie_base.tbm] keyless=[px_movie_base.tbm]
1 1 YES 12 forced=[px_movie_base.tbm] keyless=[px_movie_base.tbm]
# rule DECIDES the order on entries [0, 1]
# rule merely AGREES on entries []
# /disc/dat/GP_OPTIONS.pak
# entry forced decides elements note
0 2 no 2 forced=[po_menu_base.tbm,po_menu_base.tbm] keyless=[po_menu_base.tbm,po_menu_base.tbm]
2 2 no 2 forced=[po_menu_base.tbm,po_menu_base.tbm] keyless=[po_menu_base.tbm,po_menu_base.tbm]
# rule DECIDES the order on entries []
# rule merely AGREES on entries [0, 2]
# /disc/dat/GP_PAUSE_MENU.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_READY_ROOM.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_SAVE_LOAD.pak
# entry forced decides elements note
37 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
40 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
46 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
58 1 no 9 forced=[pfbase.tbm] keyless=[]
69 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
78 1 no 9 forced=[pfbase.tbm] keyless=[]
89 1 YES 7 forced=[px_replay_base.tbm] keyless=[px_replay_base.tbm]
93 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
98 1 YES 7 forced=[px_replay_base.tbm] keyless=[px_replay_base.tbm]
99 2 no 13 forced=[pfbase.tbm,pfbase.tbm] keyless=[]
# rule DECIDES the order on entries [46, 69, 89, 98]
# rule merely AGREES on entries [37, 40, 58, 78, 93, 99]
# /disc/dat/GP_STAGE_CLEAR.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/GP_SYSTEM.pak
# entry forced decides elements note
0 1 YES 21 forced=[pqbase.tbm] keyless=[pqbase.tbm]
1 1 YES 21 forced=[pqbase.tbm] keyless=[pqbase.tbm]
# rule DECIDES the order on entries [0, 1]
# rule merely AGREES on entries []
# /disc/dat/GP_TITLE.pak
# entry forced decides elements note
10 1 no 3 forced=[palogo_eff0.prm] keyless=[]
11 1 no 7 forced=[palogo_eff0.prm] keyless=[]
12 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
13 1 no 3 forced=[palogo_eff0.prm] keyless=[]
14 1 no 7 forced=[palogo_eff0.prm] keyless=[]
15 1 YES 10 forced=[pgloading_eff00.prm] keyless=[pgloading_eff00.prm]
# rule DECIDES the order on entries [12, 15]
# rule merely AGREES on entries [10, 11, 13, 14]
# /disc/dat/GP_TUTORIAL.pak
# entry forced decides elements note
0 1 YES 18 forced=[pubase.tbm] keyless=[pubase.tbm]
1 1 YES 18 forced=[pubase.tbm] keyless=[pubase.tbm]
# rule DECIDES the order on entries [0, 1]
# rule merely AGREES on entries []
# /disc/dat/fonts.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/sound.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []
# /disc/dat/tables.pak
# entry forced decides elements note
# rule DECIDES the order on entries []
# rule merely AGREES on entries []

View File

@@ -0,0 +1,83 @@
# What forced_backdrop costs IN PIXELS on the 62 builds whose order it decides.
#
# Produced by: cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost
# (no argument = every dat/*.pak), 2026-08-30, SYLPHEED_DISC=/disc.
# Each build rendered twice at 1280x720 with include_primitives=true and a
# black backdrop: once in derived_paint_order(), once with the
# forced_backdrop fallback removed. changed_px is the diff.
#
# RESULT, and it splits perfectly along the element kind:
# 38 .prm deciders: changed_px > 0, and changed_px == ink_px in ALL 38.
# Without the rule the screen composites to PURE BLACK.
# 24 .tbm deciders: changed_px == 0 in all 24 -- but see the caveat, this
# is our compositor drawing no pixels for a .tbm at all,
# NOT the rule being free. The control below did not
# catch it and was the wrong control.
#
# archive entry element changed_px total_px ink_px(control) pct
GP_BUNK.pak 0 px_bunk_base.tbm 0 921600 75487 0.00%
GP_BUNK.pak 2 px_bunk_base.tbm 0 921600 70372 0.00%
GP_BUNK.pak 4 pvbase.tbm 0 921600 41137 0.00%
GP_BUNK.pak 6 pvbase.tbm 0 921600 34773 0.00%
GP_DEBRIEFING_PILOTLOG.pak 118 px_deb_base.tbm 0 921600 25812 0.00%
GP_DEBRIEFING_PILOTLOG.pak 130 px_deb_base.tbm 0 921600 25812 0.00%
GP_DEBRIEFING_PILOTLOG.pak 131 pjbgbase2.tbm 0 921600 33329 0.00%
GP_DEBRIEFING_PILOTLOG.pak 134 px_deb_base.tbm 0 921600 44147 0.00%
GP_DEBRIEFING_PILOTLOG.pak 150 pjbgbase2.tbm 0 921600 34401 0.00%
GP_DEBRIEFING_PILOTLOG.pak 165 px_deb_base.tbm 0 921600 42487 0.00%
GP_DIALOG.pak 2 pcbase.tbm 0 921600 646626 0.00%
GP_DIALOG.pak 3 pcbase.tbm 0 921600 646626 0.00%
GP_DIALOG.pak 9 pzeff00.prm 867195 921600 867195 94.10%
GP_DIALOG.pak 10 pzeff00.prm 869598 921600 869598 94.36%
GP_DIALOG.pak 11 pzeff00.prm 868329 921600 868329 94.22%
GP_DIALOG.pak 12 pzeff00.prm 867629 921600 867629 94.14%
GP_DIALOG.pak 13 pzeff00.prm 867713 921600 867713 94.15%
GP_DIALOG.pak 14 pzeff00.prm 868023 921600 868023 94.19%
GP_DIALOG.pak 15 pzeff00.prm 867294 921600 867294 94.11%
GP_DIALOG.pak 16 pzeff00.prm 865326 921600 865326 93.89%
GP_DIALOG.pak 17 pzeff00.prm 867276 921600 867276 94.11%
GP_DIALOG.pak 18 pzeff00.prm 868899 921600 868899 94.28%
GP_DIALOG.pak 19 pzeff00.prm 866752 921600 866752 94.05%
GP_DIALOG.pak 20 pzeff00.prm 868266 921600 868266 94.21%
GP_DIALOG.pak 21 pzeff00.prm 868388 921600 868388 94.23%
GP_DIALOG.pak 22 pzeff00.prm 870053 921600 870053 94.41%
GP_DIALOG.pak 23 pzeff00.prm 867982 921600 867982 94.18%
GP_DIALOG.pak 24 pzeff00.prm 868386 921600 868386 94.23%
GP_DIALOG.pak 25 pzeff00.prm 865233 921600 865233 93.88%
GP_DIALOG.pak 26 pzeff00.prm 865525 921600 865525 93.92%
GP_DIALOG.pak 28 pzeff00.prm 865233 921600 865233 93.88%
GP_DIALOG.pak 29 pzeff00.prm 865500 921600 865500 93.91%
GP_DIALOG.pak 30 pzeff00.prm 866183 921600 866183 93.99%
GP_DIALOG.pak 31 pzeff00.prm 866233 921600 866233 93.99%
GP_DIALOG.pak 32 pzeff00.prm 865192 921600 865192 93.88%
GP_DIALOG.pak 33 pzeff00.prm 865538 921600 865538 93.92%
GP_DIALOG.pak 34 pzeff00.prm 865233 921600 865233 93.88%
GP_DIALOG.pak 35 pzeff00.prm 866079 921600 866079 93.98%
GP_DIALOG.pak 36 pzeff00.prm 865508 921600 865508 93.91%
GP_DIALOG.pak 37 pzeff00.prm 866218 921600 866218 93.99%
GP_DIALOG.pak 38 pzeff00.prm 868259 921600 868259 94.21%
GP_DIALOG.pak 39 pzeff00.prm 866804 921600 866804 94.05%
GP_DIALOG.pak 40 pzeff00.prm 866062 921600 866062 93.97%
GP_DIALOG.pak 41 pzeff00.prm 866792 921600 866792 94.05%
GP_DIALOG.pak 86 esrb_base.prm 6388 921600 6388 0.69%
GP_DIALOG.pak 130 esrb_base.prm 6388 921600 6388 0.69%
GP_GAMEOVER.pak 4 pnbase.tbm 0 921600 921600 0.00%
GP_GAMEOVER.pak 7 pnbase.tbm 0 921600 921600 0.00%
GP_MISSION_SELECT.pak 3 px_mission_base.tbm 0 921600 661678 0.00%
GP_MISSION_SELECT.pak 5 px_mission_base.tbm 0 921600 659464 0.00%
GP_MOVIE_THEATER.pak 0 px_movie_base.tbm 0 921600 32871 0.00%
GP_MOVIE_THEATER.pak 1 px_movie_base.tbm 0 921600 27726 0.00%
GP_SAVE_LOAD.pak 46 pgloading_eff00.prm 49771 921600 49771 5.40%
GP_SAVE_LOAD.pak 69 pgloading_eff00.prm 49771 921600 49771 5.40%
GP_SAVE_LOAD.pak 89 px_replay_base.tbm 0 921600 269421 0.00%
GP_SAVE_LOAD.pak 98 px_replay_base.tbm 0 921600 269421 0.00%
GP_SYSTEM.pak 0 pqbase.tbm 0 921600 828253 0.00%
GP_SYSTEM.pak 1 pqbase.tbm 0 921600 828199 0.00%
GP_TITLE.pak 12 pgloading_eff00.prm 49771 921600 49771 5.40%
GP_TITLE.pak 15 pgloading_eff00.prm 49771 921600 49771 5.40%
GP_TUTORIAL.pak 0 pubase.tbm 0 921600 92946 0.00%
GP_TUTORIAL.pak 1 pubase.tbm 0 921600 92879 0.00%
# builds whose ORDER the rule decides: 62
# of those, costing ZERO pixels: 24
# of those, BLIND (build renders no ink, control fails): 0

View File

@@ -0,0 +1,34 @@
# The game's own audio output over the boot intro (Q9/#4 groundwork).
#
# 2026-08-30. Recipe: docs/re/audio-capture-alsa-file-tee.md, exactly.
# ARGV: run-canary --apu=alsa --mute=false --gpu=null --xma_param_probe=true
# ALSA file tee in front of a paced pulse slave; 6ch float32 @ 48 kHz.
# Raw is 170 MB and is NOT committed -- sent via share.
#
# PROVENANCE (better than a screenshot for an audio question): the XMA probe
# logged ADV's three contexts byte-exact against the disc --
# ctx0 packets=632 byte_size=1294336
# ctx1 packets=546 byte_size=1118208
# ctx2 packets=572 byte_size=1171456
# then two more (1150976 / 1269760) = the documented BGM_102 pair.
#
# CAPTURE QUALITY: 0.15-0.16 % silence on five channels, against the
# 0.31 % the recipe page records for its clean --gpu=null run.
#
# ALSA CHANNEL ORDER: captured i holds source [0,1,4,5,2,3], i.e. the
# labels below are FL FR BL BR FC LFE. Deterministic, not data loss.
#
frames 7105024 = 148.02 s, 6ch float32 @ 48 kHz
ch name peak dBFS rms dBFS %silent
0 FL -3.65 -22.26 0.16
1 FR -2.22 -20.89 0.15
2 BL -4.41 -24.77 0.15
3 BR -11.65 -36.06 82.18
4 FC -4.57 -24.81 0.16
5 LFE -4.66 -24.38 0.16
pairwise |r| > 0.5:
ch0(FL) vs ch1(FR): r=+0.6996
ch0(FL) vs ch4(FC): r=+0.5202
ch1(FR) vs ch5(LFE): r=+0.5639

View File

@@ -0,0 +1,42 @@
# What the game emits over the boot intro, decomposed against the movie's own track.
#
# 2026-08-30. Capture: docs/re/structures/intro-audio-output-census.md
# Reference: ffmpeg -i /disc/dat/movie/ADV.wmv -map 0:a:0 -f f32le -ar 48000 -ac 6
#
# ADV.wmv carries ONE audio stream: wmapro, 48000 Hz, 5.1, 384 kb/s. Not XMA.
#
# ALIGNMENT (energy envelope, 100 Hz, summed over channels = permutation-invariant)
# movie begins +6.63 s into the capture; envelope r = 0.7692
# control: peak 0.7692, 99.9th pct 0.6241, median -0.0009
# refined by sample-level correlation to +224 samples, r = 0.900
#
# CHANNEL MAP -- measured, not assumed. Every row's max is a distinct movie
# channel, i.e. a genuine permutation, and it is the IDENTITY:
# cap ch0->FL +0.899 ch1->FR +0.935 ch2->FC +0.185
# cap ch3->LFE +1.000 ch4->BL +0.960 ch5->BR +0.971
# 🔴 The ALSA order [0,1,4,5,2,3] documented in audio-capture-alsa-file-tee.md
# does NOT apply to this capture. See the doc for what that corrected.
#
# DECOMPOSITION capture = 0.600 x movie + residual (80 s from movie t=20 s)
# ch gain r cap rms resid rms resid/cap
# FL 0.600 +0.905 -21.86 -29.28 -7.43 dB
# FR 0.600 +0.935 -20.28 -29.29 -9.02 dB
# FC 0.597 +0.146 -25.18 -25.27 -0.09 dB <- movie explains NOTHING
# LFE 0.600 +1.000 -43.66 -115.73 -72.06 dB <- exact to precision
# BL 0.600 +0.956 -24.89 -35.46 -10.58 dB
# BR 0.600 +0.964 -24.02 -35.49 -11.47 dB
#
# RESIDUAL STRUCTURE -- residual vs residual correlation
# FL FR FC LFE BL BR
# FL +1.000 +0.918 +0.017 +0.001 +0.009 +0.004
# FR +0.918 +1.000 +0.034 +0.001 +0.015 +0.014
# FC +0.017 +0.034 +1.000 +0.000 +0.385 +0.378
# LFE +0.001 +0.001 +0.000 +1.000 +0.000 -0.000
# BL +0.009 +0.015 +0.385 +0.000 +1.000 +0.929
# BR +0.004 +0.014 +0.378 -0.000 +0.929 +1.000
#
# Three coherent groups: a FRONT pair (0.918), a REAR pair (0.929), and a
# CENTRE whose partner LFE is empty. That is three stereo streams in 5.1.
#
# FC residual 100 ms frame levels: median -53.9 dB, p90 -19.9 dB,
# dynamic range 34.0 dB -- bursty, not steady noise.

View File

@@ -0,0 +1,14 @@
paks scanned : 33
placement groups : 13991
A. lead-in prepended to the shifted times is non-decreasing
13991/13991 = 100.000%
B. non-zero lead-in is strictly less than the next time
5058/5058 = 100.000%
control (another group's lead-in, same bundle): 35837/50580 = 70.852%
gap to the next time, most common: [(10, 2076), (1, 2022), (30, 116), (40, 80), (12, 78), (90, 78), (149, 78), (20, 78)]
C. constant d(alpha)/d(time) across a multi-segment ramp
corrected (time precedes pose): 857/1540 = 55.649%
old (+36 is own time) : 0/1042 = 0.000%

View File

@@ -0,0 +1,40 @@
2859 builds, 90347 keyframes (parents + leaves)
+4: 12 distinct values, 4289 non-zero keyframes (4.7473%)
180 x4200
-180 x24
22 x24
90 x18
-45 x8
60 x8
+8: 11 distinct values, 4064 non-zero keyframes (4.4982%)
180 x3288
90 x201
-180 x156
178 x144
45 x99
23 x95
+12 (rotation): 157 distinct values, 12520 non-zero keyframes (13.8577%)
90 x1968
-90 x1260
180 x608
120 x540
-58 x426
53 x408
keyframes with a non-zero +4 or +8: 6345
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0
GP_BUNK.pak e1 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0
GP_BUNK.pak e3 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
GP_BUNK.pak e4 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=0
GP_BUNK.pak e4 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=360
GP_BUNK.pak e6 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=0
GP_BUNK.pak e6 pjeff02.rat->pjeff21.rat +4=180 +8=0 +12=360
GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_base.t32 +4=180 +8=0 +12=0
GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_loop1.rat +4=180 +8=0 +12=0
GP_CHALLENGE.pak e71 pjnet_bg.rat->pjnet_loop2.rat +4=180 +8=0 +12=0

View File

@@ -0,0 +1,125 @@
# tie-break pixel cost — /disc/dat/GP_TITLE.pak
entry 0 7 elements 1 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
entry 1 7 elements 1 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
entry 4 24 elements 13 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 36305 px (max Δ 254)
[default (what `screen render` draws)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 295 px differ (0.0320% of frame), max Δ 1 | ink 2483 / 6547 px, shared 2483 px
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 861 px differ (0.0934% of frame), max Δ 2 | ink 2483 / 9698 px, shared 2483 px
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1555 px differ (0.1687% of frame), max Δ 2 | ink 2483 / 13926 px, shared 2483 px
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6645 px differ (0.7210% of frame), max Δ 3 | ink 2483 / 22834 px, shared 2398 px
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 420 px differ (0.0456% of frame), max Δ 1 | ink 6547 / 9698 px, shared 6547 px
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1209 px differ (0.1312% of frame), max Δ 2 | ink 6547 / 13926 px, shared 6547 px
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6641 px differ (0.7206% of frame), max Δ 2 | ink 6547 / 22834 px, shared 6360 px
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 584 px differ (0.0634% of frame), max Δ 1 | ink 9698 / 13926 px, shared 9698 px
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6390 px differ (0.6934% of frame), max Δ 2 | ink 9698 / 22834 px, shared 9462 px
[default (what `screen render` draws)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5516 px differ (0.5985% of frame), max Δ 1 | ink 13926 / 22834 px, shared 13480 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 860461 px (max Δ 254)
[everything on (focus+animated+primitives)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 280 px differ (0.0304% of frame), max Δ 1 | ink 2516 / 6589 px, shared 2516 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 811 px differ (0.0880% of frame), max Δ 2 | ink 2516 / 9754 px, shared 2516 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1527 px differ (0.1657% of frame), max Δ 2 | ink 2516 / 14072 px, shared 2516 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6586 px differ (0.7146% of frame), max Δ 3 | ink 2516 / 22970 px, shared 2419 px
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 395 px differ (0.0429% of frame), max Δ 1 | ink 6589 / 9754 px, shared 6589 px
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1204 px differ (0.1306% of frame), max Δ 2 | ink 6589 / 14072 px, shared 6589 px
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6567 px differ (0.7126% of frame), max Δ 2 | ink 6589 / 22970 px, shared 6397 px
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 599 px differ (0.0650% of frame), max Δ 1 | ink 9754 / 14072 px, shared 9754 px
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6333 px differ (0.6872% of frame), max Δ 2 | ink 9754 / 22970 px, shared 9512 px
[everything on (focus+animated+primitives)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5427 px differ (0.5889% of frame), max Δ 1 | ink 14072 / 22970 px, shared 13620 px
entry 5 16 elements 2 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 764030 px (max Δ 67)
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4783 / 5297 px, shared 0 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 725164 px (max Δ 50)
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4781 / 5305 px, shared 0 px
entry 6 18 elements 2 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 761600 px (max Δ 67)
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 721144 px (max Δ 50)
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
entry 7 30 elements 16 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL ok: swapping [8] ptlogo_eff3.t32 x [14] pteff04.t32 moves 240308 px (max Δ 225)
[default (what `screen render` draws)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 1 px differ (0.0001% of frame), max Δ 1 | ink 58790 / 1062 px, shared 5 px
[default (what `screen render` draws)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 67 px differ (0.0073% of frame), max Δ 1 | ink 74167 / 723 px, shared 8 px
[default (what `screen render` draws)] [8] ptlogo_eff3.t32 x [24] ptlogo_back2eff.t32 (key 32897): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 14507 px, shared 0 px
[default (what `screen render` draws)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 211 px differ (0.0229% of frame), max Δ 1 | ink 2124 / 4909 px, shared 2124 px
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 538 px differ (0.0584% of frame), max Δ 2 | ink 2124 / 7538 px, shared 2124 px
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 941 px differ (0.1021% of frame), max Δ 2 | ink 2124 / 9413 px, shared 2124 px
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1061 px differ (0.1151% of frame), max Δ 2 | ink 2124 / 16198 px, shared 2124 px
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 216 px differ (0.0234% of frame), max Δ 1 | ink 4909 / 7538 px, shared 4909 px
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 663 px differ (0.0719% of frame), max Δ 2 | ink 4909 / 9413 px, shared 4909 px
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 847 px differ (0.0919% of frame), max Δ 2 | ink 4909 / 16198 px, shared 4909 px
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 330 px differ (0.0358% of frame), max Δ 1 | ink 7538 / 9413 px, shared 7538 px
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 526 px differ (0.0571% of frame), max Δ 2 | ink 7538 / 16198 px, shared 7538 px
[default (what `screen render` draws)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 9 px differ (0.0010% of frame), max Δ 1 | ink 9413 / 16198 px, shared 9413 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [8] ptlogo_eff3.t32 x [14] pteff04.t32 moves 825048 px (max Δ 225)
[everything on (focus+animated+primitives)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 3 px differ (0.0003% of frame), max Δ 1 | ink 58742 / 1062 px, shared 5 px
[everything on (focus+animated+primitives)] [2] ptlogo1.t32 x [4] ptlogo1.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] [3] ptlogo2.t32 x [5] ptlogo2.t32 (key 32928): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 67 px differ (0.0073% of frame), max Δ 1 | ink 73690 / 729 px, shared 7 px
[everything on (focus+animated+primitives)] [8] ptlogo_eff3.t32 x [24] ptlogo_back2eff.t32 (key 32897): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 14531 px, shared 0 px
[everything on (focus+animated+primitives)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 193 px differ (0.0209% of frame), max Δ 1 | ink 2137 / 4917 px, shared 2137 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 511 px differ (0.0554% of frame), max Δ 2 | ink 2137 / 7552 px, shared 2137 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 931 px differ (0.1010% of frame), max Δ 2 | ink 2137 / 9422 px, shared 2137 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1034 px differ (0.1122% of frame), max Δ 3 | ink 2137 / 16223 px, shared 2137 px
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 233 px differ (0.0253% of frame), max Δ 1 | ink 4917 / 7552 px, shared 4917 px
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 668 px differ (0.0725% of frame), max Δ 2 | ink 4917 / 9422 px, shared 4917 px
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 841 px differ (0.0913% of frame), max Δ 2 | ink 4917 / 16223 px, shared 4917 px
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 319 px differ (0.0346% of frame), max Δ 1 | ink 7552 / 9422 px, shared 7552 px
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 539 px differ (0.0585% of frame), max Δ 2 | ink 7552 / 16223 px, shared 7552 px
[everything on (focus+animated+primitives)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 6 px differ (0.0007% of frame), max Δ 1 | ink 9422 / 16223 px, shared 9422 px
entry 8 16 elements 2 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 771479 px (max Δ 66)
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4778 / 5302 px, shared 0 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 733320 px (max Δ 50)
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4782 / 5309 px, shared 0 px
entry 9 18 elements 2 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 768159 px (max Δ 66)
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 729480 px (max Δ 50)
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
entry 12 10 elements 1 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
entry 15 10 elements 1 overlapping tied pair(s)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
default-options summary: 26 of 31 overlapping tied pairs change at least one pixel; 6 dead/unavailable controls

View File

@@ -0,0 +1,55 @@
entry 0 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
overlapping tie: [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
entry 1 (no measured order) 7 elements, 2 tied pairs, 1 of them OVERLAPPING
overlapping tie: [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
entry 2 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 3 (no measured order) 1 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 4 title 24 elements
derived == measured : NO
inverted pairs : 8 (of which same-layer-key ties: 8)
measured: [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8]
derived : [9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, 22, 23, 21, 8]
keys : [32928, 32928, 32928, 32928, 32928, 32928, 32832, 32928, 4294967295, 32768, 32800, 32784, 32784, 4294967295, 32899, 32899, 32899, 32899, 32899, 32898, 32897, 33024, 32936, 32937]
entry 5 main menu 16 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 6 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING
overlapping tie: [0] ptframe3.t32 x [1] ptframe4.t32 key 32848 rect (440, 230, 246, 220) / (584, 318, 256, 210) overlap 102x132
overlapping tie: [14] ptloop01.rat x [15] ptloop02.rat key 32784 rect (441, 270, 400, 180) / (441, 270, 400, 180) overlap 400x180
entry 7 (no measured order) 30 elements, 37 tied pairs, 16 of them OVERLAPPING
overlapping tie: [1] ptlogo2.t32 x [11] ptlogo_tm.t32 key 32928 rect (193, 335, 898, 92) / (1073, 392, 44, 28) overlap 18x28
overlapping tie: [2] ptlogo1.t32 x [4] ptlogo1.t32 key 32928 rect (-65, 33, 902, 100) / (-65, 33, 902, 100) overlap 902x100
overlapping tie: [3] ptlogo2.t32 x [5] ptlogo2.t32 key 32928 rect (493, 535, 898, 92) / (493, 535, 898, 92) overlap 898x92
overlapping tie: [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 key 32898 rect (412, 96, 338, 338) / (134, 173, 1000, 234) overlap 338x234
overlapping tie: [8] ptlogo_eff3.t32 x [24] ptlogo_back2eff.t32 key 32897 rect (98, 42, 946, 386) / (127, 164, 1014, 252) overlap 917x252
overlapping tie: [15] ptloop01.rat x [16] ptloop02.rat key 32784 rect (441, 270, 400, 180) / (441, 270, 400, 180) overlap 400x180
overlapping tie: [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 key 32899 rect (910, 227, 156, 120) / (910, 164, 232, 182) overlap 156x119
overlapping tie: [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 key 32899 rect (910, 227, 156, 120) / (802, 164, 340, 182) overlap 156x119
overlapping tie: [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 key 32899 rect (910, 227, 156, 120) / (483, 164, 658, 182) overlap 156x119
overlapping tie: [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (910, 227, 156, 120) / (127, 164, 1014, 252) overlap 156x120
overlapping tie: [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 key 32899 rect (910, 164, 232, 182) / (802, 164, 340, 182) overlap 232x182
overlapping tie: [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 key 32899 rect (910, 164, 232, 182) / (483, 164, 658, 182) overlap 231x182
overlapping tie: [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (910, 164, 232, 182) / (127, 164, 1014, 252) overlap 231x182
overlapping tie: [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 key 32899 rect (802, 164, 340, 182) / (483, 164, 658, 182) overlap 339x182
overlapping tie: [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (802, 164, 340, 182) / (127, 164, 1014, 252) overlap 339x182
overlapping tie: [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 key 32899 rect (483, 164, 658, 182) / (127, 164, 1014, 252) overlap 658x182
entry 8 main menu 16 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 9 (no measured order) 18 elements, 15 tied pairs, 2 of them OVERLAPPING
overlapping tie: [0] ptframe3.t32 x [1] ptframe4.t32 key 32848 rect (440, 230, 246, 220) / (584, 318, 256, 210) overlap 102x132
overlapping tie: [14] ptloop01.rat x [15] ptloop02.rat key 32784 rect (441, 270, 400, 180) / (441, 270, 400, 180) overlap 400x180
entry 10 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 11 splash 7 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 12 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING
overlapping tie: [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
entry 13 (no measured order) 3 elements, 0 tied pairs, 0 of them OVERLAPPING
entry 14 splash 7 elements
derived == measured : YES
inverted pairs : 0 (of which same-layer-key ties: 0)
entry 15 (no measured order) 10 elements, 2 tied pairs, 1 of them OVERLAPPING
overlapping tie: [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 key 49408 rect (202, 528, 332, 144) / (74, 518, 188, 186) overlap 60x144
5 build(s) with a measured order were checked

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
# The title's two light-sweep leaves: position, alpha and on-screen extent
# across the window where the draw-capture fit (t=357.7) and the port's
# PNG fit (~400) disagree.
#
# Produced by: cargo run -p sylpheed-formats --example ptloop_leaf_sweep_at
# 2026-08-30, SYLPHEED_DISC=/disc, GP_TITLE entry 4.
#
# CONTROL: at t=355 this reproduces ui-leaf-vs-parent-alpha.md's published
# centres exactly -- 981 and 478. The probe is reading the same leaves.
#
# centre = keyframe x + pivot_x. The keyframe x is the quad's LEFT edge;
# the draw-capture fit is quoted in centres.
#
# At t=357.7: A centre 991.8, B centre 467.2 (measured: 992.0 / 467.2)
# At t=400: A centre 1161, B centre 295 -- +169.0 and -172.2 px off
#
# The two nested records cycle at DIFFERENT lengths, 600 and 720.
#
-- ptloop01.rat nested record: loop length (+0x08) = 600
== ptloop01.rat -> leaf pteff03.t32 (sprite None, pivot 200x90, 4 keyframes, last t=Some(Some(600)))
t | x | centre | a | on-screen px of a 400px-wide quad
340 | 721 | 921 | 190 | 400 px
350 | 761 | 961 | 193 | 400 px
355 | 781 | 981 | 195 | 400 px
357 | 789 | 989 | 195 | 400 px
358 | 793 | 993 | 196 | 400 px
360 | 801 | 1001 | 196 | 400 px
370 | 841 | 1041 | 200 | 400 px
380 | 881 | 1081 | 203 | 399 px
390 | 921 | 1121 | 206 | 359 px
395 | 941 | 1141 | 208 | 339 px
400 | 961 | 1161 | 209 | 319 px
405 | 981 | 1181 | 211 | 299 px
410 | 1001 | 1201 | 213 | 279 px
420 | 1041 | 1241 | 216 | 239 px
440 | 1121 | 1321 | 222 | 159 px
480 | 1281 | 1481 | 235 | 0 px *** ENTIRELY OFF SCREEN ***
540 | 1521 | 1721 | 255 | 0 px *** ENTIRELY OFF SCREEN ***
-- ptloop02.rat nested record: loop length (+0x08) = 720
== ptloop02.rat -> leaf pteff03a.t32 (sprite None, pivot 200x90, 4 keyframes, last t=Some(Some(720)))
t | x | centre | a | on-screen px of a 400px-wide quad
340 | 339 | 539 | 178 | 400 px
350 | 298 | 498 | 181 | 400 px
355 | 278 | 478 | 182 | 400 px
357 | 270 | 470 | 183 | 400 px
358 | 266 | 466 | 183 | 400 px
360 | 258 | 458 | 184 | 400 px
370 | 217 | 417 | 186 | 400 px
380 | 177 | 377 | 189 | 400 px
390 | 136 | 336 | 192 | 400 px
395 | 116 | 316 | 193 | 400 px
400 | 95 | 295 | 194 | 400 px
405 | 75 | 275 | 195 | 400 px
410 | 55 | 255 | 197 | 400 px
420 | 14 | 214 | 199 | 400 px
440 | -67 | 133 | 205 | 333 px
480 | -230 | -30 | 215 | 170 px
540 | -473 | -273 | 231 | 0 px *** ENTIRELY OFF SCREEN ***

View File

@@ -0,0 +1,20 @@
nested records with timed keyframes : 1781
+08 == max keyframe time (exact) : 1643 (92.3%)
+08 > max keyframe time (a hold) : 138 (7.7%)
+08 < max keyframe time 🔴 : 0 (0.00%) <- the falsifier
slack (+08 - max t) distribution, most common first:
slack 0 : 1643
slack 40 : 32
slack 10 : 20
slack 4 : 13
slack 54 : 12
slack 30 : 8
slack 1 : 6
slack 6 : 6
slack 9 : 6
slack 36 : 6
slack 405 : 6
slack 16 : 5
slack 58 : 5
slack 80 : 5

View File

@@ -0,0 +1,22 @@
# tied pairs that could cost a pixel, over time — /disc/dat/GP_TITLE.pak
entry 0 peak 1 live pair(s) over t=0..40 settle window [34,38] at t=36: 0 ACROSS THE WHOLE WINDOW: max 0
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1
entry 1 peak 1 live pair(s) over t=0..40 settle window [34,38] at t=36: 0 ACROSS THE WHOLE WINDOW: max 0
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1
entry 4 peak 6 live pair(s) over t=0..269 settle window [160,236] at t=198: 1 ACROSS THE WHOLE WINDOW: max 1
live at 62 of 88 sampled instants
entry 5 peak 2 live pair(s) over t=0..80 settle window [44,56] at t=50: 2 ACROSS THE WHOLE WINDOW: max 2
live at 47 of 47 sampled instants
entry 6 peak 2 live pair(s) over t=0..74 settle window [38,50] at t=44: 2 ACROSS THE WHOLE WINDOW: max 2
live at 46 of 46 sampled instants
entry 7 peak 6 live pair(s) over t=0..269 settle window [190,236] at t=213: 2 ACROSS THE WHOLE WINDOW: max 2
live at 92 of 117 sampled instants
entry 8 peak 2 live pair(s) over t=0..80 settle window [44,56] at t=50: 2 ACROSS THE WHOLE WINDOW: max 2
live at 47 of 47 sampled instants
entry 9 peak 2 live pair(s) over t=0..74 settle window [38,50] at t=44: 2 ACROSS THE WHOLE WINDOW: max 2
live at 46 of 46 sampled instants
entry 12 peak 1 live pair(s) over t=0..48 settle window [40,48] at t=44: 0 ACROSS THE WHOLE WINDOW: max 0
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1
entry 15 peak 1 live pair(s) over t=0..48 settle window [40,48] at t=44: 0 ACROSS THE WHOLE WINDOW: max 0
live only at t17:1 t18:1 t19:1 t20:1 t21:1 t22:1 t23:1 t24:1 t25:1 t26:1 t27:1 t28:1 t30:1 t32:1 t33:1

View File

@@ -0,0 +1,142 @@
# tie-break pixel cost — /disc/dat/GP_TITLE.pak
entry 0 7 elements 1 overlapping tied pair(s) at rest settle t=36 (window 4 units)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
entry 1 7 elements 1 overlapping tied pair(s) at rest settle t=36 (window 4 units)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [4] pgloading_loop4.rat x [6] pgloading_eff02.t32 moves 3654 px (max Δ 23)
[everything on (focus+animated+primitives)] [5] pgloading_eff01.t32 x [6] pgloading_eff02.t32 (key 49408): 1610 px differ (0.1747% of frame), max Δ 1 | ink 29173 / 21017 px, shared 3139 px
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
entry 4 24 elements 11 overlapping tied pair(s) at rest settle t=198 (window 76 units)
[default (what `screen render` draws)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 36305 px (max Δ 254)
[default (what `screen render` draws)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 295 px differ (0.0320% of frame), max Δ 1 | ink 2483 / 6547 px, shared 2483 px
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 861 px differ (0.0934% of frame), max Δ 2 | ink 2483 / 9698 px, shared 2483 px
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1555 px differ (0.1687% of frame), max Δ 2 | ink 2483 / 13926 px, shared 2483 px
[default (what `screen render` draws)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6645 px differ (0.7210% of frame), max Δ 3 | ink 2483 / 22834 px, shared 2398 px
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 420 px differ (0.0456% of frame), max Δ 1 | ink 6547 / 9698 px, shared 6547 px
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1209 px differ (0.1312% of frame), max Δ 2 | ink 6547 / 13926 px, shared 6547 px
[default (what `screen render` draws)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6641 px differ (0.7206% of frame), max Δ 2 | ink 6547 / 22834 px, shared 6360 px
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 584 px differ (0.0634% of frame), max Δ 1 | ink 9698 / 13926 px, shared 9698 px
[default (what `screen render` draws)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6390 px differ (0.6934% of frame), max Δ 2 | ink 9698 / 22834 px, shared 9462 px
[default (what `screen render` draws)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5516 px differ (0.5985% of frame), max Δ 1 | ink 13926 / 22834 px, shared 13480 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [10] pteff04.t32 x [18] ptlogo_back2eff5.t32 moves 860461 px (max Δ 254)
[everything on (focus+animated+primitives)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [15] ptlogo_back2eff2.t32 (key 32899): 280 px differ (0.0304% of frame), max Δ 1 | ink 2516 / 6589 px, shared 2516 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 811 px differ (0.0880% of frame), max Δ 2 | ink 2516 / 9754 px, shared 2516 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1527 px differ (0.1657% of frame), max Δ 2 | ink 2516 / 14072 px, shared 2516 px
[everything on (focus+animated+primitives)] [14] ptlogo_back2eff1.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6586 px differ (0.7146% of frame), max Δ 3 | ink 2516 / 22970 px, shared 2419 px
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [16] ptlogo_back2eff3.t32 (key 32899): 395 px differ (0.0429% of frame), max Δ 1 | ink 6589 / 9754 px, shared 6589 px
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 1204 px differ (0.1306% of frame), max Δ 2 | ink 6589 / 14072 px, shared 6589 px
[everything on (focus+animated+primitives)] [15] ptlogo_back2eff2.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6567 px differ (0.7126% of frame), max Δ 2 | ink 6589 / 22970 px, shared 6397 px
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [17] ptlogo_back2eff4.t32 (key 32899): 599 px differ (0.0650% of frame), max Δ 1 | ink 9754 / 14072 px, shared 9754 px
[everything on (focus+animated+primitives)] [16] ptlogo_back2eff3.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 6333 px differ (0.6872% of frame), max Δ 2 | ink 9754 / 22970 px, shared 9512 px
[everything on (focus+animated+primitives)] [17] ptlogo_back2eff4.t32 x [18] ptlogo_back2eff5.t32 (key 32899): 5427 px differ (0.5889% of frame), max Δ 1 | ink 14072 / 22970 px, shared 13620 px
[AT THE SETTLE TIME (what the player sees)] 🔴 10 of the 11 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [10] pteff04.t32 x [20] ptlogo_back2eff.t32 moves 25310 px (max Δ 243)
[AT THE SETTLE TIME (what the player sees)] [11] ptloop01.rat x [12] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
entry 5 16 elements 2 overlapping tied pair(s) at rest settle t=50 (window 12 units)
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 764030 px (max Δ 67)
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4783 / 5297 px, shared 0 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 725164 px (max Δ 50)
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4781 / 5305 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 765778 px (max Δ 67)
[AT THE SETTLE TIME (what the player sees)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[AT THE SETTLE TIME (what the player sees)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4783 / 5297 px, shared 0 px
entry 6 18 elements 2 overlapping tied pair(s) at rest settle t=44 (window 12 units)
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 761600 px (max Δ 67)
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 721144 px (max Δ 50)
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 764104 px (max Δ 67)
[AT THE SETTLE TIME (what the player sees)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
entry 7 30 elements 13 overlapping tied pair(s) at rest settle t=213 (window 46 units)
[default (what `screen render` draws)] CONTROL ok: swapping [14] pteff04.t32 x [22] ptlogo_back2eff5.t32 moves 278139 px (max Δ 248)
[default (what `screen render` draws)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 1 px differ (0.0001% of frame), max Δ 1 | ink 58773 / 1062 px, shared 5 px
[default (what `screen render` draws)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 1 px differ (0.0001% of frame), max Δ 1 | ink 47839 / 659 px, shared 3 px
[default (what `screen render` draws)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 211 px differ (0.0229% of frame), max Δ 1 | ink 2124 / 4909 px, shared 2124 px
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 538 px differ (0.0584% of frame), max Δ 2 | ink 2124 / 7538 px, shared 2124 px
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 941 px differ (0.1021% of frame), max Δ 2 | ink 2124 / 10876 px, shared 2124 px
[default (what `screen render` draws)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1237 px differ (0.1342% of frame), max Δ 2 | ink 2124 / 17678 px, shared 2124 px
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 216 px differ (0.0234% of frame), max Δ 1 | ink 4909 / 7538 px, shared 4909 px
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 663 px differ (0.0719% of frame), max Δ 2 | ink 4909 / 10876 px, shared 4909 px
[default (what `screen render` draws)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1023 px differ (0.1110% of frame), max Δ 2 | ink 4909 / 17678 px, shared 4909 px
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 330 px differ (0.0358% of frame), max Δ 1 | ink 7538 / 10876 px, shared 7538 px
[default (what `screen render` draws)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 702 px differ (0.0762% of frame), max Δ 2 | ink 7538 / 17678 px, shared 7538 px
[default (what `screen render` draws)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 185 px differ (0.0201% of frame), max Δ 1 | ink 10876 / 17678 px, shared 10876 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [14] pteff04.t32 x [22] ptlogo_back2eff5.t32 moves 862138 px (max Δ 248)
[everything on (focus+animated+primitives)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 3 px differ (0.0003% of frame), max Δ 1 | ink 58727 / 1062 px, shared 5 px
[everything on (focus+animated+primitives)] [7] ptlogo_eff2.rat x [23] ptlogo_back2.t32 (key 32898): 1 px differ (0.0001% of frame), max Δ 1 | ink 47217 / 664 px, shared 3 px
[everything on (focus+animated+primitives)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [19] ptlogo_back2eff2.t32 (key 32899): 193 px differ (0.0209% of frame), max Δ 1 | ink 2137 / 4917 px, shared 2137 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 511 px differ (0.0554% of frame), max Δ 2 | ink 2137 / 7552 px, shared 2137 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 931 px differ (0.1010% of frame), max Δ 2 | ink 2137 / 10892 px, shared 2137 px
[everything on (focus+animated+primitives)] [18] ptlogo_back2eff1.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1215 px differ (0.1318% of frame), max Δ 3 | ink 2137 / 17707 px, shared 2137 px
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [20] ptlogo_back2eff3.t32 (key 32899): 233 px differ (0.0253% of frame), max Δ 1 | ink 4917 / 7552 px, shared 4917 px
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 668 px differ (0.0725% of frame), max Δ 2 | ink 4917 / 10892 px, shared 4917 px
[everything on (focus+animated+primitives)] [19] ptlogo_back2eff2.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 1022 px differ (0.1109% of frame), max Δ 2 | ink 4917 / 17707 px, shared 4917 px
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [21] ptlogo_back2eff4.t32 (key 32899): 319 px differ (0.0346% of frame), max Δ 1 | ink 7552 / 10892 px, shared 7552 px
[everything on (focus+animated+primitives)] [20] ptlogo_back2eff3.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 720 px differ (0.0781% of frame), max Δ 2 | ink 7552 / 17707 px, shared 7552 px
[everything on (focus+animated+primitives)] [21] ptlogo_back2eff4.t32 x [22] ptlogo_back2eff5.t32 (key 32899): 187 px differ (0.0203% of frame), max Δ 1 | ink 10892 / 17707 px, shared 10892 px
[AT THE SETTLE TIME (what the player sees)] 🔴 11 of the 13 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [14] pteff04.t32 x [24] ptlogo_back2eff.t32 moves 268698 px (max Δ 247)
[AT THE SETTLE TIME (what the player sees)] [1] ptlogo2.t32 x [11] ptlogo_tm.t32 (key 32928): 1 px differ (0.0001% of frame), max Δ 1 | ink 58770 / 1062 px, shared 5 px
[AT THE SETTLE TIME (what the player sees)] [15] ptloop01.rat x [16] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
entry 8 16 elements 2 overlapping tied pair(s) at rest settle t=50 (window 12 units)
[default (what `screen render` draws)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 771479 px (max Δ 66)
[default (what `screen render` draws)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[default (what `screen render` draws)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4778 / 5302 px, shared 0 px
[everything on (focus+animated+primitives)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 733320 px (max Δ 50)
[everything on (focus+animated+primitives)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[everything on (focus+animated+primitives)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4782 / 5309 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [1] ptbase.t32 x [2] pteff05.t32 moves 773431 px (max Δ 66)
[AT THE SETTLE TIME (what the player sees)] [3] ptloop01.rat x [4] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[AT THE SETTLE TIME (what the player sees)] [6] ptframe1.t32 x [7] ptframe2.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 4778 / 5302 px, shared 0 px
entry 9 18 elements 2 overlapping tied pair(s) at rest settle t=44 (window 12 units)
[default (what `screen render` draws)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 768159 px (max Δ 66)
[default (what `screen render` draws)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[default (what `screen render` draws)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
[everything on (focus+animated+primitives)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 729480 px (max Δ 50)
[everything on (focus+animated+primitives)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[everything on (focus+animated+primitives)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] CONTROL ok: swapping [11] ptbase.t32 x [12] pteff05.t32 moves 771048 px (max Δ 66)
[AT THE SETTLE TIME (what the player sees)] [0] ptframe3.t32 x [1] ptframe4.t32 (key 32848): 0 px differ (0.0000% of frame), max Δ 0 | ink 3646 / 3584 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] [14] ptloop01.rat x [15] ptloop02.rat (key 32784): NOT BOTH DRAWN — unreachable here
entry 12 10 elements 1 overlapping tied pair(s) at rest settle t=44 (window 8 units)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
entry 15 10 elements 1 overlapping tied pair(s) at rest settle t=44 (window 8 units)
[default (what `screen render` draws)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
[default (what `screen render` draws)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 1761 px differ (0.1911% of frame), max Δ 1 | ink 30864 / 21163 px, shared 3298 px
[everything on (focus+animated+primitives)] CONTROL DEAD: swapping [6] pgloading_loop5.rat x [7] pgloading_baseeff.t32 changes NOTHING — zeros below are uninterpretable
[everything on (focus+animated+primitives)] [8] pgloading_eff01.t32 x [9] pgloading_eff02.t32 (key 49408): 0 px differ (0.0000% of frame), max Δ 0 | ink 0 / 0 px, shared 0 px
[AT THE SETTLE TIME (what the player sees)] 🔴 1 of the 1 tied pairs are GONE at this pose (an element is transparent or collapsed there) — they cannot cost a pixel
[AT THE SETTLE TIME (what the player sees)] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn
default-options summary: 26 of 30 overlapping tied pairs change at least one pixel; 10 dead/unavailable controls

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
45 leaves opened
scale count examples
0,0 12 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
0,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
* 75,75 4 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
* 75,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
* 96,96 4 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
* 96,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
* 99,99 4 e0/pgloading_loop4.rat, e1/pgloading_loop4.rat, e12/pgloading_loop4.rat
* 99,100 4 e0/pgloading_line.t32, e1/pgloading_line.t32, e12/pgloading_line.t32
100,100 759 e0/pgloading_eff01.t32, e0/pgloading_eff02.t32, e0/pgloading_line.t32
100,600 24 e4/ptloop01.rat->LEAF/pteff03.t32, e5/ptloop01.rat->LEAF/pteff03.t32, e6/ptloop01.rat->LEAF/pteff03.t32
100,800 24 e4/ptloop02.rat->LEAF/pteff03a.t32, e5/ptloop02.rat->LEAF/pteff03a.t32, e6/ptloop02.rat->LEAF/pteff03a.t32
* 101,101 12 e4/ptlogo1.t32, e4/ptlogo2.t32, e7/ptlogo1.t32
* 103,103 12 e4/ptlogo1.t32, e4/ptlogo2.t32, e7/ptlogo1.t32
* 112,112 12 e4/ptlogo1.t32, e4/ptlogo2.t32, e7/ptlogo1.t32
* 125,125 2 e7/ptlogo_eff2.rat
* 150,150 28 e0/pgloading_loop1.rat, e1/pgloading_loop1.rat, e12/pgloading_loop1.rat
200,200 46 e12/pgloading_baseeff.t32, e15/pgloading_baseeff.t32, e4/ptbase2.t32
200,500 20 e5/pteff10.t32, e6/pteff10.t32, e8/pteff10.t32
* 204,208 1 e4/ptlogoall_eff.t32
* 210,220 1 e4/ptlogoall_eff.t32
* 250,250 2 e12/pgloading_loop5.rat->LEAF/pgloading_ring.t32, e15/pgloading_loop5.rat->LEAF/pgloading_ring.t32
300,100 2 e6/pteff21.t32, e9/pteff21.t32
400,400 5 e4/ptlogoall_eff2.t32
800,800 2 e12/pgloading_loop5.rat->LEAF/pgloading_ring.t32, e15/pgloading_loop5.rat->LEAF/pgloading_ring.t32
1000,1000 4 e12/pgloading_loop5.rat->LEAF/pgloading_ring.t32, e15/pgloading_loop5.rat->LEAF/pgloading_ring.t32
* = not a whole multiple of 100%

View File

@@ -0,0 +1,36 @@
ADV today [806912, 1118208, 1171456]
prop [1294336, 1118208, 1171456] first chunk GREW, tail identical
S00A today [1323008, 1263616, 98304]
prop [1810432, 1263616, 98304] first chunk GREW, tail identical
S01A today [1153024, 1390592, 1552384]
prop [1640448, 1390592, 1552384] first chunk GREW, tail identical
S02B today [430080, 505856, 866304]
prop [919552, 505856, 866304] first chunk GREW, tail identical
S02C today [1867776, 1349632, 2390016]
prop [2353152, 1349632, 2390016] first chunk GREW, tail identical
S03A today [251904, 540672, 739328]
prop [741376, 540672, 739328] first chunk GREW, tail identical
S04B today [555008, 870400, 991232]
prop [1044480, 870400, 991232] first chunk GREW, tail identical
S06A today [350208, 294912, 860160]
prop [839680, 294912, 860160] first chunk GREW, tail identical
S06B today [606208, 874496, 1449984]
prop [1093632, 874496, 1449984] first chunk GREW, tail identical
S07A today [503808, 473088, 1040384]
prop [993280, 473088, 1040384] first chunk GREW, tail identical
S09B today [176128, 571392, 843776]
prop [665600, 571392, 843776] first chunk GREW, tail identical
S11C today [741376, 1075200, 1107968]
prop [1228800, 1075200, 1107968] first chunk GREW, tail identical
S12C today [1843200, 1273856, 2502656]
prop [2328576, 1273856, 2502656] first chunk GREW, tail identical
S13A today [401408, 585728, 962560]
prop [890880, 585728, 962560] first chunk GREW, tail identical
S14A today [1816576, 1634304, 2541568]
prop [2301952, 1634304, 2541568] first chunk GREW, tail identical
S15A today [253952, 618496, 1122304]
prop [743424, 618496, 1122304] first chunk GREW, tail identical
S15C today [929792, 1101824, 1384448]
prop [1417216, 1101824, 1384448] first chunk GREW, tail identical
unchanged 78 fixed-cleanly 17 would-break 0 skipped 9

View File

@@ -0,0 +1,103 @@
104 movies in the manifest
ADV 433930240.. 437044592 3114352 B chunks 3 leading STREAM (808304 B = 394 packets + 1392 B)
S00A 452798464.. 455499120 2700656 B chunks 3 leading STREAM (1324400 B = 646 packets + 1392 B)
S01A 456003584.. 460117360 4113776 B chunks 3 leading STREAM (1154416 B = 563 packets + 1392 B)
RT01A 437044592.. 437345648 301056 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT01B 437345648.. 437712240 366592 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT01C_1 437712240.. 438080880 368640 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT01C_2 438080880.. 438451568 370688 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S02A 460117360.. 461518192 1400832 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
S02B 462022656.. 463838576 1815920 B chunks 3 leading STREAM (431472 B = 210 packets + 1392 B)
S02C 464343040.. 469970288 5627248 B chunks 3 leading STREAM (1869168 B = 912 packets + 1392 B)
RT02A 438451568.. 438789488 337920 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT02B 438789488.. 439192944 403456 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT02C 439192944.. 439645552 452608 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT02D_1 439645552.. 439852400 206848 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT02D_2 439852400.. 440112496 260096 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s02A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s02H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S03A 470474752.. 472020336 1545584 B chunks 3 leading STREAM (253296 B = 123 packets + 1392 B)
RT03A 440112496.. 440448368 335872 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT03B 440448368.. 440730992 282624 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT03C 440730992.. 441144688 413696 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT03D 441144688.. 441773424 628736 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s03A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s03H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S04B 480507904.. 482938224 2430320 B chunks 3 leading STREAM (556400 B = 271 packets + 1392 B)
RT04A 441773424.. 442019184 245760 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT04B 442019184.. 442254704 235520 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s02A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S05A 482938224.. 483433840 495616 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
RT05A 442254704.. 442578288 323584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT05B 442578288.. 442938736 360448 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT05C 442938736.. 443143536 204800 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s02A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S06A 483938304.. 485457264 1518960 B chunks 3 leading STREAM (351600 B = 171 packets + 1392 B)
S06B 485961728.. 488908144 2946416 B chunks 3 leading STREAM (607600 B = 296 packets + 1392 B)
RT06A 443143536.. 443460976 317440 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT06B 443460976.. 443802992 342016 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT06C 443802992.. 444046704 243712 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT06D 444046704.. 444183920 137216 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s06A 430128496.. 430196080 67584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s06H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S07A 489412608.. 491443568 2030960 B chunks 3 leading STREAM (505200 B = 246 packets + 1392 B)
S07B 491443568.. 491634032 190464 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
RT07A 444183920.. 444706160 522240 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT07B 444706160.. 444980592 274432 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT07C 444980592.. 445130096 149504 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s07A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s07H 430390640.. 430464368 73728 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT08A 445130096.. 445326704 196608 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT08B 445326704.. 445517168 190464 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT08C 445517168.. 445676912 159744 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s08A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S09B 492138496.. 493743472 1604976 B chunks 3 leading STREAM (177520 B = 86 packets + 1392 B)
RT09A 445676912.. 446000496 323584 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT09B 446000496.. 446485872 485376 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT09C 446485872.. 446748016 262144 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT09D 446748016.. 447149424 401408 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s09A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s09H 430335344.. 430390640 55296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT10A 447149424.. 447272304 122880 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT10B 447272304.. 447356272 83968 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S11A 501900656.. 502173040 272384 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
S11C 502677504.. 505619824 2942320 B chunks 3 leading STREAM (742768 B = 362 packets + 1392 B)
RT11A 447356272.. 447776112 419840 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT11B 447776112.. 447880560 104448 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT11C 447880560.. 448097648 217088 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s11A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S12A 505619824.. 506191216 571392 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
S12B 506191216.. 506262896 71680 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
S12C 506767360.. 512406896 5639536 B chunks 3 leading STREAM (1844592 B = 900 packets + 1392 B)
RT12A 448097648.. 448374128 276480 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT12B_1 448374128.. 448544112 169984 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT12B_2 448544112.. 448767344 223232 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s07A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s07H 430390640.. 430464368 73728 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S13A 512911360.. 514874736 1963376 B chunks 3 leading STREAM (402800 B = 196 packets + 1392 B)
S13B 514874736.. 515278192 403456 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
RT13A 448767344.. 449002864 235520 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT13B_1 449002864.. 449099120 96256 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT13B_2 449099120.. 449340784 241664 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s13A 430265712.. 430335344 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S14A 515782656.. 521794928 6012272 B chunks 3 leading STREAM (1817968 B = 887 packets + 1392 B)
RT14A 449340784.. 449721712 380928 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT14B 449721712.. 450033008 311296 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT14C 450033008.. 450280816 247808 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_DS_s14H 430390640.. 430464368 73728 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
S15A 522299392.. 524309872 2010480 B chunks 3 leading STREAM (255344 B = 124 packets + 1392 B)
S15B 524309872.. 525626736 1316864 B chunks 3 BANK HEADER (10240 B = 5 packets exactly)
S15C 526131200.. 529565040 3433840 B chunks 3 leading STREAM (931184 B = 454 packets + 1392 B)
RT15A 450280816.. 450985328 704512 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT15B 450985328.. 451282288 296960 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
RT15C 451282288.. 451614064 331776 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s15A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s24A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
hokyu_LS_s27A 430196080.. 430265712 69632 B chunks 1 BANK HEADER (10240 B = 5 packets exactly)
78 region(s) open with a BANK HEADER (bank_header_len fires)
17 open with a leading STREAM
0 start at a RIFF
leading-stream length mod 2048, i.e. the derived data offset:
1392 B x17

View File

@@ -0,0 +1,63 @@
movie chunks first chunk clip pkts verdict
ADV 3 806912 243 STARTS MID-STREAM
S00A 3 1323008 243 STARTS MID-STREAM
S01A 3 1153024 243 STARTS MID-STREAM
RT01A 1 284672 0 starts at a boundary
RT01B 1 350208 0 starts at a boundary
RT01C_1 1 352256 0 starts at a boundary
RT01C_2 1 354304 0 starts at a boundary
S02A 3 665600 0 starts at a boundary
S02B 3 430080 243 STARTS MID-STREAM
S02C 3 1867776 243 STARTS MID-STREAM
RT02A 1 321536 0 starts at a boundary
RT02B 1 387072 0 starts at a boundary
RT02C 1 436224 0 starts at a boundary
RT02D_1 1 190464 0 starts at a boundary
RT02D_2 1 243712 0 starts at a boundary
hokyu_LS_s02A 1 51200 0 starts at a boundary
hokyu_LS_s02H 1 38912 0 starts at a boundary
S03A 3 251904 243 STARTS MID-STREAM
RT03A 1 319488 0 starts at a boundary
RT03B 1 266240 0 starts at a boundary
RT03C 1 397312 0 starts at a boundary
RT03D 1 612352 0 starts at a boundary
hokyu_LS_s03A 1 51200 0 starts at a boundary
hokyu_LS_s03H 1 38912 0 starts at a boundary
S04B 3 555008 243 STARTS MID-STREAM
RT04A 1 229376 0 starts at a boundary
RT04B 1 219136 0 starts at a boundary
hokyu_DS_s02A 1 53248 0 starts at a boundary
S05A 3 161792 0 starts at a boundary
RT05A 1 307200 0 starts at a boundary
RT05B 1 344064 0 starts at a boundary
RT05C 1 188416 0 starts at a boundary
hokyu_LS_s02A 1 51200 0 starts at a boundary
S06A 3 350208 243 STARTS MID-STREAM
S06B 3 606208 243 STARTS MID-STREAM
RT06A 1 301056 0 starts at a boundary
RT06B 1 325632 0 starts at a boundary
RT06C 1 227328 0 starts at a boundary
RT06D 1 120832 0 starts at a boundary
hokyu_LS_s06A 1 51200 0 starts at a boundary
hokyu_LS_s06H 1 38912 0 starts at a boundary
S07A 3 503808 243 STARTS MID-STREAM
S07B 3 53248 0 starts at a boundary
RT07A 1 505856 0 starts at a boundary
RT07B 1 258048 0 starts at a boundary
RT07C 1 133120 0 starts at a boundary
hokyu_DS_s07A 1 53248 0 starts at a boundary
hokyu_DS_s07H 1 57344 0 starts at a boundary
RT08A 1 180224 0 starts at a boundary
RT08B 1 174080 0 starts at a boundary
RT08C 1 143360 0 starts at a boundary
hokyu_DS_s08A 1 53248 0 starts at a boundary
S09B 3 176128 243 STARTS MID-STREAM
RT09A 1 307200 0 starts at a boundary
RT09B 1 468992 0 starts at a boundary
RT09C 1 245760 0 starts at a boundary
RT09D 1 385024 0 starts at a boundary
hokyu_LS_s09A 1 53248 0 starts at a boundary
hokyu_LS_s09H 1 38912 0 starts at a boundary
RT10A 1 106496 0 starts at a boundary
RT10B 1 67584 0 starts at a boundary
S11A 3 81920 0 starts at a boundary

View File

@@ -0,0 +1,75 @@
# resolve_movie_voice_region starts 238 packets LATE for ADV.
#
# 2026-08-30. Raised by the port agent, whose arithmetic is the whole reason
# this was found: the running decoder's three ADV contexts sum to 3 584 000
# payload bytes, but the resolved region is 3 114 352 -- 15 % too small to
# hold them. One of the two spans was wrong, and it was the disc side.
#
# The gap is a WHOLE NUMBER OF PACKETS, which is what a start offset looks
# like and corruption does not:
# ctx0 declares 632 packets = 1 294 336 B
# the resolver's leading chunk has 394 packets = 806 912 B
# difference 238 packets = 487 424 B
#
# GROUND TRUTH is the running decoder's own byte_sizes. This is not free to
# fit: the span either lands on all three or it does not.
#
resolver says 433930240..437044592 (3114352 B)
decoder wants [1294336, 1118208, 1171456] = 3584000 B payload
- 0 packets (start 433930240): 3 chunk(s) [806912, 1118208, 1171456]
- 100 packets (start 433725440): 3 chunk(s) [1011712, 1118208, 1171456]
- 200 packets (start 433520640): 3 chunk(s) [1216512, 1118208, 1171456]
- 237 packets (start 433444864): 3 chunk(s) [1292288, 1118208, 1171456]
- 238 packets (start 433442816): 3 chunk(s) [1294336, 1118208, 1171456] <== MATCHES THE DECODER
- 239 packets (start 433440768): 3 chunk(s) [1296384, 1118208, 1171456]
- 300 packets (start 433315840): 5 chunk(s) [57344, 45056, 1294336, 1118208, 1171456]
- 400 packets (start 433111040): 8 chunk(s) [59392, 59392, 47104, 47104, 45056, 1294336, 1118208, 1171456]
# -238 is a real boundary, not the end of a sweep: at -300 and -400 the
# PREVIOUS asset's chunks appear (57344, 45056, ...) while the three ADV
# sizes stay exactly stable. The stream starts at -238 and something else
# ends just before it.
# ---------------------------------------------------------------------------
# DISC-WIDE (examples/voice_region_start_audit.rs, full table in
# voice-region-start-audit.txt)
#
# 1-chunk regions: 24 of 24 start at a chunk boundary
# 3-chunk regions: 8 of 10 START MID-STREAM
#
# So the defect is specific to the three-stream (multichannel) voice regions.
#
# ⚠️ The audit's "243" column is an UPPER BOUND on the clip, not the clip. Its
# stopping rule is "step back until the chunk COUNT changes", and to_xma_riffs
# will happily absorb a few packets of the PREVIOUS asset into the first chunk
# before that happens -- for ADV it reports 243 where the decoder-verified
# answer is 238. Only ADV has external ground truth.
# ---------------------------------------------------------------------------
# WHY (examples/voice_region_start_why.rs), and the FIX (cap_sweep)
#
# The resolver picks start = the predecessor cue's trailer, then filters it with
# .filter(|&s| s < end && end - s < 1_500_000)
# "only within one bank (~1.5 MB), else this is the first cue in its block and
# the audio starts at the anchor itself".
#
# ADV's predecessor sits 3 618 816 B before `end`. The filter REJECTS it, and the
# start falls back to `anchor` -- a TOC offset, not a stream boundary:
#
# movie id anchor pred(before) span chosen
# ADV 1600 433930240 433425776 3618816 anchor <- REJECTED
#
# predecessor 433425776 + 17 040 B of descriptor/padding = 433442816,
# which is exactly the -238 packet start measured against the decoder.
#
# 17 of 95 resolving movies hit this. Reading from the predecessor instead:
#
# anchor (today) start 433930240 -> [806912, 1118208, 1171456]
# predecessor (proposed) start 433425776 -> [1294336, 1118208, 1171456] MATCHES
#
# DISC-WIDE CONSEQUENCE of dropping the cap (voice-region-cap-sweep.txt):
# unchanged 78 fixed-cleanly 17 would-break 0 skipped 9
# In all 17 the first chunk GROWS and every later chunk is byte-identical --
# which is what a corrected start looks like, and what pulling in a neighbouring
# asset does not.

View File

@@ -0,0 +1,55 @@
registry: 4280 cue names, 4280 distinct ids
scanning dat/sound 421739888..537953648 (116.2 MB)
287 trailer descriptors found
of those, 287 carry an id the registry names, 0 do not
leading span -> owning cue
ADV lead 808304 B bracketed by desc@Some((433425776, 1528)) .. desc@Some((437044592, 1600)) owner VOICE_ADV [movie cue]
S00A lead 1324400 B bracketed by desc@Some((452294000, 1500)) .. desc@Some((455499120, 1501)) owner VOICE_S00A [movie cue]
S01A lead 1154416 B bracketed by desc@Some((455499120, 1501)) .. desc@Some((460117360, 1502)) owner VOICE_S01A [movie cue]
S02B lead 431472 B bracketed by desc@Some((461518192, 1503)) .. desc@Some((463838576, 1504)) owner VOICE_S02B [movie cue]
S02C lead 1869168 B bracketed by desc@Some((463838576, 1504)) .. desc@Some((469970288, 1506)) owner VOICE_S02C [movie cue]
S03A lead 253296 B bracketed by desc@Some((469970288, 1506)) .. desc@Some((472020336, 1507)) owner VOICE_S03A [movie cue]
S04B lead 556400 B bracketed by desc@Some((480003440, 1508)) .. desc@Some((482938224, 1509)) owner VOICE_S04B [movie cue]
S06A lead 351600 B bracketed by desc@Some((483433840, 1510)) .. desc@Some((485457264, 1511)) owner VOICE_S06A [movie cue]
S06B lead 607600 B bracketed by desc@Some((485457264, 1511)) .. desc@Some((488908144, 1512)) owner VOICE_S06B [movie cue]
S07A lead 505200 B bracketed by desc@Some((488908144, 1512)) .. desc@Some((491443568, 1513)) owner VOICE_S07A [movie cue]
S09B lead 177520 B bracketed by desc@Some((491634032, 1514)) .. desc@Some((493743472, 1515)) owner VOICE_S09B [movie cue]
S11C lead 742768 B bracketed by desc@Some((502173040, 1517)) .. desc@Some((505619824, 1518)) owner VOICE_S11C [movie cue]
S12C lead 1844592 B bracketed by desc@Some((506262896, 1520)) .. desc@Some((512406896, 1521)) owner VOICE_S12C [movie cue]
S13A lead 402800 B bracketed by desc@Some((512406896, 1521)) .. desc@Some((514874736, 1522)) owner VOICE_S13A [movie cue]
S14A lead 1817968 B bracketed by desc@Some((515278192, 1523)) .. desc@Some((521794928, 1524)) owner VOICE_S14A [movie cue]
S15A lead 255344 B bracketed by desc@Some((521794928, 1524)) .. desc@Some((524309872, 1525)) owner VOICE_S15A [movie cue]
S15C lead 931184 B bracketed by desc@Some((525626736, 1526)) .. desc@Some((529565040, 1527)) owner VOICE_S15C [movie cue]
verdicts: {"movie cue": 17}
cue span vs the 1.5 MB guard, and what the region actually starts at:
ADV true cue span 3618816 B (> guard: true) region starts at 433930240, true start 433425776 -> 504464 B of the cue's own audio is OUTSIDE the region
S00A true cue span 3205120 B (> guard: true) region starts at 452798464, true start 452294000 -> 504464 B of the cue's own audio is OUTSIDE the region
S01A true cue span 4618240 B (> guard: true) region starts at 456003584, true start 455499120 -> 504464 B of the cue's own audio is OUTSIDE the region
S02B true cue span 2320384 B (> guard: true) region starts at 462022656, true start 461518192 -> 504464 B of the cue's own audio is OUTSIDE the region
S02C true cue span 6131712 B (> guard: true) region starts at 464343040, true start 463838576 -> 504464 B of the cue's own audio is OUTSIDE the region
S03A true cue span 2050048 B (> guard: true) region starts at 470474752, true start 469970288 -> 504464 B of the cue's own audio is OUTSIDE the region
S04B true cue span 2934784 B (> guard: true) region starts at 480507904, true start 480003440 -> 504464 B of the cue's own audio is OUTSIDE the region
S06A true cue span 2023424 B (> guard: true) region starts at 483938304, true start 483433840 -> 504464 B of the cue's own audio is OUTSIDE the region
S06B true cue span 3450880 B (> guard: true) region starts at 485961728, true start 485457264 -> 504464 B of the cue's own audio is OUTSIDE the region
S07A true cue span 2535424 B (> guard: true) region starts at 489412608, true start 488908144 -> 504464 B of the cue's own audio is OUTSIDE the region
S09B true cue span 2109440 B (> guard: true) region starts at 492138496, true start 491634032 -> 504464 B of the cue's own audio is OUTSIDE the region
S11C true cue span 3446784 B (> guard: true) region starts at 502677504, true start 502173040 -> 504464 B of the cue's own audio is OUTSIDE the region
S12C true cue span 6144000 B (> guard: true) region starts at 506767360, true start 506262896 -> 504464 B of the cue's own audio is OUTSIDE the region
S13A true cue span 2467840 B (> guard: true) region starts at 512911360, true start 512406896 -> 504464 B of the cue's own audio is OUTSIDE the region
S14A true cue span 6516736 B (> guard: true) region starts at 515782656, true start 515278192 -> 504464 B of the cue's own audio is OUTSIDE the region
S15A true cue span 2514944 B (> guard: true) region starts at 522299392, true start 521794928 -> 504464 B of the cue's own audio is OUTSIDE the region
S15C true cue span 3938304 B (> guard: true) region starts at 526131200, true start 525626736 -> 504464 B of the cue's own audio is OUTSIDE the region
cues over the 1.5 MB guard: 17, of which stream-opening: 17
cues under the guard: 78, of which stream-opening: 0
stream starts inside each cue's TRUE span (desc(N-1)..desc(N)):
all inter-descriptor spans: {1: 258, 3: 28}
spans >= 1.5 MB (the long cues): {3: 20}

View File

@@ -0,0 +1,34 @@
cue stream1 stream2 stream3 s3/s2 rate2 rate3
ADV 1294396 1118268 1171516 1.0476 8142 8530
S00A 1810492 1263676 98364 0.0778 13485 1049
S01A 1640508 1390652 1552444 1.1163 6952 7760
S02A 665660 356412 350268 0.9828 6673 6558
S02B 919612 505916 866364 1.7125 4825 8263
S02C 2353212 1349692 2390076 1.7708 10547 18677
S03A 741436 540732 739388 1.3674 6549 8955
S04A 2775100 2486332 2680892 1.0783 9688 10446
S04B 1044540 870460 991292 1.1388 9361 10661
S05A 161852 147516 157756 1.0694 4447 4756
S06A 839740 294972 860220 2.9163 5661 16513
S06B 1093692 874556 1450044 1.6580 10678 17706
S07A 993340 473148 1040444 2.1990 6810 14977
S07B 53308 55356 53308 0.9630 3114 2999
S09B 665660 571452 843836 1.4767 5033 7432
S10B 2904124 2564156 2648124 1.0327 6629 6846
S11A 81980 81980 79932 0.9750 1842 1796
S11C 1228860 1075260 1108028 1.0305 5594 5765
S12A 196668 135228 211004 1.5604 7938 12388
S12B 14396 14396 14396 1.0000 1072 1072
S12C 2328636 1273916 2502716 1.9646 7215 14176
S13A 890940 585788 962620 1.6433 6990 11487
S13B 131132 116796 127036 1.0877 7488 8145
S14A 2302012 1634364 2541628 1.5551 8211 12770
S15A 743484 618556 1122364 1.8145 5985 10861
S15B 475196 348220 464956 1.3352 3968 5299
S15C 1417276 1101884 1384508 1.2565 5988 7524
BIRD_224 2050108 1607740 1978428 1.2306 2387 2937
28 three-stream cues
stream3/stream2 ratio: min 0.0778 median 1.2565 max 2.9163 mean 1.3593 sd 0.5057
cues where stream 3 is less than HALF of stream 2: 1
cues where stream 3 is within 15% of stream 2: 12 of 28

View File

@@ -0,0 +1,4 @@
w> 01000014 XMA-PROBE active (ctx 0 first decode) — cvar parsed OK
w> 01000014 XMA-PARAM ctx=0 buf=0 ptr=0x13544000 read_off=32 stereo=1 channels=2 rate_id=3 rate=48000 packets=632 byte_size=1294336 sig_off=1024 head=080000000095fc01c001020408c01f7f0004081023007dfc001020408c01f7f0 sig=004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004
w> 01000014 XMA-PARAM ctx=1 buf=0 ptr=0x13682000 read_off=32 stereo=1 channels=2 rate_id=3 rate=48000 packets=546 byte_size=1118208 sig_off=1024 head=080000000095fc01c001020408c01f7f0004081023007dfc001020408c01f7f0 sig=004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004
w> 01000014 XMA-PARAM ctx=2 buf=0 ptr=0x13795000 read_off=32 stereo=1 channels=2 rate_id=3 rate=48000 packets=572 byte_size=1171456 sig_off=1024 head=080000000095fc01c001020408c01f7f0004081023007dfc001020408c01f7f0 sig=004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004081023007dfc001020408c01f7f0004

Some files were not shown because too many files have changed in this diff Show More