re: the menu loop starts at 9.44 s -- measured, and 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 to 125 spanning 32..515,239, so the first
pass is sampled like any later cycle. Offsets below loop_start play exactly once,
which is why the previous run could not measure them.

Wraps at 96.46 / 158.33 / 220.21 s, gaps 61.87 / 61.87, both contexts together.

Two derivations, neither converting bits to seconds:
  (a) time to read_offset crossing loop_start, plus a 1.33 s head correction at a
      rate measured on 748 timestamped samples of that same stretch
  (b) first pass (offset 32 -> loop_end) minus the cycle
Both give 9.44 s on both contexts -- four numbers, one value.

So the loop region is [9.44, 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
sylph-decoder
2026-08-30 10:13:14 +00:00
parent fc78123f27
commit 712cac8984
4 changed files with 170 additions and 2 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)