diff --git a/Cargo.lock b/Cargo.lock index c3d303d5..fbec3414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4611,7 +4611,7 @@ dependencies = [ "colored", "image", "indicatif", - "sylpheed-formats", + "sylpheed-formats 0.1.0", "texpresso", "tokio", "tracing", @@ -4627,7 +4627,7 @@ dependencies = [ "image", "serde", "serde_json", - "sylpheed-formats", + "sylpheed-formats 0.1.0 (git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-08-29c)", ] [[package]] @@ -4648,6 +4648,25 @@ dependencies = [ "xdvdfs", ] +[[package]] +name = "sylpheed-formats" +version = "0.1.0" +source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-08-29c#b28dfedf3548200cc7269b762a422e4c2adeaadd" +dependencies = [ + "anyhow", + "binrw", + "flate2", + "futures", + "rayon", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "ttf-parser 0.24.1", + "xdvdfs", +] + [[package]] name = "sylpheed-viewer" version = "0.1.0" @@ -4659,7 +4678,7 @@ dependencies = [ "image", "rfd", "rodio", - "sylpheed-formats", + "sylpheed-formats 0.1.0", "thiserror 2.0.18", "tracing", "tracing-subscriber", diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index a8da7e34..f5c7c9ee 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -55,7 +55,27 @@ license.workspace = true # a squash-merge can orphan, and no way for the exporter to be built against a # decoder it was never tested with. A decoder change and the exporter change it # requires now land in the same commit or not at all. -sylpheed-formats = { path = "../sylpheed-formats" } +# PINNED BY TAG, which is what MISSION section 2 prescribes and what the tagging +# rule exists for: "the RE agent tags when it lands something you need and tells +# you over the message channel -- that is how you stay current without floating." +# That is exactly what happened here. +# +# The tag carries the CORRECTED keyframe association: a placement group is an +# 8-byte header then `frames` x {u32 time; 36-byte pose}, so pose 0's time is the +# group's lead-in word and EVERY POSE IS TIMED, including the last. The working +# tree's copy still has the retired `SYLPHEED_KF_TIME_SHIFT` knob -- a superseded +# partial fix that got the association right but left pose 0 untimed, which is +# why testing it moved the untimed frame from last to first instead of removing +# it. The old reading is behind `SYLPHEED_KF_TIME_LEGACY=1` here. +# +# ๐Ÿ”ด THE COST, STATED: `sylpheed-cli` builds from the WORKSPACE crate, so until +# this lands on `main` the exporter and the reference renderer read DIFFERENT +# decoders and `tools/port/verify-screen` is comparing two eras rather than +# detecting drift. `tools/port/verify-capture` is unaffected -- it compares the +# port against oracle CAPTURES and never touches the CLI -- and it is the check +# that matters. Revert to the path dependency the day the tag is an ancestor of +# `main`. +sylpheed-formats = { git = "https://git.mc02.dev/fabi/Sylpheed.git", tag = "formats-pin-2026-08-29c" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 666a502b..e1b4f55a 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -128,7 +128,7 @@ HANDOFF. | ~~P6 โ€” runtime headroom~~ | ~~the Master bus clips~~ | โ€” | ๐ŸŸข **withdrawn by the port, 2026-08-29 โ€” it was my own overstatement.** Filed ๐Ÿ”ด twice on a peak reading of 0.0 dBFS. Measured properly: **43 samples at full scale in 5.9 s and 24 in 98.5 s, longest clamped run 0.25 ms** โ€” the disc's own `confirm` cue touching the ceiling on a transient, possibly only in the recording's 16-bit conversion since Godot mixes in float. Not a defect, and nothing is changed: attenuating to buy headroom would be an unmeasured level decision of exactly the kind this port refused for the BGM loop point. **A peak reading is not a clipping measurement** โ€” one sample at 0 dBFS and two seconds of square wave give the same number. | | Modding โ€” rule 4 | ~~base-and-overrides is unimplemented~~ | โ€” | โœ… **implemented 2026-08-29, and it was not blocked on anybody.** `MODDING.md` calls it a constraint on the exporter *today*; nothing read `data/mods/` for eight milestones. `ExportTree.resolve` now shadows by path for every asset kind, each replacement is logged as it is read, and `.gitignore` excludes the directory's contents โ€” a mod is usually an edited game asset, and that directory was the one place git would have taken one. โš ๏ธ The `export/` vs `data/base/` naming split between `PORT-MISSION.md` ยง3 and `MODDING.md` is **raised, not resolved**: only the human changes a mission. | -| P1โ€“P7 โ€” the keyframe record layout | **adopt the corrected pose/time pairing** | โ€” | ๐ŸŸก **AVAILABLE TODAY โ€” this row's "cannot be taken yet" was WRONG.** It said the change reaches the port only when the Decoder's branch lands on `main`. It does not: `ui_layout.rs` in this checkout carries the corrected association behind **`SYLPHEED_KF_TIME_SHIFT=1`**, and has all along. I read that file twice in one session without noticing the switch. **Measured** by re-exporting under it and asking the oracle: `publisher_logo` 1.00 % โ†’ **0.75 %**, `developer_logos` 0.39 % โ†’ **0.33 %**, `title` RMSE 21.07 โ†’ **20.41** โ€” but `main_menu` 0.06 % โ†’ 0.10 % and `extras` 0.20 % โ†’ 0.24 % **worse**. โš ๏ธ **Not decisive, and the reason matters**: under the shift the untimed keyframe moves from **last to first**, while `pose_at` is written around *"the final keyframe carries no `t`"*. So the numbers measure a renderer/association mismatch, not the association. Adopting it means re-deriving `exit_ramp_units`, `settle_units`, `spin_period_units` and the plate against a layout where the untimed frame is first โ€” real work, not a flag flip. Export left on the default. | +| ~~P1โ€“P7 โ€” the keyframe record layout~~ | ~~adopt the corrected pose/time pairing~~ | โ€” | โœ… **ADOPTED 2026-08-29 by pinning `formats-pin-2026-08-29c`.** This row was wrong twice: it said the change *"cannot be taken yet"* and that it *"reaches the port only when that branch lands on `main`"*. **It arrives when the tag is pinned**, which is what MISSION ยง2's tagging rule exists for. โš ๏ธ And the knob I tested first, `SYLPHEED_KF_TIME_SHIFT`, is a **retired partial fix** that left pose 0 untimed โ€” the real correction is the tagged crate's default, with the old reading behind `SYLPHEED_KF_TIME_LEGACY=1`. **The blast radius was far smaller than this row predicted**: under the correction *every pose is timed* (866 keyframes, 0 untimed), so `pose_at`'s synthetic-exit branch became dead code rather than wrong code and nothing needed re-deriving. Oracle: `publisher_logo` 1.00 %โ†’**0.75 %**, `developer_logos` 0.39 %โ†’**0.33 %**, `extras`' differing region collapsing from 736ร—525 to **398ร—295 at the sweep position**. ๐Ÿ”ด Open cost: `sylpheed-cli` builds from the workspace crate, so `verify-screen` compares two decoder eras until the tag reaches `main`. Revert to the path dependency then. | | ~~P7 / naming โ€” the four unnamed builds~~ | ~~which locale and variant is each of entries 0, 1, 12, 15?~~ | โ€” | โœ… **answered 2026-08-29** (`docs/re/ui-title-build-map.md`): all four are the loading screen, two variants โ€” plain (7 elements) and dressed (10) โ€” decoded from their own `pgloading_*` element names. โš ๏ธ **Not adopted as names yet, for two reasons the RE agent gave and one the port found.** Theirs: the executable names exactly two, and *which* bundle takes which name is ๐ŸŸก undecided, so `LOADING`/`LOADING2` must not go in an asset path; and locale is ๐ŸŸก โ€” the English member of a pair is the one in the first half of `GP_TITLE.p00`, 8/8 structurally but only 3/3 where a capture can check, and the three pairs that matter are the three no capture can check. Mine: **the message gives the bundles as "0/1 and 10/11", which is the `is_build` ordinal, and `authored/screen_names.json` is keyed by PAK ENTRY** โ€” in entry space 10 and 11 are `palogo_sqex` and `palogo_gamearts`, the splashes. See the refutation section in `DECISIONS.md`. | ## Answered since this file was last written โ€” no longer blocking diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 0372315e..39f96a94 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -4170,3 +4170,90 @@ same run with one input changed. cues reach the Master bus at the right moments; it does not show they are the cues the game plays โ€” that is HANDOFF Q8, and `authored/audio.json` still carries the offsets as measured rather than decoded. + +## Pinned `formats-pin-2026-08-29c` โ€” and the knob I tested last iteration was retired + +๐Ÿ”ด **I tested the wrong switch.** `SYLPHEED_KF_TIME_SHIFT` is a **superseded +partial fix**: it got the association right but **left pose 0 untimed**, which is +exactly why the untimed keyframe appeared to "move from last to first". It does +not exist in the current parser. The real correction is the **default** in the +tagged crate, with the old reading behind `SYLPHEED_KF_TIME_LEGACY=1` โ€” the +opposite polarity from what I had. + +So last iteration's five rows measured a renderer/association mismatch **against +a knob nobody should use**. I suspected they were not decisive; I did not suspect +the knob itself was retired. + +### The consequence is smaller than I budgeted for: there is no untimed keyframe + +A placement group is an 8-byte header then `frames` ร— `{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.** Measured on the re-export: **866 keyframes, 0 untimed.** + +`pose_at`'s premise โ€” *"the final keyframe carries no `t`, so it is given a +synthetic time `exit_ramp_units` after the last timed frame"* โ€” does not invert, +it **disappears**. The branch is now dead code rather than wrong code, which is +why nothing needed re-deriving to adopt this. + +And the leaf reads as the Decoder's table says: t=0 x=โˆ’639, t=150 x=โˆ’39, t=540 +x=1521. At t=355 that interpolates to **x = 781** โ€” the top-left it predicted, and +the 1 300 px discrepancy is gone. + +### Pinned by tag, which is what MISSION ยง2 is 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 precisely what happened, so `crates/sylpheed-export/Cargo.toml` now pins +`formats-pin-2026-08-29c` by tag. โš ๏ธ **`BLOCKED.md` was wrong in both +directions** โ€” it said the change "cannot be taken yet" *and* that it arrives +only when the branch lands on `main`. It arrives when the tag is pinned. + +๐Ÿ”ด **The cost, stated rather than discovered later:** `sylpheed-cli` builds from +the **workspace** crate, so until this reaches `main` the exporter and the +reference renderer read **different decoders**, and `verify-screen` is comparing +two eras rather than detecting drift. `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`. + +### What the oracle says + +| screen | before | after | +|---|---|---| +| `publisher_logo` | 1.00 % | **0.75 %** | +| `developer_logos` | 0.39 % | **0.33 %** | +| `extras` | 0.20 %, region **736ร—525** | 0.19 %, region **398ร—295 at (441,230)** | +| `main_menu` | 0.06 % | 0.06 % | +| `title` | 1.82 % | 1.82 % | + +The splashes improve outright. โš ๏ธ **`extras` is the interesting row**: the *area* +barely moved but its differing region **collapsed onto the sweep position** +(441, 270) โ€” the residual is now localised to the one element still in question +rather than spread over the screen. + +The title does not move. Its row is now posed at **t=355**, the Decoder's fitted +sweep time, because the leaf group ends at t=600 with the quads parked off-screen +at x=1521 โ€” posing at the settle simply omits them. โš ๏ธ **t=355 is not the time +that minimises the difference**: t=390 measures **1.65 %**. Picking that would be +fitting the pose to the score, which is what this harness exists not to do. + +## Refuted โ€” my own "the single non-whole-multiple scale in the export" + +`DECISIONS` has said since P1 that `ptlogo_eff2` is *"the single drawn element in +the whole export at a scale that is not a whole multiple of 100 % (125 %)"*. +**That census was parents-only.** Opening the 45 leaves finds **thirteen** distinct +non-whole-multiple scales โ€” 75, 96, 99, 101, 103, 112, 125, 150, 204ร—208, +210ร—220, 250 โ€” and 125 % is among the *rarest* at two occurrences. +`ptlogo1`/`ptlogo2` carry 101/103/112 on the **English** title. + +The claim's real content was *"the only one **the port draws**"* โ€” a fact about my +element set, not about the disc. Corrected. + +๐Ÿ”ด **And `ptlogo_eff2` stays withheld, now for a stronger reason than caution.** +Its 125 % is a **pop**, not a steady scale: scale-0 โ†’ 125 % โ†’ scale-0 between +t=50 and t=107, โ‰ˆ0.95 s. The leaf draws at 100 %, as **two superimposed copies** +at alpha 160 and 80, each rotating 360ยฐ over 960 units โ€” 16 s per revolution. +**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, `title_jp` has no oracle capture, +and the Japanese-locale capture MISSION has parked is what would settle it. diff --git a/tools/port/verify-capture b/tools/port/verify-capture index a1ab1596..1205ba3d 100755 --- a/tools/port/verify-capture +++ b/tools/port/verify-capture @@ -52,7 +52,18 @@ CAPS=docs/re/captures/title-builds MAP=( "main_menu:$CAPS/live-main-menu.png:menu" "extras:$CAPS/live-extras.png:menu" - "title:$CAPS/live-title-build4-no-plate.png:screen" + # โš ๏ธ THE TITLE IS POSED AT t=355 UNITS, NOT AT ITS SETTLE, and the time is + # MEASURED rather than chosen. The two `ptloop` sweeps are a continuous + # animation whose leaf group ends at t=600 with the quads parked off-screen at + # x=1521, so posing at the settle compares a still frame against a capture + # taken mid-sweep and simply omits them. The Decoder fitted t=355 from the + # game's own composed alpha -- per-draw vertex colours 195 and 182 against the + # two leaf ramps -- and the same t then predicted the quad centres to ~11 px. + # + # โš ๏ธ It is NOT the time that minimises the difference: t=390 measures 1.65 % + # against 1.82 % here. Picking that one would be fitting the pose to the + # score, which is the thing this harness exists not to do. + "title:$CAPS/live-title-build4-no-plate.png:t355" "publisher_logo:$CAPS/live-splash-publisher.png:screen" "developer_logos:$CAPS/live-splash-developer.png:screen" ) @@ -72,6 +83,9 @@ for row in "${MAP[@]}"; do godot --path port --resolution 1280x720 -- "--menu=$name" --script=wait \ "--shots=$OUT/$name" >"$OUT/$name.log" 2>&1 || true [ -f "$OUT/${name}_00_start.png" ] && cp "$OUT/${name}_00_start.png" "$OUT/$name.render.png" + elif [ "$pose" = t355 ]; then + godot --path port --resolution 1280x720 -- "--screen=$name" --time=5.9167 \ + "--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true else godot --path port --resolution 1280x720 -- "--screen=$name" --time=99 \ "--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true @@ -84,7 +98,7 @@ for row in "${MAP[@]}"; do note="" case "$name" in main_menu|extras) note="rendered with authored initial focus" ;; - title) note="ptloop sweeps never stop -- one moment vs another" ;; + title) note="posed at t=355, the Decoder's fitted sweep time" ;; esac # Where the difference lives. This comes FIRST because it is what the gamma # sweep has to be protected from.