Trace shots against current map geometry instead of stale render depth. Preserve outline counters across volatile pixel calls and freeze gameplay on cinematic, transition and death ticks. Clamp packed HP before narrowing and match legacy archetype rules. Publish IDE saves atomically; failed saves block file switches and builds. Drain compiler output while hidden. Verify runtime/editor builds, 112 editor checks, 148 ASM checks and live preview parity. Record remaining stages in docs/STABILIZATION.md.
196 lines
11 KiB
Python
196 lines
11 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", "stability_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")
|
|
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")
|
|
(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()
|