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
+131 -46
View File
@@ -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 <id> <mask> <name ...> a switch whose hit is a shot: the switches section below
* callout <role> <id> scene <role> <40-hex id>
* 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" };
/* 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 <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
* 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) {