Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save jun-lsh/ecd7d231182d1559627a6c21bb593a4b to your computer and use it in GitHub Desktop.
2bird2can solve script
#!/usr/bin/env python3
import argparse
import concurrent.futures
import functools
import hashlib
import math
import os
import re
import struct
import subprocess
import sys
import time
import urllib.request
import zlib
import websocket
import xxhash
SIG = 0x01505455
PLAYER_HASH = 0x3D2E5A4B
FLAG_CHEST_HASH = 0x5D124AEB
GAME_MANAGER_HASH = 0x7866F579
CONFIG_HASH = 0x21DCDE43F7A3E2F0
MOVE_RPC = 0x6CBBEDD4
FLAG_RPC = 0x0729BFCF
CHUNK_RPC = 0x3A8A70E0
MASK48 = (1 << 48) - 1
SEED_LOW_MASK = (1 << 18) - 1
WORLD_RAND_MULT = 0x5DEECE66D
WORLD_RAND_ADD = 0xB
MIX_X = 0x4F9939F508
MIX_Y = 0x1EF1565BD5
MESSAGE_NAMES = [
"ConnectionApprovedMessage",
"ConnectionRequestMessage",
"ChangeOwnershipMessage",
"ClientConnectedMessage",
"ClientDisconnectedMessage",
"ClientRpcMessage",
"CreateObjectMessage",
"DestroyObjectMessage",
"DisconnectReasonMessage",
"ForwardClientRpcMessage",
"ForwardServerRpcMessage",
"NamedMessage",
"NetworkTransformMessage",
"NetworkVariableDeltaMessage",
"ParentSyncMessage",
"ProxyMessage",
"RpcMessage",
"SceneEventMessage",
"ServerLogMessage",
"ServerRpcMessage",
"TimeSyncMessage",
"UnnamedMessage",
"SessionOwnerMessage",
"AnticipationCounterSyncPingMessage",
"AnticipationCounterSyncPongMessage",
]
MESSAGE_VERSIONS = {
"ConnectionApprovedMessage": 2,
"ConnectionRequestMessage": 1,
"DestroyObjectMessage": 2,
"NetworkVariableDeltaMessage": 1,
}
def bitpack_uint(value, bits=32):
if bits == 16:
if value > (1 << 14) - 1:
return bytes([3]) + struct.pack("<H", value)
shifted = value << 2
elif bits == 32:
if value > (1 << 29) - 1:
return bytes([5]) + struct.pack("<I", value)
shifted = value << 3
elif bits == 64:
if value > (1 << 60) - 1:
return bytes([9]) + struct.pack("<Q", value)
shifted = value << 4
else:
raise ValueError(bits)
nbytes = max(1, (shifted.bit_length() + 7) // 8)
return int(shifted | nbytes).to_bytes(nbytes, "little")
def bitpack_int(value):
zigzag = ((value << 1) ^ (value >> 31)) & 0xFFFFFFFF
return bitpack_uint(zigzag, 32)
def read_bit_uint(data, pos, bits=32):
mask = {16: 3, 32: 7, 64: 15}[bits]
nbytes = data[pos] & mask
if bits == 16:
if nbytes == 3:
return struct.unpack_from("<H", data, pos + 1)[0], pos + 3
return int.from_bytes(data[pos : pos + nbytes], "little") >> 2, pos + nbytes
if bits == 32:
if nbytes == 5:
return struct.unpack_from("<I", data, pos + 1)[0], pos + 5
return int.from_bytes(data[pos : pos + nbytes], "little") >> 3, pos + nbytes
if nbytes == 9:
return struct.unpack_from("<Q", data, pos + 1)[0], pos + 9
return int.from_bytes(data[pos : pos + nbytes], "little") >> 4, pos + nbytes
def read_bit_int(data, pos):
raw, pos = read_bit_uint(data, pos, 32)
return ((raw >> 1) ^ -(raw & 1)), pos
def make_batch(message_type, body):
message = bitpack_uint(message_type, 32) + bitpack_uint(len(body), 32) + body
aligned = (16 + len(message) + 7) & ~7
payload = message + b"\x00" * (aligned - 16 - len(message))
return struct.pack("<HHIQ", 0x1160, 1, aligned, xxhash.xxh64(payload).intdigest()) + payload
def write_string(value):
return struct.pack("<I", len(value)) + value.encode("utf-16le")
def connection_request_body():
body = bitpack_int(len(MESSAGE_NAMES))
for name in MESSAGE_NAMES:
full_name = "Unity.Netcode." + name
body += struct.pack("<I", xxhash.xxh32(full_name.encode()).intdigest())
body += bitpack_int(MESSAGE_VERSIONS.get(name, 0))
body += struct.pack("<Q", CONFIG_HASH)
body += struct.pack("<I", 0)
return body
def server_rpc_body(network_object_id, behaviour_id, method_id, payload):
return (
bitpack_uint(network_object_id, 64)
+ bitpack_uint(behaviour_id, 16)
+ bitpack_uint(method_id, 32)
+ payload
)
def scene_sync_complete_body(scene_hash, scene_handle, object_ids):
body = bytearray()
body += b"\x08" # SceneEventType.SynchronizeComplete
body += b"\x00" # LoadSceneMode.Single
body += b"\x00" * 16 # default Guid; not used for synchronize-complete server handling
body += struct.pack("<I", scene_hash)
body += struct.pack("<i", scene_handle)
body += struct.pack("<I", len(object_ids))
for object_id in object_ids:
body += struct.pack("<I", object_id & 0xFFFFFFFF)
return bytes(body)
def reliable_ack_frame(token, sequence_id):
# ReliableUtility.PacketHeader + 8-byte ack mask + dummy payload byte.
header = struct.pack("<BBHHH", 1, 0, 0, 0, sequence_id & 0xFFFF)
return b"\x01" + token + b"\x03" + header + (b"\xff" * 8) + b"\x00"
def simple_data_frame(token, pipeline_id, payload):
return b"\x01" + token + bytes([pipeline_id]) + payload
def batch_frame(token, message_type, body):
batch = make_batch(message_type, body)
return simple_data_frame(token, 0, struct.pack("<i", len(batch)) + batch)
def extract_batches(packet, token):
if not isinstance(packet, bytes) or len(packet) < 10:
return []
if packet[0] != 1 or packet[1:9] != token:
return []
pipeline_id = packet[9]
payload = packet[10:]
batches = []
reliable_sequence = None
if pipeline_id == 3 and len(payload) >= 16:
packet_type, _, _, reliable_sequence, _ = struct.unpack_from("<BBHHH", payload, 0)
if packet_type == 1:
return []
payload = payload[16:]
elif pipeline_id == 1 and len(payload) >= 2:
payload = payload[2:]
start = None
for off in range(min(40, max(0, len(payload) - 6))):
size = struct.unpack_from("<i", payload, off)[0]
if 16 <= size <= 100000 and off + 4 + size <= len(payload):
if payload[off + 4 : off + 6] == b"\x60\x11":
start = off
break
if start is None:
return []
pos = start
while pos + 4 <= len(payload):
size = struct.unpack_from("<i", payload, pos)[0]
if not (16 <= size <= 100000 and pos + 4 + size <= len(payload)):
break
batch = payload[pos + 4 : pos + 4 + size]
if batch[:2] != b"\x60\x11":
break
batches.append((pipeline_id, reliable_sequence, batch))
pos += 4 + size
return batches
def parse_messages(batch):
count = struct.unpack_from("<H", batch, 2)[0]
pos = 16
for _ in range(count):
message_type, pos = read_bit_uint(batch, pos, 32)
message_size, pos = read_bit_uint(batch, pos, 32)
body = batch[pos : pos + message_size]
pos += message_size
yield message_type, body
def parse_connection_approved(body):
try:
pos = 0
message_count, pos = read_bit_int(body, pos)
for _ in range(message_count):
pos += 4
_, pos = read_bit_int(body, pos)
owner_client_id, pos = read_bit_uint(body, pos, 64)
return owner_client_id
except (IndexError, struct.error):
return None
def parse_scene_sync(body, local_client_id=None):
pos = 0
event_type = body[pos]
pos += 1
if event_type != 2:
return None
pos += 1 # LoadSceneMode byte
pos += 4 # ClientSynchronizationMode
scene_hash = struct.unpack_from("<I", body, pos)[0]
pos += 4
scene_handle = struct.unpack_from("<i", body, pos)[0]
pos += 4
pos += 4 # active scene hash
scene_count = struct.unpack_from("<i", body, pos)[0]
pos += 4 + (4 * scene_count)
handle_count = struct.unpack_from("<i", body, pos)[0]
pos += 4 + (4 * handle_count)
sync_size = struct.unpack_from("<i", body, pos)[0]
pos += 4
sync_end = pos + sync_size
object_count = struct.unpack_from("<i", body, pos)[0]
pos += 4
object_ids = []
player_candidates = []
game_manager_object_id = None
for _ in range(object_count):
flags = struct.unpack_from("<H", body, pos)[0]
pos += 2
object_hash = struct.unpack_from("<I", body, pos)[0]
pos += 4
object_id, pos = read_bit_uint(body, pos, 64)
owner_id, pos = read_bit_uint(body, pos, 64)
object_ids.append(object_id)
if flags & 0x002:
_, pos = read_bit_uint(body, pos, 64)
if flags & 0x010:
_, pos = read_bit_uint(body, pos, 64)
if flags & 0x100:
pos += 2
if flags & 0x200:
observer_count, pos = read_bit_uint(body, pos, 32)
for _ in range(observer_count):
_, pos = read_bit_uint(body, pos, 64)
if flags & 0x008:
pos += 40
pos += 4
sync_data_size = struct.unpack_from("<i", body, pos)[0]
pos += 4 + sync_data_size
if object_hash == PLAYER_HASH:
player_candidates.append((object_id, owner_id))
elif object_hash == GAME_MANAGER_HASH:
game_manager_object_id = object_id
pos += 4 # despawned in-scene object count
if pos != sync_end:
pass
player_object_id = None
owner_client_id = None
if local_client_id is not None:
for object_id, owner_id in player_candidates:
if owner_id == local_client_id:
player_object_id = object_id
owner_client_id = owner_id
break
if player_object_id is None and player_candidates:
player_object_id, owner_client_id = player_candidates[-1]
return scene_hash, scene_handle, object_ids, player_object_id, owner_client_id, game_manager_object_id
def parse_client_rpc_header(body):
pos = 0
object_id, pos = read_bit_uint(body, pos, 64)
behaviour_id, pos = read_bit_uint(body, pos, 16)
method_id, pos = read_bit_uint(body, pos, 32)
return object_id, behaviour_id, method_id, body[pos:]
def parse_flag_client_rpc_payload(payload):
marker = payload.find(b"bbb{")
if marker >= 0:
end = payload.find(b"}", marker)
if end >= 0:
return payload[marker : end + 1].decode("ascii", errors="ignore")
utf16_marker = payload.find("bbb{".encode("utf-16le"))
if utf16_marker >= 0:
utf16_end = payload.find("}".encode("utf-16le"), utf16_marker)
if utf16_end >= 0:
raw = payload[utf16_marker : utf16_end + 2]
try:
return raw.decode("utf-16le")
except UnicodeDecodeError:
pass
if len(payload) < 4:
return None
length = struct.unpack_from("<I", payload, 0)[0]
raw = payload[4 : 4 + length * 2]
try:
decoded = raw.decode("utf-16le")
return decoded if decoded else None
except UnicodeDecodeError:
return None
def parse_client_rpc(body):
pos = 0
object_id, pos = read_bit_uint(body, pos, 64)
behaviour_id, pos = read_bit_uint(body, pos, 16)
method_id, pos = read_bit_uint(body, pos, 32)
payload = body[pos:]
if method_id != FLAG_RPC:
return None
return parse_flag_client_rpc_payload(payload)
def parse_chunk_rpc_payload(payload):
try:
pos = 0
chunk_x, pos = read_bit_int(payload, pos)
chunk_y, pos = read_bit_int(payload, pos)
decoration_count = payload[pos]
pos += 1
decoration_xs = list(struct.unpack_from("<" + "f" * decoration_count, payload, pos))
pos += 4 * decoration_count
decoration_ys = list(struct.unpack_from("<" + "f" * decoration_count, payload, pos))
pos += 4 * decoration_count
decoration_variants = list(payload[pos : pos + decoration_count])
pos += decoration_count
has_structure = bool(payload[pos])
pos += 1
structure_type = payload[pos] if pos < len(payload) else 0
return {
"chunk_x": chunk_x,
"chunk_y": chunk_y,
"decoration_xs": decoration_xs,
"decoration_ys": decoration_ys,
"decoration_variants": decoration_variants,
"has_structure": has_structure,
"structure_type": structure_type,
}
except (IndexError, struct.error):
return None
def parse_network_variable_delta(body):
try:
pos = 0
object_id, pos = read_bit_uint(body, pos, 64)
behaviour_id, pos = read_bit_uint(body, pos, 16)
delivery = struct.unpack_from("<i", body, pos)[0]
pos += 4
mask = body[pos]
pos += 1
if mask & 1 and pos + 8 <= len(body):
x, y = struct.unpack_from("<ff", body, pos)
return object_id, behaviour_id, delivery, mask, x, y
except (IndexError, struct.error):
return None
return None
class NgoClient:
def __init__(self, url, timeout=1.0):
self.url = url
self.timeout = timeout
last_error = None
for _ in range(30):
try:
self.ws = websocket.create_connection(url, timeout=timeout)
break
except Exception as exc:
last_error = exc
time.sleep(0.25)
else:
raise last_error
self.token = os.urandom(8)
self.player_object_id = None
self.owner_client_id = None
self.scene_hash = None
self.scene_handle = None
self.synced_object_ids = []
self.game_manager_object_id = 1
self.position = (0.0, 0.0)
self.wind = (0.0, 0.0)
self.flag = None
self.chunks = {}
def close(self):
try:
self.ws.close()
except Exception:
pass
def send_connection_request(self):
self.ws.send_binary(struct.pack("<I", SIG) + b"\x01" + self.token)
self.ws.recv()
self.ws.send_binary(batch_frame(self.token, 1, connection_request_body()))
def read_messages(self, duration=0.5):
deadline = time.time() + duration
while time.time() < deadline:
try:
self.ws.settimeout(max(0.05, min(self.timeout, deadline - time.time())))
packet = self.ws.recv()
except Exception:
continue
for _, sequence_id, batch in extract_batches(packet, self.token):
if sequence_id is not None:
try:
self.ws.send_binary(reliable_ack_frame(self.token, sequence_id))
except Exception:
return
for message_type, body in parse_messages(batch):
yield message_type, body
def connect_and_sync(self):
self.send_connection_request()
deadline = time.time() + 5.0
while time.time() < deadline and self.player_object_id is None:
for message_type, body in self.read_messages(0.5):
if message_type == 0:
approved_id = parse_connection_approved(body)
if approved_id is not None:
self.owner_client_id = approved_id
elif message_type == 17:
parsed = parse_scene_sync(body, self.owner_client_id)
if parsed:
(
self.scene_hash,
self.scene_handle,
self.synced_object_ids,
self.player_object_id,
self.owner_client_id,
self.game_manager_object_id,
) = parsed
break
elif message_type == 8:
raise RuntimeError("server disconnected during approval")
if self.player_object_id is None:
raise RuntimeError("did not receive scene synchronization")
body = scene_sync_complete_body(self.scene_hash, self.scene_handle, self.synced_object_ids)
self.ws.send_binary(batch_frame(self.token, 17, body))
# Give the server a moment to mark the client connected.
self.pump_state(0.25)
def connect_quick_sync(self):
self.send_connection_request()
deadline = time.time() + 3.0
while time.time() < deadline and self.player_object_id is None:
for message_type, body in self.read_messages(0.2):
if message_type == 0:
approved_id = parse_connection_approved(body)
if approved_id is not None:
self.owner_client_id = approved_id
elif message_type == 17:
parsed = parse_scene_sync(body, self.owner_client_id)
if parsed:
(
self.scene_hash,
self.scene_handle,
self.synced_object_ids,
self.player_object_id,
self.owner_client_id,
self.game_manager_object_id,
) = parsed
break
elif message_type == 8:
raise RuntimeError("server disconnected helper during approval")
if self.player_object_id is None:
raise RuntimeError("helper did not receive scene synchronization")
body = scene_sync_complete_body(self.scene_hash, self.scene_handle, self.synced_object_ids)
self.ws.send_binary(batch_frame(self.token, 17, body))
self.pump_state(0.08)
def connect_fast_sync(self):
self.send_connection_request()
# Helpers do not need to instantiate the scene locally. A synchronize-complete
# event with an empty object list is enough for the server-side connection path.
list(self.read_messages(0.15))
body = scene_sync_complete_body(0, 0, [])
self.ws.send_binary(batch_frame(self.token, 17, body))
list(self.read_messages(0.10))
def send_move(self, target_object_id, x, y, sender_id=None):
payload = struct.pack("<ff", float(x), float(y))
body = bitpack_uint(sender_id if sender_id is not None else (self.owner_client_id or 0), 64)
body += server_rpc_body(target_object_id, 0, MOVE_RPC, payload)
self.ws.send_binary(batch_frame(self.token, 16, body))
def pump_state(self, duration=0.1):
for message_type, body in self.read_messages(duration):
if message_type == 13:
delta = parse_network_variable_delta(body)
if not delta:
continue
object_id, _, _, _, x, y = delta
if object_id == self.player_object_id:
self.position = (x, y)
elif object_id == self.game_manager_object_id:
self.wind = (x, y)
elif message_type == 5:
try:
_, _, method_id, payload = parse_client_rpc_header(body)
except (IndexError, struct.error):
continue
if method_id == FLAG_RPC:
flag = parse_flag_client_rpc_payload(payload)
if flag:
self.flag = flag
return flag
elif method_id == CHUNK_RPC:
chunk = parse_chunk_rpc_payload(payload)
if chunk:
self.chunks[(chunk["chunk_x"], chunk["chunk_y"])] = chunk
return None
def poll_flag(self, duration=0.05):
for message_type, body in self.read_messages(duration):
if message_type == 5:
try:
_, _, method_id, payload = parse_client_rpc_header(body)
except (IndexError, struct.error):
continue
if method_id == FLAG_RPC:
flag = parse_flag_client_rpc_payload(payload)
if flag:
self.flag = flag
return flag
elif method_id == CHUNK_RPC:
chunk = parse_chunk_rpc_payload(payload)
if chunk:
self.chunks[(chunk["chunk_x"], chunk["chunk_y"])] = chunk
return None
def read_current_docker_logs(wait_seconds=5.0):
since_args = []
try:
container_id = subprocess.check_output(
["docker", "compose", "ps", "-q", "server"],
text=True,
stderr=subprocess.DEVNULL,
).strip()
if container_id:
started_at = subprocess.check_output(
["docker", "inspect", "-f", "{{.State.StartedAt}}", container_id],
text=True,
stderr=subprocess.DEVNULL,
).strip()
if started_at:
since_args = ["--since", started_at]
except Exception:
since_args = []
deadline = time.time() + wait_seconds
last_output = ""
while True:
try:
output = subprocess.check_output(
["docker", "compose", "logs", *since_args, "--tail=2000", "server"],
text=True,
stderr=subprocess.DEVNULL,
)
if output:
return output
last_output = output
except Exception as exc:
last_output = str(exc)
if time.time() >= deadline:
return last_output
time.sleep(0.25)
def read_target_from_docker_logs():
try:
deadline = time.time() + 7.0
output = ""
while time.time() < deadline:
output = read_current_docker_logs(wait_seconds=0.1)
matches = re.findall(
r"flag chest prepared at \(([-0-9.]+),\s*([-0-9.]+),\s*[-0-9.]+\)",
output,
)
if matches:
x, y = matches[-1]
return float(x), float(y)
time.sleep(0.25)
except Exception as exc:
raise RuntimeError(f"could not read docker logs: {exc}") from exc
matches = re.findall(
r"flag chest prepared at \(([-0-9.]+),\s*([-0-9.]+),\s*[-0-9.]+\)",
output,
)
if not matches:
output = subprocess.check_output(
["docker", "compose", "logs", "server"],
text=True,
stderr=subprocess.DEVNULL,
)
matches = re.findall(
r"flag chest prepared at \(([-0-9.]+),\s*([-0-9.]+),\s*[-0-9.]+\)",
output,
)
if not matches:
raise RuntimeError("could not find flag coordinates in docker logs")
x, y = matches[-1]
return float(x), float(y)
def read_seed_from_docker_logs():
try:
deadline = time.time() + 7.0
output = ""
while time.time() < deadline:
output = read_current_docker_logs(wait_seconds=0.1)
matches = re.findall(r"seed:\s*([0-9]+)", output)
if matches:
return int(matches[-1])
time.sleep(0.25)
except Exception as exc:
raise RuntimeError(f"could not read docker logs: {exc}") from exc
matches = re.findall(r"seed:\s*([0-9]+)", output)
if not matches:
output = subprocess.check_output(
["docker", "compose", "logs", "server"],
text=True,
stderr=subprocess.DEVNULL,
)
matches = re.findall(r"seed:\s*([0-9]+)", output)
if not matches:
raise RuntimeError("could not find world seed in docker logs")
return int(matches[-1])
def unit_towards(pos_x, pos_y, target_x, target_y):
dx = target_x - pos_x
dy = target_y - pos_y
dist = math.hypot(dx, dy)
if dist < 1e-6:
return 0.0, 0.0, dist
return dx / dist, dy / dist, dist
def vector_len(x, y):
return math.hypot(x, y)
def normalize(x, y):
length = vector_len(x, y)
if length < 1e-6:
return 0.0, 0.0
return x / length, y / length
def world_to_chunk(x, y):
return math.floor(x / 16.0), math.floor(y / 16.0)
def chunk_to_region(chunk_x, chunk_y):
return chunk_x // 4, chunk_y // 4
def structure_chunk(seed, region_x, region_y):
state = (seed ^ (region_x * MIX_X) ^ (region_y * MIX_Y)) & MASK48
state = (state * WORLD_RAND_MULT + WORLD_RAND_ADD) & MASK48
dx = ((state >> 16) & 0x7FFFFFFF) % 4
state = (state * WORLD_RAND_MULT + WORLD_RAND_ADD) & MASK48
dy = ((state >> 16) & 0x7FFFFFFF) % 4
return region_x * 4 + dx, region_y * 4 + dy
def chunk_has_structure_for_seed(seed, chunk_x, chunk_y):
region_x, region_y = chunk_to_region(chunk_x, chunk_y)
return structure_chunk(seed, region_x, region_y) == (chunk_x, chunk_y)
def structure_constraints_from_chunks(chunks):
constraints = {}
for (chunk_x, chunk_y), chunk in chunks.items():
region = chunk_to_region(chunk_x, chunk_y)
required, forbidden = constraints.setdefault(region, [None, set()])
pos = (chunk_x, chunk_y)
if chunk["has_structure"]:
if required is not None and required != pos:
raise RuntimeError(f"conflicting structure observations in region {region}")
constraints[region][0] = pos
else:
forbidden.add(pos)
return constraints
def seed_low_candidates_from_chunks(chunks):
constraints = structure_constraints_from_chunks(chunks)
candidates = []
for seed_low in range(SEED_LOW_MASK + 1):
ok = True
for (region_x, region_y), (required, forbidden) in constraints.items():
structure = structure_chunk(seed_low, region_x, region_y)
if required is not None:
if structure != required:
ok = False
break
elif structure in forbidden:
ok = False
break
if ok:
candidates.append(seed_low)
return candidates
def fetch_url(url, timeout=5.0):
last_error = None
for _ in range(30):
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
return response.read()
except Exception as exc:
last_error = exc
time.sleep(0.25)
raise last_error
def paeth_predictor(a, b, c):
p = a + b - c
pa = abs(p - a)
pb = abs(p - b)
pc = abs(p - c)
if pa <= pb and pa <= pc:
return a
if pb <= pc:
return b
return c
def decode_png_rgba(data):
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise RuntimeError("map response is not a PNG")
pos = 8
width = height = bit_depth = color_type = interlace = None
compressed = bytearray()
while pos + 8 <= len(data):
length = struct.unpack_from(">I", data, pos)[0]
chunk_type = data[pos + 4 : pos + 8]
chunk_data = data[pos + 8 : pos + 8 + length]
pos += 12 + length
if chunk_type == b"IHDR":
width, height, bit_depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", chunk_data)
elif chunk_type == b"IDAT":
compressed += chunk_data
elif chunk_type == b"IEND":
break
if width is None or height is None:
raise RuntimeError("PNG is missing IHDR")
if bit_depth != 8 or color_type != 6 or interlace != 0:
raise RuntimeError("expected an 8-bit non-interlaced RGBA PNG")
raw = zlib.decompress(bytes(compressed))
stride = width * 4
rows = []
prev = bytearray(stride)
pos = 0
for _ in range(height):
filter_type = raw[pos]
pos += 1
row = bytearray(raw[pos : pos + stride])
pos += stride
for i, value in enumerate(row):
left = row[i - 4] if i >= 4 else 0
up = prev[i]
up_left = prev[i - 4] if i >= 4 else 0
if filter_type == 1:
row[i] = (value + left) & 0xFF
elif filter_type == 2:
row[i] = (value + up) & 0xFF
elif filter_type == 3:
row[i] = (value + ((left + up) // 2)) & 0xFF
elif filter_type == 4:
row[i] = (value + paeth_predictor(left, up, up_left)) & 0xFF
elif filter_type != 0:
raise RuntimeError(f"unsupported PNG filter {filter_type}")
rows.append(bytes(row))
prev = row
return width, height, rows
MAP_STRUCTURE_COLORS = {
(232, 212, 150, 255), # beach
(210, 180, 110, 255), # island
(120, 40, 30, 255), # flag X
}
MAP_FLAG_COLOR = (120, 40, 30, 255)
def parse_map_structure_offsets(png_data):
width, height, rows = decode_png_rgba(png_data)
if width != height or width % 16 != 0:
raise RuntimeError(f"unexpected map dimensions {width}x{height}")
cell = width // 16
structure_cells = set()
red_counts = {}
for cell_y in range(16):
for cell_x in range(16):
has_structure = False
red_count = 0
for y in range(cell_y * cell, (cell_y + 1) * cell):
row = rows[y]
for x in range(cell_x * cell, (cell_x + 1) * cell):
off = x * 4
color = tuple(row[off : off + 4])
if color in MAP_STRUCTURE_COLORS:
has_structure = True
if color == MAP_FLAG_COLOR:
red_count += 1
if has_structure:
structure_cells.add((cell_x, cell_y))
if red_count:
red_counts[(cell_x, cell_y)] = red_count
if not red_counts:
raise RuntimeError("map does not contain a flag X marker")
flag_cell = max(red_counts, key=red_counts.get)
flag_x, flag_y = flag_cell
offsets = {(cell_x - flag_x, flag_y - cell_y) for cell_x, cell_y in structure_cells}
if (0, 0) not in offsets:
raise RuntimeError("flag marker cell was not parsed as a structure")
bounds = (-flag_x, 15 - flag_x, flag_y - 15, flag_y)
return offsets, bounds, flag_cell
def expected_structure_offsets(seed, flag_chunk_x, flag_chunk_y, bounds):
min_dx, max_dx, min_dy, max_dy = bounds
min_x = flag_chunk_x + min_dx
max_x = flag_chunk_x + max_dx
min_y = flag_chunk_y + min_dy
max_y = flag_chunk_y + max_dy
offsets = set()
for region_y in range(min_y // 4 - 1, max_y // 4 + 2):
for region_x in range(min_x // 4 - 1, max_x // 4 + 2):
sx, sy = structure_chunk(seed, region_x, region_y)
if min_x <= sx <= max_x and min_y <= sy <= max_y:
offsets.add((sx - flag_chunk_x, sy - flag_chunk_y))
return offsets
def iter_flag_candidate_regions(min_radius=1898, max_radius=2102):
min_sq = min_radius * min_radius
max_sq = max_radius * max_radius
for region_y in range(-max_radius, max_radius + 1):
x_limit = int(math.floor(math.sqrt(max(0, max_sq - region_y * region_y))))
inner = min_sq - region_y * region_y
x_inner = math.ceil(math.sqrt(inner)) if inner > 0 else 0
if x_inner > 0:
ranges = (range(-x_limit, -x_inner + 1), range(x_inner, x_limit + 1))
else:
ranges = (range(-x_limit, x_limit + 1),)
for region_range in ranges:
for region_x in region_range:
yield region_x, region_y
def find_flag_chunk_from_map(seed, png_data):
observed_offsets, bounds, flag_cell = parse_map_structure_offsets(png_data)
filters = [offset for offset in observed_offsets if offset != (0, 0)]
filters.sort(key=lambda item: -(abs(item[0]) + abs(item[1])))
matches = []
for region_x, region_y in iter_flag_candidate_regions():
flag_chunk_x, flag_chunk_y = structure_chunk(seed, region_x, region_y)
ok = True
for dx, dy in filters[:8]:
if not chunk_has_structure_for_seed(seed, flag_chunk_x + dx, flag_chunk_y + dy):
ok = False
break
if not ok:
continue
expected = expected_structure_offsets(seed, flag_chunk_x, flag_chunk_y, bounds)
if expected == observed_offsets:
matches.append((flag_chunk_x, flag_chunk_y, region_x, region_y))
if len(matches) > 1:
break
if len(matches) != 1:
raise RuntimeError(
f"map fingerprint was not unique for seed-low {seed & SEED_LOW_MASK}: "
f"{len(matches)} matches, flag_cell={flag_cell}"
)
return matches[0]
@functools.lru_cache(maxsize=131072)
def rock_center(seed, chunk_x, chunk_y):
digest = hashlib.sha256(struct.pack("<qii", seed, chunk_x, chunk_y)).digest()
return chunk_x * 16.0 + digest[1] / 255.0 * 16.0, chunk_y * 16.0 + digest[2] / 255.0 * 16.0
def local_avoidance_direction(
seed,
pos_x,
pos_y,
target_x,
target_y,
base_ux,
base_uy,
lookahead,
include_rocks=True,
):
side_x, side_y = -base_uy, base_ux
chunk_x, chunk_y = world_to_chunk(pos_x, pos_y)
radius_chunks = int(math.ceil(lookahead / 16.0)) + 3
best = None
def consider(ox, oy, clearance, weight):
nonlocal best
rel_x = ox - pos_x
rel_y = oy - pos_y
ahead = rel_x * base_ux + rel_y * base_uy
if ahead <= 0.0 or ahead > lookahead:
return
lateral = rel_x * side_x + rel_y * side_y
margin = clearance + 1.0
if abs(lateral) > margin:
return
score = ahead - (margin - abs(lateral)) * weight
if best is None or score < best[0]:
best = (score, lateral, margin, ahead, weight)
region_x = math.floor(chunk_x / 4)
region_y = math.floor(chunk_y / 4)
region_radius = radius_chunks // 4 + 2
for ry in range(region_y - region_radius, region_y + region_radius + 1):
for rx in range(region_x - region_radius, region_x + region_radius + 1):
sx, sy = structure_chunk(seed, rx, ry)
consider(sx * 16.0 + 8.0, sy * 16.0 + 8.0, 8.5, 2.2)
if include_rocks:
for cy in range(chunk_y - radius_chunks, chunk_y + radius_chunks + 1):
for cx in range(chunk_x - radius_chunks, chunk_x + radius_chunks + 1):
ox, oy = rock_center(seed, cx, cy)
consider(ox, oy, 2.6, 0.9)
if best is None:
return base_ux, base_uy, False
_, lateral, margin, ahead, weight = best
sign = -1.0 if lateral > 0.0 else 1.0
strength = min(2.4, 0.85 + (margin - abs(lateral)) / max(margin, 1.0) * weight)
# Prefer a tangent-like heading around the first upcoming collider.
return normalize(base_ux + side_x * sign * strength, base_uy + side_y * sign * strength) + (True,)
def local_escape_direction(seed, pos_x, pos_y, include_rocks=True):
chunk_x, chunk_y = world_to_chunk(pos_x, pos_y)
best = None
def record(dx, dy, clearance, weight):
nonlocal best
dist = vector_len(dx, dy)
if dist > clearance:
return
if dist < 1e-4:
ux, uy = 1.0, 0.0
else:
ux, uy = dx / dist, dy / dist
score = (clearance - dist) * weight
if best is None or score > best[0]:
best = (score, ux, uy)
if include_rocks:
for cy in range(chunk_y - 2, chunk_y + 3):
for cx in range(chunk_x - 2, chunk_x + 3):
ox, oy = rock_center(seed, cx, cy)
record(pos_x - ox, pos_y - oy, 4.5, 1.0)
region_x = math.floor(chunk_x / 4)
region_y = math.floor(chunk_y / 4)
for ry in range(region_y - 2, region_y + 3):
for rx in range(region_x - 2, region_x + 3):
sx, sy = structure_chunk(seed, rx, ry)
cx = sx * 16.0 + 8.0
cy = sy * 16.0 + 8.0
dx = pos_x - cx
dy = pos_y - cy
# Island collider is a box; use a slightly inflated square and push
# away from the nearest face/corner.
inflated = 9.0
if abs(dx) <= inflated and abs(dy) <= inflated:
if abs(dx) > abs(dy):
record(math.copysign(inflated - abs(dx) + 1.0, dx), 0.0, inflated + 1.0, 2.5)
else:
record(0.0, math.copysign(inflated - abs(dy) + 1.0, dy), inflated + 1.0, 2.5)
if best is None:
return None
return best[1], best[2]
def choose_live_input_direction(
seed,
pos_x,
pos_y,
target_x,
target_y,
base_x,
base_y,
live_count,
lookahead,
include_rocks=True,
):
target_ux, target_uy, _ = unit_towards(pos_x, pos_y, target_x, target_y)
if live_count <= 0:
return target_ux, target_uy, False
chunk_x, chunk_y = world_to_chunk(pos_x, pos_y)
radius_chunks = int(math.ceil(lookahead / 16.0)) + 3
obstacles = []
region_x = math.floor(chunk_x / 4)
region_y = math.floor(chunk_y / 4)
region_radius = radius_chunks // 4 + 2
for ry in range(region_y - region_radius, region_y + region_radius + 1):
for rx in range(region_x - region_radius, region_x + region_radius + 1):
sx, sy = structure_chunk(seed, rx, ry)
obstacles.append((sx * 16.0 + 8.0, sy * 16.0 + 8.0, 8.6, 4.0))
if include_rocks:
for cy in range(chunk_y - radius_chunks, chunk_y + radius_chunks + 1):
for cx in range(chunk_x - radius_chunks, chunk_x + radius_chunks + 1):
ox, oy = rock_center(seed, cx, cy)
obstacles.append((ox, oy, 2.8, 1.0))
candidates = [(target_ux, target_uy)]
base_angle = math.atan2(target_uy, target_ux)
for step in range(32):
angle = base_angle + (step / 32.0) * math.tau
candidates.append((math.cos(angle), math.sin(angle)))
best = None
for input_x, input_y in candidates:
wind_x = base_x + input_x * live_count
wind_y = base_y + input_y * live_count
wind_mag = vector_len(wind_x, wind_y)
if wind_mag < 0.1:
continue
path_x = wind_x / wind_mag
path_y = wind_y / wind_mag
progress = wind_x * target_ux + wind_y * target_uy
if progress <= 0.0:
continue
side_x, side_y = -path_y, path_x
risk = 0.0
blocked = False
for ox, oy, clearance, weight in obstacles:
rel_x = ox - pos_x
rel_y = oy - pos_y
ahead = rel_x * path_x + rel_y * path_y
if ahead <= 0.0 or ahead > lookahead:
continue
lateral = abs(rel_x * side_x + rel_y * side_y)
margin = clearance + 1.2
if lateral >= margin:
continue
overlap = (margin - lateral) / margin
near = (lookahead - ahead + 20.0) / (lookahead + 20.0)
risk += overlap * overlap * near * weight
if ahead < 18.0 and lateral < clearance:
blocked = True
target_alignment = path_x * target_ux + path_y * target_uy
score = progress * 1.6 + target_alignment * 8.0 - risk * 130.0
if blocked:
score -= 500.0
if best is None or score > best[0]:
best = (score, input_x, input_y, risk)
if best is None:
return target_ux, target_uy, False
_, input_x, input_y, risk = best
adjusted = risk > 0.05 or input_x * target_ux + input_y * target_uy < 0.95
return input_x, input_y, adjusted
def add_one_wind(url, direction_x, direction_y, pre_send_seconds, settle_seconds):
helper = NgoClient(url, timeout=0.4)
try:
helper.connect_quick_sync()
time.sleep(pre_send_seconds)
helper.send_move(helper.player_object_id, direction_x, direction_y)
time.sleep(settle_seconds)
finally:
helper.close()
def add_wind(
url,
direction_x,
direction_y,
count,
observer=None,
batch_size=3,
pre_send_seconds=0.12,
settle_seconds=0.035,
):
remaining = int(max(0, count))
while remaining:
group = min(max(1, batch_size), remaining)
with concurrent.futures.ThreadPoolExecutor(max_workers=group) as executor:
futures = [
executor.submit(add_one_wind, url, direction_x, direction_y, pre_send_seconds, settle_seconds)
for _ in range(group)
]
for future in concurrent.futures.as_completed(futures):
future.result()
remaining -= group
if observer:
flag = observer.pump_state(0.015)
if flag:
return flag
time.sleep(0.03)
return None
def steer_global_wind(
client,
url,
desired_x,
desired_y,
label,
batch_size=3,
pre_send_seconds=0.12,
settle_seconds=0.035,
):
client.pump_state(0.2)
wind_x, wind_y = client.wind
diff_x = desired_x - wind_x
diff_y = desired_y - wind_y
diff = vector_len(diff_x, diff_y)
if diff < 0.75:
return
count = int(math.ceil(diff))
unit_x = diff_x / diff
unit_y = diff_y / diff
print(
f"[+] wind {label}: current=({wind_x:.1f},{wind_y:.1f}) "
f"target=({desired_x:.1f},{desired_y:.1f}) add={count}",
flush=True,
)
flag = add_wind(
url,
unit_x,
unit_y,
count,
observer=client,
batch_size=batch_size,
pre_send_seconds=pre_send_seconds,
settle_seconds=settle_seconds,
)
if flag:
return flag
client.pump_state(0.5)
# The server stores a sum of unit vectors; keep a local estimate even if the
# latest NetworkVariable delta was coalesced away.
if vector_len(client.wind[0] - wind_x, client.wind[1] - wind_y) < 0.1:
client.wind = (wind_x + unit_x * count, wind_y + unit_y * count)
print(f"[+] wind now approximately ({client.wind[0]:.1f},{client.wind[1]:.1f})", flush=True)
return None
def pulse_global_wind(
client,
url,
direction_x,
direction_y,
count,
label,
batch_size=3,
pre_send_seconds=0.12,
settle_seconds=0.035,
):
unit_x, unit_y = normalize(direction_x, direction_y)
count = int(max(0, round(count)))
if count <= 0 or vector_len(unit_x, unit_y) < 0.5:
return None
client.pump_state(0.05)
old_x, old_y = client.wind
print(
f"[+] pulse {label}: current=({old_x:.1f},{old_y:.1f}) "
f"dir=({unit_x:.2f},{unit_y:.2f}) add={count}",
flush=True,
)
flag = add_wind(
url,
unit_x,
unit_y,
count,
observer=client,
batch_size=batch_size,
pre_send_seconds=pre_send_seconds,
settle_seconds=settle_seconds,
)
if flag:
return flag
client.pump_state(0.2)
if vector_len(client.wind[0] - old_x, client.wind[1] - old_y) < 0.1:
client.wind = (old_x + unit_x * count, old_y + unit_y * count)
print(f"[+] wind now approximately ({client.wind[0]:.1f},{client.wind[1]:.1f})", flush=True)
return None
def seed_scan_waypoints(max_waypoints):
points = []
for y in (8, 72, -56, 136, -120, 200, -184):
for x in (8, 72, 136, -56, -120, 200, -184):
points.append((float(x), float(y)))
if len(points) >= max_waypoints:
return points
return points
def recover_seed_low_from_server(args):
probe = NgoClient(args.url, timeout=0.5)
try:
probe.connect_and_sync()
probe.pump_state(1.0)
candidates = seed_low_candidates_from_chunks(probe.chunks)
print(
f"[+] seed-low scan: chunks={len(probe.chunks)} "
f"structures={sum(1 for chunk in probe.chunks.values() if chunk['has_structure'])} "
f"candidates={len(candidates)}",
flush=True,
)
if len(candidates) == 1:
return candidates[0]
for idx, (target_x, target_y) in enumerate(seed_scan_waypoints(args.seed_scan_max_waypoints), start=1):
pos_x, pos_y = probe.position
ux, uy, _ = unit_towards(pos_x, pos_y, target_x, target_y)
flag = steer_global_wind(
probe,
args.url,
ux * args.seed_scan_wind_mag,
uy * args.seed_scan_wind_mag,
f"seed-scan/{idx}",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
raise RuntimeError("received flag during seed scan before target discovery")
deadline = time.time() + args.seed_scan_waypoint_seconds
while time.time() < deadline:
flag = probe.pump_state(0.08)
if flag:
print(flag, flush=True)
raise RuntimeError("received flag during seed scan before target discovery")
_, _, dist = unit_towards(probe.position[0], probe.position[1], target_x, target_y)
if dist <= args.seed_scan_radius:
break
candidates = seed_low_candidates_from_chunks(probe.chunks)
print(
f"[+] seed-low scan waypoint {idx}: pos=({probe.position[0]:.1f},{probe.position[1]:.1f}) "
f"chunks={len(probe.chunks)} "
f"structures={sum(1 for chunk in probe.chunks.values() if chunk['has_structure'])} "
f"candidates={len(candidates)}",
flush=True,
)
if len(candidates) == 1:
return candidates[0]
raise RuntimeError(f"seed-low scan did not converge: {len(candidates)} candidates remain")
finally:
probe.close()
def discover_target_from_map(args, supplied_seed=None):
print(f"[+] fetching map from {args.map_url}", flush=True)
png_data = fetch_url(args.map_url)
if supplied_seed is None:
seed_for_structures = recover_seed_low_from_server(args)
full_seed = False
print(f"[+] recovered seed low18={seed_for_structures}", flush=True)
else:
seed_for_structures = supplied_seed
full_seed = True
print(f"[+] using supplied seed={seed_for_structures}", flush=True)
flag_chunk_x, flag_chunk_y, region_x, region_y = find_flag_chunk_from_map(seed_for_structures, png_data)
target_x = flag_chunk_x * 16.0 + 8.0
target_y = flag_chunk_y * 16.0 + 8.0
print(
f"[+] map matched flag chunk=({flag_chunk_x},{flag_chunk_y}) "
f"region=({region_x},{region_y}) target=({target_x:.2f},{target_y:.2f})",
flush=True,
)
return target_x, target_y, seed_for_structures, full_seed
def send_live_inputs(clients, x, y):
for client in clients:
client.send_move(client.player_object_id, x, y)
def pump_helpers(clients, duration=0.002):
for client in clients:
client.poll_flag(duration)
def connect_live_helpers(url, count):
helpers = []
for idx in range(count):
helper = NgoClient(url, timeout=0.5)
try:
helper.connect_quick_sync()
except Exception:
helper.close()
raise
helpers.append(helper)
print(
f"[+] live helper {idx + 1}/{count} id={helper.owner_client_id} "
f"player_oid={helper.player_object_id}",
flush=True,
)
return helpers
def run_live_steering(args, target_x, target_y, seed):
clients = []
helpers = []
try:
main_client = NgoClient(args.url)
main_client.connect_and_sync()
clients.append(main_client)
print(f"[+] main client id={main_client.owner_client_id} player_oid={main_client.player_object_id}", flush=True)
if seed is not None:
print(f"[+] seed={seed}", flush=True)
print(f"[+] target=({target_x:.2f}, {target_y:.2f})", flush=True)
ux, uy, _ = unit_towards(main_client.position[0], main_client.position[1], target_x, target_y)
flag = steer_global_wind(
main_client,
args.url,
ux * args.live_base_wind_mag,
uy * args.live_base_wind_mag,
"live-base",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
base_x, base_y = main_client.wind
helpers = connect_live_helpers(args.url, args.live_helpers)
clients.extend(helpers)
live_clients = [main_client] + helpers
live_count = max(1, len(live_clients))
started = time.time()
samples = []
avoid_until = 0.0
avoid_sign = 1.0
helper_pump_at = 0.0
last_status = 0.0
last_heavy_recovery = 0.0
detour_until = 0.0
detour_input = (0.0, 0.0)
detour_sign = 1.0
final_retarget_at = 0.0
final_mode = False
while time.time() - started < args.max_seconds:
now = time.time()
flag = main_client.pump_state(args.live_tick_seconds)
if flag:
print(flag, flush=True)
return 0
pos_x, pos_y = main_client.position
ux, uy, dist = unit_towards(pos_x, pos_y, target_x, target_y)
wind_x, wind_y = main_client.wind
wind_mag = vector_len(wind_x, wind_y)
samples.append((now, pos_x, pos_y, dist))
samples = [sample for sample in samples if now - sample[0] <= max(8.0, args.live_stuck_window + 1.0)]
if now >= helper_pump_at:
pump_helpers(helpers, 0.001)
helper_pump_at = now + 1.0
if now - last_status > args.live_status_interval:
print(
f"[+] t={now-started:.1f}s pos=({pos_x:.1f},{pos_y:.1f}) "
f"dist={dist:.1f} wind=({wind_x:.1f},{wind_y:.1f})",
flush=True,
)
last_status = now
if dist < 12.0:
send_live_inputs(live_clients, 0.0, 0.0)
flag = main_client.pump_state(1.0)
if flag:
print(flag, flush=True)
return 0
flag = steer_global_wind(
main_client,
args.url,
0.0,
0.0,
"capture-stop",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
send_live_inputs(live_clients, ux, uy)
flag = main_client.pump_state(1.0)
if flag:
print(flag, flush=True)
return 0
if not final_mode and dist < args.live_final_radius:
print("[+] entering final approach", flush=True)
send_live_inputs(live_clients, 0.0, 0.0)
time.sleep(0.25)
main_client.pump_state(0.3)
for helper in helpers:
helper.close()
helpers.clear()
clients[:] = [main_client]
live_clients = [main_client]
live_count = 1
flag = steer_global_wind(
main_client,
args.url,
ux * args.near_wind_mag,
uy * args.near_wind_mag,
"final",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
base_x, base_y = main_client.wind
final_retarget_at = time.time() + args.live_final_retarget_interval
final_mode = True
samples.clear()
continue
if final_mode and dist > 12.0 and now >= final_retarget_at:
flag = steer_global_wind(
main_client,
args.url,
ux * args.near_wind_mag,
uy * args.near_wind_mag,
"final-track",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
base_x, base_y = main_client.wind
final_retarget_at = time.time() + args.live_final_retarget_interval
samples.clear()
continue
drive_ux, drive_uy = ux, uy
obstacle_adjusted = False
chosen_live_input = None
if seed is not None and not args.ignore_map_avoidance and dist > 20.0 and not final_mode:
lookahead = max(
args.live_min_lookahead,
min(args.live_max_lookahead, max(1.0, wind_mag) * 7.0 * args.live_lookahead_seconds),
)
chosen_x, chosen_y, obstacle_adjusted = choose_live_input_direction(
seed,
pos_x,
pos_y,
target_x,
target_y,
base_x,
base_y,
live_count,
lookahead,
include_rocks=args.avoid_rocks,
)
chosen_live_input = (chosen_x, chosen_y)
drive_ux, drive_uy = chosen_x, chosen_y
old_enough = [sample for sample in samples if now - sample[0] >= args.live_stuck_window]
stuck = False
if old_enough and dist > 18.0:
old_t, old_x, old_y, old_dist = old_enough[0]
elapsed = max(0.1, now - old_t)
moved = vector_len(pos_x - old_x, pos_y - old_y)
progress = old_dist - dist
if final_mode:
stuck = progress < 1.0 and moved < 1.5 and wind_mag > 0.5
else:
low_progress = max(args.live_stuck_min_progress, wind_mag * 7.0 * elapsed * 0.08)
stuck = progress < low_progress and moved < low_progress * 1.8 and wind_mag > 3.0
if stuck and now >= avoid_until:
escape = (
local_escape_direction(seed, pos_x, pos_y, include_rocks=args.avoid_rocks)
if seed is not None
else None
)
if escape:
drive_ux, drive_uy = escape
else:
drive_ux = -uy * avoid_sign
drive_uy = ux * avoid_sign
avoid_sign *= -1.0
avoid_until = now + args.live_escape_seconds
samples.clear()
print(f"[+] live escape dir=({drive_ux:.2f},{drive_uy:.2f})", flush=True)
if (
args.live_heavy_recovery
and now - last_heavy_recovery >= args.live_recovery_cooldown
):
print("[+] heavy recovery: freeing helper slots and re-vectoring wind", flush=True)
send_live_inputs(live_clients, 0.0, 0.0)
time.sleep(0.25)
main_client.pump_state(0.25)
for helper in helpers:
helper.close()
helpers.clear()
clients[:] = [main_client]
live_clients = [main_client]
live_count = 1
recovery_start_x, recovery_start_y = main_client.position
recovery_dirs = [
(drive_ux, drive_uy),
(-drive_ux, -drive_uy),
(-uy, ux),
(uy, -ux),
]
for attempt, (recover_x, recover_y) in enumerate(recovery_dirs, start=1):
recover_x, recover_y = normalize(recover_x, recover_y)
if vector_len(recover_x, recover_y) < 0.5:
continue
recover_mag = args.live_recovery_wind_mag * (1.0 + 0.35 * (attempt - 1))
flag = steer_global_wind(
main_client,
args.url,
recover_x * recover_mag,
recover_y * recover_mag,
f"recover/{attempt}",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
base_x, base_y = main_client.wind
send_live_inputs(live_clients, recover_x, recover_y)
recovery_deadline = time.time() + args.live_recovery_seconds
while time.time() < recovery_deadline:
flag = main_client.pump_state(0.12)
if flag:
print(flag, flush=True)
return 0
moved = vector_len(
main_client.position[0] - recovery_start_x,
main_client.position[1] - recovery_start_y,
)
if moved >= args.live_recovery_min_move:
drive_ux, drive_uy = recover_x, recover_y
break
print(
f"[+] recovery attempt {attempt} moved {moved:.1f}; trying alternate",
flush=True,
)
send_live_inputs(live_clients, 0.0, 0.0)
time.sleep(0.15)
main_client.pump_state(0.25)
pos_x, pos_y = main_client.position
ux, uy, _ = unit_towards(pos_x, pos_y, target_x, target_y)
resume_mag = args.near_wind_mag if final_mode else args.live_base_wind_mag
flag = steer_global_wind(
main_client,
args.url,
ux * resume_mag,
uy * resume_mag,
"resume",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
base_x, base_y = main_client.wind
if final_mode:
live_clients = [main_client]
live_count = 1
final_retarget_at = time.time() + args.live_final_retarget_interval
else:
helpers = connect_live_helpers(args.url, args.live_helpers)
clients.extend(helpers)
live_clients = [main_client] + helpers
live_count = max(1, len(live_clients))
side_x, side_y = -uy, ux
if abs(drive_ux * side_x + drive_uy * side_y) > 0.15:
detour_sign = math.copysign(1.0, drive_ux * side_x + drive_uy * side_y)
detour_input = (side_x * detour_sign, side_y * detour_sign)
detour_sign *= -1.0
detour_until = time.time() + args.live_detour_seconds
samples.clear()
last_heavy_recovery = time.time()
avoid_until = last_heavy_recovery + 0.5
continue
elif now < avoid_until:
drive_ux = -uy * avoid_sign
drive_uy = ux * avoid_sign
elif final_mode:
drive_ux, drive_uy = ux, uy
if now < detour_until and not final_mode:
input_x, input_y = detour_input
elif chosen_live_input is not None and not final_mode:
input_x, input_y = chosen_live_input
elif args.live_fractional_inputs:
desired_mag = args.near_wind_mag if final_mode else args.live_base_wind_mag + live_count
need_x = drive_ux * desired_mag - wind_x
need_y = drive_uy * desired_mag - wind_y
input_x = need_x / live_count
input_y = need_y / live_count
input_mag = vector_len(input_x, input_y)
if input_mag > 1.0:
input_x /= input_mag
input_y /= input_mag
else:
input_x, input_y = drive_ux, drive_uy
if obstacle_adjusted and chosen_live_input is None and now >= avoid_until and not final_mode:
side_x, side_y = -uy, ux
side_dot = input_x * side_x + input_y * side_y
if abs(side_dot) < 0.2:
input_x, input_y = normalize(input_x + side_x * 0.45, input_y + side_y * 0.45)
send_live_inputs(live_clients, input_x, input_y)
raise RuntimeError("timed out before receiving flag")
finally:
for client in clients:
client.close()
def relay_desired_wind(args, pos_x, pos_y, target_x, target_y):
ux, uy, dist = unit_towards(pos_x, pos_y, target_x, target_y)
mag = args.near_wind_mag if dist < args.slow_radius else args.wind_mag
return ux * mag, uy * mag, dist
def run_relay(args, target_x, target_y, seed):
clients = []
try:
frontier = NgoClient(args.url)
frontier.connect_and_sync()
clients.append(frontier)
print(f"[+] relay boat0 id={frontier.owner_client_id} player_oid={frontier.player_object_id}", flush=True)
if seed is not None:
print(f"[+] seed={seed}", flush=True)
print(f"[+] target=({target_x:.2f}, {target_y:.2f})", flush=True)
desired_x, desired_y, _ = relay_desired_wind(
args,
frontier.position[0],
frontier.position[1],
target_x,
target_y,
)
flag = steer_global_wind(
frontier,
args.url,
desired_x,
desired_y,
"relay-cruise",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
started = time.time()
samples = []
handoffs = 0
capture_failures = 0
connected_at = time.time()
last_status = 0.0
last_revector = 0.0
while time.time() - started < args.max_seconds:
now = time.time()
flag = frontier.pump_state(args.live_tick_seconds)
if flag:
print(flag, flush=True)
return 0
pos_x, pos_y = frontier.position
ux, uy, dist = unit_towards(pos_x, pos_y, target_x, target_y)
wind_x, wind_y = frontier.wind
samples.append((now, pos_x, pos_y, dist))
samples = [sample for sample in samples if now - sample[0] <= max(3.0, args.relay_stuck_window + 0.5)]
if now - last_status >= args.live_status_interval:
print(
f"[+] t={now-started:.1f}s relay#{handoffs} pos=({pos_x:.1f},{pos_y:.1f}) "
f"dist={dist:.1f} wind=({wind_x:.1f},{wind_y:.1f})",
flush=True,
)
last_status = now
force_handoff = False
if dist < args.reach_radius:
flag = steer_global_wind(
frontier,
args.url,
0.0,
0.0,
"relay-stop",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
input_dirs = [
(0.0, 0.0),
(ux, uy),
(-uy, ux),
(uy, -ux),
(-ux, -uy),
]
hold_deadline = time.time() + args.capture_hold_seconds
input_idx = 0
while time.time() < hold_deadline:
move_x, move_y = input_dirs[input_idx % len(input_dirs)]
input_idx += 1
frontier.send_move(frontier.player_object_id, move_x, move_y)
flag = frontier.pump_state(0.08)
if flag:
print(flag, flush=True)
return 0
capture_failures += 1
if capture_failures >= args.capture_tunnel_after:
tunnel_x, tunnel_y, _ = unit_towards(
frontier.position[0],
frontier.position[1],
target_x,
target_y,
)
print(
f"[+] capture tunnel: failures={capture_failures} "
f"dir=({tunnel_x:.2f},{tunnel_y:.2f})",
flush=True,
)
flag = steer_global_wind(
frontier,
args.url,
tunnel_x * args.capture_tunnel_wind_mag,
tunnel_y * args.capture_tunnel_wind_mag,
"capture-tunnel",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
tunnel_deadline = time.time() + args.capture_tunnel_seconds
while time.time() < tunnel_deadline:
frontier.send_move(frontier.player_object_id, tunnel_x, tunnel_y)
flag = frontier.pump_state(0.05)
if flag:
print(flag, flush=True)
return 0
flag = steer_global_wind(
frontier,
args.url,
0.0,
0.0,
"capture-tunnel-stop",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
flag = frontier.pump_state(0.4)
if flag:
print(flag, flush=True)
return 0
capture_failures = 0
force_handoff = True
elif dist > args.reach_radius * 4.0:
capture_failures = 0
desired_x, desired_y, _ = relay_desired_wind(args, pos_x, pos_y, target_x, target_y)
desired_mag = args.near_wind_mag if dist < args.slow_radius else args.wind_mag
if not force_handoff and now - last_revector >= args.relay_revector_interval:
flag = steer_global_wind(
frontier,
args.url,
desired_x,
desired_y,
"relay-final" if dist < args.slow_radius else "relay-cruise",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
last_revector = now
old_enough = [sample for sample in samples if now - sample[0] >= args.relay_stuck_window]
stuck = force_handoff
if old_enough and now - connected_at >= args.relay_settle_seconds and dist > args.reach_radius:
old_t, old_x, old_y, old_dist = old_enough[0]
elapsed = max(0.1, now - old_t)
moved = vector_len(pos_x - old_x, pos_y - old_y)
progress = old_dist - dist
min_progress = args.relay_min_progress
if desired_mag > args.near_wind_mag:
min_progress = max(min_progress, desired_mag * 7.0 * elapsed * 0.02)
stuck = moved < args.relay_min_move and progress < min_progress
if not stuck:
continue
anchor_x, anchor_y = frontier.position
old_client = frontier
handoffs += 1
print(
f"[+] relay handoff #{handoffs}: stuck at ({anchor_x:.1f},{anchor_y:.1f}), "
f"dist={dist:.1f}",
flush=True,
)
new_client = NgoClient(args.url)
try:
old_wind = old_client.wind
new_client.connect_quick_sync()
# If the new boat wedges immediately, it may not dirty its position
# variable. Seed it with the old anchor until movement frames arrive.
new_client.position = (anchor_x, anchor_y)
new_client.wind = old_wind
clients.append(new_client)
learn_deadline = time.time() + args.relay_handoff_seconds
while time.time() < learn_deadline:
flag = new_client.pump_state(0.06)
if flag:
print(flag, flush=True)
return 0
flag = old_client.pump_state(0.001)
if flag:
print(flag, flush=True)
return 0
old_client.close()
clients.remove(old_client)
frontier = new_client
connected_at = time.time()
samples.clear()
last_revector = 0.0
print(
f"[+] relay frontier id={frontier.owner_client_id} "
f"player_oid={frontier.player_object_id} pos=({frontier.position[0]:.1f},{frontier.position[1]:.1f})",
flush=True,
)
except Exception:
new_client.close()
raise
raise RuntimeError("timed out before receiving flag")
finally:
for client in clients:
client.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:7777/")
parser.add_argument("--target-x", type=float)
parser.add_argument("--target-y", type=float)
parser.add_argument("--seed", type=int)
parser.add_argument("--from-docker-logs", action="store_true")
parser.add_argument("--from-map", action="store_true")
parser.add_argument("--discover-only", action="store_true")
parser.add_argument("--map-url", default="http://127.0.0.1:7778/map")
parser.add_argument("--wind-mag", type=float, default=120.0)
parser.add_argument("--near-wind-mag", type=float, default=8.0)
parser.add_argument("--avoid-push", type=float, default=120.0)
parser.add_argument("--slow-radius", type=float, default=650.0)
parser.add_argument("--avoid-seconds", type=float, default=1.7)
parser.add_argument("--stuck-window", type=float, default=1.4)
parser.add_argument("--batch-size", type=int, default=3)
parser.add_argument("--pre-send-seconds", type=float, default=0.12)
parser.add_argument("--settle-seconds", type=float, default=0.035)
parser.add_argument("--tunnel-on-stuck", action="store_true")
parser.add_argument("--straight-tunnel", action="store_true")
parser.add_argument("--ignore-map-avoidance", action="store_true")
parser.add_argument("--avoid-rocks", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--stuck-escape-mode", choices=("side", "map"), default="map")
parser.add_argument("--additive-steering", action="store_true")
parser.add_argument("--forward-pulse", type=float, default=45.0)
parser.add_argument("--side-pulse", type=float, default=70.0)
parser.add_argument("--additive-interval", type=float, default=0.8)
parser.add_argument("--tunnel-mult", type=float, default=1.8)
parser.add_argument("--max-wind-mag", type=float, default=0.0)
parser.add_argument("--max-seconds", type=float, default=420.0)
parser.add_argument("--live-steering", action="store_true")
parser.add_argument("--relay", action="store_true")
parser.add_argument("--live-helpers", type=int, default=3)
parser.add_argument("--live-base-wind-mag", type=float, default=7.0)
parser.add_argument("--live-final-radius", type=float, default=120.0)
parser.add_argument("--live-tick-seconds", type=float, default=0.08)
parser.add_argument("--live-lookahead-seconds", type=float, default=0.9)
parser.add_argument("--live-min-lookahead", type=float, default=55.0)
parser.add_argument("--live-max-lookahead", type=float, default=240.0)
parser.add_argument("--live-stuck-window", type=float, default=3.5)
parser.add_argument("--live-stuck-min-progress", type=float, default=8.0)
parser.add_argument("--live-escape-seconds", type=float, default=1.4)
parser.add_argument("--live-status-interval", type=float, default=2.0)
parser.add_argument("--live-fractional-inputs", action="store_true")
parser.add_argument("--live-heavy-recovery", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--live-recovery-wind-mag", type=float, default=12.0)
parser.add_argument("--live-recovery-seconds", type=float, default=1.8)
parser.add_argument("--live-recovery-cooldown", type=float, default=8.0)
parser.add_argument("--live-recovery-min-move", type=float, default=8.0)
parser.add_argument("--live-detour-seconds", type=float, default=3.5)
parser.add_argument("--live-final-retarget-interval", type=float, default=2.0)
parser.add_argument("--seed-scan-wind-mag", type=float, default=18.0)
parser.add_argument("--seed-scan-waypoint-seconds", type=float, default=3.0)
parser.add_argument("--seed-scan-max-waypoints", type=int, default=18)
parser.add_argument("--seed-scan-radius", type=float, default=18.0)
parser.add_argument("--relay-stuck-window", type=float, default=1.4)
parser.add_argument("--relay-settle-seconds", type=float, default=1.4)
parser.add_argument("--relay-handoff-seconds", type=float, default=0.15)
parser.add_argument("--relay-revector-interval", type=float, default=3.0)
parser.add_argument("--relay-min-move", type=float, default=3.0)
parser.add_argument("--relay-min-progress", type=float, default=8.0)
parser.add_argument("--reach-radius", type=float, default=8.0)
parser.add_argument("--capture-hold-seconds", type=float, default=1.2)
parser.add_argument("--capture-tunnel-after", type=int, default=3)
parser.add_argument("--capture-tunnel-wind-mag", type=float, default=45.0)
parser.add_argument("--capture-tunnel-seconds", type=float, default=0.35)
args = parser.parse_args()
seed = args.seed
seed_is_full = seed is not None
if args.from_docker_logs:
target_x, target_y = read_target_from_docker_logs()
if seed is None:
seed = read_seed_from_docker_logs()
seed_is_full = True
elif args.from_map:
target_x, target_y, seed, seed_is_full = discover_target_from_map(args, seed)
if not seed_is_full:
# The structure PRNG only needs low18 seed bits, but rock placement uses
# SHA256 over the full seed. Skip rock prediction when only low18 is known.
args.avoid_rocks = False
elif args.target_x is not None and args.target_y is not None:
target_x, target_y = args.target_x, args.target_y
else:
parser.error("provide --target-x/--target-y, --from-map, or --from-docker-logs")
if args.discover_only:
return 0
if args.live_steering:
return run_live_steering(args, target_x, target_y, seed)
if args.relay:
return run_relay(args, target_x, target_y, seed)
clients = []
try:
main_client = NgoClient(args.url)
main_client.connect_and_sync()
clients.append(main_client)
print(f"[+] main client id={main_client.owner_client_id} player_oid={main_client.player_object_id}", flush=True)
started = time.time()
samples = []
avoid_until = 0.0
avoid_sign = 1.0
next_retarget = 0.0
last_status = 0.0
tunnel_mag = args.wind_mag
if seed is not None:
print(f"[+] seed={seed}", flush=True)
print(f"[+] target=({target_x:.2f}, {target_y:.2f})", flush=True)
while time.time() - started < args.max_seconds:
now = time.time()
flag = main_client.pump_state(0.15)
if flag:
print(flag, flush=True)
return 0
pos_x, pos_y = main_client.position
ux, uy, dist = unit_towards(pos_x, pos_y, target_x, target_y)
samples.append((now, pos_x, pos_y, dist))
sample_window = max(3.0, args.stuck_window + 0.5)
if args.additive_steering:
sample_window = max(sample_window, 30.0)
samples = [sample for sample in samples if now - sample[0] <= sample_window]
if now - last_status > 2.0:
print(
f"[+] t={now-started:.1f}s pos=({pos_x:.1f},{pos_y:.1f}) "
f"dist={dist:.1f} wind=({main_client.wind[0]:.1f},{main_client.wind[1]:.1f})",
flush=True,
)
last_status = now
if dist < 12.0:
flag = steer_global_wind(
main_client,
args.url,
0.0,
0.0,
"stop",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
flag = main_client.pump_state(1.0)
if flag:
print(flag, flush=True)
return 0
cruise_mag = args.near_wind_mag if dist < args.slow_radius else max(args.wind_mag, tunnel_mag)
drive_ux, drive_uy = ux, uy
obstacle_adjusted = False
if seed is not None and dist > 20.0 and not args.ignore_map_avoidance:
lookahead = max(80.0, min(420.0, cruise_mag * 2.6))
drive_ux, drive_uy, obstacle_adjusted = local_avoidance_direction(
seed,
pos_x,
pos_y,
target_x,
target_y,
ux,
uy,
lookahead,
include_rocks=args.avoid_rocks,
)
old_enough = [sample for sample in samples if now - sample[0] >= args.stuck_window]
stuck = False
if old_enough and dist > 15.0:
old_t, old_x, old_y, old_dist = old_enough[0]
moved = vector_len(pos_x - old_x, pos_y - old_y)
progress = old_dist - dist
wind_mag = vector_len(*main_client.wind)
if args.additive_steering:
elapsed = max(0.1, now - old_t)
low_progress = min(250.0, max(12.0, wind_mag * elapsed * 0.02))
stuck = progress < low_progress and moved < low_progress * 1.5 and wind_mag > 40.0
else:
stuck = moved < 2.0 and progress < 2.0 and wind_mag > 4.0
if stuck and now >= avoid_until:
current_wind_mag = vector_len(*main_client.wind)
perp_x = -uy * avoid_sign
perp_y = ux * avoid_sign
avoid_sign *= -1.0
avoid_until = now + args.avoid_seconds
next_retarget = avoid_until + 0.2
escape = (
local_escape_direction(seed, pos_x, pos_y, include_rocks=args.avoid_rocks)
if seed is not None
else None
)
if args.additive_steering:
desired_x = drive_ux * 0.15 + perp_x
desired_y = drive_uy * 0.15 + perp_y
flag = pulse_global_wind(
main_client,
args.url,
desired_x,
desired_y,
args.side_pulse,
"side",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
samples.clear()
continue
if args.tunnel_on_stuck:
tunnel_mag = max(tunnel_mag * args.tunnel_mult, current_wind_mag * args.tunnel_mult, args.avoid_push)
if args.max_wind_mag > 0.0:
tunnel_mag = min(tunnel_mag, args.max_wind_mag)
if args.straight_tunnel:
desired_x = ux * tunnel_mag
desired_y = uy * tunnel_mag
elif escape:
escape_x, escape_y = escape
desired_x = escape_x * tunnel_mag
desired_y = escape_y * tunnel_mag
else:
desired_x = (ux * 0.65 + perp_x * 0.35) * tunnel_mag
desired_y = (uy * 0.65 + perp_y * 0.35) * tunnel_mag
label = f"tunnel/{tunnel_mag:.0f}"
elif args.stuck_escape_mode == "map" and escape:
escape_x, escape_y = escape
desired_x = escape_x * max(cruise_mag, args.avoid_push)
desired_y = escape_y * max(cruise_mag, args.avoid_push)
label = "escape"
else:
side_mag = max(cruise_mag, args.avoid_push)
desired_x = drive_ux * cruise_mag * 0.10 + perp_x * side_mag
desired_y = drive_uy * cruise_mag * 0.10 + perp_y * side_mag
label = "avoid"
flag = steer_global_wind(
main_client,
args.url,
desired_x,
desired_y,
label,
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
samples.clear()
continue
if now >= next_retarget and now >= avoid_until:
if args.additive_steering and dist >= args.slow_radius:
wind_x, wind_y = main_client.wind
side_x, side_y = -drive_uy, drive_ux
projection = wind_x * drive_ux + wind_y * drive_uy
lateral = wind_x * side_x + wind_y * side_y
desired_x, desired_y = drive_ux, drive_uy
if not obstacle_adjusted and abs(lateral) > args.wind_mag * 0.7:
desired_x, desired_y = normalize(
drive_ux - side_x * math.copysign(0.8, lateral),
drive_uy - side_y * math.copysign(0.8, lateral),
)
pulse = args.forward_pulse
label = "avoid-forward" if obstacle_adjusted else "forward"
if obstacle_adjusted:
pulse = max(pulse, args.side_pulse)
if args.max_wind_mag > 0.0 and vector_len(wind_x, wind_y) > args.max_wind_mag and projection > args.wind_mag:
pulse = 0
flag = pulse_global_wind(
main_client,
args.url,
desired_x,
desired_y,
pulse,
label,
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
next_retarget = now + args.additive_interval
continue
flag = steer_global_wind(
main_client,
args.url,
drive_ux * cruise_mag,
drive_uy * cruise_mag,
"avoid-map" if obstacle_adjusted else "cruise",
batch_size=args.batch_size,
pre_send_seconds=args.pre_send_seconds,
settle_seconds=args.settle_seconds,
)
if flag:
print(flag, flush=True)
return 0
if seed is not None:
next_retarget = now + (0.9 if obstacle_adjusted else 1.4)
else:
next_retarget = now + (1.5 if dist < args.slow_radius else 5.0)
raise RuntimeError("timed out before receiving flag")
finally:
for client in clients:
client.close()
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment