Files
tdkpin/original/tools/trace_original_state.py
ddidderr f79652bc9f fix(physics): restore relative slingshot response
A full comparison against the 175-record ledger found stale guessed coordinates
in both relative slingshot chains. Circles 56/58/71/73 and lines 57/59/72/74
were displaced by up to 70 pixels. The corrected record-57 geometry then
exposed a second issue: readable C and Rust had collapsed two independent
binary response projections into one.

Transcribe the accumulated initializer coordinates exactly. Use the
swapped-component projection only to sign the current tangential response
`abs(normal_velocity) * response_tangent`, and use the standard dot product for
`spin_delta * 0.02 * response_tangent`. Correct the readable C evidence and
Rust together, extend live tracing with the six-byte spin field, and seal the
stopped-Wine transition from `(75530,354765)/(3000,15)` to
`(77369,356597)/(1839,1832)` with Real48 spin `-5.28` in both harnesses.

Test Plan:
- stopped Wine 11.15 record-57 injection/trace -- matched position, velocity, and spin
- `cargo test --workspace --all-targets --all-features` -- 123 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `bash original/tools/test_reconstructed_c.sh` -- passed
- `python3 original/tools/audit_reconstruction.py --require-complete` -- passed with zero incomplete or unclassified units
- `python3 -m py_compile original/tools/trace_original_state.py original/tools/inject_original_state.py` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:28:29 +02:00

346 lines
11 KiB
Python
Executable File

#!/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
PHYSICS_SUBSTEPS = 0x0871
# Recovered offsets in the live game-window object.
MAXIMUM_SPEED = 0x0056
GRAVITY_PER_SUBSTEP = 0x005A
ACTIVE_BALL_COUNT = 0x0061
VELOCITY_X = 0x0BAA
VELOCITY_Y = 0x0BAE
SPIN_REAL48 = 0x0BCE
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
BALL_ACTIVE = 0x0BDD
COLLISION_DISABLED = 0x0BD8
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, *, 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", "r+b" if writable else "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 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:
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",
"physics_substeps",
"active_ball_count",
"maximum_speed_milli_per_tick",
"gravity_milli_per_substep",
"ball_x_milli",
"ball_y_milli",
"next_ball_x_milli",
"next_ball_y_milli",
"velocity_x_milli_per_tick",
"velocity_y_milli_per_tick",
"spin_real48_bits",
"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_active",
"collision_disabled",
"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_u16(data, PHYSICS_SUBSTEPS),
obj[ACTIVE_BALL_COUNT],
unpack_i32(obj, MAXIMUM_SPEED),
unpack_i32(obj, GRAVITY_PER_SUBSTEP),
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),
int.from_bytes(obj[SPIN_REAL48 : SPIN_REAL48 + 6], "little"),
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_ACTIVE],
obj[COLLISION_DISABLED],
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(
"--object-base",
type=lambda value: int(value, 0),
help="known live object base; otherwise locate it from the waiting ball",
)
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 = (
args.object_base
if args.object_base is not None
else 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