Files
MrBruh 75912da87e Describe net_compare.py's use without other platforms' builds (#360)
The tool's documentation and comments compared an x86-64, x32 or arm64
build against the i686 one, builds the project does not have. They now
compare a build with a simulation, compiler or maths change against one
without, and the wrapper's example is a tracer rather than an emulator.
2026-10-20 00:21:59 +13:00

895 lines
41 KiB
Python

#!/usr/bin/env python3
"""Check that two builds of the game play a network game in step.
Runs two builds (any two executables, each with its game data) in one
system link game on the loopback: build A hosts and B joins, then (with
--swap) B hosts and A joins. Each copy plays its player with a scripted,
seeded bot (debug.test_input "bot:<seed>") and logs every player's state
once a second (debug.network_test, port/linux/game/network_test.c). The
host decides; a client predicts its own player and runs the others from
the input the host relays. So between builds that simulate alike, what the
host logged of each player at a tick and what the client logged of that
player at the same tick agree to within what the netcode's prediction and
correction allow, and the two machines' copies of a player, where one hears
the other's word on it, stay a rounding apart ("drift", below). A build
that simulates otherwise disagrees there first.
The comparison and its tolerances are described in port/linux/NETCODE.md
("Comparing two builds"). It needs the retail maps, so it is a local (or
self-hosted) check, not a public CI one.
python tools/net_compare.py --a build/linux/halo --data /path/to/halo \\
--b ../other/build/linux/halo --maps bloodgulch,beavercreek --swap
Exit status: 0 when every run passes; 1 when a run diverges (FAIL), even if
another could not be played, as a divergence is the finding and an error
only a gap in it; else 2 when a run could not be played (ERROR: a copy
failed to start, join or log); 128 + N when the tool was stopped by signal
N (SIGINT, SIGTERM, SIGHUP), after stopping the copies.
Each copy runs in a session of its own (its process group), which the tool
ends however it ends itself, so that no copy outlives it.
"""
import argparse
import atexit
import ctypes
import json
import math
import os
import re
import shlex
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
TICKS_PER_SECOND = 30
# ---------- tolerances
#
# Chosen from how the netcode works (port/linux/NETCODE.md) and checked
# against identical builds (see the NETCODE.md section). The machines'
# clocks agree to a tick or two, a remote player's word comes a tick or so
# late and a client's prediction reaches the host as late, so the logged
# positions of a running player differ by the few ticks' running between
# them; a client corrects its copy of a remote player at once past
# REMOTE_BLEND_DISTANCE (1 world unit) and its own past
# LOCAL_CORRECTION_TOLERANCE (3), so further apart than that for long is no
# lag but a disagreement.
TOLERANCES = {
# world units between the host's and a client's copy of a player at
# one tick (a player runs about 0.08 a tick; jumps and falls more)
"position": 1.0,
# degrees of aim (yaw, pitch) apart: input a tick or two late, as the
# bot turns (it turns at most a few degrees a tick)
"aim": 15.0,
# body and shield vitality apart (the host's word, a tick late)
"vitality": 0.15,
# rounds apart in a weapon (a client fires at once; the host's word
# on its ammunition comes a round trip later)
"rounds": 3,
# consecutive seconds a discrete field (alive, riding, weapons,
# grenades, scores, team) may disagree before it counts: the host's
# word on a death, a spawn, a pickup or a score is a round trip or
# a death blow's wait (network_distributed.c) late
"persist": 3,
# consecutive seconds the position may be out of tolerance (a
# teleporter, a death's fall, a respawn's move)
"persist_continuous": 2,
# ... a weapon's rounds: a client keeps its own player's ammunition
# while it fires, and takes the host's (a pickup's) once it has
# stopped a round trip (network_objects.c): 4 seconds seen
"persist_rounds": 6,
# drift: on the host, the ticks where the host moved a client's player
# one tick on from where it took the client's last prediction, with the
# client's input of that tick, as the client did, and how far apart the
# two moves were (network_distributed.c's distributed_divergence).
# Builds that simulate alike move it alike to the last bit but where the
# player touched something the two machines have in slightly different
# places (the other player, a grenade's blast, an item): identical
# builds have 0-13% of those ticks' moves further apart than
# DIVERGENCE_APART_DISTANCE (0.0001 world units), up to 42% when the
# machines' frames come unevenly (a loaded computer), 36% with
# --events. A build whose players run 0.5% faster has 77-88% apart, 2%
# faster 88-93%. More than drift_share of the ticks apart fails.
# (Gravity 0.5-2% stronger is 25-38% apart, as it changes only the
# ticks in the air: within what identical builds can show, so not
# caught by this.)
"drift_share": 0.6,
# (fewer ticks compared than this is an error: the client's player
# never on foot, or the two not paired)
"drift_samples": 300,
}
LOG_PREFIX = "network test: tick "
PLAYER_RE = re.compile(r" player (\d+): ")
ALIVE_RE = re.compile(
r"\((?P<x>-?[\d.]+|-?nan|-?inf) (?P<y>-?[\d.]+|-?nan|-?inf) (?P<z>-?[\d.]+|-?nan|-?inf)\) "
r"h(?P<body>-?[\d.]+)/(?P<shield>-?[\d.]+)(?P<riding> riding)?(?P<camo> camo)? "
r"g(?P<g0>\d+)/(?P<g1>\d+) w a(?P<yaw>-?\d+)/(?P<pitch>-?\d+) f(?P<facing>-?\d+) "
r"l(?P<look_yaw>-?\d+)/(?P<look_pitch>-?\d+) as(?P<aim_screen>-?\d+)/(?P<look_screen>-?\d+) "
r"st(?P<state>-?\d+) thr(?P<throttle>[\d.]+)(?P<weapons>(?: [0-9a-f]+:-?\d+)*) ")
DEAD_RE = re.compile(r"dead ")
STATS_RE = re.compile(r"s(?P<score>-?\d+) k(?P<kills>-?\d+) d(?P<deaths>-?\d+) f(?P<ff>-?\d+) "
r"t(?P<team>-?\d+) m(?P<machine>-?\d+)$")
DRIFT_RE = re.compile(r"\| drift (\d+) (\d+) ([\d.]+|-?nan|-?inf) ([\d.]+|-?nan|-?inf)")
CORRECTED_RE = re.compile(r"corrected (\d+)")
LOCAL_RE = re.compile(r"\| local (-?\d+) ")
STATUS_RE = re.compile(r"\| (playing|game over) to ")
# ---------- reading a copy's log
def parse_players(text):
"""{player index: state} of the players part of a log line"""
players = {}
starts = list(PLAYER_RE.finditer(text))
for number, start in enumerate(starts):
end = starts[number + 1].start() if number + 1 < len(starts) else len(text)
body = text[start.end():end].rstrip() + " "
index = int(start.group(1))
state = {}
alive = ALIVE_RE.match(body)
if alive:
state["alive"] = True
state["position"] = (float(alive["x"]), float(alive["y"]), float(alive["z"]))
state["body"] = float(alive["body"])
state["shield"] = float(alive["shield"])
state["riding"] = bool(alive["riding"])
state["camo"] = bool(alive["camo"])
state["grenades"] = (int(alive["g0"]), int(alive["g1"]))
state["aim"] = (int(alive["yaw"]), int(alive["pitch"]))
state["facing"] = int(alive["facing"])
state["look"] = (int(alive["look_yaw"]), int(alive["look_pitch"]))
state["animation"] = int(alive["state"])
state["throttle"] = float(alive["throttle"])
weapons = []
for weapon in alive["weapons"].split():
definition, rounds = weapon.split(":")
weapons.append((definition, int(rounds)))
state["weapons"] = weapons
rest = body[alive.end():]
elif DEAD_RE.match(body):
state["alive"] = False
rest = body[len("dead "):]
else:
raise ValueError(f"unreadable player {index}: {body!r}")
stats = STATS_RE.match(rest.strip())
if not stats:
raise ValueError(f"unreadable statistics of player {index}: {rest!r}")
for key in ("score", "kills", "deaths", "ff", "team", "machine"):
state[key] = int(stats[key])
players[index] = state
return players
def parse_log(path):
"""{tick: sample} of a copy's per-second lines (the last game's, as a
tick seen again starts the samples over)"""
samples = {}
names = {}
errors = []
with open(path, errors="replace") as log:
for line in log:
position = line.find(LOG_PREFIX)
if position < 0:
named = re.search(r"network test: player (\d+) is named (.*)$", line)
if named:
names[int(named.group(1))] = named.group(2).strip()
continue
text = line[position + len(LOG_PREFIX):].rstrip("\n")
parts = text.split(" | ")
head = parts[0]
tick_text, _, players_text = head.partition(" ")
try:
tick = int(tick_text)
players = parse_players(" " + players_text)
except ValueError as error:
errors.append(f"{path}: {error}")
continue
sample = {"tick": tick, "players": players}
rest = " | " + " | ".join(parts[1:])
for pattern, key in ((CORRECTED_RE, "corrected"), (LOCAL_RE, "local")):
found = pattern.search(rest)
if found:
sample[key] = int(found.group(1))
status = STATUS_RE.search(rest)
sample["playing"] = bool(status and status.group(1) == "playing")
drift = DRIFT_RE.search(rest)
if drift:
sample["drift"] = (int(drift.group(1)), int(drift.group(2)), float(drift.group(3)),
float(drift.group(4)))
if tick in samples or (samples and tick < max(samples)):
# a new game (the next variant's): only the last is compared
samples = {}
samples[tick] = sample
return samples, names, errors
# ---------- comparing
def angle_apart(a, b):
difference = abs(a - b) % 360
return min(difference, 360 - difference)
def distance(a, b):
return math.sqrt(sum((p - q) ** 2 for p, q in zip(a, b)))
def pair_ticks(host, client):
"""[(host tick, client tick)] of the seconds both logged, the client's
nearest within two ticks (each logs the first frame a second on)"""
pairs = []
client_ticks = sorted(client)
for tick in sorted(host):
best = None
for other in client_ticks:
if abs(other - tick) <= 2 and (best is None or abs(other - tick) < abs(best - tick)):
best = other
if best is not None:
pairs.append((tick, best))
return pairs
class Streaks:
"""consecutive seconds each (player, field) disagrees"""
def __init__(self):
self.counts = {}
self.first = {}
def note(self, key, bad, tick, detail):
if bad:
if self.counts.get(key, 0) == 0:
self.first[key] = (tick, detail)
self.counts[key] = self.counts.get(key, 0) + 1
else:
self.counts[key] = 0
return self.counts.get(key, 0)
def compare_pair(host, client, tolerances, skip_seconds):
"""the divergences of one client's log against the host's, and figures
of how far apart they were"""
divergences = []
figures = {"seconds": 0, "position_max": 0.0, "position_sum": 0.0, "position_count": 0,
"aim_max": 0.0, "mismatches": {}}
streaks = Streaks()
pairs = pair_ticks(host, client)
figures["tick_offsets"] = sorted({c - h for h, c in pairs})
for host_tick, client_tick in pairs:
if host_tick < skip_seconds * TICKS_PER_SECOND:
continue
host_sample = host[host_tick]
client_sample = client[client_tick]
if not host_sample["playing"] or not client_sample["playing"]:
continue
figures["seconds"] += 1
found = []
players = sorted(set(host_sample["players"]) | set(client_sample["players"]))
for index in players:
h = host_sample["players"].get(index)
c = client_sample["players"].get(index)
checks = []
if h is None or c is None:
checks.append(("present", True, f"host {'has' if h else 'lacks'} it, client {'has' if c else 'lacks'} it",
tolerances["persist"]))
else:
checks.append(("alive", h["alive"] != c["alive"], f"host {h['alive']} client {c['alive']}",
tolerances["persist"]))
for key in ("score", "kills", "deaths", "ff", "team"):
checks.append((key, h[key] != c[key], f"host {h[key]} client {c[key]}", tolerances["persist"]))
if h["alive"] and c["alive"]:
apart = distance(h["position"], c["position"])
finite = all(math.isfinite(v) for v in h["position"] + c["position"])
figures["position_max"] = max(figures["position_max"], apart if finite else math.inf)
figures["position_sum"] += apart if finite else 0.0
figures["position_count"] += 1
checks.append(("position", not finite or apart > tolerances["position"],
f"{apart:.3f} world units apart (host {h['position']} client {c['position']})",
tolerances["persist_continuous"]))
aim = max(angle_apart(h["aim"][0], c["aim"][0]), abs(h["aim"][1] - c["aim"][1]))
figures["aim_max"] = max(figures["aim_max"], aim)
# (a player thrown through the air by a blast: its aim,
# driven by no input, can differ for a second or two)
checks.append(("aim", aim > tolerances["aim"],
f"{aim:.0f} degrees apart (host {h['aim']} client {c['aim']})",
tolerances["persist"]))
for key in ("body", "shield"):
checks.append((key, abs(h[key] - c[key]) > tolerances["vitality"],
f"host {h[key]:.2f} client {c[key]:.2f}", tolerances["persist"]))
checks.append(("riding", h["riding"] != c["riding"], f"host {h['riding']} client {c['riding']}",
tolerances["persist"]))
checks.append(("grenades", h["grenades"] != c["grenades"],
f"host {h['grenades']} client {c['grenades']}", tolerances["persist"]))
host_weapons = [w for w, _ in h["weapons"]]
client_weapons = [w for w, _ in c["weapons"]]
checks.append(("weapons", host_weapons != client_weapons,
f"host {host_weapons} client {client_weapons}", tolerances["persist"]))
if host_weapons == client_weapons:
rounds = max([abs(a - b) for (_, a), (_, b) in zip(h["weapons"], c["weapons"])] or [0])
checks.append(("rounds", rounds > tolerances["rounds"],
f"host {h['weapons']} client {c['weapons']}", tolerances["persist_rounds"]))
for field, bad, detail, allowed in checks:
key = (index, field)
if bad:
figures["mismatches"][field] = figures["mismatches"].get(field, 0) + 1
count = streaks.note(key, bad, host_tick, detail)
if count == allowed:
first_tick, first_detail = streaks.first[key]
found.append({"player": index, "field": field, "tick": first_tick,
"detail": first_detail, "seconds": count})
divergences.extend(found)
return divergences, figures
def drift_summary(samples, skip_seconds):
"""the host's drift figures over the game: ticks compared, ticks apart,
mean and largest distance, and each second's (tick, compared, apart)"""
total_samples = 0
total_apart = 0
total = 0.0
largest = 0.0
seconds = []
for tick in sorted(samples):
sample = samples[tick]
if tick < skip_seconds * TICKS_PER_SECOND or "drift" not in sample or not sample["playing"]:
continue
count, apart, mean, top = sample["drift"]
if count <= 0:
continue
if not (math.isfinite(mean) and math.isfinite(top)):
mean = top = math.inf
apart = count
total_samples += count
total_apart += apart
total += mean * count
largest = max(largest, top)
seconds.append((tick, count, apart))
return {
"samples": total_samples,
"apart": total_apart,
"mean": total / total_samples if total_samples else 0.0,
"largest": largest,
"seconds": seconds,
}
def judge_drift(summary, tolerances, player):
"""the divergence of the host's drift figures, if any: too many of the
ticks compared apart (a tick apart now and then is the player touching
something the two machines do not have in quite the same place: the
other player, a grenade's blast, a weapon knocked about)"""
if summary["samples"] < tolerances["drift_samples"]:
return []
share = summary["apart"] / summary["samples"]
allowed = tolerances["drift_share"]
if share <= allowed:
return []
# (the first second with as many apart)
first = next(((tick, count, apart) for tick, count, apart in summary["seconds"]
if apart / count > allowed and count >= 10), summary["seconds"][0])
return [{"player": player, "field": "drift", "tick": first[0],
"detail": f"the host's and the client's moves of the client's player, a tick from the same place "
f"with the same input, were apart in {summary['apart']} of {summary['samples']} ticks "
f"({share:.0%}; tolerance {allowed:.0%}), {summary['mean']:.6f} world "
f"units on average, {summary['largest']:.6f} at most; the first second as far apart ends "
f"at tick {first[0]} ({first[2]} of {first[1]} ticks)"}]
# ---------- running the copies
def link_once(link, target):
if not link.exists() and not link.is_symlink():
link.symlink_to(target)
def prepare_copy(work, name, executable, data_root, wrapper=""):
"""a folder for one copy: its own executable (config.toml is written
in the game's folder, beside it), its own data root
(debug.txt goes there; maps/ is the data's) and its own saves"""
executable = Path(executable).resolve()
data_root = Path(data_root).resolve()
if not executable.is_file():
raise SystemExit(f"net_compare: no executable {executable}")
if not (data_root / "maps").is_dir():
raise SystemExit(f"net_compare: no maps folder in {data_root}")
folder = work / name
bin_folder = folder / "bin"
data = folder / "data"
saves = folder / "saves"
for path in (bin_folder, data, saves):
path.mkdir(parents=True, exist_ok=True)
# what the executable needs beside it (its libraries),
# linked; the executable copied, so that its folder is this one
for entry in executable.parent.iterdir():
if entry.name in (executable.name, "config.toml") or entry.name.endswith(".log"):
continue
link_once(bin_folder / entry.name, entry.resolve())
copied = bin_folder / executable.name
if not copied.exists():
try:
os.link(executable, copied)
except OSError:
shutil.copy2(executable, copied)
for entry in ("maps", "custom_maps"):
if (data_root / entry).is_dir() and not (data / entry).exists():
(data / entry).symlink_to((data_root / entry).resolve())
return {"name": name, "executable": copied, "data": data, "saves": saves, "folder": folder,
"command": shlex.split(wrapper) + [str(copied)]}
def copy_environment(copy, address, broadcast, test, seed, args, extra):
environment = dict(os.environ)
for key in [key for key in environment if key.startswith("HALO_")]:
del environment[key]
environment.update({
"HALO_DATA_ROOT": str(copy["data"]),
"HALO_SAVE_ROOT": str(copy["saves"]),
"HALO_NET_ADDRESS": address,
"HALO_NET_BROADCAST": broadcast,
"HALO_NET_ONLINE": "false",
"HALO_NET_PUBLIC_LOBBY": "false",
"HALO_NET_MAP_SHARE": "false",
"HALO_NET_MAP_SEED": "false",
"HALO_NET_MAP_DOWNLOAD": "false",
"HALO_NET_JOIN_FROM_CLIPBOARD": "false",
"HALO_DISCORD_APPLICATION": "",
"HALO_UPDATE_AUTO": "false",
"HALO_NETWORK_TEST": test,
"HALO_NETWORK_TEST_START": str(args.start),
"HALO_TEST_INPUT": f"bot:{seed}",
# (a safety: the tool stops the copies itself)
"HALO_EXIT_AFTER": str(args.start + args.skip + args.seconds + 120),
"SDL_AUDIO_DRIVER": "dummy",
})
if args.events:
# hits every few seconds (each machine's player the next player)
# and a kill now and then (the host's), so that damage, deaths,
# respawns and scores are compared too
environment["HALO_NETWORK_TEST_SHOOT"] = "4"
environment["HALO_NETWORK_TEST_KILL"] = "17"
if args.window:
# (on the display the tool was started with: DISPLAY or
# WAYLAND_DISPLAY as they are, or --display's)
environment["HALO_HIDDEN_WINDOW"] = "1"
if args.display:
environment["DISPLAY"] = args.display
environment.pop("WAYLAND_DISPLAY", None)
environment["SDL_VIDEO_DRIVER"] = "x11"
else:
environment["HALO_NULL_RENDERER"] = "1"
# (SDL's video started without a display: without it, SDL_Init
# fails, and is tried again, every frame; and none of the caller's,
# so that no copy can open a window on it)
environment["SDL_VIDEO_DRIVER"] = "dummy"
for key in ("DISPLAY", "WAYLAND_DISPLAY"):
environment.pop(key, None)
for setting in extra:
key, _, value = setting.partition("=")
environment[key] = value
return environment
def latest_tick(path):
"""the last per-second tick a copy's log has, and whether it plays"""
tick = None
try:
with open(path, "rb") as log:
log.seek(max(0, os.path.getsize(path) - 65536))
for line in log.read().decode(errors="replace").splitlines():
position = line.find(LOG_PREFIX)
if position >= 0:
try:
tick = int(line[position + len(LOG_PREFIX):].split(" ", 1)[0])
except ValueError:
pass
except OSError:
pass
return tick
# ---------- the copies' processes
#
# Each copy runs in a session of its own (start_new_session), so that the
# copy and whatever it started (a wrapper's) are one process group, which
# stop() ends; and so that a terminal's Ctrl-C reaches only the tool, which
# stops them. The tool stops them on every way out: returning, an exception,
# Ctrl-C (KeyboardInterrupt), SIGTERM and SIGHUP (Stopped), and atexit; and
# a copy is killed by the kernel if the tool dies without that (SIGKILL:
# PR_SET_PDEATHSIG).
# the copies running
RUNNING = []
# the signals the tool stops on, and the one that stopped it
STOP_SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
stopping = None
class Stopped(Exception):
"""the tool was sent SIGTERM or SIGHUP"""
def __init__(self, number):
super().__init__(f"stopped by signal {number}")
self.number = number
def on_stop_signal(number, frame):
global stopping
# (once: a second signal while the copies are being stopped waits)
if stopping is not None:
return
stopping = number
if number == signal.SIGINT:
raise KeyboardInterrupt
raise Stopped(number)
def stop_on_signals():
for number in STOP_SIGNALS:
signal.signal(number, on_stop_signal)
atexit.register(stop_all)
def die_with_the_tool(parent=os.getpid()):
"""(in a copy, before it runs) SIGKILL when the tool ends, however it
ends (Linux's PR_SET_PDEATHSIG; elsewhere nothing)"""
try:
ctypes.CDLL(None, use_errno=True).prctl(1, int(signal.SIGKILL), 0, 0, 0) # PR_SET_PDEATHSIG
except (OSError, AttributeError):
return
# (the tool gone already, before that)
if os.getppid() != parent:
os.kill(os.getpid(), signal.SIGKILL)
def start(command, **options):
"""a copy started in a session of its own, and noted as running before
a signal can come between (it is blocked meanwhile)"""
blocked = signal.pthread_sigmask(signal.SIG_BLOCK, STOP_SIGNALS)
try:
parent = os.getpid()
process = subprocess.Popen(command, start_new_session=True,
preexec_fn=lambda: die_with_the_tool(parent), **options)
RUNNING.append(process)
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, blocked)
return process
def signal_group(process, number):
try:
os.killpg(process.pid, number)
except (ProcessLookupError, PermissionError):
pass
def stop(process):
"""ends a copy's process group: SIGTERM (the game quits on it, its files
all written by now), then SIGKILL two seconds on (a build from before it
quit on SIGTERM)"""
if process.poll() is None:
signal_group(process, signal.SIGTERM)
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
# (the group: the copy, and what it started that is left)
signal_group(process, signal.SIGKILL)
process.wait()
if process in RUNNING:
RUNNING.remove(process)
def stop_all():
blocked = signal.pthread_sigmask(signal.SIG_BLOCK, STOP_SIGNALS)
try:
for process in list(RUNNING):
stop(process)
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, blocked)
def play(run_folder, host_copy, client_copy, map_name, args, host_extra, client_extra, seeds):
"""runs one game; the paths of the host's and the client's logs"""
run_folder.mkdir(parents=True, exist_ok=True)
base = args.address_base.rsplit(".", 1)
host_address = args.address_base
client_address = f"{base[0]}.{int(base[1]) + 1}"
host_log = run_folder / "host.log"
client_log = run_folder / "client.log"
# (each copy's debug.txt is its own, and grows: a fresh one each game)
for copy in (host_copy, client_copy):
(copy["data"] / "debug.txt").unlink(missing_ok=True)
test = f"host:{map_name}" + (f":{args.variant}" if args.variant else "")
processes = []
try:
with open(host_log, "w") as log:
processes.append(start(
host_copy["command"], cwd=host_copy["folder"], stdout=log, stderr=subprocess.STDOUT,
env=copy_environment(host_copy, host_address, client_address, test, seeds[0], args, host_extra)))
time.sleep(1.0)
with open(client_log, "w") as log:
processes.append(start(
client_copy["command"], cwd=client_copy["folder"], stdout=log, stderr=subprocess.STDOUT,
env=copy_environment(client_copy, client_address, host_address, "join", seeds[1], args,
client_extra)))
goal = (args.skip + args.seconds) * TICKS_PER_SECOND
deadline = time.monotonic() + args.start + args.seconds + args.skip + args.timeout
while time.monotonic() < deadline:
if any(process.poll() is not None for process in processes):
break
ticks = [latest_tick(host_log), latest_tick(client_log)]
if all(tick is not None and tick >= goal for tick in ticks):
# (a second more, so the client's last second is logged too)
time.sleep(1.0)
break
time.sleep(0.5)
exits = [process.poll() for process in processes]
finally:
# (with the signals that stop the tool held off, so that the
# stopping is not cut short)
blocked = signal.pthread_sigmask(signal.SIG_BLOCK, STOP_SIGNALS)
try:
for process in processes:
stop(process)
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, blocked)
# (the end of each copy's debug.txt, beside its log: a crash's story)
for copy, name in ((host_copy, "host"), (client_copy, "client")):
try:
with open(copy["data"] / "debug.txt", "rb") as debug:
debug.seek(max(0, os.path.getsize(debug.name) - 65536))
(run_folder / f"{name}.debug.txt").write_bytes(debug.read())
except OSError:
pass
return host_log, client_log, exits
# ---------- a run, judged
def judge_run(host_log, client_log, args, tolerances):
host, host_names, host_errors = parse_log(host_log)
client, _, client_errors = parse_log(client_log)
result = {"divergences": [], "problems": host_errors + client_errors}
if not host:
result["problems"].append(f"the host logged no game ({host_log})")
if not client:
result["problems"].append(f"the client logged no game ({client_log})")
if result["problems"] and (not host or not client):
return result
played = (max(client) - min(client)) // TICKS_PER_SECOND
if played < args.seconds * 3 // 4:
result["problems"].append(f"the client played only {played} seconds of {args.seconds}")
players = set(host[max(host)]["players"])
if len(players) < 2:
result["problems"].append("the client's player never joined the host's game")
divergences, figures = compare_pair(host, client, tolerances, args.skip)
result["figures"] = figures
result["divergences"].extend(divergences)
drift = drift_summary(host, args.skip)
if not any("drift" in sample for sample in host.values()):
result["problems"].append("the host's log has no drift figures (a build from before net_compare.py)")
client_players = sorted({sample["local"] for sample in client.values() if sample.get("local", -1) >= 0})
result["divergences"].extend(judge_drift(drift, tolerances, client_players[0] if client_players else None))
result["drift"] = {key: value for key, value in drift.items() if key != "seconds"}
if drift["samples"] < tolerances["drift_samples"]:
result["problems"].append(f"only {drift['samples']} ticks of the client's player on foot to judge the "
f"drift by (fewer than {tolerances['drift_samples']})")
corrections = [client[tick].get("corrected") for tick in sorted(client) if "corrected" in client[tick]]
result["client_corrections"] = corrections[-1] - corrections[0] if len(corrections) > 1 else 0
result["divergences"].sort(key=lambda item: (item["tick"], str(item["player"])))
return result
def describe(result):
lines = []
figures = result.get("figures")
if figures:
mean = figures["position_sum"] / figures["position_count"] if figures["position_count"] else 0.0
lines.append(f" {figures['seconds']} seconds compared, tick offsets {figures['tick_offsets']}; "
f"positions apart mean {mean:.3f} largest {figures['position_max']:.3f}, aim largest "
f"{figures['aim_max']:.0f} degrees; mismatched seconds {figures['mismatches'] or 'none'}")
if "drift" in result:
summary = result["drift"]
share = summary["apart"] / summary["samples"] if summary["samples"] else 0.0
lines.append(f" drift: {summary['apart']} of {summary['samples']} ticks apart ({share:.1%}), mean "
f"{summary['mean']:.6f}, largest {summary['largest']:.6f} world units")
if "client_corrections" in result:
lines.append(f" client corrections: {result['client_corrections']}")
for problem in result["problems"]:
lines.append(f" PROBLEM: {problem}")
if result["divergences"]:
first = result["divergences"][0]
who = f"player {first['player']}" if first["player"] is not None else "the copies"
lines.append(f" FIRST DIVERGENCE: {who}, tick {first['tick']}, {first['field']}: {first['detail']}")
for other in result["divergences"][1:6]:
who = f"player {other['player']}" if other["player"] is not None else "the copies"
lines.append(f" then {who}, tick {other['tick']}, {other['field']}: {other['detail']}")
if len(result["divergences"]) > 6:
lines.append(f" ({len(result['divergences']) - 6} more)")
return lines
def parse_tolerance(setting):
"""(name, value) of a --tolerance NAME=VALUE; ValueError with the reason"""
name, equals, text = setting.partition("=")
name = name.strip()
if not equals:
raise ValueError(f"{setting!r} is not NAME=VALUE")
if name not in TOLERANCES:
raise ValueError(f"no tolerance {name!r} (the tolerances: {', '.join(TOLERANCES)})")
kind = type(TOLERANCES[name])
try:
value = kind(text.strip())
except ValueError:
raise ValueError(f"{name} takes {'a whole number' if kind is int else 'a number'}, not {text!r}") from None
if kind is float and not math.isfinite(value):
raise ValueError(f"{name} takes a finite number, not {text!r}")
if value < 0:
raise ValueError(f"{name} cannot be negative ({text!r})")
if name == "drift_share" and value > 1:
raise ValueError(f"drift_share is a share of the ticks, from 0 to 1, not {text!r}")
return name, value
# a FAIL is the finding, so it decides the exit status over an ERROR
EXIT_STATUSES = {"PASS": 0, "ERROR": 2, "FAIL": 1}
def exit_status(verdicts):
"""the tool's exit status of the runs' verdicts: 1 if any FAIL, else 2 if
any ERROR, else 0"""
verdicts = set(verdicts)
if "FAIL" in verdicts:
return EXIT_STATUSES["FAIL"]
if "ERROR" in verdicts:
return EXIT_STATUSES["ERROR"]
return EXIT_STATUSES["PASS"]
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--a", help="build A's executable")
parser.add_argument("--b", help="build B's executable (default: A's)")
parser.add_argument("--data", help="the game data's folder (holding maps/) for A")
parser.add_argument("--b-data", help="the game data's folder for B (default: --data)")
parser.add_argument("--a-env", action="append", default=[], metavar="KEY=VALUE",
help="an environment variable for A's copies (repeatable)")
parser.add_argument("--b-env", action="append", default=[], metavar="KEY=VALUE",
help="an environment variable for B's copies (repeatable)")
parser.add_argument("--a-wrapper", default="", metavar="COMMAND",
help="a command A's executable is run with (a tracer: \"strace -f -o a.trace\")")
parser.add_argument("--b-wrapper", default="", metavar="COMMAND", help="the same for B")
parser.add_argument("--maps", default="bloodgulch,beavercreek",
help="comma-separated multiplayer maps, a game on each")
parser.add_argument("--variant", default="", help="the game variant (slayer if empty)")
parser.add_argument("--seconds", type=int, default=60, help="seconds of play compared in each game")
parser.add_argument("--skip", type=int, default=3, help="seconds at a game's start not compared (spawning)")
parser.add_argument("--start", type=float, default=8.0,
help="seconds after hosting that the game starts (debug.network_test_start)")
parser.add_argument("--timeout", type=float, default=90.0,
help="seconds more than the game's to wait for loading and joining")
parser.add_argument("--events", action="store_true",
help="script hits and kills (debug.network_test_shoot, debug.network_test_kill)")
parser.add_argument("--swap", action="store_true", help="also play each map with B hosting and A joining")
parser.add_argument("--repeat", type=int, default=1, help="play each game this many times")
parser.add_argument("--seed", type=int, default=1, help="the bots' first seed (each run its own)")
parser.add_argument("--address-base", default="127.0.0.210",
help="the host's loopback address; the client takes the next")
parser.add_argument("--window", action="store_true",
help="draw in a hidden window instead of the null renderer, on the display the tool has "
"(DISPLAY or WAYLAND_DISPLAY; a virtual one, Xvfb, rather than a desktop), or --display's")
parser.add_argument("--display", help="with --window: the X display for the copies' windows (\":99\"), "
"through SDL's X11 driver")
parser.add_argument("--work", help="the folder for the copies' files and logs (default: a new one in "
"build/net_compare, removed if every game passes)")
parser.add_argument("--keep", action="store_true", help="keep the default folder when every game passes")
parser.add_argument("--tolerance", action="append", default=[], metavar="NAME=VALUE",
help="change a tolerance (" + ", ".join(TOLERANCES) + ")")
parser.add_argument("--json", help="write the results to this file as JSON")
parser.add_argument("--compare", nargs=2, metavar=("HOST_LOG", "CLIENT_LOG"),
help="only compare two logs already written")
args = parser.parse_args()
tolerances = dict(TOLERANCES)
for setting in args.tolerance:
try:
name, value = parse_tolerance(setting)
except ValueError as error:
parser.error(f"--tolerance: {error}")
tolerances[name] = value
if args.compare:
for log in args.compare:
if not Path(log).is_file():
parser.error(f"--compare: no log {log}")
result = judge_run(Path(args.compare[0]), Path(args.compare[1]), args, tolerances)
verdict = "FAIL" if result["divergences"] else ("ERROR" if result["problems"] else "PASS")
print(f"{verdict}: {args.compare[0]} against {args.compare[1]}")
print("\n".join(describe(result)))
return exit_status([verdict])
if not args.a or not args.data:
parser.error("--a and --data are needed to play (or --compare two logs)")
if args.display and not args.window:
parser.error("--display is for --window's windows")
stop_on_signals()
try:
return play_all(args, tolerances)
except (Stopped, KeyboardInterrupt) as stopped:
number = stopped.number if isinstance(stopped, Stopped) else signal.SIGINT
stop_all()
print(f"net_compare: stopped by signal {int(number)} ({signal.Signals(number).name}); the copies were stopped",
file=sys.stderr, flush=True)
return 128 + int(number)
finally:
stop_all()
def play_all(args, tolerances):
"""plays every game, prints and returns the exit status"""
# (on disk: the copies' saves hold map caches of hundreds of megabytes,
# too many for a /tmp in memory)
temporary = args.work is None
work = Path(args.work or ROOT / "build" / "net_compare" / time.strftime("%Y%m%d-%H%M%S"))
work.mkdir(parents=True, exist_ok=True)
copies = {
"A": prepare_copy(work, "A", args.a, args.data, args.a_wrapper),
"B": prepare_copy(work, "B", args.b or args.a, args.b_data or args.data, args.b_wrapper),
}
extra = {"A": args.a_env, "B": args.b_env}
orders = [("A", "B")] + ([("B", "A")] if args.swap else [])
results = []
seed = args.seed
for repeat in range(args.repeat):
for map_name in [name for name in args.maps.split(",") if name]:
for host_name, client_name in orders:
label = f"{map_name}, {host_name} hosts, {client_name} joins" + \
(f", run {repeat + 1}" if args.repeat > 1 else "")
run_folder = work / "runs" / f"{map_name}.{host_name}{client_name}.{repeat + 1}"
host_copy = copies[host_name]
client_copy = copies[client_name]
started = time.monotonic()
host_log, client_log, exits = play(run_folder, host_copy, client_copy, map_name, args,
extra[host_name], extra[client_name], (seed, seed + 1))
seed += 2
result = judge_run(host_log, client_log, args, tolerances)
if any(code not in (None, 0) for code in exits):
result["problems"].append(f"a copy ended early (exit statuses {exits})")
verdict = "FAIL" if result["divergences"] else ("ERROR" if result["problems"] else "PASS")
print(f"{verdict}: {label} ({time.monotonic() - started:.0f} s; logs {run_folder})")
print("\n".join(describe(result)), flush=True)
result.update({"map": map_name, "host": host_name, "client": client_name, "repeat": repeat + 1,
"verdict": verdict, "host_log": str(host_log), "client_log": str(client_log)})
results.append(result)
passed = sum(result["verdict"] == "PASS" for result in results)
print(f"{passed} of {len(results)} games in step"
+ ("" if passed == len(results) else f"; {len(results) - passed} not"))
if args.json:
with open(args.json, "w") as output:
json.dump({"a": args.a, "b": args.b or args.a, "tolerances": tolerances, "results": results},
output, indent=1, default=str)
status = exit_status(result["verdict"] for result in results)
if temporary and not args.keep and status == 0:
shutil.rmtree(work, ignore_errors=True)
elif temporary:
print(f"files kept in {work}")
return status
if __name__ == "__main__":
sys.exit(main())