Compare commits

..

28 Commits

Author SHA1 Message Date
sylph-decoder
08416dbd0b fix(viewer,cli,export): clear the remaining 30 clippy lints
`cargo clippy --workspace -- -D warnings` now exits 0. `cargo test
--workspace` still reports 207 passed, 0 failed, 14 ignored across 30
suites — identical to runs 203 and 204, so none of this changed behaviour.

The workspace total was 73, not the 48 run 204 reported. `-D warnings`
turns a lint into a hard compile error, so `sylpheed-formats` failing
stopped its dependents from ever being built: `sylpheed-viewer` (14) and
`sylpheed-cli` (11) had never been linted by anyone. Clearing formats in
bc79817 is what made them visible.

  formats  43 -> 0   (bc79817)
  viewer   14 -> 0
  cli      11 -> 0
  export    5 -> 0

Collision surface, measured rather than assumed. Every viewer file
carrying a lint is byte-identical on both `auto/frame-blend-draw-path`
(495 commits) and `auto/port-p6-audio` (366). All eleven cli sites fall
outside every hunk either branch touches. 68 of the 73 sites could not
collide with anything.

The five that can are all in `sylpheed-export`, and three of those are
real:

  main.rs:278   `&out` -> `out`, inside frame-blend's hunk -278,12
  main.rs:318   `&out` -> `out`, inside port-p6-audio's hunk -303,44
  audio.rs:113  an added `#[allow]` in a file frame-blend DELETES

Each is one line. Resolving the first two means taking the branch's
version and re-applying a borrow removal; the third resolves to the
deletion. Flagged here so neither branch owner meets them cold.

Judgement calls, all stated at the site rather than suppressed globally:

* Three `too_many_arguments` in the viewer are false positives.
  `draw_viewer_ui`, `poll_loader_channel` and `apply_pak` are Bevy
  systems — every parameter is a `Res`/`ResMut`/`EventWriter` the
  scheduler injects, so the count is the framework's dependency list and
  cannot be reduced without a `SystemParam` struct.
* `cmd_screen_render` (cli, 8/7) is a plain function, so that one is real
  if mild; its arguments are the subcommand's flags.
* Two `dead_code` fields in export are serde schema fields. They model
  what the on-disc JSON accepts; deleting them would quietly change that.
* `iso_loader.rs` gains a `FrameRx` alias for the ffmpeg frame channel,
  which is what "very complex type" was asking for.

A site-local `#[allow]` with a reason is a decision recorded where it
applies: one lint, one function, and any new violation elsewhere still
fails the build. That is not the shape PROTOCOL.md forbids.

Closes #13

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

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

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

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

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

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

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

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

Refs #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 16:59:57 +02:00
sylph-decoder
d8dcc1fc29 docs: record the third softening, which was authored dirty
The two instances already in "Checks that were kind once" were correct
when written and decayed. The third was wrong on its first commit, and it
arrived by a different route: the check and the tree's failure to pass it
land in the same change, so the softening writes itself.

Concretely — the Clippy step had never run (no component in the
toolchain), and the tree is not clippy-clean, so fixing the step and
turning it red are the same commit. The first draft paired the fix with
`continue-on-error: true` and a comment promising removal once the debt
was paid: an expiry date nobody set, in the shape #12's closing line had
already ruled out for rustfmt. Reverted on reading it.

Adds the distinction, a table separating decay from dirty authorship, and
an earlier tell than the mechanical test:

    If you are writing the softening in the same commit as the check,
    the thing you want is an issue, not a flag.

The mechanical test is unchanged and still correct; this only catches the
same failure sooner, at the keyboard rather than at review.

Refs #12, #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 15:55:04 +02:00
sylph-decoder
4ac5c9f419 ci: install the clippy component the Clippy step needs
`dtolnay/rust-toolchain@stable` installs a minimal profile. The `native`
job named no components, so every run that reached the Clippy step died
on

    error: 'cargo-clippy' is not installed for the toolchain
           'stable-aarch64-unknown-linux-gnu'

before clippy read a line of source. That is not a lint result; the step
had never run. The `fmt` job below always named `components: rustfmt`
correctly — this one never did.

Two lines of behaviour change. The rest is the comment explaining why the
step is left gating on `-D warnings` rather than softened: the workspace
is not clippy-clean (run 203's build alone emits ~13 rustc warnings that
`-D warnings` promotes to errors), and `continue-on-error` cannot tell
"debt not yet paid" from "debt paid". That debt is scoped in #13, the way
the rustfmt debt is in #12.

Run 203 is what made this visible. With the aarch64 fix in 64bb7da the
native job got all the way through:

    cargo check  --workspace   ok  10m01s
    cargo build  --workspace   ok  19m04s
    cargo test   --workspace   ok  16m22s   214 passed, 0 failed
    cargo clippy --workspace   toolchain error

Refs #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 15:39:44 +02:00
64bb7dada3 ci: build for the machine that exists, on the runner that exists
This workflow has never once gone green on this instance: 23 runs cancelled,
2 waiting, zero successes. Not a regression -- it has been decorative since it
was written, because it describes GitHub's hosted fleet and runs on one
self-hosted aarch64 Pi advertising ["ubuntu-latest","ubuntu-24.04",
"ubuntu-22.04"].

Two failures, both configuration rather than code:

`windows-latest` and `macos-latest` match no runner label, so those jobs sit in
WAITING for ever and the RUN never reaches a terminal state. A pull request's
checks therefore never resolve either way -- not red, just never finished, which
is worse than red because a red check tells you something. Removed: a second
architecture here needs a second runner, not a second matrix row.

`--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a
cross-compile, and `wayland-sys`'s build script dies on it with "pkg-config has
not been configured to support cross-compilation". Dropped; the native job now
builds for its host.

NOT touched, deliberately: the WASM and Formatting jobs still fail, on real code
state rather than on configuration -- `getrandom` needs the `wasm_js` backend
for wasm32-unknown-unknown, and `cargo fmt --check` reports a ~13,000 line diff
across the tree. Editing those two into passing is precisely the leniency with
an expiry date nobody sets that PROTOCOL.md now forbids. They are issues, not
workflow lines.

(One latent defect noted while reading: `jetli/trunk-action` fetches
trunk-x86_64-unknown-linux-gnu onto this aarch64 host. It has never been reached
because the WASM check fails first, and it will bite the moment that is fixed.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-04 21:13:43 +02:00
MechaCat02
2d5496f754 protocol: findings before citing code, and checks that were kind once
Two rules that look unrelated and are one failure, plus the change that makes
the second enforceable.

1. A FINDING REACHES `main` BEFORE THE CODE THAT CITES IT. A citation resolving
   only on a peer branch is dead the moment it merges. Not hypothetical: 495
   decoder and 366 port commits sit off `main`, and `port/scripts/boot.gd`
   already cites two docs/re pages present on neither its own branch nor main.

2. A CHECK MAY ONLY SOFTEN AGAINST A CONDITION IT CAN TEST -- the Pi agent's
   wording, and better than mine, because it is applicable while writing rather
   than a call to be vigilant. The mechanical form:

       Can this branch tell the difference between "not yet" and "no longer"?

   `gitea-protect --verify` printed  "not a collaborator (yet)" and continued,
   so the only instrument checking Write-not-Admin could not report that gate
   being REMOVED. `check-citations` reported peer citations instead of failing
   them, because under the old topology that was unfixable from the container.
   Both were correct AND kind when written; neither recorded that the kindness
   had a scope. Nobody edits these into being wrong -- the world moves and the
   allowance stays, which is why they survive review. The smell is leniency with
   an expiry date nobody set; the fix is the testable-condition rule.

check-citations gains `--for-merge`, which turns the peer class into a failure.
A flag rather than a new default because BOTH readings are still live: mid-work
on a topic branch the peer class really is unfixable noise. What the old code
could not express is where the code is GOING, and that is a condition the caller
can state. Measured on this tree: 19 citations resolve only on a peer branch --
which is the size of the #7-depends-on-#8 edge, not the 2 I had counted in
boot.gd.

The selftest gains that third class, because a flag whose classification is
unexercised is the shape this rule exists to catch. Controlled: emptying
PEER_REFS makes the peer case collapse into "nowhere" and the selftest reports
🔴 BROKEN, rc=2.

⚠️ Pre-existing and NOT from this change: the default run already exits 1 on 4
citations of `export/...` paths. Those are the generated tree, gitignored by
design, and main's copy of the tool fails identically. The CITE regex treats
`export/` as a repo prefix. Reported, not fixed -- it is the port's file and its
call whether the regex or the citations are wrong.
2026-09-04 18:27:39 +02:00
e55221f7d1 tools: a missing collaborator is a failure, not a blank
--verify's collaborator loop printed  and continued on 404 without touching
`ok`, so the one instrument that checks Phase 1.2 could not report Phase 1.2
being undone. An agent removed from the repository read as "nothing to say"
rather than as a gate that is no longer there.

It has never fired: Gitea answers that endpoint with permission "read" for a
non-collaborator rather than 404, so the case was caught by the role test two
lines down. Correct outcome, wrong reason -- the same shape as the check that
passed on an instance with no rule at all, and not worth keeping because the
luck has held so far.

Found by the port agent reading the file rather than running it, which is the
only way this one was ever going to surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-04 17:59:51 +02:00
MechaCat02
fcf6ec0497 docs: the status block said nothing existed while nine issues were live
Phases 1-4 and 6 are done on the instance. This file still opened with "Nothing
exists on the instance: no agent users, no API tokens, no labels, no milestones,
no branch protection" -- every clause of which was false by the time the merge
that carried it landed.

Replaced with a table of measured state, and each row says what was MEASURED
rather than what was run:

  * protection is verified behaviourally -- a real push to main refused with
    `pre-receive hook declined`, as the repository owner -- not read off a
    settings page. That distinction is the whole subject of this file.
  * the tokens are probed: right identity, 403 on branch_protections for both
    agents, so the Write-not-Admin carve-out is demonstrated and not asserted.
  * the labels are 11 because the instance holds 11.

And a standing note that this block is the part most likely to be wrong, with
what to believe instead: `gitea-protect --verify` and the issue list MEASURE,
this block REMEMBERS. A remembered status is a cache with no invalidation, which
is the same failure as a 1,227-line BLOCKED.md and as the two documents this
runbook was split across an hour ago.
2026-09-04 17:55:28 +02:00
MechaCat02
ce9fc6eea6 Merge branch 'pi/gate-limit' into agents/gitea-mcp 2026-09-04 17:55:00 +02:00
MechaCat02
9aeeb8c574 docs: the tool creates 11 labels, not 12 -- I counted its own definition
Caught by the Pi agent against the live instance after Phase 4 ran. The tool
creates 5 state/*, 2 agent/*, 4 kind/* = 11.

Where the 12 came from is worth a line, because it is a shape that recurs:

    $ grep -c '^mklabel' tools/gitea-setup
    12
    $ grep -n '^mklabel' tools/gitea-setup | grep -v ':mklabel "'
    74:mklabel() { # name colour description

I counted the function DEFINITION as a call. A measurement taken one token away
from the thing being measured -- the same shape as reading protection off a
settings page and reachability off a DNS record, which is now three today. The
version that cannot make this mistake is counting what the instance holds, and
that is what found it.
2026-09-04 17:50:50 +02:00
b67b6243e6 agents: name what branch protection does not gate, and stop the tool contradicting it
Two things that read as protection while being none.

Phase 2's rule binds everyone who reaches Gitea through the API or the web, and
does not bind anyone with `gitea admin` in the container -- which includes the
supervising agent that created the agent accounts and minted their tokens. From
that shell the rule is editable and an admin token is one command away. That is
the boundary of what the phase buys, not a hole to plug there, and the document
read as though the gate were universal. Phases 1 and 2 gate the two CONTAINERISED
agents, whose design assumption is that policy lives where they cannot reach it;
a supervisor with a host shell is not in that set.

And `gitea-setup` finished by telling the reader to go and build a Gitea project
board by hand, four sections after the doc explains that a board is a second copy
of the state to hand-sync and is precisely the failure that produced a 1,227-line
BLOCKED.md. A tool instructing you to do the thing its own documentation argues
against is the drift this whole surface exists to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-04 17:48:58 +02:00
MechaCat02
184ca0b556 docs: fold the page's revisions into the file, and separate wrong from unchecked
The runbook existed as two documents -- a published page and this file -- with
no mechanism keeping them equal, only an intention to remember. Two versions was
the predicted outcome of that, not an accident on top of it. This is the fold,
and the rule that follows it: THIS FILE IS THE SOURCE, the page is derived from
it. When something is urgent enough to push to the page first, it lands here in
the same turn, not "shortly after".

Four things the file did not carry:

  * YOUR OWN PUSHES TO main STOP. `enable_push: false` compiles to CanUserPush,
    which returns false with no bypass for admins or the owner -- quoted from
    the source. Three commits went in by direct push the day this was written,
    so the first notice would have been mid-task. Now a check step.
  * the token files' MACHINES, which the table had lost.
  * do NOT add `write:repository` to the `fabi` token. That scope IS a push
    credential. Written down because that advice was given, in chat, by the
    author of this file.
  * Gitea 1.25.5 confirmed from the desktop too, not just the Pi.

And one thing deliberately NOT folded in: the page said the desktop's outbound
HTTP was blocked, and that is false. `python3 -c 'urllib...'` returns
200 {"version":"1.25.5"} from this box. What is refused here is `curl`, by a
local permission prompt -- which I read as a network constraint and then
published as one. The Phase 3 locations stand; the reason given for them did not.

The "not verified" section now separates WRONG from UNCHECKED. Four entries are
wrong -- requiring an approval does not close the gate, the check could not have
caught that, the token scope, the reachability -- and the pattern in all four is
identical: a property inferred from something ADJACENT to it (protection from a
settings page, reachability from a DNS record) instead of tested directly. That
is the frozen-splash failure, committed in the document about avoiding it. The
first two were caught by the other agent, which is the argument for the review
gate this file exists to build.
2026-09-04 17:18:46 +02:00
799fa93383 tools: apply and re-check the branch protection rule, rather than clicking it
Phase 2 as a file. Six settings where two are load-bearing and both were missing
from the first draft is the shape of thing that gets mis-clicked at 1am, so it
goes through the API: what was applied is readable in a diff, and `--verify`
can re-check it later instead of it being checked once.

--verify states its expectations INDEPENDENTLY of what the apply path sends.
A check derived from "whatever we posted" cannot fail -- it re-derives the
expectation from the thing under test, which is the same instrument-shaped
failure as a check that passes on an instance with no rule at all.

It also asserts both agents are still Write and not Admin, because an agent
promoted to Admin can edit the rule and then merge, so a green rule proves
nothing on its own. That is the `gitea-verify` card from "Still to build";
what is left of it is only putting it on a timer.

`block_admin_merge_override` stays false on purpose, and the reasoning is in
the file: approvals are whitelisted to `fabi`, and Gitea will not let `fabi`
approve a `fabi` PR -- so with the override blocked, a human-authored PR could
never reach one approval and could never merge at all. The override is not a
hole in the agent gate because the agents are Write, not Admin. Phase 1.2 pays
for that; this is where it is spent.

Reads the repository-scoped credential that already exists on the agent box
(~/.sylph-git-credentials) rather than the issue-only ~/.sylph-gitea-api-token,
which every branch-protection endpoint refuses. That keeps the setup needing no
new credential, and keeps push rights on one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-04 16:49:58 +02:00
eccb789c0b docker: give each agent its own Gitea hands, and close the cross-approval hole
Phase 5 of docs/agents/GITEA-SETUP.md, plus a correction to Phase 2 that the
runbook could not have known it needed.

gitea-mcp v1.7.0 goes into both images, pinned by the sha256 the release
publishes and smoke-tested with `--version` at build time, so a bad pin fails
the build instead of the agent. Each entrypoint registers it at user scope for
that container's own identity, remove-then-add so a restart is idempotent.

The token is passed BY PATH. `-e GITEA_ACCESS_TOKEN=$(cat …)` would write it in
cleartext into ~/.claude.json, which every session in the container reads;
GITEA_ACCESS_TOKEN_FILE is new in the pinned version and leaves the secret in
its read-only mount. Verified against the binary's own --help, not assumed.

The tool filter stops being an experiment. The names are in the release README:
each agent gets issues, notifications, labels, milestones and pull requests, and
NOT `pull_request_review_write`. That one matters because separate identities
open a hole the runbook did not name: Gitea refuses to let an author approve
their own pull request, and does nothing about sylph-decoder approving
sylph-port's. Two agents could satisfy `required_approvals = 1` between
themselves and then merge, since branch protection blocks pushes to main and
never blocked merges.

Withholding the tool is defence in depth; the controls are in branch protection,
and both docs now say so: approvals whitelisted to the human so an agent's
approval does not count, merges whitelisted to the human so an approved PR is
still merged by a person. Phase 2's check gains the step that actually tests it
-- approve the throwaway PR yourself, then confirm the agent STILL has no merge
button. Without that step, the check passes on an instance where the agents can
merge each other's work.

Also settles two entries on the runbook's own "not verified" list: the tool
filter names, and the Gitea version (1.25.5, whose API schema carries
enable_merge_whitelist and enable_approvals_whitelist under those names).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-04 16:39:12 +02:00
MechaCat02
9652a5ad77 agents: the ordered runbook for standing the Gitea surface up
WORKFLOW-gitea.md said what the working surface is and why. It did not say how,
in what order, or how to know a step worked -- so it was a destination with no
route. This is the route.

Seven phases, each with a check, each marked 👤 human or 🤖 me:

  1 identities   two agent users, Write NOT Admin
  2 protection   main behind a PR + 1 approval -- BEFORE tokens exist
  3 tokens       three principals, three tokens, three files
  4 structure    labels and bundles, and deliberately NO Kanban board
  5 MCP          gitea-mcp v1.7.0, per-agent identity, user scope not .mcp.json
  6 items        migrate the live findings only -- not 1,227 historical lines
  7 restart      and verify the three things that must be true

Phase 1 leads because it is not hygiene: Gitea does not let a PR's author
approve it, so while an agent IS `fabi` either the human cannot approve its work
or it can approve its own. The review gate does not exist until the agents are
distinct people. (It also fixes 495 commits of agent work attributed to the
human's email.)

Phase 2's check is a real push and a real PR, not a reading of the settings
page. The reason protection lives in the server rather than in a brief is that
it should not depend on good behaviour -- so verifying it should not either.

Phase 5's install facts are checked, not remembered: gitea-mcp v1.7.0,
`gitea-mcp_Linux_x86_64.tar.gz`, `-t stdio -H <host>`, `GITEA_ACCESS_TOKEN`.
The `--tools` filter is flagged as an EXPERIMENT that might exclude the merge
tool as defence in depth -- explicitly not a substitute for phase 2.

Ends with what is still to build (propose-work, an attachment uploader,
gitea-verify, the wiki landing page) and a "what I have not verified" section:
the approve-your-own-PR behaviour, the --tools names, the Projects API, and the
Gitea version -- the API was unreachable from my sandbox three times running.
2026-09-04 16:25:10 +02:00
MechaCat02
a1ac3fa4c1 agents: rewrite the briefs for the Gitea workflow
The two loop files ARE the prompts -- `sylph-port` and `sylph-decoder` read them
off the host at launch -- so the workflow change had to land here or it would
not reach the agents at all.

PROTOCOL.md gains four sections:

  * Work items -- issues, milestones as bundles, the state labels, and that
    `state/blocked` uses DEPENDENCY EDGES, never prose. A prose blocker is what
    let a 1,227-line BLOCKED.md go stale.
  * Messages -- an ask is a `kind/ask` issue, not a SendMessage. With the part
    that matters: 🔴 NOTHING PUSHES. Notifications are polled, at the top of
    every iteration, and therefore an agent must NEVER wait on an ask -- set the
    edge, take the next item. The channel this replaces dropped 21 consecutive
    messages to a stale session id and reported success each time.
  * Pull requests -- one item per branch, `Closes #N`, and you may not merge
    your own. Branch protection enforces it; the rule is written down so the
    agent knows it, not so it depends on the agent.
  * Each iteration, in order -- notifications, sync, one unit, hand over, stop.

Also: evidence a human must look at now attaches to its issue, and a blunt
"never commit game content, under any directory name" with the 545 MB that
prompted it.

The two briefs shrink 697 -> 298 lines. They had accreted five dated focus
blocks between them -- sole-focus orders, F1-F6 queues, one-off "merge this
branch on your first iteration" instructions -- which is a queue, and a queue
belongs in the tracker. What is KEPT is what outlives its bug:

  * ask of any check, what would this still report if the feature were absent?
    Three instruments passed a splash that never animated.
  * the instrument must sit at or above the thing that can break -- the
    InputEventAction / input-map miss.
  * R1, and grep REFUTED.md before proposing.
  * the .pe is primary and the database is somebody's analysis of it.
  * the oracle is the real game in Canary, not any renderer of ours.

⚠️ NOT YET TRUE when this lands: the agents have no Gitea users, no API tokens
and no MCP server, so the issue tooling these briefs assume does not exist yet.
The agents are stopped. Setting that up is the prerequisite for restarting them.
2026-09-04 16:20:49 +02:00
MechaCat02
a23c321831 port: land the play-tested work, and only that
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
2026-09-04 16:17:14 +02:00
MechaCat02
ad96fe97b8 agents: move the working surface to Gitea -- issues, PRs, and where files live
The human wants to direct this project from a web UI rather than chat or Remote
Control, so Gitea becomes the working surface. No new store: adding a second
copy of the truth is this project's defining failure mode, and Gitea already
holds the code. Its first-party MCP server (gitea/gitea-mcp v1.7.0, checked) has
issues, labels, milestones, PRs, attachments and notifications.

ISSUES replace BLOCKED.md. Milestones are bundles the human defines; issues are
items agents propose and the human approves. The state labels end in
`needs-human`, which is the state the whole model turns on and the one no
off-the-shelf tool models -- the market has converged on removing the human.
`blocked` uses Gitea's DEPENDENCY EDGES rather than prose, so "the Port is
blocked on the Decoder answering X" becomes queryable and closes itself.

PULL REQUESTS, the human's proposal, adopted -- and a bigger improvement than it
looks. Today's long-lived auto/* branches have drifted 280 and 373 commits apart,
which is unreviewable by construction. One PR per item makes the human gate
NATIVE rather than a label convention, binds the change to its item, and enforces
the sizing rule: an item too big to review in one sitting was too big to be an
item.

🔴 Agents must not merge their own PRs, and pull_request_write includes merge --
so this goes in BRANCH PROTECTION on main, not in a document asking them not to.
Same principle that fixed the build-jobs cap: policy where the agent cannot reach
it.

WIKI -- the human suggested it for RE findings, and that half is declined with
reasons. A finding's value is that it sits beside its evidence, versioned with
the code that consumes it; the wiki is a separate git repo, so a decode
correction and the exporter change depending on it could never be one reviewable
PR. And wiki edits bypass review: the REFUTED.md reclassification changed the
file both agents read to decide what not to try, and as a wiki edit it would have
been an unreviewed mutation of shared ground truth. The wiki takes human-facing
orientation instead -- runbook, navigation, container notes, and a landing page,
which closes the real gap that there is no view of what is happening except
container logs.

FILES: three needs, three homes. Agent-to-agent transient stays in /exchange.
Evidence a HUMAN must look at attaches to the issue it belongs to -- it travels
with the item and cannot be orphaned from the claim. Evidence a finding cites
stays in git. Note the MCP exposes attachment_read only; upload needs a direct
REST call.

tools/gitea-setup creates the labels and bundles, idempotently, with --dry-run.
Blocked on a token with write:issue -- the push credential is write:repository
and every issue endpoint refuses it, checked rather than assumed.
2026-09-04 15:46:57 +02:00
MechaCat02
1d1ffc5750 docker: stop the wrapper typing into live sessions, and support per-agent logins
Both agents stopped, and the decoder diagnosed it itself:

  "I received '2' and '1' but I don't have a pending question those would
   answer -- I was in the middle of setting up the /loop cron job."

claude-autonomous matched the BARE SUBSTRINGS 'Choose', 'trust' and 'accept' to
answer Claude Code's one-time first-run gates. The /loop prompt is echoed into
the terminal, and that day's briefs contain 'accepted as-is' and 'least
trustworthy' -- so expect matched the agent's OWN INSTRUCTIONS and typed 2\r and
1\r into a running session, which then sat waiting for a human to explain them.

The old comment argued a multi-word pattern 'never matches' because the gate
text wraps. True of a literal string, false of a whitespace-tolerant regex, which
is what these now are: \s+ spans the wrap, and the terminal is 200 columns wide.

Measured, old against new, against the real brief text and a real gate:

  {accept}                     brief 0  gate 1   (case-sensitive; briefs say 'accepted')
  {Yes,\s*I\s+accept}          brief 0  gate 1
  {trust}                      brief 1  <- the trigger
  {Do\s+you\s+trust\s+the\s+files} brief 0

Two defences, because one is not enough for something that can type: patterns
prose cannot match, and gates skipped ENTIRELY on resume (SYLPH_SKIP_GATES) --
a resumed session cannot show a first-run gate, so there is nothing to answer
and everything to lose. Timeout cut 90s -> 25s for the same reason.

Also: SYLPH_OWN_LOGIN. Remote Control stopped registering under the long-lived
token, and the likely reason is scope -- `claude auth login` requests
user:sessions:claude_code and the token's auth status reports no email, org or
subscription. A per-agent `claude auth login` restores Remote Control AND avoids
the rotation collision, because each agent holds its own grant rather than a copy
of one. The flag stops the entrypoint seeding the host's credentials over it.
2026-09-04 15:37:15 +02:00
MechaCat02
b305aa4a5a docker: support a long-lived Claude token, and stop the seeding fighting it
The rotating OAuth credential file is why the agents kept parking, and a
long-lived token removes the failure by construction instead of recovering from
it after the fact.

MEASURED 2026-09-04. ~/.claude/.credentials.json holds a refresh token that
ROTATES ON USE. Seeding both containers from the host left three clients holding
one token; the first to refresh invalidated the other two, and on the failed
refresh Claude Code CLEARS the stored tokens -- writes empty strings, keeps the
metadata, and parks at "Login expired".

  decoder credentials emptied  13:04:28
  decoder last transcript      13:04:29   <- one second later

The emptying and the park are the same event, which is why it never self-heals:
not a stale token a retry could fix, but no token at all, with no browser in the
container to complete /login. A hollow file passes every "does it exist" check --
508 B healthy against 280 B emptied -- which is how three separate diagnoses
missed it. And recovery re-armed the bug: after re-seeding, host and decoder held
the IDENTICAL refresh token hash.

`claude setup-token` issues a long-lived token against the same Claude
subscription. Checked, not assumed: `claude auth login` defaults to --claudeai
and it is `--console` that means Console/API billing, so this is not the separate
API bill. `CLAUDE_CODE_OAUTH_TOKEN` is recognised by the installed binary.

Passed as an ENVIRONMENT VARIABLE, both halves of the failure are gone: nothing
rotates, so peers cannot invalidate each other, and there is no file for Claude
Code to empty on a failure.

Both launchers read $HOME/.sylph-claude-token if present -- same pattern as
SYLPH_GIT_CREDENTIALS -- and both entrypoints skip OAuth seeding entirely when
the variable is set, because copying the rotating file in would re-create the
exact collision the token exists to remove.

Inert until the file exists. Without it, nothing changes.

Also worth recording for the preflight work: `claude auth status` prints JSON
with loggedIn/authMethod/subscriptionType. That is a far better SessionStart
assertion than checking a file exists, and it would have caught this on the first
iteration rather than the third incident.
2026-09-04 15:20:31 +02:00
MechaCat02
108308057a docker: the expect wrapper swallowed both the signal and the exit status
A tooling review predicted a PID-1 signal problem from two symptoms we could not
explain: `OOMKilled: true` with **ExitCode 0**, and `--continue` failing to find
a conversation that plainly existed. Traced it, and the prediction was right --
though the culprit is not PID 1, it is one level below.

The path is  tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude

`spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
is the process Docker signals, and everything depends on it passing things on.
It did neither, in two lines:

1. NO SIGNAL FORWARDING, no trap of any kind. `docker stop` sent SIGTERM to
   expect, which died and took the pty with it. Claude Code never got a SIGTERM,
   so it never ran SessionEnd hooks and never wrote lastSessionId/history --
   which are written ONLY at a graceful shutdown. That is the entire reason
   `claude --continue` answered "No conversation found to continue" with 33 MB of
   transcripts in the volume beside it, and why we resume by scraping a session
   id off a transcript filename.

2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
   ZERO. When the OOM-killer took the child, expect saw EOF and reported a clean
   exit. `OOMKilled: true` with `ExitCode 0` was never Docker being odd -- it was
   this line. It also meant `--restart on-failure` would read a memory kill as
   success, which is why the policy had to be `unless-stopped`.

Fixed and MEASURED, old against new, in a container:

  child exits 7        old -> 0    (the bug)      new -> 7
  SIGTERM to wrapper   old -> 143, child's trap NEVER RAN
                       new -> 42,  child trapped and cleaned up

Same file in both images; they were byte-identical, so the port copy takes the
same change.

Consequences worth stating: a kill now reports 137 rather than 0, so exit codes
mean what they say; `docker stop` gives Claude Code a real SIGTERM, so it runs
SessionEnd and writes the session index -- which may make the transcript-filename
resume unnecessary. That is not assumed here: the resume path stays as it is
until it is verified redundant.
2026-09-03 21:07:19 +02:00
MechaCat02
620ec5e60b agents: one item only -- the title's animation timing -- and split work into human-checkable units
Two new findings from the human, both about WHEN a title animation starts, and
both handed over rather than guessed:

F5 Does (A) SNAP the title to finished, or ACCELERATE it? The human says they
   cannot tell and is right that they cannot -- a three-frame acceleration and a
   one-frame cut look identical to an eye. Two routes that should agree: a
   per-frame capture (acceleration shows intermediate alphas, a cut shows none)
   and the code (assigning a target time and raising a rate multiplier are
   different instructions). Their "looks more like a snap on multiple attempts"
   is recorded as a PRIOR, not a result.

F6 The title's sweeping white glow -- ptloop01/ptloop02, the blue PCB-like lines
   -- starts only when the plate appears in the real game, and starts earlier in
   the port. A lead from the exported declaration, mine and unverified: those
   elements are keyed at t = 0, 70, 100, 238, 250 while the plate reaches full
   alpha at 236, with pteff02 keyed at exactly 236 and ptlogo_back2eff and
   ptcopyright at 238. 236-238 is a synchronisation point in the declared data
   and a human just reported a behaviour change there. Flagged AGAINST itself
   too: 238...250 looks equally like an exit ramp -- ptcopyright uses that shape
   and starts nothing -- and the sweep lives in a nested .rat leaf with its own
   timeline.

F6 bears on clock: "shared" and on F4: if a title element does not move until
the plate arrives, either the declared data says so and our keyframe reading is
wrong, or something at the plate's arrival STARTS it, which is a mechanism
nobody has proposed.

And the process change, which is the human's and outlives this item:

  "attacking the 'whole' mission was too big for them to handle. Split the given
   missions and tasks into even smaller tasks which they can tackle and give to
   a human for feedback."

PROTOCOL.md gains "Work in units a human can check in a minute". A milestone is
not a unit of work, it is a bag of them. A unit is right-sized when it ends in
something a person can judge in under a minute WITHOUT READING ANYTHING, and
each one states its question, what the human looks at, and what it does NOT
cover. Do one, hand it over, stop -- an unverified fix under a second change
makes a regression two-variable.

The evidence for the rule is this week: the splash sat through a whole milestone
and took one day once scoped to "does it animate?". The bar is a HUMAN check,
not a green tool -- three instruments passed a frozen screen.
2026-09-02 20:14:46 +02:00
MechaCat02
de5f04038d agents: correct "both clocks" -- there is ONE, and F4 tests whether it is right
I wrote "whether the game snaps both clocks forward" into yesterday's F4 and the
human asked which clocks. There are none: authored/flow.json sets
`clock: "shared"`, so the title's two composited builds -- build 4 the artwork
(finishes t~=118) and build 2/3 the plate (full alpha t=236) -- run on ONE clock
started together. Left standing, that phrasing sends an agent hunting for a
second clock this corpus says does not exist.

Corrected in both briefs and in the playtest page, marked as a correction rather
than silently edited.

And the question is better than I first framed it. `clock: "shared"` is
AUTHORED, and the port's own plate-arrival-halves.md calls it "not falsified...
not confirmed to better than ~20 % either", with an unresolved anchor
disagreement inside one binary: the reconciliation picked t=118 while
settle_time() returns 160 and the boot prints "settles at t=160".

So F4 is a TEST OF THAT PREMISE, and the discriminator is observable -- press (A)
early, while the wordmark is still building in, and watch the ARTWORK rather
than the plate:

  advances the shared clock   -> the artwork SNAPS to finished
  only forces the plate       -> the artwork KEEPS ANIMATING its build-in

Both briefs now say to answer F4 before building on `shared`, and tell the port
not to choose what "jump" means.
2026-09-02 18:39:13 +02:00
MechaCat02
06f890361b agents: P5's gate is MET, and four findings from the same walk
"Menu walk and navigation is fine. Video skips too. Extras open. New Game
   shows new game intro video."  -- 2026-09-02

P5 is done. Its gate was "a human clicks through it", the retro said it had been
waiting on that and not on code for the whole milestone, and it has happened.
PORT-MISSION.md updated. The NEW GAME gap is accepted as-is.

Four findings, three of them the Decoder's:

F1 THE MENU REPEATS ON A HELD DIRECTION AND OURS DOES NOT. One step per
   deflection was authored as the conservative choice because nobody knew; a
   human has now watched the real game and it repeats, "at a medium pace... slow
   enough to see which item is selected". That settles the existence half of H1
   against us. The RATE is still unmeasured and must not be guessed -- the
   description bounds it and supplies no number. Decoder measures initial delay
   and repeat interval as frame counts; the port implements the mechanism and
   waits for the numbers.

F2 THE SFX ARE TOO LOUD BECAUSE THERE IS NO MIX AT ALL. Measured: confirm
   -17.7 dB mean / -0.0 dB peak, 3 dB hotter in mean than the music and 6.4 dB
   above move. No volume or gain value exists anywhere in export/ or authored/,
   so every clip plays at unity on one bus. Decoder: is per-cue or per-bus gain
   on the disc -- the cue table is the obvious place and cue 1103 is already
   decoded. Port: gains at PLAYBACK as data, and explicitly NOT normalisation in
   the exporter, which destroys the relationship between clips and cannot be
   undone by a modder.

F3 SOMETHING IS MISSING ON THE TITLE SCREEN. The export carries one music file
   and the port plays nothing on the title. Which cue does the title play, and
   is there a sting on the plate or on accept? A negative needs a positive
   control: find the menu's cue by the same method first.

F4 (A) SKIPS FORWARD THROUGH THE BOOT AND WE IMPLEMENT TWO OF THREE PRESSES.
   In the game: skip video, reveal plate immediately, accept plate. The middle
   one is missing here. Whether the game snaps both clocks forward or only
   reveals the plate is a question, not a detail -- and it is a cheap second
   route to the plate-arrival question, since a press that skips to the plate
   says where the game thinks the plate belongs.

H3, the plate delay, is ACCEPTED -- "feels the same... sufficient". Left
unattributed rather than closed green.
2026-09-02 18:32:38 +02:00
MechaCat02
a71dea9b8d agents: the logo splashes are DONE -- the human cannot tell them from the game
"Looks good! Cannot notice any obvious difference from the actual game.
   Mark logos as done."  -- 2026-09-02

Not "the check passes": a person compared the port against the real game and
could not tell them apart. That is the oracle, and it is the strongest result
this port has produced. The sole-focus order is lifted; both agents return to
their milestones.

The fix was one word -- pose_at ASSIGNED the settle instant instead of clamping
to it, so every query returned the settled pose whatever the clock said. The
same line manufactured the false green: the capture harness shoots after two
frames, so it was photographing t~=2 units, which looked settled only because
everything looked settled. The 0.01 % agreement that closed H2 was measured
through the accident. One bug produced the defect AND the evidence of its
absence.

Verified here before it went to the human, by film rather than by claim:
motion 16.4 % -> 27.7 %, distinct luma states 26 -> 43, the publisher ramp 6
steps -> 13 in one continuous run, and the developer splash's interrupting
0.50 s freeze gone. The publisher trajectory rises to a peak and settles back --
the crossfade signature.

The port then closed a gap motion-census names in its own header ("a wrong ramp
that moves every frame passes here") with a shape check pre-registered from the
disc, measured off a film, on a non-overlapped strip, in ratios so the texture
divides out: rise:last declared 1.20, measured 1.20 exact.

Kept as the standing lesson, because it is the fourth instance: an instrument
that sits below the thing under test cannot see it fail. Ask of any new check
what it would still report if the feature were entirely absent.

Explicitly NOT claimed: P5's gate is "a human clicks through it" and nobody has
said the milestone is met. The briefs say so, and say not to record it on the
human's behalf.

The decoder's end-to-end pipeline work returns to normal priority rather than
being dropped -- it is what decides whether the port's 60 units/s matches the
game. The ramp is now right in SHAPE and unverified in DURATION.
2026-09-02 18:15:03 +02:00
MechaCat02
d394c55cbb agents: the splash does not animate, and three instruments could not see it
A human on a GPU at ~140 fps: "the logos just switch, there is no animation at
all." Measured from a real boot with --film at 0.05 s, then per-frame change:

  splash moves        1.30 s of 7.95 s = 16.4 %
  publisher splash    0.30 s of motion, then 3.20 s FROZEN
  developer splash    0.35 s + 0.25 s, then 2.40 s FROZEN
  distinct luma states in 7.95 s   26

A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one
picture for 3.20 s. The frame counter says 24.8 fps achieved; both are true --
the port is DRAWING 25 times a second and CHANGING almost never.

🔴 Why every check passed, which matters more than the bug:

  frozen sweep     drives the clock BY HAND -- proves the renderer can draw
                   pose N, never that the poses are drawn in sequence
  settled compare  0.01 % against the capture -- a screen frozen 84 % of the
                   time matches a settled reference PERFECTLY, that is what
                   frozen means
  achieved fps     counts frames DRAWN -- the same pixels 25x/s scores
                   identically to animating

Every one measured throughput or a pose. None measured CHANGE. Same shape as
InputEventAction bypassing the input map: the instrument sat below the thing
that was broken, so the break could not appear in it.

tools/motion-census closes the class. It measures change and nothing else, and
its --selftest asserts it separates a fade (97.4 % moving) from a switch (2.6 %)
from a frozen film (0.0 %) -- a detector that cannot tell those apart would
report the same green line on all three.

Both briefs: this is the SOLE focus. The port reproduces before changing
anything and gates every fix on a film rather than a still. The decoder maps the
whole pipeline end to end -- disc bytes, the game's per-frame update (does it
interpolate between keyframes or hold?), what is submitted per frame, and what
Canary does to it before a capture records it -- delivered as a SERIES, not a
settled value.

The port should also record the refutation against itself: H2 reads ANSWERED on
the strength of the frozen sweep. The mechanism half stands, the blur is a baked
companion texture. The behaviour half does not.
2026-09-02 17:24:42 +02:00
MechaCat02
18b5b3d5f1 port: give the port container a GPU path -- it never had one
Reported as "the port has low FPS". Godot 4 renders through Vulkan and this
launcher passed nothing through, so it fell back to lavapipe: software Vulkan,
correct and slow. The decoder's launcher has had this block for a long time;
the container that actually runs a renderer was the one without it.

Same three cases as the decoder, including the part worth repeating: passing
/dev/dri alone does NOT work for NVIDIA -- Mesa cannot drive the card and the
proprietary userspace lives outside the image. It needs the container toolkit.

The NOTE now prints the full repo-add sequence, because the package is not in
Ubuntu's default repos and `apt install nvidia-container-toolkit` on its own
fails with 'no installation candidate' -- which reads like the package is
wrong rather than the source being missing.
2026-09-01 20:22:25 +02:00
MechaCat02
4ac23b94dd docker: auto-restart, and resume the session the agent was actually in
The decoder died mid-task and it took four separate findings to explain, each
of which read as something else:

1. OOM-KILLED, REPORTED AS A CLEAN EXIT. `OOMKilled: true` with **ExitCode 0**.
   So `--restart on-failure` would treat a memory kill as a successful finish
   and leave the agent down -- the policy has to be `unless-stopped`.

2. THE JOB CAP WAS SET AND THEN REMOVED THREE LINES LATER. build-reborn has
   always exported CARGO_BUILD_JOBS, but a raw `cargo test --release -p
   sylpheed-formats` never reaches the wrapper. Adding `-e CARGO_BUILD_JOBS` to
   the launcher did not help either: the entrypoint recomputes and exports over
   it unconditionally. An explicit value now wins, and says so in the log.

3. THE MEMORY CONSTANT WAS WRONG. `mem_gib * 2 / 3` assumes ~1.5 GB per job;
   release rustc on this workspace needs ~2 GB, and 4 jobs in 6 GB is what died.
   Divisor is now 2.

4. `--continue` CANNOT RESUME AN ABRUPT DEATH, which is the only kind we get.
   It resolves through ~/.claude.json's per-project `history`/`lastSessionId`,
   and MEASURED mid-session both are None -- they are written at a graceful
   shutdown. A killed container never writes them, so `--continue` answered
   "No conversation found to continue" with 33 MB of transcripts in the volume
   beside it. Persisting .claude.json did not help, because the fields were
   never populated in the first place; that attempt is removed rather than left
   in looking useful.

   The TRANSCRIPTS are durable and named by session id, so the entrypoint reads
   the id off the newest one for its cwd and passes `--resume <id>`. Verified
   on both agents: each reattached to its exact prior session and appended to
   the same file rather than opening a new one.

The /loop prompt is still passed alongside `--resume`, so the loop is RE-ARMED
rather than merely restored -- a resumed conversation with no wake-up scheduled
answers once and stops, which looks like resuming and is not.

Restarting into the same death is guarded at the other end: a start less than
120 s after the previous one begins FRESH instead of continuing back into
whatever killed it. That fired correctly during this work.

On resume the agent is told it was restarted, that its in-progress work is
uncommitted in the tree, that any build or capture it had running did not
finish and its absence is not a result, and which wrapper to prefer over a raw
release build.
2026-09-01 20:20:51 +02:00
485 changed files with 36389 additions and 66733 deletions

View File

@@ -10,36 +10,53 @@ env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# ── What this file may assume about where it runs ────────────────────────────
#
# It runs on ONE self-hosted runner: `rpi5-runner`, aarch64, advertising
# ["ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04"]. Nothing else exists.
#
# This file was written for GitHub's hosted fleet — three operating systems and
# x86_64 throughout — and had never once gone green here: 23 runs cancelled, 2
# waiting, zero successes. Two separate reasons, and both are configuration
# describing a world that is not this one:
#
# * `windows-latest` / `macos-latest` match no runner label, so those jobs sit
# in WAITING for ever. The run therefore never reaches a terminal state, and
# a pull request's checks never resolve either way — not red, just never
# finished. That is worse than a failure: a red check tells you something.
# * `--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a
# cross-compile, and `wayland-sys`'s build script dies on it —
# "pkg-config has not been configured to support cross-compilation".
#
# So: one job, on the machine that exists, building for the machine that exists.
# If a second architecture is ever wanted here it needs a second RUNNER, not a
# second matrix row.
jobs:
# ── Native builds: Windows, macOS, Linux ────────────────────────────────────
# ── Native build, on the one runner there is ────────────────────────────────
native:
name: Native — ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: macos-latest
target: aarch64-apple-darwin
name: Native — linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
# `stable` installs a MINIMAL profile: rustc, cargo, rust-std and no
# more. Components have to be named. Without this line the Clippy step
# below dies on "'cargo-clippy' is not installed for the toolchain
# 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it
# is the step never having run. The `fmt` job below always got this
# right; this one never did.
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
components: clippy
- name: Cache Cargo registry and build
uses: Swatinem/rust-cache@v2
# Linux: install Bevy's system dependencies (X11, Wayland, audio)
- name: Install Linux system dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
@@ -52,16 +69,30 @@ jobs:
pkg-config
- name: Check (fast compile check)
run: cargo check --workspace --target ${{ matrix.target }}
run: cargo check --workspace
- name: Build (debug)
run: cargo build --workspace --target ${{ matrix.target }}
run: cargo build --workspace
- name: Run tests
run: cargo test --workspace --target ${{ matrix.target }}
run: cargo test --workspace
# This step has never once executed on this codebase: the toolchain above
# shipped without the component, so every run died on "not installed"
# before clippy saw a line of source. Its result was never pass or fail,
# only unmeasured. With the component installed it becomes a real check,
# and the first honest thing it will report is that the workspace is not
# clean — the build already emits ~13 plain rustc warnings (unused
# imports, unused variables, needless `mut`, dead fields) that
# `-D warnings` promotes to errors, before clippy's own lints are counted.
#
# Left gating on purpose. A red check that measures something is worth
# more than a green one that measures nothing, and the alternative —
# `continue-on-error`, or dropping `-D warnings` — cannot tell "debt not
# yet paid" from "debt paid", which is the shape PROTOCOL.md forbids.
# The debt is scoped in #13, as the rustfmt debt is in #12.
- name: Clippy
run: cargo clippy --workspace --target ${{ matrix.target }} -- -D warnings
run: cargo clippy --workspace -- -D warnings
# ── WASM / Web build ─────────────────────────────────────────────────────────
wasm:

31
.gitignore vendored
View File

@@ -25,10 +25,41 @@ __pycache__/
# ── The port ────────────────────────────────────────────────────────────────
# Generated from the user's own disc. This repo stays clean-room: code, schemas,
# authored mappings and documentation only -- never game content.
#
# BOTH names are ignored on purpose. `export/` is what the exporter writes and
# what `ExportTree.locate()` reads today; `data/base/` is the name MODDING.md
# gives that same tree. Only one of them existed here, and it was the one
# nothing writes -- so the live output directory was tracked while MISSION §4
# said it was ignored. Ignoring both means renaming the tree to match the docs
# cannot silently start committing the disc.
/export/
/data/base/
#
# ⚠️ Enumerating names is what FAILED. The two rules above were written --
# carefully, with the comment above -- while 850 files and 299 MB of extracted
# sprites, audio and transcoded video sat committed under `export-probe/` and
# `export-probe2/`, a third name nobody had thought to list. So ignore the
# SHAPE, not the instances: any top-level directory whose name starts `export`,
# and game media anywhere it lands.
/export*/
*.ogv
*.ogg
*.wav
*.xpr
*.pak
# Loose capture output at the repo root -- 246 MB of it arrived this way.
/*.tsv
/*.log
# Transient inter-agent files. Deliberately outside history: they are working
# artefacts with provenance in their manifest, not results.
/exchange/
!/exchange/.gitkeep
.godot/
port/.godot/
# A mod is usually an EDITED GAME ASSET, and this repository never holds game
# assets. `data/mods/` is the user's own directory -- the exporter never touches
# it and neither does git, except for the README that explains the rule.
/data/mods/*
!/data/mods/README.md
!/data/mods/.gitkeep

25
Cargo.lock generated
View File

@@ -4611,7 +4611,7 @@ dependencies = [
"colored",
"image",
"indicatif",
"sylpheed-formats",
"sylpheed-formats 0.1.0",
"texpresso",
"tokio",
"tracing",
@@ -4627,7 +4627,7 @@ dependencies = [
"image",
"serde",
"serde_json",
"sylpheed-formats",
"sylpheed-formats 0.1.0 (git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01)",
]
[[package]]
@@ -4648,6 +4648,25 @@ dependencies = [
"xdvdfs",
]
[[package]]
name = "sylpheed-formats"
version = "0.1.0"
source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01#1cd5b8b1cb1f02eefc0865e1a1fe280e44831c9d"
dependencies = [
"anyhow",
"binrw",
"flate2",
"futures",
"rayon",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"ttf-parser 0.24.1",
"xdvdfs",
]
[[package]]
name = "sylpheed-viewer"
version = "0.1.0"
@@ -4659,7 +4678,7 @@ dependencies = [
"image",
"rfd",
"rodio",
"sylpheed-formats",
"sylpheed-formats 0.1.0",
"thiserror 2.0.18",
"tracing",
"tracing-subscriber",

View File

@@ -42,6 +42,7 @@ docs/
re/ the corpus: findings, refutations, method traps
game/ how the game is navigated -- menus, modals, flight
port/ the port's mission, its handoff contract, modding rules
-- and RUNNING.md, which is how you actually start it
agents/ how the agent team works together
tools/ capture harnesses, probes, the share tool
exchange/ transient inter-agent files. NOT in git

384
authored/audio.json Normal file
View File

@@ -0,0 +1,384 @@
{
"format": "sylpheed.audio/1",
"_": [
"Menu audio. EVERY VALUE IN THIS FILE IS MEASURED OR CHOSEN -- none of it is",
"in a data file the exporter can read, which is why it is here and not in the",
"exporter. `measured` and `chosen` are NOT the same thing and this file keeps",
"them apart: a measurement is deleted when the disc states it, a choice is",
"deleted when somebody measures it.",
"",
"Two different kinds of not-on-the-disc live in this file and they are not",
"interchangeable:",
"",
" * `se` -- MEASURED. `Static.slb` is a delimiter-less run of whole 2048-byte",
" XMA1 packets: no RIFF, no seek chunk, no XACT container. A wave is defined",
" ONLY by (offset, packet_count), and both numbers come from the running",
" game, not from the file. HANDOFF Q8. Delete a row the day a table on the",
" disc states the same thing.",
"",
" * `bgm` -- MEASURED, and only the LOOP POLICY beside it is chosen. HANDOFF",
" Q10's negative is about the TABLES: `SOUNDS`, `FILES` and the bank headers",
" name no screen. The executable does -- cue 1103 = `BGM_103`, corroborated",
" by a byte-for-byte match against what the XMA probe saw at the main menu.",
" An earlier draft read the negative as unbounded, picked a track at random",
" and called it authored. See the `bgm._` block for what that cost.",
"",
"The exporter reads this file and emits `export/audio/**` from it. It holds no",
"cue table of its own: a measured offset compiled into a Rust `const` is a",
"measurement wearing the costume of a decoded field, and MISSION section 3 is",
"explicit that measured values live here."
],
"se": {
"_": [
"MEASURED, HANDOFF Q8, and the RE agent retracted an earlier 'cannot be",
"extracted' to publish these. The waves were located BY PLAYING THEM: Canary",
"with `--xma_param_probe=true` prints a stream's packet count and first 32",
"bytes when it is played, and searching those bytes in the bank gives the",
"offset.",
"",
"WARNING, from the same finding: the file order is NOT cue-id order. These",
"cannot be counted out, and an index here would be a fabrication.",
"",
"`name_match` is the authors' own identifier GUESSED BY NAME. It is carried",
"so the guess is not lost and is never presented as the measurement. Where",
"the RE agent did not separate two candidates, there is no name at all --",
"an absent `name_match` means nobody has claimed one, never that the",
"BINDING is unknown. The binding is the measured part.",
"",
"All three are mono 48 kHz; that is the RE agent's statement in",
"`sylpheed_formats::media::se_wave_riff`, not something re-derived here."
],
"move": {
"bank": "Static.slb",
"offset": "0x1ec0",
"packets": 4,
"channels": 1,
"rate": 48000,
"name_match": "SE_UI_CURSOR",
"why": "HANDOFF Q8, measured: the d-pad move cue, 8 192 B / 0.533 s, reproduced across two boots. Left/right play nothing at all, which is a measurement too and is why there is no `left`/`right` row here rather than a silent file.",
"kind": "measured"
},
"confirm": {
"bank": "Static.slb",
"offset": "0x5d6c0",
"packets": 6,
"channels": 1,
"rate": 48000,
"why": "HANDOFF Q8, measured: the (A) confirm cue, 12 288 B / 1.016 s. NO `name_match`: Q8 is explicit that (A)'s wave was not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`, so naming it would invent the one thing the measurement did not settle.",
"kind": "measured"
},
"back": {
"bank": "Static.slb",
"offset": "0x0ec0",
"packets": 2,
"channels": 1,
"rate": 48000,
"why": "HANDOFF Q8, measured: the (B) back cue, 4 096 B / 0.344 s, reproduced across two boots. No `name_match` for the same reason as `confirm` -- Q8 names no identifier for it.",
"kind": "measured"
}
},
"bgm": {
"_": [
"MEASURED, NOT CHOSEN -- and the port got this wrong for one iteration.",
"",
"`docs/port/BLOCKED.md` carried a row reading 'not on the disc ... the port",
"is choosing a track, and that choice is authored', and the first draft of",
"this file duly picked BGM_001 and labelled it arbitrary. That row was not",
"stale: `BGM_103` is in HANDOFF at `9ca1eb5`, which is the exact commit the",
"row says it was reconciled against. It was WRONG WHEN WRITTEN.",
"",
"What HANDOFF actually says is a negative with a stated reach, and the reach",
"is what the port dropped: the *tables* cannot say which BGM a screen plays",
"-- `SOUNDS`, `FILES` and the bank headers name no screen. The EXECUTABLE",
"can. `GamePart_Title`'s phase handler `sub_821C5580` carries `li r5, 1103`",
"into a sound call, cue 1103 is `BGM_103`, and `BGM_103.slb`'s two declared",
"waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA",
"probe saw decoding at the main menu. Static code, disc census and runtime",
"agree. HANDOFF's own words: 'The port does not have to choose a track.'",
"",
"So this section is a CITATION, not a decision. It lives in `authored/`",
"only because the binding is in the .xex and the exporter reads data files,",
"not code -- and it must be deleted the day something the exporter can read",
"states it. The loop policy below IS still a decision."
],
"main_menu": {
"bank": "BGM_103.slb",
"loop": "restart",
"kind": "measured",
"why": "MEASURED, HANDOFF Q10 -- NOT a port choice. `GamePart_Title`'s phase handler `sub_821C5580` plays cue 1103 = `BGM_103`, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree; see docs/re/menu-audio-cues.md and docs/re/structures/bgm-two-stems.md. The name carries its `.slb` extension because that is what `sound.pak` hashes -- `BGM_103` alone resolves to nothing, which is how the first draft of this file failed. ✅ AUDITED 2026-08-31 -- the THREE legs are three, and that is now measured rather than asserted. Prompted by the Decoder's point that a decorative second support is worse than none, since a conclusion with two supports reads as better evidenced than one and apparent redundancy is itself the misinformation. Read literally, 'disc census' and 'runtime' could be ONE comparison -- declared wave sizes matched byte-for-byte against the probe -- which would make three legs two. It is a real third leg only if the census EXCLUDES alternatives: if another bank carried the same two sizes, the byte match would not distinguish BGM_103. Measured with this port's own reader (`crates/sylpheed-export/examples/bgm_size_census.rs`): of 32 readable BGM_* banks on the disc, EXACTLY ONE carries waves of that size. The census therefore excludes, the static-code leg names the cue independently, and the three legs stand. ✅ AND THE EXCLUSION IS TIGHTER THAN I STATED. The Decoder attempted to refute it from their own census tool rather than this port's reader: of 32 census rows, exactly one bank carries EITHER of those wave sizes -- not merely both together, which is what I measured. A collision would therefore need to reproduce a single size, not a pair, and none does.",
"loop_why": "MEASURED, and this field's own history is why it says so first. The bed loops; the loop is a RUNTIME field -- `loop_start`/`loop_end` in the XMA decoder context, set by `XMASetLoopData` and logged by Xenia -- and the cycle was watched directly: three wraps, both contexts wrapping at the same instant every time, mean 61.81 s against the 61.93 s authored in `loop_end_s`, 0.2 % apart from instruments sharing nothing. The export is TRIMMED to that window, because Godot loops a whole file and a loop region therefore has to BE the file. ⚠️ The window's START is not measured and is authored as 0, which is known to be wrong -- see `loop_start_why`. 🔴 EVERY SENTENCE THAT PRECEDED THIS ONE WAS REFUTED, and the previous text survived in the manifest for two days after the corrections were written. It said the loop would be `AUDIBLY WRONG AT THE SEAM [refuted]`, that `no loop-point field has been identified [refuted] anywhere`, and that trimming `would INVENT a loop point`. All three are false: the field exists, the 3.4 s of near-silence was the PORT'S loop and not the game's, and the trim is now what the measurement says. The corrections went into `loop_end_why` and `loop_start_why`; this field is the one the exporter concatenates into `manifest.json`, so the export went on telling readers the refuted story. A correction that does not reach the artifact a consumer reads has not been made. 📌 CITATIONS ADDED 2026-09-01, and their absence was found by `audit-kinds` the moment this field got a `kind` -- it had 1 400 characters of prose and nothing openable, which is exactly the state the audit exists to catch and could not see while the field was unlabelled. The wrap measurement is docs/re/data/menu-bgm-loop-measured.txt and the start is docs/re/data/menu-bgm-loop-start.txt; the bank's two-stem structure is docs/re/structures/bgm-two-stems.md.",
"loop_kind": "measured",
"stems": "sum",
"stems_why": "MEASURED, HANDOFF Q10: a bank is exactly TWO waves of identical duration (32/32 banks on the disc), sample-synchronous -- transient correlation peaks at lag 0.00 s over +/-5 s and both stop at the same millisecond. Concatenating them plays the piece twice, the second time as a bass-less stem; that was the previous reading and it is refuted. Emitting two files would be wrong for a second reason: MODDING rule 1 is one logical asset, one file, and handing a modder two stems to line up by hand is the reassembly the exporter exists to have already done. WHAT IS SUMMED IS SETTLED; WHAT WAVE 1 IS, IS NOT -- HANDOFF calls it quieter, far more L/R-decorrelated and almost bass-free, so it reads as a surround-rear pair OR a second intensity layer, and `ChannelMask` is 0x0002 on both so the file will not say. A unity sum is right under either reading; a weighting would only be justified once that is settled.",
"stems_kind": "measured",
"loop_start_s": 9.44,
"loop_start_why": [
"MEASURED 2026-08-30 -- 9.44 s. The loop region is [9.44 s, 71.31 s] of an",
"87.744 s wave: the first 9.44 s is an intro played ONCE, and the last 16.4 s",
"is a fade-out never played at all.",
"",
"Two derivations, both stems, and NEITHER converts bits to seconds -- the",
"conversion that refuted itself earlier by giving two sample-synchronous stems",
"62.34 and 63.29 s. (a) time to `read_offset` crossing `loop_start`, plus a",
"1.33 s head correction at a LOCALLY measured rate; (b) first pass minus cycle.",
"9.44 s on both stems either way.",
"",
"⚠️ ONE BOOT, ONE BANK. The decoder reads ahead of playback, but both endpoints",
"are `read_offset` events so the lead cancels in the difference.",
"",
"🔴 THIS FIELD WAS 0.0 AND FLAGGED WRONG FOR ONE ITERATION, deliberately. The",
"value was not guessable -- linear back-extrapolation said 9-13 s and linearity",
"is refuted by a 4.4 % rate variation within one stream. What made the wait",
"cheap was that the field EXISTED and the `-ss`/`-t` ordering had been proved",
"with a stand-in value, so arriving at 9.44 was a one-value edit.",
"",
"📌 CITATION ADDED 2026-09-01 -- found the moment this field got a `kind`. It",
"carried 1 041 characters describing two derivations and cited no file. The",
"numbers are in docs/re/data/menu-bgm-loop-start.txt, and the loop region's",
"wrap timing is in docs/re/data/menu-bgm-loop-measured.txt.",
"",
"⚠️ Second uncited MEASURED field in this one entry, after `loop_why`. Both",
"described their evidence carefully in prose and pointed at nothing. A why that",
"recounts a measurement reads as well-sourced precisely because it is detailed,",
"which is why neither looked wrong.",
"",
"✅ AUDITED 2026-09-01 with the exclusion test: could either derivation have come",
"out differently given the other? YES, and they discriminate different errors --",
"(a) depends on a locally measured RATE and (b) on the CYCLE, so a wrong rate",
"breaks (a) and leaves (b) standing, and a wrong cycle does the reverse. Two legs",
"that fail independently, which is what 'two derivations' was claiming.",
"",
"⚠️ BOUND: they share one trace. A systematic error in the read_offset stream",
"moves both, and the ONE BOOT, ONE BANK caveat above is that limit stated. What",
"they exclude is arithmetic error, not trace error."
],
"loop_start_kind": "measured",
"loop_end_s": 61.87,
"loop_end_why": [
"MEASURED off the running game 2026-08-30, 240 s parked on the menu",
"(docs/re/structures/menu-bgm-loop-measured.md). The bed loops at 61.93 s, NOT",
"at the summed wave's 87.744 s length, and the last ~25.8 s is never played --",
"exactly the fade-out and trailing silence bgm-two-stems.md found. The game",
"loops BEFORE the fade.",
"",
"🔴 THIS CORRECTS AN AUTHORED VALUE THAT WAS WRONG IN BOTH DIRECTIONS. `restart`",
"at the wave's end produced a seam of about 3.4 SECONDS of near-silence, and",
"this port measured that seam off its own Master bus and recorded it as the",
"cost of a missing loop point. It was not the game's seam; it was OURS. Zero",
"runs of >=0.3 s below median-18 dB appear in 232 s of the real menu.",
"",
"Two instruments agree: correlation gives a top lag of 61.909 s and r = -0.009",
"at 87.750 s, and locating 30 s slices inside the decoded waves shows playback",
"advancing exactly +5.00 s per 5 s and wrapping at 61.93 s, three times, with a",
"control that finds slices cut at 10/45/70 s at 10.00/45.00/70.00.",
"",
"⚠️ The loop START is inferred, not measured: [0.0, 61.93) and [0.25, 62.18) are",
"not separated at their resolution. The port takes 0 because a bank's own start",
"is where its data begins, and records that the choice was not measured.",
"",
"⚠️ Godot loops a WHOLE FILE, so the export is TRIMMED to 61.93 s rather than",
"carrying a loop point the runtime could not honour. The trimmed tail is",
"content the game never reaches, so nothing playable is lost -- but a modder",
"replacing this file is replacing the loop region, not the whole bank.",
"",
"🔴 CONFLICT, OPEN AS OF 2026-08-30. The loop IS a runtime field: `loop_start`",
"and `loop_end` live in the XMA decoder context, set by `XMASetLoopData`, and",
"the RE agent read 8734 records off the menu. Converted, they imply a cycle of",
"roughly [10 s, 72 s] against the [0.25, 57.18] their audio tracking reported.",
"BOTH CANNOT BE RIGHT and neither has been withdrawn.",
"",
"They judge the weak link probably theirs: the locator's control matched slices",
"cut from the wave ITSELF -- exact copies -- which is an easier problem than",
"matching a capture that differs by decoder, gain and mix. A control easier than",
"the measurement does not bound the measurement's error, and music with repeated",
"sections is where a locator aliases.",
"",
"⚠️ THE VALUE IS KEPT ON THEIR INSTRUCTION, and because the LENGTH survives",
"better than the PLACEMENT: 61.93 has an autocorrelation behind it that used no",
"wave at all, and the trimmed loop has no seam in this port's own output.",
"",
"This port added one check neither of their instruments ran: whether the trim",
"JOINS SMOOTHLY. Over 126.5 s the wrap at 61.93 s and again at 123.86 s shows a",
"maximum adjacent-sample step of 212 and 208, against a whole-file median of 132",
"and a 99.9th percentile of 3737. So the join is not a click and nothing is",
"audibly broken.",
"",
"⚠️ THAT DOES NOT DISCRIMINATE THE TWO READINGS. A smooth join says the waveform",
"does not jump; it does not say the loop is at the musically right point, and a",
"cut landing near a zero crossing is smooth wherever it falls.",
"",
"🔴 What the conflict would COST if their runtime fields win: under [10 s, 72 s]",
"this export is about 10 SECONDS SHORT -- the content in [61.93, 72] is played",
"by the game and absent here. That is the number to weigh when it resolves, and",
"it is why this entry is not being treated as settled.",
"",
"✅ CONFIRMED 2026-08-30 BY A SECOND INSTRUMENT SHARING NOTHING WITH THE FIRST.",
"The RE agent stopped converting the runtime fields and TIMED them instead --",
"a probe tailing the Apu debug log and stamping `read_offset` on arrival --",
"and watched THREE wraps, each from its own `loop_end` to its own `loop_start`,",
"with both contexts wrapping at the SAME INSTANT every time. Cycle 61.56 and",
"62.06 s, mean 61.81 s: 0.2 % from the 61.93 authored here, measured by wall",
"clock between decoder events against an autocorrelation that never touched",
"the wave. Both contexts wrapping together is the sample-synchrony the linear",
"bit conversion could not produce.",
"",
"So the LENGTH is settled and the WINDOW is not. See `loop_start_why`.",
"",
"✅ 61.87 ADOPTED 2026-08-30, replacing 61.93. Their wrap timing gives 61.87 --",
"wraps at 96.46 / 158.33 / 220.21 s, gaps 61.87 and 61.87 -- against the 61.93",
"this port's autocorrelation gave. 0.1 % apart. The measured value is taken",
"because it is the one with the loop's own endpoints under it; the",
"autocorrelation never touched the wave and agreed to a tenth of a percent,",
"which is what makes both worth having."
],
"loop_end_kind": "measured"
}
},
"voice": {
"_": [
"🔴 KNOWN WRONG, HELD DELIBERATELY. Which of a voice region's streams to",
"export. The premise this entry was built on has been REFUTED BY THE RUNNING",
"GAME and the entry is kept, escalated, rather than swapped for another guess.",
"",
"The premise was: a region carries THREE PRESENTATIONS OF ONE TAKE, so the",
"exporter picks one. The Decoder booted with `--xma_param_probe=true` -- the",
"cvar that reports which sub-wave the game decodes -- and the game decodes",
"ALL THREE, CONCURRENTLY, in three separate XMA contexts, with byte sizes",
"matching the three disc payloads exactly (1294336 / 1118208 / 1171456",
"against RIFF size - 60 of 1294396 / 1118268 / 1171516).",
"",
"SO THERE IS NO 'WHICH ONE' TO ANSWER. `presentation` below discards two of",
"three streams the game plays. It is not a preference between rules any more;",
"it is a known-incomplete export.",
"",
"WHY IT IS NOT CHANGED TODAY. Reverting to the 1/n sum is not obviously less",
"wrong: an equal-gain sum of channel pairs is not a downmix -- MISSION",
"section 6 makes exactly that point when it pins an explicit matrix for the",
"movies' 5.1 fold rather than letting ffmpeg default -- and the 6.02 dB the",
"sum cost S00A was a real defect. Swapping one guess for another on a message",
"is what produced this entry twice already.",
"",
"🟡 HYPOTHESIS, NOT A RESULT, and it is the Decoder's: three concurrent stereo",
"streams is six channels, and N stereo streams is how XMA carries",
"multichannel on the 360, so 5.1 would explain the differing byte rates, the",
"near-silent stream and why cues are 1-stream or 3-stream and never 2. AGAINST",
"IT: all three declare ChannelMask = 0x0002 identically, which is odd for",
"distinct channel roles. Do not build on it.",
"",
"WHAT SETTLES IT: a recording of the game's own output over the intro,",
"through the PulseAudio null sink (AUDIO-VERIFICATION section 3). Candidate",
"combinations of the three decoded streams can then be correlated against",
"what the game actually played. Asked 2026-08-29.",
"",
"🔴 REFUTED FROM THE OUTPUT SIDE, 2026-08-30, not merely suspected.",
"",
"The RE agent recorded 148 s of the game's own output over the boot intro",
"(ALSA tee, --gpu=null, 0.15 % silence -- cleaner than the recipe page's own",
"reference run), with provenance from the XMA probe rather than a screenshot:",
"`ADV`'s three contexts appear byte-exact, then the `BGM_102` pair.",
"",
"FIVE OF SIX CHANNELS CARRY DISTINCT CONTENT. No channel is a copy of another;",
"the largest pairwise correlation is 0.70, between FL and FR, which is what a",
"stereo pair looks like. BR is 82 % silent and 11 dB down.",
"",
"So `presentation: \"loudest\"` -- keeping ONE stream -- cannot be right. That",
"was already labelled known-wrong here on the strength of the game decoding",
"all three concurrently; it is now refuted by what the game PLAYS.",
"",
"⚠️ AND IT IS STILL NOT FIXED, DELIBERATELY, on the RE agent's own instruction.",
"Three limits they state:",
" * it does not make summing right -- the output is multichannel, which says",
" nothing about which stream lands where;",
" * '6 channels' is NOT evidence the game is 5.1 -- that count is Xenia's",
" hardcoded kFrameChannelsDefault. The evidence is that five of them DIFFER,",
" which a stereo guest cannot produce;",
" * 🔴 the stream-to-channel mapping is NOT RUN. Cross-correlating each",
" captured channel against each decoded `ADV` stream is the step that",
" answers this, and it is their next iteration.",
"",
"Changing the mapping now would swap one authored guess for another, which is",
"a worse position than a guess that is labelled. The value stays; the label is",
"upgraded from suspicion to refutation."
],
"presentation": "all",
"presentation_why": [
"`loudest` = the full-length stream whose peak is nearest full scale.",
"",
"🔴 READ THE BLOCK ABOVE FIRST. This selects one of three streams the game",
"decodes concurrently, so whatever it selects, two are missing. The",
"paragraphs below are the history of how the value was arrived at, kept",
"because the reasoning is what makes the error checkable -- NOT because the",
"choice is defensible on its own terms any more.",
"",
"It was `highest_rate`, on a recommendation withdrawn as self-contradictory:",
"'the highest-rate, highest-gain one is chunk 1' selects different streams --",
"ADV stream 2 is 1118268 B at 0.0 dBFS, stream 3 is 1171516 B at -8.3.",
"",
"A structural argument for `loudest` was offered and withdrawn too: ADV",
"stream 2 is mono-in-stereo and stream 3 is dual-mono, so the extra bytes",
"looked like a duplicated channel rather than fidelity. The CHANNEL",
"MEASUREMENT stands and now reads differently -- these are channel pairs, and",
"0.60x with the residual 26.8 dB down is what a correlated pair at a lower",
"level looks like. The GENERALISATION was refuted by census: the stream-3 /",
"stream-2 size ratio over the 28 three-stream cues runs 0.0778 to 2.9163.",
"",
"⚠️ THE FAILURE MODE HERE IS THAT IT SOUNDS FINE. A single stream decodes to",
"clean audible dialogue, so nothing in the output reveals that two streams",
"are missing. That is why the manifest says it in words on every voice entry",
"rather than leaving it to this file.",
"",
"📌 WHERE THE OPEN QUESTION LIVES, added 2026-09-01 under this port's own rule:",
"an `authored` kind must cite the question it stands in for, or an invented",
"value and a placeholder for a measurement read identically. This one stands in",
"for the three-concurrent-streams problem, recorded in docs/port/BLOCKED.md and",
"delivered in docs/port/HANDOFF.md -- the game decodes all three at once, so",
"ANY single selection is missing two, and the export states that per movie",
"rather than choosing quietly.",
"",
"⚠️ 1 402 characters of careful reasoning and nothing openable until now. It is",
"the third uncited field in this file, and all three were detailed rather than",
"sloppy -- the detail is what made them look sourced."
],
"presentation_kind": "authored",
"stream_weights": {
"_": [
"Declared XMA `byte_size` -> the coefficient that stream's position takes in a",
"stereo downmix. MEASURED by the RE agent 2026-08-30",
"(docs/re/structures/intro-audio-decomposed.md): decomposing the game's own",
"6-channel output as capture = 0.600 x movie + residual puts ctx0 at FL/FR,",
"ctx1 at FC with LFE silent, and ctx2 at BL/BR.",
"",
"🔴 KEYED BY BYTE SIZE ON PURPOSE. The assignment is indexed by the decoder's",
"own declared size, so the exporter can CHECK that the stream in front of it is",
"the one the measurement describes rather than assume it. A region whose chunks",
"do not match falls back to the count divisor and says so. That is not defensive",
"programming: on 2026-08-30 this table's sizes did NOT fit the region the",
"resolver returned, which is what exposed `resolve_movie_voice_region` starting",
"238 packets late. Had the weights been applied positionally they would have",
"been applied to the wrong streams silently.",
"",
"⚠️ ONE BOOT, ONE MOVIE. Only `ADV`'s three streams were measured. `S00A`'s",
"sizes match nothing here and it keeps the divisor -- extending this by",
"POSITION would be assuming the ordering generalises, which is exactly the",
"inference the byte-size key exists to avoid.",
"",
"⚠️ The weights are a stereo downmix's, folded to mono. They sum to 1.0, so the",
"total is the movie's own; what they distribute is the balance between three",
"positions. Whether the game's 0.600 mixer gain is a constant or a volume",
"setting is unknown and the port applies no gain of its own."
],
"1294336": {
"position": "FL/FR",
"weight": 0.4142
},
"1118208": {
"position": "FC (LFE silent)",
"weight": 0.2929
},
"1171456": {
"position": "BL/BR",
"weight": 0.2929
}
}
}
}

View File

@@ -1,55 +1,669 @@
{
"format": "sylpheed.flow/1",
"_": [
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
"negative -- the order is in none of the four places it could have been. It is",
"not in config.ini's empty [SYSTEM], not in the movie manifest (which carries",
"assets, not transitions), not in a persistent GamePart field (the requested id",
"lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero",
"xrefs of any kind. A transition is a call with a name argument, chosen by code.",
"",
"So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE",
"agent watched the game do, not what any file on the disc says it does. Nothing",
"here may be presented as decoded."
],
"boot": [
{
"screen": "publisher_logo",
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both."
},
{
"screen": "developer_logos",
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
},
{
"video": "ADV",
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
"skippable": true,
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline."
},
{
"screen": "title",
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go."
}
],
"dwell": {
"_": [
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer",
"logos at t=190 (3.17 s), both read from the disc. Holding beyond that would",
"be a number nobody has measured, so the sequencer holds for zero extra time",
"and the pacing is the disc's own.",
"",
"When a capture times the real boot, the extra hold per screen goes here."
]
"format": "sylpheed.flow/1",
"_": [
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
"negative -- the order is in none of the four places it could have been. It is",
"not in config.ini's empty [SYSTEM], not in the movie manifest (which carries",
"assets, not transitions), not in a persistent GamePart field (the requested id",
"lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero",
"xrefs of any kind. A transition is a call with a name argument, chosen by code.",
"",
"So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE",
"agent watched the game do, not what any file on the disc says it does. Nothing",
"here may be presented as decoded."
],
"boot": [
{
"screen": "publisher_logo",
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both. 📌 SOURCE, added 2026-09-01: the boot's screen order and dwells are derived from GP_TITLE's own entries -- see docs/port/FORMAT.md for the export shape and docs/re/ui-title-build-map.md for which entry is which screen. The order here is not authored; it is what the archive declares."
},
"screens": {
"_": [
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",
"destination screens and the RE agent later decoded that a transition is a",
"lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,",
"LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`",
"targets when this is written, marked as the name match they are."
]
{
"screen": "developer_logos",
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
},
{
"video": "ADV",
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
"skippable": true,
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline.",
"skippable_kind": "measured"
},
{
"screen": "title",
"overlay": {
"screen": "press_start",
"clock": "shared",
"why": "MEASURED, 2026-08-29, docs/re/title-plate-delay-measured.md on branch auto/no-disc-and-menu-captures at 5b0a6e6 (NOT on main when this was written). The boot title shows build 4 ALONE and the `PRESS (A) BUTTON` plate -- build 2 -- arrives later. This is the ONE case in the port where two builds are drawn at once.",
"no_constant_why": "THERE IS NO AUTHORED DELAY HERE, AND THERE WAS ONE FOR ONE ITERATION. The first version of this block carried `after_settle_seconds: 2.13`, taken from the RE agent's instruction. The port refuted that instruction with arithmetic off the disc -- build 2 has a group of its own, and starting it at settle put the plate 3.97 s late -- and the corrected answer needs no constant at all: BOTH BUILDS RUN ON ONE CLOCK, STARTED TOGETHER, and the plate arrives at its own declared t=236 (CORRECTED 2026-09-01 from t=238, which is the last opaque frame rather than the arrival). `clock: \"shared\"` is that, spelled out rather than implied by the absence of a delay field. 📌 SOURCES, added 2026-09-01 in the uncited-why backfill: the plate's arrival is docs/re/title-plate-delay-measured.md and its pulse is docs/re/structures/plate-pulse-measured.md. 🔴 AND `clock: \"shared\"` IS AUTHORED FROM OUR OWN ARITHMETIC, NOT MEASURED. Nobody has watched whether build 2's group starts with build 4's; it is the reading that reconciles the oracle's 2.13 s IF the settle anchor is t=118. See docs/port/plate-arrival-halves.md and BLOCKED.md H3.",
"arithmetic_why": "Why one clock reproduces the measurement, checked against this export rather than taken on trust: build 4's effect quads `pteff01`, `pteff02` and `ptlogoall_eff` end their ramps together at t=118; `ptbtn00` reaches alpha 255 at t=236; the difference is 118 units = 1.967 s at 60 units/s. The oracle measured 2.138 s and 2.132 s. The gap is presentation rate: the emulator presents at 28.1 fps against a nominal 30, and the corpus had independently measured the idle title at 28.5 fps before these runs. 🔴 CORRECTED 2026-09-01: this said `ptbtn00` reaches 255 at t=238 and that the difference is 120 units = 2.000 s. It reaches 255 at t=236 and HOLDS to 238, so 238 is the last opaque frame, not the arrival; 236 - 118 = 118. The port printed the contradiction in one sentence on every boot. The correction moves the reconciliation by 0.033 s and overturns nothing -- see docs/port/plate-arrival-halves.md. 🔴 AND THE ANCHOR IS NOW OPEN. The oracle defines \"title settled\" operationally, as its glyph counter first reading the no-plate value 154. This export offers TWO anchors 42 units apart: t=118 (the effect quads) and t=160 (`ptcopyright` at full alpha -- the LAST element to finish building in, and the only one made of glyphs). This line picked 118, while `ScreenView.settle_time()` returns 160 and the boot prints `settles at t=160`, so one binary holds both. Asked in BLOCKED.md H3; not guessed here. 📌 SOURCE: the pulse period and its phase behaviour are in docs/re/structures/plate-pulse-measured.md and docs/re/structures/plate-pulse-phase-lock.md, with the raw series in docs/re/data/plate-pulse-timeseries.txt. ✅ AUDITED 2026-09-01: the corpus's 28.5 fps is a genuinely independent leg -- a different quantity (idle-title presentation rate), measured BEFORE these runs, so it could have come out disagreeing. It agrees to 1.4 %.",
"the_premise_that_failed_why": "The port's own, and it is worth keeping because it will bite again: `rest.t` IS NOT WHEN A SCREEN SETTLES. It is the last hold keyframe before the exit. Reading it as the settle put build 4's arrival at 4.35 s instead of 1.97 s, and every reconciliation computed from it came out wrong by exactly that error. `ScreenView.settle_time()` still uses rest.t -- see docs/port/BLOCKED.md. 🔴 THE EXAMPLE THIS CITED IS GONE, THOUGH THE CONCLUSION IS NOT. It read \"`ptlogo1` has rest.t=251 and stops MOVING at t=42\". In the CURRENT export `ptlogo1.rest.t` is 42 -- equal to when it stops moving. The record-layout fix repaired precisely that element, and the entry was never re-derived under it (REFUTED.md now carries this at 🟡 ⟨our-reader⟩). rest.t is still wrong for transients -- `ptlogo_back2eff1` is a two-frame flash whose rest.t=54 is the flash PEAK -- and for `pteff00`, whose rest.t=16 sits at the end of the fade-FROM-black while a fade-TO-black runs 261..269. Re-derived 2026-09-01: docs/port/plate-arrival-halves.md. 🔴 AND IT IS NOT THIS DEFECT'S CAUSE. The plate's ARRIVAL is a declared keyframe (transparent to t=214, opaque at t=236), not a rest pose; rest.t=236 only chooses where `holding` parks it, and 236 is that ramp's own peak. Confirmed on a filmed boot with rest.t untouched: the onset is bracketed within one frame of 214.",
"scope_why": "Attached to the BOOT STEP, not to the `title` screen, and that is deliberate. What was measured is the boot title. Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured, and putting the overlay on the screen would quietly claim it is. 📌 SOURCE, added 2026-09-01: the plate belongs to the boot's overlay step rather than to the title screen because its arrival is measured against the boot clock -- docs/re/title-plate-delay-measured.md. 🔴 STALE CLAUSE, CORRECTED 2026-09-01: this said \"Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured\". It IS measured now, and has been since 2026-08-30: after (B) from the menu the plate is re-drawn, pressed at 351.2 s with its pulse back at 358.5 s (the Decoder, nav-autorepeat-and-settled-b data). The port re-arms the overlay on arrival at the title by any path, and that is correct. What stayed true is the structural half -- the declaration lives on the boot STEP and is looked up from there, so a screen that gains an overlay gets it on both paths at once. ⚠️ What is STILL not measured is whether the returned plate FADES or appears at once; the 7.3 s between press and pulse is consistent with a transition plus the declared 214->236 fade, but that is consistency, not a measurement of the ramp on this path.",
"no_pulse_why": "The port draws the plate arriving and then holding. It does not pulse it. The RE agent identifies the pulse as the plate's FOCUS RECORD `ptbtn00f` -- a glow ramping 0x00 to 0x50 and back, t=6..105 -- not as a loop of `ptbtn00`'s own group, which was the port's earlier reading and was wrong. Looping that record is a candidate the port has NOT taken: its group is 105 timed units plus an AUTHORED 24-unit exit ramp, and hitting the measured 2.24 s mean requires composing that authored constant with a loop assumption, which is tuning rather than measuring. Filed in BLOCKED.md. 📌 SOURCE, added 2026-09-01: docs/re/structures/plate-pulse-measured.md, and the phase-lock caveat that bounds what a gated capture can show is docs/re/structures/plate-pulse-phase-lock.md."
},
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. This is the LAST step, and a last step is where the sequence stops rather than fading out -- a boot that ends by fading to black looks like a boot that crashed. P5 gave the title somewhere to go, but that is a HANDOVER and not another boot step: `--boot` still stops here, and `--boot --play` hands the same held title to the menu flow, where (A) opens TITLE_MENU. Kept as a stop rather than folded into `screens` because what the boot does is authored from a measured sequence, and what (A) does is a separate measurement."
}
],
"dwell": {
"_": [
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
"",
"This key has now been wrong in two opposite directions, and the second was",
"mine, so both are recorded.",
"",
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
"🔴 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
"exit is caused by something outside its timeline, so it holds. A splash's exit",
"is caused by nothing, so it plays its declared timeline and leaves. The title",
"is the exception, not the rule, and one screen was never enough to overturn",
"the other two.",
"",
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
"",
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
"",
"The developer agrees to 1.1 %, two of its three runs to 0.3 %. The port emits",
"4.400 s and 3.650 s -- each declared value plus the 9-unit black hold, exactly.",
"So the pacing was right all along and nothing changes in the code.",
"",
"🔴 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
"Decoder's own container came out 15-20 % LONGER than both the declared values",
"and the corpus -- same disc, same timeline -- and three independent readings",
"of that container's rate disagree with each other. A seconds figure records",
"one emulator's pacing on one run. The units are on the disc. If anything ever",
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
"measured to wait beyond its group."
]
},
"navigation": {
"_": [
"MEASURED off the running game, HANDOFF Q5 -- none of it is on the disc.",
"It lives here rather than in GDScript so that a reader can see it is a",
"measurement and delete it the day a field on the disc states it."
],
"wrap": true,
"wrap_why": "HANDOFF Q5: up/down move one item and WRAP at both ends. Measured on the 5-item main menu AND the 3-item EXTRAS, so it is a menu rule and not a per-screen one (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26).",
"wrap_kind": "measured",
"left_right": "nothing",
"left_right_why": "HANDOFF Q5: left/right do nothing. Measured. Implemented as an explicit no-op rather than by omission, so that 'we never wired it' and 'the game ignores it' are distinguishable in the code.",
"left_right_kind": "measured",
"input_during_transition": "ignored",
"input_during_transition_why": "AUTHORED, and NOT measured -- nobody has watched what the game does with a button pressed mid-fade. Ignoring is the choice that invents the least: it cannot queue a press the game might have dropped. Ask the RE agent before relying on it. 📌 WHERE THE ASK LIVES, added 2026-09-01: docs/port/BLOCKED.md carries it, and until now this why said \"ask the RE agent\" without naming where the question is recorded -- a pointer with no destination. An `authored` kind still needs a citation, because the thing to cite is the OPEN QUESTION the choice stands in for; without it, an invented value and a placeholder for a measurement read the same.",
"input_during_transition_kind": "authored",
"auto_repeat": false,
"auto_repeat_why": "MEASURED 2026-08-30, Decoder daf8f47: a 2.0 s held (down) moves the cursor EXACTLY ONCE. Their counter passes its own control first -- a single 0.12 s tap gives exactly 1 spike, the hold gives 1, move spike 0.0202-0.0220 against a 0.0003-0.0038 floor. The port's edge-triggered _input already behaved this way; what changed is that it is now a MEASUREMENT rather than an unexamined consequence of how the handler was written. HANDOFF Q5's 'up / down' row is split at the source: one-item-per-press (evidenced by the 4-press wrap count) from no-auto-repeat (which had nothing until this run).",
"auto_repeat_kind": "measured"
},
"screens": {
"_": [
"What each button does. The NAVIGATION ORDER is not here -- it is derived,",
"in each screen file's `buttons` (button-role elements sorted by resting Y).",
"Only the destinations, the initial focus and the cancel target are",
"authored, because only those are measurements or decisions.",
"",
"`goto` is an EXPORTED SCREEN NAME or null. `goto_name` is the game's own",
"screen vocabulary from the decoded transition lookup -- carried so the",
"binding is not lost, and marked below as the NAME MATCH it is, never as a",
"measurement (HANDOFF: the strings are what the call sites reference, not",
"proven arguments, and the same list mixes in TEXT_FONT and GAMMA_RGB).",
"",
"`goto: null` with a `blocked` note means the destination screen is real and",
"measured but is NOT IN THIS EXPORT -- it lives in another archive. That is a",
"milestone boundary, not an unknown."
],
"title": {
"on_accept": {
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF: (A) on the title opens the main menu, with (A) on the boot title as the control in the same run."
},
"on_cancel": null,
"on_cancel_why": "MEASURED 2026-08-30, Decoder daf8f47, docs/re/data/nav-autorepeat-and-settled-b.txt: twenty seconds after a delivery-confirmed B the screen is still the title with PRESS (A) BUTTON up. The run waited for the PLATE PULSE -- the title's own settled signature -- before pressing, which is exactly what the earlier confounded attempt did not. This cell briefly said 'MEASURED, HANDOFF Q5' on no evidence, then said AUTHORED once that was caught; it is now measured for real. Value unchanged throughout: null.",
"on_cancel_kind": "measured"
},
"main_menu": {
"initial_focus": "ptbtn01",
"initial_focus_kind": "measured",
"focus_persists": true,
"focus_persists_kind": "measured",
"focus_persists_why": [
"MEASURED 2026-08-30, Decoder: the main menu REMEMBERS ITS CURSOR across a",
"round trip through the title. (B) out and (A) back returns to the item you",
"left, not to a default. Their control passed first -- two delivery-confirmed",
"DOWNs moved the cursor exactly two items before the round trip, so the",
"cursor demonstrably was not where it started.",
"",
"The port reset to `initial_focus` on every entry, so this was a real defect",
"and not a refinement: a player who moved to EXTRAS, pressed (B), then (A),",
"landed back on NEW GAME.",
"",
"🔴 SCOPED TO THIS SCREEN ON PURPOSE, and the scope is the authored part.",
"The measurement is of the MAIN MENU. Making it a menu-wide rule would be",
"n=1 wearing a rule's clothes -- and here it would actively contradict a",
"measurement, because `extras` opens on MISSION SELECT as a MEASURED initial",
"focus, and a remembered cursor would override it on re-entry. `wrap` is a",
"menu rule because it was measured on two screens; this was measured on one.",
"",
"⚠️ WHAT IS NOT KNOWN: whether the memory survives a return to the BOOT",
"(as opposed to the title), and whether any other screen has it. Ask before",
"widening this.",
"",
"🔴 CORRECTED 2026-08-30, SAME DAY, by the Decoder: the paragraph above argued",
"the scope from `extras` having a MEASURED initial focus that a remembered",
"cursor would override. That is a good reason to be CAUTIOUS and NOT a finding",
"that `extras` resets. Nothing has measured what a submenu's own cursor does on",
"re-entry: the corpus has EXTRAS' opening item from ONE entry, and (B) restoring",
"the PARENT's focus 4/4, and neither answers it.",
"",
"So `focus_persists: false` everywhere else is THE PORT'S DEFAULT, not the",
"game's behaviour. It invents the least and it preserves the one measurement",
"there is. `tools/port/contract-check` asserts only the main-menu half against",
"the contract and reports the scope as a GUARD, because for one iteration it",
"asserted non-persistence as though it had been measured -- which would have",
"held the port to the wrong behaviour and passed while doing it.",
"",
"❔ The Decoder is measuring EXTRAS re-entry now. Do not build on the",
"non-persistence half until it returns.",
"",
"📌 SOURCE, added 2026-09-01 in the uncited-why backfill: docs/re/data/focus-persists-across-title.txt carries the round trip, and docs/re/data/extras-focus-resets.txt carries the contrasting submenu result that keeps this scoped to one screen."
],
"initial_focus_why": [
"MEASURED 2026-08-30 (later) -- `NEW GAME` on a fresh boot, 2/2 fresh boots,",
"both the FIRST menu entry. Decoder, HANDOFF `bf9e07f`, section \"correcting",
"today's focus delivery\"; ring row y=225.5 against a measured 79.25 px step,",
"data in docs/re/data/menu-focus-reader-offset.txt.",
"",
"🔴 THIS FIELD WAS `authored` UNTIL NOW AND THE UPGRADE IS NOT BECAUSE IT",
"AGREES WITH ME. The value did not change; its standing did. The confirmation",
"is a direct reading of a fresh boot's first menu entry, independent of the",
"reasoning that chose NEW GAME here -- and the Decoder had said explicitly that",
"my agreeing with their records was no evidence, which was correct at the time.",
"",
"✅ AND IT SURVIVES A REBOOT -- MEASURED 2026-08-31. Six fresh boots all",
"opened on NEW GAME, and THREE of them followed a session that ended with the",
"cursor on EXTRAS or OPTIONS. That is what makes it a test of persistence",
"rather than six repetitions of the same start.",
"",
"⚠️ REACH, and it is the Decoder's own caveat rather than mine: every one of",
"those sessions ended with the emulator KILLED, not shut down cleanly. A game",
"that writes menu state on a clean exit never gets the chance, so this",
"measures 'does not survive a KILLED session'. If a real console remembers a",
"cursor across a power cycle, that does not contradict this.",
"",
"⚠️ WHY 'FIRST ENTRY' IS LOAD-BEARING: the menu REMEMBERS ITS CURSOR (see",
"`focus_persists`), so any reading not taken on a fresh boot's first entry is",
"measuring HISTORY, not what the screen opens on. That objection is what",
"invalidated the earlier TUTORIAL/NEW GAME disagreement, and this measurement",
"is the one that is immune to it.",
"",
"The superseded reasoning is kept below, because it is what made the wait cheap:",
"the field existed and was labelled honestly, so arriving at a measurement was a",
"label change and not an archaeology problem.",
"",
" (was) AUTHORED, standing in for HANDOFF Q5, which measured that initial focus is NOT STABLE: four boots of the same harness opened on TUTORIAL, TUTORIAL, NEW GAME, NEW GAME. A port has to open on something. ptbtn01 (NEW GAME) is picked because it is one of the two states actually observed and it is the top item, so a reader can predict it. It is a CHOICE. Delete this the day the RE agent finds what selects it. CORROBORATED 2026-08-29, and still not decoded: the committed capture live-main-menu.png has NEW GAME focused. Identified by rendering all five focus states and taking the minimum difference -- 531 differing pixels against 6080-7094 for the others, an 11.5x margin -- with the method controlled on live-main-menu-options-focused.png, whose answer is in its filename and which it picks by 4.7x. That means the port's choice matches the state of one committed frame. It does NOT make focus stable: Q5's four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME, and this identifies one frame rather than a rule. Delete this entry the day something says what SELECTS it. TIGHTENED 2026-08-29: Q5 now has SIX boots, and the shape is sharper than 'unstable' -- TUTORIAL x3, NEW GAME x3, and NO OTHER ITEM EVER OBSERVED. So it is not uniform over five buttons; whatever selects it has to explain a two-way split. That does not change this choice (NEW GAME remains one of exactly two observed states, and it is the state of the committed capture) but it does change what would REFUTE it: a boot opening on LOAD GAME, OPTIONS or EXTRAS would break the two-way shape, and a rule that predicts the split would delete this entry outright.",
" (was) ",
" (was) ✅ CONSISTENT WITH THE ONE CAPTURE, measured 2026-08-30. Rendering each of the",
" (was) five buttons focused against `live-main-menu.png` gives 0.0705 % for ptbtn01",
" (was) and 0.72-0.84 % for the other four -- a 10x discrimination. So that capture",
" (was) shows NEW GAME focused, and the authored choice matches it.",
" (was) ",
" (was) ⚠️ THIS DOES NOT OVERTURN Q5. Q5 measured initial focus as UNSTABLE across",
" (was) four boots; one capture showing ptbtn01 is consistent with that and does not",
" (was) contradict it. What the measurement establishes is narrower and still worth",
" (was) having: the port's focus rendering is distinctive enough that a capture",
" (was) identifies which button is focused, and this authored value is not at odds",
" (was) with the only frame we can check it against. It stays AUTHORED."
],
"on_cancel": {
"goto": "title",
"goto_name": "TITLE_SCREEN",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_SCREEN` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"kind": "measured",
"why": "MEASURED 2026-08-30, delivery-confirmed (B = 0x5801), 73.5 % of pixels changed, and both captures name themselves. Latency <= 0.4 s and NO loading screen in between, which matters because the disc carries four pgloading_* screens. This entry previously read 'likely but UNPROVEN': it had been seen once without a capture, and the title ALSO returns on its own after ~8-10 s idle, so an observer could not tell a response from a timeout. The <= 0.4 s latency is what kills that confound -- it is twenty times faster than the idle return. Decoder 86a8ce7, menu-navigation-semantics.md row 'B on the main menu', docs/re/data/b-on-main-menu.txt."
},
"buttons": {
"ptbtn01": {
"label": "NEW GAME",
"goto": null,
"goto_name": "DLG_SELECT_DIFFICULTY",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"✅ CORRECTED 2026-08-31: this read `DIFFICULTY`, and the destination is a",
"DIALOG rather than a GamePart -- `DLG_SELECT_DIFFICULTY`, `GP_DIALOG.pak`",
"entries 2/3 [see the withdrawal below]. Decoder, TWO arguments [corrected below]; the geometry one is",
"re-derived here with this port's own reader: entries 2 and 3 are the ONLY",
"builds in that archive carrying `pcbtn00`-`pcbtn03`, at design rows",
"259/329/399/469, spacing exactly 70. See",
"`crates/sylpheed-export/examples/dialog_rows.rs`.",
"",
"🔴 SO THE FOUR EXTERNAL DESTINATIONS ARE NOT UNIFORM: three open GameParts",
"and this one opens a dialog. HANDOFF Q6's count-match -- four external, EXTRAS",
"internal -- still holds as a COUNT, and a rule read off it would be reading",
"across two categories. The Decoder sent that count with disc support",
"yesterday and weakened it themselves today; recorded at the weaker strength.",
"",
"✅ THE REACH IS NOW BOUNDED -- 2026-08-31, and both agents scanned for it.",
"",
"It read: \"another four-button dialog with the same rows would be",
"indistinguishable by this evidence\". The Decoder searched every build in",
"every pak for four buttons within 6 px of those rows and found ZERO rivals.",
"Re-run here with this port's reader and a BROADER filter -- any element",
"whose name contains `btn`, not only `pcbtn`, so a rival under a different",
"naming convention would still be caught: 2 859 builds across 33 paks,",
"EXACTLY 2 matches, entries 2 and 3. The run carries its own known positive:",
"fewer than 2 would mean the reader cannot see the incumbents and its zero",
"would mean nothing.",
"",
"✅ And the name is now backed by a TABLE ENTRY rather than an inference",
"from a string list: every `DLG_` name in the image sits in a 12-byte record",
"(id, name pointer, handler [corrected]) spanning 0x820A0A2C-0x820A0D68 -- 70 names,",
"70 records, none unmatched. `DLG_SELECT_DIFFICULTY` is **id 2000**.",
"",
"🔴 \"THREE INDEPENDENT ROUTES\" CORRECTED TO TWO -- 2026-08-31, by the Decoder,",
"and I had relayed the count unchecked for the second time from one delivery.",
"",
"The image leg says DIFFICULTY is a dialog and names no entry, so alone it",
"identifies nothing. The disc and oracle legs are ONE COMPOUND ARGUMENT: the",
"capture is compared against the disc's rows. What makes that discriminating is",
"the EXCLUSION SCAN -- zero rivals within 6 px anywhere on the disc -- and that",
"is what the word \"three\" was taking credit for. The conclusion is unchanged;",
"the evidence is two arguments, one of them compound, and was never three.",
"",
"📌 The test that falls out of it, theirs: ask of an n-routes claim not whether",
"the routes are correct but whether ANY COULD HAVE COME OUT DIFFERENTLY GIVEN",
"THE OTHERS. That is an exclusion argument, and it is usually absent.",
"",
"🔴 WITHDRAWN 2026-08-31 -- \"AN EN/JP PAIR\", AND I RELAYED IT.",
"",
"The Decoder stated entries 2/3 as a language pair in the same HANDOFF row that",
"identifies DIFFICULTY, as a fact, and has withdrawn it: nothing established the",
"pairing. I copied it into this `why` -- twice -- in the SAME SENTENCE where I",
"was careful to say my re-derivation confirms the geometry and does not name the",
"screen. The unchecked half rode along inside the clause I had checked.",
"",
"What the scan actually shows is that adjacent GP_DIALOG entries are UNRELATED",
"DIALOGS: 26 of 65 adjacent pairs differ in BUTTON COUNT, which no language pair",
"can. Identical element sets is the language signature in GP_TITLE; here it is",
"equally consistent with a duplicate. So `2/3` are two builds with the same four",
"buttons at the same rows, and calling them EN and JP is an assumption.",
"",
"⚠️ THE IDENTIFICATION DOES NOT REST ON IT -- unique four-button geometry with",
"zero rivals disc-wide, plus the oracle capture. The pairing was decoration on a",
"conclusion that stands without it, which is exactly why it travelled unchecked.",
"",
"✅ RESTORED 2026-08-31, ON A MEASUREMENT RATHER THAN A RELAY. The Decoder took",
"the `ja` capture of DIFFICULTY that was missing and 2/3 ARE English/Japanese:",
"EN vs JP differ in 1.82 % of pixels in FOUR BANDS AND NOWHERE ELSE -- the",
"heading (DIFFICULTY -> the JP heading), the ring by 2 px, the BACK label, and",
"the footer. EASY/NORMAL/HARD are NOT in the differing set: the Japanese release",
"leaves the three difficulty names in Latin script, which is why the disc figure",
"is only 2.77 % of bytes against 1.82 % of pixels.",
"",
"📌 MY OBJECTION WAS NOT WRONG AND IS NOT WITHDRAWN. It was that IDENTICAL",
"ELEMENT SETS DO NOT IMPLY A LANGUAGE PAIR -- 26 of 65 adjacent pairs differ in",
"button count, so adjacency proves nothing. That argument still holds; what has",
"changed is that the conclusion now rests on a direct locale capture instead of",
"on that inference. A bad argument for a true claim is still a bad argument, and",
"the claim was correctly out of this file until somebody went and looked.",
"",
"⚠️ REACH, THEIRS: one JP boot, one screen, does not generalise. GP_TITLE 4/7 is",
"known to differ by MORE than text -- entry 7 carries nine sprites entry 4 lacks.",
"Nothing in the port keys off locale today; this is recorded, not consumed.",
"",
"🔴 RECORD LAYOUT CORRECTED 2026-09-01, and I had copied the wrong one. I wrote",
"\"(handler, id, name pointer)\"; it is {id, name_ptr, handler} -- the same three",
"fields shifted one word, so every record was being credited with the PREVIOUS",
"record's handler. The Decoder caught it with a control dump: under the old",
"alignment record 0 had a handler of 0x10000000, which is not a code address.",
"ids and names are unaffected and DLG_SELECT_DIFFICULTY is still 2000, so",
"nothing here moves except the sentence.",
"",
"📌 FOURTH aside of theirs relayed into this file. The first three were an EN/JP",
"pairing, a leg count and an independence claim -- all decorative. This one is a",
"STRUCTURE, which is worse: a wrong field order is the kind of thing a later",
"reader builds on, and it carried no weight here only by luck.",
"",
"❔ AND THE JOIN IS NOT REACHABLE BY THAT ROUTE -- their negative, with their",
"reach. All three handlers load the same global at 0x828E2B14 and take addresses",
"at 0x828E45E0/4640/467C, every one inside a 364 601-byte contiguous zero run:",
"BSS, populated only at runtime. Controlled, because an all-zero read is also",
"what a wrong address gives, and the dialog table itself reads non-zero through",
"the same arithmetic.",
"",
"⚠️ That closes the DIALOG HANDLERS, not the image. The archive loader and any",
"id-keyed table elsewhere are unexamined, so \"not in the image\" is NOT",
"established. Recorded as a route rather than an answer, which is how they sent",
"it.",
"",
"❔ STILL UNBOUND, and it is what would make this airtight: nothing connects",
"id 2000 to a pak entry. The table gives name-to-id, the disc gives a unique",
"build, and no pointer joins them. The tie is UNIQUENESS PLUS THE ORACLE",
"CAPTURE, not a binding -- so if a rival build ever appeared, this",
"identification would go with it.",
"",
"button count and geometry, NOT by a binding from the `DLG_` name to a pak",
"entry. No such binding was found. Another four-button dialog with the same",
"rows would be indistinguishable by this evidence -- my re-derivation",
"confirms the geometry and does not name the screen.",
"",
" (was) NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
" (was) screens are measured; the ids are a name match onto the executable's class",
" (was) names.\" So `DIFFICULTY` is a string that exists in the executable and plausibly",
" (was) denotes this screen -- nothing observed binds it to this transition.",
" (was) ",
" (was) It is carried so a reader can search for it and so the port never has to",
" (was) invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
" (was) a screen file, and this field is documentation.",
" (was) ",
" (was) 🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
" (was) labels rested on a sibling `why` that argues the DESTINATION -- a different",
" (was) claim from where the NAME came from. `tools/port/audit-kinds` reports that",
" (was) as BORROWED rather than ok, because a label resting on a neighbour's",
" (was) argument reads as evidenced and is not."
],
"blocked": "DIFFICULTY is not in this export. MEASURED destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA) but it is not a GP_TITLE build, so there is no screen file to go to yet.",
"skipped_chain": [
"DIFFICULTY",
"SELECT DATA"
],
"skipped_chain_why": "THE PORT SKIPS TWO MEASURED SCREENS HERE, AND IT SAYS SO OUT LOUD RATHER THAN PRETENDING. The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot -> ~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations (HANDOFF Q4) but neither is a GP_TITLE build, so there is no screen file to go to. The port jumps from NEW GAME to the one thing in that chain it has, and the runtime prints what it skipped on every run. This is a GAP, not a sequence: nobody may read the port's behaviour here as what the game does.",
"skipped_chain_kind": "measured",
"then_video": "S00A",
"then_video_why": "P7. HANDOFF Q9, DECODED from the movie manifest: MS00A -> S00A.wmv is the new-game intro, 93.9 s. Its POSITION is measured as well -- the movie starts ~4.5 s after (A) on the save slot, matched off the running game at 0.96-1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples.",
"then_video_kind": "decoded",
"unobserved_why": "WHAT FILLS THE ~4.5 s between the save slot and the movie is NOT KNOWN. The oracle run that would have shown it hit the already-documented sub_823070B0 cache crash after SELECT DATA. GP_TITLE does carry a LOADING screen -- entries 0/1 and 12/15, whose elements are every one of them named pgloading_* -- and LOADING is in the game's own screen vocabulary, but nobody has watched it appear here and the port does NOT put it in the chain on that basis. 📌 WHERE THE OPEN QUESTION LIVES, added 2026-09-01: docs/port/BLOCKED.md carries the row -- 'what fills the 4.5 s before S00A'. An explicit unknown still needs a citation, or it cannot be distinguished from an unexamined one.",
"skippable": true,
"skippable_why": "HANDOFF Q9, MEASURED: one (A) press skips a movie -- the title was reached at 57 s against a 193 s baseline. Same rule the boot intro already uses.",
"skippable_kind": "measured",
"after_video": {
"goto": "title",
"kind": "authored",
"why": "AUTHORED, and it has to be: the game goes into MISSION 1, and gameplay is out of scope (PORT-MISSION section 7). P7's gate asks for 'plays, then returns to a defined state' -- this is that state. The title is chosen over the main menu because the boot's own end state is the title, so a run that finishes the new-game intro lands somewhere a player can start again from. Nothing measured says the game does this."
}
},
"ptbtn02": {
"label": "LOAD GAME",
"goto": null,
"goto_name": null,
"blocked": "The save-slot list is GP_SAVE_LOAD, not in this export. Destination MEASURED."
},
"ptbtn03": {
"label": "TUTORIAL",
"goto": null,
"goto_name": "TUTORIAL_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TUTORIAL_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"blocked": "The lesson list is not a GP_TITLE build. Destination MEASURED."
},
"ptbtn04": {
"label": "OPTIONS",
"goto": null,
"goto_name": null,
"blocked": "The settings menu is GP_OPTIONS, not in this export. Destination MEASURED."
},
"ptbtn05": {
"label": "EXTRAS",
"goto": "extras",
"goto_name": "EXTRA_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `EXTRA_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF Q4: EXTRAS opens GP_TITLE build 6. It is the ONLY main-menu destination inside this archive, and therefore the only (A)-into-a-submenu the P5 gate can actually walk."
}
},
"labels_why": "The five labels are read off live-main-menu.png, a capture of the running game (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26). They are carried for logs and for a human reading this file; nothing draws them -- the button sprite already has its own text."
},
"extras": {
"initial_focus": "ptbtn11",
"initial_focus_kind": "measured",
"focus_persists": false,
"focus_persists_kind": "measured",
"focus_persists_why": [
"MEASURED 2026-08-30 -- EXTRAS RESETS. HANDOFF `4ed75e6`: ring back to",
"y=347.5 on re-entry after a confirmed DOWN, frame 0.0 % different from the",
"first entry, and the screen confirmed by eye as EXTRAS because an earlier",
"run was fooled about which screen it was on.",
"",
"📌 WRITTEN EXPLICITLY, THOUGH THE PORT'S DEFAULT IS ALREADY false. The",
"absent key and the measured false behave identically and mean completely",
"different things: one is 'nobody looked', the other is 'the game was",
"watched doing it'. `tools/port/audit-kinds` can see the second and not the",
"first, which is the whole reason for spending a key on it.",
"",
"🔴 AND THIS IS NOT A VINDICATION OF HOW IT GOT HERE. For one iteration the",
"port ASSERTED non-persistence for EXTRAS in `contract-check` while nothing",
"had measured it; the Decoder flagged that, and it turned out right. Being",
"right by luck does not retroactively make it evidence -- declining to",
"generalise the memory was the correct move, and encoding 'not measured",
"here' as a positive claim was a different and wrong one that happened to",
"land. The measurement is what makes it true; the assertion never did.",
"",
"⚠️ Do NOT generalise in either direction: main_menu persists, EXTRAS resets,",
"and OPTIONS / LOAD GAME / TUTORIAL are untouched."
],
"initial_focus_why": [
"MEASURED, unlike the main menu's: EXTRAS opens focused on MISSION SELECT (live-extras.png). It is authored here only because there is nowhere else to put a measurement -- it is not a choice.",
"",
"",
"✅ CAVEAT LIFTED 2026-08-30 -- MEASURED, not a single-entry reading any more.",
"HANDOFF `4ed75e6`, docs/re/data/extras-focus-resets.txt: EXTRAS opens at ring",
"y=347.5 on MISSION SELECT, moves to 427.5 after one delivery-confirmed DOWN,",
"and returns to 347.5 on re-entry with the frame 0.0 % different from the first",
"entry. Because this screen RESETS, a single-entry reading of it is not",
"measuring history -- which is precisely what made the caveat necessary while",
"persistence here was unknown.",
"",
"✅ THE AMBIGUITY IS RESOLVED -- MEASURED 2026-08-31, and it went the way",
"that makes `ptbtn11` right for a REASON rather than by coincidence.",
"",
"A submenu resets to ITS OWN OPENING ITEM, and that item is a per-screen",
"default which need NOT be the first. Decoder, docs/re/data/",
"difficulty-resets-to-named-item.txt: DIFFICULTY opens on NORMAL (second of",
"four); after one confirmed DOWN to HARD, (B) out and (A) back returns to",
"NORMAL -- in-cursor 1.0 from where it opened against 93.9 from where it was",
"left. Reproduced on a FRESH BOOT and confirmed by eye, not read off the",
"2026-08-29 capture.",
"",
"So the port's `initial_focus` is the reset target, and `buttons[0]` in",
"`MenuFlow.initial_focus` is a REPAIR rather than a default -- which is how",
"it was already documented, and is now measured rather than principled.",
"",
"❔ STILL OPEN, and not leaned on: whether the reset target MOVES once a",
"difficulty has actually been confirmed. A game that remembered your last",
"choice would behave differently, and the probe never confirms one -- the",
"same SELECT DATA crash that constrains the run prevents testing it.",
"",
"",
"🔴 CORRECTED 2026-08-31. This read \"it matters IF another screen is ever",
"authored\" whose opening item is not its first. Such a screen exists and is",
"recorded IN THIS FILE: DIFFICULTY, under `main_menu/buttons/ptbtn01`, is",
"EASY/NORMAL/HARD/BACK and opens on NORMAL -- the SECOND of four. Measured:",
"driven with no d-pad, unchanged for 90 s, matching the committed capture at",
"r=+0.999 (Decoder, docs/re/captures/newgame-path/newgame-difficulty.png).",
"",
"So \"a screen opens on its first item\" is REFUTED as a general description of",
"this game. On EXTRAS, TUTORIAL and OPTIONS the named item and the top item",
"coincide BY ACCIDENT. A top-item rule would be wrong on DIFFICULTY.",
"",
"nobody can separate \"resets to MISSION SELECT\" from \"resets to the TOP ITEM\".",
"They coincide here -- ptbtn11 is both. The port's value is correct under either",
"reading, and the REASON is not established.",
"",
"The superseded caveat is kept below.",
" (was) ⚠️ WEAKENED 2026-08-30 -- the OBSERVATION stands, its reading as an INITIAL",
" (was) focus does not. It was taken on a single entry. Now that the main menu is known",
" (was) to remember its cursor across a round trip, a one-entry reading of any screen",
" (was) may be measuring HISTORY rather than what the screen opens on -- the same",
" (was) objection that reframed the main menu's TUTORIAL/NEW GAME disagreement.",
" (was) ",
" (was) Kept as `measured` because the frame really does show MISSION SELECT focused,",
" (was) and kept as the port's opening item because it is the only reading there is.",
" (was) 🔴 If EXTRAS turns out to persist, this becomes history and the kind must",
" (was) change with it.",
"",
"🔴 CHECKED AGAINST THE BYTES 2026-08-31 by both agents -- and NOT independently.",
"Settled by fact, not by my inference: the Decoder's 282/362/442 came from",
"`crates/sylpheed-formats/examples/extras_button_order.rs`, which calls",
"`ui_layout::parse_build` -- THE SAME CRATE this port's export uses. The",
"Python RATC parsers in their tree exist and did not produce that number.",
"So the two legs are ONE READER USED TWICE, and the agreement carries no",
"information about the reader being right; it carries information only about",
"two callers of it agreeing, which they could not fail to do.",
"",
"⚠️ The VALUE is unaffected -- `ptbtn11` is decided by the DIFFICULTY",
"measurement and by the reset finding. What died is a word I used about the",
"evidence, which is the third such word in three iterations.",
"",
"is WEAKENED, by my own audit rather than by theirs.",
"",
"Applying their test to my own sentence: could my reading have come out",
"differently given theirs? Only if the implementations differ. Mine is",
"`sylpheed_formats::ui_layout::parse_build` via this port's export. Their tree",
"does carry separate Python RATC parsers (`kf_record_census.py` and others),",
"so a second implementation EXISTS -- but which reader produced their",
"282/362/442 is not established by me, and if they used the same crate the",
"two legs are one reader used twice.",
"",
"So: the values agreeing is still evidence, and calling it INDEPENDENT was a",
"claim about their tooling that I did not check. Recorded at the strength I",
"can support. ⚠️ Nothing rests on it -- the row order is also decided by the",
"DIFFICULTY measurement -- which is exactly why it went unexamined.",
"",
"Decoder attempted to refute this value and it survives: `ptbtn11` is the TOP",
"button on this screen -- y 282 against 362 and 442 -- so the port is right",
"whichever reading of the reset target applies. Confirmed from THIS port's own",
"export, a different reader of the same disc: extras 282/362/442, and the main",
"menu as a control at 162/242/322/401/482.",
"",
"🔴 WHICH ALSO MEANS EXTRAS CANNOT SEPARATE the two readings -- named item and",
"top item coincide here. It was DIFFICULTY, opening on its second of four, that",
"settled it."
],
"on_cancel": {
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you came from. EXTRAS advertises (B) in its own footer -- the red glyph is in ptmsg2.png and absent from the main menu's ptmsg.png."
},
"buttons": {
"ptbtn11": {
"label": "MISSION SELECT",
"goto": null,
"goto_name": null,
"blocked": "The stage list is GP_MISSION_SELECT, not in this export. Destination MEASURED."
},
"ptbtn12": {
"label": "MOVIE THEATER",
"goto": null,
"goto_name": null,
"blocked": "NEVER OPENED. docs/game/navigation.md marks this one unknown -- not merely unexported. Do not assume it opens GP_MOVIE_THEATER; that would be a name match dressed as a destination."
},
"ptbtn13": {
"label": "BACK",
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"same_as_cancel": true,
"why": "MEASURED: EXTRAS' third item is BACK (live-extras.png). Treated as (B): it pops the stack, so focus is restored on the main menu exactly as (B) does. Whether the game distinguishes them is untested and there is no reason here to invent a difference."
}
}
}
}
}

172
authored/rendering.json Normal file
View File

@@ -0,0 +1,172 @@
{
"format": "sylpheed.rendering/1",
"_": [
"WHICH decoded rules the runtime applies where. AUTHORED because it is a",
"choice about the REACH of somebody else's decode, not about the disc.",
"Delete an entry the day the decode covers the case outright.",
"",
"The exporter flags `leaf_carries_geometry` on 15 elements -- those whose",
"nested `.rat` leaf declares a scale or rotation the parent does not. That",
"flag is a CENSUS FACT and it is emitted for all 15. What is DECODED is",
"narrower: the Decoder fitted the game's own composed alpha (per-draw vertex",
"colours C3FFFFFF / B6FFFFFF = 195 and 182) against the ptloop leaves and got",
"one consistent time, then PREDICTED the quad centres to ~11 px. That covers",
"`ptloop01` and `ptloop02` and nothing else."
],
"draw_leaf_for": [
"ptloop01",
"ptloop02"
],
"draw_leaf_why": [
"The two the decode covers. `docs/re/structures/ui-leaf-vs-parent-alpha.md`.",
"",
"NOT DRAWN, though the exporter flags them and ships their data:",
"",
" `title_jp/ptlogo_eff2` -- OUT OF SCOPE, which is a better reason than",
" the caution this entry first gave. MISSION section 7 scopes out",
" 'localisation beyond English', and this element exists only on the",
" Japanese title. So it is not a thing the menu port has to answer, and the",
" parked Japanese-locale capture does not need reviving on its account --",
" that is the human's call and not something either agent widens quietly.",
"",
" It is ALSO undecidable here even if it were in scope. Its 125% is a POP,",
" not a steady scale: scale-0 -> 125% -> scale-0 between t=50 and t=107,",
" about 0.95 s. The leaf draws at 100%, as two superimposed copies at alpha",
" 160 and 80, each rotating 360 degrees over 960 units -- 16 s a turn. If",
" parent scale gates the leaf it is a 0.95 s flash; if the leaf runs free it",
" spins for 16 s. Nothing on the disc chooses and title_jp has no oracle",
" capture.",
" `build_12,15/pgloading_loop5` -- STILL NOT DRAWN, but the reason given here",
" was WRONG and is replaced. It read \"leaf scale (0,0). A zero scale is one of",
" the three historical failures this corpus names\" -- which describes t=0 and",
" t=30 and nothing after them.",
"",
" What the leaf actually holds, read out of the export: ONE element,",
" `pgloading_ring`, with a sprite, whose scale ramps 0 -> 250 -> 800 -> 1000",
" while its alpha rises to full at t=55 and falls to nothing by t=130. An",
" expanding, fading ring -- a loading pulse, not a degenerate record.",
"",
" 🔴 And it is VISIBLE at the instant this port poses. `build_12`'s settle",
" window is [40, 48], so the pose lands near t=44, where the ring interpolates",
" to scale 140 at alpha 143. So withholding it is not declining to draw",
" nothing; it is declining to draw something, and the old reason hid that.",
"",
" It stays withheld on the reason below, which is the one that always applied:",
" there is no way to adjudicate it here. The loading screens have no oracle",
" capture -- the RE agent records them as not reachable from the title path --",
" and `verify-screen` compares against a renderer that draws no leaves at all.",
" Drawing it would put unadjudicable content on a screen, which is the same",
" test `ptlogo_eff2` fails.",
"",
"AND THERE IS NO WAY TO ADJUDICATE EITHER HERE. `title_jp` has no oracle",
"capture, and `verify-screen` compares against `sylpheed-cli`, which does not",
"draw leaves at all -- so ANY leaf drawing increases that divergence whether",
"it is right or wrong. Its max went 155 -> 232 when they were drawn, and that",
"number is not evidence in either direction.",
"",
"What deletes this list: a decode covering those cases, or an oracle capture",
"of title_jp."
],
"draw_leaf_kind": "decoded",
"loop_leaf_on_screens": [
"title"
],
"loop_leaf_why": [
"WHICH screens replay a leaf's group instead of letting it run once and park.",
"MEASURED on the title, UNRESOLVED on the menus, so it is scoped to the title.",
"",
"The disc gives one pass: ptloop01's leaf runs t=0..600 and ptloop02's t=0..720,",
"each ending parked off-screen at x=1521 / -839. The port ran them once.",
"",
"THE ORACLE SAYS THEY LOOP ON THE TITLE. Across two title dwells the sweep quad",
"oscillates over its whole x range and resets hard to the same start value --",
"one reset inside the first dwell, two inside the second. A run-once-and-park",
"shows one traverse and then a constant x.",
"",
"🔴 THE LOOP-LENGTH FIELD CANNOT SETTLE THIS, and I had hoped it would.",
"`ptloop01` declares 600 with keyframes to exactly 600; `ptloop02` declares 720",
"to 720. SLACK ZERO -- and 'loops at 600' and 'runs once for 600 and stops'",
"write the identical header. 92.3% of records on the disc are in that state, so",
"the field discriminates loop length only where there IS slack, as the plate's",
"105-in-120 had.",
"",
"⚠️ THE MENUS ARE NOT COVERED, on purpose. Both declare the same 600/720, so",
"nothing on the disc distinguishes them -- but the oracle measurement is of the",
"title, and my own weak evidence points the other way for the menu: sweeping the",
"phase against live-main-menu.png, the port matches best with the sweeps",
"OFF-SCREEN (0.061%) and three times worse mid-screen (0.183%). If they looped",
"with a 600-unit period the sweep is on screen for roughly 73% of the cycle, so",
"a capture showing none is not nothing -- but it is one capture, and 'best",
"match' is a weak instrument for an absence. Two weak signals in opposite",
"directions is a reason to scope, not to pick.",
"",
"What settles the menu: a direct capture of it, which the Decoder has offered.",
"",
"🔴 RE-MEASURED 2026-08-31, BECAUSE THE EVIDENCE ABOVE WAS TAKEN WITH THE WRONG",
"BLEND. The phase sweep that produced '0.061 % off-screen, 0.183 % mid-screen'",
"drew the sweeps ALPHA-OVER. They are additive -- measured off the running game",
"the same day (`additive_elements`) -- so an on-screen sweep composited the wrong",
"way was being scored against the capture, and 'mid-screen is worse' could have",
"been an artefact of my own compositing rather than of the sweeps being absent.",
"",
"Re-run with additive sweeps and looping switched on for the menu, against",
"`live-main-menu.png`:",
"",
" phase 0 0.0208 % sweeps paint 0 px -- off screen",
" phase 150 0.0851 % sweeps paint 58 027 px, bbox 884x720",
" phase 300 0.0205 % sweeps paint 0 px -- off screen",
" phase 75 / 225 / 375 / 450 / 525: 0.086..0.122 %",
" run-once-and-park, which is what the port ships: 0.0208 %",
"",
"✅ THE CONCLUSION HELD AND GOT STRONGER. The ratio was 3x with the wrong blend",
"and is 4-6x with the right one, and the absolute numbers improved everywhere.",
"The capture still matches best with the sweeps NOT VISIBLE. So this entry stays",
"scoped to the title, and the correction is recorded rather than the scoping",
"changed.",
"",
"⚠️ It is still one capture and 'best match' is still a weak instrument for an",
"absence -- that caveat is not repaired by fixing the blend, only cleared of one",
"confound.",
"",
"📌 AND THE NEW DRAW LOG DOES NOT SETTLE IT EITHER, though it looks like it",
"should. `docs/re/captures/ui-draws/blend-main-menu-2026-08-31.log` shows both",
"sweep strips SUBMITTED on the main menu, in every frame group. That is not",
"evidence they animate there: a quad parked off-screen at x=1521 is still a draw",
"call. A DRAW IS NOT A VISIBLE ELEMENT, and reading that log as 'the sweeps run",
"on the menu' would have contradicted the pixels for no reason."
],
"loop_leaf_kind": "measured",
"additive_elements_deleted_why": [
"✅ DELETED 2026-09-01, and the deletion is the point.",
"",
"This held `additive_elements`, a per-screen list of element ids transcribed",
"from the Decoder's per-draw RB_BLENDCONTROL0 log. PORT-MISSION section 3: 'When",
"the RE agent later decodes something you had authored, delete the authored",
"entry and let the exporter emit it. That deletion is the measure of progress.'",
"",
"The blend is now DECODED -- `T8aD +0x04` bit 0x02, docs/re/structures/",
"ui-blend-mode-decoded.md -- and reachable since formats-pin-2026-09-01 exposed",
"`ui_layout::sprite_blend_additive` and `blend_additive_by_name`. The exporter",
"emits `blend_additive` per element and per nested focus/leaf element, and",
"ScreenView reads it there.",
"",
"🔴 CHECKED BEFORE THE SWAP, and the map turned out to be a SUBSET rather than",
"the answer. Over main_menu, extras, press_start and title:",
"",
" 15 the map called additive AND the disc agrees",
" 0 the map called additive and the disc does not <- no contradictions",
" 17 the disc calls additive and the map did not",
"",
"So nothing transcribed was wrong; it was incomplete, and was being read as",
"complete. The 17 include `pteff03`/`pteff03a` -- the sweep LEAVES, which are",
"what `draw_leaf_for` actually puts on screen while the map listed their parents",
"`ptloop01`/`ptloop02` -- and TWELVE on `title`, where this map was deliberately",
"empty and the port therefore drew every title effect alpha-over.",
"",
"A name-keyed map can only answer for a screen somebody drove the game to. That",
"is what made the Japanese menus an open question (BLOCKED.md H6): the port drew",
"main_menu additive and main_menu_jp alpha-over, asserting by omission that the",
"JP build blends differently. The bit is on the disc for every screen at once, so",
"that asymmetry is now answered statically and H6 needs no capture."
]
}

View File

@@ -1,94 +1,94 @@
{
"format": "sylpheed.screen_names/1",
"_": [
"Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its",
"builds, so every name here is a decision. The identifications come from",
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
"captures of the running game; the exporter stamps the name into the screen",
"file with name_source: \"authored\" so a reader can tell a recovered name from",
"an invented one.",
"",
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
"a name that moves when the enumeration rule changes is not a name. The entry",
"was always described here as the stronger locator -- now it is the only",
"stable one.",
"",
"Delete an entry here the day the RE agent decodes a name field."
],
"archives": {
"dat/GP_TITLE.pak": {
"2": {
"name": "press_start",
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
},
"3": {
"name": "press_start_jp",
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need."
},
"4": {
"name": "title",
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
},
"5": {
"name": "main_menu",
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
},
"6": {
"name": "extras",
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
},
"7": {
"name": "title_jp",
"why": "HANDOFF Q2: the Japanese twin of build 4."
},
"8": {
"name": "main_menu_jp",
"why": "HANDOFF Q2: the Japanese twin of build 5."
},
"9": {
"name": "extras_jp",
"why": "HANDOFF Q2: the Japanese twin of build 6."
},
"10": {
"name": "publisher_logo",
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)."
},
"13": {
"name": "publisher_logo_r",
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs."
},
"11": {
"name": "developer_logos",
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements."
},
"14": {
"name": "developer_logos_r",
"why": "The region twin of entry 11, as 13 is to 10."
}
}
},
"unnamed": {
"dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing."
},
"also_export": {
"dat/GP_TITLE.pak": {
"10": {
"name": "publisher_logo",
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments."
},
"11": {
"name": "developer_logos",
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn."
},
"13": {
"name": "publisher_logo_r",
"why": "As entry 10, region twin."
},
"14": {
"name": "developer_logos_r",
"why": "As entry 11, region twin."
}
}
"format": "sylpheed.screen_names/1",
"_": [
"Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its",
"builds, so every name here is a decision. The identifications come from",
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
"captures of the running game; the exporter stamps the name into the screen",
"file with name_source: \"authored\" so a reader can tell a recovered name from",
"an invented one.",
"",
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
"a name that moves when the enumeration rule changes is not a name. The entry",
"was always described here as the stronger locator -- now it is the only",
"stable one.",
"",
"Delete an entry here the day the RE agent decodes a name field."
],
"archives": {
"dat/GP_TITLE.pak": {
"2": {
"name": "press_start",
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"3": {
"name": "press_start_jp",
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"4": {
"name": "title",
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"5": {
"name": "main_menu",
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.) 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"6": {
"name": "extras",
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"7": {
"name": "title_jp",
"why": "HANDOFF Q2: the Japanese twin of build 4. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"8": {
"name": "main_menu_jp",
"why": "HANDOFF Q2: the Japanese twin of build 5. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"9": {
"name": "extras_jp",
"why": "HANDOFF Q2: the Japanese twin of build 6. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"10": {
"name": "publisher_logo",
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM). 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"13": {
"name": "publisher_logo_r",
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"11": {
"name": "developer_logos",
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"14": {
"name": "developer_logos_r",
"why": "The region twin of entry 11, as 13 is to 10. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
}
}
},
"unnamed": {
"dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing."
},
"also_export": {
"dat/GP_TITLE.pak": {
"10": {
"name": "publisher_logo",
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"11": {
"name": "developer_logos",
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"13": {
"name": "publisher_logo_r",
"why": "As entry 10, region twin. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"14": {
"name": "developer_logos_r",
"why": "As entry 11, region twin. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
}
}
}
}

View File

@@ -1,59 +1,604 @@
{
"format": "sylpheed.timing/1",
"keyframe_units_per_second": 60,
"why": [
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
"transition quad is declared black for 12 units, and a capture measured the",
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
"",
"Expressed as units-per-second rather than seconds-per-unit so the value is",
"exact rather than a repeating decimal a reader has to recognise.",
"",
"DELETE THIS FILE when a field on the disc is found that states the unit.",
"Nothing here is on the disc."
"format": "sylpheed.timing/1",
"keyframe_units_per_second": 60,
"why": [
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
"transition quad is declared black for 12 units, and a capture measured the",
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
"",
"Expressed as units-per-second rather than seconds-per-unit so the value is",
"exact rather than a repeating decimal a reader has to recognise.",
"",
"DELETE THIS FILE when a field on the disc is found that states the unit.",
"Nothing here is on the disc.",
"",
"🔴 DO NOT 'CORRECT' THIS AGAINST AN EMULATOR FRAME RATE. A draw-stream",
"measurement on 2026-08-29 found the presented units-per-frame rising 33 % over",
"a single boot (1.765 early, 2.357 late) and three independent readings of one",
"container's rate disagreeing with each other. That is the EMULATOR's",
"presentation pacing drifting, and no single units-per-frame figure describes a",
"run there.",
"",
"60 is a different quantity: the GAME's logical unit rate, measured off the",
"running game as HANDOFF Q1 (a declared t=30 landing on the linear value at",
"every one of seven sampled frames). The port renders at its own frame rate and",
"converts through this constant, so guest pacing cannot reach it. The two",
"numbers are not comparable and one is not evidence about the other.",
"",
"🔴 2026-09-01 — THE FIRST LEG ABOVE IS RETIRED. THE VALUE IS NOT.",
"",
"'2 units per rendered frame ... a 30 Hz game, giving 60 units per second' is a",
"FRAME-COUNT derivation, and the Decoder retired that mechanism the same day",
"(docs/re/units-per-second-measured.md): the same animation takes 21 frame",
"labels in one capture and 33 in another, and one splash logo steps +136,+34 in",
"one run and +17,+51,+34,+34,+17,+17 in the other. A fixed per-frame increment",
"cannot do that. The clock is TIME-INTEGRATED, not frame-counted, so `units =",
"2 x frames` computes an emulator artefact. The 2 was that run's frame pacing.",
"",
"✅ The port's RUNTIME was already right: `boot.gd` advances",
"`time_units += delta * units_per_second`, off delta time. Nothing in this port",
"derives a unit from a frame count. Audited 2026-09-01, and it is why the",
"retirement cost a justification and not a behaviour.",
"",
"✅ AND THE SECOND LEG NEVER TOUCHED A FRAME COUNT, which is why 60 survives:",
"the transition quad is declared black for 12 units and the capture bracketed",
"the pure-black plateau at 0.14-0.30 s (title-plate-delay-measured.md, at a",
"0.125 s sampling resolution). 12 units in 0.14-0.30 s is 40-86 units/s. That",
"is a declared unit count against a wall-clock duration, with no frames in the",
"chain -- and it EXCLUDES 120 units/s, which would need 0.10 s.",
"",
"✅ MEASURED DIRECTLY 2026-09-01: 56.8 units per guest second, control passing",
"at 1.15 %, from two elements agreeing at one clock (`ptbtn00` 657.9 alpha/s,",
"`ptcopyright` 650.4 alpha/s, which puts ptcopyright's segment at T = 22.25 --",
"a rate agreement AND a round declared length). 30 and 120 are both excluded.",
"",
"60 IS KEPT. 56.8 is 5.6 % away against a ~5 % quantisation resolution, so it",
"does not refute 60, and the Decoder explicitly did not ask for a change. The",
"reach is the TITLE: the splashes are a different GamePart and nothing yet shows",
"they tick at the same rate.",
"",
"⚠️ If anyone re-fits this from alpha: DROP THE LAST STEP of a ramp. It clamps",
"at 255 and reports more elapsed time than it consumed -- worth 4 % on the plate.",
"",
"🔴 2026-09-01 (later) — A PER-SCREEN RATE WAS PROPOSED AND NOT ADOPTED.",
"",
"docs/re/splash-declared-vs-captured.md proposes ~57 units/s for the title and",
"~35-40 for the splashes, i.e. that one constant cannot be right and that a",
"splash at 60 runs 1.5-1.7x too fast. THE PORT DID NOT MOVE, and the reason is",
"arithmetic on a measurement already cited in this file:",
"",
" the 160-unit hold is the DEVELOPER splash's a=255 plateau, t=30..190, and it",
" is measured at 4.514 s. The 210-unit group CONTAINING it is measured at",
" 3.37/3.50/3.51 s over three cold boots (the dwell_why block below). A",
" sub-interval cannot outlast the interval containing it.",
"",
"The same three boots put the splashes at 57.7 and 60.7 units/s -- corroborating",
"60 on exactly the two screens the new figure puts at 35-39. At 35.4 the declared",
"groups would run 5.93 s and 7.20 s against corpus dwells of 3.37-3.51 and",
"4.30-4.60, i.e. each splash ~70 % longer than measured.",
"",
"⚠️ DO NOT ADOPT EITHER NUMBER UNTIL THAT IS RESOLVED, and do not split the",
"difference -- averaging two measurements that cannot both be true is not a",
"third measurement. docs/port/splash-rate-contradiction.md, asked as BLOCKED H7.",
"",
"⚠️ AND THE STRUCTURAL CLAIM MAY STILL BE RIGHT. 'One rate cannot cover every",
"screen' is a claim about the format, and the title's 56.8 does sit ~5 % off the",
"splashes' 58-61. If a per-screen rate is real this file should carry a MECHANISM",
"-- a field or a GamePart constant -- not two authored numbers. The Decoder has",
"'where the per-GamePart rate comes from' as its next item.",
"",
"🔴 2026-09-01 (later still) — RECLASSIFIED measured -> authored. THE VALUE DOES",
"NOT MOVE; THE LABEL WAS FALSE.",
"",
"The Decoder withdrew their guest-frame-rate finding the same day they published",
"it. ⚠️ THAT DOCUMENT IS NOT IN THIS CHECKOUT -- it is `guest-frame-rate-WITHDRAWN.md`",
"on their branch, named here in prose deliberately rather than in `source`:",
"`audit-kinds` flagged the first version of this entry DANGLING because I cited",
"a file I cannot read, which is exactly the check doing its job. The reading",
"below is from their message and is labelled as such.",
"It read the guest's presentation as 30 fps from a movie-frame ruler and",
"concluded 2 x 30 = 60. This file carried `kind: measured` on that strength.",
"`kind: measured` on the strength of it. It cannot any more.",
"",
"Three routes now disagree and at most one can be right:",
"",
" withdrawn movie cadence 60 units/s",
" vblank cadence (Xenia, 60 Hz) ~120",
" title-plate-delay, 120 units ~56 -- two runs agreeing to 6 ms",
"",
"⚠️ 60 IS KEPT ANYWAY, and it is not a coin toss between the three. The one leg",
"of this file's own reasoning that never touched a frame count still stands and",
"still brackets it: the transition quad is declared black for 12 units and the",
"capture measured the plateau at 0.14-0.30 s, i.e. 40-86 units/s. 60 sits inside",
"that; 120 does not. And ~56 is 7 % from 60, inside the same bracket.",
"",
"So the honest statement is: 60 is AUTHORED, bracketed by one surviving",
"frame-free measurement, and consistent with the nearest of the three live",
"routes. It is no longer 'measured', and anything that cited it as measured is",
"citing a withdrawal.",
"",
"📌 THE METHOD NOTE IS WORTH MORE THAN THE NUMBER, and it is the Decoder's: their",
"pre-registration named three ways the ruler could lie and guarded two. The third",
"occurred, and a PERFECT 1.0000 is exactly what it produces -- a triple buffer",
"rotating once per present gives run-length 1 at any frame rate. Both guards",
"tested how the buffer was READ, neither tested whether a change meant a decode.",
"",
" A clean result on an instrument whose key assumption is unguarded is not",
" confirmation. The cleanness may be the failure mode's own signature.",
"",
"Same family as this port's non-inverting latch check, which passed for the wrong",
"reason until its control failed.",
"",
"🔴 2026-09-01 — THE 12-UNIT BRACKET ABOVE IS WITHDRAWN. IT EXCLUDES NOTHING.",
"",
"I kept 60 on the ground that '12 declared units measured at 0.14-0.30 s gives",
"40-86 units/s, so 120 is excluded'. The Decoder refuted it and the refutation",
"holds on arithmetic I checked myself:",
"",
" the source doc says of that number, in its own words, 'at a sampling",
" resolution (0.125 s) that cannot do better'. 120 units/s predicts 12 units in",
" 0.100 s -- BELOW one sample interval. A 0.125 s sampler cannot resolve it and",
" reports about one sample, ~0.125-0.14 s. The 0.14 s low end is the",
" INSTRUMENT'S FLOOR, and 12/0.14 = 85.7 is an upper bound produced by dividing",
" by a floored duration. It is the value 120 predicts once the sampler is",
" accounted for.",
"",
"🔴 AND THE DEEPER ERROR IS MINE, NOT THE ARITHMETIC. I argued the leg survived",
"because it 'never touched a frame count'. True, and INSUFFICIENT: every",
"wall-clock duration off Canary is true/speed_factor, so apparent units/s =",
"true x speed -- and the speed factor is precisely what makes the three routes",
"disagree. I checked the leg for the WRONG CONTAMINANT. Frame-free is not",
"clock-free, and on this emulator clock-free is the property that matters.",
"",
"What actually survives from that leg, and it is the half I did not lead with:",
"the declared 12 units are independently confirmed as SIX FRAMES by",
"screen-transitions.md's 255/6-per-frame ramp. No wall clock in it at all. That",
"is evidence about units per FRAME -- which was never in dispute -- and silent",
"about units per second.",
"",
"SO 60 HAS NO SURVIVING BRACKET. It stays because nothing supports 120 either and",
"moving a shipped timeline on no evidence is worse than leaving it. That is a",
"default, not a derivation, and this entry now says so. `kind` is already",
"`authored`, which is the honest label for a default.",
"",
"🟡 2026-09-01 — 120 units/s IS NOW MEASURED, AND THIS PORT HAS NOT MOVED.",
"",
"The Decoder's content-hash experiment gives 120 (2 units/present x 60",
"presents/s), with the controls the withdrawn version lacked -- a static texture",
"hashing constant, 1 change in 403 samples, and movie luma not constant, 102",
"distinct hashes. Pre-registered bands, and the observed 0.5739 falls inside",
"them. It is a better experiment than either of the two it replaces.",
"",
"IT IS ALSO THEIR THIRD POSITION ON THIS NUMBER IN ONE DAY, reach is one boot,",
"and they said themselves that a second independent boot before a timeline is",
"rewritten is the defensible call. Agreed. 60 stays for now.",
"",
"⚠️ 60 IS NOT DEFENDED EITHER -- its bracket was withdrawn this morning. Both",
"numbers are undefended; the port keeps the one it ships because switching on a",
"single capture is a worse failure than holding on none. That is the whole",
"reasoning and it is not evidence about the game.",
"",
"✅ AUDITED, SO THE SWITCH IS CHEAP WHEN IT COMES: no seconds are baked into the",
"timeline anywhere. Every second this port prints or acts on is computed as",
"units / keyframe_units_per_second at the point of use. audio.json's loop_start_s",
"and loop_end_s ARE seconds and correctly do NOT follow this constant -- they are",
"positions in an audio file with no keyframe unit in them.",
"",
"🔴 One exception found and fixed: tools/port/verify-dwell read black_hold_units",
"from this file 'so it cannot drift again' and then divided by a literal 60.0.",
"The value could not drift; the conversion could.",
"",
"📌 THE FALSIFIER IS PRE-REGISTERED in docs/port/units-per-second-switch-readiness.md:",
"at 120 the publisher splash runs 2.13 s and the developer 1.75 s, against three",
"cold boots measuring 4.30/4.60/4.37 and 3.51/3.50/3.37. 120 and the dwell corpus",
"cannot both be right in wall-clock seconds -- the same collision that killed the",
"35 units/s proposal from the other direction.",
"",
"✅ 2026-09-01 (final position of the day) — 120 IS WITHDRAWN BY ITS AUTHOR AND 60",
"IS POSITIVELY SUPPORTED. The port never moved, so nothing has to be undone.",
"",
"The mechanism is worth more than the number: `units per present` HALVED when the",
"present rate doubled (Δα +34 at 27.2 presents/s, +17 at 51.4) while units per",
"second did not move (54.4 vs 51.4). The UI clock advances by elapsed TIME, not",
"by frame count -- so '2 units per frame' was never a property of the game, only",
"of a capture that happened to run at 27 fps. The 120 was 2 units/present x 60",
"presents/s, and the first factor is not a constant, so the product was not a",
"rate.",
"",
"Their write-up is `units-per-frame-is-not-a-constant.md`, under docs/re/ on",
"their branch. 🔴 NOT IN THIS CHECKOUT, so it is named WITHOUT a resolvable",
"path -- `tools/port/check-citations` flagged the first version of this very",
"paragraph as DANGLING, in the entry where I was recording the lesson about",
"dangling citations. The check does not care about a disclaimer, which is",
"correct: a path that does not resolve does not resolve.",
"",
"✅ 2026-09-01 (settled) — THE GAME'S CLOCK IS FRAME-BASED, 1 UNIT PER PRESENT.",
"Measured by the Decoder with a DESIGNED experiment rather than an inference:",
"`--framerate_limit=30` halved units/second to 30.2, doubled the publisher dwell",
"to 8.450 s, and left the modal alpha step at 17 where a time-based clock",
"predicts 34. Both controls passed first -- the limiter demonstrably took effect,",
"and all 8 splash quad rects were identical, so nothing but the frame rate",
"differed. `255 x 1 / 15 = 17` at 28.4, 51.4 and 54.8 presents/s alike.",
"",
"⚠️ THIS CHANGES WHAT 60 MEANS HERE, AND MAKES IT MORE FALSIFIABLE. If the game",
"advances 1 unit per present, its units/second IS its present rate. So",
"`keyframe_units_per_second = 60` is now equivalent to the claim:",
"",
" the game presented these screens at 60 Hz on the console.",
"",
"That is a sharper statement than 'the unit is 1/60 s' and it is checkable.",
"",
"✅ AND IT IS SUPPORTED, which the constant has not been until now. Canary",
"unlimited presents at 51-55 Hz and the splash dwell is 4.30/4.60/4.37 s over",
"three cold boots. A natively 30 Hz game would present at ~30 in Canary too --",
"which the framerate_limit run confirms, since forcing 30 made the same splash",
"take 8.45 s. It does not take 8.45 s unforced. So the game asks for ~60, not 30.",
"",
"🔴 AND THAT CLOSES THE CONSTANT AS A CAUSE OF 'THE PLATE IS LATE', for a NEW",
"reason and in the direction that matters. Under the frame-based model the only",
"alternative console rate is 30 Hz, which puts the plate at 236/30 = 7.87 s --",
"LATER than the 3.93 s the port ships, not earlier. There is no console present",
"rate that makes the plate arrive sooner than it already does here.",
"",
"⚠️ KEPT AS `authored`, NOT PROMOTED TO `measured`. The chain is inference over",
"three measurements (frame-based clock; Canary's unlimited present rate; the",
"dwell corpus) rather than a measurement of units per second. It becomes",
"`measured` the day someone reads the console's present rate for these screens",
"directly.",
"",
"📌 AND THE PORT'S OWN DESIGN IS DELIBERATELY NOT THE GAME'S, which is worth",
"stating so nobody 'fixes' it. The game is frame-based; this port is time-based",
"(`time_units += delta * units_per_second`). They agree at 60 fps, which is the",
"only rate the console ever asked the game to be right at. A time-based port",
"reproduces a 60 Hz console on hardware that is not 60 Hz; a frame-based port",
"would drift on every machine that is not -- and this port has measured itself at",
"9.7 to 69.4 fps depending on the renderer. DO NOT make the port frame-based to",
"match the game.",
"",
"✅ 2026-09-02 — 60 NOW STANDS ON A THIRD INDEPENDENT ROUTE, and the REASON",
"changed again while the value did not.",
"",
"The Decoder reconciled three of their own pages that held incompatible",
"positions -- 2 units per guest frame, time-integrated at 56.8, and 1 unit per",
"present -- with one mechanism: THE CLOCK ADVANCES ONE UNIT PER VBLANK, and",
"presents may be dropped without the clock caring. That explains steps that are",
"always multiples of 17 (1, 2 or 3 vblanks between two logged presents), and the",
"same animation spanning 21 labels in one capture and 33 in another, which a",
"strict per-present clock cannot produce.",
"",
"Their rate result is a MANIPULATION rather than an observation: 255 declared",
"units take 4.263/4.162 s at a 60 Hz vblank and 8.450 s at --framerate_limit=30",
"-- 59.8/61.3 against 30.2 units/s. So the vblank rate sets the unit rate, and a",
"console vblanks at 60.",
"",
"So the justification for 60 has now been: '2 units per rendered frame' (retired),",
"'the game presents at 60 Hz' (superseded), and now 'one unit per 60 Hz vblank'.",
"THE NUMBER HAS NEVER MOVED. That is worth noticing rather than celebrating -- a",
"value whose reason changes three times while it survives is either robust or",
"under-constrained, and the honest label is still `authored`.",
"",
"📌 THIS PORT INSTANTIATES THEIR NULL MODEL, which is the one thing this side can",
"contribute to that argument. Their reasoning turns on 'a time-integrated clock",
"predicts 4.25 s in BOTH conditions'. This port IS a working time-integrated",
"clock at 60 units/s, and its splash dwell across a 4.0x change in its own",
"rendering rate is 4.28 / 4.26 / 4.27 / 4.26 s -- flat to 0.5 %. So their",
"counterfactual is demonstrated rather than assumed. ⚠️ It is evidence about the",
"NULL, not about the game; it says what a time-integrated clock does, not what",
"the game's clock is.",
"",
"🟡 PER-VBLANK VS PER-PRESENT IS STILL OPEN, and they name the discriminating",
"experiment (log Xenia's vblank counter beside each present). ⚠️ IT IS",
"IMMATERIAL TO THIS PORT AND THEY SHOULD NOT RUN IT ON THE PORT'S ACCOUNT. The",
"two models differ only when the console DROPS a present: per-vblank keeps",
"real-time pace through a drop, per-present slows. This port is time-based, so",
"it matches per-vblank exactly and would run marginally ahead of per-present",
"during drops only. On a console presenting every vblank the two coincide, and",
"the screens in question are a handful of quads.",
"",
"🔴 2026-09-02 (later) — 'A THIRD INDEPENDENT ROUTE' IS WITHDRAWN BY ITS AUTHOR.",
"The paragraph above says 60 now stands three ways. It does not, and I recorded",
"the claim before challenging it hard enough.",
"",
"I raised that three routes to one number are weaker than they look if they share",
"an upstream assumption -- vblank rate, present rate and declared dwell are not",
"obviously independent. The Decoder audited it and agreed: route B needs 'the",
"guest presents 60x/s', which comes from the vblank histogram UNDER XENIA'S 60 Hz",
"LIMITER; route C needs 'the vblank is 60 Hz', which is that limiter's cvar; route",
"D is a wall-clock duration that lands on 60 only BECAUSE the vblank is 60 Hz.",
"All three reduce to one upstream fact: the display refreshes 60 times a second",
"on that emulator. One witness in three coats.",
"",
"✅ WHAT SURVIVES IS CONDITIONAL AND BETTER, and it is established by MANIPULATION",
"rather than agreement -- forcing 30 Hz gave 30.2 units/s, 60 Hz gives 59.8/61.3:",
"",
" units per second = THE DISPLAY REFRESH RATE.",
"",
"It becomes '60' only through a fact this corpus has never measured: an Xbox 360",
"outputs 60 Hz. That is a hardware specification. It is solid, and it belongs",
"CITED as a spec rather than folded in as a third measurement.",
"",
"📌 And the conditional form is the one that justifies this port's construction",
"rather than excusing it. 'units/s = refresh rate' says what to do on hardware",
"that is NOT 60 Hz, which is exactly why a time-based clock at a fixed 60 units/s",
"is right and a frame-based one would drift. `kind` stays `authored`: nothing",
"here promotes it, and the reason it is not `measured` is now sharper -- the",
"measurement is of a RELATIONSHIP, and the constant that closes it comes from a",
"datasheet."
],
"kind": "authored",
"source": "docs/re/ui-keyframe-time-unit.md, docs/port/HANDOFF.md",
"ramp": "linear",
"ramp_why": [
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
"linear value at every one of the seven sampled frames, so there is no ease."
],
"ramp_kind": "measured",
"dwell_seconds": null,
"dwell_why": [
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
"",
"This key has now been wrong in two opposite directions, and the second was",
"mine, so both are recorded.",
"",
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
"🔴 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
"exit is caused by something outside its timeline, so it holds. A splash's exit",
"is caused by nothing, so it plays its declared timeline and leaves. The title",
"is the exception, not the rule, and one screen was never enough to overturn",
"the other two.",
"",
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
"",
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
"",
"The developer agrees to 1.1 %, two of its three runs to 0.3 %.",
"",
"🔴 CORRECTED 2026-09-01. This said: 'The port emits 4.400 s and 3.650 s -- each",
"declared value plus the 9-unit black hold, exactly. So the pacing was right all",
"along and nothing changes in the code.' THE PORT DOES NOT DO THAT, and this",
"file is what stops it: `black_hold_units` is 0, set deliberately (see",
"black_hold_why -- a uniform value is positively excluded and only an",
"ordered-pair key survives). There is no 9-unit hold to add, so the sentence",
"described a behaviour asserted three keys above it and refused one key below.",
"",
"MEASURED off the shipping boot, three runs, 2026-09-01:",
"",
" publisher declared 255 units = 4.250 s 4.28 / 4.26 / 4.27 mean 4.270 s",
" developer declared 210 units = 3.500 s 3.50 / 3.57 / 3.51 mean 3.527 s",
"",
"Residuals +1.2 and +1.6 units -- frame granularity on the exit check, not a",
"hold. The claimed 4.400 and 3.650 are each ~0.13 s longer than what has been",
"shipping since P3. Against the corpus (4.42 and 3.46 means) neither the claimed",
"nor the measured figure dominates: the port is 3.4 % short on the publisher and",
"2.0 % long on the developer, the claim would be 0.5 % short and 5.5 % long. So",
"this corrects a false statement about our own behaviour; it does not settle",
"whether a hold belongs there. That is still black_hold_why's ordered-pair ask.",
"",
"🔴 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
"Decoder's own container came out 15-20 % LONGER than both the declared values",
"and the corpus -- same disc, same timeline -- and three independent readings",
"of that container's rate disagree with each other. A seconds figure records",
"one emulator's pacing on one run. The units are on the disc. If anything ever",
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
"measured to wait beyond its group.",
"",
"🔴 2026-09-01 (later) — 'So the pacing was right all along and nothing changes in",
"the code' IS CONDITIONAL, AND MAY BE A COINCIDENCE OF TWO CANCELLING ERRORS.",
"",
"That sentence rests on the port's total screen time matching the corpus dwells.",
"It does: 4.270 s against 4.30/4.60/4.37 and 3.527 s against 3.51/3.50/3.37.",
"",
"But a TOTAL cannot see two errors of opposite sign inside it. Measured:",
"",
" the port's screen time IS its animation time. publisher 4.270 s against a",
" 4.250 s animation -- a hold of +0.020 s, i.e. none. The port does not hold",
" after a splash timeline at all.",
"",
" the GAME does: the Decoder counts the publisher on screen for 219 presents and",
" animating for ~128 of them, about 42 % hold.",
"",
"So IF keyframe_units_per_second is 120 rather than 60, this port animates every",
"splash 2x too slow AND omits the hold entirely, and the two sum to almost exactly",
"the right total. The agreement above would then be evidence of nothing.",
"",
"⚠️ THE HOLD AND THE CONSTANT ARE COUPLED. At 60 the port must NOT gain a hold --",
"the animation already fills the screen time and a hold would overshoot by ~40 %.",
"The missing hold is a defect only if 120 is right. They stand or fall together,",
"which is another reason not to move on one capture.",
"",
"📌 And when it does move it is TWO changes, not one: the constant, and a hold",
"measured as (screen presents - animation presents). It must NOT be inferred from",
"the total, because the total is precisely the quantity that cannot distinguish",
"the two errors. docs/port/units-per-second-switch-readiness.md.",
"",
"✅ 2026-09-01 (later still) — THE PARAGRAPH ABOVE IS WITHDRAWN. 'The pacing was",
"right all along' WAS right all along.",
"",
"I claimed the dwell agreement might be a coincidence of two cancelling errors --",
"a 2x-slow animation plus a missing hold. There is no missing hold. I misread a",
"presents split from the Decoder's instrument ('219 on screen, ~128 animating')",
"as a hold OUTSIDE the declared timeline. It is a split WITHIN it: the publisher",
"ramps 0-30, HOLDS 30-235 (205 units, 80.4 % of the screen) and fades 235-255,",
"and this port plays all three.",
"",
"Measured rather than read -- frozen samples of the logo region across the",
"publisher splash: 0.405488 at t=60, 120, 180 and 228 units, identical to six",
"decimals across 168 units, with 0.391 at t=15 (mid-ramp) and 0.038 at t=252",
"(in the exit fade). The hold is there and it is played.",
"",
"⚠️ The failure was not a mis-measurement. I took a two-part split from someone",
"else's instrument and assumed its boundary sat where my own model put it.",
"Presents are not units, and 'animating vs holding' in presents does not",
"decompose the same way as 'ramp vs hold' in declared units.",
"",
"AND THE DWELL FIGURES HERE ARE NOW POSITIVE EVIDENCE, not merely survivors. A",
"time-based clock is immune to dropped frames, so a dwell measured in seconds is",
"stable across runs at different frame rates. This port's own splash dwell across",
"a 4.0x change in its rendering rate: 4.28 s at 17.3 fps, 4.26 at 19.6, 4.27 at",
"25.0, 4.26 at 69.4 -- a 0.5 % spread, putting 255 units at 59.6-59.9 units/s",
"every time. That establishes these dwells are frame-rate-independent",
"MEASUREMENTS rather than artefacts of whatever rate a run hit, which is the",
"property the Decoder's argument needs of them.",
"",
"✅ 2026-09-02 — THE +1.2 / +1.6 UNIT RESIDUAL WAS FRAME GRANULARITY, and that is",
"now measured rather than inferred.",
"",
"The dwells were recorded as 4.270 s and 3.527 s against declared 4.250 and 3.500",
"-- residuals of +1.2 and +1.6 units -- and I attributed them to the granularity",
"of the exit check without testing it. A hardware GPU makes that testable: same",
"boot, same declared groups, three runs at 65-66 fps instead of 17-25.",
"",
"Pre-registered: if the residual is frame granularity it should shrink roughly",
"with the frame rate, so <= 0.5 units at 65 fps. Measured:",
"",
" publisher 4.27 / 4.26 / 4.27 mean 4.253 s residual +0.20 units",
" developer 3.50 / 3.52 / 3.50 mean 3.500 s residual +0.00 units",
"",
"From +1.2 and +1.6 down to +0.20 and +0.00. The prediction held and the",
"attribution is no longer an assumption. ⚠️ It also means the figures quoted",
"elsewhere in this corpus as 4.270 / 3.527 carry a rendering-rate term; the",
"declared values are what the port actually targets and 4.250 / 3.500 is what it",
"hits when the renderer keeps up."
],
"dwell_kind": "measured",
"looping_focus_records": {
"_": [
"WHICH focus records the port draws, unconditionally and on a loop, OVER the",
"element's own sprite rather than instead of it.",
"",
"RESTORED 2026-08-30 on a MEASUREMENT, having been deleted on 2026-08-29 for",
"a real defect that was in the RENDERER, not in this table. The old entry made",
"`_draw` substitute the glow for the plate's own bright sprite, so the plate",
"was invisible at every instant (max 0 against max 252.5). `ScreenView` now",
"draws the base and the record over it, and the entry comes back."
],
"kind": "measured",
"source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md",
"ramp": "linear",
"ramp_why": [
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
"linear value at every one of the seven sampled frames, so there is no ease."
],
"exit_ramp_seconds": 0.4,
"exit_ramp_why": [
"HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.",
"press_start/ptbtn00": {
"record_element": "ptbtn00f",
"period_units": 120,
"kind": "measured",
"source": "docs/re/structures/plate-pulse-measured.md, RE agent 2026-08-30",
"why": [
"MEASURED off the running game, held at the title with NO INPUT: the plate",
"oscillates continuously -- two windows in one boot of 58 s and 57 s, about",
"23 cycles each, with no decay and no settling.",
"",
"Every element of a screen ends on exactly ONE untimed keyframe, so there is",
"exactly one unknown duration per screen -- the ramp INTO that final keyframe.",
"This is that duration. ~0.4 s, which is 24 units at 60 units/s.",
"🔴 IT NEVER GOES OFF. The plate-absent floor is 159 thresholded green",
"pixels -- the title art's own, measured on live-title-build4-no-plate.png --",
"and the pulse bottoms at 714, four and a half times that. So `ptbtn00`",
"going transparent at t=244 is not the end of the plate; that is its EXIT",
"ramp, which plays when the screen leaves. While the screen is held the base",
"sits at its own hold (alpha 255 at t=238) and `ptbtn00f`'s cycle runs over",
"it. Base-only and base-plus-glow are what the 714 and the 1520 are.",
"",
"The alternative readings were tested and refuted. It is not a black quad laid",
"over a frozen screen: under that model a black rect scales every region by the",
"same 1-alpha, so the button-region / background-region brightness RATIO would",
"be constant through the fade. Measured on the RE agent's filmstrip it falls",
"6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen",
"itself plays out: pteff00.prm ramps to opaque black while the button labels,",
"ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.",
"",
"REACH, quoted from the RE agent rather than smoothed over: the filmstrip is",
"downsampled and the button region contains some background, so this pins the",
"DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the",
"number as approximate and the model as established."
],
"exit_ramp_units": 24,
"dwell_seconds": null,
"dwell_why": [
"NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the",
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos",
"at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would",
"be inventing a number nobody measured, so the sequencer holds for zero extra",
"time and the pacing you see is the disc's own.",
"",
"If a capture ever times the real boot, this is where that number goes."
]
"⚠️ 120 UNITS, NOT SECONDS, and that is the RE agent's own instruction. Their",
"run measured 2.530 and 2.540 s; an earlier corpus run measured 2.24 s. Same",
"declared number, different emulator pacing -- x1.27 and x1.12 against a",
"nominal 2.000 s, which IS 120 units at 60 units/s. Hardcoding 2.5 s would",
"author one loaded container's clock."
],
"limits": [
"ONE BOOT. Two windows inside it are not two boots.",
"It does NOT distinguish the boot title from an attract-loop title: run 1",
"opens at t~255 s against Q9's ~193 s no-input baseline, so it may already",
"be the attract title. Both are 'the title, held, no input' -- which is what",
"was asked -- but it is not proof about the first appearance.",
"🔴 714/1520 IS NOT AN ALPHA RATIO. The counter is thresholded pixels, so dim",
"pixels drop out first. No duty cycle and no ramp shape may be read off it;",
"the port draws the record's own declared alpha ramp and infers nothing."
]
}
},
"exit_ramp_deleted_why": [
"DELETED 2026-08-29, and the deletion is the point.",
"",
"`exit_ramp_seconds` (~0.4 s) and `exit_ramp_units` (24) were AUTHORED because",
"the disc had no time slot on a group's final keyframe, so the ramp into it was",
"the one unknown duration per screen. Under the corrected record layout",
"(formats-pin-2026-08-29c onward) there IS no untimed keyframe -- a group is an",
"8-byte header then frames x {u32 time; 36-byte pose}, so every pose is timed",
"including the last. The unknown the constant stood in for does not exist.",
"",
"MISSION section 3: 'When the RE agent later decodes something you had",
"authored, delete the authored entry and let the exporter emit it. That",
"deletion is the measure of progress.'",
"",
"VERIFIED DEAD BEFORE DELETING, not assumed: setting it to 9999 (166 seconds)",
"changed the boot's transitions by 0.04 s -- wall-clock jitter, not a 166 s",
"ramp. Both of its uses in ScreenView were gated on `not last_frame.has('t')`,",
"which no longer fires on any of the export's 866 keyframes.",
"",
"The measurement it recorded is not lost: HANDOFF Q7's ~0.4 s fade-out and the",
"0.17-0.23 s black hold are still measured facts, and the hold is still used --",
"`tools/port/verify-dwell` compares a transition INTERVAL against the oracle's",
"visible SPAN plus that hold. What is deleted is the port's need to invent a",
"duration the disc now states."
],
"black_hold_units": 0,
"black_hold_why": [
"0 = NOT MODELLED. The escalation is resolved: a uniform value is positively",
"EXCLUDED, so 0 is no longer one option among several -- it is the only honest",
"uniform choice, because it is the one that does not claim a constant exists.",
"",
"UPDATE: TWO candidate models are now excluded, not one. The Decoder has five",
"replicates with NO variation -- title->menu 3,3,3 and EXTRAS->menu 2,2 -- and",
"every differing value comes from a different ORDERED PAIR. The same origin",
"gives different values to different destinations (menu 0 vs 1, EXTRAS 2 vs 3).",
"So a constant is excluded AND keying on the outgoing screen is excluded; only",
"an ordered-pair key survives, with a measured value needed per pair.",
"",
"I checked independently whether anything DECLARED predicts it, from the",
"quantities in my export. None does: outgoing close (15,10,10,10), incoming",
"clear (12,12,16,12), outgoing span (269,74,80,80) and incoming span",
"(80,80,269,74) each have two rows sharing a value with different gaps.",
"",
"I did NOT search combinations of them. Four intra-archive pairs against many",
"candidate two-screen functions fits by construction -- that is the error this",
"corpus has catalogued five times, and finding a formula here would be",
"indistinguishable from finding one in noise.",
"",
"The Decoder ordered the gaps by the screen being LEFT (frames): menu 0 and 1,",
"EXTRAS 2, title 3. Three hypotheses are positively ruled out, not merely",
"unsupported. DIRECTION: EXTRAS->menu (2) and menu->EXTRAS (1) are the same",
"pair both ways and differ. BUTTON: (B) gives 0 and 2, (A) gives 1 and 3.",
"INCOMING SCREEN: an incoming menu takes 3 from the title and 2 from EXTRAS.",
"",
"So the quantity varies 0-3 frames by outgoing screen, and any uniform non-zero",
"value is wrong as a MODEL rather than merely off in magnitude. 0 models the",
"gap as absent; 6 would model it as constant, which the data excludes.",
"",
"MY OWN RULE IS REFUTED, not just unadopted. It was gap + the incoming",
"screen's opening black-clear = a constant, holding at 16/16/18 on three",
"transitions. Their fourth gives 16, 14, 16, 18 -- and decisively, the two",
"transitions with the SAME incoming screen (main_menu) have different gaps,",
"so the incoming screen cannot determine it. A fourth point did to a",
"three-point fit exactly what it should.",
"",
"DO NOT key this per outgoing screen yet. Three outgoing screens with one",
"value each restates the data rather than predicting it -- the same objection",
"I raised against my own 16/16/18. Key it when a screen has more than one",
"measured value, and key it on the screen being LEFT.",
"",
"📌 CITATION ADDED 2026-09-01, and its absence propagated from the delivery.",
"This why carried over a thousand characters and NOTHING OPENABLE. The Decoder",
"sent the `(B)`-from-EXTRAS leg as an inline frame table with no file cited,",
"while docs/re/data/fade-four-transitions.txt -- which carries that leg and",
"eight others -- had been committed the whole time. They found it in their own",
"audit and cited it; it had already landed here uncited.",
"",
"⚠️ An uncited measurement propagates as an uncited value. The receiving end",
"cannot tell a summarised measurement from a recalled one, and both read as",
"prose.",
"",
"✅ AUDITED 2026-09-01 and this one needed nothing: it was already an EXCLUSION argument rather than a count. It excludes a constant, excludes keying on the outgoing screen, and excludes every declared quantity in the export as a predictor -- four of them named, each shown not to separate the pairs. That is the form the week's other claims were found to be missing."
],
"black_hold_kind": "measured"
}

View File

@@ -38,7 +38,6 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use tracing::info;
use sylpheed_formats::vfs::{identify_format, GameAssets};
use sylpheed_formats::{IdxdObject, PakArchive};
@@ -195,36 +194,6 @@ enum ScreenCommands {
/// `--build`**, which is why it is a flag and not the default.
#[arg(long)]
all: bool,
/// Pose every element at this KEYFRAME TIME instead of at its resting
/// pose (60 units = 1 second). The resting pose is each element's last
/// *hold* keyframe, picked independently of every other element, so it is
/// not the screen at any one moment: it omits anything still moving (the
/// title's light sweeps hold off the right edge) and freezes a transient
/// at its PEAK (the title's five two-frame flashes burn forever).
/// ⚠️ This help used to end "Prefer `--settle`". That is WITHDRAWN and was
/// never measured: scored against a live capture of the JP title, settle
/// gives RMSE 40.210 and rest 41.690 — a margin of 1.48 against that
/// instrument's own noise floor of 1.2, which is NOT decisive. `--settle`
/// also has its own failure mode (25.5 % of elements are mid-ramp at their
/// screen's settle instant). Neither is established as better; pick by what
/// you are measuring. See `docs/re/structures/ui-resting-pose.md`.
#[arg(long, conflicts_with = "settle")]
at: Option<u32>,
/// Pose every element at the instant the screen is SETTLED, derived from
/// the disc: the midpoint of the longest interval containing no keyframe
/// of any element. Prints the window it used, whose width is how much the
/// midpoint is worth — a narrow one means the bundle never settles.
/// ⚠️ That is **38 % of the screen builds this command renders** (185 of
/// 491 carrying two or more keyframe times) and 39 % of the wider set
/// `--all` admits (862 of 2 211), mostly `loop*` fragments. This help used
/// to say "42 % of them" without saying of WHAT: 42 % was 731/1 758 over
/// composable bundles, computed before the keyframe record-layout fix,
/// which times a group's final pose and so admits ~450 bundles that
/// previously had only one timed keyframe. ⚠️ NOT established as better
/// than the resting pose — see the note on `--at`. See
/// `docs/re/structures/ui-settle-time.md`.
#[arg(long)]
settle: bool,
},
}
@@ -383,10 +352,8 @@ async fn main() -> Result<()> {
black,
all,
primitives,
at,
settle,
} => cmd_screen_render(
&pak, &output, build, focus, animated, black, all, primitives, at, settle,
&pak, &output, build, focus, animated, black, all, primitives,
),
},
Commands::Save { cmd } => match cmd {
@@ -604,6 +571,11 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
}
}
// 8 parameters against a threshold of 7 — a plain function, unlike the Bevy
// systems in the viewer, so this one is real if mild. Left as-is because the
// arguments are the CLI flags this subcommand takes; grouping them into a
// struct is a change to the command surface, not a lint fix.
#[allow(clippy::too_many_arguments)]
fn cmd_screen_render(
pak: &Path,
output: &Path,
@@ -613,40 +585,12 @@ 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,
@@ -659,7 +603,6 @@ fn cmd_screen_render(
ComposeOptions::default().backdrop
},
include_primitives: primitives,
at,
},
None,
);
@@ -682,35 +625,14 @@ fn cmd_screen_render(
if !screen.missing.is_empty() {
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
}
// 🔴 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
let undrawn: Vec<&str> = b
.elements
.iter()
.filter(|e| !screen.drawn.contains(&e.index))
.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)
})
.map(|e| e.name.as_str())
.collect();
if !undrawn.is_empty() {
println!(" not drawn ({}):", undrawn.len());
for u in &undrawn {
println!(" {u}");
}
println!(" not drawn ({}): {undrawn:?}", undrawn.len());
}
Ok(())
}
@@ -826,16 +748,8 @@ 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 {
let how = if info.codec == sylpheed_formats::AudioCodec::Xma {
" (from the declared byte rate, not decoded)"
} else {
""
};
println!(" Duration : {d:.2} s{how}");
println!(" Duration : {d:.2} s");
}
if let Some(p) = info.xma_packets {
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());
@@ -982,7 +896,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
println!();
println!("{}", "Format Summary:".bold());
let mut summary: Vec<_> = counts.into_iter().collect();
summary.sort_by(|a, b| b.1.cmp(&a.1));
summary.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
for (fmt, count) in summary {
println!(
" {:>6} .{}",
@@ -1124,7 +1038,7 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
let nv = sub.positions.len();
let mut referenced = vec![false; nv];
let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32);
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let (a, b, c) = (tri[0], tri[1], tri[2]);
imax = imax.max(a).max(b).max(c);
if a == b || b == c || a == c {
@@ -1146,7 +1060,7 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
};
let mut maxedges: Vec<f32> = sub
.indices
.chunks_exact(3)
.as_chunks::<3>().0.iter()
.map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
.collect();
maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
@@ -1310,7 +1224,7 @@ fn cmd_mesh_render(
let med = {
let mut e: Vec<f32> = sub
.indices
.chunks_exact(3)
.as_chunks::<3>().0.iter()
.filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n)
.map(|t| {
let d = |a: u32, b: u32| {
@@ -1344,7 +1258,7 @@ fn cmd_mesh_render(
(p[2] - center[2]) * scale * mirror[2] + cell[2],
]
};
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a < n && b < n && c < n {
if span_only || span_hide {
@@ -1530,7 +1444,7 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
// Emit RGBA. X8 has no meaningful alpha.
let opaque = matches!(tex.format, F::X8R8G8B8);
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
for (px, out) in tex.data.as_chunks::<4>().0.iter().zip(rgba.as_chunks_mut::<4>().0) {
out[0] = px[1]; // R
out[1] = px[2]; // G
out[2] = px[3]; // B
@@ -1780,12 +1694,12 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
let mut idx = 0usize;
while let Some(pos) = payload[off..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.position(|w| w == t8ad::T8AD_MAGIC)
{
let start = off + pos;
let next = payload[start + 4..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.position(|w| w == t8ad::T8AD_MAGIC)
.map(|p| start + 4 + p)
.unwrap_or(payload.len());
emit_t8ad(

View File

@@ -55,7 +55,33 @@ license.workspace = true
# a squash-merge can orphan, and no way for the exporter to be built against a
# decoder it was never tested with. A decoder change and the exporter change it
# requires now land in the same commit or not at all.
sylpheed-formats = { path = "../sylpheed-formats" }
# PINNED BY TAG, which is what MISSION section 2 prescribes and what the tagging
# rule exists for: "the RE agent tags when it lands something you need and tells
# you over the message channel -- that is how you stay current without floating."
# That is exactly what happened here.
#
# The tag carries the CORRECTED keyframe association: a placement group is an
# 8-byte header then `frames` x {u32 time; 36-byte pose}, so pose 0's time is the
# group's lead-in word and EVERY POSE IS TIMED, including the last. The working
# tree's copy still has the retired `SYLPHEED_KF_TIME_SHIFT` knob -- a superseded
# partial fix that got the association right but left pose 0 untimed, which is
# why testing it moved the untimed frame from last to first instead of removing
# it. The old reading is behind `SYLPHEED_KF_TIME_LEGACY=1` here.
#
# 🔴 THE COST, STATED: `sylpheed-cli` builds from the WORKSPACE crate, so until
# this lands on `main` the exporter and the reference renderer read DIFFERENT
# decoders and `tools/port/verify-screen` is comparing two eras rather than
# detecting drift. `tools/port/verify-capture` is unaffected -- it compares the
# port against oracle CAPTURES and never touches the CLI -- and it is the check
# that matters. Revert to the path dependency the day the tag is an ancestor of
# `main`.
# Bumped c -> d 2026-08-29. What I wanted from the new state: `d` carries parser
# and `audio.rs` changes on top of `c`. ⚠️ Its headline change -- Reborn's
# renderer drawing `rotation_deg`, and `compose` drawing a leaf that carries
# geometry -- does NOT reach this port from here: `sylpheed-cli` builds from the
# WORKSPACE crate, so the reference renderer stays unrotated until the tag lands
# on `main`. This bump is for the parser, not for the renderer.
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Sylpheed.git", tag = "formats-pin-2026-09-01" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,45 @@
//! Throwaway probe: what are a music bank's sub-waves, decoded and timed?
//!
//! `export_bgm` sums every sub-wave `media` returns and scales by 1/n. If one of
//! them is not music, the divisor is wrong and every real stem is attenuated for
//! nothing -- the same defect already found and fixed in `export_voice`.
use std::process::Command;
use sylpheed_formats::media;
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&disc);
for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] {
match media::sound_bank_riffs(&src, bank) {
Ok(riffs) => {
println!("{bank}: {} sub-wave(s)", riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = std::env::temp_dir().join(format!("bk_{i}.xma.wav"));
std::fs::write(&p, r).unwrap();
let w = std::env::temp_dir().join(format!("bk_{i}.wav"));
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&p).arg(&w).output();
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "info", "-i"])
.arg(&w)
.args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"])
.output().unwrap();
let t = String::from_utf8_lossy(&out.stderr).into_owned();
let get = |k: &str| t.lines().find_map(|l| l.split_once(k).map(|x| x.1.trim().to_string()))
.unwrap_or_else(|| "?".into());
let dur = Command::new("ffprobe")
.args(["-v","error","-show_entries","format=duration","-of","csv=p=0"])
.arg(&w).output().ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
println!(" sub-wave {i}: {:>9} B -> {:>10} s peak {:>10} rms {}",
r.len(), dur, get("Peak level dB:"), get("RMS level dB:"));
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(&w);
}
}
Err(e) => println!("{bank}: {e}"),
}
}
}

View File

@@ -0,0 +1,47 @@
//! Is `BGM_103` the ONLY bank with those two wave sizes?
//!
//! `authored/audio.json` says *"Static code, disc census and runtime all agree"*
//! — three legs. Reading the sentence beneath it, legs two and three are **one**
//! comparison: the disc's declared wave sizes matched byte-for-byte against what
//! the XMA probe saw at the menu. That is a disc-to-runtime match, not two
//! independent confirmations.
//!
//! It is a third leg only if the census independently EXCLUDES alternatives — if
//! some other bank carried the same two sizes, the byte match would not
//! distinguish it. So the sizes are counted across every `BGM_*` bank on the
//! disc.
//!
//! Prompted by the Decoder's point that a decorative second support is worse
//! than none: **a conclusion with two supports reads as better evidenced than
//! one with a single support, so apparent redundancy is itself the
//! misinformation.**
use sylpheed_formats::media;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&root);
const WANT: [usize; 2] = [3_876_864, 3_930_112];
let (mut found, mut matches) = (0usize, Vec::new());
for n in 0..=199u32 {
let name = format!("BGM_{n:03}.slb");
let Ok(riffs) = media::sound_bank_riffs(&src, &name) else { continue };
if riffs.is_empty() { continue }
found += 1;
let sizes: Vec<usize> = riffs.iter().map(|r| r.len()).collect();
// Compare on the DATA payload the port sums, not on the RIFF wrapper:
// a wrapper differs by header bytes and would hide a real collision.
let near = sizes.iter().any(|s| WANT.iter().any(|w| s.abs_diff(*w) < 4096));
if near {
matches.push((name.clone(), sizes.clone()));
}
}
println!(" {found} BGM_* bank(s) readable on this disc");
for (n, s) in &matches {
println!(" {n:<14} wave sizes {s:?}");
}
println!("\n {} bank(s) carry a wave within 4 KiB of {WANT:?}", matches.len());
println!(" Exactly 1 means the census EXCLUDES alternatives and is a real third");
println!(" leg. More than 1 means the byte match does not distinguish BGM_103,");
println!(" and \"three legs\" is two. Zero means this reader cannot see the");
println!(" incumbent and its answer means nothing.");
}

View File

@@ -0,0 +1,87 @@
//! Test the Decoder's UNTESTED reading of a residual they recorded as odd.
//!
//! `GP_DIALOG` holds 140 entries against a 70-record dialog table — a 2:1 ratio
//! that would make the id→entry join an ordering question. It does not hold:
//! adjacent pairing gives identical element-name sets on **2 of 65** pairs,
//! halves pairing on **0**. In `GP_TITLE` a language pair shares its element set
//! exactly, so identical sets are the signature there and almost nothing matches
//! here.
//!
//! The residual: the only two adjacent pairs that DO match are entries `0/1` and
//! `2/3` — and `2/3` is the DIFFICULTY build. Their plausible reading is that
//! dialog text is baked into language-specific sprites, so EN/JP entries differ
//! by construction. ⚠️ **They flagged it as untested and did not assert it**, and
//! it has a hole they named themselves: it would explain the 63 that differ and
//! leave the 2 that match needing their own explanation.
//!
//! This prints what the differences actually look like, so the reading is judged
//! against the names rather than accepted as plausible.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeSet;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = pak::PakArchive::open(format!("{root}/dat/GP_DIALOG.pak")).expect("GP_DIALOG.pak");
let sets: Vec<Option<BTreeSet<String>>> = ar.entries().iter().map(|e| {
let by = ar.read(e).ok()?;
if !ratc::is_ratc(&by) { return None }
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|el| el.name.clone()).collect())
}).collect();
let (mut same, mut diff, mut pairs) = (0usize, 0usize, 0usize);
let mut shown = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
let (Some(a), Some(b)) = (&sets[i], &sets[i + 1]) else { continue };
pairs += 1;
if a == b {
same += 1;
println!(" entries {i:>3}/{:<3} IDENTICAL sets, {} element(s)", i + 1, a.len());
continue;
}
diff += 1;
// The stage-dialog pairs, checked by name and by SPRITE COUNT. A
// translation of one dialog carries the same amount of text; a
// different stage does not. This is the Decoder's closing evidence for
// the 37 pairs that differ WITHOUT a button-count mismatch, re-derived
// here because it settles a bound I had recorded as unlikely to be
// tested -- and saying so is what got it tested.
if (10..=15).contains(&i) {
let sp = |x: &BTreeSet<String>| x.iter().filter(|n| n.ends_with(".t32")).count();
let stage = |x: &BTreeSet<String>| -> Vec<String> {
let mut v: Vec<String> = x.iter().filter_map(|n| n.strip_prefix("pzstg")
.and_then(|r| r.get(..2)).map(|s| s.to_string())).collect();
v.sort(); v.dedup(); v
};
println!(" entries {i:>3}/{:<3} stages {:?} vs {:?} sprites {} vs {}",
i + 1, stage(a), stage(b), sp(a), sp(b));
}
if shown < 3 {
shown += 1;
let only_a: Vec<_> = a.difference(b).cloned().collect();
let only_b: Vec<_> = b.difference(a).cloned().collect();
println!(" entries {i:>3}/{:<3} differ: {} only-in-first, {} only-in-second",
i + 1, only_a.len(), only_b.len());
println!(" first : {:?}", &only_a[..only_a.len().min(4)]);
println!(" second : {:?}", &only_b[..only_b.len().min(4)]);
}
}
// 🔴 THE DECISIVE DETAIL, not the impressionistic one. Two languages of one
// dialog cannot differ in BUTTON COUNT. If adjacent entries do, they are
// different dialogs and the whole adjacent-pairing premise is wrong -- which
// is a stronger statement than "the language reading is untested".
let btns = |s: &Option<BTreeSet<String>>| -> usize {
s.as_ref().map_or(0, |x| x.iter().filter(|n| n.contains("btn")).count())
};
let mut mismatched = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
if sets[i].is_none() || sets[i + 1].is_none() { continue }
if btns(&sets[i]) != btns(&sets[i + 1]) { mismatched += 1 }
}
println!("\n adjacent pairs whose BUTTON COUNTS differ: {mismatched}");
println!(" A language pair cannot. Every one of these is two different dialogs.");
println!("\n {pairs} adjacent pair(s): {same} identical, {diff} differing");
println!(" Their reading -- text baked into language-specific sprites -- predicts");
println!(" the differing names look SYSTEMATIC (a locale suffix, a parallel set).");
println!(" Judge it against the names above rather than against its plausibility.");
}

View File

@@ -0,0 +1,67 @@
//! Independent check of "DIFFICULTY is a dialog: GP_DIALOG entries 2/3".
//!
//! The Decoder identified `DLG_SELECT_DIFFICULTY` as `GP_DIALOG.pak` entries 2/3
//! by TWO arguments, one of them compound — corrected from "three routes", which
//! was taking credit for the exclusion scan. The image leg names no entry, and
//! the disc and oracle legs are one argument, since the capture is compared
//! against the disc's rows. One of them is button count and geometry. That half is
//! readable from the disc with this port's own reader, so it is checked here
//! rather than taken on their word — the same form as re-deriving `ptbtn11`'s
//! row order from my export when they offered it.
//!
//! ⚠️ What this CANNOT check is their binding claim, and they flagged it first:
//! entries 2/3 are identified by button count and geometry, **not** by a binding
//! from the `DLG_` name to a pak entry. Another four-button dialog with the same
//! rows would be indistinguishable by this evidence. Reproducing the geometry
//! confirms the geometry; it does not name the screen.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
// 🔴 WIDENED 2026-08-31 to every pak, to check the Decoder's rival search
// independently. They report zero four-button builds within 6 px of
// 259/329/399/469 anywhere on the disc, which turns "another dialog with
// these rows would be indistinguishable" from a standing reach into a
// bounded one. A disc-wide negative is exactly the claim worth re-running
// with a different reader, because its whole content is an absence.
const WANT: [i32; 4] = [259, 329, 399, 469];
const TOL: i32 = 6;
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut hits, mut scanned) = (0usize, 0usize);
for path in &paks {
let Ok(ar) = pak::PakArchive::open(path) else { continue };
let arch = path.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
scanned += 1;
// Any button-shaped record, not just `pcbtn`: a rival need not share the
// naming convention, and restricting by name would answer a narrower
// question than the one asked.
let mut rows: Vec<(String, i32)> = b.elements.iter()
.filter(|el| el.name.contains("btn"))
.filter_map(|el| el.rest().map(|r| (el.name.clone(), r.y)))
.collect();
if rows.is_empty() { continue }
rows.sort_by(|a, b| a.1.cmp(&b.1));
let ys: Vec<i32> = rows.iter().map(|r| r.1).collect();
let gaps: Vec<i32> = ys.windows(2).map(|w| w[1] - w[0]).collect();
if rows.len() == 4 && ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= TOL) {
hits += 1;
println!(" {arch} entry {i:>2} {} record(s): {}", rows.len(),
rows.iter().map(|r| r.0.as_str()).collect::<Vec<_>>().join(" "));
println!(" rows {ys:?} gaps {gaps:?}");
}
}
}
println!("\n {scanned} build(s) scanned across {} pak(s); {hits} match the",
paks.len());
println!(" DIFFICULTY row signature within +/-{TOL} px.");
println!(" Expected: exactly 2 -- the EN/JP pair. More means a RIVAL exists and");
println!(" the geometric identification is not unique; fewer means this reader");
println!(" cannot see the incumbents and its zero would mean nothing.");
}

View File

@@ -0,0 +1,49 @@
//! Probe: does a `.rat` leaf record carry geometry the parent element does not?
//!
//! The GPU capture says the title submits `ptloop01`/`ptloop02` scaled 600 %/800 %
//! and rotated +30.26°/45.28°, while the export writes scale 100 % and rotation
//! 0 for both. `ui_layout`'s own note says the rotated quads come from the
//! **nested `.rat` leaf records**, which is where `export_screen` already looks
//! for focus records and nowhere else.
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open");
let e = &ar.entries()[4]; // entry 4 = the English title
let bundle = ar.read(e).expect("read");
let b = ui_layout::parse_build(&bundle).expect("parse");
println!("build has {} elements, {} records", b.elements.len(), b.records.len());
let mut names: Vec<&String> = b.records.keys().collect();
names.sort();
println!("records: {names:?}");
for el in &b.elements {
if !el.name.starts_with("ptloop") { continue; }
let r = el.rest();
println!("\nPARENT {} sprite={:?} -> rest scale {:?} rot {:?}", el.name, el.sprite,
r.map(|r| (r.scale_x, r.scale_y)), r.map(|r| r.rotation_deg));
if let Some(&(off, size)) = b.records.get(&el.name) {
match ui_layout::parse_build(&bundle[off..off + size]) {
Some(leaf) => {
println!(" LEAF {} parses: {} element(s)", el.name, leaf.elements.len());
for le in &leaf.elements {
let lr = le.rest();
println!(" {:<20} rest scale {:?} rot {:?} pos {:?}",
le.name,
lr.map(|r| (r.scale_x, r.scale_y)),
lr.map(|r| r.rotation_deg),
lr.map(|r| (r.x, r.y)));
for k in &le.keyframes {
println!(" t={:?} scale=({},{}) rot={} pos=({},{}) fade={:#010x} u4={} u8={}",
k.time, k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y,
k.fade, k.unknown_4, k.unknown_8);
}
}
}
None => println!(" LEAF {} does NOT parse as a build", el.name),
}
} else {
println!(" no record named {}", el.name);
}
}
}

View File

@@ -0,0 +1,130 @@
//! Run the Decoder's own falsifier for "a nested record's `+0x08` is its loop
//! length" against the bundles THIS PORT SHIPS, before shipping 120 for 105.
//!
//! HANDOFF (`27938aa`, delivered at `07e93ce`) says the plate's glow cycles over
//! **120** units while its keyframes end at 105, and instructs the port to stop
//! shipping 105. The port's `ScreenView` derives a looping record's period from
//! the element's largest keyframe time, so it does ship 105 — and the field that
//! would fix it is decoded in an *example* and a *test* on the Decoder's branch
//! and **exposed in `sylpheed_formats`' public API on no ref at all**.
//!
//! ✅ **Since then the crate exposes it** — `ui_layout::loop_length_units`, taken
//! at `formats-pin-2026-08-30b` — and `screen.rs` has deleted its local copy.
//!
//! 🔴 **This file deliberately did NOT follow it.** The read below is still the
//! raw four bytes, because the moment a control calls the API it is meant to
//! check, it stops being a control and becomes the API tested against itself. It
//! is the independent reading that makes the falsifier mean anything.
//!
//! So this re-runs both of their controls:
//!
//! * **the falsifier** — `+0x08 < max keyframe time` must never occur; an
//! animation cannot restart before its own last pose;
//! * **non-triviality** — if every record had `+0x08 == max t` the field would
//! carry nothing and the name would be a relabelling of the keyframes.
//!
//! and adds the one they could not run: the same two, restricted to the records
//! **this port actually animates**. A disc-wide 0.00 % violation rate says
//! nothing about my six screens if all six sit in the exceptional tail.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
/// The records the port animates: the plate glow, the five menu focus records,
/// and the title's two sweeps. Named rather than pattern-matched, because the
/// point is to check the ones that are shipped, not the ones that match a glob.
const SHIPPED: &[&str] = &[
"ptbtn00f", "ptbtn01f", "ptbtn02f", "ptbtn03f", "ptbtn04f", "ptbtn05f",
"ptloop01", "ptloop02",
];
/// Which header word to read as the loop length. `0x08` is the decoded one;
/// `--offset=N` re-runs the same falsifier at a neighbour, which is the only way
/// to learn whether the falsifier is evidence for the offset or just for the
/// disc.
static mut OFFSET: usize = 8;
fn main() {
let off: usize = std::env::args().find_map(|a| a.strip_prefix("--offset=")
.and_then(|v| v.parse().ok())).unwrap_or(8);
unsafe { OFFSET = off };
println!(" reading the loop length at header +0x{off:02x}");
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
let mut slack_hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut shipped: BTreeMap<String, (i64, i64)> = BTreeMap::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
if o + off + 4 > by.len() || o + s > by.len() { continue }
if &by[o..o + 4] != b"RATC" { continue }
// 🔴 THE FALSIFIER IS RUN AT NEIGHBOURING OFFSETS TOO. The
// Decoder's struct-layout control showed that a homogeneous
// repeated table type-checks at every field boundary, so an
// interior test carries no information about phase -- 69 of 70
// records passed under BOTH shifted alignments of their dialog
// table. My falsifier (`+0x08 >= max keyframe time`) is an
// interior test of exactly that kind, and I re-ran it as
// "confirmation" without asking whether it discriminates the
// OFFSET or merely the file.
let len = u32::from_be_bytes(by[o + off..o + off + 4].try_into().unwrap()) as i64;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0) as i64;
if maxt == 0 { continue } // static: declares no cycle at all
total += 1;
let slack = len - maxt;
*slack_hist.entry(slack).or_default() += 1;
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else { violations += 1 }
let stem = rn.trim_end_matches(".rat");
if SHIPPED.contains(&stem) {
shipped.entry(stem.to_string()).or_insert((len, maxt));
}
}
}
}
println!("disc-wide, records with timed keyframes: {total}");
println!(" +08 == max t (exact) : {exact:5} {:5.1} %", pc(exact, total));
println!(" +08 > max t (a hold) : {holds:5} {:5.1} %", pc(holds, total));
println!(" +08 < max t <- FALSIFIER : {violations:5} {:5.2} %", pc(violations, total));
println!("\nslack distribution, most common first:");
let mut h: Vec<_> = slack_hist.iter().collect();
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
for (k, n) in h.iter().take(8) { println!(" slack {k:>6} : {n}"); }
println!("\nthe records THIS PORT animates:");
println!(" {:<12} {:>6} {:>7} {:>7}", "record", "+0x08", "max t", "slack");
let (mut ship_exact, mut ship_hold, mut ship_bad) = (0, 0, 0);
for (n, (len, maxt)) in &shipped {
let slack = len - maxt;
match slack { 0 => ship_exact += 1, s if s > 0 => ship_hold += 1, _ => ship_bad += 1 }
println!(" {n:<12} {len:>6} {maxt:>7} {slack:>7}{}",
if slack < 0 { " 🔴 FALSIFIED" } else { "" });
}
println!("\n shipped: {ship_exact} exact, {ship_hold} hold, {ship_bad} falsified");
if shipped.len() < SHIPPED.len() {
let missing: Vec<_> = SHIPPED.iter().filter(|s| !shipped.contains_key(**s)).collect();
println!(" ⚠️ not found on the disc: {missing:?} -- a name the port ships and");
println!(" this control never checked is worse than a violation it found.");
}
println!("\n verdict: {}", if ship_bad > 0 {
"🔴 the reading fails on a record the port animates -- do NOT adopt"
} else if ship_hold == 0 {
"⚠️ every shipped record is exact, so this port cannot tell loop length\n from max keyframe time -- adopting 120 would change nothing here"
} else {
"✅ falsifier clean and the field is non-trivial ON THE SHIPPED SET"
});
}
fn pc(n: usize, d: usize) -> f64 { if d == 0 { 0.0 } else { 100.0 * n as f64 / d as f64 } }

View File

@@ -0,0 +1,65 @@
//! Why do two "every pak, every timed record" scans disagree by 86 %?
//!
//! This port counts 1 781 timed nested records and reports `+0x08 == max t` at
//! 92.3 %. The Decoder counts 3 311 and reports 49.6 %. Both scans are described
//! the same way, so at least one of them is narrower than its own description --
//! and the exactness figure this port has quoted repeatedly is a property of
//! whichever subset it actually walks.
//!
//! Counts the survivors at each filter, so the gap is located rather than
//! guessed at.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut records, mut in_bounds, mut magic, mut parsed, mut timed) = (0, 0, 0, 0, 0);
let (mut untimed, mut all_at_zero) = (0usize, 0usize);
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (_, &(o, s)) in &b.records {
records += 1;
if o + 12 > by.len() || o + s > by.len() { continue }
in_bounds += 1;
if &by[o..o + 4] != b"RATC" { continue }
magic += 1;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
parsed += 1;
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
// 🔴 `maxt == 0` merges two different populations, and the
// Decoder's cause -- `.max()` returning `Some(0)` -- is only one
// of them. A record with NO timed keyframe has no largest
// keyframe time; a record whose keyframes all sit at t=0 has
// one, and it is 0. Only the first is a question without
// content. Both of us called all 1 530 "the question has no
// meaning"; that is true of one group and an assumption about
// the other.
let any_timed = lb.elements.iter()
.any(|el| el.keyframes.iter().any(|k| k.time.is_some()));
if maxt == 0 {
if any_timed { all_at_zero += 1 } else { untimed += 1 }
continue;
}
timed += 1;
}
}
}
println!(" records declared by parse_build : {records}");
println!(" within the entry's bounds : {in_bounds}");
println!(" carrying the RATC magic : {magic} <- {} dropped here",
in_bounds - magic);
println!(" parsing as a nested build : {parsed}");
println!(" with a largest keyframe time > 0: {timed}");
println!(" of the {} excluded:", untimed + all_at_zero);
println!(" NO timed keyframe at all : {untimed} <- the question has no content");
println!(" timed, but every pose at t=0 : {all_at_zero} <- a largest time EXISTS, and it is 0");
}

View File

@@ -0,0 +1,46 @@
//! Do any screens THIS PORT SHIPS carry a record that declares a cycle while all
//! its poses sit at t = 0?
//!
//! The substantive finding from the denominator thread: 1 530 nested records
//! disc-wide are timed with every pose at t = 0 and still declare a nonzero
//! `+0x08`. A static record that declares a cycle length is a real thing, not a
//! counting artefact — so the question for the port is whether it holds one of
//! those still while the disc says it cycles.
//!
//! Scoped to `GP_TITLE`, because that is the archive the port exports.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = pak::PakArchive::open(format!("{root}/dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut total, mut hits, mut multipose) = (0usize, 0usize, 0usize);
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (name, &(o, s)) in &b.records {
if o + 12 > by.len() || o + s > by.len() || &by[o..o + 4] != b"RATC" { continue }
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
let len = ui_layout::loop_length_units(&by[o..o + s]).unwrap_or(0);
total += 1;
if maxt == 0 && len > 0 {
hits += 1;
// A cycle can only produce motion if there is more than one pose
// to move between. All-at-t=0 with a single keyframe per element
// is visually inert however it is played.
let kf: usize = lb.elements.iter().map(|el| el.keyframes.len()).sum();
let multi = lb.elements.iter().filter(|el| el.keyframes.len() > 1).count();
if multi > 0 { multipose += 1 }
println!(" entry {i:>2} {name:<16} {len}-unit cycle, {kf} keyframe(s) \
across {} element(s), {multi} with >1 pose", lb.elements.len());
}
}
}
println!("\n {total} nested record(s) in GP_TITLE; {hits} declare a cycle while static.");
println!(" Of those, {multipose} have an element with MORE THAN ONE pose -- the only");
println!(" ones where looping could differ visibly from holding. A record whose");
println!(" elements each carry a single pose renders identically either way, so a");
println!(" declared cycle there is inert rather than a defect.");
}

View File

@@ -0,0 +1,48 @@
//! Throwaway probe: how long is each region chunk of a movie's voice?
//!
//! The question it answers is whether the chunks of a resolved voice region are
//! CONSECUTIVE SEGMENTS (concatenate them) or ALTERNATE TAKES (chunk 0 is the
//! whole track). Getting that backwards plays the dialogue three times over.
use std::process::Command;
use sylpheed_formats::{media, slb::VoiceLang};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let src = media::DirectorySource::new(&disc);
for movie in ["ADV", "S00A", "RT01A"] {
let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
else {
println!("{movie}: no region");
continue;
};
let riffs = media::voice_region_riffs(&src, s, e).expect("riffs");
println!("{movie}: region [{s}, {e}) = {} bytes, {} chunk(s)", e - s, riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = std::env::temp_dir().join(format!("vc_{movie}_{i}.xma.wav"));
std::fs::write(&p, r).unwrap();
// XMA declares no duration, so DECODE it and measure the result.
let w = std::env::temp_dir().join(format!("vc_{movie}_{i}.wav"));
let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&p)
.arg(&w)
.output();
let out = Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
.arg(&w)
.output()
.unwrap();
let dur = String::from_utf8_lossy(&out.stdout).trim().to_string();
if std::env::var("KEEP_WAV").is_ok() {
let keep = std::path::Path::new(&std::env::var("KEEP_WAV").unwrap())
.join(format!("{movie}_chunk{i}.wav"));
let _ = std::fs::rename(&w, &keep);
println!(" kept -> {}", keep.display());
} else {
let _ = std::fs::remove_file(&w);
}
println!(" chunk {i}: {} bytes -> {dur} s", r.len());
let _ = std::fs::remove_file(&p);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,9 @@
//! * a `buttons` entry naming an element that is not a button, or out of
//! resting-Y order;
//! * a sprite path that does not exist, or a PNG that does not decode;
//! * a name presented as recovered when it was authored.
//! * a name presented as recovered when it was authored;
//! * an audio file that is silent or clips -- the two audio failures that pass
//! every check that is not looking for them.
//!
//! It deliberately does **not** check that the export matches the disc. That is
//! what `sylpheed-cli screen render` is for.
@@ -184,10 +186,26 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
for (k, kf) in kfs.iter().enumerate() {
check_pose(&mut c, &format!("{at} keyframe {k}"), kf);
}
// The last keyframe of a group carries no time slot on the disc, and
// an invented one is exactly the kind of value this format refuses.
if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) {
c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there"));
// 🔴 INVERTED 2026-08-29, and the old rule is the more interesting
// half. It read: "the last keyframe of a group carries no time slot
// on the disc, and an invented one is exactly the kind of value this
// format refuses." That was true of the OLD keyframe association,
// where a group's data stopped four bytes short of its final block's
// time slot.
//
// Under the corrected layout (`formats-pin-2026-08-29c` onward) a
// group is an 8-byte header then `frames` x {u32 time; 36-byte
// pose}, so **pose 0's time is the group's lead-in word and EVERY
// POSE IS TIMED, including the last.** The rule now says the
// opposite, and an untimed keyframe is the thing to refuse.
//
// ⚠️ This fired 150 times on a re-export and I had not run `check`
// between pinning the tag and measuring against the oracle -- the
// pixel harness was green while the format validator was failing on
// every screen with a multi-keyframe group. A correctness harness
// does not replace a format one; they fail at different layers.
if kfs.len() > 1 && kfs.iter().any(|k| k.get("t").is_none()) {
c.err(format!("{at}: a keyframe has no `t`; every pose is timed under the corrected record layout"));
}
}
}
@@ -267,6 +285,8 @@ pub fn run(root: &Path) -> Result<usize> {
check_screen(root, file, &mut errors)?;
}
check_audio(root, &m, &mut errors);
if !errors.is_empty() {
for e in &errors {
eprintln!("{e}");
@@ -275,3 +295,88 @@ pub fn run(root: &Path) -> Result<usize> {
}
Ok(screens.len())
}
/// The `audio` array, checked the way a consumer would have to.
///
/// Two of these are content checks rather than schema checks, and they are here
/// on purpose. `docs/port/AUDIO-VERIFICATION.md` names silence as "the failure
/// that looks like success": a file of exactly the right duration, the right
/// channel count and the right size, full of zeroes, because something opened
/// the wrong thing. Every structural check passes it. So does clipping, which
/// the BGM can produce because it is a **sum of two stems** at unity gain.
///
/// The exporter measures both at export time and writes them here; this refuses
/// the tree if what it wrote is a file nobody would want to play. Neither is a
/// judgement about whether the audio is the RIGHT audio — nothing in this
/// binary can know that, and `docs/port/BLOCKED.md` says which parts are still
/// authored guesses.
fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
let Some(audio) = m.get("audio").and_then(Value::as_array) else {
// Absent is correct for every export taken before P6.
return;
};
for a in audio {
let name = a.get("name").and_then(Value::as_str).unwrap_or("?");
let kind = a.get("kind").and_then(Value::as_str).unwrap_or("");
if !matches!(kind, "se" | "bgm" | "voice") {
errors.push(format!(
"manifest.json: audio `{name}` has kind {kind:?}, which a consumer cannot dispatch on"
));
}
for key in ["file", "command", "why"] {
if a.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
errors.push(format!("manifest.json: audio `{name}` has no `{key}`"));
}
}
let Some(file) = a.get("file").and_then(Value::as_str) else { continue };
if !root.join(file).exists() {
errors.push(format!("manifest.json: lists audio {file}, which does not exist"));
continue;
}
match a.get("peak_dbfs").and_then(Value::as_f64) {
None => errors.push(format!(
"manifest.json: audio `{name}` carries no `peak_dbfs` -- it was not measured, \
and silence is the audio failure that passes every check that is not looking \
for it"
)),
Some(p) if p <= -90.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- this file is silent"
)),
// The bound differs by kind, and the difference is the point. A
// `bgm` is something WE combined -- a sum of stems -- so a peak at
// or above full scale is our arithmetic and is refused outright. An
// `se` is a single wave off the disc: it is mastered near full
// scale, and a lossy decode of a near-full-scale signal overshoots
// by a fraction of a dB (`confirm` lands at +0.18). Refusing that
// would be refusing the disc's own mastering, and "fixing" it would
// mean attenuating a game asset to make a number smaller.
//
// 🟡 +1.0 dB is a JUDGEMENT, not a measurement: a few tenths is
// reconstruction overshoot, a whole dB is not. Nobody has measured
// the overshoot distribution across a corpus of cues, and if a cue
// ever trips this the right response is that measurement, not a
// looser bound.
// `voice` was on the strict side of this bound while it was a SUM of a
// region's chunks. It no longer is: a region carries three
// presentations of one take, so the exporter keeps ONE stream and
// performs no arithmetic on it. That puts `voice` with `se` -- a
// single wave off the disc, mastered near full scale, whose lossy
// decode overshoots by a fraction of a dB. `ADV`'s louder
// presentation measures +0.0003 dBFS at source; refusing that would
// be refusing the disc's own mastering.
Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- a SUM we produced clips"
)),
Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!(
"{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot"
)),
Some(_) => {}
}
match a.get("duration_s").and_then(Value::as_f64) {
Some(d) if d > 0.0 => {}
_ => errors.push(format!(
"{file}: no positive `duration_s` -- a zero-length asset plays as silence"
)),
}
}
}

View File

@@ -12,6 +12,7 @@
//!
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod audio;
mod check;
mod video;
mod screen;
@@ -20,7 +21,7 @@ use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use std::path::{Path, PathBuf};
use sylpheed_formats::{pak::PakArchive, ui_layout};
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
@@ -76,6 +77,48 @@ struct ManifestVideo {
/// dislikes the quality re-runs one line rather than reverse-engineering it.
command: String,
why: &'static str,
/// What the runtime should have played, so it can report what it did.
/// See `video::Transcoded::duration_s` — the port measured its player
/// presenting 2847 % of a stream's frames, and seconds alone hide that.
duration_s: f64,
fps: f64,
}
/// One exported audio file. Carries the same provenance a video does, plus the
/// measured peak and duration: silence and clipping are the two audio failures
/// that pass every check that is not looking for them.
#[derive(Serialize)]
struct ManifestAudio {
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
/// than a prefix on `name` that a consumer would have to parse.
kind: &'static str,
name: String,
file: String,
command: String,
why: String,
#[serde(skip_serializing_if = "Option::is_none")]
peak_dbfs: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_s: Option<f32>,
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
/// runtime to announce. Absent means nothing is known to be missing --
/// never that the asset was checked and is complete.
///
/// It exists because the export could already say this and the RUNTIME
/// could not. `why` carries the full account, but it is a paragraph aimed
/// at a reader of the manifest; a player hears clean dialogue and has no
/// way to learn that a stream is absent from it. This port already
/// announces the two measured screens NEW GAME jumps over, on the principle
/// that a gap is announced before it is opened. Audio had no equivalent.
#[serde(skip_serializing_if = "Option::is_none")]
incomplete: Option<String>,
/// The game's own cue identifier where one is a NAME MATCH. Absent means
/// nobody has claimed one -- never that the binding is unknown.
#[serde(skip_serializing_if = "Option::is_none")]
name_match: Option<String>,
/// What the runtime does at the end of the file, where that was authored.
#[serde(skip_serializing_if = "Option::is_none")]
loop_mode: Option<String>,
}
#[derive(Serialize)]
@@ -88,6 +131,8 @@ struct Manifest {
screens: Vec<ManifestScreen>,
#[serde(skip_serializing_if = "Vec::is_empty")]
videos: Vec<ManifestVideo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
audio: Vec<ManifestAudio>,
warnings: Vec<String>,
}
@@ -113,6 +158,9 @@ fn load_names(authored: &Path) -> Result<NameMap> {
#[derive(serde::Deserialize)]
struct File {
archives: NameMap,
// Deserialised to model the on-disc schema, not read in Rust.
// Removing it would silently change what this struct accepts.
#[allow(dead_code)]
#[serde(default)]
also_export: AlsoExport,
}
@@ -195,13 +243,55 @@ fn main() -> Result<()> {
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
let names = load_names(authored_dir)?;
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
// has to know about; it is not an error, and it is not a log line, because
// the person who needs it reads `manifest.json` and never sees stdout.
let mut warnings: Vec<String> = vec![
"GP_TITLE screen builds only. No other archive, and only the two movies \
MISSION section 6 puts in scope."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
];
// Derived output is regenerated wholesale: clear it, so a screen that stops
// being exported stops existing rather than lingering as a stale file that
// still validates.
//
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
// `video::transcode` has always carried a cache -- it writes a `.cmd`
// sidecar with the exact command, the source size and the channel count, and
// skips the encode when all three still match. Its own doc comment says
// "without it every re-export pays ~4 minutes to produce a byte-identical
// file". **This wipe deleted the sidecar and the output immediately before
// the check, so the cache had never hit once.** Six exports in one session
// paid ~48 minutes of Theora to produce five byte-identical files, and
// nothing reported it: the cache is silent when it works and silent when it
// does not.
//
// The wholesale guarantee is kept rather than weakened -- everything else is
// still cleared outright, and `prune_videos` below deletes any file in
// `video/` that this run did not claim, so a movie that stops being exported
// still stops existing.
if out.exists() {
std::fs::remove_dir_all(&out).context("clear the output tree")?;
for entry in std::fs::read_dir(out).context("clear the output tree")? {
let entry = entry?;
if entry.file_name() == "video" {
continue;
}
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(entry.path())
} else {
std::fs::remove_file(entry.path())
}
.with_context(|| format!("clear {}", entry.path().display()))?;
}
}
std::fs::create_dir_all(&out)?;
std::fs::create_dir_all(out)?;
let archive = "dat/GP_TITLE.pak";
let pak = disc.join(archive);
@@ -228,7 +318,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
None => (format!("build_{entry:02}"), "index", None),
};
let ex = screen::export_build(
&out,
out,
archive,
*entry,
build_idx,
@@ -261,20 +351,153 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// MISSION §6: the boot intro and the one new-game intro only.
let mut videos = Vec::new();
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
// 🔴 The export deviates from a HUMAN decision, and until this warning
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
// justified for one of the two movies and over-broad for the other, and
// which of the three options to take is not the exporter's call -- so it is
// reported on every run rather than left in a doc comment nobody opens.
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
warnings.push(
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
.to_string(),
);
}
for m in video::MOVIES {
match video::transcode(disc, out, m)? {
Some(t) => {
println!(" video {} -> {}", m.src, t.file);
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
videos.push(ManifestVideo {
name: t.name,
file: t.file,
command: t.command,
why: t.why,
duration_s: t.duration_s,
fps: t.fps,
});
}
None => println!(" video {} not on this disc -- skipped", m.src),
}
}
prune_videos(out, &videos)?;
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
// because they were measured off the running game and are on the disc in no
// findable form, the BGM choice because HANDOFF Q10 is a negative and
// nothing states which track a menu plays. See `authored/audio.json`.
let mut audio = Vec::new();
let audio_cfg = audio::load(authored_dir)?;
match &audio_cfg {
None => println!(" no authored/audio.json -- no audio exported"),
Some(cfg) => {
let source = media::DirectorySource::new(disc);
for a in audio::export_cues(&source, out, &cfg.se)? {
println!(
" se {:<8} -> {} ({})",
a.name,
a.file,
describe(&a)
);
audio.push(ManifestAudio::from(a));
}
for (role, spec) in &cfg.bgm {
match audio::export_bgm(&source, out, role, spec)? {
Some(a) => {
println!(
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
a.name,
a.file,
describe(&a),
spec.bank,
a.sub_waves
);
// HANDOFF Q10's census is "exactly two waves of
// identical duration, 32/32 banks on the disc". When
// `media` hands back a different number, SAY SO -- the
// port does not get to decide that one of them is not a
// stem, and silently summing an extra region into the
// music is precisely the media-assembly mistake MISSION
// section 2 names. The decoder's answer is what ships;
// the disagreement is what gets reported.
if a.sub_waves != 2 {
warnings.push(format!(
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
says a music bank is EXACTLY TWO waves of identical duration \
(32/32 banks). All {} are summed, because choosing which to drop \
is a decoding question and this exporter does not answer those. \
See docs/port/BLOCKED.md.",
a.sub_waves, spec.bank, a.sub_waves
));
}
audio.push(ManifestAudio::from(a));
}
// Not an error: the authored bank may simply not be on this
// disc, and the export of everything else is still good.
None => warnings.push(format!(
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
this disc's sound.pak -- no BGM exported for that role.",
spec.bank
)),
}
}
}
}
// The cutscene voices are DERIVED, not authored, so this runs outside the
// `authored/audio.json` block above: the binding comes off the disc (the
// movie manifest in `tables.pak`), and an export with no authored audio
// should still carry the dialogue for the movies it ships.
//
// A movie that resolves to no region is genuinely unvoiced and gets a
// warning rather than a substitute -- for both movies in scope this port
// expects a region, so a warning here is a real signal and not noise.
{
let source = media::DirectorySource::new(disc);
for (stem, len) in &movie_lengths {
// The presentation choice is AUTHORED and this block runs even when
// there is no `authored/audio.json` -- the voice binding is decoded,
// so the dialogue exports either way and only the choice defaults.
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
Some(a) => {
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
// export is known to be missing audio the game plays, and
// the failure sounds like success: one stream decodes to
// clean dialogue, so nobody listening finds out.
if a.kept_waves < a.content_waves {
warnings.push(format!(
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
three XMA contexts, byte sizes matching the disc payloads exactly). \
The export carries ONE. Nothing in the audio reveals this -- a \
single stream is clean audible dialogue. Held rather than summed \
because an equal-gain sum of channel pairs is not a downmix and \
would be a second guess, not a fix. See authored/audio.json voice \
and docs/port/BLOCKED.md.",
a.file, a.sub_waves
));
}
println!(
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
a.name,
a.file,
describe(&a),
a.kept_waves,
a.sub_waves,
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
);
audio.push(ManifestAudio::from(a));
}
None => warnings.push(format!(
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
)),
}
}
}
let manifest = Manifest {
format: "sylpheed.manifest/1",
@@ -283,16 +506,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
disc: disc.display().to_string(),
screens,
videos,
warnings: vec![
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
],
audio,
warnings,
};
std::fs::write(
out.join("manifest.json"),
@@ -301,3 +516,81 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
println!("wrote {}/manifest.json", out.display());
Ok(())
}
impl From<audio::Exported> for ManifestAudio {
fn from(a: audio::Exported) -> Self {
ManifestAudio {
kind: a.kind,
name: a.name,
file: a.file,
command: a.command,
why: a.why,
peak_dbfs: a.peak_dbfs,
duration_s: a.duration_s,
incomplete: (a.kept_waves < a.content_waves).then(|| {
format!(
"{} of {} streams. The running game decodes all {} concurrently. \
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
WHICH streams are dropped and why differs per asset; the manifest \
entry's `why` says, and it is not the same story twice.",
a.kept_waves, a.sub_waves, a.sub_waves
)
}),
name_match: a.name_match,
loop_mode: a.loop_mode,
}
}
}
/// The two numbers worth reading on an audio line, in the console.
///
/// Printed rather than left to the manifest because the failure this catches is
/// a SILENT file: the right duration, the right channel count, the right size,
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
/// person notices without being told to look.
fn describe(a: &audio::Exported) -> String {
let peak = match a.peak_dbfs {
Some(p) => format!("peak {p:.1} dBFS"),
None => "peak unmeasured".into(),
};
match a.duration_s {
Some(d) => format!("{d:.3} s, {peak}"),
None => peak,
}
}
/// Delete anything in `video/` this run did not produce.
///
/// `video/` is the one directory the wholesale wipe spares, so that the
/// transcode cache survives to be consulted. This restores the guarantee the
/// wipe exists for: a movie that stops being exported stops existing, rather
/// than lingering as a file the manifest no longer lists.
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
let dir = out.join("video");
if !dir.exists() {
return Ok(());
}
let mut keep: Vec<String> = Vec::new();
for v in kept {
if let Some(name) = Path::new(&v.file).file_name() {
let name = name.to_string_lossy().into_owned();
keep.push(name.clone());
// The cache sidecar goes with the file it stamps.
if let Some(stem) = Path::new(&name).file_stem() {
keep.push(format!("{}.cmd", stem.to_string_lossy()));
}
}
}
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if keep.contains(&name) {
continue;
}
println!(" video {name} is no longer exported -- removed");
let _ = std::fs::remove_file(entry.path());
}
Ok(())
}

View File

@@ -103,6 +103,14 @@ pub struct FocusElement {
pub id: String,
pub declared: String,
pub sprite: Option<String>,
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
///
/// A leaf's sprite may live in the leaf's own table or in the parent
/// bundle's, so the bit is looked up in the same two places, in the same
/// order, that the PNG is written from.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub pivot: [u32; 2],
pub rest: Rest,
pub keyframes: Vec<Keyframe>,
@@ -112,6 +120,34 @@ pub struct FocusElement {
pub struct Focus {
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
pub record: String,
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
/// units — which is not the same thing as the last keyframe's time.
///
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
/// the period from the largest keyframe time — what the port did until now —
/// runs it 14 % fast and deletes the dark rest entirely.
///
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
/// bare until 2026-09-01, which is a population-scoped statistic reported
/// without its population):
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
/// their own last pose**; on the eight records this port animates, seven
/// exact and `ptbtn00f` the one hold.
///
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
/// held its own guard and byte read, because the field was decoded in an
/// example and a test and exposed in no public API on any ref. It is now
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
/// the local copy is deleted — the doc comment that promised that deletion
/// is the only reason it did not quietly become permanent.
pub loop_length_units: Option<u32>,
/// Back-to-front, in the leaf's own declaration order.
pub elements: Vec<FocusElement>,
}
@@ -141,6 +177,59 @@ pub struct Element {
/// convention and a consumer may still want the bare highlight texture.
#[serde(skip_serializing_if = "Option::is_none")]
pub focus: Option<Focus>,
/// This element's own `.rat` leaf, when its declared name is itself a
/// record in the bundle.
///
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
/// the title declare scale 100 % and rotation 0 at the parent, and their
/// leaves declare **(100, 600) at +30°** and **(100, 800) at 45°** — and
/// the leaves *move*, x from 639 → 1521 and 1721 → 839. `ui_layout`'s own
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
/// records, which the census never opened."* Neither did this exporter: it
/// opened a leaf only for a FOCUS record, via `highlight_name`.
///
/// That omission is measurable. It is the whole of the title's 1.82 %
/// disagreement with the oracle — the port draws two 400 px sprites upright
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
/// quads across the frame at opposite leans.
///
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
/// ramp on a different span — parent 0→255 over t=70…238, leaf
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
/// question and not the port's to answer. The data is exported so it stops
/// being invisible; `ScreenView` ignores it until the composition rule is
/// known.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf: Option<Focus>,
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
/// what the game draws.
///
/// Decided here rather than in the runtime because it is disc knowledge.
/// The Decoder's rule: *"the discriminator is which record carries the
/// geometry, not a fixed order"* — and the census over this export splits
/// cleanly, with no ambiguous middle:
///
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
/// exactly. That is the BASE-record case `screen.rs` already handled: the
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
/// y=402) and the parent wins. Flag is false; nothing changes.
/// * **16 differ**, and all of them differ in scale or rotation, not by a
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
/// and (100,800) at 45° against an identity parent), two
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
///
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
/// measured. The other two are the same shape and are **not** separately
/// confirmed; they are flagged so the harness can adjudicate them rather
/// than being asserted.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub leaf_carries_geometry: bool,
/// The raw `opt ` link inside this element's `.rat` record.
///
/// ⚠️ **This is not a focus link.** It was read as one, and that was
@@ -161,6 +250,23 @@ pub struct Element {
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
/// `"implied"` = **measured off the running game**, for elements that carry
/// no header. `"none"` = neither; sorts last.
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
/// `0x02`.
///
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
/// A name-keyed map cannot answer for a screen nobody drove the game to,
/// which is why the port was drawing the English menus additive and the
/// Japanese ones alpha-over — asserting by omission that the JP build
/// blends differently. The bit is on the disc for every screen at once.
///
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
/// 0 of 14 additive elements set it and 9 non-additive ones do.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub layer_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
@@ -201,6 +307,35 @@ pub struct Screen {
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
/// and not to be trusted for anything else.
pub buttons: Vec<String>,
/// The instant every element of this screen is settled at, and the width of
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
/// units, absent when the screen has fewer than two keyframe times.
///
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
/// each element at its own `rest()` is right for anything that ends the
/// screen settled and **exactly wrong for a transient**: the title's
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t5456, 0
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
/// it burning forever. There are five of these, and `rest()` draws all five
/// at once, saturating the light arc.
///
/// The window is the **longest interval containing no keyframe time**, over
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
/// that exclusion is what reproduces the Decoder's independently computed
/// `[160, 236]` for the title: including the `ptloop` leaves gives
/// `[269, 540]` instead.
///
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
/// export the widths split with nothing in between — `press_start` 214,
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
#[serde(skip_serializing_if = "Option::is_none")]
pub settle_window: Option<[i64; 3]>,
/// What this file does not answer. A consumer needing one of these must get
/// it from `authored/`.
pub unresolved: Vec<&'static str>,
@@ -316,6 +451,76 @@ pub fn export_build(
// Contrast with a BASE record, where the leaf duplicates the parent's
// placement and the two can differ by a unit (ptbtn04: parent y=401,
// leaf y=402). There the parent wins. Here there is no parent.
// Reads one record in the bundle as a nested build and returns its
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
// element whose OWN declared name is a record (`ptloop01.rat`). One
// implementation, because the second case was missing for eight
// milestones and a second copy is how it would go missing again.
let read_leaf = |rec: &str,
written: &mut std::collections::BTreeMap<String, ()>,
missing: &mut Vec<String>|
-> Result<Option<Focus>> {
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
return Ok(None);
};
let mut fes = Vec::new();
for fe in &leaf.elements {
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None;
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
{
fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") {
missing.push(sp.to_string());
}
let Some(r) = fe.rest() else { continue };
fes.push(FocusElement {
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
scale: [r.scale_x, r.scale_y],
tint_rgba: hex32(r.tint),
fade_argb: hex32(r.fade),
rotation_deg: r.rotation_deg,
t: r.time,
},
keyframes: fe
.keyframes
.iter()
.map(|k| Keyframe {
t: k.time,
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
rotation_deg: k.rotation_deg,
})
.collect(),
});
}
Ok(if fes.is_empty() {
None
} else {
Some(Focus {
record: rec.to_string(),
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
})
})
};
// An element whose own declared name is a record in this bundle carries
// its geometry THERE, not in its parent entry. See `Element::leaf`.
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
let mut focus = None;
if let Some(rec) = highlight_name(&el.name) {
if let Some(&(off, size)) = b.records.get(&rec) {
@@ -341,6 +546,9 @@ pub fn export_build(
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
@@ -365,7 +573,11 @@ pub fn export_build(
});
}
if !fes.is_empty() {
focus = Some(Focus { record: rec, elements: fes });
focus = Some(Focus {
record: rec,
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
});
}
}
}
@@ -397,10 +609,21 @@ pub fn export_build(
sprite: sprite_out,
focus_sprite,
focus,
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
let p = el.rest();
l.elements.iter().any(|le| {
p.is_none_or(|p| {
le.rest.scale != [p.scale_x, p.scale_y]
|| le.rest.rotation_deg != p.rotation_deg
})
})
}),
leaf,
opt_link: el.focus_link.clone(),
pivot: [el.pivot_x, el.pivot_y],
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
parent: el.parent,
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
layer_source,
layer,
focused: el.focused,
@@ -427,6 +650,12 @@ pub fn export_build(
.collect();
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let window = settle_window(&elements);
let order = forced_backdrop_first(
ui_layout::derived_paint_order(&b, bundle),
&elements,
[b.design_w, b.design_h],
);
let screen = Screen {
format: "sylpheed.screen/3",
exporter: exporter.to_string(),
@@ -441,8 +670,9 @@ pub fn export_build(
name_why,
design: [b.design_w, b.design_h],
elements,
paint_order: ui_layout::derived_paint_order(&b, bundle),
paint_order: order,
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
settle_window: window,
unresolved: vec![
// The time unit is measured off the running game, not on the disc.
"keyframe_time_unit",
@@ -473,3 +703,234 @@ pub fn export_build(
missing,
})
}
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
///
/// See [`Screen::settle_window`] for why this is the settled instant and why
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
let mut times: Vec<i64> = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
.collect();
times.sort_unstable();
times.dedup();
if times.len() < 2 {
return None;
}
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
//
// The widest keyframe-free interval is only a settled state if the screen is
// actually PRESENTING something across it. `press_start` is the case that
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
// and the runtime then answered every question about that screen at t=107.
// The result was that the PRESS (A) plate could not be drawn at any instant
// at all, including the boot's own end state, whose entire purpose is to
// show it.
//
// The fix is not a tuned threshold: it is that the heuristic was reading an
// interval where the screen is BLANK as the interval where it has arrived.
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
// under the runtime's 30-unit bar, so it falls back to each element's own
// hold -- which is the plate, opaque, exactly as the disc declares it.
//
// ⚠️ This does not disturb the windows the settle instant was measured on.
// `title` keeps [160, 236]: elements are visible across it, and the
// Decoder's draw stream independently found the game's clock freezing in
// that same interval.
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
let (a, b) = times
.windows(2)
.map(|w| (w[0], w[1]))
.filter(|(a, b)| visible_at((a + b) / 2))
.max_by_key(|(a, b)| b - a)?;
Some([a, b, (a + b) / 2])
}
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
fn alpha_at(e: &Element, t: i64) -> u8 {
let ks = &e.keyframes;
let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
.unwrap_or(0) >> 24) as i64;
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return 0;
}
if t <= timed[0].t.unwrap() as i64 {
return a(timed[0]) as u8;
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return a(w[0]) as u8;
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
}
}
a(timed[timed.len() - 1]) as u8
}
/// Scale of one element at instant `t`, in percent per axis, under the same
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
/// that animates passes through every value between its keyframes.
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return [100.0, 100.0];
}
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
if t <= timed[0].t.unwrap() as i64 {
return [g(timed[0], 0), g(timed[0], 1)];
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return [g(w[0], 0), g(w[0], 1)];
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return [
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
];
}
}
let l = timed[timed.len() - 1];
[g(l, 0), g(l, 1)]
}
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
/// file forces it there.
///
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
/// *an element that covers the screen and is fully opaque at some instant cannot
/// paint above anything visible at that instant; where the elements visible
/// during its opaque span are ALL of them, its position is forced to first.*
///
/// It was found because `build_12`/`build_15` are **black at every instant** of
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
/// 39 instants while all 9 other elements live and die inside that span. A
/// screen that is black for its whole life is impossible on its face, which is
/// the only kind of check that survives two renderers sharing an assumption:
/// `sylpheed-cli` agreed with the port here because it agreed about
/// `implied_layer_key`.
///
/// Two measured controls, both prior orders off the running game:
///
/// | primitive | measured | opaque instants | forced below | |
/// |---|---|---|---|---|
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
///
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
/// matches 77 of 80 and fails on exactly the three families that cross it —
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
/// an overlay* and is measured painting first. The name is not the rule.
///
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
/// `.t32` textures must sort first *against their own layer keys*. **An
/// element's alpha says nothing about whether its texture covers the screen** —
/// most of a sprite may be transparent.
///
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
/// ordering — it says nothing about elements that are constrained but not
/// forced. Delete this when a pinned `sylpheed-formats` does it.
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
let screen_end: i64 = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
.map(i64::from)
.max()
.unwrap_or(0);
let forced: Vec<usize> = elements
.iter()
.enumerate()
.filter(|(_, e)| {
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
// sprite". Those coincide in GP_TITLE and the distinction is still
// the whole point, because the negative test guards a SYMPTOM.
//
// The rule needs the element's alpha to BE its pixels' alpha. That
// is true of a `.prm` solid quad and of nothing else. The Decoder
// found this the expensive way twice: first `.t32` sprites (an
// element's alpha says nothing about a texture that is mostly
// transparent), guarded with "no sprite" -- and then `.tbm`, which
// is 38 of their 80 forced-first verdicts and declares fade
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
// make the screen white; no screen is white, so a `.tbm`'s white is
// a modulation ON a texture and its element alpha proves nothing
// about coverage either.
//
// "No sprite" would keep admitting a `.tbm` that this exporter
// happens not to emit a sprite for. `role == "primitive"` cannot.
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
// full-screen element here is `.prm` and pure black, checked -- so
// this changes no verdict today and is a guard against a corpus
// that grows.
// Cheap prefilter only -- the binding coverage test is per-instant,
// in `covers` below. An element scaled ABOVE 100 could cover the
// screen from a smaller declared size, so this deliberately does
// not reject on size.
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
})
.filter(|(i, e)| {
let span: Vec<i64> = e
.keyframes
.iter()
.filter_map(|k| k.t)
.map(i64::from)
.collect();
let Some(&lo) = span.first() else { return false };
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
// Declared size alone is not what the element draws: scale is a
// percent per axis and it animates. `pbafc.prm` is the disc's own
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
// about 17x18 px, a moving glint rather than a wash. A rule that
// read its declared size would call it screen-covering.
//
// Nothing in GP_TITLE needs this: every layerless full-screen
// element here is at scale 100 on every keyframe, so no verdict
// moves. It is in because the data that would break it exists on
// this disc, which is a better reason than a failure would have been.
let covers = |t: i64| {
let sc = scale_at(e, t);
e.size.is_some_and(|s| {
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
})
};
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
// that shows why: it declares ONE keyframe, opaque black full-screen
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
// a single-instant event instead of the thing that is on screen for
// the whole splash. So the span runs to the SCREEN's last keyframe.
let hi = screen_end.max(*span.last().unwrap());
let opaque: Vec<i64> = (lo..=hi)
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
.collect();
if opaque.is_empty() {
return false;
}
// Every OTHER element must be visible somewhere inside that span.
elements.iter().enumerate().all(|(j, o)| {
j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
})
})
.map(|(i, _)| i)
.collect();
if forced.is_empty() {
return order;
}
let mut out = forced.clone();
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
out
}

View File

@@ -63,8 +63,34 @@ pub const MOVIES: &[Movie] = &[
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
/// default *is* this matrix; the point is that the manifest now says so.
///
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
/// why the normalisation is here rather than the textbook coefficients.
/// # 🔴 This is NOT the matrix MISSION §6 pins, and that was never said out loud
///
/// MISSION §6 records a **human decision of 2026-08-29** fixing the fold at
/// `FL = 1.0·FL + 0.707·FC + 0.707·BL` (plus 7.1 terms a 5.1 source does not
/// have). This constant is that matrix scaled by 0.4142 — the same relative
/// weighting, **7.65 dB quieter** — and until now nothing in the code, the
/// manifest or the docs said so. Recording the command you ran does not disclose
/// that it is not the command you were given.
///
/// The original justification for the deviation was *"the unnormalised form
/// clips: peak 0.0 dBFS"*, and that is a peak reading — the instrument
/// `docs/port/BLOCKED.md` records this port declaring unfit for the clipping
/// question, because one sample at full scale and two seconds of square wave
/// give the same number. Re-measured properly (float decode, whole file, count
/// the samples that would clamp):
///
/// | | peak | ≥ full scale | > +1 dB over | longest run |
/// |---|---|---|---|---|
/// | `ADV`, MISSION §6 | **+4.26 dBFS** | 4 406 / 13 187 900 | 1 874 | 0.333 ms |
/// | `S00A`, MISSION §6 | 1.34 dBFS | **0** | 0 | — |
///
/// So the pin really does overload `ADV` — and this constant is over-broad,
/// because `S00A` never needed it. The smallest single scalar under which
/// neither clamps is `1/1.6339 = 0.612`, +3.39 dB on today.
///
/// **Not changed here.** The level of a mix is what §6 reserves to a human
/// (*"adjust it deliberately, as a commit"*), so the export carries a warning
/// with these numbers instead. See `docs/port/DECISIONS.md`.
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
/// How many audio channels the source declares.
@@ -80,6 +106,34 @@ fn channels(src: &Path) -> Result<u32> {
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
}
/// Duration and frame rate of a finished transcode, straight from the file.
///
/// Probed from the OUTPUT, not the source: what the runtime will play is this
/// file, and the two differ — `ADV` is 137.44 s against a 137.71 s source.
/// Returns zeros rather than failing, because a missing number should make the
/// runtime say "unknown", not stop an export that otherwise succeeded.
fn probe_timebase(out: &Path) -> (f64, f64) {
let probe = |entries: &str, stream: bool| -> String {
let mut c = Command::new("ffprobe");
c.args(["-v", "error"]);
if stream {
c.args(["-select_streams", "v:0"]);
}
c.args(["-show_entries", entries, "-of", "csv=p=0"]).arg(out);
c.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
};
let secs = probe("format=duration", false).parse().unwrap_or(0.0);
// `r_frame_rate` is a rational, "30/1".
let rate = probe("stream=r_frame_rate", true);
let fps = match rate.split_once('/') {
Some((n, d)) => n.parse::<f64>().unwrap_or(0.0) / d.parse::<f64>().unwrap_or(1.0),
None => rate.parse().unwrap_or(0.0),
};
(secs, fps)
}
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
let mut v: Vec<String> = [
"-hide_banner", "-loglevel", "error", "-y",
@@ -108,6 +162,30 @@ pub struct Transcoded {
pub file: String,
pub command: String,
pub why: &'static str,
/// The transcode's own duration and frame rate, probed from the file that
/// was just written.
///
/// Recorded so the RUNTIME can say what it actually presented.
///
/// 🔴 CORRECTED 2026-09-01. This read: *"Godot's video player drops frames to
/// hold its schedule, and it drops a lot of them here — measured at 28 % of [refuted]
/// `S00A`'s frames presented and 47 % of `ADV`'s"*. **Both numbers are
/// retracted.** They came from CONTENDED runs, and the counter is an upper
/// bound on ENGINE frames that is vacuous once the engine outruns the stream
/// — quiet, `ADV` draws 6 480 frames across a 4 123-frame video. On a quiet
/// box the bound is 8890 % for `S00A`, and playback runs **+6.7 %…+6.9 %**
/// long for both films. What survives is that elapsed seconds hide whatever
/// the player does, which is why the count is in the manifest. Without a frame count in the manifest a run can only
/// report elapsed seconds, and elapsed seconds are exactly what stays
/// plausible while three frames in four go missing.
///
/// 🔴 This field exists because the port asserted the opposite. The claim was
/// *"a player that runs long decoded everything"*, argued from the absence of
/// an overrun rather than measured; the measurement was four lines and
/// refuted it. **The instrument is now permanent so the argument cannot be
/// made again from a run that never counted.**
pub duration_s: f64,
pub fps: f64,
}
/// Transcode one movie, skipping the encode when the output already exists and
@@ -131,10 +209,35 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
let argv = args(&src, &ogv, ch);
let command = format!("ffmpeg {}", argv.join(" "));
let size = std::fs::metadata(&src)?.len();
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
// The sidecar SAYS WHAT IT IS. It sits in the modder-facing asset tree next
// to the `.ogv`, and MODDING rule 2's principle is that a generated file
// should be tellable from a hand-made one by reading it -- a bare ffmpeg
// line beside a video looks like something a modder should edit or delete.
//
// The header is NOT part of the cache key: `fresh` compares only the lines
// that describe the encode. Otherwise rewording this comment would re-encode
// four minutes of video to no purpose, which is a cache that punishes
// documentation.
let key = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
let want = format!(
"# Generated by sylpheed-export. NOT an asset and not hand-editable: this\n\
# records how {}.ogv beside it was encoded, so a re-export can skip the\n\
# encode when the source and the command are both unchanged. Deleting it\n\
# only forces one re-encode. To change the video, override the .ogv under\n\
# data/mods/ (MODDING rule 4) -- editing this file changes nothing.\n{key}",
m.stem
);
let cache_key = |s: &str| -> String {
s.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
};
let fresh = ogv.exists()
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
&& std::fs::read_to_string(&stamp)
.map(|s| cache_key(&s) == cache_key(&want))
.unwrap_or(false);
if !fresh {
// Encode to a temp name and rename on success. A reader that catches
// this mid-write sees no file at all rather than a valid-looking one
@@ -155,12 +258,26 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
bail!("ffmpeg failed on {}", m.src);
}
std::fs::rename(&partial, &ogv)?;
}
// Refresh the sidecar whenever its TEXT differs, encode or no encode.
//
// It used to be written only inside the `!fresh` branch, which is right for
// the cache and wrong for the file: a change to the header alone -- the part
// deliberately excluded from the key -- would then never reach an existing
// export, because nothing that reads the header can trigger the write that
// updates it. The explanation would be correct in the source and absent on
// disc, which is the same shape as every other documented-but-unexercised
// thing this port has had to find the hard way.
if std::fs::read_to_string(&stamp).map(|s| s != want).unwrap_or(true) {
std::fs::write(&stamp, &want)?;
}
let (duration_s, fps) = probe_timebase(&ogv);
Ok(Some(Transcoded {
name: m.stem.to_string(),
file: format!("video/{}.ogv", m.stem),
command,
why: m.why,
duration_s,
fps,
}))
}

View File

@@ -1,38 +0,0 @@
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

@@ -1,21 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,27 +0,0 @@
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

@@ -1,53 +0,0 @@
//! 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

@@ -1,41 +0,0 @@
//! 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

@@ -1,40 +0,0 @@
//! 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

@@ -1,22 +0,0 @@
//! Dump one BGM bank's waves as RIFF/XMA so they can be decoded and compared
//! against a capture of the running game.
//!
//! cargo run -p sylpheed-formats --example bgm_dump -- BGM_103.slb OUTDIR
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::slb;
fn main() {
let name = std::env::args().nth(1).unwrap_or_else(|| "BGM_103.slb".into());
let out = std::env::args().nth(2).expect("OUTDIR");
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
std::fs::create_dir_all(&out).expect("outdir");
let h = sylpheed_formats::hash::name_hash(&name);
let bytes = media::read_sound_bank(&src, h).expect("bank");
println!("{name}: {} B", bytes.len());
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
let p = format!("{out}/{}_{i}.xma", name.trim_end_matches(".slb"));
std::fs::write(&p, r).expect("write");
println!(" wave {i}: {} B (byte_size {}) -> {p}", r.len(), r.len() - 60);
}
}

View File

@@ -1,78 +0,0 @@
//! Does a screen declare its own OPAQUE BLACK backdrop? Disc-wide.
//!
//! `sylpheed-port` observed that the splash builds declare `palogo_eff0.prm` as a
//! full-screen primitive at t=0 with `fade_argb 0xff000000` -- alpha 255 over RGB
//! 000000 -- and turned it into a candidate predicate: a declared opaque-black
//! backdrop separates STANDALONE screens from COMPOSITED ones. On their sixteen
//! exported screens it splits 12 / 4, with all four exceptions independently known
//! to be composited (the two `press_start` plates, and two loading builds that
//! carry the `pgloading_*` set without its backdrop).
//!
//! That matters because the corpus previously told them "no content rule exists,
//! take the entry index" -- correct for the question asked (recognise the splash),
//! but this is a content rule for a different and useful question. They asked for
//! it to be tested against an archive they do not have. This is that test.
//!
//! CONTROL: it must reproduce the 12/4 split on GP_TITLE's sixteen composable
//! bundles before its disc-wide numbers mean anything.
//!
//! cargo run -p sylpheed-formats --example black_backdrop_predicate
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
/// A screen declares its own backdrop if some `.prm` primitive holds
/// `fade == 0xff000000` at t = 0: full alpha over black.
fn has_black_backdrop(b: &ui_layout::UiBuild) -> Option<String> {
for el in &b.elements {
if !el.name.ends_with(".prm") { continue }
if let Some(k) = el.keyframes.iter().find(|k| k.time == Some(0)) {
if k.fade == 0xff00_0000 { return Some(el.name.clone()) }
}
}
None
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
println!("== CONTROL: GP_TITLE's 16 composable bundles (port reports 12 with, 4 without)");
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut y, mut n) = (0, 0);
for e in 0..16usize {
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
match has_black_backdrop(&b) {
Some(nm) => { y += 1; println!(" entry {e:>2} YES {nm}") }
None => { n += 1; println!(" entry {e:>2} no") }
}
}
println!(" -> {y} with, {n} without\n");
println!("== DISC-WIDE, over every screen build");
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut tot, mut with) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut t, mut w) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
t += 1;
if has_black_backdrop(&b).is_some() { w += 1 }
}
if t > 0 {
println!("{name:30} {w:4} / {t:<4} declare a black backdrop");
std::io::stdout().flush().ok();
}
tot += t; with += w;
}
println!("\n{with} of {tot} screen builds disc-wide declare an opaque-black backdrop \
({:.1} %)", 100.0 * with as f64 / tot as f64);
println!("--- END ---");
}

View File

@@ -1,64 +0,0 @@
//! Control for the new public accessor `ui_layout::sprite_blend_additive`.
//!
//! `blend_vs_t8ad_bit` established the field by reading the `T8aD` header inline.
//! The exporter cannot do that — `Element` exposed nothing at `+0x04`, which is
//! why a blend map keyed by SCREEN NAME had to be authored, and why the Japanese
//! menus were being asserted-by-omission to blend differently from the English
//! ones. This checks the accessor the exporter will actually call, against the
//! same 35 oracle rows, so a later refactor cannot silently change the field.
//!
//! cargo run -p sylpheed-formats --example blend_api_check
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// (build entry, sprite, measured additive?) — from `data/blend-bit-vs-oracle.txt`,
/// every label an `RB_BLENDCONTROL0` value read out of the guest command stream.
const MEASURED: &[(usize, &str, bool)] = &[
(4, "ptbase2.t32", false), (4, "ptlogo1.t32", false), (4, "ptlogo2.t32", false),
(4, "ptlogo_tm.t32", false), (4, "ptcopyright.t32", false),
(4, "ptlogo_back2.t32", false), (4, "ptlogo_back2eff.t32", false),
(2, "ptbtn00.t32", false), (2, "ptbtn00f.t32", true),
(5, "ptbase.t32", false), (5, "ptmsg.t32", false), (5, "ptbtn01f.t32", false),
(5, "ptbtneff01.t32", false), (5, "pteff10.t32", true), (5, "pteff12.t32", true),
(6, "pteff21.t32", true), (6, "pteff22.t32", true), (6, "pteff23.t32", true),
(6, "ptframe3.t32", true), (6, "ptframe4.t32", true),
(6, "pteff03.t32", true), (6, "pteff03a.t32", true),
];
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");
let (mut ok, mut bad, mut missing) = (0, 0, 0);
println!("{:<7} {:<22} {:<10} {:<10} {}", "entry", "sprite", "expected", "accessor", "");
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for &(oe, name, additive) in MEASURED {
if oe != e { continue }
// Prefer the Element accessor; fall back to the by-name one for
// focused variants, which are reached through `opt ` and carry no
// top-level element of their own.
let got = b.elements.iter().find(|x| x.sprite.as_deref() == Some(name))
.and_then(|el| ui_layout::sprite_blend_additive(&b, &by, el))
.or_else(|| ui_layout::blend_additive_by_name(&b, &by, name));
if got.is_none() { println!("{e:<7} {name:<22} {additive:<10} {:<10} MISSING", "-"); missing += 1; continue }
match got {
Some(g) if g == additive => { ok += 1;
println!("{e:<7} {name:<22} {additive:<10} {g:<10} OK"); }
other => { bad += 1;
println!("{e:<7} {name:<22} {additive:<10} {other:?} MISMATCH"); }
}
}
}
println!("\n{ok} agree, {bad} mismatched, {missing} not found (of {})", MEASURED.len());
// The control that removes the test's own subject: the accessor must also
// report a MIX. An accessor stuck at one value would pass every `false` row.
let by = ar.read(&ar.entries()[6]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
let add = b.elements.iter().filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(true)).count();
let over = b.elements.iter().filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(false)).count();
println!("control -- entry 6 must report BOTH values: additive={add} alpha-over={over}");
assert!(add > 0 && over > 0, "accessor is not discriminating");
assert_eq!(bad, 0, "the public accessor disagrees with the committed oracle");
println!("PASS");
}

View File

@@ -1,46 +0,0 @@
//! A PREDICTION, written before the capture that tests it.
//!
//! `blend_vs_t8ad_bit` finds that `T8aD +0x04` bit `0x02` separates additive from
//! alpha-over on all 35 elements whose blend has been measured off the GPU, and
//! that no other bit of the 48-byte header does. That is a fit to three screens.
//!
//! The developer splash (`GP_TITLE` entries 10 and 13) has **never been captured**
//! and is one of the five screens the port ships. This prints what the bit says
//! its elements should be, so the capture can falsify it rather than confirm it.
//!
//! Takes an archive and a build list, so the prediction can be written for any
//! screen -- including one in a DIFFERENT pak, which is the sharper test: the
//! splash predicts alpha-over for both its elements and so can only fail, never
//! discriminate, while a screen with a predicted MIX can do both.
//!
//! cargo run -p sylpheed-formats --example blend_prediction_splash
//! cargo run -p sylpheed-formats --example blend_prediction_splash -- GP_OPTIONS 0 1 2
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let args: Vec<String> = std::env::args().skip(1).collect();
let pak = args.iter().find(|a| a.parse::<usize>().is_err())
.cloned().unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
let builds: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
let builds = if builds.is_empty() { (0..ar.entries().len()).collect() } else { builds };
for e in builds {
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
println!("=== {pak} entry {e} ===");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
if s.len() < 8 || &s[0..4] != b"T8aD" { continue }
let w = u32::from_be_bytes([s[4], s[5], s[6], s[7]]);
println!("{n:<26} +0x04 = {w:08X} bit 0x02 {} PREDICT {}",
if w & 2 != 0 { "SET " } else { "clear" },
if w & 2 != 0 { "ADDITIVE" } else { "alpha-over" });
}
println!();
}
}

View File

@@ -1,220 +0,0 @@
//! Does `T8aD +0x04` bit `0x02` predict the blend the GAME uses?
//!
//! ⚠️ **`REFUTED.md` kills this claim**: *"`T8aD +0x04` bit `0x02` selects an
//! additive blend" → mine, and refuted. Blending those sprites additively
//! worsens every measure against the capture.* That refutation rests entirely on
//! **our renderer** — it is a claim about our renderer, and the corpus's own rule
//! says so. Since it was written, the blend has been measured off the GPU per
//! draw on three screens (`structures/ui-blend-mode-measured.md`), so the claim
//! can now be tested against the oracle instead of against a render.
//!
//! The labels below are **not** from a render. Every one is a
//! `RB_BLENDCONTROL0` value read out of the guest command stream and attributed
//! to an element by quad size:
//! `data/ui-blend-mode-measured.txt`, `data/ui-blend-title-and-replication.txt`,
//! `data/ui-blend-extras-complete.txt`.
//!
//! cargo run -p sylpheed-formats --example blend_vs_t8ad_bit
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::collections::BTreeMap;
use std::path::PathBuf;
/// (build entry, sprite, measured additive?) — the oracle's verdicts, verbatim.
const MEASURED: &[(usize, &str, bool)] = &[
// --- GP_TITLE entry 4 + 2, the live title -------------------------------
(4, "ptbase2.t32", false),
(4, "ptlogo1.t32", false),
(4, "ptlogo2.t32", false),
(4, "ptlogo_tm.t32", false),
(4, "ptcopyright.t32", false),
(4, "ptlogo_back2.t32", false),
(4, "ptlogo_back2eff.t32", false),
(2, "ptbtn00.t32", false),
(2, "ptbtn00f.t32", true),
// --- entry 5, the main menu ---------------------------------------------
(5, "ptbase.t32", false),
(5, "ptmsg.t32", false),
(5, "ptbtn01f.t32", false),
(5, "ptbtneff01.t32", false),
(5, "pteff10.t32", true),
(5, "pteff12.t32", true),
(5, "ptframe1.t32", true),
(5, "ptframe2.t32", true),
(5, "pteff03.t32", true), // the rotated sweep strips, via ptloop01/02
(5, "pteff03a.t32", true),
// --- entry 6, EXTRAS ------------------------------------------------------
(6, "ptbase.t32", false),
(6, "ptmsg2.t32", false),
(6, "pttitle.t32", false),
(6, "ptbtn11f.t32", false),
(6, "ptbtn12.t32", false),
(6, "ptbtn13.t32", false),
(6, "ptbtneff02.t32", false),
(6, "pteff10.t32", true),
(6, "pteff20.t32", true),
(6, "pteff21.t32", true),
(6, "pteff22.t32", true),
(6, "pteff23.t32", true),
(6, "ptframe3.t32", true),
(6, "ptframe4.t32", true),
(6, "pteff03.t32", true),
(6, "pteff03a.t32", true),
];
fn main() {
if std::env::args().any(|a| a == "decl") { decl_rivals(); return }
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let mut hdr: BTreeMap<(usize, String), u32> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for (n, &(off, size)) in &b.sprites {
let s = &by[off..(off + size).min(by.len())];
if s.len() < 8 || &s[0..4] != b"T8aD" { continue }
hdr.insert((e, n.clone()), u32::from_be_bytes([s[4], s[5], s[6], s[7]]));
}
}
println!("{:<10} {:<22} {:<10} {:>10} {}", "entry", "sprite", "+0x04", "bit 0x02", "measured blend");
let (mut tp, mut tn, mut fp, mut fnn, mut missing) = (0, 0, 0, 0, 0);
for &(e, n, additive) in MEASURED {
let Some(&w) = hdr.get(&(e, n.to_string())) else {
println!("{e:<10} {n:<22} {:<10} {:>10} {}", "MISSING", "-",
if additive { "ADDITIVE" } else { "alpha-over" });
missing += 1;
continue;
};
let bit = w & 0x02 != 0;
match (bit, additive) {
(true, true) => tp += 1,
(false, false) => tn += 1,
(true, false) => fp += 1,
(false, true) => fnn += 1,
}
println!("{e:<10} {n:<22} {:08X} {:>10} {}{}", w, bit,
if additive { "ADDITIVE" } else { "alpha-over" },
if bit == additive { "" } else { " <== DISAGREES" });
}
println!("\nbit set & additive {tp}");
println!("bit clear & alpha-over {tn}");
println!("bit set & alpha-over {fp} <- false positives");
println!("bit clear & additive {fnn} <- false negatives");
println!("sprite not found {missing}");
println!("\n{}", if fp == 0 && fnn == 0 && missing == 0 {
"PERFECT PARTITION on every element whose blend was measured."
} else {
"THE BIT DOES NOT PREDICT THE MEASURED BLEND."
});
// ── THE CONTROL THAT MATTERS ────────────────────────────────────────────
// A perfect partition is worthless if half the header partitions equally
// well: then the sample is too small to single out a field, and picking
// `+0x04` bit 0x02 out of the tie is the same mistake as picking `+0x08`
// 0x8050 was. So: how many OTHER bits of the first 12 header words separate
// the same 35 elements without error?
let mut rivals: Vec<String> = Vec::new();
let mut words: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for (n, &(off, size)) in &b.sprites {
let s = &by[off..(off + size).min(by.len())];
if s.len() < 48 || &s[0..4] != b"T8aD" { continue }
words.insert((e, n.clone()), (0..12)
.map(|k| u32::from_be_bytes([s[k*4], s[k*4+1], s[k*4+2], s[k*4+3]]))
.collect());
}
}
for w in 0..12 {
for bit in 0..32 {
let mut ok = true;
let mut set_seen = false;
let mut clear_seen = false;
for &(e, n, additive) in MEASURED {
let Some(v) = words.get(&(e, n.to_string())) else { ok = false; break };
let on = (v[w] >> bit) & 1 == 1;
if on { set_seen = true } else { clear_seen = true }
if on != additive { ok = false; break }
}
// A constant bit trivially "agrees" with nothing; require both sides.
if ok && set_seen && clear_seen {
rivals.push(format!("+0x{:02X} bit {bit} (0x{:X})", w * 4, 1u32 << bit));
}
}
}
println!("\nRIVAL FIELDS — other bits of the first 12 header words that separate");
println!("the same 35 elements with zero errors: {}", rivals.len());
for r in &rivals { println!(" {r}"); }
if rivals.len() == 1 {
println!(" -> the sample singles out ONE field. Nothing else in the header does it.");
} else {
println!(" -> the sample does NOT single out a field; {} candidates tie.", rivals.len());
}
}
// ── An integrity check the published decode did NOT do ──────────────────────
// The rival sweep above covers the 48-byte T8aD header. It does NOT cover the
// 60-byte DECLARATION entry, and the earlier declaration hunt was run with
// labels taken from the port's RENDER -- which put pteff10, pteff12, pteff20 and
// pteff21..23 on the alpha-over side, where the oracle says all six are
// additive. So the declaration has never been swept with correct labels, and if
// one of its words also partitions the 35 without error, "the field is the T8aD
// bit" is underdetermined.
//
// Run as: cargo run -p sylpheed-formats --example blend_vs_t8ad_bit -- decl
#[allow(dead_code)]
fn decl_rivals() {
use std::collections::BTreeMap;
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");
const AT: usize = 0x20;
const STRIDE: usize = 60;
let mut decl: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
if by.len() < 0x18 { continue }
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for i in 0..count {
let at = AT + i * STRIDE;
if at + STRIDE > by.len() { break }
let end = by[at..at + 12].iter().position(|&c| c == 0).unwrap_or(12);
let name = String::from_utf8_lossy(&by[at..at + end]).to_string();
decl.insert((e, name), (0..15)
.map(|k| u32::from_be_bytes([by[at+k*4], by[at+k*4+1], by[at+k*4+2], by[at+k*4+3]]))
.collect());
}
}
let mut missing: Vec<String> = Vec::new();
for &(e, n, _) in MEASURED {
if !decl.contains_key(&(e, n.to_string())) {
missing.push(format!("entry {e} {n}"));
}
}
println!("\n=== DECLARATION-ENTRY RIVAL SWEEP ===");
println!("measured elements with NO declaration entry of their own: {} of {}",
missing.len(), MEASURED.len());
for m in &missing { println!(" {m}"); }
if !missing.is_empty() {
println!(" -> no declaration field can select the blend for these, because they");
println!(" have no declaration entry. The header is the only per-sprite home.");
}
let labelled: Vec<&(usize, &str, bool)> = MEASURED.iter()
.filter(|(e, n, _)| decl.contains_key(&(*e, n.to_string()))).collect();
let mut rivals = 0;
for w in 0..15 {
for bit in 0..32 {
let (mut ok, mut s, mut c) = (true, false, false);
for &&(e, n, additive) in &labelled {
let on = (decl[&(e, n.to_string())][w] >> bit) & 1 == 1;
if on { s = true } else { c = true }
if on != additive { ok = false; break }
}
if ok && s && c {
println!(" RIVAL: declaration +0x{:02X} bit {bit}", w * 4);
rivals += 1;
}
}
}
println!("declaration bits that separate the {} labellable elements: {rivals}",
labelled.len());
}

View File

@@ -1,48 +0,0 @@
//! Does a textured element's declaration carry anything that distinguishes the
//! two FRAME elements from every other element on the main menu?
//!
//! `sylpheed-port` asks for the blend/alpha mode of `ptframe1`/`ptframe2`. Prior
//! work is on `.prm` PRIMITIVES (`ui-prm-blend-mode.md`, undecodable with reach —
//! no field, the declaration words are constant) and on a refuted `T8aD +0x04`
//! bit. Neither covers a `.t32` element's own declaration entry, which is 60
//! bytes and mostly unread.
//!
//! This dumps every declaration entry on the menu and reports, per 4-byte word,
//! whether the two frames share a value that no other element has. A word that
//! separates exactly those two is a candidate; one that does not, is not.
//!
//! cargo run -p sylpheed-formats --example decl_entry_diff
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
const AT: usize = 0x20;
const N: usize = 60;
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");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
let names: Vec<String> = b.elements.iter().map(|e| e.name.clone()).collect();
let frames: Vec<usize> = names.iter().enumerate()
.filter(|(_, n)| n.starts_with("ptframe")).map(|(i, _)| i).collect();
println!("{} elements; frames at indices {:?}", names.len(), frames);
let word = |i: usize, w: usize| -> u32 {
let o = AT + i * N + w * 4;
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
};
println!("\nper-word: does a value separate EXACTLY the two frames?");
for w in 0..N / 4 {
let fv: Vec<u32> = frames.iter().map(|&i| word(i, w)).collect();
let same_in_frames = fv.windows(2).all(|p| p[0] == p[1]);
let others: Vec<u32> = (0..names.len()).filter(|i| !frames.contains(i))
.map(|i| word(i, w)).collect();
let unique = same_in_frames && !others.contains(&fv[0]);
let distinct = { let mut v: Vec<u32> = (0..names.len()).map(|i| word(i, w)).collect();
v.sort_unstable(); v.dedup(); v.len() };
println!(" +0x{:02X} frames {:?} distinct values {distinct:2}{}",
w * 4, fv.iter().map(|v| format!("{v:08X}")).collect::<Vec<_>>(),
if unique { " <- SEPARATES THE FRAMES" } else { "" });
}
}

View File

@@ -1,32 +0,0 @@
//! Which elements carry which values at the low-cardinality declaration words?
//!
//! `decl_entry_diff` found nothing separating `ptframe1`/`ptframe2` except their
//! NAME — +0x00 and +0x08 are the name string ("ptfr", ".t32"), so those two hits
//! are a false positive of that test, not a field.
//!
//! The remaining candidates for a per-element mode flag are the words with few
//! distinct values: +0x28 (3) and +0x2C (6). This prints who has what.
//!
//! cargo run -p sylpheed-formats --example decl_flag_words
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
const AT: usize = 0x20;
const N: usize = 60;
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");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
let w = |i: usize, off: usize| -> u32 {
let o = AT + i * N + off;
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
};
println!("{:<22} {:>10} {:>10} {:>10} {:>10}", "element", "+0x28", "+0x2C", "+0x34", "kind");
for (i, e) in b.elements.iter().enumerate() {
let mark = if e.name.starts_with("ptframe") { " <- FRAME" } else { "" };
println!("{:<22} {:>10} {:>10} {:>10} {:>#10x}{mark}",
e.name, w(i, 0x28), w(i, 0x2C) as i32, w(i, 0x34), e.kind);
}
}

View File

@@ -1,63 +0,0 @@
//! How often is a screen's design size READ, and how often is it FABRICATED?
//!
//! `ui_layout.rs` scans the `.rat` records for a `(w,h)` at `+0x18`/`+0x1c` and,
//! finding none, falls back to `(DESIGN_W, DESIGN_H)` = 1280x720. Its own comment
//! says "every screen seen is 1280x720, **which is also the fallback**" -- which
//! is precisely the problem: the fabricated value equals the expected one, so no
//! output of the parser can distinguish a read design size from an invented one.
//! The port sizes its screens off this number.
//!
//! This replicates the scan through the public RATC API and counts.
//!
//! cargo run -p sylpheed-formats --example design_size_fallback
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
use std::io::Write;
use std::path::PathBuf;
fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() { return 0 }
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut read, mut fell_back, mut nonstd) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut r, mut f) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(kids) = ratc::parse(&by) else { continue };
// the same predicate ui_layout uses, over the same records
// ⚠️ A first version took EVERY RATC child and failed its control:
// it reported all 965 builds stating a non-1280x720 size, where
// `screen list` prints 1280x720 for every one. `records` in
// ui_layout is the `.rat` children only; a T8aD sprite header read
// at +0x18 is garbage that passes the range test.
let found = kids.iter().filter(|k| k.kind == "RATC" || k.name.ends_with(".rat")).find_map(|k| {
let rec = &by[k.offset..(k.offset + k.size).min(by.len())];
let (w, h) = (be32(rec, 0x18), be32(rec, 0x1c));
(w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h))
});
match found {
Some((w, h)) => { r += 1; if (w, h) != (1280, 720) { nonstd += 1;
println!(" {name} : a build states a NON-standard design size {w}x{h}"); } }
None => f += 1,
}
}
if r + f > 0 {
println!("{name:30} {r:5} read {f:5} FABRICATED");
std::io::stdout().flush().ok();
}
read += r; fell_back += f;
}
println!("\n{read} builds state a design size, {fell_back} get the 1280x720 FALLBACK");
println!("{nonstd} builds state something other than 1280x720");
println!("--- END ---");
}

View File

@@ -1,46 +0,0 @@
//! Is `GP_DIALOG` entry 2 the `DLG_SELECT_DIFFICULTY` screen?
//!
//! The image lists `DLG_SELECT_DIFFICULTY` among the `DLG_*` names at
//! `0x820A41BB`, so DIFFICULTY is a DIALOG, not a GamePart screen with its own
//! pak — which is why a search for an 8-record `btn` build in a difficulty-named
//! archive found nothing. `GP_DIALOG` entry 2 carries `pcbtn00`..`03`: four
//! buttons, matching EASY / NORMAL / HARD / BACK.
//!
//! CONTROL: `GP_TITLE` entry 5's five buttons must come back at the rows the disc
//! is independently known to place them (162/242/322/401/482, spacing 80).
//!
//! cargo run -p sylpheed-formats --example dialog_button_rows
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn rows(ar: &PakArchive, entry: usize, what: &str) {
let Ok(by) = ar.read(&ar.entries()[entry]) else { return };
let Some(b) = ui_layout::parse_build(&by) else { return };
let mut v: Vec<(i32, String)> = b
.elements
.iter()
.filter(|e| {
let n = &e.name;
(n.starts_with("pcbtn") || n.starts_with("ptbtn")) && !n.contains('f')
})
.map(|e| (e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32), e.name.clone()))
.collect();
v.sort_by_key(|r| r.0);
println!("\n{what} (entry {entry}):");
for (y, n) in &v {
println!(" y {y:5} {n}");
}
if v.len() > 1 {
let sp: Vec<i32> = v.windows(2).map(|w| w[1].0 - w[0].0).collect();
println!(" spacing {sp:?}");
}
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let t = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
rows(&t, 5, "CONTROL: GP_TITLE main menu (must be 162/242/322/401/482)");
let d = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
rows(&d, 2, "GP_DIALOG candidate for DLG_SELECT_DIFFICULTY");
rows(&d, 3, "GP_DIALOG entry 3 (the pair)");
}

View File

@@ -1,58 +0,0 @@
//! Are the 37 equal-button-count `GP_DIALOG` pairs language pairs, or not?
//!
//! 26 of 65 adjacent pairs differ in BUTTON COUNT — two languages cannot, so those
//! are unrelated dialogs. For the rest my language reading was left UNSUPPORTED
//! rather than refuted, and both agents observed that nothing rewards closing it.
//!
//! A language pair must share its BUTTON NAMES and ROWS exactly (a locale changes
//! glyphs, not layout) and differ only elsewhere. An unrelated pair will differ in
//! button names or rows too.
//!
//! CONTROL: entries 2/3 (identical element sets) must come out as "buttons match".
//!
//! cargo run -p sylpheed-formats --example dialog_pair_37
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn sig(ar: &PakArchive, i: usize) -> Option<(Vec<(String, i32)>, usize)> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
let mut v: Vec<(String, i32)> = b
.elements
.iter()
.filter(|e| e.name.contains("btn"))
.map(|e| (e.name.clone(), e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32)))
.collect();
v.sort();
let n = v.len();
Some((v, n))
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut match_btn, mut differ_btn, mut ctrl) = (0, 0, false);
let mut examples = 0;
for k in (0..n).step_by(2) {
let (Some((a, na)), Some((b, nb))) = (sig(&ar, k), sig(&ar, k + 1)) else { continue };
if na != nb {
continue; // the 26 already settled
}
if a == b {
match_btn += 1;
if k == 2 { ctrl = true }
} else {
differ_btn += 1;
if examples < 5 {
println!(" entries {k:3}/{:<3} buttons DIFFER", k + 1);
println!(" {:?}", a.iter().map(|x| &x.0).collect::<Vec<_>>());
println!(" {:?}", b.iter().map(|x| &x.0).collect::<Vec<_>>());
examples += 1;
}
}
}
println!("\nequal-button-count pairs whose button NAMES+ROWS match : {match_btn}");
println!("equal-button-count pairs whose buttons DIFFER : {differ_btn}");
println!("control (entries 2/3 counted as matching): {}", if ctrl { "PASSED" } else { "FAILED" });
}

View File

@@ -1,45 +0,0 @@
//! Do adjacent `GP_DIALOG` entries differ in BUTTON COUNT?
//!
//! I offered an untested reading for why 63 of 65 adjacent pairs have different
//! element sets: dialog text baked into language-specific sprites, so EN/JP
//! entries differ by construction. `sylpheed-port` refuted it with a count — two
//! languages of one dialog cannot differ in how many buttons they have. This
//! re-derives that with my own reader before I record the refutation.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_button_counts
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn btns(ar: &PakArchive, i: usize) -> Option<usize> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().filter(|e| e.name.contains("btn")).count())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut diff, mut same, mut skip) = (0, 0, 0);
let mut show = 0;
for k in (0..n).step_by(2) {
match (btns(&ar, k), btns(&ar, k + 1)) {
(Some(a), Some(b)) => {
if a != b {
diff += 1;
if show < 6 {
println!(" entries {k:3}/{:<3} button counts {a} vs {b}", k + 1);
show += 1;
}
} else {
same += 1
}
}
_ => skip += 1,
}
}
println!("\nadjacent pairs differing in BUTTON COUNT: {diff}");
println!("adjacent pairs with equal button counts : {same}");
println!("unreadable : {skip}");
println!("\ntwo languages of one dialog cannot differ in button count.");
}

View File

@@ -1,43 +0,0 @@
//! What differs between equal-button-count `GP_DIALOG` adjacent pairs?
//!
//! All 39 share button names and rows. That is consistent with a LANGUAGE PAIR and
//! equally with TWO DIALOGS SHARING A BUTTON TEMPLATE (two yes/no boxes differing
//! only in their message sprite). The difference is in what else differs: a
//! language pair should differ in the SAME slots with locale-marked names.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_diffs
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::collections::BTreeSet;
use std::path::PathBuf;
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|e| e.name.clone()).collect())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let mut shown = 0;
for k in (0..n).step_by(2) {
let (Some(a), Some(b)) = (names(&ar, k), names(&ar, k + 1)) else { continue };
if a == b {
continue;
}
let oa: Vec<&String> = a.difference(&b).collect();
let ob: Vec<&String> = b.difference(&a).collect();
// only the equal-button-count ones
let ba = a.iter().filter(|x| x.contains("btn")).count();
let bb = b.iter().filter(|x| x.contains("btn")).count();
if ba != bb {
continue;
}
if shown < 6 {
println!("entries {k:3}/{:<3} shared {} only-in-{k}: {:?} only-in-{}: {:?}",
k + 1, a.intersection(&b).count(), oa, k + 1, ob);
shown += 1;
}
}
}

View File

@@ -1,47 +0,0 @@
//! Are `GP_DIALOG` entries 0/1 and 2/3 a LANGUAGE PAIR or a DUPLICATE?
//!
//! They are the only two adjacent pairs in that archive with identical element
//! sets; every other adjacent pair is two unrelated dialogs. Left open as
//! "untested" — identical element names are equally consistent with a language
//! pair (same layout, different glyphs baked into the textures) and with a
//! byte-for-byte duplicate.
//!
//! The bytes decide it: identical entries are a duplicate; entries that share
//! every element name but differ in payload are a language pair.
//!
//! CONTROL: entries 10/11, known to be two DIFFERENT dialogs (stage 10 vs stage
//! 02), must come out as differing — and by a lot. A comparator that cannot
//! separate two unrelated dialogs cannot judge two similar ones.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_identity
use sylpheed_formats::pak::PakArchive;
use std::path::PathBuf;
fn cmp(ar: &PakArchive, a: usize, b: usize, what: &str) {
let (Ok(x), Ok(y)) = (ar.read(&ar.entries()[a]), ar.read(&ar.entries()[b])) else {
println!("{what}: unreadable");
return;
};
let same_len = x.len() == y.len();
let n = x.len().min(y.len());
let diff = (0..n).filter(|&i| x[i] != y[i]).count();
let first = (0..n).find(|&i| x[i] != y[i]);
println!("{what}");
println!(" sizes {} / {} ({})", x.len(), y.len(),
if same_len { "equal" } else { "DIFFER" });
println!(" differing bytes over the common prefix: {diff} / {n} ({:.2}%)",
100.0 * diff as f64 / n as f64);
match first {
None if same_len => println!(" => BYTE-IDENTICAL — a duplicate"),
None => println!(" => one is a prefix of the other"),
Some(o) => println!(" => first difference at offset 0x{o:X}"),
}
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
cmp(&ar, 10, 11, "CONTROL: entries 10/11 — known two different dialogs");
cmp(&ar, 0, 1, "entries 0/1");
cmp(&ar, 2, 3, "entries 2/3 — the DIFFICULTY build");
}

View File

@@ -1,47 +0,0 @@
//! Are `GP_DIALOG`'s 140 entries adjacent EN/JP pairs, one per dialog record?
//!
//! The dialog table has 70 records and the archive has 140 entries. If the
//! pairing is adjacent — (0,1), (2,3), … — then dialog index = entry / 2, and the
//! unbound id-to-entry join becomes an ordering question rather than a search.
//!
//! Test: for each pair, compare the SET of element names. GP_TITLE's EN/JP pairs
//! share their sprite sets exactly except for the title art (4/7), so identical
//! sets are the signature of a language pair.
//!
//! cargo run -p sylpheed-formats --example dialog_pairing
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::collections::BTreeSet;
use std::path::PathBuf;
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|e| e.name.clone()).collect())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut adj_same, mut adj_diff, mut skipped) = (0, 0, 0);
for k in (0..n).step_by(2) {
match (names(&ar, k), names(&ar, k + 1)) {
(Some(a), Some(b)) => {
if a == b { adj_same += 1; println!(" identical pair: entries {k}/{}", k+1) } else { adj_diff += 1 }
}
_ => skipped += 1,
}
}
println!("ADJACENT pairing (2k, 2k+1): identical {adj_same} differing {adj_diff} unreadable {skipped}");
// rival hypothesis: halves, (i, i+70)
let (mut h_same, mut h_diff, mut h_skip) = (0, 0, 0);
for k in 0..n / 2 {
match (names(&ar, k), names(&ar, k + n / 2)) {
(Some(a), Some(b)) => {
if a == b { h_same += 1 } else { h_diff += 1 }
}
_ => h_skip += 1,
}
}
println!("HALVES pairing (i, i+70): identical {h_same} differing {h_diff} unreadable {h_skip}");
}

View File

@@ -1,29 +0,0 @@
//! How is `GP_DIALOG.pak` organised? Testing whether dialog id maps positionally.
//!
//! The dialog table gives name -> id (70 records, `DLG_SELECT_DIFFICULTY` = 2000)
//! and the disc gives a unique four-button build at GP_DIALOG entries 2/3. Nothing
//! joins them. If the archive were laid out in table order, or in id order, the
//! join would be positional — this checks.
//!
//! cargo run -p sylpheed-formats --example dialog_pak_shape
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let mut builds = 0;
let mut with_btn = 0;
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if let Some(b) = ui_layout::parse_build(&by) {
builds += 1;
if b.elements.iter().any(|el| el.name.contains("btn")) {
with_btn += 1;
}
}
}
println!("entries {n}, parse as builds {builds}, of those with a btn element {with_btn}");
println!("dialog table has 70 records; 70 x 2 (EN/JP) = 140");
}

View File

@@ -1,44 +0,0 @@
//! Is `ptbtn11` the TOP item of the `EXTRAS` screen?
//!
//! `sylpheed-port` authors `extras/initial_focus: ptbtn11` and states it is
//! correct under the surviving reading — "a submenu resets to the item it opens
//! on". The oracle shows EXTRAS opening on `MISSION SELECT`, the first of
//! MISSION SELECT / MOVIE THEATER / BACK. So their value is right only if
//! `ptbtn11` is that first item. This checks it against the disc.
//!
//! CONTROL: the same read on the MAIN MENU build, whose five buttons have a known
//! top-to-bottom order (NEW GAME first). If the ordering rule cannot reproduce a
//! known screen it cannot be trusted on an unknown one.
//!
//! cargo run -p sylpheed-formats --example extras_button_order
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn report(ar: &PakArchive, entry: usize, what: &str) {
let Ok(by) = ar.read(&ar.entries()[entry]) else { return };
let Some(b) = ui_layout::parse_build(&by) else { return };
let mut rows: Vec<(i32, String, u32)> = b
.elements
.iter()
.filter(|e| e.name.starts_with("ptbtn") && !e.name.contains('f'))
.map(|e| {
let y = e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32);
(y, e.name.clone(), e.kind)
})
.collect();
rows.sort_by_key(|r| r.0);
println!("\n{what} (entry {entry}) — buttons top to bottom:");
for (y, n, k) in &rows {
println!(" y {y:5} {n:14} kind 0x{k:04x}");
}
if let Some((_, first, _)) = rows.first() {
println!(" => TOP item is {first}");
}
}
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");
report(&ar, 5, "CONTROL: main menu (NEW GAME must be top)");
report(&ar, 6, "EXTRAS");
}

View File

@@ -1,62 +0,0 @@
//! Where does the `DIFFICULTY` screen live?
//!
//! `boot-config-and-gamepart-registry.md` records a count-match — "Ⓑ = event 0,
//! four menu items load an external archive, EXTRAS stays inside GP_TITLE" —
//! explicitly as an observation, not a decode. The disc can test half of it:
//! OPTIONS, LOAD GAME and TUTORIAL have their own paks, and EXTRAS' two items
//! have GP_MISSION_SELECT / GP_MOVIE_THEATER while EXTRAS itself is GP_TITLE
//! entries 6/9. NEW GAME is the fourth, and there is no GP_DIFFICULTY.pak.
//!
//! So: which archive holds a build with EASY / NORMAL / HARD buttons?
//!
//! CONTROL: the same scan must find the EXTRAS build in GP_TITLE, whose location
//! is independently known (entries 6/9, buttons ptbtn11/12/13).
//!
//! cargo run -p sylpheed-formats --example find_difficulty_build
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let dat = root.join("dat");
let mut paks: Vec<_> = std::fs::read_dir(&dat)
.expect("dat")
.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 found_extras = false;
for p in &paks {
let Ok(ar) = PakArchive::open(p) else { continue };
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 btns: Vec<&String> = b
.records
.keys()
.filter(|n| n.starts_with("ptbtn") || n.contains("btn"))
.collect();
if btns.len() != 8 {
continue;
}
let name = p.file_name().unwrap().to_string_lossy();
// CONTROL: the known MAIN MENU build (11 records, so this control is now vacuous) must show up.
if name == "GP_TITLE.pak" && (i == 5 || i == 8) {
found_extras = true;
println!("CONTROL {name} entry {i}: {} button records — the known MAIN MENU build (11 records, so this control is now vacuous)",
btns.len());
}
// any build outside GP_TITLE with a small button set is a candidate
if name != "GP_TITLE.pak" {
let mut names: Vec<String> = btns.iter().map(|s| (*s).clone()).collect();
names.sort();
println!(" {name:28} entry {i:3} {} buttons {:?}", btns.len(),
&names[..names.len().min(8)]);
}
}
}
println!("\ncontrol {} — the known MAIN MENU build (11 records, so this control is now vacuous) was {}found",
if found_extras { "PASSED" } else { "FAILED" },
if found_extras { "" } else { "NOT " });
}

View File

@@ -1,66 +0,0 @@
//! Where is the `DIFFICULTY` screen? Search by NAME, not by structure.
//!
//! A previous pass searched every pak for a build with exactly 8 `btn`-named
//! records, on the assumption that DIFFICULTY's four items (EASY / NORMAL / HARD
//! / BACK) pair with `f` focus variants the way GP_TITLE's screens do. Nothing
//! plausible turned up, and the assumption was mine — recorded as a negative
//! narrower than "not found" (data/gp-title-holds-three-button-screens.txt).
//!
//! This drops the structural assumption and looks for the words instead, across
//! every sprite AND record name in every build on the disc.
//!
//! CONTROL: the same scan must find `ptbtn11` in GP_TITLE — a name whose home is
//! independently known — when asked for it. A name scan that finds nothing
//! proves nothing unless it can find something.
//!
//! cargo run -p sylpheed-formats --example find_difficulty_names
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
const WANTED: &[&str] = &["easy", "normal", "hard", "diff", "level", "rank"];
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.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 control = false;
let mut hits = 0usize;
for p in &paks {
let Ok(ar) = PakArchive::open(p) else { continue };
let pname = p.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 all: Vec<String> = b
.sprites
.keys()
.chain(b.records.keys())
.cloned()
.collect();
if pname == "GP_TITLE.pak" && all.iter().any(|n| n.contains("ptbtn11")) {
control = true;
}
let m: Vec<&String> = all
.iter()
.filter(|n| {
let l = n.to_lowercase();
WANTED.iter().any(|w| l.contains(w))
})
.collect();
if !m.is_empty() {
hits += 1;
let mut s: Vec<String> = m.iter().map(|x| (*x).clone()).collect();
s.sort();
s.dedup();
println!(" {pname:28} entry {i:3} {:?}", &s[..s.len().min(6)]);
}
}
}
println!("\ncontrol (found ptbtn11 in GP_TITLE): {}", if control { "PASSED" } else { "FAILED" });
println!("{hits} build(s) carried a difficulty-ish name");
}

View File

@@ -1,123 +0,0 @@
//! 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

@@ -1,65 +0,0 @@
//! 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

@@ -1,90 +0,0 @@
//! 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

@@ -1,71 +0,0 @@
//! 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

@@ -1,114 +0,0 @@
//! 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

@@ -1,119 +0,0 @@
//! 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

@@ -1,55 +0,0 @@
//! Is `GP_DIALOG` 2/3 the ONLY build on the disc with four buttons at 259/329/399/469?
//!
//! Both agents recorded the same reach on the DIFFICULTY identification: entries
//! 2/3 are picked out by button count and geometry, not by a binding from
//! `DLG_SELECT_DIFFICULTY` to a pak entry, so "another four-button dialog with the
//! same rows would be indistinguishable". This tests whether such a rival exists.
//!
//! CONTROL: the scan must find GP_DIALOG 2 and 3 themselves. A rival-search that
//! cannot find the incumbent proves nothing.
//!
//! cargo run -p sylpheed-formats --example four_button_row_rivals
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
const WANT: [i32; 4] = [259, 329, 399, 469];
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.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 incumbent, mut rivals) = (0, 0);
for p in &paks {
let Ok(ar) = PakArchive::open(p) else { continue };
let pname = p.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 mut ys: Vec<i32> = b
.elements
.iter()
.filter(|el| el.name.contains("btn") && !el.name.contains('f'))
.map(|el| el.rest().map(|k| k.y).unwrap_or(el.pivot_y as i32))
.collect();
ys.sort();
ys.dedup();
if ys.len() != 4 {
continue;
}
let close = ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= 6);
if close {
let is_inc = pname == "GP_DIALOG.pak" && (i == 2 || i == 3);
if is_inc { incumbent += 1 } else { rivals += 1 }
println!(" {}{pname:24} entry {i:4} rows {ys:?}",
if is_inc { "INCUMBENT " } else { "RIVAL " });
}
}
}
println!("\ncontrol: found {incumbent} incumbent build(s) (want 2) — {}",
if incumbent == 2 { "PASSED" } else { "FAILED" });
println!("{rivals} rival build(s) elsewhere on the disc");
}

View File

@@ -1,64 +0,0 @@
//! The main menu's sprites, by their ALPHA channel — is `ptframe1`/`ptframe2`'s
//! "no fully-opaque pixel" a property of the artwork, and does any alpha value
//! look like a scale the game expands (e.g. 0..128) rather than 0..255?
//!
//! The port measures both frames as rendering too DARK against the capture, with
//! the shortfall correlating with the BACKGROUND. Two different causes predict
//! that: a background-scaling blend selected in code, or an alpha that is too
//! LOW in our decode. This example tests the second, which is on the disc.
//!
//! cargo run -p sylpheed-formats --example frame_alpha_census
use sylpheed_formats::{pak::PakArchive, t8ad, ui_layout};
use std::path::PathBuf;
fn census(name: &str, img: &t8ad::T8adImage) {
let n = (img.width * img.height) as usize;
let mut hist = [0usize; 256];
for p in 0..n {
hist[img.rgba[p * 4 + 3] as usize] += 1;
}
let zero = hist[0];
let full = hist[255];
let max = (0..256).rev().find(|&a| hist[a] > 0).unwrap_or(0);
let nonzero = n - zero;
// the top five alpha values that actually occur, by population
let mut top: Vec<(usize, usize)> = (1..256).map(|a| (hist[a], a)).filter(|&(c, _)| c > 0).collect();
top.sort_unstable_by(|a, b| b.0.cmp(&a.0));
let top5: Vec<String> = top.iter().take(5).map(|&(c, a)| format!("{a}x{c}")).collect();
println!(
"{name:<16} {}x{:<4} px={n:<8} a=0:{:5.1}% a=255:{:5.1}% max={max:<3} \
partial(1..254)/nonzero={:5.1}% top:[{}]",
img.width, img.height,
100.0 * zero as f64 / n as f64,
100.0 * full as f64 / n as f64,
if nonzero > 0 { 100.0 * (nonzero - full) as f64 / nonzero as f64 } else { 0.0 },
top5.join(" ")
);
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let argv: Vec<String> = std::env::args().skip(1).collect();
let pak = argv.iter().find(|a| a.parse::<usize>().is_err())
.cloned().unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
// Builds default to the two the port ships and can be overridden, so the
// same census serves the title (4) and the `PRESS (A)` plate (2).
let args: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
let builds: Vec<usize> = if args.is_empty() { vec![5, 6] } else { args };
for build in builds {
let Ok(by) = ar.read(&ar.entries()[build]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
println!("=== {pak} build {build} ===");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
match t8ad::parse(s) {
Some(img) => census(n, &img),
None => println!("{n:<16} (not a T8aD / failed to parse)"),
}
}
}
}

View File

@@ -1,46 +0,0 @@
//! The keyframe record's two unexplained words (`+4`, `+8`) and the fade/tint —
//! do any of them separate the four elements the port measures as rendering too
//! dark (`ptframe1`/`2`, `ptframe3`/`4`) from the ones it measures as accurate?
//!
//! The T8aD header does not: no word and no bit of `+0x04`/`+0x08` puts the four
//! frames on one side and `pteff10` (max alpha 130, wholly semi-transparent, and
//! rendered nearly exact) on the other. The keyframe is the other place a
//! per-element draw mode could live.
//!
//! cargo run -p sylpheed-formats --example frame_keyframe_unknowns
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
println!("{:<22} {:>5} {:>3} {:>10} {:>10} {:>8} {:>8} {:>8}",
"element", "build", "kf", "unknown_4", "unknown_8", "fade", "tint", "rot");
let mut frame_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
let mut other_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
for build in [5usize, 6] {
let by = ar.read(&ar.entries()[build]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for e in &b.elements {
for (i, k) in e.keyframes.iter().enumerate() {
println!("{:<22} {build:>5} {i:>3} {:>10} {:>10} {:08X} {:08X} {:>8}",
e.name, k.unknown_4, k.unknown_8, k.fade, k.tint, k.rotation_deg);
let row = (e.name.clone(), k.unknown_4, k.unknown_8, k.fade, k.tint, k.rotation_deg);
if e.name.contains("frame") { frame_sets.push(row) } else { other_sets.push(row) }
}
}
}
println!("\nframe keyframes: {} other keyframes: {}", frame_sets.len(), other_sets.len());
for (label, get) in [
("unknown_4", 0usize), ("unknown_8", 1), ("fade", 2), ("tint", 3), ("rotation", 4),
] {
let val = |r: &(String, i32, i32, u32, u32, i32)| -> i64 {
match get { 0 => r.1 as i64, 1 => r.2 as i64, 2 => r.3 as i64, 3 => r.4 as i64, _ => r.5 as i64 }
};
let fv: std::collections::BTreeSet<i64> = frame_sets.iter().map(val).collect();
let ov: std::collections::BTreeSet<i64> = other_sets.iter().map(val).collect();
let only_frames: Vec<&i64> = fv.iter().filter(|v| !ov.contains(v)).collect();
println!("{label:<10} frames take {:?} others take {} distinct values; frame-only values: {:?}",
fv, ov.len(), only_frames);
}
}

View File

@@ -1,63 +0,0 @@
//! Which T8aD header word, if any, separates the FOUR elements the port measures
//! as rendering too dark (`ptframe1`/`2` on the main menu, `ptframe3`/`4` on
//! `EXTRAS`) from the elements on the same two screens it measures as accurate?
//!
//! The control that matters: `pteff10` has max alpha 130 and no fully-opaque
//! pixel — the same "wholly semi-transparent" property the port proposed as the
//! reason the frames are special — and it renders nearly exact. So the separator
//! must put `pteff10` on the ACCURATE side.
//!
//! cargo run -p sylpheed-formats --example frame_vs_accurate_words
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let mut rows: Vec<(String, usize, Vec<u32>)> = Vec::new();
for build in [5usize, 6] {
let by = ar.read(&ar.entries()[build]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
if s.len() < 48 || &s[0..4] != b"T8aD" { continue }
let ws: Vec<u32> = (0..12)
.map(|k| u32::from_be_bytes([s[k*4], s[k*4+1], s[k*4+2], s[k*4+3]]))
.collect();
rows.push((n.clone(), build, ws));
}
}
let is_frame = |n: &str| n.starts_with("ptframe");
println!("{:<16} {:>5} +0x04 +0x08 +0x1C +0x2C", "sprite", "build");
for (n, b, w) in &rows {
println!("{n:<16} {b:>5} {:08X} {:08X} {:08X} {:08X}{}",
w[1], w[2], w[7], w[11], if is_frame(n) { " <- TOO DARK" } else { "" });
}
println!("\nwords where every ptframe* agrees and NO other sprite takes that value:");
let frames: Vec<&(String, usize, Vec<u32>)> = rows.iter().filter(|(n,_,_)| is_frame(n)).collect();
let mut any = false;
for k in 0..12 {
let v = frames[0].2[k];
if !frames.iter().all(|r| r.2[k] == v) { continue }
if rows.iter().any(|(n,_,w)| !is_frame(n) && w[k] == v) { continue }
println!(" word {k} (+0x{:02X}) = {v:08X}", k*4); any = true;
}
if !any { println!(" NONE — no header word separates the four frames from the rest"); }
println!("\nper-bit check on +0x04 and +0x08 (a bit that is 1 on all frames, 0 on all others):");
let mut anyb = false;
for &k in &[1usize, 2] {
for bit in 0..32 {
let on = |v: u32| (v >> bit) & 1 == 1;
if frames.iter().all(|r| on(r.2[k])) && rows.iter().all(|(n,_,w)| is_frame(n) || !on(w[k])) {
println!(" +0x{:02X} bit {bit} (0x{:X})", k*4, 1u32 << bit); anyb = true;
}
if frames.iter().all(|r| !on(r.2[k])) && rows.iter().all(|(n,_,w)| is_frame(n) || on(w[k])) {
println!(" +0x{:02X} bit {bit} (0x{:X}) INVERTED", k*4, 1u32 << bit); anyb = true;
}
}
}
if !anyb { println!(" NONE"); }
}

View File

@@ -1,30 +0,0 @@
//! Every button record in `GP_TITLE.pak`, per entry.
//!
//! Testing half of the count-match in boot-config-and-gamepart-registry.md:
//! "four menu items load an external archive, EXTRAS stays inside GP_TITLE".
//! If DIFFICULTY (NEW GAME's destination, EASY/NORMAL/HARD/BACK) is also inside
//! GP_TITLE, then NEW GAME loads nothing external and that reading is wrong.
//!
//! cargo run -p sylpheed-formats --example gp_title_buttons
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for (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 mut btns: Vec<String> = b
.records
.keys()
.filter(|n| n.starts_with("ptbtn"))
.cloned()
.collect();
btns.sort();
if btns.is_empty() {
continue;
}
println!("entry {i:2} {:2} button records {:?}", btns.len(), btns);
}
}

View File

@@ -1,14 +0,0 @@
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main(){
let root=PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap());
let ar=PakArchive::open(root.join("dat/GP_TITLE.pak")).unwrap();
for (i,e) in ar.entries().iter().enumerate(){
let Ok(by)=ar.read(e) else { println!("{i:2} <unreadable>"); continue };
let names: Vec<String> = ui_layout::parse_build(&by)
.map(|b| b.sprites.keys().take(2).cloned().collect()).unwrap_or_default();
let rec: Vec<String> = ui_layout::parse_build(&by)
.map(|b| b.records.keys().take(2).cloned().collect()).unwrap_or_default();
println!("{i:2} {} B sprites {:?} records {:?}", by.len(), names, rec);
}
}

View File

@@ -1,42 +0,0 @@
//! Is `GP_TITLE.pak` really "8 screens shipped twice, EN/JP"?
//!
//! The Q2 headline says each screen appears twice. The entry dump raised a
//! doubt: entry 11 shows `palogo_gamearts` and entry 14 shows `palogo_seta`,
//! which are different studios, not a language pair. If the two halves of a
//! "pair" declare different sprites, "shipped twice" is the wrong description of
//! at least that pair.
//!
//! CONTROL: a pair known to be a real EN/JP pair must come out as matching. 2/3
//! (the PRESS Ⓐ plate) is byte-identical in size and is the control.
//!
//! cargo run -p sylpheed-formats --example gp_title_pair_check
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::collections::BTreeSet;
use std::path::PathBuf;
fn sprites(ar: &PakArchive, i: usize) -> BTreeSet<String> {
let Ok(by) = ar.read(&ar.entries()[i]) else { return BTreeSet::new() };
ui_layout::parse_build(&by)
.map(|b| b.sprites.keys().cloned().collect())
.unwrap_or_default()
}
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 pairs = [(0, 1, "loading plain"), (2, 3, "PRESS (A) plate [CONTROL]"),
(4, 7, "title art"), (5, 8, "main menu"), (6, 9, "EXTRAS"),
(10, 13, "publisher splash"), (11, 14, "developer splash"),
(12, 15, "loading dressed")];
for (a, b, what) in pairs {
let (sa, sb) = (sprites(&ar, a), sprites(&ar, b));
let only_a: Vec<_> = sa.difference(&sb).cloned().collect();
let only_b: Vec<_> = sb.difference(&sa).cloned().collect();
let shared = sa.intersection(&sb).count();
let verdict = if only_a.is_empty() && only_b.is_empty() { "IDENTICAL SET" }
else { "DIFFERS" };
println!("\n{a:2}/{b:<2} {what:26} {shared:3} shared {verdict}");
if !only_a.is_empty() { println!(" only in {a}: {only_a:?}"); }
if !only_b.is_empty() { println!(" only in {b}: {only_b:?}"); }
}
}

View File

@@ -1,86 +0,0 @@
//! Do `ui_layout`'s two IN-RANGE fallbacks ever fire? Counted, disc-wide.
//!
//! An in-range fallback supplies a value that is legitimate, so no output can
//! distinguish it from the real thing and inspection cannot settle it. The only
//! question that has an answer is *how often does it fire*.
//!
//! ui_layout.rs:1681 kf.time.unwrap_or(0) -- 0 is a real keyframe time
//! (pose 0's time IS 0), so a fabricated one is invisible.
//! ui_layout.rs:1010 pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0
//! -- alpha 0 is legitimate, and it makes "no pose here"
//! read as "fully transparent", biasing an occlusion test
//! toward NOT occluded.
//!
//! (ui_layout.rs:973's `unwrap_or(0)` is NOT counted: it is guarded two lines
//! later by `if tmax == 0 { return false; }`, so 0 is handled, not assumed.)
//!
//! cargo run -p sylpheed-formats --example inrange_fallback_count
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut kf, mut untimed, mut builds) = (0u64, 0u64, 0u64);
let (mut queries, mut none_at) = (0u64, 0u64);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
builds += 1;
// (a) :1681 -- how many poses carry no time?
for el in &b.elements {
for k in &el.keyframes {
kf += 1;
if k.time.is_none() { untimed += 1 }
}
}
// (b) :1010 -- ask every element for a pose at every time that any
// element declares, which is the set the occlusion test draws from.
let mut times: Vec<u32> = b.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
times.sort_unstable(); times.dedup();
for el in &b.elements {
for &t in &times {
queries += 1;
if el.pose_at(t).is_none() { none_at += 1 }
}
}
}
print!("."); std::io::stdout().flush().ok();
}
println!();
println!("{builds} builds, {kf} keyframes");
println!(":1681 untimed poses (the fallback would fabricate t=0): {untimed}");
println!(":1010 pose_at queries {queries}, of which None (fallback reads a=0): {none_at}");
// NEGATIVE CONTROL. Both counters above report 0, and a zero is the result
// this corpus has learned to distrust most -- it reads clean rather than
// suspicious. So prove the detector CAN see a hit: ask every element for a
// pose at a time no build declares. If pose_at is total, `none_out` is 0 too
// and the 0 above means nothing.
let mut out_queries = 0u64;
let mut none_out = 0u64;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
for &t in &[u32::MAX, 1_000_000u32] {
out_queries += 1;
if el.pose_at(t).is_none() { none_out += 1 }
}
}
}
}
println!("CONTROL pose_at at an undeclared time: {out_queries} queries, {none_out} None");
println!(" (if this is 0 the detector is blind and the 0 above is meaningless)");
println!("--- END ---");
}

View File

@@ -1,72 +0,0 @@
//! 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

@@ -1,19 +0,0 @@
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

@@ -1,83 +0,0 @@
//! 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

@@ -1,135 +0,0 @@
//! Refutation check on `sylpheed-port`'s kind census: *"Every sprite decoration
//! on both screens is `0x0` — `ptframe1`…`ptframe4` included — and every button
//! is `0x3002`."*
//!
//! Their exporter decodes the field independently; this reads it from the other
//! side. The check is deliberately WIDER than their claim in two ways, because a
//! census that only looks where the claim looks cannot fail:
//!
//! * it covers every element, not only `.t32` sprites, so a decoration with an
//! unexpected kind cannot hide behind the word "sprite";
//! * it covers every build of `GP_TITLE`, not the two screens they checked, so
//! the claim's *reach* gets tested and not just its instances.
//!
//! It also cross-checks `kind` against the focus/nav index at `+0x2C`, which is
//! `-1` on anything that cannot take the cursor. That turns a census into a
//! decode: if the two fields agree everywhere, the bit that separates them is
//! identified rather than guessed. Run over EVERY UI pak on the disc, not just
//! `GP_TITLE`, so the claim is disc-wide.
//!
//! cargo run -p sylpheed-formats --example kind_census_five_screens
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::collections::BTreeMap;
use std::path::PathBuf;
fn suffix(n: &str) -> &str {
match n.rfind('.') { Some(i) => &n[i..], None => "(none)" }
}
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");
let n = ar.entries().len();
// kind -> suffix -> count, and the exceptions we care about by name
let mut table: BTreeMap<u32, BTreeMap<String, usize>> = BTreeMap::new();
let mut t32_nonzero: Vec<(usize, String, u32)> = Vec::new();
let mut btn_nonstd: Vec<(usize, String, u32)> = Vec::new();
let mut builds = 0usize;
for e in 0..n {
let by = match ar.read(&ar.entries()[e]) { Ok(b) => b, Err(_) => continue };
let b = match ui_layout::parse_build(&by) { Some(b) => b, None => continue };
builds += 1;
for el in &b.elements {
*table.entry(el.kind).or_default().entry(suffix(&el.name).to_string()).or_default() += 1;
if el.name.ends_with(".t32") && !el.name.contains("btn") && el.kind != 0 {
t32_nonzero.push((e, el.name.clone(), el.kind));
}
if el.name.contains("btn") && el.kind != 0x3002 {
btn_nonstd.push((e, el.name.clone(), el.kind));
}
}
}
println!("GP_TITLE: {builds} parseable builds of {n} entries\n");
println!("{:<10} {}", "kind", "elements by file suffix");
for (k, m) in &table {
let s: Vec<String> = m.iter().map(|(sfx, c)| format!("{sfx}x{c}")).collect();
println!("0x{k:<8X} {}", s.join(" "));
}
println!("\nNON-BUTTON .t32 elements with kind != 0: {}", t32_nonzero.len());
for (e, nm, k) in t32_nonzero.iter().take(30) { println!(" entry {e:>2} {nm:<24} kind 0x{k:X}"); }
println!("\n*btn* elements with kind != 0x3002: {}", btn_nonstd.len());
for (e, nm, k) in btn_nonstd.iter().take(30) { println!(" entry {e:>2} {nm:<24} kind 0x{k:X}"); }
// Is `kind` a bitfield, and does bit 0x2 mean "focusable"? The focus/nav
// index at +0x2C is -1 on everything that cannot take the cursor, so the two
// fields cross-check each other. Printed rather than asserted: this is the
// evidence for the reading, not the reading itself.
println!("\nkind vs the focus index at +0x2C (-1 = not focusable), GP_TITLE:");
// +0x2C is not in `Element`, so it is read straight out of the 60-byte
// declaration entry: table at 0x20, 0x14 = count, entry stride 60.
const AT: usize = 0x20;
const STRIDE: usize = 60;
let mut cross: BTreeMap<(u32, i32), usize> = BTreeMap::new();
for e in 0..n {
let by = match ar.read(&ar.entries()[e]) { Ok(b) => b, Err(_) => continue };
if ui_layout::parse_build(&by).is_none() { continue }
if by.len() < 0x18 { continue }
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for i in 0..count {
let at = AT + i * STRIDE;
if at + STRIDE > by.len() { break }
let kind = u32::from_be_bytes([by[at+0x28], by[at+0x29], by[at+0x2A], by[at+0x2B]]);
let foc = i32::from_be_bytes([by[at+0x2C], by[at+0x2D], by[at+0x2E], by[at+0x2F]]);
*cross.entry((kind, if foc < 0 { -1 } else { 1 })).or_default() += 1;
}
}
for ((k, f), c) in &cross {
println!(" kind 0x{k:<6X} focus {:<10} {c:>4} elements",
if *f < 0 { "= -1" } else { ">= 0" });
}
// ── the same test, every UI pak on the disc ──────────────────────────────
let mut all: BTreeMap<(u32, i32), usize> = BTreeMap::new();
let mut paks = 0usize;
let mut violations: Vec<String> = Vec::new();
let mut dir: Vec<_> = std::fs::read_dir(root.join("dat")).expect("dat")
.filter_map(|d| d.ok()).map(|d| d.path()).collect();
dir.sort();
for path in dir {
let name = path.file_name().unwrap().to_string_lossy().to_string();
if !name.ends_with(".pak") { continue }
let ar = match PakArchive::open(&path) { Ok(a) => a, Err(_) => continue };
let mut used = false;
for i in 0..ar.entries().len() {
let by = match ar.read(&ar.entries()[i]) { Ok(b) => b, Err(_) => continue };
if ui_layout::parse_build(&by).is_none() { continue }
if by.len() < 0x18 { continue }
used = true;
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for e in 0..count {
let at = AT + e * STRIDE;
if at + STRIDE > by.len() { break }
let kind = u32::from_be_bytes([by[at+0x28], by[at+0x29], by[at+0x2A], by[at+0x2B]]);
let foc = i32::from_be_bytes([by[at+0x2C], by[at+0x2D], by[at+0x2E], by[at+0x2F]]);
let f = if foc < 0 { -1 } else { 1 };
*all.entry((kind, f)).or_default() += 1;
if ((kind & 0x2) != 0) != (f > 0) {
violations.push(format!("{name} entry {i} elem {e}: kind 0x{kind:X} focus {foc}"));
}
}
}
if used { paks += 1 }
}
let total: usize = all.values().sum();
println!("\nDISC-WIDE — {paks} UI paks, {total} declaration entries");
println!("{:<12} {:>12} {:>12}", "kind", "focus = -1", "focus >= 0");
let kinds: std::collections::BTreeSet<u32> = all.keys().map(|(k, _)| *k).collect();
for k in kinds {
println!("0x{k:<10X} {:>12} {:>12}",
all.get(&(k, -1)).copied().unwrap_or(0),
all.get(&(k, 1)).copied().unwrap_or(0));
}
println!("\nHYPOTHESIS: bit 0x2 of kind == (focus index >= 0)");
println!("violations: {} of {total}", violations.len());
for v in violations.iter().take(20) { println!(" {v}"); }
}

View File

@@ -1,56 +0,0 @@
//! 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

@@ -1,42 +0,0 @@
//! 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

@@ -1,20 +0,0 @@
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

@@ -1,26 +0,0 @@
// 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

@@ -1,68 +0,0 @@
//! Does the `+0x08` falsifier actually identify `+0x08`?
//!
//! `ui-record-loop-length.md` (mine) rests on: an animation cannot restart before
//! its own last pose, so a wrong reading should produce violations, and none exist
//! in 1 781 records. `sylpheed-port` re-ran it at the neighbouring offsets and
//! reports the falsifier ACCEPTS `+0x04` too — meaning it does not discriminate,
//! and the real evidence is the exactness statistic I called a formality.
//!
//! This checks that from my own reader before I correct the page.
//!
//! cargo run -p sylpheed-formats --example loop_length_offset_discriminates
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn be32(b: &[u8], o: usize) -> Option<u32> {
(b.len() >= o + 4).then(|| u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]))
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let dat = root.join("dat");
let mut paks: Vec<_> = std::fs::read_dir(&dat)
.expect("dat")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
// offset -> (records, violations where word < max_t, exact matches)
let mut stat = [(0usize, 0usize, 0usize); 3];
let offsets = [0x04usize, 0x08, 0x0c];
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 };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (_, (off, size)) in &b.records {
let rec = &by[*off..(*off + *size).min(by.len())];
if rec.len() < 0x10 || &rec[0..4] != b"RATC" {
continue;
}
let Some(leaf) = ui_layout::parse_build(rec) else { continue };
let max_t = leaf
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max();
let Some(max_t) = max_t else { continue };
// sylpheed-port's reconciliation: max_t == 0 makes "does the word
// equal the largest keyframe time?" vacuous, and those records were
// silently in my denominator. Filter them and the counts must meet.
if std::env::var("MEANINGFUL_ONLY").is_ok() && max_t == 0 { continue }
for (i, o) in offsets.iter().enumerate() {
if let Some(w) = be32(rec, *o) {
stat[i].0 += 1;
if w < max_t { stat[i].1 += 1 }
if w == max_t { stat[i].2 += 1 }
}
}
}
}
}
println!("{:>8} {:>9} {:>12} {:>14}", "offset", "records", "violations", "exact == max_t");
for (i, o) in offsets.iter().enumerate() {
let (n, v, x) = stat[i];
println!(" +0x{o:02X} {n:>9} {v:>12} ({:>5.1}%) {x:>8} ({:>5.1}%)",
100.0 * v as f64 / n.max(1) as f64, 100.0 * x as f64 / n.max(1) as f64);
}
}

View File

@@ -1,46 +0,0 @@
//! What elements sit under the port's hot residual tiles on the main menu?
//!
//! `sylpheed-port` mapped the menu's edge residual at 64 px tiles and handed over
//! coordinates without names — the element inventory is this side's. Hot tiles
//! cluster at x 384704, y 64256, hottest at (512,128).
//!
//! ⚠️ Their tiles are in the frame they compare in; this prints DESIGN space, and
//! the two differ by the capture offset (capture_y ≈ 64.8 + 0.992·design_y). Both
//! readings are printed so the mapping is not assumed.
//!
//! cargo run -p sylpheed-formats --example main_menu_element_extents
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
println!("{:<26} {:>6} {:>6} {:>7} {:>7} {}", "element", "x", "y", "pivot_x", "pivot_y", "sprite");
let mut rows: Vec<(i32, i32, String, String)> = b
.elements
.iter()
.map(|e| {
let k = e.rest();
(k.map(|k| k.x).unwrap_or(0), k.map(|k| k.y).unwrap_or(0),
e.name.clone(), e.sprite.clone().unwrap_or_default())
})
.collect();
rows.sort_by_key(|r| (r.1, r.0));
for (x, y, n, s) in &rows {
// flag anything whose rest position lands in the hot band, read both ways
let cap_y = 64.82 + 0.9919 * (*y as f64);
let hot_design = (384..=704).contains(x) && (64..=256).contains(y);
let hot_capture = (384..=704).contains(x) && (64.0..=256.0).contains(&cap_y);
let mark = match (hot_design, hot_capture) {
(true, true) => " <- HOT both readings",
(true, false) => " <- hot in DESIGN space",
(false, true) => " <- hot in CAPTURE space",
_ => "",
};
println!("{n:<26} {x:>6} {y:>6} {:>7} {:>7} {s}{mark}",
b.elements.iter().find(|e| &e.name == n).map(|e| e.pivot_x).unwrap_or(0),
b.elements.iter().find(|e| &e.name == n).map(|e| e.pivot_y).unwrap_or(0));
}
}

View File

@@ -1,46 +0,0 @@
//! 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

@@ -1,63 +0,0 @@
//! Where does `screen --build N`'s ORDINAL diverge from the pak ENTRY index?
//!
//! `screen render --build N` takes an ordinal into the filtered build list, not
//! a pak entry. On `GP_TITLE` `[10]` is entry 12, which is how I rendered two
//! loading screens while believing they were the splashes — and every downstream
//! number validated. This enumerates the divergence across the disc so any
//! `--build N` in `docs/` can be checked instead of trusted.
//!
//! Two lists, because `screen list --all` swaps the predicate (`is_composable`
//! for `is_build`) and therefore RENUMBERS: `--build 4` and `--build 4 --all`
//! are not necessarily the same object.
//!
//! cargo run -p sylpheed-formats --example ordinal_entry_map
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
/// One decompression pass per entry, both predicates applied to it: reading the
/// archive twice doubled the cost on `GP_READY_ROOM` (902 entries) for nothing.
fn maps(ar: &PakArchive) -> (Vec<usize>, Vec<usize>) {
let (mut d, mut a) = (Vec::new(), Vec::new());
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if ui_layout::is_build(&by) { d.push(i) }
if ui_layout::is_composable(&by) { a.push(i) }
}
(d, a)
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut clean, mut div, mut allshift) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (d, a) = maps(&ar);
if d.is_empty() && a.is_empty() { continue }
let bad = d.iter().enumerate().find(|(o, &e)| *o != e).map(|(o, _)| o);
// does `--all` renumber? compare the entry each ordinal resolves to
let shift = (0..d.len().min(a.len())).find(|&o| d[o] != a[o]);
let tag = match bad {
None => { clean += 1; format!("{:4} builds ordinal == entry throughout", d.len()) }
Some(o) => { div += 1;
let t: Vec<String> = d.iter().enumerate().skip(o).take(5)
.map(|(x, &y)| format!("[{x}]->{y}")).collect();
format!("{:4} builds 🔴 diverges at ordinal {o}: {}", d.len(), t.join(" ")) }
};
let s = match shift {
Some(o) => { allshift += 1;
format!(" ⚠️ --all renumbers from [{o}]: entry {} -> {}", d[o], a[o]) }
None if a.len() != d.len() => format!(" (--all appends {} more)", a.len() - d.len()),
None => String::new(),
};
println!("{name:30} {tag}{s}");
std::io::stdout().flush().ok();
}
println!("\n{clean} archives ordinal==entry, {div} diverge, {allshift} renumbered by --all");
println!("--- END ---");
}

View File

@@ -1,40 +0,0 @@
//! Are `palogo_gamearts_eff` / `palogo_seta_eff` dwell-FALLBACK cases, or PLATEAU
//! cases? The port agent lists them among `GP_TITLE`'s four visible fallback
//! fires; this census listed only `palogo_sqex_eff` and `palogo_anima_eff`.
//!
//! It matters because the two are different defects. A plateau is a pose the
//! element genuinely HOLDS, and `rest_plateau()` returning it is correct. Only the
//! fallback is the unsound path.
//! cargo run -p sylpheed-formats --example palogo_eff_check
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if !el.name.starts_with("palogo") || !el.name.contains("eff") { continue }
// A single-keyframe element has no gap to maximise, so neither path
// applies and `rest()` trivially returns the only pose. Excluding it
// here matches the census, which filters `len < 2`.
if el.keyframes.len() < 2 { continue }
let plateau = el.keyframes.windows(2).position(|w| {
w[0].x == w[1].x && w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade
});
let ks: Vec<String> = el.keyframes.iter().map(|k| format!(
"{}:a{} {},{} {}%",
k.time.map(|v| v.to_string()).unwrap_or("-".into()),
(k.fade >> 24) & 0xff, k.x, k.y, k.scale_x)).collect();
let r = el.rest();
println!("e{i:<3} {:24} kf=[{}]", el.name, ks.join(" "));
println!(" plateau at pair {:?} -> path: {} rest a={} t={:?}",
plateau,
if plateau.is_some() { "PLATEAU (sound: the pose is held)" } else { "DWELL FALLBACK (unsound)" },
r.map(|k| (k.fade >> 24) & 0xff).unwrap_or(0),
r.and_then(|k| k.time));
}
}
}

View File

@@ -1,90 +0,0 @@
//! When an element has MORE THAN ONE plateau, does `rest_plateau()` pick the
//! wrong one — and is that the 21.9 % residual?
//!
//! `rest_vs_settle` found that among elements holding a pose ACROSS the screen's
//! settle instant, `pose_at(settle)` and `rest()` still disagree 21.9 % of the
//! time. I hypothesised that `rest_plateau()` picks the **longest** run (it does —
//! `len >= any_len`), which need not be the run covering the settle instant.
//!
//! ⚠️ **Control**: on elements with exactly ONE plateau that covers the settle
//! instant, the two MUST agree. If they do not, the hypothesis is not the
//! explanation and something else is wrong.
//!
//! cargo run -p sylpheed-formats --example plateau_choice
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut one_cov, mut one_agree) = (0usize, 0usize); // control
let (mut multi_cov, mut multi_agree, mut multi_wrongrun) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
if hi - lo < 10 { continue }
let st = lo + (hi - lo) / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 { continue }
let same = |a: &ui_layout::Keyframe, c: &ui_layout::Keyframe| {
a.fade == c.fade && a.scale_x == c.scale_x && a.scale_y == c.scale_y
&& a.tint == c.tint && a.x == c.x && a.y == c.y
};
// enumerate maximal runs of length >= 2, with their time spans
let mut runs: Vec<(usize, usize)> = Vec::new();
let mut i = 0usize;
while i < k.len() {
let mut j = i;
while j + 1 < k.len() && same(&k[j], &k[j + 1]) { j += 1 }
if j - i + 1 >= 2 { runs.push((i, j)) }
i = j + 1;
}
if runs.is_empty() { continue }
let covers = |&(a, c): &(usize, usize)| match (k[a].time, k[c].time) {
(Some(t0), Some(t1)) => t0 <= st && st <= t1,
_ => false,
};
let covering: Vec<_> = runs.iter().filter(|r| covers(r)).collect();
if covering.is_empty() { continue }
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
let agree = r.fade == s.fade && r.x == s.x && r.y == s.y
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y;
if runs.len() == 1 {
one_cov += 1;
if agree { one_agree += 1 }
} else {
multi_cov += 1;
if agree { multi_agree += 1 }
else {
// did rest() land on a run that does NOT cover settle?
let on_covering = covering.iter().any(|&&(a, c)| {
(a..=c).any(|idx| {
let kk = &k[idx];
kk.fade == r.fade && kk.x == r.x && kk.y == r.y
&& kk.scale_x == r.scale_x && kk.scale_y == r.scale_y
})
});
if !on_covering { multi_wrongrun += 1 }
}
}
}
}
}
println!("CONTROL — exactly ONE plateau, and it covers the settle instant:");
println!(" {one_cov} elements, rest() and pose_at(settle) agree on {one_agree} ({:.1} %)",
100.0 * one_agree as f64 / one_cov.max(1) as f64);
println!("\nTEST — MORE THAN ONE plateau, at least one covering the settle instant:");
println!(" {multi_cov} elements, agree on {multi_agree} ({:.1} %)",
100.0 * multi_agree as f64 / multi_cov.max(1) as f64);
println!(" of the {} disagreements, rest() landed on a run that does NOT cover",
multi_cov - multi_agree);
println!(" the settle instant: {multi_wrongrun}");
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,50 +0,0 @@
//! 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

@@ -1,37 +0,0 @@
//! 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

@@ -1,58 +0,0 @@
//! 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

@@ -1,73 +0,0 @@
//! 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

@@ -1,82 +0,0 @@
//! 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

@@ -1,65 +0,0 @@
//! The FULL extent of the two title sweep leaves, across their whole cycle.
//!
//! `ptloop_leaf_sweep_at.rs` samples t=340..540 — a window chosen to compare two
//! competing fits — so it never showed how far the leaves travel. That gap let a
//! claim stand that `ptloop01/02` "do not free-run", measured over the PARENT's
//! 200x90 rect, which is a pivot anchor the leaf spends almost no time inside.
//! `sylpheed-port` reports x tracks of -639..1521 and -839..1721 from their
//! export; this checks that against the disc.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_extent
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [4usize, 5, 7] {
let Ok(by) = ar.read(&ar.entries()[entry]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
println!("\n######## GP_TITLE entry {entry} ########");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
println!(" {parent}: not present in this build"); continue };
let Some(&(off, size)) = b.records.get(&el.name) else {
println!(" {}: no nested record", el.name); continue };
let span = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
println!(" {}: leaf will not parse", el.name); continue };
println!(" {} parent rest ({},{}) nested cycle span {span}",
el.name, el.keyframes.last().map(|k| k.x).unwrap_or(0),
el.keyframes.last().map(|k| k.y).unwrap_or(0));
for le in &lb.elements {
let (mut lo, mut hi) = (i64::MAX, i64::MIN);
let (mut sxs, mut sys) = (Vec::new(), Vec::new());
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
lo = lo.min(k.x as i64); hi = hi.max(k.x as i64);
if !sxs.contains(&k.scale_x) { sxs.push(k.scale_x) }
if !sys.contains(&k.scale_y) { sys.push(k.scale_y) }
}
}
// A CYCLE LENGTH IS NOT A MOTION DURATION. Find the last t at
// which x still changes: sylpheed-port reports the final segment
// HOLDS, which would make px/unit larger than cycle-based maths.
let mut last_move = 0u32;
let mut prev = None;
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
if prev.map_or(false, |p| p != k.x) { last_move = t }
prev = Some(k.x);
}
}
let w = (le.pivot_x * 2) as i64;
println!(" leaf {:<12} pivot {}x{} quad w={w} x track {lo} .. {hi} \
(centre {} .. {}) scale_x {:?} scale_y {:?}",
le.name, le.pivot_x, le.pivot_y,
lo + le.pivot_x as i64, hi + le.pivot_x as i64, sxs, sys);
println!(" motion ends at t={last_move} of a {span}-unit cycle -> {:.3} px/unit over the MOVING span (vs {:.3} over the cycle)",
(hi - lo) as f64 / last_move.max(1) as f64,
(hi - lo) as f64 / span as f64);
}
}
}
println!("--- END ---");
}

View File

@@ -1,37 +0,0 @@
//! The sweep leaves' RAW keyframes, so a segment rate can be checked not assumed.
//!
//! `sylpheed-port` reports `pteff03` as +4.0000 px/unit over t 0..150 and +4.0000
//! again over 150..540 -- perfectly linear -- against `pteff03a` at -4.0667 then
//! -4.0625. That asymmetry is what makes their "inversion" observation sharp: my
//! linearity gate fails on the leaf whose source is exactly straight. It is their
//! number from their export, so it is worth deriving independently.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_keyframes
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let by = ar.read(&ar.entries()[4]).expect("entry 4");
let b = ui_layout::parse_build(&by).expect("parse");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else { continue };
let Some(&(off, size)) = b.records.get(&el.name) else { continue };
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else { continue };
for le in &lb.elements {
println!("\n== {} -> leaf {}", el.name, le.name);
let ks: Vec<_> = le.keyframes.iter().collect();
for w in ks.windows(2) {
let (a, c) = (w[0], w[1]);
match (a.time, c.time) {
(Some(t0), Some(t1)) if t1 > t0 => println!(
" t {t0:>4} -> {t1:<4} x {:>6} -> {:<6} = {:+.4} px/unit",
a.x, c.x, (c.x - a.x) as f64 / (t1 - t0) as f64),
_ => println!(" t {:?} -> {:?} x {} -> {} (no rate)", a.time, c.time, a.x, c.x),
}
}
}
}
println!("--- END ---");
}

View File

@@ -1,91 +0,0 @@
//! 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

@@ -1,69 +0,0 @@
//! 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

@@ -1,63 +0,0 @@
//! Verify the newly-public `ui_layout::loop_length_units` against the disc.
//!
//! `sylpheed-port` reads a record's `+0x08` itself, guarded on the RATC magic,
//! because the field was exposed on no public ref at all — example, test and
//! `docs/re/` only. This checks the public function reproduces the numbers the
//! finding was written from before the port depends on it.
//!
//! CONTROL FIRST: the function must return `None` for a non-RATC slice and for a
//! slice too short to hold the field. An accessor that returns a number for
//! anything cannot be trusted to return the right one.
//!
//! cargo run -p sylpheed-formats --example record_loop_length_api
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
// ---- controls -------------------------------------------------------
assert_eq!(ui_layout::loop_length_units(b"NOTR\x00\x00\x00\x00\x00\x00\x00\x78"), None,
"control FAILED: accepted a non-RATC slice");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00"), None,
"control FAILED: accepted a slice too short for +0x08");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00\x00\x00\x00\x00\x00\x78"), Some(120),
"control FAILED: did not read +0x08 big-endian");
println!("controls pass: rejects non-RATC, rejects short, reads BE at +0x08");
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
// The records the finding names, with their published values.
let expect: &[(&str, u32)] = &[("ptbtn00f.rat", 120), ("ptloop01.rat", 600),
("ptloop02.rat", 720)];
let mut seen = 0usize;
let (mut recs, mut viol) = (0usize, 0usize);
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (name, (off, size)) in &b.records {
let rec = &by[*off..(*off + *size).min(by.len())];
let Some(len) = ui_layout::loop_length_units(rec) else { continue };
recs += 1;
// the disc-wide invariant the finding rests on
let largest = ui_layout::parse_build(rec)
.map(|l| l.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0))
.unwrap_or(0);
if len < largest { viol += 1; }
for (want_name, want) in expect {
if name == want_name && seen < 16 {
seen += 1;
let ok = if len == *want { "OK" } else { "MISMATCH" };
println!(" entry {ei:2} {name:16} +0x08 = {len:4} \
(published {want}) largest kf {largest:4} {ok}");
assert_eq!(len, *want, "{name} disagrees with the published value");
}
}
}
}
println!("\n{recs} records read through the public fn; \
{viol} violate +0x08 >= largest keyframe time");
assert!(seen > 0, "found none of the named records — the check proved nothing");
}

View File

@@ -1,68 +0,0 @@
//! Does "1 697 fallback fires return a visible pose" survive being said out loud?
//!
//! `rest-fallback-census.txt` reports that of 2 305 elements where the dwell
//! fallback decides, 1 697 rest at `alpha > 0`. It was written as if that number
//! were the defect. **It is only a defect where the element is a transient.** An
//! element that genuinely ends visible and stays visible SHOULD rest visible, and
//! the fallback happening to be the path that got there is not an error.
//!
//! The port agent hit the mirror image of this: it counted a screen's own exit
//! ramp as the end of an element's visibility, so `ptmsg` — the main menu's
//! permanent footer — came out as "a 2-unit flash". The story collapsed when
//! said aloud. This asks the same question of my number.
//!
//! Split the 1 697 by what the element's LAST keyframe does:
//! * last alpha > 0 -> the element ends visible; resting visible is right
//! * last alpha == 0 -> it fades out; a visible rest is a transient's peak
//!
//! cargo run -p sylpheed-formats --example rest_fallback_audit
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut fires, mut vis, mut ends_visible, mut ends_zero, mut at_peak) = (0, 0, 0, 0, 0);
// ⚠️ The port agent's exit-ramp finding applies to THIS split too: if a
// screen's exit ramp drives every element to a=0, then "last keyframe a=0"
// says nothing about the element being a transient. Measure it on ALL
// elements before using it on the 1 697.
let (mut all_el, mut all_end_zero) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
all_el += 1;
if (el.keyframes.last().unwrap().fade >> 24) & 0xff == 0 { all_end_zero += 1 }
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade) { continue }
fires += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a == 0 { continue }
vis += 1;
let last = (el.keyframes.last().unwrap().fade >> 24) & 0xff;
if last > 0 { ends_visible += 1 } else { ends_zero += 1 }
let peak = el.keyframes.iter().map(|k| (k.fade >> 24) & 0xff).max().unwrap_or(0);
if a == peak { at_peak += 1 }
}
}
}
println!("fallback fires {fires}");
println!(" of those, rest alpha > 0 {vis}");
println!(" element's LAST keyframe alpha > 0 {ends_visible} <- ends visible; resting visible is CORRECT");
println!(" element's LAST keyframe alpha = 0 {ends_zero} <- fades out; a visible rest is a transient's peak");
println!(" rest alpha == the element's MAX {at_peak}");
println!("\nCONTROL on the split itself — is 'ends at a=0' near-universal?");
println!(" all elements with >= 2 keyframes {all_el}");
println!(" of those, last keyframe alpha = 0 {all_end_zero} ({:.1} %)",
100.0 * all_end_zero as f64 / all_el as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,77 +0,0 @@
//! When the resting-pose DWELL FALLBACK actually runs, does it pick a visible pose?
//!
//! `ui-resting-pose.md` argues the fallback is structurally unsound — the gap it
//! maximises is time spent *interpolating*, so neither endpoint is held. Its one
//! worked example, `GP_TITLE` build 7's `ptlogo_eff3.t32`, **no longer
//! discriminates**: under the corrected keyframe-record layout the longest gap
//! moved from `61→103` to `0→46`, and both ends of that are `a = 0`. The page's
//! listing still shows the stale parser's trailing `-`.
//!
//! Losing the example is not the same as closing the question, so: disc-wide, how
//! often does the fallback fire, and when it does, does it return something the
//! player would see? An element resting at `a = 0` is harmless whichever end the
//! rule lands on.
//!
//! cargo run -p sylpheed-formats --example rest_fallback_census
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut elements, mut plateau, mut fallback, mut fb_visible) = (0usize, 0, 0, 0);
let mut worst: Vec<(u32, String, String)> = Vec::new();
let mut per_pak: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
elements += 1;
// a plateau is two ADJACENT poses that are equal — the same test
// the plateau path makes before the fallback can run
let has_plateau = el.keyframes.windows(2).any(|w| {
w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
});
if has_plateau { plateau += 1; continue }
fallback += 1;
per_pak.entry(name.clone()).or_default().0 += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a > 0 {
fb_visible += 1;
per_pak.entry(name.clone()).or_default().1 += 1;
worst.push((a, name.clone(), format!("e{i}/{}", el.name)));
}
}
}
}
println!("POPULATION: {elements} elements with >= 2 keyframes, over {} archives", paks.len());
println!("COVERAGE: {plateau} have a plateau (fallback never runs)");
println!(" {fallback} have NONE -> the dwell fallback decides");
println!(" {fb_visible} of those rest at alpha > 0 -- i.e. VISIBLE\n");
println!("PER ARCHIVE — fallback fires / of those, rests VISIBLE:");
let mut rows: Vec<_> = per_pak.into_iter().collect();
rows.sort_by(|a, b| b.1.1.cmp(&a.1.1));
for (pak, (fires, vis)) in &rows {
println!(" {pak:34} {fires:5} fires {vis:5} visible");
}
println!();
worst.sort_by(|a, b| b.0.cmp(&a.0));
for (a, pak, el) in worst.iter().take(6) {
println!(" a={a:3} {pak} {el}");
}
println!("\n--- END OF CENSUS (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,27 +0,0 @@
//! Which `GP_TITLE` elements does the resting-pose dwell fallback decide, and does
//! it hand back a visible pose? The disc-wide census says 5 fires / 4 visible here.
//! cargo run -p sylpheed-formats --example rest_fallback_title
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade) { continue }
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
let ks: Vec<String> = el.keyframes.iter()
.map(|k| format!("{}:a{}", k.time.map(|v| v.to_string()).unwrap_or("-".into()), (k.fade >> 24) & 0xff))
.collect();
println!("entry {i:2} {:24} rest a={a:3} t={:?} [{}]{}",
el.name, r.time, ks.join(" "),
if a > 0 { " <== VISIBLE" } else { "" });
}
}
}

View File

@@ -1,35 +0,0 @@
//! The resting SCALE of each element, so a drawn quad's size can be predicted.
//!
//! One additive draw on both the main menu and `EXTRAS` measures 819.2 x 720 px
//! and matches no sprite at 1x or 2x. Scale is the missing factor: the keyframe
//! carries scale_x / scale_y in percent, and a sprite drawn at 200 % x 500 % is
//! nothing like its stored size.
//!
//! cargo run -p sylpheed-formats --example rest_scale_of -- 5 6 4
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let argv: Vec<String> = std::env::args().skip(1).collect();
let pak = argv.iter().find(|a| a.parse::<usize>().is_err())
.cloned().unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
let builds: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
for build in if builds.is_empty() { vec![5usize, 6] } else { builds } {
let Ok(by) = ar.read(&ar.entries()[build]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
println!("=== {pak} entry {build} ===");
println!("{:<22} {:>10} {:>7} {:>7} {:>12} {}", "element", "pivot(w,h)", "sx%", "sy%", "drawn px", "at 1x/2x of pivot*2");
for e in &b.elements {
let k = match e.rest() { Some(k) => k, None => continue };
let w = e.pivot_x * 2;
let h = e.pivot_y * 2;
let dw = w as f64 * k.scale_x as f64 / 100.0;
let dh = h as f64 * k.scale_y as f64 / 100.0;
println!("{:<22} {:>4},{:<5} {:>7} {:>7} {:>6.1}x{:<5.1} {:>6.1}x{:<5.1}",
e.name, w, h, k.scale_x, k.scale_y, dw, dh, dw * 2.0, dh * 2.0);
}
println!();
}
}

View File

@@ -1,97 +0,0 @@
//! Should the settled pose come from each element's `rest()`, or from the
//! **screen's** settle instant?
//!
//! Three iterations have measured how badly `rest()`'s dwell fallback behaves —
//! 2 305 elements where it decides, 1 457 of them handed the element's *maximum*
//! alpha, and by construction none of those poses is held. What has been missing
//! is a proposal.
//!
//! `UiBuild::settle_time()` already exists: the midpoint of the longest
//! keyframe-free interval **across the whole build**. That is the port agent's
//! "re-key on the screen's span rather than the element's", and its shipped path
//! poses `pose_at(hold)` and agrees with every capture it holds at 0.01 %.
//!
//! ⚠️ **Control first.** On elements where `rest()` is already sound — the plateau
//! path, a pose the element genuinely holds — `pose_at(settle)` must AGREE. If it
//! disagrees there, it is not a better rule, it is a different one.
//!
//! ⚠️ `ui-settle-time.md` records that 42 % of bundles have a settle window under
//! 10 units and never settle at all. Bundles are split on that here rather than
//! averaged over.
//!
//! cargo run -p sylpheed-formats --example rest_vs_settle
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// control population (plateau) and test population (fallback), each split by
// whether the bundle settles at all
let (mut ctl_n, mut ctl_cov, mut ctl_agree) = (0usize, 0usize, 0usize);
let (mut fb_n, mut fb_rest_vis, mut fb_settle_vis) = (0usize, 0usize, 0usize);
let mut narrow = 0usize;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
if hi - lo < 10 { narrow += 1; continue } // this bundle never settles
let st = lo + (hi - lo) / 2;
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
let plateau = el.keyframes.windows(2).any(|w| w[0].x == w[1].x
&& w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade);
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
let (ra, sa) = ((r.fade >> 24) & 0xff, (s.fade >> 24) & 0xff);
if plateau {
// ⚠️ The first version of this control compared EVERY plateau
// element and got 46.6 % agreement — then I asked what that
// means physically. `rest()` finds *a* held pose; many
// elements hold one during the build-in and then move on.
// `pose_at(settle)` asks what is on screen WHEN THE SCREEN HAS
// SETTLED. Those are different questions, so disagreement
// proves nothing. The fair control is the subset where the
// held interval actually CONTAINS the settle instant.
ctl_n += 1;
let covers = el.keyframes.windows(2).any(|w| {
let held = w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade;
match (w[0].time, w[1].time) {
(Some(a), Some(bb)) => held && a <= st && st <= bb,
_ => false,
}
});
if covers {
ctl_cov += 1;
if ra == sa && r.x == s.x && r.y == s.y
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y { ctl_agree += 1 }
}
} else {
fb_n += 1;
if ra > 0 { fb_rest_vis += 1 }
if sa > 0 { fb_settle_vis += 1 }
}
}
}
}
println!("bundles skipped as never-settling (window < 10 units): {narrow}\n");
println!("CONTROL — elements where rest() takes the SOUND plateau path:");
println!(" {ctl_n} plateau elements in settling bundles");
println!(" {ctl_cov} of them HOLD ACROSS the settle instant — the fair control");
println!(" pose_at(settle) agrees with rest() on {ctl_agree} of those ({:.1} %)",
100.0 * ctl_agree as f64 / ctl_cov.max(1) as f64);
println!("\nTEST — elements where the unsound dwell fallback decides:");
println!(" {fb_n} elements");
println!(" rest() returns a VISIBLE pose on {fb_rest_vis} ({:.1} %)",
100.0 * fb_rest_vis as f64 / fb_n.max(1) as f64);
println!(" pose_at(settle) returns a VISIBLE pose on {fb_settle_vis} ({:.1} %)",
100.0 * fb_settle_vis as f64 / fb_n.max(1) as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,48 +0,0 @@
//! 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

@@ -1,75 +0,0 @@
//! How often does posing at the SCREEN's settle instant catch an element
//! mid-ramp? The adversarial census of my own proposal.
//!
//! The port agent found `ptmsg` — the main menu's footer — at alpha **127.5 of
//! 255** at that screen's settle instant, because the longest keyframe-free
//! interval ends exactly as the footer starts to arrive. `screen render --settle`
//! already prints "⚠️ narrow — this bundle may never settle" there: the window is
//! **12 units**.
//!
//! ⚠️ **And my `rest_vs_settle` filter was too permissive**: it dropped bundles
//! with a window under 10 units, so a 12-unit window passed while the tool itself
//! was flagging it. This splits by width instead of picking one cutoff.
//!
//! "Mid-ramp" = at the settle instant the element sits strictly inside an interval
//! whose two endpoint poses DIFFER — it is interpolating, not held.
//!
//! cargo run -p sylpheed-formats --example settle_midramp_census
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// buckets by settle-window width
let edges = [0u32, 10, 20, 30, 60, u32::MAX];
let names = ["< 10", "1019", "2029", "3059", ">= 60"];
let mut els = [0usize; 5];
let mut mid = [0usize; 5];
let mut bundles = [0usize; 5];
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
let w = hi - lo;
let bi = edges.windows(2).position(|p| w >= p[0] && w < p[1]).unwrap_or(4);
bundles[bi] += 1;
let st = lo + w / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 { continue }
els[bi] += 1;
// the interval containing the settle instant
let mut interpolating = false;
for pair in k.windows(2) {
if let (Some(t0), Some(t1)) = (pair[0].time, pair[1].time) {
if t0 <= st && st <= t1 && t0 != t1 {
let same = pair[0].fade == pair[1].fade
&& pair[0].x == pair[1].x && pair[0].y == pair[1].y
&& pair[0].scale_x == pair[1].scale_x
&& pair[0].scale_y == pair[1].scale_y;
if !same && st != t0 && st != t1 { interpolating = true }
break;
}
}
}
if interpolating { mid[bi] += 1 }
}
}
}
println!("{:8}{:>10}{:>10}{:>12}{:>10}", "window", "bundles", "elements", "mid-ramp", "share");
for i in 0..5 {
if els[i] == 0 { continue }
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", names[i], bundles[i], els[i], mid[i],
100.0 * mid[i] as f64 / els[i] as f64);
}
let te: usize = els.iter().sum(); let tm: usize = mid.iter().sum();
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", "ALL", bundles.iter().sum::<usize>(), te, tm,
100.0 * tm as f64 / te as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,49 +0,0 @@
//! What share of bundles have a NARROW settle window -- and of WHICH bundles?
//!
//! `screen render --settle`'s help says "a narrow one means the bundle never
//! settles (42 % of them, mostly `loop*` fragments)". The 42 % is correct and is
//! stated precisely in ui-settle-time.md: 731 of **1 758 composable bundles
//! carrying two or more keyframe times**. But inside `screen render`, "them"
//! reads as the bundles you would render -- the SCREEN BUILDS -- which is a
//! different and much smaller population. This computes both.
//!
//! cargo run -p sylpheed-formats --example settle_narrow_rate
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// (with >=2 keyframe times, narrow) for each population
let (mut b2, mut bn) = (0usize, 0usize); // screen builds (is_build)
let (mut c2, mut cn) = (0usize, 0usize); // composable (is_composable)
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let is_b = ui_layout::is_build(&by);
let is_c = ui_layout::is_composable(&by);
if !is_b && !is_c { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let mut ts: Vec<u32> = b.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
if ts.len() < 2 { continue }
let narrow = match b.settle_window() { Some((lo, hi)) => hi - lo < 10, None => true };
if is_b { b2 += 1; if narrow { bn += 1 } }
if is_c { c2 += 1; if narrow { cn += 1 } }
}
}
println!("population n narrow (<10 u) share");
println!("SCREEN BUILDS (is_build, what `screen render` renders by default)");
println!(" {b2:5} {bn:9} {:.0} %",
100.0 * bn as f64 / b2.max(1) as f64);
println!("COMPOSABLE bundles (is_composable, what --all admits)");
println!(" {c2:5} {cn:9} {:.0} %",
100.0 * cn as f64 / c2.max(1) as f64);
println!("\nui-settle-time.md quotes 731 / 1758 = 42 % over composable bundles.");
println!("--- END ---");
}

View File

@@ -1,31 +0,0 @@
// 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

@@ -1,27 +0,0 @@
//! Where does `settle_window()`'s answer come from? The port agent recomputes the
//! publisher splash's widest keyframe-free gap as 190 units; `--settle` reports 8.
//! One of the two readings is wrong and the file settles it.
//! cargo run -p sylpheed-formats --example settle_window_check -- <build>
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let b: usize = std::env::args().nth(1).unwrap_or("10".into()).parse().unwrap();
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let by = ar.read(&ar.entries()[b]).expect("entry");
let build = ui_layout::parse_build(&by).expect("parse");
println!("entry {b}: {} elements", build.elements.len());
for el in &build.elements {
let ts: Vec<String> = el.keyframes.iter()
.map(|k| k.time.map(|v| v.to_string()).unwrap_or("-".into())).collect();
println!(" {:26} [{}]", el.name, ts.join(" "));
}
let mut ts: Vec<u32> = build.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
println!("\nunion of all element keyframe times: {ts:?}");
let gaps: Vec<(u32,u32,u32)> = ts.windows(2).map(|w| (w[1]-w[0], w[0], w[1])).collect();
let mut g = gaps.clone(); g.sort_by(|a,b| b.0.cmp(&a.0));
println!("widest gaps: {:?}", &g[..g.len().min(4)]);
println!("settle_window() reports {:?}", build.settle_window());
}

View File

@@ -1,75 +0,0 @@
//! Out-of-sample test of the `T8aD +0x04` blend bit on the **boot splashes**.
//!
//! `ui-blend-mode-decoded.md` established the field on 35 elements over three
//! screens (GP_TITLE entries 2, 4, 5, 6). The two splashes -- entries 10 and 11 --
//! were NOT in that sample, and they are the screens the 2026-09-01 play-test
//! says are wrong.
//!
//! The oracle for these two screens is `data/splash-draw-pass-census.txt`: over
//! **1048 draws of frames 4..226**, covering both splashes end to end, the only
//! blend states submitted are `0x00010001` (the clear) and `0x07010701`
//! (source-over). Additive, `0x01010101`, appears **zero** times.
//!
//! PRE-REGISTERED PREDICTION, written before reading the disc: if the bit is the
//! blend selector and it generalises off its training screens, then every sprite
//! in entries 10 and 11 must report `additive = false`. Any `true` is a
//! discrepancy the field has to answer for.
//!
//! cargo run --release -p sylpheed-formats --example splash_blend_check
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 ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let mut additive = 0usize;
let mut alpha_over = 0usize;
let mut no_header = 0usize;
for entry in [10usize, 11] {
let by = ar.read(&ar.entries()[entry]).expect("entry");
let Some(b) = ui_layout::parse_build(&by) else {
println!("entry {entry}: not a build");
continue;
};
println!("\n## GP_TITLE entry {entry} -- {} elements, {} sprites",
b.elements.len(), b.sprites.len());
// Every sprite the bundle carries, not only those a top-level element
// names: a focused variant is reached through `focus_link` and would
// otherwise be invisible to this check.
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
match ui_layout::blend_additive_by_name(&b, &by, n) {
Some(true) => { additive += 1;
println!(" {n:<26} word04=0x{:08X} ADDITIVE",
ui_layout::header_word_04_by_name(&b, &by, n).unwrap()); }
Some(false) => { alpha_over += 1;
println!(" {n:<26} word04=0x{:08X} alpha-over",
ui_layout::header_word_04_by_name(&b, &by, n).unwrap()); }
None => { no_header += 1; println!(" {n:<26} (no T8aD header)"); }
}
}
}
println!("\nadditive={additive} alpha-over={alpha_over} no-header={no_header}");
println!("oracle (splash-draw-pass-census.txt, 1048 draws): additive draws = 0");
if additive == 0 {
println!("PREDICTION HELD -- the bit agrees with the capture on both splashes");
} else {
println!("PREDICTION FAILED -- {additive} sprite(s) claim additive, \
the capture submits none");
}
// Control: this harness must be able to REPORT additive, or "additive=0"
// is a property of the harness and not of the splashes. Entry 6 is a screen
// the oracle measures as mixed.
let by6 = ar.read(&ar.entries()[6]).expect("entry 6");
let b6 = ui_layout::parse_build(&by6).expect("build 6");
let c_add = b6.sprites.keys()
.filter(|n| ui_layout::blend_additive_by_name(&b6, &by6, n) == Some(true)).count();
println!("control -- entry 6 through the SAME code path reports additive={c_add} \
(must be > 0): {}", if c_add > 0 { "PASS" } else { "FAIL" });
}

View File

@@ -1,141 +0,0 @@
//! Name the capture's eight anonymous splash quads, from the disc.
//!
//! `data/splash-quad-timeline.txt` recorded eight distinct NDC rects off the
//! guest's vertex stream and could only call them Q0..Q7 -- a draw capture sees
//! geometry, not names. This predicts each rect from the DECLARED position and
//! the DECODED sprite size in `GP_TITLE.pak` entries 10 and 11, and matches.
//!
//! x_ndc = 2*x/1280 - 1 y_ndc = 1 - 2*y/720 (y down on screen)
//!
//! Instruments: the prediction is ⟨disc⟩ -- `parse_build` + `t8ad::parse`, i.e.
//! OUR reader. The target is ⟨capture⟩ -- the oracle's vertex stream. So an
//! agreement here is not a claim resting on the reader: it VALIDATES the reader
//! on the two splash bundles, which is what `REFUTED.md`'s 🟡 `⟨our-reader⟩`
//! entry on the splash timeline asks for. A disagreement would indict the reader.
//!
//! CONTROL: an assignment is only meaningful if the rects are separable, so this
//! reports the runner-up distance for every sprite. If the best and second-best
//! were comparable, "8/8 matched" would be an artefact of eight similar boxes.
//!
//! cargo run --release -p sylpheed-formats --example splash_quad_names
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, t8ad, ui_layout};
/// The oracle. Verbatim from the header of `docs/re/data/splash-quad-timeline.txt`,
/// which read them off the vertex buffer. Quantised to 0.01 by that file.
const CAPTURED: &[(&str, f64, f64, f64, f64, usize)] = &[
// name, x0, x1, y0, y1, draws submitted in
("Q0", -0.520, 0.520, -0.100, 0.080, 111),
("Q1", -0.390, 0.390, 0.350, 0.550, 87),
("Q2", -0.190, 0.190, -0.120, 0.120, 87),
("Q3", -0.300, 0.300, -0.620, -0.250, 87),
("Q4", -0.410, 0.410, 0.320, 0.570, 21),
("Q5", -0.200, 0.210, -0.150, 0.150, 21),
("Q6", -0.320, 0.310, -0.650, -0.220, 21),
("Q7", -0.530, 0.540, -0.130, 0.120, 8),
];
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");
// Predict a rect for every sprite-bearing element of the two splash builds.
let mut predicted: Vec<(String, f64, f64, f64, f64)> = Vec::new();
for entry in [10usize, 11] {
let by = ar.read(&ar.entries()[entry]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for el in &b.elements {
let Some(sprite) = el.sprite.as_ref() else { continue };
let Some(&(off, size)) = b.sprites.get(sprite) else { continue };
let Some(img) = t8ad::parse(&by[off..off + size]) else { continue };
// Declared placement is constant across every keyframe on these
// eight elements, so any keyframe gives the same rect; take the first.
let Some(kf) = el.keyframes.first() else { continue };
let (x, y) = (kf.x as f64, kf.y as f64);
let (w, h) = (img.width as f64, img.height as f64);
predicted.push((
sprite.clone(),
2.0 * x / 1280.0 - 1.0,
2.0 * (x + w) / 1280.0 - 1.0,
1.0 - 2.0 * (y + h) / 720.0,
1.0 - 2.0 * y / 720.0,
));
}
}
let dist = |p: &(String, f64, f64, f64, f64), c: &(&str, f64, f64, f64, f64, usize)| {
(p.1 - c.1).abs().max((p.2 - c.2).abs())
.max((p.3 - c.3).abs()).max((p.4 - c.4).abs())
};
// The capture rounds to 0.01, so a correct prediction must land inside half
// a step plus the pixel grid: 0.01 NDC is 6.4 px in x, 3.6 px in y.
const TOL: f64 = 0.010;
println!("{:<26} {:<4} {:>8} {:>9} {:>7} {}",
"sprite (disc)", "quad", "max|d|", "runner-up", "draws", "verdict");
let (mut ok, mut bad) = (0, 0);
let mut used: Vec<&str> = Vec::new();
for p in &predicted {
let mut ds: Vec<(f64, &(&str, f64, f64, f64, f64, usize))> =
CAPTURED.iter().map(|c| (dist(p, c), c)).collect();
ds.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let (d0, best) = (ds[0].0, ds[0].1);
let d1 = ds[1].0;
let good = d0 <= TOL && !used.contains(&best.0);
if good { ok += 1; used.push(best.0) } else { bad += 1 }
println!("{:<26} {:<4} {:>8.4} {:>9.4} {:>7} {}",
p.0, best.0, d0, d1, best.5,
if good { "OK" } else { "MISMATCH" });
}
println!("\n{ok} named, {bad} unmatched, of {} predicted / {} captured",
predicted.len(), CAPTURED.len());
println!("CONTROL: every runner-up above must be far outside the {TOL} tolerance; \
if it is not, the rects are not separable and the naming is luck.");
}
/// The second question this data answers, appended as its own pass: what IS an
/// `_eff` companion? `splash-quad-timeline.txt` called them "the same rects
/// scaled slightly larger", which is an inference from four rounded NDC numbers.
/// The disc says otherwise, and the difference matters to anyone drawing them.
#[test]
fn eff_is_a_concentric_outset_not_a_scale() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
for (entry, pairs) in [
(10usize, &[("palogo_sqex", "palogo_sqex_eff")][..]),
(11, &[("palogo_gamearts", "palogo_gamearts_eff"),
("palogo_seta", "palogo_seta_eff"),
("palogo_anima", "palogo_anima_eff")][..]),
] {
let by = ar.read(&ar.entries()[entry]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for (logo, eff) in pairs {
let g = |n: &str| {
let el = b.elements.iter()
.find(|e| e.sprite.as_deref() == Some(&format!("{n}.t32"))).unwrap();
let &(off, size) = b.sprites.get(&format!("{n}.t32")).unwrap();
let img = t8ad::parse(&by[off..off + size]).unwrap();
let kf = el.keyframes.first().unwrap();
(kf.x as f64, kf.y as f64, img.width as f64, img.height as f64)
};
let (lx, ly, lw, lh) = g(logo);
let (ex, ey, ew, eh) = g(eff);
// concentric?
let dcx = (ex + ew / 2.0) - (lx + lw / 2.0);
let dcy = (ey + eh / 2.0) - (ly + lh / 2.0);
assert!(dcx.abs() <= 2.0 && dcy.abs() <= 2.0,
"{eff}: centres differ by ({dcx},{dcy}) -- not concentric");
// uniform outset, NOT a uniform scale?
let (ox, oy) = ((ew - lw) / 2.0, (eh - lh) / 2.0);
assert!((ox - oy).abs() <= 2.0,
"{eff}: outset ({ox},{oy}) is not uniform");
assert!(ox >= 8.0 && ox <= 12.0, "{eff}: outset {ox} px outside 8..12");
// and the scale reading it displaces
let (sx, sy) = (ew / lw, eh / lh);
assert!((sx - sy).abs() > 0.05,
"{eff}: scales {sx:.3}/{sy:.3} ARE uniform -- 'scaled larger' would stand");
println!("{eff:<26} outset {ox:.0}x{oy:.0} px, centre off by \
({dcx:.1},{dcy:.1}), scale {sx:.3}/{sy:.3} (NOT uniform)");
}
}
}

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