diff --git a/Cargo.lock b/Cargo.lock index fbec3414..aa808c11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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-08-29c)", + "sylpheed-formats 0.1.0 (git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-08-29d)", ] [[package]] @@ -4651,7 +4651,7 @@ dependencies = [ [[package]] name = "sylpheed-formats" version = "0.1.0" -source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-08-29c#b28dfedf3548200cc7269b762a422e4c2adeaadd" +source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-08-29d#e2640338e149acc903fac551f400a5319950afb5" dependencies = [ "anyhow", "binrw", diff --git a/authored/timing.json b/authored/timing.json index 61851d16..ad745426 100644 --- a/authored/timing.json +++ b/authored/timing.json @@ -24,28 +24,6 @@ "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.", - "", - "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.", - "", - "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", @@ -112,5 +90,30 @@ "in the same frame, or a loop flag decoded on the disc." ] } - } + }, + "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." + ] } diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index f5c7c9ee..d0951923 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -75,7 +75,13 @@ license.workspace = true # 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" } +# 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-08-29d" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/sylpheed-export/src/check.rs b/crates/sylpheed-export/src/check.rs index c416024b..82f71b1c 100644 --- a/crates/sylpheed-export/src/check.rs +++ b/crates/sylpheed-export/src/check.rs @@ -186,10 +186,26 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec) -> Result<()> for (k, kf) in kfs.iter().enumerate() { check_pose(&mut c, &format!("{at} keyframe {k}"), kf); } - // The last keyframe of a group carries no time slot on the disc, and - // an invented one is exactly the kind of value this format refuses. - if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) { - c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there")); + // ๐Ÿ”ด INVERTED 2026-08-29, and the old rule is the more interesting + // half. It read: "the last keyframe of a group carries no time slot + // on the disc, and an invented one is exactly the kind of value this + // format refuses." That was true of the OLD keyframe association, + // where a group's data stopped four bytes short of its final block's + // time slot. + // + // Under the corrected layout (`formats-pin-2026-08-29c` onward) a + // group is an 8-byte header then `frames` x {u32 time; 36-byte + // pose}, so **pose 0's time is the group's lead-in word and EVERY + // POSE IS TIMED, including the last.** The rule now says the + // opposite, and an untimed keyframe is the thing to refuse. + // + // โš ๏ธ This fired 150 times on a re-export and I had not run `check` + // between pinning the tag and measuring against the oracle -- the + // pixel harness was green while the format validator was failing on + // every screen with a multi-keyframe group. A correctness harness + // does not replace a format one; they fail at different layers. + if kfs.len() > 1 && kfs.iter().any(|k| k.get("t").is_none()) { + c.err(format!("{at}: a keyframe has no `t`; every pose is timed under the corrected record layout")); } } } diff --git a/docs/port/FORMAT.md b/docs/port/FORMAT.md index 859a3762..0bf69f26 100644 --- a/docs/port/FORMAT.md +++ b/docs/port/FORMAT.md @@ -212,10 +212,25 @@ from: the quad is `pivot ร— 2`, and its colour is the keyframe's `fade_argb`. **`keyframes`** carry the on-disc time verbatim in `t`. A keyframe is the **start of a ramp toward the next**, not a pose that is held, and the ramp is -linear. The **last keyframe of a group has no `t`** โ€” the disc has no time slot -there โ€” and a file that puts one on it is wrong, not merely odd. The unit of `t` -is measured, not on the disc, and so lives in `authored/` and is applied in -exactly one place. +linear. + +๐Ÿ”ด **Every keyframe has a `t`, including the last**, and this paragraph said the +exact opposite until 2026-08-29. A placement group is an 8-byte header followed +by `frames` ร— `{u32 time; 36-byte pose}`, so **pose 0's time is the group's +lead-in word** and no pose is untimed. The old reading โ€” that a group's data +stopped four bytes short of its final block's time slot โ€” paired every pose with +the *next* pose's time, and `sylpheed-export check` enforced it as a rule. A file +with an untimed keyframe is now the wrong one. + +โš ๏ธ Two things went with that correction. The **exit ramp is gone**: there is no +untimed final keyframe to give a synthetic time to, so `authored/timing.json`'s +`exit_ramp_units` โ€” an authored *measured* constant since P3 โ€” is **deleted**, +which is what MISSION ยง3 means by a deletion being the measure of progress. And +`rest.t` moved on several screens: `publisher_logo` settles at t=30 rather than +t=235. + +The unit of `t` is still measured rather than on the disc, so it stays in +`authored/` and is applied in exactly one place. **`rotation_deg`** is screen-plane rotation in degrees, clockwise-positive, decoded from the keyframe's `+12`. **The game renders it**, confirmed twice by diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 0d322880..4a6a3e0b 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -117,6 +117,13 @@ func _ready() -> void: if args.has("boot"): _capture_to = args.get("capture", "") _film = args.get("film", "") + # `--film-interval=` in seconds. Configurable because the fixed 0.25 s could + # not resolve the boot's own black hold: the transition's pure-black plateau + # is MEASURED at 0.17-0.23 s (authored/timing.json, HANDOFF Q7), which is + # shorter than the cadence that was meant to observe it. `verify-dwell` duly + # reported two screens as one 93 s span and called it a regression, when the + # black frame had simply fallen between samples. + _film_interval = maxf(0.01, float(args.get("film-interval", "0.25"))) _shots = args.get("shots", "") if args.has("script"): _script = args["script"].split(",", false) @@ -177,7 +184,12 @@ func _ready() -> void: view.units_per_second = float(timing["keyframe_units_per_second"]) # The one unknown duration per screen: the ramp into the final untimed # keyframe. Authored, because the disc has no time slot there. - view.exit_ramp_units = float(timing["exit_ramp_units"]) + # `exit_ramp_units` is DELETED from authored/timing.json -- under the corrected + # record layout every pose is timed, so there is no untimed final keyframe to + # give a synthetic time to. The default below is now unreachable rather than + # authored, and both of ScreenView's uses are dead branches kept only so an + # older export still loads. + view.exit_ramp_units = float(timing.get("exit_ramp_units", 24.0)) # Which focus records draw unconditionally and loop. Kept out of ScreenView's # own logic on purpose -- see `looping_focus` there for the census that says # this cannot be a rule. @@ -278,6 +290,8 @@ var _step := 0 var _film := "" var _film_frame := 0 var _film_next := 0.0 +## Seconds between `--film` frames. See `--film-interval`. +var _film_interval := 0.25 var _elapsed := 0.0 var _boot_done := false @@ -652,7 +666,7 @@ func _film_capture() -> void: var img := viewport.get_texture().get_image() img.save_png("%s_%03d.png" % [_film, _film_frame]) _film_frame += 1 - _film_next += 0.25 + _film_next += _film_interval # Godot passes everything after `--` through untouched; take `--key=value`. diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index bb47fe96..0e62e050 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -404,7 +404,9 @@ static func _rot_of(pose: Dictionary) -> float: ## โ” Every observation behind this has parent alpha 0, so "the leaf wins" and ## "the parent is ignored because it draws nothing" are NOT separated. A capture ## during t=100...238 would separate them. -func _draw_leaf(element: Dictionary) -> void: +## Returns whether anything was actually drawn, so the caller can fall back. +func _draw_leaf(element: Dictionary) -> bool: + var any_drawn := false for fe: Dictionary in element.get("leaf", {}).get("elements", []): var rel: String = fe.get("sprite", "") if rel == "": @@ -422,10 +424,27 @@ func _draw_leaf(element: Dictionary) -> void: holding = false var pose := pose_at(fe, time_units) holding = was + # ๐Ÿ”ด A SCALE-0 LEAF MUST NOT CLAIM THE DRAW. The Decoder hit this in its own + # renderer: its leaf branch marked the element drawn unconditionally, but + # the blit returns early on zero scale, so a scale-0 leaf suppressed its + # parent and BLANKED the element -- live on all four loading screens via + # `pgloading_loop5`, whose leaf is scale (0, 0). + # + # โš ๏ธ This port did not have the bug only because `authored/rendering.json` + # happens not to list `pgloading_loop5`. That is an accident of a gate + # written for a different reason, not a defence, so the guard is here: a + # leaf that would draw nothing reports so, and `_draw` falls back to the + # parent rather than losing the element. + var scale: Array = pose.get("scale", [100, 100]) + if int(scale[0]) == 0 or int(scale[1]) == 0: + skipped.append("%s (leaf scale 0 -- parent drawn instead)" % fe.get("id", "")) + continue var pivot := _vec(fe.get("pivot", [0, 0])) _draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose), pivot, _vec(pose.get("pos", [0, 0])), _rot_of(pose)) drawn.append(fe.get("id", "")) + any_drawn = true + return any_drawn func _draw_focus(element: Dictionary) -> void: @@ -508,8 +527,8 @@ func _draw() -> void: # itself: the parent is a container whose own record has identity scale # and rotation. See `_draw_leaf`. if element.get("leaf_carries_geometry", false) \ - and draw_leaf_for.has(String(element.get("id", ""))): - _draw_leaf(element) + and draw_leaf_for.has(String(element.get("id", ""))) \ + and _draw_leaf(element): continue # A focused button draws its own record instead of its base sprite -- and # so does an element the authored table says always shows it, which is diff --git a/tools/port/verify-dwell b/tools/port/verify-dwell index 7f36ee67..6f74db2e 100755 --- a/tools/port/verify-dwell +++ b/tools/port/verify-dwell @@ -1,91 +1,105 @@ #!/usr/bin/env bash -# Measure the port's own VISIBLE SPANS and put them beside the oracle's dwells. +# Check the port's boot pacing against captures of the real game. # # tools/port/verify-dwell # -# WHY THIS IS A TOOL AND NOT A ONE-OFF. Doing this by hand once already refuted a -# ๐Ÿ”ด I had filed myself. `docs/port/BLOCKED.md` said `rest.t` was the wrong -# settle landmark AND that "everything the sequencer paces off it is therefore -# late". The first half is true; the second was wrong, and I nearly went and -# "fixed" screens that already matched the game to 0.05 s. +# WHY THIS IS A TOOL AND NOT A ONE-OFF. Doing it by hand once already refuted a +# ๐Ÿ”ด I had filed myself: `docs/port/BLOCKED.md` said `rest.t` was the wrong settle +# landmark AND that "everything the sequencer paces off it is therefore late". +# The first half is true; the second was wrong, and I nearly re-paced screens +# that already matched the game to 0.05 s. # -# โš ๏ธ THE TRAP THAT CAUSED IT, AND THE REASON THIS SCRIPT EXISTS AT ALL: -# a port's TRANSITION TIMESTAMPS and the oracle's VISIBLE SPANS are not the same -# quantity. They differ by the exit ramp plus the black hold -- about 0.6 s here, -# which was the entire discrepancy. This corpus has been bitten by the identical -# confusion before, on the plate delay: "timed from where build 4 stops -# animating, not from where it first appears -- measured the other way the two -# runs differ by 0.48 s against 6 ms." So this measures the port the way the -# oracle was measured: first ink to last ink. +# โš ๏ธ A PORT'S TRANSITION INTERVAL IS NOT THE ORACLE'S VISIBLE SPAN. They differ +# by the black hold between screens, and confusing the two cost this corpus 0.6 s +# once and 0.48 s on the plate delay. So the comparison here is explicit: the +# port's interval is checked against the oracle's span PLUS the measured hold. # -# THE EXPECTED NUMBERS BELOW ARE THE ORACLE'S, NOT THE PORT'S. They are three -# cold boots from `docs/re/boot-order-and-splash-dwell.md`, first-visible to -# last-visible, and they are quoted here as a test fixture. Nothing in the port -# derives them and nothing may. +# ๐Ÿ”ด AND THE VERDICT DOES NOT COME FROM THE FILMSTRIP ANY MORE. It used to +# measure ink spans from `--film` frames. The boot's black hold is 0.17-0.23 s +# (HANDOFF Q7) -- shorter than the 0.25 s cadence meant to observe it -- so when +# the black frame fell between samples two screens merged into one span and this +# tool reported `developer logos` as 93 s against an oracle of 3.5 s. Filming at +# 0.1 s made it WORSE: 2.5x the screenshots slows the run enough that the capture +# catches up in bursts, and the publisher span came back as 7.80 s. +# +# The sequencer already knows exactly when it changed screens and prints it. +# Sampling a picture to rediscover a number the program can state is how this +# went wrong. The filmstrip is kept, and marked advisory. +# +# THE EXPECTED NUMBERS ARE THE ORACLE'S, NOT THE PORT'S: three cold boots from +# `docs/re/boot-order-and-splash-dwell.md`, quoted as a test fixture. Nothing in +# the port derives them and nothing may. set -euo pipefail cd "${PROJECT_DIR:-/work}" export DISPLAY="${DISPLAY:-:97}" OUT="${OUT:-$(mktemp -d)}" mkdir -p "$OUT" +INTERVAL="${INTERVAL:-0.25}" echo "running the boot (the intro is skipped -- the splashes are what this measures)" -timeout "${TIMEOUT:-200}" godot --path port --resolution 1280x720 -- \ - --boot --skip-at=1 "--film=$OUT/f" >"$OUT/boot.log" 2>&1 || true +timeout "${TIMEOUT:-300}" godot --path port --resolution 1280x720 -- \ + --boot --skip-at=1 "--film-interval=$INTERVAL" "--film=$OUT/f" \ + >"$OUT/boot.log" 2>&1 || true -python3 - "$OUT" <<'PYEOF' -import glob, os, subprocess, sys +INTERVAL="$INTERVAL" python3 - "$OUT" <<'PYEOF' +import glob, os, re, subprocess, sys out = sys.argv[1] -frames = sorted(glob.glob(os.path.join(out, "f_*.png"))) -if not frames: - print("no frames -- is there a display? see", os.path.join(out, "boot.log")); raise SystemExit(2) -INTERVAL = 0.25 # boot.gd's --film cadence -means = [] -for f in frames: - m = subprocess.run(["convert", f, "-colorspace", "Gray", "-format", - "%[fx:mean*255]", "info:"], capture_output=True, text=True) - means.append(float(m.stdout.strip() or 0)) +INTERVAL = float(os.environ.get("INTERVAL", "0.25")) +HOLD_LO, HOLD_HI = 0.17, 0.23 # HANDOFF Q7, measured -# A frame is "ink" if anything at all is drawn. The splashes are dim -- surface -# means of 5/255 -- so the bar is ">0", not a percentage of full scale. -ink = [m > 0.0 for m in means] -spans, i = [], 0 -while i < len(ink): - if ink[i]: - j = i - while j < len(ink) and ink[j]: j += 1 - spans.append((i * INTERVAL, j * INTERVAL)) - i = j - else: - i += 1 +marks = [] +for line in open(os.path.join(out, "boot.log"), errors="replace"): + m = re.match(r"\s+-> (\S+) at ([0-9.]+) s", line) + if m: + marks.append((m.group(1), float(m.group(2)))) +if not marks: + print("no transitions in the boot log -- see", os.path.join(out, "boot.log")) + raise SystemExit(2) -# The oracle: three cold boots, first-visible to last-visible. -# docs/re/boot-order-and-splash-dwell.md. NOT the port's numbers. ORACLE = [ ("publisher wordmark", [4.297, 4.604, 4.370]), ("developer logos", [3.508, 3.503, 3.366]), ] +starts = [0.0] + [t for _, t in marks] print() -print("%-20s %-12s %-26s %s" % ("screen", "port", "oracle (3 cold boots)", "verdict")) +print("%-20s %-14s %-26s %s" % ("screen", "port interval", "oracle span (3 boots)", "verdict")) +bad = 0 for k, (name, runs) in enumerate(ORACLE): - if k >= len(spans): - print("%-20s %-12s %-26s no such span in this run" % (name, "-", "")) - continue - a, b = spans[k] - d = b - a - lo, hi = min(runs), max(runs) - # Inside the oracle's own run-to-run spread, or within one film interval of - # it. The spread is the honest bar: three boots of the real game differ by - # 0.3 s, so agreeing more tightly than that would not mean anything. - ok = (lo - INTERVAL) <= d <= (hi + INTERVAL) - print("%-20s %-12s %-26s %s" + if k + 1 >= len(starts): + print("%-20s %-14s %s" % (name, "-", "no such transition this run")); continue + d = starts[k + 1] - starts[k] + lo, hi = min(runs) + HOLD_LO, max(runs) + HOLD_HI + ok = lo - 0.15 <= d <= hi + 0.15 + bad += 0 if ok else 1 + print("%-20s %-14s %-26s %s" % (name, "%.2f s" % d, "%.3f / %.3f / %.3f" % tuple(runs), "agrees" if ok else "DIFFERS")) print() -print("port spans (first ink -> last ink), +/- %.2f s from the film cadence:" % INTERVAL) -for a, b in spans[:6]: - print(" %6.2f - %6.2f s (%.2f s)" % (a, b, b - a)) -print() -print("โš ๏ธ Read this beside the transition timestamps in the boot log, never") -print(" instead of them -- they differ by the exit ramp plus the black hold.") +print(" An interval is the oracle's SPAN plus the black hold (%.2f-%.2f s)," % (HOLD_LO, HOLD_HI)) +print(" compared against span+hold with 0.15 s of slack for wall-clock jitter.") +print(" transitions:", ", ".join("%s@%.2f" % m for m in marks[:4])) + +frames = sorted(glob.glob(os.path.join(out, "f_*.png")))[:120] +if frames: + means = [float(subprocess.run(["convert", f, "-colorspace", "Gray", "-format", + "%[fx:mean*255]", "info:"], capture_output=True, text=True).stdout or 0) + for f in frames] + ink = [m > 0.0 for m in means] + spans, i = [], 0 + while i < len(ink): + if ink[i]: + j = i + while j < len(ink) and ink[j]: j += 1 + spans.append((i * INTERVAL, j * INTERVAL)); i = j + else: + i += 1 + print() + print(" advisory -- filmstrip ink spans at %.2f s, which CANNOT resolve a" % INTERVAL) + print(" %.2f-%.2f s hold and merges screens whenever it misses one:" % (HOLD_LO, HOLD_HI)) + for a, b in spans[:4]: + print(" %6.2f - %6.2f s (%.2f s)" % (a, b, b - a)) +raise SystemExit(1 if bad else 0) PYEOF +rc=$? echo "artifacts in $OUT" +exit $rc