From d24ef6612f6007c2d5381b8a77e109cf9639cde4 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 22 Aug 2026 20:20:26 +0200 Subject: [PATCH] feat(reverse): inject controlled original ball states Add an atomic live-state injector for the traceable Wine process. It updates all recovered global and object copies of ball position and velocity while the process is stopped, clears pending impulses, and re-enables normal collision processing before resuming the unmodified executable. This makes individual walls, circles, gates, and mechanisms reachable without relying on random gameplay. Document the first controlled claw probe, including its original terminal-18 animation, exact release state, and three-substep gravity behavior. Test Plan: - `ruff check original/tools/trace_original_state.py original/tools/inject_original_state.py` -- passed - controlled `(289,94)` injection and one-second live claw trace -- passed - terminal-18 release matched `(325000,92000)` and `(0,1000)` -- passed - `shellcheck original/tools/run_traceable_original.sh` -- passed - `git diff --cached --check` -- passed --- .gitignore | 1 + original/MECHANICS_PROGRESS.md | 33 ++++-- original/tools/inject_original_state.py | 139 ++++++++++++++++++++++++ original/tools/trace_original_state.py | 9 +- 4 files changed, 173 insertions(+), 9 deletions(-) create mode 100755 original/tools/inject_original_state.py 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: