Skip to content

Instantly share code, notes, and snippets.

@jun-lsh
Created May 31, 2026 10:59
Show Gist options
  • Select an option

  • Save jun-lsh/78b7298927a267b7d27bf9ab3f254cf0 to your computer and use it in GitHub Desktop.

Select an option

Save jun-lsh/78b7298927a267b7d27bf9ab3f254cf0 to your computer and use it in GitHub Desktop.
claire code boat movemint script
"""2bird2can — voyage: wind-pump + leapfrog relay in one process.
The loop (the original strategy, just with correct boat-id tracking):
1. spawn a boat; let it ride the leaked wind until it gets STUCK
2. spawn a NEW boat (BOTH alive now -> the new one anchors to the old frontier,
spawning ~10u away via GetSpawnPositionForJoin)
3. after a short delay (--handoff-ms), during which we LEARN the new boat's id,
kill the old boat (so the new one becomes the oldest and the NEXT spawn
anchors to it -> the frontier walks forward)
4. the new boat is now the frontier; let it ride until it stucks; repeat
We ALWAYS commit to the new boat. It may spawn straight onto a rock and be stuck
immediately — no harm, the monitor just detects that and we hand off again. The
~10u spawn offset random-walks the anchor, which is what eventually clears a rock
pocket. No reroll / no "must be moving to commit" gate.
The ONLY clever bit is identifying the new boat's id at handoff (this is what v1
got wrong, blending all boats / latching None). We pick the new id (!= old) that
spawned closest to the anchor and traces smoothly. Per-boat isolation by id
(bytes --boat-offset.., from boat_id.py) keeps tracking clean; filter_id(None)
returns [] so an unidentified frontier never blends; a >--max-jump step is
dropped so a misaligned/teleport packet can't pollute the track.
boat0 is the one special case: it spawns at origin where parked junk also sits,
so it must be seen MOVING (not just identified) to confirm.
This runs the leak phase ITSELF (no separate windpump), then drains and sails —
all under ONE wall clock, so the t+/avg/ETA readouts include the pump time and
reflect your real budget. The pump auto-aims at the bearing from the origin
(boats spawn at 0,0) to --flag unless you pass an explicit --target.
If dist-to-flag climbs during travel, the wind is aimed wrong; re-run with a
corrected --target (or --skip-pump if the wind is already leaked).
Usage:
python voyage.py --flag "117368 56984" [--until-wind 110] [--headless]
python voyage.py --flag "117368 56984" --skip-pump # wind already leaked
"""
import argparse
import asyncio
import math
import time
FLAG_HASH_HEX = "cfbf2907" # FlagChest.SendFlagClientRpc hash 0x0729BFCF, little-endian
INIT_JS = r"""
(() => {
if (window._uiPatched) return;
window._uiPatched = true;
const wrap = (fn)=>{ if(fn&&fn.__wrapped) return fn;
const w=function(){ return fn.apply(this,arguments).then(i=>{window.unityInstance=i;return i;}); };
w.__wrapped=true; return w; };
const tryWrap=()=>{ const f=window.createUnityInstance;
if(typeof f==='function'&&!f.__wrapped){ try{window.createUnityInstance=wrap(f);}catch(e){} } };
tryWrap();
const _iv=setInterval(()=>{ tryWrap(); if(window.unityInstance) clearInterval(_iv); },1);
setTimeout(()=>clearInterval(_iv),60000);
window._raw = []; // [x,y,t_ms,nid] position points (this connection's view)
window._gotFlagHex = null;
window._exploiting = false; // pump pages set this true -> the send-hook leaks wind & closes
// ---- protocol-correct position parse (from NetworkVariableDeltaMessage.Serialize) ----
// body: bp NetObjId(u64), bp behaviourIndex(u16), +4 NetworkDelivery, then per
// variable a 1-byte presence bool followed by the value if set. index 0 = position
// (Vec2, 8B), index 1 = facingAngle (float, 4B). We read index 0 only.
function _bpU64(u8, pos){ const b0=u8[pos], n=b0&7;
if(n===0||pos+n>u8.length) return null;
let v=0; for(let k=0;k<n;k++) v += u8[pos+k]*Math.pow(256,k);
return [Math.floor(v/8), pos+n]; }
function _bpU16(u8, pos){ const b0=u8[pos], n=b0&3;
if(n===1) return [b0>>2, pos+1];
if(n===2) return [((u8[pos+1]<<8)|b0)>>2, pos+2];
if(n===3) return [u8[pos+1]|(u8[pos+2]<<8), pos+3];
return null; }
// TWO MODES. Discovery (no _trackNid): BROAD scan -> every id incl. garbage, but
// catches the fast boat's BATCHED travel frames so we can identify it WHILE it's
// still moving (Python rejects garbage by requiring a smooth, moving track).
// Tracking (_trackNid set): scan for THAT id only -> dense + garbage-free.
function _scanAll(u8){
const out=[]; const dv=new DataView(u8.buffer,u8.byteOffset,u8.byteLength);
const hi=u8.length-12;
for(let S=0;S<=hi;S++){
let r=_bpU64(u8,S); if(!r) continue; const nid=r[0]; let pos=r[1];
if(nid<1||nid>100000) continue;
r=_bpU16(u8,pos); if(!r) continue; pos=r[1]; // behaviourIndex
pos+=4; // NetworkDelivery (targetVersion>0)
// variable section: per-var 1-byte presence bool (NGO non-DA). index 0 = position (Vec2).
if(pos>=u8.length) continue;
const present0=u8[pos]; pos+=1;
if(present0!==1) continue; // position not in this delta -> skip
if(pos+8>u8.length) continue;
const x=dv.getFloat32(pos,true), y=dv.getFloat32(pos+4,true);
if(!Number.isFinite(x)||!Number.isFinite(y)||Math.abs(x)>5e5||Math.abs(y)>5e5) continue;
out.push([x,y,nid]);
}
return out;
}
function _scanFor(u8, target){
const out=[]; const dv=new DataView(u8.buffer,u8.byteOffset,u8.byteLength);
const hi=u8.length-12;
for(let S=0;S<=hi;S++){
let r=_bpU64(u8,S); if(!r) continue; if(r[0]!==target) continue; let pos=r[1];
r=_bpU16(u8,pos); if(!r) continue; pos=r[1]; // behaviourIndex
pos+=4; // NetworkDelivery (targetVersion>0)
// variable section: per-var 1-byte presence bool (NGO non-DA). index 0 = position (Vec2).
if(pos>=u8.length) continue;
const present0=u8[pos]; pos+=1;
if(present0!==1) continue; // position not in this delta -> skip
if(pos+8>u8.length) continue;
const x=dv.getFloat32(pos,true), y=dv.getFloat32(pos+4,true);
if(!Number.isFinite(x)||!Number.isFinite(y)||Math.abs(x)>5e5||Math.abs(y)>5e5) continue;
out.push([x,y,target]);
}
return out;
}
const OrigWS=window.WebSocket;
function Hook(url,p){
const ws=new OrigWS(url,p); window._lastWs=ws;
// ---- wind-leak primitive (pump phase only): on the FIRST non-zero Move frame,
// mark + close the socket within the disconnect grace so the input sticks in
// _globalWindAccum. Inert unless window._exploiting is set on this page. ----
ws._gotMove=false;
const realSend=OrigWS.prototype.send.bind(ws);
ws.send=function(data){
let s8;
if(data instanceof ArrayBuffer) s8=new Uint8Array(data);
else if(ArrayBuffer.isView(data)) s8=new Uint8Array(data.buffer.slice(data.byteOffset,data.byteOffset+data.byteLength));
if(window._exploiting && ws._gotMove) return; // swallow extra sends post-leak
if(window._exploiting && s8 && s8.length===70){
const hex=Array.from(s8).map(b=>b.toString(16).padStart(2,'0')).join('');
const i=hex.indexOf('d4edbb6c'); // Move RPC, LE hash
if(i>=0 && hex.substr(i+8,16)!=='0000000000000000'){ ws._gotMove=true; setTimeout(()=>{try{ws.close();}catch(e){}},25); }
}
return realSend(data);
};
ws.addEventListener('message', e=>{
let u8;
if(e.data instanceof ArrayBuffer) u8=new Uint8Array(e.data);
else if(ArrayBuffer.isView(e.data)) u8=new Uint8Array(e.data.buffer.slice(e.data.byteOffset,e.data.byteOffset+e.data.byteLength));
if(!u8) return;
if(!window._gotFlagHex){
const hex=Array.from(u8).map(b=>b.toString(16).padStart(2,'0')).join('');
if(hex.includes('""" + FLAG_HASH_HEX + r"""')) window._gotFlagHex=hex;
}
const tn=window._trackNid;
if(tn!=null){
for(const pp of _scanFor(u8, tn)) window._raw.push([pp[0],pp[1],performance.now(),String(pp[2])]);
} else {
for(const pp of _scanAll(u8)) window._raw.push([pp[0],pp[1],performance.now(),String(pp[2])]);
}
if(window._raw.length>2000) window._raw.splice(0, window._raw.length-2000);
});
return ws;
}
Hook.prototype=OrigWS.prototype; Object.setPrototypeOf(Hook,OrigWS);
Hook.OPEN=OrigWS.OPEN;Hook.CLOSED=OrigWS.CLOSED;Hook.CONNECTING=OrigWS.CONNECTING;Hook.CLOSING=OrigWS.CLOSING;
window.WebSocket=Hook;
})();
"""
LOADER_APPEND = r"""
;(function(){ try {
if (typeof createUnityInstance === 'function' && !createUnityInstance.__wrapped) {
var __r=createUnityInstance;
var __w=function(){ return __r.apply(this,arguments).then(function(i){window.unityInstance=i;return i;}); };
__w.__wrapped=true; createUnityInstance=__w; try{window.createUnityInstance=__w;}catch(e){}
}
} catch(e){} })();
"""
def parse_xy(s):
if not s:
return None
parts = s.replace(",", " ").split()
try:
return (float(parts[0]), float(parts[1])) if len(parts) == 2 else None
except ValueError:
return None
def linreg(ts, vs):
n = len(ts)
if n < 2:
return 0.0, (vs[-1] if vs else 0.0)
mt = sum(ts) / n
mv = sum(vs) / n
den = sum((t - mt) ** 2 for t in ts)
if den < 1e-12:
return 0.0, mv
s = sum((t - mt) * (v - mv) for t, v in zip(ts, vs)) / den
return s, mv - s * mt
# ---- pump aiming (from windpump.py): leak global wind toward a bearing using ONE
# cardinal key per cycle; the error-feedback picker alternates cardinals so the
# CUMULATIVE wind points where we want (the headless driver fumbles 2-key holds).
SQ = 1.0 / math.sqrt(2.0)
OCTANTS = [
("E", ["d"], (1.0, 0.0)),
("N", ["w"], (0.0, 1.0)),
("W", ["a"], (-1.0, 0.0)),
("S", ["s"], (0.0, -1.0)),
]
TOKEN_VEC = {
"E": (1.0, 0.0), "N": (0.0, 1.0), "W": (-1.0, 0.0), "S": (0.0, -1.0),
"NE": (SQ, SQ), "NW": (-SQ, SQ), "SE": (SQ, -SQ), "SW": (-SQ, -SQ),
}
def parse_target(s, fallback):
if not s:
return fallback
s = s.strip()
if s.upper() in TOKEN_VEC:
return TOKEN_VEC[s.upper()]
parts = s.replace(",", " ").split()
try:
if len(parts) == 2 and parts[0].lower() == "angle":
a = math.radians(float(parts[1]))
return (math.cos(a), math.sin(a))
if len(parts) == 2:
x, y = float(parts[0]), float(parts[1])
m = math.hypot(x, y)
if m > 1e-9:
return (x / m, y / m)
except ValueError:
pass
return fallback
def read_target(path, fallback):
try:
with open(path) as f:
return parse_target(f.read(), fallback)
except OSError:
return fallback
def pick_octant(acc, target, n):
ideal = (target[0] * (n + 1), target[1] * (n + 1))
err = (ideal[0] - acc[0], ideal[1] - acc[1])
if err == (0.0, 0.0):
err = target
return max(OCTANTS, key=lambda o: o[2][0] * err[0] + o[2][1] * err[1])
def filter_id(raw, only_id):
# None means "no boat locked" -> NO data (never fall back to all = blend).
if only_id is None:
return []
return [p for p in raw if len(p) > 3 and p[3] == only_id]
def clean_seq(pts, max_jump):
"""Drop points that jump > max_jump from the last kept point (kills single
teleport/misaligned packets). pts are time-ordered (x,y,t,...)."""
out = []
for p in pts:
if not out or math.hypot(p[0] - out[-1][0], p[1] - out[-1][1]) <= max_jump:
out.append(p)
return out
def boat_progress(raw, now, only_id, window_ms, max_jump, stale_ms):
"""Position/velocity for one boat, measured RELATIVE TO NOW. If the latest
frame is older than stale_ms, the position NetVar hasn't gone dirty -> the
boat hasn't moved -> report STATIC (v=0). This kills the v2 bug where a frozen
boat kept replaying the last batch of moving frames forever (|v| stuck at
e.g. 233 while pos and dist never changed), because the fit was anchored to
the last frame's timestamp instead of the wall clock."""
mine = clean_seq(filter_id(raw, only_id), max_jump)
if not mine:
return None
lx, ly, last_t = mine[-1][0], mine[-1][1], mine[-1][2]
age = now - last_t
recent = [(x, y, t) for (x, y, t, *_) in mine if now - t <= window_ms]
if age > stale_ms or len(recent) < 2:
# no fresh frames -> position NetVar isn't dirtying -> boat is static
return {"x": lx, "y": ly, "vx": 0.0, "vy": 0.0, "n": len(recent),
"age": age, "disp": 0.0, "span_ms": 0.0, "static": True}
ts = [t / 1000.0 for (_, _, t) in recent]
xs = [x for (x, _, _) in recent]
ys = [y for (_, y, _) in recent]
vx, _ = linreg(ts, xs)
vy, _ = linreg(ts, ys)
return {"x": xs[-1], "y": ys[-1], "vx": vx, "vy": vy, "n": len(recent),
"age": age, "disp": math.hypot(xs[-1] - xs[0], ys[-1] - ys[0]),
"span_ms": recent[-1][2] - recent[0][2], "static": False}
def confirm_pick(raw, exclude, anchor, radius, min_frames, min_move, max_jump):
"""boat0-at-origin confirm: the real boat is the one actually MOVING (parked
junk doesn't exist anymore now that we parse real positions, but requiring
motion still cleanly picks the boat we just spawned and launched). Newest id
that isn't `exclude`, >= min_frames, smooth, displacement >= min_move.
(anchor/radius kept in signature but unused — id is real, no geometry needed.)"""
by = {}
for p in raw:
if len(p) > 3 and p[3] != exclude:
by.setdefault(p[3], []).append(p)
best = None
for idh, pts in by.items():
pts = sorted(pts, key=lambda q: q[2])
if len(pts) < min_frames:
continue
steps = [math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1)]
if steps and max(steps) > max_jump:
continue
disp = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1])
if disp < min_move:
continue
ni = _nid_int(idh)
if best is None or ni > best[0]:
best = (ni, idh)
return best[1] if best else None
def _nid_int(idh):
try:
return int(idh)
except (TypeError, ValueError):
return -1
def identify_pick(raw, exclude, anchor, radius, min_frames, max_jump, min_travel=40.0):
"""Identify the just-spawned boat from BROAD-scan discovery data (which contains
(0,0)/random garbage ids). The real traveling boat is the one whose track is
SMOOTH and actually MOVED (displacement >= min_travel) — garbage sits at ~(0,0)
with zero displacement and gets rejected. Among qualifying ids, pick the one with
the most frames (the dominant real boat). This catches a fast boat WHILE it's
still moving, so we can lock onto it before it wedges. (anchor/radius unused.)"""
by = {}
for p in raw:
if len(p) > 3 and p[3] != exclude:
by.setdefault(p[3], []).append(p)
best = None # (nframes, idh, pts)
for idh, pts in by.items():
pts = sorted(pts, key=lambda q: q[2])
if len(pts) < min_frames:
continue
steps = [math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1)]
if steps and max(steps) > max_jump:
continue # teleporter / misaligned packet
disp = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1])
if disp < min_travel:
continue # not moving -> garbage (0,0) jitter
if best is None or len(pts) > best[0]:
best = (len(pts), idh, pts)
return (0.0, best[1], best[2]) if best else None
def flag_from_hex(hex_str):
try:
raw = bytes.fromhex(hex_str)
except ValueError:
return None
ascii_run = "".join(chr(b) if 32 <= b < 127 else "." for b in raw)
i = ascii_run.find("bbb{")
if i >= 0:
j = ascii_run.find("}", i)
if j >= 0:
return ascii_run[i:j + 1]
return ascii_run
class Boat:
def __init__(self, page, n, boat_id=None):
self.page, self.n, self.boat_id = page, n, boat_id
self.connected_at = time.time()
self.x = self.y = None
async def main():
from playwright.async_api import async_playwright
ap = argparse.ArgumentParser(description="2bird2can leapfrog relay (candidate-commit)")
ap.add_argument("--flag", default=None, help='flag world pos "X Y" for distance readout')
ap.add_argument("--wind", type=float, default=0.0, help="expected |wind| (speed sanity)")
ap.add_argument("--url", default="http://localhost:3000")
# ---- pump (leak) phase ----
ap.add_argument("--target", default=None,
help="pump aim: octant (NE), vector ('117368 56984'), or 'angle 26'. "
"default = bearing from origin to --flag")
ap.add_argument("--pump-workers", type=int, default=3,
help="parallel pump slots during the leak phase (<=4; they close before travel)")
ap.add_argument("--until-wind", type=float, default=110.0,
help="pump until |wind| reaches this (0 = use --pump-secs)")
ap.add_argument("--pump-secs", type=float, default=0.0,
help="pump for this many seconds instead of a wind target (0 = use --until-wind)")
ap.add_argument("--drain", type=float, default=1.2,
help="sec after the pump for the disconnect grace so leaked wind persists")
ap.add_argument("--target-file", default=None, help="optional live re-aim file for the pump")
ap.add_argument("--skip-pump", action="store_true", default=False,
help="skip the leak phase (wind already leaked from a previous run)")
ap.add_argument("--every", type=float, default=350.0, help="monitor interval ms")
ap.add_argument("--stale-ms", type=float, default=800.0, help="no new frame this long => frozen")
ap.add_argument("--min-progress", type=float, default=12.0, help="frontier displacement over fit window below this => stuck")
ap.add_argument("--settle-ms", type=float, default=1500.0, help="grace after commit before stuck can fire")
ap.add_argument("--confirm-ms", type=float, default=2500.0, help="boat0 only: window to confirm a MOVING boat at origin")
ap.add_argument("--handoff-ms", type=float, default=1000.0, help="both-alive window to learn the new boat's id before killing the old")
ap.add_argument("--ident-frames", type=int, default=2, help="min frames to identify the freshly-spawned boat at handoff")
ap.add_argument("--fit-window", type=float, default=1500.0, help="ms of frames for velocity/progress")
ap.add_argument("--msg-start", type=int, default=48, help="byte offset of the position message body in the frame (from derive_layout.py)")
ap.add_argument("--learn-radius", type=float, default=150.0,
help="max MIN-distance of the new boat's track from the anchor (it spawns ~10u away; "
"keeps us from grabbing a far-off non-boat frame)")
ap.add_argument("--confirm-frames", type=int, default=4, help="boat0 only: min frames to confirm")
ap.add_argument("--confirm-move", type=float, default=20.0, help="boat0 only: min displacement to count as moving")
ap.add_argument("--max-jump", type=float, default=3000.0, help="single-step jump above this = misaligned/teleport, dropped (raise for very fast/sparse boats)")
ap.add_argument("--min-travel", type=float, default=40.0, help="min track displacement to accept an id during discovery (rejects (0,0) garbage)")
ap.add_argument("--reach", type=float, default=8.0, help="stop handing off / park if within this of flag")
ap.add_argument("--headless", action="store_true", default=False)
args = ap.parse_args()
flag = parse_xy(args.flag)
async def wrap_loader(route):
try:
resp = await route.fetch(); body = await resp.text()
await route.fulfill(status=resp.status, content_type="application/javascript",
body=body + LOADER_APPEND)
except Exception:
try: await route.continue_()
except Exception: pass
async with async_playwright() as p:
browser = await p.chromium.launch(headless=args.headless)
session_start = time.time() # ONE clock for pump + drain + travel
# pump aim: explicit --target wins; else bearing from origin (spawn) to flag
if args.target:
pump_dir = parse_target(args.target, (SQ, SQ))
elif flag:
_m = math.hypot(*flag)
pump_dir = (flag[0] / _m, flag[1] / _m) if _m > 1e-9 else (SQ, SQ)
else:
pump_dir = (SQ, SQ)
async def pump_phase(direction):
"""Leak global wind toward `direction` until --until-wind / --pump-secs,
on N independent slots, then close + drain so the wind persists. Shares
session_start, so the leak time counts against the budget."""
acc = [0.0, 0.0]
stats = {"n": 0, "fails": 0, "stop": False}
slots = []
for wid in range(args.pump_workers):
pctx = await browser.new_context(viewport={"width": 800, "height": 600})
await pctx.add_init_script(INIT_JS)
await pctx.route("**/*loader.js", wrap_loader)
pg = await pctx.new_page()
pg.on("pageerror", lambda e: None)
await pg.goto(args.url)
ok = False
for _ in range(80):
if await pg.evaluate("() => !!window.unityInstance"):
await pg.evaluate("() => { window._exploiting = true; }")
ok = True
break
await asyncio.sleep(0.5)
if ok:
slots.append((wid, pg, pctx))
else:
print(f"[pump w{wid}] failed to load Unity; skipping", flush=True)
await pctx.close()
if not slots:
print("[pump] no slots loaded — is the stack up? skipping leak phase.", flush=True)
return
deadline = (time.time() + args.pump_secs) if args.pump_secs else None
bearing0 = math.degrees(math.atan2(direction[1], direction[0]))
goal = (f"|wind|>={args.until_wind}" if args.until_wind else f"{args.pump_secs:.0f}s")
print(f"[pump] leaking toward {bearing0:+.1f}deg on {len(slots)} slot(s) until {goal}...", flush=True)
async def pworker(wid, pg):
await asyncio.sleep(wid * 0.7) # stagger to ease the 4-player cap
while not stats["stop"]:
if args.until_wind and math.hypot(*acc) >= args.until_wind:
break
if deadline and time.time() >= deadline:
break
tgt = read_target(args.target_file, direction) if args.target_file else direction
name, keys, vec = pick_octant(acc, tgt, stats["n"])
try:
await pg.evaluate("() => window.unityInstance && window.unityInstance.SendMessage('MainMenu','HandleConnect')")
opened = False
for _ in range(40):
if (await pg.evaluate("() => window._lastWs && window._lastWs.readyState")) == 1:
opened = True
break
await asyncio.sleep(0.1)
if not opened:
raise RuntimeError("ws never opened (cap hit?)")
await asyncio.sleep(1.4)
await pg.focus("#unity-canvas")
for k in keys:
await pg.keyboard.down(k)
await asyncio.sleep(0.25)
for k in keys:
await pg.keyboard.up(k)
await asyncio.sleep(0.4)
except Exception as e:
stats["fails"] += 1
print(f" [pump w{wid}] cycle fail: {e}", flush=True)
await asyncio.sleep(0.5)
continue
acc[0] += vec[0]; acc[1] += vec[1]; stats["n"] += 1
mag = math.hypot(*acc)
el = time.time() - session_start
print(f" [t+{el:6.0f}s pump w{wid}] cycle {stats['n']:>4} press {name:<2} "
f"|wind|~{mag:6.1f} bearing {math.degrees(math.atan2(acc[1], acc[0])):+6.1f}deg "
f"vel~{7*mag:7.0f} u/s (fails {stats['fails']})", flush=True)
tasks = [asyncio.create_task(pworker(wid, pg)) for (wid, pg, _c) in slots]
try:
await asyncio.gather(*tasks)
finally:
stats["stop"] = True
for t in tasks:
t.cancel()
for (_w, pg, _c) in slots:
try:
await pg.evaluate("() => { try { if (window._lastWs && window._lastWs.readyState <= 1) window._lastWs.close(); } catch(e){} window._exploiting=false; }")
except Exception:
pass
await asyncio.sleep(args.drain) # disconnect grace -> wind sticks in _globalWindAccum
for (_w, _pg, pctx) in slots:
try:
await pctx.close()
except Exception:
pass
mag = math.hypot(*acc)
bear = math.degrees(math.atan2(acc[1], acc[0])) if mag else 0.0
print(f"[pump] done: {stats['n']} cycles, |wind|~{mag:.1f} bearing {bear:+.1f}deg "
f"vel~{7*mag:.0f} u/s; drained. sailing now.\n", flush=True)
if not args.skip_pump:
await pump_phase(pump_dir)
else:
print("[pump] skipped (--skip-pump); assuming wind already leaked.\n", flush=True)
ctx = await browser.new_context(viewport={"width": 900, "height": 600})
await ctx.add_init_script(INIT_JS)
await ctx.route("**/*loader.js", wrap_loader)
async def load_page():
page = await ctx.new_page()
page.on("pageerror", lambda e: None)
await page.goto(args.url)
for _ in range(80):
if await page.evaluate("() => !!window.unityInstance"):
return page
await asyncio.sleep(0.5)
return None
async def connect(page, n):
await page.evaluate("(cfg) => { window._raw=[]; window._gotFlagHex=null;"
" window._msgStart=cfg.s; window._trackNid=null; }",
{"s": args.msg_start})
await page.evaluate("() => window.unityInstance.SendMessage('MainMenu','HandleConnect')")
for _ in range(40):
if (await page.evaluate("() => window._lastWs && window._lastWs.readyState")) == 1:
break
await asyncio.sleep(0.1)
return Boat(page, n)
async def disconnect(page):
try:
await page.evaluate("() => { try { if (window._lastWs) window._lastWs.close(); } catch(e){} }")
except Exception:
pass
async def set_track(page, idh):
"""Lock the WS hook to ONE NetObjId -> dense, garbage-free tracking
(id-locked scan). None = discovery mode (clean fixed-offset parse)."""
val = "null" if idh is None else str(int(idh))
try:
await page.evaluate(f"() => {{ window._trackNid = {val}; }}")
except Exception:
pass
async def confirm(page, exclude, anchor):
"""Poll a candidate page until a near-anchor, moving, smooth boat id
appears. Returns (id, x, y) or None. Used ONLY for boat0 at origin,
where movement is what separates the real boat from parked junk."""
deadline = time.time() + args.confirm_ms / 1000.0
while time.time() < deadline:
raw = await page.evaluate("() => window._raw")
idh = confirm_pick(raw, exclude, anchor, args.learn_radius,
args.confirm_frames, args.confirm_move, args.max_jump)
if idh:
pts = clean_seq(filter_id(raw, idh), args.max_jump)
return idh, pts[-1][0], pts[-1][1]
await asyncio.sleep(0.12)
return None
async def learn_new(page, exclude, anchor):
"""Spawn-then-identify: poll the new boat's page for handoff_ms and
return (id, x, y) of the boat that just spawned at the anchor. No
movement needed — it's allowed to be stuck immediately. Returns the
best find even if it took the whole window; None only if nothing
identifiable appeared at all."""
deadline = time.time() + args.handoff_ms / 1000.0
found = None
while time.time() < deadline:
raw = await page.evaluate("() => window._raw")
pick = identify_pick(raw, exclude, anchor, args.learn_radius,
args.ident_frames, args.max_jump, args.min_travel)
if pick:
_, idh, pts = pick
found = (idh, pts[-1][0], pts[-1][1])
break
await asyncio.sleep(0.1)
return found
print("loading 2 game pages once (only slow step)...", flush=True)
pool = []
for i in range(2):
pg = await load_page()
if not pg:
print("ERROR: a page failed to load Unity (stack up?)"); await browser.close(); return
pool.append(pg)
print("pages loaded. relay running — no more reloads. Ctrl-C to stop.\n", flush=True)
# --- boat0: spawns at origin (no eligible player), anchor = (0,0). Must
# move to confirm (separates the real drifting boat from parked junk). ---
cur = 0
nextn = 0
frontier = None
while frontier is None:
cand = await connect(pool[cur], nextn)
res = await confirm(cand.page, exclude=None, anchor=(0.0, 0.0))
if res:
cand.boat_id, cand.x, cand.y = res
frontier = cand
await set_track(frontier.page, frontier.boat_id)
print(f"boat{nextn} CONFIRMED id {frontier.boat_id} at ({frontier.x:+.0f},{frontier.y:+.0f})\n", flush=True)
else:
print(f"boat{nextn} didn't confirm (no moving boat near origin — is wind leaked?); retrying...", flush=True)
await disconnect(cand.page)
nextn += 1
handoffs = 0
unidentified_streak = 0
won = False
start_dist = None # dist-to-flag at first reading (baseline for avg rate)
# session_start was set at launch so the pump time counts toward the budget
try:
while True:
try:
snap = await frontier.page.evaluate(
"() => ({raw: window._raw, flag: window._gotFlagHex, now: performance.now()})")
except Exception:
print("[frontier page died] reloading a replacement...", flush=True)
pg = await load_page()
if not pg:
break
pool[cur] = pg
frontier = None
while frontier is None:
cand = await connect(pool[cur], nextn)
res = await confirm(cand.page, None, (0.0, 0.0))
if res:
cand.boat_id, cand.x, cand.y = res; frontier = cand
await set_track(frontier.page, frontier.boat_id)
else:
await disconnect(cand.page)
nextn += 1
continue
if snap.get("flag"):
fx = snap["flag"]
print(f"\n*** FLAG RPC RECEIVED ***\n{flag_from_hex(fx)}\n(raw hex: {fx[:120]}...)", flush=True)
won = True
break
raw = snap.get("raw") or []
now = snap.get("now") or 0.0
since_commit = time.time() - frontier.connected_at
prog = boat_progress(raw, now, frontier.boat_id, args.fit_window, args.max_jump, args.stale_ms)
if prog:
frontier.x, frontier.y = prog["x"], prog["y"]
speed = math.hypot(prog["vx"], prog["vy"])
tag = " STATIC" if prog.get("static") else ""
elapsed = time.time() - session_start
line = f"[t+{elapsed:6.0f}s] boat{frontier.n}[{frontier.boat_id}]: pos=({prog['x']:+9.1f},{prog['y']:+9.1f}) |v|={speed:7.1f} u/s{tag}"
if flag:
dist = math.hypot(flag[0]-prog['x'], flag[1]-prog['y'])
if start_dist is None:
start_dist = dist
line += f" dist={dist:9.0f}"
made = start_dist - dist # net units closed since session start
if elapsed > 1.0 and made > 0:
vmg = made / elapsed # overall made-good speed toward flag
eta_min = (dist / vmg) / 60.0
line += f" avg={vmg:6.1f} u/s ETA={eta_min:5.1f}min"
else:
line += " avg= -- u/s ETA= --"
line += f" prog={prog['disp']:5.0f}u/{prog['span_ms']/1000:.1f}s age={prog['age']/1000:.2f}s"
print(line, flush=True)
else:
elapsed = time.time() - session_start
print(f"[t+{elapsed:6.0f}s] boat{frontier.n}[{frontier.boat_id}]: (no frames, {since_commit:0.1f}s since commit)", flush=True)
# parked on the flag: stop handing off, let reveal/interact fire here
if flag and prog and math.hypot(flag[0]-prog["x"], flag[1]-prog["y"]) < args.reach:
print(f"\n*** within {args.reach}u of flag — holding, waiting for RPC ***", flush=True)
await asyncio.sleep(args.every / 1000.0)
continue
# SPEED-TUNE HOOK: once consistent, throttle wind near the flag so
# per-tick step < ~10u to dwell inside r=10. For now: greedy ride.
settled = since_commit * 1000.0 > args.settle_ms
if prog is None:
frozen = since_commit * 1000.0 > (args.settle_ms + args.stale_ms)
crept = False
else:
frozen = prog.get("static", False)
crept = (not frozen and prog["n"] >= 4 and prog["span_ms"] >= 0.6 * args.fit_window
and prog["disp"] < args.min_progress)
stuck = settled and (frozen or crept)
if stuck:
anchor = (frontier.x, frontier.y) if frontier.x is not None else None
why = "frozen" if (prog is None or prog.get("static")) else "no-progress"
lastpos = f"({anchor[0]:+.0f},{anchor[1]:+.0f})" if anchor else "?"
nxt = 1 - cur
nextn += 1
# 1) spawn the new boat (BOTH alive now -> it anchors to the old frontier)
newb = await connect(pool[nxt], nextn - 1)
# 2) short delay: learn the new boat's id (it spawned ~10u from anchor)
res = await learn_new(newb.page, exclude=frontier.boat_id, anchor=anchor)
# 3) kill the old boat (new is now the oldest -> next spawn anchors to IT)
await disconnect(frontier.page)
# 4) the new boat is the frontier; let it ride until it stucks, then repeat
if res:
newb.boat_id, newb.x, newb.y = res
else:
# nothing identifiable yet (rare: spawned silent). adopt anyway with no
# id; next loop sees no frames -> frozen -> we hand off again. self-heals.
newb.boat_id, newb.x, newb.y = None, anchor[0] if anchor else None, anchor[1] if anchor else None
await set_track(newb.page, newb.boat_id)
frontier = newb
cur = nxt
handoffs += 1
if frontier.boat_id:
unidentified_streak = 0
else:
unidentified_streak += 1
idtxt = frontier.boat_id if frontier.boat_id else "unidentified"
atxt = f"({frontier.x:+.0f},{frontier.y:+.0f})" if frontier.x is not None else "?"
print(f" -> handoff #{handoffs} ({why} @ {lastpos}): boat{frontier.n} id {idtxt} at {atxt}", flush=True)
if unidentified_streak and unidentified_streak % 6 == 0:
print(f" !! {unidentified_streak} spawns in a row emitted no frames at {lastpos} — "
f"boats are wedging instantly. Wind is likely TOO STRONG: they slam into a rock "
f"and the ~10u respawn can't clear the pocket. Try a LOWER |wind|.", flush=True)
print(flush=True)
continue
await asyncio.sleep(args.every / 1000.0)
except (KeyboardInterrupt, asyncio.CancelledError):
print("\nstopping relay.", flush=True)
if not won:
elapsed = time.time() - session_start
made = (start_dist - math.hypot(flag[0]-frontier.x, flag[1]-frontier.y)) \
if (flag and start_dist is not None and frontier.x is not None) else None
extra = f" closed {made:.0f}u in {elapsed/60:.1f}min (~{made/elapsed:.1f} u/s avg)" \
if made else f" {elapsed/60:.1f}min elapsed"
print(f"\n[done] {handoffs} handoffs.{extra}. wind stays leaked; rerun to continue.", flush=True)
try:
await browser.close()
except Exception:
pass
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment