mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-17 15:00:27 +02:00
570 lines
27 KiB
Python
570 lines
27 KiB
Python
"""Profiling build, CPU recorder, written parts and CPU report tests.
|
|
For local WSL runs in a Windows worktree, set HALO_GIT=git.exe.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from tools import linux_build, net_report, ninja_syntax, profile_fixture
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
GIT = os.environ.get("HALO_GIT", "git")
|
|
|
|
|
|
# ---------- the build switch
|
|
|
|
|
|
def test_configuration_defines():
|
|
assert linux_build.configuration_defines(SimpleNamespace()) == []
|
|
assert linux_build.configuration_defines(SimpleNamespace(port_release=True)) == ["-DHALO_RELEASE"]
|
|
assert linux_build.configuration_defines(SimpleNamespace(port_release=True, port_profile=True)) == [
|
|
"-DHALO_RELEASE", "-DHALO_PROFILE"]
|
|
|
|
|
|
def test_every_native_build_takes_the_configuration_defines():
|
|
# (the guest, the game and the platform layer of all three: one place
|
|
# decides what --release and --profile define)
|
|
for name in ("linux_build.py", "windows_build.py", "android_build.py"):
|
|
text = (ROOT / "tools" / name).read_text(encoding="utf-8")
|
|
assert "configuration_defines(sln)" in text, name
|
|
assert '["-DHALO_RELEASE"] if' not in text, name
|
|
|
|
|
|
def test_profile_refuses_pgo_training():
|
|
with pytest.raises(ValueError, match="--pgo=train"):
|
|
linux_build.check_profile_options(True, "train")
|
|
linux_build.check_profile_options(True, "use")
|
|
linux_build.check_profile_options(True, "off")
|
|
linux_build.check_profile_options(False, "train")
|
|
|
|
|
|
def test_linux_build_compiles_with_the_profile_define(monkeypatch):
|
|
monkeypatch.chdir(ROOT)
|
|
out = io.StringIO()
|
|
linux_build.generate_linux_build(ninja_syntax.Writer(out), SimpleNamespace(
|
|
build_dir=Path("build"), linux_cc="clang", compiler_launcher=None, port_release=False, port_lto="off",
|
|
port_portable=True, port_pgo="off", port_pgo_profile=None, port_profile=True))
|
|
# (ninja_syntax wraps long lines with " $"; Windows paths have backslashes)
|
|
text = re.sub(r" \$\n *", " ", out.getvalue()).replace("\\", "/")
|
|
for obj in ("build/linux/obj/source/game/game.o", "build/linux/obj/port/linux/src/p2p.o"):
|
|
block = text[text.index(f"build {obj}:"):]
|
|
cflags = next(line for line in block.splitlines() if line.strip().startswith("cflags = "))
|
|
assert "-DHALO_PROFILE" in cflags, obj
|
|
|
|
|
|
# ---------- the recording's C units (tools/profile_check.c)
|
|
|
|
PROFILE_SOURCES = ["port/linux/src/profile_trace.c", "port/linux/src/profile_json.c"]
|
|
|
|
|
|
def check_compiler():
|
|
"""a C compiler of this machine with POSIX threads, or None (Windows has
|
|
them only through the port's own layer, so the check runs on Linux and
|
|
in WSL)"""
|
|
if os.name == "nt":
|
|
return None
|
|
for name in ("clang", "cc", "gcc"):
|
|
if shutil.which(name):
|
|
return name
|
|
return None
|
|
|
|
|
|
def build_check(tmp_path, sanitizers):
|
|
compiler = check_compiler()
|
|
if not compiler:
|
|
pytest.skip("needs a C compiler with POSIX threads (Linux, WSL)")
|
|
program = tmp_path / ("profile_check" + "".join(f"_{name}" for name in sanitizers))
|
|
flags = [f"-fsanitize={','.join(sanitizers)}", "-fno-omit-frame-pointer"] if sanitizers else []
|
|
built = subprocess.run([compiler, "-std=gnu11", "-Wall", "-Werror", "-DHALO_PROFILE", "-pthread", "-g", "-O1",
|
|
*flags, "-idirafter", "port/linux/include", "-Iport/linux/src",
|
|
"-o", str(program),
|
|
"tools/profile_check.c", *PROFILE_SOURCES],
|
|
cwd=ROOT, capture_output=True, text=True)
|
|
if built.returncode != 0 and sanitizers and "sanitize" in built.stderr:
|
|
pytest.skip(f"{compiler} cannot build with {sanitizers}")
|
|
assert built.returncode == 0, built.stderr[-4000:]
|
|
return program
|
|
|
|
|
|
def run_check(program, folder):
|
|
folder.mkdir(exist_ok=True)
|
|
result = subprocess.run([str(program), str(folder)], capture_output=True, text=True, timeout=300,
|
|
env={**os.environ, "ASAN_OPTIONS": "detect_leaks=1", "TSAN_OPTIONS": "halt_on_error=1"})
|
|
assert result.returncode == 0, result.stdout + result.stderr[-4000:]
|
|
assert "PASS" in result.stdout
|
|
|
|
|
|
def test_check_program_passes(tmp_path):
|
|
run_check(build_check(tmp_path, []), tmp_path / "out")
|
|
|
|
|
|
def test_check_program_has_no_leaks_or_memory_errors(tmp_path):
|
|
"""AddressSanitizer and LeakSanitizer over every recording the check
|
|
makes: arenas, the writer's files, the parts"""
|
|
run_check(build_check(tmp_path, ["address", "undefined"]), tmp_path / "out")
|
|
|
|
|
|
def test_check_program_has_no_data_races(tmp_path):
|
|
"""ThreadSanitizer: the p2p track, the writer hand-off, the recording
|
|
flag"""
|
|
run_check(build_check(tmp_path, ["thread"]), tmp_path / "out")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def written_parts(tmp_path_factory):
|
|
folder = tmp_path_factory.mktemp("written")
|
|
run_check(build_check(tmp_path_factory.mktemp("build"), []), folder)
|
|
recordings = {}
|
|
for path in folder.glob("*.part*.json"):
|
|
try:
|
|
header = json.loads(path.read_text(encoding="utf-8"))["halo"]["header"]
|
|
except (OSError, json.JSONDecodeError, KeyError):
|
|
continue
|
|
recordings.setdefault(header["recording"], []).append((header["part"], path))
|
|
parts = max(recordings.values(), key=len)
|
|
assert len(parts) >= 2, "the real C recording is split into parts"
|
|
return [path for number, path in sorted(parts)]
|
|
|
|
|
|
def test_written_parts_are_traces(written_parts):
|
|
for path in written_parts:
|
|
text = path.read_text(encoding="utf-8")
|
|
trace = json.loads(text)
|
|
assert list(trace) == ["halo", "cpu_summary", "traceEvents", "displayTimeUnit"]
|
|
assert text.startswith('{"halo": {\n"header": {')
|
|
assert trace["halo"]["header"]["last_part"] == (path == written_parts[-1])
|
|
header = trace["halo"]["header"]
|
|
assert (header["role"], header["own_machine"], header["players"], header["players_most"]) == (
|
|
"host", -1, 2, 5)
|
|
assert (header["map_name"], header["gametype"]) == ("b30", "campaign")
|
|
lines = text.splitlines()
|
|
events = lines.index('"traceEvents": [')
|
|
# one event a line, the scopes in order of their start
|
|
starts = [json.loads(line.rstrip(","))["ts"] for line in lines[events + 1:-2] if '"ph":"X"' in line]
|
|
assert starts == sorted(starts)
|
|
assert '"quote\\"back\\\\slash"' in text or path != written_parts[0]
|
|
|
|
|
|
def test_written_parts_nest_on_tracks(written_parts):
|
|
trace = json.loads(written_parts[0].read_text(encoding="utf-8"))
|
|
names = {event["args"]["name"] for event in trace["traceEvents"] if event["ph"] == "M"}
|
|
assert {"game", "p2p"} <= names
|
|
scopes = [event for event in trace["traceEvents"] if event["ph"] == "X" and event["tid"] == 1]
|
|
frame = next(event for event in scopes if event["name"] == "frame")
|
|
inside = [event for event in scopes if frame["ts"] <= event["ts"] < frame["ts"] + frame["dur"] and event is not frame]
|
|
assert inside and all(event["ts"] + event["dur"] <= frame["ts"] + frame["dur"] + 0.001 for event in inside)
|
|
|
|
|
|
def test_written_parts_import_in_perfetto(written_parts):
|
|
"""trace_processor's import, when the perfetto package is installed (run
|
|
by hand on a real recording otherwise)"""
|
|
trace_processor = pytest.importorskip("perfetto.trace_processor")
|
|
with trace_processor.TraceProcessor(trace=str(written_parts[0])) as processor:
|
|
errors = processor.query("select name, value from stats where severity = 'error' and value > 0")
|
|
rows = list(errors)
|
|
assert not rows, [(row.name, row.value) for row in rows]
|
|
|
|
|
|
def test_written_parts_read_by_the_report(written_parts):
|
|
recording = net_report.Recording(written_parts[0])
|
|
assert recording.complete and not recording.warnings
|
|
cpu_rows = [dict(zip(summary["columns"], row)) for summary in
|
|
[json.loads(path.read_text(encoding="utf-8"))["cpu_summary"] for path in written_parts]
|
|
for row in summary["rows"]]
|
|
totals = {}
|
|
for row in cpu_rows:
|
|
totals[row["name"]] = totals.get(row["name"], 0) + row["count"]
|
|
assert totals["texture"] == 2400
|
|
assert totals["render_model"] == 2400
|
|
assert totals["aggregate_child"] == 2400
|
|
frames = sum(1 for path in written_parts for event in json.loads(path.read_text(encoding="utf-8"))["traceEvents"]
|
|
if event["ph"] == "X" and event["name"] == "frame")
|
|
assert recording.cpu["frames"] == frames
|
|
|
|
|
|
# ---------- tools/net_report.py
|
|
|
|
EXPECTED_SUMMARY = ROOT / "tools" / "profile_fixture.summary.txt"
|
|
|
|
|
|
def cpu_recording(tmp_path, role="host"):
|
|
base = tmp_path / f"compat_{role}"
|
|
for number, start, duration in ((1, 0.0, 1.25), (2, 1.25, 2.75)):
|
|
halo, cpu = profile_fixture.part(number, [number - 1], number == 2, 0, 0)
|
|
halo["header"].update(role=role, start_s=start, duration_s=duration)
|
|
profile_fixture.write_part(Path(f"{base}.part{number}.json"), {"header": halo["header"]}, cpu)
|
|
return net_report.Recording(base)
|
|
|
|
|
|
def test_cpu_only_duration_comes_from_each_part_header(tmp_path):
|
|
recording = cpu_recording(tmp_path)
|
|
assert recording.select() == 4.0
|
|
assert recording.select(0.5, 3.0) == 2.5
|
|
assert recording.select(5.0, 6.0) == 0.0
|
|
|
|
|
|
|
|
def test_cpu_report_prints_and_exports_only_cpu_measurements(tmp_path, capsys):
|
|
base = profile_fixture.write_recording(tmp_path)
|
|
directory = tmp_path / "csv"
|
|
assert net_report.main([str(base), "--no-summary", "--csv", str(directory)]) == 0
|
|
output = capsys.readouterr().out
|
|
assert "## cpu scopes" in output and "## worst frames" in output
|
|
assert "## traffic" not in output and "## message types" not in output
|
|
assert "wire_rtt_ms" not in output and "msgs/s" not in output
|
|
assert {path.name.rsplit(".", 2)[1] for path in directory.glob("*.csv")} == {"cpu", "worst_frames"}
|
|
|
|
|
|
@pytest.fixture
|
|
def host(tmp_path):
|
|
return profile_fixture.write_recording(tmp_path)
|
|
|
|
|
|
def test_report_summary_is_the_expected_one(host):
|
|
assert net_report.main([str(host) + ".part2.json"]) == 0
|
|
written = Path(str(host) + ".summary.txt").read_text(encoding="ascii")
|
|
assert written == EXPECTED_SUMMARY.read_text(encoding="ascii")
|
|
assert len(written.splitlines()) <= net_report.SUMMARY_LINE_LIMIT
|
|
|
|
|
|
def test_report_uses_players_most_and_accepts_old_headers(tmp_path):
|
|
base = profile_fixture.write_recording(tmp_path / "legacy")
|
|
paths = [Path(f"{base}.part{number}.json") for number in (1, 2, 3)]
|
|
for index, path in enumerate(paths):
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
header = data["halo"]["header"]
|
|
for key in ("map_name", "gametype", "players_most"):
|
|
header.pop(key, None)
|
|
header["players"] = 1 + index
|
|
profile_fixture.write_part(path, data["halo"], data["cpu_summary"])
|
|
old_parts_report = net_report.Recording(paths[0])
|
|
old_summary = net_report.summary_lines(old_parts_report, None, None, 40)
|
|
assert any("players: 1 at start, 3 most" in line for line in old_summary)
|
|
assert net_report.main([str(paths[0]), "--no-summary"]) == 0
|
|
|
|
|
|
def test_report_summary_uses_current_players_most(tmp_path):
|
|
base = profile_fixture.write_recording(tmp_path / "current")
|
|
last = Path(f"{base}.part3.json")
|
|
data = json.loads(last.read_text(encoding="utf-8"))
|
|
data["halo"]["header"]["players_most"] = 9
|
|
profile_fixture.write_part(last, data["halo"], data["cpu_summary"])
|
|
recording = net_report.Recording(Path(f"{base}.part1.json"))
|
|
summary = net_report.summary_lines(recording, None, None, 40)
|
|
assert any("players: 3 at start, 9 most" in line for line in summary)
|
|
|
|
|
|
def test_report_merges_parts_as_one(tmp_path):
|
|
split = profile_fixture.write_recording(tmp_path / "split")
|
|
whole = profile_fixture.write_recording(tmp_path / "whole", split=False)
|
|
assert net_report.main([str(split), "--no-summary", "--csv", str(tmp_path / "split_csv")]) == 0
|
|
assert net_report.main([str(whole), "--no-summary", "--csv", str(tmp_path / "whole_csv")]) == 0
|
|
for table in sorted((tmp_path / "split_csv").glob("*.csv")):
|
|
if table.name.endswith(".worst_frames.csv"):
|
|
continue
|
|
assert table.read_text() == (tmp_path / "whole_csv" / table.name).read_text(), table.name
|
|
|
|
|
|
def test_report_warns_about_a_missing_part(host, capsys):
|
|
Path(str(host) + ".part2.json").unlink()
|
|
net_report.main([str(host), "--no-summary"])
|
|
captured = capsys.readouterr()
|
|
assert "part 2 of profile_20261005-142233_host is missing" in captured.err
|
|
assert "(incomplete)" in captured.out
|
|
|
|
|
|
def test_report_warns_about_no_last_part(host, capsys):
|
|
Path(str(host) + ".part3.json").unlink()
|
|
net_report.main([str(host), "--no-summary"])
|
|
assert "has no last part: the recording is incomplete" in capsys.readouterr().err
|
|
|
|
|
|
def test_summary_glossary_has_one_line_per_printed_column(host):
|
|
net_report.main([str(host)])
|
|
lines = Path(str(host) + ".summary.txt").read_text(encoding="ascii").splitlines()
|
|
start = lines.index("## columns") + 1
|
|
end = lines.index("", start)
|
|
glossary = [re.split(r"\s{2,}", line, maxsplit=1)[0] for line in lines[start:end]]
|
|
printed = set()
|
|
for index, line in enumerate(lines[end:], end):
|
|
if line.startswith("## ") and index + 1 < len(lines) and lines[index + 1] not in ("", "(none)"):
|
|
printed.update(re.split(r"\s{2,}", lines[index + 1].strip()))
|
|
assert printed and len(glossary) == len(set(glossary))
|
|
assert set(glossary) == printed
|
|
|
|
|
|
def test_summary_file_ignores_top(host):
|
|
assert net_report.main([str(host), "--top", "1"]) == 0
|
|
written = Path(str(host) + ".summary.txt").read_text(encoding="ascii")
|
|
assert written == EXPECTED_SUMMARY.read_text(encoding="ascii")
|
|
assert len(written.splitlines()) <= net_report.SUMMARY_LINE_LIMIT
|
|
|
|
|
|
def test_report_top_limits_the_printed_tables(host, capsys):
|
|
net_report.main([str(host), "--no-summary", "--top", "1"])
|
|
assert "... 4 more rows (net_report.py --top N)" in capsys.readouterr().out
|
|
|
|
|
|
def test_report_says_cpu_tables_cover_the_whole_recording(host, capsys):
|
|
net_report.main([str(host), "--no-summary", "--from", "2", "--to", "4"])
|
|
out = capsys.readouterr().out
|
|
assert "# window: 2.0 s selected (seconds 2 to 4)" in out
|
|
assert "cpu tables, ticks and frames cover the whole recording" in out
|
|
assert "## cpu scopes (top 40 by total_ms, whole recording)" in out
|
|
assert "## worst frames (10, whole recording)" in out
|
|
|
|
|
|
def test_report_names_the_broken_part(host, capsys):
|
|
path = Path(str(host) + ".part2.json")
|
|
path.write_text(path.read_text().replace('"cpu_summary"', '"cpu_summery"'), encoding="utf-8")
|
|
assert net_report.main([str(host), "--no-summary"]) == 1
|
|
err = capsys.readouterr().err
|
|
assert err.startswith("net_report: ") and "part2" in err and "cpu_summary" in err
|
|
path.write_text(Path(str(host) + ".part1.json").read_text().replace('"last_part"', '"last_prt"'), encoding="utf-8")
|
|
assert net_report.main([str(host), "--no-summary"]) == 1
|
|
assert "last_part" in capsys.readouterr().err
|
|
|
|
|
|
def test_windows_compile_command_parser_preserves_quoted_define_and_paths():
|
|
command = ('clang -I"source/saved films" -include port\\windows\\include\\prefix.h '
|
|
'-I"C:\\Program Files\\LLVM\\include" '
|
|
'-DHALO_BUILD_FLAVOR=\\"release\\" source\\game.c -o build\\game.o')
|
|
assert parse_compile_command(command, windows=True) == [
|
|
"clang", "-Isource/saved films", "-include", "port/windows/include/prefix.h",
|
|
"-IC:/Program Files/LLVM/include", '-DHALO_BUILD_FLAVOR="release"',
|
|
"source/game.c", "-o", "build/game.o"]
|
|
|
|
|
|
def test_console_words_and_record_seconds_use_the_real_parser(tmp_path):
|
|
compiler = check_compiler()
|
|
if not compiler:
|
|
pytest.skip("needs a C compiler (Linux, WSL)")
|
|
text = (ROOT / "port/linux/game/profile_console.c").read_text()
|
|
word = text[text.index("static char const *profile_console_word("):text.index("/* ---------- public code */")]
|
|
command = text[text.index("boolean profile_console_command("):text.rindex("\n#endif")]
|
|
program = tmp_path / "console_check"
|
|
code = tmp_path / "console_check.c"
|
|
# Compile the game's actual parsing functions; only their external game effects are stubbed.
|
|
code.write_text(r'''
|
|
#include <assert.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include "profile_trace.h"
|
|
typedef int boolean;
|
|
#define TRUE 1
|
|
#define FALSE 0
|
|
static int requests;
|
|
static double accepted_seconds;
|
|
static void profile_console_install(void) {}
|
|
static long config_integer(const char *name) { (void)name; return 32; }
|
|
static void console_printf(boolean flag, const char *format, ...) { (void)flag; (void)format; }
|
|
int profile_trace_request_start(double seconds, int when, long memory) {
|
|
assert(when == _profile_trace_when_now && memory == 32);
|
|
requests++; accepted_seconds = seconds; return _profile_trace_answer_armed;
|
|
}
|
|
int profile_trace_request_stop(void) { return _profile_trace_answer_not_recording; }
|
|
void profile_trace_status(struct profile_trace_status *status) { memset(status, 0, sizeof(*status)); }
|
|
''' + word + command + r'''
|
|
int main(void) {
|
|
assert(strcmp(profile_console_word(" (PROFILE_RECORD 1.5)", "profile_record"), "1.5)") == 0);
|
|
assert(profile_console_word("profile_record_extra", "profile_record") == NULL);
|
|
assert(profile_console_command("PROFILE_RECORD 1.5"));
|
|
assert(requests == 1 && accepted_seconds == 1.5);
|
|
assert(profile_console_command("profile_record x"));
|
|
assert(requests == 1);
|
|
assert(profile_console_command("profile_record -1"));
|
|
assert(requests == 1);
|
|
assert(profile_console_command("profile_record"));
|
|
assert(requests == 2 && accepted_seconds == 0.0);
|
|
assert(!profile_console_command("profile_record_extra 1"));
|
|
return 0;
|
|
}
|
|
''')
|
|
subprocess.run([compiler, "-std=gnu11", "-Wall", "-Werror", "-DHALO_PROFILE",
|
|
"-I", str(ROOT / "port/linux/include"), str(code), "-o", str(program)], check=True)
|
|
subprocess.run([str(program)], check=True)
|
|
|
|
|
|
# ---------- a normal build is unchanged
|
|
|
|
PROFILE_PREFIXES = ("profile_trace_", "profile_net_", "profile_json_", "profile_overlay_")
|
|
|
|
|
|
def parse_compile_command(command, windows=False):
|
|
"""Split a Ninja C command, normalizing Windows path separators without changing escaped quotes."""
|
|
if windows:
|
|
command = re.sub(r'\\(?!")', "/", command)
|
|
return shlex.split(command, posix=True)
|
|
|
|
|
|
def unavailable(reason):
|
|
"""a run that asks for the check (HALO_PROFILE_BASE or HALO_PROFILE_OBJECTS) must not skip it silently"""
|
|
if os.environ.get("HALO_PROFILE_BASE") or os.environ.get("HALO_PROFILE_OBJECTS"):
|
|
pytest.fail(reason)
|
|
pytest.skip(reason)
|
|
|
|
|
|
def preprocess_flags():
|
|
"""the game's or the platform layer's compile flags for this computer's
|
|
build, as ninja has them, without HALO_PROFILE and without what makes
|
|
an object; absolute where they name a generated file"""
|
|
missing = [name for name in ("clang", "ninja") if not shutil.which(name)]
|
|
if not (ROOT / "build.ninja").is_file():
|
|
missing.append("a configured build (build.ninja)")
|
|
if missing:
|
|
unavailable("needs " + ", ".join(missing))
|
|
# (HALO_PROFILE_OBJECTS names another configured flavour's object folder, such as build/android/guest/obj,
|
|
# where this computer has no build of its own)
|
|
objects = os.environ.get("HALO_PROFILE_OBJECTS") or f"build/{'windows' if os.name == 'nt' else 'linux'}/obj"
|
|
|
|
def flags(object_path):
|
|
listing = subprocess.run(["ninja", "-t", "commands", object_path], cwd=ROOT, capture_output=True, text=True)
|
|
if listing.returncode != 0:
|
|
unavailable(f"needs a build.ninja that has {object_path}")
|
|
command = listing.stdout.strip().splitlines()[-1]
|
|
words = parse_compile_command(command, windows=os.name == "nt")
|
|
while words and not words[0].startswith("-"):
|
|
words = words[1:]
|
|
# (the Android guest's command is a pipeline whose first compile makes assembly)
|
|
words = words[:words.index("&&")] if "&&" in words else words
|
|
kept, skip = [], False
|
|
for word in words:
|
|
if skip:
|
|
skip = False
|
|
elif word in ("-MF", "-o", "-c"):
|
|
skip = True
|
|
elif word in ("-MMD", "-S") or word == "-DHALO_PROFILE" or word.startswith(("-flto", "-fprofile-use")):
|
|
continue
|
|
elif word.endswith(".c") and not word.startswith("-"):
|
|
continue
|
|
elif word.startswith(("build/", "build\\")):
|
|
kept.append(str(ROOT / word))
|
|
elif word.startswith(("-Ibuild/", "-Ibuild\\")):
|
|
kept.append("-I" + str(ROOT / word[2:]))
|
|
else:
|
|
kept.append(word)
|
|
return kept
|
|
|
|
flag_sets = {
|
|
"game": flags(f"{objects}/source/game/game.o"),
|
|
"platform": flags(f"{objects}/port/linux/src/p2p.o"),
|
|
"port_game": flags(f"{objects}/port/linux/game/network_coop.o"),
|
|
}
|
|
# (only a missing 32-bit C library is "no build here"; any other error is a wrong command, which must not hide)
|
|
probe = subprocess.run(["clang", *flag_sets["game"], "-E", "-P", "-x", "c", "-"], cwd=ROOT,
|
|
input="#include <stddef.h>\n#include <stdio.h>\n",
|
|
capture_output=True, text=True)
|
|
if probe.returncode != 0:
|
|
reason = "the game's flags do not preprocess: " + next((line for line in probe.stderr.splitlines() if "error" in line), "")[:200]
|
|
if "bits/libc-header-start.h" in probe.stderr:
|
|
pytest.skip("needs a 32-bit C library (gcc-multilib); " + reason)
|
|
pytest.fail(reason)
|
|
return flag_sets
|
|
|
|
|
|
def flags_for(path, flag_sets):
|
|
if path.startswith("port/linux/src/"):
|
|
return flag_sets["platform"]
|
|
if path.startswith("port/linux/game/"):
|
|
return flag_sets["port_game"]
|
|
return flag_sets["game"]
|
|
|
|
|
|
def preprocessed(tree, path, flags):
|
|
# (path None: an empty file, which is all that the forced includes leave of a file that is empty)
|
|
arguments = ["-x", "c", "-"] if path is None else [path]
|
|
# (__DATE__ and __TIME__ pinned: main.c prints them, and the two trees can be preprocessed a second apart)
|
|
pinned = ["-Wno-builtin-macro-redefined", '-D__DATE__="Jan 1 2000"', '-D__TIME__="00:00:00"']
|
|
result = subprocess.run(["clang", *flags, *pinned, "-E", "-P", *arguments], cwd=tree,
|
|
input="" if path is None else None,
|
|
capture_output=True, text=True)
|
|
assert result.returncode == 0, f"{path}: {result.stderr[-2000:]}"
|
|
# (only the tokens: blank lines and layout differ where #ifdef blocks were)
|
|
return " ".join(result.stdout.split())
|
|
|
|
|
|
def includers_of_changed_headers(base, already):
|
|
"""the C files that include a header that changed since the base: a header's #ifdef can change them too"""
|
|
def names(*arguments):
|
|
return subprocess.run([GIT, *arguments], cwd=ROOT, capture_output=True, text=True, check=True).stdout.split()
|
|
|
|
added = set(names("diff", "--name-only", "--diff-filter=A", base, "HEAD", "--", "*.c"))
|
|
found = set()
|
|
for header in names("diff", "--name-only", "--diff-filter=M", base, "HEAD", "--", "*.h"):
|
|
pattern = r'#[ ]*include[ ]*[<"]([^">]*/)?' + re.escape(header.rsplit("/", 1)[-1]) + '[">]'
|
|
for path in names("grep", "-l", "-E", pattern, "HEAD", "--", "*.c"):
|
|
path = path.split(":", 1)[1]
|
|
if path.startswith(("source/", "port/linux/src/", "port/linux/game/")) and path not in added:
|
|
found.add(path)
|
|
return sorted(found - set(already))
|
|
|
|
|
|
@pytest.mark.skipif(not os.environ.get("HALO_PROFILE_BASE"), reason="HALO_PROFILE_BASE names the base commit")
|
|
def test_normal_build_preprocesses_as_before(tmp_path):
|
|
base = os.environ["HALO_PROFILE_BASE"]
|
|
flag_sets = preprocess_flags()
|
|
changed = subprocess.run([GIT, "diff", "--name-only", "--diff-filter=M", base, "HEAD", "--", "*.c"], cwd=ROOT,
|
|
capture_output=True, text=True, check=True).stdout.split()
|
|
assert changed, "no C file changed since the base"
|
|
changed += includers_of_changed_headers(base, changed)
|
|
tree = tmp_path / "base"
|
|
tree.mkdir()
|
|
archive = subprocess.run([GIT, "archive", "--format=tar", base], cwd=ROOT, check=True, capture_output=True).stdout
|
|
with tarfile.open(fileobj=io.BytesIO(archive)) as reference:
|
|
reference.extractall(tree)
|
|
for path in changed:
|
|
flags = flags_for(path, flag_sets)
|
|
assert preprocessed(tree, path, flags) == preprocessed(ROOT, path, flags), path
|
|
|
|
|
|
def test_new_files_are_empty_in_a_normal_build():
|
|
flag_sets = preprocess_flags()
|
|
# (found, not listed: a new profile_*.c that forgets its #ifdef shows up here)
|
|
paths = sorted(str(path.relative_to(ROOT)).replace("\\", "/")
|
|
for directory in ("port/linux/src", "port/linux/game")
|
|
for path in (ROOT / directory).glob("profile_*.c"))
|
|
assert set(paths) == {*PROFILE_SOURCES, "port/linux/game/profile_console.c"}, paths
|
|
for path in paths:
|
|
flags = flags_for(path, flag_sets)
|
|
assert preprocessed(ROOT, path, flags) == preprocessed(ROOT, None, flags), path
|
|
|
|
|
|
def test_normal_build_has_no_profiling_symbols():
|
|
ninja_file = ROOT / "build.ninja"
|
|
if not ninja_file.is_file():
|
|
pytest.skip("needs a configured build (build.ninja)")
|
|
if "-DHALO_PROFILE" in ninja_file.read_text(encoding="utf-8"):
|
|
pytest.skip("a profiling build is configured")
|
|
nm = shutil.which("llvm-nm") or shutil.which("nm")
|
|
if os.name == "nt" or os.environ.get("HALO_PROFILE_OBJECTS"):
|
|
folder = ROOT / (os.environ.get("HALO_PROFILE_OBJECTS") or "build/windows/obj")
|
|
objects = sorted(str(path) for path in folder.rglob("*.o"))
|
|
else:
|
|
objects = [str(ROOT / "build/linux/halo")] if (ROOT / "build/linux/halo").is_file() else []
|
|
if not nm:
|
|
unavailable("needs nm")
|
|
if not objects:
|
|
unavailable("needs a built normal build (build/windows/obj/**/*.o, build/linux/halo, or the objects in HALO_PROFILE_OBJECTS)")
|
|
found, words, defined = set(), set(), 0
|
|
for start in range(0, len(objects), 200):
|
|
listing = subprocess.run([nm, "--defined-only", *objects[start:start + 200]], capture_output=True, text=True)
|
|
# (an nm that cannot read the objects, such as bitcode from --lto, lists nothing and would pass)
|
|
assert listing.returncode == 0, listing.stderr[-1000:]
|
|
words |= {word.lstrip("_") for word in listing.stdout.split()}
|
|
defined += sum(len(line.split()) == 3 for line in listing.stdout.splitlines())
|
|
found = {word for word in words if word.startswith(PROFILE_PREFIXES)}
|
|
# (a count, not a named symbol: link-time optimization inlines the functions that have one caller. The game's
|
|
# objects list thousands; an unreadable or a near-empty set lists none)
|
|
assert defined >= 300, f"nm lists {defined} defined symbols: it cannot read these objects"
|
|
assert not found, sorted(found)[:20]
|