Preserve stage-1 stability fixes alongside v5 lighting, IPC v2, weapons and studio updates. Fix barrel AOE pointer corruption and extend modal gameplay gating. Verify 137 editor checks, 191 ASM checks, lighting bake and preview parity. Document stage-2 scope without implementing it.
200 lines
12 KiB
Python
200 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Compile and run real C++/ASM regression scenarios in an isolated TEMP tree.
|
|
|
|
Requires the same MASM32 and MSVC tools as the normal builds. No test opens the
|
|
editor/game window or writes the project's levels, textures or executables.
|
|
Use --keep to retain the generated framebuffer and fixtures for inspection.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import importlib.util
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import wave
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def run(args, cwd):
|
|
result = subprocess.run(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
timeout=120, errors="replace")
|
|
if result.returncode:
|
|
print(result.stdout)
|
|
raise RuntimeError(f"Failed ({result.returncode}): {args[0]}")
|
|
return result.stdout
|
|
|
|
|
|
def fixtures(work):
|
|
assets = work / "assets"
|
|
assets.mkdir()
|
|
(work / "editor").mkdir()
|
|
grid = bytearray(1024)
|
|
for x in range(32):
|
|
grid[x] = grid[31 * 32 + x] = 1
|
|
for y in range(32):
|
|
grid[y * 32] = grid[y * 32 + 31] = 1
|
|
|
|
def level(name, *, version=3, count=2, transitions=None, black=False, spawn=(2, 2), cells=None):
|
|
cells = bytearray(grid if cells is None else cells)
|
|
transitions = [(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] if transitions is None else transitions
|
|
for t in transitions:
|
|
cells[t[1]*32+t[0]] = 10
|
|
h = struct.pack("<I7HIIH10s", 0x4E4C4245, version, 32, 32, *spawn, 0, count,
|
|
0 if black else 0x383838, 0 if black else 0x222222,
|
|
len(transitions), bytes(10))
|
|
sprites = b"".join(struct.pack("<6i", 224 << (16 if version == 1 else 0),
|
|
(224 + i * 64) << (16 if version == 1 else 0), 11, 65536, 0, 0)
|
|
for i in range(count))
|
|
data = h + cells + sprites + b"".join(struct.pack("<BB32sHHHBB", *t) for t in transitions)
|
|
(assets / name).write_bytes(data)
|
|
return data
|
|
|
|
good = level("map.dat")
|
|
level("sublevel_02.dat", count=1, spawn=(3, 2), transitions=[(2, 2, b"map.dat", 5, 2, 0, 1, 0)])
|
|
level("arena.dat", count=1, transitions=[])
|
|
level("legacy1.dat", version=1, count=1, transitions=[])
|
|
level("legacy2.dat", version=2)
|
|
level("black.dat", black=True)
|
|
# Deliberately malformed v4 payloads; ordinary fixtures stay v1..v3.
|
|
def bad_scene(name, events, strings=(b"text",), flags=0):
|
|
header = bytearray(good[:38]); struct.pack_into("<H",header,4,4)
|
|
struct.pack_into("<HHH",header,28,len(events),len(strings),flags)
|
|
payload = bytes(header)+good[38:]+b"".join(struct.pack("<HBBiii",*e) for e in events)
|
|
payload += b"".join(text.ljust(96,b"\0") for text in strings)
|
|
(assets/name).write_bytes(payload)
|
|
bad_scene("scene_badop.dat",[(0,99,0,0,0,0),(1,0,0,0,0,0)])
|
|
bad_scene("scene_badref.dat",[(0,4,0,12,0,0),(1,0,0,0,0,0)])
|
|
bad_scene("scene_badtext.dat",[(0,4,0,0,0,0),(1,0,0,0,0,0)],(b"x"*96,))
|
|
bad_scene("scene_badorder.dat",[(3,4,0,0,0,0),(1,0,0,0,0,0)])
|
|
bad_scene("scene_badflags.dat",[(1,0,0,0,0,0)],flags=2)
|
|
bad_scene("scene_noend.dat",[(0,4,0,0,0,0)])
|
|
(assets / "truncated.dat").write_bytes(good[:48])
|
|
level("overflow.dat", count=33)
|
|
level("badname.dat", transitions=[(4, 2, b"x" * 32, 3, 2, 0, 1, 0)])
|
|
level("badspawn.dat", spawn=(0, 0))
|
|
orphan = bytearray(grid); orphan[2 * 32 + 4] = 10
|
|
level("orphan.dat", transitions=[], cells=orphan)
|
|
level("duplicate.dat", transitions=[(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] * 2)
|
|
(assets / "badpack.bin").write_bytes(struct.pack("<IHH", 0x58544245, 1, 16) + bytes(12))
|
|
with wave.open(str(work / "editor/quiet.wav"), "wb") as sound:
|
|
sound.setnchannels(1); sound.setsampwidth(2); sound.setframerate(8000)
|
|
sound.writeframes(bytes(800))
|
|
|
|
|
|
def main():
|
|
sys.dont_write_bytecode = True
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--keep", action="store_true")
|
|
args = parser.parse_args()
|
|
work = Path(tempfile.mkdtemp(prefix="eblan-regressions-"))
|
|
watched = list((ROOT / "assets").rglob("*"))
|
|
original = {p: hashlib.sha256(p.read_bytes()).digest() for p in watched if p.is_file()}
|
|
succeeded = False
|
|
try:
|
|
fixtures(work)
|
|
vcvars_candidates = [Path(os.environ.get("ProgramFiles(x86)", "C:/Program Files (x86)")) /
|
|
"Microsoft Visual Studio/18/BuildTools/VC/Auxiliary/Build/vcvars64.bat"]
|
|
vcvars_candidates += list(Path("C:/Program Files/Microsoft Visual Studio").glob("*/BuildTools/VC/Auxiliary/Build/vcvars64.bat"))
|
|
vcvars = next((p for p in vcvars_candidates if p.is_file()), None)
|
|
if not vcvars:
|
|
raise RuntimeError("MSVC vcvars64.bat was not found")
|
|
sources = [ROOT / "tests/editor_regressions.cpp"] + [ROOT / "editor/imgui" / name for name in
|
|
("imgui.cpp", "imgui_draw.cpp", "imgui_tables.cpp", "imgui_widgets.cpp")]
|
|
flags = "/nologo /O2 /W3 /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /DNOMINMAX /D_CRT_SECURE_NO_WARNINGS /DNDEBUG"
|
|
batch = f'@echo off\ncall "{vcvars}" >nul 2>&1\n'
|
|
batch += f'cl {flags} "{ROOT / "tests/playtest_stub.cpp"}" /Fe:engine.exe\nif errorlevel 1 exit /b 1\n'
|
|
batch += f'cl {flags} ' + " ".join(f'"{p}"' for p in sources)
|
|
batch += ' /Fe:editor_tests.exe /link /SUBSYSTEM:CONSOLE d3d11.lib dxgi.lib user32.lib gdi32.lib shell32.lib comdlg32.lib winmm.lib\n'
|
|
(work / "build_tests.bat").write_text(batch)
|
|
run(["cmd", "/c", str(work / "build_tests.bat")], work)
|
|
print(run([str(work / "editor_tests.exe"), str(ROOT / "assets"), str(ROOT / "engine.exe")], work / "editor"), end="")
|
|
spec = importlib.util.spec_from_file_location("texturepack", ROOT / "tools/make_texturepack.py")
|
|
texturepack = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(texturepack)
|
|
generated = b"".join(texturepack.GENERATORS[i]() for i in range(16))
|
|
if generated != (work / "assets/defaults.bin").read_bytes():
|
|
raise RuntimeError("Python texture generator differs from editor/runtime defaults")
|
|
print("PASS Python, editor and engine use identical procedural texture defaults")
|
|
|
|
main_source = (ROOT / "main.asm").read_text(encoding="utf-8")
|
|
api = main_source[:main_source.index("; ------------------------------------------------------------------------------\n; Engine Includes")]
|
|
api = re.sub(r"^(GetAsyncKeyState|GetForegroundWindow|PlaySoundA)\s+PROTO.*\n", "", api, flags=re.M)
|
|
(work / "engine_api.inc").write_text(api)
|
|
start = main_source.index("ParseStartupMap PROC")
|
|
end = main_source.index("ParseStartupMap ENDP", start) + len("ParseStartupMap ENDP")
|
|
(work / "startup_parser.inc").write_text(main_source[start:end])
|
|
(work / "project_paths.inc").write_text(
|
|
f'.data\nprojectMapPath db "{ROOT / "assets/map.dat"}",0\n'
|
|
f'projectPackPath db "{ROOT / "assets/textures/texturepack.bin"}",0\n.code\n')
|
|
masm_candidates = [Path(p) for p in ("E:/MASM32/masm32/bin/ml.exe", "D:/MASM32/masm32/bin/ml.exe", "C:/masm32/bin/ml.exe", "F:/MASM32/masm32/bin/ml.exe")]
|
|
masm_candidates += list(Path("C:/Program Files (x86)/Microsoft Visual Studio").glob("*/BuildTools/VC/Tools/MSVC/*/bin/Hostx64/x86/ml.exe"))
|
|
ml = next((p for p in masm_candidates if p.is_file()), None)
|
|
if not ml:
|
|
raise RuntimeError("x86 MASM assembler was not found")
|
|
sdk = sorted(Path("C:/Program Files (x86)/Windows Kits/10/Lib").glob("*/um/x86"))
|
|
if not sdk:
|
|
raise RuntimeError("Windows x86 SDK libraries were not found")
|
|
run([str(ml), "/c", "/coff", "/Cp", "/nologo", f"/I{work}", f"/I{ROOT}",
|
|
f"/Fo{work / 'engine_tests.obj'}", str(ROOT / "tests/engine_regressions.asm")], work)
|
|
link = ml.with_name("link.exe")
|
|
run([str(link), "/NOLOGO", "/SUBSYSTEM:CONSOLE", f"/LIBPATH:{sdk[-1]}",
|
|
f"/OUT:{work / 'engine_tests.exe'}", str(work / "engine_tests.obj"),
|
|
"kernel32.lib", "user32.lib", "gdi32.lib", "winmm.lib"], work)
|
|
run([str(work / "engine_tests.exe"), "pace map.dat"], work)
|
|
source = (ROOT / "tests/engine_regressions.asm").read_text() + "".join(
|
|
(ROOT / "tests" / name).read_text() for name in
|
|
("cutscene_engine_checks.inc", "lighting_engine_checks.inc", "stability_engine_checks.inc", "integration_engine_checks.inc"))
|
|
names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M)
|
|
raw = (work / "engine_results.bin").read_bytes()
|
|
values = struct.unpack(f"<{len(raw)//4}I", raw)
|
|
if values[0] != len(names):
|
|
raise RuntimeError(f"ASM result count mismatch: {values[0]} vs {len(names)}")
|
|
for name, passed in zip(names, values[1:]):
|
|
print(("PASS " if passed else "FAIL ") + name)
|
|
angles = struct.unpack("<320i", (work / "angles.bin").read_bytes())
|
|
expected = tuple(round(math.atan((x-160)/160*math.tan(48*math.pi/256))*256/math.pi) for x in range(320))
|
|
if angles != expected or not all(values[1:]):
|
|
raise RuntimeError("ASM regressions failed")
|
|
print(f"ASM: {len(names)}/{len(names)} passed; 320 ray angles match the specification")
|
|
frame = (work / "frame.bgra").read_bytes()
|
|
bitmap_header = struct.pack("<2sIHHI", b"BM", len(frame)+54, 0, 0, 54)
|
|
bitmap_header += struct.pack("<IiiHHIIiiII", 40, 320, -240, 1, 32, 0, len(frame), 0, 0, 0, 0)
|
|
(work / "flat_wall.bmp").write_bytes(bitmap_header + frame)
|
|
project_frame = (work / "project_frame.bgra").read_bytes()
|
|
(work / "project_preview.bmp").write_bytes(bitmap_header + project_frame)
|
|
for path, digest in original.items():
|
|
if hashlib.sha256(path.read_bytes()).digest() != digest:
|
|
raise RuntimeError(f"Test changed a project asset: {path}")
|
|
reference = (work / "assets/cutscene_reference.bin").read_bytes()
|
|
if (work / "cutscene_trace.bin").read_bytes() != reference:
|
|
raise RuntimeError("ASM and editor cinematic players disagree on frame-by-frame state")
|
|
print("PASS editor and ASM cutscene state agrees on every tested frame")
|
|
if (work / "lighting_bake.bin").read_bytes() != (work / "assets/lighting_reference.bin").read_bytes():
|
|
raise RuntimeError("Editor and ASM static RGB bakes differ")
|
|
print("PASS all 1024 baked RGB cells match between the editor and ASM")
|
|
viewport_spec = importlib.util.spec_from_file_location("preview_ipc", ROOT / "tools/test_preview_ipc.py")
|
|
viewport_tests = importlib.util.module_from_spec(viewport_spec)
|
|
viewport_spec.loader.exec_module(viewport_tests)
|
|
viewport_tests.check_preview(ROOT / "engine.exe",work / "project_frame.bgra",work / "live_viewport.bmp")
|
|
viewport_tests.check_preview(ROOT / "engine.exe",work / "lighting_frame.bin",work / "lighting_preview.bmp",work / "assets/lit.dat")
|
|
(work / "cutscene_preview.bmp").write_bytes(bitmap_header + (work / "cutscene_frame.bgra").read_bytes())
|
|
print("PASS original project assets remain byte-for-byte unchanged")
|
|
succeeded = True
|
|
finally:
|
|
if args.keep or not succeeded:
|
|
print(f"Test artifacts: {work}")
|
|
else:
|
|
# Only delete the verified dedicated directory created above.
|
|
if work.resolve().parent == Path(tempfile.gettempdir()).resolve() and work.name.startswith("eblan-regressions-"):
|
|
shutil.rmtree(work)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|