diff --git a/.gitignore b/.gitignore index 35f4398..68cb0be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # build artifacts *.so *.o +__pycache__/ diff --git a/README.md b/README.md index 93126bb..9f7b78e 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,37 @@ framework) and added to `ports/king_kong_le-0.97.port`. | 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_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` | | `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` | +| `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) | ## 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 -"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`. +"counts as a hit" verdict, `game` whether a player is up. The logged ids and +names are the port's `switch` lines (on King Kong: 34 lockdown, 36 start, +62/63 the flipper buttons). ## Build @@ -123,6 +148,15 @@ 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` +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) 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 | |---|---| | `/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`) | Deploy a new build: @@ -200,8 +234,8 @@ Built on the PAD Modes SDK 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`) +- `tests/test_multi_game.py` (new) - `README.md` `ports/king_kong_le-0.97.port`, `event_probe.c`, `event_read.py`, diff --git a/pad_mode_runtime.c b/pad_mode_runtime.c index 7c2dd64..ec36149 100644 --- a/pad_mode_runtime.c +++ b/pad_mode_runtime.c @@ -17,6 +17,7 @@ extern long read(int, void *, __SIZE_TYPE__); extern long write(int, const void *, __SIZE_TYPE__); extern int close(int); extern int unlink(const char *); +extern void *memset(void *, int, __SIZE_TYPE__); extern int vsnprintf(char *, __SIZE_TYPE__, const char *, va_list); extern int clock_gettime(int, void *); extern int mprotect(void *, __SIZE_TYPE__, int); @@ -148,7 +149,9 @@ void pm_commas(char *out, unsigned cap, uint64_t v) * switch a switch whose hit is a shot: the switches section below * callout scene <40-hex id> * text 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" }; /* 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; }; 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 named data[N_DATA]; int n_data; 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); } -/* 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). */ -static int port_load(void) +static int port_read(const char *path) { static char buf[PORT_CHUNK]; char line[256]; long n, i, j = 0, total = 0; - unsigned k; - 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; - } - } + int fd = open(path, O_RDONLY); 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) { if (n > PORT_MAX - total) n = PORT_MAX - total; 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 * 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 - * verbatim) */ -static int maps_has(unsigned long addr, unsigned long len, unsigned how) + * verbatim) */static int maps_has(unsigned long addr, unsigned long len, unsigned how) { unsigned long at = addr, end = addr + len; int i, steps; @@ -450,9 +445,15 @@ static int maps_has(unsigned long addr, unsigned long len, unsigned how) 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 -------- @@ -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. */ extern long syscall(long, ...); #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) { @@ -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) */ +/* 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 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; 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 */ - /* GAMELOG: the buttons a human presses, to /dump/switch.log (34 lockdown, 36 start, 62/63 the - * 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); } - } - } + gl_switch_log(id, level, hit, playing); /* GAMELOG: the port's buttons, to /dump/switch.log */ 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, flags, pol, mask, playing, hit && playing ? " - a hit" : ""); @@ -8834,6 +8838,82 @@ static int site_check(struct site *x) 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 /; 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 * 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) */ @@ -9325,23 +9405,28 @@ static void sndm_arm(void) __attribute__((constructor)) 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; - struct site *tick; + long n, i; 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 */ - if (!is_game_process(tick->addr)) return; + if (!game_mapped()) return; /* the launcher's children: not the game */ clock_gettime(CLOCK_MONOTONIC, &t0); 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); if (port.too_long) say("port: longer than %d bytes - the lines after that are not read", PORT_MAX); 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)", 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); - if (!port_gate()) return; clipv_arm(); /* PAD-446: before the score gate - a clip variant scores nothing */ sndm_arm(); /* PAD-494: nor does a music mode */ if (clipv_only || sndm_only) { diff --git a/switch_names.inc b/switch_names.inc deleted file mode 100644 index 15c9040..0000000 --- a/switch_names.inc +++ /dev/null @@ -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"}, \ No newline at end of file diff --git a/tests/test_multi_game.py b/tests/test_multi_game.py new file mode 100644 index 0000000..9e45a8b --- /dev/null +++ b/tests/test_multi_game.py @@ -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 +#include +#include +#include +#include +#include +#include +#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 +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 , 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: */ + 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 +#include +#include +#include +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 +#include +#include +#include +#include +#include +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