diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index deb44765..a6d05384 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -883,7 +883,7 @@ discovered it after re-exporting. ❔ The sweeps may simply be a small term, and | Q2 | which build is which screen state | ✅ answered | `GP_TITLE` is **8 screens shipped twice, EN/JP**: 4/7 title art, 2/3 the `PRESS Ⓐ` plate, 5/8 main menu, 6/9 `EXTRAS`, and ✅ **pak entries 0/1 and 12/15 are the LOADING screen** — two variants, plain and dressed, decoded from their `pgloading_*` element names (2026-08-29). ⚠️ **Read that in ENTRY space.** This row said "0/1 and 10/11" until 2026-08-29; that is true only of `screen list`'s ordinals, where 10→entry 12 and 11→entry 15. In entry space 10/11 are the **publisher and developer splashes** (`palogo_sqex`, `palogo_gamearts`). The port caught it; see [METHOD](../re/METHOD.md#mechanics-that-have-bitten). 🟡 which of the two is `LOADING` vs `LOADING2` is undecided; 🟡 the English member of a pair is the one in the first half of the data segment — [`ui-title-build-map.md`](../re/ui-title-build-map.md) | | Q3 | paint order for the six screens | ✅ answered, ❔ tie-break | **decoded**: a `u16` layer key at `+0x0A` of each `T8aD` sprite header, stable-sorted with declaration index; unkeyed elements get an implied key. Confirmed on 5 measured orders + `EXTRAS` vs a capture. One residual: the **tie-break** is unknown and bites on one element of the title — [`structures/ui-paint-order-key.md`](../re/structures/ui-paint-order-key.md). ⚠️ **The key does not fully order a screen**: elements sharing a key are tied, and the tie-break is ❔ **undecodable from the bundle** — declaration table, `T8aD` header (exhaustive: every offset 0x00–0x7f at u8/u16/u32, both directions, **0** fields match the measured order against **64** for the control) and the RATC child order all give the same order the game does *not* use. ✅ **Your exposure is now measured at ZERO PIXELS (2026-08-29).** The 2 overlapping tied pairs on `EXTRAS` are `ptframe3`×`ptframe4` (the other is a `loop*` you never draw), and rendering the screen with that pair swapped changes **0 px** — because the two sprites put ink on ~3 600 pixels each and **share none of them**; the 102×132 "overlap" was a bounding-box artefact. Same on the main menu's `ptframe1`×`ptframe2`. This is blend-independent: layers that never touch the same pixel cannot be ordered wrongly. **Nothing about the tie-break can change a pixel on any of your five screens** — [`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md) | | Q4 | button → GamePart | ✅ answered | **measured** which screen all **5** buttons open, by pressing each one and reading the screen's own title off the framebuffer. ✅ **In the form you need it: exactly ONE main-menu button opens a `GP_TITLE` entry.** `EXTRAS` → **entry 6** (EN) / **9** (JP). The other four leave the archive: `NEW GAME` → `DIFFICULTY` → `SELECT DATA`; `LOAD GAME` → the save-slot list; `TUTORIAL` → the lesson list; `OPTIONS` → GAME/CONTROL/SOUND/SCREEN SETTINGS. None of those four is a `GP_TITLE` build — so a menu→submenu→back cycle inside this archive is `main menu ↔ EXTRAS` and nothing else. The **GamePart id is still a name match**, not a measurement, and 🔴 the "cheap way to measure it" this page used to point at is a dead route (the guest words are monotonic counters, not a screen id) — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) | -| Q5 | navigation semantics | ✅ answered | **measured**: initial focus varies boot to boot (2× `TUTORIAL`, 2× `NEW GAME`); ⬆⬇ one step, **wraps both ends**; ⬅➡ do nothing; Ⓑ returns to the parent **with focus restored**; Ⓑ on the main menu → title; Ⓑ on the title → nothing — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) | +| Q5 | navigation semantics | ✅ answered, ⚠️ **per clause** | 🔴 **This row used to open with a single `**measured**` covering six clauses of different strength, and the port's `authored/flow.json` copied that word into a `MEASURED` provenance stamp for a clause whose evidence cell reads `none`. A bundled label is exactly as strong as its weakest cell.** Split: ✅ **measured** — initial focus varies boot to boot (2× `TUTORIAL`, 2× `NEW GAME`); ⬆⬇ move **one item per press** (indirect: the 4-press wrap count only works if each press moves one) and **wrap both ends**; ⬅➡ do nothing; Ⓑ on a submenu returns to the parent **with focus restored** (4/4); Ⓑ on the main menu → **title**, ≤ 0.4 s, no loading screen (2026-08-30). 🟡 **NOT measured, evidence cell empty** — *"no auto-repeat at the durations tried"* (nothing records a **held** direction) and *"Ⓑ on the title → nothing"* (the one run that pressed there did so **during the build-in**, so its result is confounded). **Do not stamp either as MEASURED** — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) | | Q6 | boot sequence + what drives it | ✅ answered | sequence **measured** end to end; the driver is **code, not data** — four search spaces closed, so the port **authors** the sequence — [`boot-config-and-gamepart-registry.md`](../re/boot-config-and-gamepart-registry.md) | | Q7 | transitions | ✅ answered | a **fade through black**, drawn by the screen's own last-painting `.prm` quad. Fade-in ramp is **decoded** from its keyframes; the ~0.4 s fade-out is **measured** (not in the file) — [`screen-transitions.md`](../re/screen-transitions.md) | | Q8 | menu audio bindings | ✅ answered | cue vocabulary + bank **decoded**; event binding is a **name match** (the authors' own event names). ✅ **You CAN have the SE audio** — ⚠️ an earlier version of this row said it was "undecodable from the disc"; that was **retracted** and the row was stale. Three cues are located in `Static.slb` and **decode to PCM**: d-pad move `0x1ec0` (4 packets), Ⓑ back `0x0ec0` (2), Ⓐ confirm `0x5d6c0` (6), all mono 48 kHz. The bank is a packed run of XMA waves with no delimiter, so a wave is only (offset, packet count) — and ⚠️ the file order is **not** cue-id order, so the index cannot be counted out — [`menu-audio-cues.md`](../re/menu-audio-cues.md) | diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index e1cf85a9..ae3ed1c8 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -1532,3 +1532,26 @@ control is what said so, which is the only reason the weaker number never got us 📌 Ask, before building a matcher: **does the artefact already state the answer?** Screens have titles, files have magic, logs have names. A derived statistic is for when nothing states it. + +## A bundled label is exactly as strong as its weakest cell + +`HANDOFF.md`'s Q5 row opened with a single **`measured`** and then listed **six** +clauses of very different strength — two of which had an evidence cell reading +`none` in the source table. The port agent's `authored/flow.json` then stamped +`title/on_cancel_why = "MEASURED, HANDOFF Q5"` for one of those two. + +It did not invent that. **It read the label the summary offered**, and the summary +was the document it is told to author against. + +⚠️ **The failure is in summarising, not in either endpoint.** The source table was +honest — it had the empty cells. The consumer was honest — it cited its source. +Flattening six claims into one adjective is what created a provenance that nothing +supports, and provenance is the part that gets believed later, long after anyone +would re-derive the value. + +**So a summary row carries a label per clause, or it carries the weakest one.** +Never the strongest, and never one label over a list. If that makes the row ugly, +the row is telling you it is doing too much. + +📌 The general shape: a strength label is **not distributive**. "These six things are +measured" is a claim about the conjunction, and a reader takes it about each element. diff --git a/docs/re/menu-navigation-semantics.md b/docs/re/menu-navigation-semantics.md index 70a9bb4d..f61d9048 100644 --- a/docs/re/menu-navigation-semantics.md +++ b/docs/re/menu-navigation-semantics.md @@ -18,7 +18,8 @@ with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py). |---|---|---| | **initial focus, main menu** | **`TUTORIAL`** — the *middle* item, not the top | 2/2 boots, the first frame after the menu appears | | **initial focus, `EXTRAS`** | `MISSION SELECT` — the top item | [`extras-wrap.png`](captures/menu-nav/extras-wrap.png) | -| **up / down** | one item per press, no auto-repeat at the durations tried | | +| **⬆⬇ — one item per press** | one item per press | ✅ *indirect but sound*: the wrap montage's **4 presses from `EXTRAS` landing on `OPTIONS`** only counts out if each press moves exactly one | +| **⬆⬇ — no auto-repeat** | 🟡 *"no auto-repeat at the durations tried"* | **none** — and the hedge is carrying the claim. Nothing here records a *held* direction | | **wrap at the top** | ⬆ from the first item goes to the **last** | [`wrap-montage.png`](captures/menu-nav/wrap-montage.png), panels 1→2 | | **wrap at the bottom** | ⬇ from the last item goes to the **first** | same, panels 3→4, and 4 presses from `EXTRAS` landing on `OPTIONS` — i.e. wrapping — is what makes the count come out | | **left / right** | **nothing**, on the main menu | cursor unmoved across one ⬅ and one ➡ | diff --git a/tools/re-capture/nav_repeat_and_b.py b/tools/re-capture/nav_repeat_and_b.py new file mode 100755 index 00000000..8a109a5b --- /dev/null +++ b/tools/re-capture/nav_repeat_and_b.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Two empty evidence cells in one run: d-pad auto-repeat, and Ⓑ on a SETTLED title. + +Both asked for by the port agent, and both are rows in +`menu-navigation-semantics.md` with nothing in the evidence column: + +* **"no auto-repeat at the durations tried"** — the hedge is doing the work. Hold + ⬇ for 2 s and count cursor moves. +* **"Ⓑ on the title → nothing"** — the previous run's second Ⓑ landed *during* the + title's build-in, so what followed was the build-in finishing. This one waits for + the plate pulse, which is the title's own settled signature + (`plate-pulse-measured.md`), before pressing. + +⚠️ **The move counter is controlled before it is used**: a single 0.12 s tap must +produce exactly ONE frame-to-frame spike. If the control does not give 1, the hold +result means nothing and is not reported. + + nav_repeat_and_b.py LOG OUTDIR [wait_s] +""" +import os +import re +import subprocess +import sys +import time + +import numpy as np +from PIL import Image + +LOG, OUT = sys.argv[1], sys.argv[2] +WAIT = float(sys.argv[3]) if len(sys.argv) > 3 else 520 +W, H = 1280, 720 +NEED, CEIL, HOLD_N = 500, 2500, 12 +MENU_LO, MENU_HI, MENU_HOLD = 250, 420, 6 +PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py") +SPIKE = 0.004 # fraction of pixels that must change to count as a cursor move + + +def deliveries(vk): + pat = re.compile((r"vk=%s flags=0001" % vk).encode()) + try: + return len(pat.findall(open(LOG, "rb").read())) + except FileNotFoundError: + return 0 + + +def _open(): + return subprocess.Popen( + ["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0", + "-video_size", f"{W}x{H}", "-i", ":98", "-r", "4", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], + stdout=subprocess.PIPE, bufsize=W * H * 3 * 2) + + +def grab(p, n): + buf = p.stdout.read(n) + if len(buf) < n: + return None + return np.frombuffer(buf, np.uint8).reshape(H, W, 3).astype(float) + + +def glyph(a): + r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2] + return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum()) + + +def count_moves(p, n, secs, label): + """Frame-to-frame spikes over `secs`. Each cursor move repaints the highlight.""" + prev, spikes, t0, series = None, 0, time.time(), [] + while time.time() - t0 < secs: + a = grab(p, n) + if a is None: + continue + if prev is not None: + d = float((np.abs(a - prev).max(axis=2) > 12).mean()) + series.append(round(d, 5)) + if d > SPIKE: + spikes += 1 + prev = a + print(f" {label}: {spikes} spike(s) over {secs:.1f}s diffs={series}", flush=True) + return spikes + + +T0 = time.time() +p, n, seg = _open(), W * H * 3, time.time() +phase, streak, mark, base = "wait", 0, None, None +res = open(f"{OUT}/result.txt", "w") +while True: + el = time.time() - T0 + if el > WAIT: + print(f"TIMEOUT in {phase}", flush=True); break + if time.time() - seg > 30: + p.kill(); p = _open(); seg = time.time() + a = grab(p, n) + if a is None: + p.kill(); p = _open(); seg = time.time(); continue + c = glyph(a) + if phase == "wait": + streak = streak + 1 if NEED <= c <= CEIL else 0 + if streak >= HOLD_N: + print(f"[{el:7.1f}s] TITLE", flush=True) + subprocess.run([sys.executable, PAD, "tap", "A", "0.5"], check=False) + phase, streak = "tomenu", 0 + elif phase == "tomenu": + streak = streak + 1 if MENU_LO <= c <= MENU_HI else 0 + if streak >= MENU_HOLD: + print(f"[{el:7.1f}s] MENU (glyph {c})", flush=True); time.sleep(2) + print(" CONTROL: one 0.12 s DOWN tap — must give exactly 1 spike", flush=True) + subprocess.run([sys.executable, PAD, "tap", "DOWN", "0.12"], check=False) + ctrl = count_moves(p, n, 3.0, "control") + time.sleep(1.5) + print(" TEST: hold DOWN for 2.0 s", flush=True) + subprocess.Popen([sys.executable, PAD, "hold", "press=DOWN", "2.0"]) + test = count_moves(p, n, 4.0, "hold-2s") + res.write(f"control_tap_spikes\t{ctrl}\nhold_2s_spikes\t{test}\n") + res.flush() + print(f" => control {ctrl}, hold {test} — " + f"{'CONTROL FAILED, hold result void' if ctrl != 1 else ('AUTO-REPEAT' if test > 1 else 'NO AUTO-REPEAT')}", + flush=True) + time.sleep(1.5) + subprocess.run([sys.executable, PAD, "tap", "B", "0.5"], check=False) + print(f"[{time.time()-T0:7.1f}s] B pressed on the menu — waiting for the title to SETTLE", flush=True) + phase, streak = "resettle", 0 + elif phase == "resettle": + streak = streak + 1 if NEED <= c <= CEIL else 0 + if streak >= HOLD_N: + Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/title-settled.png") + print(f"[{el:7.1f}s] TITLE SETTLED (plate pulse, glyph {c}) — pressing B", flush=True) + base = a.copy() + before = deliveries("5801") + subprocess.run([sys.executable, PAD, "tap", "B", "0.5"], check=False) + for _ in range(20): + time.sleep(0.25) + if deliveries("5801") > before: + print(" B delivered", flush=True); break + mark = time.time(); phase = "afterB" + elif phase == "afterB": + if time.time() - mark > 20: + Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/after-b-on-settled-title.png") + d = float((np.abs(a - base).max(axis=2) > 12).mean()) + print(f"[{el:7.1f}s] 20 s after Ⓑ on the settled title: {100*d:.1f}% of pixels differ " + f"from the moment of the press, glyph {c}", flush=True) + res.write(f"b_on_settled_title_diff_pct\t{100*d:.2f}\nb_on_settled_title_glyph\t{c}\n") + break +p.kill(); res.close()