All GAMELOG lines (EV/SHOT/SCORES/AWARD in mode.log, the runtime's MODE START/END and AWARD lines) and every switch.log line now carry the wall clock as RFC 3339 UTC with millisecond precision; the +Nms stamps relative to runtime load are gone (dur= on MODE END stays: it is a duration). The timestamp comes from a new gl_rfc3339() in the runtime: clock_gettime (CLOCK_REALTIME) plus the civil-from-days algorithm, no libc time calls. switch.log now logs only the human-activated buttons (34 lockdown, 36 start, 62/63 flippers) with quoted names. The upper-flipper button switches (64/65) fire in parallel with the main flipper buttons on this cabinet and are left out with every non-button switch. Deployed and verified on the machine: the boot banner reads 2026-10-13T01:39:39.640Z against the machine's own UTC clock. Co-Authored-By: Claude Code <noreply@anthropic.com>
214 lines
12 KiB
Markdown
214 lines
12 KiB
Markdown
# 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 RFC 3339 UTC
|
||
timestamps (millisecond precision) — 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) | **deployed on the machine, verified in play** (flippers on both edges; sites located with `portswitch.switch_source` and added to the port) |
|
||
|
||
The machine currently runs the v3 build (events, shots, modes, awards, switch
|
||
log). The v3 work needed more than a rebuild: the port had no `switch_edge` /
|
||
`switch_drain` sites or `switch_*` values, so the runtime's edge logger could
|
||
never arm. They were located in the King Kong LE 0.97 ELF with the decryptor's
|
||
`pinball_decryptor.plugins.stern.portswitch.switch_source()` (generation-B
|
||
framework) and added to `ports/king_kong_le-0.97.port`.
|
||
|
||
## 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 (104 entries, ids 1–104), `#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, RFC 3339 UTC stamps with millisecond precision, survives
|
||
reboots:
|
||
|
||
```
|
||
[pad] port ...: king_kong_le 0.97, 121 sites, 55 shots <- runtime armed (all hook words verified)
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z up: game king_kong_le 0.97, 55 named shots; event ids 77/37/52/40/200/198; extra_ball_award=13
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z EV game_start - GAME START
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z EV ball_start player=1 ball=1 score=0
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z SHOT X1 player=1 ball=1 score=7,349,830 <- all 55 named shots
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z EV ball_end player=1 ball=1 score=91,153,090
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z EV bonus_end
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z SCORES P1=95,421,190 P2=0 P3=0 P4=0
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z EV tilt_warning player=1
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z EV tilt player=1 ball=2
|
||
[pad] gamelog: 2026-10-12T20:45:12.123Z MODE START 5 (Island Scene Escape the Swamp) player=1 score=...
|
||
[pad] gamelog: 2026-10-12T20:45:12.123Z MODE END 5 (Island Scene Escape the Swamp) player=1 score=... mode_score=... dur=...ms
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z AWARD EXTRA BALL (id=13) player=1 <- next ball logs as "ball N (EXTRA BALL)"
|
||
[GAMELOG] 2026-10-12T20:45:12.123Z AWARD id=<n> 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` (deployed)
|
||
|
||
The runtime's switch-edge handler writes every edge of the human-activated
|
||
buttons to `/dump/switch.log` — the lockdown/action button (34), the start
|
||
button (36) and the two flipper buttons (62/63), attract included. Names are
|
||
quoted; the upper-flipper button switches (64/65) are wired in parallel with
|
||
the main flipper buttons on this cabinet, so they are left out:
|
||
|
||
```
|
||
2026-10-12T20:45:12.123Z SW <id> "<NAME>" 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. The logged ids are
|
||
the `gl_buttons[]` list in the runtime's switch-edge handler; names come from
|
||
`switch_names.inc`.
|
||
|
||
## 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 <label> > /dump/event.mark` to close a
|
||
window (and open the next); `echo <id> > /dump/event.force` starts/stops a
|
||
stock mode the way a rule does.
|
||
5. `event_read.py /dump/event.log` — which ids fired in which window, with
|
||
handlers, priorities and callers.
|
||
|
||
The ids you name that way become the `event` lines of a port (the six in
|
||
`king_kong_le-0.97.port` were found this way, in the SDK's emulator).
|
||
|
||
## Unfinished work / known issues
|
||
|
||
- **The extra-ball award id is unconfirmed.** Default 13 is a static guess from
|
||
the game's object-name tables; every award id is logged, so earn an extra
|
||
ball, read the `AWARD` line, and pin it down (or override it in the port).
|
||
- **The master SD image does not contain the logger** — it was installed over
|
||
SSH onto the card in the machine. Making it survive a re-flash needs a
|
||
debugfs inject of `mode.so` + `game.port` + the hooked `game_monitor` into
|
||
the master image.
|
||
- **No network streaming yet.** Logs are pulled over SSH; the plan is for the
|
||
mode to ship them itself (`socket()` is not on the build's banned list).
|
||
- **Cosmetic**: the boot banner still prints the upstream "score reports stay
|
||
on this machine" line (see Insider Connected above).
|
||
- `mode_score` is an approximation (see above).
|
||
|
||
## Provenance
|
||
|
||
Built on the PAD Modes SDK
|
||
(`pinball-asset-decryptor/tools/spike2_emu/modes/sdk/` — the
|
||
`mattlqx/pinball-asset-decryptor` Gitea repo, itself a fork of
|
||
`davidvanderburgh/pinball-asset-decryptor`; the SDK docs there — `MODE_SDK.md`,
|
||
`MODE_PARAMETERS.md`, `MODE_LIMITS.md` — apply to everything in this repo).
|
||
Everything in this repo is unmodified SDK content except:
|
||
|
||
- `gamelog.c` (new)
|
||
- `switch_names.inc` (new)
|
||
- `pad_mode_runtime.c` (upstream runtime + the patch in `docs/runtime-patch.diff`)
|
||
- `README.md`
|
||
|
||
`ports/king_kong_le-0.97.port`, `event_probe.c`, `event_read.py`,
|
||
`build_mode.sh` and the headers are the SDK's, unmodified (the port is
|
||
emulator-proven by the SDK repo authors).
|
||
|
||
The machine's SSH setup (dropbear + sftp-server on the Spike 2) is documented
|
||
separately in the local `pinball-assets/README.md` and is **not** part of this
|
||
project — this logger uses that SSH, it does not provide it.
|