Until now this repo was an export of a private tree, pushed as squashed snapshots. From here on it is the working tree itself, and the private repo is archived. The root holds AGENTS.md and the Rust and GitHub files only. Everything an agent needs to orient is in docs/: METHOD.md (the rules and how the project is built, 123 lines, project method only), MAP.md (generated from the code at every land) and CONTRIBUTING.md (what gets in and how a change is judged). One skill, `probe`, holds the procedure for running the client unattended against the local server. Brought over because the project needs it: reference/ (the 1.12 name catalogues 34 source files read; the export failed its own tests without them), the gate chain and the pool (scripts/), the hooks that enforce the install rule and the worktree rule, the full .gitignore, the `cargo play` alias, and the original wording of eleven source files the export had reworded. Left out: the decision records (archived with the private repo), the export tooling, the operator's Discord and triage loop, the lab-laptop scripts and the instruments nothing references, which live outside the repo now. What read the records is gone from the tooling: no corpus lint, no records index, no `reserve` verb, no number-collision check. Three hooks are gone: fmt-at-commit and the file-size nudge were redundant with the gates, and the assets/ui guard is now a test (`assets_ui_does_not_grow`) that fails for everyone, not only for a Claude Code session. The remaining pool hooks stand down on a machine without a pool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
218 lines
9.6 KiB
Python
Executable File
218 lines
9.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Read a `vpl`/`bub` overlay trace and say WHERE the overlay's motion came from — projection, solve, or snap.
|
|
|
|
# walk a town with friendly plates on, tracing one line per plate per frame
|
|
WOW_WIN=1440x810 WOW_NOSOUND=1 WOW_GM=off \
|
|
WOW_UNATTENDED=1 WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> \
|
|
WOW_PROBE_CHAT=".go xyz -9465 74 58 0" WOW_PROBE_CHAT_AT=10 \
|
|
WOW_PROBE_KEY="Shift@20:3;V@20.5:0.2;W@24:8" \
|
|
WOW_MOVE_TRACE=/tmp/vpl.trace WOW_MOVE_TRACE_TAGS=vpl \
|
|
WOW_PROBE_EXIT_AT=34 cargo run -q -p benilla
|
|
python3 scripts/vplsum.py /tmp/vpl.trace --from 24 --to 32 # the walk leg only
|
|
|
|
Why it exists (decision 1341): "the plates are jittery when moving" is four different bugs wearing
|
|
one coat — a noisy camera, a stale anchor, the anti-overlap solve relocating a plate, or the pixel
|
|
snap quantizing its glide — and the eye cannot tell them apart. Every `vpl` line carries the world
|
|
anchor, the camera pose, the RAW projected point, the SOLVED rect and the SNAPPED rect, so the
|
|
question is arithmetic instead of judgement. It found phantom plates (30% of everything drawn was a
|
|
unit off screen, dragged to the border by the seat's clamp) in one run.
|
|
|
|
Read it in this order:
|
|
|
|
* **projection smoothness** — the frame-to-frame acceleration of the raw projected point, per unit.
|
|
Small (~0.05 px at 60 fps) means the camera and the anchor are innocent and the cause is
|
|
downstream. Large means stop reading and go look at the camera or the anchor.
|
|
* **off-screen** — plates drawn for a unit that is not on screen. Must be 0.
|
|
* **solve jumps** — a plate moving further than its own projection did. Some is the reference's own
|
|
anti-overlap bounce (a plate re-solving when a blocker moves); a lot, or hundreds of pixels in a
|
|
frame, is a defect.
|
|
* **snap** — the extra per-frame displacement the pixel snap adds to a plate's glide. It is bounded
|
|
by half a device pixel by construction; anything larger means the snap is on the wrong grid.
|
|
|
|
The `--from`/`--to` window matters as much as it does for `tracesum.py`: a run is a login, a
|
|
teleport, a stand and a walk glued together, and their average describes none of them.
|
|
|
|
**It reads the CHAT BUBBLE's `bub` lines too** (1398), auto-detected from the file — the bubble is
|
|
the plate's sibling overlay (same `plate_basis`/`gx_px`/`device_snap` family, same projector, same
|
|
`overhead_anchor`), so "the bubble is jittery when running" is the same four-way question and
|
|
deserves the same arithmetic rather than a second script that drifts from this one. The bubble has
|
|
no anti-overlap solver, so its lines carry no `solved=` stage and the SOLVE section is skipped; the
|
|
ANCHOR section below is the one that matters for it, because the bubble's height rides the *posed*
|
|
head attachment and therefore bobs with the run cycle:
|
|
|
|
WOW_MOVE_TRACE=/tmp/bub.trace WOW_MOVE_TRACE_TAGS=bub \
|
|
WOW_PROBE_CHAT="hello" WOW_PROBE_CHAT_AT=20 WOW_PROBE_KEY="W@20:6" ... cargo run -q -p benilla
|
|
python3 scripts/vplsum.py /tmp/bub.trace --from 20 --to 26
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
|
|
# One regex for both overlays: the `solved=` stage is the plate's anti-overlap solver, which the
|
|
# bubble has no counterpart to, so it is optional and the final rect is `plate=` or `frame=`.
|
|
LINE = re.compile(
|
|
r"t=\s*([\d.]+) (vpl|bub)\s+e=(\d+) vp=\((\d+),(\d+)\) anchor=\[([-\d.,e]+)\] "
|
|
r"cam=\[([-\d.,e]+)\] fwd=\[([-\d.,e]+)\] scr=\(([-\d.e]+),([-\d.e]+)\) "
|
|
r"(?:solved=\(([-\d.e]+),([-\d.e]+)\) )?(?:plate|frame)=\(([-\d.e]+),([-\d.e]+)\)"
|
|
)
|
|
|
|
# A step this far past the plate's own projected motion is a JUMP, not a glide: about a quarter of
|
|
# the plate's height, i.e. the smallest relocation a viewer reads as the plate moving by itself.
|
|
JUMP_PX = 4.0
|
|
# Frames further apart than this are not consecutive — the plate blinked out and came back, and
|
|
# differencing across the gap would invent a jump.
|
|
GAP_S = 0.1
|
|
|
|
|
|
def vec3(s):
|
|
return [float(v) for v in s.split(",")]
|
|
|
|
|
|
def load(path, lo, hi):
|
|
"""-> (tag, viewport, {entity: [(t, anchor, cam, scr, solved, rect)]}, {t: overlay count})
|
|
|
|
A bubble line has no solver stage; its `solved` slot mirrors the raw point, which makes the
|
|
SOLVE arithmetic below identically zero rather than special-cased (the section is skipped).
|
|
"""
|
|
rows, frames, vp, tag = defaultdict(list), defaultdict(int), None, None
|
|
for line in open(path):
|
|
m = LINE.match(line)
|
|
if not m:
|
|
continue
|
|
t = float(m.group(1))
|
|
if not (lo <= t <= hi):
|
|
continue
|
|
tag = m.group(2)
|
|
vp = (float(m.group(4)), float(m.group(5)))
|
|
frames[t] += 1
|
|
scr = (float(m.group(9)), float(m.group(10)))
|
|
solved = (
|
|
(float(m.group(11)), float(m.group(12)))
|
|
if m.group(11) is not None
|
|
else scr
|
|
)
|
|
rows[int(m.group(3))].append(
|
|
(
|
|
t,
|
|
vec3(m.group(6)),
|
|
vec3(m.group(7)),
|
|
scr,
|
|
solved,
|
|
(float(m.group(13)), float(m.group(14))),
|
|
)
|
|
)
|
|
for s in rows.values():
|
|
s.sort()
|
|
return tag, vp, rows, frames
|
|
|
|
|
|
def stat(vals, fmt="{:6.3f}"):
|
|
if not vals:
|
|
return " —"
|
|
v = sorted(abs(x) for x in vals)
|
|
return " ".join(
|
|
fmt.format(x) for x in (v[len(v) // 2], v[int(0.9 * (len(v) - 1))], v[-1])
|
|
)
|
|
|
|
|
|
def on_screen(p, vp):
|
|
return 0.0 <= p[0] <= vp[0] and 0.0 <= p[1] <= vp[1]
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("trace")
|
|
ap.add_argument("--from", dest="lo", type=float, default=0.0)
|
|
ap.add_argument("--to", dest="hi", type=float, default=1e9)
|
|
ap.add_argument("--worst", type=int, default=10, help="how many jumps to name")
|
|
a = ap.parse_args()
|
|
|
|
tag, vp, rows, frames = load(a.trace, a.lo, a.hi)
|
|
if not rows:
|
|
sys.exit(
|
|
f"{a.trace}: no `vpl`/`bub` lines in [{a.lo}, {a.hi}] — "
|
|
"was WOW_MOVE_TRACE_TAGS=vpl (or =bub) set?"
|
|
)
|
|
what = "plates" if tag == "vpl" else "bubbles"
|
|
|
|
ts = sorted(frames)
|
|
dts = sorted(b - a_ for a_, b in zip(ts, ts[1:]) if b - a_ < GAP_S)
|
|
drawn = sum(len(s) for s in rows.values())
|
|
off = sum(1 for s in rows.values() for r in s if not on_screen(r[3], vp))
|
|
print(
|
|
f"viewport {vp[0]:.0f}x{vp[1]:.0f} frames {len(ts)} in [{ts[0]:.2f},{ts[-1]:.2f}] "
|
|
f"dt med {1000 * dts[len(dts) // 2]:.1f} ms {what}/frame {drawn / len(ts):.2f}"
|
|
)
|
|
print(f"OFF-SCREEN {what} drawn: {off}/{drawn} ({100 * off / drawn:.1f}%) [must be 0]")
|
|
|
|
snap, jumps, steps = [], [], 0
|
|
for e, s in rows.items():
|
|
for p, q in zip(s, s[1:]):
|
|
if q[0] - p[0] > GAP_S:
|
|
continue
|
|
steps += 1
|
|
d_scr = [q[3][i] - p[3][i] for i in (0, 1)]
|
|
d_sol = [q[4][i] - p[4][i] for i in (0, 1)]
|
|
d_pl = [q[5][i] - p[5][i] for i in (0, 1)]
|
|
snap.append(max(abs(d_pl[i] - d_sol[i]) for i in (0, 1)))
|
|
solve = max(abs(d_sol[i] - d_scr[i]) for i in (0, 1))
|
|
if solve >= JUMP_PX:
|
|
jumps.append((solve, q[0], e, p[5], q[5], p[3], q[3]))
|
|
print(f"SNAP extra px/frame (med p90 max): {stat(snap)} [≤ half a device pixel]")
|
|
if tag == "vpl":
|
|
print(
|
|
f"SOLVE jumps ≥ {JUMP_PX:.0f} px: {len(jumps)}/{steps} steps "
|
|
f"({100 * len(jumps) / max(steps, 1):.2f}%)"
|
|
)
|
|
for j in sorted(jumps, reverse=True)[: a.worst]:
|
|
print(
|
|
f" t={j[1]:8.3f} e={j[2]:<5d} {j[0]:7.1f} px "
|
|
f"plate ({j[3][0]:7.1f},{j[3][1]:6.1f}) -> ({j[4][0]:7.1f},{j[4][1]:6.1f}) "
|
|
f"scr ({j[5][0]:8.1f},{j[5][1]:7.1f}) -> ({j[6][0]:8.1f},{j[6][1]:7.1f})"
|
|
)
|
|
|
|
# The world anchor's own motion, split by axis. The HORIZONTAL term is the unit gliding; the
|
|
# VERTICAL term is what the run cycle does to the posed head attachment (plus terrain). A
|
|
# standing unit reads 0.0000 in both — which is exactly why 1341 cleared the anchor for the
|
|
# plate and why that clearance says nothing about a unit that is RUNNING (1398).
|
|
print("\nANCHOR motion — the world point's own Δ per frame, yd (med p90 max):")
|
|
for e, s in sorted(rows.items(), key=lambda kv: -len(kv[1]))[:8]:
|
|
pairs = [(a_, b) for a_, b in zip(s, s[1:]) if b[0] - a_[0] < GAP_S]
|
|
if len(pairs) < 10:
|
|
continue
|
|
horiz = [
|
|
((b[1][0] - a_[1][0]) ** 2 + (b[1][2] - a_[1][2]) ** 2) ** 0.5 for a_, b in pairs
|
|
]
|
|
vert = [b[1][1] - a_[1][1] for a_, b in pairs]
|
|
ys = [r[1][1] for r in s]
|
|
print(
|
|
f" e={e:<5d} n={len(pairs):<5d} horiz {stat(horiz, '{:7.4f}')} "
|
|
f"vert {stat(vert, '{:7.4f}')} vert span {max(ys) - min(ys):.4f} yd"
|
|
)
|
|
|
|
print("\nPROJECTION smoothness — |Δ| and |ΔΔ| of the raw point, per unit (med p90 max):")
|
|
for e, s in sorted(rows.items(), key=lambda kv: -len(kv[1]))[:8]:
|
|
vis = [r for r in s if on_screen(r[3], vp)]
|
|
if len(vis) < 30:
|
|
continue
|
|
d = [
|
|
[b[3][i] - a_[3][i] for i in (0, 1)]
|
|
for a_, b in zip(vis, vis[1:])
|
|
if b[0] - a_[0] < GAP_S
|
|
]
|
|
dd = [[b[i] - a_[i] for i in (0, 1)] for a_, b in zip(d, d[1:])]
|
|
anchor = max(
|
|
(abs(b[1][i] - a_[1][i]) for a_, b in zip(s, s[1:]) for i in (0, 1)), default=0.0
|
|
)
|
|
print(
|
|
f" e={e:<5d} n={len(vis):<5d} anchor {anchor:6.4f} yd/frame "
|
|
f"|Δx| {stat([v[0] for v in d])} |ΔΔx| {stat([v[0] for v in dd])} "
|
|
f"|ΔΔy| {stat([v[1] for v in dd])}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|