#!/usr/bin/env python3 """step7_capture.py — Step 7: live capture of level-path BandConfig A/B/gamma per RPP. Method (NOTES_CAPTURE 2026-08-20c): 1. spawn reaper with (+ play.lua realtime transport by default), 2. wait for yabridge-host (soothe2 mapped, not reaper), sleep for init, 3. chunked full-heap snapshot (8MB pread chunks; large regions EIO otherwise), 4. fingerprint scan: per-band level_gain pair buffers = 0x400 [level,gain] f32 pairs with level[j] == j/1024 EXACTLY (j<1024 -> exact in fp32), 5. u64 refs to those buffers land at base+0xe0+band*0x18 (stride 0x18) -> majority-vote the level-path object base, 6. decode every qword ptr at base+0x170..0x1a8 as BandConfig: A@+0x0 B@+0x4 gamma@+0xc flag@+0x10 shaper@+0x18.. callback@+0x90, plus first mask doubles at base+0x4198+band*0x2000. Usage: python3 scripts/step7_capture.py [--offline] [--json PATH] [--snap PATH] Default mode is realtime playback (play.lua) so short projects keep the DSP host alive during capture. --offline uses -renderproject instead. """ import subprocess, os, glob, time, struct, sys, json, collections REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLAY_LUA = os.path.join(REPO, 'play.lua') def find_host(): for p in glob.glob('/proc/[0-9]*'): pid = int(os.path.basename(p)) try: cmd = open(f'/proc/{pid}/cmdline', 'rb').read().replace(b'\0', b' ').decode('utf8', 'replace') maps = open(f'/proc/{pid}/maps').read() except Exception: continue if 'soothe2' in maps and 'reaper' not in cmd: return pid return None def snapshot(host, path): fd = os.open(f'/proc/{host}/mem', os.O_RDONLY) out = open(path, 'wb') nreg = 0; nbytes = 0 for line in open(f'/proc/{host}/maps').read().splitlines(): p = line.split() if len(p) < 2: continue lo, hi = (int(x, 16) for x in p[0].split('-')) if 'r' not in p[1]: continue a = lo while a < hi: n = min(hi - a, 8 * 1024 * 1024) try: d = os.pread(fd, n, a) except Exception: a += n; continue if not d: a += n; continue out.write(struct.pack(' len(a): continue if np.array_equal(a[s:s + 2048:2], expect): found.append(lo + 4 * s) found = sorted(set(found)) dedup = [] for h in found: if not dedup or h - dedup[-1] > 0x100: dedup.append(h) return dedup def find_object_base(regs, bufs): """u64 refs to band buffers sit at base+0xe0+band*0x18.""" votes = collections.Counter() detail = [] for k, buf in enumerate(bufs): pat = struct.pack('= 2 else (None, detail) def looks_like_bandconfig(regs, p): b = readabs(regs, p, 0x98) if not b: return False A, B = struct.unpack_from('/dev/null; pkill -9 -f "[y]abridge" 2>/dev/null; sleep 1', shell=True) if offline: cmd = ['/usr/bin/reaper', '-nosplash', '-ignoreerrors', '-renderproject', rpp] else: cmd = ['/usr/bin/reaper', '-nosplash', '-ignoreerrors', rpp, PLAY_LUA] t0 = time.time() proc = subprocess.Popen(cmd, stdout=open('/dev/null', 'w'), stderr=subprocess.STDOUT) host = None while time.time() - t0 < 60 and not host: host = find_host(); time.sleep(0.2) if not host: print('NO HOST'); return 1 print('host %d at %.1fs' % (host, time.time() - t0)) time.sleep(8) for attempt in range(3): try: nreg, nbytes = snapshot(host, spath) except Exception as e: print('snapshot failed:', e); break print('snapshot #%d: %d regs %.1f MB' % (attempt, nreg, nbytes / 1e6)) if nbytes > 50e6: break time.sleep(3) if proc.poll() is None: proc.kill() regs = parse_snap(spath) bufs = find_levelpair_buffers(regs) print('level-pair buffers:', ['0x%x' % b for b in bufs]) res = {'rpp': rpp, 'buffers': ['0x%x' % b for b in bufs]} if not bufs: json.dump(res, open(jpath, 'w'), indent=1); print('saved', jpath); return 2 # group into bands: cluster addrs with stride ~0x2000 groups = [[bufs[0]]] for b in bufs[1:]: if b - groups[-1][-1] <= 0x3000: groups[-1].append(b) else: groups.append([b]) best = None for g in groups: base, cnt = find_object_base(regs, g) print('group n=%d -> base %s (votes=%s)' % (len(g), ('0x%x' % base) if base else None, cnt if isinstance(cnt, int) else '-')) if base and (best is None or cnt > best[1]): best = (base, cnt) if not best: json.dump(res, open(jpath, 'w'), indent=1); print('saved', jpath); return 3 base = best[0] cfgs, masks = dump_bandconfigs(regs, base) res.update(levelpath_base='0x%x' % base, configs=cfgs, masks_head=masks) json.dump(res, open(jpath, 'w'), indent=1) print(json.dumps(cfgs, indent=1)) print('masks head:', json.dumps(masks)) print('saved', jpath) return 0 if __name__ == '__main__': sys.exit(main())