Group level edits across inspector and canvas gestures, preserve legacy maps, and expose opt-in switch/key door bindings without changing wire layouts. Refresh studio and pause UI with UE5-inspired surfaces and content drawer. Verify 162 editor and 307 ASM checks plus renderer parity.
210 lines
12 KiB
Python
210 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)
|
|
binding = bytearray(level("binding.dat",count=1,transitions=[]))
|
|
binding[38+2*32+3]=5
|
|
struct.pack_into("<I",binding,38+1024+20,0x0203A905)
|
|
(assets/"binding.dat").write_bytes(binding)
|
|
broken=bytearray(binding); broken[38+2*32+3]=0
|
|
(assets/"badbinding.dat").write_bytes(broken)
|
|
broken=bytearray(binding); struct.pack_into("<I",broken,38+1024+20,0x2003A905)
|
|
(assets/"badbindingbounds.dat").write_bytes(broken)
|
|
broken=bytearray(binding); struct.pack_into("<H",broken,16,2); broken+=broken[38+1024:]
|
|
(assets/"badbindingduplicate.dat").write_bytes(broken)
|
|
(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
|
|
("lighting_engine_checks.inc", "cutscene_engine_checks.inc", "stability_engine_checks.inc", "integration_engine_checks.inc", "simulation_engine_checks.inc", "studio_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()
|