Compare commits
122 Commits
pi/clippy-
...
formats-pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dbfa320ae | ||
|
|
fb6095ae3a | ||
|
|
3e8235ddbd | ||
|
|
8c71520ab8 | ||
|
|
59f5bf1b59 | ||
|
|
232ae0fa9c | ||
|
|
ef4ea8fe86 | ||
|
|
6f87ecc4db | ||
|
|
e0de5c6f22 | ||
|
|
6072d216a8 | ||
|
|
b6f0cf3fb2 | ||
|
|
3c004845fa | ||
|
|
ec06c50bf6 | ||
|
|
8dfa0ebac5 | ||
|
|
15d9037774 | ||
|
|
59f3ac6846 | ||
|
|
bec7f02c0a | ||
|
|
f8abb56bc4 | ||
|
|
aa9b7ef340 | ||
|
|
304ce9efaa | ||
|
|
e94e203a71 | ||
|
|
6d7bc87b0f | ||
|
|
159778faf1 | ||
|
|
29c99e1b3e | ||
|
|
1f6e07598e | ||
|
|
7a5f7b886a | ||
|
|
6c8ab5db94 | ||
|
|
acaacc36e0 | ||
|
|
78328c5022 | ||
|
|
cf91ad1dcc | ||
|
|
cfcda5501c | ||
|
|
47faeaa7a9 | ||
|
|
1a2f4fdc2d | ||
|
|
69bc4cd1cd | ||
|
|
311bd16ad8 | ||
|
|
d98c8214cc | ||
|
|
4c74e579a0 | ||
|
|
d4a08ad194 | ||
|
|
7b5a4aa041 | ||
|
|
38b80302b7 | ||
|
|
d0735d2c25 | ||
|
|
37af2055e3 | ||
|
|
81f42f9041 | ||
|
|
21a2c649e6 | ||
|
|
ce5505872b | ||
|
|
f2a74efe2d | ||
|
|
1f9609c37f | ||
|
|
258e1e63f7 | ||
|
|
f15c3f516d | ||
|
|
a1adb17d35 | ||
|
|
5da1a837fa | ||
|
|
14a7897c8f | ||
|
|
2f1c561d76 | ||
|
|
5a4428ec67 | ||
|
|
3bd1a61ea9 | ||
|
|
c5fe6a460c | ||
|
|
8b4dcccb67 | ||
|
|
b7930e6616 | ||
|
|
2a7ac9f859 | ||
|
|
fbe4ba5526 | ||
|
|
a13dd42c2d | ||
|
|
9e66d6a82c | ||
|
|
5fb64f3d31 | ||
|
|
6f25a6a0d0 | ||
|
|
4f77ab26d7 | ||
|
|
eaa578421f | ||
|
|
371cd0d610 | ||
|
|
3d5b5bae13 | ||
|
|
e8afbc8632 | ||
|
|
b56d0e1fb6 | ||
|
|
55cdcac61b | ||
|
|
02a99bc3e9 | ||
|
|
5938d3b1fc | ||
|
|
3a5f215179 | ||
|
|
c05c8e1719 | ||
|
|
b07f8984e3 | ||
|
|
05d278173d | ||
|
|
b047788d37 | ||
|
|
930ab1016d | ||
|
|
e2c599fa72 | ||
|
|
105a1f65e4 | ||
|
|
a8a828644a | ||
|
|
4362ea21b7 | ||
|
|
062e17bf34 | ||
|
|
a98a66190e | ||
|
|
9f34829776 | ||
|
|
b82f75979e | ||
|
|
63573d2b50 | ||
|
|
6011512334 | ||
|
|
cf3b60180e | ||
|
|
3f945808d6 | ||
|
|
432fb7450b | ||
|
|
de2fe4a110 | ||
|
|
3e7a258a9a | ||
|
|
b40f18eeb6 | ||
|
|
fb0755d190 | ||
|
|
884822ebe3 | ||
|
|
a0eb509cec | ||
|
|
6c05b72099 | ||
|
|
406517cc68 | ||
|
|
fe49c70da1 | ||
|
|
d71a74f938 | ||
|
|
2e04e24ce9 | ||
|
|
3e4864b7fa | ||
|
|
43f61a8d93 | ||
|
|
5744f379b2 | ||
|
|
b7104c6668 | ||
|
|
aafd7c1b6f | ||
|
|
14fced07b6 | ||
|
|
f24304248c | ||
|
|
f34d24941d | ||
|
|
5fcc89be55 | ||
|
|
3ee1a25f47 | ||
|
|
dd4f30a79f | ||
|
|
06c32a0fd4 | ||
|
|
7a4a74f8d7 | ||
|
|
724e06b134 | ||
|
|
e2d2dd34f0 | ||
|
|
9f34e6f7b6 | ||
|
|
c88f5e87a9 | ||
|
|
6f4b4d8b4c | ||
|
|
3db09a3806 |
69
.github/workflows/ci.yml
vendored
69
.github/workflows/ci.yml
vendored
@@ -10,53 +10,36 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# ── What this file may assume about where it runs ────────────────────────────
|
||||
#
|
||||
# It runs on ONE self-hosted runner: `rpi5-runner`, aarch64, advertising
|
||||
# ["ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04"]. Nothing else exists.
|
||||
#
|
||||
# This file was written for GitHub's hosted fleet — three operating systems and
|
||||
# x86_64 throughout — and had never once gone green here: 23 runs cancelled, 2
|
||||
# waiting, zero successes. Two separate reasons, and both are configuration
|
||||
# describing a world that is not this one:
|
||||
#
|
||||
# * `windows-latest` / `macos-latest` match no runner label, so those jobs sit
|
||||
# in WAITING for ever. The run therefore never reaches a terminal state, and
|
||||
# a pull request's checks never resolve either way — not red, just never
|
||||
# finished. That is worse than a failure: a red check tells you something.
|
||||
# * `--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a
|
||||
# cross-compile, and `wayland-sys`'s build script dies on it —
|
||||
# "pkg-config has not been configured to support cross-compilation".
|
||||
#
|
||||
# So: one job, on the machine that exists, building for the machine that exists.
|
||||
# If a second architecture is ever wanted here it needs a second RUNNER, not a
|
||||
# second matrix row.
|
||||
|
||||
jobs:
|
||||
# ── Native build, on the one runner there is ────────────────────────────────
|
||||
# ── Native builds: Windows, macOS, Linux ────────────────────────────────────
|
||||
native:
|
||||
name: Native — linux
|
||||
runs-on: ubuntu-latest
|
||||
name: Native — ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
# `stable` installs a MINIMAL profile: rustc, cargo, rust-std and no
|
||||
# more. Components have to be named. Without this line the Clippy step
|
||||
# below dies on "'cargo-clippy' is not installed for the toolchain
|
||||
# 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it
|
||||
# is the step never having run. The `fmt` job below always got this
|
||||
# right; this one never did.
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo registry and build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
# Linux: install Bevy's system dependencies (X11, Wayland, audio)
|
||||
- name: Install Linux system dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
@@ -69,30 +52,16 @@ jobs:
|
||||
pkg-config
|
||||
|
||||
- name: Check (fast compile check)
|
||||
run: cargo check --workspace
|
||||
run: cargo check --workspace --target ${{ matrix.target }}
|
||||
|
||||
- name: Build (debug)
|
||||
run: cargo build --workspace
|
||||
run: cargo build --workspace --target ${{ matrix.target }}
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace
|
||||
run: cargo test --workspace --target ${{ matrix.target }}
|
||||
|
||||
# This step has never once executed on this codebase: the toolchain above
|
||||
# shipped without the component, so every run died on "not installed"
|
||||
# before clippy saw a line of source. Its result was never pass or fail,
|
||||
# only unmeasured. With the component installed it becomes a real check,
|
||||
# and the first honest thing it will report is that the workspace is not
|
||||
# clean — the build already emits ~13 plain rustc warnings (unused
|
||||
# imports, unused variables, needless `mut`, dead fields) that
|
||||
# `-D warnings` promotes to errors, before clippy's own lints are counted.
|
||||
#
|
||||
# Left gating on purpose. A red check that measures something is worth
|
||||
# more than a green one that measures nothing, and the alternative —
|
||||
# `continue-on-error`, or dropping `-D warnings` — cannot tell "debt not
|
||||
# yet paid" from "debt paid", which is the shape PROTOCOL.md forbids.
|
||||
# The debt is scoped in #13, as the rustfmt debt is in #12.
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
run: cargo clippy --workspace --target ${{ matrix.target }} -- -D warnings
|
||||
|
||||
# ── WASM / Web build ─────────────────────────────────────────────────────────
|
||||
wasm:
|
||||
|
||||
31
.gitignore
vendored
31
.gitignore
vendored
@@ -25,41 +25,10 @@ __pycache__/
|
||||
# ── The port ────────────────────────────────────────────────────────────────
|
||||
# Generated from the user's own disc. This repo stays clean-room: code, schemas,
|
||||
# authored mappings and documentation only -- never game content.
|
||||
#
|
||||
# BOTH names are ignored on purpose. `export/` is what the exporter writes and
|
||||
# what `ExportTree.locate()` reads today; `data/base/` is the name MODDING.md
|
||||
# gives that same tree. Only one of them existed here, and it was the one
|
||||
# nothing writes -- so the live output directory was tracked while MISSION §4
|
||||
# said it was ignored. Ignoring both means renaming the tree to match the docs
|
||||
# cannot silently start committing the disc.
|
||||
/export/
|
||||
/data/base/
|
||||
#
|
||||
# ⚠️ Enumerating names is what FAILED. The two rules above were written --
|
||||
# carefully, with the comment above -- while 850 files and 299 MB of extracted
|
||||
# sprites, audio and transcoded video sat committed under `export-probe/` and
|
||||
# `export-probe2/`, a third name nobody had thought to list. So ignore the
|
||||
# SHAPE, not the instances: any top-level directory whose name starts `export`,
|
||||
# and game media anywhere it lands.
|
||||
/export*/
|
||||
*.ogv
|
||||
*.ogg
|
||||
*.wav
|
||||
*.xpr
|
||||
*.pak
|
||||
# Loose capture output at the repo root -- 246 MB of it arrived this way.
|
||||
/*.tsv
|
||||
/*.log
|
||||
# Transient inter-agent files. Deliberately outside history: they are working
|
||||
# artefacts with provenance in their manifest, not results.
|
||||
/exchange/
|
||||
!/exchange/.gitkeep
|
||||
.godot/
|
||||
port/.godot/
|
||||
|
||||
# A mod is usually an EDITED GAME ASSET, and this repository never holds game
|
||||
# assets. `data/mods/` is the user's own directory -- the exporter never touches
|
||||
# it and neither does git, except for the README that explains the rule.
|
||||
/data/mods/*
|
||||
!/data/mods/README.md
|
||||
!/data/mods/.gitkeep
|
||||
|
||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -4611,7 +4611,7 @@ dependencies = [
|
||||
"colored",
|
||||
"image",
|
||||
"indicatif",
|
||||
"sylpheed-formats 0.1.0",
|
||||
"sylpheed-formats",
|
||||
"texpresso",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4627,7 +4627,7 @@ dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sylpheed-formats 0.1.0 (git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01)",
|
||||
"sylpheed-formats",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4648,25 +4648,6 @@ dependencies = [
|
||||
"xdvdfs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sylpheed-formats"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01#1cd5b8b1cb1f02eefc0865e1a1fe280e44831c9d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"binrw",
|
||||
"flate2",
|
||||
"futures",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ttf-parser 0.24.1",
|
||||
"xdvdfs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sylpheed-viewer"
|
||||
version = "0.1.0"
|
||||
@@ -4678,7 +4659,7 @@ dependencies = [
|
||||
"image",
|
||||
"rfd",
|
||||
"rodio",
|
||||
"sylpheed-formats 0.1.0",
|
||||
"sylpheed-formats",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -42,7 +42,6 @@ docs/
|
||||
re/ the corpus: findings, refutations, method traps
|
||||
game/ how the game is navigated -- menus, modals, flight
|
||||
port/ the port's mission, its handoff contract, modding rules
|
||||
-- and RUNNING.md, which is how you actually start it
|
||||
agents/ how the agent team works together
|
||||
tools/ capture harnesses, probes, the share tool
|
||||
exchange/ transient inter-agent files. NOT in git
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
{
|
||||
"format": "sylpheed.audio/1",
|
||||
"_": [
|
||||
"Menu audio. EVERY VALUE IN THIS FILE IS MEASURED OR CHOSEN -- none of it is",
|
||||
"in a data file the exporter can read, which is why it is here and not in the",
|
||||
"exporter. `measured` and `chosen` are NOT the same thing and this file keeps",
|
||||
"them apart: a measurement is deleted when the disc states it, a choice is",
|
||||
"deleted when somebody measures it.",
|
||||
"",
|
||||
"Two different kinds of not-on-the-disc live in this file and they are not",
|
||||
"interchangeable:",
|
||||
"",
|
||||
" * `se` -- MEASURED. `Static.slb` is a delimiter-less run of whole 2048-byte",
|
||||
" XMA1 packets: no RIFF, no seek chunk, no XACT container. A wave is defined",
|
||||
" ONLY by (offset, packet_count), and both numbers come from the running",
|
||||
" game, not from the file. HANDOFF Q8. Delete a row the day a table on the",
|
||||
" disc states the same thing.",
|
||||
"",
|
||||
" * `bgm` -- MEASURED, and only the LOOP POLICY beside it is chosen. HANDOFF",
|
||||
" Q10's negative is about the TABLES: `SOUNDS`, `FILES` and the bank headers",
|
||||
" name no screen. The executable does -- cue 1103 = `BGM_103`, corroborated",
|
||||
" by a byte-for-byte match against what the XMA probe saw at the main menu.",
|
||||
" An earlier draft read the negative as unbounded, picked a track at random",
|
||||
" and called it authored. See the `bgm._` block for what that cost.",
|
||||
"",
|
||||
"The exporter reads this file and emits `export/audio/**` from it. It holds no",
|
||||
"cue table of its own: a measured offset compiled into a Rust `const` is a",
|
||||
"measurement wearing the costume of a decoded field, and MISSION section 3 is",
|
||||
"explicit that measured values live here."
|
||||
],
|
||||
"se": {
|
||||
"_": [
|
||||
"MEASURED, HANDOFF Q8, and the RE agent retracted an earlier 'cannot be",
|
||||
"extracted' to publish these. The waves were located BY PLAYING THEM: Canary",
|
||||
"with `--xma_param_probe=true` prints a stream's packet count and first 32",
|
||||
"bytes when it is played, and searching those bytes in the bank gives the",
|
||||
"offset.",
|
||||
"",
|
||||
"WARNING, from the same finding: the file order is NOT cue-id order. These",
|
||||
"cannot be counted out, and an index here would be a fabrication.",
|
||||
"",
|
||||
"`name_match` is the authors' own identifier GUESSED BY NAME. It is carried",
|
||||
"so the guess is not lost and is never presented as the measurement. Where",
|
||||
"the RE agent did not separate two candidates, there is no name at all --",
|
||||
"an absent `name_match` means nobody has claimed one, never that the",
|
||||
"BINDING is unknown. The binding is the measured part.",
|
||||
"",
|
||||
"All three are mono 48 kHz; that is the RE agent's statement in",
|
||||
"`sylpheed_formats::media::se_wave_riff`, not something re-derived here."
|
||||
],
|
||||
"move": {
|
||||
"bank": "Static.slb",
|
||||
"offset": "0x1ec0",
|
||||
"packets": 4,
|
||||
"channels": 1,
|
||||
"rate": 48000,
|
||||
"name_match": "SE_UI_CURSOR",
|
||||
"why": "HANDOFF Q8, measured: the d-pad move cue, 8 192 B / 0.533 s, reproduced across two boots. Left/right play nothing at all, which is a measurement too and is why there is no `left`/`right` row here rather than a silent file.",
|
||||
"kind": "measured"
|
||||
},
|
||||
"confirm": {
|
||||
"bank": "Static.slb",
|
||||
"offset": "0x5d6c0",
|
||||
"packets": 6,
|
||||
"channels": 1,
|
||||
"rate": 48000,
|
||||
"why": "HANDOFF Q8, measured: the (A) confirm cue, 12 288 B / 1.016 s. NO `name_match`: Q8 is explicit that (A)'s wave was not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`, so naming it would invent the one thing the measurement did not settle.",
|
||||
"kind": "measured"
|
||||
},
|
||||
"back": {
|
||||
"bank": "Static.slb",
|
||||
"offset": "0x0ec0",
|
||||
"packets": 2,
|
||||
"channels": 1,
|
||||
"rate": 48000,
|
||||
"why": "HANDOFF Q8, measured: the (B) back cue, 4 096 B / 0.344 s, reproduced across two boots. No `name_match` for the same reason as `confirm` -- Q8 names no identifier for it.",
|
||||
"kind": "measured"
|
||||
}
|
||||
},
|
||||
"bgm": {
|
||||
"_": [
|
||||
"MEASURED, NOT CHOSEN -- and the port got this wrong for one iteration.",
|
||||
"",
|
||||
"`docs/port/BLOCKED.md` carried a row reading 'not on the disc ... the port",
|
||||
"is choosing a track, and that choice is authored', and the first draft of",
|
||||
"this file duly picked BGM_001 and labelled it arbitrary. That row was not",
|
||||
"stale: `BGM_103` is in HANDOFF at `9ca1eb5`, which is the exact commit the",
|
||||
"row says it was reconciled against. It was WRONG WHEN WRITTEN.",
|
||||
"",
|
||||
"What HANDOFF actually says is a negative with a stated reach, and the reach",
|
||||
"is what the port dropped: the *tables* cannot say which BGM a screen plays",
|
||||
"-- `SOUNDS`, `FILES` and the bank headers name no screen. The EXECUTABLE",
|
||||
"can. `GamePart_Title`'s phase handler `sub_821C5580` carries `li r5, 1103`",
|
||||
"into a sound call, cue 1103 is `BGM_103`, and `BGM_103.slb`'s two declared",
|
||||
"waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA",
|
||||
"probe saw decoding at the main menu. Static code, disc census and runtime",
|
||||
"agree. HANDOFF's own words: 'The port does not have to choose a track.'",
|
||||
"",
|
||||
"So this section is a CITATION, not a decision. It lives in `authored/`",
|
||||
"only because the binding is in the .xex and the exporter reads data files,",
|
||||
"not code -- and it must be deleted the day something the exporter can read",
|
||||
"states it. The loop policy below IS still a decision."
|
||||
],
|
||||
"main_menu": {
|
||||
"bank": "BGM_103.slb",
|
||||
"loop": "restart",
|
||||
"kind": "measured",
|
||||
"why": "MEASURED, HANDOFF Q10 -- NOT a port choice. `GamePart_Title`'s phase handler `sub_821C5580` plays cue 1103 = `BGM_103`, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree; see docs/re/menu-audio-cues.md and docs/re/structures/bgm-two-stems.md. The name carries its `.slb` extension because that is what `sound.pak` hashes -- `BGM_103` alone resolves to nothing, which is how the first draft of this file failed. ✅ AUDITED 2026-08-31 -- the THREE legs are three, and that is now measured rather than asserted. Prompted by the Decoder's point that a decorative second support is worse than none, since a conclusion with two supports reads as better evidenced than one and apparent redundancy is itself the misinformation. Read literally, 'disc census' and 'runtime' could be ONE comparison -- declared wave sizes matched byte-for-byte against the probe -- which would make three legs two. It is a real third leg only if the census EXCLUDES alternatives: if another bank carried the same two sizes, the byte match would not distinguish BGM_103. Measured with this port's own reader (`crates/sylpheed-export/examples/bgm_size_census.rs`): of 32 readable BGM_* banks on the disc, EXACTLY ONE carries waves of that size. The census therefore excludes, the static-code leg names the cue independently, and the three legs stand. ✅ AND THE EXCLUSION IS TIGHTER THAN I STATED. The Decoder attempted to refute it from their own census tool rather than this port's reader: of 32 census rows, exactly one bank carries EITHER of those wave sizes -- not merely both together, which is what I measured. A collision would therefore need to reproduce a single size, not a pair, and none does.",
|
||||
"loop_why": "MEASURED, and this field's own history is why it says so first. The bed loops; the loop is a RUNTIME field -- `loop_start`/`loop_end` in the XMA decoder context, set by `XMASetLoopData` and logged by Xenia -- and the cycle was watched directly: three wraps, both contexts wrapping at the same instant every time, mean 61.81 s against the 61.93 s authored in `loop_end_s`, 0.2 % apart from instruments sharing nothing. The export is TRIMMED to that window, because Godot loops a whole file and a loop region therefore has to BE the file. ⚠️ The window's START is not measured and is authored as 0, which is known to be wrong -- see `loop_start_why`. 🔴 EVERY SENTENCE THAT PRECEDED THIS ONE WAS REFUTED, and the previous text survived in the manifest for two days after the corrections were written. It said the loop would be `AUDIBLY WRONG AT THE SEAM [refuted]`, that `no loop-point field has been identified [refuted] anywhere`, and that trimming `would INVENT a loop point`. All three are false: the field exists, the 3.4 s of near-silence was the PORT'S loop and not the game's, and the trim is now what the measurement says. The corrections went into `loop_end_why` and `loop_start_why`; this field is the one the exporter concatenates into `manifest.json`, so the export went on telling readers the refuted story. A correction that does not reach the artifact a consumer reads has not been made. 📌 CITATIONS ADDED 2026-09-01, and their absence was found by `audit-kinds` the moment this field got a `kind` -- it had 1 400 characters of prose and nothing openable, which is exactly the state the audit exists to catch and could not see while the field was unlabelled. The wrap measurement is docs/re/data/menu-bgm-loop-measured.txt and the start is docs/re/data/menu-bgm-loop-start.txt; the bank's two-stem structure is docs/re/structures/bgm-two-stems.md.",
|
||||
"loop_kind": "measured",
|
||||
"stems": "sum",
|
||||
"stems_why": "MEASURED, HANDOFF Q10: a bank is exactly TWO waves of identical duration (32/32 banks on the disc), sample-synchronous -- transient correlation peaks at lag 0.00 s over +/-5 s and both stop at the same millisecond. Concatenating them plays the piece twice, the second time as a bass-less stem; that was the previous reading and it is refuted. Emitting two files would be wrong for a second reason: MODDING rule 1 is one logical asset, one file, and handing a modder two stems to line up by hand is the reassembly the exporter exists to have already done. WHAT IS SUMMED IS SETTLED; WHAT WAVE 1 IS, IS NOT -- HANDOFF calls it quieter, far more L/R-decorrelated and almost bass-free, so it reads as a surround-rear pair OR a second intensity layer, and `ChannelMask` is 0x0002 on both so the file will not say. A unity sum is right under either reading; a weighting would only be justified once that is settled.",
|
||||
"stems_kind": "measured",
|
||||
"loop_start_s": 9.44,
|
||||
"loop_start_why": [
|
||||
"MEASURED 2026-08-30 -- 9.44 s. The loop region is [9.44 s, 71.31 s] of an",
|
||||
"87.744 s wave: the first 9.44 s is an intro played ONCE, and the last 16.4 s",
|
||||
"is a fade-out never played at all.",
|
||||
"",
|
||||
"Two derivations, both stems, and NEITHER converts bits to seconds -- the",
|
||||
"conversion that refuted itself earlier by giving two sample-synchronous stems",
|
||||
"62.34 and 63.29 s. (a) time to `read_offset` crossing `loop_start`, plus a",
|
||||
"1.33 s head correction at a LOCALLY measured rate; (b) first pass minus cycle.",
|
||||
"9.44 s on both stems either way.",
|
||||
"",
|
||||
"⚠️ ONE BOOT, ONE BANK. The decoder reads ahead of playback, but both endpoints",
|
||||
"are `read_offset` events so the lead cancels in the difference.",
|
||||
"",
|
||||
"🔴 THIS FIELD WAS 0.0 AND FLAGGED WRONG FOR ONE ITERATION, deliberately. The",
|
||||
"value was not guessable -- linear back-extrapolation said 9-13 s and linearity",
|
||||
"is refuted by a 4.4 % rate variation within one stream. What made the wait",
|
||||
"cheap was that the field EXISTED and the `-ss`/`-t` ordering had been proved",
|
||||
"with a stand-in value, so arriving at 9.44 was a one-value edit.",
|
||||
"",
|
||||
"📌 CITATION ADDED 2026-09-01 -- found the moment this field got a `kind`. It",
|
||||
"carried 1 041 characters describing two derivations and cited no file. The",
|
||||
"numbers are in docs/re/data/menu-bgm-loop-start.txt, and the loop region's",
|
||||
"wrap timing is in docs/re/data/menu-bgm-loop-measured.txt.",
|
||||
"",
|
||||
"⚠️ Second uncited MEASURED field in this one entry, after `loop_why`. Both",
|
||||
"described their evidence carefully in prose and pointed at nothing. A why that",
|
||||
"recounts a measurement reads as well-sourced precisely because it is detailed,",
|
||||
"which is why neither looked wrong.",
|
||||
"",
|
||||
"✅ AUDITED 2026-09-01 with the exclusion test: could either derivation have come",
|
||||
"out differently given the other? YES, and they discriminate different errors --",
|
||||
"(a) depends on a locally measured RATE and (b) on the CYCLE, so a wrong rate",
|
||||
"breaks (a) and leaves (b) standing, and a wrong cycle does the reverse. Two legs",
|
||||
"that fail independently, which is what 'two derivations' was claiming.",
|
||||
"",
|
||||
"⚠️ BOUND: they share one trace. A systematic error in the read_offset stream",
|
||||
"moves both, and the ONE BOOT, ONE BANK caveat above is that limit stated. What",
|
||||
"they exclude is arithmetic error, not trace error."
|
||||
],
|
||||
"loop_start_kind": "measured",
|
||||
"loop_end_s": 61.87,
|
||||
"loop_end_why": [
|
||||
"MEASURED off the running game 2026-08-30, 240 s parked on the menu",
|
||||
"(docs/re/structures/menu-bgm-loop-measured.md). The bed loops at 61.93 s, NOT",
|
||||
"at the summed wave's 87.744 s length, and the last ~25.8 s is never played --",
|
||||
"exactly the fade-out and trailing silence bgm-two-stems.md found. The game",
|
||||
"loops BEFORE the fade.",
|
||||
"",
|
||||
"🔴 THIS CORRECTS AN AUTHORED VALUE THAT WAS WRONG IN BOTH DIRECTIONS. `restart`",
|
||||
"at the wave's end produced a seam of about 3.4 SECONDS of near-silence, and",
|
||||
"this port measured that seam off its own Master bus and recorded it as the",
|
||||
"cost of a missing loop point. It was not the game's seam; it was OURS. Zero",
|
||||
"runs of >=0.3 s below median-18 dB appear in 232 s of the real menu.",
|
||||
"",
|
||||
"Two instruments agree: correlation gives a top lag of 61.909 s and r = -0.009",
|
||||
"at 87.750 s, and locating 30 s slices inside the decoded waves shows playback",
|
||||
"advancing exactly +5.00 s per 5 s and wrapping at 61.93 s, three times, with a",
|
||||
"control that finds slices cut at 10/45/70 s at 10.00/45.00/70.00.",
|
||||
"",
|
||||
"⚠️ The loop START is inferred, not measured: [0.0, 61.93) and [0.25, 62.18) are",
|
||||
"not separated at their resolution. The port takes 0 because a bank's own start",
|
||||
"is where its data begins, and records that the choice was not measured.",
|
||||
"",
|
||||
"⚠️ Godot loops a WHOLE FILE, so the export is TRIMMED to 61.93 s rather than",
|
||||
"carrying a loop point the runtime could not honour. The trimmed tail is",
|
||||
"content the game never reaches, so nothing playable is lost -- but a modder",
|
||||
"replacing this file is replacing the loop region, not the whole bank.",
|
||||
"",
|
||||
"🔴 CONFLICT, OPEN AS OF 2026-08-30. The loop IS a runtime field: `loop_start`",
|
||||
"and `loop_end` live in the XMA decoder context, set by `XMASetLoopData`, and",
|
||||
"the RE agent read 8734 records off the menu. Converted, they imply a cycle of",
|
||||
"roughly [10 s, 72 s] against the [0.25, 57.18] their audio tracking reported.",
|
||||
"BOTH CANNOT BE RIGHT and neither has been withdrawn.",
|
||||
"",
|
||||
"They judge the weak link probably theirs: the locator's control matched slices",
|
||||
"cut from the wave ITSELF -- exact copies -- which is an easier problem than",
|
||||
"matching a capture that differs by decoder, gain and mix. A control easier than",
|
||||
"the measurement does not bound the measurement's error, and music with repeated",
|
||||
"sections is where a locator aliases.",
|
||||
"",
|
||||
"⚠️ THE VALUE IS KEPT ON THEIR INSTRUCTION, and because the LENGTH survives",
|
||||
"better than the PLACEMENT: 61.93 has an autocorrelation behind it that used no",
|
||||
"wave at all, and the trimmed loop has no seam in this port's own output.",
|
||||
"",
|
||||
"This port added one check neither of their instruments ran: whether the trim",
|
||||
"JOINS SMOOTHLY. Over 126.5 s the wrap at 61.93 s and again at 123.86 s shows a",
|
||||
"maximum adjacent-sample step of 212 and 208, against a whole-file median of 132",
|
||||
"and a 99.9th percentile of 3737. So the join is not a click and nothing is",
|
||||
"audibly broken.",
|
||||
"",
|
||||
"⚠️ THAT DOES NOT DISCRIMINATE THE TWO READINGS. A smooth join says the waveform",
|
||||
"does not jump; it does not say the loop is at the musically right point, and a",
|
||||
"cut landing near a zero crossing is smooth wherever it falls.",
|
||||
"",
|
||||
"🔴 What the conflict would COST if their runtime fields win: under [10 s, 72 s]",
|
||||
"this export is about 10 SECONDS SHORT -- the content in [61.93, 72] is played",
|
||||
"by the game and absent here. That is the number to weigh when it resolves, and",
|
||||
"it is why this entry is not being treated as settled.",
|
||||
"",
|
||||
"✅ CONFIRMED 2026-08-30 BY A SECOND INSTRUMENT SHARING NOTHING WITH THE FIRST.",
|
||||
"The RE agent stopped converting the runtime fields and TIMED them instead --",
|
||||
"a probe tailing the Apu debug log and stamping `read_offset` on arrival --",
|
||||
"and watched THREE wraps, each from its own `loop_end` to its own `loop_start`,",
|
||||
"with both contexts wrapping at the SAME INSTANT every time. Cycle 61.56 and",
|
||||
"62.06 s, mean 61.81 s: 0.2 % from the 61.93 authored here, measured by wall",
|
||||
"clock between decoder events against an autocorrelation that never touched",
|
||||
"the wave. Both contexts wrapping together is the sample-synchrony the linear",
|
||||
"bit conversion could not produce.",
|
||||
"",
|
||||
"So the LENGTH is settled and the WINDOW is not. See `loop_start_why`.",
|
||||
"",
|
||||
"✅ 61.87 ADOPTED 2026-08-30, replacing 61.93. Their wrap timing gives 61.87 --",
|
||||
"wraps at 96.46 / 158.33 / 220.21 s, gaps 61.87 and 61.87 -- against the 61.93",
|
||||
"this port's autocorrelation gave. 0.1 % apart. The measured value is taken",
|
||||
"because it is the one with the loop's own endpoints under it; the",
|
||||
"autocorrelation never touched the wave and agreed to a tenth of a percent,",
|
||||
"which is what makes both worth having."
|
||||
],
|
||||
"loop_end_kind": "measured"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"_": [
|
||||
"🔴 KNOWN WRONG, HELD DELIBERATELY. Which of a voice region's streams to",
|
||||
"export. The premise this entry was built on has been REFUTED BY THE RUNNING",
|
||||
"GAME and the entry is kept, escalated, rather than swapped for another guess.",
|
||||
"",
|
||||
"The premise was: a region carries THREE PRESENTATIONS OF ONE TAKE, so the",
|
||||
"exporter picks one. The Decoder booted with `--xma_param_probe=true` -- the",
|
||||
"cvar that reports which sub-wave the game decodes -- and the game decodes",
|
||||
"ALL THREE, CONCURRENTLY, in three separate XMA contexts, with byte sizes",
|
||||
"matching the three disc payloads exactly (1294336 / 1118208 / 1171456",
|
||||
"against RIFF size - 60 of 1294396 / 1118268 / 1171516).",
|
||||
"",
|
||||
"SO THERE IS NO 'WHICH ONE' TO ANSWER. `presentation` below discards two of",
|
||||
"three streams the game plays. It is not a preference between rules any more;",
|
||||
"it is a known-incomplete export.",
|
||||
"",
|
||||
"WHY IT IS NOT CHANGED TODAY. Reverting to the 1/n sum is not obviously less",
|
||||
"wrong: an equal-gain sum of channel pairs is not a downmix -- MISSION",
|
||||
"section 6 makes exactly that point when it pins an explicit matrix for the",
|
||||
"movies' 5.1 fold rather than letting ffmpeg default -- and the 6.02 dB the",
|
||||
"sum cost S00A was a real defect. Swapping one guess for another on a message",
|
||||
"is what produced this entry twice already.",
|
||||
"",
|
||||
"🟡 HYPOTHESIS, NOT A RESULT, and it is the Decoder's: three concurrent stereo",
|
||||
"streams is six channels, and N stereo streams is how XMA carries",
|
||||
"multichannel on the 360, so 5.1 would explain the differing byte rates, the",
|
||||
"near-silent stream and why cues are 1-stream or 3-stream and never 2. AGAINST",
|
||||
"IT: all three declare ChannelMask = 0x0002 identically, which is odd for",
|
||||
"distinct channel roles. Do not build on it.",
|
||||
"",
|
||||
"WHAT SETTLES IT: a recording of the game's own output over the intro,",
|
||||
"through the PulseAudio null sink (AUDIO-VERIFICATION section 3). Candidate",
|
||||
"combinations of the three decoded streams can then be correlated against",
|
||||
"what the game actually played. Asked 2026-08-29.",
|
||||
"",
|
||||
"🔴 REFUTED FROM THE OUTPUT SIDE, 2026-08-30, not merely suspected.",
|
||||
"",
|
||||
"The RE agent recorded 148 s of the game's own output over the boot intro",
|
||||
"(ALSA tee, --gpu=null, 0.15 % silence -- cleaner than the recipe page's own",
|
||||
"reference run), with provenance from the XMA probe rather than a screenshot:",
|
||||
"`ADV`'s three contexts appear byte-exact, then the `BGM_102` pair.",
|
||||
"",
|
||||
"FIVE OF SIX CHANNELS CARRY DISTINCT CONTENT. No channel is a copy of another;",
|
||||
"the largest pairwise correlation is 0.70, between FL and FR, which is what a",
|
||||
"stereo pair looks like. BR is 82 % silent and 11 dB down.",
|
||||
"",
|
||||
"So `presentation: \"loudest\"` -- keeping ONE stream -- cannot be right. That",
|
||||
"was already labelled known-wrong here on the strength of the game decoding",
|
||||
"all three concurrently; it is now refuted by what the game PLAYS.",
|
||||
"",
|
||||
"⚠️ AND IT IS STILL NOT FIXED, DELIBERATELY, on the RE agent's own instruction.",
|
||||
"Three limits they state:",
|
||||
" * it does not make summing right -- the output is multichannel, which says",
|
||||
" nothing about which stream lands where;",
|
||||
" * '6 channels' is NOT evidence the game is 5.1 -- that count is Xenia's",
|
||||
" hardcoded kFrameChannelsDefault. The evidence is that five of them DIFFER,",
|
||||
" which a stereo guest cannot produce;",
|
||||
" * 🔴 the stream-to-channel mapping is NOT RUN. Cross-correlating each",
|
||||
" captured channel against each decoded `ADV` stream is the step that",
|
||||
" answers this, and it is their next iteration.",
|
||||
"",
|
||||
"Changing the mapping now would swap one authored guess for another, which is",
|
||||
"a worse position than a guess that is labelled. The value stays; the label is",
|
||||
"upgraded from suspicion to refutation."
|
||||
],
|
||||
"presentation": "all",
|
||||
"presentation_why": [
|
||||
"`loudest` = the full-length stream whose peak is nearest full scale.",
|
||||
"",
|
||||
"🔴 READ THE BLOCK ABOVE FIRST. This selects one of three streams the game",
|
||||
"decodes concurrently, so whatever it selects, two are missing. The",
|
||||
"paragraphs below are the history of how the value was arrived at, kept",
|
||||
"because the reasoning is what makes the error checkable -- NOT because the",
|
||||
"choice is defensible on its own terms any more.",
|
||||
"",
|
||||
"It was `highest_rate`, on a recommendation withdrawn as self-contradictory:",
|
||||
"'the highest-rate, highest-gain one is chunk 1' selects different streams --",
|
||||
"ADV stream 2 is 1118268 B at 0.0 dBFS, stream 3 is 1171516 B at -8.3.",
|
||||
"",
|
||||
"A structural argument for `loudest` was offered and withdrawn too: ADV",
|
||||
"stream 2 is mono-in-stereo and stream 3 is dual-mono, so the extra bytes",
|
||||
"looked like a duplicated channel rather than fidelity. The CHANNEL",
|
||||
"MEASUREMENT stands and now reads differently -- these are channel pairs, and",
|
||||
"0.60x with the residual 26.8 dB down is what a correlated pair at a lower",
|
||||
"level looks like. The GENERALISATION was refuted by census: the stream-3 /",
|
||||
"stream-2 size ratio over the 28 three-stream cues runs 0.0778 to 2.9163.",
|
||||
"",
|
||||
"⚠️ THE FAILURE MODE HERE IS THAT IT SOUNDS FINE. A single stream decodes to",
|
||||
"clean audible dialogue, so nothing in the output reveals that two streams",
|
||||
"are missing. That is why the manifest says it in words on every voice entry",
|
||||
"rather than leaving it to this file.",
|
||||
"",
|
||||
"📌 WHERE THE OPEN QUESTION LIVES, added 2026-09-01 under this port's own rule:",
|
||||
"an `authored` kind must cite the question it stands in for, or an invented",
|
||||
"value and a placeholder for a measurement read identically. This one stands in",
|
||||
"for the three-concurrent-streams problem, recorded in docs/port/BLOCKED.md and",
|
||||
"delivered in docs/port/HANDOFF.md -- the game decodes all three at once, so",
|
||||
"ANY single selection is missing two, and the export states that per movie",
|
||||
"rather than choosing quietly.",
|
||||
"",
|
||||
"⚠️ 1 402 characters of careful reasoning and nothing openable until now. It is",
|
||||
"the third uncited field in this file, and all three were detailed rather than",
|
||||
"sloppy -- the detail is what made them look sourced."
|
||||
],
|
||||
"presentation_kind": "authored",
|
||||
"stream_weights": {
|
||||
"_": [
|
||||
"Declared XMA `byte_size` -> the coefficient that stream's position takes in a",
|
||||
"stereo downmix. MEASURED by the RE agent 2026-08-30",
|
||||
"(docs/re/structures/intro-audio-decomposed.md): decomposing the game's own",
|
||||
"6-channel output as capture = 0.600 x movie + residual puts ctx0 at FL/FR,",
|
||||
"ctx1 at FC with LFE silent, and ctx2 at BL/BR.",
|
||||
"",
|
||||
"🔴 KEYED BY BYTE SIZE ON PURPOSE. The assignment is indexed by the decoder's",
|
||||
"own declared size, so the exporter can CHECK that the stream in front of it is",
|
||||
"the one the measurement describes rather than assume it. A region whose chunks",
|
||||
"do not match falls back to the count divisor and says so. That is not defensive",
|
||||
"programming: on 2026-08-30 this table's sizes did NOT fit the region the",
|
||||
"resolver returned, which is what exposed `resolve_movie_voice_region` starting",
|
||||
"238 packets late. Had the weights been applied positionally they would have",
|
||||
"been applied to the wrong streams silently.",
|
||||
"",
|
||||
"⚠️ ONE BOOT, ONE MOVIE. Only `ADV`'s three streams were measured. `S00A`'s",
|
||||
"sizes match nothing here and it keeps the divisor -- extending this by",
|
||||
"POSITION would be assuming the ordering generalises, which is exactly the",
|
||||
"inference the byte-size key exists to avoid.",
|
||||
"",
|
||||
"⚠️ The weights are a stereo downmix's, folded to mono. They sum to 1.0, so the",
|
||||
"total is the movie's own; what they distribute is the balance between three",
|
||||
"positions. Whether the game's 0.600 mixer gain is a constant or a volume",
|
||||
"setting is unknown and the port applies no gain of its own."
|
||||
],
|
||||
"1294336": {
|
||||
"position": "FL/FR",
|
||||
"weight": 0.4142
|
||||
},
|
||||
"1118208": {
|
||||
"position": "FC (LFE silent)",
|
||||
"weight": 0.2929
|
||||
},
|
||||
"1171456": {
|
||||
"position": "BL/BR",
|
||||
"weight": 0.2929
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,669 +1,55 @@
|
||||
{
|
||||
"format": "sylpheed.flow/1",
|
||||
"_": [
|
||||
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
|
||||
"negative -- the order is in none of the four places it could have been. It is",
|
||||
"not in config.ini's empty [SYSTEM], not in the movie manifest (which carries",
|
||||
"assets, not transitions), not in a persistent GamePart field (the requested id",
|
||||
"lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero",
|
||||
"xrefs of any kind. A transition is a call with a name argument, chosen by code.",
|
||||
"",
|
||||
"So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE",
|
||||
"agent watched the game do, not what any file on the disc says it does. Nothing",
|
||||
"here may be presented as decoded."
|
||||
],
|
||||
"boot": [
|
||||
{
|
||||
"screen": "publisher_logo",
|
||||
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both. 📌 SOURCE, added 2026-09-01: the boot's screen order and dwells are derived from GP_TITLE's own entries -- see docs/port/FORMAT.md for the export shape and docs/re/ui-title-build-map.md for which entry is which screen. The order here is not authored; it is what the archive declares."
|
||||
},
|
||||
{
|
||||
"screen": "developer_logos",
|
||||
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
|
||||
},
|
||||
{
|
||||
"video": "ADV",
|
||||
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
|
||||
"skippable": true,
|
||||
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline.",
|
||||
"skippable_kind": "measured"
|
||||
},
|
||||
{
|
||||
"screen": "title",
|
||||
"overlay": {
|
||||
"screen": "press_start",
|
||||
"clock": "shared",
|
||||
"why": "MEASURED, 2026-08-29, docs/re/title-plate-delay-measured.md on branch auto/no-disc-and-menu-captures at 5b0a6e6 (NOT on main when this was written). The boot title shows build 4 ALONE and the `PRESS (A) BUTTON` plate -- build 2 -- arrives later. This is the ONE case in the port where two builds are drawn at once.",
|
||||
"no_constant_why": "THERE IS NO AUTHORED DELAY HERE, AND THERE WAS ONE FOR ONE ITERATION. The first version of this block carried `after_settle_seconds: 2.13`, taken from the RE agent's instruction. The port refuted that instruction with arithmetic off the disc -- build 2 has a group of its own, and starting it at settle put the plate 3.97 s late -- and the corrected answer needs no constant at all: BOTH BUILDS RUN ON ONE CLOCK, STARTED TOGETHER, and the plate arrives at its own declared t=236 (CORRECTED 2026-09-01 from t=238, which is the last opaque frame rather than the arrival). `clock: \"shared\"` is that, spelled out rather than implied by the absence of a delay field. 📌 SOURCES, added 2026-09-01 in the uncited-why backfill: the plate's arrival is docs/re/title-plate-delay-measured.md and its pulse is docs/re/structures/plate-pulse-measured.md. 🔴 AND `clock: \"shared\"` IS AUTHORED FROM OUR OWN ARITHMETIC, NOT MEASURED. Nobody has watched whether build 2's group starts with build 4's; it is the reading that reconciles the oracle's 2.13 s IF the settle anchor is t=118. See docs/port/plate-arrival-halves.md and BLOCKED.md H3.",
|
||||
"arithmetic_why": "Why one clock reproduces the measurement, checked against this export rather than taken on trust: build 4's effect quads `pteff01`, `pteff02` and `ptlogoall_eff` end their ramps together at t=118; `ptbtn00` reaches alpha 255 at t=236; the difference is 118 units = 1.967 s at 60 units/s. The oracle measured 2.138 s and 2.132 s. The gap is presentation rate: the emulator presents at 28.1 fps against a nominal 30, and the corpus had independently measured the idle title at 28.5 fps before these runs. 🔴 CORRECTED 2026-09-01: this said `ptbtn00` reaches 255 at t=238 and that the difference is 120 units = 2.000 s. It reaches 255 at t=236 and HOLDS to 238, so 238 is the last opaque frame, not the arrival; 236 - 118 = 118. The port printed the contradiction in one sentence on every boot. The correction moves the reconciliation by 0.033 s and overturns nothing -- see docs/port/plate-arrival-halves.md. 🔴 AND THE ANCHOR IS NOW OPEN. The oracle defines \"title settled\" operationally, as its glyph counter first reading the no-plate value 154. This export offers TWO anchors 42 units apart: t=118 (the effect quads) and t=160 (`ptcopyright` at full alpha -- the LAST element to finish building in, and the only one made of glyphs). This line picked 118, while `ScreenView.settle_time()` returns 160 and the boot prints `settles at t=160`, so one binary holds both. Asked in BLOCKED.md H3; not guessed here. 📌 SOURCE: the pulse period and its phase behaviour are in docs/re/structures/plate-pulse-measured.md and docs/re/structures/plate-pulse-phase-lock.md, with the raw series in docs/re/data/plate-pulse-timeseries.txt. ✅ AUDITED 2026-09-01: the corpus's 28.5 fps is a genuinely independent leg -- a different quantity (idle-title presentation rate), measured BEFORE these runs, so it could have come out disagreeing. It agrees to 1.4 %.",
|
||||
"the_premise_that_failed_why": "The port's own, and it is worth keeping because it will bite again: `rest.t` IS NOT WHEN A SCREEN SETTLES. It is the last hold keyframe before the exit. Reading it as the settle put build 4's arrival at 4.35 s instead of 1.97 s, and every reconciliation computed from it came out wrong by exactly that error. `ScreenView.settle_time()` still uses rest.t -- see docs/port/BLOCKED.md. 🔴 THE EXAMPLE THIS CITED IS GONE, THOUGH THE CONCLUSION IS NOT. It read \"`ptlogo1` has rest.t=251 and stops MOVING at t=42\". In the CURRENT export `ptlogo1.rest.t` is 42 -- equal to when it stops moving. The record-layout fix repaired precisely that element, and the entry was never re-derived under it (REFUTED.md now carries this at 🟡 ⟨our-reader⟩). rest.t is still wrong for transients -- `ptlogo_back2eff1` is a two-frame flash whose rest.t=54 is the flash PEAK -- and for `pteff00`, whose rest.t=16 sits at the end of the fade-FROM-black while a fade-TO-black runs 261..269. Re-derived 2026-09-01: docs/port/plate-arrival-halves.md. 🔴 AND IT IS NOT THIS DEFECT'S CAUSE. The plate's ARRIVAL is a declared keyframe (transparent to t=214, opaque at t=236), not a rest pose; rest.t=236 only chooses where `holding` parks it, and 236 is that ramp's own peak. Confirmed on a filmed boot with rest.t untouched: the onset is bracketed within one frame of 214.",
|
||||
"scope_why": "Attached to the BOOT STEP, not to the `title` screen, and that is deliberate. What was measured is the boot title. Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured, and putting the overlay on the screen would quietly claim it is. 📌 SOURCE, added 2026-09-01: the plate belongs to the boot's overlay step rather than to the title screen because its arrival is measured against the boot clock -- docs/re/title-plate-delay-measured.md. 🔴 STALE CLAUSE, CORRECTED 2026-09-01: this said \"Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured\". It IS measured now, and has been since 2026-08-30: after (B) from the menu the plate is re-drawn, pressed at 351.2 s with its pulse back at 358.5 s (the Decoder, nav-autorepeat-and-settled-b data). The port re-arms the overlay on arrival at the title by any path, and that is correct. What stayed true is the structural half -- the declaration lives on the boot STEP and is looked up from there, so a screen that gains an overlay gets it on both paths at once. ⚠️ What is STILL not measured is whether the returned plate FADES or appears at once; the 7.3 s between press and pulse is consistent with a transition plus the declared 214->236 fade, but that is consistency, not a measurement of the ramp on this path.",
|
||||
"no_pulse_why": "The port draws the plate arriving and then holding. It does not pulse it. The RE agent identifies the pulse as the plate's FOCUS RECORD `ptbtn00f` -- a glow ramping 0x00 to 0x50 and back, t=6..105 -- not as a loop of `ptbtn00`'s own group, which was the port's earlier reading and was wrong. Looping that record is a candidate the port has NOT taken: its group is 105 timed units plus an AUTHORED 24-unit exit ramp, and hitting the measured 2.24 s mean requires composing that authored constant with a loop assumption, which is tuning rather than measuring. Filed in BLOCKED.md. 📌 SOURCE, added 2026-09-01: docs/re/structures/plate-pulse-measured.md, and the phase-lock caveat that bounds what a gated capture can show is docs/re/structures/plate-pulse-phase-lock.md."
|
||||
},
|
||||
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. This is the LAST step, and a last step is where the sequence stops rather than fading out -- a boot that ends by fading to black looks like a boot that crashed. P5 gave the title somewhere to go, but that is a HANDOVER and not another boot step: `--boot` still stops here, and `--boot --play` hands the same held title to the menu flow, where (A) opens TITLE_MENU. Kept as a stop rather than folded into `screens` because what the boot does is authored from a measured sequence, and what (A) does is a separate measurement."
|
||||
}
|
||||
],
|
||||
"dwell": {
|
||||
"format": "sylpheed.flow/1",
|
||||
"_": [
|
||||
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
|
||||
"",
|
||||
"This key has now been wrong in two opposite directions, and the second was",
|
||||
"mine, so both are recorded.",
|
||||
"",
|
||||
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
|
||||
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
|
||||
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
|
||||
"🔴 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
|
||||
"exit is caused by something outside its timeline, so it holds. A splash's exit",
|
||||
"is caused by nothing, so it plays its declared timeline and leaves. The title",
|
||||
"is the exception, not the rule, and one screen was never enough to overturn",
|
||||
"the other two.",
|
||||
"",
|
||||
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
|
||||
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
|
||||
"",
|
||||
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
|
||||
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
|
||||
"",
|
||||
"The developer agrees to 1.1 %, two of its three runs to 0.3 %. The port emits",
|
||||
"4.400 s and 3.650 s -- each declared value plus the 9-unit black hold, exactly.",
|
||||
"So the pacing was right all along and nothing changes in the code.",
|
||||
"",
|
||||
"🔴 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
|
||||
"Decoder's own container came out 15-20 % LONGER than both the declared values",
|
||||
"and the corpus -- same disc, same timeline -- and three independent readings",
|
||||
"of that container's rate disagree with each other. A seconds figure records",
|
||||
"one emulator's pacing on one run. The units are on the disc. If anything ever",
|
||||
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
|
||||
"measured to wait beyond its group."
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"_": [
|
||||
"MEASURED off the running game, HANDOFF Q5 -- none of it is on the disc.",
|
||||
"It lives here rather than in GDScript so that a reader can see it is a",
|
||||
"measurement and delete it the day a field on the disc states it."
|
||||
"The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a",
|
||||
"negative -- the order is in none of the four places it could have been. It is",
|
||||
"not in config.ini's empty [SYSTEM], not in the movie manifest (which carries",
|
||||
"assets, not transitions), not in a persistent GamePart field (the requested id",
|
||||
"lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero",
|
||||
"xrefs of any kind. A transition is a call with a name argument, chosen by code.",
|
||||
"",
|
||||
"So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE",
|
||||
"agent watched the game do, not what any file on the disc says it does. Nothing",
|
||||
"here may be presented as decoded."
|
||||
],
|
||||
"wrap": true,
|
||||
"wrap_why": "HANDOFF Q5: up/down move one item and WRAP at both ends. Measured on the 5-item main menu AND the 3-item EXTRAS, so it is a menu rule and not a per-screen one (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26).",
|
||||
"wrap_kind": "measured",
|
||||
"left_right": "nothing",
|
||||
"left_right_why": "HANDOFF Q5: left/right do nothing. Measured. Implemented as an explicit no-op rather than by omission, so that 'we never wired it' and 'the game ignores it' are distinguishable in the code.",
|
||||
"left_right_kind": "measured",
|
||||
"input_during_transition": "ignored",
|
||||
"input_during_transition_why": "AUTHORED, and NOT measured -- nobody has watched what the game does with a button pressed mid-fade. Ignoring is the choice that invents the least: it cannot queue a press the game might have dropped. Ask the RE agent before relying on it. 📌 WHERE THE ASK LIVES, added 2026-09-01: docs/port/BLOCKED.md carries it, and until now this why said \"ask the RE agent\" without naming where the question is recorded -- a pointer with no destination. An `authored` kind still needs a citation, because the thing to cite is the OPEN QUESTION the choice stands in for; without it, an invented value and a placeholder for a measurement read the same.",
|
||||
"input_during_transition_kind": "authored",
|
||||
"auto_repeat": false,
|
||||
"auto_repeat_why": "MEASURED 2026-08-30, Decoder daf8f47: a 2.0 s held (down) moves the cursor EXACTLY ONCE. Their counter passes its own control first -- a single 0.12 s tap gives exactly 1 spike, the hold gives 1, move spike 0.0202-0.0220 against a 0.0003-0.0038 floor. The port's edge-triggered _input already behaved this way; what changed is that it is now a MEASUREMENT rather than an unexamined consequence of how the handler was written. HANDOFF Q5's 'up / down' row is split at the source: one-item-per-press (evidenced by the 4-press wrap count) from no-auto-repeat (which had nothing until this run).",
|
||||
"auto_repeat_kind": "measured"
|
||||
},
|
||||
"screens": {
|
||||
"_": [
|
||||
"What each button does. The NAVIGATION ORDER is not here -- it is derived,",
|
||||
"in each screen file's `buttons` (button-role elements sorted by resting Y).",
|
||||
"Only the destinations, the initial focus and the cancel target are",
|
||||
"authored, because only those are measurements or decisions.",
|
||||
"",
|
||||
"`goto` is an EXPORTED SCREEN NAME or null. `goto_name` is the game's own",
|
||||
"screen vocabulary from the decoded transition lookup -- carried so the",
|
||||
"binding is not lost, and marked below as the NAME MATCH it is, never as a",
|
||||
"measurement (HANDOFF: the strings are what the call sites reference, not",
|
||||
"proven arguments, and the same list mixes in TEXT_FONT and GAMMA_RGB).",
|
||||
"",
|
||||
"`goto: null` with a `blocked` note means the destination screen is real and",
|
||||
"measured but is NOT IN THIS EXPORT -- it lives in another archive. That is a",
|
||||
"milestone boundary, not an unknown."
|
||||
"boot": [
|
||||
{
|
||||
"screen": "publisher_logo",
|
||||
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both."
|
||||
},
|
||||
{
|
||||
"screen": "developer_logos",
|
||||
"why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2."
|
||||
},
|
||||
{
|
||||
"video": "ADV",
|
||||
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
|
||||
"skippable": true,
|
||||
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline."
|
||||
},
|
||||
{
|
||||
"screen": "title",
|
||||
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go."
|
||||
}
|
||||
],
|
||||
"title": {
|
||||
"on_accept": {
|
||||
"goto": "main_menu",
|
||||
"goto_name": "TITLE_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"why": "MEASURED, HANDOFF: (A) on the title opens the main menu, with (A) on the boot title as the control in the same run."
|
||||
},
|
||||
"on_cancel": null,
|
||||
"on_cancel_why": "MEASURED 2026-08-30, Decoder daf8f47, docs/re/data/nav-autorepeat-and-settled-b.txt: twenty seconds after a delivery-confirmed B the screen is still the title with PRESS (A) BUTTON up. The run waited for the PLATE PULSE -- the title's own settled signature -- before pressing, which is exactly what the earlier confounded attempt did not. This cell briefly said 'MEASURED, HANDOFF Q5' on no evidence, then said AUTHORED once that was caught; it is now measured for real. Value unchanged throughout: null.",
|
||||
"on_cancel_kind": "measured"
|
||||
"dwell": {
|
||||
"_": [
|
||||
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
|
||||
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer",
|
||||
"logos at t=190 (3.17 s), both read from the disc. Holding beyond that would",
|
||||
"be a number nobody has measured, so the sequencer holds for zero extra time",
|
||||
"and the pacing is the disc's own.",
|
||||
"",
|
||||
"When a capture times the real boot, the extra hold per screen goes here."
|
||||
]
|
||||
},
|
||||
"main_menu": {
|
||||
"initial_focus": "ptbtn01",
|
||||
"initial_focus_kind": "measured",
|
||||
"focus_persists": true,
|
||||
"focus_persists_kind": "measured",
|
||||
"focus_persists_why": [
|
||||
"MEASURED 2026-08-30, Decoder: the main menu REMEMBERS ITS CURSOR across a",
|
||||
"round trip through the title. (B) out and (A) back returns to the item you",
|
||||
"left, not to a default. Their control passed first -- two delivery-confirmed",
|
||||
"DOWNs moved the cursor exactly two items before the round trip, so the",
|
||||
"cursor demonstrably was not where it started.",
|
||||
"",
|
||||
"The port reset to `initial_focus` on every entry, so this was a real defect",
|
||||
"and not a refinement: a player who moved to EXTRAS, pressed (B), then (A),",
|
||||
"landed back on NEW GAME.",
|
||||
"",
|
||||
"🔴 SCOPED TO THIS SCREEN ON PURPOSE, and the scope is the authored part.",
|
||||
"The measurement is of the MAIN MENU. Making it a menu-wide rule would be",
|
||||
"n=1 wearing a rule's clothes -- and here it would actively contradict a",
|
||||
"measurement, because `extras` opens on MISSION SELECT as a MEASURED initial",
|
||||
"focus, and a remembered cursor would override it on re-entry. `wrap` is a",
|
||||
"menu rule because it was measured on two screens; this was measured on one.",
|
||||
"",
|
||||
"⚠️ WHAT IS NOT KNOWN: whether the memory survives a return to the BOOT",
|
||||
"(as opposed to the title), and whether any other screen has it. Ask before",
|
||||
"widening this.",
|
||||
"",
|
||||
"🔴 CORRECTED 2026-08-30, SAME DAY, by the Decoder: the paragraph above argued",
|
||||
"the scope from `extras` having a MEASURED initial focus that a remembered",
|
||||
"cursor would override. That is a good reason to be CAUTIOUS and NOT a finding",
|
||||
"that `extras` resets. Nothing has measured what a submenu's own cursor does on",
|
||||
"re-entry: the corpus has EXTRAS' opening item from ONE entry, and (B) restoring",
|
||||
"the PARENT's focus 4/4, and neither answers it.",
|
||||
"",
|
||||
"So `focus_persists: false` everywhere else is THE PORT'S DEFAULT, not the",
|
||||
"game's behaviour. It invents the least and it preserves the one measurement",
|
||||
"there is. `tools/port/contract-check` asserts only the main-menu half against",
|
||||
"the contract and reports the scope as a GUARD, because for one iteration it",
|
||||
"asserted non-persistence as though it had been measured -- which would have",
|
||||
"held the port to the wrong behaviour and passed while doing it.",
|
||||
"",
|
||||
"❔ The Decoder is measuring EXTRAS re-entry now. Do not build on the",
|
||||
"non-persistence half until it returns.",
|
||||
"",
|
||||
"📌 SOURCE, added 2026-09-01 in the uncited-why backfill: docs/re/data/focus-persists-across-title.txt carries the round trip, and docs/re/data/extras-focus-resets.txt carries the contrasting submenu result that keeps this scoped to one screen."
|
||||
],
|
||||
"initial_focus_why": [
|
||||
"MEASURED 2026-08-30 (later) -- `NEW GAME` on a fresh boot, 2/2 fresh boots,",
|
||||
"both the FIRST menu entry. Decoder, HANDOFF `bf9e07f`, section \"correcting",
|
||||
"today's focus delivery\"; ring row y=225.5 against a measured 79.25 px step,",
|
||||
"data in docs/re/data/menu-focus-reader-offset.txt.",
|
||||
"",
|
||||
"🔴 THIS FIELD WAS `authored` UNTIL NOW AND THE UPGRADE IS NOT BECAUSE IT",
|
||||
"AGREES WITH ME. The value did not change; its standing did. The confirmation",
|
||||
"is a direct reading of a fresh boot's first menu entry, independent of the",
|
||||
"reasoning that chose NEW GAME here -- and the Decoder had said explicitly that",
|
||||
"my agreeing with their records was no evidence, which was correct at the time.",
|
||||
"",
|
||||
"✅ AND IT SURVIVES A REBOOT -- MEASURED 2026-08-31. Six fresh boots all",
|
||||
"opened on NEW GAME, and THREE of them followed a session that ended with the",
|
||||
"cursor on EXTRAS or OPTIONS. That is what makes it a test of persistence",
|
||||
"rather than six repetitions of the same start.",
|
||||
"",
|
||||
"⚠️ REACH, and it is the Decoder's own caveat rather than mine: every one of",
|
||||
"those sessions ended with the emulator KILLED, not shut down cleanly. A game",
|
||||
"that writes menu state on a clean exit never gets the chance, so this",
|
||||
"measures 'does not survive a KILLED session'. If a real console remembers a",
|
||||
"cursor across a power cycle, that does not contradict this.",
|
||||
"",
|
||||
"⚠️ WHY 'FIRST ENTRY' IS LOAD-BEARING: the menu REMEMBERS ITS CURSOR (see",
|
||||
"`focus_persists`), so any reading not taken on a fresh boot's first entry is",
|
||||
"measuring HISTORY, not what the screen opens on. That objection is what",
|
||||
"invalidated the earlier TUTORIAL/NEW GAME disagreement, and this measurement",
|
||||
"is the one that is immune to it.",
|
||||
"",
|
||||
"The superseded reasoning is kept below, because it is what made the wait cheap:",
|
||||
"the field existed and was labelled honestly, so arriving at a measurement was a",
|
||||
"label change and not an archaeology problem.",
|
||||
"",
|
||||
" (was) AUTHORED, standing in for HANDOFF Q5, which measured that initial focus is NOT STABLE: four boots of the same harness opened on TUTORIAL, TUTORIAL, NEW GAME, NEW GAME. A port has to open on something. ptbtn01 (NEW GAME) is picked because it is one of the two states actually observed and it is the top item, so a reader can predict it. It is a CHOICE. Delete this the day the RE agent finds what selects it. CORROBORATED 2026-08-29, and still not decoded: the committed capture live-main-menu.png has NEW GAME focused. Identified by rendering all five focus states and taking the minimum difference -- 531 differing pixels against 6080-7094 for the others, an 11.5x margin -- with the method controlled on live-main-menu-options-focused.png, whose answer is in its filename and which it picks by 4.7x. That means the port's choice matches the state of one committed frame. It does NOT make focus stable: Q5's four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME, and this identifies one frame rather than a rule. Delete this entry the day something says what SELECTS it. TIGHTENED 2026-08-29: Q5 now has SIX boots, and the shape is sharper than 'unstable' -- TUTORIAL x3, NEW GAME x3, and NO OTHER ITEM EVER OBSERVED. So it is not uniform over five buttons; whatever selects it has to explain a two-way split. That does not change this choice (NEW GAME remains one of exactly two observed states, and it is the state of the committed capture) but it does change what would REFUTE it: a boot opening on LOAD GAME, OPTIONS or EXTRAS would break the two-way shape, and a rule that predicts the split would delete this entry outright.",
|
||||
" (was) ",
|
||||
" (was) ✅ CONSISTENT WITH THE ONE CAPTURE, measured 2026-08-30. Rendering each of the",
|
||||
" (was) five buttons focused against `live-main-menu.png` gives 0.0705 % for ptbtn01",
|
||||
" (was) and 0.72-0.84 % for the other four -- a 10x discrimination. So that capture",
|
||||
" (was) shows NEW GAME focused, and the authored choice matches it.",
|
||||
" (was) ",
|
||||
" (was) ⚠️ THIS DOES NOT OVERTURN Q5. Q5 measured initial focus as UNSTABLE across",
|
||||
" (was) four boots; one capture showing ptbtn01 is consistent with that and does not",
|
||||
" (was) contradict it. What the measurement establishes is narrower and still worth",
|
||||
" (was) having: the port's focus rendering is distinctive enough that a capture",
|
||||
" (was) identifies which button is focused, and this authored value is not at odds",
|
||||
" (was) with the only frame we can check it against. It stays AUTHORED."
|
||||
],
|
||||
"on_cancel": {
|
||||
"goto": "title",
|
||||
"goto_name": "TITLE_SCREEN",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_SCREEN` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"kind": "measured",
|
||||
"why": "MEASURED 2026-08-30, delivery-confirmed (B = 0x5801), 73.5 % of pixels changed, and both captures name themselves. Latency <= 0.4 s and NO loading screen in between, which matters because the disc carries four pgloading_* screens. This entry previously read 'likely but UNPROVEN': it had been seen once without a capture, and the title ALSO returns on its own after ~8-10 s idle, so an observer could not tell a response from a timeout. The <= 0.4 s latency is what kills that confound -- it is twenty times faster than the idle return. Decoder 86a8ce7, menu-navigation-semantics.md row 'B on the main menu', docs/re/data/b-on-main-menu.txt."
|
||||
},
|
||||
"buttons": {
|
||||
"ptbtn01": {
|
||||
"label": "NEW GAME",
|
||||
"goto": null,
|
||||
"goto_name": "DLG_SELECT_DIFFICULTY",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"✅ CORRECTED 2026-08-31: this read `DIFFICULTY`, and the destination is a",
|
||||
"DIALOG rather than a GamePart -- `DLG_SELECT_DIFFICULTY`, `GP_DIALOG.pak`",
|
||||
"entries 2/3 [see the withdrawal below]. Decoder, TWO arguments [corrected below]; the geometry one is",
|
||||
"re-derived here with this port's own reader: entries 2 and 3 are the ONLY",
|
||||
"builds in that archive carrying `pcbtn00`-`pcbtn03`, at design rows",
|
||||
"259/329/399/469, spacing exactly 70. See",
|
||||
"`crates/sylpheed-export/examples/dialog_rows.rs`.",
|
||||
"",
|
||||
"🔴 SO THE FOUR EXTERNAL DESTINATIONS ARE NOT UNIFORM: three open GameParts",
|
||||
"and this one opens a dialog. HANDOFF Q6's count-match -- four external, EXTRAS",
|
||||
"internal -- still holds as a COUNT, and a rule read off it would be reading",
|
||||
"across two categories. The Decoder sent that count with disc support",
|
||||
"yesterday and weakened it themselves today; recorded at the weaker strength.",
|
||||
"",
|
||||
"✅ THE REACH IS NOW BOUNDED -- 2026-08-31, and both agents scanned for it.",
|
||||
"",
|
||||
"It read: \"another four-button dialog with the same rows would be",
|
||||
"indistinguishable by this evidence\". The Decoder searched every build in",
|
||||
"every pak for four buttons within 6 px of those rows and found ZERO rivals.",
|
||||
"Re-run here with this port's reader and a BROADER filter -- any element",
|
||||
"whose name contains `btn`, not only `pcbtn`, so a rival under a different",
|
||||
"naming convention would still be caught: 2 859 builds across 33 paks,",
|
||||
"EXACTLY 2 matches, entries 2 and 3. The run carries its own known positive:",
|
||||
"fewer than 2 would mean the reader cannot see the incumbents and its zero",
|
||||
"would mean nothing.",
|
||||
"",
|
||||
"✅ And the name is now backed by a TABLE ENTRY rather than an inference",
|
||||
"from a string list: every `DLG_` name in the image sits in a 12-byte record",
|
||||
"(id, name pointer, handler [corrected]) spanning 0x820A0A2C-0x820A0D68 -- 70 names,",
|
||||
"70 records, none unmatched. `DLG_SELECT_DIFFICULTY` is **id 2000**.",
|
||||
"",
|
||||
"🔴 \"THREE INDEPENDENT ROUTES\" CORRECTED TO TWO -- 2026-08-31, by the Decoder,",
|
||||
"and I had relayed the count unchecked for the second time from one delivery.",
|
||||
"",
|
||||
"The image leg says DIFFICULTY is a dialog and names no entry, so alone it",
|
||||
"identifies nothing. The disc and oracle legs are ONE COMPOUND ARGUMENT: the",
|
||||
"capture is compared against the disc's rows. What makes that discriminating is",
|
||||
"the EXCLUSION SCAN -- zero rivals within 6 px anywhere on the disc -- and that",
|
||||
"is what the word \"three\" was taking credit for. The conclusion is unchanged;",
|
||||
"the evidence is two arguments, one of them compound, and was never three.",
|
||||
"",
|
||||
"📌 The test that falls out of it, theirs: ask of an n-routes claim not whether",
|
||||
"the routes are correct but whether ANY COULD HAVE COME OUT DIFFERENTLY GIVEN",
|
||||
"THE OTHERS. That is an exclusion argument, and it is usually absent.",
|
||||
"",
|
||||
"🔴 WITHDRAWN 2026-08-31 -- \"AN EN/JP PAIR\", AND I RELAYED IT.",
|
||||
"",
|
||||
"The Decoder stated entries 2/3 as a language pair in the same HANDOFF row that",
|
||||
"identifies DIFFICULTY, as a fact, and has withdrawn it: nothing established the",
|
||||
"pairing. I copied it into this `why` -- twice -- in the SAME SENTENCE where I",
|
||||
"was careful to say my re-derivation confirms the geometry and does not name the",
|
||||
"screen. The unchecked half rode along inside the clause I had checked.",
|
||||
"",
|
||||
"What the scan actually shows is that adjacent GP_DIALOG entries are UNRELATED",
|
||||
"DIALOGS: 26 of 65 adjacent pairs differ in BUTTON COUNT, which no language pair",
|
||||
"can. Identical element sets is the language signature in GP_TITLE; here it is",
|
||||
"equally consistent with a duplicate. So `2/3` are two builds with the same four",
|
||||
"buttons at the same rows, and calling them EN and JP is an assumption.",
|
||||
"",
|
||||
"⚠️ THE IDENTIFICATION DOES NOT REST ON IT -- unique four-button geometry with",
|
||||
"zero rivals disc-wide, plus the oracle capture. The pairing was decoration on a",
|
||||
"conclusion that stands without it, which is exactly why it travelled unchecked.",
|
||||
"",
|
||||
"✅ RESTORED 2026-08-31, ON A MEASUREMENT RATHER THAN A RELAY. The Decoder took",
|
||||
"the `ja` capture of DIFFICULTY that was missing and 2/3 ARE English/Japanese:",
|
||||
"EN vs JP differ in 1.82 % of pixels in FOUR BANDS AND NOWHERE ELSE -- the",
|
||||
"heading (DIFFICULTY -> the JP heading), the ring by 2 px, the BACK label, and",
|
||||
"the footer. EASY/NORMAL/HARD are NOT in the differing set: the Japanese release",
|
||||
"leaves the three difficulty names in Latin script, which is why the disc figure",
|
||||
"is only 2.77 % of bytes against 1.82 % of pixels.",
|
||||
"",
|
||||
"📌 MY OBJECTION WAS NOT WRONG AND IS NOT WITHDRAWN. It was that IDENTICAL",
|
||||
"ELEMENT SETS DO NOT IMPLY A LANGUAGE PAIR -- 26 of 65 adjacent pairs differ in",
|
||||
"button count, so adjacency proves nothing. That argument still holds; what has",
|
||||
"changed is that the conclusion now rests on a direct locale capture instead of",
|
||||
"on that inference. A bad argument for a true claim is still a bad argument, and",
|
||||
"the claim was correctly out of this file until somebody went and looked.",
|
||||
"",
|
||||
"⚠️ REACH, THEIRS: one JP boot, one screen, does not generalise. GP_TITLE 4/7 is",
|
||||
"known to differ by MORE than text -- entry 7 carries nine sprites entry 4 lacks.",
|
||||
"Nothing in the port keys off locale today; this is recorded, not consumed.",
|
||||
"",
|
||||
"🔴 RECORD LAYOUT CORRECTED 2026-09-01, and I had copied the wrong one. I wrote",
|
||||
"\"(handler, id, name pointer)\"; it is {id, name_ptr, handler} -- the same three",
|
||||
"fields shifted one word, so every record was being credited with the PREVIOUS",
|
||||
"record's handler. The Decoder caught it with a control dump: under the old",
|
||||
"alignment record 0 had a handler of 0x10000000, which is not a code address.",
|
||||
"ids and names are unaffected and DLG_SELECT_DIFFICULTY is still 2000, so",
|
||||
"nothing here moves except the sentence.",
|
||||
"",
|
||||
"📌 FOURTH aside of theirs relayed into this file. The first three were an EN/JP",
|
||||
"pairing, a leg count and an independence claim -- all decorative. This one is a",
|
||||
"STRUCTURE, which is worse: a wrong field order is the kind of thing a later",
|
||||
"reader builds on, and it carried no weight here only by luck.",
|
||||
"",
|
||||
"❔ AND THE JOIN IS NOT REACHABLE BY THAT ROUTE -- their negative, with their",
|
||||
"reach. All three handlers load the same global at 0x828E2B14 and take addresses",
|
||||
"at 0x828E45E0/4640/467C, every one inside a 364 601-byte contiguous zero run:",
|
||||
"BSS, populated only at runtime. Controlled, because an all-zero read is also",
|
||||
"what a wrong address gives, and the dialog table itself reads non-zero through",
|
||||
"the same arithmetic.",
|
||||
"",
|
||||
"⚠️ That closes the DIALOG HANDLERS, not the image. The archive loader and any",
|
||||
"id-keyed table elsewhere are unexamined, so \"not in the image\" is NOT",
|
||||
"established. Recorded as a route rather than an answer, which is how they sent",
|
||||
"it.",
|
||||
"",
|
||||
"❔ STILL UNBOUND, and it is what would make this airtight: nothing connects",
|
||||
"id 2000 to a pak entry. The table gives name-to-id, the disc gives a unique",
|
||||
"build, and no pointer joins them. The tie is UNIQUENESS PLUS THE ORACLE",
|
||||
"CAPTURE, not a binding -- so if a rival build ever appeared, this",
|
||||
"identification would go with it.",
|
||||
"",
|
||||
"button count and geometry, NOT by a binding from the `DLG_` name to a pak",
|
||||
"entry. No such binding was found. Another four-button dialog with the same",
|
||||
"rows would be indistinguishable by this evidence -- my re-derivation",
|
||||
"confirms the geometry and does not name the screen.",
|
||||
"",
|
||||
" (was) NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
" (was) screens are measured; the ids are a name match onto the executable's class",
|
||||
" (was) names.\" So `DIFFICULTY` is a string that exists in the executable and plausibly",
|
||||
" (was) denotes this screen -- nothing observed binds it to this transition.",
|
||||
" (was) ",
|
||||
" (was) It is carried so a reader can search for it and so the port never has to",
|
||||
" (was) invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
" (was) a screen file, and this field is documentation.",
|
||||
" (was) ",
|
||||
" (was) 🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
" (was) labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
" (was) claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
" (was) as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
" (was) argument reads as evidenced and is not."
|
||||
],
|
||||
"blocked": "DIFFICULTY is not in this export. MEASURED destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA) but it is not a GP_TITLE build, so there is no screen file to go to yet.",
|
||||
"skipped_chain": [
|
||||
"DIFFICULTY",
|
||||
"SELECT DATA"
|
||||
],
|
||||
"skipped_chain_why": "THE PORT SKIPS TWO MEASURED SCREENS HERE, AND IT SAYS SO OUT LOUD RATHER THAN PRETENDING. The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot -> ~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations (HANDOFF Q4) but neither is a GP_TITLE build, so there is no screen file to go to. The port jumps from NEW GAME to the one thing in that chain it has, and the runtime prints what it skipped on every run. This is a GAP, not a sequence: nobody may read the port's behaviour here as what the game does.",
|
||||
"skipped_chain_kind": "measured",
|
||||
"then_video": "S00A",
|
||||
"then_video_why": "P7. HANDOFF Q9, DECODED from the movie manifest: MS00A -> S00A.wmv is the new-game intro, 93.9 s. Its POSITION is measured as well -- the movie starts ~4.5 s after (A) on the save slot, matched off the running game at 0.96-1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples.",
|
||||
"then_video_kind": "decoded",
|
||||
"unobserved_why": "WHAT FILLS THE ~4.5 s between the save slot and the movie is NOT KNOWN. The oracle run that would have shown it hit the already-documented sub_823070B0 cache crash after SELECT DATA. GP_TITLE does carry a LOADING screen -- entries 0/1 and 12/15, whose elements are every one of them named pgloading_* -- and LOADING is in the game's own screen vocabulary, but nobody has watched it appear here and the port does NOT put it in the chain on that basis. 📌 WHERE THE OPEN QUESTION LIVES, added 2026-09-01: docs/port/BLOCKED.md carries the row -- 'what fills the 4.5 s before S00A'. An explicit unknown still needs a citation, or it cannot be distinguished from an unexamined one.",
|
||||
"skippable": true,
|
||||
"skippable_why": "HANDOFF Q9, MEASURED: one (A) press skips a movie -- the title was reached at 57 s against a 193 s baseline. Same rule the boot intro already uses.",
|
||||
"skippable_kind": "measured",
|
||||
"after_video": {
|
||||
"goto": "title",
|
||||
"kind": "authored",
|
||||
"why": "AUTHORED, and it has to be: the game goes into MISSION 1, and gameplay is out of scope (PORT-MISSION section 7). P7's gate asks for 'plays, then returns to a defined state' -- this is that state. The title is chosen over the main menu because the boot's own end state is the title, so a run that finishes the new-game intro lands somewhere a player can start again from. Nothing measured says the game does this."
|
||||
}
|
||||
},
|
||||
"ptbtn02": {
|
||||
"label": "LOAD GAME",
|
||||
"goto": null,
|
||||
"goto_name": null,
|
||||
"blocked": "The save-slot list is GP_SAVE_LOAD, not in this export. Destination MEASURED."
|
||||
},
|
||||
"ptbtn03": {
|
||||
"label": "TUTORIAL",
|
||||
"goto": null,
|
||||
"goto_name": "TUTORIAL_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TUTORIAL_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"blocked": "The lesson list is not a GP_TITLE build. Destination MEASURED."
|
||||
},
|
||||
"ptbtn04": {
|
||||
"label": "OPTIONS",
|
||||
"goto": null,
|
||||
"goto_name": null,
|
||||
"blocked": "The settings menu is GP_OPTIONS, not in this export. Destination MEASURED."
|
||||
},
|
||||
"ptbtn05": {
|
||||
"label": "EXTRAS",
|
||||
"goto": "extras",
|
||||
"goto_name": "EXTRA_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `EXTRA_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"why": "MEASURED, HANDOFF Q4: EXTRAS opens GP_TITLE build 6. It is the ONLY main-menu destination inside this archive, and therefore the only (A)-into-a-submenu the P5 gate can actually walk."
|
||||
}
|
||||
},
|
||||
"labels_why": "The five labels are read off live-main-menu.png, a capture of the running game (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26). They are carried for logs and for a human reading this file; nothing draws them -- the button sprite already has its own text."
|
||||
},
|
||||
"extras": {
|
||||
"initial_focus": "ptbtn11",
|
||||
"initial_focus_kind": "measured",
|
||||
"focus_persists": false,
|
||||
"focus_persists_kind": "measured",
|
||||
"focus_persists_why": [
|
||||
"MEASURED 2026-08-30 -- EXTRAS RESETS. HANDOFF `4ed75e6`: ring back to",
|
||||
"y=347.5 on re-entry after a confirmed DOWN, frame 0.0 % different from the",
|
||||
"first entry, and the screen confirmed by eye as EXTRAS because an earlier",
|
||||
"run was fooled about which screen it was on.",
|
||||
"",
|
||||
"📌 WRITTEN EXPLICITLY, THOUGH THE PORT'S DEFAULT IS ALREADY false. The",
|
||||
"absent key and the measured false behave identically and mean completely",
|
||||
"different things: one is 'nobody looked', the other is 'the game was",
|
||||
"watched doing it'. `tools/port/audit-kinds` can see the second and not the",
|
||||
"first, which is the whole reason for spending a key on it.",
|
||||
"",
|
||||
"🔴 AND THIS IS NOT A VINDICATION OF HOW IT GOT HERE. For one iteration the",
|
||||
"port ASSERTED non-persistence for EXTRAS in `contract-check` while nothing",
|
||||
"had measured it; the Decoder flagged that, and it turned out right. Being",
|
||||
"right by luck does not retroactively make it evidence -- declining to",
|
||||
"generalise the memory was the correct move, and encoding 'not measured",
|
||||
"here' as a positive claim was a different and wrong one that happened to",
|
||||
"land. The measurement is what makes it true; the assertion never did.",
|
||||
"",
|
||||
"⚠️ Do NOT generalise in either direction: main_menu persists, EXTRAS resets,",
|
||||
"and OPTIONS / LOAD GAME / TUTORIAL are untouched."
|
||||
],
|
||||
"initial_focus_why": [
|
||||
"MEASURED, unlike the main menu's: EXTRAS opens focused on MISSION SELECT (live-extras.png). It is authored here only because there is nowhere else to put a measurement -- it is not a choice.",
|
||||
"",
|
||||
"",
|
||||
"✅ CAVEAT LIFTED 2026-08-30 -- MEASURED, not a single-entry reading any more.",
|
||||
"HANDOFF `4ed75e6`, docs/re/data/extras-focus-resets.txt: EXTRAS opens at ring",
|
||||
"y=347.5 on MISSION SELECT, moves to 427.5 after one delivery-confirmed DOWN,",
|
||||
"and returns to 347.5 on re-entry with the frame 0.0 % different from the first",
|
||||
"entry. Because this screen RESETS, a single-entry reading of it is not",
|
||||
"measuring history -- which is precisely what made the caveat necessary while",
|
||||
"persistence here was unknown.",
|
||||
"",
|
||||
"✅ THE AMBIGUITY IS RESOLVED -- MEASURED 2026-08-31, and it went the way",
|
||||
"that makes `ptbtn11` right for a REASON rather than by coincidence.",
|
||||
"",
|
||||
"A submenu resets to ITS OWN OPENING ITEM, and that item is a per-screen",
|
||||
"default which need NOT be the first. Decoder, docs/re/data/",
|
||||
"difficulty-resets-to-named-item.txt: DIFFICULTY opens on NORMAL (second of",
|
||||
"four); after one confirmed DOWN to HARD, (B) out and (A) back returns to",
|
||||
"NORMAL -- in-cursor 1.0 from where it opened against 93.9 from where it was",
|
||||
"left. Reproduced on a FRESH BOOT and confirmed by eye, not read off the",
|
||||
"2026-08-29 capture.",
|
||||
"",
|
||||
"So the port's `initial_focus` is the reset target, and `buttons[0]` in",
|
||||
"`MenuFlow.initial_focus` is a REPAIR rather than a default -- which is how",
|
||||
"it was already documented, and is now measured rather than principled.",
|
||||
"",
|
||||
"❔ STILL OPEN, and not leaned on: whether the reset target MOVES once a",
|
||||
"difficulty has actually been confirmed. A game that remembered your last",
|
||||
"choice would behave differently, and the probe never confirms one -- the",
|
||||
"same SELECT DATA crash that constrains the run prevents testing it.",
|
||||
"",
|
||||
"",
|
||||
"🔴 CORRECTED 2026-08-31. This read \"it matters IF another screen is ever",
|
||||
"authored\" whose opening item is not its first. Such a screen exists and is",
|
||||
"recorded IN THIS FILE: DIFFICULTY, under `main_menu/buttons/ptbtn01`, is",
|
||||
"EASY/NORMAL/HARD/BACK and opens on NORMAL -- the SECOND of four. Measured:",
|
||||
"driven with no d-pad, unchanged for 90 s, matching the committed capture at",
|
||||
"r=+0.999 (Decoder, docs/re/captures/newgame-path/newgame-difficulty.png).",
|
||||
"",
|
||||
"So \"a screen opens on its first item\" is REFUTED as a general description of",
|
||||
"this game. On EXTRAS, TUTORIAL and OPTIONS the named item and the top item",
|
||||
"coincide BY ACCIDENT. A top-item rule would be wrong on DIFFICULTY.",
|
||||
"",
|
||||
"nobody can separate \"resets to MISSION SELECT\" from \"resets to the TOP ITEM\".",
|
||||
"They coincide here -- ptbtn11 is both. The port's value is correct under either",
|
||||
"reading, and the REASON is not established.",
|
||||
"",
|
||||
"The superseded caveat is kept below.",
|
||||
" (was) ⚠️ WEAKENED 2026-08-30 -- the OBSERVATION stands, its reading as an INITIAL",
|
||||
" (was) focus does not. It was taken on a single entry. Now that the main menu is known",
|
||||
" (was) to remember its cursor across a round trip, a one-entry reading of any screen",
|
||||
" (was) may be measuring HISTORY rather than what the screen opens on -- the same",
|
||||
" (was) objection that reframed the main menu's TUTORIAL/NEW GAME disagreement.",
|
||||
" (was) ",
|
||||
" (was) Kept as `measured` because the frame really does show MISSION SELECT focused,",
|
||||
" (was) and kept as the port's opening item because it is the only reading there is.",
|
||||
" (was) 🔴 If EXTRAS turns out to persist, this becomes history and the kind must",
|
||||
" (was) change with it.",
|
||||
"",
|
||||
"🔴 CHECKED AGAINST THE BYTES 2026-08-31 by both agents -- and NOT independently.",
|
||||
"Settled by fact, not by my inference: the Decoder's 282/362/442 came from",
|
||||
"`crates/sylpheed-formats/examples/extras_button_order.rs`, which calls",
|
||||
"`ui_layout::parse_build` -- THE SAME CRATE this port's export uses. The",
|
||||
"Python RATC parsers in their tree exist and did not produce that number.",
|
||||
"So the two legs are ONE READER USED TWICE, and the agreement carries no",
|
||||
"information about the reader being right; it carries information only about",
|
||||
"two callers of it agreeing, which they could not fail to do.",
|
||||
"",
|
||||
"⚠️ The VALUE is unaffected -- `ptbtn11` is decided by the DIFFICULTY",
|
||||
"measurement and by the reset finding. What died is a word I used about the",
|
||||
"evidence, which is the third such word in three iterations.",
|
||||
"",
|
||||
"is WEAKENED, by my own audit rather than by theirs.",
|
||||
"",
|
||||
"Applying their test to my own sentence: could my reading have come out",
|
||||
"differently given theirs? Only if the implementations differ. Mine is",
|
||||
"`sylpheed_formats::ui_layout::parse_build` via this port's export. Their tree",
|
||||
"does carry separate Python RATC parsers (`kf_record_census.py` and others),",
|
||||
"so a second implementation EXISTS -- but which reader produced their",
|
||||
"282/362/442 is not established by me, and if they used the same crate the",
|
||||
"two legs are one reader used twice.",
|
||||
"",
|
||||
"So: the values agreeing is still evidence, and calling it INDEPENDENT was a",
|
||||
"claim about their tooling that I did not check. Recorded at the strength I",
|
||||
"can support. ⚠️ Nothing rests on it -- the row order is also decided by the",
|
||||
"DIFFICULTY measurement -- which is exactly why it went unexamined.",
|
||||
"",
|
||||
"Decoder attempted to refute this value and it survives: `ptbtn11` is the TOP",
|
||||
"button on this screen -- y 282 against 362 and 442 -- so the port is right",
|
||||
"whichever reading of the reset target applies. Confirmed from THIS port's own",
|
||||
"export, a different reader of the same disc: extras 282/362/442, and the main",
|
||||
"menu as a control at 162/242/322/401/482.",
|
||||
"",
|
||||
"🔴 WHICH ALSO MEANS EXTRAS CANNOT SEPARATE the two readings -- named item and",
|
||||
"top item coincide here. It was DIFFICULTY, opening on its second of four, that",
|
||||
"settled it."
|
||||
],
|
||||
"on_cancel": {
|
||||
"goto": "main_menu",
|
||||
"goto_name": "TITLE_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"why": "MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you came from. EXTRAS advertises (B) in its own footer -- the red glyph is in ptmsg2.png and absent from the main menu's ptmsg.png."
|
||||
},
|
||||
"buttons": {
|
||||
"ptbtn11": {
|
||||
"label": "MISSION SELECT",
|
||||
"goto": null,
|
||||
"goto_name": null,
|
||||
"blocked": "The stage list is GP_MISSION_SELECT, not in this export. Destination MEASURED."
|
||||
},
|
||||
"ptbtn12": {
|
||||
"label": "MOVIE THEATER",
|
||||
"goto": null,
|
||||
"goto_name": null,
|
||||
"blocked": "NEVER OPENED. docs/game/navigation.md marks this one unknown -- not merely unexported. Do not assume it opens GP_MOVIE_THEATER; that would be a name match dressed as a destination."
|
||||
},
|
||||
"ptbtn13": {
|
||||
"label": "BACK",
|
||||
"goto": "main_menu",
|
||||
"goto_name": "TITLE_MENU",
|
||||
"goto_name_kind": "name match, not measured",
|
||||
"goto_name_why": [
|
||||
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
|
||||
"screens are measured; the ids are a name match onto the executable's class",
|
||||
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
|
||||
"denotes this screen -- nothing observed binds it to this transition.",
|
||||
"",
|
||||
"It is carried so a reader can search for it and so the port never has to",
|
||||
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
|
||||
"a screen file, and this field is documentation.",
|
||||
"",
|
||||
"🔴 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
|
||||
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
|
||||
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
|
||||
"as BORROWED rather than ok, because a label resting on a neighbour's",
|
||||
"argument reads as evidenced and is not."
|
||||
],
|
||||
"same_as_cancel": true,
|
||||
"why": "MEASURED: EXTRAS' third item is BACK (live-extras.png). Treated as (B): it pops the stack, so focus is restored on the main menu exactly as (B) does. Whether the game distinguishes them is untested and there is no reason here to invent a difference."
|
||||
}
|
||||
}
|
||||
"screens": {
|
||||
"_": [
|
||||
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",
|
||||
"destination screens and the RE agent later decoded that a transition is a",
|
||||
"lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,",
|
||||
"LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`",
|
||||
"targets when this is written, marked as the name match they are."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
{
|
||||
"format": "sylpheed.rendering/1",
|
||||
"_": [
|
||||
"WHICH decoded rules the runtime applies where. AUTHORED because it is a",
|
||||
"choice about the REACH of somebody else's decode, not about the disc.",
|
||||
"Delete an entry the day the decode covers the case outright.",
|
||||
"",
|
||||
"The exporter flags `leaf_carries_geometry` on 15 elements -- those whose",
|
||||
"nested `.rat` leaf declares a scale or rotation the parent does not. That",
|
||||
"flag is a CENSUS FACT and it is emitted for all 15. What is DECODED is",
|
||||
"narrower: the Decoder fitted the game's own composed alpha (per-draw vertex",
|
||||
"colours C3FFFFFF / B6FFFFFF = 195 and 182) against the ptloop leaves and got",
|
||||
"one consistent time, then PREDICTED the quad centres to ~11 px. That covers",
|
||||
"`ptloop01` and `ptloop02` and nothing else."
|
||||
],
|
||||
"draw_leaf_for": [
|
||||
"ptloop01",
|
||||
"ptloop02"
|
||||
],
|
||||
"draw_leaf_why": [
|
||||
"The two the decode covers. `docs/re/structures/ui-leaf-vs-parent-alpha.md`.",
|
||||
"",
|
||||
"NOT DRAWN, though the exporter flags them and ships their data:",
|
||||
"",
|
||||
" `title_jp/ptlogo_eff2` -- OUT OF SCOPE, which is a better reason than",
|
||||
" the caution this entry first gave. MISSION section 7 scopes out",
|
||||
" 'localisation beyond English', and this element exists only on the",
|
||||
" Japanese title. So it is not a thing the menu port has to answer, and the",
|
||||
" parked Japanese-locale capture does not need reviving on its account --",
|
||||
" that is the human's call and not something either agent widens quietly.",
|
||||
"",
|
||||
" It is ALSO undecidable here even if it were in scope. Its 125% is a POP,",
|
||||
" not a steady scale: scale-0 -> 125% -> scale-0 between t=50 and t=107,",
|
||||
" about 0.95 s. The leaf draws at 100%, as two superimposed copies at alpha",
|
||||
" 160 and 80, each rotating 360 degrees over 960 units -- 16 s a turn. If",
|
||||
" parent scale gates the leaf it is a 0.95 s flash; if the leaf runs free it",
|
||||
" spins for 16 s. Nothing on the disc chooses and title_jp has no oracle",
|
||||
" capture.",
|
||||
" `build_12,15/pgloading_loop5` -- STILL NOT DRAWN, but the reason given here",
|
||||
" was WRONG and is replaced. It read \"leaf scale (0,0). A zero scale is one of",
|
||||
" the three historical failures this corpus names\" -- which describes t=0 and",
|
||||
" t=30 and nothing after them.",
|
||||
"",
|
||||
" What the leaf actually holds, read out of the export: ONE element,",
|
||||
" `pgloading_ring`, with a sprite, whose scale ramps 0 -> 250 -> 800 -> 1000",
|
||||
" while its alpha rises to full at t=55 and falls to nothing by t=130. An",
|
||||
" expanding, fading ring -- a loading pulse, not a degenerate record.",
|
||||
"",
|
||||
" 🔴 And it is VISIBLE at the instant this port poses. `build_12`'s settle",
|
||||
" window is [40, 48], so the pose lands near t=44, where the ring interpolates",
|
||||
" to scale 140 at alpha 143. So withholding it is not declining to draw",
|
||||
" nothing; it is declining to draw something, and the old reason hid that.",
|
||||
"",
|
||||
" It stays withheld on the reason below, which is the one that always applied:",
|
||||
" there is no way to adjudicate it here. The loading screens have no oracle",
|
||||
" capture -- the RE agent records them as not reachable from the title path --",
|
||||
" and `verify-screen` compares against a renderer that draws no leaves at all.",
|
||||
" Drawing it would put unadjudicable content on a screen, which is the same",
|
||||
" test `ptlogo_eff2` fails.",
|
||||
"",
|
||||
"AND THERE IS NO WAY TO ADJUDICATE EITHER HERE. `title_jp` has no oracle",
|
||||
"capture, and `verify-screen` compares against `sylpheed-cli`, which does not",
|
||||
"draw leaves at all -- so ANY leaf drawing increases that divergence whether",
|
||||
"it is right or wrong. Its max went 155 -> 232 when they were drawn, and that",
|
||||
"number is not evidence in either direction.",
|
||||
"",
|
||||
"What deletes this list: a decode covering those cases, or an oracle capture",
|
||||
"of title_jp."
|
||||
],
|
||||
"draw_leaf_kind": "decoded",
|
||||
"loop_leaf_on_screens": [
|
||||
"title"
|
||||
],
|
||||
"loop_leaf_why": [
|
||||
"WHICH screens replay a leaf's group instead of letting it run once and park.",
|
||||
"MEASURED on the title, UNRESOLVED on the menus, so it is scoped to the title.",
|
||||
"",
|
||||
"The disc gives one pass: ptloop01's leaf runs t=0..600 and ptloop02's t=0..720,",
|
||||
"each ending parked off-screen at x=1521 / -839. The port ran them once.",
|
||||
"",
|
||||
"THE ORACLE SAYS THEY LOOP ON THE TITLE. Across two title dwells the sweep quad",
|
||||
"oscillates over its whole x range and resets hard to the same start value --",
|
||||
"one reset inside the first dwell, two inside the second. A run-once-and-park",
|
||||
"shows one traverse and then a constant x.",
|
||||
"",
|
||||
"🔴 THE LOOP-LENGTH FIELD CANNOT SETTLE THIS, and I had hoped it would.",
|
||||
"`ptloop01` declares 600 with keyframes to exactly 600; `ptloop02` declares 720",
|
||||
"to 720. SLACK ZERO -- and 'loops at 600' and 'runs once for 600 and stops'",
|
||||
"write the identical header. 92.3% of records on the disc are in that state, so",
|
||||
"the field discriminates loop length only where there IS slack, as the plate's",
|
||||
"105-in-120 had.",
|
||||
"",
|
||||
"⚠️ THE MENUS ARE NOT COVERED, on purpose. Both declare the same 600/720, so",
|
||||
"nothing on the disc distinguishes them -- but the oracle measurement is of the",
|
||||
"title, and my own weak evidence points the other way for the menu: sweeping the",
|
||||
"phase against live-main-menu.png, the port matches best with the sweeps",
|
||||
"OFF-SCREEN (0.061%) and three times worse mid-screen (0.183%). If they looped",
|
||||
"with a 600-unit period the sweep is on screen for roughly 73% of the cycle, so",
|
||||
"a capture showing none is not nothing -- but it is one capture, and 'best",
|
||||
"match' is a weak instrument for an absence. Two weak signals in opposite",
|
||||
"directions is a reason to scope, not to pick.",
|
||||
"",
|
||||
"What settles the menu: a direct capture of it, which the Decoder has offered.",
|
||||
"",
|
||||
"🔴 RE-MEASURED 2026-08-31, BECAUSE THE EVIDENCE ABOVE WAS TAKEN WITH THE WRONG",
|
||||
"BLEND. The phase sweep that produced '0.061 % off-screen, 0.183 % mid-screen'",
|
||||
"drew the sweeps ALPHA-OVER. They are additive -- measured off the running game",
|
||||
"the same day (`additive_elements`) -- so an on-screen sweep composited the wrong",
|
||||
"way was being scored against the capture, and 'mid-screen is worse' could have",
|
||||
"been an artefact of my own compositing rather than of the sweeps being absent.",
|
||||
"",
|
||||
"Re-run with additive sweeps and looping switched on for the menu, against",
|
||||
"`live-main-menu.png`:",
|
||||
"",
|
||||
" phase 0 0.0208 % sweeps paint 0 px -- off screen",
|
||||
" phase 150 0.0851 % sweeps paint 58 027 px, bbox 884x720",
|
||||
" phase 300 0.0205 % sweeps paint 0 px -- off screen",
|
||||
" phase 75 / 225 / 375 / 450 / 525: 0.086..0.122 %",
|
||||
" run-once-and-park, which is what the port ships: 0.0208 %",
|
||||
"",
|
||||
"✅ THE CONCLUSION HELD AND GOT STRONGER. The ratio was 3x with the wrong blend",
|
||||
"and is 4-6x with the right one, and the absolute numbers improved everywhere.",
|
||||
"The capture still matches best with the sweeps NOT VISIBLE. So this entry stays",
|
||||
"scoped to the title, and the correction is recorded rather than the scoping",
|
||||
"changed.",
|
||||
"",
|
||||
"⚠️ It is still one capture and 'best match' is still a weak instrument for an",
|
||||
"absence -- that caveat is not repaired by fixing the blend, only cleared of one",
|
||||
"confound.",
|
||||
"",
|
||||
"📌 AND THE NEW DRAW LOG DOES NOT SETTLE IT EITHER, though it looks like it",
|
||||
"should. `docs/re/captures/ui-draws/blend-main-menu-2026-08-31.log` shows both",
|
||||
"sweep strips SUBMITTED on the main menu, in every frame group. That is not",
|
||||
"evidence they animate there: a quad parked off-screen at x=1521 is still a draw",
|
||||
"call. A DRAW IS NOT A VISIBLE ELEMENT, and reading that log as 'the sweeps run",
|
||||
"on the menu' would have contradicted the pixels for no reason."
|
||||
],
|
||||
"loop_leaf_kind": "measured",
|
||||
"additive_elements_deleted_why": [
|
||||
"✅ DELETED 2026-09-01, and the deletion is the point.",
|
||||
"",
|
||||
"This held `additive_elements`, a per-screen list of element ids transcribed",
|
||||
"from the Decoder's per-draw RB_BLENDCONTROL0 log. PORT-MISSION section 3: 'When",
|
||||
"the RE agent later decodes something you had authored, delete the authored",
|
||||
"entry and let the exporter emit it. That deletion is the measure of progress.'",
|
||||
"",
|
||||
"The blend is now DECODED -- `T8aD +0x04` bit 0x02, docs/re/structures/",
|
||||
"ui-blend-mode-decoded.md -- and reachable since formats-pin-2026-09-01 exposed",
|
||||
"`ui_layout::sprite_blend_additive` and `blend_additive_by_name`. The exporter",
|
||||
"emits `blend_additive` per element and per nested focus/leaf element, and",
|
||||
"ScreenView reads it there.",
|
||||
"",
|
||||
"🔴 CHECKED BEFORE THE SWAP, and the map turned out to be a SUBSET rather than",
|
||||
"the answer. Over main_menu, extras, press_start and title:",
|
||||
"",
|
||||
" 15 the map called additive AND the disc agrees",
|
||||
" 0 the map called additive and the disc does not <- no contradictions",
|
||||
" 17 the disc calls additive and the map did not",
|
||||
"",
|
||||
"So nothing transcribed was wrong; it was incomplete, and was being read as",
|
||||
"complete. The 17 include `pteff03`/`pteff03a` -- the sweep LEAVES, which are",
|
||||
"what `draw_leaf_for` actually puts on screen while the map listed their parents",
|
||||
"`ptloop01`/`ptloop02` -- and TWELVE on `title`, where this map was deliberately",
|
||||
"empty and the port therefore drew every title effect alpha-over.",
|
||||
"",
|
||||
"A name-keyed map can only answer for a screen somebody drove the game to. That",
|
||||
"is what made the Japanese menus an open question (BLOCKED.md H6): the port drew",
|
||||
"main_menu additive and main_menu_jp alpha-over, asserting by omission that the",
|
||||
"JP build blends differently. The bit is on the disc for every screen at once, so",
|
||||
"that asymmetry is now answered statically and H6 needs no capture."
|
||||
]
|
||||
}
|
||||
@@ -1,94 +1,94 @@
|
||||
{
|
||||
"format": "sylpheed.screen_names/1",
|
||||
"_": [
|
||||
"Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its",
|
||||
"builds, so every name here is a decision. The identifications come from",
|
||||
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
|
||||
"captures of the running game; the exporter stamps the name into the screen",
|
||||
"file with name_source: \"authored\" so a reader can tell a recovered name from",
|
||||
"an invented one.",
|
||||
"",
|
||||
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
|
||||
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
|
||||
"a name that moves when the enumeration rule changes is not a name. The entry",
|
||||
"was always described here as the stronger locator -- now it is the only",
|
||||
"stable one.",
|
||||
"",
|
||||
"Delete an entry here the day the RE agent decodes a name field."
|
||||
],
|
||||
"archives": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"2": {
|
||||
"name": "press_start",
|
||||
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"3": {
|
||||
"name": "press_start_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"4": {
|
||||
"name": "title",
|
||||
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"5": {
|
||||
"name": "main_menu",
|
||||
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.) 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"6": {
|
||||
"name": "extras",
|
||||
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"7": {
|
||||
"name": "title_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 4. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"8": {
|
||||
"name": "main_menu_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 5. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"9": {
|
||||
"name": "extras_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 6. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM). 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "The region twin of entry 11, as 13 is to 10. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
}
|
||||
"format": "sylpheed.screen_names/1",
|
||||
"_": [
|
||||
"Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its",
|
||||
"builds, so every name here is a decision. The identifications come from",
|
||||
"HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer",
|
||||
"captures of the running game; the exporter stamps the name into the screen",
|
||||
"file with name_source: \"authored\" so a reader can tell a recovered name from",
|
||||
"an invented one.",
|
||||
"",
|
||||
"KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the",
|
||||
"ordinal; widening the enumeration to reach the splash renumbers ordinals, and",
|
||||
"a name that moves when the enumeration rule changes is not a name. The entry",
|
||||
"was always described here as the stronger locator -- now it is the only",
|
||||
"stable one.",
|
||||
"",
|
||||
"Delete an entry here the day the RE agent decodes a name field."
|
||||
],
|
||||
"archives": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"2": {
|
||||
"name": "press_start",
|
||||
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
|
||||
},
|
||||
"3": {
|
||||
"name": "press_start_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need."
|
||||
},
|
||||
"4": {
|
||||
"name": "title",
|
||||
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
|
||||
},
|
||||
"5": {
|
||||
"name": "main_menu",
|
||||
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
|
||||
},
|
||||
"6": {
|
||||
"name": "extras",
|
||||
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
|
||||
},
|
||||
"7": {
|
||||
"name": "title_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 4."
|
||||
},
|
||||
"8": {
|
||||
"name": "main_menu_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 5."
|
||||
},
|
||||
"9": {
|
||||
"name": "extras_jp",
|
||||
"why": "HANDOFF Q2: the Japanese twin of build 6."
|
||||
},
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "The region twin of entry 11, as 13 is to 10."
|
||||
}
|
||||
}
|
||||
},
|
||||
"unnamed": {
|
||||
"dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing."
|
||||
},
|
||||
"also_export": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "As entry 10, region twin."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "As entry 11, region twin."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"unnamed": {
|
||||
"dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing."
|
||||
},
|
||||
"also_export": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"10": {
|
||||
"name": "publisher_logo",
|
||||
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"11": {
|
||||
"name": "developer_logos",
|
||||
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"13": {
|
||||
"name": "publisher_logo_r",
|
||||
"why": "As entry 10, region twin. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
},
|
||||
"14": {
|
||||
"name": "developer_logos_r",
|
||||
"why": "As entry 11, region twin. 📌 SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. ⚠️ The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,604 +1,59 @@
|
||||
{
|
||||
"format": "sylpheed.timing/1",
|
||||
"keyframe_units_per_second": 60,
|
||||
"why": [
|
||||
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
|
||||
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
|
||||
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
|
||||
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
|
||||
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
|
||||
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
|
||||
"transition quad is declared black for 12 units, and a capture measured the",
|
||||
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
|
||||
"",
|
||||
"Expressed as units-per-second rather than seconds-per-unit so the value is",
|
||||
"exact rather than a repeating decimal a reader has to recognise.",
|
||||
"",
|
||||
"DELETE THIS FILE when a field on the disc is found that states the unit.",
|
||||
"Nothing here is on the disc.",
|
||||
"",
|
||||
"🔴 DO NOT 'CORRECT' THIS AGAINST AN EMULATOR FRAME RATE. A draw-stream",
|
||||
"measurement on 2026-08-29 found the presented units-per-frame rising 33 % over",
|
||||
"a single boot (1.765 early, 2.357 late) and three independent readings of one",
|
||||
"container's rate disagreeing with each other. That is the EMULATOR's",
|
||||
"presentation pacing drifting, and no single units-per-frame figure describes a",
|
||||
"run there.",
|
||||
"",
|
||||
"60 is a different quantity: the GAME's logical unit rate, measured off the",
|
||||
"running game as HANDOFF Q1 (a declared t=30 landing on the linear value at",
|
||||
"every one of seven sampled frames). The port renders at its own frame rate and",
|
||||
"converts through this constant, so guest pacing cannot reach it. The two",
|
||||
"numbers are not comparable and one is not evidence about the other.",
|
||||
"",
|
||||
"🔴 2026-09-01 — THE FIRST LEG ABOVE IS RETIRED. THE VALUE IS NOT.",
|
||||
"",
|
||||
"'2 units per rendered frame ... a 30 Hz game, giving 60 units per second' is a",
|
||||
"FRAME-COUNT derivation, and the Decoder retired that mechanism the same day",
|
||||
"(docs/re/units-per-second-measured.md): the same animation takes 21 frame",
|
||||
"labels in one capture and 33 in another, and one splash logo steps +136,+34 in",
|
||||
"one run and +17,+51,+34,+34,+17,+17 in the other. A fixed per-frame increment",
|
||||
"cannot do that. The clock is TIME-INTEGRATED, not frame-counted, so `units =",
|
||||
"2 x frames` computes an emulator artefact. The 2 was that run's frame pacing.",
|
||||
"",
|
||||
"✅ The port's RUNTIME was already right: `boot.gd` advances",
|
||||
"`time_units += delta * units_per_second`, off delta time. Nothing in this port",
|
||||
"derives a unit from a frame count. Audited 2026-09-01, and it is why the",
|
||||
"retirement cost a justification and not a behaviour.",
|
||||
"",
|
||||
"✅ AND THE SECOND LEG NEVER TOUCHED A FRAME COUNT, which is why 60 survives:",
|
||||
"the transition quad is declared black for 12 units and the capture bracketed",
|
||||
"the pure-black plateau at 0.14-0.30 s (title-plate-delay-measured.md, at a",
|
||||
"0.125 s sampling resolution). 12 units in 0.14-0.30 s is 40-86 units/s. That",
|
||||
"is a declared unit count against a wall-clock duration, with no frames in the",
|
||||
"chain -- and it EXCLUDES 120 units/s, which would need 0.10 s.",
|
||||
"",
|
||||
"✅ MEASURED DIRECTLY 2026-09-01: 56.8 units per guest second, control passing",
|
||||
"at 1.15 %, from two elements agreeing at one clock (`ptbtn00` 657.9 alpha/s,",
|
||||
"`ptcopyright` 650.4 alpha/s, which puts ptcopyright's segment at T = 22.25 --",
|
||||
"a rate agreement AND a round declared length). 30 and 120 are both excluded.",
|
||||
"",
|
||||
"60 IS KEPT. 56.8 is 5.6 % away against a ~5 % quantisation resolution, so it",
|
||||
"does not refute 60, and the Decoder explicitly did not ask for a change. The",
|
||||
"reach is the TITLE: the splashes are a different GamePart and nothing yet shows",
|
||||
"they tick at the same rate.",
|
||||
"",
|
||||
"⚠️ If anyone re-fits this from alpha: DROP THE LAST STEP of a ramp. It clamps",
|
||||
"at 255 and reports more elapsed time than it consumed -- worth 4 % on the plate.",
|
||||
"",
|
||||
"🔴 2026-09-01 (later) — A PER-SCREEN RATE WAS PROPOSED AND NOT ADOPTED.",
|
||||
"",
|
||||
"docs/re/splash-declared-vs-captured.md proposes ~57 units/s for the title and",
|
||||
"~35-40 for the splashes, i.e. that one constant cannot be right and that a",
|
||||
"splash at 60 runs 1.5-1.7x too fast. THE PORT DID NOT MOVE, and the reason is",
|
||||
"arithmetic on a measurement already cited in this file:",
|
||||
"",
|
||||
" the 160-unit hold is the DEVELOPER splash's a=255 plateau, t=30..190, and it",
|
||||
" is measured at 4.514 s. The 210-unit group CONTAINING it is measured at",
|
||||
" 3.37/3.50/3.51 s over three cold boots (the dwell_why block below). A",
|
||||
" sub-interval cannot outlast the interval containing it.",
|
||||
"",
|
||||
"The same three boots put the splashes at 57.7 and 60.7 units/s -- corroborating",
|
||||
"60 on exactly the two screens the new figure puts at 35-39. At 35.4 the declared",
|
||||
"groups would run 5.93 s and 7.20 s against corpus dwells of 3.37-3.51 and",
|
||||
"4.30-4.60, i.e. each splash ~70 % longer than measured.",
|
||||
"",
|
||||
"⚠️ DO NOT ADOPT EITHER NUMBER UNTIL THAT IS RESOLVED, and do not split the",
|
||||
"difference -- averaging two measurements that cannot both be true is not a",
|
||||
"third measurement. docs/port/splash-rate-contradiction.md, asked as BLOCKED H7.",
|
||||
"",
|
||||
"⚠️ AND THE STRUCTURAL CLAIM MAY STILL BE RIGHT. 'One rate cannot cover every",
|
||||
"screen' is a claim about the format, and the title's 56.8 does sit ~5 % off the",
|
||||
"splashes' 58-61. If a per-screen rate is real this file should carry a MECHANISM",
|
||||
"-- a field or a GamePart constant -- not two authored numbers. The Decoder has",
|
||||
"'where the per-GamePart rate comes from' as its next item.",
|
||||
"",
|
||||
"🔴 2026-09-01 (later still) — RECLASSIFIED measured -> authored. THE VALUE DOES",
|
||||
"NOT MOVE; THE LABEL WAS FALSE.",
|
||||
"",
|
||||
"The Decoder withdrew their guest-frame-rate finding the same day they published",
|
||||
"it. ⚠️ THAT DOCUMENT IS NOT IN THIS CHECKOUT -- it is `guest-frame-rate-WITHDRAWN.md`",
|
||||
"on their branch, named here in prose deliberately rather than in `source`:",
|
||||
"`audit-kinds` flagged the first version of this entry DANGLING because I cited",
|
||||
"a file I cannot read, which is exactly the check doing its job. The reading",
|
||||
"below is from their message and is labelled as such.",
|
||||
"It read the guest's presentation as 30 fps from a movie-frame ruler and",
|
||||
"concluded 2 x 30 = 60. This file carried `kind: measured` on that strength.",
|
||||
"`kind: measured` on the strength of it. It cannot any more.",
|
||||
"",
|
||||
"Three routes now disagree and at most one can be right:",
|
||||
"",
|
||||
" withdrawn movie cadence 60 units/s",
|
||||
" vblank cadence (Xenia, 60 Hz) ~120",
|
||||
" title-plate-delay, 120 units ~56 -- two runs agreeing to 6 ms",
|
||||
"",
|
||||
"⚠️ 60 IS KEPT ANYWAY, and it is not a coin toss between the three. The one leg",
|
||||
"of this file's own reasoning that never touched a frame count still stands and",
|
||||
"still brackets it: the transition quad is declared black for 12 units and the",
|
||||
"capture measured the plateau at 0.14-0.30 s, i.e. 40-86 units/s. 60 sits inside",
|
||||
"that; 120 does not. And ~56 is 7 % from 60, inside the same bracket.",
|
||||
"",
|
||||
"So the honest statement is: 60 is AUTHORED, bracketed by one surviving",
|
||||
"frame-free measurement, and consistent with the nearest of the three live",
|
||||
"routes. It is no longer 'measured', and anything that cited it as measured is",
|
||||
"citing a withdrawal.",
|
||||
"",
|
||||
"📌 THE METHOD NOTE IS WORTH MORE THAN THE NUMBER, and it is the Decoder's: their",
|
||||
"pre-registration named three ways the ruler could lie and guarded two. The third",
|
||||
"occurred, and a PERFECT 1.0000 is exactly what it produces -- a triple buffer",
|
||||
"rotating once per present gives run-length 1 at any frame rate. Both guards",
|
||||
"tested how the buffer was READ, neither tested whether a change meant a decode.",
|
||||
"",
|
||||
" A clean result on an instrument whose key assumption is unguarded is not",
|
||||
" confirmation. The cleanness may be the failure mode's own signature.",
|
||||
"",
|
||||
"Same family as this port's non-inverting latch check, which passed for the wrong",
|
||||
"reason until its control failed.",
|
||||
"",
|
||||
"🔴 2026-09-01 — THE 12-UNIT BRACKET ABOVE IS WITHDRAWN. IT EXCLUDES NOTHING.",
|
||||
"",
|
||||
"I kept 60 on the ground that '12 declared units measured at 0.14-0.30 s gives",
|
||||
"40-86 units/s, so 120 is excluded'. The Decoder refuted it and the refutation",
|
||||
"holds on arithmetic I checked myself:",
|
||||
"",
|
||||
" the source doc says of that number, in its own words, 'at a sampling",
|
||||
" resolution (0.125 s) that cannot do better'. 120 units/s predicts 12 units in",
|
||||
" 0.100 s -- BELOW one sample interval. A 0.125 s sampler cannot resolve it and",
|
||||
" reports about one sample, ~0.125-0.14 s. The 0.14 s low end is the",
|
||||
" INSTRUMENT'S FLOOR, and 12/0.14 = 85.7 is an upper bound produced by dividing",
|
||||
" by a floored duration. It is the value 120 predicts once the sampler is",
|
||||
" accounted for.",
|
||||
"",
|
||||
"🔴 AND THE DEEPER ERROR IS MINE, NOT THE ARITHMETIC. I argued the leg survived",
|
||||
"because it 'never touched a frame count'. True, and INSUFFICIENT: every",
|
||||
"wall-clock duration off Canary is true/speed_factor, so apparent units/s =",
|
||||
"true x speed -- and the speed factor is precisely what makes the three routes",
|
||||
"disagree. I checked the leg for the WRONG CONTAMINANT. Frame-free is not",
|
||||
"clock-free, and on this emulator clock-free is the property that matters.",
|
||||
"",
|
||||
"What actually survives from that leg, and it is the half I did not lead with:",
|
||||
"the declared 12 units are independently confirmed as SIX FRAMES by",
|
||||
"screen-transitions.md's 255/6-per-frame ramp. No wall clock in it at all. That",
|
||||
"is evidence about units per FRAME -- which was never in dispute -- and silent",
|
||||
"about units per second.",
|
||||
"",
|
||||
"SO 60 HAS NO SURVIVING BRACKET. It stays because nothing supports 120 either and",
|
||||
"moving a shipped timeline on no evidence is worse than leaving it. That is a",
|
||||
"default, not a derivation, and this entry now says so. `kind` is already",
|
||||
"`authored`, which is the honest label for a default.",
|
||||
"",
|
||||
"🟡 2026-09-01 — 120 units/s IS NOW MEASURED, AND THIS PORT HAS NOT MOVED.",
|
||||
"",
|
||||
"The Decoder's content-hash experiment gives 120 (2 units/present x 60",
|
||||
"presents/s), with the controls the withdrawn version lacked -- a static texture",
|
||||
"hashing constant, 1 change in 403 samples, and movie luma not constant, 102",
|
||||
"distinct hashes. Pre-registered bands, and the observed 0.5739 falls inside",
|
||||
"them. It is a better experiment than either of the two it replaces.",
|
||||
"",
|
||||
"IT IS ALSO THEIR THIRD POSITION ON THIS NUMBER IN ONE DAY, reach is one boot,",
|
||||
"and they said themselves that a second independent boot before a timeline is",
|
||||
"rewritten is the defensible call. Agreed. 60 stays for now.",
|
||||
"",
|
||||
"⚠️ 60 IS NOT DEFENDED EITHER -- its bracket was withdrawn this morning. Both",
|
||||
"numbers are undefended; the port keeps the one it ships because switching on a",
|
||||
"single capture is a worse failure than holding on none. That is the whole",
|
||||
"reasoning and it is not evidence about the game.",
|
||||
"",
|
||||
"✅ AUDITED, SO THE SWITCH IS CHEAP WHEN IT COMES: no seconds are baked into the",
|
||||
"timeline anywhere. Every second this port prints or acts on is computed as",
|
||||
"units / keyframe_units_per_second at the point of use. audio.json's loop_start_s",
|
||||
"and loop_end_s ARE seconds and correctly do NOT follow this constant -- they are",
|
||||
"positions in an audio file with no keyframe unit in them.",
|
||||
"",
|
||||
"🔴 One exception found and fixed: tools/port/verify-dwell read black_hold_units",
|
||||
"from this file 'so it cannot drift again' and then divided by a literal 60.0.",
|
||||
"The value could not drift; the conversion could.",
|
||||
"",
|
||||
"📌 THE FALSIFIER IS PRE-REGISTERED in docs/port/units-per-second-switch-readiness.md:",
|
||||
"at 120 the publisher splash runs 2.13 s and the developer 1.75 s, against three",
|
||||
"cold boots measuring 4.30/4.60/4.37 and 3.51/3.50/3.37. 120 and the dwell corpus",
|
||||
"cannot both be right in wall-clock seconds -- the same collision that killed the",
|
||||
"35 units/s proposal from the other direction.",
|
||||
"",
|
||||
"✅ 2026-09-01 (final position of the day) — 120 IS WITHDRAWN BY ITS AUTHOR AND 60",
|
||||
"IS POSITIVELY SUPPORTED. The port never moved, so nothing has to be undone.",
|
||||
"",
|
||||
"The mechanism is worth more than the number: `units per present` HALVED when the",
|
||||
"present rate doubled (Δα +34 at 27.2 presents/s, +17 at 51.4) while units per",
|
||||
"second did not move (54.4 vs 51.4). The UI clock advances by elapsed TIME, not",
|
||||
"by frame count -- so '2 units per frame' was never a property of the game, only",
|
||||
"of a capture that happened to run at 27 fps. The 120 was 2 units/present x 60",
|
||||
"presents/s, and the first factor is not a constant, so the product was not a",
|
||||
"rate.",
|
||||
"",
|
||||
"Their write-up is `units-per-frame-is-not-a-constant.md`, under docs/re/ on",
|
||||
"their branch. 🔴 NOT IN THIS CHECKOUT, so it is named WITHOUT a resolvable",
|
||||
"path -- `tools/port/check-citations` flagged the first version of this very",
|
||||
"paragraph as DANGLING, in the entry where I was recording the lesson about",
|
||||
"dangling citations. The check does not care about a disclaimer, which is",
|
||||
"correct: a path that does not resolve does not resolve.",
|
||||
"",
|
||||
"✅ 2026-09-01 (settled) — THE GAME'S CLOCK IS FRAME-BASED, 1 UNIT PER PRESENT.",
|
||||
"Measured by the Decoder with a DESIGNED experiment rather than an inference:",
|
||||
"`--framerate_limit=30` halved units/second to 30.2, doubled the publisher dwell",
|
||||
"to 8.450 s, and left the modal alpha step at 17 where a time-based clock",
|
||||
"predicts 34. Both controls passed first -- the limiter demonstrably took effect,",
|
||||
"and all 8 splash quad rects were identical, so nothing but the frame rate",
|
||||
"differed. `255 x 1 / 15 = 17` at 28.4, 51.4 and 54.8 presents/s alike.",
|
||||
"",
|
||||
"⚠️ THIS CHANGES WHAT 60 MEANS HERE, AND MAKES IT MORE FALSIFIABLE. If the game",
|
||||
"advances 1 unit per present, its units/second IS its present rate. So",
|
||||
"`keyframe_units_per_second = 60` is now equivalent to the claim:",
|
||||
"",
|
||||
" the game presented these screens at 60 Hz on the console.",
|
||||
"",
|
||||
"That is a sharper statement than 'the unit is 1/60 s' and it is checkable.",
|
||||
"",
|
||||
"✅ AND IT IS SUPPORTED, which the constant has not been until now. Canary",
|
||||
"unlimited presents at 51-55 Hz and the splash dwell is 4.30/4.60/4.37 s over",
|
||||
"three cold boots. A natively 30 Hz game would present at ~30 in Canary too --",
|
||||
"which the framerate_limit run confirms, since forcing 30 made the same splash",
|
||||
"take 8.45 s. It does not take 8.45 s unforced. So the game asks for ~60, not 30.",
|
||||
"",
|
||||
"🔴 AND THAT CLOSES THE CONSTANT AS A CAUSE OF 'THE PLATE IS LATE', for a NEW",
|
||||
"reason and in the direction that matters. Under the frame-based model the only",
|
||||
"alternative console rate is 30 Hz, which puts the plate at 236/30 = 7.87 s --",
|
||||
"LATER than the 3.93 s the port ships, not earlier. There is no console present",
|
||||
"rate that makes the plate arrive sooner than it already does here.",
|
||||
"",
|
||||
"⚠️ KEPT AS `authored`, NOT PROMOTED TO `measured`. The chain is inference over",
|
||||
"three measurements (frame-based clock; Canary's unlimited present rate; the",
|
||||
"dwell corpus) rather than a measurement of units per second. It becomes",
|
||||
"`measured` the day someone reads the console's present rate for these screens",
|
||||
"directly.",
|
||||
"",
|
||||
"📌 AND THE PORT'S OWN DESIGN IS DELIBERATELY NOT THE GAME'S, which is worth",
|
||||
"stating so nobody 'fixes' it. The game is frame-based; this port is time-based",
|
||||
"(`time_units += delta * units_per_second`). They agree at 60 fps, which is the",
|
||||
"only rate the console ever asked the game to be right at. A time-based port",
|
||||
"reproduces a 60 Hz console on hardware that is not 60 Hz; a frame-based port",
|
||||
"would drift on every machine that is not -- and this port has measured itself at",
|
||||
"9.7 to 69.4 fps depending on the renderer. DO NOT make the port frame-based to",
|
||||
"match the game.",
|
||||
"",
|
||||
"✅ 2026-09-02 — 60 NOW STANDS ON A THIRD INDEPENDENT ROUTE, and the REASON",
|
||||
"changed again while the value did not.",
|
||||
"",
|
||||
"The Decoder reconciled three of their own pages that held incompatible",
|
||||
"positions -- 2 units per guest frame, time-integrated at 56.8, and 1 unit per",
|
||||
"present -- with one mechanism: THE CLOCK ADVANCES ONE UNIT PER VBLANK, and",
|
||||
"presents may be dropped without the clock caring. That explains steps that are",
|
||||
"always multiples of 17 (1, 2 or 3 vblanks between two logged presents), and the",
|
||||
"same animation spanning 21 labels in one capture and 33 in another, which a",
|
||||
"strict per-present clock cannot produce.",
|
||||
"",
|
||||
"Their rate result is a MANIPULATION rather than an observation: 255 declared",
|
||||
"units take 4.263/4.162 s at a 60 Hz vblank and 8.450 s at --framerate_limit=30",
|
||||
"-- 59.8/61.3 against 30.2 units/s. So the vblank rate sets the unit rate, and a",
|
||||
"console vblanks at 60.",
|
||||
"",
|
||||
"So the justification for 60 has now been: '2 units per rendered frame' (retired),",
|
||||
"'the game presents at 60 Hz' (superseded), and now 'one unit per 60 Hz vblank'.",
|
||||
"THE NUMBER HAS NEVER MOVED. That is worth noticing rather than celebrating -- a",
|
||||
"value whose reason changes three times while it survives is either robust or",
|
||||
"under-constrained, and the honest label is still `authored`.",
|
||||
"",
|
||||
"📌 THIS PORT INSTANTIATES THEIR NULL MODEL, which is the one thing this side can",
|
||||
"contribute to that argument. Their reasoning turns on 'a time-integrated clock",
|
||||
"predicts 4.25 s in BOTH conditions'. This port IS a working time-integrated",
|
||||
"clock at 60 units/s, and its splash dwell across a 4.0x change in its own",
|
||||
"rendering rate is 4.28 / 4.26 / 4.27 / 4.26 s -- flat to 0.5 %. So their",
|
||||
"counterfactual is demonstrated rather than assumed. ⚠️ It is evidence about the",
|
||||
"NULL, not about the game; it says what a time-integrated clock does, not what",
|
||||
"the game's clock is.",
|
||||
"",
|
||||
"🟡 PER-VBLANK VS PER-PRESENT IS STILL OPEN, and they name the discriminating",
|
||||
"experiment (log Xenia's vblank counter beside each present). ⚠️ IT IS",
|
||||
"IMMATERIAL TO THIS PORT AND THEY SHOULD NOT RUN IT ON THE PORT'S ACCOUNT. The",
|
||||
"two models differ only when the console DROPS a present: per-vblank keeps",
|
||||
"real-time pace through a drop, per-present slows. This port is time-based, so",
|
||||
"it matches per-vblank exactly and would run marginally ahead of per-present",
|
||||
"during drops only. On a console presenting every vblank the two coincide, and",
|
||||
"the screens in question are a handful of quads.",
|
||||
"",
|
||||
"🔴 2026-09-02 (later) — 'A THIRD INDEPENDENT ROUTE' IS WITHDRAWN BY ITS AUTHOR.",
|
||||
"The paragraph above says 60 now stands three ways. It does not, and I recorded",
|
||||
"the claim before challenging it hard enough.",
|
||||
"",
|
||||
"I raised that three routes to one number are weaker than they look if they share",
|
||||
"an upstream assumption -- vblank rate, present rate and declared dwell are not",
|
||||
"obviously independent. The Decoder audited it and agreed: route B needs 'the",
|
||||
"guest presents 60x/s', which comes from the vblank histogram UNDER XENIA'S 60 Hz",
|
||||
"LIMITER; route C needs 'the vblank is 60 Hz', which is that limiter's cvar; route",
|
||||
"D is a wall-clock duration that lands on 60 only BECAUSE the vblank is 60 Hz.",
|
||||
"All three reduce to one upstream fact: the display refreshes 60 times a second",
|
||||
"on that emulator. One witness in three coats.",
|
||||
"",
|
||||
"✅ WHAT SURVIVES IS CONDITIONAL AND BETTER, and it is established by MANIPULATION",
|
||||
"rather than agreement -- forcing 30 Hz gave 30.2 units/s, 60 Hz gives 59.8/61.3:",
|
||||
"",
|
||||
" units per second = THE DISPLAY REFRESH RATE.",
|
||||
"",
|
||||
"It becomes '60' only through a fact this corpus has never measured: an Xbox 360",
|
||||
"outputs 60 Hz. That is a hardware specification. It is solid, and it belongs",
|
||||
"CITED as a spec rather than folded in as a third measurement.",
|
||||
"",
|
||||
"📌 And the conditional form is the one that justifies this port's construction",
|
||||
"rather than excusing it. 'units/s = refresh rate' says what to do on hardware",
|
||||
"that is NOT 60 Hz, which is exactly why a time-based clock at a fixed 60 units/s",
|
||||
"is right and a frame-based one would drift. `kind` stays `authored`: nothing",
|
||||
"here promotes it, and the reason it is not `measured` is now sharper -- the",
|
||||
"measurement is of a RELATIONSHIP, and the constant that closes it comes from a",
|
||||
"datasheet."
|
||||
],
|
||||
"kind": "authored",
|
||||
"source": "docs/re/ui-keyframe-time-unit.md, docs/port/HANDOFF.md",
|
||||
"ramp": "linear",
|
||||
"ramp_why": [
|
||||
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
|
||||
"linear value at every one of the seven sampled frames, so there is no ease."
|
||||
],
|
||||
"ramp_kind": "measured",
|
||||
"dwell_seconds": null,
|
||||
"dwell_why": [
|
||||
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
|
||||
"",
|
||||
"This key has now been wrong in two opposite directions, and the second was",
|
||||
"mine, so both are recorded.",
|
||||
"",
|
||||
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
|
||||
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
|
||||
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
|
||||
"🔴 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
|
||||
"exit is caused by something outside its timeline, so it holds. A splash's exit",
|
||||
"is caused by nothing, so it plays its declared timeline and leaves. The title",
|
||||
"is the exception, not the rule, and one screen was never enough to overturn",
|
||||
"the other two.",
|
||||
"",
|
||||
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
|
||||
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
|
||||
"",
|
||||
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
|
||||
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
|
||||
"",
|
||||
"The developer agrees to 1.1 %, two of its three runs to 0.3 %.",
|
||||
"",
|
||||
"🔴 CORRECTED 2026-09-01. This said: 'The port emits 4.400 s and 3.650 s -- each",
|
||||
"declared value plus the 9-unit black hold, exactly. So the pacing was right all",
|
||||
"along and nothing changes in the code.' THE PORT DOES NOT DO THAT, and this",
|
||||
"file is what stops it: `black_hold_units` is 0, set deliberately (see",
|
||||
"black_hold_why -- a uniform value is positively excluded and only an",
|
||||
"ordered-pair key survives). There is no 9-unit hold to add, so the sentence",
|
||||
"described a behaviour asserted three keys above it and refused one key below.",
|
||||
"",
|
||||
"MEASURED off the shipping boot, three runs, 2026-09-01:",
|
||||
"",
|
||||
" publisher declared 255 units = 4.250 s 4.28 / 4.26 / 4.27 mean 4.270 s",
|
||||
" developer declared 210 units = 3.500 s 3.50 / 3.57 / 3.51 mean 3.527 s",
|
||||
"",
|
||||
"Residuals +1.2 and +1.6 units -- frame granularity on the exit check, not a",
|
||||
"hold. The claimed 4.400 and 3.650 are each ~0.13 s longer than what has been",
|
||||
"shipping since P3. Against the corpus (4.42 and 3.46 means) neither the claimed",
|
||||
"nor the measured figure dominates: the port is 3.4 % short on the publisher and",
|
||||
"2.0 % long on the developer, the claim would be 0.5 % short and 5.5 % long. So",
|
||||
"this corrects a false statement about our own behaviour; it does not settle",
|
||||
"whether a hold belongs there. That is still black_hold_why's ordered-pair ask.",
|
||||
"",
|
||||
"🔴 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
|
||||
"Decoder's own container came out 15-20 % LONGER than both the declared values",
|
||||
"and the corpus -- same disc, same timeline -- and three independent readings",
|
||||
"of that container's rate disagree with each other. A seconds figure records",
|
||||
"one emulator's pacing on one run. The units are on the disc. If anything ever",
|
||||
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
|
||||
"measured to wait beyond its group.",
|
||||
"",
|
||||
"🔴 2026-09-01 (later) — 'So the pacing was right all along and nothing changes in",
|
||||
"the code' IS CONDITIONAL, AND MAY BE A COINCIDENCE OF TWO CANCELLING ERRORS.",
|
||||
"",
|
||||
"That sentence rests on the port's total screen time matching the corpus dwells.",
|
||||
"It does: 4.270 s against 4.30/4.60/4.37 and 3.527 s against 3.51/3.50/3.37.",
|
||||
"",
|
||||
"But a TOTAL cannot see two errors of opposite sign inside it. Measured:",
|
||||
"",
|
||||
" the port's screen time IS its animation time. publisher 4.270 s against a",
|
||||
" 4.250 s animation -- a hold of +0.020 s, i.e. none. The port does not hold",
|
||||
" after a splash timeline at all.",
|
||||
"",
|
||||
" the GAME does: the Decoder counts the publisher on screen for 219 presents and",
|
||||
" animating for ~128 of them, about 42 % hold.",
|
||||
"",
|
||||
"So IF keyframe_units_per_second is 120 rather than 60, this port animates every",
|
||||
"splash 2x too slow AND omits the hold entirely, and the two sum to almost exactly",
|
||||
"the right total. The agreement above would then be evidence of nothing.",
|
||||
"",
|
||||
"⚠️ THE HOLD AND THE CONSTANT ARE COUPLED. At 60 the port must NOT gain a hold --",
|
||||
"the animation already fills the screen time and a hold would overshoot by ~40 %.",
|
||||
"The missing hold is a defect only if 120 is right. They stand or fall together,",
|
||||
"which is another reason not to move on one capture.",
|
||||
"",
|
||||
"📌 And when it does move it is TWO changes, not one: the constant, and a hold",
|
||||
"measured as (screen presents - animation presents). It must NOT be inferred from",
|
||||
"the total, because the total is precisely the quantity that cannot distinguish",
|
||||
"the two errors. docs/port/units-per-second-switch-readiness.md.",
|
||||
"",
|
||||
"✅ 2026-09-01 (later still) — THE PARAGRAPH ABOVE IS WITHDRAWN. 'The pacing was",
|
||||
"right all along' WAS right all along.",
|
||||
"",
|
||||
"I claimed the dwell agreement might be a coincidence of two cancelling errors --",
|
||||
"a 2x-slow animation plus a missing hold. There is no missing hold. I misread a",
|
||||
"presents split from the Decoder's instrument ('219 on screen, ~128 animating')",
|
||||
"as a hold OUTSIDE the declared timeline. It is a split WITHIN it: the publisher",
|
||||
"ramps 0-30, HOLDS 30-235 (205 units, 80.4 % of the screen) and fades 235-255,",
|
||||
"and this port plays all three.",
|
||||
"",
|
||||
"Measured rather than read -- frozen samples of the logo region across the",
|
||||
"publisher splash: 0.405488 at t=60, 120, 180 and 228 units, identical to six",
|
||||
"decimals across 168 units, with 0.391 at t=15 (mid-ramp) and 0.038 at t=252",
|
||||
"(in the exit fade). The hold is there and it is played.",
|
||||
"",
|
||||
"⚠️ The failure was not a mis-measurement. I took a two-part split from someone",
|
||||
"else's instrument and assumed its boundary sat where my own model put it.",
|
||||
"Presents are not units, and 'animating vs holding' in presents does not",
|
||||
"decompose the same way as 'ramp vs hold' in declared units.",
|
||||
"",
|
||||
"AND THE DWELL FIGURES HERE ARE NOW POSITIVE EVIDENCE, not merely survivors. A",
|
||||
"time-based clock is immune to dropped frames, so a dwell measured in seconds is",
|
||||
"stable across runs at different frame rates. This port's own splash dwell across",
|
||||
"a 4.0x change in its rendering rate: 4.28 s at 17.3 fps, 4.26 at 19.6, 4.27 at",
|
||||
"25.0, 4.26 at 69.4 -- a 0.5 % spread, putting 255 units at 59.6-59.9 units/s",
|
||||
"every time. That establishes these dwells are frame-rate-independent",
|
||||
"MEASUREMENTS rather than artefacts of whatever rate a run hit, which is the",
|
||||
"property the Decoder's argument needs of them.",
|
||||
"",
|
||||
"✅ 2026-09-02 — THE +1.2 / +1.6 UNIT RESIDUAL WAS FRAME GRANULARITY, and that is",
|
||||
"now measured rather than inferred.",
|
||||
"",
|
||||
"The dwells were recorded as 4.270 s and 3.527 s against declared 4.250 and 3.500",
|
||||
"-- residuals of +1.2 and +1.6 units -- and I attributed them to the granularity",
|
||||
"of the exit check without testing it. A hardware GPU makes that testable: same",
|
||||
"boot, same declared groups, three runs at 65-66 fps instead of 17-25.",
|
||||
"",
|
||||
"Pre-registered: if the residual is frame granularity it should shrink roughly",
|
||||
"with the frame rate, so <= 0.5 units at 65 fps. Measured:",
|
||||
"",
|
||||
" publisher 4.27 / 4.26 / 4.27 mean 4.253 s residual +0.20 units",
|
||||
" developer 3.50 / 3.52 / 3.50 mean 3.500 s residual +0.00 units",
|
||||
"",
|
||||
"From +1.2 and +1.6 down to +0.20 and +0.00. The prediction held and the",
|
||||
"attribution is no longer an assumption. ⚠️ It also means the figures quoted",
|
||||
"elsewhere in this corpus as 4.270 / 3.527 carry a rendering-rate term; the",
|
||||
"declared values are what the port actually targets and 4.250 / 3.500 is what it",
|
||||
"hits when the renderer keeps up."
|
||||
],
|
||||
"dwell_kind": "measured",
|
||||
"looping_focus_records": {
|
||||
"_": [
|
||||
"WHICH focus records the port draws, unconditionally and on a loop, OVER the",
|
||||
"element's own sprite rather than instead of it.",
|
||||
"",
|
||||
"RESTORED 2026-08-30 on a MEASUREMENT, having been deleted on 2026-08-29 for",
|
||||
"a real defect that was in the RENDERER, not in this table. The old entry made",
|
||||
"`_draw` substitute the glow for the plate's own bright sprite, so the plate",
|
||||
"was invisible at every instant (max 0 against max 252.5). `ScreenView` now",
|
||||
"draws the base and the record over it, and the entry comes back."
|
||||
"format": "sylpheed.timing/1",
|
||||
"keyframe_units_per_second": 60,
|
||||
"why": [
|
||||
"HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a",
|
||||
"`t` is. The unit was MEASURED off the running game, not decoded: a declared",
|
||||
"15-unit fade lands on round(255*k/15) for all seven of its samples with k",
|
||||
"stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2",
|
||||
"units per rendered frame -- and the idle title presents at 28.3-28.8 fps,",
|
||||
"a 30 Hz game, giving 60 units per second. A second line agrees: the",
|
||||
"transition quad is declared black for 12 units, and a capture measured the",
|
||||
"pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.",
|
||||
"",
|
||||
"Expressed as units-per-second rather than seconds-per-unit so the value is",
|
||||
"exact rather than a repeating decimal a reader has to recognise.",
|
||||
"",
|
||||
"DELETE THIS FILE when a field on the disc is found that states the unit.",
|
||||
"Nothing here is on the disc."
|
||||
],
|
||||
"press_start/ptbtn00": {
|
||||
"record_element": "ptbtn00f",
|
||||
"period_units": 120,
|
||||
"kind": "measured",
|
||||
"source": "docs/re/structures/plate-pulse-measured.md, RE agent 2026-08-30",
|
||||
"why": [
|
||||
"MEASURED off the running game, held at the title with NO INPUT: the plate",
|
||||
"oscillates continuously -- two windows in one boot of 58 s and 57 s, about",
|
||||
"23 cycles each, with no decay and no settling.",
|
||||
"kind": "measured",
|
||||
"source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md",
|
||||
"ramp": "linear",
|
||||
"ramp_why": [
|
||||
"Also HANDOFF Q1, and part of the same measurement: the fade lands on the",
|
||||
"linear value at every one of the seven sampled frames, so there is no ease."
|
||||
],
|
||||
"exit_ramp_seconds": 0.4,
|
||||
"exit_ramp_why": [
|
||||
"HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.",
|
||||
"",
|
||||
"🔴 IT NEVER GOES OFF. The plate-absent floor is 159 thresholded green",
|
||||
"pixels -- the title art's own, measured on live-title-build4-no-plate.png --",
|
||||
"and the pulse bottoms at 714, four and a half times that. So `ptbtn00`",
|
||||
"going transparent at t=244 is not the end of the plate; that is its EXIT",
|
||||
"ramp, which plays when the screen leaves. While the screen is held the base",
|
||||
"sits at its own hold (alpha 255 at t=238) and `ptbtn00f`'s cycle runs over",
|
||||
"it. Base-only and base-plus-glow are what the 714 and the 1520 are.",
|
||||
"Every element of a screen ends on exactly ONE untimed keyframe, so there is",
|
||||
"exactly one unknown duration per screen -- the ramp INTO that final keyframe.",
|
||||
"This is that duration. ~0.4 s, which is 24 units at 60 units/s.",
|
||||
"",
|
||||
"⚠️ 120 UNITS, NOT SECONDS, and that is the RE agent's own instruction. Their",
|
||||
"run measured 2.530 and 2.540 s; an earlier corpus run measured 2.24 s. Same",
|
||||
"declared number, different emulator pacing -- x1.27 and x1.12 against a",
|
||||
"nominal 2.000 s, which IS 120 units at 60 units/s. Hardcoding 2.5 s would",
|
||||
"author one loaded container's clock."
|
||||
],
|
||||
"limits": [
|
||||
"ONE BOOT. Two windows inside it are not two boots.",
|
||||
"It does NOT distinguish the boot title from an attract-loop title: run 1",
|
||||
"opens at t~255 s against Q9's ~193 s no-input baseline, so it may already",
|
||||
"be the attract title. Both are 'the title, held, no input' -- which is what",
|
||||
"was asked -- but it is not proof about the first appearance.",
|
||||
"🔴 714/1520 IS NOT AN ALPHA RATIO. The counter is thresholded pixels, so dim",
|
||||
"pixels drop out first. No duty cycle and no ramp shape may be read off it;",
|
||||
"the port draws the record's own declared alpha ramp and infers nothing."
|
||||
]
|
||||
}
|
||||
},
|
||||
"exit_ramp_deleted_why": [
|
||||
"DELETED 2026-08-29, and the deletion is the point.",
|
||||
"",
|
||||
"`exit_ramp_seconds` (~0.4 s) and `exit_ramp_units` (24) were AUTHORED because",
|
||||
"the disc had no time slot on a group's final keyframe, so the ramp into it was",
|
||||
"the one unknown duration per screen. Under the corrected record layout",
|
||||
"(formats-pin-2026-08-29c onward) there IS no untimed keyframe -- a group is an",
|
||||
"8-byte header then frames x {u32 time; 36-byte pose}, so every pose is timed",
|
||||
"including the last. The unknown the constant stood in for does not exist.",
|
||||
"",
|
||||
"MISSION section 3: 'When the RE agent later decodes something you had",
|
||||
"authored, delete the authored entry and let the exporter emit it. That",
|
||||
"deletion is the measure of progress.'",
|
||||
"",
|
||||
"VERIFIED DEAD BEFORE DELETING, not assumed: setting it to 9999 (166 seconds)",
|
||||
"changed the boot's transitions by 0.04 s -- wall-clock jitter, not a 166 s",
|
||||
"ramp. Both of its uses in ScreenView were gated on `not last_frame.has('t')`,",
|
||||
"which no longer fires on any of the export's 866 keyframes.",
|
||||
"",
|
||||
"The measurement it recorded is not lost: HANDOFF Q7's ~0.4 s fade-out and the",
|
||||
"0.17-0.23 s black hold are still measured facts, and the hold is still used --",
|
||||
"`tools/port/verify-dwell` compares a transition INTERVAL against the oracle's",
|
||||
"visible SPAN plus that hold. What is deleted is the port's need to invent a",
|
||||
"duration the disc now states."
|
||||
],
|
||||
"black_hold_units": 0,
|
||||
"black_hold_why": [
|
||||
"0 = NOT MODELLED. The escalation is resolved: a uniform value is positively",
|
||||
"EXCLUDED, so 0 is no longer one option among several -- it is the only honest",
|
||||
"uniform choice, because it is the one that does not claim a constant exists.",
|
||||
"",
|
||||
"UPDATE: TWO candidate models are now excluded, not one. The Decoder has five",
|
||||
"replicates with NO variation -- title->menu 3,3,3 and EXTRAS->menu 2,2 -- and",
|
||||
"every differing value comes from a different ORDERED PAIR. The same origin",
|
||||
"gives different values to different destinations (menu 0 vs 1, EXTRAS 2 vs 3).",
|
||||
"So a constant is excluded AND keying on the outgoing screen is excluded; only",
|
||||
"an ordered-pair key survives, with a measured value needed per pair.",
|
||||
"",
|
||||
"I checked independently whether anything DECLARED predicts it, from the",
|
||||
"quantities in my export. None does: outgoing close (15,10,10,10), incoming",
|
||||
"clear (12,12,16,12), outgoing span (269,74,80,80) and incoming span",
|
||||
"(80,80,269,74) each have two rows sharing a value with different gaps.",
|
||||
"",
|
||||
"I did NOT search combinations of them. Four intra-archive pairs against many",
|
||||
"candidate two-screen functions fits by construction -- that is the error this",
|
||||
"corpus has catalogued five times, and finding a formula here would be",
|
||||
"indistinguishable from finding one in noise.",
|
||||
"",
|
||||
"The Decoder ordered the gaps by the screen being LEFT (frames): menu 0 and 1,",
|
||||
"EXTRAS 2, title 3. Three hypotheses are positively ruled out, not merely",
|
||||
"unsupported. DIRECTION: EXTRAS->menu (2) and menu->EXTRAS (1) are the same",
|
||||
"pair both ways and differ. BUTTON: (B) gives 0 and 2, (A) gives 1 and 3.",
|
||||
"INCOMING SCREEN: an incoming menu takes 3 from the title and 2 from EXTRAS.",
|
||||
"",
|
||||
"So the quantity varies 0-3 frames by outgoing screen, and any uniform non-zero",
|
||||
"value is wrong as a MODEL rather than merely off in magnitude. 0 models the",
|
||||
"gap as absent; 6 would model it as constant, which the data excludes.",
|
||||
"",
|
||||
"MY OWN RULE IS REFUTED, not just unadopted. It was gap + the incoming",
|
||||
"screen's opening black-clear = a constant, holding at 16/16/18 on three",
|
||||
"transitions. Their fourth gives 16, 14, 16, 18 -- and decisively, the two",
|
||||
"transitions with the SAME incoming screen (main_menu) have different gaps,",
|
||||
"so the incoming screen cannot determine it. A fourth point did to a",
|
||||
"three-point fit exactly what it should.",
|
||||
"",
|
||||
"DO NOT key this per outgoing screen yet. Three outgoing screens with one",
|
||||
"value each restates the data rather than predicting it -- the same objection",
|
||||
"I raised against my own 16/16/18. Key it when a screen has more than one",
|
||||
"measured value, and key it on the screen being LEFT.",
|
||||
"",
|
||||
"📌 CITATION ADDED 2026-09-01, and its absence propagated from the delivery.",
|
||||
"This why carried over a thousand characters and NOTHING OPENABLE. The Decoder",
|
||||
"sent the `(B)`-from-EXTRAS leg as an inline frame table with no file cited,",
|
||||
"while docs/re/data/fade-four-transitions.txt -- which carries that leg and",
|
||||
"eight others -- had been committed the whole time. They found it in their own",
|
||||
"audit and cited it; it had already landed here uncited.",
|
||||
"",
|
||||
"⚠️ An uncited measurement propagates as an uncited value. The receiving end",
|
||||
"cannot tell a summarised measurement from a recalled one, and both read as",
|
||||
"prose.",
|
||||
"",
|
||||
"✅ AUDITED 2026-09-01 and this one needed nothing: it was already an EXCLUSION argument rather than a count. It excludes a constant, excludes keying on the outgoing screen, and excludes every declared quantity in the export as a predictor -- four of them named, each shown not to separate the pairs. That is the form the week's other claims were found to be missing."
|
||||
],
|
||||
"black_hold_kind": "measured"
|
||||
"The alternative readings were tested and refuted. It is not a black quad laid",
|
||||
"over a frozen screen: under that model a black rect scales every region by the",
|
||||
"same 1-alpha, so the button-region / background-region brightness RATIO would",
|
||||
"be constant through the fade. Measured on the RE agent's filmstrip it falls",
|
||||
"6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen",
|
||||
"itself plays out: pteff00.prm ramps to opaque black while the button labels,",
|
||||
"ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.",
|
||||
"",
|
||||
"REACH, quoted from the RE agent rather than smoothed over: the filmstrip is",
|
||||
"downsampled and the button region contains some background, so this pins the",
|
||||
"DIRECTION, not 0.4 s to +/-0.05 s, and it is one transition pair. Treat the",
|
||||
"number as approximate and the model as established."
|
||||
],
|
||||
"exit_ramp_units": 24,
|
||||
"dwell_seconds": null,
|
||||
"dwell_why": [
|
||||
"NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the",
|
||||
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos",
|
||||
"at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would",
|
||||
"be inventing a number nobody measured, so the sequencer holds for zero extra",
|
||||
"time and the pacing you see is the disc's own.",
|
||||
"",
|
||||
"If a capture ever times the real boot, this is where that number goes."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ 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};
|
||||
@@ -194,6 +195,23 @@ enum ScreenCommands {
|
||||
/// `--build`**, which is why it is a flag and not the default.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
/// Pose every element at this KEYFRAME TIME instead of at its resting
|
||||
/// pose (60 units = 1 second). The resting pose is each element's last
|
||||
/// *hold* keyframe, picked independently of every other element — so it
|
||||
/// is not the screen at any one moment, and it is wrong twice over: it
|
||||
/// omits anything still moving (the title's light sweeps hold off the
|
||||
/// right edge), and it freezes a transient at its PEAK (the title's five
|
||||
/// two-frame flashes burn forever). Prefer `--settle`.
|
||||
#[arg(long, conflicts_with = "settle")]
|
||||
at: Option<u32>,
|
||||
/// Pose every element at the instant the screen is SETTLED, derived from
|
||||
/// the disc: the midpoint of the longest interval containing no keyframe
|
||||
/// of any element. Prints the window it used, whose width is how much the
|
||||
/// midpoint is worth — a narrow one means the bundle never settles (42 %
|
||||
/// of them, mostly `loop*` fragments). See
|
||||
/// `docs/re/structures/ui-settle-time.md`.
|
||||
#[arg(long)]
|
||||
settle: bool,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -352,8 +370,10 @@ async fn main() -> Result<()> {
|
||||
black,
|
||||
all,
|
||||
primitives,
|
||||
at,
|
||||
settle,
|
||||
} => cmd_screen_render(
|
||||
&pak, &output, build, focus, animated, black, all, primitives,
|
||||
&pak, &output, build, focus, animated, black, all, primitives, at, settle,
|
||||
),
|
||||
},
|
||||
Commands::Save { cmd } => match cmd {
|
||||
@@ -571,11 +591,6 @@ 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,
|
||||
@@ -585,12 +600,40 @@ fn cmd_screen_render(
|
||||
black: bool,
|
||||
all: bool,
|
||||
primitives: bool,
|
||||
at: Option<u32>,
|
||||
settle: bool,
|
||||
) -> Result<()> {
|
||||
use sylpheed_formats::ui_layout::{self, ComposeOptions};
|
||||
let builds = screen_builds(pak, all)?;
|
||||
let idx = pick_build(&builds, want)?;
|
||||
let bytes = &builds[idx].1;
|
||||
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
|
||||
let at = if settle {
|
||||
match (b.settle_window(), b.settle_time()) {
|
||||
(Some((lo, hi)), Some(t)) => {
|
||||
// Report the width, not just the answer. A 4-unit window and a
|
||||
// 190-unit one give the same kind of number and mean entirely
|
||||
// different things.
|
||||
println!(
|
||||
"settle window [{lo}, {hi}] = {} units ({:.2} s) -> posing at t={t}{}",
|
||||
hi - lo,
|
||||
(hi - lo) as f64 / 60.0,
|
||||
if hi - lo < 30 {
|
||||
" ⚠️ narrow — this bundle may never settle"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
Some(t)
|
||||
}
|
||||
_ => {
|
||||
println!("no settle window (fewer than two distinct keyframe times) — using rest()");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
at
|
||||
};
|
||||
let screen = ui_layout::compose(
|
||||
&b,
|
||||
bytes,
|
||||
@@ -603,6 +646,7 @@ fn cmd_screen_render(
|
||||
ComposeOptions::default().backdrop
|
||||
},
|
||||
include_primitives: primitives,
|
||||
at,
|
||||
},
|
||||
None,
|
||||
);
|
||||
@@ -625,14 +669,35 @@ fn cmd_screen_render(
|
||||
if !screen.missing.is_empty() {
|
||||
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
|
||||
}
|
||||
let undrawn: Vec<&str> = b
|
||||
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4`
|
||||
// ghost instance carries its template's name, so a bare name list shows
|
||||
// `ptlogo1.t32` twice and reads as "the logo is missing" when what is
|
||||
// skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That
|
||||
// misreading cost this project a wrong finding sent to another agent.
|
||||
let undrawn: Vec<String> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| !screen.drawn.contains(&e.index))
|
||||
.map(|e| e.name.as_str())
|
||||
.map(|e| {
|
||||
let why = if e.name.ends_with(".prm") {
|
||||
"untextured primitive, needs --primitives"
|
||||
} else if e.name.ends_with(".rat") {
|
||||
"animation, needs --animated"
|
||||
} else if e.kind == 0x4 {
|
||||
"kind 0x4 ghost instance"
|
||||
} else if e.rest().map(|k| k.fade >> 24) == Some(0) {
|
||||
"transparent at its pose"
|
||||
} else {
|
||||
"no reason established"
|
||||
};
|
||||
format!("[{}] {} ({why})", e.index, e.name)
|
||||
})
|
||||
.collect();
|
||||
if !undrawn.is_empty() {
|
||||
println!(" not drawn ({}): {undrawn:?}", undrawn.len());
|
||||
println!(" not drawn ({}):", undrawn.len());
|
||||
for u in &undrawn {
|
||||
println!(" {u}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -748,8 +813,16 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
|
||||
println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
|
||||
println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
|
||||
println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
|
||||
if let Some(b) = info.avg_bytes_per_sec {
|
||||
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
|
||||
}
|
||||
if let Some(d) = info.duration_secs {
|
||||
println!(" Duration : {d:.2} s");
|
||||
let how = if info.codec == sylpheed_formats::AudioCodec::Xma {
|
||||
" (from the declared byte rate, not decoded)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" Duration : {d:.2} s{how}");
|
||||
}
|
||||
if let Some(p) = info.xma_packets {
|
||||
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());
|
||||
@@ -896,7 +969,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_key(|&(_, count)| std::cmp::Reverse(count));
|
||||
summary.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
for (fmt, count) in summary {
|
||||
println!(
|
||||
" {:>6} .{}",
|
||||
@@ -1038,7 +1111,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.as_chunks::<3>().0 {
|
||||
for tri in sub.indices.chunks_exact(3) {
|
||||
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 {
|
||||
@@ -1060,7 +1133,7 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
|
||||
};
|
||||
let mut maxedges: Vec<f32> = sub
|
||||
.indices
|
||||
.as_chunks::<3>().0.iter()
|
||||
.chunks_exact(3)
|
||||
.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());
|
||||
@@ -1224,7 +1297,7 @@ fn cmd_mesh_render(
|
||||
let med = {
|
||||
let mut e: Vec<f32> = sub
|
||||
.indices
|
||||
.as_chunks::<3>().0.iter()
|
||||
.chunks_exact(3)
|
||||
.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| {
|
||||
@@ -1258,7 +1331,7 @@ fn cmd_mesh_render(
|
||||
(p[2] - center[2]) * scale * mirror[2] + cell[2],
|
||||
]
|
||||
};
|
||||
for tri in sub.indices.as_chunks::<3>().0 {
|
||||
for tri in sub.indices.chunks_exact(3) {
|
||||
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 {
|
||||
@@ -1444,7 +1517,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.as_chunks::<4>().0.iter().zip(rgba.as_chunks_mut::<4>().0) {
|
||||
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||
out[0] = px[1]; // R
|
||||
out[1] = px[2]; // G
|
||||
out[2] = px[3]; // B
|
||||
@@ -1694,12 +1767,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(
|
||||
|
||||
@@ -55,33 +55,7 @@ license.workspace = true
|
||||
# a squash-merge can orphan, and no way for the exporter to be built against a
|
||||
# decoder it was never tested with. A decoder change and the exporter change it
|
||||
# requires now land in the same commit or not at all.
|
||||
# PINNED BY TAG, which is what MISSION section 2 prescribes and what the tagging
|
||||
# rule exists for: "the RE agent tags when it lands something you need and tells
|
||||
# you over the message channel -- that is how you stay current without floating."
|
||||
# That is exactly what happened here.
|
||||
#
|
||||
# The tag carries the CORRECTED keyframe association: a placement group is an
|
||||
# 8-byte header then `frames` x {u32 time; 36-byte pose}, so pose 0's time is the
|
||||
# group's lead-in word and EVERY POSE IS TIMED, including the last. The working
|
||||
# tree's copy still has the retired `SYLPHEED_KF_TIME_SHIFT` knob -- a superseded
|
||||
# partial fix that got the association right but left pose 0 untimed, which is
|
||||
# why testing it moved the untimed frame from last to first instead of removing
|
||||
# it. The old reading is behind `SYLPHEED_KF_TIME_LEGACY=1` here.
|
||||
#
|
||||
# 🔴 THE COST, STATED: `sylpheed-cli` builds from the WORKSPACE crate, so until
|
||||
# this lands on `main` the exporter and the reference renderer read DIFFERENT
|
||||
# decoders and `tools/port/verify-screen` is comparing two eras rather than
|
||||
# detecting drift. `tools/port/verify-capture` is unaffected -- it compares the
|
||||
# port against oracle CAPTURES and never touches the CLI -- and it is the check
|
||||
# that matters. Revert to the path dependency the day the tag is an ancestor of
|
||||
# `main`.
|
||||
# Bumped c -> d 2026-08-29. What I wanted from the new state: `d` carries parser
|
||||
# and `audio.rs` changes on top of `c`. ⚠️ Its headline change -- Reborn's
|
||||
# renderer drawing `rotation_deg`, and `compose` drawing a leaf that carries
|
||||
# geometry -- does NOT reach this port from here: `sylpheed-cli` builds from the
|
||||
# WORKSPACE crate, so the reference renderer stays unrotated until the tag lands
|
||||
# on `main`. This bump is for the parser, not for the renderer.
|
||||
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Sylpheed.git", tag = "formats-pin-2026-09-01" }
|
||||
sylpheed-formats = { path = "../sylpheed-formats" }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//! Throwaway probe: what are a music bank's sub-waves, decoded and timed?
|
||||
//!
|
||||
//! `export_bgm` sums every sub-wave `media` returns and scales by 1/n. If one of
|
||||
//! them is not music, the divisor is wrong and every real stem is attenuated for
|
||||
//! nothing -- the same defect already found and fixed in `export_voice`.
|
||||
use std::process::Command;
|
||||
use sylpheed_formats::media;
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let src = media::DirectorySource::new(&disc);
|
||||
for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] {
|
||||
match media::sound_bank_riffs(&src, bank) {
|
||||
Ok(riffs) => {
|
||||
println!("{bank}: {} sub-wave(s)", riffs.len());
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let p = std::env::temp_dir().join(format!("bk_{i}.xma.wav"));
|
||||
std::fs::write(&p, r).unwrap();
|
||||
let w = std::env::temp_dir().join(format!("bk_{i}.wav"));
|
||||
let _ = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(&p).arg(&w).output();
|
||||
let out = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-v", "info", "-i"])
|
||||
.arg(&w)
|
||||
.args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"])
|
||||
.output().unwrap();
|
||||
let t = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
let get = |k: &str| t.lines().find_map(|l| l.split_once(k).map(|x| x.1.trim().to_string()))
|
||||
.unwrap_or_else(|| "?".into());
|
||||
let dur = Command::new("ffprobe")
|
||||
.args(["-v","error","-show_entries","format=duration","-of","csv=p=0"])
|
||||
.arg(&w).output().ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default();
|
||||
println!(" sub-wave {i}: {:>9} B -> {:>10} s peak {:>10} rms {}",
|
||||
r.len(), dur, get("Peak level dB:"), get("RMS level dB:"));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(&w);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{bank}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Is `BGM_103` the ONLY bank with those two wave sizes?
|
||||
//!
|
||||
//! `authored/audio.json` says *"Static code, disc census and runtime all agree"*
|
||||
//! — three legs. Reading the sentence beneath it, legs two and three are **one**
|
||||
//! comparison: the disc's declared wave sizes matched byte-for-byte against what
|
||||
//! the XMA probe saw at the menu. That is a disc-to-runtime match, not two
|
||||
//! independent confirmations.
|
||||
//!
|
||||
//! It is a third leg only if the census independently EXCLUDES alternatives — if
|
||||
//! some other bank carried the same two sizes, the byte match would not
|
||||
//! distinguish it. So the sizes are counted across every `BGM_*` bank on the
|
||||
//! disc.
|
||||
//!
|
||||
//! Prompted by the Decoder's point that a decorative second support is worse
|
||||
//! than none: **a conclusion with two supports reads as better evidenced than
|
||||
//! one with a single support, so apparent redundancy is itself the
|
||||
//! misinformation.**
|
||||
use sylpheed_formats::media;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let src = media::DirectorySource::new(&root);
|
||||
const WANT: [usize; 2] = [3_876_864, 3_930_112];
|
||||
let (mut found, mut matches) = (0usize, Vec::new());
|
||||
for n in 0..=199u32 {
|
||||
let name = format!("BGM_{n:03}.slb");
|
||||
let Ok(riffs) = media::sound_bank_riffs(&src, &name) else { continue };
|
||||
if riffs.is_empty() { continue }
|
||||
found += 1;
|
||||
let sizes: Vec<usize> = riffs.iter().map(|r| r.len()).collect();
|
||||
// Compare on the DATA payload the port sums, not on the RIFF wrapper:
|
||||
// a wrapper differs by header bytes and would hide a real collision.
|
||||
let near = sizes.iter().any(|s| WANT.iter().any(|w| s.abs_diff(*w) < 4096));
|
||||
if near {
|
||||
matches.push((name.clone(), sizes.clone()));
|
||||
}
|
||||
}
|
||||
println!(" {found} BGM_* bank(s) readable on this disc");
|
||||
for (n, s) in &matches {
|
||||
println!(" {n:<14} wave sizes {s:?}");
|
||||
}
|
||||
println!("\n {} bank(s) carry a wave within 4 KiB of {WANT:?}", matches.len());
|
||||
println!(" Exactly 1 means the census EXCLUDES alternatives and is a real third");
|
||||
println!(" leg. More than 1 means the byte match does not distinguish BGM_103,");
|
||||
println!(" and \"three legs\" is two. Zero means this reader cannot see the");
|
||||
println!(" incumbent and its answer means nothing.");
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! Test the Decoder's UNTESTED reading of a residual they recorded as odd.
|
||||
//!
|
||||
//! `GP_DIALOG` holds 140 entries against a 70-record dialog table — a 2:1 ratio
|
||||
//! that would make the id→entry join an ordering question. It does not hold:
|
||||
//! adjacent pairing gives identical element-name sets on **2 of 65** pairs,
|
||||
//! halves pairing on **0**. In `GP_TITLE` a language pair shares its element set
|
||||
//! exactly, so identical sets are the signature there and almost nothing matches
|
||||
//! here.
|
||||
//!
|
||||
//! The residual: the only two adjacent pairs that DO match are entries `0/1` and
|
||||
//! `2/3` — and `2/3` is the DIFFICULTY build. Their plausible reading is that
|
||||
//! dialog text is baked into language-specific sprites, so EN/JP entries differ
|
||||
//! by construction. ⚠️ **They flagged it as untested and did not assert it**, and
|
||||
//! it has a hole they named themselves: it would explain the 63 that differ and
|
||||
//! leave the 2 that match needing their own explanation.
|
||||
//!
|
||||
//! This prints what the differences actually look like, so the reading is judged
|
||||
//! against the names rather than accepted as plausible.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let ar = pak::PakArchive::open(format!("{root}/dat/GP_DIALOG.pak")).expect("GP_DIALOG.pak");
|
||||
let sets: Vec<Option<BTreeSet<String>>> = ar.entries().iter().map(|e| {
|
||||
let by = ar.read(e).ok()?;
|
||||
if !ratc::is_ratc(&by) { return None }
|
||||
let b = ui_layout::parse_build(&by)?;
|
||||
Some(b.elements.iter().map(|el| el.name.clone()).collect())
|
||||
}).collect();
|
||||
|
||||
let (mut same, mut diff, mut pairs) = (0usize, 0usize, 0usize);
|
||||
let mut shown = 0;
|
||||
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
|
||||
let (Some(a), Some(b)) = (&sets[i], &sets[i + 1]) else { continue };
|
||||
pairs += 1;
|
||||
if a == b {
|
||||
same += 1;
|
||||
println!(" entries {i:>3}/{:<3} IDENTICAL sets, {} element(s)", i + 1, a.len());
|
||||
continue;
|
||||
}
|
||||
diff += 1;
|
||||
// The stage-dialog pairs, checked by name and by SPRITE COUNT. A
|
||||
// translation of one dialog carries the same amount of text; a
|
||||
// different stage does not. This is the Decoder's closing evidence for
|
||||
// the 37 pairs that differ WITHOUT a button-count mismatch, re-derived
|
||||
// here because it settles a bound I had recorded as unlikely to be
|
||||
// tested -- and saying so is what got it tested.
|
||||
if (10..=15).contains(&i) {
|
||||
let sp = |x: &BTreeSet<String>| x.iter().filter(|n| n.ends_with(".t32")).count();
|
||||
let stage = |x: &BTreeSet<String>| -> Vec<String> {
|
||||
let mut v: Vec<String> = x.iter().filter_map(|n| n.strip_prefix("pzstg")
|
||||
.and_then(|r| r.get(..2)).map(|s| s.to_string())).collect();
|
||||
v.sort(); v.dedup(); v
|
||||
};
|
||||
println!(" entries {i:>3}/{:<3} stages {:?} vs {:?} sprites {} vs {}",
|
||||
i + 1, stage(a), stage(b), sp(a), sp(b));
|
||||
}
|
||||
if shown < 3 {
|
||||
shown += 1;
|
||||
let only_a: Vec<_> = a.difference(b).cloned().collect();
|
||||
let only_b: Vec<_> = b.difference(a).cloned().collect();
|
||||
println!(" entries {i:>3}/{:<3} differ: {} only-in-first, {} only-in-second",
|
||||
i + 1, only_a.len(), only_b.len());
|
||||
println!(" first : {:?}", &only_a[..only_a.len().min(4)]);
|
||||
println!(" second : {:?}", &only_b[..only_b.len().min(4)]);
|
||||
}
|
||||
}
|
||||
// 🔴 THE DECISIVE DETAIL, not the impressionistic one. Two languages of one
|
||||
// dialog cannot differ in BUTTON COUNT. If adjacent entries do, they are
|
||||
// different dialogs and the whole adjacent-pairing premise is wrong -- which
|
||||
// is a stronger statement than "the language reading is untested".
|
||||
let btns = |s: &Option<BTreeSet<String>>| -> usize {
|
||||
s.as_ref().map_or(0, |x| x.iter().filter(|n| n.contains("btn")).count())
|
||||
};
|
||||
let mut mismatched = 0;
|
||||
for i in (0..sets.len().saturating_sub(1)).step_by(2) {
|
||||
if sets[i].is_none() || sets[i + 1].is_none() { continue }
|
||||
if btns(&sets[i]) != btns(&sets[i + 1]) { mismatched += 1 }
|
||||
}
|
||||
println!("\n adjacent pairs whose BUTTON COUNTS differ: {mismatched}");
|
||||
println!(" A language pair cannot. Every one of these is two different dialogs.");
|
||||
println!("\n {pairs} adjacent pair(s): {same} identical, {diff} differing");
|
||||
println!(" Their reading -- text baked into language-specific sprites -- predicts");
|
||||
println!(" the differing names look SYSTEMATIC (a locale suffix, a parallel set).");
|
||||
println!(" Judge it against the names above rather than against its plausibility.");
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Independent check of "DIFFICULTY is a dialog: GP_DIALOG entries 2/3".
|
||||
//!
|
||||
//! The Decoder identified `DLG_SELECT_DIFFICULTY` as `GP_DIALOG.pak` entries 2/3
|
||||
//! by TWO arguments, one of them compound — corrected from "three routes", which
|
||||
//! was taking credit for the exclusion scan. The image leg names no entry, and
|
||||
//! the disc and oracle legs are one argument, since the capture is compared
|
||||
//! against the disc's rows. One of them is button count and geometry. That half is
|
||||
//! readable from the disc with this port's own reader, so it is checked here
|
||||
//! rather than taken on their word — the same form as re-deriving `ptbtn11`'s
|
||||
//! row order from my export when they offered it.
|
||||
//!
|
||||
//! ⚠️ What this CANNOT check is their binding claim, and they flagged it first:
|
||||
//! entries 2/3 are identified by button count and geometry, **not** by a binding
|
||||
//! from the `DLG_` name to a pak entry. Another four-button dialog with the same
|
||||
//! rows would be indistinguishable by this evidence. Reproducing the geometry
|
||||
//! confirms the geometry; it does not name the screen.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
// 🔴 WIDENED 2026-08-31 to every pak, to check the Decoder's rival search
|
||||
// independently. They report zero four-button builds within 6 px of
|
||||
// 259/329/399/469 anywhere on the disc, which turns "another dialog with
|
||||
// these rows would be indistinguishable" from a standing reach into a
|
||||
// bounded one. A disc-wide negative is exactly the claim worth re-running
|
||||
// with a different reader, because its whole content is an absence.
|
||||
const WANT: [i32; 4] = [259, 329, 399, 469];
|
||||
const TOL: i32 = 6;
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut hits, mut scanned) = (0usize, 0usize);
|
||||
for path in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(path) else { continue };
|
||||
let arch = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
scanned += 1;
|
||||
// Any button-shaped record, not just `pcbtn`: a rival need not share the
|
||||
// naming convention, and restricting by name would answer a narrower
|
||||
// question than the one asked.
|
||||
let mut rows: Vec<(String, i32)> = b.elements.iter()
|
||||
.filter(|el| el.name.contains("btn"))
|
||||
.filter_map(|el| el.rest().map(|r| (el.name.clone(), r.y)))
|
||||
.collect();
|
||||
if rows.is_empty() { continue }
|
||||
rows.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
let ys: Vec<i32> = rows.iter().map(|r| r.1).collect();
|
||||
let gaps: Vec<i32> = ys.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
if rows.len() == 4 && ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= TOL) {
|
||||
hits += 1;
|
||||
println!(" {arch} entry {i:>2} {} record(s): {}", rows.len(),
|
||||
rows.iter().map(|r| r.0.as_str()).collect::<Vec<_>>().join(" "));
|
||||
println!(" rows {ys:?} gaps {gaps:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n {scanned} build(s) scanned across {} pak(s); {hits} match the",
|
||||
paks.len());
|
||||
println!(" DIFFICULTY row signature within +/-{TOL} px.");
|
||||
println!(" Expected: exactly 2 -- the EN/JP pair. More means a RIVAL exists and");
|
||||
println!(" the geometric identification is not unique; fewer means this reader");
|
||||
println!(" cannot see the incumbents and its zero would mean nothing.");
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! Probe: does a `.rat` leaf record carry geometry the parent element does not?
|
||||
//!
|
||||
//! The GPU capture says the title submits `ptloop01`/`ptloop02` scaled 600 %/800 %
|
||||
//! and rotated +30.26°/−45.28°, while the export writes scale 100 % and rotation
|
||||
//! 0 for both. `ui_layout`'s own note says the rotated quads come from the
|
||||
//! **nested `.rat` leaf records**, which is where `export_screen` already looks
|
||||
//! for focus records and nowhere else.
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open");
|
||||
let e = &ar.entries()[4]; // entry 4 = the English title
|
||||
let bundle = ar.read(e).expect("read");
|
||||
let b = ui_layout::parse_build(&bundle).expect("parse");
|
||||
println!("build has {} elements, {} records", b.elements.len(), b.records.len());
|
||||
let mut names: Vec<&String> = b.records.keys().collect();
|
||||
names.sort();
|
||||
println!("records: {names:?}");
|
||||
for el in &b.elements {
|
||||
if !el.name.starts_with("ptloop") { continue; }
|
||||
let r = el.rest();
|
||||
println!("\nPARENT {} sprite={:?} -> rest scale {:?} rot {:?}", el.name, el.sprite,
|
||||
r.map(|r| (r.scale_x, r.scale_y)), r.map(|r| r.rotation_deg));
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
match ui_layout::parse_build(&bundle[off..off + size]) {
|
||||
Some(leaf) => {
|
||||
println!(" LEAF {} parses: {} element(s)", el.name, leaf.elements.len());
|
||||
for le in &leaf.elements {
|
||||
let lr = le.rest();
|
||||
println!(" {:<20} rest scale {:?} rot {:?} pos {:?}",
|
||||
le.name,
|
||||
lr.map(|r| (r.scale_x, r.scale_y)),
|
||||
lr.map(|r| r.rotation_deg),
|
||||
lr.map(|r| (r.x, r.y)));
|
||||
for k in &le.keyframes {
|
||||
println!(" t={:?} scale=({},{}) rot={} pos=({},{}) fade={:#010x} u4={} u8={}",
|
||||
k.time, k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y,
|
||||
k.fade, k.unknown_4, k.unknown_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => println!(" LEAF {} does NOT parse as a build", el.name),
|
||||
}
|
||||
} else {
|
||||
println!(" no record named {}", el.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Run the Decoder's own falsifier for "a nested record's `+0x08` is its loop
|
||||
//! length" against the bundles THIS PORT SHIPS, before shipping 120 for 105.
|
||||
//!
|
||||
//! HANDOFF (`27938aa`, delivered at `07e93ce`) says the plate's glow cycles over
|
||||
//! **120** units while its keyframes end at 105, and instructs the port to stop
|
||||
//! shipping 105. The port's `ScreenView` derives a looping record's period from
|
||||
//! the element's largest keyframe time, so it does ship 105 — and the field that
|
||||
//! would fix it is decoded in an *example* and a *test* on the Decoder's branch
|
||||
//! and **exposed in `sylpheed_formats`' public API on no ref at all**.
|
||||
//!
|
||||
//! ✅ **Since then the crate exposes it** — `ui_layout::loop_length_units`, taken
|
||||
//! at `formats-pin-2026-08-30b` — and `screen.rs` has deleted its local copy.
|
||||
//!
|
||||
//! 🔴 **This file deliberately did NOT follow it.** The read below is still the
|
||||
//! raw four bytes, because the moment a control calls the API it is meant to
|
||||
//! check, it stops being a control and becomes the API tested against itself. It
|
||||
//! is the independent reading that makes the falsifier mean anything.
|
||||
//!
|
||||
//! So this re-runs both of their controls:
|
||||
//!
|
||||
//! * **the falsifier** — `+0x08 < max keyframe time` must never occur; an
|
||||
//! animation cannot restart before its own last pose;
|
||||
//! * **non-triviality** — if every record had `+0x08 == max t` the field would
|
||||
//! carry nothing and the name would be a relabelling of the keyframes.
|
||||
//!
|
||||
//! and adds the one they could not run: the same two, restricted to the records
|
||||
//! **this port actually animates**. A disc-wide 0.00 % violation rate says
|
||||
//! nothing about my six screens if all six sit in the exceptional tail.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The records the port animates: the plate glow, the five menu focus records,
|
||||
/// and the title's two sweeps. Named rather than pattern-matched, because the
|
||||
/// point is to check the ones that are shipped, not the ones that match a glob.
|
||||
const SHIPPED: &[&str] = &[
|
||||
"ptbtn00f", "ptbtn01f", "ptbtn02f", "ptbtn03f", "ptbtn04f", "ptbtn05f",
|
||||
"ptloop01", "ptloop02",
|
||||
];
|
||||
|
||||
/// Which header word to read as the loop length. `0x08` is the decoded one;
|
||||
/// `--offset=N` re-runs the same falsifier at a neighbour, which is the only way
|
||||
/// to learn whether the falsifier is evidence for the offset or just for the
|
||||
/// disc.
|
||||
static mut OFFSET: usize = 8;
|
||||
|
||||
fn main() {
|
||||
let off: usize = std::env::args().find_map(|a| a.strip_prefix("--offset=")
|
||||
.and_then(|v| v.parse().ok())).unwrap_or(8);
|
||||
unsafe { OFFSET = off };
|
||||
println!(" reading the loop length at header +0x{off:02x}");
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut slack_hist: BTreeMap<i64, usize> = BTreeMap::new();
|
||||
let mut shipped: BTreeMap<String, (i64, i64)> = BTreeMap::new();
|
||||
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
if o + off + 4 > by.len() || o + s > by.len() { continue }
|
||||
if &by[o..o + 4] != b"RATC" { continue }
|
||||
// 🔴 THE FALSIFIER IS RUN AT NEIGHBOURING OFFSETS TOO. The
|
||||
// Decoder's struct-layout control showed that a homogeneous
|
||||
// repeated table type-checks at every field boundary, so an
|
||||
// interior test carries no information about phase -- 69 of 70
|
||||
// records passed under BOTH shifted alignments of their dialog
|
||||
// table. My falsifier (`+0x08 >= max keyframe time`) is an
|
||||
// interior test of exactly that kind, and I re-ran it as
|
||||
// "confirmation" without asking whether it discriminates the
|
||||
// OFFSET or merely the file.
|
||||
let len = u32::from_be_bytes(by[o + off..o + off + 4].try_into().unwrap()) as i64;
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
let maxt = lb.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0) as i64;
|
||||
if maxt == 0 { continue } // static: declares no cycle at all
|
||||
total += 1;
|
||||
let slack = len - maxt;
|
||||
*slack_hist.entry(slack).or_default() += 1;
|
||||
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else { violations += 1 }
|
||||
let stem = rn.trim_end_matches(".rat");
|
||||
if SHIPPED.contains(&stem) {
|
||||
shipped.entry(stem.to_string()).or_insert((len, maxt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("disc-wide, records with timed keyframes: {total}");
|
||||
println!(" +08 == max t (exact) : {exact:5} {:5.1} %", pc(exact, total));
|
||||
println!(" +08 > max t (a hold) : {holds:5} {:5.1} %", pc(holds, total));
|
||||
println!(" +08 < max t <- FALSIFIER : {violations:5} {:5.2} %", pc(violations, total));
|
||||
println!("\nslack distribution, most common first:");
|
||||
let mut h: Vec<_> = slack_hist.iter().collect();
|
||||
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
|
||||
for (k, n) in h.iter().take(8) { println!(" slack {k:>6} : {n}"); }
|
||||
|
||||
println!("\nthe records THIS PORT animates:");
|
||||
println!(" {:<12} {:>6} {:>7} {:>7}", "record", "+0x08", "max t", "slack");
|
||||
let (mut ship_exact, mut ship_hold, mut ship_bad) = (0, 0, 0);
|
||||
for (n, (len, maxt)) in &shipped {
|
||||
let slack = len - maxt;
|
||||
match slack { 0 => ship_exact += 1, s if s > 0 => ship_hold += 1, _ => ship_bad += 1 }
|
||||
println!(" {n:<12} {len:>6} {maxt:>7} {slack:>7}{}",
|
||||
if slack < 0 { " 🔴 FALSIFIED" } else { "" });
|
||||
}
|
||||
println!("\n shipped: {ship_exact} exact, {ship_hold} hold, {ship_bad} falsified");
|
||||
if shipped.len() < SHIPPED.len() {
|
||||
let missing: Vec<_> = SHIPPED.iter().filter(|s| !shipped.contains_key(**s)).collect();
|
||||
println!(" ⚠️ not found on the disc: {missing:?} -- a name the port ships and");
|
||||
println!(" this control never checked is worse than a violation it found.");
|
||||
}
|
||||
println!("\n verdict: {}", if ship_bad > 0 {
|
||||
"🔴 the reading fails on a record the port animates -- do NOT adopt"
|
||||
} else if ship_hold == 0 {
|
||||
"⚠️ every shipped record is exact, so this port cannot tell loop length\n from max keyframe time -- adopting 120 would change nothing here"
|
||||
} else {
|
||||
"✅ falsifier clean and the field is non-trivial ON THE SHIPPED SET"
|
||||
});
|
||||
}
|
||||
|
||||
fn pc(n: usize, d: usize) -> f64 { if d == 0 { 0.0 } else { 100.0 * n as f64 / d as f64 } }
|
||||
@@ -1,65 +0,0 @@
|
||||
//! Why do two "every pak, every timed record" scans disagree by 86 %?
|
||||
//!
|
||||
//! This port counts 1 781 timed nested records and reports `+0x08 == max t` at
|
||||
//! 92.3 %. The Decoder counts 3 311 and reports 49.6 %. Both scans are described
|
||||
//! the same way, so at least one of them is narrower than its own description --
|
||||
//! and the exactness figure this port has quoted repeatedly is a property of
|
||||
//! whichever subset it actually walks.
|
||||
//!
|
||||
//! Counts the survivors at each filter, so the gap is located rather than
|
||||
//! guessed at.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut records, mut in_bounds, mut magic, mut parsed, mut timed) = (0, 0, 0, 0, 0);
|
||||
let (mut untimed, mut all_at_zero) = (0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (_, &(o, s)) in &b.records {
|
||||
records += 1;
|
||||
if o + 12 > by.len() || o + s > by.len() { continue }
|
||||
in_bounds += 1;
|
||||
if &by[o..o + 4] != b"RATC" { continue }
|
||||
magic += 1;
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
parsed += 1;
|
||||
let maxt = lb.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
// 🔴 `maxt == 0` merges two different populations, and the
|
||||
// Decoder's cause -- `.max()` returning `Some(0)` -- is only one
|
||||
// of them. A record with NO timed keyframe has no largest
|
||||
// keyframe time; a record whose keyframes all sit at t=0 has
|
||||
// one, and it is 0. Only the first is a question without
|
||||
// content. Both of us called all 1 530 "the question has no
|
||||
// meaning"; that is true of one group and an assumption about
|
||||
// the other.
|
||||
let any_timed = lb.elements.iter()
|
||||
.any(|el| el.keyframes.iter().any(|k| k.time.is_some()));
|
||||
if maxt == 0 {
|
||||
if any_timed { all_at_zero += 1 } else { untimed += 1 }
|
||||
continue;
|
||||
}
|
||||
timed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" records declared by parse_build : {records}");
|
||||
println!(" within the entry's bounds : {in_bounds}");
|
||||
println!(" carrying the RATC magic : {magic} <- {} dropped here",
|
||||
in_bounds - magic);
|
||||
println!(" parsing as a nested build : {parsed}");
|
||||
println!(" with a largest keyframe time > 0: {timed}");
|
||||
println!(" of the {} excluded:", untimed + all_at_zero);
|
||||
println!(" NO timed keyframe at all : {untimed} <- the question has no content");
|
||||
println!(" timed, but every pose at t=0 : {all_at_zero} <- a largest time EXISTS, and it is 0");
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Do any screens THIS PORT SHIPS carry a record that declares a cycle while all
|
||||
//! its poses sit at t = 0?
|
||||
//!
|
||||
//! The substantive finding from the denominator thread: 1 530 nested records
|
||||
//! disc-wide are timed with every pose at t = 0 and still declare a nonzero
|
||||
//! `+0x08`. A static record that declares a cycle length is a real thing, not a
|
||||
//! counting artefact — so the question for the port is whether it holds one of
|
||||
//! those still while the disc says it cycles.
|
||||
//!
|
||||
//! Scoped to `GP_TITLE`, because that is the archive the port exports.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let ar = pak::PakArchive::open(format!("{root}/dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let (mut total, mut hits, mut multipose) = (0usize, 0usize, 0usize);
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (name, &(o, s)) in &b.records {
|
||||
if o + 12 > by.len() || o + s > by.len() || &by[o..o + 4] != b"RATC" { continue }
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
let maxt = lb.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
|
||||
let len = ui_layout::loop_length_units(&by[o..o + s]).unwrap_or(0);
|
||||
total += 1;
|
||||
if maxt == 0 && len > 0 {
|
||||
hits += 1;
|
||||
// A cycle can only produce motion if there is more than one pose
|
||||
// to move between. All-at-t=0 with a single keyframe per element
|
||||
// is visually inert however it is played.
|
||||
let kf: usize = lb.elements.iter().map(|el| el.keyframes.len()).sum();
|
||||
let multi = lb.elements.iter().filter(|el| el.keyframes.len() > 1).count();
|
||||
if multi > 0 { multipose += 1 }
|
||||
println!(" entry {i:>2} {name:<16} {len}-unit cycle, {kf} keyframe(s) \
|
||||
across {} element(s), {multi} with >1 pose", lb.elements.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n {total} nested record(s) in GP_TITLE; {hits} declare a cycle while static.");
|
||||
println!(" Of those, {multipose} have an element with MORE THAN ONE pose -- the only");
|
||||
println!(" ones where looping could differ visibly from holding. A record whose");
|
||||
println!(" elements each carry a single pose renders identically either way, so a");
|
||||
println!(" declared cycle there is inert rather than a defect.");
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Throwaway probe: how long is each region chunk of a movie's voice?
|
||||
//!
|
||||
//! The question it answers is whether the chunks of a resolved voice region are
|
||||
//! CONSECUTIVE SEGMENTS (concatenate them) or ALTERNATE TAKES (chunk 0 is the
|
||||
//! whole track). Getting that backwards plays the dialogue three times over.
|
||||
use std::process::Command;
|
||||
use sylpheed_formats::{media, slb::VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let src = media::DirectorySource::new(&disc);
|
||||
for movie in ["ADV", "S00A", "RT01A"] {
|
||||
let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
|
||||
else {
|
||||
println!("{movie}: no region");
|
||||
continue;
|
||||
};
|
||||
let riffs = media::voice_region_riffs(&src, s, e).expect("riffs");
|
||||
println!("{movie}: region [{s}, {e}) = {} bytes, {} chunk(s)", e - s, riffs.len());
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let p = std::env::temp_dir().join(format!("vc_{movie}_{i}.xma.wav"));
|
||||
std::fs::write(&p, r).unwrap();
|
||||
// XMA declares no duration, so DECODE it and measure the result.
|
||||
let w = std::env::temp_dir().join(format!("vc_{movie}_{i}.wav"));
|
||||
let _ = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
|
||||
.arg(&p)
|
||||
.arg(&w)
|
||||
.output();
|
||||
let out = Command::new("ffprobe")
|
||||
.args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
|
||||
.arg(&w)
|
||||
.output()
|
||||
.unwrap();
|
||||
let dur = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if std::env::var("KEEP_WAV").is_ok() {
|
||||
let keep = std::path::Path::new(&std::env::var("KEEP_WAV").unwrap())
|
||||
.join(format!("{movie}_chunk{i}.wav"));
|
||||
let _ = std::fs::rename(&w, &keep);
|
||||
println!(" kept -> {}", keep.display());
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&w);
|
||||
}
|
||||
println!(" chunk {i}: {} bytes -> {dur} s", r.len());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,7 @@
|
||||
//! * a `buttons` entry naming an element that is not a button, or out of
|
||||
//! resting-Y order;
|
||||
//! * a sprite path that does not exist, or a PNG that does not decode;
|
||||
//! * a name presented as recovered when it was authored;
|
||||
//! * an audio file that is silent or clips -- the two audio failures that pass
|
||||
//! every check that is not looking for them.
|
||||
//! * a name presented as recovered when it was authored.
|
||||
//!
|
||||
//! It deliberately does **not** check that the export matches the disc. That is
|
||||
//! what `sylpheed-cli screen render` is for.
|
||||
@@ -186,26 +184,10 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
|
||||
for (k, kf) in kfs.iter().enumerate() {
|
||||
check_pose(&mut c, &format!("{at} keyframe {k}"), kf);
|
||||
}
|
||||
// 🔴 INVERTED 2026-08-29, and the old rule is the more interesting
|
||||
// half. It read: "the last keyframe of a group carries no time slot
|
||||
// on the disc, and an invented one is exactly the kind of value this
|
||||
// format refuses." That was true of the OLD keyframe association,
|
||||
// where a group's data stopped four bytes short of its final block's
|
||||
// time slot.
|
||||
//
|
||||
// Under the corrected layout (`formats-pin-2026-08-29c` onward) a
|
||||
// group is an 8-byte header then `frames` x {u32 time; 36-byte
|
||||
// pose}, so **pose 0's time is the group's lead-in word and EVERY
|
||||
// POSE IS TIMED, including the last.** The rule now says the
|
||||
// opposite, and an untimed keyframe is the thing to refuse.
|
||||
//
|
||||
// ⚠️ This fired 150 times on a re-export and I had not run `check`
|
||||
// between pinning the tag and measuring against the oracle -- the
|
||||
// pixel harness was green while the format validator was failing on
|
||||
// every screen with a multi-keyframe group. A correctness harness
|
||||
// does not replace a format one; they fail at different layers.
|
||||
if kfs.len() > 1 && kfs.iter().any(|k| k.get("t").is_none()) {
|
||||
c.err(format!("{at}: a keyframe has no `t`; every pose is timed under the corrected record layout"));
|
||||
// The last keyframe of a group carries no time slot on the disc, and
|
||||
// an invented one is exactly the kind of value this format refuses.
|
||||
if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) {
|
||||
c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,8 +267,6 @@ pub fn run(root: &Path) -> Result<usize> {
|
||||
check_screen(root, file, &mut errors)?;
|
||||
}
|
||||
|
||||
check_audio(root, &m, &mut errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
for e in &errors {
|
||||
eprintln!(" ✗ {e}");
|
||||
@@ -295,88 +275,3 @@ pub fn run(root: &Path) -> Result<usize> {
|
||||
}
|
||||
Ok(screens.len())
|
||||
}
|
||||
|
||||
/// The `audio` array, checked the way a consumer would have to.
|
||||
///
|
||||
/// Two of these are content checks rather than schema checks, and they are here
|
||||
/// on purpose. `docs/port/AUDIO-VERIFICATION.md` names silence as "the failure
|
||||
/// that looks like success": a file of exactly the right duration, the right
|
||||
/// channel count and the right size, full of zeroes, because something opened
|
||||
/// the wrong thing. Every structural check passes it. So does clipping, which
|
||||
/// the BGM can produce because it is a **sum of two stems** at unity gain.
|
||||
///
|
||||
/// The exporter measures both at export time and writes them here; this refuses
|
||||
/// the tree if what it wrote is a file nobody would want to play. Neither is a
|
||||
/// judgement about whether the audio is the RIGHT audio — nothing in this
|
||||
/// binary can know that, and `docs/port/BLOCKED.md` says which parts are still
|
||||
/// authored guesses.
|
||||
fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
|
||||
let Some(audio) = m.get("audio").and_then(Value::as_array) else {
|
||||
// Absent is correct for every export taken before P6.
|
||||
return;
|
||||
};
|
||||
for a in audio {
|
||||
let name = a.get("name").and_then(Value::as_str).unwrap_or("?");
|
||||
let kind = a.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
if !matches!(kind, "se" | "bgm" | "voice") {
|
||||
errors.push(format!(
|
||||
"manifest.json: audio `{name}` has kind {kind:?}, which a consumer cannot dispatch on"
|
||||
));
|
||||
}
|
||||
for key in ["file", "command", "why"] {
|
||||
if a.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
|
||||
errors.push(format!("manifest.json: audio `{name}` has no `{key}`"));
|
||||
}
|
||||
}
|
||||
let Some(file) = a.get("file").and_then(Value::as_str) else { continue };
|
||||
if !root.join(file).exists() {
|
||||
errors.push(format!("manifest.json: lists audio {file}, which does not exist"));
|
||||
continue;
|
||||
}
|
||||
match a.get("peak_dbfs").and_then(Value::as_f64) {
|
||||
None => errors.push(format!(
|
||||
"manifest.json: audio `{name}` carries no `peak_dbfs` -- it was not measured, \
|
||||
and silence is the audio failure that passes every check that is not looking \
|
||||
for it"
|
||||
)),
|
||||
Some(p) if p <= -90.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- this file is silent"
|
||||
)),
|
||||
// The bound differs by kind, and the difference is the point. A
|
||||
// `bgm` is something WE combined -- a sum of stems -- so a peak at
|
||||
// or above full scale is our arithmetic and is refused outright. An
|
||||
// `se` is a single wave off the disc: it is mastered near full
|
||||
// scale, and a lossy decode of a near-full-scale signal overshoots
|
||||
// by a fraction of a dB (`confirm` lands at +0.18). Refusing that
|
||||
// would be refusing the disc's own mastering, and "fixing" it would
|
||||
// mean attenuating a game asset to make a number smaller.
|
||||
//
|
||||
// 🟡 +1.0 dB is a JUDGEMENT, not a measurement: a few tenths is
|
||||
// reconstruction overshoot, a whole dB is not. Nobody has measured
|
||||
// the overshoot distribution across a corpus of cues, and if a cue
|
||||
// ever trips this the right response is that measurement, not a
|
||||
// looser bound.
|
||||
// `voice` was on the strict side of this bound while it was a SUM of a
|
||||
// region's chunks. It no longer is: a region carries three
|
||||
// presentations of one take, so the exporter keeps ONE stream and
|
||||
// performs no arithmetic on it. That puts `voice` with `se` -- a
|
||||
// single wave off the disc, mastered near full scale, whose lossy
|
||||
// decode overshoots by a fraction of a dB. `ADV`'s louder
|
||||
// presentation measures +0.0003 dBFS at source; refusing that would
|
||||
// be refusing the disc's own mastering.
|
||||
Some(p) if kind == "bgm" && p >= 0.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- a SUM we produced clips"
|
||||
)),
|
||||
Some(p) if kind != "bgm" && p > 1.0 => errors.push(format!(
|
||||
"{file}: peak is {p:.1} dBFS -- too far over full scale to be decode overshoot"
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
match a.get("duration_s").and_then(Value::as_f64) {
|
||||
Some(d) if d > 0.0 => {}
|
||||
_ => errors.push(format!(
|
||||
"{file}: no positive `duration_s` -- a zero-length asset plays as silence"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
//!
|
||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||
|
||||
mod audio;
|
||||
mod check;
|
||||
mod video;
|
||||
mod screen;
|
||||
@@ -21,7 +20,7 @@ use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
|
||||
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
|
||||
@@ -77,48 +76,6 @@ struct ManifestVideo {
|
||||
/// dislikes the quality re-runs one line rather than reverse-engineering it.
|
||||
command: String,
|
||||
why: &'static str,
|
||||
/// What the runtime should have played, so it can report what it did.
|
||||
/// See `video::Transcoded::duration_s` — the port measured its player
|
||||
/// presenting 28–47 % of a stream's frames, and seconds alone hide that.
|
||||
duration_s: f64,
|
||||
fps: f64,
|
||||
}
|
||||
|
||||
/// One exported audio file. Carries the same provenance a video does, plus the
|
||||
/// measured peak and duration: silence and clipping are the two audio failures
|
||||
/// that pass every check that is not looking for them.
|
||||
#[derive(Serialize)]
|
||||
struct ManifestAudio {
|
||||
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
|
||||
/// than a prefix on `name` that a consumer would have to parse.
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
file: String,
|
||||
command: String,
|
||||
why: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
peak_dbfs: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration_s: Option<f32>,
|
||||
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
|
||||
/// runtime to announce. Absent means nothing is known to be missing --
|
||||
/// never that the asset was checked and is complete.
|
||||
///
|
||||
/// It exists because the export could already say this and the RUNTIME
|
||||
/// could not. `why` carries the full account, but it is a paragraph aimed
|
||||
/// at a reader of the manifest; a player hears clean dialogue and has no
|
||||
/// way to learn that a stream is absent from it. This port already
|
||||
/// announces the two measured screens NEW GAME jumps over, on the principle
|
||||
/// that a gap is announced before it is opened. Audio had no equivalent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
incomplete: Option<String>,
|
||||
/// The game's own cue identifier where one is a NAME MATCH. Absent means
|
||||
/// nobody has claimed one -- never that the binding is unknown.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name_match: Option<String>,
|
||||
/// What the runtime does at the end of the file, where that was authored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
loop_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -131,8 +88,6 @@ struct Manifest {
|
||||
screens: Vec<ManifestScreen>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
videos: Vec<ManifestVideo>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
audio: Vec<ManifestAudio>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -158,9 +113,6 @@ 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,
|
||||
}
|
||||
@@ -243,55 +195,13 @@ fn main() -> Result<()> {
|
||||
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
let names = load_names(authored_dir)?;
|
||||
|
||||
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
|
||||
// has to know about; it is not an error, and it is not a log line, because
|
||||
// the person who needs it reads `manifest.json` and never sees stdout.
|
||||
let mut warnings: Vec<String> = vec![
|
||||
"GP_TITLE screen builds only. No other archive, and only the two movies \
|
||||
MISSION section 6 puts in scope."
|
||||
.into(),
|
||||
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||
layout child, so `is_build` cannot see them and no content rule can: element \
|
||||
count and design size both overlap with two-element fragments in other archives. \
|
||||
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
|
||||
which is a locator and not a claim -- see each one's name_why."
|
||||
.into(),
|
||||
];
|
||||
|
||||
// Derived output is regenerated wholesale: clear it, so a screen that stops
|
||||
// being exported stops existing rather than lingering as a stale file that
|
||||
// still validates.
|
||||
//
|
||||
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
|
||||
// `video::transcode` has always carried a cache -- it writes a `.cmd`
|
||||
// sidecar with the exact command, the source size and the channel count, and
|
||||
// skips the encode when all three still match. Its own doc comment says
|
||||
// "without it every re-export pays ~4 minutes to produce a byte-identical
|
||||
// file". **This wipe deleted the sidecar and the output immediately before
|
||||
// the check, so the cache had never hit once.** Six exports in one session
|
||||
// paid ~48 minutes of Theora to produce five byte-identical files, and
|
||||
// nothing reported it: the cache is silent when it works and silent when it
|
||||
// does not.
|
||||
//
|
||||
// The wholesale guarantee is kept rather than weakened -- everything else is
|
||||
// still cleared outright, and `prune_videos` below deletes any file in
|
||||
// `video/` that this run did not claim, so a movie that stops being exported
|
||||
// still stops existing.
|
||||
if out.exists() {
|
||||
for entry in std::fs::read_dir(out).context("clear the output tree")? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == "video" {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
std::fs::remove_dir_all(entry.path())
|
||||
} else {
|
||||
std::fs::remove_file(entry.path())
|
||||
}
|
||||
.with_context(|| format!("clear {}", entry.path().display()))?;
|
||||
}
|
||||
std::fs::remove_dir_all(&out).context("clear the output tree")?;
|
||||
}
|
||||
std::fs::create_dir_all(out)?;
|
||||
std::fs::create_dir_all(&out)?;
|
||||
|
||||
let archive = "dat/GP_TITLE.pak";
|
||||
let pak = disc.join(archive);
|
||||
@@ -318,7 +228,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,
|
||||
@@ -351,153 +261,20 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
|
||||
// MISSION §6: the boot intro and the one new-game intro only.
|
||||
let mut videos = Vec::new();
|
||||
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
|
||||
// 🔴 The export deviates from a HUMAN decision, and until this warning
|
||||
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
|
||||
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
|
||||
// justified for one of the two movies and over-broad for the other, and
|
||||
// which of the three options to take is not the exporter's call -- so it is
|
||||
// reported on every run rather than left in a doc comment nobody opens.
|
||||
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
|
||||
warnings.push(
|
||||
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
for m in video::MOVIES {
|
||||
match video::transcode(disc, out, m)? {
|
||||
Some(t) => {
|
||||
println!(" video {} -> {}", m.src, t.file);
|
||||
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
|
||||
videos.push(ManifestVideo {
|
||||
name: t.name,
|
||||
file: t.file,
|
||||
command: t.command,
|
||||
why: t.why,
|
||||
duration_s: t.duration_s,
|
||||
fps: t.fps,
|
||||
});
|
||||
}
|
||||
None => println!(" video {} not on this disc -- skipped", m.src),
|
||||
}
|
||||
}
|
||||
prune_videos(out, &videos)?;
|
||||
|
||||
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
|
||||
// because they were measured off the running game and are on the disc in no
|
||||
// findable form, the BGM choice because HANDOFF Q10 is a negative and
|
||||
// nothing states which track a menu plays. See `authored/audio.json`.
|
||||
let mut audio = Vec::new();
|
||||
let audio_cfg = audio::load(authored_dir)?;
|
||||
match &audio_cfg {
|
||||
None => println!(" no authored/audio.json -- no audio exported"),
|
||||
Some(cfg) => {
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for a in audio::export_cues(&source, out, &cfg.se)? {
|
||||
println!(
|
||||
" se {:<8} -> {} ({})",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a)
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
for (role, spec) in &cfg.bgm {
|
||||
match audio::export_bgm(&source, out, role, spec)? {
|
||||
Some(a) => {
|
||||
println!(
|
||||
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
spec.bank,
|
||||
a.sub_waves
|
||||
);
|
||||
// HANDOFF Q10's census is "exactly two waves of
|
||||
// identical duration, 32/32 banks on the disc". When
|
||||
// `media` hands back a different number, SAY SO -- the
|
||||
// port does not get to decide that one of them is not a
|
||||
// stem, and silently summing an extra region into the
|
||||
// music is precisely the media-assembly mistake MISSION
|
||||
// section 2 names. The decoder's answer is what ships;
|
||||
// the disagreement is what gets reported.
|
||||
if a.sub_waves != 2 {
|
||||
warnings.push(format!(
|
||||
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
|
||||
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
|
||||
says a music bank is EXACTLY TWO waves of identical duration \
|
||||
(32/32 banks). All {} are summed, because choosing which to drop \
|
||||
is a decoding question and this exporter does not answer those. \
|
||||
See docs/port/BLOCKED.md.",
|
||||
a.sub_waves, spec.bank, a.sub_waves
|
||||
));
|
||||
}
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
// Not an error: the authored bank may simply not be on this
|
||||
// disc, and the export of everything else is still good.
|
||||
None => warnings.push(format!(
|
||||
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
|
||||
this disc's sound.pak -- no BGM exported for that role.",
|
||||
spec.bank
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cutscene voices are DERIVED, not authored, so this runs outside the
|
||||
// `authored/audio.json` block above: the binding comes off the disc (the
|
||||
// movie manifest in `tables.pak`), and an export with no authored audio
|
||||
// should still carry the dialogue for the movies it ships.
|
||||
//
|
||||
// A movie that resolves to no region is genuinely unvoiced and gets a
|
||||
// warning rather than a substitute -- for both movies in scope this port
|
||||
// expects a region, so a warning here is a real signal and not noise.
|
||||
{
|
||||
let source = media::DirectorySource::new(disc);
|
||||
for (stem, len) in &movie_lengths {
|
||||
// The presentation choice is AUTHORED and this block runs even when
|
||||
// there is no `authored/audio.json` -- the voice binding is decoded,
|
||||
// so the dialogue exports either way and only the choice defaults.
|
||||
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
|
||||
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
|
||||
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
|
||||
Some(a) => {
|
||||
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
|
||||
// export is known to be missing audio the game plays, and
|
||||
// the failure sounds like success: one stream decodes to
|
||||
// clean dialogue, so nobody listening finds out.
|
||||
if a.kept_waves < a.content_waves {
|
||||
warnings.push(format!(
|
||||
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
|
||||
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
|
||||
three XMA contexts, byte sizes matching the disc payloads exactly). \
|
||||
The export carries ONE. Nothing in the audio reveals this -- a \
|
||||
single stream is clean audible dialogue. Held rather than summed \
|
||||
because an equal-gain sum of channel pairs is not a downmix and \
|
||||
would be a second guess, not a fix. See authored/audio.json voice \
|
||||
and docs/port/BLOCKED.md.",
|
||||
a.file, a.sub_waves
|
||||
));
|
||||
}
|
||||
println!(
|
||||
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
|
||||
a.name,
|
||||
a.file,
|
||||
describe(&a),
|
||||
a.kept_waves,
|
||||
a.sub_waves,
|
||||
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
|
||||
);
|
||||
audio.push(ManifestAudio::from(a));
|
||||
}
|
||||
None => warnings.push(format!(
|
||||
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
@@ -506,8 +283,16 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
disc: disc.display().to_string(),
|
||||
screens,
|
||||
videos,
|
||||
audio,
|
||||
warnings,
|
||||
warnings: vec![
|
||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||
.into(),
|
||||
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||
layout child, so `is_build` cannot see them and no content rule can: element \
|
||||
count and design size both overlap with two-element fragments in other archives. \
|
||||
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
|
||||
which is a locator and not a claim -- see each one's name_why."
|
||||
.into(),
|
||||
],
|
||||
};
|
||||
std::fs::write(
|
||||
out.join("manifest.json"),
|
||||
@@ -516,81 +301,3 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
println!("wrote {}/manifest.json", out.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
impl From<audio::Exported> for ManifestAudio {
|
||||
fn from(a: audio::Exported) -> Self {
|
||||
ManifestAudio {
|
||||
kind: a.kind,
|
||||
name: a.name,
|
||||
file: a.file,
|
||||
command: a.command,
|
||||
why: a.why,
|
||||
peak_dbfs: a.peak_dbfs,
|
||||
duration_s: a.duration_s,
|
||||
incomplete: (a.kept_waves < a.content_waves).then(|| {
|
||||
format!(
|
||||
"{} of {} streams. The running game decodes all {} concurrently. \
|
||||
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
|
||||
WHICH streams are dropped and why differs per asset; the manifest \
|
||||
entry's `why` says, and it is not the same story twice.",
|
||||
a.kept_waves, a.sub_waves, a.sub_waves
|
||||
)
|
||||
}),
|
||||
name_match: a.name_match,
|
||||
loop_mode: a.loop_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two numbers worth reading on an audio line, in the console.
|
||||
///
|
||||
/// Printed rather than left to the manifest because the failure this catches is
|
||||
/// a SILENT file: the right duration, the right channel count, the right size,
|
||||
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
|
||||
/// person notices without being told to look.
|
||||
fn describe(a: &audio::Exported) -> String {
|
||||
let peak = match a.peak_dbfs {
|
||||
Some(p) => format!("peak {p:.1} dBFS"),
|
||||
None => "peak unmeasured".into(),
|
||||
};
|
||||
match a.duration_s {
|
||||
Some(d) => format!("{d:.3} s, {peak}"),
|
||||
None => peak,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Delete anything in `video/` this run did not produce.
|
||||
///
|
||||
/// `video/` is the one directory the wholesale wipe spares, so that the
|
||||
/// transcode cache survives to be consulted. This restores the guarantee the
|
||||
/// wipe exists for: a movie that stops being exported stops existing, rather
|
||||
/// than lingering as a file the manifest no longer lists.
|
||||
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
|
||||
let dir = out.join("video");
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut keep: Vec<String> = Vec::new();
|
||||
for v in kept {
|
||||
if let Some(name) = Path::new(&v.file).file_name() {
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
keep.push(name.clone());
|
||||
// The cache sidecar goes with the file it stamps.
|
||||
if let Some(stem) = Path::new(&name).file_stem() {
|
||||
keep.push(format!("{}.cmd", stem.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if keep.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
println!(" video {name} is no longer exported -- removed");
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -103,14 +103,6 @@ pub struct FocusElement {
|
||||
pub id: String,
|
||||
pub declared: String,
|
||||
pub sprite: Option<String>,
|
||||
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
|
||||
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
|
||||
///
|
||||
/// A leaf's sprite may live in the leaf's own table or in the parent
|
||||
/// bundle's, so the bit is looked up in the same two places, in the same
|
||||
/// order, that the PNG is written from.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend_additive: Option<bool>,
|
||||
pub pivot: [u32; 2],
|
||||
pub rest: Rest,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
@@ -120,34 +112,6 @@ pub struct FocusElement {
|
||||
pub struct Focus {
|
||||
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
|
||||
pub record: String,
|
||||
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
|
||||
/// units — which is not the same thing as the last keyframe's time.
|
||||
///
|
||||
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
|
||||
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
|
||||
/// the period from the largest keyframe time — what the port did until now —
|
||||
/// runs it 14 % fast and deletes the dark rest entirely.
|
||||
///
|
||||
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
|
||||
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
|
||||
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
|
||||
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
|
||||
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
|
||||
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
|
||||
/// bare until 2026-09-01, which is a population-scoped statistic reported
|
||||
/// without its population):
|
||||
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
|
||||
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
|
||||
/// their own last pose**; on the eight records this port animates, seven
|
||||
/// exact and `ptbtn00f` the one hold.
|
||||
///
|
||||
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
|
||||
/// held its own guard and byte read, because the field was decoded in an
|
||||
/// example and a test and exposed in no public API on any ref. It is now
|
||||
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
|
||||
/// the local copy is deleted — the doc comment that promised that deletion
|
||||
/// is the only reason it did not quietly become permanent.
|
||||
pub loop_length_units: Option<u32>,
|
||||
/// Back-to-front, in the leaf's own declaration order.
|
||||
pub elements: Vec<FocusElement>,
|
||||
}
|
||||
@@ -177,59 +141,6 @@ pub struct Element {
|
||||
/// convention and a consumer may still want the bare highlight texture.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<Focus>,
|
||||
|
||||
/// This element's own `.rat` leaf, when its declared name is itself a
|
||||
/// record in the bundle.
|
||||
///
|
||||
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
|
||||
/// the title declare scale 100 % and rotation 0 at the parent, and their
|
||||
/// leaves declare **(100, 600) at +30°** and **(100, 800) at −45°** — and
|
||||
/// the leaves *move*, x from −639 → 1521 and 1721 → −839. `ui_layout`'s own
|
||||
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
|
||||
/// records, which the census never opened."* Neither did this exporter: it
|
||||
/// opened a leaf only for a FOCUS record, via `highlight_name`.
|
||||
///
|
||||
/// That omission is measurable. It is the whole of the title's 1.82 %
|
||||
/// disagreement with the oracle — the port draws two 400 px sprites upright
|
||||
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
|
||||
/// quads across the frame at opposite leans.
|
||||
///
|
||||
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
|
||||
/// ramp on a different span — parent 0→255 over t=70…238, leaf
|
||||
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
|
||||
/// question and not the port's to answer. The data is exported so it stops
|
||||
/// being invisible; `ScreenView` ignores it until the composition rule is
|
||||
/// known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leaf: Option<Focus>,
|
||||
|
||||
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
|
||||
/// what the game draws.
|
||||
///
|
||||
/// Decided here rather than in the runtime because it is disc knowledge.
|
||||
/// The Decoder's rule: *"the discriminator is which record carries the
|
||||
/// geometry, not a fixed order"* — and the census over this export splits
|
||||
/// cleanly, with no ambiguous middle:
|
||||
///
|
||||
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
|
||||
/// exactly. That is the BASE-record case `screen.rs` already handled: the
|
||||
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
|
||||
/// y=402) and the parent wins. Flag is false; nothing changes.
|
||||
/// * **16 differ**, and all of them differ in scale or rotation, not by a
|
||||
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
|
||||
/// and (100,800) at −45° against an identity parent), two
|
||||
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
|
||||
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
|
||||
///
|
||||
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
|
||||
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
|
||||
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
|
||||
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
|
||||
/// measured. The other two are the same shape and are **not** separately
|
||||
/// confirmed; they are flagged so the harness can adjudicate them rather
|
||||
/// than being asserted.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub leaf_carries_geometry: bool,
|
||||
/// The raw `opt ` link inside this element's `.rat` record.
|
||||
///
|
||||
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
||||
@@ -250,23 +161,6 @@ pub struct Element {
|
||||
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
|
||||
/// `"implied"` = **measured off the running game**, for elements that carry
|
||||
/// no header. `"none"` = neither; sorts last.
|
||||
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
|
||||
/// `0x02`.
|
||||
///
|
||||
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
|
||||
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
|
||||
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
|
||||
/// A name-keyed map cannot answer for a screen nobody drove the game to,
|
||||
/// which is why the port was drawing the English menus additive and the
|
||||
/// Japanese ones alpha-over — asserting by omission that the JP build
|
||||
/// blends differently. The bit is on the disc for every screen at once.
|
||||
///
|
||||
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
|
||||
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
|
||||
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
|
||||
/// 0 of 14 additive elements set it and 9 non-additive ones do.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend_additive: Option<bool>,
|
||||
pub layer_source: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
@@ -307,35 +201,6 @@ pub struct Screen {
|
||||
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
|
||||
/// and not to be trusted for anything else.
|
||||
pub buttons: Vec<String>,
|
||||
|
||||
/// The instant every element of this screen is settled at, and the width of
|
||||
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
|
||||
/// units, absent when the screen has fewer than two keyframe times.
|
||||
///
|
||||
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
|
||||
/// each element at its own `rest()` is right for anything that ends the
|
||||
/// screen settled and **exactly wrong for a transient**: the title's
|
||||
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t54–56, 0
|
||||
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
|
||||
/// it burning forever. There are five of these, and `rest()` draws all five
|
||||
/// at once, saturating the light arc.
|
||||
///
|
||||
/// The window is the **longest interval containing no keyframe time**, over
|
||||
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
|
||||
/// that exclusion is what reproduces the Decoder's independently computed
|
||||
/// `[160, 236]` for the title: including the `ptloop` leaves gives
|
||||
/// `[269, 540]` instead.
|
||||
///
|
||||
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
|
||||
/// export the widths split with nothing in between — `press_start` 214,
|
||||
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
|
||||
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
|
||||
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
|
||||
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
|
||||
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
|
||||
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settle_window: Option<[i64; 3]>,
|
||||
/// What this file does not answer. A consumer needing one of these must get
|
||||
/// it from `authored/`.
|
||||
pub unresolved: Vec<&'static str>,
|
||||
@@ -451,76 +316,6 @@ pub fn export_build(
|
||||
// Contrast with a BASE record, where the leaf duplicates the parent's
|
||||
// placement and the two can differ by a unit (ptbtn04: parent y=401,
|
||||
// leaf y=402). There the parent wins. Here there is no parent.
|
||||
// Reads one record in the bundle as a nested build and returns its
|
||||
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
|
||||
// element whose OWN declared name is a record (`ptloop01.rat`). One
|
||||
// implementation, because the second case was missing for eight
|
||||
// milestones and a second copy is how it would go missing again.
|
||||
let read_leaf = |rec: &str,
|
||||
written: &mut std::collections::BTreeMap<String, ()>,
|
||||
missing: &mut Vec<String>|
|
||||
-> Result<Option<Focus>> {
|
||||
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
|
||||
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut fes = Vec::new();
|
||||
for fe in &leaf.elements {
|
||||
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
|
||||
let mut fsprite = None;
|
||||
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|
||||
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
|
||||
{
|
||||
fsprite = Some(sprite_rel(sp));
|
||||
} else if sp.ends_with(".t32") {
|
||||
missing.push(sp.to_string());
|
||||
}
|
||||
let Some(r) = fe.rest() else { continue };
|
||||
fes.push(FocusElement {
|
||||
id: id_of(&fe.name),
|
||||
declared: fe.name.clone(),
|
||||
sprite: fsprite,
|
||||
blend_additive: ui_layout::blend_additive_by_name(
|
||||
&leaf, &bundle[off..off + size], sp)
|
||||
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
|
||||
pivot: [fe.pivot_x, fe.pivot_y],
|
||||
rest: Rest {
|
||||
pos: [r.x, r.y],
|
||||
scale: [r.scale_x, r.scale_y],
|
||||
tint_rgba: hex32(r.tint),
|
||||
fade_argb: hex32(r.fade),
|
||||
rotation_deg: r.rotation_deg,
|
||||
t: r.time,
|
||||
},
|
||||
keyframes: fe
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|k| Keyframe {
|
||||
t: k.time,
|
||||
pos: [k.x, k.y],
|
||||
scale: [k.scale_x, k.scale_y],
|
||||
tint_rgba: hex32(k.tint),
|
||||
fade_argb: hex32(k.fade),
|
||||
rotation_deg: k.rotation_deg,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
Ok(if fes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Focus {
|
||||
record: rec.to_string(),
|
||||
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
|
||||
elements: fes,
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
// An element whose own declared name is a record in this bundle carries
|
||||
// its geometry THERE, not in its parent entry. See `Element::leaf`.
|
||||
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
|
||||
|
||||
let mut focus = None;
|
||||
if let Some(rec) = highlight_name(&el.name) {
|
||||
if let Some(&(off, size)) = b.records.get(&rec) {
|
||||
@@ -546,9 +341,6 @@ pub fn export_build(
|
||||
id: id_of(&fe.name),
|
||||
declared: fe.name.clone(),
|
||||
sprite: fsprite,
|
||||
blend_additive: ui_layout::blend_additive_by_name(
|
||||
&leaf, &bundle[off..off + size], sp)
|
||||
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
|
||||
pivot: [fe.pivot_x, fe.pivot_y],
|
||||
rest: Rest {
|
||||
pos: [r.x, r.y],
|
||||
@@ -573,11 +365,7 @@ pub fn export_build(
|
||||
});
|
||||
}
|
||||
if !fes.is_empty() {
|
||||
focus = Some(Focus {
|
||||
record: rec,
|
||||
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
|
||||
elements: fes,
|
||||
});
|
||||
focus = Some(Focus { record: rec, elements: fes });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,21 +397,10 @@ pub fn export_build(
|
||||
sprite: sprite_out,
|
||||
focus_sprite,
|
||||
focus,
|
||||
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
|
||||
let p = el.rest();
|
||||
l.elements.iter().any(|le| {
|
||||
p.is_none_or(|p| {
|
||||
le.rest.scale != [p.scale_x, p.scale_y]
|
||||
|| le.rest.rotation_deg != p.rotation_deg
|
||||
})
|
||||
})
|
||||
}),
|
||||
leaf,
|
||||
opt_link: el.focus_link.clone(),
|
||||
pivot: [el.pivot_x, el.pivot_y],
|
||||
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
|
||||
parent: el.parent,
|
||||
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
|
||||
layer_source,
|
||||
layer,
|
||||
focused: el.focused,
|
||||
@@ -650,12 +427,6 @@ pub fn export_build(
|
||||
.collect();
|
||||
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
|
||||
let window = settle_window(&elements);
|
||||
let order = forced_backdrop_first(
|
||||
ui_layout::derived_paint_order(&b, bundle),
|
||||
&elements,
|
||||
[b.design_w, b.design_h],
|
||||
);
|
||||
let screen = Screen {
|
||||
format: "sylpheed.screen/3",
|
||||
exporter: exporter.to_string(),
|
||||
@@ -670,9 +441,8 @@ pub fn export_build(
|
||||
name_why,
|
||||
design: [b.design_w, b.design_h],
|
||||
elements,
|
||||
paint_order: order,
|
||||
paint_order: ui_layout::derived_paint_order(&b, bundle),
|
||||
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
|
||||
settle_window: window,
|
||||
unresolved: vec![
|
||||
// The time unit is measured off the running game, not on the disc.
|
||||
"keyframe_time_unit",
|
||||
@@ -703,234 +473,3 @@ pub fn export_build(
|
||||
missing,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
|
||||
///
|
||||
/// See [`Screen::settle_window`] for why this is the settled instant and why
|
||||
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
|
||||
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
|
||||
let mut times: Vec<i64> = elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
|
||||
.collect();
|
||||
times.sort_unstable();
|
||||
times.dedup();
|
||||
if times.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
|
||||
//
|
||||
// The widest keyframe-free interval is only a settled state if the screen is
|
||||
// actually PRESENTING something across it. `press_start` is the case that
|
||||
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
|
||||
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
|
||||
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
|
||||
// and the runtime then answered every question about that screen at t=107.
|
||||
// The result was that the PRESS (A) plate could not be drawn at any instant
|
||||
// at all, including the boot's own end state, whose entire purpose is to
|
||||
// show it.
|
||||
//
|
||||
// The fix is not a tuned threshold: it is that the heuristic was reading an
|
||||
// interval where the screen is BLANK as the interval where it has arrived.
|
||||
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
|
||||
// under the runtime's 30-unit bar, so it falls back to each element's own
|
||||
// hold -- which is the plate, opaque, exactly as the disc declares it.
|
||||
//
|
||||
// ⚠️ This does not disturb the windows the settle instant was measured on.
|
||||
// `title` keeps [160, 236]: elements are visible across it, and the
|
||||
// Decoder's draw stream independently found the game's clock freezing in
|
||||
// that same interval.
|
||||
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
|
||||
let (a, b) = times
|
||||
.windows(2)
|
||||
.map(|w| (w[0], w[1]))
|
||||
.filter(|(a, b)| visible_at((a + b) / 2))
|
||||
.max_by_key(|(a, b)| b - a)?;
|
||||
Some([a, b, (a + b) / 2])
|
||||
}
|
||||
|
||||
|
||||
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
|
||||
fn alpha_at(e: &Element, t: i64) -> u8 {
|
||||
let ks = &e.keyframes;
|
||||
let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
|
||||
.unwrap_or(0) >> 24) as i64;
|
||||
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
|
||||
if timed.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if t <= timed[0].t.unwrap() as i64 {
|
||||
return a(timed[0]) as u8;
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
|
||||
if t < t1 {
|
||||
if t1 <= t0 {
|
||||
return a(w[0]) as u8;
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
|
||||
}
|
||||
}
|
||||
a(timed[timed.len() - 1]) as u8
|
||||
}
|
||||
|
||||
/// Scale of one element at instant `t`, in percent per axis, under the same
|
||||
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
|
||||
/// that animates passes through every value between its keyframes.
|
||||
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
|
||||
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
|
||||
if timed.is_empty() {
|
||||
return [100.0, 100.0];
|
||||
}
|
||||
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
|
||||
if t <= timed[0].t.unwrap() as i64 {
|
||||
return [g(timed[0], 0), g(timed[0], 1)];
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
|
||||
if t < t1 {
|
||||
if t1 <= t0 {
|
||||
return [g(w[0], 0), g(w[0], 1)];
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
return [
|
||||
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
|
||||
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
|
||||
];
|
||||
}
|
||||
}
|
||||
let l = timed[timed.len() - 1];
|
||||
[g(l, 0), g(l, 1)]
|
||||
}
|
||||
|
||||
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
|
||||
/// file forces it there.
|
||||
///
|
||||
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
|
||||
/// *an element that covers the screen and is fully opaque at some instant cannot
|
||||
/// paint above anything visible at that instant; where the elements visible
|
||||
/// during its opaque span are ALL of them, its position is forced to first.*
|
||||
///
|
||||
/// It was found because `build_12`/`build_15` are **black at every instant** of
|
||||
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
|
||||
/// 39 instants while all 9 other elements live and die inside that span. A
|
||||
/// screen that is black for its whole life is impossible on its face, which is
|
||||
/// the only kind of check that survives two renderers sharing an assumption:
|
||||
/// `sylpheed-cli` agreed with the port here because it agreed about
|
||||
/// `implied_layer_key`.
|
||||
///
|
||||
/// Two measured controls, both prior orders off the running game:
|
||||
///
|
||||
/// | primitive | measured | opaque instants | forced below | |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
|
||||
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
|
||||
///
|
||||
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
|
||||
/// matches 77 of 80 and fails on exactly the three families that cross it —
|
||||
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
|
||||
/// an overlay* and is measured painting first. The name is not the rule.
|
||||
///
|
||||
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
|
||||
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
|
||||
/// `.t32` textures must sort first *against their own layer keys*. **An
|
||||
/// element's alpha says nothing about whether its texture covers the screen** —
|
||||
/// most of a sprite may be transparent.
|
||||
///
|
||||
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
|
||||
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
|
||||
/// ordering — it says nothing about elements that are constrained but not
|
||||
/// forced. Delete this when a pinned `sylpheed-formats` does it.
|
||||
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
|
||||
let screen_end: i64 = elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
|
||||
.map(i64::from)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let forced: Vec<usize> = elements
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
|
||||
// sprite". Those coincide in GP_TITLE and the distinction is still
|
||||
// the whole point, because the negative test guards a SYMPTOM.
|
||||
//
|
||||
// The rule needs the element's alpha to BE its pixels' alpha. That
|
||||
// is true of a `.prm` solid quad and of nothing else. The Decoder
|
||||
// found this the expensive way twice: first `.t32` sprites (an
|
||||
// element's alpha says nothing about a texture that is mostly
|
||||
// transparent), guarded with "no sprite" -- and then `.tbm`, which
|
||||
// is 38 of their 80 forced-first verdicts and declares fade
|
||||
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
|
||||
// make the screen white; no screen is white, so a `.tbm`'s white is
|
||||
// a modulation ON a texture and its element alpha proves nothing
|
||||
// about coverage either.
|
||||
//
|
||||
// "No sprite" would keep admitting a `.tbm` that this exporter
|
||||
// happens not to emit a sprite for. `role == "primitive"` cannot.
|
||||
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
|
||||
// full-screen element here is `.prm` and pure black, checked -- so
|
||||
// this changes no verdict today and is a guard against a corpus
|
||||
// that grows.
|
||||
// Cheap prefilter only -- the binding coverage test is per-instant,
|
||||
// in `covers` below. An element scaled ABOVE 100 could cover the
|
||||
// screen from a smaller declared size, so this deliberately does
|
||||
// not reject on size.
|
||||
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
|
||||
})
|
||||
.filter(|(i, e)| {
|
||||
let span: Vec<i64> = e
|
||||
.keyframes
|
||||
.iter()
|
||||
.filter_map(|k| k.t)
|
||||
.map(i64::from)
|
||||
.collect();
|
||||
let Some(&lo) = span.first() else { return false };
|
||||
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
|
||||
// Declared size alone is not what the element draws: scale is a
|
||||
// percent per axis and it animates. `pbafc.prm` is the disc's own
|
||||
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
|
||||
// about 17x18 px, a moving glint rather than a wash. A rule that
|
||||
// read its declared size would call it screen-covering.
|
||||
//
|
||||
// Nothing in GP_TITLE needs this: every layerless full-screen
|
||||
// element here is at scale 100 on every keyframe, so no verdict
|
||||
// moves. It is in because the data that would break it exists on
|
||||
// this disc, which is a better reason than a failure would have been.
|
||||
let covers = |t: i64| {
|
||||
let sc = scale_at(e, t);
|
||||
e.size.is_some_and(|s| {
|
||||
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
|
||||
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
|
||||
})
|
||||
};
|
||||
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
|
||||
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
|
||||
// that shows why: it declares ONE keyframe, opaque black full-screen
|
||||
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
|
||||
// a single-instant event instead of the thing that is on screen for
|
||||
// the whole splash. So the span runs to the SCREEN's last keyframe.
|
||||
let hi = screen_end.max(*span.last().unwrap());
|
||||
let opaque: Vec<i64> = (lo..=hi)
|
||||
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
|
||||
.collect();
|
||||
if opaque.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Every OTHER element must be visible somewhere inside that span.
|
||||
elements.iter().enumerate().all(|(j, o)| {
|
||||
j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
|
||||
})
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if forced.is_empty() {
|
||||
return order;
|
||||
}
|
||||
let mut out = forced.clone();
|
||||
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -63,34 +63,8 @@ pub const MOVIES: &[Movie] = &[
|
||||
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
|
||||
/// default *is* this matrix; the point is that the manifest now says so.
|
||||
///
|
||||
/// # 🔴 This is NOT the matrix MISSION §6 pins, and that was never said out loud
|
||||
///
|
||||
/// MISSION §6 records a **human decision of 2026-08-29** fixing the fold at
|
||||
/// `FL = 1.0·FL + 0.707·FC + 0.707·BL` (plus 7.1 terms a 5.1 source does not
|
||||
/// have). This constant is that matrix scaled by 0.4142 — the same relative
|
||||
/// weighting, **7.65 dB quieter** — and until now nothing in the code, the
|
||||
/// manifest or the docs said so. Recording the command you ran does not disclose
|
||||
/// that it is not the command you were given.
|
||||
///
|
||||
/// The original justification for the deviation was *"the unnormalised form
|
||||
/// clips: peak 0.0 dBFS"*, and that is a peak reading — the instrument
|
||||
/// `docs/port/BLOCKED.md` records this port declaring unfit for the clipping
|
||||
/// question, because one sample at full scale and two seconds of square wave
|
||||
/// give the same number. Re-measured properly (float decode, whole file, count
|
||||
/// the samples that would clamp):
|
||||
///
|
||||
/// | | peak | ≥ full scale | > +1 dB over | longest run |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `ADV`, MISSION §6 | **+4.26 dBFS** | 4 406 / 13 187 900 | 1 874 | 0.333 ms |
|
||||
/// | `S00A`, MISSION §6 | −1.34 dBFS | **0** | 0 | — |
|
||||
///
|
||||
/// So the pin really does overload `ADV` — and this constant is over-broad,
|
||||
/// because `S00A` never needed it. The smallest single scalar under which
|
||||
/// neither clamps is `1/1.6339 = 0.612`, +3.39 dB on today.
|
||||
///
|
||||
/// **Not changed here.** The level of a mix is what §6 reserves to a human
|
||||
/// (*"adjust it deliberately, as a commit"*), so the export carries a warning
|
||||
/// with these numbers instead. See `docs/port/DECISIONS.md`.
|
||||
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
|
||||
/// why the normalisation is here rather than the textbook coefficients.
|
||||
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
|
||||
|
||||
/// How many audio channels the source declares.
|
||||
@@ -106,34 +80,6 @@ fn channels(src: &Path) -> Result<u32> {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
|
||||
}
|
||||
|
||||
/// Duration and frame rate of a finished transcode, straight from the file.
|
||||
///
|
||||
/// Probed from the OUTPUT, not the source: what the runtime will play is this
|
||||
/// file, and the two differ — `ADV` is 137.44 s against a 137.71 s source.
|
||||
/// Returns zeros rather than failing, because a missing number should make the
|
||||
/// runtime say "unknown", not stop an export that otherwise succeeded.
|
||||
fn probe_timebase(out: &Path) -> (f64, f64) {
|
||||
let probe = |entries: &str, stream: bool| -> String {
|
||||
let mut c = Command::new("ffprobe");
|
||||
c.args(["-v", "error"]);
|
||||
if stream {
|
||||
c.args(["-select_streams", "v:0"]);
|
||||
}
|
||||
c.args(["-show_entries", entries, "-of", "csv=p=0"]).arg(out);
|
||||
c.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let secs = probe("format=duration", false).parse().unwrap_or(0.0);
|
||||
// `r_frame_rate` is a rational, "30/1".
|
||||
let rate = probe("stream=r_frame_rate", true);
|
||||
let fps = match rate.split_once('/') {
|
||||
Some((n, d)) => n.parse::<f64>().unwrap_or(0.0) / d.parse::<f64>().unwrap_or(1.0),
|
||||
None => rate.parse().unwrap_or(0.0),
|
||||
};
|
||||
(secs, fps)
|
||||
}
|
||||
|
||||
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
|
||||
let mut v: Vec<String> = [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
@@ -162,30 +108,6 @@ pub struct Transcoded {
|
||||
pub file: String,
|
||||
pub command: String,
|
||||
pub why: &'static str,
|
||||
/// The transcode's own duration and frame rate, probed from the file that
|
||||
/// was just written.
|
||||
///
|
||||
/// Recorded so the RUNTIME can say what it actually presented.
|
||||
///
|
||||
/// 🔴 CORRECTED 2026-09-01. This read: *"Godot's video player drops frames to
|
||||
/// hold its schedule, and it drops a lot of them here — measured at 28 % of [refuted]
|
||||
/// `S00A`'s frames presented and 47 % of `ADV`'s"*. **Both numbers are
|
||||
/// retracted.** They came from CONTENDED runs, and the counter is an upper
|
||||
/// bound on ENGINE frames that is vacuous once the engine outruns the stream
|
||||
/// — quiet, `ADV` draws 6 480 frames across a 4 123-frame video. On a quiet
|
||||
/// box the bound is 88–90 % for `S00A`, and playback runs **+6.7 %…+6.9 %**
|
||||
/// long for both films. What survives is that elapsed seconds hide whatever
|
||||
/// the player does, which is why the count is in the manifest. Without a frame count in the manifest a run can only
|
||||
/// report elapsed seconds, and elapsed seconds are exactly what stays
|
||||
/// plausible while three frames in four go missing.
|
||||
///
|
||||
/// 🔴 This field exists because the port asserted the opposite. The claim was
|
||||
/// *"a player that runs long decoded everything"*, argued from the absence of
|
||||
/// an overrun rather than measured; the measurement was four lines and
|
||||
/// refuted it. **The instrument is now permanent so the argument cannot be
|
||||
/// made again from a run that never counted.**
|
||||
pub duration_s: f64,
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
/// Transcode one movie, skipping the encode when the output already exists and
|
||||
@@ -209,35 +131,10 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
|
||||
let argv = args(&src, &ogv, ch);
|
||||
let command = format!("ffmpeg {}", argv.join(" "));
|
||||
let size = std::fs::metadata(&src)?.len();
|
||||
// The sidecar SAYS WHAT IT IS. It sits in the modder-facing asset tree next
|
||||
// to the `.ogv`, and MODDING rule 2's principle is that a generated file
|
||||
// should be tellable from a hand-made one by reading it -- a bare ffmpeg
|
||||
// line beside a video looks like something a modder should edit or delete.
|
||||
//
|
||||
// The header is NOT part of the cache key: `fresh` compares only the lines
|
||||
// that describe the encode. Otherwise rewording this comment would re-encode
|
||||
// four minutes of video to no purpose, which is a cache that punishes
|
||||
// documentation.
|
||||
let key = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
|
||||
let want = format!(
|
||||
"# Generated by sylpheed-export. NOT an asset and not hand-editable: this\n\
|
||||
# records how {}.ogv beside it was encoded, so a re-export can skip the\n\
|
||||
# encode when the source and the command are both unchanged. Deleting it\n\
|
||||
# only forces one re-encode. To change the video, override the .ogv under\n\
|
||||
# data/mods/ (MODDING rule 4) -- editing this file changes nothing.\n{key}",
|
||||
m.stem
|
||||
);
|
||||
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
|
||||
|
||||
let cache_key = |s: &str| -> String {
|
||||
s.lines()
|
||||
.filter(|l| !l.starts_with('#'))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let fresh = ogv.exists()
|
||||
&& std::fs::read_to_string(&stamp)
|
||||
.map(|s| cache_key(&s) == cache_key(&want))
|
||||
.unwrap_or(false);
|
||||
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
|
||||
if !fresh {
|
||||
// Encode to a temp name and rename on success. A reader that catches
|
||||
// this mid-write sees no file at all rather than a valid-looking one
|
||||
@@ -258,26 +155,12 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
|
||||
bail!("ffmpeg failed on {}", m.src);
|
||||
}
|
||||
std::fs::rename(&partial, &ogv)?;
|
||||
}
|
||||
// Refresh the sidecar whenever its TEXT differs, encode or no encode.
|
||||
//
|
||||
// It used to be written only inside the `!fresh` branch, which is right for
|
||||
// the cache and wrong for the file: a change to the header alone -- the part
|
||||
// deliberately excluded from the key -- would then never reach an existing
|
||||
// export, because nothing that reads the header can trigger the write that
|
||||
// updates it. The explanation would be correct in the source and absent on
|
||||
// disc, which is the same shape as every other documented-but-unexercised
|
||||
// thing this port has had to find the hard way.
|
||||
if std::fs::read_to_string(&stamp).map(|s| s != want).unwrap_or(true) {
|
||||
std::fs::write(&stamp, &want)?;
|
||||
}
|
||||
let (duration_s, fps) = probe_timebase(&ogv);
|
||||
Ok(Some(Transcoded {
|
||||
name: m.stem.to_string(),
|
||||
file: format!("video/{}.ogv", m.stem),
|
||||
command,
|
||||
why: m.why,
|
||||
duration_s,
|
||||
fps,
|
||||
}))
|
||||
}
|
||||
|
||||
38
crates/sylpheed-formats/examples/_all2.rs
Normal file
38
crates/sylpheed-formats/examples/_all2.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
println!("entry {i}: {} elements, {} records, {} sprites",
|
||||
b.elements.len(), b.records.len(), b.sprites.len());
|
||||
let mut rk:Vec<&String>=b.records.keys().collect(); rk.sort();
|
||||
println!(" records: {:?}", rk);
|
||||
for (rn,&(o,sz)) in &b.records {
|
||||
if o+sz>by.len() { continue }
|
||||
let Some(lb)=ui_layout::parse_build(&by[o..o+sz]) else { continue };
|
||||
println!(" RECORD {rn}: {} elements", lb.elements.len());
|
||||
for le in &lb.elements {
|
||||
let lts:Vec<String>=le.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" [{}] {:<22} kind=0x{:<6x} {}", le.index, le.name, le.kind, lts.join(" "));
|
||||
}
|
||||
}
|
||||
for e in &b.elements {
|
||||
let ts:Vec<String>=e.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" [{}] {:<24} kind=0x{:<6x} {}", e.index, e.name, e.kind, ts.join(" "));
|
||||
if let Some(&(o,s))=b.records.get(&e.name) {
|
||||
if o+s<=by.len() {
|
||||
if let Some(lb)=ui_layout::parse_build(&by[o..o+s]) {
|
||||
for le in &lb.elements {
|
||||
let lts:Vec<String>=le.keyframes.iter()
|
||||
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
|
||||
println!(" leaf {:<20} kind=0x{:<6x} {}", le.name, le.kind, lts.join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/sylpheed-formats/examples/_keys.rs
Normal file
21
crates/sylpheed-formats/examples/_keys.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
println!("=== entry {i} ===");
|
||||
let order=ui_layout::derived_paint_order(&b,&by);
|
||||
for e in &b.elements {
|
||||
let k=ui_layout::sprite_layer_key(&b,&by,e);
|
||||
let pos=order.iter().position(|&x|x==e.index);
|
||||
println!(" [{}] {:<24} kind=0x{:<5x} sprite={:<24} key={:<12} paint#{:?}",
|
||||
e.index, e.name, e.kind,
|
||||
e.sprite.clone().unwrap_or_else(||"<none>".into()),
|
||||
k.map(|v|format!("0x{v:08x}")).unwrap_or_else(||"NONE".into()), pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
crates/sylpheed-formats/examples/_pbafc.rs
Normal file
39
crates/sylpheed-formats/examples/_pbafc.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
fn main(){
|
||||
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
|
||||
let ar=pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap();
|
||||
for (i,e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
let Some(p)=b.elements.iter().find(|el|el.name=="pbafc.prm") else { continue };
|
||||
println!("=== entry {i}: {} elements ===", b.elements.len());
|
||||
println!(" pbafc.prm pivot=({},{}) -> {}x{}", p.pivot_x,p.pivot_y,p.pivot_x*2,p.pivot_y*2);
|
||||
for k in &p.keyframes {
|
||||
println!(" t={:<5} fade={:08x} a={:<4} xy=({},{}) s={}/{}",
|
||||
k.time.map(|v|v as i64).unwrap_or(-1), k.fade, k.fade>>24, k.x,k.y,k.scale_x,k.scale_y);
|
||||
}
|
||||
// what does it cover, and is anything visible while it is opaque?
|
||||
let rest=p.rest().unwrap();
|
||||
let (px,py,pw,ph)=(rest.x, rest.y, (p.pivot_x*2) as i32, (p.pivot_y*2) as i32);
|
||||
println!(" its rect at rest: ({px},{py}) {pw}x{ph}");
|
||||
let tmax=b.elements.iter().flat_map(|e|e.keyframes.iter().filter_map(|k|k.time)).max().unwrap_or(0);
|
||||
let op:Vec<u32>=(0..=tmax).filter(|&t|p.pose_at(t).map(|k|k.fade>>24)==Some(255)).collect();
|
||||
println!(" opaque at {} instants (t={:?}..{:?}) of 0..{tmax}", op.len(), op.first(), op.last());
|
||||
let mut cov=0; let mut vis=0;
|
||||
for o in &b.elements {
|
||||
if o.index==p.index { continue }
|
||||
let Some(ok)=o.rest() else { continue };
|
||||
let (ow,oh)=((o.pivot_x*2) as i32,(o.pivot_y*2) as i32);
|
||||
let overlap=(px+pw).min(ok.x+ow)-px.max(ok.x)>0 && (py+ph).min(ok.y+oh)-py.max(ok.y)>0;
|
||||
if !overlap { continue }
|
||||
cov+=1;
|
||||
if op.iter().any(|&t| o.pose_at(t).map(|k|k.fade>>24).unwrap_or(0)>0) {
|
||||
vis+=1;
|
||||
if vis<=6 { println!(" covered AND visible while opaque: {}", o.name); }
|
||||
}
|
||||
}
|
||||
println!(" elements its rect covers: {cov}; visible while it is opaque: {vis}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
27
crates/sylpheed-formats/examples/_rechdr.rs
Normal file
27
crates/sylpheed-formats/examples/_rechdr.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
let top=u32::from_be_bytes(by[8..12].try_into().unwrap());
|
||||
println!("entry {i:2} TOP +08 = {top}");
|
||||
let mut ks:Vec<&String>=b.records.keys().collect(); ks.sort();
|
||||
for rn in ks {
|
||||
let &(o,s)=b.records.get(rn).unwrap();
|
||||
if o+16>by.len() { continue }
|
||||
let magic=&by[o..o+4];
|
||||
let h4=u32::from_be_bytes(by[o+4..o+8].try_into().unwrap());
|
||||
let h8=u32::from_be_bytes(by[o+8..o+12].try_into().unwrap());
|
||||
let maxt=ui_layout::parse_build(&by[o..o+s])
|
||||
.map(|lb|lb.elements.iter().flat_map(|e|e.keyframes.iter()
|
||||
.filter_map(|k|k.time)).max().unwrap_or(0)).unwrap_or(0);
|
||||
println!(" {rn:<24} magic={:?} +04={:08x}({:.1}) +08={h8:<6} max keyframe t={maxt} ratio={:.4}",
|
||||
String::from_utf8_lossy(magic), h4, h4 as f64/65536.0,
|
||||
if maxt>0 {h8 as f64/maxt as f64} else {0.0});
|
||||
}
|
||||
}
|
||||
}
|
||||
53
crates/sylpheed-formats/examples/adv_region_extend.rs
Normal file
53
crates/sylpheed-formats/examples/adv_region_extend.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Does `resolve_movie_voice_region` start LATE, and by exactly how much?
|
||||
//!
|
||||
//! The port agent's arithmetic: the running decoder's three `ADV` XMA contexts sum
|
||||
//! to **3 584 000** payload bytes, but the resolved voice region is **3 114 352** —
|
||||
//! 15 % too small to hold them. One of the two spans is not what the other thinks
|
||||
//! it is, and the disc side is this crate's.
|
||||
//!
|
||||
//! The gap is exact. `ctx0` declares **632** packets (1 294 336 B); the leading
|
||||
//! chunk the resolver yields has **394** (806 912 B). The difference is **238
|
||||
//! packets = 487 424 B**, a whole number of packets — which is what a start offset
|
||||
//! looks like, not corruption.
|
||||
//!
|
||||
//! So: walk the region start backwards and report where `to_xma_riffs` first
|
||||
//! reproduces the decoder's own three sizes. The probe's byte_sizes are the
|
||||
//! control — this is not free to fit, it either lands on them or it does not.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example adv_region_extend
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
/// What the running decoder reported (docs/re/structures/voice-three-streams-are-concurrent.md).
|
||||
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let (start, end) =
|
||||
media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English).expect("region");
|
||||
println!("resolver says {start}..{end} ({} B)", end - start);
|
||||
println!("decoder wants {:?} = {} B payload\n", WANT, WANT.iter().sum::<usize>());
|
||||
|
||||
for back_packets in [0usize, 100, 200, 237, 238, 239, 300, 400] {
|
||||
let back = (back_packets * 2048) as u64;
|
||||
if back > start {
|
||||
continue;
|
||||
}
|
||||
let s = start - back;
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", s, (end - s) as usize) else {
|
||||
println!("-{back_packets:4} packets: unreadable");
|
||||
continue;
|
||||
};
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
let sizes: Vec<usize> = riffs.iter().map(|r| r.len() - 60).collect();
|
||||
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
|
||||
println!(
|
||||
"-{back_packets:4} packets (start {s}): {} chunk(s) {:?}{}",
|
||||
riffs.len(),
|
||||
sizes,
|
||||
if hit { " <== MATCHES THE DECODER" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
crates/sylpheed-formats/examples/adv_voice_dump.rs
Normal file
41
crates/sylpheed-formats/examples/adv_voice_dump.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Dump `ADV`'s voice chunks as RIFF/XMA, so each can be decoded and identified.
|
||||
//!
|
||||
//! `intro-audio-decomposed.md` measured that the intro's output is the movie's own
|
||||
//! WMA Pro 5.1 track at 0.600 **plus** three streams occupying a front pair, a
|
||||
//! centre (with a silent partner) and a rear pair. What it could **not** say is
|
||||
//! *which* stream sits where — the assignment there is by position, not content.
|
||||
//! The port needs that to weight a positional downmix.
|
||||
//!
|
||||
//! This writes the chunks out so they can be decoded (ffmpeg has `xma2`) and
|
||||
//! correlated against the per-channel residuals.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example adv_voice_dump -- OUTDIR [MOVIE]
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args().nth(1).expect("OUTDIR");
|
||||
let movie = std::env::args().nth(2).unwrap_or_else(|| "ADV".into());
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
std::fs::create_dir_all(&out).expect("outdir");
|
||||
|
||||
let (start, end) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
|
||||
.expect("voice region");
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (end - start) as usize)
|
||||
.expect("region");
|
||||
println!("{movie}: region {start}..{end} = {} B", end - start);
|
||||
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
println!("{} RIFF chunk(s)", riffs.len());
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let p = format!("{out}/{movie}_{i}.xma");
|
||||
std::fs::write(&p, r).expect("write");
|
||||
// the probe reports `byte_size` = RIFF total - 60; print both so the
|
||||
// dump can be tied to a specific XMA context by its own number
|
||||
println!(" chunk {i}: {} B byte_size-equivalent {} -> {p}",
|
||||
r.len(), r.len() as i64 - 60);
|
||||
}
|
||||
}
|
||||
40
crates/sylpheed-formats/examples/bank_streams.rs
Normal file
40
crates/sylpheed-formats/examples/bank_streams.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! List a sound bank's streams and their declared rates.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example bank_streams -- <disc> BGM_102.slb …
|
||||
use sylpheed_formats::media::{self, DirectorySource};
|
||||
use sylpheed_formats::{hash::name_hash, slb};
|
||||
|
||||
fn main() {
|
||||
let mut a = std::env::args().skip(1);
|
||||
let disc = a.next().expect("usage: bank_streams <disc> NAME.slb…");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
for name in a {
|
||||
let h = name_hash(&name);
|
||||
match media::read_sound_bank(&src, h) {
|
||||
Ok(bytes) => {
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
println!(
|
||||
"{name} (hash {h:08x}, {} B on disc) header {:?} -> {} stream(s)",
|
||||
bytes.len(),
|
||||
slb::bank_header_len(&bytes),
|
||||
riffs.len()
|
||||
);
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let rate = if r.len() >= 0x28 {
|
||||
u32::from_le_bytes(r[0x20..0x24].try_into().unwrap())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let payload = r.len() - 60;
|
||||
println!(
|
||||
" stream {i}: payload {payload} B ({} packets) declared {rate} B/s \
|
||||
=> {:.3} s",
|
||||
payload / 2048,
|
||||
if rate > 0 { payload as f64 / rate as f64 } else { 0.0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
123
crates/sylpheed-formats/examples/find_stream_by_size.rs
Normal file
123
crates/sylpheed-formats/examples/find_stream_by_size.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Which cue owns an XMA stream of a given payload size?
|
||||
//!
|
||||
//! A boot with `--xma_param_probe` logs each decoded stream's `byte_size`. Three
|
||||
//! of the five on the take-2 `ADV` boot are that movie's own streams; two —
|
||||
//! 1 150 976 and 1 269 760 B — belong to something unidentified. The probe gives
|
||||
//! a size and nothing else, so the disc has to be asked which cue has a stream
|
||||
//! that long.
|
||||
//!
|
||||
//! Searches every inter-descriptor span of the continuous voice stream, and
|
||||
//! every `sound.pak` entry, for a stream whose payload matches.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example find_stream_by_size -- <disc> <bytes>…
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::{slb, PakArchive};
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let disc = args.next().expect("usage: find_stream_by_size <disc> <bytes>…");
|
||||
let wanted: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
|
||||
assert!(!wanted.is_empty(), "give at least one payload size");
|
||||
// A `to_xma_riffs` chunk is the payload plus a 60-byte RIFF wrapper.
|
||||
let want_riff: Vec<usize> = wanted.iter().map(|w| w + 60).collect();
|
||||
println!("looking for payloads {wanted:?} (riff sizes {want_riff:?})\n");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// --- 1. the continuous movie-voice stream, span by span
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
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)");
|
||||
}
|
||||
65
crates/sylpheed-formats/examples/focus_alpha_census.rs
Normal file
65
crates/sylpheed-formats/examples/focus_alpha_census.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Which focus records have a VARYING alpha — disc-wide, not export-wide?
|
||||
//!
|
||||
//! `rest()` returns an element's last hold keyframe. For a constant-alpha
|
||||
//! element that is harmless. For one that pulses it returns the PEAK, which is
|
||||
//! the `ui-settle-time.md` pathology: the plate's `ptbtn00f` ramps 0→80→0 and
|
||||
//! `rest()` reports 80, its maximum.
|
||||
//!
|
||||
//! The port censused this over its own export (34 records, 2 varying) and
|
||||
//! concluded there is nothing to fix. That conclusion is only as wide as the
|
||||
//! export. This asks the same question of the whole disc.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut n_rec, mut n_elem, mut varying) = (0usize, 0usize, 0usize);
|
||||
let (mut at_peak, mut mid_ramp) = (0usize, 0usize);
|
||||
let mut by_pak: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
let mut hits: Vec<String> = Vec::new();
|
||||
for p in &paks {
|
||||
let pn = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
// A focus record is one whose name is another record's plus `f`.
|
||||
let Some(stem) = rn.strip_suffix("f.rat") else { continue };
|
||||
if !b.records.contains_key(&format!("{stem}.rat")) { continue }
|
||||
if o + s > by.len() { continue }
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
n_rec += 1;
|
||||
for el in &lb.elements {
|
||||
if el.keyframes.is_empty() { continue }
|
||||
n_elem += 1;
|
||||
let a: Vec<u32> = el.keyframes.iter().map(|k| k.fade >> 24).collect();
|
||||
let (lo, hi) = (*a.iter().min().unwrap(), *a.iter().max().unwrap());
|
||||
if lo == hi { continue }
|
||||
varying += 1;
|
||||
let rest = el.rest().map(|k| k.fade >> 24).unwrap_or(0);
|
||||
if rest == hi { at_peak += 1 } else { mid_ramp += 1 }
|
||||
*by_pak.entry(pn.clone()).or_default() += 1;
|
||||
hits.push(format!(
|
||||
"{pn} [{ei}] {rn}::{} alpha {lo}..{hi} rest()={rest}{}",
|
||||
el.name, if rest == hi { " 🔴 == PEAK" } else { "" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("focus records disc-wide : {n_rec}");
|
||||
println!(" their timed elements : {n_elem}");
|
||||
println!(" with a VARYING alpha : {varying}");
|
||||
println!(" of which rest() == the PEAK : {at_peak} <- burns bright forever");
|
||||
println!(" of which rest() is MID-RAMP : {mid_ramp} <- neither extreme; looks plausible");
|
||||
println!("\nby pak:");
|
||||
for (k, v) in &by_pak { println!(" {k:<34} {v}") }
|
||||
println!("\nevery varying one:");
|
||||
hits.sort(); hits.dedup();
|
||||
for h in &hits { println!(" {h}") }
|
||||
println!("\n({} distinct)", hits.len());
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Reconcile two ink counts for one screen that were never counting the same pixels.
|
||||
//!
|
||||
//! The port agent double-witnessed the pixel-cost claim in Godot — a renderer
|
||||
//! sharing no code with `compose` — and got `GP_TITLE` entry 12 at **59 530 px**
|
||||
//! ink above threshold 0 and **48 368** above 1. This crate reported **49 771**.
|
||||
//! Neither is wrong; the question is which convention each was using, and on a
|
||||
//! mostly-dark frame the answer moves thousands of pixels.
|
||||
//!
|
||||
//! So: count the same composite every way, and print the family. Whichever row
|
||||
//! the port's numbers land in is the convention, and then the two renderers can be
|
||||
//! compared on purpose rather than by coincidence.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use ui_layout::ComposeOptions;
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn counts(rgba: &[u8], t: u8) -> (usize, usize) {
|
||||
let rgb = rgba
|
||||
.chunks_exact(4)
|
||||
.filter(|p| p[0] > t || p[1] > t || p[2] > t)
|
||||
.count();
|
||||
let alpha = rgba.chunks_exact(4).filter(|p| p[3] > t).count();
|
||||
(rgb, alpha)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
|
||||
for entry in [12usize, 15] {
|
||||
let by = ar.read(&ar.entries()[entry]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
for (label, opts) in [
|
||||
(
|
||||
"primitives on (what the cost run used)",
|
||||
ComposeOptions {
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"primitives+focus+animated",
|
||||
ComposeOptions {
|
||||
include_primitives: true,
|
||||
include_focus: true,
|
||||
include_animated: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
|
||||
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
|
||||
println!("\n== GP_TITLE entry {entry} — {label} ({}x{})", a.width, a.height);
|
||||
println!(" threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT");
|
||||
for t in [0u8, 1, 2, 4, 8, 16] {
|
||||
let (r1, a1) = counts(&a.rgba, t);
|
||||
let (r0, a0) = counts(&c.rgba, t);
|
||||
println!(" >{t:<8} | {r1:>16} | {a1:>14} | {r0:>13} | {a0:>11}");
|
||||
}
|
||||
let changed = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.zip(c.rgba.chunks_exact(4))
|
||||
.filter(|(x, y)| x != y)
|
||||
.count();
|
||||
println!(" exact-RGBA changed pixels between the two orders: {changed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Of the forced instances the rule merely CONFIRMS, how many have a key READ
|
||||
//! FROM THE FILE, and how many an IMPLIED key that is itself a measurement?
|
||||
//!
|
||||
//! `forced_backdrop_necessity.rs` asked only whether an element had *a* key,
|
||||
//! collapsing `sprite_layer_key` (a `u16` read out of the `T8aD` header — decoded)
|
||||
//! with `implied_layer_key` (this crate's per-name table of positions **measured
|
||||
//! in the running game**). For counting whether the rule moves anything that is
|
||||
//! the right question. For describing what a confirmation is *made of*, it is not:
|
||||
//! "the file already settles it" and "another measurement already settles it" are
|
||||
//! different claims, and a reader who sees "own key" will take the first.
|
||||
//!
|
||||
//! Raised by the port agent 2026-08-30.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_key_source
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut read, mut implied, mut none) = (0usize, 0usize, 0usize);
|
||||
println!("# archive entry element key_source key");
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
for el in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, el) {
|
||||
continue;
|
||||
}
|
||||
let (src, key) = match ui_layout::sprite_layer_key(&b, &by, el) {
|
||||
Some(k) => {
|
||||
read += 1;
|
||||
("read_T8aD", Some(k))
|
||||
}
|
||||
None => match ui_layout::implied_layer_key(&el.name) {
|
||||
Some(k) => {
|
||||
implied += 1;
|
||||
("implied_MEASURED", Some(k))
|
||||
}
|
||||
None => {
|
||||
none += 1;
|
||||
("none", None)
|
||||
}
|
||||
},
|
||||
};
|
||||
println!(
|
||||
" {name} {i} {} {src} {}",
|
||||
el.name,
|
||||
key.map(|k| format!("0x{k:08X}")).unwrap_or("-".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n# forced instances by key source:");
|
||||
println!("# read from the T8aD header (decoded): {read}");
|
||||
println!("# implied — this crate's MEASURED name table: {implied}");
|
||||
println!("# none — only forced_backdrop can speak: {none}");
|
||||
}
|
||||
114
crates/sylpheed-formats/examples/forced_backdrop_necessity.rs
Normal file
114
crates/sylpheed-formats/examples/forced_backdrop_necessity.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! Which screens does `forced_backdrop` DECIDE, and which does it merely agree with?
|
||||
//!
|
||||
//! Every check this corpus has run on the rule measured its **stability** — that
|
||||
//! no verdict moved when something else changed. That is a different property
|
||||
//! from **necessity**: an element whose position is already fixed by a read or an
|
||||
//! implied key is confirmed by the rule, not decided by it.
|
||||
//!
|
||||
//! So: compute `derived_paint_order` with the rule, and again with the
|
||||
//! `forced_backdrop` fallback removed, and report every entry whose order moves.
|
||||
//! Where nothing moves, the rule is decorative on that screen; where it moves,
|
||||
//! the rule is the only thing holding the order up.
|
||||
//!
|
||||
//! Raised by the port agent 2026-08-30. Reach note in
|
||||
//! `docs/re/structures/ui-forced-backdrop.md`.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_necessity -- [pak...]
|
||||
//!
|
||||
//! 🔴 With no argument this used to default to `GP_TITLE` alone, so a bare run
|
||||
//! reported **6 instances, not 80** — a thirteenth of the census, printed in the
|
||||
//! same format and reading like the whole thing. The port agent hit it and nearly
|
||||
//! filed the discrepancy back at me. It now walks every `dat/*.pak` by default and
|
||||
//! says on stderr how many archives it opened, because "I ran your instrument" has
|
||||
//! to mean the same thing to both of us.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let (mut total_decides, mut total_agrees) = (0usize, 0usize);
|
||||
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paks.is_empty() {
|
||||
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
all.sort();
|
||||
paks = all;
|
||||
}
|
||||
eprintln!("# scanning {} archive(s)", paks.len());
|
||||
for path in &paks {
|
||||
let ar = PakArchive::open(path).expect("pak");
|
||||
println!("# {}", path.display());
|
||||
println!("# entry forced decides elements note");
|
||||
|
||||
let mut decides = Vec::new();
|
||||
let mut agrees = Vec::new();
|
||||
#[allow(unused)]
|
||||
let _ = (&decides, &agrees);
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
|
||||
// Which elements does the rule fire on, and of those, which have no key
|
||||
// of their own to fall back on?
|
||||
let mut forced = Vec::new();
|
||||
let mut keyless = Vec::new();
|
||||
for el in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, el) {
|
||||
continue;
|
||||
}
|
||||
forced.push(el.name.clone());
|
||||
let own = ui_layout::sprite_layer_key(&b, &by, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name));
|
||||
if own.is_none() {
|
||||
keyless.push(el.name.clone());
|
||||
}
|
||||
}
|
||||
if forced.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let moved = with != without;
|
||||
if moved {
|
||||
decides.push(i);
|
||||
} else {
|
||||
agrees.push(i);
|
||||
}
|
||||
println!(
|
||||
" {i:>5} {:>6} {:>7} {:>8} forced=[{}] keyless=[{}]",
|
||||
forced.len(),
|
||||
if moved { "YES" } else { "no" },
|
||||
b.elements.len(),
|
||||
forced.join(","),
|
||||
keyless.join(","),
|
||||
);
|
||||
}
|
||||
println!("# rule DECIDES the order on entries {decides:?}");
|
||||
println!("# rule merely AGREES on entries {agrees:?}\n");
|
||||
total_decides += decides.len();
|
||||
total_agrees += agrees.len();
|
||||
}
|
||||
println!("# TOTAL over {} archive(s): {total_decides} deciding entries, \
|
||||
{total_agrees} agreeing", paks.len());
|
||||
}
|
||||
119
crates/sylpheed-formats/examples/forced_backdrop_pixel_cost.rs
Normal file
119
crates/sylpheed-formats/examples/forced_backdrop_pixel_cost.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! What does `forced_backdrop` cost IN PIXELS on the screens it decides?
|
||||
//!
|
||||
//! `forced_backdrop_necessity.rs` answers "does the derived ORDER move", which is
|
||||
//! a property of the sort. The port agent then pointed out — correctly — that its
|
||||
//! re-run of that probe was **my code executed twice**, not a second witness, so
|
||||
//! the disc-wide 62 has one measurement behind it and only `GP_TITLE` has two.
|
||||
//!
|
||||
//! This does not fix that (it is still this crate), but it moves the question to a
|
||||
//! **different layer**: render each deciding build twice, once in the order
|
||||
//! `compose` derives and once with the `forced_backdrop` fallback removed, and
|
||||
//! count the pixels that differ. "The order moved" and "the picture moved" are not
|
||||
//! the same claim, and the second is the one anybody cares about — the tie-break
|
||||
//! work already found overlapping reorders that cost exactly zero pixels.
|
||||
//!
|
||||
//! Each entry carries its own CONTROL: the pixel count of the composite itself.
|
||||
//! If a build renders empty, its zero means the instrument saw nothing, not that
|
||||
//! the rule is free.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost -- [pak...]
|
||||
//!
|
||||
//! With no argument it walks **every `dat/*.pak`** — the necessity probe defaulted
|
||||
//! to `GP_TITLE`, which made a bare run report a thirteenth of the census and read
|
||||
//! like the whole thing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
use ui_layout::ComposeOptions;
|
||||
|
||||
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
let el = &build.elements[i];
|
||||
(
|
||||
ui_layout::sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| ui_layout::implied_layer_key(&el.name))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
});
|
||||
idx
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paks.is_empty() {
|
||||
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
|
||||
.collect();
|
||||
all.sort();
|
||||
paks = all;
|
||||
}
|
||||
eprintln!("# scanning {} archive(s)", paks.len());
|
||||
|
||||
let opts = ComposeOptions {
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
println!("# archive entry element changed_px total_px ink_px(control) pct");
|
||||
let (mut decided, mut zero_cost, mut blind) = (0usize, 0usize, 0usize);
|
||||
for pak in &paks {
|
||||
let Ok(ar) = PakArchive::open(pak) else { continue };
|
||||
let name = pak.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else {
|
||||
continue;
|
||||
};
|
||||
let with = ui_layout::derived_paint_order(&b, &by);
|
||||
let without = order_without_rule(&b, &by);
|
||||
if with == without {
|
||||
continue;
|
||||
}
|
||||
let forced: Vec<&str> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|el| ui_layout::forced_backdrop(&b, el))
|
||||
.map(|el| el.name.as_str())
|
||||
.collect();
|
||||
|
||||
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
|
||||
let c = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&without));
|
||||
let n = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.zip(c.rgba.chunks_exact(4))
|
||||
.filter(|(x, y)| x != y)
|
||||
.count();
|
||||
// Control: does this build put any ink down at all, against the bare
|
||||
// backdrop? A build that renders to nothing cannot show a reorder.
|
||||
let ink = a
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.filter(|p| p[..3] != [0, 0, 0])
|
||||
.count();
|
||||
let total = a.rgba.len() / 4;
|
||||
|
||||
decided += 1;
|
||||
if ink == 0 {
|
||||
blind += 1;
|
||||
} else if n == 0 {
|
||||
zero_cost += 1;
|
||||
}
|
||||
println!(
|
||||
" {name} {i} {} {n} {total} {ink} {:.2}%",
|
||||
forced.join(","),
|
||||
100.0 * n as f64 / total as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\n# builds whose ORDER the rule decides: {decided}");
|
||||
println!("# of those, costing ZERO pixels: {zero_cost}");
|
||||
println!("# of those, BLIND (build renders no ink, control fails): {blind}");
|
||||
}
|
||||
72
crates/sylpheed-formats/examples/kf_flip_test.rs
Normal file
72
crates/sylpheed-formats/examples/kf_flip_test.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Do `+4` / `+8` = 180 mean MIRROR?
|
||||
//!
|
||||
//! The disc-wide census shows `+4` and `+8` are dominated by 180 and ±90, while
|
||||
//! `+12` (the decoded screen-plane rotation) takes 157 distinct values including
|
||||
//! odd ones. That shape says flips rather than free rotation.
|
||||
//!
|
||||
//! Structural test, no renderer involved: if 180 means "mirror", then the same
|
||||
//! sprite should appear both with the field 0 and with it 180 **within one
|
||||
//! build** -- a mirrored pair. Free-rotation semantics predicts no such pairing.
|
||||
//!
|
||||
//! CONTROL: the same search run on `+12`, which is decoded as a real rotation
|
||||
//! and should NOT show a 0/180 pairing pattern of the same strength.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("<disc>/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dir).expect("dir")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)).collect();
|
||||
paks.sort();
|
||||
|
||||
// field -> (pak, entry, sprite) -> set of values seen
|
||||
let mut seen: [BTreeMap<(String, usize, String), BTreeSet<i32>>; 3] =
|
||||
[BTreeMap::new(), BTreeMap::new(), BTreeMap::new()];
|
||||
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
let pn = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> =
|
||||
b.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
|
||||
for el in &b.elements {
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
for le in &lb.elements {
|
||||
groups.push((le.name.clone(), le.keyframes.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nm, ks) in groups {
|
||||
for k in &ks {
|
||||
let key = (pn.clone(), i, nm.clone());
|
||||
seen[0].entry(key.clone()).or_default().insert(k.unknown_4);
|
||||
seen[1].entry(key.clone()).or_default().insert(k.unknown_8);
|
||||
seen[2].entry(key).or_default().insert(k.rotation_deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, label) in [(0, "+4"), (1, "+8"), (2, "+12 (rotation, CONTROL)")] {
|
||||
let m = &seen[idx];
|
||||
let mut pair_0_180 = 0usize; // a sprite seen at BOTH 0 and 180 in one build
|
||||
let mut only_180 = 0usize;
|
||||
let mut multi = 0usize; // more than two distinct values
|
||||
for v in m.values() {
|
||||
if v.len() > 2 { multi += 1; }
|
||||
let has0 = v.contains(&0);
|
||||
let has180 = v.contains(&180) || v.contains(&-180);
|
||||
if has0 && has180 { pair_0_180 += 1; }
|
||||
else if has180 && !has0 { only_180 += 1; }
|
||||
}
|
||||
println!("{label}: {} sprite-instances", m.len());
|
||||
println!(" both 0 and ±180 in one build : {pair_0_180}");
|
||||
println!(" ±180 without any 0 : {only_180}");
|
||||
println!(" more than 2 distinct values : {multi}");
|
||||
}
|
||||
}
|
||||
19
crates/sylpheed-formats/examples/kf_timeline.rs
Normal file
19
crates/sylpheed-formats/examples/kf_timeline.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let bi:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[bi]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
for t in a {
|
||||
let i:usize=t.parse().unwrap();
|
||||
let e=&b.elements[i];
|
||||
println!("[{i}] {} kind=0x{:x} parent={:?} pivot=({},{}) kfs={}",
|
||||
e.name, e.kind, e.parent, e.pivot_x, e.pivot_y, e.keyframes.len());
|
||||
for k in &e.keyframes {
|
||||
println!(" t={:<5} a={:<4} xy=({},{}) s={}/{} rot={} u4={} u8={} tint={:08x}",
|
||||
k.time.map(|v|v as i64).unwrap_or(-1), k.fade>>24, k.x,k.y,
|
||||
k.scale_x,k.scale_y,k.rotation_deg,k.unknown_4,k.unknown_8,k.tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
crates/sylpheed-formats/examples/kf_unknown_census.rs
Normal file
83
crates/sylpheed-formats/examples/kf_unknown_census.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! What are the keyframe block's `+4` and `+8`?
|
||||
//!
|
||||
//! `+12` is decoded as a screen-plane rotation in degrees. `+4` and `+8` sit
|
||||
//! immediately before it and are carried but unexplained; one standing 🟡
|
||||
//! reading is that the three together are rotations about three axes, "not tied
|
||||
//! to an observed rotation". This censuses them across every UI pak on the disc
|
||||
//! so the reading can be argued with rather than assumed.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let dir = std::env::args().nth(1).expect("usage: <disc>/dat");
|
||||
let mut paks: Vec<_> = std::fs::read_dir(&dir)
|
||||
.expect("dir")
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let mut h4: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut h8: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut h12: BTreeMap<i32, usize> = BTreeMap::new();
|
||||
let mut both_nz: Vec<String> = Vec::new();
|
||||
let (mut kfs, mut builds) = (0usize, 0usize);
|
||||
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
builds += 1;
|
||||
// parents and leaves alike
|
||||
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> = b
|
||||
.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
|
||||
for el in &b.elements {
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
for le in &lb.elements {
|
||||
groups.push((format!("{}->{}", el.name, le.name), le.keyframes.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nm, ks) in groups {
|
||||
for k in &ks {
|
||||
kfs += 1;
|
||||
*h4.entry(k.unknown_4).or_default() += 1;
|
||||
*h8.entry(k.unknown_8).or_default() += 1;
|
||||
*h12.entry(k.rotation_deg).or_default() += 1;
|
||||
if k.unknown_4 != 0 || k.unknown_8 != 0 {
|
||||
both_nz.push(format!("{name} e{i} {nm} +4={} +8={} +12={}",
|
||||
k.unknown_4, k.unknown_8, k.rotation_deg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{builds} builds, {kfs} keyframes (parents + leaves)\n");
|
||||
for (nm, h) in [("+4", &h4), ("+8", &h8), ("+12 (rotation)", &h12)] {
|
||||
let nz: usize = h.iter().filter(|(k, _)| **k != 0).map(|(_, v)| *v).sum();
|
||||
println!("{nm}: {} distinct values, {} non-zero keyframes ({:.4}%)",
|
||||
h.len(), nz, 100.0 * nz as f64 / kfs as f64);
|
||||
let mut top: Vec<_> = h.iter().filter(|(k, _)| **k != 0).collect();
|
||||
top.sort_by_key(|(_, v)| std::cmp::Reverse(**v));
|
||||
for (k, v) in top.iter().take(6) { println!(" {k:>8} x{v}"); }
|
||||
}
|
||||
println!("\nkeyframes with a non-zero +4 or +8: {}", both_nz.len());
|
||||
// Per-pak, so a reader can ask whether a pak they have a CAPTURE of is
|
||||
// among them -- which decides whether the field is testable at all.
|
||||
let mut per: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for l in &both_nz {
|
||||
let pak = l.split_whitespace().next().unwrap_or("?").to_string();
|
||||
*per.entry(pak).or_default() += 1;
|
||||
}
|
||||
println!(" by pak:");
|
||||
for (k, v) in &per { println!(" {k:<28} {v}"); }
|
||||
println!(" paks with NONE: (any UI pak not listed above)");
|
||||
if let Ok(f) = std::env::var("KF_SHOW") {
|
||||
println!("\n all lines for {f}:");
|
||||
for l in both_nz.iter().filter(|l| l.starts_with(&f)) { println!(" {l}"); }
|
||||
}
|
||||
}
|
||||
56
crates/sylpheed-formats/examples/leaf_alpha_compose.rs
Normal file
56
crates/sylpheed-formats/examples/leaf_alpha_compose.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! How do a parent record's alpha ramp and its nested `.rat` leaf's compose?
|
||||
//!
|
||||
//! The port emits both but will not draw the leaf without knowing the rule --
|
||||
//! rightly, since drawing on a guess trades a visible 1.82 % error for an
|
||||
//! invisible wrong one. There is an oracle for this: the GPU draw capture
|
||||
//! records **vertex colours**, and on the title's `ptloop` draw they are
|
||||
//! `C3FFFFFF` and `B6FFFFFF` -- alpha **195** and **182**, not 255. So the game's
|
||||
//! composed alpha is observable, and a candidate rule either predicts those two
|
||||
//! numbers or does not.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example leaf_alpha_compose -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn alpha_of(kf: &ui_layout::Keyframe) -> u32 {
|
||||
// The keyframe block's +0 is an ARGB fade colour; alpha is its high byte.
|
||||
kf.fade >> 24
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let e = &ar.entries()[4]; // GP_TITLE entry 4 = the English title
|
||||
let bytes = ar.read(e).expect("read");
|
||||
let build = ui_layout::parse_build(&bytes).expect("build");
|
||||
|
||||
for name in ["ptloop01.rat", "ptloop02.rat"] {
|
||||
println!("\n=== {name} ===");
|
||||
if let Some(el) = build.elements.iter().find(|x| x.name == name) {
|
||||
println!(" PARENT keyframes (t, alpha, scale, rot, x,y):");
|
||||
for k in &el.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
|
||||
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y);
|
||||
}
|
||||
}
|
||||
match build.records.get(name) {
|
||||
Some(&(off, size)) => {
|
||||
let leaf = &bytes[off..off + size];
|
||||
match ui_layout::parse_build(leaf) {
|
||||
Some(lb) => {
|
||||
for le in &lb.elements {
|
||||
println!(" LEAF element {:?}:", le.name);
|
||||
for k in &le.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
|
||||
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y,
|
||||
k.rotation_deg, k.x, k.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => println!(" leaf did not parse"),
|
||||
}
|
||||
}
|
||||
None => println!(" no leaf record"),
|
||||
}
|
||||
}
|
||||
println!("\nOBSERVED in the draw capture: quad A alpha 195 (0xC3), quad B alpha 182 (0xB6)");
|
||||
}
|
||||
42
crates/sylpheed-formats/examples/leaf_dump.rs
Normal file
42
crates/sylpheed-formats/examples/leaf_dump.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Dump a record's parent keyframes and its nested `.rat` leaf's, for any entry.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example leaf_dump -- <pak> <entry> <name>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
|
||||
fn a(k: &ui_layout::Keyframe) -> u32 { k.fade >> 24 }
|
||||
fn t(k: &ui_layout::Keyframe) -> i64 { k.time.map(|v| v as i64).unwrap_or(-1) }
|
||||
|
||||
fn show(tag: &str, els: &[ui_layout::Element]) {
|
||||
for e in els {
|
||||
println!(" {tag} {:?} pivot=({},{}) kind={:#x}", e.name, e.pivot_x, e.pivot_y, e.kind);
|
||||
for k in &e.keyframes {
|
||||
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} pos=({},{}) tint={:#010x}",
|
||||
t(k), a(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y, k.tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut it = std::env::args().skip(1);
|
||||
let path = it.next().expect("pak");
|
||||
let entry: usize = it.next().expect("entry").parse().expect("entry");
|
||||
let want = it.next().expect("name");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let bytes = ar.read(&ar.entries()[entry]).expect("read");
|
||||
let b = ui_layout::parse_build(&bytes).expect("build");
|
||||
println!("entry {entry}: {} elements, design {}x{}", b.elements.len(), b.design_w, b.design_h);
|
||||
let els: Vec<_> = b.elements.iter().filter(|e| e.name.contains(&want)).cloned().collect();
|
||||
show("PARENT", &els);
|
||||
for e in &els {
|
||||
match b.records.get(&e.name) {
|
||||
Some(&(off, size)) => {
|
||||
println!(" -- leaf of {:?}: {size} B at {off}", e.name);
|
||||
match ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
Some(lb) => show(" LEAF", &lb.elements),
|
||||
None => println!(" leaf did not parse"),
|
||||
}
|
||||
}
|
||||
None => println!(" -- {:?} has NO leaf record", e.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
20
crates/sylpheed-formats/examples/leafdbg.rs
Normal file
20
crates/sylpheed-formats/examples/leafdbg.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
|
||||
let by=ar.read(&ar.entries()[4]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
for n in ["ptloop01.rat","ptloop02.rat"] {
|
||||
let el=b.elements.iter().find(|e| e.name==n).unwrap();
|
||||
println!("{n}: kind={:#x} animated={} rec={:?}", el.kind, el.animated,
|
||||
b.records.get(n).map(|&(o,s)|(o,s)));
|
||||
if let Some(&(o,s))=b.records.get(n) {
|
||||
match ui_layout::parse_build(&by[o..o+s]) {
|
||||
Some(lb)=>for le in &lb.elements {
|
||||
println!(" leaf {:?} sprite={:?} in_sprites={}", le.name, le.sprite,
|
||||
le.sprite.as_ref().map(|x| b.sprites.contains_key(x)).unwrap_or(false));
|
||||
},
|
||||
None=>println!(" leaf parse FAILED"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
crates/sylpheed-formats/examples/loo_band.rs
Normal file
26
crates/sylpheed-formats/examples/loo_band.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Leave-one-out over the elements that touch a band, dumping raw RGBA each time.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let bi:usize=a.next().unwrap().parse().unwrap();
|
||||
let dir=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[bi]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
let o=ui_layout::ComposeOptions{ backdrop:[0,0,0,255], ..Default::default() };
|
||||
let n=b.elements.len();
|
||||
let base=ui_layout::compose(&b,&by,o.clone(),None);
|
||||
std::fs::write(format!("{dir}/base.raw"),&base.rgba).unwrap();
|
||||
println!("canvas {}x{} elements {n}", base.width, base.height);
|
||||
for i in 0..n {
|
||||
let e=&b.elements[i];
|
||||
let Some(kf)=e.rest() else { continue };
|
||||
// only bother with elements whose rest pose can touch the band
|
||||
let mut v=vec![true;n]; v[i]=false;
|
||||
let c=ui_layout::compose(&b,&by,o.clone(),Some(&v));
|
||||
std::fs::write(format!("{dir}/wo-{i}.raw"),&c.rgba).unwrap();
|
||||
println!("{i}\t{}\t{}\t({},{})\ta={}", e.name,
|
||||
e.sprite.clone().unwrap_or_default(), kf.x, kf.y, kf.fade>>24);
|
||||
}
|
||||
}
|
||||
46
crates/sylpheed-formats/examples/name_from_hash.rs
Normal file
46
crates/sylpheed-formats/examples/name_from_hash.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Recover a `sound.pak` TOC name from its hash by generating candidates.
|
||||
//!
|
||||
//! The hash is a Barrett-reduction over the uppercased path, so it cannot be
|
||||
//! inverted — but the naming is regular enough to enumerate. Tries the shapes
|
||||
//! this disc actually uses for sound entries.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example name_from_hash -- <hex-hash>…
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
|
||||
fn main() {
|
||||
let wanted: Vec<u32> = std::env::args()
|
||||
.skip(1)
|
||||
.filter_map(|a| u32::from_str_radix(a.trim_start_matches("0x"), 16).ok())
|
||||
.collect();
|
||||
assert!(!wanted.is_empty(), "give hashes in hex");
|
||||
let langs = ["eng", "jpn", ""];
|
||||
let dirs = ["", "Movie", "etc", "Voice", "Sound", "BGM", "bgm", "se", "SE"];
|
||||
let mut tried = 0usize;
|
||||
let mut check = |name: String, tried: &mut usize| {
|
||||
*tried += 1;
|
||||
let h = name_hash(&name);
|
||||
if wanted.contains(&h) {
|
||||
println!(" ✅ {h:08x} {name}");
|
||||
}
|
||||
};
|
||||
for l in langs {
|
||||
for d in dirs {
|
||||
let pre = match (l.is_empty(), d.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(true, false) => format!("{d}\\"),
|
||||
(false, true) => format!("{l}\\"),
|
||||
(false, false) => format!("{l}\\{d}\\"),
|
||||
};
|
||||
for stem in ["BGM", "bgm", "JNGL", "jngl", "SE", "Static", "VOICE"] {
|
||||
for n in 0..1200u32 {
|
||||
check(format!("{pre}{stem}_{n:03}.slb"), &mut tried);
|
||||
check(format!("{pre}{stem}{n:03}.slb"), &mut tried);
|
||||
}
|
||||
}
|
||||
for bare in ["Static.slb", "static.slb", "SE.slb", "BGM.slb"] {
|
||||
check(format!("{pre}{bare}"), &mut tried);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("tried {tried} candidate names");
|
||||
}
|
||||
50
crates/sylpheed-formats/examples/prm_alpha_census.rs
Normal file
50
crates/sylpheed-formats/examples/prm_alpha_census.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Does a primitive's alpha AT t=0 predict the layer it paints on?
|
||||
//!
|
||||
//! `implied_layer_key` is a measured per-name table. The four entries in it, read
|
||||
//! against their own keyframes, suggest a rule derived from the file instead:
|
||||
//!
|
||||
//! * `palogo_eff0.prm` measured FIRST (0x0000) -- alpha at t=0 = ?
|
||||
//! * `pfbase.tbm` measured FIRST (0x0000) -- alpha at t=0 = ?
|
||||
//! * `pteff02.prm` measured MIDDLE (0x8030) -- alpha at t=0 = ?
|
||||
//! * `pteff00.prm` measured LAST -- alpha at t=0 = ?
|
||||
//!
|
||||
//! ⚠️ The rule was invented AFTER seeing three of those answers, so it is fitted
|
||||
//! on them and only `pfbase.tbm` is out of sample. This prints all four plus a
|
||||
//! disc-wide census, so the fit and its reach are visible together.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
// name -> (count, set of t=0 alphas, set of "starts at max" flags)
|
||||
let mut byname: BTreeMap<String, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
// primitives and the keyless: anything with no layer key
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
let Some(k0) = el.keyframes.first() else { continue };
|
||||
let a0 = k0.fade >> 24;
|
||||
let ent = byname.entry(el.name.clone()).or_default();
|
||||
ent.0 += 1;
|
||||
*ent.1.entry(a0).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:>28} {:>7} alpha at t=0 (count)", "keyless element", "n");
|
||||
let known = ["palogo_eff0.prm", "pfbase.tbm", "pteff02.prm", "pteff00.prm"];
|
||||
for (n, (c, a)) in &byname {
|
||||
let tag = if known.contains(&n.as_str()) { " <- IN THE MEASURED TABLE" } else { "" };
|
||||
if *c < 4 && tag.is_empty() { continue }
|
||||
let al: Vec<String> = a.iter().map(|(k, v)| format!("{k}x{v}")).collect();
|
||||
println!(" {n:>26} {c:>7} {}{tag}", al.join(" "));
|
||||
}
|
||||
}
|
||||
37
crates/sylpheed-formats/examples/prm_colour_census.rs
Normal file
37
crates/sylpheed-formats/examples/prm_colour_census.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! What COLOUR is a primitive, and does any of them only make sense additively?
|
||||
//!
|
||||
//! `ui-prm-primitives.md` leaves blend mode open, and `forced_backdrop` assumes
|
||||
//! straight alpha-over. A quad whose ARGB would tint the whole screen a colour no
|
||||
//! screen shows is evidence against alpha-over for that quad.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
fn main(){
|
||||
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
|
||||
let mut paks:Vec<_>=std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e|e.path())
|
||||
.filter(|p|p.extension().and_then(|s|s.to_str())==Some("pak")).collect();
|
||||
paks.sort();
|
||||
let mut m:BTreeMap<String,BTreeMap<String,usize>>=BTreeMap::new();
|
||||
for p in &paks {
|
||||
let Ok(ar)=pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
for el in &b.elements {
|
||||
if el.sprite.is_some() { continue }
|
||||
for k in &el.keyframes {
|
||||
*m.entry(el.name.clone()).or_default()
|
||||
.entry(format!("{:08x}", k.fade)).or_default()+=1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{:>26} fade ARGB values (count)", "keyless element");
|
||||
for (n,v) in &m {
|
||||
let tot:usize=v.values().sum();
|
||||
if tot<4 { continue }
|
||||
let s:Vec<String>=v.iter().map(|(k,c)|format!("{k}x{c}")).collect();
|
||||
println!(" {n:>24} {}", s.join(" "));
|
||||
}
|
||||
}
|
||||
58
crates/sylpheed-formats/examples/prm_forced_first.rs
Normal file
58
crates/sylpheed-formats/examples/prm_forced_first.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Which keyless primitives have their paint position FORCED by occlusion?
|
||||
//!
|
||||
//! An opaque full-screen quad must sort below every element visible at any
|
||||
//! instant it is opaque. Where that set is *every* other element, its position is
|
||||
//! forced to first — derived from the file, not analogised from a neighbour.
|
||||
//!
|
||||
//! Controls, both measured in the running game and both reproduced here:
|
||||
//! * `palogo_eff0.prm` is measured painting FIRST — and comes out forced first.
|
||||
//! * `pteff00.prm` is measured painting LAST — and is forced below only a
|
||||
//! handful, so the constraint permits it on top.
|
||||
//!
|
||||
//! ⚠️ Assumes straight alpha-over blending. Blend mode is ❔ in
|
||||
//! `ui-prm-primitives.md`; an additive quad at alpha 255 would not occlude.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut forced, mut partial, mut free) = (0usize, 0usize, 0usize);
|
||||
let mut names: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
if tmax == 0 { continue }
|
||||
for el in &b.elements {
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
// full-screen only: a quad that does not cover cannot occlude
|
||||
if el.pivot_x * 2 < 1280 || el.pivot_y * 2 < 720 { continue }
|
||||
let op: Vec<u32> = (0..=tmax)
|
||||
.filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)).collect();
|
||||
if op.is_empty() { continue }
|
||||
let others: Vec<&ui_layout::Element> =
|
||||
b.elements.iter().filter(|o| o.index != el.index).collect();
|
||||
if others.is_empty() { continue }
|
||||
let below = others.iter().filter(|o|
|
||||
op.iter().any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)).count();
|
||||
let ent = names.entry(el.name.clone()).or_default();
|
||||
ent.1 += 1;
|
||||
if below == others.len() { forced += 1; ent.0 += 1 }
|
||||
else if below > 0 { partial += 1 } else { free += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("keyless FULL-SCREEN primitives with an opaque interval:");
|
||||
println!(" position FORCED FIRST (below every other element) : {forced}");
|
||||
println!(" forced below SOME but not all : {partial}");
|
||||
println!(" occludes nothing : {free}");
|
||||
println!("\nby name — instances forced first / total:");
|
||||
for (n, (f, t)) in &names { println!(" {n:>24} {f:>4} / {t}"); }
|
||||
}
|
||||
73
crates/sylpheed-formats/examples/prm_occlusion_check.rs
Normal file
73
crates/sylpheed-formats/examples/prm_occlusion_check.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! An OPAQUE full-screen primitive cannot paint on top of elements that are
|
||||
//! visible at the same time — the screen would be blank.
|
||||
//!
|
||||
//! That is a constraint read off the file, not a preference. For each keyless
|
||||
//! primitive this computes the interval over which it is opaque, and the interval
|
||||
//! over which any OTHER element is visible, and reports the overlap.
|
||||
//!
|
||||
//! The falsifier: `pteff00.prm` is MEASURED painting last on the title and the
|
||||
//! main menu. If any of its instances is opaque while content is up, the
|
||||
//! constraint is wrong and this whole line is dead.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
/// Interval(s) where alpha >= `thr`, sampled at every half unit.
|
||||
fn opaque_span(el: &ui_layout::Element, thr: u32, tmax: u32) -> Vec<(f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<f64> = None;
|
||||
let mut t = 0.0;
|
||||
while t <= tmax as f64 {
|
||||
let a = el.pose_at(t as u32).map(|k| k.fade >> 24).unwrap_or(0);
|
||||
if a >= thr {
|
||||
if cur.is_none() { cur = Some(t) }
|
||||
} else if let Some(s) = cur.take() {
|
||||
out.push((s, t));
|
||||
}
|
||||
t += 0.5;
|
||||
}
|
||||
if let Some(s) = cur { out.push((s, tmax as f64)) }
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let want = ["pteff00.prm", "pgloading_eff00.prm", "palogo_eff0.prm",
|
||||
"pfbase.tbm", "pteff02.prm", "pzeff00.prm", "pceff00.prm", "pdeff00.prm"];
|
||||
println!("{:>22} {:>5} {:>16} {:>18} overlap", "primitive", "entry", "opaque while", "content visible");
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
if tmax == 0 { continue }
|
||||
for el in &b.elements {
|
||||
if !want.contains(&el.name.as_str()) { continue }
|
||||
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
|
||||
let op = opaque_span(el, 255, tmax);
|
||||
if op.is_empty() { continue }
|
||||
// when is any OTHER element visible?
|
||||
let mut cmin = f64::MAX; let mut cmax = f64::MIN;
|
||||
for o in &b.elements {
|
||||
if o.index == el.index { continue }
|
||||
for (s, t) in opaque_span(o, 1, tmax) { cmin = cmin.min(s); cmax = cmax.max(t) }
|
||||
}
|
||||
if cmin > cmax { continue }
|
||||
// overlap of the primitive's opaque span with the content span
|
||||
let ov: f64 = op.iter()
|
||||
.map(|&(s, t)| (t.min(cmax) - s.max(cmin)).max(0.0)).sum();
|
||||
let opd: String = op.iter().map(|&(s,t)| format!("{s:.0}-{t:.0}")).collect::<Vec<_>>().join(",");
|
||||
let flag = if ov > 2.0 { " 🔴 CANNOT BE ON TOP" } else { "" };
|
||||
println!("{:>22} {:>5} {:>16} {:>18} {ov:6.1}{flag}",
|
||||
el.name, format!("{}:{}", p.file_name().unwrap().to_string_lossy()
|
||||
.trim_end_matches(".pak").trim_start_matches("GP_"), ei),
|
||||
opd, format!("{cmin:.0}-{cmax:.0}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
crates/sylpheed-formats/examples/prm_span_sensitivity.rs
Normal file
82
crates/sylpheed-formats/examples/prm_span_sensitivity.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Does `forced_backdrop`'s verdict depend on how the screen's timeline ENDS?
|
||||
//!
|
||||
//! The rule quantifies over "every instant the primitive is opaque" and "every
|
||||
//! element visible then", so both halves depend on where the timeline stops and
|
||||
//! on what an element does after its own last keyframe. The port asked, and it is
|
||||
//! the right question: a verdict that flips with the convention is not a decode.
|
||||
//!
|
||||
//! Four conventions, all applied to the same disc:
|
||||
//! A span = max keyframe time over all elements; elements HOLD their last pose
|
||||
//! (what `forced_backdrop` does, and what the port implements)
|
||||
//! B span = the primitive's OWN last keyframe time; elements hold
|
||||
//! C span = the bundle header `+0x08` (the declared length); elements hold
|
||||
//! D span = max keyframe time; an element is GONE after its own last keyframe
|
||||
//!
|
||||
//! D is the one worth the most: it is the assumption the port flagged as "doing
|
||||
//! real work", and it strictly shrinks the visible set, so it can only turn
|
||||
//! `forced` into `not forced`.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn last_t(el: &ui_layout::Element) -> u32 {
|
||||
el.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn forced(b: &ui_layout::UiBuild, el: &ui_layout::Element, tmax: u32, hold: bool) -> Option<bool> {
|
||||
if el.sprite.is_some() { return None }
|
||||
if (el.pivot_x * 2) < b.design_w as u32 || (el.pivot_y * 2) < b.design_h as u32 { return None }
|
||||
if tmax == 0 { return None }
|
||||
let alpha = |e: &ui_layout::Element, t: u32| -> u32 {
|
||||
if !hold && t > last_t(e) { return 0 }
|
||||
e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0)
|
||||
};
|
||||
let op: Vec<u32> = (0..=tmax).filter(|&t| alpha(el, t) == 255).collect();
|
||||
if op.is_empty() { return None }
|
||||
let others: Vec<&ui_layout::Element> = b.elements.iter().filter(|o| o.index != el.index).collect();
|
||||
if others.is_empty() { return None }
|
||||
let below = others.iter().filter(|o| op.iter().any(|&t| alpha(o, t) > 0)).count();
|
||||
Some(below == others.len())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut n, mut a_true) = (0usize, 0usize);
|
||||
let mut flips = [0usize; 3];
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for (ei, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let tall = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
let hdr = if by.len() >= 12 { u32::from_be_bytes(by[8..12].try_into().unwrap()) } else { 0 };
|
||||
for el in &b.elements {
|
||||
let Some(va) = forced(&b, el, tall, true) else { continue };
|
||||
n += 1; if va { a_true += 1 }
|
||||
for (k, vb) in [forced(&b, el, last_t(el), true),
|
||||
forced(&b, el, hdr, true),
|
||||
forced(&b, el, tall, false)].into_iter().enumerate() {
|
||||
if vb != Some(va) {
|
||||
flips[k] += 1;
|
||||
if k == 2 && examples.len() < 6 {
|
||||
examples.push(format!("{}:{} {} A={va} D={vb:?}",
|
||||
p.file_name().unwrap().to_string_lossy(), ei, el.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("keyless full-screen primitives with an opaque interval: {n}");
|
||||
println!(" convention A (span = all elements' max, hold) -> forced first: {a_true}\n");
|
||||
println!(" verdicts that CHANGE under:");
|
||||
println!(" B span = the primitive's own last keyframe : {}", flips[0]);
|
||||
println!(" C span = the header's declared length +0x08 : {}", flips[1]);
|
||||
println!(" D elements GONE after their last keyframe : {}", flips[2]);
|
||||
for e in &examples { println!(" {e}") }
|
||||
}
|
||||
91
crates/sylpheed-formats/examples/ptloop_leaf_sweep_at.rs
Normal file
91
crates/sylpheed-formats/examples/ptloop_leaf_sweep_at.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! Where are the title's two light-sweep quads at a given time — and is a
|
||||
//! best-fit against a framebuffer PNG even measuring their position?
|
||||
//!
|
||||
//! `ui-leaf-vs-parent-alpha.md` solves the sweep instant as **t = 357.7** from a
|
||||
//! GPU per-draw capture: quad centre x measured off the submitted vertex buffer,
|
||||
//! which is a position measurement at 4 px/unit. The port agent separately
|
||||
//! best-fits the same leaf against `live-title-build4-no-plate.png` and gets
|
||||
//! **~400 units**, and asked whether the two used the same capture.
|
||||
//!
|
||||
//! Before comparing the numbers, check whether the second method can see what it
|
||||
//! claims to measure. A fit that is minimised by the quad being OFF-SCREEN is
|
||||
//! minimised by absence, and would return "best" at whatever time draws least —
|
||||
//! the same shape as the `.tbm` control that could not fail.
|
||||
//!
|
||||
//! So: print the leaves' own x, alpha, and on-screen overlap across the window.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example ptloop_leaf_sweep_at
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
const SCREEN_W: i64 = 1280;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let by = ar.read(&ar.entries()[4]).expect("entry 4");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
|
||||
for parent in ["ptloop01", "ptloop02"] {
|
||||
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
|
||||
eprintln!("no element {parent}");
|
||||
continue;
|
||||
};
|
||||
let Some(&(off, size)) = b.records.get(&el.name) else {
|
||||
eprintln!("{}: no nested record", el.name);
|
||||
continue;
|
||||
};
|
||||
// `ui-record-loop-length.md`: a nested record's header `+0x08` is its
|
||||
// CYCLE LENGTH, and its keyframes need not fill it. This is why two
|
||||
// captures of the same settled title do not share a sweep phase.
|
||||
let loop_len = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
|
||||
println!("\n-- {} nested record: loop length (+0x08) = {loop_len}", el.name);
|
||||
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
|
||||
eprintln!("{}: leaf will not parse", el.name);
|
||||
continue;
|
||||
};
|
||||
for le in &lb.elements {
|
||||
let w = (le.pivot_x * 2) as i64;
|
||||
println!(
|
||||
"\n== {} -> leaf {} (sprite {:?}, pivot {}x{}, {} keyframes, last t={:?})",
|
||||
el.name,
|
||||
le.name,
|
||||
le.sprite,
|
||||
le.pivot_x,
|
||||
le.pivot_y,
|
||||
le.keyframes.len(),
|
||||
le.keyframes.last().map(|k| k.time)
|
||||
);
|
||||
// ⚠️ A keyframe's `x` is the quad's LEFT edge, not its centre. The
|
||||
// draw-capture fit is quoted in CENTRES, so compare `centre`, which is
|
||||
// `x + pivot_x`. Printing `x` under a "centre" heading is how a
|
||||
// 200-px offset gets into a comparison unnoticed.
|
||||
println!(" t | x | centre | a | on-screen px of a {w}px-wide quad");
|
||||
for t in [
|
||||
340u32, 350, 355, 357, 358, 360, 370, 380, 390, 395, 400, 405, 410, 420, 440, 480,
|
||||
540,
|
||||
] {
|
||||
let Some(k) = le.pose_at(t) else {
|
||||
println!(" {t:>4} | (no pose)");
|
||||
continue;
|
||||
};
|
||||
let x = k.x as i64;
|
||||
let a = k.fade >> 24;
|
||||
let l = x;
|
||||
let r = l + w;
|
||||
let vis = (r.min(SCREEN_W) - l.max(0)).max(0);
|
||||
println!(
|
||||
" {t:>4} | {x:>4} | {:>6} | {a:>3} | {vis:>5} px {}",
|
||||
x + le.pivot_x as i64,
|
||||
if vis == 0 {
|
||||
"*** ENTIRELY OFF SCREEN ***"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
crates/sylpheed-formats/examples/record_loop_length.rs
Normal file
69
crates/sylpheed-formats/examples/record_loop_length.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! Is a nested record's header `+0x08` its LOOP LENGTH — and do its keyframes
|
||||
//! have to fill it?
|
||||
//!
|
||||
//! A `*f` focus record animates forever while its button is focused, so
|
||||
//! something must say where the cycle restarts. The keyframes cannot: the plate's
|
||||
//! `ptbtn00f` runs 0→80→0 over 105 units, and looping at 105 gives a period 15 %
|
||||
//! short of every measurement of the real thing.
|
||||
//!
|
||||
//! Each record is itself a RATC bundle with its own header, and `+0x08` is a
|
||||
//! frame count. If it is the loop length then it must never be LESS than the
|
||||
//! record's largest keyframe time — an animation cannot restart before its own
|
||||
//! last pose — and it may be more, which is a hold at the final pose.
|
||||
//!
|
||||
//! Two controls, both of which a wrong reading fails:
|
||||
//! * `+0x08 < max keyframe time` must never happen. That is the falsifier.
|
||||
//! * The distribution must not be trivial: if every record had exactly
|
||||
//! `+0x08 == max t`, the field would carry nothing and "loop length" would be
|
||||
//! an unfalsifiable relabelling of the keyframes.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
|
||||
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
|
||||
.flatten().map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
|
||||
paks.sort();
|
||||
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut hold_hist: BTreeMap<i64, usize> = BTreeMap::new();
|
||||
let mut worst: Vec<(i64, String)> = Vec::new();
|
||||
for p in &paks {
|
||||
let Ok(ar) = pak::PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) { continue }
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
if o + 12 > by.len() || o + s > by.len() { continue }
|
||||
if &by[o..o + 4] != b"RATC" { continue }
|
||||
let len = u32::from_be_bytes(by[o + 8..o + 12].try_into().unwrap()) as i64;
|
||||
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
|
||||
let maxt = lb.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0) as i64;
|
||||
if maxt == 0 { continue } // a static record declares no cycle
|
||||
total += 1;
|
||||
let slack = len - maxt;
|
||||
*hold_hist.entry(slack).or_default() += 1;
|
||||
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else {
|
||||
violations += 1;
|
||||
if worst.len() < 12 {
|
||||
worst.push((slack, format!("{}:{rn} len={len} maxt={maxt}",
|
||||
p.file_name().unwrap().to_string_lossy())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("nested records with timed keyframes : {total}");
|
||||
println!(" +08 == max keyframe time (exact) : {exact} ({:.1}%)", 100.0*exact as f64/total as f64);
|
||||
println!(" +08 > max keyframe time (a hold) : {holds} ({:.1}%)", 100.0*holds as f64/total as f64);
|
||||
println!(" +08 < max keyframe time 🔴 : {violations} ({:.2}%) <- the falsifier",
|
||||
100.0*violations as f64/total as f64);
|
||||
println!("\nslack (+08 - max t) distribution, most common first:");
|
||||
let mut h: Vec<_> = hold_hist.iter().collect();
|
||||
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
|
||||
for (k, n) in h.iter().take(14) { println!(" slack {k:>6} : {n}"); }
|
||||
if !worst.is_empty() { println!("\nviolations:"); for (s, w) in &worst { println!(" {s:>6} {w}"); } }
|
||||
}
|
||||
48
crates/sylpheed-formats/examples/scale_census.rs
Normal file
48
crates/sylpheed-formats/examples/scale_census.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Every scale value on the disc's UI, parents AND nested leaves.
|
||||
//!
|
||||
//! `DECISIONS.md` records `ptlogo_eff2` at 125 % as "the single drawn element in
|
||||
//! the whole export at a scale that is not a whole multiple of 100 %". That
|
||||
//! census was over parents only -- leaves were never opened. This opens them.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("pak");
|
||||
let ar = pak::PakArchive::open(&path).expect("open");
|
||||
let mut hist: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
|
||||
let mut leaves_opened = 0usize;
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
|
||||
for el in &b.elements {
|
||||
for k in &el.keyframes {
|
||||
hist.entry((k.scale_x, k.scale_y))
|
||||
.or_default()
|
||||
.push(format!("e{i}/{}", el.name));
|
||||
}
|
||||
if let Some(&(off, size)) = b.records.get(&el.name) {
|
||||
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
|
||||
leaves_opened += 1;
|
||||
for le in &lb.elements {
|
||||
for k in &le.keyframes {
|
||||
hist.entry((k.scale_x, k.scale_y))
|
||||
.or_default()
|
||||
.push(format!("e{i}/{}->LEAF/{}", el.name, le.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{leaves_opened} leaves opened\n");
|
||||
println!("{:>12} {:>7} examples", "scale", "count");
|
||||
for (k, v) in &hist {
|
||||
let mut ex: Vec<&String> = v.iter().collect();
|
||||
ex.sort(); ex.dedup();
|
||||
let odd = k.0 % 100 != 0 || k.1 % 100 != 0;
|
||||
println!("{}{:>5},{:<5} {:>7} {}", if odd { "* " } else { " " },
|
||||
k.0, k.1, v.len(),
|
||||
ex.iter().take(3).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
println!("\n* = not a whole multiple of 100%");
|
||||
}
|
||||
31
crates/sylpheed-formats/examples/settle_window.rs
Normal file
31
crates/sylpheed-formats/examples/settle_window.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
// The settled screen, computed from the keyframe times alone.
|
||||
//
|
||||
// `rest()` picks each element's last HOLD keyframe independently, which is right
|
||||
// for an element that ends settled and wrong for a transient: a 2-frame flash
|
||||
// holds at its PEAK, so `rest()` leaves it burning forever. The settled screen is
|
||||
// instead one INSTANT that every element is posed at, and the instant to pick is
|
||||
// inside the longest interval during which no element has a keyframe at all.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap();
|
||||
let ar=pak::PakArchive::open(&pk).unwrap();
|
||||
let only:Option<usize>=a.next().and_then(|s|s.parse().ok());
|
||||
for (i,e) in ar.entries().iter().enumerate() {
|
||||
if let Some(o)=only { if o!=i { continue } }
|
||||
let Ok(by)=ar.read(e) else { continue };
|
||||
let Some(b)=ui_layout::parse_build(&by) else { continue };
|
||||
if b.elements.len()<2 { continue }
|
||||
let mut ts:Vec<u32>=b.elements.iter().flat_map(|el|
|
||||
el.keyframes.iter().filter_map(|k|k.time)).collect();
|
||||
if ts.len()<2 { continue }
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
// longest gap between consecutive keyframe times
|
||||
let (mut best,mut lo,mut hi)=(0u32,0u32,0u32);
|
||||
for w in ts.windows(2) {
|
||||
if w[1]-w[0] > best { best=w[1]-w[0]; lo=w[0]; hi=w[1]; }
|
||||
}
|
||||
println!("{:>4} {:<28} times {:>3} span {:>4} settle window [{lo},{hi}] = {best} units ({:.2}s) -> t={}",
|
||||
i, format!("{:08x}",e.name_hash), ts.len(), ts.last().unwrap(), best as f64/60.0, lo+best/2);
|
||||
}
|
||||
}
|
||||
12
crates/sylpheed-formats/examples/sprite_dims.rs
Normal file
12
crates/sylpheed-formats/examples/sprite_dims.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use sylpheed_formats::{pak, ui_layout, t8ad};
|
||||
fn main(){
|
||||
let mut a=std::env::args().skip(1);
|
||||
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
|
||||
let ar=pak::PakArchive::open(pk).unwrap();
|
||||
let by=ar.read(&ar.entries()[i]).unwrap();
|
||||
let b=ui_layout::parse_build(&by).unwrap();
|
||||
let mut v:Vec<(String,u32,u32)>=b.sprites.iter().filter_map(|(n,&(o,s))|{
|
||||
let im=t8ad::parse(&by[o..o+s])?; Some((n.clone(),im.width,im.height))}).collect();
|
||||
v.sort();
|
||||
for (n,w,h) in v { println!(" {w:>5} x {h:<5} {n}"); }
|
||||
}
|
||||
9
crates/sylpheed-formats/examples/sprite_of.rs
Normal file
9
crates/sylpheed-formats/examples/sprite_of.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
fn main(){
|
||||
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
|
||||
let b=ui_layout::parse_build(&ar.read(&ar.entries()[4]).unwrap()).unwrap();
|
||||
for el in b.elements.iter().filter(|e| e.name.contains("ptloop")||e.name.contains("ptbtn")) {
|
||||
println!("{:<18} sprite={:?} has_leaf={}", el.name, el.sprite,
|
||||
b.records.contains_key(&el.name));
|
||||
}
|
||||
}
|
||||
332
crates/sylpheed-formats/examples/tie_break_pixel_cost.rs
Normal file
332
crates/sylpheed-formats/examples/tie_break_pixel_cost.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
//! What does the unknown paint-order TIE-BREAK actually cost, in pixels?
|
||||
//!
|
||||
//! `ui-paint-order-derived-check.md` bounds *where* a wrong tie-break could
|
||||
//! show — 24 overlapping tied pairs across `GP_TITLE` — and says outright that
|
||||
//! nobody has measured how many of them change a pixel. Overlap is an upper
|
||||
//! bound: two elements can overlap and still composite identically in either
|
||||
//! order, if either is transparent where they meet.
|
||||
//!
|
||||
//! This renders each screen twice — once in the order `compose` derives, once
|
||||
//! with one tied pair swapped — and counts the pixels that differ. Same-key
|
||||
//! elements are contiguous in the derived order (a stable sort by `(key, i)`),
|
||||
//! so swapping two of them paints nothing else in between: the diff is the
|
||||
//! tie-break's cost and nothing else.
|
||||
//!
|
||||
//! Every entry also runs a CONTROL: a swap of two OVERLAPPING elements with
|
||||
//! DIFFERENT keys, i.e. a pair whose order the game is known to care about. If
|
||||
//! the control diff is zero the instrument cannot see a reorder on this screen
|
||||
//! and its zeros mean nothing.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example tie_break_pixel_cost -- <GP_TITLE.pak>
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use ui_layout::{ComposeOptions, UiBuild};
|
||||
|
||||
/// Pixels that differ, and the largest per-channel difference.
|
||||
fn diff(a: &[u8], b: &[u8]) -> (usize, u8) {
|
||||
let (mut n, mut worst) = (0usize, 0u8);
|
||||
for (pa, pb) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
|
||||
if pa != pb {
|
||||
n += 1;
|
||||
for k in 0..4 {
|
||||
worst = worst.max(pa[k].abs_diff(pb[k]));
|
||||
}
|
||||
}
|
||||
}
|
||||
(n, worst)
|
||||
}
|
||||
|
||||
/// Where does element `ei` actually put ink? Render with it and without it;
|
||||
/// the pixels that move are the ones it paints. This is what turns a bare
|
||||
/// "0 px differ" into an explained one: bounding boxes can overlap while the
|
||||
/// sprites inside them never touch the same pixel.
|
||||
fn ink_mask(
|
||||
build: &UiBuild,
|
||||
bundle: &[u8],
|
||||
opts: ComposeOptions,
|
||||
order: &[usize],
|
||||
base: &[u8],
|
||||
ei: usize,
|
||||
) -> Vec<bool> {
|
||||
let n_el = build.elements.iter().map(|e| e.index).max().unwrap_or(0) + 1;
|
||||
let mut vis = vec![true; n_el.max(build.elements.len())];
|
||||
vis[build.elements[ei].index] = false;
|
||||
let without = ui_layout::compose_with_order(build, bundle, opts, Some(&vis), Some(order));
|
||||
base.chunks_exact(4)
|
||||
.zip(without.rgba.chunks_exact(4))
|
||||
.map(|(x, y)| x != y)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn swapped(order: &[usize], a: usize, b: usize) -> Vec<usize> {
|
||||
let mut o = order.to_vec();
|
||||
let (pa, pb) = (
|
||||
o.iter().position(|&e| e == a).unwrap(),
|
||||
o.iter().position(|&e| e == b).unwrap(),
|
||||
);
|
||||
o.swap(pa, pb);
|
||||
o
|
||||
}
|
||||
|
||||
/// The element's on-screen rect, the same approximation the tie census uses:
|
||||
/// the declared pivot doubled, placed at the keyframe. `at` selects the pose —
|
||||
/// `None` is `rest()`, which is where the original census was computed.
|
||||
///
|
||||
/// 🔴 An element that is TRANSPARENT at the chosen pose gets no rect at all. A
|
||||
/// tie involving something invisible cannot cost a pixel, and counting it as an
|
||||
/// overlap is what made the original census an upper bound rather than a cost.
|
||||
fn rect(e: &ui_layout::Element, at: Option<u32>) -> Option<(i32, i32, i32, i32)> {
|
||||
let kf = match at {
|
||||
Some(t) => e.pose_at(t)?,
|
||||
None => e.rest()?.clone(),
|
||||
};
|
||||
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((kf.x, kf.y, w, h))
|
||||
}
|
||||
|
||||
fn overlaps(a: &ui_layout::Element, b: &ui_layout::Element, at: Option<u32>) -> bool {
|
||||
let (Some(ra), Some(rb)) = (rect(a, at), rect(b, at)) else {
|
||||
return false;
|
||||
};
|
||||
(ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
|
||||
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0
|
||||
}
|
||||
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
opts: ComposeOptions,
|
||||
}
|
||||
|
||||
fn cases() -> Vec<Case> {
|
||||
vec![
|
||||
Case {
|
||||
name: "default (what `screen render` draws)",
|
||||
opts: ComposeOptions {
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "everything on (focus+animated+primitives)",
|
||||
opts: ComposeOptions {
|
||||
include_focus: true,
|
||||
include_animated: true,
|
||||
include_primitives: true,
|
||||
backdrop: [0, 0, 0, 255],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
// 🔴 The two cases above pose at `rest()`, which is each element's last
|
||||
// hold picked independently — so they draw transients that the settled
|
||||
// screen does not have (`docs/re/structures/ui-settle-time.md`). A tie
|
||||
// between two elements that are transparent at the settle time cannot
|
||||
// cost a pixel on the screen the player sees, however much their rects
|
||||
// overlap at rest. `at` is filled in per entry.
|
||||
Case {
|
||||
name: "AT THE SETTLE TIME (what the player sees)",
|
||||
opts: ComposeOptions {
|
||||
backdrop: [0, 0, 0, 255],
|
||||
at: Some(0), // replaced per entry
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Vec<(usize, usize, u32)> {
|
||||
let keys: Vec<u32> = build
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
for a in 0..keys.len() {
|
||||
for b in (a + 1)..keys.len() {
|
||||
if keys[a] != keys[b] || keys[a] == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
if overlaps(&build.elements[a], &build.elements[b], at) {
|
||||
out.push((a, b, keys[a]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A pair the game's own order DOES separate: overlapping, different keys.
|
||||
/// Used as the control — swapping it must move pixels.
|
||||
fn control_pair(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Option<(usize, usize)> {
|
||||
let keys: Vec<u32> = build
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
|
||||
.collect();
|
||||
let mut best: Option<(i64, usize, usize)> = None;
|
||||
for a in 0..keys.len() {
|
||||
for b in (a + 1)..keys.len() {
|
||||
if keys[a] == keys[b] || keys[a] == u32::MAX || keys[b] == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
if !overlaps(&build.elements[a], &build.elements[b], at) {
|
||||
continue;
|
||||
}
|
||||
let (ra, rb) = (rect(&build.elements[a], at)?, rect(&build.elements[b], at)?);
|
||||
let ox = ((ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0)) as i64;
|
||||
let oy = ((ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1)) as i64;
|
||||
let area = ox * oy;
|
||||
if best.map_or(true, |(x, _, _)| area > x) {
|
||||
best = Some((area, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, a, b)| (a, b))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args()
|
||||
.nth(1)
|
||||
.expect("usage: tie_break_pixel_cost <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
let entries: Vec<_> = ar.entries().to_vec();
|
||||
println!("# tie-break pixel cost — {path}\n");
|
||||
let mut totals = (0usize, 0usize, 0usize); // pairs, changed-a-pixel, controls-dead
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
let Some(build) = ui_layout::parse_build(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
// The census pairs are still computed at rest, so the report can say
|
||||
// how many of THOSE survive posing at the settle time.
|
||||
let pairs_at_rest = tied_overlapping_pairs(&build, &bytes, None);
|
||||
if pairs_at_rest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let settle = build.settle_time();
|
||||
println!(
|
||||
"entry {i:2} {} elements {} overlapping tied pair(s) at rest{}",
|
||||
build.elements.len(),
|
||||
pairs_at_rest.len(),
|
||||
match (settle, build.settle_window()) {
|
||||
(Some(t), Some((lo, hi))) => format!(" settle t={t} (window {} units)", hi - lo),
|
||||
_ => " NO SETTLE WINDOW".to_string(),
|
||||
}
|
||||
);
|
||||
let derived = ui_layout::derived_paint_order(&build, &bytes);
|
||||
for mut c in cases() {
|
||||
// The settle case is a no-op on a bundle that never settles.
|
||||
if c.opts.at.is_some() {
|
||||
match settle {
|
||||
Some(t) => c.opts.at = Some(t),
|
||||
None => {
|
||||
println!(" [{}] SKIPPED: no settle window", c.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pairs = tied_overlapping_pairs(&build, &bytes, c.opts.at);
|
||||
if pairs.len() != pairs_at_rest.len() {
|
||||
println!(
|
||||
" [{}] 🔴 {} of the {} tied pairs are GONE at this pose (an element is \
|
||||
transparent or collapsed there) — they cannot cost a pixel",
|
||||
c.name,
|
||||
pairs_at_rest.len() - pairs.len(),
|
||||
pairs_at_rest.len()
|
||||
);
|
||||
}
|
||||
let base = ui_layout::compose_with_order(&build, &bytes, c.opts, None, Some(&derived));
|
||||
let drawn: std::collections::HashSet<usize> = base.drawn.iter().copied().collect();
|
||||
// Control first. An instrument that cannot see a reorder it is
|
||||
// supposed to see makes every zero below meaningless.
|
||||
let ctrl = match control_pair(&build, &bytes, c.opts.at) {
|
||||
Some((a, b)) if drawn.contains(&a) && drawn.contains(&b) => {
|
||||
let alt = ui_layout::compose_with_order(
|
||||
&build,
|
||||
&bytes,
|
||||
c.opts,
|
||||
None,
|
||||
Some(&swapped(&derived, a, b)),
|
||||
);
|
||||
let (n, w) = diff(&base.rgba, &alt.rgba);
|
||||
Some((a, b, n, w))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match ctrl {
|
||||
Some((a, b, n, w)) if n > 0 => println!(
|
||||
" [{}] CONTROL ok: swapping [{a}] {} x [{b}] {} moves {n} px (max Δ {w})",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
),
|
||||
Some((a, b, _, _)) => {
|
||||
totals.2 += 1;
|
||||
println!(
|
||||
" [{}] CONTROL DEAD: swapping [{a}] {} x [{b}] {} changes NOTHING — \
|
||||
zeros below are uninterpretable",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
)
|
||||
}
|
||||
None => {
|
||||
totals.2 += 1;
|
||||
println!(
|
||||
" [{}] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn",
|
||||
c.name
|
||||
)
|
||||
}
|
||||
}
|
||||
for &(a, b, key) in &pairs {
|
||||
let both_drawn = drawn.contains(&a) && drawn.contains(&b);
|
||||
if !both_drawn {
|
||||
println!(
|
||||
" [{}] [{a}] {} x [{b}] {} (key {key}): NOT BOTH DRAWN — unreachable here",
|
||||
c.name, build.elements[a].name, build.elements[b].name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let alt = ui_layout::compose_with_order(
|
||||
&build,
|
||||
&bytes,
|
||||
c.opts,
|
||||
None,
|
||||
Some(&swapped(&derived, a, b)),
|
||||
);
|
||||
let (n, w) = diff(&base.rgba, &alt.rgba);
|
||||
let total = (base.width as usize) * (base.height as usize);
|
||||
// Explain the number: how many pixels do the two BOTH paint on?
|
||||
// A zero with a large shared-ink count is a real "order does
|
||||
// not matter here"; a zero with no shared ink means the
|
||||
// bounding boxes overlapped and the sprites did not.
|
||||
let ma = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, a);
|
||||
let mb = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, b);
|
||||
let shared = ma.iter().zip(&mb).filter(|(x, y)| **x && **y).count();
|
||||
let (ia, ib) = (
|
||||
ma.iter().filter(|x| **x).count(),
|
||||
mb.iter().filter(|x| **x).count(),
|
||||
);
|
||||
println!(
|
||||
" [{}] [{a}] {} x [{b}] {} (key {key}): {n} px differ ({:.4}% of frame), \
|
||||
max Δ {w} | ink {ia} / {ib} px, shared {shared} px",
|
||||
c.name,
|
||||
build.elements[a].name,
|
||||
build.elements[b].name,
|
||||
100.0 * n as f64 / total as f64
|
||||
);
|
||||
if c.name.starts_with("default") {
|
||||
totals.0 += 1;
|
||||
if n > 0 {
|
||||
totals.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!(
|
||||
"default-options summary: {} of {} overlapping tied pairs change at least one pixel; \
|
||||
{} dead/unavailable controls",
|
||||
totals.1, totals.0, totals.2
|
||||
);
|
||||
}
|
||||
76
crates/sylpheed-formats/examples/tie_cost_over_time.rs
Normal file
76
crates/sylpheed-formats/examples/tie_cost_over_time.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! How many tied pairs can cost a pixel, as a function of TIME?
|
||||
//!
|
||||
//! `tie_break_pixel_cost` answers "at one pose". That leaves the answer looking
|
||||
//! like it might be a knife-edge: pick a different instant and the count could
|
||||
//! jump. This sweeps every keyframe time in the bundle and reports, per entry,
|
||||
//! how many same-key pairs are simultaneously **opaque, non-collapsed and
|
||||
//! overlapping** — the pairs whose order could possibly matter at that instant.
|
||||
//!
|
||||
//! The instrument's control is built in: the count at t=0 (nothing has faded in)
|
||||
//! and the count at rest must bracket it, and an entry whose count is flat at
|
||||
//! zero for the whole sweep would be suspicious rather than reassuring — so the
|
||||
//! peak is printed too.
|
||||
use sylpheed_formats::{pak, ui_layout};
|
||||
use ui_layout::UiBuild;
|
||||
|
||||
fn rect(e: &ui_layout::Element, t: u32) -> Option<(i32, i32, i32, i32)> {
|
||||
let kf = e.pose_at(t)?;
|
||||
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
|
||||
if w == 0 || h == 0 { return None; }
|
||||
Some((kf.x, kf.y, w, h))
|
||||
}
|
||||
|
||||
fn live_pairs(b: &UiBuild, bytes: &[u8], keys: &[u32], t: u32) -> usize {
|
||||
let mut n = 0;
|
||||
for a in 0..keys.len() {
|
||||
for c in (a + 1)..keys.len() {
|
||||
if keys[a] != keys[c] || keys[a] == u32::MAX { continue }
|
||||
let (Some(ra), Some(rb)) = (rect(&b.elements[a], t), rect(&b.elements[c], t)) else { continue };
|
||||
if (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
|
||||
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0 { n += 1 }
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: tie_cost_over_time <pak>");
|
||||
let ar = pak::PakArchive::open(&path).expect("open pak");
|
||||
println!("# tied pairs that could cost a pixel, over time — {path}\n");
|
||||
for (i, e) in ar.entries().to_vec().iter().enumerate() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let keys: Vec<u32> = b.elements.iter()
|
||||
.map(|el| ui_layout::sprite_layer_key(&b, &by, el).unwrap_or(u32::MAX)).collect();
|
||||
let mut ts: Vec<u32> = b.elements.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
|
||||
ts.sort_unstable(); ts.dedup();
|
||||
if ts.len() < 2 { continue }
|
||||
let last = *ts.last().unwrap();
|
||||
// sample every keyframe time AND every midpoint between them
|
||||
let mut samples: Vec<u32> = ts.clone();
|
||||
for w in ts.windows(2) { samples.push(w[0] + (w[1] - w[0]) / 2); }
|
||||
samples.sort_unstable(); samples.dedup();
|
||||
let counts: Vec<(u32, usize)> =
|
||||
samples.iter().map(|&t| (t, live_pairs(&b, &by, &keys, t))).collect();
|
||||
let peak = counts.iter().map(|&(_, n)| n).max().unwrap_or(0);
|
||||
if peak == 0 { continue }
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
let st = b.settle_time().unwrap();
|
||||
let at_settle = live_pairs(&b, &by, &keys, st);
|
||||
// the whole plateau, not just its midpoint
|
||||
let plateau: Vec<usize> =
|
||||
(lo..=hi).step_by(((hi - lo).max(1) / 8).max(1) as usize)
|
||||
.map(|t| live_pairs(&b, &by, &keys, t)).collect();
|
||||
let pmax = plateau.iter().copied().max().unwrap_or(0);
|
||||
println!("entry {i:2} peak {peak} live pair(s) over t=0..{last} \
|
||||
settle window [{lo},{hi}] at t={st}: {at_settle} ACROSS THE WHOLE WINDOW: max {pmax}");
|
||||
let busy: Vec<String> = counts.iter().filter(|&&(_, n)| n > 0)
|
||||
.map(|&(t, n)| format!("t{t}:{n}")).collect();
|
||||
if busy.len() <= 24 { println!(" live only at {}", busy.join(" ")); }
|
||||
else { println!(" live at {} of {} sampled instants", busy.len(), counts.len()); }
|
||||
}
|
||||
}
|
||||
71
crates/sylpheed-formats/examples/voice_region_cap_sweep.rs
Normal file
71
crates/sylpheed-formats/examples/voice_region_cap_sweep.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Is raising the start filter's 1.5 MB cap safe, disc-wide?
|
||||
//!
|
||||
//! `voice_region_fix_test.rs` shows that for `ADV` the predecessor start recovers
|
||||
//! the decoder's own three byte_sizes exactly. But the cap exists to protect a
|
||||
//! case: the code says *"only within one bank (~1.5 MB), else this is the first cue
|
||||
//! in its block and the audio starts at the anchor itself"*. Raising it blindly
|
||||
//! could pull a **previous asset's** streams into the region.
|
||||
//!
|
||||
//! So compare, per movie: the chunk list the resolver gives today against the one
|
||||
//! the predecessor start gives. A safe change makes the FIRST chunk bigger and
|
||||
//! leaves the rest identical. An unsafe one adds leading chunks.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_cap_sweep
|
||||
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
use sylpheed_formats::{movie_manifest, movie_voice};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let code = VoiceLang::English.code_pub();
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak.entries().iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||
let registry = tpak.entries().iter().find_map(|e| {
|
||||
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
}).expect("registry");
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
|
||||
let (mut same, mut grew, mut extra, mut skip) = (0, 0, 0, 0);
|
||||
for m in movie_manifest::parse(&manifest) {
|
||||
let movie = m.movie;
|
||||
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { skip += 1; continue };
|
||||
let Some(&id) = ids.get(&token) else { skip += 1; continue };
|
||||
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||||
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
|
||||
}) else { skip += 1; continue };
|
||||
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
|
||||
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { skip += 1; continue };
|
||||
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { skip += 1; continue };
|
||||
let end = win_start + end_local as u64;
|
||||
let cand = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end);
|
||||
let today = cand.filter(|&s| end - s < 1_500_000).unwrap_or(anchor);
|
||||
let Some(proposed) = cand else { skip += 1; continue };
|
||||
if today == proposed { same += 1; continue }
|
||||
let sizes = |s: u64| -> Vec<usize> {
|
||||
src.read_segment_range("dat/sound", s, (end - s) as usize)
|
||||
.map(|b| slb::to_xma_riffs(&b).iter().map(|r| r.len() - 60).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (a, b) = (sizes(today), sizes(proposed));
|
||||
let tail_same = a.len() == b.len() && a.iter().skip(1).eq(b.iter().skip(1));
|
||||
let verdict = if a.len() == b.len() && tail_same && b[0] > a[0] {
|
||||
grew += 1; "first chunk GREW, tail identical"
|
||||
} else if b.len() > a.len() { extra += 1; "EXTRA leading chunks" }
|
||||
else { extra += 1; "changed otherwise" };
|
||||
println!("{movie:10} today {a:?}\n{:10} prop {b:?} {verdict}", "");
|
||||
}
|
||||
println!("\nunchanged {same} fixed-cleanly {grew} would-break {extra} skipped {skip}");
|
||||
}
|
||||
97
crates/sylpheed-formats/examples/voice_region_chunks.rs
Normal file
97
crates/sylpheed-formats/examples/voice_region_chunks.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! What ARE the chunks a movie-voice region decodes to?
|
||||
//!
|
||||
//! The port reports a resolved voice region decoding to **three** chunks — two
|
||||
//! of equal duration each spanning the whole movie, and a leading one that
|
||||
//! "matches nothing" — and notes that this is the same 2+1 signature the BGM
|
||||
//! banks showed before `bank_header_len` attributed the extra to a bank header.
|
||||
//! Two different asset kinds with one signature is worth checking, because if
|
||||
//! the same explanation applies then `bank_header_len` is incomplete, and if it
|
||||
//! does not then the leading chunk is something we are discarding.
|
||||
//!
|
||||
//! `slb.rs`'s own doc comment already predicts the answer and disagrees with
|
||||
//! "drop it": the header signature fires on 28 entries, all music banks, with
|
||||
//! "zero false positives on the 7 993 mid-bank windows, WHERE THE LEADING
|
||||
//! REGION IS REAL". A voice region is a mid-bank window by construction —
|
||||
//! `resolve_movie_voice_region` starts it at the PREDECESSOR cue's trailer.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_chunks -- <disc-dir>
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// Disc-wide, not the four movies that motivated the question: every movie
|
||||
// the manifest binds a voice to.
|
||||
let movies: Vec<String> = {
|
||||
use sylpheed_formats::movie_manifest;
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("movie manifest");
|
||||
movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.map(|m| m.movie)
|
||||
.collect()
|
||||
};
|
||||
println!("{} movies in the manifest\n", movies.len());
|
||||
let mut census: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
let (mut with_header, mut with_leading, mut no_leading) = (0, 0, 0);
|
||||
for movie in movies.iter().map(|s| s.as_str()) {
|
||||
let Some((start, end)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let len = end - start;
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", start, len as usize) else {
|
||||
println!("{movie:8} region {start}..{end} unreadable");
|
||||
continue;
|
||||
};
|
||||
// Where does the first RIFF sit? Everything before it is the leading
|
||||
// headerless packet region.
|
||||
let first_riff = bytes
|
||||
.windows(4)
|
||||
.position(|w| w == b"RIFF")
|
||||
.map(|p| p as i64)
|
||||
.unwrap_or(-1);
|
||||
let hdr = slb::bank_header_len(&bytes);
|
||||
let riffs = slb::to_xma_riffs(&bytes);
|
||||
let kind = if first_riff <= 0 {
|
||||
no_leading += 1;
|
||||
"no leading region".to_string()
|
||||
} else if hdr == Some(first_riff as usize) {
|
||||
with_header += 1;
|
||||
format!("BANK HEADER ({first_riff} B = {} packets exactly)", first_riff / 2048)
|
||||
} else {
|
||||
with_leading += 1;
|
||||
*census.entry(first_riff as usize % 2048).or_default() += 1;
|
||||
format!(
|
||||
"leading STREAM ({first_riff} B = {} packets + {} B)",
|
||||
first_riff / 2048,
|
||||
first_riff % 2048
|
||||
)
|
||||
};
|
||||
println!(
|
||||
"{movie:10} {start:12}..{end:12} {len:9} B chunks {} {kind}",
|
||||
riffs.len()
|
||||
);
|
||||
if let Ok(dir) = std::env::var("VOICE_CHUNK_DUMP") {
|
||||
for (i, r) in riffs.iter().enumerate() {
|
||||
let _ = std::fs::write(format!("{dir}/{movie}-chunk{i}.wav"), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\n{with_header} region(s) open with a BANK HEADER (bank_header_len fires)\n\
|
||||
{with_leading} open with a leading STREAM\n{no_leading} start at a RIFF"
|
||||
);
|
||||
println!("leading-stream length mod 2048, i.e. the derived data offset:");
|
||||
for (rem, n) in &census {
|
||||
println!(" {rem:5} B x{n}");
|
||||
}
|
||||
}
|
||||
40
crates/sylpheed-formats/examples/voice_region_fix_test.rs
Normal file
40
crates/sylpheed-formats/examples/voice_region_fix_test.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Would keeping the predecessor (instead of falling back to `anchor`) recover
|
||||
//! the streams the running decoder actually decodes?
|
||||
//!
|
||||
//! `voice_region_start_why.rs` shows the failing branch: the start filter
|
||||
//! `end - s < 1_500_000` rejects `ADV`'s predecessor because its span is 3.6 MB,
|
||||
//! so `start` falls back to `anchor` — a TOC offset, not a stream boundary.
|
||||
//!
|
||||
//! This does NOT patch the resolver. It asks the one question that decides whether
|
||||
//! raising that cap is the fix: **from the predecessor, does `to_xma_riffs` return
|
||||
//! the decoder's own byte_sizes?** For `ADV` those are known, so this is a test and
|
||||
//! not a fit.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_fix_test
|
||||
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb;
|
||||
|
||||
const ADV_PRED: u64 = 433_425_776;
|
||||
const ADV_ANCHOR: u64 = 433_930_240;
|
||||
const ADV_END: u64 = 437_044_592;
|
||||
/// What the running decoder reported.
|
||||
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
for (label, start) in [("anchor (today)", ADV_ANCHOR), ("predecessor (proposed)", ADV_PRED)] {
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (ADV_END - start) as usize)
|
||||
.expect("region");
|
||||
let sizes: Vec<usize> = slb::to_xma_riffs(&bytes).iter().map(|r| r.len() - 60).collect();
|
||||
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
|
||||
println!(
|
||||
"{label:24} start {start} span {:>9} -> {:?}{}",
|
||||
ADV_END - start,
|
||||
sizes,
|
||||
if hit { " <== MATCHES THE DECODER" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
63
crates/sylpheed-formats/examples/voice_region_start_audit.rs
Normal file
63
crates/sylpheed-formats/examples/voice_region_start_audit.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Does `resolve_movie_voice_region` start inside the first stream, disc-wide?
|
||||
//!
|
||||
//! Verified on `ADV`: the resolver starts **238 packets (487 424 B) late**, and
|
||||
//! extending the span by exactly that reproduces the running decoder's three
|
||||
//! byte_sizes to the byte. The decoder is the ground truth there, but it exists
|
||||
//! for one movie only — so this asks a structural question instead.
|
||||
//!
|
||||
//! **If the region began at a stream boundary, stepping the start backwards would
|
||||
//! immediately expose the PREVIOUS asset's chunks.** If it began mid-stream, the
|
||||
//! first chunk instead *grows*, packet for packet, until the real boundary. The
|
||||
//! number of packets it grows for is the clip.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_start_audit
|
||||
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::movie_manifest;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let movies: Vec<String> = movie_manifest::parse(&manifest)
|
||||
.into_iter()
|
||||
.map(|m| m.movie)
|
||||
.collect();
|
||||
|
||||
println!("{:10} {:>8} {:>12} {:>10} {}", "movie", "chunks", "first chunk", "clip pkts", "verdict");
|
||||
let (mut clipped, mut clean, mut skipped) = (0, 0, 0);
|
||||
for movie in movies {
|
||||
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
|
||||
else { skipped += 1; continue };
|
||||
// ONE read of the region plus a lead-in, then slide inside it. Re-reading
|
||||
// several MB per step made this too slow to finish at all.
|
||||
const LEAD: u64 = 600 * 2048;
|
||||
let lead = LEAD.min(start);
|
||||
let buf = match src.read_segment_range("dat/sound", start - lead, (end - (start - lead)) as usize) {
|
||||
Ok(b) => b, Err(_) => { skipped += 1; continue }
|
||||
};
|
||||
let at = |back: usize| -> Vec<Vec<u8>> {
|
||||
let off = lead as usize - back * 2048;
|
||||
slb::to_xma_riffs(&buf[off..])
|
||||
};
|
||||
let base_riffs = at(0);
|
||||
if base_riffs.is_empty() { skipped += 1; continue }
|
||||
let n0 = base_riffs.len();
|
||||
let first0 = base_riffs[0].len() - 60;
|
||||
let mut clip = 0usize;
|
||||
for k in 1..=(lead as usize / 2048) {
|
||||
if at(k).len() != n0 { break }
|
||||
clip = k;
|
||||
}
|
||||
let verdict = if clip == 0 { clean += 1; "starts at a boundary" }
|
||||
else { clipped += 1; "STARTS MID-STREAM" };
|
||||
println!("{movie:10} {n0:>8} {first0:>12} {clip:>10} {verdict}");
|
||||
}
|
||||
println!("\nclipped {clipped} clean {clean} skipped {skipped}");
|
||||
}
|
||||
70
crates/sylpheed-formats/examples/voice_region_start_why.rs
Normal file
70
crates/sylpheed-formats/examples/voice_region_start_why.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! WHY does `resolve_movie_voice_region` start inside the first stream?
|
||||
//!
|
||||
//! [`voice-region-starts-late.md`] establishes that it does — 238 packets late for
|
||||
//! `ADV`, 8 of 10 multichannel regions disc-wide — but not why, and a fix guessed
|
||||
//! from one movie would be worse than a documented defect. This reproduces the
|
||||
//! resolver's own steps and prints each candidate, so the failing branch is visible
|
||||
//! rather than inferred.
|
||||
//!
|
||||
//! The suspicion the code itself raises: the start is filtered by
|
||||
//! `end - s < 1_500_000` — "only within one bank" — and `ADV`'s region has to span
|
||||
//! **3.6 MB**. If that filter rejects the real predecessor, `start` silently falls
|
||||
//! back to `anchor`, which is a TOC offset and not a stream boundary at all.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_region_start_why
|
||||
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
use sylpheed_formats::{movie_manifest, movie_voice};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let code = VoiceLang::English.code_pub();
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let manifest = tpak
|
||||
.entries().iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
|
||||
let registry = tpak.entries().iter().find_map(|e| {
|
||||
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
}).expect("registry");
|
||||
let ids = movie_voice::registry_voice_ids(®istry);
|
||||
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
|
||||
let entries = PakArchive::parse_toc(&stoc).expect("toc");
|
||||
|
||||
println!("{:10} {:>6} {:>12} {:>12} {:>12} {:>10} {:>9} {}",
|
||||
"movie","id","anchor","pred(id-1)","pred(before)","span","chosen","note");
|
||||
for m in movie_manifest::parse(&manifest) {
|
||||
let movie = m.movie;
|
||||
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { continue };
|
||||
let Some(&id) = ids.get(&token) else { continue };
|
||||
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
|
||||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||||
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
|
||||
}) else { continue };
|
||||
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
|
||||
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { continue };
|
||||
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { continue };
|
||||
let end = win_start + end_local as u64;
|
||||
let p1 = movie_voice::find_descriptor(&window, id.wrapping_sub(1)).map(|o| win_start + o as u64);
|
||||
let pb = movie_voice::find_descriptor_before(&window, end_local).map(|o| win_start + o as u64);
|
||||
let cand = p1.or(pb);
|
||||
// the resolver's own filter
|
||||
let kept = cand.filter(|&s| s < end && end - s < 1_500_000);
|
||||
let chosen = kept.unwrap_or(anchor);
|
||||
let span = cand.map(|s| end.saturating_sub(s)).unwrap_or(0);
|
||||
let note = match (cand, kept) {
|
||||
(Some(_), None) => "REJECTED by the 1.5 MB filter -> fell back to anchor",
|
||||
(Some(_), Some(_)) => "predecessor kept",
|
||||
(None, _) => "no predecessor found -> anchor",
|
||||
};
|
||||
println!("{movie:10} {id:>6} {anchor:>12} {:>12} {:>12} {span:>10} {:>9} {note}",
|
||||
p1.map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
pb.map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
if chosen == anchor { "anchor" } else { "pred" });
|
||||
}
|
||||
}
|
||||
205
crates/sylpheed-formats/examples/voice_stream_cue_map.rs
Normal file
205
crates/sylpheed-formats/examples/voice_stream_cue_map.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Who owns the bytes in front of a movie-voice region's first `RIFF`?
|
||||
//!
|
||||
//! [`voice-region-leading-chunk.md`] left one thing open: the leading chunk of
|
||||
//! the 17 stream-opening regions is real XMA audio that **no other movie-voice
|
||||
//! region claims** — but the census only enumerated the 95 movie cues, while the
|
||||
//! same continuous stream also carries the in-mission `VOICE_D_*` lines. The
|
||||
//! leading hypothesis was that the bytes belong to one of those, and the port
|
||||
//! pointed out that the byte-span test already written settles it *without
|
||||
//! anyone listening* if the enumeration is widened.
|
||||
//!
|
||||
//! So this widens it the whole way: rather than resolving cues one at a time
|
||||
//! through the manifest, scan the stream itself for **every** trailer descriptor
|
||||
//! — the `(id: u32be, 0x11, …)` pair whose id repeats at `+0x800`, which
|
||||
//! `movie_voice` documents as the end of a cue's audio. Cue N's audio is
|
||||
//! `[descriptor(N-1) .. descriptor(N)]`, so the full descriptor list IS the
|
||||
//! complete cue partition of the stream, movie and mission alike.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_stream_cue_map -- <disc-dir>
|
||||
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
/// Every trailer descriptor in `buf`, as `(offset, id)`.
|
||||
///
|
||||
/// Same predicate `movie_voice::find_descriptor` uses — the id-repeat at +0x800
|
||||
/// is what makes a false match inside XMA audio ~2^-64.
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
|
||||
// The cue-name -> id registry, so a descriptor id can be named.
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("voice registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
println!("registry: {} cue names, {} distinct ids", ids.len(), name_of.len());
|
||||
|
||||
// The 17 regions that open with a headerless stream, from the manifest.
|
||||
let movies: Vec<String> = {
|
||||
use sylpheed_formats::movie_manifest;
|
||||
let manifest = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("manifest");
|
||||
movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect()
|
||||
};
|
||||
let mut regions = Vec::new();
|
||||
for m in &movies {
|
||||
if let Some((s, e)) = media::resolve_movie_voice_region(&src, m, VoiceLang::English) {
|
||||
regions.push((m.clone(), s, e));
|
||||
}
|
||||
}
|
||||
let lo = regions.iter().map(|r| r.1).min().unwrap();
|
||||
let hi = regions.iter().map(|r| r.2).max().unwrap();
|
||||
// Scan a window covering every region, with margin for cues either side.
|
||||
let win_start = lo.saturating_sub(8 * 1024 * 1024) & !3;
|
||||
let win_len = (hi - win_start + 8 * 1024 * 1024) as usize;
|
||||
println!("scanning dat/sound {win_start}..{} ({:.1} MB)", win_start + win_len as u64,
|
||||
win_len as f64 / 1e6);
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, win_len)
|
||||
.expect("stream window");
|
||||
let descs = all_descriptors(&buf);
|
||||
println!("{} trailer descriptors found\n", descs.len());
|
||||
|
||||
let named = descs.iter().filter(|(_, id)| name_of.contains_key(id)).count();
|
||||
println!(" of those, {named} carry an id the registry names, {} do not\n",
|
||||
descs.len() - named);
|
||||
|
||||
// For each stream-opening region, name the cue that OWNS the leading span:
|
||||
// the cue whose [prev_desc .. desc] interval contains it.
|
||||
println!("leading span -> owning cue\n");
|
||||
let mut verdicts: std::collections::BTreeMap<&str, usize> = Default::default();
|
||||
for (m, s, e) in ®ions {
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
|
||||
let Some(fr) = bytes.windows(4).position(|w| w == b"RIFF") else { continue };
|
||||
if fr == 0 || sylpheed_formats::slb::bank_header_len(&bytes) == Some(fr) {
|
||||
continue; // bank-header case, not ours
|
||||
}
|
||||
let (a, b) = (*s, *s + fr as u64); // the leading span, global offsets
|
||||
// Descriptors bracketing the MIDDLE of the leading span.
|
||||
let mid = (a + b) / 2;
|
||||
let mid_local = (mid - win_start) as usize;
|
||||
let before = descs.iter().rev().find(|(o, _)| (*o as u64) < mid_local as u64);
|
||||
let after = descs.iter().find(|(o, _)| *o >= mid_local);
|
||||
let owner = after.map(|(_, id)| *id);
|
||||
let owner_name = owner
|
||||
.and_then(|id| name_of.get(&id).cloned())
|
||||
.unwrap_or_else(|| owner.map(|i| format!("<unnamed id {i}>")).unwrap_or("<none>".into()));
|
||||
let kind = if owner_name.starts_with("VOICE_D_") {
|
||||
"MISSION line"
|
||||
} else if owner_name.starts_with("VOICE_") {
|
||||
"movie cue"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
*verdicts.entry(kind).or_default() += 1;
|
||||
println!(
|
||||
" {m:8} lead {:8} B bracketed by desc@{:?} .. desc@{:?} owner {owner_name} [{kind}]",
|
||||
b - a,
|
||||
before.map(|(o, i)| (*o as u64 + win_start, *i)),
|
||||
after.map(|(o, i)| (*o as u64 + win_start, *i)),
|
||||
);
|
||||
}
|
||||
println!("\nverdicts: {verdicts:?}");
|
||||
|
||||
// WHY do exactly these 17 open mid-cue? `resolve_movie_voice_region` takes
|
||||
// the predecessor trailer as the region start, but guards it with
|
||||
// `end - start < 1_500_000` and falls back to the .slb TOC anchor when that
|
||||
// fails. If the guard is the cause, then the stream-opening regions are
|
||||
// exactly the cues whose true span exceeds the guard.
|
||||
println!("\ncue span vs the 1.5 MB guard, and what the region actually starts at:\n");
|
||||
let (mut over, mut under, mut over_is_stream, mut under_is_stream) = (0, 0, 0, 0);
|
||||
for (m, s, e) in ®ions {
|
||||
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
|
||||
let fr = bytes.windows(4).position(|w| w == b"RIFF");
|
||||
let is_stream = matches!(fr, Some(f) if f > 0
|
||||
&& sylpheed_formats::slb::bank_header_len(&bytes) != Some(f));
|
||||
// The true predecessor trailer for this cue, from the full descriptor list.
|
||||
let end_local = (*e - win_start) as usize;
|
||||
let prev = descs.iter().rev().find(|(o, _)| *o < end_local).map(|(o, _)| *o as u64 + win_start);
|
||||
let Some(prev) = prev else { continue };
|
||||
let span = e - prev;
|
||||
let guarded = span >= 1_500_000;
|
||||
if guarded { over += 1; if is_stream { over_is_stream += 1 } }
|
||||
else { under += 1; if is_stream { under_is_stream += 1 } }
|
||||
if is_stream {
|
||||
println!(
|
||||
" {m:8} true cue span {span:8} B (> guard: {guarded}) region starts at {s}, \
|
||||
true start {prev} -> {} B of the cue's own audio is OUTSIDE the region",
|
||||
s.saturating_sub(prev)
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("\ncues over the 1.5 MB guard: {over}, of which stream-opening: {over_is_stream}");
|
||||
println!("cues under the guard: {under}, of which stream-opening: {under_is_stream}");
|
||||
|
||||
// How many streams is ONE cue stored as? The port measured that a region's
|
||||
// leading chunk is the TAIL of its first full-length chunk, i.e. the cue is
|
||||
// re-presented. Structurally that predicts a fixed number of stream starts
|
||||
// inside a cue's TRUE span [desc(N-1) .. desc(N)] -- which is measurable
|
||||
// from the bytes alone, with no decoder.
|
||||
println!("\nstream starts inside each cue's TRUE span (desc(N-1)..desc(N)):\n");
|
||||
let mut hist: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
let mut hist_long: std::collections::BTreeMap<usize, usize> = Default::default();
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
let span = &buf[a..b];
|
||||
// A stream start is a RIFF; plus the run before the first one, when it
|
||||
// is not a bank header, is itself a stream.
|
||||
let riffs = span
|
||||
.windows(4)
|
||||
.enumerate()
|
||||
.filter(|(_, w)| *w == b"RIFF")
|
||||
.count();
|
||||
let lead_is_stream = match span.windows(4).position(|w| w == b"RIFF") {
|
||||
Some(f) if f > 0 => sylpheed_formats::slb::bank_header_len(span) != Some(f),
|
||||
_ => false,
|
||||
};
|
||||
let streams = riffs + usize::from(lead_is_stream);
|
||||
*hist.entry(streams).or_default() += 1;
|
||||
if b - a >= 1_500_000 {
|
||||
*hist_long.entry(streams).or_default() += 1;
|
||||
}
|
||||
}
|
||||
println!(" all inter-descriptor spans: {hist:?}");
|
||||
println!(" spans >= 1.5 MB (the long cues): {hist_long:?}");
|
||||
}
|
||||
122
crates/sylpheed-formats/examples/voice_three_stream_sizes.rs
Normal file
122
crates/sylpheed-formats/examples/voice_three_stream_sizes.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Are the three streams of a voice cue in a CONSISTENT size relationship?
|
||||
//!
|
||||
//! The port selected a voice presentation on this argument: `ADV` chunk 1 is
|
||||
//! mono-in-stereo (channel 2 digitally silent) and chunk 2 is dual-mono (both
|
||||
//! channels identical), so chunk 2's extra bytes encode a duplicate channel
|
||||
//! rather than fidelity — which would explain its higher declared
|
||||
//! `PsuedoBytesPerSec` without appealing to encode quality.
|
||||
//!
|
||||
//! That is a claim about the *encoding*, and it makes a structural prediction:
|
||||
//! if stream 3 is always "the same take with its channel duplicated", it should
|
||||
//! sit in a consistent size ratio to stream 2 across every 3-stream cue on the
|
||||
//! disc. If the ratio scatters — or if some third streams are tiny — then the
|
||||
//! observation is about `ADV`, not about the format.
|
||||
//!
|
||||
//! cargo run -p sylpheed-formats --example voice_three_stream_sizes -- <disc>
|
||||
use sylpheed_formats::media::{DirectorySource, DiscSource};
|
||||
use sylpheed_formats::slb;
|
||||
|
||||
const DESC_MARK: u32 = 0x11;
|
||||
const DESC_REPEAT: usize = 0x800;
|
||||
const ID_MAX: u32 = 0x1_0000;
|
||||
|
||||
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
|
||||
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
|
||||
let mut out = Vec::new();
|
||||
if buf.len() < DESC_REPEAT + 8 {
|
||||
return out;
|
||||
}
|
||||
let end = buf.len() - (DESC_REPEAT + 4);
|
||||
let mut o = 0;
|
||||
while o <= end {
|
||||
let id = be(o);
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
out.push((o, id));
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declared `PsuedoBytesPerSec` at `fmt +0x20` of a RIFF chunk, if present.
|
||||
fn declared_rate(riff: &[u8]) -> Option<u32> {
|
||||
if riff.len() < 0x28 || &riff[0..4] != b"RIFF" {
|
||||
return None;
|
||||
}
|
||||
Some(u32::from_le_bytes(riff[0x20..0x24].try_into().ok()?))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc"));
|
||||
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
|
||||
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
|
||||
let marker = "eng\\Movie\\VOICE_ADV.slb";
|
||||
let registry = tpak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
tpak.read(e)
|
||||
.ok()
|
||||
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
|
||||
})
|
||||
.expect("registry");
|
||||
let ids = sylpheed_formats::movie_voice::registry_voice_ids(®istry);
|
||||
let name_of: std::collections::HashMap<u32, String> =
|
||||
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
|
||||
|
||||
// Same window the cue map uses.
|
||||
let win_start: u64 = 421_739_888 & !3;
|
||||
let win_len: usize = 116_300_000;
|
||||
let buf = src
|
||||
.read_segment_range("dat/sound", win_start, win_len)
|
||||
.expect("window");
|
||||
let descs = all_descriptors(&buf);
|
||||
|
||||
println!("{:<14} {:>10} {:>10} {:>10} {:>7} {:>9} {:>9}",
|
||||
"cue", "stream1", "stream2", "stream3", "s3/s2", "rate2", "rate3");
|
||||
let mut ratios: Vec<f64> = Vec::new();
|
||||
let mut tiny = 0;
|
||||
for w in descs.windows(2) {
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
if b <= a || b - a < 4096 {
|
||||
continue;
|
||||
}
|
||||
let span = &buf[a..b];
|
||||
let riffs = slb::to_xma_riffs(span);
|
||||
if riffs.len() != 3 {
|
||||
continue;
|
||||
}
|
||||
let name = name_of
|
||||
.get(&w[1].1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("id{}", w[1].1));
|
||||
let (s1, s2, s3) = (riffs[0].len(), riffs[1].len(), riffs[2].len());
|
||||
let r = s3 as f64 / s2 as f64;
|
||||
ratios.push(r);
|
||||
if r < 0.5 {
|
||||
tiny += 1;
|
||||
}
|
||||
println!(
|
||||
"{:<14} {s1:>10} {s2:>10} {s3:>10} {r:>7.4} {:>9} {:>9}",
|
||||
name.trim_start_matches("VOICE_"),
|
||||
declared_rate(&riffs[1]).map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
declared_rate(&riffs[2]).map(|v| v.to_string()).unwrap_or("-".into()),
|
||||
);
|
||||
}
|
||||
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = ratios.len();
|
||||
println!("\n{n} three-stream cues");
|
||||
if n > 0 {
|
||||
let mean = ratios.iter().sum::<f64>() / n as f64;
|
||||
let var = ratios.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
|
||||
println!(
|
||||
" stream3/stream2 ratio: min {:.4} median {:.4} max {:.4} mean {:.4} sd {:.4}",
|
||||
ratios[0], ratios[n / 2], ratios[n - 1], mean, var.sqrt()
|
||||
);
|
||||
println!(" cues where stream 3 is less than HALF of stream 2: {tiny}");
|
||||
let near = ratios.iter().filter(|r| (**r - 1.0).abs() < 0.15).count();
|
||||
println!(" cues where stream 3 is within 15% of stream 2: {near} of {n}");
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,14 @@ pub struct AudioInfo {
|
||||
pub size_bytes: usize,
|
||||
/// 2048-byte XMA packet count, for XMA/raw-XMA streams.
|
||||
pub xma_packets: Option<u32>,
|
||||
/// The stream's **declared** average bytes per second.
|
||||
///
|
||||
/// For XMA1 this is `XMASTREAMFORMAT::PsuedoBytesPerSec`. It is what makes a
|
||||
/// duration available for a codec we cannot decode: `data_bytes / this`
|
||||
/// agreed with an independently decoded duration to **0.01 %** on the two
|
||||
/// movie voices it was checked against
|
||||
/// (`docs/re/structures/voice-region-leading-chunk.md`).
|
||||
pub avg_bytes_per_sec: Option<u32>,
|
||||
}
|
||||
|
||||
impl AudioInfo {
|
||||
@@ -110,6 +118,7 @@ impl AudioInfo {
|
||||
duration_secs: None,
|
||||
size_bytes: size,
|
||||
xma_packets: None,
|
||||
avg_bytes_per_sec: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +190,7 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
|
||||
let mut pos = 12;
|
||||
let (mut tag, mut channels, mut rate, mut bits) = (0u16, 0u16, 0u32, 0u16);
|
||||
let mut avg_bps = 0u32;
|
||||
let mut data_bytes: Option<u64> = None;
|
||||
let mut have_fmt = false;
|
||||
|
||||
@@ -191,13 +201,37 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
match id {
|
||||
b"fmt " if body + 16 <= bytes.len() => {
|
||||
tag = le16(body);
|
||||
channels = le16(body + 2);
|
||||
rate = le32(body + 4);
|
||||
bits = le16(body + 14);
|
||||
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's first
|
||||
// two bytes, right after cbSize (+2) → +24 from the fmt body.
|
||||
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
|
||||
tag = le16(body + 24);
|
||||
// 🔴 XMA1 is NOT a WAVEFORMATEX. Reading it as one is where
|
||||
// `audio info` got "16 channels, 4310 Hz, 2-bit" from the
|
||||
// movie voices: 16 is `wBitsPerSample` read as channels, and
|
||||
// 4310 is `wEncodeOptions` (0x10d6) read as a sample rate.
|
||||
//
|
||||
// XMA1 carries `XMAWAVEFORMAT`, then one `XMASTREAMFORMAT` per
|
||||
// stream (xenia-canary `src/xenia/apu/xma_context.h`,
|
||||
// cross-checked against the disc's own movie-voice headers):
|
||||
//
|
||||
// +0 wFormatTag +2 wBitsPerSample +4 wEncodeOptions
|
||||
// +6 wLargestSkip +8 wNumStreams +10 bLoopCount (u8)
|
||||
// +11 bStreamCount (u8)
|
||||
// +12 PsuedoBytesPerSec +16 SampleRate +20 LoopStart
|
||||
// +24 LoopEnd +28 SubframeData (u8) +29 Channels (u8)
|
||||
// +30 ChannelMask
|
||||
if tag == WAVE_FORMAT_XMA && body + 32 <= bytes.len() {
|
||||
bits = le16(body + 2);
|
||||
avg_bps = le32(body + 12);
|
||||
rate = le32(body + 16);
|
||||
channels = bytes[body + 29] as u16;
|
||||
} else {
|
||||
channels = le16(body + 2);
|
||||
rate = le32(body + 4);
|
||||
bits = le16(body + 14);
|
||||
avg_bps = le32(body + 8);
|
||||
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's
|
||||
// first two bytes, right after cbSize (+2) → +24 from the
|
||||
// fmt body.
|
||||
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
|
||||
tag = le16(body + 24);
|
||||
}
|
||||
}
|
||||
have_fmt = true;
|
||||
}
|
||||
@@ -222,14 +256,22 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
|
||||
info.channels = Some(channels).filter(|&c| c > 0);
|
||||
info.sample_rate = Some(rate).filter(|&r| r > 0);
|
||||
info.bits_per_sample = Some(bits).filter(|&b| b > 0);
|
||||
info.avg_bytes_per_sec = Some(avg_bps).filter(|&b| b > 0);
|
||||
|
||||
// A declared byte rate gives a duration for a codec we cannot decode. Only
|
||||
// for XMA1, where the field is `PsuedoBytesPerSec` and means exactly this.
|
||||
if codec == AudioCodec::Xma && avg_bps > 0 {
|
||||
if let Some(d) = data_bytes {
|
||||
info.duration_secs = Some(d as f32 / avg_bps as f32);
|
||||
}
|
||||
}
|
||||
|
||||
match codec {
|
||||
AudioCodec::Pcm | AudioCodec::PcmFloat => {
|
||||
if let (Some(d), true) = (data_bytes, channels > 0 && bits > 0) {
|
||||
// `bits` under 8 makes the frame size zero, so this stays
|
||||
// fallible: `checked_div` states that once, where it happens.
|
||||
let frame = channels as u64 * (bits as u64 / 8);
|
||||
if let Some(spc) = d.checked_div(frame) {
|
||||
if frame > 0 {
|
||||
let spc = d / frame;
|
||||
info.samples_per_channel = Some(spc);
|
||||
if rate > 0 {
|
||||
info.duration_secs = Some(spc as f32 / rate as f32);
|
||||
@@ -276,11 +318,11 @@ impl GameAudio {
|
||||
let samples: Vec<f32> = match (info.codec, bits) {
|
||||
(AudioCodec::Pcm, 8) => data.iter().map(|&b| (b as f32 - 128.0) / 128.0).collect(),
|
||||
(AudioCodec::Pcm, 16) => data
|
||||
.as_chunks::<2>().0.iter()
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||
.collect(),
|
||||
(AudioCodec::Pcm, 24) => data
|
||||
.as_chunks::<3>().0.iter()
|
||||
.chunks_exact(3)
|
||||
.map(|c| {
|
||||
let v = ((c[2] as i32) << 16) | ((c[1] as i32) << 8) | c[0] as i32;
|
||||
let v = (v << 8) >> 8; // sign-extend 24→32
|
||||
@@ -288,7 +330,7 @@ impl GameAudio {
|
||||
})
|
||||
.collect(),
|
||||
(AudioCodec::PcmFloat, 32) => data
|
||||
.as_chunks::<4>().0.iter()
|
||||
.chunks_exact(4)
|
||||
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
|
||||
.collect(),
|
||||
_ => return Err(AudioError::UnsupportedPcm { tag: 0, bits }),
|
||||
@@ -357,6 +399,58 @@ mod tests {
|
||||
assert!((audio.samples[2] - 0.99997).abs() < 1e-3); // 32767/32768
|
||||
}
|
||||
|
||||
/// XMA1 is not a `WAVEFORMATEX`, and reading it as one produced nonsense.
|
||||
///
|
||||
/// The bytes here are the real `fmt ` chunk of `ADV`'s first movie-voice
|
||||
/// presentation, copied off the disc. Read as a `WAVEFORMATEX` it reports
|
||||
/// **16 channels, 4310 Hz, 2-bit** — 16 is `wBitsPerSample`, 4310 is
|
||||
/// `wEncodeOptions` (`0x10d6`). Read as an `XMAWAVEFORMAT` it reports 2
|
||||
/// channels, 48 kHz, 16-bit, 8142 B/s.
|
||||
///
|
||||
/// The duration is the part worth guarding: this crate has no XMA decoder,
|
||||
/// and `data_bytes / PsuedoBytesPerSec` is the only route to one. It agrees
|
||||
/// with an independently decoded 137.324 s to **0.02 %**.
|
||||
#[test]
|
||||
fn xma1_fmt_is_not_a_waveformatex() {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.extend_from_slice(b"WAVE");
|
||||
v.extend_from_slice(b"fmt ");
|
||||
v.extend_from_slice(&32u32.to_le_bytes());
|
||||
// XMAWAVEFORMAT, exactly as it appears on the disc.
|
||||
v.extend_from_slice(&[
|
||||
0x65, 0x01, // wFormatTag = 0x0165 (XMA1)
|
||||
0x10, 0x00, // wBitsPerSample = 16
|
||||
0xd6, 0x10, // wEncodeOptions = 0x10d6 <- was misread as the rate
|
||||
0x00, 0x00, // wLargestSkip
|
||||
0x01, 0x00, // wNumStreams
|
||||
0x00, // bLoopCount
|
||||
0x02, // bStreamCount
|
||||
0xce, 0x1f, 0x00, 0x00, // PsuedoBytesPerSec = 8142
|
||||
0x80, 0xbb, 0x00, 0x00, // SampleRate = 48000
|
||||
0x00, 0x00, 0x00, 0x00, // LoopStart
|
||||
0x00, 0x00, 0x00, 0x00, // LoopEnd
|
||||
0x00, // SubframeData
|
||||
0x02, // Channels = 2 <- was read from +2 as 16
|
||||
0x02, 0x00, // ChannelMask
|
||||
]);
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&1_118_208u32.to_le_bytes());
|
||||
|
||||
let info = AudioInfo::probe(&v);
|
||||
assert_eq!(info.codec, AudioCodec::Xma);
|
||||
assert_eq!(info.channels, Some(2), "channels came from wBitsPerSample");
|
||||
assert_eq!(info.sample_rate, Some(48_000), "rate came from wEncodeOptions");
|
||||
assert_eq!(info.bits_per_sample, Some(16));
|
||||
assert_eq!(info.avg_bytes_per_sec, Some(8142));
|
||||
let d = info.duration_secs.expect("duration from the declared byte rate");
|
||||
assert!(
|
||||
(d - 137.324).abs() < 0.05,
|
||||
"declared-rate duration {d} should match the decoded 137.324 s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_xma2_riff_reports_metadata_not_decode() {
|
||||
// Minimal RIFF/WAVE with an XMA2 fmt tag.
|
||||
|
||||
@@ -1131,7 +1131,7 @@ pub fn load_squadrons(pak: &PakArchive) -> Vec<Squadron> {
|
||||
let Some(f) = records.record(id) else { continue };
|
||||
let slots: Vec<&str> = f.positional.iter().map(|(_, v)| v.as_str()).collect();
|
||||
let members = slots
|
||||
.as_chunks::<4>().0.iter()
|
||||
.chunks_exact(4)
|
||||
.map(|m| SquadronMember {
|
||||
unit: m[0].to_string(),
|
||||
message_set: text(Some(m[1])),
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn name_hash(name: &str) -> u32 {
|
||||
// through untouched, including any high-bit bytes, which are then sign-extended).
|
||||
let mut bytes = name.as_bytes().to_vec();
|
||||
for byte in &mut bytes {
|
||||
if byte.is_ascii_uppercase() {
|
||||
if (b'A'..=b'Z').contains(byte) {
|
||||
*byte += 0x20;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
|
||||
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for pair in bytes.as_chunks::<2>().0 {
|
||||
for pair in bytes.chunks_exact(2) {
|
||||
let u = u16::from_be_bytes([pair[0], pair[1]]);
|
||||
match char::from_u32(u as u32) {
|
||||
Some(c) if !c.is_control() => cur.push(c),
|
||||
|
||||
@@ -288,12 +288,29 @@ pub fn resolve_movie_voice_region<S: DiscSource + ?Sized>(
|
||||
|
||||
// Start = the predecessor trailer. Prefer the exact `id-1`; where the id
|
||||
// sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer
|
||||
// below — but only within one bank (~1.5 MB), else this is the first cue in
|
||||
// its block and the audio starts at the anchor itself.
|
||||
// below.
|
||||
//
|
||||
// 🔴 There used to be a second condition here — `end - s < 1_500_000`, "only
|
||||
// within one bank, else this is the first cue in its block and the audio
|
||||
// starts at the anchor itself". **It was wrong, and it silently truncated the
|
||||
// first stream of every region larger than 1.5 MB.** `anchor` is a TOC offset,
|
||||
// not a stream boundary, so the fallback started mid-packet-run: `ADV` began
|
||||
// **238 packets (487 424 B) into its own first stream**, and a consumer then
|
||||
// saw a leading chunk that "matched nothing" and dropped 62 % of a real stream.
|
||||
//
|
||||
// Ground truth is the running decoder, which reports `ADV`'s three contexts as
|
||||
// 1 294 336 / 1 118 208 / 1 171 456 (`--xma_param_probe`). With the cap gone the
|
||||
// region reproduces all three exactly; with it, the first is 806 912.
|
||||
//
|
||||
// Disc-wide over the 95 manifest movies that resolve: **17 regions fixed, 78
|
||||
// unchanged, 0 changed in any other way** — in every one of the 17 the first
|
||||
// chunk grows and the remaining chunks are byte-identical, which is what a
|
||||
// corrected start looks like and what pulling in a neighbouring asset does not.
|
||||
// `docs/re/structures/voice-region-starts-late.md`.
|
||||
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||||
.map(|o| win_start + o as u64)
|
||||
.filter(|&s| s < end && end - s < 1_500_000)
|
||||
.filter(|&s| s < end)
|
||||
.unwrap_or(anchor);
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
@@ -700,7 +700,7 @@ impl Xbg7Model {
|
||||
let (vc, ic) = r.markers[0];
|
||||
let sig = (r.decl.stride, vc, ic);
|
||||
let floor = last_by_sig.get(&sig).map_or(0, |o| o + 1);
|
||||
if m.meshes[0].vbuf_offset.is_some_and(|o| o < floor) {
|
||||
if m.meshes[0].vbuf_offset.map_or(false, |o| o < floor) {
|
||||
if let Some(alt) = anchor_pool_mesh(
|
||||
bytes,
|
||||
starts,
|
||||
@@ -1339,13 +1339,6 @@ fn vertex_run_starts(bytes: &[u8], data_base: usize, stride: usize) -> Vec<usize
|
||||
/// the `index_count` indices ending just before it are all `< vtx_count`,
|
||||
/// reference (nearly) all vertices, and produce non-degenerate triangles with a
|
||||
/// real spatial extent — a signature strong enough to pin the block.
|
||||
// 8 parameters against clippy's threshold of 7. Not refactored here on
|
||||
// purpose: the fix is a shared params struct across this and the two
|
||||
// `validate_block*` functions below, which is a change to the decoder's
|
||||
// mesh-anchoring signatures and belongs to whoever owns that path — not to
|
||||
// a CI-lint pass. Recorded rather than silenced: this allow covers one lint
|
||||
// at one site, and any new violation elsewhere still fails the build.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn anchor_pool_mesh(
|
||||
bytes: &[u8],
|
||||
starts: &[usize],
|
||||
@@ -1413,7 +1406,7 @@ fn anchor_pool_mesh(
|
||||
}
|
||||
let (degen, wind) = index_run_quality(bytes, ib, vb, index_count, decl);
|
||||
let cand = (degen, -wind, pad);
|
||||
if best.is_none_or(|b| cand < b) {
|
||||
if best.map_or(true, |b| cand < b) {
|
||||
best = Some(cand);
|
||||
}
|
||||
}
|
||||
@@ -1459,11 +1452,6 @@ fn anchor_pool_mesh(
|
||||
/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool
|
||||
/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place*
|
||||
/// `ib`/`vb`, not in how they validate a placement.
|
||||
// 8/7, as above. This and `validate_block_report` take the *same* eight
|
||||
// parameters — one delegates straight to the other — which is exactly why a
|
||||
// params struct is the right fix and why it should be done once, deliberately,
|
||||
// rather than piecemeal here.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn validate_block(
|
||||
bytes: &[u8],
|
||||
ib: usize,
|
||||
@@ -1490,8 +1478,6 @@ fn validate_block(
|
||||
/// [`validate_block`], but naming the gate that rejected a block. A runtime
|
||||
/// capture can prove a block is real; when the decoder still refuses it, this
|
||||
/// says which test is wrong rather than leaving a threshold to be guessed at.
|
||||
// 8/7 — the signature `validate_block` delegates into. See above.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn validate_block_report(
|
||||
bytes: &[u8],
|
||||
ib: usize,
|
||||
@@ -1693,7 +1679,7 @@ fn anchor_grouped_meshes(
|
||||
if n == 0 || decls.len() < n {
|
||||
return Vec::new();
|
||||
}
|
||||
let _decl = &decls[0];
|
||||
let decl = &decls[0];
|
||||
|
||||
// Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers,
|
||||
// and cumulative vertex offsets (vb0 = 0) — both derived from the marker list.
|
||||
@@ -1737,7 +1723,7 @@ fn anchor_grouped_meshes(
|
||||
let (vc, ic) = markers[i];
|
||||
let ib = ib0 + rel_ib[i];
|
||||
if ib + ic * 2 > bytes.len()
|
||||
|| vc.checked_mul(decls[i].stride).is_none_or(|b| vb + b > bytes.len())
|
||||
|| vc.checked_mul(decls[i].stride).map_or(true, |b| vb + b > bytes.len())
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -1798,7 +1784,7 @@ fn anchor_grouped_meshes(
|
||||
}
|
||||
let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, &decls[kmax]);
|
||||
let cand = (degen, -wind, pad);
|
||||
if best.is_none_or(|b| cand < b) {
|
||||
if best.map_or(true, |b| cand < b) {
|
||||
best = Some(cand);
|
||||
}
|
||||
if pad_first_match() {
|
||||
@@ -1811,7 +1797,7 @@ fn anchor_grouped_meshes(
|
||||
if meshes.len() == n {
|
||||
return meshes;
|
||||
}
|
||||
if partial.as_ref().is_none_or(|p| meshes.len() > p.len()) {
|
||||
if partial.as_ref().map_or(true, |p| meshes.len() > p.len()) {
|
||||
partial = Some(meshes);
|
||||
}
|
||||
}
|
||||
@@ -1943,7 +1929,7 @@ fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
|
||||
while rel + 40 <= desc.len() {
|
||||
let a = be32(desc, rel);
|
||||
let c = be32(desc, rel + 4);
|
||||
if c >= 3 && c.is_multiple_of(3) && c < 400_000 && a == c * 2 {
|
||||
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
|
||||
return Some((rel, c as usize));
|
||||
}
|
||||
rel += 4;
|
||||
@@ -1965,7 +1951,7 @@ fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> {
|
||||
while rel + 8 <= desc.len() {
|
||||
let a = be32(desc, rel);
|
||||
let c = be32(desc, rel + 4);
|
||||
if c >= 3 && c.is_multiple_of(3) && c < 400_000 && a == c * 2 && rel >= 32 {
|
||||
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 {
|
||||
let vc = be32(desc, rel - 32) as usize;
|
||||
if (3..=200_000).contains(&vc) {
|
||||
out.push((vc, c as usize));
|
||||
@@ -1993,7 +1979,7 @@ fn all_vertex_decls(desc: &[u8]) -> Vec<VertexDecl> {
|
||||
while rel + 8 <= desc.len() {
|
||||
let a = be32(desc, rel);
|
||||
let c = be32(desc, rel + 4);
|
||||
if c >= 3 && c.is_multiple_of(3) && c < 400_000 && a == c * 2 && rel >= 32 {
|
||||
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 {
|
||||
let vc = be32(desc, rel - 32) as usize;
|
||||
if (3..=200_000).contains(&vc) {
|
||||
match parse_decl_at(desc, rel + 8) {
|
||||
@@ -2100,8 +2086,9 @@ fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> {
|
||||
let t = be32(desc, rel + 12);
|
||||
if (3..=65535).contains(&a)
|
||||
&& z == 0
|
||||
&& (3..=200_000).contains(&c)
|
||||
&& c.is_multiple_of(3)
|
||||
&& c >= 3
|
||||
&& c <= 200_000
|
||||
&& c % 3 == 0
|
||||
&& (1..=64).contains(&t)
|
||||
{
|
||||
out.push((a as usize, c as usize));
|
||||
@@ -2164,7 +2151,7 @@ fn topology_report(
|
||||
let mut agree = 0usize;
|
||||
let mut counted = 0usize;
|
||||
let mut edges: HashMap<(u32, u32), u32> = HashMap::new();
|
||||
for t in indices.as_chunks::<3>().0 {
|
||||
for t in indices.chunks_exact(3) {
|
||||
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||||
if a == b || b == c || a == c {
|
||||
degen += 1;
|
||||
@@ -2343,7 +2330,7 @@ pub fn submesh_albedos(bytes: &[u8], resource_name: &str) -> Vec<(usize, usize,
|
||||
if (3..=65535).contains(&vtx)
|
||||
&& z == 0
|
||||
&& idx >= 3
|
||||
&& idx.is_multiple_of(3)
|
||||
&& idx % 3 == 0
|
||||
&& idx < 400_000
|
||||
&& tail > 0
|
||||
&& tail < 0x10_0000
|
||||
@@ -2436,7 +2423,7 @@ pub fn material_groups(bytes: &[u8], resource_name: &str, target: usize) -> Vec<
|
||||
if (1..=70000).contains(&vtx)
|
||||
&& tail == 4
|
||||
&& cnt >= 3
|
||||
&& cnt.is_multiple_of(3)
|
||||
&& cnt % 3 == 0
|
||||
&& cnt < 400_000
|
||||
&& (off as usize) < 4_000_000
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! table (statically reversed). Beyond the movie→voice binding, it defines the
|
||||
//! whole cutscene *structure*: which video (+ subtitle, + on-screen text overlay,
|
||||
//! + voice) plays at each mission phase, and of what kind (story intro, in-mission
|
||||
//! phase cutscene, resupply, …).
|
||||
//! phase cutscene, resupply, …).
|
||||
//!
|
||||
//! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`)
|
||||
//!
|
||||
|
||||
@@ -96,7 +96,7 @@ pub fn find_descriptor_before(buf: &[u8], before: usize) -> Option<usize> {
|
||||
let mut o = (before - 1).min(cap) & !3;
|
||||
loop {
|
||||
let id = be(o);
|
||||
if (1..ID_MAX).contains(&id) && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
|
||||
return Some(o);
|
||||
}
|
||||
if o < 4 {
|
||||
|
||||
@@ -287,7 +287,7 @@ pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
|
||||
let mut pos: Vec<[f32; 3]> = Vec::new();
|
||||
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
|
||||
|
||||
let flush = |base: u32,
|
||||
let mut flush = |base: u32,
|
||||
size: u32,
|
||||
stride: u32,
|
||||
pos: &mut Vec<[f32; 3]>,
|
||||
@@ -426,7 +426,7 @@ pub fn correlate(
|
||||
let Some((score, mirrored)) = pos_validate(d, &key.ref_pos) else {
|
||||
continue; // positions disagree — not this part
|
||||
};
|
||||
if best.is_none_or(|(_, s, _)| score > s) {
|
||||
if best.map_or(true, |(_, s, _)| score > s) {
|
||||
best = Some((d, score, mirrored));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +377,39 @@ pub fn leading_data_offset(first_riff: usize) -> usize {
|
||||
first_riff % XMA1_PACKET
|
||||
}
|
||||
|
||||
/// Length of the **bank header** when an entry begins with one, in bytes.
|
||||
///
|
||||
/// A music bank opens with a header the header itself sizes: big-endian, the
|
||||
/// 2048-byte block size sits at `+0x18`, the bank id is repeated at `+0x00` and
|
||||
/// `+0x20`, and `+0x24` is the header's length **in blocks** (5, i.e. 10 240 B,
|
||||
/// on every music bank on this disc).
|
||||
///
|
||||
/// This exists because [`leading_data_offset`] derives a leading packet stream's
|
||||
/// start as `first_riff % XMA1_PACKET`, which is only correct when the header is
|
||||
/// SMALLER than one packet. A music bank's header is exactly five packets, so
|
||||
/// the modulus returns 0 and the whole header was being emitted as a sub-wave —
|
||||
/// a third "stem" on a bank the corpus documents as two
|
||||
/// (`docs/re/structures/bgm-two-stems.md`).
|
||||
///
|
||||
/// Disc-wide over `sound.pak`'s 9 519 entries the signature fires on **28**, all
|
||||
/// of them music banks (ids 1001–1023, 1101–1105), and on every one of the 28
|
||||
/// the declared header ends **exactly** at the first `RIFF` — so no bank on this
|
||||
/// disc has both a header at offset 0 and a leading packet stream. Zero false
|
||||
/// positives on the 7 993 mid-bank windows, where the leading region IS real.
|
||||
pub fn bank_header_len(slb: &[u8]) -> Option<usize> {
|
||||
if slb.len() < 0x38 {
|
||||
return None;
|
||||
}
|
||||
if slb[0x18..0x1c] != [0x00, 0x00, 0x08, 0x00] {
|
||||
return None;
|
||||
}
|
||||
if slb[0x00..0x04] != slb[0x20..0x24] {
|
||||
return None;
|
||||
}
|
||||
let blocks = u32::from_be_bytes(slb[0x24..0x28].try_into().ok()?) as usize;
|
||||
blocks.checked_mul(XMA1_PACKET)
|
||||
}
|
||||
|
||||
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let first_riff = find(slb, b"RIFF", 0);
|
||||
@@ -422,7 +455,15 @@ pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||||
// bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and
|
||||
// ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway.
|
||||
if let Some(ri) = first_riff {
|
||||
let start = leading_data_offset(ri);
|
||||
// A bank that carries its OWN header at offset 0 states how long it is,
|
||||
// and on this disc that header always runs right up to the first `RIFF`
|
||||
// — so there is no leading packet stream at all. Without this the
|
||||
// modulus below returns 0 for a 5-packet header and the header itself is
|
||||
// emitted as a sub-wave: `BGM_103.slb` came back as THREE waves against a
|
||||
// census, an executable reference and a runtime XMA probe that all say
|
||||
// two. It decodes to 0.009 s of PCM (the same chain returns 87.744 s for
|
||||
// the bank's real wave 0), and it is 99.1 % zero bytes.
|
||||
let start = bank_header_len(slb).unwrap_or_else(|| leading_data_offset(ri));
|
||||
if ri > start {
|
||||
if let Some(data) = slb.get(start..ri) {
|
||||
if data.iter().any(|b| *b != 0) {
|
||||
@@ -517,7 +558,7 @@ pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
|
||||
while let Some(di) = find(slb, b"data", i) {
|
||||
let declared = le32(slb, di + 4).unwrap_or(0) as usize;
|
||||
let usable = declared.min(slb.len().saturating_sub(di + 8));
|
||||
if best.is_none_or(|(_, b)| usable > b) {
|
||||
if best.map_or(true, |(_, b)| usable > b) {
|
||||
best = Some((di, usable));
|
||||
}
|
||||
i = di + 4;
|
||||
|
||||
@@ -587,19 +587,19 @@ pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
|
||||
match endianness {
|
||||
1 => {
|
||||
// k8in16 — swap the two bytes of each 16-bit half.
|
||||
for c in data.as_chunks_mut::<2>().0 {
|
||||
for c in data.chunks_exact_mut(2) {
|
||||
c.swap(0, 1);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// k8in32 — reverse each 32-bit word.
|
||||
for c in data.as_chunks_mut::<4>().0 {
|
||||
for c in data.chunks_exact_mut(4) {
|
||||
c.reverse();
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// k16in32 — swap the two 16-bit halves of each 32-bit word.
|
||||
for c in data.as_chunks_mut::<4>().0 {
|
||||
for c in data.chunks_exact_mut(4) {
|
||||
c.swap(0, 2);
|
||||
c.swap(1, 3);
|
||||
}
|
||||
@@ -626,8 +626,8 @@ fn decode_surface(
|
||||
} else {
|
||||
// Linear layout — copy only the mip-0 slice.
|
||||
let block_size = format.block_size() as u32;
|
||||
let bw = width.div_ceil(block_size).max(1);
|
||||
let bh = height.div_ceil(block_size).max(1);
|
||||
let bw = ((width + block_size - 1) / block_size).max(1);
|
||||
let bh = ((height + block_size - 1) / block_size).max(1);
|
||||
let needed = bw as usize * bh as usize * format.bytes_per_block();
|
||||
if raw_data.len() < needed {
|
||||
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
|
||||
@@ -651,7 +651,7 @@ fn decode_surface(
|
||||
match format {
|
||||
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
|
||||
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
|
||||
for block in linear_data.as_chunks_mut::<16>().0 {
|
||||
for block in linear_data.chunks_exact_mut(16) {
|
||||
swap_bc_block_dwords(&mut block[8..16]);
|
||||
}
|
||||
}
|
||||
@@ -666,8 +666,8 @@ fn decode_surface(
|
||||
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
|
||||
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
|
||||
let bs = format.block_size() as u32;
|
||||
let bw = width.div_ceil(bs).max(1);
|
||||
let bh = height.div_ceil(bs).max(1);
|
||||
let bw = ((width + bs - 1) / bs).max(1);
|
||||
let bh = ((height + bs - 1) / bs).max(1);
|
||||
let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
|
||||
@@ -682,7 +682,7 @@ fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usiz
|
||||
/// after the byte-level endian swap. Any trailing bytes that don't fill a full
|
||||
/// 8-byte group are left untouched.
|
||||
pub fn swap_bc_block_dwords(data: &mut [u8]) {
|
||||
for unit in data.as_chunks_mut::<8>().0 {
|
||||
for unit in data.chunks_exact_mut(8) {
|
||||
// [d0 d1 d2 d3 | d4 d5 d6 d7] → [d4 d5 d6 d7 | d0 d1 d2 d3]
|
||||
let (lo, hi) = unit.split_at_mut(4);
|
||||
lo.swap_with_slice(hi);
|
||||
@@ -742,8 +742,8 @@ pub fn detile(
|
||||
let bpb_log2 = (bpb as u32).trailing_zeros();
|
||||
|
||||
// Visible dimensions in blocks, and the padded storage pitch/height.
|
||||
let blocks_wide = width.div_ceil(block_size).max(1);
|
||||
let blocks_tall = height.div_ceil(block_size).max(1);
|
||||
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
|
||||
let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
|
||||
|
||||
|
||||
@@ -111,12 +111,22 @@ pub struct Keyframe {
|
||||
/// starts are negative.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
/// Keyframe time, or `None` for the group's **last** frame.
|
||||
/// The time at which this pose is reached.
|
||||
///
|
||||
/// A group's data stops 4 bytes short of its final block's time slot — that
|
||||
/// word is already the next group's element index. Reading it anyway is
|
||||
/// where a stray `time = 1869640736` comes from, and it silently corrupts
|
||||
/// the max-dwell pick in [`Element::rest`].
|
||||
/// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header
|
||||
/// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time
|
||||
/// word **precedes** the pose it belongs to. Our block window starts at the
|
||||
/// pose, so pose `k`'s time is the previous stride's `+36` word, and pose
|
||||
/// 0's is the group's lead-in word at `header + 8`.
|
||||
///
|
||||
/// ⚠️ The old reading took `+36` as *this* pose's time. That left the final
|
||||
/// pose — the end of every fade-out — untimed, and it is where the "a
|
||||
/// group's data stops 4 bytes short of its final block's time slot" note and
|
||||
/// the stray `time = 1869640736` both came from. There is no short group and
|
||||
/// no missing word; the association was off by one.
|
||||
/// See `docs/re/ui-keyframe-record-layout.md`.
|
||||
///
|
||||
/// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch.
|
||||
pub time: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -182,6 +192,77 @@ impl Element {
|
||||
///
|
||||
/// Falls back to the longest-dwell rule when no two adjacent keyframes
|
||||
/// agree — a group that ramps through every frame and never holds.
|
||||
/// The element's pose at keyframe time `t`, linearly interpolated.
|
||||
///
|
||||
/// `rest()` returns the last HOLD keyframe **of one element, chosen
|
||||
/// independently of every other element**. That is the wrong pose twice over:
|
||||
///
|
||||
/// * for anything still moving — the title's light sweeps hold at `x = 1521`,
|
||||
/// off the right edge, so a resting composite deletes them rather than
|
||||
/// settling them;
|
||||
/// * 🔴 and for anything **transient**. `ptlogo_back2eff1` is a two-frame
|
||||
/// flash (`a=0` until t52, `255` at t54–56, `0` again by t58); its last
|
||||
/// hold *is* the flash peak, so `rest()` leaves it burning forever. Five
|
||||
/// such flashes stack on the title and blow the light arc out to pure
|
||||
/// white — see `docs/re/structures/ui-settle-time.md`.
|
||||
///
|
||||
/// A settled screen is one INSTANT that every element is posed at, which is
|
||||
/// what [`UiBuild::settle_time`] computes and `ComposeOptions::at` applies.
|
||||
///
|
||||
/// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group
|
||||
/// **holds** at its last keyframe rather than looping, so `t` past the end
|
||||
/// clamps.
|
||||
pub fn pose_at(&self, t: u32) -> Option<Keyframe> {
|
||||
let ks = &self.keyframes;
|
||||
if ks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let timed: Vec<(u32, &Keyframe)> =
|
||||
ks.iter().filter_map(|k| k.time.map(|tt| (tt, k))).collect();
|
||||
if timed.is_empty() {
|
||||
return Some(ks[ks.len() - 1].clone());
|
||||
}
|
||||
if t <= timed[0].0 {
|
||||
return Some(timed[0].1.clone());
|
||||
}
|
||||
if t >= timed[timed.len() - 1].0 {
|
||||
return Some(timed[timed.len() - 1].1.clone());
|
||||
}
|
||||
for w in timed.windows(2) {
|
||||
let ((t0, a), (t1, b)) = (w[0], w[1]);
|
||||
if t >= t0 && t <= t1 {
|
||||
if t1 == t0 {
|
||||
return Some(b.clone());
|
||||
}
|
||||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||||
let li = |x: i32, y: i32| x + ((y - x) as f64 * f).round() as i32;
|
||||
let lu = |x: u32, y: u32| (x as f64 + (y as f64 - x as f64) * f).round() as u32;
|
||||
// ARGB / RGBA words interpolate per BYTE, not as integers.
|
||||
let lc = |x: u32, y: u32| {
|
||||
let mut o = 0u32;
|
||||
for sh in [24, 16, 8, 0] {
|
||||
let (cx, cy) = ((x >> sh) & 0xff, (y >> sh) & 0xff);
|
||||
o |= (lu(cx, cy) & 0xff) << sh;
|
||||
}
|
||||
o
|
||||
};
|
||||
return Some(Keyframe {
|
||||
fade: lc(a.fade, b.fade),
|
||||
rotation_deg: li(a.rotation_deg, b.rotation_deg),
|
||||
unknown_4: li(a.unknown_4, b.unknown_4),
|
||||
unknown_8: li(a.unknown_8, b.unknown_8),
|
||||
scale_x: lu(a.scale_x, b.scale_x),
|
||||
scale_y: lu(a.scale_y, b.scale_y),
|
||||
tint: lc(a.tint, b.tint),
|
||||
x: li(a.x, b.x),
|
||||
y: li(a.y, b.y),
|
||||
time: Some(t),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(timed[timed.len() - 1].1.clone())
|
||||
}
|
||||
|
||||
pub fn rest(&self) -> Option<&Keyframe> {
|
||||
// `lastall`: the LAST keyframe for every element, bypassing the plateau
|
||||
// rule entirely. This is what the shifted time reading predicts — under
|
||||
@@ -493,8 +574,10 @@ fn mark_focused_states(elements: &mut [Element]) {
|
||||
/// Read the placement region that follows the declaration table, filling in each
|
||||
/// element's keyframe group.
|
||||
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
// Experiment gate, default off; see the `time` field below.
|
||||
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
|
||||
// Escape hatch for the pre-2026-08-29 reading, which mis-associated every
|
||||
// keyframe time by one slot and could not time a group's final pose at all.
|
||||
// See `docs/re/ui-keyframe-record-layout.md`.
|
||||
let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1");
|
||||
let count = elements.len();
|
||||
let mut order = Vec::with_capacity(count);
|
||||
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
|
||||
@@ -507,7 +590,13 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
if idx >= count || frames == 0 || frames > 4096 {
|
||||
break;
|
||||
}
|
||||
// Group header is (index, count) then one lead-in word; blocks follow.
|
||||
// The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte
|
||||
// pose }`, after an 8-byte header — so the word at `pos + 8` is the
|
||||
// FIRST pose's time, and each 40-byte stride's `+36` word is the time of
|
||||
// the pose that follows it. Our block window is offset 4 bytes into the
|
||||
// record (it starts at the pose), which is why the pose field offsets
|
||||
// below are right while the times were off by one.
|
||||
let first_time = be32(bundle, pos + 8);
|
||||
let first = pos + 12;
|
||||
// The region is packed so that the next group's header sits 4 bytes
|
||||
// inside the last block — i.e. the group owns `frames * 40 - 4` bytes of
|
||||
@@ -529,16 +618,17 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||||
tint: be32(bundle, blk + 24),
|
||||
x: be32(bundle, blk + 28) as i32,
|
||||
y: be32(bundle, blk + 32) as i32,
|
||||
// Only a block wholly inside the group carries a time.
|
||||
//
|
||||
// ⚠️ Which block a time word BELONGS TO is under test — see
|
||||
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
|
||||
// to read `W[k-1]` as block `k`'s time ("the word is the time the
|
||||
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
|
||||
time: if shift_times {
|
||||
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
|
||||
} else {
|
||||
// Pose `k`'s time is the word that PRECEDES it: the group's
|
||||
// lead-in word for `k == 0`, and the previous stride's `+36`
|
||||
// otherwise. Every pose is timed; nothing is missing and nothing
|
||||
// is special-cased. Checked disc-wide — see
|
||||
// `docs/re/ui-keyframe-record-layout.md`.
|
||||
time: if legacy_times {
|
||||
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
|
||||
} else if k == 0 {
|
||||
Some(first_time)
|
||||
} else {
|
||||
Some(be32(bundle, blk - KEYFRAME + 36))
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -724,6 +814,12 @@ pub struct ComposeOptions {
|
||||
/// Left on with the derived order, an opaque black quad sorts last and wipes
|
||||
/// 32 of `GP_DIALOG`'s builds.
|
||||
pub include_primitives: bool,
|
||||
/// Pose every element at this keyframe time instead of at its resting pose.
|
||||
///
|
||||
/// `None` keeps the settled composite, which is what every existing caller
|
||||
/// wants. A capture taken mid-animation needs the render posed at the same
|
||||
/// instant — see [`Element::pose_at`].
|
||||
pub at: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for ComposeOptions {
|
||||
@@ -733,6 +829,7 @@ impl Default for ComposeOptions {
|
||||
include_animated: false,
|
||||
backdrop: [14, 14, 20, 255],
|
||||
include_primitives: false,
|
||||
at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,6 +922,99 @@ pub fn implied_layer_key(name: &str) -> Option<u32> {
|
||||
/// they land does not affect a composite. Ties keep declaration order — the game
|
||||
/// breaks them some other way, which is unexplained and looks harmless because
|
||||
/// tied elements are same-layer.
|
||||
/// Is this element an opaque full-screen quad that **must** sort below everything?
|
||||
///
|
||||
/// A keyless primitive has no layer key and the game's own code decides where it
|
||||
/// paints ([`implied_layer_key`] records the names measured in the running game).
|
||||
/// For one whole class of them the file settles it without a measurement: an
|
||||
/// element that covers the screen and is **fully opaque** at some instant cannot
|
||||
/// paint above anything visible at that instant, or the screen would be blank.
|
||||
/// Where the elements visible during its opaque span are *all* of them, its
|
||||
/// position is forced to first.
|
||||
///
|
||||
/// Two controls, both measured in the running game and both reproduced by this
|
||||
/// rule rather than assumed by it:
|
||||
///
|
||||
/// * `palogo_eff0.prm` is measured painting **first** — and comes out forced
|
||||
/// first (opaque for 211 instants, below 6 of 6). A rule keyed on the *name*
|
||||
/// would get this wrong: it is named like an overlay.
|
||||
/// * `pteff00.prm` is measured painting **last** — and is forced below only 3 of
|
||||
/// 23 elements on the title, because it is opaque for 2 instants at the screen's
|
||||
/// entry and exit, so the rule permits it on top.
|
||||
///
|
||||
/// Disc-wide: 80 instances forced first, 50 constrained but not forced, 0
|
||||
/// unconstrained. It also explains the 36 dialog builds that composite to one
|
||||
/// colour — `pzeff00.prm` is forced first in 32 of 32 instances.
|
||||
///
|
||||
/// ⚠️ Assumes straight alpha-over blending. Blend mode is an open question in
|
||||
/// `docs/re/structures/ui-prm-primitives.md`; an *additive* quad at alpha 255
|
||||
/// would not occlude, and this rule would then be placing it wrongly.
|
||||
pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool {
|
||||
// 🔴 Untextured primitives only. A `.t32` sprite's ELEMENT alpha being 255
|
||||
// says nothing about whether its texture covers the screen — most of it may
|
||||
// be transparent, so it occludes nothing. Applied without this guard the
|
||||
// rule claims 22 textured sprites must sort first, against their own layer
|
||||
// keys: `pneff01.t32` (key 0xd850, paints #8 of 13) and `pbfriendly.t32`
|
||||
// (key 0x9230, #17 of 49). Those disagreements are the rule being wrong,
|
||||
// not the keys.
|
||||
if el.sprite.is_some() {
|
||||
return false;
|
||||
}
|
||||
// The element must have a declared size at all; coverage itself is tested
|
||||
// per instant below, against the SCALED size.
|
||||
if el.pivot_x == 0 || el.pivot_y == 0 {
|
||||
return false;
|
||||
}
|
||||
let tmax = build
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if tmax == 0 {
|
||||
return false;
|
||||
}
|
||||
// An instant counts only where the element is BOTH fully opaque AND actually
|
||||
// covering — tested together, because both animate on the same ramp.
|
||||
//
|
||||
// ⚠️ Coverage is the SCALED size, not the declared one, and the test must be
|
||||
// two-sided. A quad scaled down does not cover what its pivot suggests —
|
||||
// `pbafc.prm` declares 844x600 and draws ~17x18 at 2 %/3 %. And a quad scaled
|
||||
// *up* can cover from a smaller declared size, so rejecting on the declared
|
||||
// size would replace one error with its mirror. Checked before adopting:
|
||||
// across 921 keyless elements, **0** cover the screen only via scale, so the
|
||||
// mirror case does not occur on this disc — the per-instant test is in
|
||||
// because it does not need that to stay true.
|
||||
let (dw, dh) = ((el.pivot_x * 2) as u64, (el.pivot_y * 2) as u64);
|
||||
let opaque: Vec<u32> = (0..=tmax)
|
||||
.filter(|&t| {
|
||||
el.pose_at(t).map_or(false, |k| {
|
||||
k.fade >> 24 == 255
|
||||
&& dw * k.scale_x as u64 / 100 >= build.design_w as u64
|
||||
&& dh * k.scale_y as u64 / 100 >= build.design_h as u64
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if opaque.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut others = 0usize;
|
||||
let mut occluded = 0usize;
|
||||
for o in &build.elements {
|
||||
if o.index == el.index {
|
||||
continue;
|
||||
}
|
||||
others += 1;
|
||||
if opaque
|
||||
.iter()
|
||||
.any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)
|
||||
{
|
||||
occluded += 1;
|
||||
}
|
||||
}
|
||||
others > 0 && occluded == others
|
||||
}
|
||||
|
||||
pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||||
idx.sort_by_key(|&i| {
|
||||
@@ -832,6 +1022,9 @@ pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||||
(
|
||||
sprite_layer_key(build, bundle, el)
|
||||
.or_else(|| implied_layer_key(&el.name))
|
||||
// An opaque full-screen quad that covers every other element
|
||||
// while it is opaque cannot be on top — see `forced_backdrop`.
|
||||
.or_else(|| forced_backdrop(build, el).then_some(0))
|
||||
.unwrap_or(u32::MAX),
|
||||
i,
|
||||
)
|
||||
@@ -900,17 +1093,79 @@ fn measured_paint_order(build: &UiBuild) -> Option<Vec<usize>> {
|
||||
None
|
||||
}
|
||||
|
||||
impl UiBuild {
|
||||
/// The instant at which this screen is *settled*, in keyframe time units.
|
||||
///
|
||||
/// A screen is not settled when each element sits at its own last hold —
|
||||
/// that is what [`Element::rest`] gives, and it is wrong for a transient
|
||||
/// (see the note there). It is settled at one shared instant, and the disc
|
||||
/// says which: gather every keyframe time in the build, and take the
|
||||
/// **longest interval containing none of them**. Inside that gap nothing has
|
||||
/// an inflection, so every element is either holding or on a long linear
|
||||
/// ramp — which is exactly what "the screen has stopped changing" means.
|
||||
///
|
||||
/// Returns the midpoint of that gap, or `None` when the build has fewer than
|
||||
/// two distinct keyframe times.
|
||||
///
|
||||
/// ⚠️ **Not every bundle has a settled instant.** Disc-wide over the 1 758
|
||||
/// composable bundles carrying two or more keyframe times, 30 % have a gap of
|
||||
/// at least half a second and 42 % have one under 10 units — the latter are
|
||||
/// mostly `loop*` animation fragments, which are *meant* to be in motion and
|
||||
/// have no settled pose to find. Check the gap width before trusting the
|
||||
/// midpoint; `settle_window` returns it.
|
||||
pub fn settle_time(&self) -> Option<u32> {
|
||||
self.settle_window().map(|(lo, hi)| lo + (hi - lo) / 2)
|
||||
}
|
||||
|
||||
/// The `[start, end]` of the longest keyframe-free interval — see
|
||||
/// [`UiBuild::settle_time`]. The width `end - start` is how much confidence
|
||||
/// the midpoint deserves.
|
||||
pub fn settle_window(&self) -> Option<(u32, u32)> {
|
||||
let mut ts: Vec<u32> = self
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
|
||||
.collect();
|
||||
ts.sort_unstable();
|
||||
ts.dedup();
|
||||
if ts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
ts.windows(2)
|
||||
.map(|w| (w[1] - w[0], w[0], w[1]))
|
||||
.max_by_key(|&(d, _, _)| d)
|
||||
.map(|(_, lo, hi)| (lo, hi))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compose(
|
||||
build: &UiBuild,
|
||||
bundle: &[u8],
|
||||
opts: ComposeOptions,
|
||||
visible: Option<&[bool]>,
|
||||
) -> ComposedScreen {
|
||||
compose_with_order(build, bundle, opts, visible, None)
|
||||
}
|
||||
|
||||
/// `compose`, with the paint order supplied by the caller.
|
||||
///
|
||||
/// The only reason this exists is to *measure* what a paint order costs: render
|
||||
/// a screen twice, once with the order `compose` would pick and once with two
|
||||
/// elements swapped, and diff the pixels. `order` is a permutation of element
|
||||
/// indices, first painted first; `None` means "whatever `compose` would use".
|
||||
/// Nothing in the normal render path passes anything but `None`.
|
||||
pub fn compose_with_order(
|
||||
build: &UiBuild,
|
||||
bundle: &[u8],
|
||||
opts: ComposeOptions,
|
||||
visible: Option<&[bool]>,
|
||||
order_override: Option<&[usize]>,
|
||||
) -> ComposedScreen {
|
||||
let (w, h) = (build.design_w, build.design_h);
|
||||
// A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind
|
||||
// an in-mission screen.
|
||||
let mut canvas = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
for px in canvas.as_chunks_mut::<4>().0 {
|
||||
for px in canvas.chunks_exact_mut(4) {
|
||||
px.copy_from_slice(&opts.backdrop);
|
||||
}
|
||||
let mut drawn = Vec::new();
|
||||
@@ -928,8 +1183,10 @@ pub fn compose(
|
||||
// same-layer-key ties, two being total occlusions. Of the port's five
|
||||
// screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs,
|
||||
// 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md.
|
||||
let order: Vec<usize> =
|
||||
measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle));
|
||||
let order: Vec<usize> = match order_override {
|
||||
Some(o) => o.to_vec(),
|
||||
None => measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle)),
|
||||
};
|
||||
for &ei in &order {
|
||||
let Some(el) = build.elements.get(ei) else {
|
||||
continue;
|
||||
@@ -967,7 +1224,23 @@ pub fn compose(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(kf) = el.rest() else { continue };
|
||||
// 🔴 `at` poses LEAVES ONLY, never the top-level elements.
|
||||
//
|
||||
// Posing everything at one global time was tried and is wrong: a
|
||||
// top-level group's final keyframes are its **exit ramp** — the fade-out
|
||||
// played when the screen leaves — and `rest()` deliberately stops at the
|
||||
// last *hold* keyframe before it. Posing the title at t=358 walked every
|
||||
// parent into its exit and drove the render's disagreement with the
|
||||
// capture from 10.92 to 61.74. The leaf is the thing still animating at
|
||||
// that instant, and it runs on its own timeline
|
||||
// (`docs/re/structures/ui-leaf-vs-parent-alpha.md`).
|
||||
let Some(kf) = (match opts.at {
|
||||
Some(t) => el.pose_at(t),
|
||||
None => el.rest().cloned(),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let kf = &kf;
|
||||
// An untextured primitive: a solid quad of the keyframe's `fade` colour,
|
||||
// sized by the declared pivot. `kind & 0x10` marks these exactly — see
|
||||
// `docs/re/structures/ui-prm-primitives.md`. They are the screen's
|
||||
@@ -999,6 +1272,65 @@ pub fn compose(
|
||||
missing.push(sprite.clone());
|
||||
continue;
|
||||
};
|
||||
// A nested `.rat` leaf sometimes carries the geometry while the parent
|
||||
// carries none — the title's light sweeps are the case: the parent sits
|
||||
// fixed at (441,270) scale 100 %, and the leaf holds the 600 %/800 %
|
||||
// scale, the +30°/−45° rotation and the whole sweep. Drawing the parent
|
||||
// put the sprite upright in the middle of the screen.
|
||||
//
|
||||
// ⚠️ NOT a blanket rule. A button's base record has a leaf that
|
||||
// DUPLICATES it, and there the parent wins
|
||||
// (`docs/re/structures/ui-button-focus-record.md`). The discriminator is
|
||||
// which record actually carries geometry, so the leaf is used only when
|
||||
// its pose genuinely differs — see `ui-leaf-vs-parent-alpha.md`.
|
||||
let leaf = build.records.get(&el.name).and_then(|&(lo, ls)| {
|
||||
if lo + ls > bundle.len() {
|
||||
return None;
|
||||
}
|
||||
let lb = parse_build(&bundle[lo..lo + ls])?;
|
||||
let pose = |e: &Element| match opts.at {
|
||||
Some(t) => e.pose_at(t),
|
||||
None => e.rest().cloned(),
|
||||
};
|
||||
let differs = lb.elements.iter().any(|le| {
|
||||
pose(le).map_or(false, |lk| {
|
||||
lk.rotation_deg != 0 || lk.scale_x != kf.scale_x || lk.scale_y != kf.scale_y
|
||||
})
|
||||
});
|
||||
if differs { Some(lb) } else { None }
|
||||
});
|
||||
if let Some(lb) = leaf {
|
||||
let mut any = false;
|
||||
for le in &lb.elements {
|
||||
let lk = match opts.at {
|
||||
Some(t) => le.pose_at(t),
|
||||
None => le.rest().cloned(),
|
||||
};
|
||||
let Some(lk) = lk else { continue };
|
||||
// A leaf element resolves no sprite of its own: sprite names are
|
||||
// resolved against the bundle a build was parsed from, and a leaf
|
||||
// is parsed from its own slice. Its NAME is the sprite name, and
|
||||
// the sprite itself lives in the PARENT bundle's table.
|
||||
let lsp = le.sprite.clone().unwrap_or_else(|| le.name.clone());
|
||||
let Some(&(so, ss)) = build.sprites.get(&lsp) else { continue };
|
||||
let Some(limg) = t8ad::parse(&bundle[so..so + ss]) else { continue };
|
||||
// 🔴 Only count it as drawn if it CAN draw. `blit` returns early
|
||||
// on a zero scale — "collapsed to nothing", not "unset" — so
|
||||
// setting the flag unconditionally would let a scale-0 leaf
|
||||
// suppress its parent and blank the element outright.
|
||||
// `pgloading_loop5`'s leaf is scale (0,0), and scale-0 is one of
|
||||
// the failures this corpus is already named for.
|
||||
if lk.scale_x == 0 || lk.scale_y == 0 {
|
||||
continue;
|
||||
}
|
||||
blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y);
|
||||
any = true;
|
||||
}
|
||||
if any {
|
||||
drawn.push(el.index);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y);
|
||||
drawn.push(el.index);
|
||||
}
|
||||
@@ -1117,6 +1449,11 @@ fn blit(
|
||||
// Keep the pivot point fixed as the element scales.
|
||||
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
|
||||
let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100;
|
||||
// The pivot's ABSOLUTE position is invariant under scale, which is the whole
|
||||
// point of the two lines above: at 100 % `ox = kf.x` so the pivot sits at
|
||||
// `kf.x + pivot_x`; at 200 % `ox = kf.x - pivot_x` and the pivot sits at
|
||||
// `ox + 2·pivot_x`, the same place. So rotation turns about it.
|
||||
let (pax, pay) = (kf.x + pivot_x as i32, kf.y + pivot_y as i32);
|
||||
// Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on
|
||||
// essentially every keyframe seen) and `fade` (**ARGB** — the high byte is
|
||||
// the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff`
|
||||
@@ -1135,6 +1472,73 @@ fn blit(
|
||||
((kf.tint >> 8) & 0xff) * fb / 255,
|
||||
(kf.tint & 0xff) * fa / 255,
|
||||
);
|
||||
// ---- rotated path -------------------------------------------------------
|
||||
// `+12` is a screen-plane rotation in DEGREES, clockwise-positive with Y
|
||||
// down (`docs/re/structures/ui-keyframe-rotation.md`). The game submits
|
||||
// rotated quads for it; this used to draw them axis-aligned, which put the
|
||||
// title's two light sweeps upright instead of at +30° / −45° and left at
|
||||
// least two thirds of that screen's disagreement with the capture
|
||||
// (`title-residual-tone-vs-geometry.md`).
|
||||
//
|
||||
// Zero rotation keeps the original forward-mapped path byte for byte, so
|
||||
// the screens that do not rotate cannot regress. A rotated element is drawn
|
||||
// by INVERSE mapping instead: forward-mapping a rotation leaves gaps.
|
||||
let rot = ((kf.rotation_deg % 360) + 360) % 360;
|
||||
if rot != 0 {
|
||||
let th = (rot as f64).to_radians();
|
||||
let (cs, sn) = (th.cos(), th.sin());
|
||||
// Axis-aligned bounds of the rotated destination rect.
|
||||
let corners = [
|
||||
(ox, oy),
|
||||
(ox + dw as i32, oy),
|
||||
(ox + dw as i32, oy + dh as i32),
|
||||
(ox, oy + dh as i32),
|
||||
];
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for (cx, cy) in corners {
|
||||
let (rx, ry) = ((cx - pax) as f64, (cy - pay) as f64);
|
||||
let px = pax as f64 + rx * cs - ry * sn;
|
||||
let py = pay as f64 + rx * sn + ry * cs;
|
||||
x0 = x0.min(px.floor() as i32);
|
||||
y0 = y0.min(py.floor() as i32);
|
||||
x1 = x1.max(px.ceil() as i32);
|
||||
y1 = y1.max(py.ceil() as i32);
|
||||
}
|
||||
for ty in y0.max(0)..=y1.min(ch as i32 - 1) {
|
||||
for tx in x0.max(0)..=x1.min(cw as i32 - 1) {
|
||||
// Rotate the destination pixel BACK to find its source pixel.
|
||||
let (rx, ry) = ((tx - pax) as f64 + 0.5, (ty - pay) as f64 + 0.5);
|
||||
let ux = rx * cs + ry * sn;
|
||||
let uy = -rx * sn + ry * cs;
|
||||
let dx = ux + (pax - ox) as f64;
|
||||
let dy = uy + (pay - oy) as f64;
|
||||
if dx < 0.0 || dy < 0.0 || dx >= dw as f64 || dy >= dh as f64 {
|
||||
continue;
|
||||
}
|
||||
let sxi = ((dx as u32) * sw / dw).min(sw - 1);
|
||||
let syi = ((dy as u32) * sh / dh).min(sh - 1);
|
||||
let si = ((syi * sw + sxi) * 4) as usize;
|
||||
if si + 3 >= img.rgba.len() {
|
||||
continue;
|
||||
}
|
||||
let sr = img.rgba[si] as u32 * tr / 255;
|
||||
let sg = img.rgba[si + 1] as u32 * tg / 255;
|
||||
let sb = img.rgba[si + 2] as u32 * tb / 255;
|
||||
let sa = img.rgba[si + 3] as u32 * ta / 255;
|
||||
if sa == 0 {
|
||||
continue;
|
||||
}
|
||||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||||
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
|
||||
let dc = canvas[di + k] as u32;
|
||||
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
|
||||
}
|
||||
canvas[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ---- unrotated path (unchanged) -----------------------------------------
|
||||
for row in 0..dh {
|
||||
let ty = oy + row as i32;
|
||||
if ty < 0 {
|
||||
@@ -1182,6 +1586,70 @@ fn blit(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A solid opaque rectangle sprite, for exercising `blit` geometry.
|
||||
fn solid(w: u32, h: u32) -> t8ad::T8adImage {
|
||||
t8ad::T8adImage { width: w, height: h, rgba: vec![255u8; (w * h * 4) as usize],
|
||||
flags: 0 }
|
||||
}
|
||||
fn kf_at(x: i32, y: i32, rot: i32) -> Keyframe {
|
||||
Keyframe { fade: 0xff_ff_ff_ff, rotation_deg: rot, unknown_4: 0, unknown_8: 0,
|
||||
scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(0) }
|
||||
}
|
||||
fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec<u8> {
|
||||
let mut c = vec![0u8; 64 * 64 * 4];
|
||||
blit(&mut c, 64, 64, img, kf, px, py);
|
||||
c
|
||||
}
|
||||
fn covered(c: &[u8]) -> Vec<(i32, i32)> {
|
||||
let mut v = Vec::new();
|
||||
for y in 0..64 { for x in 0..64 {
|
||||
if c[((y * 64 + x) * 4 + 3) as usize] != 0 { v.push((x as i32, y as i32)); }
|
||||
}}
|
||||
v
|
||||
}
|
||||
|
||||
/// 🔴 CONTROL for the rotated path. An estimator that is wrong on a known
|
||||
/// angle cannot be trusted on an unknown one, so the rotated blit is pinned
|
||||
/// against angles whose answer is arithmetic rather than measured.
|
||||
#[test]
|
||||
fn rotation_control_known_angles() {
|
||||
let img = solid(10, 4);
|
||||
// pivot at the sprite's centre, so rotation turns in place
|
||||
let (px, py) = (5u32, 2u32);
|
||||
let base = draw(&img, &kf_at(20, 30, 0), px, py);
|
||||
|
||||
// 0° and 360° must be identical to the unrotated path, byte for byte:
|
||||
// the fast path must be exactly the old behaviour.
|
||||
assert_eq!(base, draw(&img, &kf_at(20, 30, 360), px, py),
|
||||
"360 degrees must equal the unrotated path exactly");
|
||||
|
||||
// 90° must turn a 10x4 into a 4x10 about the same centre.
|
||||
let r90 = covered(&draw(&img, &kf_at(20, 30, 90), px, py));
|
||||
let b = covered(&base);
|
||||
let bw = b.iter().map(|p| p.0).max().unwrap() - b.iter().map(|p| p.0).min().unwrap();
|
||||
let bh = b.iter().map(|p| p.1).max().unwrap() - b.iter().map(|p| p.1).min().unwrap();
|
||||
let rw = r90.iter().map(|p| p.0).max().unwrap() - r90.iter().map(|p| p.0).min().unwrap();
|
||||
let rh = r90.iter().map(|p| p.1).max().unwrap() - r90.iter().map(|p| p.1).min().unwrap();
|
||||
assert_eq!((bw, bh), (9, 3), "unrotated extent");
|
||||
assert_eq!((rw, rh), (3, 9), "90 degrees must swap the extents");
|
||||
|
||||
// The covered area must be conserved to a few percent -- a rotation that
|
||||
// loses or invents pixels is the forward-mapping bug this path avoids.
|
||||
let (a0, a90) = (b.len() as f64, r90.len() as f64);
|
||||
assert!((a0 - a90).abs() / a0 < 0.15,
|
||||
"area changed too much under rotation: {a0} -> {a90}");
|
||||
|
||||
// And the centroid must stay on the pivot.
|
||||
let cen = |v: &Vec<(i32, i32)>| {
|
||||
let n = v.len() as f64;
|
||||
(v.iter().map(|p| p.0 as f64).sum::<f64>() / n,
|
||||
v.iter().map(|p| p.1 as f64).sum::<f64>() / n)
|
||||
};
|
||||
let (c0, c9) = (cen(&b), cen(&r90));
|
||||
assert!((c0.0 - c9.0).abs() < 1.0 && (c0.1 - c9.1).abs() < 1.0,
|
||||
"rotation moved the centroid: {c0:?} -> {c9:?}");
|
||||
}
|
||||
|
||||
/// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration
|
||||
/// table at 0x20, then a placement region.
|
||||
fn synth_build(decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec<Keyframe>)]) -> Vec<u8> {
|
||||
|
||||
@@ -237,7 +237,7 @@ pub fn decode_text(bytes: &[u8]) -> (String, &'static str) {
|
||||
|
||||
fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
|
||||
let units: Vec<u16> = bytes
|
||||
.as_chunks::<2>().0.iter()
|
||||
.chunks_exact(2)
|
||||
.map(|c| {
|
||||
if big_endian {
|
||||
u16::from_be_bytes([c[0], c[1]])
|
||||
|
||||
@@ -53,7 +53,7 @@ impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
|
||||
|
||||
info!(
|
||||
"Opened XISO: root directory table at sector {}",
|
||||
{ volume.root_table.region.sector }
|
||||
{ let s = volume.root_table.region.sector; s }
|
||||
);
|
||||
Ok(Self { volume, file: wrapper })
|
||||
}
|
||||
|
||||
@@ -86,3 +86,39 @@ fn manifest_binding_is_the_only_route() {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The resolved `ADV` voice region must contain **all three** streams the running
|
||||
/// decoder decodes — not a truncated first one.
|
||||
///
|
||||
/// Ground truth is the emulator, not this crate: booting with `--xma_param_probe`
|
||||
/// reports three XMA contexts with `byte_size` 1 294 336 / 1 118 208 / 1 171 456
|
||||
/// (`docs/re/structures/voice-three-streams-are-concurrent.md`). Until 2026-08-30
|
||||
/// the resolver's start filter capped a region at 1.5 MB, `ADV`'s span is 3.6 MB,
|
||||
/// so the start fell back to `anchor` — a TOC offset, 238 packets into the first
|
||||
/// stream — and this returned 806 912 for the first chunk.
|
||||
///
|
||||
/// This is a regression test against an EXTERNAL measurement, which is the only
|
||||
/// kind that can catch the class of bug it was written for: every internal check
|
||||
/// passed happily while a third of a stream was missing.
|
||||
#[test]
|
||||
fn adv_voice_region_holds_all_three_decoded_streams() {
|
||||
let Some(src) = disc() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let (start, end) = media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English)
|
||||
.expect("ADV voice region");
|
||||
let bytes = src
|
||||
.read_segment_range("dat/sound", start, (end - start) as usize)
|
||||
.expect("region bytes");
|
||||
let sizes: Vec<usize> = sylpheed_formats::slb::to_xma_riffs(&bytes)
|
||||
.iter()
|
||||
.map(|r| r.len() - 60)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sizes,
|
||||
vec![1_294_336, 1_118_208, 1_171_456],
|
||||
"the region must reproduce the RUNNING DECODER's byte_sizes; \
|
||||
a first chunk of 806912 means the start filter has come back"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,3 +269,62 @@ fn a_waves_declared_size_is_confirmed_by_the_next_seek() {
|
||||
assert!(checked >= 30, "expected banks to check, got {checked}");
|
||||
eprintln!("wave-boundary identity held for {checked} banks");
|
||||
}
|
||||
|
||||
/// A **music** bank has no leading segment — the bytes before its first `RIFF`
|
||||
/// are the bank header, and emitting them made `BGM_103` look like three stems.
|
||||
///
|
||||
/// The header sizes itself (`+0x24`, in 2048-byte blocks), and on every bank on
|
||||
/// this disc that size lands exactly on the first `RIFF`. So the guard is not a
|
||||
/// heuristic and has no threshold: if a bank states a header, believe it.
|
||||
#[test]
|
||||
fn a_bank_that_states_its_own_header_has_no_leading_segment() {
|
||||
skip_without_disc!(root);
|
||||
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
|
||||
let mut with_header = 0usize;
|
||||
let mut mid_bank = 0usize;
|
||||
// Peek at the 56-byte header through the archive's flat data rather than
|
||||
// decompressing 9 519 entries: `sound.pak` stores them uncompressed, and a
|
||||
// full read of all of them is several GB (it OOM-killed the test runner).
|
||||
for entry in snd.entries() {
|
||||
let Some(head) = snd.data_at(entry.offset as usize, 0x38) else { continue };
|
||||
match slb::bank_header_len(head) {
|
||||
Some(h) => {
|
||||
let b = snd.read(entry).expect("read a bank that states a header");
|
||||
let ri = b.windows(4).position(|w| w == b"RIFF").expect("has a RIFF");
|
||||
// Declared header ends exactly at the first RIFF: no gap, so
|
||||
// nothing before it can be a packet stream.
|
||||
assert_eq!(h, ri, "a bank header that does not end at its first RIFF");
|
||||
with_header += 1;
|
||||
}
|
||||
None => mid_bank += 1,
|
||||
}
|
||||
}
|
||||
// 28 music banks (ids 1001-1023, 1101-1105); the rest are mid-bank windows,
|
||||
// where the leading region IS real and must keep being emitted.
|
||||
assert_eq!(with_header, 28, "banks stating their own header at offset 0");
|
||||
assert!(mid_bank > 9000, "mid-bank windows, got {mid_bank}");
|
||||
eprintln!("{with_header} banks state a header; {mid_bank} mid-bank windows");
|
||||
}
|
||||
|
||||
/// The regression itself: the menu's music bank is **two** sub-waves, and they
|
||||
/// are the two the corpus names — matching the executable's `BGM_103` and the
|
||||
/// two streams the runtime XMA probe saw at the main menu.
|
||||
#[test]
|
||||
fn the_menu_music_bank_is_exactly_two_sub_waves() {
|
||||
skip_without_disc!(root);
|
||||
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
|
||||
for (name, sizes) in [
|
||||
("BGM_103.slb", [3_876_864usize, 3_930_112]),
|
||||
("BGM_001.slb", [4_466_688, 4_673_536]),
|
||||
] {
|
||||
let entry = snd.find_by_name(name).expect("bank present");
|
||||
let b = snd.read(entry).expect("read");
|
||||
let riffs = slb::to_xma_riffs(&b);
|
||||
assert_eq!(riffs.len(), 2, "{name}: sub-wave count");
|
||||
for (r, want) in riffs.iter().zip(sizes) {
|
||||
let di = r.windows(4).position(|w| w == b"data").expect("data chunk");
|
||||
let got = u32::from_le_bytes(r[di + 4..di + 8].try_into().unwrap()) as usize;
|
||||
assert_eq!(got, want, "{name}: sub-wave payload size");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
197
crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs
Normal file
197
crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! An opaque full-screen primitive cannot paint above what it would hide.
|
||||
//!
|
||||
//! A keyless primitive has no layer key, and `implied_layer_key` records the
|
||||
//! handful whose position was measured in the running game. For one class the
|
||||
//! file settles it without a measurement: an element covering the screen and
|
||||
//! fully opaque at some instant cannot paint above anything visible then, or the
|
||||
//! screen is blank. Where that set is *every* other element, the position is
|
||||
//! forced to first.
|
||||
//!
|
||||
//! The port found this by contradiction on `build_12`/`build_15`, which its
|
||||
//! renderer composited to solid black at every instant of their declared life.
|
||||
//!
|
||||
//! Argument, census and reach: `docs/re/structures/ui-forced-backdrop.md`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
fn build(ar: &PakArchive, i: usize) -> (Vec<u8>, ui_layout::UiBuild) {
|
||||
let by = ar.read(&ar.entries()[i]).expect("entry");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
(by, b)
|
||||
}
|
||||
|
||||
fn el<'a>(b: &'a ui_layout::UiBuild, name: &str) -> &'a ui_layout::Element {
|
||||
b.elements.iter().find(|e| e.name == name).expect(name)
|
||||
}
|
||||
|
||||
/// The two controls are measured orders from the running game. The rule has to
|
||||
/// reproduce one and permit the other, or it is not measuring occlusion.
|
||||
#[test]
|
||||
fn the_rule_reproduces_both_measured_primitives() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
|
||||
// `palogo_eff0.prm` is MEASURED painting first. Named like an overlay, so a
|
||||
// name-based rule gets it wrong; occlusion gets it right.
|
||||
let (_, splash) = build(&ar, 11);
|
||||
assert!(
|
||||
ui_layout::forced_backdrop(&splash, el(&splash, "palogo_eff0.prm")),
|
||||
"the developer splash's backdrop is measured FIRST and must come out forced"
|
||||
);
|
||||
|
||||
// `pteff00.prm` is MEASURED painting last. It is opaque only at its screen's
|
||||
// entry and exit, so the rule must NOT force it down.
|
||||
for entry in [4usize, 5] {
|
||||
let (_, b) = build(&ar, entry);
|
||||
assert!(
|
||||
!ui_layout::forced_backdrop(&b, el(&b, "pteff00.prm")),
|
||||
"entry {entry}: pteff00.prm is measured painting LAST and must stay permitted on top"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The case that prompted it: the loading screens.
|
||||
#[test]
|
||||
fn the_loading_screens_backdrop_sorts_first() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
for entry in [12usize, 15] {
|
||||
let (by, b) = build(&ar, entry);
|
||||
let prim = el(&b, "pgloading_eff00.prm");
|
||||
assert!(ui_layout::forced_backdrop(&b, prim), "entry {entry}");
|
||||
let order = ui_layout::derived_paint_order(&b, &by);
|
||||
assert_eq!(
|
||||
order.first().copied(),
|
||||
Some(prim.index),
|
||||
"entry {entry}: the backdrop must be painted first, not last"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 The rule quantifies over "every instant the primitive is opaque" and "every
|
||||
/// element visible then", so both halves depend on where the timeline ends and on
|
||||
/// what an element does after its own last keyframe. **The hold is not a
|
||||
/// convenience: a measured order requires it.**
|
||||
///
|
||||
/// `palogo_eff0.prm` is a SINGLE keyframe at t=0. If an element counted as *gone*
|
||||
/// after its last keyframe, the splash's backdrop would exist for one instant, no
|
||||
/// other element would be up yet, and the rule would call it free — against the
|
||||
/// order measured in the running game, which paints it first.
|
||||
///
|
||||
/// Disc-wide the choice decides **72 of 130** verdicts, so this is the load-bearing
|
||||
/// half of the rule. (Using the header's declared `+0x08` as the span instead of
|
||||
/// the elements' maximum changes **0**.)
|
||||
#[test]
|
||||
fn the_hold_after_a_final_keyframe_is_required_by_a_measured_order() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
for entry in [10usize, 11] {
|
||||
let (_, b) = build(&ar, entry);
|
||||
let prim = el(&b, "palogo_eff0.prm");
|
||||
assert_eq!(prim.keyframes.len(), 1, "entry {entry}: the case rests on it being static");
|
||||
|
||||
// With the hold — what `pose_at` does, and what the game does.
|
||||
assert!(
|
||||
ui_layout::forced_backdrop(&b, prim),
|
||||
"entry {entry}: measured painting FIRST, so the rule must force it"
|
||||
);
|
||||
|
||||
// Without it, spelled out here rather than imported, so the test states
|
||||
// the counterfactual it is pinning.
|
||||
let tmax = b.elements.iter()
|
||||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
|
||||
let last = |e: &ui_layout::Element| e.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0);
|
||||
let alpha_no_hold = |e: &ui_layout::Element, t: u32| -> u32 {
|
||||
if t > last(e) { 0 } else { e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) }
|
||||
};
|
||||
let opaque: Vec<u32> = (0..=tmax).filter(|&t| alpha_no_hold(prim, t) == 255).collect();
|
||||
let others: Vec<_> = b.elements.iter().filter(|o| o.index != prim.index).collect();
|
||||
let below = others.iter()
|
||||
.filter(|o| opaque.iter().any(|&t| alpha_no_hold(o, t) > 0)).count();
|
||||
assert_ne!(
|
||||
below, others.len(),
|
||||
"entry {entry}: without the hold this element would come out FREE — which is \
|
||||
why the hold is load-bearing rather than incidental"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disc-wide: the rule must fire on a real population and never on something it
|
||||
/// cannot occlude.
|
||||
#[test]
|
||||
fn forced_backdrops_are_full_screen_and_plentiful() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
let (mut forced, mut prm, mut tbm) = (0usize, 0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for element in &b.elements {
|
||||
if !ui_layout::forced_backdrop(&b, element) {
|
||||
continue;
|
||||
}
|
||||
forced += 1;
|
||||
if element.name.ends_with(".prm") { prm += 1 }
|
||||
else if element.name.ends_with(".tbm") { tbm += 1 }
|
||||
// 🔴 Untextured only. This assertion caught the rule's real
|
||||
// limit: applied to `.t32` sprites it claimed 22 of them must
|
||||
// sort first, against their own layer keys — a sprite's element
|
||||
// alpha says nothing about its texture's coverage.
|
||||
assert!(
|
||||
element.sprite.is_none(),
|
||||
"{}: a textured sprite cannot be judged to occlude by element alpha",
|
||||
element.name
|
||||
);
|
||||
assert!(
|
||||
element.pivot_x * 2 >= b.design_w as u32
|
||||
&& element.pivot_y * 2 >= b.design_h as u32,
|
||||
"{}: a quad that does not cover the screen cannot occlude it",
|
||||
element.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(forced > 50, "expected a real population, got {forced}");
|
||||
eprintln!("{forced} keyless primitives have their position forced to first");
|
||||
|
||||
// 🔴 Pin the split, so anyone tightening this rule sees what it would cost.
|
||||
// Only the `.prm` half is DECODED: a solid colour quad's fade IS its pixel, so
|
||||
// opacity and coverage are the same fact. Every `.tbm` in the set carries fade
|
||||
// `ffffffff` — a white SOLID quad painted first would make the screen white, so
|
||||
// they are textured, and element alpha does not establish their coverage.
|
||||
// Their verdicts are kept because restricting to `.prm` would send eleven
|
||||
// screens' backgrounds back to last, which is the bug this rule fixed.
|
||||
assert!(prm >= 40 && tbm >= 30,
|
||||
"expected roughly 42 .prm / 38 .tbm forced instances, got {prm} / {tbm} — \
|
||||
if this moved, re-read the self-refutation section of ui-forced-backdrop.md");
|
||||
}
|
||||
@@ -120,8 +120,17 @@ fn header_0x08_against_the_keyframe_times() {
|
||||
worst.push(format!("{pak}: max keyframe {max_time} > header {dur}"));
|
||||
}
|
||||
}
|
||||
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
|
||||
*ratio.entry(r.min(30)).or_default() += 1;
|
||||
// ⚠️ Bundles whose every group is a single static pose contribute
|
||||
// `max_time == 0` and say nothing about whether `+0x08` is a length.
|
||||
// Before 2026-08-29 they were invisible here, because the old keyframe
|
||||
// time reading left a one-frame group's only pose untimed; the corrected
|
||||
// record layout (`docs/re/ui-keyframe-record-layout.md`) gives it the
|
||||
// group's lead-in time, which is 0. They are excluded rather than
|
||||
// allowed to swamp the histogram's zero bucket — 546 of them do.
|
||||
if max_time > 0 {
|
||||
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
|
||||
*ratio.entry(r.min(30)).or_default() += 1;
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}");
|
||||
@@ -142,11 +151,18 @@ fn header_0x08_against_the_keyframe_times() {
|
||||
|
||||
assert!(animated > 0, "no animated bundles — the sweep is broken");
|
||||
|
||||
// MEASURED 2026-08-24. +0x08 bounds the keyframe times in EVERY one of the
|
||||
// 2313 bundles that have both, and 444 of them reach it exactly. The
|
||||
// MEASURED 2026-08-24, re-measured 2026-08-29 under the corrected keyframe
|
||||
// record layout. +0x08 bounds the keyframe times in EVERY one of the 2 859
|
||||
// bundles that have both (2 313 before the correction, which could not time
|
||||
// a group's final pose at all), and 444 of them reach it exactly. The
|
||||
// spread-out ratio histogram is what rules out the boring explanation: a
|
||||
// large unrelated constant would bound everything too, but then the ratios
|
||||
// would pile up near zero instead of peaking at 1.0.
|
||||
//
|
||||
// ✅ The correction STRENGTHENS this result rather than weakening it: 546
|
||||
// more bundles now carry a readable last-pose time, and `over` is still 0 —
|
||||
// i.e. the newly-visible times, which are the LATEST in every group, still
|
||||
// do not run past the header's.
|
||||
assert_eq!(over, 0, "a keyframe time runs past the header's +0x08");
|
||||
assert!(exact > 400, "the bound is never attained — it may be unrelated");
|
||||
let near_one = ratio.get(&10).copied().unwrap_or(0);
|
||||
|
||||
204
crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs
Normal file
204
crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! A placement group's time word **precedes** the pose it belongs to.
|
||||
//!
|
||||
//! A group is an 8-byte header `{u32 element_index, u32 frame_count}` followed
|
||||
//! by `frame_count` records of 40 bytes, each `{u32 time; 36-byte pose}`. The
|
||||
//! parser's block window starts at the *pose*, four bytes into the record, so
|
||||
//! the word at a block's `+36` is the time of the pose that FOLLOWS it, and the
|
||||
//! first pose's time is the group's lead-in word at `header + 8`.
|
||||
//!
|
||||
//! The old reading took `+36` as the block's own time. That is off by one, and
|
||||
//! it is where two long-standing oddities came from: the group looked four bytes
|
||||
//! short, and the final pose — the end of every fade-out — carried no time.
|
||||
//!
|
||||
//! Full argument and disc-wide census: `docs/re/ui-keyframe-record-layout.md`.
|
||||
//!
|
||||
//! Two checks here, both disc-wide:
|
||||
//!
|
||||
//! 1. **Every pose is timed, and the times are non-decreasing.** Under the old
|
||||
//! reading the last pose has no time at all, so this cannot even be asked.
|
||||
//! 2. **A monotone alpha ramp of three or more segments runs at a constant
|
||||
//! rate.** Interpolation between keyframes is linear
|
||||
//! (`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment makes
|
||||
//! multi-keyframe ramps come out at a constant d(alpha)/d(time). The old
|
||||
//! reading achieves this on **zero** ramps on the whole disc.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let default = Path::new(
|
||||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
||||
);
|
||||
if default.join("dat").is_dir() {
|
||||
return Some(default.to_path_buf());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
for p in &paks {
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_string();
|
||||
let Ok(arc) = PakArchive::open(p) else { continue };
|
||||
for e in arc.entries() {
|
||||
let Ok(bytes) = arc.read(e) else { continue };
|
||||
if ratc::is_ratc(&bytes) {
|
||||
f(&name, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximal runs of strictly monotone alpha with at least `min_seg` segments.
|
||||
fn monotone_ramps(alphas: &[i32], min_seg: usize) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let (mut i, n) = (0usize, alphas.len());
|
||||
while i + 1 < n {
|
||||
if alphas[i] == alphas[i + 1] {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let up = alphas[i + 1] > alphas[i];
|
||||
let mut j = i + 1;
|
||||
while j + 1 < n && ((alphas[j + 1] > alphas[j]) == up) && alphas[j + 1] != alphas[j] {
|
||||
j += 1;
|
||||
}
|
||||
if j - i >= min_seg {
|
||||
out.push((i, j));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn constant_rate(times: &[u32], alphas: &[i32], a: usize, b: usize) -> Option<bool> {
|
||||
let mut rates = Vec::new();
|
||||
for k in a..b {
|
||||
let dt = times[k + 1].checked_sub(times[k])?;
|
||||
if dt == 0 {
|
||||
return None;
|
||||
}
|
||||
rates.push((alphas[k + 1] - alphas[k]).unsigned_abs() as f64 / dt as f64);
|
||||
}
|
||||
let mean = rates.iter().sum::<f64>() / rates.len() as f64;
|
||||
if mean == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let worst = rates.iter().map(|r| (r - mean).abs()).fold(0.0, f64::max);
|
||||
Some(worst / mean <= 0.06)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pose_is_timed_and_the_times_are_ordered() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
|
||||
eprintln!("SKIP: the legacy time reading is selected");
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut groups, mut untimed, mut out_of_order) = (0usize, 0usize, 0usize);
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
for_each_build(&root, |pak, bytes| {
|
||||
let Some(build) = ui_layout::parse_build(bytes) else {
|
||||
return;
|
||||
};
|
||||
if build.from_fallback {
|
||||
return;
|
||||
}
|
||||
for el in &build.elements {
|
||||
if el.keyframes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
groups += 1;
|
||||
if el.keyframes.iter().any(|k| k.time.is_none()) {
|
||||
untimed += 1;
|
||||
if examples.len() < 8 {
|
||||
examples.push(format!("{pak}: {} has an untimed pose", el.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
|
||||
if t.windows(2).any(|w| w[1] < w[0]) {
|
||||
out_of_order += 1;
|
||||
if examples.len() < 8 {
|
||||
examples.push(format!("{pak}: {} times {t:?} descend", el.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!("placement groups: {groups}; untimed: {untimed}; out of order: {out_of_order}");
|
||||
for e in &examples {
|
||||
eprintln!(" {e}");
|
||||
}
|
||||
assert!(groups > 10_000, "expected the whole disc, saw {groups} groups");
|
||||
assert_eq!(untimed, 0, "every pose must carry a time");
|
||||
assert_eq!(out_of_order, 0, "keyframe times must not descend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_segment_alpha_ramps_run_at_a_constant_rate() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
|
||||
eprintln!("SKIP: the legacy time reading is selected");
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut ramps, mut constant) = (0usize, 0usize);
|
||||
for_each_build(&root, |_pak, bytes| {
|
||||
let Some(build) = ui_layout::parse_build(bytes) else {
|
||||
return;
|
||||
};
|
||||
if build.from_fallback {
|
||||
return;
|
||||
}
|
||||
for el in &build.elements {
|
||||
if el.keyframes.iter().any(|k| k.time.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
|
||||
let a: Vec<i32> = el
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|k| ((k.fade >> 24) & 0xff) as i32)
|
||||
.collect();
|
||||
for (lo, hi) in monotone_ramps(&a, 3) {
|
||||
if let Some(ok) = constant_rate(&t, &a, lo, hi) {
|
||||
ramps += 1;
|
||||
constant += usize::from(ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let share = 100.0 * constant as f64 / ramps as f64;
|
||||
eprintln!("multi-segment alpha ramps: {constant}/{ramps} at a constant rate ({share:.1}%)");
|
||||
assert!(ramps > 500, "expected the whole disc, saw {ramps} ramps");
|
||||
// The old reading scores 0 of 1042. Half is a floor, not a target: the rest
|
||||
// are genuinely shaped ramps, authored with keyframes that are not evenly
|
||||
// spaced. Anything near zero means the time assignment has slipped again.
|
||||
assert!(
|
||||
share > 45.0,
|
||||
"only {share:.1}% of ramps run at a constant rate — the time \
|
||||
association has probably slipped (the old off-by-one scored 0%)"
|
||||
);
|
||||
}
|
||||
121
crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs
Normal file
121
crates/sylpheed-formats/tests/ui_record_loop_length_disc.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! A nested record's header `+0x08` is its LOOP LENGTH, and its keyframes need
|
||||
//! not fill it.
|
||||
//!
|
||||
//! A `*f` focus record animates for as long as its button is focused, so
|
||||
//! something has to say where the cycle restarts. The keyframes cannot: the
|
||||
//! `PRESS Ⓐ` plate's `ptbtn00f` ramps 0→80→0 over **105** units, and a 105-unit
|
||||
//! period is 15 % short of every measurement of the real thing.
|
||||
//!
|
||||
//! Each record is itself a RATC bundle with its own header. `+0x08` is a frame
|
||||
//! count, and if it is the loop length it must never be **less** than the
|
||||
//! record's largest keyframe time — an animation cannot restart before its own
|
||||
//! last pose. Disc-wide that holds 1 781 times out of 1 781, and 7.7 % of records
|
||||
//! declare *more*, which is a hold at the final pose before the cycle repeats.
|
||||
//!
|
||||
//! `ptbtn00f` is one of those: 105 units of ramp inside a **120**-unit cycle, so
|
||||
//! it rests dark for 15 units between pulses.
|
||||
//!
|
||||
//! Argument, the falsification test against the measured period, and the census:
|
||||
//! `docs/re/structures/ui-record-loop-length.md`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
/// Read a nested record's declared length and its largest keyframe time.
|
||||
fn record_len_and_maxt(bundle: &[u8], off: usize, size: usize) -> Option<(i64, i64)> {
|
||||
if off + 12 > bundle.len() || off + size > bundle.len() || &bundle[off..off + 4] != b"RATC" {
|
||||
return None;
|
||||
}
|
||||
let len = u32::from_be_bytes(bundle[off + 8..off + 12].try_into().ok()?) as i64;
|
||||
let lb = ui_layout::parse_build(&bundle[off..off + size])?;
|
||||
let maxt = lb
|
||||
.elements
|
||||
.iter()
|
||||
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max()? as i64;
|
||||
(maxt > 0).then_some((len, maxt))
|
||||
}
|
||||
|
||||
/// The falsifier: a loop cannot restart before its own last keyframe.
|
||||
#[test]
|
||||
fn a_records_declared_length_is_never_shorter_than_its_keyframes() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut total, mut exact, mut holds) = (0usize, 0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(ar) = PakArchive::open(p) else { continue };
|
||||
for e in ar.entries() {
|
||||
let Ok(by) = ar.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
for (rn, &(o, s)) in &b.records {
|
||||
let Some((len, maxt)) = record_len_and_maxt(&by, o, s) else { continue };
|
||||
total += 1;
|
||||
assert!(
|
||||
len >= maxt,
|
||||
"{}:{rn} declares a {len}-unit cycle but has a keyframe at t={maxt} — \
|
||||
a loop cannot restart before its own last pose",
|
||||
p.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
if len == maxt { exact += 1 } else { holds += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(total > 1500, "expected >1500 timed nested records, got {total}");
|
||||
// The field must carry information. If every record declared exactly its own
|
||||
// last keyframe time, "loop length" would be an unfalsifiable relabelling.
|
||||
assert!(
|
||||
holds > 50,
|
||||
"only {holds} of {total} records declare a hold — the field would be carrying nothing"
|
||||
);
|
||||
eprintln!("{total} records: {exact} exact, {holds} with a hold before the cycle repeats");
|
||||
}
|
||||
|
||||
/// The case that motivated it, pinned by name.
|
||||
#[test]
|
||||
fn the_press_a_plate_glow_holds_dark_for_fifteen_units() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let by = ar.read(&ar.entries()[2]).expect("entry 2 — the PRESS A plate");
|
||||
let b = ui_layout::parse_build(&by).expect("parse");
|
||||
let &(o, s) = b.records.get("ptbtn00f.rat").expect("the focus record");
|
||||
let (len, maxt) = record_len_and_maxt(&by, o, s).expect("a timed record");
|
||||
assert_eq!(maxt, 105, "the glow's ramp ends at t=105");
|
||||
assert_eq!(len, 120, "but the cycle is 120 units");
|
||||
assert_eq!(len - maxt, 15, "so it holds dark for 15 units between pulses");
|
||||
|
||||
// The five main-menu focus records fill their cycle exactly — the contrast
|
||||
// that shows the slack is a property of this record, not of the format.
|
||||
let menu = ar.read(&ar.entries()[5]).expect("entry 5 — the main menu");
|
||||
let mb = ui_layout::parse_build(&menu).expect("parse");
|
||||
let mut checked = 0;
|
||||
for n in 1..=5 {
|
||||
let name = format!("ptbtn0{n}f.rat");
|
||||
let Some(&(mo, ms)) = mb.records.get(&name) else { continue };
|
||||
let (l, m) = record_len_and_maxt(&menu, mo, ms).expect("timed");
|
||||
assert_eq!((l, m), (120, 120), "{name} should fill its 120-unit cycle exactly");
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 5, "expected five main-menu focus records");
|
||||
}
|
||||
142
crates/sylpheed-formats/tests/ui_settle_time_disc.rs
Normal file
142
crates/sylpheed-formats/tests/ui_settle_time_disc.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! A settled screen is one INSTANT, not one hold per element.
|
||||
//!
|
||||
//! `Element::rest()` returns an element's last *hold* keyframe, picked for that
|
||||
//! element alone. For anything that ends the screen settled that is right. For a
|
||||
//! **transient** it is exactly wrong: a two-frame flash's last hold is the flash
|
||||
//! *peak*, so `rest()` leaves it burning for the whole screen.
|
||||
//!
|
||||
//! `GP_TITLE` build 4 is the case that found this. `ptlogo_back2eff1` … `eff5`
|
||||
//! are five staggered flashes — `a=0` until t52, `255` for two frames, `0` again
|
||||
//! two frames later — that sweep left to right across the logo once and are gone
|
||||
//! by t110. Two elements, `ptlogo_back2eff` (t66–238) and `ptlogo_back2`
|
||||
//! (t80–243), then hold for the rest of the screen. `rest()` draws all seven at
|
||||
//! `a=255` simultaneously, and stacking five extra white glows blows the light
|
||||
//! arc out to saturation: against the console capture the arc's mean error is
|
||||
//! 33.22 and 8 581 pixels sit at the clipping level, where the console has 1 459.
|
||||
//!
|
||||
//! `UiBuild::settle_time()` recovers the right instant from the disc alone — the
|
||||
//! midpoint of the longest keyframe-free interval — with no reference to any
|
||||
//! capture. For this build that is t=198, and posing there takes the arc error to
|
||||
//! 11.79 and the clipped count to 1 452 against the console's 1 459.
|
||||
//!
|
||||
//! Argument, controls and the disc-wide census: `docs/re/structures/ui-settle-time.md`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The case that found the bug, asserted end to end.
|
||||
#[test]
|
||||
fn a_flash_is_transparent_at_the_settle_time_and_opaque_at_rest() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let arc = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
|
||||
let bytes = arc.read(&arc.entries()[4]).expect("build 4");
|
||||
let b = ui_layout::parse_build(&bytes).expect("parse");
|
||||
|
||||
let (lo, hi) = b.settle_window().expect("a settle window");
|
||||
let t = b.settle_time().expect("a settle time");
|
||||
assert!(hi - lo >= 60, "title's settle window should be a second or more, got {lo}..{hi}");
|
||||
assert!(lo < t && t < hi, "settle time {t} must lie inside {lo}..{hi}");
|
||||
|
||||
let alpha = |k: &ui_layout::Keyframe| k.fade >> 24;
|
||||
let mut flashes = 0;
|
||||
for el in &b.elements {
|
||||
let Some(name) = el.name.strip_prefix("ptlogo_back2eff") else { continue };
|
||||
// `ptlogo_back2eff.t32` itself holds; only the numbered ones flash.
|
||||
if !name.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
flashes += 1;
|
||||
let rest = el.rest().expect("a rest pose");
|
||||
let posed = el.pose_at(t).expect("a posed keyframe");
|
||||
assert_eq!(
|
||||
alpha(rest),
|
||||
255,
|
||||
"{}: rest() is expected to report the FLASH PEAK — that is the bug",
|
||||
el.name
|
||||
);
|
||||
assert_eq!(
|
||||
alpha(&posed),
|
||||
0,
|
||||
"{} flashes once before t110 and must be gone at the settle time {t}",
|
||||
el.name
|
||||
);
|
||||
}
|
||||
assert_eq!(flashes, 5, "GP_TITLE build 4 has five numbered back2 flashes");
|
||||
|
||||
// …while the two that genuinely hold are still opaque there.
|
||||
for want in ["ptlogo_back2eff.t32", "ptlogo_back2.t32"] {
|
||||
let el = b.elements.iter().find(|e| e.name == want).expect(want);
|
||||
assert_eq!(
|
||||
alpha(&el.pose_at(t).expect("posed")),
|
||||
255,
|
||||
"{want} holds across the settle time and must stay opaque"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window is a property of the data, so it must be computable disc-wide
|
||||
/// without panicking, and must be self-consistent wherever it exists.
|
||||
#[test]
|
||||
fn settle_windows_are_self_consistent_disc_wide() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SYLPHEED_DISC unset — skipping");
|
||||
return;
|
||||
};
|
||||
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
|
||||
.expect("dat/")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
|
||||
.collect();
|
||||
paks.sort();
|
||||
|
||||
let (mut with, mut wide) = (0usize, 0usize);
|
||||
for p in &paks {
|
||||
let Ok(a) = PakArchive::open(p) else { continue };
|
||||
for e in a.entries() {
|
||||
let Ok(by) = a.read(e) else { continue };
|
||||
if !ratc::is_ratc(&by) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let Some((lo, hi)) = b.settle_window() else { continue };
|
||||
with += 1;
|
||||
assert!(lo < hi, "an empty window is not a window: {lo}..{hi}");
|
||||
let t = b.settle_time().expect("a window implies a time");
|
||||
assert!((lo..=hi).contains(&t), "settle time {t} outside {lo}..{hi}");
|
||||
// No element may have a keyframe strictly inside the window — that
|
||||
// is the whole definition, so it is worth asserting rather than
|
||||
// trusting.
|
||||
for el in &b.elements {
|
||||
for k in &el.keyframes {
|
||||
if let Some(kt) = k.time {
|
||||
assert!(
|
||||
kt <= lo || kt >= hi,
|
||||
"{}: keyframe t={kt} lies inside the settle window {lo}..{hi}",
|
||||
el.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if hi - lo >= 30 {
|
||||
wide += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(with > 1000, "expected >1000 bundles with a settle window, got {with}");
|
||||
eprintln!("{with} bundles have a settle window; {wide} are at least 30 units wide");
|
||||
}
|
||||
@@ -101,7 +101,7 @@ pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadErr
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
let opaque = matches!(tex.format, X360TextureFormat::X8R8G8B8);
|
||||
let mut out = tex.data;
|
||||
for px in out.as_chunks_mut::<4>().0 {
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
|
||||
px[0] = r;
|
||||
px[1] = g;
|
||||
|
||||
@@ -2035,10 +2035,6 @@ fn decode_audio_wav(video: &Path, wav: &Path) -> Result<(), String> {
|
||||
|
||||
// ── Video decode + audio (main thread) ────────────────────────────────────────
|
||||
|
||||
/// `(pts, rgba_bytes)` as the reader thread forwards them from `ffmpeg`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type FrameRx = mpsc::Receiver<(f32, Vec<u8>)>;
|
||||
|
||||
/// Spawn an `ffmpeg` process decoding to raw RGBA on stdout, plus a reader
|
||||
/// thread that chunks it into frames and forwards `(pts, bytes)` over a bounded
|
||||
/// channel. Optional `-ss start` seeks the input; output pts re-base to 0, so we
|
||||
@@ -2050,7 +2046,7 @@ fn spawn_video_decoder(
|
||||
h: u32,
|
||||
fps: f32,
|
||||
start: f32,
|
||||
) -> Result<(Child, FrameRx), String> {
|
||||
) -> Result<(Child, mpsc::Receiver<(f32, Vec<u8>)>), String> {
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.arg("-v").arg("error");
|
||||
if start > 0.0 {
|
||||
@@ -2181,12 +2177,6 @@ fn scrub_worker(
|
||||
|
||||
/// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and
|
||||
/// `PendingFileBytes` as messages arrive.
|
||||
// Bevy system: every parameter is a `Res`/`ResMut`/`EventWriter` the
|
||||
// scheduler injects. The count is the framework's dependency list, not a
|
||||
// signature anyone calls by hand, and it cannot be reduced without
|
||||
// bundling into a `SystemParam` struct. clippy's general heuristic does
|
||||
// not know about the idiom.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn poll_loader_channel(
|
||||
channels: Res<IsoChannels>,
|
||||
@@ -2567,7 +2557,7 @@ fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option<usize> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
|
||||
let mut acc = vec![Vec3::ZERO; positions.len()];
|
||||
for tri in indices.as_chunks::<3>().0 {
|
||||
for tri in indices.chunks_exact(3) {
|
||||
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
||||
if a >= positions.len() || b >= positions.len() || c >= positions.len() {
|
||||
continue;
|
||||
@@ -3116,7 +3106,7 @@ fn prepare_models_impl(
|
||||
// Append the slice's triangles, re-emitting vertices per index
|
||||
// (no dedup) so each material buffer stays self-contained. A
|
||||
// reflected instance swaps two corners to keep front faces out.
|
||||
for tri in sub.indices[(*off).min(end)..end].as_chunks::<3>().0 {
|
||||
for tri in sub.indices[(*off).min(end)..end].chunks_exact(3) {
|
||||
let corners = if reflect {
|
||||
[tri[0], tri[2], tri[1]]
|
||||
} else {
|
||||
@@ -3415,12 +3405,6 @@ fn apply_prepared_xpr(
|
||||
|
||||
/// Consumes a staged pack, freeing the previous texture/text previews and
|
||||
/// populating `PakView` for the master-detail browser.
|
||||
// Bevy system: every parameter is a `Res`/`ResMut`/`EventWriter` the
|
||||
// scheduler injects. The count is the framework's dependency list, not a
|
||||
// signature anyone calls by hand, and it cannot be reduced without
|
||||
// bundling into a `SystemParam` struct. clippy's general heuristic does
|
||||
// not know about the idiom.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn apply_pak(
|
||||
mut pending: ResMut<PendingPak>,
|
||||
@@ -4092,7 +4076,7 @@ fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
|
||||
Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() })
|
||||
})
|
||||
.collect();
|
||||
characters.sort_by_key(|a| (a.faction.clone(), a.name.clone()));
|
||||
characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone())));
|
||||
|
||||
// Combat rosters, keyed by stage where the table self-identifies.
|
||||
let rosters = gd::load_unit_rosters(&main);
|
||||
@@ -4758,7 +4742,7 @@ fn build_ship_model(
|
||||
*nrm = rot(&p.m, nrm);
|
||||
}
|
||||
if det < 0.0 {
|
||||
for tri in sub.indices.as_chunks_mut::<3>().0 {
|
||||
for tri in sub.indices.chunks_exact_mut(3) {
|
||||
tri.swap(1, 2);
|
||||
}
|
||||
}
|
||||
@@ -4927,7 +4911,7 @@ fn handle_cutscene_cues_request(
|
||||
// The naming convention is the obvious route and it is wrong often
|
||||
// enough to matter -- resolving the region is the only reading that
|
||||
// yields the right audio.
|
||||
let voice = {
|
||||
let voice = (|| {
|
||||
use sylpheed_formats::{hash::name_hash, media, PakArchive};
|
||||
let vlang = match lang.pak_code() {
|
||||
"jpn" => sylpheed_formats::slb::VoiceLang::Japanese,
|
||||
@@ -4950,7 +4934,7 @@ fn handle_cutscene_cues_request(
|
||||
named_range,
|
||||
region,
|
||||
})
|
||||
};
|
||||
})();
|
||||
|
||||
let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded {
|
||||
generation,
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn run() {
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Project Sylpheed: Arc of Deception — Asset Viewer".into(),
|
||||
resolution: (1280.0_f32, 720.0_f32).into(),
|
||||
resolution: (1280.0, 720.0).into(),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
|
||||
@@ -157,12 +157,6 @@ struct UiEvents<'w> {
|
||||
cutscenes: EventWriter<'w, RequestCutscenes>,
|
||||
}
|
||||
|
||||
// Bevy system: every parameter is a `Res`/`ResMut`/`EventWriter` the
|
||||
// scheduler injects. The count is the framework's dependency list, not a
|
||||
// signature anyone calls by hand, and it cannot be reduced without
|
||||
// bundling into a `SystemParam` struct. clippy's general heuristic does
|
||||
// not know about the idiom.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_viewer_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut viewer: ResMut<ViewerState>,
|
||||
@@ -662,7 +656,7 @@ fn draw_viewer_ui(
|
||||
ui.strong("Notes");
|
||||
ui.end_row();
|
||||
|
||||
let row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| {
|
||||
let mut row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| {
|
||||
ui.label(fmt);
|
||||
ui.colored_label(c, status);
|
||||
ui.label(notes);
|
||||
@@ -1242,7 +1236,7 @@ fn draw_video_player(
|
||||
// Subtitle + voice controls (right-aligned): language, CC, and Voice.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let before = subs.lang;
|
||||
egui::ComboBox::from_id_salt("subtitle_lang")
|
||||
egui::ComboBox::from_id_source("subtitle_lang")
|
||||
.selected_text(subs.lang.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for lang in SubLang::ALL {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Your mods go here
|
||||
|
||||
A mod **replaces a file by shadowing its path**. There is no manifest, no
|
||||
registration and no load order: if a file exists here at the same relative path
|
||||
it has in the export tree, the game reads yours instead.
|
||||
|
||||
```
|
||||
export/sprites/title/main_menu/ptbtn01.png <- what the exporter wrote
|
||||
data/mods/sprites/title/main_menu/ptbtn01.png <- what the game will use
|
||||
```
|
||||
|
||||
That works for **every** asset kind the port reads — a screen's JSON, a sprite
|
||||
PNG, a sound cue, the music bed, a movie — because every read goes through one
|
||||
resolver (`port/scripts/export_tree.gd`, `ExportTree.resolve`).
|
||||
|
||||
Nothing under `export/` is ever touched, so **re-exporting from your disc is
|
||||
always safe**, and *"did I break it?"* is answered by moving your file out of
|
||||
this directory.
|
||||
|
||||
Point the game somewhere else with `SYLPHEED_MODS=/path/to/tree`.
|
||||
|
||||
## The game tells you what you changed
|
||||
|
||||
Every file a mod replaces is printed the first time it is read:
|
||||
|
||||
```
|
||||
mod: sprites/title/main_menu/ptbtn01.png <- /work/data/mods/sprites/title/main_menu/ptbtn01.png
|
||||
```
|
||||
|
||||
A modded run that looked identical to an unmodded one in the log would leave you
|
||||
with exactly one debugging tool — delete the mod and try again.
|
||||
|
||||
## Try it in ten seconds
|
||||
|
||||
Replace the `NEW GAME` label with a magenta block. The size is the original's,
|
||||
`203x43`, and nothing here is derived from the disc:
|
||||
|
||||
```bash
|
||||
mkdir -p data/mods/sprites/title/main_menu
|
||||
ffmpeg -f lavfi -i "color=c=0xff00c8:s=203x43" -frames:v 1 -pix_fmt rgba \
|
||||
data/mods/sprites/title/main_menu/ptbtn01.png
|
||||
godot --path port -- --menu
|
||||
```
|
||||
|
||||
Delete the file to put it back.
|
||||
|
||||
## Nothing in here is committed
|
||||
|
||||
`.gitignore` excludes everything in this directory except this README. That is
|
||||
deliberate: a mod is usually an *edited game asset*, and this repository never
|
||||
holds game assets — not in `export/`, and not here either.
|
||||
|
||||
## One tree, not a stack
|
||||
|
||||
Several mods layering over each other would need a load order, and a load order
|
||||
needs a rule nobody has asked for yet. Today there is one override tree. If you
|
||||
want more, say so rather than assuming the port has an answer.
|
||||
@@ -82,24 +82,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& npm cache clean --force \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── gitea-mcp ────────────────────────────────────────────────────────────────
|
||||
# The agent's hands on issues, pull requests and notifications — Gitea's own MCP
|
||||
# server, so there is no second store of truth to drift out of sync with the
|
||||
# first.
|
||||
#
|
||||
# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is
|
||||
# handed a token that can write to the repository. The checksum is the one
|
||||
# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset.
|
||||
ARG GITEA_MCP_VERSION=1.7.0
|
||||
ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d
|
||||
RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \
|
||||
"https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \
|
||||
&& echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \
|
||||
&& chmod +x /usr/local/bin/gitea-mcp \
|
||||
&& rm -f /tmp/gitea-mcp.tar.gz \
|
||||
&& gitea-mcp --version
|
||||
|
||||
# ── The agent user ───────────────────────────────────────────────────────────
|
||||
# NOT root, and not negotiable: Claude Code refuses --dangerously-skip-permissions
|
||||
# when it has root privileges. uid/gid 1000 matches the host account so files
|
||||
|
||||
@@ -39,117 +39,34 @@ set answered_trust 0
|
||||
set answered_bypass 0
|
||||
|
||||
spawn -noecho claude --dangerously-skip-permissions {*}$argv
|
||||
set child_pid [exp_pid]
|
||||
|
||||
# 🔴 THIS WRAPPER USED TO SWALLOW BOTH THE SIGNAL AND THE EXIT STATUS, and those
|
||||
# two omissions caused most of this project's multi-hour outages. Found
|
||||
# 2026-09-03 by tracing the signal path, after a tooling review predicted exactly
|
||||
# this from the symptoms.
|
||||
#
|
||||
# The path is: tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
|
||||
#
|
||||
# `spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
|
||||
# is the process Docker signals, and everything below it depends on expect
|
||||
# passing things along. It did not.
|
||||
#
|
||||
# 1. NO SIGNAL FORWARDING. `docker stop` sent SIGTERM to expect, which died and
|
||||
# took the pty with it. Claude Code never received a SIGTERM, so it never ran
|
||||
# its `SessionEnd` hooks and never wrote `lastSessionId`/`history` to
|
||||
# `~/.claude.json` -- which are written only at a GRACEFUL shutdown. That is
|
||||
# the whole reason `claude --continue` answered "No conversation found to
|
||||
# continue" with 33 MB of transcripts sitting in the volume beside it, and why
|
||||
# we resume by scraping a session id off a transcript filename instead.
|
||||
#
|
||||
# 2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
|
||||
# ZERO. So when the kernel OOM-killer took the child, expect saw EOF and
|
||||
# reported a clean exit -- `OOMKilled: true` with `ExitCode 0`, which is not
|
||||
# Docker being odd, it is this line. It also meant `--restart on-failure`
|
||||
# would have treated a memory kill as success, which is why the policy had to
|
||||
# be `unless-stopped`.
|
||||
#
|
||||
# Both are fixed here. Signals are forwarded to the child and its real status is
|
||||
# propagated, so a kill reads as 137, a clean stop lets Claude Code shut down
|
||||
# properly, and the exit code means what it says.
|
||||
proc forward {sig} {
|
||||
global child_pid
|
||||
catch { exec kill -$sig $child_pid }
|
||||
}
|
||||
trap { forward TERM } SIGTERM
|
||||
trap { forward INT } SIGINT
|
||||
trap { forward HUP } SIGHUP
|
||||
|
||||
# 🔴 THIS BLOCK TYPED INTO A LIVE SESSION, and the single-word patterns were why.
|
||||
#
|
||||
# 2026-09-04: both agents stopped, and the decoder said so itself --
|
||||
#
|
||||
# "I received '2' and '1' but I don't have a pending question those would
|
||||
# answer -- I was in the middle of setting up the /loop cron job."
|
||||
#
|
||||
# The patterns were the bare substrings `Choose`, `trust` and `accept`. The
|
||||
# /loop PROMPT is echoed into the terminal, and that day's brief contained
|
||||
# "H3, the plate delay, is ACCEPTED" and "Do not choose what jump means". So
|
||||
# expect matched the agent's own instructions and sent `2\r` and `1\r` into a
|
||||
# running session, which then sat waiting for a human to explain them.
|
||||
#
|
||||
# The original comment argued that a multi-word pattern "never matches" because
|
||||
# the gate text wraps. That is true of a LITERAL multi-word string and false of a
|
||||
# whitespace-tolerant regex, which is what these now are: `\s+` spans the wrap.
|
||||
# The terminal is also 200 columns wide (set above), so these lines rarely wrap
|
||||
# at all.
|
||||
#
|
||||
# Two defences, because one is not enough for something that can type:
|
||||
# 1. patterns specific enough that ordinary prose cannot match them
|
||||
# 2. gates are skipped ENTIRELY when resuming -- a resumed session cannot show
|
||||
# a first-run gate, so there is nothing to answer and everything to lose
|
||||
if {[info exists env(SYLPH_SKIP_GATES)] && $env(SYLPH_SKIP_GATES) ne "0"} {
|
||||
send_user "\[claude-autonomous] resuming: first-run gates cannot appear, not watching for them\n"
|
||||
} else {
|
||||
# Shorter than the old 90 s. The gates appear immediately or not at all, and
|
||||
# every extra second is a second in which this can type into a live session.
|
||||
set timeout 25
|
||||
expect {
|
||||
-re {Choose\s+the\s+text\s+style} {
|
||||
if {!$answered_theme} { set answered_theme 1; send "\r" }
|
||||
exp_continue
|
||||
}
|
||||
-re {Do\s+you\s+trust\s+the\s+files} {
|
||||
if {!$answered_trust} {
|
||||
set answered_trust 1
|
||||
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
|
||||
send "1\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
-re {Yes,\s*I\s+accept} {
|
||||
if {!$answered_bypass} {
|
||||
set answered_bypass 1
|
||||
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
|
||||
send "2\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
timeout {
|
||||
# No gate appeared. Stop matching so nothing later in the run can be
|
||||
# answered by accident -- which is exactly what used to happen.
|
||||
}
|
||||
eof { exit }
|
||||
expect {
|
||||
-re {Choose} {
|
||||
if {!$answered_theme} { set answered_theme 1; send "\r" }
|
||||
exp_continue
|
||||
}
|
||||
-re {trust} {
|
||||
if {!$answered_trust} {
|
||||
set answered_trust 1
|
||||
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
|
||||
send "1\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
-re {accept} {
|
||||
if {!$answered_bypass} {
|
||||
set answered_bypass 1
|
||||
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
|
||||
send "2\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
timeout {
|
||||
# No new gate for a while: the session is up (or never had one). Stop
|
||||
# matching so nothing later in the run can be answered by accident.
|
||||
}
|
||||
eof { exit }
|
||||
}
|
||||
|
||||
# Hand the terminal over for the rest of the run.
|
||||
interact
|
||||
|
||||
# Propagate the child's REAL exit status. `interact` returns when the child is
|
||||
# gone; `wait` then yields {pid spawnid os_error status}. Without this the script
|
||||
# simply ran off the end and returned 0 -- see the note at `spawn` above for what
|
||||
# that cost.
|
||||
catch wait result
|
||||
set status 0
|
||||
if {[info exists result] && [llength $result] >= 4} {
|
||||
# os_error_flag (index 2) is -1 for a normal exit; anything else means the
|
||||
# wait itself failed and the status field is not a status.
|
||||
if {[lindex $result 2] == 0} {
|
||||
set status [lindex $result 3]
|
||||
}
|
||||
}
|
||||
exit $status
|
||||
|
||||
@@ -84,26 +84,11 @@ if [ -r /sys/fs/cgroup/memory.max ]; then
|
||||
[ "$_m" != max ] && mem_gib=$(( _m / 1073741824 ))
|
||||
fi
|
||||
[ "${mem_gib:-0}" -lt 1 ] && mem_gib=1
|
||||
# 🔴 THIS CONSTANT WAS WRONG, AND IT COST A RUN. `mem_gib * 2 / 3` assumes
|
||||
# ~1.5 GB per job. On 2026-09-01 a raw `cargo test --release -p sylpheed-formats`
|
||||
# ran 4 jobs in a 6 GB container and was OOM-killed mid-task. Release-mode rustc
|
||||
# on this workspace needs closer to 2 GB, so the divisor is 2, not 3/2.
|
||||
#
|
||||
# ⚠️ And the kill reported `OOMKilled: true` with **ExitCode 0**, so it read as a
|
||||
# clean exit — which is why the restart policy is `unless-stopped` rather than
|
||||
# `on-failure`.
|
||||
by_mem=$(( mem_gib / 2 ))
|
||||
by_mem=$(( mem_gib * 2 / 3 ))
|
||||
[ "$by_mem" -lt 1 ] && by_mem=1
|
||||
jobs=$(( cpus < by_mem ? cpus : by_mem ))
|
||||
# An EXPLICIT cap from the launcher wins. Without this the launcher's
|
||||
# `-e CARGO_BUILD_JOBS=...` was computed, exported over, and silently discarded
|
||||
# — the guardrail was set and then removed three lines later.
|
||||
if [ -n "${CARGO_BUILD_JOBS:-}" ] && [ "${CARGO_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then
|
||||
jobs="$CARGO_BUILD_JOBS"
|
||||
_why=" (explicit, from the launcher)"
|
||||
fi
|
||||
export SYLPH_JOBS="$jobs" CARGO_BUILD_JOBS="$jobs" CMAKE_BUILD_PARALLEL_LEVEL="$jobs"
|
||||
log "build parallelism: $jobs${_why:-} (cpus=$cpus, mem=${mem_gib}GiB avail)"
|
||||
log "build parallelism: $jobs (cpus=$cpus, mem=${mem_gib}GiB avail)"
|
||||
|
||||
mkdir -p "$HOME/shots" "$HOME/logs"
|
||||
|
||||
@@ -156,22 +141,7 @@ mkdir -p /exchange/files 2>/dev/null || true
|
||||
#
|
||||
# Newer-wins rather than always-copy, because the container refreshes its own
|
||||
# token during a run and that copy may legitimately be the fresher one.
|
||||
# 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT.
|
||||
#
|
||||
# With CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file
|
||||
# in would re-create the exact collision the token exists to remove: three
|
||||
# clients on one rotating refresh token, the losers of a rotation race getting
|
||||
# their stored tokens CLEARED to empty strings and parking at "Login expired".
|
||||
# Measured 2026-09-04 -- see the launcher.
|
||||
# 🔴 PER-AGENT LOGIN: never seed. Set SYLPH_OWN_LOGIN=1 once this container
|
||||
# has run `claude auth login` itself. Its grant is its OWN -- copying the
|
||||
# host's over it re-creates the rotation collision that empties credentials
|
||||
# and parks the session, which is the whole reason per-agent logins exist.
|
||||
if [ -n "${SYLPH_OWN_LOGIN:-}" ] && [ "${SYLPH_OWN_LOGIN}" != "0" ]; then
|
||||
log "auth: this agent has its own login; not seeding from the host"
|
||||
elif [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
||||
log "auth: using the long-lived token from the environment; not seeding OAuth"
|
||||
elif [ -d "$HOME/.claude.seed" ] && \
|
||||
if [ -d "$HOME/.claude.seed" ] && \
|
||||
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
|
||||
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
|
||||
mkdir -p "$HOME/.claude"
|
||||
@@ -190,10 +160,6 @@ fi
|
||||
if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then
|
||||
cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Nothing is restored into `~/.claude.json` on purpose. Resuming is done by
|
||||
# SESSION ID off the transcript instead — see the resume block below for why
|
||||
# the index is useless for this.
|
||||
# `credential.helper=store` rewrites this file by rename-over-target, which
|
||||
# fails with EBUSY on a bind mount -- reported as `fatal: unable to write
|
||||
# credential store`, while the push itself succeeds. A fatal line that is
|
||||
@@ -208,54 +174,6 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER"
|
||||
"$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true
|
||||
chmod 600 "$HOME/.claude.json" 2>/dev/null || true
|
||||
|
||||
# ── The Gitea MCP server ─────────────────────────────────────────────────────
|
||||
# Registered at USER scope rather than from a committed `.mcp.json`: the token
|
||||
# differs per agent and none of it belongs in git.
|
||||
#
|
||||
# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat
|
||||
# …)` would write the secret in cleartext into ~/.claude.json, where it is read
|
||||
# by every session in this container and lands in any copy of that file.
|
||||
# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its
|
||||
# read-only mount and lets the server read it itself.
|
||||
#
|
||||
# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name
|
||||
# that already exists, and ~/.claude.json is re-seeded above — neither ordering
|
||||
# survives alone.
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}"
|
||||
GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}"
|
||||
# Which tools this agent gets. Deliberately not all of them:
|
||||
#
|
||||
# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one.
|
||||
# Gitea will not let an author approve its own pull request — but the moment
|
||||
# the two agents are separate people, nothing stops them approving each
|
||||
# OTHER's and satisfying `required_approvals` between themselves with no
|
||||
# human involved. Separate identities open that hole; withholding the tool
|
||||
# closes it here, and the approvals whitelist on `main` closes it there.
|
||||
# * the file / branch / repo WRITE tools are absent: a change reaches `main`
|
||||
# as a reviewable commit through git, or it does not reach it.
|
||||
#
|
||||
# `pull_request_write` bundles `merge` into one tool and cannot be split, so
|
||||
# merging stays blocked where the agent cannot reach it — the merge whitelist in
|
||||
# branch protection. This list is defence in depth BEHIND that, never instead.
|
||||
GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-get_me,notification_read,notification_write,list_issues,issue_read,issue_write,attachment_read,search_issues,label_read,milestone_read,list_pull_requests,pull_request_read,pull_request_write}"
|
||||
if [ ! -s "$GITEA_TOKEN_FILE" ]; then
|
||||
echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered."
|
||||
echo "[entrypoint] This agent cannot read its notifications or open a pull"
|
||||
echo "[entrypoint] request, which is most of what its brief asks of it."
|
||||
elif ! command -v gitea-mcp >/dev/null 2>&1; then
|
||||
echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2
|
||||
else
|
||||
claude mcp remove gitea -s user >/dev/null 2>&1 || true
|
||||
if claude mcp add -s user gitea \
|
||||
-e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
|
||||
-- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then
|
||||
echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL"
|
||||
else
|
||||
echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2
|
||||
echo "[entrypoint] no pull requests and no notifications." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Claude Code ──────────────────────────────────────────────────────────────
|
||||
if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
|
||||
# Drop the image's default CMD first, or `claude` is handed the literal string
|
||||
@@ -263,81 +181,6 @@ if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then
|
||||
set --
|
||||
fi
|
||||
# ── Resume across a restart ────────────────────────────────────────────────
|
||||
#
|
||||
# The container restarts automatically now, and a restart that opens a BLANK
|
||||
# session throws away everything the agent knew. That is not hypothetical: on
|
||||
# 2026-09-01 an OOM kill ended a run mid-task with a 2.7 MB transcript and two
|
||||
# files uncommitted in the volume.
|
||||
#
|
||||
# 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured 2026-09-01:
|
||||
#
|
||||
# `--continue` resolves through `~/.claude.json`'s per-project `history` and
|
||||
# `lastSessionId`. Those are written at a GRACEFUL SHUTDOWN — mid-session the
|
||||
# live file has `history: None`, `lastSessionId: None`. A container that is
|
||||
# OOM-killed or `docker rm -f`ed never writes them, which is exactly the case
|
||||
# this feature exists for. So `--continue` answered "No conversation found to
|
||||
# continue" with 33 MB of perfectly good transcripts in the volume beside it,
|
||||
# and persisting `.claude.json` did not help because the fields were never
|
||||
# populated in the first place.
|
||||
#
|
||||
# The TRANSCRIPTS are durable and are named by session id, so read the id off
|
||||
# the newest one for this working directory. Claude Code has not started yet
|
||||
# at this point, so the newest is the previous run's.
|
||||
#
|
||||
# The /loop prompt is still passed, so the loop is RE-ARMED rather than merely
|
||||
# restored — a resumed conversation with no wake-up scheduled answers once and
|
||||
# stops, which looks like resuming and is not.
|
||||
SYLPH_STAMP="$HOME/.claude/.sylph-last-start"
|
||||
SYLPH_RESUME=0
|
||||
SYLPH_SESSION=""
|
||||
SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')"
|
||||
if [ -d "$SYLPH_PROJ" ]; then
|
||||
_newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1)
|
||||
if [ -n "$_newest" ]; then
|
||||
SYLPH_SESSION=$(basename "$_newest" .jsonl)
|
||||
SYLPH_RESUME=1
|
||||
fi
|
||||
fi
|
||||
# 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. If the last start was under
|
||||
# two minutes ago we are already in a restart loop, and continuing back into
|
||||
# whatever killed us is the one thing guaranteed not to help. Start fresh and
|
||||
# say so, rather than burning tokens on the same death repeatedly.
|
||||
if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then
|
||||
_last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0)
|
||||
_now=$(date +%s)
|
||||
if [ $((_now - _last)) -lt 120 ]; then
|
||||
SYLPH_RESUME=0
|
||||
log "restarted <120s after the last start — restart loop suspected;"
|
||||
log " starting a FRESH session rather than continuing into the same death"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$HOME/.claude" 2>/dev/null || true
|
||||
date +%s > "$SYLPH_STAMP" 2>/dev/null || true
|
||||
|
||||
if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then
|
||||
set -- "$1
|
||||
|
||||
⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact,
|
||||
but the process that was running when it died is gone. Before anything else:
|
||||
|
||||
1. \`git -C /work status\`. Whatever you had in progress is still in the tree,
|
||||
UNCOMMITTED. Commit it and \`push-work\` before starting anything new.
|
||||
2. Any build, test or capture you had running did NOT finish. Do not read its
|
||||
absence as a result.
|
||||
3. The likeliest cause is an OOM kill — this container is capped at 6 GB.
|
||||
\`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it,
|
||||
and prefer \`build-reborn test\` over a raw \`cargo test --release\`, which
|
||||
bypasses the wrapper's job cap. That is exactly what killed the run on
|
||||
2026-09-01."
|
||||
log "resuming session ${SYLPH_SESSION%%-*}… with a restart notice"
|
||||
fi
|
||||
# Tell the gate-answering wrapper to stand down: a resumed session cannot
|
||||
# show a first-run gate, and on 2026-09-04 its single-word patterns matched
|
||||
# the /loop prompt itself and typed "2" and "1" into a live session.
|
||||
[ "$SYLPH_RESUME" = "1" ] && export SYLPH_SKIP_GATES=1
|
||||
[ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@"
|
||||
|
||||
# The flag the user asked for. It is refused under root, which is why this
|
||||
# image runs as `agent`.
|
||||
# Remote Control registers the session with your account so you can chat with
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
# SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent)
|
||||
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
|
||||
# (default: $HOME/.sylph-git-credentials)
|
||||
# SYLPH_GITEA_TOKEN this agent's own Gitea token file
|
||||
# (default: $HOME/.sylph-gitea-token-decoder)
|
||||
# SYLPH_LOOP_INTERVAL fixed loop cadence, e.g. 30m (default: 45m)
|
||||
# SYLPH_CPUS / SYLPH_MEM_GB override the computed half
|
||||
set -euo pipefail
|
||||
@@ -132,19 +130,6 @@ docker_args() {
|
||||
-e "PROJECT_DIR=/work"
|
||||
-e "SYLPH_EXCHANGE=/exchange"
|
||||
-e "SYLPH_AGENT=decoder"
|
||||
# 🔴 THE JOB CAP LIVES IN THE ENVIRONMENT, NOT IN THE WRAPPER.
|
||||
#
|
||||
# `build-reborn` has always exported CARGO_BUILD_JOBS, and on 2026-09-01
|
||||
# that was not enough: the agent ran a RAW `cargo test --release -p
|
||||
# sylpheed-formats`, which never touches the wrapper, got one rustc per
|
||||
# granted CPU, and the container was OOM-killed at its 6 GB cap mid-task.
|
||||
# Docker reported ExitCode 0 with OOMKilled true, so it read as a clean
|
||||
# exit and cost a diagnosis.
|
||||
#
|
||||
# A guardrail reachable only through a wrapper protects the calls that use
|
||||
# the wrapper. This one is inherited by every process in the container, so
|
||||
# bypassing it takes an explicit override rather than forgetting.
|
||||
-e "CARGO_BUILD_JOBS=${SYLPH_JOBS:-2}"
|
||||
-e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git"
|
||||
-e "XENIA_SRC=/canary"
|
||||
# ── claude ──
|
||||
@@ -187,39 +172,6 @@ docker_args() {
|
||||
# Read-only, and only ever used by `push-work`, which refuses anything but an
|
||||
# auto/* branch and never force-pushes. Without this the agent's work only
|
||||
# exists inside the container and dies with it.
|
||||
# ── Claude auth ──
|
||||
#
|
||||
# 🔴 THE ROTATING OAUTH FILE IS WHY THIS AGENT KEPT PARKING, and a long-lived
|
||||
# token removes the failure by construction rather than recovering from it.
|
||||
#
|
||||
# Measured 2026-09-04: `~/.claude/.credentials.json` holds a REFRESH TOKEN THAT
|
||||
# ROTATES ON USE. Seeding both containers from the host's copy left three
|
||||
# clients holding one token; the first to refresh invalidated the other two,
|
||||
# and on the failed refresh **Claude Code CLEARS the stored tokens** -- it
|
||||
# writes empty strings, keeps the metadata, and parks at "Login expired". The
|
||||
# decoder's file was caught emptied at 13:04:28 with its last work at 13:04:29.
|
||||
# A hollow file passes every "does it exist" check, which is why three separate
|
||||
# diagnoses missed it.
|
||||
#
|
||||
# `claude setup-token` issues a LONG-LIVED token against the same Claude
|
||||
# subscription (not Console/API billing -- `claude auth login` defaults to
|
||||
# `--claudeai`, and `--console` is the billed one). Passed as an environment
|
||||
# variable it cannot be rotated out from under a peer and there is no file for
|
||||
# Claude Code to empty, so both halves of the failure are gone.
|
||||
#
|
||||
# Inert until the file exists: without it the OAuth path below is unchanged.
|
||||
# Pass through: set SYLPH_OWN_LOGIN=1 when this container has run
|
||||
# `claude auth login` itself, so the entrypoint never copies the host's
|
||||
# rotating credentials over its own grant. Remote Control needs a real
|
||||
# login -- the long-lived token does not carry the sessions scope.
|
||||
[ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN")
|
||||
|
||||
CLAUDETOK="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
|
||||
if [ -f "$CLAUDETOK" ]; then
|
||||
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$CLAUDETOK")")
|
||||
echo "==> auth: long-lived token from $CLAUDETOK (no rotating credential file)" >&2
|
||||
fi
|
||||
|
||||
GITCRED="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
||||
if [ -f "$GITCRED" ]; then
|
||||
_out+=(-v "$GITCRED:/sylph-home/re/.git-credentials.host:ro")
|
||||
@@ -231,29 +183,6 @@ docker_args() {
|
||||
echo " or point SYLPH_GIT_CREDENTIALS elsewhere." >&2
|
||||
fi
|
||||
|
||||
# ── Gitea ──
|
||||
# This agent's OWN token, for its OWN Gitea account — not the push credential
|
||||
# and not the human's. Three reasons it is separate: `~/.sylph-git-credentials`
|
||||
# is scoped `write:repository` and every issue endpoint REFUSES it; a pull
|
||||
# request the agent authored is one a human can approve, which is the entire
|
||||
# review gate; and revoking one agent then touches neither the other nor you.
|
||||
#
|
||||
# Mounted read-only and passed to the MCP server BY PATH — see the entrypoint
|
||||
# for why the value must not go through the environment.
|
||||
# Inert until the file exists: the container still runs, with no issues.
|
||||
GITEATOK="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-decoder}"
|
||||
if [ -f "$GITEATOK" ]; then
|
||||
_out+=(
|
||||
-v "$GITEATOK:/sylph-home/re/.sylph-gitea-token:ro"
|
||||
-e "GITEA_TOKEN_FILE=/sylph-home/re/.sylph-gitea-token"
|
||||
)
|
||||
else
|
||||
echo "==> NOTE: no Gitea token at $GITEATOK — this agent cannot read its" >&2
|
||||
echo " notifications, open an issue or open a pull request. Generate one" >&2
|
||||
echo " while logged in AS sylph-decoder: Settings -> Applications, scopes" >&2
|
||||
echo " write:repository, write:issue, write:notification, read:user." >&2
|
||||
fi
|
||||
|
||||
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
|
||||
[ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN")
|
||||
[ -n "${SYLPH_REMOTE:-}" ] && _out+=(-e "SYLPH_REMOTE=$SYLPH_REMOTE")
|
||||
@@ -340,17 +269,7 @@ case "${1:-}" in
|
||||
echo "==> repo: own clone in volume sylpheed-decoder-repo -> /work"
|
||||
echo "==> pacing: ${INTERVAL:-self-paced}"
|
||||
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
# 🔴 `unless-stopped`, NOT `on-failure` -- and the reason is a trap worth
|
||||
# keeping. When this container was OOM-killed on 2026-09-01, Docker reported
|
||||
# `OOMKilled: true` with **ExitCode 0**. `on-failure` keys off the exit code,
|
||||
# so it would have treated a memory kill as a clean finish and left the agent
|
||||
# down. `unless-stopped` restarts regardless, and still honours an explicit
|
||||
# `./sylph-agent stop`.
|
||||
#
|
||||
# Restarting into the same death is handled at the other end: the entrypoint
|
||||
# refuses to `--continue` if the last start was under two minutes ago.
|
||||
docker run -d -i -t --restart unless-stopped "${ARGS[@]}" "$IMAGE" \
|
||||
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
|
||||
docker run -d -i -t "${ARGS[@]}" "$IMAGE" "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
|
||||
echo
|
||||
echo " running detached as '$NAME'."
|
||||
echo " ./sylph-agent remote link to chat with it from anywhere"
|
||||
|
||||
@@ -62,24 +62,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& npm cache clean --force \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── gitea-mcp ────────────────────────────────────────────────────────────────
|
||||
# The agent's hands on issues, pull requests and notifications — Gitea's own MCP
|
||||
# server, so there is no second store of truth to drift out of sync with the
|
||||
# first.
|
||||
#
|
||||
# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is
|
||||
# handed a token that can write to the repository. The checksum is the one
|
||||
# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset.
|
||||
ARG GITEA_MCP_VERSION=1.7.0
|
||||
ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d
|
||||
RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \
|
||||
"https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \
|
||||
&& echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \
|
||||
&& chmod +x /usr/local/bin/gitea-mcp \
|
||||
&& rm -f /tmp/gitea-mcp.tar.gz \
|
||||
&& gitea-mcp --version
|
||||
|
||||
# ── The agent user ───────────────────────────────────────────────────────────
|
||||
# NOT root: Claude Code refuses --dangerously-skip-permissions with root
|
||||
# privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the
|
||||
|
||||
@@ -39,117 +39,34 @@ set answered_trust 0
|
||||
set answered_bypass 0
|
||||
|
||||
spawn -noecho claude --dangerously-skip-permissions {*}$argv
|
||||
set child_pid [exp_pid]
|
||||
|
||||
# 🔴 THIS WRAPPER USED TO SWALLOW BOTH THE SIGNAL AND THE EXIT STATUS, and those
|
||||
# two omissions caused most of this project's multi-hour outages. Found
|
||||
# 2026-09-03 by tracing the signal path, after a tooling review predicted exactly
|
||||
# this from the symptoms.
|
||||
#
|
||||
# The path is: tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
|
||||
#
|
||||
# `spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
|
||||
# is the process Docker signals, and everything below it depends on expect
|
||||
# passing things along. It did not.
|
||||
#
|
||||
# 1. NO SIGNAL FORWARDING. `docker stop` sent SIGTERM to expect, which died and
|
||||
# took the pty with it. Claude Code never received a SIGTERM, so it never ran
|
||||
# its `SessionEnd` hooks and never wrote `lastSessionId`/`history` to
|
||||
# `~/.claude.json` -- which are written only at a GRACEFUL shutdown. That is
|
||||
# the whole reason `claude --continue` answered "No conversation found to
|
||||
# continue" with 33 MB of transcripts sitting in the volume beside it, and why
|
||||
# we resume by scraping a session id off a transcript filename instead.
|
||||
#
|
||||
# 2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
|
||||
# ZERO. So when the kernel OOM-killer took the child, expect saw EOF and
|
||||
# reported a clean exit -- `OOMKilled: true` with `ExitCode 0`, which is not
|
||||
# Docker being odd, it is this line. It also meant `--restart on-failure`
|
||||
# would have treated a memory kill as success, which is why the policy had to
|
||||
# be `unless-stopped`.
|
||||
#
|
||||
# Both are fixed here. Signals are forwarded to the child and its real status is
|
||||
# propagated, so a kill reads as 137, a clean stop lets Claude Code shut down
|
||||
# properly, and the exit code means what it says.
|
||||
proc forward {sig} {
|
||||
global child_pid
|
||||
catch { exec kill -$sig $child_pid }
|
||||
}
|
||||
trap { forward TERM } SIGTERM
|
||||
trap { forward INT } SIGINT
|
||||
trap { forward HUP } SIGHUP
|
||||
|
||||
# 🔴 THIS BLOCK TYPED INTO A LIVE SESSION, and the single-word patterns were why.
|
||||
#
|
||||
# 2026-09-04: both agents stopped, and the decoder said so itself --
|
||||
#
|
||||
# "I received '2' and '1' but I don't have a pending question those would
|
||||
# answer -- I was in the middle of setting up the /loop cron job."
|
||||
#
|
||||
# The patterns were the bare substrings `Choose`, `trust` and `accept`. The
|
||||
# /loop PROMPT is echoed into the terminal, and that day's brief contained
|
||||
# "H3, the plate delay, is ACCEPTED" and "Do not choose what jump means". So
|
||||
# expect matched the agent's own instructions and sent `2\r` and `1\r` into a
|
||||
# running session, which then sat waiting for a human to explain them.
|
||||
#
|
||||
# The original comment argued that a multi-word pattern "never matches" because
|
||||
# the gate text wraps. That is true of a LITERAL multi-word string and false of a
|
||||
# whitespace-tolerant regex, which is what these now are: `\s+` spans the wrap.
|
||||
# The terminal is also 200 columns wide (set above), so these lines rarely wrap
|
||||
# at all.
|
||||
#
|
||||
# Two defences, because one is not enough for something that can type:
|
||||
# 1. patterns specific enough that ordinary prose cannot match them
|
||||
# 2. gates are skipped ENTIRELY when resuming -- a resumed session cannot show
|
||||
# a first-run gate, so there is nothing to answer and everything to lose
|
||||
if {[info exists env(SYLPH_SKIP_GATES)] && $env(SYLPH_SKIP_GATES) ne "0"} {
|
||||
send_user "\[claude-autonomous] resuming: first-run gates cannot appear, not watching for them\n"
|
||||
} else {
|
||||
# Shorter than the old 90 s. The gates appear immediately or not at all, and
|
||||
# every extra second is a second in which this can type into a live session.
|
||||
set timeout 25
|
||||
expect {
|
||||
-re {Choose\s+the\s+text\s+style} {
|
||||
if {!$answered_theme} { set answered_theme 1; send "\r" }
|
||||
exp_continue
|
||||
}
|
||||
-re {Do\s+you\s+trust\s+the\s+files} {
|
||||
if {!$answered_trust} {
|
||||
set answered_trust 1
|
||||
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
|
||||
send "1\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
-re {Yes,\s*I\s+accept} {
|
||||
if {!$answered_bypass} {
|
||||
set answered_bypass 1
|
||||
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
|
||||
send "2\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
timeout {
|
||||
# No gate appeared. Stop matching so nothing later in the run can be
|
||||
# answered by accident -- which is exactly what used to happen.
|
||||
}
|
||||
eof { exit }
|
||||
expect {
|
||||
-re {Choose} {
|
||||
if {!$answered_theme} { set answered_theme 1; send "\r" }
|
||||
exp_continue
|
||||
}
|
||||
-re {trust} {
|
||||
if {!$answered_trust} {
|
||||
set answered_trust 1
|
||||
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
|
||||
send "1\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
-re {accept} {
|
||||
if {!$answered_bypass} {
|
||||
set answered_bypass 1
|
||||
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
|
||||
send "2\r"
|
||||
}
|
||||
exp_continue
|
||||
}
|
||||
timeout {
|
||||
# No new gate for a while: the session is up (or never had one). Stop
|
||||
# matching so nothing later in the run can be answered by accident.
|
||||
}
|
||||
eof { exit }
|
||||
}
|
||||
|
||||
# Hand the terminal over for the rest of the run.
|
||||
interact
|
||||
|
||||
# Propagate the child's REAL exit status. `interact` returns when the child is
|
||||
# gone; `wait` then yields {pid spawnid os_error status}. Without this the script
|
||||
# simply ran off the end and returned 0 -- see the note at `spawn` above for what
|
||||
# that cost.
|
||||
catch wait result
|
||||
set status 0
|
||||
if {[info exists result] && [llength $result] >= 4} {
|
||||
# os_error_flag (index 2) is -1 for a normal exit; anything else means the
|
||||
# wait itself failed and the status field is not a status.
|
||||
if {[lindex $result 2] == 0} {
|
||||
set status [lindex $result 3]
|
||||
}
|
||||
}
|
||||
exit $status
|
||||
|
||||
@@ -38,20 +38,7 @@ echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
|
||||
#
|
||||
# Newer-wins rather than always-copy, because the container refreshes its own
|
||||
# token during a run and that copy may legitimately be the fresher one.
|
||||
# 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT. With
|
||||
# CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file in
|
||||
# would re-create the collision the token exists to remove: three clients on one
|
||||
# rotating refresh token, and the loser of a rotation race gets its stored tokens
|
||||
# CLEARED to empty strings by Claude Code and parks. Measured 2026-09-04.
|
||||
# 🔴 PER-AGENT LOGIN: never seed. Set SYLPH_OWN_LOGIN=1 once this container
|
||||
# has run `claude auth login` itself. Its grant is its OWN -- copying the
|
||||
# host's over it re-creates the rotation collision that empties credentials
|
||||
# and parks the session, which is the whole reason per-agent logins exist.
|
||||
if [ -n "${SYLPH_OWN_LOGIN:-}" ] && [ "${SYLPH_OWN_LOGIN}" != "0" ]; then
|
||||
echo "[entrypoint] auth: this agent has its own login; not seeding from the host"
|
||||
elif [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
||||
echo "[entrypoint] auth: long-lived token from the environment; not seeding OAuth"
|
||||
elif [ -d "$HOME/.claude.seed" ] && \
|
||||
if [ -d "$HOME/.claude.seed" ] && \
|
||||
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
|
||||
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
|
||||
mkdir -p "$HOME/.claude"
|
||||
@@ -70,10 +57,6 @@ fi
|
||||
if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then
|
||||
cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Nothing is restored into `~/.claude.json` on purpose. Resuming is done by
|
||||
# SESSION ID off the transcript instead — see the resume block below for why the
|
||||
# index cannot serve.
|
||||
# Same reason as .claude.json above: `credential.helper=store` rewrites this
|
||||
# file by rename-over-target, which fails with EBUSY on a bind mount. Copy it to
|
||||
# a writable path; nothing is ever written back to the host's file.
|
||||
@@ -87,54 +70,6 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER"
|
||||
"$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true
|
||||
chmod 600 "$HOME/.claude.json" 2>/dev/null || true
|
||||
|
||||
# ── The Gitea MCP server ─────────────────────────────────────────────────────
|
||||
# Registered at USER scope rather than from a committed `.mcp.json`: the token
|
||||
# differs per agent and none of it belongs in git.
|
||||
#
|
||||
# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat
|
||||
# …)` would write the secret in cleartext into ~/.claude.json, where it is read
|
||||
# by every session in this container and lands in any copy of that file.
|
||||
# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its
|
||||
# read-only mount and lets the server read it itself.
|
||||
#
|
||||
# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name
|
||||
# that already exists, and ~/.claude.json is re-seeded above — neither ordering
|
||||
# survives alone.
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}"
|
||||
GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}"
|
||||
# Which tools this agent gets. Deliberately not all of them:
|
||||
#
|
||||
# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one.
|
||||
# Gitea will not let an author approve its own pull request — but the moment
|
||||
# the two agents are separate people, nothing stops them approving each
|
||||
# OTHER's and satisfying `required_approvals` between themselves with no
|
||||
# human involved. Separate identities open that hole; withholding the tool
|
||||
# closes it here, and the approvals whitelist on `main` closes it there.
|
||||
# * the file / branch / repo WRITE tools are absent: a change reaches `main`
|
||||
# as a reviewable commit through git, or it does not reach it.
|
||||
#
|
||||
# `pull_request_write` bundles `merge` into one tool and cannot be split, so
|
||||
# merging stays blocked where the agent cannot reach it — the merge whitelist in
|
||||
# branch protection. This list is defence in depth BEHIND that, never instead.
|
||||
GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-get_me,notification_read,notification_write,list_issues,issue_read,issue_write,attachment_read,search_issues,label_read,milestone_read,list_pull_requests,pull_request_read,pull_request_write}"
|
||||
if [ ! -s "$GITEA_TOKEN_FILE" ]; then
|
||||
echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered."
|
||||
echo "[entrypoint] This agent cannot read its notifications or open a pull"
|
||||
echo "[entrypoint] request, which is most of what its brief asks of it."
|
||||
elif ! command -v gitea-mcp >/dev/null 2>&1; then
|
||||
echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2
|
||||
else
|
||||
claude mcp remove gitea -s user >/dev/null 2>&1 || true
|
||||
if claude mcp add -s user gitea \
|
||||
-e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
|
||||
-- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then
|
||||
echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL"
|
||||
else
|
||||
echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2
|
||||
echo "[entrypoint] no pull requests and no notifications." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── The repository, cloned into THIS AGENT'S OWN volume ─────────────────────
|
||||
# Not a bind mount of a human's working tree. That arrangement bit this project
|
||||
# three times: an agent's `git config --local` captured a human's commits, a
|
||||
@@ -175,74 +110,6 @@ if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then
|
||||
set --
|
||||
fi
|
||||
# ── Resume across a restart ────────────────────────────────────────────────
|
||||
#
|
||||
# The container restarts automatically now, and a restart that opens a BLANK
|
||||
# session throws away everything the agent knew.
|
||||
#
|
||||
# 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured on the decoder
|
||||
# 2026-09-01: `--continue` resolves through `~/.claude.json`'s per-project
|
||||
# `history` / `lastSessionId`, and those are written at a GRACEFUL SHUTDOWN --
|
||||
# mid-session the live file has both as `None`. A container that is OOM-killed
|
||||
# or `docker rm -f`ed never writes them, which is exactly the case this exists
|
||||
# for, so `--continue` answered "No conversation found to continue" with the
|
||||
# transcripts sitting in the volume beside it.
|
||||
#
|
||||
# The TRANSCRIPTS are durable and named by session id. Claude Code has not
|
||||
# started yet here, so the newest is the previous run's.
|
||||
#
|
||||
# The /loop prompt is still passed so the loop is RE-ARMED rather than merely
|
||||
# restored -- a resumed conversation with no wake-up scheduled answers once
|
||||
# and stops, which looks like resuming and is not.
|
||||
SYLPH_STAMP="$HOME/.claude/.sylph-last-start"
|
||||
SYLPH_RESUME=0
|
||||
SYLPH_SESSION=""
|
||||
SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')"
|
||||
if [ -d "$SYLPH_PROJ" ]; then
|
||||
_newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1)
|
||||
if [ -n "$_newest" ]; then
|
||||
SYLPH_SESSION=$(basename "$_newest" .jsonl)
|
||||
SYLPH_RESUME=1
|
||||
fi
|
||||
fi
|
||||
# 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. Restarted under two minutes
|
||||
# after the last start, we are already looping: continuing back into whatever
|
||||
# killed us is the one thing guaranteed not to help.
|
||||
if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then
|
||||
_last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0)
|
||||
_now=$(date +%s)
|
||||
if [ $((_now - _last)) -lt 120 ]; then
|
||||
SYLPH_RESUME=0
|
||||
echo "[entrypoint] restarted <120s after the last start -- restart loop"
|
||||
echo "[entrypoint] suspected; starting FRESH rather than continuing"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$HOME/.claude" 2>/dev/null || true
|
||||
date +%s > "$SYLPH_STAMP" 2>/dev/null || true
|
||||
|
||||
if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then
|
||||
set -- "$1
|
||||
|
||||
⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact,
|
||||
but the process that was running when it died is gone. Before anything else:
|
||||
|
||||
1. \`git -C /work status\`. Whatever you had in progress is still in the tree,
|
||||
UNCOMMITTED. Commit it and \`push-work\` before starting anything new.
|
||||
2. Any build, test, export or Godot run you had going did NOT finish. Do not
|
||||
read its absence as a result.
|
||||
3. The likeliest cause is an OOM kill — this container is capped at 4 GB.
|
||||
\`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it,
|
||||
and prefer \`build-export\` / \`build-reference-cli\` over a raw
|
||||
\`cargo build --release\`, which bypasses the wrapper's job cap. That is what
|
||||
killed the decoder's run on 2026-09-01."
|
||||
echo "[entrypoint] resuming session ${SYLPH_SESSION%%-*}… with a restart notice"
|
||||
fi
|
||||
# Tell the gate-answering wrapper to stand down: a resumed session cannot
|
||||
# show a first-run gate, and on 2026-09-04 its single-word patterns matched
|
||||
# the /loop prompt itself and typed "2" and "1" into a live session.
|
||||
[ "$SYLPH_RESUME" = "1" ] && export SYLPH_SKIP_GATES=1
|
||||
[ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@"
|
||||
|
||||
# Remote Control registers the session with the account so the agent can be
|
||||
# reached from claude.ai -- the point of a detached run being that nobody is
|
||||
# sitting in front of it. The name is passed EXPLICITLY: the flag's value is
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
# SYLPH_PORT_REPO repo to mount at /work (default: this script's parent)
|
||||
# SYLPH_DISC extracted disc root
|
||||
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
|
||||
# SYLPH_GITEA_TOKEN this agent's own Gitea token file
|
||||
# (default: $HOME/.sylph-gitea-token-port)
|
||||
# SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m)
|
||||
#
|
||||
# ── Two hard-won constraints ────────────────────────────────────────────────
|
||||
@@ -69,12 +67,6 @@ docker_args() {
|
||||
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
|
||||
-v "sylpheed-exchange:/exchange"
|
||||
-e "PROJECT_DIR=/work"
|
||||
# Same guardrail as the decoder, added the same day and for its reason: the
|
||||
# decoder was OOM-killed mid-task by a RAW `cargo test --release`, which
|
||||
# never reaches `build-export`/`build-reference-cli` and so never saw their
|
||||
# CARGO_BUILD_JOBS. This container is smaller (4 GB, 3 CPUs), so the same
|
||||
# bypass is at least as easy to hit here.
|
||||
-e "CARGO_BUILD_JOBS=${SYLPH_PORT_JOBS:-2}"
|
||||
-e "SYLPH_EXCHANGE=/exchange"
|
||||
-e "SYLPH_AGENT=port"
|
||||
-e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git"
|
||||
@@ -107,26 +99,6 @@ docker_args() {
|
||||
# routinely wrong teaches the reader to ignore the one that is real. Mounting
|
||||
# rw would also silence it, but then the container can clobber the host's
|
||||
# credential file; copying cannot.
|
||||
# ── Claude auth ──
|
||||
# See the decoder's launcher for the full note. Short version: the OAuth
|
||||
# credential file holds a refresh token that ROTATES ON USE, three clients were
|
||||
# seeded from one copy, and the loser of a rotation race has its tokens CLEARED
|
||||
# to empty strings by Claude Code and parks at "Login expired". A long-lived
|
||||
# `claude setup-token` credential passed in the environment has nothing to
|
||||
# rotate and no file to empty. Same subscription, not API billing.
|
||||
# Inert until the file exists.
|
||||
# Pass through: set SYLPH_OWN_LOGIN=1 when this container has run
|
||||
# `claude auth login` itself, so the entrypoint never copies the host's
|
||||
# rotating credentials over its own grant. Remote Control needs a real
|
||||
# login -- the long-lived token does not carry the sessions scope.
|
||||
[ -n "${SYLPH_OWN_LOGIN:-}" ] && _out+=(-e "SYLPH_OWN_LOGIN=$SYLPH_OWN_LOGIN")
|
||||
|
||||
local claudetok="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
|
||||
if [ -f "$claudetok" ]; then
|
||||
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$claudetok")")
|
||||
echo "==> auth: long-lived token from $claudetok (no rotating credential file)" >&2
|
||||
fi
|
||||
|
||||
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
||||
if [ -f "$gitcred" ]; then
|
||||
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro")
|
||||
@@ -135,77 +107,7 @@ docker_args() {
|
||||
echo " so its work dies with the container." >&2
|
||||
fi
|
||||
|
||||
# ── Gitea ──
|
||||
# This agent's OWN token, for its OWN Gitea account — not the push credential
|
||||
# and not the human's. Three reasons it is separate: `~/.sylph-git-credentials`
|
||||
# is scoped `write:repository` and every issue endpoint REFUSES it; a pull
|
||||
# request the agent authored is one a human can approve, which is the entire
|
||||
# review gate; and revoking one agent then touches neither the other nor you.
|
||||
#
|
||||
# Mounted read-only and passed to the MCP server BY PATH — see the entrypoint
|
||||
# for why the value must not go through the environment.
|
||||
# Inert until the file exists: the container still runs, with no issues.
|
||||
local giteatok="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-port}"
|
||||
if [ -f "$giteatok" ]; then
|
||||
_out+=(
|
||||
-v "$giteatok:/sylph-home/port/.sylph-gitea-token:ro"
|
||||
-e "GITEA_TOKEN_FILE=/sylph-home/port/.sylph-gitea-token"
|
||||
)
|
||||
else
|
||||
echo "==> NOTE: no Gitea token at $giteatok — this agent cannot read its" >&2
|
||||
echo " notifications, open an issue or open a pull request. Generate one" >&2
|
||||
echo " while logged in AS sylph-port: Settings -> Applications, scopes" >&2
|
||||
echo " write:repository, write:issue, write:notification, read:user." >&2
|
||||
fi
|
||||
|
||||
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
|
||||
|
||||
# ── GPU ──
|
||||
# This block did not exist until 2026-09-01, and its absence was reported as
|
||||
# a symptom rather than a cause: **the port agent reported low FPS.** Godot 4
|
||||
# renders through Vulkan, and with nothing passed through it was falling back
|
||||
# to lavapipe — software Vulkan, which is correct and slow. The decoder's
|
||||
# launcher had this logic; this one never did, so the container that actually
|
||||
# runs a renderer was the one without a GPU.
|
||||
#
|
||||
# Three distinct cases, and conflating them is how you end up believing you
|
||||
# have hardware Vulkan while running llvmpipe:
|
||||
#
|
||||
# NVIDIA needs the NVIDIA Container Toolkit (`--gpus all`). Passing
|
||||
# /dev/dri alone does NOT work — Mesa cannot drive an NVIDIA card,
|
||||
# and the proprietary userspace lives outside the image.
|
||||
# Mesa (AMD/Intel) works with a plain /dev/dri passthrough plus the
|
||||
# host's render/video GIDs.
|
||||
# neither software Vulkan (lavapipe): correct, and slow.
|
||||
if [ "${SYLPH_VULKAN:-auto}" = "sw" ]; then
|
||||
_out+=(-e SYLPH_VULKAN=sw)
|
||||
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
|
||||
if docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q nvidia; then
|
||||
_out+=(--gpus all)
|
||||
else
|
||||
echo "==> NOTE: NVIDIA GPU found but the NVIDIA Container Toolkit is not" >&2
|
||||
echo " installed, so Godot falls back to lavapipe (software — correct," >&2
|
||||
echo " slow, and the reason for any low-FPS report). To enable it:" >&2
|
||||
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \\" >&2
|
||||
echo " | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg" >&2
|
||||
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \\" >&2
|
||||
echo " | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \\" >&2
|
||||
echo " | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list" >&2
|
||||
echo " sudo apt update && sudo apt install -y nvidia-container-toolkit" >&2
|
||||
echo " sudo nvidia-ctk runtime configure --runtime=docker" >&2
|
||||
echo " sudo systemctl restart docker" >&2
|
||||
_out+=(-e SYLPH_VULKAN=sw)
|
||||
fi
|
||||
elif [ -e /dev/dri/renderD128 ]; then
|
||||
_out+=(--device /dev/dri)
|
||||
for g in render video; do
|
||||
gid=$(getent group "$g" | cut -d: -f3 || true)
|
||||
[ -n "$gid" ] && _out+=(--group-add "$gid")
|
||||
done
|
||||
else
|
||||
_out+=(-e SYLPH_VULKAN=sw)
|
||||
fi
|
||||
|
||||
printf '%s\n' "${_out[@]}"
|
||||
}
|
||||
|
||||
@@ -238,12 +140,7 @@ case "${1:-}" in
|
||||
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
|
||||
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}"
|
||||
echo "==> repo: own clone in volume sylpheed-port-repo -> /work"
|
||||
# `unless-stopped`, NOT `on-failure`: an OOM kill on this setup reports
|
||||
# `OOMKilled: true` with **ExitCode 0**, so `on-failure` would read a memory
|
||||
# kill as a clean finish and leave the agent down. Restarting into the same
|
||||
# death is handled in the entrypoint, which refuses to `--continue` when the
|
||||
# last start was under two minutes ago.
|
||||
docker run -d -i -t --restart unless-stopped "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
|
||||
docker run -d -i -t "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
|
||||
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
|
||||
echo
|
||||
echo " running detached as '$NAME'."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user