diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 93fd12a8..7796140e 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -2529,7 +2529,33 @@ whatever it returns β€” the same reason a truncated log and a t=0 render both lo fine from inside. Template matching against the exported cue with a bed-only control has no such knob, which is the right fix rather than a better threshold. -## πŸ”΄ 2026-08-30 (latest) β€” I WATCHED the wrap. Your 61.93 is right; your **span** is wrong. +## βœ… 2026-08-30 (final) β€” the window is **`-ss 9.44 -t 61.87`** + +`loop_start` is **9.44 s**, measured. Your `loop_start_s` field β€” authored as 0.0 +and flagged wrong β€” now has its value, and flagging it is what makes this a +one-line change rather than an archaeology problem. + +| | | +|---|---| +| loop region | **[9.44 s, 71.31 s]** of an 87.744 s wave | +| cycle | **61.87 s** (wraps at 96.46 / 158.33 / 220.21, gaps 61.87 / 61.87) | +| played once | the first **9.44 s** β€” an intro | +| never played | the last **16.4 s** β€” the fade-out | + +**Two derivations, both contexts, four numbers, one value**, and neither converts +bits to seconds: (a) time to `read_offset` crossing `loop_start` plus a 1.33 s head +correction at a **locally measured** rate, (b) first pass minus cycle. Both give +9.44 s on both stems. + +βœ… **Your 61.93 stands** β€” my wrap timing says 61.87, 0.1 % apart. Either is fine; +mine is not more precise than yours. + +⚠️ One boot, one bank. The decoder reads ahead of playback, but both endpoints are +`read_offset` events so the lead cancels. +[`menu-bgm-loop-fields-conflict.md`](../re/structures/menu-bgm-loop-fields-conflict.md) Β· +[numbers](../re/data/menu-bgm-loop-start.txt) + +## πŸ”΄ 2026-08-30 (earlier) β€” I WATCHED the wrap. Your 61.93 is right; your **span** is wrong. **Three wraps observed**, each exactly `loop_end` β†’ `loop_start`, and **both contexts wrap at the same instant** all three times β€” the sample-synchrony the diff --git a/docs/re/data/menu-bgm-loop-start.txt b/docs/re/data/menu-bgm-loop-start.txt new file mode 100644 index 00000000..75a08ffd --- /dev/null +++ b/docs/re/data/menu-bgm-loop-start.txt @@ -0,0 +1,30 @@ +# WHERE the menu loop starts -- measured, no bits-to-seconds conversion. +# +# 2026-08-30. tools/re-capture/menu_loop_firstpass.py. The fix over the +# previous run was scheduling, not analysis: tail the log from BEFORE the +# music starts, so the first pass is sampled at the same cadence as every +# later cycle. Offsets below loop_start are played exactly ONCE. +# +# Backlog compression, previous run vs this one: +# before: 616 samples at t=0.002 spanning offsets 32..2,559,033 +# now: 125 samples at t=26.479 spanning offsets 32..515,239 +# +# WRAPS: t = 96.46, 158.33, 220.21 gaps 61.87, 61.87 (both contexts, same +# instant, as they must be to stay sample-synchronous) +# +# TWO DERIVATIONS, both contexts: +# (a) time from offset 32 to read_offset crossing loop_start, +# with the unsampled head corrected at the LOCAL measured rate +# (387,823 / 377,440 bits/s over 748 timestamped samples): +# 8.11 s sampled + 1.33 s head = 9.44 s +# (b) first pass (offset 32 -> loop_end) minus the cycle: +# 70.0 + 1.33 - 61.87 = 9.44 s +# +# ctx0 -> 9.44 s ctx1 -> 9.44 s agreeing to the digit +# +# => LOOP REGION [9.44 s, 71.31 s] of an 87.744 s wave; cycle 61.87 s. +# The first 9.44 s is an intro played ONCE; the last 16.4 s -- the +# fade-out bgm-two-stems.md documents -- is NEVER played. +# +# The decoder reads ahead of playback, but both endpoints are read_offset +# events, so the lead cancels in the difference. diff --git a/docs/re/structures/menu-bgm-loop-fields-conflict.md b/docs/re/structures/menu-bgm-loop-fields-conflict.md index 220c1d9f..74608e17 100644 --- a/docs/re/structures/menu-bgm-loop-fields-conflict.md +++ b/docs/re/structures/menu-bgm-loop-fields-conflict.md @@ -109,7 +109,35 @@ the aliasing the real problem has. **So the length was right and the placement was wrong** β€” which is why the port's shipped loop sounds correct: it has the right *duration*, over the wrong *span*. -### 🟑 Where `loop_start` sits in seconds is still not measured +### βœ… MEASURED (2026-08-30, latest): `loop_start` is at **9.44 s** + +The fix was scheduling. Tailing the log from *before* the music starts cut the +unsampled backlog from 616 samples spanning offsets 32…2 559 033 down to **125 +spanning 32…515 239**, and the first pass is then sampled like any other cycle +([`../data/menu-bgm-loop-start.txt`](../data/menu-bgm-loop-start.txt)). + +Wraps at **96.46 / 158.33 / 220.21 s**, gaps **61.87 / 61.87**, both contexts +together. + +Two derivations, neither converting bits to seconds: + +| | ctx0 | ctx1 | +|---|---|---| +| (a) time to read_offset crossing `loop_start`, + the head at the **local measured** rate | **9.44 s** | **9.44 s** | +| (b) first pass (offset 32 β†’ `loop_end`) βˆ’ cycle | **9.44 s** | **9.44 s** | + +Four numbers, one value. The head correction is **1.33 s** and uses a rate measured +on 748 timestamped samples of that same stretch β€” not the cycle mean, and not an +assumption of linearity across the stream. + +**So the loop region is `[9.44 s, 71.31 s]` of an 87.744 s wave, cycling every +61.87 s.** The first **9.44 s is an intro played once**; the last **16.4 s β€” the +fade-out `bgm-two-stems.md` documents β€” is never played at all.** + +⚠️ The decoder reads ahead of playback, but both endpoints are `read_offset` events, +so the lead cancels in the difference. One boot, one bank. + +### The superseded position Offsets below `loop_start` play **exactly once**, before the first wrap, and this trace stamped that whole stretch at `t=0.002` β€” 616 samples spanning offsets diff --git a/tools/re-capture/menu_loop_firstpass.py b/tools/re-capture/menu_loop_firstpass.py new file mode 100755 index 00000000..866ad2af --- /dev/null +++ b/tools/re-capture/menu_loop_firstpass.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Measure where the menu loop STARTS, by sampling the first pass properly. + +`menu-bgm-loop-fields-conflict.md` settles the loop's *length* (61.81 s, three +wraps) but not where in the wave it begins. Offsets below `loop_start` are played +**exactly once**, before the first wrap β€” and the previous trace started after the +music and swallowed that whole stretch in one read, stamping 616 samples spanning +offsets 32…2 559 033 at `t=0.002`. + +The fix is scheduling, not analysis: **tail the log from before the music starts**, +so the first pass is sampled at the same cadence as every later cycle. Then + + loop_start_time = (first pass, offset 32 β†’ loop_end) βˆ’ (cycle, wrap to wrap) + +with no bitsβ†’seconds conversion anywhere β€” the step that is refuted (the rate +varies 4.4 % within one stream). + +⚠️ Context ids are reused: `ADV`'s streams are also ctx0/1/2. Everything is +timestamped and the BGM start time is recorded, so the analysis can discard +anything before it rather than relying on the ids. + + menu_loop_firstpass.py LOG OUTDIR [hold_s] +""" +import os +import re +import subprocess +import sys +import time + +LOG, OUT = sys.argv[1], sys.argv[2] +HOLD = float(sys.argv[3]) if len(sys.argv) > 3 else 200 +PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py") +ADV = {1_294_336, 1_118_208, 1_171_456} +BGM = {3_876_864, 3_930_112} +SIZE = re.compile(rb"byte_size=(\d+)") +LOOP = re.compile(rb"XmaContext (\d+): Looped Data: (\d+) < (\d+) \(Start: (\d+)\)") + +T0 = time.time() +off = 0 +adv_seen = False +bgm_at = None +taps = 0 +tsv = open(f"{OUT}/readoff.tsv", "w") +tsv.write("# t_s\tctx\tread_offset\tloop_end\tloop_start\n") +ev = open(f"{OUT}/events.tsv", "w") + + +def tap(): + subprocess.run([sys.executable, PAD, "tap", "A", "0.12"], check=False) + print(f"[{time.time()-T0:7.1f}s] tapped A", flush=True) + + +while time.time() - T0 < 900: + try: + with open(LOG, "rb") as f: + f.seek(off) + chunk = f.read() + off += len(chunk) + except FileNotFoundError: + time.sleep(0.3); continue + now = time.time() - T0 + sizes = {int(m.group(1)) for m in SIZE.finditer(chunk)} + # the loop trace runs from the very first poll, so the first pass is sampled + for m in LOOP.finditer(chunk): + tsv.write(f"{now:.3f}\t{int(m.group(1))}\t{int(m.group(2))}\t" + f"{int(m.group(3))}\t{int(m.group(4))}\n") + tsv.flush() + if sizes & ADV and not adv_seen: + adv_seen = True + print(f"[{now:7.1f}s] ADV β€” intro playing", flush=True) + ev.write(f"{now:.3f}\tadv\n"); ev.flush() + time.sleep(3); tap(); taps += 1 + if sizes & BGM and bgm_at is None: + bgm_at = now + print(f"[{now:7.1f}s] BGM_103 β€” ON THE MENU, holding {HOLD}s", flush=True) + ev.write(f"{now:.3f}\tbgm\n"); ev.flush() + if bgm_at is None and adv_seen and taps <= 4 and now % 25 < 0.4: + tap(); taps += 1; time.sleep(1) + if bgm_at is not None and now - bgm_at > HOLD: + print(f"[{now:7.1f}s] hold complete", flush=True) + break + time.sleep(0.3) +tsv.close(); ev.close() +print(f"bgm_at={bgm_at} taps={taps}", flush=True)