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
This commit is contained in:
2026-08-22 20:20:26 +02:00
parent dcc664346c
commit d24ef6612f
4 changed files with 173 additions and 9 deletions
+1
View File
@@ -1 +1,2 @@
target/
__pycache__/
+26 -7
View File
@@ -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.
+139
View File
@@ -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("<i", millipixels)
def main() -> 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
+7 -2
View File
@@ -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: