Files

180 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""The wire format of HALO_PORT_NETWORK_VERSION (port/linux/network_format.txt).
Taken from the sources: the game's message numbers, sizes and fields
(network_messages.c, .h), the distributed netcode's (network_*.c, .h,
map_share*.c, .h), and the gametype's PC options (game_engine.h's struct
game_variant_options) and where the game settings record has them: the enums
with _distributed_ members, the DISTRIBUTED_ constants, the structs sent
whole (the _message structs, those whose size is taken, and the structs in
them) as hashes, and the constants their arrays are sized by, as their
definitions. An entry the netcode packs itself (a unit's state, a relayed
action) is covered by its flags and its least and largest sizes only.
test_linux_port.py fails when the sources differ from the record. An item
changed or removed is one older builds cannot read: raise the version and
say why in NETCODE.md. An item added (a message kind, a bit) may be one they
ignore. Then record it: network_format.py --record
"""
import hashlib
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RECORD = ROOT / "port/linux/network_format.txt"
def _blocks(text, opener):
"""each declaration (to its semicolon) that starts with the opener"""
for match in re.finditer(opener, text, re.M):
end, depth = text.index("{", match.start()), 0
while True:
depth += {"{": 1, "}": -1}.get(text[end], 0)
end += 1
if not depth:
break
yield text[match.start():text.index(";", end) + 1]
def _hash(text):
return hashlib.sha256(" ".join(text.split()).encode()).hexdigest()[:12]
def items():
"""the wire format's items, name to value"""
paths = sorted((ROOT / "port/linux/game").glob("network_*.[ch]"))
paths += sorted((ROOT / "port/linux/game").glob("map_share*.[ch]"))
paths += [ROOT / "source/networking/network_messages.c", ROOT / "source/networking/network_messages.h"]
texts = [re.sub(r"/\*.*?\*/|//[^\n]*", " ", path.read_text(encoding="latin-1"), flags=re.S) for path in paths]
everything = "\n".join(texts)
result, structs = {}, {}
def add(name, value):
assert name not in result, f"{name} is declared twice"
result[name] = str(value)
for text in texts:
for block in _blocks(text, r"^struct distributed_\w+\s*\{"):
structs[re.match(r"struct (\w+)", block).group(1)] = block
for block in _blocks(text, r"^enum\b[^;{]*\{"):
if "_distributed_" not in block and "network_game_message_type" not in block:
continue
value = -1
for member in filter(None, map(str.strip, block[block.index("{") + 1:block.rindex("}")].split(","))):
name, _, expression = (part.strip() for part in member.partition("="))
value = int(expression, 0) if expression else value + 1
if not name.startswith("NUMBER_OF_"):
add(f"enum {name}", value)
for name, value in re.findall(r"^#define (DISTRIBUTED_\w+)\s+(.*?)\s*$", text, re.M):
add(f"define {name}", value)
sent = {name for name in structs if name.endswith("_message") or f"sizeof(struct {name})" in everything}
while True:
bodies = " ".join(structs[name].split("{", 1)[1] for name in sent)
held = structs.keys() & set(re.findall(r"\bstruct (distributed_\w+)", bodies))
if held <= sent:
break
sent |= held
for name in sent:
add(f"struct {name}", _hash(structs[name]))
# the constants the structs' arrays are sized by, and the netcode's
# DISTRIBUTED_ sizes in enums of no name, as their definitions' text, with
# the names those use in turn
bounds = set()
for name in sent:
for size in re.findall(r"\[([^\]]+)\]", structs[name].split("{", 1)[1]):
bounds |= set(re.findall(r"\b[A-Z_][A-Z0-9_]*\b", size))
bounds |= set(re.findall(r"^\s*(DISTRIBUTED_\w+)\s*=", everything, re.M)) - {
key.split(" ", 1)[1] for key in result if key.startswith("enum ")}
headers = None
pending, done = sorted(bounds), set()
while pending:
name = pending.pop()
if name in done:
continue
done.add(name)
pattern = rf"^#define {name}\b[ \t]*((?:[^\n]*\\\n)*[^\n]*)|\b{name}[ \t]*=(?!=)([^,{{}};]*)"
found = re.findall(pattern, everything, re.M)
if not found:
if headers is None:
headers = "\n".join(re.sub(r"/\*.*?\*/|//[^\n]*", " ", path.read_text(encoding="latin-1"), flags=re.S)
for root in ("source", "port/linux/include", "port/linux/game", "port/linux/src")
for path in sorted((ROOT / root).rglob("*.h")))
found = re.findall(pattern, headers, re.M)
if not found:
# (an enum's member counted, NUMBER_OF_ at its end: the enum)
found = [("enum " + _hash(block), "") for text in (everything, headers)
for block in _blocks(text, r"\benum\b[^;{]*\{")
if re.search(rf"\b{name}\s*[,}}]", block)][:1]
if not found:
assert name not in bounds, f"{name}: a size of the wire format with no definition found"
continue
values = sorted({" ".join((define or expression).replace("\\\n", " ").split()) for define, expression in found})
add(f"bound {name}", " | ".join(values))
pending += re.findall(r"\b[A-Z_][A-Z0-9_]*\b", " ".join(values))
# the gametype's PC options, which the game settings record carries
# whole, and where that record puts them
engine = re.sub(r"/\*.*?\*/|//[^\n]*", " ", (ROOT / "source/game/game_engine.h").read_text(encoding="latin-1"),
flags=re.S)
add("struct game_variant_options", _hash(next(_blocks(engine, r"^struct game_variant_options\s*\{"))))
limits = (ROOT / "port/linux/include/halo_port_limits.h").read_text()
for name, value in re.findall(r"^#define (HALO_PORT_NETWORK_GAME_(?:VARIANT_OPTIONS_OFFSET|LOCAL_DATA_OFFSET|SIZE)) "
r"(.*)$", limits, re.M):
add(f"layout {name}", " ".join(value.split()))
messages = texts[-2]
for name, size in re.findall(r"^DEFINE_NETWORK_GAME_MESSAGE\((\w+), (.*)\);", messages, re.M):
add(f"size {name}", " ".join(size.split()))
for fields, name in re.findall(r"\t\{\n((?:\t\t.*\n)*?)\t\},\n\tNETWORK_GAME_MESSAGE_DEFINITION\((\w+),", messages):
add(f"fields {name}", _hash(fields))
return result
def version():
limits = (ROOT / "port/linux/include/halo_port_limits.h").read_text()
return int(re.search(r"#define HALO_PORT_NETWORK_VERSION (\d+)", limits).group(1))
def recorded():
lines = [line.split(" ", 2) for line in RECORD.read_text().splitlines()]
return int(lines[0][1]), {f"{kind} {name}": value for kind, name, value in lines[1:]}
def changes(old, new):
"""(changed or removed, added)"""
return sorted(name for name in old if old[name] != new.get(name)), sorted(name for name in new if name not in old)
def check():
"""what is wrong with the record, as lines to show"""
problems = []
recorded_version, old = recorded()
breaking, added = changes(old, items())
for names, what in ((breaking, "changed or removed (older builds cannot read them: raise the version)"),
(added, "added (raise the version unless older builds ignore them)")):
problems += [what + ":"] + [f" {name}" for name in names] if names else []
if recorded_version != version():
problems.append(f"recorded for version {recorded_version}, the sources' is {version()}")
if problems:
problems.append("then record them: python tools/network_format.py --record")
netcode = (ROOT / "port/linux/NETCODE.md").read_text()
if not re.search(rf"\bversion\s+{version()}\b", netcode):
problems.append(f"port/linux/NETCODE.md's versions say nothing of version {version()}")
return problems
def record():
new = items()
recorded_version, old = recorded()
breaking = changes(old, new)[0]
if breaking and recorded_version == version():
sys.exit("changed or removed: " + ", ".join(breaking) + "\nraise HALO_PORT_NETWORK_VERSION first")
RECORD.write_text(f"version {version()}\n" + "".join(f"{name} {new[name]}\n" for name in sorted(new)))
if __name__ == "__main__":
if sys.argv[1:] == ["--record"]:
record()
else:
print("\n".join(check()) or "the wire format is the one recorded")