Files
psxrecomp/runtime/src/game_options.c
T
Matthew Stanley 905b52da57 game_options: restore-time value-range validation + format version
A persisted option value outside its declared [min,max] is now REJECTED at load
(the game's own default is used instead), so a corrupt/stale options file can no
longer inject an out-of-range value into a config global the game may use as a
jump-table index (wild jump = softlock). Ranges declared per option in
game_options.toml (min/max; absent => no validation, e.g. signed screen offsets).
Adds a format_version header (newer-than-known files are rejected wholesale).
Restore stays name-keyed (offset-drift-safe). Runtime-only, no regen.
Validated: corrupt values rejected (defaults applied); valid file restores intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:47:27 -07:00

183 lines
8.0 KiB
C

/* game_options.c — see game_options.h.
*
* Persists a game's native OPTION-screen settings across launches (issue #5),
* for titles (e.g. Tomba) that keep them only in a per-boot RAM global with no
* memory-card config block. Two halves, both deterministic — no polling, no
* timing heuristics:
*
* RESTORE (once, at init): the recompiler rewrites each setting's boot-init
* store (sb/sh) to route its value through psx_game_option_store(addr,val).
* When the game runs that init, the store writes our PERSISTED value instead
* of the default (or `val` unchanged if nothing is persisted). The in-OPTION
* write is a different instruction, untouched, so the player can still change
* settings in-game.
* SAVE (on exit): game_options_save_now() reads the current globals and writes
* them to <memcard_dir>/<game_id>.options. Registered via atexit, so a clean
* exit captures whatever the player last set in the OPTION screen.
*
* Guest RAM via psx_read_byte; no debug server — works in release builds. The
* field set is declared per game in game_options.toml (read by both the
* recompiler, for the hook PCs, and here, for the addresses + saved values).
*/
#include "game_options.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
extern uint8_t psx_read_byte(uint32_t addr); /* memory.c */
#define GO_MAX_FIELDS 32
/* State-file format version. Written as `format_version=N` on save; on load a
* file declaring a HIGHER version than this is rejected wholesale (a format we
* don't understand). A missing version is treated as legacy 0 and still accepted
* (the field set is name-keyed + value-validated, so old files stay safe). Bump
* this only on an incompatible format change. */
#define GO_FORMAT_VERSION 1
static int g_count = 0;
static uint32_t g_addr[GO_MAX_FIELDS];
static uint8_t g_size[GO_MAX_FIELDS];
static char g_name[GO_MAX_FIELDS][32];
static int32_t g_vmin[GO_MAX_FIELDS]; /* inclusive valid range (restore) */
static int32_t g_vmax[GO_MAX_FIELDS];
static char g_path[1024];
static int g_have_file = 0; /* a saved state file was loaded */
static uint32_t g_loaded[GO_MAX_FIELDS]; /* values from the state file */
static uint8_t g_loaded_valid[GO_MAX_FIELDS]; /* in-range AND matched a field */
static int g_dbg_fopen = 0; /* load_state_file: fopen succeeded */
static int g_dbg_matched = 0; /* load_state_file: in-range matches */
static int g_dbg_rejected = 0; /* values dropped (out of range) */
static int g_dbg_ver_bad = 0; /* file rejected: version too new */
static uint32_t read_field(int i) {
uint32_t v = 0;
for (int b = 0; b < g_size[i]; b++)
v |= (uint32_t)psx_read_byte(g_addr[i] + (uint32_t)b) << (8 * b);
return v;
}
/* Load "name=value" lines into g_loaded[] (matched by name), validating each
* value against its declared range and honouring a format_version line. A value
* outside [g_vmin,g_vmax] is DROPPED (the field keeps its default), so a corrupt
* or stale file can never inject an out-of-range value; a file declaring a newer
* format_version than we understand is rejected wholesale. */
static void load_state_file(void) {
g_have_file = 0;
g_dbg_fopen = 0;
g_dbg_matched = 0;
g_dbg_rejected = 0;
g_dbg_ver_bad = 0;
for (int i = 0; i < g_count; i++) g_loaded_valid[i] = 0;
FILE *f = fopen(g_path, "r");
if (!f) return;
g_dbg_fopen = 1;
char line[256];
int matched = 0;
while (fgets(line, sizeof(line), f)) {
if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue;
char *eq = strchr(line, '=');
if (!eq) continue;
*eq = '\0';
long val = strtol(eq + 1, NULL, 0);
/* Reserved key: a file from a newer (incompatible) format is dropped. */
if (strcmp(line, "format_version") == 0) {
if (val > GO_FORMAT_VERSION) { g_dbg_ver_bad = 1; fclose(f); return; }
continue;
}
for (int i = 0; i < g_count; i++) {
if (strcmp(line, g_name[i]) != 0) continue;
if ((long)val < (long)g_vmin[i] || (long)val > (long)g_vmax[i]) {
g_dbg_rejected++; /* out of range -> keep the default */
} else {
g_loaded[i] = (uint32_t)val;
g_loaded_valid[i] = 1;
matched++;
}
break;
}
}
fclose(f);
g_dbg_matched = matched;
if (matched > 0) g_have_file = 1;
}
void game_options_configure(const char *state_path,
const uint32_t *addrs,
const uint8_t *sizes,
const char *const *names,
const int32_t *vmins,
const int32_t *vmaxs,
int count) {
g_count = 0;
if (!state_path || count <= 0) return;
if (count > GO_MAX_FIELDS) count = GO_MAX_FIELDS;
snprintf(g_path, sizeof(g_path), "%s", state_path);
for (int i = 0; i < count; i++) {
g_addr[i] = addrs[i];
g_size[i] = (sizes[i] == 2) ? 2 : 1;
snprintf(g_name[i], sizeof(g_name[i]), "%s", names[i] ? names[i] : "f");
g_vmin[i] = vmins ? vmins[i] : (int32_t)0x80000000; /* INT32_MIN default */
g_vmax[i] = vmaxs ? vmaxs[i] : (int32_t)0x7FFFFFFF; /* INT32_MAX default */
}
g_count = count;
load_state_file();
}
/* Recompiler hook: at each config global's boot-init store, the generated code
* calls this with the store's target address and the default value being stored.
* Return the persisted value for that address if we have one, else the default
* (so a fresh install is byte-identical). Address is matched modulo the KUSEG/
* KSEG region bits. Called from the emu thread during init only. */
int psx_game_option_store(uint32_t addr, int val) {
if (!g_have_file) return val;
uint32_t a = addr & 0x1FFFFFFFu;
for (int i = 0; i < g_count; i++)
if ((g_addr[i] & 0x1FFFFFFFu) == a)
return g_loaded_valid[i] ? (int)g_loaded[i] : val; /* dropped -> default */
return val;
}
/* Save on exit (atexit). Reads the current globals — which reflect the player's
* last OPTION-screen choices — and writes the state file. Skips writing if the
* game never booted far enough to initialise any field AND there is no prior
* file, so a quit during early boot can't stamp an all-zero file. */
void game_options_save_now(void) {
if (g_count <= 0) return;
uint32_t cur[GO_MAX_FIELDS];
int any = 0;
for (int i = 0; i < g_count; i++) { cur[i] = read_field(i); if (cur[i]) any = 1; }
if (!any && !g_have_file) return;
FILE *f = fopen(g_path, "w");
if (!f) return;
fputs("# Persistent game options (issue #5). Written by the runtime on exit.\n", f);
fprintf(f, "format_version=%d\n", GO_FORMAT_VERSION);
for (int i = 0; i < g_count; i++)
fprintf(f, "%s=%u\n", g_name[i], (unsigned)cur[i]);
fclose(f);
}
/* JSON state dump for the debug server. Path uses forward slashes to stay valid
* JSON. */
int game_options_debug_json(char *out, int cap) {
char p[1024];
snprintf(p, sizeof(p), "%s", g_path);
for (char *c = p; *c; c++) if (*c == '\\') *c = '/';
int n = snprintf(out, cap,
"{\"count\":%d,\"have_file\":%d,\"fopen\":%d,\"matched\":%d,\"rejected\":%d,"
"\"version_rejected\":%d,\"format_version\":%d,\"path\":\"%s\",\"fields\":[",
g_count, g_have_file, g_dbg_fopen, g_dbg_matched, g_dbg_rejected,
g_dbg_ver_bad, GO_FORMAT_VERSION, p);
for (int i = 0; i < g_count && n < cap; i++) {
n += snprintf(out + n, cap - n,
"%s{\"name\":\"%s\",\"addr\":\"0x%08X\",\"size\":%d,\"min\":%d,\"max\":%d,"
"\"loaded\":%u,\"valid\":%d,\"cur\":%u}",
i ? "," : "", g_name[i], g_addr[i], g_size[i], g_vmin[i], g_vmax[i],
(unsigned)g_loaded[i], g_loaded_valid[i], (unsigned)read_field(i));
}
n += snprintf(out + n, (cap - n) > 0 ? cap - n : 0, "]}");
return n;
}