mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-23 15:00:31 +02:00
394 lines
20 KiB
Python
394 lines
20 KiB
Python
"""Pinned actual-x86 replay of RenderDebug box, its faces, and its cache caller.
|
|
|
|
Each role executes its own box, quadrilateral, triangle, and private cache code.
|
|
Only genuine external assert/exit, clock/error, and rasterizer triangle sinks are
|
|
modeled. The finite corpus checks ordinary, inverted, degenerate, signed-zero and
|
|
overlapping read-only inputs, cache boundaries and assertion order. It is not an
|
|
exhaustive floating-point, GPU, final-link, or whole-object admission proof.
|
|
|
|
The existing 3D oracle supplies the reviewed COFF relocation loader and cache
|
|
model through a PRIVATE module instance. This file never changes that module on
|
|
disk or the instance imported by its original tests. Input objects are read-only;
|
|
negative controls alter host copies of target instructions before Unicorn mapping.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
ROOT = next(p for p in Path(__file__).resolve().parents if (p / "tools/coff_compare.py").is_file())
|
|
sys.path.insert(0, str(ROOT))
|
|
from tools import coff_compare as cc
|
|
|
|
BASE_PATH = ROOT / "tools/audit/render_debug_three_runtime.py"
|
|
BASE_PIN = "fd66d5fd9df8df4f79d0ff98c526470c42d4c286f023545a8c0cdd6eda084a08"
|
|
spec = importlib.util.spec_from_file_location("_render_debug_box_private_base", BASE_PATH)
|
|
b = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(b)
|
|
|
|
BOX = "_render_debug_box"
|
|
QUAD = "_render_debug_quadrilateral"
|
|
TRIANGLE = "_render_debug_triangle"
|
|
FUNCTIONS = (BOX, QUAD, TRIANGLE, b.CACHE_ENTRY, b.CACHE_STRING)
|
|
TARGET_PIN = "d3bbaa9484914bd9cf4695ef1775198a60c7891f466aff4fcefe87caf293fdd0"
|
|
TARGET_FUNCTIONS = {
|
|
BOX: (384, 15, "84117ae7cea9313a9233634e8b3951384145c74cb6559d1a6757a7ded2fdfc7d"),
|
|
QUAD: (288, 26, "a4a08ca2262639129ff65d24d4797763a36f897ba439e56241851aa2a44addc4"),
|
|
TRIANGLE: (224, 21, "a8fa95bfe2c5b27979c1ee7689b084a28b9fd2a9a7128331007b208946bd7c2d"),
|
|
}
|
|
|
|
b.FUNCTIONS = FUNCTIONS
|
|
b.FUNCTION_ADDRESSES = {name: b.CODE + index * 0x1000 for index, name in enumerate(FUNCTIONS)}
|
|
b.APIS = ("_display_assert", "_system_exit", "_rasterizer_debug_triangle", "_game_time_get",
|
|
"_error", "_csstrcmp", "_csstrncpy", "_csstrlen")
|
|
b.STUBS = {name: b.STUB + index * 0x100 for index, name in enumerate(b.APIS)}
|
|
b.LITERALS.update({
|
|
"??_C@_09MBGEJPJA@immediate?$AA@": b"immediate\0",
|
|
"??_C@_06IGAKEMNE@point2?$AA@": b"point2\0",
|
|
"??_C@_06JPBBHNJF@point3?$AA@": b"point3\0",
|
|
})
|
|
b.LITERAL_ADDRESSES = {name: b.LITERAL + index * 0x400 for index, name in enumerate(sorted(b.LITERALS))}
|
|
|
|
# January face order, independently decoded from the six call argument lists.
|
|
FACES = ((0, 2, 4, 6), (1, 3, 5, 7), (0, 1, 3, 2),
|
|
(4, 5, 7, 6), (0, 1, 7, 6), (2, 3, 5, 4))
|
|
|
|
|
|
def cases():
|
|
result = []
|
|
geometries = {
|
|
"ordinary": (-3., 4., -5., 6., -7., 8.),
|
|
"asymmetric": (0.125, 32., -0.25, 0.5, -64., 2.),
|
|
"inverted": (4., -3., 6., -5., 8., -7.),
|
|
"plane": (2., 2., -5., 6., -7., 8.),
|
|
"line": (2., 2., -1., -1., -7., 8.),
|
|
"point": (2., 2., -1., -1., 0., 0.),
|
|
"signed_zero": (-0., 0., 0., -0., -0., 0.),
|
|
}
|
|
for name, bounds in geometries.items():
|
|
result.append({"name": "immediate_" + name, "immediate": True, "bounds": bounds})
|
|
result += [
|
|
{"name": "immediate_overlapping_inputs", "immediate": True, "overlap": True},
|
|
{"name": "deferred_overlapping_inputs", "immediate": False, "overlap": True},
|
|
{"name": "deferred_empty", "immediate": False},
|
|
{"name": "deferred_same_tick", "immediate": False, "count": 9},
|
|
{"name": "deferred_last_slot", "immediate": False, "count": 511},
|
|
{"name": "deferred_full_warn", "immediate": False, "count": 512},
|
|
{"name": "deferred_full_already_warned", "immediate": False, "count": 512, "cache_warning": 1},
|
|
{"name": "deferred_new_tick", "immediate": False, "count": 511,
|
|
"cache_time": 9, "game_time": 10, "second_time": 11, "pool": b"stale\0",
|
|
"cache_warning": 1, "string_warning": 1},
|
|
{"name": "deferred_signed_tick_wrap", "immediate": False, "count": 4,
|
|
"cache_time": 0x7fff, "game_time": 0x8000, "second_time": 0x8001},
|
|
{"name": "deferred_same_low_word_tick", "immediate": False, "count": 5,
|
|
"cache_time": 0x8000, "game_time": 0x18000},
|
|
]
|
|
for immediate in (False, True):
|
|
for missing in ("bounds", "color", "both"):
|
|
result.append({"name": f"null_{missing}_{int(immediate)}", "immediate": immediate, "null": missing})
|
|
return [dict(case, function=BOX) for case in result]
|
|
|
|
|
|
def input_image(case):
|
|
raw, blocks, locations = b.input_image(case)
|
|
if case.get("overlap"):
|
|
raw = bytearray(raw)
|
|
locations["color"] = locations["bounds"] + 4
|
|
offset = locations["color"] - b.INPUT
|
|
raw[offset:offset + 16] = blocks["color"]
|
|
start = locations["bounds"] - b.INPUT
|
|
blocks["bounds"] = bytes(raw[start:start + 24])
|
|
raw = bytes(raw)
|
|
return raw, blocks, locations
|
|
|
|
|
|
def arguments(case, locations):
|
|
null = case.get("null")
|
|
bounds = 0 if null in ("bounds", "both") else locations["bounds"]
|
|
color = 0 if null in ("color", "both") else locations["color"]
|
|
return b.p32(int(case["immediate"])) + b.p32(bounds) + b.p32(color)
|
|
|
|
|
|
def box_points(raw):
|
|
# Copy component bytes, including signed zero, without host FP arithmetic.
|
|
x0, x1, y0, y1, z0, z1 = (raw[index:index + 4] for index in range(0, 24, 4))
|
|
return tuple(a + c + d for a, c, d in (
|
|
(x0, y0, z0), (x1, y0, z0), (x0, y0, z1), (x1, y0, z1),
|
|
(x0, y1, z1), (x1, y1, z1), (x0, y1, z0), (x1, y1, z0)))
|
|
|
|
|
|
def triangle_event(points, color):
|
|
return {"api": "rasterizer_debug_triangle", "points": [point.hex() for point in points], "color": color.hex()}
|
|
|
|
|
|
def expected(case, before, blocks):
|
|
if case.get("null"):
|
|
name = "bounds" if case["null"] in ("bounds", "both") else "color"
|
|
return bytes(before), [
|
|
{"api": "display_assert", "information": name, "file": b.SOURCE_TEXT[:-1].decode("ascii"),
|
|
"line": 776 if name == "bounds" else 777, "fatal": 1},
|
|
{"api": "system_exit", "code": 0xffffffff},
|
|
], [BOX], None
|
|
if not case["immediate"]:
|
|
after, events, index = b.cache_model(before, dict(case, function=b.BOX), blocks)
|
|
if index is not None:
|
|
start = b.POOL_SIZE + index * b.ENTRY_SIZE
|
|
after[start:start + 2] = b.p16(6) # Solid box, not box-outline tag7.
|
|
return bytes(after), events, [BOX, b.CACHE_ENTRY], index
|
|
points = box_points(blocks["bounds"])
|
|
events = []
|
|
trace = [BOX]
|
|
for a, c, d, e in FACES:
|
|
events.append(triangle_event((points[a], points[c], points[d]), blocks["color"]))
|
|
events.append(triangle_event((points[a], points[d], points[e]), blocks["color"]))
|
|
trace += [QUAD, TRIANGLE, TRIANGLE]
|
|
return bytes(before), events, trace, None
|
|
|
|
|
|
class Loader(b.Loader):
|
|
def __init__(self, label, path, code_patches=None):
|
|
# Same reviewed mapper, but load only the actual box's reachable graph.
|
|
self.label, self.path = label, Path(path)
|
|
self.obj = cc.load(self.path)
|
|
self.sections = b.function_sections(self.obj)
|
|
self.code_patches = code_patches or {}
|
|
self.uc = b.u.Uc(b.u.UC_ARCH_X86, b.u.UC_MODE_32)
|
|
for base, size in ((b.CODE, 0x10000), (b.STUB, 0x10000), (b.LITERAL, 0x10000),
|
|
(b.CACHE - 0x100, 0x8000), (b.INPUT - 0x100, 0x11000), (b.STACK, 0x10000)):
|
|
self.uc.mem_map(base, size)
|
|
self.loaded, self.extents = set(), {}
|
|
for address in b.STUBS.values():
|
|
self.uc.mem_write(address, b"\xc3")
|
|
self.uc.mem_write(b.STOP, b"\xcc")
|
|
for name, payload in b.LITERALS.items():
|
|
self.uc.mem_write(b.LITERAL_ADDRESSES[name], payload)
|
|
self.function(BOX)
|
|
for base in (b.CODE, b.STUB):
|
|
self.uc.mem_protect(base, 0x10000, b.u.UC_PROT_READ | b.u.UC_PROT_EXEC)
|
|
self.uc.mem_protect(b.LITERAL, 0x10000, b.u.UC_PROT_READ)
|
|
self.uc.mem_protect(b.INPUT - 0x100, 0x11000, b.u.UC_PROT_READ)
|
|
for base, size in ((b.CACHE - 0x100, 0x8000), (b.STACK, 0x10000)):
|
|
self.uc.mem_protect(base, size, b.u.UC_PROT_READ | b.u.UC_PROT_WRITE)
|
|
|
|
def run(self, case, control):
|
|
uc = self.uc
|
|
raw_input, blocks, locations = input_image(case)
|
|
before = b.initial_cache(case)
|
|
wanted_cache, wanted_events, wanted_trace, index = expected(case, before, blocks)
|
|
uc.mem_write(b.CACHE - 64, b"\xa5" * 64 + bytes(before) + b"\xa5" * 64)
|
|
uc.mem_write(b.INPUT - 64, b"\xa5" * 64 + raw_input + b"\xa5" * 64)
|
|
stack = bytearray(b"\x5a" * 0x10000)
|
|
at = b.SP - b.STACK
|
|
payload = b.p32(b.STOP) + arguments(case, locations)
|
|
stack[at:at + len(payload)] = payload
|
|
uc.mem_write(b.STACK, bytes(stack))
|
|
events, trace, snapshots = [], [], []
|
|
state = {"exited": False, "time_calls": 0}
|
|
|
|
def args(count):
|
|
return struct.unpack("<" + "I" * count, uc.mem_read(uc.reg_read(b.UC_X86_REG_ESP) + 4, count * 4))
|
|
|
|
def code_hook(emulator, address, size, unused):
|
|
assert (address == b.STOP or address in b.STUBS.values() or
|
|
any(b.contains(start, length, address, size) for start, length in self.extents.items())), (
|
|
self.label, "unexpected executable address", address)
|
|
for name, start in b.FUNCTION_ADDRESSES.items():
|
|
if address == start:
|
|
trace.append(name)
|
|
name = next((name for name, start in b.STUBS.items() if address == start), None)
|
|
if name is None:
|
|
return
|
|
snapshots.append({"api": name, "cache_sha256": b.digest(bytes(uc.mem_read(b.CACHE, b.CACHE_SIZE)))})
|
|
result = 0x76543210
|
|
if name == "_rasterizer_debug_triangle":
|
|
p0, p1, p2, color = args(4)
|
|
assert all(b.contains(b.STACK + 64, b.SP - b.STACK - 64, point, 12) for point in (p0, p1, p2))
|
|
assert b.contains(b.INPUT, 0x10000, color, 16)
|
|
event = triangle_event([bytes(uc.mem_read(point, 12)) for point in (p0, p1, p2)],
|
|
bytes(uc.mem_read(color, 16)))
|
|
event["point_pointers"] = [self.pointer_region(point) for point in (p0, p1, p2)]
|
|
event["color_pointer"] = self.pointer_region(color)
|
|
elif name == "_game_time_get":
|
|
key = "game_time" if not state["time_calls"] else "second_time"
|
|
result = case.get(key, case.get("game_time", 10)) & 0xffffffff
|
|
state["time_calls"] += 1
|
|
event = {"api": "game_time_get", "result": result}
|
|
elif name == "_error":
|
|
priority, message = args(2)
|
|
event = {"api": "error", "priority": priority, "message": self.cstring(message)}
|
|
elif name == "_display_assert":
|
|
information, file_name, line, fatal = args(4)
|
|
event = {"api": "display_assert", "information": self.cstring(information),
|
|
"file": self.cstring(file_name), "line": line, "fatal": fatal}
|
|
elif name == "_system_exit":
|
|
event = {"api": "system_exit", "code": args(1)[0]}
|
|
events.append(event)
|
|
state["exited"] = True
|
|
emulator.emu_stop()
|
|
return
|
|
else:
|
|
raise AssertionError((self.label, "unexpected string-cache API", name))
|
|
events.append(event)
|
|
uc.reg_write(b.UC_X86_REG_EAX, result)
|
|
uc.reg_write(b.UC_X86_REG_ECX, 0xa1b2c3d4)
|
|
uc.reg_write(b.UC_X86_REG_EDX, 0xb2c3d4e5)
|
|
|
|
def memory_hook(emulator, access, address, size, value, unused):
|
|
if access == b.u.UC_MEM_WRITE:
|
|
allowed = b.contains(b.CACHE, b.CACHE_SIZE, address, size) or b.contains(b.STACK + 64, b.SP - b.STACK - 64, address, size)
|
|
else:
|
|
allowed = any(b.contains(start, length, address, size) for start, length in (
|
|
(b.CACHE, b.CACHE_SIZE), (b.INPUT, 0x10000), (b.STACK + 64, 0x10000 - 128),
|
|
(b.LITERAL, 0x10000), (b.CODE, 0x10000)))
|
|
assert allowed, (self.label, "forbidden memory access", access, address, size, value)
|
|
|
|
code_handle = uc.hook_add(b.u.UC_HOOK_CODE, code_hook)
|
|
memory_handle = uc.hook_add(b.u.UC_HOOK_MEM_READ | b.u.UC_HOOK_MEM_WRITE, memory_hook)
|
|
try:
|
|
uc.reg_write(b.UC_X86_REG_ESP, b.SP)
|
|
uc.reg_write(b.UC_X86_REG_EFLAGS, 0x202)
|
|
uc.reg_write(b.UC_X86_REG_FPCW, control)
|
|
uc.reg_write(b.UC_X86_REG_FPTAG, 0xffff)
|
|
uc.reg_write(b.UC_X86_REG_FPSW, 0)
|
|
initial_tag = uc.reg_read(b.UC_X86_REG_FPTAG)
|
|
for register, value in b.SAVED.items():
|
|
uc.reg_write(register, value)
|
|
uc.emu_start(b.FUNCTION_ADDRESSES[BOX], b.STOP, count=500000)
|
|
finally:
|
|
uc.hook_del(code_handle)
|
|
uc.hook_del(memory_handle)
|
|
|
|
returned = not bool(case.get("null"))
|
|
assert state["exited"] == (not returned)
|
|
if returned:
|
|
assert uc.reg_read(b.UC_X86_REG_EIP) == b.STOP
|
|
assert uc.reg_read(b.UC_X86_REG_ESP) == b.SP + 4
|
|
assert all(uc.reg_read(register) == value for register, value in b.SAVED.items())
|
|
assert uc.reg_read(b.UC_X86_REG_FPCW) == control
|
|
assert uc.reg_read(b.UC_X86_REG_FPTAG) == initial_tag
|
|
assert uc.reg_read(b.UC_X86_REG_FPSW) & 0x3800 == 0
|
|
assert not uc.reg_read(b.UC_X86_REG_EFLAGS) & 0x400
|
|
cache = bytes(uc.mem_read(b.CACHE - 64, b.CACHE_SIZE + 128))
|
|
inputs = bytes(uc.mem_read(b.INPUT - 64, 0x10000 + 128))
|
|
final_stack = bytes(uc.mem_read(b.STACK, 0x10000))
|
|
assert cache[:64] == cache[-64:] == inputs[:64] == inputs[-64:] == b"\xa5" * 64
|
|
assert inputs[64:-64] == raw_input
|
|
assert final_stack[:64] == stack[:64] and final_stack[at:] == stack[at:]
|
|
assert cache[64:-64] == wanted_cache, (self.label, case["name"], "cache mismatch")
|
|
assert trace == wanted_trace, (self.label, case["name"], "trace mismatch", trace, wanted_trace)
|
|
semantic_events = [{key: value for key, value in event.items() if key not in ("point_pointers", "color_pointer")}
|
|
for event in events]
|
|
assert semantic_events == wanted_events, (self.label, case["name"], "event mismatch", semantic_events, wanted_events)
|
|
entry = None if index is None else cache[64 + b.POOL_SIZE + index * b.ENTRY_SIZE:64 + b.POOL_SIZE + (index + 1) * b.ENTRY_SIZE].hex()
|
|
return {"case": case["name"], "events": events, "trace": trace, "api_cache_snapshots": snapshots,
|
|
"cache_sha256": b.digest(cache[64:-64]), "cache_entry_hex": entry,
|
|
"returned": returned, "system_exit_nonreturn": state["exited"],
|
|
"abi": {"esp_restored": True if returned else None,
|
|
"nonvolatiles_restored": True if returned else None,
|
|
"direction_flag_clear": True, "fpcw": control,
|
|
"x87_top_restored": True, "x87_tags_restored": True,
|
|
"input_immutable": True, "guards_unchanged": True}}
|
|
|
|
|
|
def validate_objects(paths):
|
|
objects = {role: cc.load(path) for role, path in paths.items()}
|
|
assert b.digest(objects["target"]["data"]) == TARGET_PIN
|
|
target = objects["target"]
|
|
metadata = {}
|
|
for role, obj in objects.items():
|
|
metadata[role] = {}
|
|
storage = cc.symbol(obj, "_render_debug_globals")
|
|
section = obj["sections"][storage["section"] - 1]
|
|
assert storage["storage"] == 3 and storage["value"] == 0
|
|
assert section["name"] == ".bss" and section["size"] == b.CACHE_SIZE
|
|
assert section["reloc_count"] == 0 and b._section_aux_selection(obj, storage["section"]) == 0
|
|
assert not any(cc._section_bytes(obj, section)), (role, "nonzero cache initializer")
|
|
metadata[role]["_render_debug_globals"] = {
|
|
"storage": storage["storage"], "value": storage["value"], "type": storage["type"],
|
|
"size": section["size"], "flags": section["flags"], "selection": 0,
|
|
"normalized_sha256": b.digest(bytes(cc._section_bytes(obj, section))),
|
|
}
|
|
for name in FUNCTIONS:
|
|
symbol = cc.symbol(obj, name)
|
|
assert symbol["value"] == 0 and symbol["type"] == 32
|
|
assert symbol["storage"] == (3 if name in (b.CACHE_ENTRY, b.CACHE_STRING) else 2)
|
|
info = cc.section_info(obj, name)
|
|
if name in TARGET_FUNCTIONS:
|
|
size, relocations, digest = TARGET_FUNCTIONS[name]
|
|
assert (info["size"], info["relocation_count"], info["normalized_sha256"]) == (size, relocations, digest)
|
|
assert cc.section_infos_equal(info, cc.section_info(target, name)), (role, name, "strict mismatch")
|
|
metadata[role][name] = {"storage": symbol["storage"], "type": symbol["type"],
|
|
"value": symbol["value"], "selection": b._section_aux_selection(obj, symbol["section"]), **info}
|
|
return metadata
|
|
|
|
|
|
def negative_controls(path):
|
|
controls = []
|
|
# +0x6c: mov eax,[esi+4] loads x1. Loading y0 is a runnable wrong corner.
|
|
# +0x16a: push6 sends the box cache tag. push7 would silently select outline.
|
|
for name, offset, old, new, case_name, reason in (
|
|
("wrong_corner", 0x6e, 4, 8, "immediate_ordinary", "event mismatch"),
|
|
("wrong_cache_tag", 0x16b, 6, 7, "deferred_same_tick", "cache mismatch"),
|
|
):
|
|
case = next(case for case in cases() if case["name"] == case_name)
|
|
loader = Loader(name, path, {BOX: [(offset, old, new)]})
|
|
try:
|
|
loader.run(case, 0x037f)
|
|
except AssertionError as error:
|
|
detail = error.args[0]
|
|
assert isinstance(detail, tuple) and detail[:3] == (name, case_name, reason), repr(error)
|
|
controls.append({"name": name, "detected": True, "offset": offset, "old": old, "new": new, "reason": reason})
|
|
else:
|
|
raise AssertionError((name, "negative not detected"))
|
|
return controls
|
|
|
|
|
|
def read_manifest(path):
|
|
manifest = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
assert manifest["version"] == 1
|
|
assert set(manifest["objects"]) == {"target", "first", "actual"}
|
|
paths = {}
|
|
for role, item in manifest["objects"].items():
|
|
file = Path(item["path"])
|
|
assert file.is_absolute() and re.fullmatch(r"[0-9a-f]{64}", item["sha256"])
|
|
assert b.digest(file.read_bytes()) == item["sha256"], (role, "file pin mismatch")
|
|
paths[role] = file
|
|
return manifest, paths
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--manifest", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
assert not args.output.exists(), "preserve prior report; use a new path"
|
|
assert b.digest(BASE_PATH.read_bytes()) == BASE_PIN
|
|
manifest, paths = read_manifest(args.manifest)
|
|
metadata = validate_objects(paths)
|
|
loaders = {role: Loader(role, path) for role, path in paths.items()}
|
|
results = []
|
|
for case in cases():
|
|
for control in b.FPCW_MODES:
|
|
outcomes = {role: loader.run(case, control) for role, loader in loaders.items()}
|
|
assert outcomes["target"] == outcomes["first"] == outcomes["actual"], (case["name"], "differential mismatch")
|
|
results.append({"case": case["name"], "fpcw": control, "target": outcomes["target"],
|
|
"first_equal": True, "actual_equal": True})
|
|
controls = negative_controls(paths["target"])
|
|
report = {"scope": __doc__, "harness_sha256": b.digest(Path(__file__).read_bytes()), "base_sha256": BASE_PIN,
|
|
"manifest": manifest, "objects": metadata, "scenario_count": len(cases()),
|
|
"accepted_execution_count": len(results) * len(loaders), "negative_execution_count": len(controls),
|
|
"all_pass": True, "negative_controls": controls, "results": results}
|
|
# Generated evidence output, never an input/source/object write.
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps({key: report[key] for key in ("scenario_count", "accepted_execution_count", "negative_execution_count", "all_pass")}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|