feat(reverse): trace the live original physics state
WineDbg changes the Win16 exception path and cannot capture continuous game state reliably. Add a Linux-only launcher that opts the normally running Wine process into same-user tracing without modifying TDKPIN.EXE, plus a sampler that locates the loaded DGROUP and game object from recovered signatures. The CSV trace exposes original millipixel positions, predicted positions, velocities, flipper state, and claw state whenever they change. This provides an external oracle for reconstructing and differentially testing the fixed- point mechanics instead of tuning the Rust approximation against screenshots. Test Plan: - `shellcheck original/tools/run_traceable_original.sh` -- passed - `ruff check original/tools/trace_original_state.py` -- passed - strict shared-library compile with `cc` warnings as errors -- passed - live Wine smoke trace and four-second charged launch capture -- passed - `git diff --cached --check` -- passed
This commit is contained in:
@@ -75,3 +75,21 @@ 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.
|
||||
|
||||
## Live fixed-point tracing
|
||||
|
||||
Wine's remote debugger changes the Win16 exception path and is not suitable for
|
||||
continuous gameplay traces. On Linux, `tools/run_traceable_original.sh` starts
|
||||
the unmodified executable with a same-user ptrace opt-in. While it is waiting at
|
||||
the launcher, `tools/trace_original_state.py` locates the live DGROUP and game
|
||||
object by recovered binary/state signatures and records their fixed-point state:
|
||||
|
||||
```sh
|
||||
original/tools/run_traceable_original.sh
|
||||
original/tools/trace_original_state.py --duration 5 --output /tmp/launch.csv
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#define _GNU_SOURCE
|
||||
|
||||
#include <linux/prctl.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <time.h>
|
||||
|
||||
/*
|
||||
* Wine's Win16 process is re-parented during startup, so Linux's default Yama
|
||||
* policy rejects a later same-user debugger attach. This preload helper only
|
||||
* relaxes that relationship check for the process into which it is loaded.
|
||||
* Normal uid-based ptrace permission checks still apply.
|
||||
*/
|
||||
static void apply_ptrace_policy(void)
|
||||
{
|
||||
(void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0);
|
||||
(void)prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0);
|
||||
}
|
||||
|
||||
static void *reapply_ptrace_policy(void *unused)
|
||||
{
|
||||
const struct timespec interval = {.tv_sec = 0, .tv_nsec = 250000000};
|
||||
(void)unused;
|
||||
|
||||
/* Wine adjusts process policy during bootstrap; outlast those updates. */
|
||||
for (unsigned int attempt = 0; attempt < 20; attempt++) {
|
||||
(void)nanosleep(&interval, NULL);
|
||||
apply_ptrace_policy();
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void allow_same_user_debugger(void)
|
||||
{
|
||||
pthread_t worker;
|
||||
|
||||
apply_ptrace_policy();
|
||||
if (pthread_create(&worker, NULL, reapply_ptrace_policy, NULL) == 0) {
|
||||
(void)pthread_detach(worker);
|
||||
}
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
original_dir=$(cd -- "$script_dir/.." && pwd -P)
|
||||
trace_build_dir=$(mktemp -d /tmp/tdkpin-trace.XXXXXX)
|
||||
trace_library="$trace_build_dir/allow_ptrace.so"
|
||||
|
||||
cleanup() {
|
||||
rm -f -- "$trace_library"
|
||||
rmdir -- "$trace_build_dir" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cc -shared -fPIC -O2 -Wall -Wextra -Werror -pthread \
|
||||
-o "$trace_library" "$script_dir/allow_ptrace.c"
|
||||
|
||||
echo "Launching traceable TDKPIN.EXE; attach to the winevdm.exe Linux PID." >&2
|
||||
LD_PRELOAD="$trace_library${LD_PRELOAD:+:$LD_PRELOAD}" \
|
||||
wine "$original_dir/TDKPIN.EXE"
|
||||
Executable
+310
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sample the live fixed-point state of the original Win16 game under Wine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
ORIGINAL_SHA256 = "a9022f1894e3e6e21fc42e8f6c932f7c549ca77f63aaa0c488bb9d55d9d0174c"
|
||||
DATA_SIGNATURE = b"IWIKTDK Highscores"
|
||||
DATA_SIGNATURE_OFFSET = 0x10
|
||||
DATA_READ_SIZE = 0x900
|
||||
|
||||
# Recovered offsets in the original DGROUP segment.
|
||||
PLAYER_COUNT = 0x07D0
|
||||
CURRENT_PLAYER = 0x07D1
|
||||
BALL_X = 0x07D3
|
||||
BALL_Y = 0x07D7
|
||||
NEXT_BALL_X = 0x07DB
|
||||
NEXT_BALL_Y = 0x07DF
|
||||
CLAW_ACTIVE = 0x07CA
|
||||
CLAW_FRAME = 0x086D
|
||||
CLAW_TARGET_FRAME = 0x086F
|
||||
CLAW_SPRITE_BANK = 0x0873
|
||||
CLAW_ANIMATION_BLOCKED = 0x0874
|
||||
|
||||
# Recovered offsets in the live game-window object.
|
||||
VELOCITY_X = 0x0BAA
|
||||
VELOCITY_Y = 0x0BAE
|
||||
PREVIOUS_BALL_X = 0x0BBA
|
||||
PREVIOUS_BALL_Y = 0x0BBE
|
||||
OBJECT_BALL_X = 0x0BC2
|
||||
OBJECT_BALL_Y = 0x0BC6
|
||||
LEFT_FLIPPER_POSITION = 0x0BD4
|
||||
RIGHT_FLIPPER_POSITION = 0x0BD6
|
||||
LEFT_FLIPPER_DOWN = 0x0BDA
|
||||
RIGHT_FLIPPER_DOWN = 0x0BDB
|
||||
BALL_SUSPENDED = 0x0BDF
|
||||
OBJECT_READ_SIZE = 0x0BE0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryMap:
|
||||
start: int
|
||||
end: int
|
||||
permissions: str
|
||||
path: str
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return self.end - self.start
|
||||
|
||||
|
||||
class ProcessMemory:
|
||||
def __init__(self, pid: int) -> 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
|
||||
)
|
||||
except PermissionError as error:
|
||||
raise RuntimeError(
|
||||
"cannot read the Wine process; launch it with "
|
||||
"original/tools/run_traceable_original.sh"
|
||||
) from error
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.stream.close()
|
||||
|
||||
def _read_maps(self) -> list[MemoryMap]:
|
||||
result = []
|
||||
with open(f"/proc/{self.pid}/maps", encoding="ascii") as stream:
|
||||
for line in stream:
|
||||
fields = line.rstrip().split(maxsplit=5)
|
||||
start_text, end_text = fields[0].split("-")
|
||||
result.append(
|
||||
MemoryMap(
|
||||
start=int(start_text, 16),
|
||||
end=int(end_text, 16),
|
||||
permissions=fields[1],
|
||||
path=fields[5] if len(fields) == 6 else "",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def read(self, address: int, size: int) -> bytes:
|
||||
data = os.pread(self.stream.fileno(), size, address)
|
||||
if len(data) != size:
|
||||
raise RuntimeError(f"short process read at {address:#x}: {len(data)}/{size}")
|
||||
return data
|
||||
|
||||
def search_live_writable_memory(self, needle: bytes) -> list[int]:
|
||||
matches = []
|
||||
for mapping in self.maps:
|
||||
if (
|
||||
mapping.start >= 0x68000000
|
||||
or "r" not in mapping.permissions
|
||||
or "w" not in mapping.permissions
|
||||
or mapping.path
|
||||
):
|
||||
continue
|
||||
try:
|
||||
data = self.read(mapping.start, mapping.size)
|
||||
except (OSError, RuntimeError):
|
||||
continue
|
||||
offset = data.find(needle)
|
||||
while offset >= 0:
|
||||
matches.append(mapping.start + offset)
|
||||
offset = data.find(needle, offset + 1)
|
||||
return matches
|
||||
|
||||
|
||||
def verify_original() -> None:
|
||||
executable = Path(__file__).resolve().parent.parent / "TDKPIN.EXE"
|
||||
digest = hashlib.sha256(executable.read_bytes()).hexdigest()
|
||||
if digest != ORIGINAL_SHA256:
|
||||
raise RuntimeError(f"unexpected TDKPIN.EXE SHA-256: {digest}")
|
||||
|
||||
|
||||
def find_winevdm_pid() -> int:
|
||||
matches = []
|
||||
for process in Path("/proc").iterdir():
|
||||
if not process.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
command = (process / "cmdline").read_bytes().replace(b"\0", b" ").lower()
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError):
|
||||
continue
|
||||
if b"winevdm.exe" in command and b"tdkpin.exe" in command:
|
||||
matches.append(int(process.name))
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(f"expected one TDKPIN winevdm.exe process, found {matches}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def unpack_i32(data: bytes, offset: int) -> int:
|
||||
return struct.unpack_from("<i", data, offset)[0]
|
||||
|
||||
|
||||
def unpack_u16(data: bytes, offset: int) -> int:
|
||||
return struct.unpack_from("<H", data, offset)[0]
|
||||
|
||||
|
||||
def locate_data_segment(memory: ProcessMemory) -> int:
|
||||
matches = memory.search_live_writable_memory(DATA_SIGNATURE)
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(f"expected one live DGROUP signature, found {[hex(x) for x in matches]}")
|
||||
return matches[0] - DATA_SIGNATURE_OFFSET
|
||||
|
||||
|
||||
def locate_game_object(memory: ProcessMemory, data: bytes) -> int:
|
||||
current = data[BALL_X : BALL_Y + 4]
|
||||
current_x = unpack_i32(data, BALL_X)
|
||||
current_y = unpack_i32(data, BALL_Y)
|
||||
next_x = unpack_i32(data, NEXT_BALL_X)
|
||||
next_y = unpack_i32(data, NEXT_BALL_Y)
|
||||
candidates = []
|
||||
|
||||
for address in memory.search_live_writable_memory(current):
|
||||
candidate = address - OBJECT_BALL_X
|
||||
try:
|
||||
obj = memory.read(candidate, OBJECT_READ_SIZE)
|
||||
except (OSError, RuntimeError):
|
||||
continue
|
||||
if obj[OBJECT_BALL_X : OBJECT_BALL_Y + 4] != current:
|
||||
continue
|
||||
velocity_x = unpack_i32(obj, VELOCITY_X)
|
||||
velocity_y = unpack_i32(obj, VELOCITY_Y)
|
||||
previous_x = unpack_i32(obj, PREVIOUS_BALL_X)
|
||||
previous_y = unpack_i32(obj, PREVIOUS_BALL_Y)
|
||||
waiting_at_current_position = (
|
||||
velocity_x == 0
|
||||
and velocity_y == 0
|
||||
and previous_x == current_x
|
||||
and previous_y == current_y
|
||||
)
|
||||
prediction_matches = waiting_at_current_position or (
|
||||
next_x - current_x == velocity_x and next_y - current_y == velocity_y
|
||||
)
|
||||
if prediction_matches:
|
||||
candidates.append(candidate)
|
||||
|
||||
unique = sorted(set(candidates))
|
||||
if len(unique) != 1:
|
||||
raise RuntimeError(f"expected one live game object, found {[hex(x) for x in unique]}")
|
||||
return unique[0]
|
||||
|
||||
|
||||
CSV_FIELDS = [
|
||||
"sequence",
|
||||
"elapsed_us",
|
||||
"player_count",
|
||||
"current_player",
|
||||
"ball_x_milli",
|
||||
"ball_y_milli",
|
||||
"next_ball_x_milli",
|
||||
"next_ball_y_milli",
|
||||
"velocity_x_milli_per_tick",
|
||||
"velocity_y_milli_per_tick",
|
||||
"previous_ball_x_milli",
|
||||
"previous_ball_y_milli",
|
||||
"object_ball_x_milli",
|
||||
"object_ball_y_milli",
|
||||
"left_flipper_position",
|
||||
"right_flipper_position",
|
||||
"left_flipper_down",
|
||||
"right_flipper_down",
|
||||
"ball_suspended",
|
||||
"claw_active",
|
||||
"claw_frame",
|
||||
"claw_target_frame",
|
||||
"claw_sprite_bank",
|
||||
"claw_animation_blocked",
|
||||
]
|
||||
|
||||
|
||||
def sample_state(data: bytes, obj: bytes) -> tuple[int, ...]:
|
||||
return (
|
||||
data[PLAYER_COUNT],
|
||||
data[CURRENT_PLAYER],
|
||||
unpack_i32(data, BALL_X),
|
||||
unpack_i32(data, BALL_Y),
|
||||
unpack_i32(data, NEXT_BALL_X),
|
||||
unpack_i32(data, NEXT_BALL_Y),
|
||||
unpack_i32(obj, VELOCITY_X),
|
||||
unpack_i32(obj, VELOCITY_Y),
|
||||
unpack_i32(obj, PREVIOUS_BALL_X),
|
||||
unpack_i32(obj, PREVIOUS_BALL_Y),
|
||||
unpack_i32(obj, OBJECT_BALL_X),
|
||||
unpack_i32(obj, OBJECT_BALL_Y),
|
||||
unpack_u16(obj, LEFT_FLIPPER_POSITION),
|
||||
unpack_u16(obj, RIGHT_FLIPPER_POSITION),
|
||||
obj[LEFT_FLIPPER_DOWN],
|
||||
obj[RIGHT_FLIPPER_DOWN],
|
||||
obj[BALL_SUSPENDED],
|
||||
data[CLAW_ACTIVE],
|
||||
unpack_u16(data, CLAW_FRAME),
|
||||
unpack_u16(data, CLAW_TARGET_FRAME),
|
||||
data[CLAW_SPRITE_BANK],
|
||||
data[CLAW_ANIMATION_BLOCKED],
|
||||
)
|
||||
|
||||
|
||||
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("--duration", type=float, default=10.0, help="capture duration in seconds")
|
||||
parser.add_argument("--interval-ms", type=float, default=1.0, help="sampling interval in milliseconds")
|
||||
parser.add_argument("--output", type=Path, help="CSV path; stdout when omitted")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.duration <= 0 or args.interval_ms <= 0:
|
||||
raise RuntimeError("duration and interval must be positive")
|
||||
verify_original()
|
||||
pid = args.pid if args.pid is not None else find_winevdm_pid()
|
||||
output = args.output.open("w", newline="", encoding="utf-8") if args.output else sys.stdout
|
||||
|
||||
try:
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(CSV_FIELDS)
|
||||
with ProcessMemory(pid) as memory:
|
||||
data_base = locate_data_segment(memory)
|
||||
initial_data = memory.read(data_base, DATA_READ_SIZE)
|
||||
object_base = locate_game_object(memory, initial_data)
|
||||
print(
|
||||
f"TDKPIN pid={pid} data={data_base:#x} object={object_base:#x}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
started_ns = time.monotonic_ns()
|
||||
deadline_ns = started_ns + int(args.duration * 1_000_000_000)
|
||||
interval_seconds = args.interval_ms / 1000.0
|
||||
last_state = None
|
||||
sequence = 0
|
||||
while time.monotonic_ns() < deadline_ns:
|
||||
data = memory.read(data_base, DATA_READ_SIZE)
|
||||
obj = memory.read(object_base, OBJECT_READ_SIZE)
|
||||
state = sample_state(data, obj)
|
||||
if state != last_state:
|
||||
sequence += 1
|
||||
elapsed_us = (time.monotonic_ns() - started_ns) // 1000
|
||||
writer.writerow((sequence, elapsed_us, *state))
|
||||
output.flush()
|
||||
last_state = state
|
||||
time.sleep(interval_seconds)
|
||||
finally:
|
||||
if output is not sys.stdout:
|
||||
output.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, RuntimeError) as error:
|
||||
raise SystemExit(f"trace_original_state.py: {error}") from error
|
||||
Reference in New Issue
Block a user