diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35f4398 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# build artifacts +*.so +*.o diff --git a/README.md b/README.md index 53e7724..b871672 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,210 @@ # stern-event-agent +A passive gameplay-event logger for Stern Spike 2 pinball, developed against +**King Kong LE 0.97**. It records the game's own events with millisecond +timestamps — the game is not modified, nothing scores, nothing blocks. + +The logger is a PAD mode: a small shared object (`mode.so`) that the game +process loads via `LD_PRELOAD`. It hooks function sites inside the game, and +every hook is verified against the first two machine instructions of that +function before it is touched — a wrong or missing port hooks nothing, logs +why, and the game runs stock. + +## Status (as of 2026-10-12) + +| Part | State | +|---|---| +| Bus events (`EV`): game/ball/bonus start/end, tilt, all players' scores at bonus end | **deployed on the machine, verified in play** | +| Shots (`SHOT`): every named shot dispatched while a ball is up | **deployed on the machine, verified in play** | +| Stock modes (`gamelog: MODE START/END`): the game's own 22 modes with score delta | **deployed on the machine, verified in play** (`mode_score` is approximate) | +| Award screens (`AWARD id=…`): every award the game shows, extra-ball tracking | **deployed on the machine, verified in play** — except the extra-ball award id is not yet confirmed (below) | +| Switch/button log (`/dump/switch.log`, `SW` lines with generated names) | **implemented in this tree, NOT yet deployed or verified** — the deployed `mode.so` (built 2026-10-12 10:46) predates it | + +The machine currently runs the v2 build (events, shots, modes, awards). The +switch logger (v3) is in this tree and needs a rebuild + redeploy before it can +be relied on. + +## Files + +| File | What it is | +|---|---| +| `gamelog.c` | the logger: a passive PAD mode. It never `pm_begin()s`, never blocks, allocates nothing — it only watches and writes `[GAMELOG]` lines | +| `pad_mode_runtime.c` | the PAD Modes SDK runtime, **patched**: (a) a GAMELOG section — passive stock-mode and award watching, exporting `gl_award_seq()` / `gl_last_award_id()` to modes; (b) the per-edge switch logger to `/dump/switch.log`; (c) the Insider Connected gate made passive. Our exact changes vs upstream: `docs/runtime-patch.diff` | +| `switch_names.inc` | King Kong LE 0.97 switch id → name table (103 entries), `#include`d by the runtime's switch logger. Generated from the game's own data (method: `swnames.py` in the decryptor repo); fix names here if any are wrong | +| `pad_mode.h`, `pad_stock.h` | unmodified SDK headers (needed by the build) | +| `build_mode.sh` | unmodified SDK build script: compiles the runtime + your mode into one `-nostdlib` shared object and refuses banned libc calls | +| `event_probe.c`, `event_read.py` | the event-bus measurement instrument and its log reader — how bus event ids are identified for another game (MODE_SDK.md, "Measuring events on another game") | +| `ports/king_kong_le-0.97.port` | the King Kong LE 0.97 port: 121 verified sites, 55 named shots, 6 bus events. Installed on the machine as `/usr/local/padmode/game.port` | +| `docs/runtime-patch.diff` | this project's changes to the upstream runtime (base: `pinball-asset-decryptor` commit `7faf8f08`, the last upstream change to that file) | + +## What gets logged + +### `/dump/mode.log` (deployed) + +Append-only, millisecond stamps relative to the runtime loading, survives +reboots: + +``` +[pad] port ...: king_kong_le 0.97, 121 sites, 55 shots <- runtime armed (all hook words verified) +[GAMELOG] up: game king_kong_le 0.97, 55 named shots; event ids 77/37/52/40/200/198; extra_ball_award=13 +[GAMELOG] +2408751ms EV game_start - GAME START +[GAMELOG] +2408751ms EV ball_start player=1 ball=1 score=0 +[GAMELOG] +2424007ms SHOT X1 player=1 ball=1 score=7,349,830 <- all 55 named shots +[GAMELOG] +2644003ms EV ball_end player=1 ball=1 score=91,153,090 +[GAMELOG] +2644003ms EV bonus_end +[GAMELOG] +2644003ms SCORES P1=95,421,190 P2=0 P3=0 P4=0 +[GAMELOG] +...ms EV tilt_warning player=1 +[GAMELOG] +...ms EV tilt player=1 ball=2 +[pad] gamelog: MODE START 5 (Island Scene Escape the Swamp) player=1 score=... +[pad] gamelog: MODE END 5 (Island Scene Escape the Swamp) player=1 score=... mode_score=... dur=...ms +[GAMELOG] +...ms AWARD EXTRA BALL (id=13) player=1 <- next ball logs as "ball N (EXTRA BALL)" +[GAMELOG] +...ms AWARD id= player=1 score=... <- every award screen +``` + +Bus event ids (King Kong 0.97): `game_start`=0x4d, `ball_start`=0x25, +`ball_end`=0x34, `bonus_end`=0x28, `tilt_warning`=0xc8, `tilt`=0xc6. This build +has **no** bus id for game over (the last `ball_end`/`bonus_end` is the close) +or for bonus start. + +**Extra balls**: the game re-fires `ball_start` for an extra ball. The runtime's +award-screen hook counts the awards, and one whose id matches +`extra_ball_award` (default **13**, overridable with a `value extra_ball_award` +line in the port file) makes the replayed ball log as `ball N (EXTRA BALL)` +instead of advancing the count. + +**Mode detail**: the runtime polls each of the game's 22 mode objects' ACTIVE +state per tick and logs START/END with the player's score at each edge — +`mode_score=` is the score delta across the mode (approximate when other +scoring runs simultaneously). + +**Insider Connected**: stock. The upstream runtime vetoes score/session reports +whenever modes are installed; this build makes that gate **passive** — reports +flow exactly as stock (and are logged once), and the logger adds no scores of +its own. Known cosmetic issue: the boot banner still prints the upstream "score +reports stay on this machine" line — the later `passive gate` line is the truth. + +### `/dump/switch.log` (implemented, not yet deployed) + +The runtime's switch-edge handler writes **every edge of every switch** to +`/dump/switch.log` — buttons included, attract included: + +``` + SW level=<0|1> hit=<0|1> game=<0|1> +``` + +`level` is the switch's raw state after the edge, `hit` is the game's own +"counts as a hit" verdict, `game` whether a player is up. Names come from +`switch_names.inc`; an id not in the table logs `?`. + +## Build + +Cross-compile on macOS in a Debian container (armhf, hard-float, `-nostdlib`): + +```bash +docker run --rm --platform linux/arm64 -v "$PWD:/work" -w /work debian:12 \ + sh -c 'apt-get update -qq && apt-get install -y -qq gcc-arm-linux-gnueabihf >/dev/null \ + && bash build_mode.sh -o mode.so gamelog.c' +``` + +`build_mode.sh` compiles `pad_mode_runtime.c` + `gamelog.c` into one object +(~228 KB) and fails the build if any `pm_` symbol does not exist or any banned +libc call is made (allocation, threads, sleeping, stdio, processes). The few +libc calls that are used (`clock_gettime`, `open`, `write`, `close`, …) resolve +from the game's own libc when the object loads. + +The event probe builds without the runtime: +`build_mode.sh -p -o event_probe.so event_probe.c` + +## Install / deploy (the machine) + +The machine (hostname `stern-spike2`) has root SSH (key-only, port 22). The +logger lives on the rootfs, which is normally read-only: + +| Path on the machine | From this repo | +|---|---| +| `/usr/local/padmode/mode.so` | the built `mode.so` | +| `/usr/local/padmode/game.port` | `ports/king_kong_le-0.97.port` | +| `/etc/init.d/game_monitor` | boot hook: preloads `mode.so` into the game **only if it exists** (stock script kept at `game_monitor.stock`) | + +Deploy a new build: + +```bash +scp mode.so root@stern-spike2:/tmp/ +ssh root@stern-spike2 'mount -o remount,rw /; cp /tmp/mode.so /usr/local/padmode/mode.so; + chmod 755 /usr/local/padmode/mode.so; sync; mount -o remount,ro / || true; reboot' +``` + +Live view: `ssh root@stern-spike2 'tail -f /dump/mode.log'` + +Housekeeping: `/dump` is a 532 MiB rw partition shared with crash dumps and +connectivity logs; `mode.log` grows a few MB per long session (a few hundred +`SHOT` lines per ball is normal). Pull it over SSH and rotate as needed; +`: > /dump/mode.log` truncates it safely while running (same for `switch.log` +once deployed). + +Rollback: `rm -rf /usr/local/padmode`, restore `/etc/init.d/game_monitor.stock`, +reboot — the machine is stock again (SSH remains). + +## Measuring events for another game + +`event_probe.c` hooks the game's event bus (`subscribe` / `dispatch`) and its +tick, and logs every registration and per-window dispatch counts to +`/dump/event.log`. Procedure (the full version is MODE_SDK.md, "Measuring +events on another game"): + +1. `build_mode.sh -p -o event_probe.so event_probe.c` +2. Put the target's `subscribe` / `dispatch` / `tick` sites (address + first two + instruction words) in `/dump/event.sites` — the SDK's `port_words.py` + (in the decryptor repo) fills and checks those words from the game's ELF. +3. Preload `event_probe.so` with `PAD_MODE_SO` (beside the stock runtime — its + trampoline keeps the moved words where the runtime still verifies them). +4. Play; at interesting moments `echo