diff --git a/docs/re/BACKLOG.md b/docs/re/BACKLOG.md index 4bdb3462..ee503190 100644 --- a/docs/re/BACKLOG.md +++ b/docs/re/BACKLOG.md @@ -620,6 +620,16 @@ search cannot find a *schedule*. (template strip covers only **0 1 2 4 8**; most samples read `00?`/`???`), or a counter that can rise. **Next: widen the scan to u16/u8 and LE, and beyond the heap** — one function, no combat cost. Also extend `ob_digits.png`. +* 🔴 **(2026-08-24) Widened to 7 encodings; run inconclusive.** `ob_by_hud.py` + now keeps a candidate set per encoding (`u32be/le`, `u16be/le` at both + alignments, `u8`) as byte offsets. **`u32le` is tightest at 154** vs u32be's + 4452 — a hint, not a result. 🔴 HUD read **4 at every sample**, so nothing + collapsed; and counts were **byte-identical across five samples in all seven + encodings** from t=136 s, which is what a freeze looks like. The probe had **no + witness** — now added. ⚠️ **Fourth probe written without one, third flat run + that cannot be told from a freeze**; the recurring fix is the shared harness + noted earlier. Needs two HUD readings at *different* values in non-stalled + samples. * ~~🚧 BLOCKER: t=210/240 unreachable in one turn~~ — **superseded, see above**; it rested on an untested assumption that a turn is one shell call. 595 s shell cap − ~220 s boot (a ~190 s title movie that cannot be tapped through) − ~25 s startup = **~350 s observation ≈ 193 game-seconds**. diff --git a/docs/re/remaining-ob-hunt.md b/docs/re/remaining-ob-hunt.md index 02e154e3..a1ce4de8 100644 --- a/docs/re/remaining-ob-hunt.md +++ b/docs/re/remaining-ob-hunt.md @@ -215,3 +215,46 @@ kill-driven route it costs no combat. Also worth extending `ob_digits.png` with the missing digits — most samples were unreadable, which is why only two data points survived a 480 s run. + +--- + +# Widened to every plausible encoding (2026-08-24) + +`ob_by_hud.py` now scans seven readings of the same bytes and keeps a separate +candidate set for each, expressed as byte offsets: + +`u32be`, `u32le`, `u16be`, `u16be@1`, `u16le`, `u16le@1`, `u8`. + +First run, HUD showing **4** throughout: + +``` +t= 7s u32be:6285 u32le:414 u16be:10668 u16be@1:2718 u16le:656 u16le@1:9012 u8:25559 +t=110s u32be:4472 u32le:156 u16be: 8794 u16be@1:1386 u16le:286 u16le@1:8124 u8:20381 +t=136s u32be:4452 u32le:154 u16be: 8773 u16be@1:1380 u16le:284 u16le@1:8119 u8:20346 +t=162s .. t=267s identical to t=136 in every encoding +``` + +**`u32le` is much the tightest at 154**, an order of magnitude below `u32be`. +That is a hint about the encoding, not a result — a rarer bit pattern narrows +faster regardless of meaning. + +## 🔴 Inconclusive, and probably a frozen guest + +The HUD read **4** at every sample, so there was no second value to collapse the +sets against. Worse, from t = 136 s the candidate counts are **byte-identical +across five samples in all seven encodings**, which is what a frozen guest looks +like — nothing in 32 MB changed at all. + +`ob_by_hud.py` had **no stall witness**, so the run cannot prove it. Added now. + +**This is the fourth probe written without a witness and the third whose flat +output could not be told from a freeze.** Each time the fix is applied to that +one script. The durable fix is the shared harness already noted in this file, and +the fact that the lesson keeps recurring is itself the argument for building it. + +## What is needed + +Two HUD readings at **different** values, in non-stalled samples. The counter +changes on kills, so this lands back on the combat limit — unless a phase change +or another event moves it. The earlier 4 → 11 observation shows it does move, +which is what makes the approach worth continuing. diff --git a/tools/re-capture/ob_by_hud.py b/tools/re-capture/ob_by_hud.py index c0fa24f4..5a1b09a0 100755 --- a/tools/re-capture/ob_by_hud.py +++ b/tools/re-capture/ob_by_hud.py @@ -24,7 +24,30 @@ def region(fd): out, pos = bytearray(), lo while pos < hi: n = min(1 << 24, hi - pos); out += os.pread(fd, n, pos); pos += n - return np.frombuffer(bytes(out), dtype='>u4'), lo + return bytes(out), lo + +# The big-endian u32 reading was refuted: no word held 4 then 11. Widen rather +# than assume -- the counter may be narrower, little-endian, or unaligned. Each +# encoding keeps its own candidate set, expressed as BYTE OFFSETS so the answer +# is directly usable whichever one wins. +ENCODINGS = [ + ('u32be', '>u4', 4, 0), ('u32le', 'u2', 2, 0), ('u16be@1', '>u2', 2, 1), + ('u16le', ' (1 << (8 * w)) - 1: + continue + n = (len(buf) - skew) // w * w + a = np.frombuffer(buf[skew:skew + n], dtype=dt) + idx = np.nonzero(a == value)[0] + out[name] = idx.astype(np.int64) * w + skew + return out def hud_value(): subprocess.run(['screenshot', SHOT], capture_output=True, timeout=60) @@ -40,6 +63,19 @@ def main(): secs = int(sys.argv[1]) if len(sys.argv) > 1 else 240 every = int(sys.argv[2]) if len(sys.argv) > 2 else 25 w = gworld.World(); fd = w.fd + # Witness. This is the FOURTH probe written without one, and the third to + # produce a run whose flat output could not be distinguished from a freeze. + # The recurring fix is a shared harness; until that exists, carry it. + b0, _ = region(fd); time.sleep(3.0); b1, _ = region(fd) + a0 = np.frombuffer(b0, dtype='>u4').astype(np.int64) + a1 = np.frombuffer(b1, dtype='>u4').astype(np.int64) + d = a1 - a0 + rates = collections.Counter((d[(d > 15) & (d < 600)] // 3).tolist()) + band = [r for r in rates if 8 <= r <= 40] + pick = max(band, key=lambda r: rates[r]) if band else None + ticks = np.nonzero(d // 3 == pick)[0][:32] if pick is not None else np.array([], dtype=int) + print('tick witnesses: %d at %s/s' % (len(ticks), pick), flush=True) + last_t = a1[ticks] if len(ticks) else None cand = None; base = None; seen = [] t0 = time.time() while time.time() - t0 < secs: @@ -48,23 +84,39 @@ def main(): if v is None: print(' t=%4ds HUD unreadable (%s)' % (el, note), flush=True) else: - r, base = region(fd) - hit = set(np.nonzero(r == v)[0].tolist()) - cand = hit if cand is None else (cand & hit) + buf, base = region(fd) + hit = matches(buf, v) + if cand is None: + cand = hit + else: + cand = {k: np.intersect1d(cand[k], hit[k], assume_unique=True) + for k in cand if k in hit} seen.append(v) - print(' t=%4ds HUD=%-4d words==%d: %-8d -> candidates %d (%s)' - % (el, v, v, len(hit), len(cand), note), flush=True) - if len(cand) <= 40 and len(set(seen)) >= 2: + st = '' + if len(ticks): + now_t = np.frombuffer(buf, dtype='>u4').astype(np.int64)[ticks] + if int((now_t > last_t).sum()) == 0: st = ' *** GUEST STALLED ***' + last_t = now_t + print(' t=%4ds HUD=%-4d %s%s (%s)' + % (el, v, ' '.join('%s:%d' % (k, len(cand[k])) for k in cand), st, note), + flush=True) + live = {k: c for k, c in cand.items() if len(c)} + if live and len(set(seen)) >= 2 and min(len(c) for c in live.values()) <= 40: break time.sleep(every) print('\nHUD values seen: %s' % sorted(set(seen))) if cand and base is not None: - print('candidates: %d' % len(cand)) - for i in sorted(cand)[:20]: - va = gmem.primary_va(base + i * 4) - print(' va %s' % (('%#010x' % va) if va else '?')) + for k in sorted(cand, key=lambda k: len(cand[k])): + c = cand[k] + print(' %-8s %d candidate(s)' % (k, len(c))) + for off in c[:8]: + va = gmem.primary_va(base + int(off)) + print(' va %s' % (('%#010x' % va) if va else '?')) + if all(len(c) == 0 for c in cand.values()): + print('\nEVERY encoding eliminated -- the counter is not in this region ' + 'in any of them, or a HUD reading is wrong.') else: - print('no candidates (HUD never read, or value never matched a u32)') + print('no candidates (HUD never read)') return 0 if __name__ == '__main__':