diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 7796140e..7eb03d96 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -1727,9 +1727,13 @@ discovered it after re-exporting. ❔ The sweeps may simply be a small term, and layer — 🟡 which of those is unsettled, and `ChannelMask` is `0x0002` on both, so the file will not say. Today's 347 s concatenation plays the piece twice, the second time as a bass-less stem. - ❔ **And it is not a seamless loop**: `BGM_001` fades out at 167.663 s and is - followed by 6.15 s of silence, with no loop-point field identified. A menu loop - is authored. + 🔴 **"not a seamless loop … no loop-point field identified … a menu loop is + authored" — REFUTED 2026-08-30, and this bullet kept saying it for days after + the correction existed elsewhere.** The fade and the 6.15 s of silence are real, + but there **is** a loop point — in the **XMA decoder context**, set at runtime by + `XMASetLoopData`. For `BGM_103` it is **`[9.44 s, 71.31 s]`, cycling every + 61.87 s**, watched over three wraps. The game never reaches the fade, which is + why the stored tail looked unusable. See the 2026-08-30 entries at the top. ✅ **The menu's music is `BGM_103`.** The cue *table* cannot say — its BGM entries are numeric — but `GamePart_Title`'s `sub_821C5580` plays **cue 1103**, and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 B) are diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index cf395686..9d201969 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -1431,3 +1431,31 @@ too — a span of zero cannot occur. Both cases point the same way: **The tell is in the rows that agree with you.** Scrutinising the disconfirming half harder is the intuitive discipline and it is the wrong half — a broken instrument and an easy control both fail *silently on the side you were hoping for*. + +## A correction that does not reach the artifact a consumer reads has not been made + +The port agent found that after correcting `loop_end_why` and `loop_start_why`, the +field its exporter actually concatenates into `manifest.json` — `loop_why` — was +**still shipping** "AUDIBLY WRONG AT THE SEAM" and "no loop-point field has been +identified anywhere". Both refuted days earlier. Its corrections existed, were +accurate, and were in the wrong fields. + +**And the same audit against this corpus found the same failure here.** The claim +*"no loop-point field has been identified in the XMA header, so a menu loop is +authored"* was refuted on 2026-08-30 — in a **new page**. The sentence stayed +untouched in [`bgm-two-stems.md`](structures/bgm-two-stems.md), which is where +anyone looking up BGM behaviour arrives, **and** in `HANDOFF.md`, which is the one +page the port is told to read. A reader following either would have got the dead +answer with nothing to warn them. + +⚠️ **Writing the correction down is not the same as landing it.** A new page records +that you learned something; it does not stop the old sentence being read. When +something is refuted, **grep the corpus for the claim, not for the file you were +working in**, and fix it where it is stated — especially in `HANDOFF.md`, whose +whole purpose is to be the page somebody reads instead of the rest. + +📌 And a trap in doing that audit, also the port's: its first verification reported +the stale text **still present**, because the replacement *quotes* the refuted +sentences in order to name them — so a substring search matches them inside the +paragraph saying they are false. The match was real and meant the opposite of what +the search implied. This is "the tell is in the rows that agree" arriving as a grep. diff --git a/docs/re/structures/bgm-two-stems.md b/docs/re/structures/bgm-two-stems.md index 0bdfa6ed..47ea5066 100644 --- a/docs/re/structures/bgm-two-stems.md +++ b/docs/re/structures/bgm-two-stems.md @@ -117,11 +117,22 @@ transcribing one. * a music bank is **two waves that play together**, not one track and not a sequence — do not concatenate; * both are full length; align them at sample 0; -* **the track is not a seamless loop.** `BGM_001` fades out and is followed by - **6.15 s of silence** (last sound 167.663 s of 173.809 s; the final second - before silence is at RMS 168 against 4 788 at the head). Looping the wave as - stored gives a fade-out and six seconds of nothing every cycle. No loop-point - field has been identified in the XMA header, so a menu loop is **authored**. +* **the track is not a seamless loop *as stored*.** `BGM_001` fades out and is + followed by **6.15 s of silence** (last sound 167.663 s of 173.809 s; the final + second before silence is at RMS 168 against 4 788 at the head). Looping the wave + end-to-end gives a fade-out and six seconds of nothing every cycle. + + 🔴 **But "no loop-point field has been identified … so a menu loop is + authored" is REFUTED (2026-08-30), and this bullet said it for days after.** + There is no loop point in the **file header**; there is one in the **XMA decoder + context**, written at runtime via `XMASetLoopData`, and Xenia already logs it. + For `BGM_103` it is **`[9.44 s, 71.31 s]`, cycling every 61.87 s** — measured by + watching three wraps, so the game never reaches the fade at all. That is *why* + the stored tail looks unusable: it is never played. + [`menu-bgm-loop-fields-conflict.md`](menu-bgm-loop-fields-conflict.md) + + ⚠️ Reach: measured on `BGM_103` only. Whether every bank carries loop bounds is + untested — but "the format has nowhere to put one" is dead. ## ✅ Confirmed at runtime — two stereo streams decode at once diff --git a/tools/re-capture/tbm_screen_capture.py b/tools/re-capture/tbm_screen_capture.py new file mode 100755 index 00000000..a7e40982 --- /dev/null +++ b/tools/re-capture/tbm_screen_capture.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Reach a submenu that carries a `.tbm` and capture it. + +`ui-forced-backdrop.md` leaves 24 of its 62 deciding verdicts on `.tbm` elements +whose pixels this corpus cannot locate: not in the bundle, not a file, not a pak +entry, and **not in our composite** — `compose` skips an element with no resolvable +sprite, so our renderer draws *nothing* for a `.tbm`. The open question is whether +the game draws anything either. If it does not, those verdicts are inert rather +than correct. + +⚠️ **No focus detector is needed, and that is deliberate.** +`s00a-drive-blocked-by-focus.md` records that a per-row brightness statistic +**failed its own control**, and that wrap-around makes counting presses useless. +But every main-menu destination except `EXTRAS` lands on an archive holding a +`.tbm` decider — `GP_SYSTEM` (`pqbase`), `GP_TUTORIAL` (`pubase`), +`GP_SAVE_LOAD` (`px_replay_base`), `GP_DIALOG` (`pcbase`). So pressing Ⓐ on +whatever happens to be focused is very likely to land somewhere useful, and the +screen is identified **afterwards, from the capture**, rather than chosen in +advance. + + tbm_screen_capture.py OUTDIR [wait_s] +""" +import os +import subprocess +import sys +import time + +import numpy as np +from PIL import Image + +OUT = sys.argv[1] +WAIT = float(sys.argv[2]) if len(sys.argv) > 2 else 420 +W, H = 1280, 720 +NEED, CEIL, HOLD = 500, 2500, 12 +PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py") + + +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 tap(btn="A"): + subprocess.run([sys.executable, PAD, "tap", btn, "0.12"], check=False) + print(f"[{time.time()-T0:7.1f}s] tapped {btn}", flush=True) + + +def glyph(a): + r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2] + return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum()) + + +T0 = time.time() +p, n, seg = _open(), W * H * 3, time.time() +log = open(f"{OUT}/series.tsv", "w"); log.write("# t_s\tglyph\tmean\tphase\n") +phase, streak, mark = "wait", 0, None +while True: + el = time.time() - T0 + if phase == "wait" and el > WAIT: + print("TITLE NEVER APPEARED", flush=True); break + if time.time() - seg > 30: + p.kill(); p = _open(); seg = time.time() + buf = p.stdout.read(n) + if len(buf) < n: + p.kill(); p = _open(); seg = time.time(); continue + a = np.frombuffer(buf, np.uint8).reshape(H, W, 3).astype(int) + c = glyph(a) + log.write(f"{el:.3f}\t{c}\t{a.mean():.3f}\t{phase}\n"); log.flush() + if phase == "wait": + streak = streak + 1 if NEED <= c <= CEIL else 0 + if streak >= HOLD: + tap(); mark = time.time(); phase = "menu" + elif phase == "menu" and time.time() - mark > 8: + Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/menu.png") + print(f"[{el:7.1f}s] menu captured (glyph {c}) — pressing A into a submenu", flush=True) + tap(); mark = time.time(); phase = "submenu" + elif phase == "submenu" and time.time() - mark > 10: + Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/submenu.png") + print(f"[{el:7.1f}s] submenu captured (glyph {c}, mean {a.mean():.1f})", flush=True) + break +p.kill(); log.close()