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
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
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
`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
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
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
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.
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
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
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
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
The audio work was three parts and I shipped two. The transcode-fidelity method
and the pinned 5.1 downmix landed; the null sink -- the only one that answers
"what does the GAME play" -- I deferred to "the next natural rebuild window" and
then rebuilt both images four times without doing it.
pulseaudio-utils is now in both, with tools/audio-capture wrapping it: a null
sink is a real device as far as an application is concerned, so Canary and Godot
open it normally and parec records what they emit.
This unblocks the decoder's Q8. The cue-to-event bindings are currently a name
match against the authors' own identifiers -- a plausible guess, not a
measurement -- and capturing what the game plays on a menu move converts them.
`audio-capture run` reports the peak level and warns when the capture is silent,
because silence is the failure that looks like success: a WAV of exactly the
right duration, full of zeroes, because the application opened a different sink.
A duration check alone passes it, which is how a confident wrong number gets
made.
The container was replaced at 11:07:38 UTC, 25 minutes after b9aca6a 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
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 06676d3, 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. 06676d3 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
The last structural fix for the collision class that has bitten three times. Both
containers now clone the repository into their OWN named volume instead of
bind-mounting a human's working tree, so an agent's local git config cannot
capture a human's commits, a credential helper cannot leak a container-only path
onto the host, and a `git add -A` cannot sweep another party's in-flight files.
Cloned once at startup and never auto-pulled: pulling under a running agent
moves files out from under whatever it is mid-edit, which is the same bug again.
Accepted knowingly: Claude Code keys per-project memory off the working
directory, so moving off the host path starts that memory empty. The corpus in
docs/ is the memory that matters and it travels with the clone.
Other changes:
* docker/agent -> docker/decoder; the launcher is sylph-decoder. Roles, not
"the agent", now that there is more than one.
* /reborn is gone -- one repository now, so the port reads HANDOFF from its own
checkout rather than through a live read-only mount of someone else's tree.
* Canary mounts separately at /canary; it stays a fork tracking upstream.
* A shared `sylpheed-exchange` volume at /exchange, with tools/ on PATH so
`share` is available in both.
* The decoder's credential file gets the .host-copy treatment the port already
had -- `credential.helper=store` rewrites by rename-over-target, which is
EBUSY on a bind mount and reports a fatal that is not one.
* Budget split deliberately: decoder 5 cpu / 6 GB, port 3 / 4, leaving room for
the planned Referee. "Half the host" was right when there was one agent.
Prompts move to docs/agents/ and are rewritten around the protocol: the oracle
is the running game, dynamic RE stays with the decoder, each iteration must
attempt to refute one claim of the other, and neither may verify its way out of
its own role.
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
Two fixes to push-work and the policy that goes with them.
**Tags.** The port's exporter depends on sylpheed-formats BY REVISION, so a
commit of ours is part of its build -- and the commit it pinned lived on one
auto/* branch and nowhere else. Deleting that branch orphans it; squash-merging
it is worse, because squash creates NEW commits, so main appears to contain the
work while the pinned sha becomes unreachable and the port stops building for a
fresh checkout. Silently, at their build, long after the breakage.
push-work now pushes with --follow-tags, which publishes annotated tags
reachable from the pushed commits, and MISSION.md says to tag whatever the port
needs. formats-pin-2026-08-29 at 7eeae30 is the first, created after the fact.
**Credentials.** This script set credential.helper as --local config, which
PERSISTS in the repository. The repo is a bind mount the host also uses, so the
host's git inherited a path that exists only inside the container and every host
push failed with "unable to get credential storage lock". Now applied with -c to
the single push. Same fix already landed on the port side; this copy still had
the bug and would have re-leaked on the next push.
Committed with a pathspec so the agent's in-flight work is untouched.
1 SPLASH ADDRESSING (was blocking P3). No content predicate exists: design size
fails (every extra composable bundle sampled is 1280x720, like every screen) and
element count fails (fragments run 2..15, the splash halves are 3 and 7). But
GP_TITLE needs none -- `--all` adds exactly four bundles there and all four are
real screens, with the --all index equal to the pak entry index 1:1. And there
are TWO splash screens: 11/14 are the developer logos, 10/13 are the SQUARE ENIX
publisher wordmark, which the port did not have and which the boot shows first.
2 FADE-OUT (was blocking P3). It is (a), and it is bigger than the fade quad.
Every element ends on exactly ONE untimed keyframe, which rules out (b); that
block is where the screen plays out -- quad to a=255, buttons/labels/glows to
a=0, frames hold. (c) is refuted by a null test that discriminates: a black quad
alone holds the button/background brightness ratio constant, and through the
fade it falls 6.50 -> 1.94, 3.4x monotonic.
3 FOCUS (saves P5 rework). Over-vs-instead is unobservable -- the focused sprite
covers the base at 100.0% of base-visible pixels on three pairs once aligned
(true offset (7,7); the centre alignment reads a misleading 78-84%), and
compositing both ways differs by RMSE 1.1 inside the button rect. The real defect
is the focus record's SECOND element: ptbtn0Nf.rat declares ptbtneff01.t32 (a
42x46 glowing ring, focus only) plus the bright label, where the base declares
one sprite. That ring is the marker the port draws nowhere.
5 GAMMA. The capture is not neutral: capture ~ 255*(render/255)^g, g ~ 1.34-1.49,
and the chain says it is a ramp the GAME installed, not a capture artefact. So
RMSE against captures has a floor. Reach stated: the flat patches are all dark
(render ~0-60), so midtones and highlights are unconstrained.
4 ROTATION is a human's call and is recorded in MISSION, not acted on -- the port
rotating while the reference renderer does not would make verify-screen report a
large diff meaning "the port is right". The RE half is answered: rotation is
about the declared pivot, measured against a GPU capture.
The focus record's +20 element-count word is marked 🟡 not ✅ -- read on GP_TITLE's
ten button records only; the disc-wide check is written and still running.
Closes the reach caveat the `opt ` name fix left behind: 60 of 18 002 RATC
children carry no `opt ` block, and it was not established whether they lack one
or sit past our 128-byte window.
Neither. They are not children. `examples/ratc_optless_children.rs` re-runs
`ratc::parse`'s own guards over the disc and reports which one fired: all 60 are
"tag beyond the window", none is rejected by length, gap or charset, none is
child #0, and all 60 live in six bundles of one archive. Within a bundle the
distances back to the nearest tag are an exact arithmetic progression, step
60 600 -- ten different records finding the SAME tag, because there is only one.
Reading a bundle directly: children 1..10 are equal-size T8aD blocks under a
single `opt ` name, `pb_f15_eg_anm.tan`. `.tan` is a FRAME SEQUENCE. One block
declares the resource; its payload is a run of T8aD frames.
Disc-wide, over all 18 718 `opt ` names in all 33 paks: a RATC bundle names
exactly six kinds of resource -- `.t32` 14 756, `.rat` 3 311, `.prm` 367,
`.tbm` 224, `.sbo` 54, `.tan` 6. Six `.tan`, ten frames each = 60, the entire
population with nothing left over. The negative is closed, not narrowed.
Consequence recorded but deliberately not fixed: `ratc::parse` over-reports
there, listing a `.tan`'s frames as anonymous children. Nothing in the menu
milestone reads a `.tan` -- it occurs only in GP_READY_ROOM, which S1 ruled
out -- so no screen the port draws changes.
Also a METHOD entry for this container OOM-killing `slb_leading_segment_disc`
under default test parallelism (SIGKILL, no assertion; 8/8 pass with
--test-threads=1).
`ratc::parse` named each child by scanning backwards for the last printable run
of bytes before its magic. The format states the name explicitly instead, in an
`opt ` block: `"opt " | BE32 len | name | NUL | 3 bytes | magic` -- the same
block `ui_layout::opt_link` already read for a button's focus link.
The scan agrees with it 17 918 times out of 17 942 and is wrong 24 times, every
one the same failure: the 3 trailing payload bytes are themselves printable and
beat the real name. For `pteff05.t32` those bytes are `38 41 58` = `8AX`, so the
full-resolution background of all five menu screens registered under a name no
element declares, resolved to no sprite, and `compose` dropped it through an
early `continue` that -- unlike the two arms above it -- records nothing. The
screen lost its background and `screen render` still reported "all resolved".
`8AX` was never a name. Docs that treated it as one are corrected here.
Disc-wide, and the control is the 17 918 the scan already got right: the `opt `
reading reproduces every one of them. Effect on the five screens is the
signature of the same art at twice the resolution -- mean brightness unmoved,
high-frequency detail x1.15..x1.30 -- which is what the separately-measured
`ui-8ax-fullres-background` result said the game draws.
Also closes a long-standing dangling reference: `pmbase.t32`, recorded as "on
the disc nowhere", is the `GP_STAGE_CLEAR` child the scan called `8AX`. RATC
sibling references now resolve 10 148 of 10 148.
Verified: 114/114 sylpheed-formats unit tests (including two new ones pinning
the `8AX` case byte for byte and the no-block fallback), and every disc-gated
integration suite in sylpheed-formats/sylpheed-cli.
Last iteration I claimed the splash's measured_paint_order [0,2,4,6,1,3,5]
records, between its glow and logo halves, the temporal order they were
seen in rather than depth -- because the halves never share a frame.
The no-overlap measurement is right (glows f94-115, logos f116-211). The
inference is wrong, on two independent grounds:
* Wrong source. That vector is not a read of the draw capture. It is a
read of the live screen object's CHILD ARRAY -- ui-screen-runtime.md
records it literally as "paint order (child slots)". A child list has
a definite order whether or not its children are ever drawn together,
so co-occurrence does not bear on it. The capture was the CHECK.
* The order is in the file anyway. paint_order_audit on GP_TITLE entry
11: derived == measured, 0 inverted pairs, 0 same-layer-key ties. The
glows and logos carry distinct T8aD keys (0xa100 < 0xa110), so the
file orders the halves statically, no capture involved.
I asked the question that started this iteration -- do the title and menu
orders have the same problem -- and the answer is that none of the three
does, for the same reason.
What survives is narrower and now recorded with numbers: how much of each
order its capture actually cross-checks. The title capture is stable (8
draws / 12 quads / 5 textures, identical in all five captured frames
across two logs) and confirms 7 of 24 positions; the menu capture is not
(texture 0x11C30000 present in frame 0, gone by frame 3); the splash
capture cannot cross-check its middle at all.
A counting trap worth the tool: count QUADS, not draws. The menu's draw 9
is indices=24 -- six quads batched from one texture. Counting draws reads
9 where 16 are on screen, and an earlier pass of this analysis briefly
"found" three quads for six declarations that way and concluded elements
were missing. They were batched.
METHOD: check what a "measured" value was measured FROM before reasoning
about its limits. The co-occurrence rule is real, and it is specific to
orders read from draw captures.
ui-prm-primitives recorded that where a .prm paints on a screen without a
measured order is unsolved. For the developer splash it is now measured.
Every frame opens with two full-screen draws before any sprite. The
second is untextured in all 212 frames with a constant vertex colour of
FF000000 -- opaque black -- matching palogo_eff0.prm's declaration
exactly: kind 0x10, pivot (640,360) -> 1280x720, one keyframe, a = 255.
So the splash backdrop is an opaque black full-screen quad from the
bundle itself, painted behind every sprite, which is why a splash render
needs --black rather than the default backdrop.
Not a general rule, and said so: the measured main-menu order puts
pteff02.prm at position 4 and pteff00.prm LAST, the latter being the
screen-transition fade.
And a correction to an existing row. measured_paint_order returns
[0, 2, 4, 6, 1, 3, 5] for the splash, described as "the .prm, then all
three glows, then the three logos". But the glows and the logos never
appear in the same frame -- 0 overlapping frames in 235 -- and two
elements that never co-occur have no observable relative depth. Between
those halves the vector records the order they were SEEN IN, not a
front-to-back relationship.
That does not make the render wrong, and element 0 is a real depth
observation since the .prm co-occurs with everything. But the type of the
claim matters: reading the vector as depth invites compositing all seven
elements at once, which is exactly what does not reproduce the screen.
METHOD: two things that never co-occur have no observable relative order;
when recording an order, note which pairs actually appeared together.
The activation decision is code, and MISSION already says the port
authors the sequence -- so the useful move is to hand over the sequence
measured rather than chase the code. From the 235-frame draw capture, at
1 frame = 1/30 s (2 units/frame, 1 unit = 1/60 s, both settled in Q1):
publisher: SQUARE ENIX logo f1-90 90 frames 3.00 s+ at 0.00
(gap, nothing drawn) f91-93 3 frames 0.10 s at 3.00
developer: both glows f94-115 22 frames 0.73 s at 3.10
developer: both logos f116-211 96 frames 3.20 s at 3.83
Three limits, stated with the numbers rather than after them. The capture
opens with palogo_sqex already at a=255, so the publisher phase began
before the window and 3.00 s is a FLOOR -- every "starts at" is relative
to the capture, not to boot. palogo_anima and palogo_anima_eff get 0
draws in all 214 frames, so a third pair's phase is not in this
measurement. And it is one capture, one run: the glow->logo switch being
a single frame boundary with no overlap is a strong shape, but each
duration is one sample.
What is solid is the part that matters: the 0.73 s and 3.20 s phases are
each within 2% of their element's declared span, so the durations are the
bundle's own and only the ordering is authored. That is the difference
between a port transcribing timing and inventing it.
Last iteration I withdrew "a bundle is a palette" because the evidence
did not choose between selective activation within one bundle and two
compositions shown in sequence. The alternative can be killed from the
disc, which I had not tried.
Hypothesis 2 needs a bundle declaring the GLOWS WITHOUT THE LOGOS. There
is none. Every GP_TITLE entry carrying palogo elements:
10, 13 (publisher twins) palogo_eff0.prm, palogo_sqex, palogo_sqex_eff
11, 14 (developer twins) palogo_eff0.prm + all three logos + all three glows
Four entries, and each developer entry declares the complete set of six.
So whichever bundle was active across frames 94-211 -- entry 11, entry
14, or both in turn -- it declared the logos and the glows, while the
game drew two sprites at a time in disjoint phases.
Therefore only a subset of a bundle's elements is drawn at any moment,
whatever the bundle-loading story is. The conclusion no longer depends on
resolving how many bundles are involved, which is why the texture-base
test's failure stopped mattering.
So the claim is reinstated -- this time by eliminating the alternative
rather than by assuming it away. What worked was not a better capture but
asking what the competing hypothesis would REQUIRE on the disc and
finding it absent.
METHOD: a hypothesis that predicts an artefact can be killed by looking
for the artefact, which is often far cheaper than measuring the behaviour.
Last iteration I wrote that a bundle is a palette whose elements are
selectively activated. The disjoint glow/logo phases have two
explanations and I asserted one:
1. one bundle, some elements run then others;
2. two bundle-loads shown in sequence (entries 11 and 14 are twins
declaring identical sprites).
The draw log's tex[base=...] looked like it would separate them. It does
not, and the control is in the same table:
publisher splash f1-90 0x11C30000, 0x10000000
glows f94-115 0x11C30000, 0x10000000
logos f116-211 0x11C30000, 0x10000000
The publisher splash is certainly a DIFFERENT bundle from the developer
splash, and it uses the same base. So 0x11C30000 is a reused upload slot,
not a bundle identity, and the test cannot choose between the two
hypotheses.
Survives: a bundle's declared elements are not what gets drawn.
palogo_anima and palogo_gamearts carry byte-identical keyframe times and
in the same run one is drawn 95 frames and the other none -- and
whichever twin was active declares both. The phases are strictly disjoint
(0 overlapping frames in 235).
Withdrawn: the mechanism. The practical consequence is unchanged --
compositing every element of a bundle does not reproduce what the game
shows over time -- but the why is not established and I stated it as
though it were.
What would separate them: a per-draw capture recording the bundle each
draw came from, or a file-IO log showing whether a second RATC entry is
read between frames 115 and 116.
METHOD: a shared resource address does not identify the resource's owner;
and state the mechanism as a separate claim from the observation, or the
weaker one inherits the stronger one's evidence.
Two checks on last iteration's "sequential, not simultaneous" reading.
First, the phases really are disjoint. If glows and logos ever shared a
frame the claim would be wrong. Across all 235 captured frames the count
of frames containing both is ZERO, and the switch is a single clean
boundary -- f110-115 draw 1280x720 + 262x108 + 525x90, f116 onward
1280x720 + 243x86 + 499x72. Two sprites either side, no transition frame.
Second, and larger: a third of the bundle is never drawn. Entry 11
declares three logo/glow pairs and only two appear.
palogo_gamearts / _eff 95 / 22 frames
palogo_seta / _eff 95 / 22 frames
palogo_anima / _eff never
palogo_anima declares the SAME keyframe times as palogo_gamearts. Two
elements with byte-identical data, 95 frames and 0 frames in one run.
Reach: the capture covers frames 1-214, so this is "never in the window".
So a bundle is a palette, not a script. Its elements say what to draw and
for how long; which of them run, and when each starts, is decided outside
the placement data. That is the same conclusion the boot-order work
reached from the other end -- the driver is code, not data -- now with a
per-element measurement behind it.
For the port, concretely: compositing every element of a bundle does not
reproduce what the game shows over time. It is the right thing for a
static screen that settles, and it is not a timeline.
METHOD: two elements with identical data and different outcomes is the
strongest possible evidence that the decision is elsewhere.
Tested whether the splash timeline, played, reproduces the capture --
the last gap in the animation model. Half of it does.
Durations match. At 2 units/frame under the shifted reading, from the
235-frame draw capture of the developer splash:
glows drawn f94-115 (22 frames = 44 units) declared ~0..45 = 45 97.8%
logos drawn f116-211 (96 frames = 192 units) declared 15..210 = 195 98.5%
Each element is on screen for its declared span to within 2%.
Starts do not. Every glow declares the same times 15,30,45 and every
logo the same 15,30,190,194,206,210, so on one clock they would overlap
almost entirely -- and they do not overlap at all. The glows run 94-115
and the logos 116-211, strictly sequential, the logos starting the frame
after the glows end. Fitting one origin needs f0 ~ 93.5 for gamearts_eff
and ~103 for gamearts, about 19 units apart, and aligning one throws the
other off by ~9 frames at both ends.
The obvious candidate is refuted. parse_placements reads each group
header as (element index, keyframe count) plus one undecoded LEAD-IN
WORD -- exactly where a per-group start offset would live. It is
0x00000000 for all seven elements, glows and logos alike.
Reach: not the keyframe times (identical within each family), not that
word (zero), not declaration order (which interleaves logos and glows
where the observed sequencing groups them), not the RATC child order.
What remains is that the sequencing is code-driven, which agrees with
what the boot-order work concluded independently.
For the port: a group says how long an element animates and what it does,
not when it starts relative to its neighbours. The observed order on the
developer splash -- both glows, then both logos -- is measured for one
screen, not a decoded rule, so the sequencing has to be authored.
METHOD: when a model reproduces durations but not positions, the missing
piece is an origin, not a rate.
The model's sharpest prediction, tested with its control. The draw log
says that on the developer splash the _eff glows are drawn on frames
94-115 and the logos on 116-211, so at the moment the reference capture
was taken EVERY glow is already finished -- including the two that have
plateaus and which rest_plateau therefore renders visible. Suppressing
them should help on the splashes and hurt where a screen genuinely
settles.
publisher splash +0.9604 -> +0.9982 +0.0377
developer splash +0.9659 -> +0.9980 +0.0321
title (control) +0.9500 -> +0.9480 -0.0020
main menu(control) +0.9460 -> +0.8544 -0.0916
EXTRAS (control) +0.9440 -> +0.8370 -0.1070
Both splashes jump to about 0.998; all three persistent screens get
worse. The control is what makes this a finding rather than a
coincidence: the same edit helps exactly where the model says it should
and hurts exactly where it says it should not.
So rest_plateau is not over-drawing in general -- it over-draws on
TRANSIENT screens. A plateau mid-animation means the element is held at
that point in the timeline, not that it is on screen once the screen has
settled. Where a screen settles, the held pose IS the settled pose and
the rule is measurably right.
And that answers the question left open several iterations ago -- what
"rest" means for a transient element. It does not mean anything: the
splashes never rest. A static composite of them can match a chosen frame,
and about 0.998 is what these captures' frame is worth, but the format
does not answer a question the screen never poses.
For the port: play the timeline for the two splashes, which the settled
keyframe timing now supports, and composite statically for title, main
menu and EXTRAS.
METHOD: an edit that improves one set of cases is only interesting once
you have shown it damages the cases where it should.
The weakness in the rest-rule finding was that `last` had been SCORED on
only two elements. It cannot be scored on more -- only two ambiguous
elements sit on a screen with a live capture -- but the entry -> hold ->
exit model makes a prediction that can be checked on all 2 305: what does
each element's FINAL keyframe look like?
final keyframe invisible (a = 0) 1 618 transient: gone at rest
final keyframe visible, at max alpha 675 faded in and stopped
final keyframe visible, BELOW max alpha 12 genuinely unclear
Of the 687 that end visible, 472 have monotonically non-decreasing alpha
-- a plain fade-in that stops, [0, 255] over two keyframes in the
commonest case (pjex_eff.rat, pghud_speed_cut.t32) -- and another 203 end
at their maximum after dipping.
So `last` is structurally defensible for 2 293 of 2 305 (99.5 %), against
a dwell rule that returns a mid-movement frame by construction. Observed
correct for 2, structural for 675, model-consistent for 1 618, unclear
for 12.
The assumption carrying the 1 618 is stated rather than buried: that a
plateau-less element's animation has finished by the time the screen is
settled. The draw log establishes exactly this for the two splash glows
(drawn frames 94-115, logos 116-211) and establishes nothing for the
rest.
Default still unchanged. The case is now observational, structural and
model-based rather than two data points, but it would move 1 896 elements
and the decision belongs with whoever owns the renderer.
The shifted keyframe-time reading looked like it implied something
simple: the final pose is reached at a definite time and nothing follows,
so rest should just be the last keyframe and the plateau heuristic could
go. Tested by applying it to EVERY element:
title +0.9500 -> +0.6819 -0.2681
main menu +0.9460 -> +0.6416 -0.3044
EXTRAS +0.9440 -> +0.5745 -0.3695
publisher splash +0.9600 -> blank (zero variance, corr undefined)
developer splash +0.9643 -> blank
Refuted, and the failure supplies the model. A group is entry -> hold ->
exit, and the exit is the screen's DISMISSAL. While a screen is displayed
it has not reached its last keyframe; it is sitting at the hold. So
rest_plateau is the correct primary rule, and the last keyframe is the
post-exit state -- correct only once the screen is gone, which is why
applying it everywhere blanks the splashes.
This does not contradict the shifted reading. That reading says when each
pose is reached; it says nothing about the group being played to
completion while the screen is still up. The step between them was mine.
And it explains why last wins for the two plateau-less elements: an
element with no hold is a transient, it flashes and is over, and at any
settled moment it is gone -- which is its last keyframe. The draw capture
says the same independently: on the developer splash the _eff glows draw
on frames 94-115 and the logos on 116-211, so the glows are already
finished when the logos are up.
Three independent observables -- animation timing, static composites, and
the per-frame draw log -- now agree on one rule: plateau where there is
one, last keyframe where there is not.
METHOD: a blank render is a NaN correlation, not a low score, and that
NaN was the strongest form of the result; and when a model predicts
something the measurement refuses, suspect the step you supplied between
them.
The open question was whether "last keyframe" holds beyond the two
elements I could score against a capture. It cannot be scored disc-wide --
only two ambiguous elements sit on a screen with a live capture -- but the
blast radius can be measured, and it argues the same way.
genuinely ambiguous elements 2 305
the two rules AGREE on 409 (17.7 %)
they DIFFER on 1 896 (82.3 %)
dwell (current): invisible pose 1 711 (74.2 %), zero-scale 195 (8.5 %)
last : invisible pose 1 618 (70.2 %), zero-scale 43 (1.9 %)
Two things follow. It is not a marginal choice: the rules disagree on 82%
of the affected elements, so "either is fine" is not available. And the
current rule produces 4.5x more degenerate poses -- a zero-scale pose is
collapsed to nothing, i.e. an element's PRE-ROLL before it has grown in,
which is definitionally not a rest. 195 elements currently rest at a frame
they are only passing through, against 43 under last.
That is an argument from the data's own structure rather than from the two
captures, and it points the same direction.
Kept honest: it is indirect. Fewer degenerate results is not the same as
more correct results, and last still returns an invisible pose 70% of the
time -- right for a transient element, wrong for a persistent one. The
default stays put; the numbers are in HANDOFF for whoever decides.
Scored candidate rest-pose rules by rendering and correlating instead of
arguing, and both results correct something I had published.
First, the exposure. The guessing fallback is reached only by an element
that is plateau-less AND multi-keyframe -- a single-keyframe element
short-circuits at `match len { 1 => first }`. Per screen:
title (4) 24 elements 2 plateau-less 0 reach the fallback
main menu (5) 16 5 0
EXTRAS (6) 18 5 0
publisher splash (10) 3 2 1
developer splash (11) 7 2 1
So on the three screens the port cares most about, rest() never guesses.
That is why three different rules render builds 4/5/6 to identical
correlations -- the code is unreachable there, which I nearly read as
"the choice does not matter".
Second, where it does fire, the last keyframe is markedly better:
publisher splash dwell +0.9600 last +0.9982 maxalpha +0.9600
developer splash dwell +0.9643 last +0.9758 maxalpha +0.9643
That refutes my own earlier refutation. I had killed the last-keyframe
rule by arguing it makes palogo_anima_eff invisible while its two
siblings stay lit, which looked like an artefact. The capture says
otherwise: making it invisible is what improves the match. The sibling
symmetry was my expectation, not evidence.
Caveat kept in front: both captures are single frames of a transient
animation, so this fixes which pose matches THOSE frames, not which is
canonically at rest. Default unchanged -- better on both screens where it
fires and identical on the other three, but it would move 2 305 elements
disc-wide on two measurements. Reachable via SYLPHEED_REST_RULE=last.
Also confirmed: all 195 zero-scale rest poses are inside the corrected
2 305 ambiguous population; none is a single-keyframe element.
METHOD: score a rule where it can differ, or you measure nothing; and an
argument from symmetry is a prediction, not a refutation.
Two corrections from one experiment.
A keyframe group is entry -> hold -> exit, and the exit ends invisible:
on the five port screens the final keyframe is invisible for 21/24
(title), 8/16 (main menu), 12/18 (EXTRAS), 2/3 and 6/7 (splashes). So the
screen as seen is the HOLD, which is why rest_plateau is the right
primary rule and why "rest = last keyframe" would empty every screen.
That suggested a fix: an element with no hold has no representative pose,
so draw nothing rather than guess an endpoint. Tested through compose's
visible mask and correlated against the live captures:
title +0.9500 -> +0.6839 -0.2661
main menu +0.9460 -> +0.9037 -0.0423
EXTRAS +0.9440 -> +0.9094 -0.0346
Refuted on all three, and the reason invalidates a number I published. An
element with a SINGLE keyframe has no adjacent pair, so the plateau test
marks it plateau-less -- but its one pose is unambiguously its rest.
Suppressing those removes backgrounds and full-screen layers, which is
the title's -0.27.
no plateau (as published) 3 807 (24.57 %)
... single-keyframe 1 502 trivially at rest, not a guess
genuinely ambiguous 2 305 (14.88 %)
So rest() guesses for 2 305 elements, not 3 807 -- the figure I gave the
port overstated the defect by 65%. Corrected in HANDOFF and the page.
METHOD: a predicate over adjacent PAIRS silently misclassifies a
one-element list; and acting on a claim is a better test of it than
re-reading it -- this flaw survived a census, a write-up and a handoff
row, and died the moment the rule was used to change a rendering.
The case for reading +36 as "the time the NEXT pose is reached" rested on
one element's fade-out shape, then on one element's hold duration. Both
splash halves supply more, and they agree.
element screen observed hold as decoded shifted
palogo_gamearts developer splash 83 f 8 f 80 f
palogo_seta developer splash 83 f 6 f 80 f
palogo_sqex publisher splash >=77 f * 6 f 102 f
* the capture opens mid-hold at frame 1, so 77 is a floor.
The readings predict opposite structures. For palogo_gamearts, as decoded:
hold 8f, in 80f, hold 2f, out 6f, out 2f -- an eighty-frame FADE-IN and a
two-frame hold. Shifted: in 8f, hold 80f, out 2f, out 6f, out 2f. The
capture shows an 83-frame hold and no fade-in at all.
The elements that cannot discriminate are not contradicted:
palogo_gamearts_eff observed in 7f / hold 7f / out 8f, and both readings
give 8f phases -- with four blocks the shift only relabels which phase is
which. So the glows, which is where Q1's linear law was measured, say
nothing either way rather than arguing against.
The decoder's default is still unchanged, and the reason is now
articulated rather than assumed. The single thing opposing the shift is
rest() on ptlogo_eff3, where the shifted reading makes the longest-dwell
fallback return the bloom's 200% peak. That fallback is unsound whenever
it runs -- it returns an endpoint of a movement, neither of which is held
-- and checked: the shift does not fix it either. So the objection was
never evidence about the times. Timing had three discriminating
measurements; pose selection had a heuristic guessing.
For the port: animation timing should use the shift; static composites are
unaffected and the five screens' correlations stand. Classified measured,
not decoded -- three elements in one screen family, not a disc-wide check.
The answers for the port's five screens were spread across a dozen
documents and none of them said how good the result IS. Measured:
screen build drawn corr vs capture alignment
title 4 15/24 +0.9500 dy=0 dx=0
main menu 5 11/16 +0.9460 dy=0 dx=0
EXTRAS 6 13/18 +0.9440 dy=0 dx=0
publisher splash 10 2/3 +0.9600 dy=0 dx=0
developer splash 11 6/7 +0.9643 dy=0 dx=0
Every one aligns at exactly zero offset over a +/-2 px search in both
axes, so placement and scale are right and the residual is tone and
detail rather than geometry.
The drawn/total counts are not slack. Each undrawn element has a reason
already documented: kind & 0x4 ghost instances (4 on the title), .prm
primitives off by default (2 per menu, 1 per splash), loop* animations
off by default and off-screen at rest (2 per menu screen), and the 8AX
name mismatch (1 per menu screen) whose art reaches the screen anyway via
ptbase. 4+2+2+1 = 9, 2+2+1 = 5, 1. Nothing unexplained.
The residual is ranked for a consumer: tone first (gamma 1.34-1.49, the
game's own display ramp), then 8AX resolution, then one drawable
paint-order tie on EXTRAS alone, then rotation-decoded-but-not-rendered
which does not affect these five at rest.
Reach stated: these are static composites at the resting pose against
single frames, so nothing here speaks to animation, and a whole-frame
correlation is a sanity figure rather than a per-element check.
A per-sprite premultiplied-vs-straight-alpha flag would matter a lot to a
port and has a sharp static signature: premultiplied means RGB <= A
everywhere. Over the 170 decoded GP_TITLE textures that pair to a flag
word:
bit SET n= 61 mean %(RGB>A) 55.52 median 52.52
bit clear n=109 mean %(RGB>A) 33.66 median 30.17
Premultiplied requires ~0% for the flagged group. Both groups are far
from it and the flagged group violates MORE -- the opposite of the
hypothesis. Refuted.
What remains is a weak association: flagged sprites carry more
bright-RGB/low-alpha pixels, which is what glow art looks like. But the
best single threshold classifies 76.5% against a 64.1% base rate -- a
12-point lift with badly overlapping distributions. A tendency, not a
rule, and reported with its base rate so it cannot read as more.
Noted for whoever returns: "0x02 selects an additive blend" was refuted
by blending those sprites additively and finding every measure worse
against the capture -- but that ran through a title render since fixed
twice (rest_plateau, and the 8AX background the composer drops). The
refutation may well stand; it was measured through a renderer with known
other errors, so it is worth one re-run if blit ever gains additive
blending.
Parking the field. Four candidate meanings are dead -- additive blend,
eff name in both directions, transient element, premultiplied alpha --
none produced a positive account, and the bit blocks nothing: the port's
screens composite at 0.947 correlation against a capture without it. The
negative space and the sound attribution method (child order, not size)
are written down so a later attempt starts here.
METHOD: report a classifier's lift over its base rate; and park a field
after N failed hypotheses, saying what was eliminated.
Last iteration I killed the biconditional and reported that the one-way
reading survived: all 10 bit-set sprites on GP_TITLE build 4 are eff
names, so "bit set => eff name". Checked over the disc, that is false.
sprites with a resolvable preceding name 14 709
bit SET & name has 'eff' 2 338
bit SET & name lacks 'eff' 2 657 <-- counterexamples
bit clear & name has 'eff' 1 399
bit clear & name lacks 'eff' 8 315
P(eff | set) = 0.468
P(eff | clear) = 0.144
The implication fails more often than it holds. What survives is an
association -- 3.3x enrichment -- and build 4's 10/10 was a local naming
habit in an 18-element bundle, not a format rule.
The counterexamples are the useful part: pv_loading_ring0,
pv_loading_light0-3, pv_loading_line, px_bunk_line, px_top_extra. Rings,
glows, lights, thin lines -- effect-like artwork that does not carry the
eff naming convention. Consistent with the bit marking effect sprites by
authoring intent rather than by name, which is a description and not a
decode, and is labelled as such.
Names here come from the string immediately preceding each T8aD,
validated 17/18 on build 4 against the RATC child order; the single
mismatch is the known pteff04.t32 -> registered as 8AX case, so this is
the element (opt) name rather than the sprite's registered name. That
mismatch is itself an independent confirmation of the 8AX finding,
reached from the opposite direction.
METHOD: a pattern perfect on one screen can be near-chance on the disc;
and when an association survives a refuted implication, the
counterexamples are the finding.
The bit at +0x04 was recorded as a real field with its meaning "not
diagnosed", noting ptlogo_back2eff is 0x8830 "despite its name". That note
rested on a size match -- and its size is ambiguous, which is the trap
this corpus already records.
First, a sound attribution. T8aD headers appear in the bundle in RATC
CHILD ORDER, verified on GP_TITLE build 4 against an independent property
-- each header's decoded dimensions versus the dimensions the named child
should have: 18 of 18 match, 0 mismatches. Two of those eighteen share a
size (ptlogo_back2eff and ptlogo_back2eff5, both 1133x280), so a size-keyed
lookup cannot separate them; ordering can. Index 12 is back2eff5 (0x8832,
bit set), index 14 is back2eff (0x8830, bit clear). The documented
counterexample is real and correctly attributed -- now on evidence.
Two candidate meanings tested and refuted:
bit <=> name contains "eff" REFUTED: ptlogo_back2eff is an eff
name with the bit clear. All 10
bit-set sprites are eff names, so
the implication holds one way only.
bit <=> the element is transient REFUTED: pteff03/pteff03a carry the
bit and run to t=250, ramping to
a=255 and holding.
Up close, the exception pair differs in two header words: +0x04
0x8832/0x8830 and +0x08 0x8083/0x8081 -- layer keys 32899 and 32897. They
are NOT duplicates: their alpha summaries agree to one decimal (4.5%
opaque, 86.7% clear, mean 19.3) but a pixel compare gives max abs diff 21.
Two renditions of one image at one size, which is why the summaries were
not trusted.
Still not diagnosed, and said so -- but the search space is two smaller
and the attribution beneath it is now sound.
METHOD: T8aD headers sit in child order, use that not the size; and
identical summary statistics are not identical data.
Continues the previous iteration, where the game was measured calling
VdGetCurrentDisplayGamma at video init. The remaining link -- does it then
WRITE the ramp -- is a GPU register operation (XE_GPU_REG_DC_LUT_RW_INDEX
in CommandProcessor::WriteRegister), unlogged and invisible to kernel
logging. Two facts from the source close it without instrumenting.
1. The swap-path gamma stage is a PURE LUT. apply_gamma_table.xesli is
the whole transform: index by input*255, fetch from a 256-entry ramp
buffer, output. No sRGB encode, no second transfer function.
2. The table DEFAULTS TO IDENTITY. CommandProcessor::Initialize fills it
with value = i * 0x3FF / 0xFF, and its own comment says the linear
default is "what games set when starting with the sRGB (return value 1)
VdGetCurrentDisplayGamma". An unwritten ramp is a no-op.
So the only transform is a LUT, the LUT is identity unless written, the
game queries the display gamma at init, and the capture differs from our
composite by gamma 1.34-1.49 -- which identity cannot produce. The guest
wrote a non-identity ramp.
Labelled an inference, with its weak joint named: it assumes our
composite reproduces the PRE-RAMP framebuffer, which it does not exactly.
What carries it is the shape -- a systematic ~1.4 fitted on flat patches
across three screens is not a compositor bug. The obvious alternative, a
fixed sRGB stage in the presenter, fits neither direction: an encode
(^0.45) brightens and we measured darkening; a decode (^2.2) darkens far
more than 1.4.
Direct observation remains available and cheap, and needs the emulator
only to boot: a GPU trace records gamma ramps as their own command type,
or one log line at the DC_LUT register write would settle it outright.
Not done.
METHOD: a default value is evidence; and name the weak joint of an
inference in the same breath as the conclusion.
Last iteration's corrected experiment, run. Boot with --log_mask=12
--log_level=3 (Kernel logging on, Cpu/Gpu off), which changes nothing
about the output and so cannot perturb the capture harness the way the
gamma-cvar experiment would have.
VdGetCurrentDisplayGamma is called once, at video init:
d> VdGetSystemCommandBuffer(701CF830, 701CF804)
d> VdGetCurrentDisplayGamma(701CE1F8(00000000), 701CE1F0(0))
d> VdSetDisplayMode(40000000)
d> VdGetCurrentDisplayInformation(701CF110)
The control is in the same log: 359 VdRetrainEDRAM and 358
VdGetSystemCommandBuffer lines, so an absent call would have been
visible. Per the export's own comment the returned type is "used in D3D
SetGammaRamp/SetPWLGamma" -- the game asks the question a ramp-builder
asks, at the moment one would ask it.
Still open, and stated: whether it then WRITES the ramp, and whether the
measured gamma 1.34-1.49 is that ramp. The write is a GPU register
operation (DC_LUT), invisible to kernel logging; a GPU trace records
gamma ramps as a command type (TraceWriter::WriteGammaRamp), which is
where to look next.
Worth its own METHOD line: this had been parked behind "needs the
emulator to reach a menu" for several iterations, and it needed the
emulator only to BOOT -- video init happens in the first seconds. A
blocker that stops one experiment does not stop every experiment in the
same area.
I had parked the tone-curve finding behind "this may be the emulator, not
the game: canary applies kernel_display_gamma_type = 2 (BT.709) on
output", with a planned run setting it to 0 and re-fitting. Reading the
source kills both the confound and the experiment.
VdGetCurrentDisplayGamma_entry is a kStub GETTER the guest calls
(xboxkrnl_video.cc). Its own comment: "Used in D3D SetGammaRamp/
SetPWLGamma to adjust the ramp for the display." The cvar is a value
REPORTED TO THE GAME, which then builds its own ramp. Canary's role is
downstream: the guest writes DC_LUT, command_processor.cc reads it into
gamma_ramp_256_entry_table_, and the swap path applies it via
swap_apply_gamma_pipeline_layout with apply_gamma_table.ps /
apply_gamma_pwl.ps compiled in.
So there is no emulator-side BT.709 post-process to subtract, and any
gamma in a captured frame is a ramp the game installed.
What is NOT established, and the reach is stated: that this game installs
a ramp at all, or that the measured 1.34-1.49 is it. The run logs cannot
say -- kernel exports log at Debug and this harness masks Kernel logging
(log_mask = 13, per boot_menu.sh's own comment), so their silence is
guaranteed regardless of what the game did.
The planned experiment was wrong in design: changing the cvar changes
what the GUEST is told and therefore which ramp the GAME builds, so it
could never isolate a stage that does not exist -- and it perturbs the
capture harness, since skip_intro classifies movie-vs-static on an
absolute rmse threshold that a brighter frame biases. The right run
changes nothing about the output: LOG_MASK=12 LOG_LEVEL=3 and look for
the call and the DC_LUT writes.
Also for the port: the ramp depends on the display type the game is told,
and canary hard-codes TV/BT.709 where hardware uses a console setting. So
this is a display profile, not a fixed property of the game.
METHOD: read what a cvar does before building an experiment around it;
and an absence in a log is only evidence if the log would have shown it.
I had parked this as "needs a per-draw capture recording texture base
addresses". It did not. 8AX (1280x720) and ptbase (640x360 at 200%) are
the SAME artwork at two resolutions, which is exactly why comparing
either against a capture is inconclusive -- and why comparing their
DIFFERENCE is not. Compute 8AX - upscale(ptbase), the detail only 8AX
has, and ask whether the capture contains it.
Both candidates are first mapped into the capture's tone domain with the
measured gamma; without that the residual is dominated by the tone
difference and the test is blind.
main menu corr +0.0475 controls +0.0032 shift, -0.0075 flip 68% of ceiling
title corr +0.0634 controls +0.0095 shift, +0.0086 flip 68% of ceiling
Two independent screens, both at 68% of the theoretical ceiling
(sd of the 8AX-only detail over sd of the capture residual), 7-15x their
matched controls. The controls preserve spatial correlation and destroy
only alignment, so they are what "no signal" looks like.
So the recommendation changes: resolve the name and draw 8AX at 1:1.
Upscaling ptbase 2x is wrong, not merely softer. Still do not draw both --
an opaque layer over an identical one costs fill and hides later changes,
and ptbase's element is the one carrying the keyframes, so a consumer
needs its timing with 8AX's pixels.
Also recorded and withdrawn: a cruder pixel-pair test gave 0.00-0.72 for
upscales, 0.98 native and 1.01 for the capture -- apparently decisive.
Additive noise raises both terms of that ratio equally and drives any
value toward 1; fitting a noise term, both "native + noise" and "bilinear
+ noise" reproduce the observed numbers. The conclusion is right, that
test does not establish it, and it is in REFUTED because the number looks
conclusive and is not.
Not shown: whether ptbase is also drawn underneath. 8AX is ~86% opaque
and carries the same art, so it would hide it either way.
A resolve-check on HANDOFF's own rows. Q8 read "SE audio is undecodable
from the disc -- no XACT container exists anywhere". menu-audio-cues.md
retracted exactly that ("### Retracting 'cannot be extracted'") and
locates three cues in Static.slb that decode to PCM: d-pad move 0x1ec0
(4 packets), (B) back 0x0ec0 (2), (A) confirm 0x5d6c0 (6), all mono
48 kHz. The retraction landed in docs/re/ and the page the port reads
kept the superseded text -- the fourth time in this corpus.
Writing the rule down has not worked, so there is a tool now.
handoff_lint.py flags every HANDOFF line making a strong negative claim
that links a doc containing retraction language. First run: found the Q8
row, plus one benign false positive (Q3 links a doc whose retraction is
about a sprite count, not about the tie-break -- checked, and HANDOFF
repeats none of the retracted figures). The lint also caught its own bug
first: it reported existing docs as missing because it joined a guessed
repo root, so it now resolves links relative to the file as markdown does.
Separately, EXTRAS's paint-order risk narrows twice more. Of its 15 tied
pairs only 2 overlap, and of those, ptloop01 x ptloop02 are loop*
animations compose skips by default -- so exactly ONE tie can be drawn:
ptframe3 x ptframe4, overlapping 102x132 px. Against live-extras.png that
contested region correlates +0.9622, better than the whole frame (+0.9440)
and inside the range of regions where order cannot matter (+0.8502 /
+0.9903). Consistent with our order, not proof: correlation cannot see a
swap between locally similar art.
15 -> 2 -> 1 -> consistent is now the whole paint-order risk on the five
screens, and HANDOFF says so.
Q3 was delivered as "decoded: a u16 layer key at +0x0A". The audit last
iteration showed the key does not fully order a screen -- elements
sharing a key are tied, and on the title that tie-break decides two total
occlusions. This searches for what breaks the tie, and closes it as a
negative with reach.
The game paints the five tied ptlogo_back2eff glows in the order
eff1, eff2, eff5, eff3, eff4. Three static structures were searched:
1. The declaration table. Entries 14-18 are byte-identical apart from the
pivot, which is only half the sprite's own size.
2. The T8aD headers. All five carry identical +0x04 (0x8832) and
identical +0x08/+0x0A (32899, the key itself), differing only in
position and tile count. Searched exhaustively -- every offset
0x00-0x7f, u8/u16/u32, ascending and descending:
fields sorting to the MEASURED order: 0
fields sorting to the DECLARATION order (control): 64
The control is the point: 64 fields can be found that reproduce a
known ordering, so the scan finds ordering fields when they exist. It
finds none for the order the game uses.
3. The RATC child order -- a genuinely different permutation on other
screens -- gives eff1..eff5 here, declaration order again.
All three static orderings give eff1..eff5; the game gives eff1,2,5,3,4.
That agrees with ui-screen-runtime's conclusion from the other direction:
the game builds a reordered child list at load time and paints that.
Q3 now reads honestly: the layer key is decoded and orders 4 of the 5
measured bundles exactly; the tie-break within a key is undecodable, and
a consumer must use a measured order or accept declaration index as an
arbitrary stand-in. The port's exposure remains 2 overlapping tied pairs
on EXTRAS.
METHOD: an exhaustive field search needs a positive control, or "found
nothing" is worthless.
`compose` claimed the derived paint order "reproduces both measured
orders up to ties". That sentence was never measured and was stale by
one: there are three measured orders, not two. examples/paint_order_audit.rs
checks it.
main menu (entries 5, 8) derived == measured 0 inverted pairs
developer splash (11, 14) derived == measured 0 inverted pairs
title (entry 4) DIFFERS 8, all same-key ties
So the claim holds and the exception is entirely ties -- but two of those
ties are total occlusions, not near-misses. The tied family is the five
ptlogo_back2eff glows (key 32899); back2eff5 is 1133x280 and FULLY
CONTAINS back2eff3 (82,824 px^2 = 100% of the smaller) and back2eff4
(152,047 px^2 = 100%). Derived paints it on top of two glows it entirely
covers; the game paints it underneath. A tie-break by declaration index
can therefore be wrong by a whole layer. The title itself is unaffected --
it has a measured order.
The port's actual exposure, per screen: title, main menu and developer
splash all use MEASURED orders; the publisher splash is derived but has
ZERO ties, so it is fully determined; EXTRAS is derived with 15 tied
pairs of which only 2 OVERLAP. Two element pairs on one screen is the
whole risk, and that is what HANDOFF now says -- not the raw 15, which
would have overstated it 7x.
Reach stated: this compares the derived order against orders measured
from the game, not an independent derivation, so where no measured order
exists only the tie exposure can be checked. Overlap uses pivot*2 as the
element size at its resting placement.
Stale comment in compose corrected. METHOD: a hedge in a code comment is
an unmeasured claim; and count the cases that can bite, not the ones that
match the pattern.
Three earlier "the title never appears" claims came from instruments
later found broken -- a stale pixel oracle, a 41 s sampling interval, a
freezing stream. This one carries its own evidence.
title_probe_xchecked.py restarts its capture stream every 30 s AND prints
its reading beside an independent `import` grab every 60 s:
1851 frames in 560.2 s = 3.30 fps
cross-checks 9, disagreements 1
max glyph 0
t= 62s stream 6.05 | import 0.07 disagree (a fade, logos mid-transition)
t=123s stream 7.40 | import 7.49 agree
t=183s stream 8.18 | import 8.29 agree
t=243s stream 0.23 | import 0.10 agree
t=311s stream 89.68 | import 89.51 agree
t=371s stream 80.97 | import 81.58 agree
t=426s stream 117.43 | import 117.72 agree
t=487s stream 77.71 | import 76.25 agree
t=546s stream 70.43 | import 70.55 agree
Eight of nine agree within 2%, fps held at 3.30 with no collapse to 1.60,
and the surface moved through dark and bright phases. So the frames were
live: over 560 continuous seconds from launch, sampled 3.3 times a
second, the interactive title's green (A) plate never appears while the
game renders throughout. The final frame correlates 0.0145 / -0.0047 /
0.0102 with our title / main menu / EXTRAS renders -- attract-movie
content, not a UI screen.
Why remains unknown. live-title-press-a.png with its 753 glyph pixels
proves the title was reachable from this container on 2026-08-28, and
clearing the shader cache fixed the black surface but not this.
The two emulator-side questions (gamma control, 8AX vs ptbase) are
therefore blocked on a characterised failure rather than a suspicion.
Neither blocks the five menu screens, so I am returning to static work;
the probe is committed for whoever picks it up.
METHOD: a probe that cross-checks itself turns "no result" into a result.
Last iteration I blamed four failed runs on the probe sampling every
~41 s, slower than the title screen lasts, and withdrew three earlier
conclusions on that basis. Building the fix tested the claim and killed
it.
The speedup is real and control-verified. One long-lived ffmpeg x11grab
stream, raw RGB, glyph counted in numpy -- no per-sample process startup,
no PNG encode, no convert -crop:
wrapper `screenshot` 3.98 s per sample (emulator running)
import -window root -> PPM 1.20 s
long-lived x11grab stream 0.29 s 13.7x
The counter is byte-identical to is_title.py: 753 on the committed title
capture, 327 on the main menu.
Pointed at a running game it says the opposite of what I expected:
332 frames in 85.3 s = 3.89 fps; max glyph 0
1674 frames in 420.0 s = 3.99 fps; max glyph 0
1674 consecutive samples over seven unbroken minutes, four per second,
zero green-(A) pixels. Sampling rate was a real defect that happened not
to be the cause.
So "neither locale reaches the interactive title without a pad press" --
withdrawn last iteration for want of evidence -- is reinstated, now as a
dense measurement, with its reach stated: a MID-RUN window only, silent
about the boot title.
Leading hypothesis, unconfirmed: the PRESS (A) plate appears only in the
boot title window and the attract loop's title carries none, which is
exactly what title_states_capture.sh was written to test. The experiment
is to start the fast probe from t=0 rather than attach to a run already
in progress.
METHOD: fixing the instrument is how you test the explanation that blamed
it -- a plausible mechanism is a hypothesis, and the fix is its
experiment, not its proof.