diff --git a/.gitignore b/.gitignore index 2f7896d..9280984 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ target/ +__pycache__/ diff --git a/original/MECHANICS_PROGRESS.md b/original/MECHANICS_PROGRESS.md index 93841b8..cef6d1a 100644 --- a/original/MECHANICS_PROGRESS.md +++ b/original/MECHANICS_PROGRESS.md @@ -55,8 +55,9 @@ renamed. claw cadence independently of render rate. - Transcribe the full fixed-point flipper impulse calculation, then compare it with the Rust floating-point collision response. -- Force a chosen claw terminal in the live original and compare its complete - frame/trajectory sequence with the deterministic Rust trace. +- Force each chosen claw terminal in the live original and compare its complete + frame/trajectory sequence with the deterministic Rust trace. A controlled + collision probe has covered terminal 18; terminals 1, 6, and 7 remain. Those items are intentionally not counted as semantic parity. The readable C file preserves their raw constants and labels the unresolved conversion so a @@ -70,11 +71,9 @@ state. Down was then held for 1.1 seconds and released; captures confirmed that the original plunger advances while held and launches only on release. The Rust validation mode captured the corresponding started and half-charge -states plus closing and release frames for claw terminal 6. The original claw -was not forced into a chosen terminal during the live session, so its claw -parity evidence remains the decoded `1000:c79c`/`1000:eab1` control flow, -millipixel/Real48 constants, sprite sheet, and deterministic Rust traces rather -than a claimed live trajectory comparison. +states plus closing and release frames for claw terminal 6. That original live +session did not reach the claw; the controlled tracing described below later +captured an original terminal-18 sequence. ## Live fixed-point tracing @@ -93,3 +92,23 @@ The sampler records original millipixel positions, predicted positions, velocities, flipper inputs and states, and claw animation state whenever any of them changes. It neither patches `TDKPIN.EXE` nor pauses Wine. The tracing helper is Linux-only development infrastructure; it is not part of the Rust replica. + +`tools/inject_original_state.py` briefly stops the opted-in process and updates +all recovered copies of the ball position and velocity atomically. This permits +repeatable probes at individual walls, circles, gates, and mechanism triggers: + +```sh +original/tools/inject_original_state.py --x 289 --y 94 --vx 0 --vy 0 +``` + +The executable image remains unchanged; only the current disposable Wine +process is modified. Run the injector while the ball is waiting in the launcher +so it can identify the live game object, or supply the previously reported +`--object-base` during a sequence of probes. + +A probe at `(289,94)` captured the original terminal-18 path. The collision +suspended the ball while frames advanced from 10 through 18 at the configured +30 ms cadence. Release assigned `(325000,92000)` and `(0,1000)` millipixels, +then cleared suspension during the same timer callback. The three default +physics substeps each added 15 millipixels of vertical velocity, confirming the +observed total gravity increment of 45 per timer tick. diff --git a/original/tools/inject_original_state.py b/original/tools/inject_original_state.py new file mode 100755 index 0000000..8b0d816 --- /dev/null +++ b/original/tools/inject_original_state.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Inject a controlled ball state into the running original for collision probes.""" + +from __future__ import annotations + +import argparse +import os +import signal +import struct +import time + +from trace_original_state import ( + BALL_SUSPENDED, + BALL_X, + BALL_Y, + DATA_READ_SIZE, + NEXT_BALL_X, + NEXT_BALL_Y, + OBJECT_BALL_X, + OBJECT_BALL_Y, + PREVIOUS_BALL_X, + PREVIOUS_BALL_Y, + VELOCITY_X, + VELOCITY_Y, + ProcessMemory, + find_winevdm_pid, + locate_data_segment, + locate_game_object, + verify_original, +) + +BALL_ACTIVE = 0x0BDD +PLAYER_ENTRY_OPEN = 0x0BDE +COLLISION_DISABLED = 0x0BD8 +IMPULSE_X = 0x0BCA +IMPULSE_Y = 0x0BCE +IMPULSE_MODE = 0x0BD2 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pid", type=int, help="Linux PID of the TDKPIN winevdm.exe process") + parser.add_argument( + "--object-base", + type=lambda value: int(value, 0), + help="known live object base; otherwise locate it from the waiting ball", + ) + parser.add_argument("--x", type=float, required=True, help="ball center x in logical pixels") + parser.add_argument("--y", type=float, required=True, help="ball center y in logical pixels") + parser.add_argument("--vx", type=float, default=0.0, help="x velocity in pixels per timer tick") + parser.add_argument("--vy", type=float, default=0.0, help="y velocity in pixels per timer tick") + return parser.parse_args() + + +def process_is_stopped(pid: int) -> bool: + with open(f"/proc/{pid}/status", encoding="ascii") as stream: + for line in stream: + if line.startswith("State:"): + return line.split()[1] in {"T", "t"} + raise RuntimeError(f"cannot find process state for PID {pid}") + + +def stop_process(pid: int) -> bool: + already_stopped = process_is_stopped(pid) + if already_stopped: + return False + os.kill(pid, signal.SIGSTOP) + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + if process_is_stopped(pid): + return True + time.sleep(0.005) + raise RuntimeError(f"PID {pid} did not stop") + + +def pack_millipixels(value: float) -> bytes: + millipixels = round(value * 1000.0) + if not -(2**31) <= millipixels < 2**31: + raise RuntimeError(f"fixed-point value is out of range: {value}") + return struct.pack(" None: + args = parse_args() + verify_original() + pid = args.pid if args.pid is not None else find_winevdm_pid() + resume_afterward = stop_process(pid) + try: + with ProcessMemory(pid, writable=True) as memory: + data_base = locate_data_segment(memory) + data = memory.read(data_base, DATA_READ_SIZE) + object_base = ( + args.object_base if args.object_base is not None else locate_game_object(memory, data) + ) + x = pack_millipixels(args.x) + y = pack_millipixels(args.y) + velocity_x = pack_millipixels(args.vx) + velocity_y = pack_millipixels(args.vy) + next_x = pack_millipixels(args.x + args.vx) + next_y = pack_millipixels(args.y + args.vy) + + for offset, value in ( + (BALL_X, x), + (BALL_Y, y), + (NEXT_BALL_X, next_x), + (NEXT_BALL_Y, next_y), + ): + memory.write(data_base + offset, value) + for offset, value in ( + (VELOCITY_X, velocity_x), + (VELOCITY_Y, velocity_y), + (PREVIOUS_BALL_X, x), + (PREVIOUS_BALL_Y, y), + (OBJECT_BALL_X, x), + (OBJECT_BALL_Y, y), + ): + memory.write(object_base + offset, value) + + memory.write(object_base + IMPULSE_X, bytes(4)) + memory.write(object_base + IMPULSE_Y, bytes(4)) + memory.write(object_base + IMPULSE_MODE, bytes(2)) + memory.write(object_base + COLLISION_DISABLED, bytes(1)) + memory.write(object_base + BALL_ACTIVE, bytes([1])) + memory.write(object_base + PLAYER_ENTRY_OPEN, bytes(1)) + memory.write(object_base + BALL_SUSPENDED, bytes(1)) + print( + f"TDKPIN pid={pid} data={data_base:#x} object={object_base:#x} " + f"ball=({args.x:.3f},{args.y:.3f}) velocity=({args.vx:.3f},{args.vy:.3f})" + ) + finally: + if resume_afterward: + os.kill(pid, signal.SIGCONT) + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError) as error: + raise SystemExit(f"inject_original_state.py: {error}") from error diff --git a/original/tools/trace_original_state.py b/original/tools/trace_original_state.py index 7a74b13..6fc8996 100755 --- a/original/tools/trace_original_state.py +++ b/original/tools/trace_original_state.py @@ -60,12 +60,12 @@ class MemoryMap: class ProcessMemory: - def __init__(self, pid: int) -> None: + def __init__(self, pid: int, *, writable: bool = False) -> None: self.pid = pid self.maps = self._read_maps() try: self.stream = open( # noqa: SIM115 - closed by the context protocol - f"/proc/{pid}/mem", "rb", buffering=0 + f"/proc/{pid}/mem", "r+b" if writable else "rb", buffering=0 ) except PermissionError as error: raise RuntimeError( @@ -101,6 +101,11 @@ class ProcessMemory: raise RuntimeError(f"short process read at {address:#x}: {len(data)}/{size}") return data + def write(self, address: int, data: bytes) -> None: + written = os.pwrite(self.stream.fileno(), data, address) + if written != len(data): + raise RuntimeError(f"short process write at {address:#x}: {written}/{len(data)}") + def search_live_writable_memory(self, needle: bytes) -> list[int]: matches = [] for mapping in self.maps: