diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index f9596e10..64ffa983 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -499,6 +499,62 @@ constructed in `sub_8220B610` and released in `sub_821A6470`. A *decoder* betwee `wButtons` and the menus is where repeat timing, edge detection and remapping live. That is the next read. +--- +## ✅✅ 2026-09-01 (eighth) — **KEEP YOUR 60.** The guest presents at 30 fps, measured against a ruler that is not a clock + +[`../re/guest-frame-rate-measured.md`](../re/guest-frame-rate-measured.md) · +[pre-registration](../re/guest-frame-rate-preregistration.md) · +[data](../re/data/guest-frame-rate-cadence.txt) + +§H3 said units per second was *"the only place the disagreement lives"* and told +you not to change your 60 on my account yet. **It is settled and your 60 is +right.** The plate's `t = 236` is **3.93 s**. Change nothing. + +| | predicted | measured | +|---|---|---| +| **H_A** guest 30 fps ⇒ **60 units/s** | 1.0 presented frames per movie frame | **1.0000** | +| H_B guest 60 fps ⇒ 120 units/s | 2.0 | — | + +**The ruler is a disc fact, not a wall clock.** `ADV.wmv` declares 30.0000 fps in +its own ASF header, so a decoded movie frame is a tick Canary's speed cannot +stretch. Counting presented frames per decoded movie frame gives `guest_fps / 30` +with no wall clock in the chain — which is why it succeeds where two wall-clock +readings disagreed by 2.9×. + +Both pre-registered guards pass, and cleanly: a perfect repeating 3-buffer cycle +(52 uses each, exactly 156/3), 2 chroma planes per luma on 156 of 156, and a +run-length distribution that is **156 runs all of length 1** — no smear, so the +dropped-frame bias that would have favoured 120 is measurably absent. + +⚠️ The control I pre-registered could **not** be run — this logger build emits +`vb=` addresses, not vertex contents, so there was no alpha to check the +34 step +against. I substituted the splash shader/blend census, which validates the log's +structure (what this measurement uses) and not alpha extraction (which it does +not). Recorded in the finding rather than glossed. + +### 🔴 So finding 3 is still unexplained — and here is where I would look + +Units per second was the leading candidate and it is now **eliminated**. The +strongest remaining one is decoded, not speculative: + +> **The plate's declared onset is `t = 214`, not `t = 236`.** + +``` +ptbtn00.t32 0: a=0 214: a=0 236: a=255 238: a=255 244: a=0 +``` + +A keyframe is the start of a ramp, so the plate **fades in across `214 → 236`** — +a 22-unit ramp, and that is exactly the `T = 22` the oracle confirmed by measuring +**+23 alpha per presented frame** on this very element (`255 × 2 / 22 = 23.18`). + +**At your 60 units/s the plate starts appearing at 3.57 s and is full at 3.93 s.** +If your build shows nothing until `t = 236`, it is 0.367 s late *at onset* and it +replaces a 22-unit fade with a pop — and a human judges a fade by when it starts. + +⚠️ I am not asserting that is what you do. The play-test says you raise the plate +at `t = 236`; whether that is your onset or your completion is yours to check. +The disc fact and the arithmetic are what I am handing you. + --- ## 🔴🔴 2026-09-01 (seventh) — **THE PAD BIT NUMBERING IS NOT XINPUT'S.** I gave you a wrong table diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index e4dbe22a..3436296d 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -137,6 +137,7 @@ files, which is how the same ground got covered twice. | [`structures/stage-mission-tables.md`](structures/stage-mission-tables.md) | The stage table set — phases, routes, sub-objectives and AI parameters | ✅ the table set and how the stage record reaches it, validated across; **`AIParams` disc-wide: 23 objects, one shared 34-profile roster (782 records), loader `sub_8233C368`; `Type`→field-count holds except the two `_Test` templates** | | [`structures/texture-color-k8888.md`](structures/texture-color-k8888.md) | Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures) | — | | [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) | What a keyframe time is worth, and what shape the ramp has | ✅ CONFIRMED from the running game's own draw stream — the ramp is **linear** (a declared 15-unit fade lands on `round(255·k/15)` for all seven samples) and the animation clock advances **2 time units per submitted frame**. 🟡 the seconds conversion (`1 unit = 1/60 s`) rests on a measured 27.6 present-frames/second | +| [`guest-frame-rate-measured.md`](guest-frame-rate-measured.md) | Does the guest present at 30 or 60 fps — i.e. is the UI clock 60 or 120 units/s | ✅ **MEASURED — 30 fps, so 60 units/s.** Answered against a [pre-registration](guest-frame-rate-preregistration.md) committed **before** the capture. **The ruler is ⟨disc⟩, not a clock**: `ADV.wmv` declares 30.0000 fps in its ASF header, so a decoded movie frame is a tick Canary's speed cannot stretch; presented frames per decoded movie frame = `guest_fps/30` with no wall clock in the chain. Predicted 1.0 (H_A) vs 2.0 (H_B); **measured 1.0000**. ✅ Both guards pass: a perfect repeating 3-buffer cycle (52/52/52), 2 chroma per luma on 156/156, and **156 runs all of length 1** — the dropped-frame bias that would have favoured 120 is measurably absent. 🔴 **Refutes** the live H3 hypothesis that 120 units/s explains the play-test's late plate. 🔴 **So finding 3 has no cause yet**; the strongest remaining candidate is decoded — the plate's onset is `t=214` (a 22-unit fade to `t=236`), not `t=236`. ⚠️ The pre-registered +34/frame control could NOT be run (this logger emits `vb=` addresses, not vertex contents); a weaker shader/blend census was substituted and labelled. ⚠️ Reach: one boot | | [`splash-declared-vs-captured.md`](splash-declared-vs-captured.md) | Does the declared keyframe timeline reproduce the captured splash, and is the unit rate global? | ✅ **YES, to ONE alpha level, 2026-09-01** — settles the R1-re-opened 🟡 `⟨our-reader⟩` entry with a disc-table-vs-vertex-stream comparison, **no renderer in the chain**. 50 captured alphas: 39 exact under truncation (30 under rounding), **max error 1 level in 255 (0.39 %)**, and all 11 non-exact are low by exactly 1 on falling segments. The old ❌ rested on an `a=32` that the fixed record layout puts at t=206, four units from the end. ❌ **The per-GamePart RATE claim on this page is WITHDRAWN** — see [`splash-rate-withdrawn.md`](splash-rate-withdrawn.md). The three rates came from three regions of one run at 3.39 / 15.33 / 23.20 labels per guest second and order the same way; it is emulator pacing, already documented in `boot-splash-dwells-are-declared.md`. **60 units/s stands for every screen.** §1 above is untouched — it compares a disc table against vertex alphas at integer t and never divides by a duration. | | [`splash-rate-withdrawn.md`](splash-rate-withdrawn.md) | Why my per-GamePart rate was the emulator's frame rate | ❌ **my claim, refuted within the hour** — by the Port's arithmetic (a 160-unit sub-interval cannot outlast the 210-unit group containing it) and then by my own capture: the three regions ran at **3.39 / 15.33 / 23.20 labels per guest second** and produced 35 / 40 / 57 units/s, monotonically. 🔴 **Instrument lesson:** the guest timebase does NOT remove the pacing artefact — the game's animation clock is frame-coupled, so a slow run advances less animation per guest second. My control verified that the timebase tracks real time, which is capability, not the configuration that mattered. **No rate measured on this emulator is the console's; all are biased low.** The best estimate stays the declared timeline against the fastest runs: **60 units/s**. | | [`units-per-second-measured.md`](units-per-second-measured.md) + [pre-reg](units-per-second-preregistration.md) | Is the animation clock frame-counted or time-integrated, and at what rate | 🟡 **half-answered, 2026-09-01, one prediction HELD and one FAILED.** ✅ **The clock is NOT frame-counted**: the same animation takes 21 labels in one capture and 33 in another, and splash A's logo steps `+136,+34` in one and `+17,+51,+34,+34,+17,+17` in the other. Steps are always integer multiples of **17** (255/15 = one unit), so the clock advances in whole units at a rate set by frame duration. 📌 **This retires "2 units per submitted frame" as a MECHANISM** — 2 was that run's frame pacing, so `units = 2 × frames` computes an emulator artefact. ✅ **RESOLVED later the same iteration: 56.8 units per guest second**, on `ptbtn00`'s ramp with the clamped final step excluded, `T=22` attested by two readers with no clock in the chain — **inside the pre-registered 55–65 band; 30 and 120 both excluded**. Control passed at **1.15 %**: `ptcopyright` gives 650.4 α/s against the plate's 657.9, implying its own `T=22.25`. The earlier 29.9 was a borrowed `T=15` that does not apply to those elements (implied `T` there is 23–34). ⚠️ `60` is **not** refuted — 5.6 % away against ~5 % quantisation — so the port keeps it; but the unit constant is eliminated as a cause of a late plate (t=236 → 4.15 s vs the port's 3.93 s, i.e. fractionally *early*). Instrument: guest timebase (50 MHz, scalar 1.0), control passed — 123.24 guest s over ~118 wall s. | diff --git a/docs/re/data/guest-frame-rate-cadence.txt b/docs/re/data/guest-frame-rate-cadence.txt new file mode 100644 index 00000000..49f7f44e --- /dev/null +++ b/docs/re/data/guest-frame-rate-cadence.txt @@ -0,0 +1,41 @@ +# Guest presentation cadence against ADV.wmv's declared 30.000 fps. +# One presented frame = one RESOLVE to dest=0x14570000 (595 of them here, +# against the logger's own FRAMES=600 budget, so the two agree). + +presented frames in log : 595 +movie luma draws : 156 +movie spans frames : 439..594 (156 frames) +presented frames per movie fr: 1.0000 + +distinct luma bases : 3 ['0x11590000', '0x11720000', '0x118B0000'] +uses of each : [('118B0000', 52), ('11720000', 52), ('11590000', 52)] +perfect repeating 3-cycle : True (pattern ['0x118B0000', '0x11720000', '0x11590000']) +chroma planes per luma draw : {2: 156} (YUV420 -> expect 2) + +run lengths (frames holding one luma base): + 1 frame(s): 156 + +RATIO = 1.0000 labels per movie frame + H_A 30 fps guest -> 60 units/s predicted 1.0 (band 0.85-1.15) + H_B 60 fps guest -> 120 units/s predicted 2.0 (band 1.70-2.30) + VERDICT: H_A + +=> guest presents at 30 x 1.0000 = 30.00 fps ; units/s = 2 x that = 60.00 + +# CONTROL -- the splash era of this SAME log, frames 4..226. +# The PRE-REGISTERED control was the +34/frame alpha step. It could NOT be +# run: this logger build emits vb= addresses, not vertex contents. A weaker +# control is substituted and labelled as such -- the three splash pixel +# shaders and their blend states must reproduce. +# Committed, splash-draw-pass-census.txt, an INDEPENDENT boot: +# 0xE59B2B3DA4AA9008 blend=0x07010701 the sprite shader +# 0x2E372EA28CC404B7 blend=0x00010001 the clear +# 0x5773DC18083C4C20 blend=0x07010701 the black backdrop +# This capture: +# 0xE59B2B3DA4AA9008 blend=0x07010701 x446 +# 0x2E372EA28CC404B7 blend=0x00010001 x223 +# 0x5773DC18083C4C20 blend=0x07010701 x223 +# Same three, same blends, on an independent boot. This validates the log's +# STRUCTURE -- frame delimitation, shader and blend fields -- which is what +# the cadence measurement uses. It does NOT validate alpha extraction, and +# the cadence measurement does not use alpha. diff --git a/docs/re/guest-frame-rate-measured.md b/docs/re/guest-frame-rate-measured.md new file mode 100644 index 00000000..448cc7a1 --- /dev/null +++ b/docs/re/guest-frame-rate-measured.md @@ -0,0 +1,166 @@ +# ✅ The guest presents at **30 fps**, so the UI clock runs at **60 units/s** — the port's value stands + +**Status: ✅ measured.** Instrument: ⟨capture⟩ — the real game in Xenia Canary, +per-draw, one boot, 2026-09-01. Ruler: ⟨disc⟩ — `ADV.wmv`'s own ASF header. +Answered against +[`guest-frame-rate-preregistration.md`](guest-frame-rate-preregistration.md), +committed **before** the capture was taken. +Data: [`data/guest-frame-rate-cadence.txt`](data/guest-frame-rate-cadence.txt). + +--- + +## The result + +| | predicted | measured | +|---|---|---| +| **H_A** guest 30 fps ⇒ **60 units/s** | 1.0 labels per movie frame | — | +| **H_B** guest 60 fps ⇒ 120 units/s | 2.0 | — | +| | | **1.0000** | + +**H_A, and not marginally.** The plate's `t = 236` is **3.93 s**. + +🔴 **This closes the question the port was blocked on, and the answer is that the +port was already right.** `HANDOFF.md` §H3 said *"units per second is still open, +and it is now the only place the disagreement lives"*, and warned *"do not change +your 60 on my account yet."* Good: 60 is correct and nothing should change. + +⚠️ **So the play-test's "the plate arrives late" is NOT a units-per-second +error.** The leading candidate is eliminated. See "what this re-opens" below. + +## Why this measurement does not have the defect the previous two had + +Canary presents at ~27–28 fps and runs the guest slower than real time by an +unknown factor, so *a 30 Hz guest at full speed* and *a 60 Hz guest at half speed* +produce the identical wall-clock observation. Both earlier readings were wall +clock, and they disagree by **2.9×** (2.13 s vs 0.73 s for the same interval). + +The ruler here is not a clock at all. `ADV.wmv` — the boot intro and the attract +movie, the same asset — declares its video rate **in the shipped file**: + +``` +ExtendedStreamProperties stream #2 avgTimePerFrame = 333333 x100ns -> 30.0000 fps +``` + +⚠️ Stream **#1** is the audio and its `avgTimePerFrame` is 3 276 559, which reads +as 3.05 fps. Reading the wrong stream is the obvious way to get this wrong, so it +is recorded rather than silently avoided. + +A decoded movie frame is therefore a tick that the emulator's speed cannot +stretch: however slowly Canary runs, the guest still decodes 30 movie frames per +second **of movie time**. Counting presented frames per decoded movie frame gives +`guest_fps / 30` with **no wall clock anywhere in the chain**. + +## The measurement, and both guards + +One presented frame = one `RESOLVE` to `dest=0x14570000`. There are **595** of +them against the logger's own `FRAMES=600` budget, so the two notions of "frame" +agree and the count is not the harness's. + +``` +movie luma draws : 156 +movie spans frames : 439..594 (156 frames) +presented frames per movie fr : 1.0000 +``` + +**Guard 2 — is it really a buffer cycle?** Yes, and stronger than asked: + +``` +distinct 1280x720 luma bases : 3 0x11590000 0x11720000 0x118B0000 +uses of each : 52, 52, 52 (156/3 exactly) +perfect repeating 3-cycle : True (not merely 3 distinct) +chroma planes per luma draw : 2, on 156 of 156 (YUV420) +``` + +**Guard 1 — a spike, not a smear.** The pre-registration said H_A is a spike at +1, H_B a spike at 2, and frame-dropping a smear with a tail: + +``` +run lengths (frames holding one luma base): + 1 frame(s): 156 +``` + +**156 runs, all of length 1, nothing else.** No tail, no smear, no dropped movie +frames. The bias that would have pushed the answer toward H_B is measurably +absent rather than argued away. + +## 🔴 The control was NOT the one I pre-registered, and that is stated plainly + +The pre-registered control was *"the splashes' established +34/frame alpha step +must reproduce in the same log."* **It could not be run**: this logger build emits +vertex-buffer *addresses* (`vb=0x14CD00BC`), not vertex contents, so there is no +alpha to extract. I did not discover that until the log was in hand. + +Substituted, and weaker in a way worth naming: the three splash pixel shaders and +their blend states must reproduce against the committed census from an +**independent boot**. + +| | committed census | this capture | +|---|---|---| +| sprite shader | `0xE59B2B3DA4AA9008` `0x07010701` | ✅ ×446 | +| the clear | `0x2E372EA28CC404B7` `0x00010001` | ✅ ×223 | +| black backdrop | `0x5773DC18083C4C20` `0x07010701` | ✅ ×223 | + +This validates the log's **structure** — frame delimitation, shader and blend +fields — which is what the cadence measurement actually uses. It does **not** +validate alpha extraction, and the cadence measurement does not use alpha. That +is why the substitution is acceptable here and would not have been for a claim +about a ramp. + +## Independent corroboration, from data already committed + +[`data/attract-frame-match.txt`](data/attract-frame-match.txt) matched captured +attract frames to timestamps inside `ADV.wmv`. Over its fourteen high-confidence +`ADV` matches, movie time advances **≈ 5.77 movie-seconds per capture step** +against a harness that sleeps 5 s plus grab overhead — i.e. the movie plays at +roughly **real time**. A movie playing at real time, with one present per movie +frame, is a guest presenting at ≈ 30 fps. Consistent, from a different capture, +a different instrument, and a measurement taken for a different purpose. + +## 🔴 What this re-opens: finding 3 still has no cause + +Units per second was the leading candidate for *"the `PRESS Ⓐ` plate arrives +late"* and it is now **eliminated**. The play-test's remaining candidates were: +the clock origin, `rest.t`, and the record layout. One of them is now the most +likely, and it is **decoded**, not speculative: + +> **The plate's declared onset is `t = 214`, not `t = 236`.** + +``` +$ sylpheed-cli screen info --build 2 --geometry $SYLPHEED_DISC/dat/GP_TITLE.pak +ptbtn00.t32 0: a=0 214: a=0 236: a=255 238: a=255 244: a=0 +``` + +A keyframe is the **start of a ramp** (an established ✅ law), so the plate fades +in across `214 → 236` — a **22-unit ramp**, which is exactly the `T = 22` the +oracle independently confirmed by measuring **+23 alpha per presented frame** on +this element (`255 × 2 / 22 = 23.18`). + +**At 60 units/s the plate begins appearing at 3.57 s and is full at 3.93 s.** A +port that shows nothing until `t = 236` is 22 units — **0.367 s** — late at +onset, and replaces a 22-unit fade with a pop. A human watching judges a fade by +when it *starts*. + +⚠️ **I am not claiming that is what the port does.** The play-test reports the +port raising the plate at `t = 236`; whether that is its onset or its completion +is the port's to check. What is decoded here is the disc fact and the arithmetic. + +## Reach + +⟨capture⟩ over **one** boot, English locale, one machine, and the movie region of +one log. The ratio is exact and both guards pass, but a second independent boot +would make it ⟨capture⟩×2 — worth having before anything irreversible rests on +it, though nothing needs to, because the answer is "keep 60". +The 30.000 fps ruler is ⟨disc⟩ and generalises. + +## Refutation attempt, recorded per the adversarial duty + +**Target:** my predecessor's own H3 note that *"`2 × 60 fps` is 120 units/s, which +puts the plate at 1.97 s instead of 3.93 s — and 'about two seconds early' is the +size of what the human reported."* That is a live hypothesis with a plausible +motive, and it is the one I expected to confirm. + +**Result: REFUTED.** The guest presents one frame per decoded movie frame, on a +perfect 3-buffer cycle with no dropped frames. 120 units/s requires two presents +per movie frame and there are none. The hypothesis was well-formed and wrong, and +the reason it was attractive — it would have explained the play-test — is exactly +why it needed a ruler that is not a clock. diff --git a/tools/re-capture/movie_frame_cadence.py b/tools/re-capture/movie_frame_cadence.py new file mode 100755 index 00000000..6b7a642f --- /dev/null +++ b/tools/re-capture/movie_frame_cadence.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""How many swap labels does the guest present per DECODED MOVIE FRAME? + +The ruler is a disc fact: ADV.wmv declares 30.0000 fps in its ASF header +(ExtendedStreamProperties stream #2, avgTimePerFrame = 333333 x100ns). So one +decoded movie frame is one tick of a clock the emulator's speed cannot stretch, +and 'labels per movie frame' is guest_fps / 30 with no wall clock in it. + +Pre-registered in docs/re/guest-frame-rate-preregistration.md BEFORE any capture: + H_A guest 30 fps -> 60 units/s -> 1.0 labels/frame (accept 1.00 +/- 0.15) + H_B guest 60 fps -> 120 units/s -> 2.0 labels/frame (accept 2.00 +/- 0.30) +Anything else is reported as 'neither', not rounded to the closer one. + + movie_frame_cadence.py +""" +import re, sys, collections + +# The movie's luma plane. The splash census identified the movie's own textures +# as three 1280x720 and six 640x360, first appearing at frame 234; the 1280x720 +# ones are the luma, triple-buffered. +TEX = re.compile(r'tex\[base=0x([0-9A-Fa-f]+)\s+(\d+)x(\d+)\s+fmt=(\d+)') +FRAME = re.compile(r'\bframe=(\d+)') + +def main(path): + cur = None + per_frame = collections.OrderedDict() # frame -> set of luma bases + alpha_rows = [] + for line in open(path, errors='replace'): + m = FRAME.search(line) + if m: + cur = int(m.group(1)) + per_frame.setdefault(cur, set()) + for base, w, h, fmt in TEX.findall(line): + if (int(w), int(h)) == (1280, 720) and cur is not None: + per_frame.setdefault(cur, set()).add(base.upper()) + + movie = [(f, s) for f, s in per_frame.items() if s] + if not movie: + print("NO 1280x720 textures found -- this log does not contain the movie.") + print("The capture must run long enough to reach the attract movie (frame >=234).") + return 2 + print(f"movie-bearing frames: {len(movie)} (frames {movie[0][0]}..{movie[-1][0]})") + + # Guard 2: the buffers must cycle through a small fixed set. + bases = collections.Counter() + for _, s in movie: + for b in s: bases[b] += 1 + print(f"\ndistinct 1280x720 bases: {len(bases)}") + for b, n in bases.most_common(8): + print(f" 0x{b} in {n} frames") + if not 2 <= len(bases) <= 4: + print("!! not a 3-buffer cycle -- guard 2 fails, do not read the ratio below") + + # Run lengths: how many CONSECUTIVE labels carry the same base set. + runs = [] + prev = None; n = 0 + for f, s in movie: + key = tuple(sorted(s)) + if key == prev: n += 1 + else: + if prev is not None: runs.append(n) + prev = key; n = 1 + if prev is not None: runs.append(n) + + dist = collections.Counter(runs) + total = sum(runs) + print(f"\nrun-length distribution (labels holding the same luma base set):") + for k in sorted(dist): + bar = '#' * min(60, dist[k]) + print(f" {k:>3} label(s): {dist[k]:>4} {bar}") + ratio = total / len(runs) + print(f"\n {len(runs)} runs over {total} labels -> {ratio:.3f} labels per movie frame") + + # Guard 1: a spike, not a smear. + mode = dist.most_common(1)[0] + print(f" mode = {mode[0]} label(s), {100*mode[1]/len(runs):.1f}% of runs") + if 100*mode[1]/len(runs) < 70: + print(" !! not a clean spike -- frame-dropping smear, guard 1 flags this") + + print() + if abs(ratio - 1.0) <= 0.15: + print(" ==> H_A: guest presents at 30 fps, so 60 UNITS PER SECOND.") + print(" The plate's t=236 is 3.93 s. The port's current value stands.") + elif abs(ratio - 2.0) <= 0.30: + print(" ==> H_B: guest presents at 60 fps, so 120 UNITS PER SECOND.") + print(" The plate's t=236 is 1.97 s -- the port is 1.96 s LATE.") + else: + print(" ==> NEITHER band. Reporting as such rather than rounding to the closer.") + return 0 + +if __name__ == '__main__': + sys.exit(main(sys.argv[1]))