From c62803f89048332315d0609044a9a7f9b16b3ae2 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:48:09 -0400 Subject: [PATCH] =?UTF-8?q?D257:=20Game.AllUnlocked=20option=20=E2=80=94?= =?UTF-8?q?=20all=20levels,=20007=20mode,=20full=20cheat=20menu=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port-layer only (no game-code edits): - main.c seeds debug_enable_all_levels_flag + debug_007_unlock_flag after configLoad() when Game.AllUnlocked is set (same class as the existing GE_UNLOCK_ALL getenv hook). - libultra.c eep shim patches the save block at read time (the exact fileValidateSaves pattern: block 4, 5x96 bytes): sets all 20 progression-gated cheat-unlock bits per slot and fills every empty stage/difficulty completion time with the max (0x3FF), preserving existing player times, then recomputes each slot's CRC with the game's own fileGenerateCRC so validation passes. The completion-time half is required: with any SP cheat active, g_AppendCheatSinglePlayer makes mission select accept only STAGESTATUS_COMPLETED levels, so an unlock-only save locks every level once a cheat is turned on. - optionsoverlay.c: 'All unlocked' toggle row (default ON for this cut). Verified: fresh-eep GE_SAVELOG shows all 5 slots stored==calc; user playtest confirms all levels at each difficulty, 007 mode, populated cheat menu, cheats working, mission select intact with all cheats on. --- docs/dev/findings.md | 1 + port/src/libultra.c | 125 ++++++++++++++++++++++++++++++++++++++ port/src/main.c | 27 ++++++++ port/src/optionsoverlay.c | 3 + port/src/video.c | 10 +++ 5 files changed, 166 insertions(+) diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 4bcf1cf2..9e4487f3 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -534,6 +534,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D254 | **Linux crash handler destroyed its own logs: `ge007.crash.log` only ever contained the SIGABRT block, never the original SEGV.** The POSIX `crashHandler` (`port/src/crash.c`) finished by calling `sysFatalError()` → `abort()` → SIGABRT, which re-entered the *same* handler (SIGABRT was one of the installed signals); the second pass reopened the log with `"wb"` and overwrote the first pass's SEGV block — faulting PC, signal, everything. The Deck firefight crash (D255) came back as a bare `SIGNAL: 6` abort record for exactly this reason, forcing offline disassembly of the CI binary to recover what the log should have said. **Fix (port-only):** re-entrancy guard (`inCrashHandler`, second pass `_exit(128+sig)` immediately) + terminate directly with `_exit()` instead of `sysFatalError()` (the Windows path already terminated directly for the same reason) + the Linux block now dumps the faulting context: `FAULT ADDR` (`si_addr`) and the full general register set (Rax/Rcx/Rdx/Rsi/Rdi/R8–R11, Rsp/Rbp) from the `ucontext`, so a field crash now names the bad pointer on its face. | FIXED — needs one Deck crash to prove the new format in the field. | | D255 | **Facility firefight SIGSEGV on Steam Deck (v0.2.0-pre CI bundle, D253 fix confirmed working — user got past the opening crouch tile).** Crash during gunplay; abort-unwind backtrace points at `sub_GAME_7F09BAC4` (`vtxstore.c:158`) ← `sub_GAME_7F09BBBC` (`:220`) ← `lvlManageMpGame` (`lv.c:1302`) ← `bossMainloop` (`boss.c:567`). Disassembling the exact CI binary at the fault PC (`0x200db135`) gives `mov 0x14(%rax),%edi; mov 0x10(%rdi),%rax`: the function walks the active PropRecord list keeping `type == 1` — which is **PROP_TYPE_OBJ, not CHR** (the decompiler cast to the union's first member and misnamed the field `ChrRecord.chrflags`; it is actually `ObjectRecord.model` at `0x14`) — then dereferences `model->obj`. Two port-relevant wrinkles: (a) the load is **32-bit zero-extended** because the decomp types the field through the `CHRFLAG` enum, so a model pointer ≥ 4 GB would truncate to garbage; (b) the loop skips records whose `model->obj` is NULL but *dereferences* `model` unconditionally, so an active object with a NULL/freed `model` faults — plausible in a firefight where destructibles (crates etc.) die. Not reproduced on WSL after equivalent play. **Next:** the D254 handler now captures registers at fault time (`rax` = the ObjectRecord, `rdi` = its model value) — one more Deck crash identifies which case: tiny/zero `rdi` → destroy-path ordering; plausible-but-wrong `rdi` → stale/reused record or truncation. | OPEN — awaiting register capture from the Deck. | | D256 | **Saves (and config) silently failed to persist on Linux whenever the game was not launched from the directory containing `data/` — reported as "saves do not work at all" on Steam Deck.** `romdataInit` finds the ROM in `$S/` (CWD `data/`) *or* `$E/` (exe dir) *or* `./`, but `sysResolvePath`'s POSIX `$S` branch (used by `ge007.eep` and `ge007.ini`) only ever produced a CWD-relative path — its "fallback" was `./data/`, identical to the primary. Launch from any other CWD (Steam non-steam-game shortcut, terminal in `~`): ROM loads via the exe-dir candidate, game runs, but every EEPROM/config write hits `fopen(..., "wb")` on a nonexistent `./data/` and fails with a stderr-only "cannot write" — saves never persist, and **every launch also starts with default settings** (F10 changes don't stick), which presents as "draw distance/LOD is low" on levels like Dam where the user had raised `Video.DrawDistance` on another machine. The Windows branch already did CWD-else-exedir; the POSIX branch now mirrors it (`readlink("/proc/self/exe")`) and creates the directory best-effort so first-run writes don't fail. **Verified on WSL:** with exe+`data/` in `/tmp/fb2` and CWD=`/`, config/eeprom/ROM all resolve into `/tmp/fb2/data/` and a 25 s run completes clean; the pre-fix binary in the same layout fails to find the ROM at all. **Caveat:** if the user's Deck launch *was* from the bundle dir (CWD had `data/`), this fix does not explain their save failure — need the save-flow detail (does the memory-card screen appear? slot confirm? "saved" message?) before closing the report. Side note found while testing: a `data/` without the generated sidecars (`pccg-*/`, `pcmodels-*/` from `tools_pc/d69_emit.py`/`d43_emit.py`) starts fine but faults in `load_bg_file` (`bg.c:865`) on level load — the reserve functions warn "stage loads will fault" and return 0; expected degraded mode, not a new bug. | FIXED (path half) — save-flow detail still owed from the user. | +| D257 | **New `Game.AllUnlocked` option (F10, default ON for this cut): skip N64 progression entirely — all 21 solo levels selectable at every difficulty, 007 mode available, and the cheat menu fully populated. Port-layer only; no game-code edits.** Three mechanisms, all in `port/`: (1) `main.c` seeds the two live `LEFTOVERDEBUG` RAM flags after `configLoad()` — `debug_enable_all_levels_flag` (all levels) and `debug_007_unlock_flag` (007 mode; both in `src/game/debugmenu_handler.c`, same class as the existing `GE_UNLOCK_ALL` getenv hook in the getter); (2) the eep shim (`libultra.c geEepromPatchAllCheats`) patches the save block at read time — special-casing `fileValidateSaves`'s exact pattern (block 4, 5×96 bytes) — setting all 20 progression-gated cheat-unlock bits per slot (`unlocked_cheats_1/2/3`; the cheat menu row on mode-select only renders when ≥1 is set, `front.c interface_menu06_modesel`) **and** filling every *empty* stage/difficulty completion-time field with the max time 0x3FF (existing player times preserved); both are then CRC'd per slot with the game's own `fileGenerateCRC` or `fileValidateSaves` would wipe the slots. (3) `optionsoverlay.c` row so users can toggle OFF for faithful progression. **Why the completion-time half is required (found by user repro):** when ANY single-player cheat is active, `g_AppendCheatSinglePlayer` makes mission-select (`get_highest_unlocked_difficulty_for_level`, front.c) accept only levels with status `STAGESTATUS_COMPLETED (3)`, not merely `UNLOCKED (1)` — AllUnlocked's debug-flag path returns 1, so enabling a cheat locked every level ("could only do Silo"). Reading the save as a fully-completed campaign satisfies both branches. Time bit-math mirrors `fileGet/SetSaveStageDifficultyTime` verbatim (10-bit fields, difficulties 0–2; 007 times are virtual). **Pitfall hit during implementation:** the first mirror of `save_data` used `times[80]` instead of `(SP_LEVEL_MAX-1)*4 = 76`, making `sizeof` 100≠96 so the block-read size check silently never matched — now enforced by a compile-time `ge_save_slot_size_check`. **Semantics:** unlocked cheats/completions persist to `ge007.eep` on any subsequent save write (self-consistent; mirrors N64 where completing a level permanently unlocks its cheat). No *active* cheats are enabled (invincibility/all-guns stay OFF); weapons remain per-mission pickups. **Verified:** fresh eep — all 5 slots validate `stored==calc` under `GE_SAVELOG=1`; user playtest: all levels selectable at each difficulty, 007 mode present, cheat menu populated, cheats toggle and work (time-modifying cheats untested by user), mission select intact with all cheats ON. | FIXED — verified by user playtest; PR open. | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software diff --git a/port/src/libultra.c b/port/src/libultra.c index 9b18ee99..e87714da 100644 --- a/port/src/libultra.c +++ b/port/src/libultra.c @@ -1188,6 +1188,126 @@ static void geEepromStore(void) } } +/* D257: Game.AllUnlocked -- mirror of src/game/file.h save_data (96 bytes; + * keep in sync). Two things are patched in at read time: + * - the progression-gated cheat-unlock bits (front.c + * frontCheckIfCheatIsUnlocked) test per-LEVEL bits in + * unlocked_cheats_1/2/3 that normal play sets when each level is + * completed, so a fresh save has an empty cheat menu; + * - every stage/difficulty completion time. When ANY single-player cheat + * is active, g_AppendCheatSinglePlayer makes the mission-select screen + * (front.c get_highest_unlocked_difficulty_for_level) only accept levels + * whose status is STAGESTATUS_COMPLETED (3), not merely UNLOCKED (1) -- + * so AllUnlocked must read as a fully-completed campaign or enabling a + * cheat locks every level. Existing (nonzero) player times are kept; + * only empty slots get the max time (0x3FF). */ +typedef struct ge_save_slot { + s32 chksum1; + s32 chksum2; + u8 completion_bitflags; + u8 flag_007; + u8 music_vol; + u8 sfx_vol; + u16 options; + u8 unlocked_cheats_1; + u8 unlocked_cheats_2; + u8 unlocked_cheats_3; + u8 padding; + u8 times[76]; /* (SP_LEVEL_MAX-1)*4: 19 levels x 4 difficulties */ +} ge_save_slot; /* sizeof == 96 == save_data (file.h) -- enforced below */ + +/* If this ever fires, the mirror drifted from save_data and the AllUnlocked + * cheat patch silently no-ops (the block-read size check stops matching). */ +typedef char ge_save_slot_size_check[(sizeof(ge_save_slot) == 96) ? 1 : -1]; + +/* Game-side CRC (src/game/crc.c) over [completion_bitflags, next slot), + * stored in the slot's own chksum1/2 -- exactly what fileValidateSaves + * re-checks after the block read. */ +extern void fileGenerateCRC(u8 *addressA, u8 *addressB, void *retval); + +extern s32 portAllUnlocked; /* port/src/video.c */ + +/* Bit math mirrors fileGetSaveStageDifficultyTime / fileSetDifficultyStageTime + * (src/game/file2.c): 10-bit time fields, offset = (difficulty*20+level)*10, + * difficulties 0..2 (agent/secret/00) only -- 007 times are virtual. */ +static u32 geSaveGetTime(const u8 *t, s32 difficulty, s32 level) +{ + s32 offset = (difficulty * 20 + level) * 10; + s32 index = offset >> 3; + switch (7 - (offset & 7)) { + case 7: return ((u32)(t[index] & 0xFF) << 2) | ((u32)(t[index + 1] & 0xc0) >> 6); + case 5: return ((u32)(t[index] & 0x3f) << 4) | ((u32)(t[index + 1] & 0xf0) >> 4); + case 3: return ((u32)(t[index] & 0x0f) << 6) | ((u32)(t[index + 1] & 0xfc) >> 2); + case 1: return ((u32)(t[index] & 0x03) << 8) | (u32)(t[index + 1] & 0xFF); + default: return 0; + } +} + +static void geSaveSetTime(u8 *t, s32 difficulty, s32 level, u32 newtime) +{ + s32 offset = (difficulty * 20 + level) * 10; + s32 index = offset >> 3; + switch (7 - (offset & 7)) { + case 7: + t[index] = (u8)((t[index] & 0x00) | ((newtime >> 2) & 0xFF)); + t[index + 1] = (u8)((t[index + 1] & 0x3F) | ((newtime << 6) & 0xC0)); + break; + case 5: + t[index] = (u8)((t[index] & 0xC0) | ((newtime >> 4) & 0x3F)); + t[index + 1] = (u8)((t[index + 1] & 0x0F) | ((newtime << 4) & 0xF0)); + break; + case 3: + t[index] = (u8)((t[index] & 0xF0) | ((newtime >> 6) & 0x0F)); + t[index + 1] = (u8)((t[index + 1] & 0x03) | ((newtime << 2) & 0xFC)); + break; + case 1: + t[index] = (u8)((t[index] & 0xFC) | ((newtime >> 8) & 0x03)); + t[index + 1] = (u8)(newtime & 0xFF); + break; + } +} + +static void geEepromPatchAllCheats(u8 *buf) +{ + /* buf holds the five save slots read from block 4. The sixth local slot + * is only a CRC end-boundary for the last one (fileGenerateCRC reads + * [A, B), so its contents are irrelevant). */ + static ge_save_slot slots[6]; + memcpy(slots, buf, sizeof(ge_save_slot) * 5); + memset(&slots[5], 0, sizeof(ge_save_slot)); + + int changed = 0; + for (int i = 0; i < 5; i++) { + /* Cheat ids are level ids 0..19 (CHEAT_INPUT_BUFFER_SIZE == 20): + * bits 0-7 in _1, 8-15 in _2, 16-19 in the low nibble of _3. */ + if (slots[i].unlocked_cheats_1 != 0xFF || + slots[i].unlocked_cheats_2 != 0xFF || + (slots[i].unlocked_cheats_3 & 0x0F) != 0x0F) { + slots[i].unlocked_cheats_1 = 0xFF; + slots[i].unlocked_cheats_2 = 0xFF; + slots[i].unlocked_cheats_3 |= 0x0F; + changed = 1; + } + /* Fully-completed campaign: fill empty completion times with the + * max (0x3FF). Needed so mission select still works while any cheat + * is active (see header comment); player records are preserved. */ + for (s32 diff = 0; diff < 3; diff++) { + for (s32 lvl = 0; lvl < 20; lvl++) { + if (geSaveGetTime(slots[i].times, diff, lvl) == 0) { + geSaveSetTime(slots[i].times, diff, lvl, 0x3FF); + changed = 1; + } + } + } + } + if (!changed) return; + + for (int i = 0; i < 5; i++) + fileGenerateCRC(&slots[i].completion_bitflags, (u8 *)&slots[i + 1], + &slots[i]); + memcpy(buf, slots, sizeof(ge_save_slot) * 5); +} + static s32 geEepromRW(u8 block, u8 *buf, int nbytes, int write) { u32 off = (u32)block * 8; @@ -1198,6 +1318,11 @@ static s32 geEepromRW(u8 block, u8 *buf, int nbytes, int write) geEepromStore(); } else { memcpy(buf, s_eeprom + off, nbytes); + /* fileValidateSaves' block read: five save slots from block 4. */ + if (portAllUnlocked && block == 4 && + nbytes == (int)(sizeof(ge_save_slot) * 5)) { + geEepromPatchAllCheats(buf); + } } return 0; } diff --git a/port/src/main.c b/port/src/main.c index 91dc71dc..daa3ae76 100644 --- a/port/src/main.c +++ b/port/src/main.c @@ -126,6 +126,33 @@ int main(int argc, char **argv) configLoad(); atexit(portAtExit); /* persist config + window geometry on clean exit */ + /* 1a. D257: Game.AllUnlocked (default ON) -- seed the game's own RAM + * unlock flags so mission select offers every solo level at every + * difficulty plus 007 mode, with no save data required. Both are + * plain s32 globals in src/game/debugmenu_handler.c (compiled because + * the PC build defines LEFTOVERDEBUG); file2.c's + * fileIsStageUnlockedAtDifficulty() and front.c's 007-mode gate OR + * them in ahead of the EEPROM completion bits. Port-layer memory + * writes only -- no game-logic edits (AGENTS rule 2), same class as + * the existing GE_UNLOCK_ALL getenv hook in the getter. No active + * cheats (invincibility / all guns) are enabled; weapons remain + * per-mission pickups as on the N64. Separately, the eep shim + * (libultra.c geEepromPatchAllCheats) sets every progression-gated + * cheat-unlock bit in the save block at read time (per-slot CRC + * recomputed via the game's own fileGenerateCRC), so the cheat + * menu is fully populated without completed levels. */ + { + extern s32 portAllUnlocked; /* port/src/video.c */ + extern s32 debug_enable_all_levels_flag; /* src/game/debugmenu_handler.c */ + extern s32 debug_007_unlock_flag; /* ditto */ + if (portAllUnlocked) { + debug_enable_all_levels_flag = 1; + debug_007_unlock_flag = 1; + sysLogPrintf(LOG_INFO, "all-unlocked: RAM unlock flags seeded " + "(Game.AllUnlocked=1)"); + } + } + /* 2. Load the ROM and map segments. */ if (romdataInit() != 0) { sysLogPrintf(LOG_ERROR, "Failed to load ROM (expected a .z64 in the " diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 945d74ed..96dd61fb 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -130,6 +130,9 @@ static struct Row rows[] = { /* D216: read once at title-stage load (lv.c) -- toggling it takes effect * the next time the title screen is reached. */ { "Game.SkipIntro", "Skip intro", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, + /* D257: everything-unlocked goodie (default ON). Consumed at startup by + * main.c -- applies from the next launch. */ + { "Game.AllUnlocked", "All unlocked", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, }; #define NUM_ROWS ((int)(sizeof(rows) / sizeof(rows[0]))) diff --git a/port/src/video.c b/port/src/video.c index b6a1d35b..fb9fdb58 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -95,6 +95,15 @@ s32 portSkipIntro = 0; * original damage flash. */ s32 portNoHitFlash = 0; +/* D257: Game.AllUnlocked — ship-with-everything-unlocked goodie, ON by + * default for this cut. Consumed once at startup by main.c, which sets the + * game's own RAM unlock flags (debug_enable_all_levels_flag / + * debug_007_unlock_flag in src/game/debugmenu_handler.c, live because the PC + * build defines LEFTOVERDEBUG) — port-layer memory writes only, no game-code + * edits. Takes effect at launch; toggling it mid-session applies next run. + * 0 = faithful N64 progression (levels unlock as you complete them). */ +s32 portAllUnlocked = 1; + /* D211: Video.FovScale as a multiplier on the render FOV. Applied game-side * at the guPerspectiveF chokepoint (src/fr.c) so it lands BEFORE the CPU * pre-multiplies projection x view into the combined world matrix — the @@ -200,6 +209,7 @@ PD_CONSTRUCTOR static void videoConfigInit(void) configRegisterFloat("Game.ScreenShakeIntensity", &portScreenShakeScale, 0.0f, 10.0f); configRegisterInt("Game.SkipIntro", &portSkipIntro, 0, 1); configRegisterInt("Game.NoHitFlash", &portNoHitFlash, 0, 1); + configRegisterInt("Game.AllUnlocked", &portAllUnlocked, 0, 1); configRegisterInt("Video.VSync", &cfgVSync, 0, 1); configRegisterInt("Video.FpsCap", &cfgFpsCap, 0, 1000); configRegisterInt("Video.MSAA", &cfgMSAA, 1, 8);