feat: multi-game port selection — the machine says which game it is on

The runtime tried one port file (game.port) and refused it if the game's
words did not match. Now it tries every *.port in /usr/local/padmode/ports
and /dump/ports (then the single files, the old layout), resetting the port
tables between candidates, and keeps the first whose core sites verify: a
wrong candidate hooks nothing, so trying it is safe, and the port that
verifies names the game (pm_game/pm_version).

The switch log's buttons and names come from the port's switch lines now —
the baked-in King Kong button list and switch_names.inc are gone (the port
lines carry the same ids and names, so King Kong's switch.log is unchanged).

Boot log: each refused candidate says its path and the game/version it
declares; no candidates at all says so; a game update whose port is not
deployed logs the refusal and runs stock.

tests/test_multi_game.py lifts the new code verbatim and compiles it for the
host: selection among candidates, table reset between them, the getdents64
*.port scan (it caught an inverted suffix test and an off-by-one), and the
port-driven switch log. The address-dependent tests need a Linux host (the
port's site addresses are 32 bits; darwin arm64 maps nothing below 4 GiB).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-13 15:38:45 -04:00
co-authored by Claude Code
parent cbdbe75f00
commit 539bab3bdc
5 changed files with 582 additions and 158 deletions
+1
View File
@@ -1,3 +1,4 @@
# build artifacts # build artifacts
*.so *.so
*.o *.o
__pycache__/
+42 -8
View File
@@ -33,12 +33,37 @@ framework) and added to `ports/king_kong_le-0.97.port`.
| File | What it is | | 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 | | `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` | | `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`, named by the port's `switch` lines; (c) the Insider Connected gate made passive; (d) multi-game port selection (`port_select` — below). 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) | | `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 | | `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") | | `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` | | `tests/test_multi_game.py` | host-compiled tests for the port selection, the ports-directory scan and the switch log (they lift the runtime's own code verbatim; the address-dependent ones need Linux — run them in the Debian container below) |
| `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/ports/king_kong_le-0.97.port` |
## Multiple games
One `mode.so` runs on any game that has a port. The machine says which game it is
on by which port's sites verify — the runtime tries every candidate in order and
keeps the first whose core functions match the running build's instruction words
(the same gate that keeps a wrong port from hooking anything):
1. every `*.port` in `/usr/local/padmode/ports/` and `/dump/ports/` (name order
is the directory's; only the right build's port can verify, so the order
does not matter), then
2. the single files `/usr/local/padmode/game.port` and `/dump/game.port`, the
old one-game layout.
The boot log says what happened:
```
[pad] port /usr/local/padmode/ports/godzilla_le-1.16.port: says godzilla_le 1.16 - not this game's port, skipped
[pad] port /usr/local/padmode/ports/king_kong_le-0.97.port: king_kong_le 0.97, 121 sites, 55 shots
```
A game update changes the words, so its old port stops matching — the machine
logs the refusal and runs stock until the new game version's port is deployed.
The switch log's buttons and their names come from the port's `switch` lines;
a port that names none logs no switch edges.
| `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) | | `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 ## What gets logged
@@ -100,9 +125,9 @@ the main flipper buttons on this cabinet, so they are left out:
``` ```
`level` is the switch's raw state after the edge, `hit` is the game's own `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 "counts as a hit" verdict, `game` whether a player is up. The logged ids and
the `gl_buttons[]` list in the runtime's switch-edge handler; names come from names are the port's `switch` lines (on King Kong: 34 lockdown, 36 start,
`switch_names.inc`. 62/63 the flipper buttons).
## Build ## Build
@@ -123,6 +148,15 @@ from the game's own libc when the object loads.
The event probe builds without the runtime: The event probe builds without the runtime:
`build_mode.sh -p -o event_probe.so event_probe.c` `build_mode.sh -p -o event_probe.so event_probe.c`
The port-selection tests run on any Linux host (the address-dependent ones skip
on macOS, whose arm64 maps nothing below 4 GiB):
```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 python3 python3-pytest gcc >/dev/null \
&& python3 -m pytest tests/test_multi_game.py -q'
```
## Install / deploy (the machine) ## Install / deploy (the machine)
The machine (hostname `stern-spike2`) has root SSH (key-only, port 22). The The machine (hostname `stern-spike2`) has root SSH (key-only, port 22). The
@@ -131,7 +165,7 @@ logger lives on the rootfs, which is normally read-only:
| Path on the machine | From this repo | | Path on the machine | From this repo |
|---|---| |---|---|
| `/usr/local/padmode/mode.so` | the built `mode.so` | | `/usr/local/padmode/mode.so` | the built `mode.so` |
| `/usr/local/padmode/game.port` | `ports/king_kong_le-0.97.port` | | `/usr/local/padmode/ports/king_kong_le-0.97.port` | `ports/king_kong_le-0.97.port` (one file per game; see "Multiple games") |
| `/etc/init.d/game_monitor` | boot hook: preloads `mode.so` into the game **only if it exists** (stock script kept at `game_monitor.stock`) | | `/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: Deploy a new build:
@@ -200,8 +234,8 @@ Built on the PAD Modes SDK
Everything in this repo is unmodified SDK content except: Everything in this repo is unmodified SDK content except:
- `gamelog.c` (new) - `gamelog.c` (new)
- `switch_names.inc` (new)
- `pad_mode_runtime.c` (upstream runtime + the patch in `docs/runtime-patch.diff`) - `pad_mode_runtime.c` (upstream runtime + the patch in `docs/runtime-patch.diff`)
- `tests/test_multi_game.py` (new)
- `README.md` - `README.md`
`ports/king_kong_le-0.97.port`, `event_probe.c`, `event_read.py`, `ports/king_kong_le-0.97.port`, `event_probe.c`, `event_read.py`,
+131 -46
View File
@@ -17,6 +17,7 @@ extern long read(int, void *, __SIZE_TYPE__);
extern long write(int, const void *, __SIZE_TYPE__); extern long write(int, const void *, __SIZE_TYPE__);
extern int close(int); extern int close(int);
extern int unlink(const char *); extern int unlink(const char *);
extern void *memset(void *, int, __SIZE_TYPE__);
extern int vsnprintf(char *, __SIZE_TYPE__, const char *, va_list); extern int vsnprintf(char *, __SIZE_TYPE__, const char *, va_list);
extern int clock_gettime(int, void *); extern int clock_gettime(int, void *);
extern int mprotect(void *, __SIZE_TYPE__, int); extern int mprotect(void *, __SIZE_TYPE__, int);
@@ -148,7 +149,9 @@ void pm_commas(char *out, unsigned cap, uint64_t v)
* switch <id> <mask> <name ...> a switch whose hit is a shot: the switches section below * switch <id> <mask> <name ...> a switch whose hit is a shot: the switches section below
* callout <role> <id> scene <role> <40-hex id> * callout <role> <id> scene <role> <40-hex id>
* text <name> <rest of line> anything else a mode may want * text <name> <rest of line> anything else a mode may want
* Looked for at /usr/local/padmode/game.port (a card) then /dump/game.port (the rig). */ * The candidates are every *.port in a ports directory (the machine says which game it is on by
* which port's sites verify - port_select), then the single files, the old one-game layout. */
static const char *const PORT_DIRS[] = { "/usr/local/padmode/ports", "/dump/ports" };
static const char *const PORT_FILES[] = { "/usr/local/padmode/game.port", "/dump/game.port" }; static const char *const PORT_FILES[] = { "/usr/local/padmode/game.port", "/dump/game.port" };
/* The port is read in PORT_CHUNK pieces, a line at a time, up to PORT_MAX bytes. It was ONE read /* The port is read in PORT_CHUNK pieces, a line at a time, up to PORT_MAX bytes. It was ONE read
@@ -175,7 +178,7 @@ struct text { char name[40]; char value[160]; };
struct switch_shot { unsigned id; uint64_t mask; }; struct switch_shot { unsigned id; uint64_t mask; };
static struct { static struct {
char game[32], version[24], path[40]; char game[32], version[24], path[128];
struct site site[N_SITES]; int n_site; struct site site[N_SITES]; int n_site;
struct named data[N_DATA]; int n_data; struct named data[N_DATA]; int n_data;
struct named value[N_VALUES]; int n_value; struct named value[N_VALUES]; int n_value;
@@ -291,23 +294,16 @@ static void port_text_line(char *line)
if (*s && *s != '#') port_line(s); if (*s && *s != '#') port_line(s);
} }
/* The port, a chunk at a time: a line longer than 255 characters is cut, a port longer than /* One port file, a chunk at a time: a line longer than 255 characters is cut, a port longer than
* PORT_MAX is read to its last whole line before PORT_MAX (port.too_long says so). */ * PORT_MAX is read to its last whole line before PORT_MAX (port.too_long says so). */
static int port_load(void) static int port_read(const char *path)
{ {
static char buf[PORT_CHUNK]; static char buf[PORT_CHUNK];
char line[256]; char line[256];
long n, i, j = 0, total = 0; long n, i, j = 0, total = 0;
unsigned k; int fd = open(path, O_RDONLY);
int fd = -1;
for (k = 0; k < sizeof PORT_FILES / sizeof PORT_FILES[0]; k++) {
fd = open(PORT_FILES[k], O_RDONLY);
if (fd >= 0) {
str_copy(port.path, sizeof port.path, PORT_FILES[k], 40);
break;
}
}
if (fd < 0) return 0; if (fd < 0) return 0;
str_copy(port.path, sizeof port.path, path, sizeof port.path);
while (total < PORT_MAX && (n = read(fd, buf, sizeof buf)) > 0) { while (total < PORT_MAX && (n = read(fd, buf, sizeof buf)) > 0) {
if (n > PORT_MAX - total) n = PORT_MAX - total; if (n > PORT_MAX - total) n = PORT_MAX - total;
total += n; total += n;
@@ -434,8 +430,7 @@ static void maps_read(void)
/* 1 when [addr, addr + len) lies inside mappings with every bit of `how`: one mapping, or a run of /* 1 when [addr, addr + len) lies inside mappings with every bit of `how`: one mapping, or a run of
* CONTIGUOUS ones (another preloaded object's mprotect of a page for its trampoline splits the game's * CONTIGUOUS ones (another preloaded object's mprotect of a page for its trampoline splits the game's
* code mapping in three, and a site's 8 bytes may cross the split), each with every bit (tests lift it * code mapping in three, and a site's 8 bytes may cross the split), each with every bit (tests lift it
* verbatim) */ * verbatim) */static int maps_has(unsigned long addr, unsigned long len, unsigned how)
static int maps_has(unsigned long addr, unsigned long len, unsigned how)
{ {
unsigned long at = addr, end = addr + len; unsigned long at = addr, end = addr + len;
int i, steps; int i, steps;
@@ -450,9 +445,15 @@ static int maps_has(unsigned long addr, unsigned long len, unsigned how)
return 0; return 0;
} }
static int is_game_process(unsigned addr) /* 1 when this process has the game's own executable code mapped: the launcher passes the preload
* to every child the game spawns, and those have nothing of the game's mapped. Which game exactly
* (that a port's tick lies in it) the port gate says, per site. */
static int game_mapped(void)
{ {
return maps_has(addr, 8, MAP_R | MAP_X | MAP_GAME); int i;
for (i = 0; i < n_maps; i++)
if ((maps[i].how & (MAP_R | MAP_X | MAP_GAME)) == (MAP_R | MAP_X | MAP_GAME)) return 1;
return 0;
} }
/* ---- rule 2: the words, followed through another preloaded object's trampoline -------- /* ---- rule 2: the words, followed through another preloaded object's trampoline --------
@@ -7899,6 +7900,7 @@ extern const struct pm_mode *const __stop_pm_modes[] __attribute__((weak, visibi
* callbacks not overlapping rest on this, so it is measured on every boot, not assumed. */ * callbacks not overlapping rest on this, so it is measured on every boot, not assumed. */
extern long syscall(long, ...); extern long syscall(long, ...);
#define SYS_GETTID 224 /* ARM EABI */ #define SYS_GETTID 224 /* ARM EABI */
#define SYS_GETDENTS64 217 /* ARM EABI; the same number on x86_64 */
static void note_thread(const char *what, int *said) static void note_thread(const char *what, int *said)
{ {
@@ -8645,6 +8647,30 @@ static int edge_is_hit(unsigned level, unsigned flags, unsigned polarity, int ha
static long edge_log_left; /* value switch_edge_log N: log the first N edges (proving a port) */ static long edge_log_left; /* value switch_edge_log N: log the first N edges (proving a port) */
/* GAMELOG's switch log: every edge of a switch the port's `switch` lines name, with the port's
* own name for it (the buttons - on King Kong the lockdown, start and two flipper buttons). The
* runtime carries no per-game button table: a port that names none logs nothing. */
static const char *GL_SWITCH_LOG = "/dump/switch.log";
static void gl_switch_log(unsigned id, unsigned level, int hit, int playing)
{
char tbuf[40], nm[40] = "?", line[160];
int i, j, fd, len, want = 0;
for (i = 0; i < port.n_switch && !want; i++)
if (port.sw[i].id == id)
for (j = 0; j < port.n_shot; j++)
if (port.shot[j].mask == port.sw[i].mask) {
pm_snprintf(nm, sizeof nm, "%s", port.shot[j].name);
want = 1;
break;
}
if (!want) return;
gl_rfc3339(tbuf, sizeof tbuf);
len = pm_snprintf(line, sizeof line, "%s SW %u \"%s\" level=%u hit=%d game=%d\n",
tbuf, id, nm, level & 1u, hit, playing);
fd = open(GL_SWITCH_LOG, O_WRONLY | O_CREAT | O_APPEND, 0644);
if (fd >= 0) { write(fd, line, len); close(fd); }
}
static void on_switch_edge(unsigned *r) static void on_switch_edge(unsigned *r)
{ {
static volatile int said; static volatile int said;
@@ -8665,29 +8691,7 @@ static void on_switch_edge(unsigned *r)
mask = edge.mode_mask ? *(const unsigned short *)(unsigned long)edge.mode_mask : 0u; mask = edge.mode_mask ? *(const unsigned short *)(unsigned long)edge.mode_mask : 0u;
hit = edge_is_hit(level, flags, pol, edge.mode_mask != 0, mask); hit = edge_is_hit(level, flags, pol, edge.mode_mask != 0, mask);
playing = pm_in_game(); /* attract, a tilt: no player up for a mode to score */ playing = pm_in_game(); /* attract, a tilt: no player up for a mode to score */
/* GAMELOG: the buttons a human presses, to /dump/switch.log (34 lockdown, 36 start, 62/63 the gl_switch_log(id, level, hit, playing); /* GAMELOG: the port's buttons, to /dump/switch.log */
* flipper buttons). The upper-flipper button switches (64/65) are wired in parallel with the
* main flipper buttons on this cabinet, so they are left out with every non-button switch. */
{
static const unsigned gl_buttons[] = { 34, 36, 62, 63 };
static const struct { unsigned id; const char *name; } gl_sw[] = {
#include "switch_names.inc"
};
unsigned k;
char tbuf[40], nm[40] = "?", line[160];
int fd, len, want = 0;
for (k = 0; k < sizeof gl_buttons / sizeof gl_buttons[0]; k++)
if (gl_buttons[k] == id) { want = 1; break; }
if (want) {
for (k = 0; k < sizeof gl_sw / sizeof gl_sw[0]; k++)
if (gl_sw[k].id == id) { pm_snprintf(nm, sizeof nm, "%s", gl_sw[k].name); break; }
gl_rfc3339(tbuf, sizeof tbuf);
len = pm_snprintf(line, sizeof line, "%s SW %u \"%s\" level=%u hit=%d game=%d\n",
tbuf, id, nm, level & 1u, hit, playing);
fd = open("/dump/switch.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (fd >= 0) { write(fd, line, len); close(fd); }
}
}
if (edge_log_left > 0 && __sync_sub_and_fetch(&edge_log_left, 1) >= 0) if (edge_log_left > 0 && __sync_sub_and_fetch(&edge_log_left, 1) >= 0)
say("edge: switch %u level %u (flags 0x%04x, polarity 0x%04x, mode mask 0x%04x, in game %d)%s", id, level & 1u, say("edge: switch %u level %u (flags 0x%04x, polarity 0x%04x, mode mask 0x%04x, in game %d)%s", id, level & 1u,
flags, pol, mask, playing, hit && playing ? " - a hit" : ""); flags, pol, mask, playing, hit && playing ? " - a hit" : "");
@@ -8834,6 +8838,82 @@ static int site_check(struct site *x)
return x->ok; return x->ok;
} }
/* Everything a port's lines fill: emptied before each candidate, so one port's lines never leak
* into the next one's tables. */
static void port_reset(void)
{
memset(&port, 0, sizeof port);
can = 0;
n_event_names = n_site_events = 0;
n_lamps = 0;
n_stock_rules = 0;
}
/* Which port is this game's? Try each candidate in order: read it, then the gate. A port whose
* core sites do not match this build's words hooks nothing, so a wrong candidate is safe to try -
* and the first that verifies IS the game's port, its own game/version lines naming the game.
* 0 when nothing matched (every refusal said in the log): the game runs stock. */
static int port_gate(void);
static int port_select(const char *const *files, int n)
{
int i;
for (i = 0; i < n; i++) {
port_reset();
if (!port_read(files[i])) continue;
if (port_gate()) return 1;
say("port %s: says %s %s - not this game's port, skipped", files[i], port.game, port.version);
}
return 0;
}
/* ---- the ports directories -----------------------------------------------------------
* The candidates, in order: every *.port in a ports directory, then the single files. Read with
* the getdents64 syscall by hand: no DIR, no allocation, like everything here. */
/* the kernel's getdents64 record (linux/dirent.h's is not the same on every libc) */
struct port_dirent { unsigned long long ino; long long off; unsigned short reclen; unsigned char type; char name[]; };
/* the *.port names in one getdents64 buffer, appended to cand as <dir>/<name>; the new count */
static long ports_scan_buf(char *buf, long len, const char *dir, char (*cand)[128], long cap, long n)
{
const unsigned long noff = __builtin_offsetof(struct port_dirent, name);
long at = 0;
while (at + (long)sizeof(struct port_dirent) <= len) {
struct port_dirent *d = (struct port_dirent *)(void *)(buf + at);
unsigned long nl = 0;
if (!d->reclen || at + d->reclen > len) break;
while (noff + nl < d->reclen && d->name[nl]) nl++;
if (d->type == 8 /* DT_REG */ && nl > 5 && str_eq(d->name + nl - 5, ".port") && n < cap) {
pm_snprintf(cand[n], 128, "%s/%s", dir, d->name);
n++;
}
at += d->reclen;
}
return n;
}
#define PORT_CAND_MAX 16
/* every candidate port file on the machine, in the order port_select tries them */
static long ports_scan(char (*cand)[128], long cap)
{
static char buf[4096];
long n = 0, i;
for (i = 0; i < (long)(sizeof PORT_DIRS / sizeof PORT_DIRS[0]) && n < cap; i++) {
int fd = open(PORT_DIRS[i], O_RDONLY);
if (fd < 0) continue;
for (;;) {
long k = syscall(SYS_GETDENTS64, fd, buf, sizeof buf);
if (k <= 0) break;
n = ports_scan_buf(buf, k, PORT_DIRS[i], cand, cap, n);
}
close(fd);
}
for (i = 0; i < (long)(sizeof PORT_FILES / sizeof PORT_FILES[0]) && n < cap; i++)
str_copy(cand[n++], 128, PORT_FILES[i], 128);
return n;
}
/* The core sites first: when one is missing, outside the game's code or not this build's, no other /* The core sites first: when one is missing, outside the game's code or not this build's, no other
* site is read and the port is refused. Then every other site, each switching off only its own * site is read and the port is refused. Then every other site, each switching off only its own
* capability. 1 = this game's port. (tests lift it verbatim) */ * capability. 1 = this game's port. (tests lift it verbatim) */
@@ -9325,23 +9405,28 @@ static void sndm_arm(void)
__attribute__((constructor)) __attribute__((constructor))
static void pad_mode_start(void) static void pad_mode_start(void)
{ {
static char cand[PORT_CAND_MAX][128];
static const char *files[PORT_CAND_MAX];
const struct pm_mode *m; const struct pm_mode *m;
struct site *tick; long n, i;
int modes = 0; int modes = 0;
if (!port_load()) return; /* no port: stay out of the way */
tick = site("tick");
if (!tick) return;
maps_read(); /* the process's mappings, read once */ maps_read(); /* the process's mappings, read once */
if (!is_game_process(tick->addr)) return; if (!game_mapped()) return; /* the launcher's children: not the game */
clock_gettime(CLOCK_MONOTONIC, &t0); clock_gettime(CLOCK_MONOTONIC, &t0);
log_fd = open("/dump/mode.log", O_WRONLY | O_CREAT | O_APPEND, 0644); log_fd = open("/dump/mode.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
n = ports_scan(cand, PORT_CAND_MAX);
if (!n) {
say("ports: none found - no *.port in the ports directories and no game.port");
return;
}
for (i = 0; i < n; i++) files[i] = cand[i];
if (!port_select(files, (int)n)) return; /* nothing matched: the game runs stock */
say("port %s: %s %s, %d sites, %d shots", port.path, port.game, port.version, port.n_site, port.n_shot); say("port %s: %s %s, %d sites, %d shots", port.path, port.game, port.version, port.n_site, port.n_shot);
if (port.too_long) say("port: longer than %d bytes - the lines after that are not read", PORT_MAX); if (port.too_long) say("port: longer than %d bytes - the lines after that are not read", PORT_MAX);
if (port.dropped) if (port.dropped)
say("port: %d line(s) not read - a table is full (sites %d of %d, data %d of %d, values %d of %d, shots %d of %d, switches %d of %d)", say("port: %d line(s) not read - a table is full (sites %d of %d, data %d of %d, values %d of %d, shots %d of %d, switches %d of %d)",
port.dropped, port.n_site, N_SITES, port.n_data, N_DATA, port.n_value, N_VALUES, port.n_shot, N_SHOTS, port.dropped, port.n_site, N_SITES, port.n_data, N_DATA, port.n_value, N_VALUES, port.n_shot, N_SHOTS,
port.n_switch, N_SWITCHES); port.n_switch, N_SWITCHES);
if (!port_gate()) return;
clipv_arm(); /* PAD-446: before the score gate - a clip variant scores nothing */ clipv_arm(); /* PAD-446: before the score gate - a clip variant scores nothing */
sndm_arm(); /* PAD-494: nor does a music mode */ sndm_arm(); /* PAD-494: nor does a music mode */
if (clipv_only || sndm_only) { if (clipv_only || sndm_only) {
-104
View File
@@ -1,104 +0,0 @@
{1, "QR SCANNER STATUS READY"},
{2, "QR SCANNER STATUS STRING"},
{3, "QR SCANNER STATUS ENCODED DATA"},
{4, "QR SCANNER STATUS MISSED STRING"},
{5, "QR SCANNER STATUS OVERRUN"},
{6, "QR SCANNER STATUS UNSUPPORTED_AP"},
{7, "QR SCANNER STATUS FIFO OVERRUN"},
{8, "QR SCANNER STATUS CONFIG"},
{9, "QR SCANNER STATUS ERROR"},
{10, "QR SCANNER STATUS ERR1"},
{11, "QR SCANNER STATUS ERR2"},
{12, "QR SCANNER STATUS ERR3"},
{13, "QR SCANNER STATUS ERR4"},
{14, "QR SCANNER STATUS ERR5"},
{15, "QR SCANNER STATUS ERR6"},
{16, "QR SCANNER STATUS NO READER"},
{17, "DIP 1"},
{18, "DIP 2"},
{19, "DIP 3"},
{20, "DIP 4"},
{21, "DIP 5"},
{22, "DIP 6"},
{23, "DIP 7"},
{24, "DIP 8"},
{25, "SERVICE SELECT"},
{26, "SERVICE PLUS"},
{27, "SERVICE MINUS"},
{28, "SERVICE BACK"},
{29, "HEADPHONE DETECT"},
{30, "HEADPHONE KIT CABLE DETECT"},
{31, "VOLUME ENCODER 1"},
{32, "VOLUME ENCODER 2"},
{33, "COIN DOOR INTERLOCK"},
{34, "LOCKDOWN BUTTON"},
{35, "TICKET NOTCH"},
{36, "START BUTTON"},
{37, "TOURNAMENT START BUTTON"},
{38, "TILT PENDULUM"},
{39, "LEFT COIN"},
{40, "CENTER COIN"},
{41, "RIGHT COIN"},
{42, "FOURTH COIN"},
{43, "FIFTH COIN"},
{44, "SIXTH COIN"},
{45, "SLAM TILT"},
{46, "X1"},
{47, "X2"},
{48, "X3"},
{49, "KONG CAVE VUK"},
{50, "X4"},
{51, "SHOOTER EXIT"},
{52, "UP LEFT FLIPPER EOS"},
{53, "UP RIGHT FLIPPER EOS"},
{54, "LEFT FLIPPER EOS"},
{55, "LEFT OUTLANE-LOWER"},
{56, "LEFT INLANE-INNER"},
{57, "RIGHT INLANE"},
{58, "RIGHT OUTLANE"},
{59, "LEFT OUTLANE-UPPER"},
{60, "LEFT INLANE-OUTER"},
{61, "RIVER EXIT"},
{62, "RIGHT FLIPPER BUTTON"},
{63, "LEFT FLIPPER BUTTON"},
{64, "UP RIGHT FLIPPER BUTTON"},
{65, "UP LEFT FLIPPER BUTTON"},
{66, "SHOOTER LANE"},
{67, "RIGHT SLINGSHOT"},
{68, "LEFT SLINGSHOT"},
{69, "RIGHT FLIPPER EOS"},
{70, "TROUGH 6"},
{71, "TROUGH 5"},
{72, "TROUGH 4"},
{73, "TROUGH 3"},
{74, "TROUGH 2"},
{75, "TROUGH 1"},
{76, "TROUGH JAM"},
{77, "RIVER ENTER"},
{78, "ISLAND TREASURE"},
{79, "U-TURN ROLLOVER"},
{80, "LEFT ORBIT SPINNER OPTO"},
{81, "POWERUP TARGET"},
{82, "KICK TARGET"},
{83, "TRAIN ENTER OPTO"},
{84, "TRAIN LOCK OPTO"},
{85, "KONG CAVE MADE"},
{86, "GONG HIT OPTO"},
{87, "KONG GONG VUK OPTO"},
{88, "RIGHT SPINNER OPTO"},
{89, "RIGHT ORBIT"},
{90, "CENTER RAMP MADE OPTO"},
{91, "LEFT ORBIT ENTRANCE OPTO"},
{92, "LEFT RAMP MADE OPTO"},
{93, "BIPLANE RAMP MADE OPTO"},
{94, "CENTER SHOT ENTER OPTO"},
{95, "2.42 MILLION BANANAS"},
{96, "4 BANK DROP 1-TOP"},
{97, "4 BANK DROP 2"},
{98, "4 BANK DROP 3"},
{99, "4 BANK DROP 4-BOT"},
{100, "PIT TARGET-TOP"},
{101, "PIT TARGET-MID"},
{102, "PIT TARGET-BOT"},
{103, "SPIDER MAGNET FIRED VIRTUAL"},
{104, "PUNCHBACK FIRED VIRTUAL"},
+408
View File
@@ -0,0 +1,408 @@
"""Multi-game port selection: which port file is this game's.
- port_select tries each candidate port in order, resetting the port tables between them, and
keeps the first whose core sites verify against the running game - that is how the machine
says which game it is on. A wrong candidate hooks nothing (the gate), so trying it is safe.
- ports_scan_buf reads the kernel's getdents64 records: every *.port in a ports directory is a
candidate, nothing else is.
- gl_switch_log writes the human buttons to the switch log, named by the port's own `switch`
lines - the switch names are no longer baked into the runtime per game.
Lifted verbatim from pad_mode_runtime.c and compiled for the HOST (the lift/_host_run helpers
are the decryptor repo tests', tests/test_spike2_mode_roster.py). Desk only: no card, no game.
"""
import pathlib
import shutil
import subprocess
import sys
import pytest
RUNTIME = pathlib.Path(__file__).resolve().parents[1] / "pad_mode_runtime.c"
def _src():
return RUNTIME.read_text(encoding="utf-8")
def _lift(src, signature):
"""A function's text, verbatim from the runtime (brace-matched from its signature)."""
i = src.index(signature)
depth = 0
for k in range(src.index("{", i), len(src)):
depth += {"{": 1, "}": -1}.get(src[k], 0)
if depth == 0:
return src[i:k + 1]
raise AssertionError("unbalanced: " + signature)
def _between(src, start, end):
i = src.index(start)
return src[i:src.index(end, i)]
def _cc():
for c in ("gcc", "cc", "clang"):
if shutil.which(c):
return c
pytest.skip("no host C compiler")
def _host_run(tmp_path, code, flags=(), args=()):
cc = _cc()
(tmp_path / "t.c").write_text(code)
r = subprocess.run([cc, "-std=gnu17", "-Wall", "-Wno-unused-parameter", *flags,
"-o", str(tmp_path / "t"), str(tmp_path / "t.c")],
capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return subprocess.run([str(tmp_path / "t"), *args], capture_output=True, text=True,
timeout=30).stdout
def _port_section(src):
"""The port reader, lifted: its tables, port_line and port_read, then the lookups."""
helpers = "\n".join(_lift(src, s) for s in (
"static int str_eq(", "static void str_copy(", "static int hexval(", "static uint64_t number("))
reader = _between(src, "/* The port is read in PORT_CHUNK pieces",
"static struct site *site(const char *name)")
lookups = "\n".join(_lift(src, s) for s in (
"static struct site *site(const char *name)", "static unsigned fn(const char *name)",
"static long named(", "static unsigned data(const char *name)", "long pm_port_value("))
return helpers + "\n" + reader + "\n" + lookups
_SELECT_PRELUDE = r"""
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define N_BUS_IDS 208
static void port_line(const char *s);
static void port_reset(void);
static int port_select(const char *const *files, int n);
static void event_line(const char *s) { (void)s; }
static void lamp_line(const char *s) { (void)s; }
static void rule_line(const char *s) { (void)s; } /* not this test's */
/* the runtime's parse-state counters outside the port struct; the tables themselves are stubbed */
static int n_event_names, n_site_events;
static int n_lamps;
static int n_stock_rules;
static void say(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
printf("SAY ");
vprintf(fmt, ap);
va_end(ap);
putchar('\n');
}
"""
_SELECT_TAIL = r"""
/* the game's "code": words the sites point at. The port's site addresses are 32 bits (ARM), so
* the code page is mapped at a low fixed address instead of living in this binary's data. */
#include <sys/mman.h>
static unsigned *code;
static void line(const char *fmt, ...)
{
char b[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(b, sizeof b, fmt, ap);
va_end(ap);
port_line(b);
}
static void site_at(const char *name, int i)
{
line("site %s 0x%lx 0x%x 0x%x", name, (unsigned long)&code[i], code[i], code[i + 1]);
}
static void core_lines(void)
{
site_at("tick", 0);
site_at("shot_dispatch", 2);
site_at("ball_end", 4);
site_at("score_add", 6);
line("data cur_player 0x5b322c");
line("data scores 0x5b3578");
}
/* one candidate port file: game <name>, a matching core only when good. The sites carry THIS
* process's code addresses - the file is selected in this same process, as on the machine. */
static void write_port(const char *path, const char *game, int good)
{
FILE *f = fopen(path, "w");
fprintf(f, "game %s\nversion 1.0\n", game);
if (good) {
fprintf(f, "site tick 0x%lx 0x%x 0x%x\n", (unsigned long)&code[0], code[0], code[1]);
fprintf(f, "site shot_dispatch 0x%lx 0x%x 0x%x\n", (unsigned long)&code[2], code[2], code[3]);
fprintf(f, "site ball_end 0x%lx 0x%x 0x%x\n", (unsigned long)&code[4], code[4], code[5]);
fprintf(f, "site score_add 0x%lx 0x%x 0x%x\n", (unsigned long)&code[6], code[6], code[7]);
} else {
/* the file's site names the right address with the wrong words: the gate refuses it */
fprintf(f, "site tick 0x%lx 0x11111111 0x22222222\n", (unsigned long)&code[0]);
fprintf(f, "site shot_dispatch 0x%lx 0x11111111 0x22222222\n", (unsigned long)&code[2]);
fprintf(f, "site ball_end 0x%lx 0x11111111 0x22222222\n", (unsigned long)&code[4]);
fprintf(f, "site score_add 0x%lx 0x11111111 0x22222222\n", (unsigned long)&code[6]);
}
fprintf(f, "data cur_player 0x5b322c\ndata scores 0x5b3578\n");
fprintf(f, "shot 0x1 X1\nshot 0x2 Left slingshot\n");
fprintf(f, "event game_start 0x4d\nevent ball_start 0x25\n");
fclose(f);
}
int main(int argc, char **argv)
{
const char *what = argv[1];
char maps_text[128];
static const char *files[8];
int i, n = 0;
code = mmap((void *)0x08000000, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANON | MAP_FIXED, -1, 0);
if (code == (unsigned *)-1) { printf("mmap failed\n"); return 1; }
{
unsigned words[] = {
0xe92d4038, 0xe3a00037, /* 0: tick */
0xe3500b02, 0xe92d4008, /* 2: shot_dispatch */
0xe92d4008, 0xe3a00000, /* 4: ball_end */
0xe30817fe, 0xe340108b, /* 6: score_add */
};
memcpy(code, words, sizeof words);
}
snprintf(maps_text, sizeof maps_text, "%lx-%lx r-xp 00000000 00:00 0 /games/x/game",
(unsigned long)code, (unsigned long)(code + 64));
maps_line(maps_text, maps_text + strlen(maps_text));
if (!strcmp(what, "select")) {
int ok;
n = atoi(argv[2]); /* then, per candidate: <path> <game> <good> */
for (i = 0; i < n; i++) {
write_port(argv[3 + 3 * i], argv[4 + 3 * i], atoi(argv[5 + 3 * i]));
files[i] = argv[3 + 3 * i];
}
ok = port_select(files, n);
printf("SELECT %d\n", ok);
printf("GAME %s\n", port.game);
printf("SITES %d SHOTS %d EVENTS %d\n", port.n_site, port.n_shot, n_event_names);
} else { /* reset */
printf("RESET game=%s sites=%d shots=%d events=%d can=%u\n",
port.game, port.n_site, port.n_shot, n_event_names, can);
line("game leftover");
line("version 9.9");
core_lines();
line("shot 0x1 X1");
line("shot 0x2 Left slingshot");
line("shot 0x4 third");
line("event game_start 0x4d");
line("event ball_start 0x25");
line("event ball_end 0x34");
can = 0xffffffffu;
port_reset();
printf("RESET game=%s sites=%d shots=%d events=%d can=%u\n",
port.game, port.n_site, port.n_shot, n_event_names, can);
core_lines(); /* clean tables still gate a good port in */
printf("RESETGATE %d\n", port_gate());
}
return 0;
}
"""
def _select_source():
src = _src()
maps = _between(src, "#define N_MAPS", "/* ---- rule 2:")
gate = "\n".join(_lift(src, s) for s in (
"static int words_match(struct site *x)", "static void core_of_port(",
"static int site_check(", "static int port_gate(void)\n{"))
maps = maps.replace("static void maps_line(const char *s, const char *e)",
"static void maps_line(const char *s, const char *e_)")
maps = maps.replace(" unsigned long lo = 0, hi = 0;\n const char *q = s, *p;",
" const char *e = e_ ? e_ : s + strlen(s);\n"
" unsigned long lo = 0, hi = 0;\n const char *q = s, *p;", 1)
extra = "\n".join(_lift(src, s) for s in (
"static void port_reset(void)",
"static int port_select(const char *const *files, int n)"))
return (_SELECT_PRELUDE + _port_section(src) + "\n" + maps + "\n" + gate + "\n" + extra
+ _SELECT_TAIL)
def test_port_select_keeps_the_port_that_matches(tmp_path):
if sys.platform == "darwin":
pytest.skip("the port's site addresses are 32 bits and darwin arm64 maps nothing below "
"4 GiB - the Linux leg runs these")
a, b = str(tmp_path / "a.port"), str(tmp_path / "b.port")
out = _host_run(tmp_path, _select_source(),
args=("select", "2", a, "wrong_game", "0", b, "right_game", "1"))
assert "SELECT 1" in out, out
assert "GAME right_game" in out, out
assert "SAY port %s: says wrong_game 1.0 - not this game's port, skipped" % a in out, out
assert "SITES 4 SHOTS 2 EVENTS 0" in out, out # event_line is stubbed in this harness
def test_port_select_prefers_the_earlier_candidate(tmp_path):
if sys.platform == "darwin":
pytest.skip("32-bit site addresses: see the first select test")
a, b = str(tmp_path / "a.port"), str(tmp_path / "b.port")
out = _host_run(tmp_path, _select_source(),
args=("select", "2", a, "a_game", "1", b, "b_game", "1"))
assert "SELECT 1" in out and "GAME a_game" in out, out
def test_port_select_refuses_when_no_port_matches(tmp_path):
if sys.platform == "darwin":
pytest.skip("32-bit site addresses: see the first select test")
a = str(tmp_path / "a.port")
out = _host_run(tmp_path, _select_source(), args=("select", "1", a, "wrong_game", "0"))
assert "SELECT 0" in out, out
assert "not this game's port, skipped" in out, out
def test_port_select_resets_the_tables_between_candidates(tmp_path):
"""A candidate's lines must not leak into the next one's tables: after a full port is read, a
reset leaves every table empty (game, sites, shots, events, can) and still gates a good port."""
if sys.platform == "darwin":
pytest.skip("32-bit site addresses: see the first select test")
out = _host_run(tmp_path, _select_source(), args=("reset",))
got = [l for l in out.splitlines() if l.startswith("RESET ")]
assert got == ["RESET game= sites=0 shots=0 events=0 can=0",
"RESET game= sites=0 shots=0 events=0 can=0"], out
assert "RESETGATE 1" in out, out
_SCAN_MAIN = r"""
int main(void)
{
static char buf[512];
char cand[8][128];
long n, i;
char *at = buf;
const char *names[] = { "king_kong_le-0.97.port", "readme.txt", ".", "..", "godzilla_le-1.16.port" };
unsigned char types[] = { 8, 8, 4, 4, 8 };
for (i = 0; i < 5; i++) {
unsigned long long ino = (unsigned long long)i + 1;
long long off = (long long)i + 1;
unsigned short reclen = (unsigned short)(((strlen(names[i]) + 1 + 19) + 7) & ~7L);
memcpy(at, &ino, 8);
memcpy(at + 8, &off, 8);
memcpy(at + 16, &reclen, 2);
at[18] = types[i];
strcpy(at + 19, names[i]);
at += reclen;
}
n = ports_scan_buf(buf, (long)(at - buf), "/usr/local/padmode/ports", cand, 8, 0);
printf("SCAN %ld\n", n);
for (i = 0; i < n; i++) printf("CAND %s\n", cand[i]);
return 0;
}
"""
def _scan_source():
src = _src()
prelude = r"""
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
static void say(const char *fmt, ...) { (void)fmt; }
int pm_snprintf(char *out, unsigned long cap, const char *fmt, ...)
{
va_list ap;
int n;
va_start(ap, fmt);
n = vsnprintf(out, cap, fmt, ap);
va_end(ap);
return n;
}
static long ports_scan_buf(char *buf, long len, const char *dir, char (*cand)[128], long cap, long n);
"""
helpers = _lift(src, "static int str_eq(")
dirent = _between(src, "/* the kernel's getdents64 record", "static long ports_scan(")
return prelude + helpers + "\n" + dirent + _SCAN_MAIN
def test_ports_scan_buf_takes_only_port_files(tmp_path):
out = _host_run(tmp_path, _scan_source())
lines = out.splitlines()
assert "SCAN 2" in out, out
assert "CAND /usr/local/padmode/ports/king_kong_le-0.97.port" in lines, out
assert "CAND /usr/local/padmode/ports/godzilla_le-1.16.port" in lines, out
_SWITCH_PRELUDE = r"""
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
static void port_line(const char *s);
static void gl_switch_log(unsigned id, unsigned level, int hit, int playing);
static const char *GL_SWITCH_LOG;
static void say(const char *fmt, ...) { (void)fmt; }
static void gl_rfc3339(char *buf, unsigned cap) { snprintf(buf, cap, "2026-10-13T12:00:00.000Z"); }
int pm_snprintf(char *out, unsigned long cap, const char *fmt, ...)
{
va_list ap;
int n;
va_start(ap, fmt);
n = vsnprintf(out, cap, fmt, ap);
va_end(ap);
return n;
}
static void event_line(const char *s) { (void)s; }
static void lamp_line(const char *s) { (void)s; }
static void rule_line(const char *s) { (void)s; }
static void line(const char *fmt, ...)
{
char b[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(b, sizeof b, fmt, ap);
va_end(ap);
port_line(b);
}
int main(int argc, char **argv)
{
GL_SWITCH_LOG = argv[1];
line("game king_kong_le");
line("version 0.97");
line("switch 34 0x1000000000000000 LOCKDOWN BUTTON");
line("switch 36 0x2000000000000000 START BUTTON");
line("switch 62 0x4000000000000000 RIGHT FLIPPER BUTTON");
line("switch 63 0x8000000000000000 LEFT FLIPPER BUTTON");
gl_switch_log(62, 1, 1, 1);
gl_switch_log(50, 1, 1, 1); /* not a button the port names: nothing */
gl_switch_log(36, 0, 0, 0);
return 0;
}
"""
def _switch_source():
src = _src()
helpers = "\n".join(_lift(src, s) for s in (
"static int str_eq(", "static void str_copy(", "static int hexval(", "static uint64_t number("))
reader = _between(src, "/* The port is read in PORT_CHUNK pieces",
"static struct site *site(const char *name)")
lookups = _lift(src, "static struct site *site(const char *name)")
fn = _lift(src, "static void gl_switch_log(")
return _SWITCH_PRELUDE + helpers + "\n" + reader + "\n" + lookups + "\n" + fn
def test_gl_switch_log_writes_the_ports_buttons(tmp_path):
log = tmp_path / "switch.log"
out = _host_run(tmp_path, _switch_source(), args=(str(log),))
assert out == ""
text = log.read_text(encoding="utf-8").splitlines()
assert text == [
'2026-10-13T12:00:00.000Z SW 62 "RIGHT FLIPPER BUTTON" level=1 hit=1 game=1',
'2026-10-13T12:00:00.000Z SW 36 "START BUTTON" level=0 hit=0 game=0',
], text