EBLAN_ENGINE/tools/test_regressions.py
Игорь Федосеев f42679fe62 fix: stabilize gameplay and IDE source writes
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.
2026-10-31 21:56:54 +03:00

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()