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
+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: