diff --git a/original/MECHANICS_PROGRESS.md b/original/MECHANICS_PROGRESS.md index c9d50ba..93841b8 100644 --- a/original/MECHANICS_PROGRESS.md +++ b/original/MECHANICS_PROGRESS.md @@ -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. diff --git a/original/tools/allow_ptrace.c b/original/tools/allow_ptrace.c new file mode 100644 index 0000000..4b74020 --- /dev/null +++ b/original/tools/allow_ptrace.c @@ -0,0 +1,41 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include + +/* + * 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); + } +} diff --git a/original/tools/run_traceable_original.sh b/original/tools/run_traceable_original.sh new file mode 100755 index 0000000..bfc9c86 --- /dev/null +++ b/original/tools/run_traceable_original.sh @@ -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" diff --git a/original/tools/trace_original_state.py b/original/tools/trace_original_state.py new file mode 100755 index 0000000..7a74b13 --- /dev/null +++ b/original/tools/trace_original_state.py @@ -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(" int: + return struct.unpack_from(" 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