From 7e2e371a0a31cf077c91375e3bec8d99f08a16f9 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:51:23 -0400 Subject: [PATCH 01/10] =?UTF-8?q?docs(D177):=20mark=20ladders=20closed=20?= =?UTF-8?q?=E2=80=94=20PR=20#5=20merged,=20climb=20test=20confirmed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/findings.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 7850df94..aee40cb2 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -4677,8 +4677,9 @@ Touched: `src/game/stan.c` (macro + `stanCheckLinkedSpecialTile` + All `#ifdef PORT`, N64 path kept verbatim under `#else`. Candidate root-cause came from an M-36 subagent; parts 1/3 were its work, part 2 (the local size) found on review. **Verified:** build links clean; `-level_09` framediff 3/3; -`-level_36` (Surface, has mandatory ladders) boots crash-free. **Interactive -climb test owed** — headless can't drive the climb input. +`-level_36` (Surface, has mandatory ladders) boots crash-free. +**Interactive climb test — DONE (M-37): user confirmed climbing works +in-game.** Merged to `main` via PR #5 (`955349db`). porting-notes.md §C (pointer-width in ROM/record structs). From 2f5d7f9063b8422d37f537559b9b0b162c59e52d Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:03:37 -0400 Subject: [PATCH 02/10] =?UTF-8?q?port:=20D184=20=E2=80=94=20F10=20port-lay?= =?UTF-8?q?er=20options=20overlay=20(approach=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New port/src/optionsoverlay.c + port/include/optionsoverlay.h: an immediate-mode options overlay drawn as its own fast3d 2D display list, appended after the game DL. Implements docs/dev/OPTIONS-MENU-PLAN.md §2/§3. - config.c/h: configForEachOption() + configSetOptionMeta() label/step/enum side table; CONFIG_OPT_* enum. config.c stays free of specific-key knowledge. - gfx_pc.cpp gfx_run(): one-line hook runs optionsOverlayEmit() after the game DL, before gfx_flush. Returns NULL when closed -> zero bytes appended, golden dumps byte-identical. - video.c: F10 toggles (videoPumpEvents, next to F12); ESC closes; wheel scrolls; videoRequestLiveConfig()/liveCfgDirty re-applies VSync/FpsCap/ TextureFilter at frame start. - input.c: while open, controller 0 returns a neutral pad and nav (arrows/enter/wheel/mouse) routes to optionsOverlayHandleInput() — mirrors the D180 WI-1 swallow pattern. - v1 rows: VSync, FpsCap, MSAA(restart), TextureFilter, MouseAimSpeed, MouseTurnSpeed, MouseInvertY, MouseCaptureMode, ScreenShakeIntensity. Live-apply where possible; configSave() on close. - GE_OPTIONSOVERLAY=1 auto-opens at boot (env-gated diagnostic, left in). Port-layer only, no src/ edits, no #ifdef PORT in game code. Build links 241/241. Runtime framediff/soak/eyeball owed on an interactive machine (this headless env kills the GUI process early and writes 0-byte PPMs). --- docs/dev/findings.md | 1 + docs/porting-notes.md | 16 ++ port/fast3d/gfx_pc.cpp | 12 + port/include/config.h | 33 +++ port/include/optionsoverlay.h | 50 ++++ port/include/video.h | 4 + port/src/config.c | 75 ++++++ port/src/input.c | 12 + port/src/optionsoverlay.c | 427 ++++++++++++++++++++++++++++++++++ port/src/video.c | 73 ++++-- 10 files changed, 687 insertions(+), 16 deletions(-) create mode 100644 port/include/optionsoverlay.h create mode 100644 port/src/optionsoverlay.c diff --git a/docs/dev/findings.md b/docs/dev/findings.md index aee40cb2..572d3c83 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -400,6 +400,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D179 | **Packaged build crashes after the logos — the D43/D69 model + bg sidecars are ROM-derived and absent from any build that has no ROM (M-34, alpha-release QA).** The first `goldeneye-pc-port-*-win64.zip` from CI (bundle-win.sh) shows the Rare/Nintendo logos (compiled-in `assets/rarewarelogo.c`) then AVs at `0x6b157a88`/`0x70157a88` — symbolised: `modelPromoteNodeOffsetsToPointers` (`model.c`) ← `load_object_fill_header` (`objecthandler_2.c`) ← first prop/item model load. Root cause is **not** a code regression: the port loads PC-layout model geometry from `data/pcmodels-/{pcmodels.bin,manifest.csv}` and stage bg/stan from `data/pccg-/{pccg.bin,manifest.csv}`, both produced offline by `tools_pc/d43_emit.py` / `d69_emit.py` from the ROM (see `port/src/pcmodels.c` / `pccg.c`; `pcmodelsReserveSize` logs *"pcmodels.bin not found — model loads will fail"* and returns 0). CI has no ROM so it never runs the emit scripts, and the two `data/` dirs are (correctly) gitignored ROM-derived game data — **cannot be committed or shipped** (converted Nintendo/Rare geometry, DLs, collision, stan nav; = distributing assets). A local build with those dirs present runs clean (verified M-34: 1741 frames, `romdataInit ... mapped at 0x10000000`, `pcmodels: 512 sidecars`, `pccg: 73 sidecars`, no crash — so `0x10000000` is *not* the problem here). The `docs/building.md` sidecar-gen step was also missing entirely. **Fix = release-side, backlogged** (`docs/BACKLOG.md` → "Alpha release"): bundle the emit scripts + their committed inputs as a pure-stdlib-Python asset-prep tool the user runs once against their own ROM (~5 MB output, no MIPS toolchain). Secondary/latent: the fixed-address `VirtualAlloc((LPVOID)0x10000000)` in `romdata.c` has no working fallback (`"using heap copy — direct ROM reads will fail"` then limps into the same crash) — didn't bite M-34 but will on a machine where something occupies `0x10000000`; harden separately (reserve earliest in `main`, or retry low bases and derive all segment math from the base obtained). | Diagnosed, not fixed — release packaging gap + latent `romdata.c` fallback | | D180 | **Native-PC input pass (M-XX QoL run, `qol/native-pc-input-menu`, port-only).** Three parts, all config-gated with default = prior behaviour: **(WI-1) `Input.MouseCaptureMode`** (0 = legacy always-grab; 1 = Quake-style click-to-lock) in `port/src/input.c` + `port/src/video.c` — in mode 1 the OS cursor is free until a click lands in the game window (`SDL_MOUSEBUTTONDOWN` → `inputNotifyClick`), and ESC / focus-loss / entering any front-end menu (`current_menu != MENU_RUN_STAGE`) frees it again; re-entering a stage while still "armed" re-locks so unpausing needs no click. `reconcileGrab()` runs once per controller-0 poll. Mouse buttons are suppressed from the game while the cursor is free in-stage (no phantom fire). Controller paths untouched. **(WI-2) absolute-cursor menu tracking** — when capture mode is on and the cursor is free in a menu, the D165/D169 pointer P-controller takes its target from the real cursor's absolute window position mapped onto the live virtual front-end rect (`getPlayer_c_screen*`), giving true 1:1 tracking instead of the relative-delta estimator. Relative path unchanged for legacy/grabbed. **(B3) `Input.MouseAimSpeed` default 25 → 16** (aim mode still overshot at 25 per the backlog). | LANDED, port-only, no `#ifdef PORT`. Build 241/241, `-level_09` framediff 3/3 within threshold (unregressed), `GE_STARTMENU` menu boot crash-free. **Feel-checks owed** (headless can't drive the mouse): click-to-lock ergonomics, absolute menu tracking, the new aim-speed default. | | D181 | **`Game.ScreenShakeIntensity` — first route-(b) `src/` gameplay-cosmetic hook (M-XX QoL run).** `src/fr.c viShake()` gains a single `#ifdef PORT` line — `intensity *= portScreenShakeScale;` (extern `f32`, defined + `configRegisterFloat`'d 0.0–10.0 in `port/src/video.c`) — before the existing clamp. Default `1.0f` ⇒ exact no-op, every headless golden dump byte-unaffected; `0.0` disables explosion/effect screen shake, up to `10.0` exaggerates it. N64 build (no `-DPORT`) keeps the original line verbatim. Precedent per `docs/BACKLOG.md` "Fun features → screen-shake intensity slider" and AGENTS.md #2's documented-exception path. Same policy class as a future FOV hook. | LANDED behind config, default = original. Build green. | +| D184 | **F10 port-layer options overlay — the approach-(C) surface from `OPTIONS-MENU-PLAN.md` (M-37).** New `port/src/optionsoverlay.c` + `port/include/optionsoverlay.h`: an immediate-mode overlay drawn as its own fast3d 2D DL, appended after the game DL in `gfx_pc.cpp gfx_run()` (`optionsOverlayEmit()` → NULL when closed ⇒ **zero bytes appended, golden dumps byte-identical**). F10 toggles (`video.c videoPumpEvents`, next to F12); while open, `input.c inputComputePad(0)` returns a neutral pad and routes arrows/enter/wheel/mouse-buttons to `optionsOverlayHandleInput()` (mirrors the WI-1/D180 swallow). ESC closes; `configSave()` on close. `config.c` gains `configForEachOption()` + a `configSetOptionMeta()` label/step/enum side table (no per-key knowledge in config.c). Live knobs (VSync/FpsCap/TextureFilter via `videoRequestLiveConfig()`; mouse + screen-shake via the registered pointer) apply immediately; MSAA tagged "(restart)". v1 rows per plan §3: VSync, FpsCap, MSAA, TextureFilter, MouseAimSpeed, MouseTurnSpeed, MouseInvertY, MouseCaptureMode, ScreenShakeIntensity. | **LANDED, port-only, no `#ifdef PORT` in `src/`.** Build links 241/241. **Runtime verification NOT run this session** — the headless env kills the GUI process after ~8 s and `gfx_opengl_dump_bound_fbo` writes 0-byte PPMs here, so framediff / 60 s soak / overlay-frame eyeball are all owed on an interactive machine. Overlay-closed no-op is code-evident (emit returns NULL before touching the DL buffer). | | D85 | room primary/secondary DL → `texLoadFromGdl` garbage | OPEN (safety-netted; widen/pool fixes landed, geometry now renders) | | D86 · D87 | modelInitRwData truncated ptr · attract-demo BE `ramromfilestructure` | resolved | | D88.1–D88.3 · D88.5–D88.6 | `Usetup*Z` header + sub-table width/endian conversion (`d88_emit.py`) | resolved | diff --git a/docs/porting-notes.md b/docs/porting-notes.md index af4abe7c..47e39a15 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -387,6 +387,22 @@ through a converter or a runtime bswap fixup reads scrambled. ## D. N64 hardware idioms fast3d does not emulate +- **Appending a port-owned 2D overlay to the game frame: hook inside + `gfx_run()`, not `videoSubmitCommands`.** `gfx_run()` (`gfx_pc.cpp`) is + monolithic — it does `start_frame` → `gfx_run_dl` → `gfx_flush` → + `end_frame` → `swap_buffers_begin` in one call, and the game DL is executed + from `osSpTaskStartGo` (`libultra.c`), *not* through `videoSubmitCommands` + (which is dead). So a second `gfx_run()` per frame is not possible (it swaps + buffers). The clean seam is a one-line C hook between `gfx_run_dl(commands)` + and `gfx_flush()` that runs a second `gfx_run_dl()` on a port-built DL when + non-NULL (D184, F10 options overlay). The overlay DL is self-sufficient: + `gDPPipeSync` + `gDPSetCycleType(G_CYC_1CYCLE)` + `gDPSetTexturePersp(G_TP_NONE)` + + a full-screen `gDPSetScissor` + `microcode_constructor()` (the game's own + 2D combiner/rendermode prologue), then `gDPFillRectangle` / `textRender` + (game symbols, extern'd like `input.c` externs `current_menu` — UI, not + logic). 2D pixel space is `viGetX()` x `viGetY()`. Return NULL when the + overlay is closed so the frame is byte-for-byte unchanged. + - Z buffer cleared by pointing the colour image at it + fill-rect → does nothing in fast3d; must emit `G_CLEAR_DEPTH_EXT` (D105). - LOD / detail mip tiles: fast3d fabricates a crop when detail textures diff --git a/port/fast3d/gfx_pc.cpp b/port/fast3d/gfx_pc.cpp index e0b7daeb..b7e064bb 100644 --- a/port/fast3d/gfx_pc.cpp +++ b/port/fast3d/gfx_pc.cpp @@ -3068,6 +3068,12 @@ extern "C" void gfx_start_frame(void) { uint32_t num_dls = 0; +/* F10 port-layer options overlay (port/src/optionsoverlay.c). Returns a + * self-contained 2D display list to draw on top of the game's frame, or NULL + * when the overlay is closed -- in which case nothing is appended and the + * frame is byte-identical to before (golden dumps unaffected). */ +extern "C" Gfx* optionsOverlayEmit(void); + extern "C" void gfx_run(Gfx* commands) { ++num_dls; gfx_sp_reset(); @@ -3091,6 +3097,12 @@ extern "C" void gfx_run(Gfx* commands) { rendering_state.viewport = {}; rendering_state.scissor = {}; gfx_run_dl(commands); + { + Gfx* overlay = optionsOverlayEmit(); + if (overlay != nullptr) { + gfx_run_dl(overlay); + } + } gfx_flush(); gfxFramebuffer = 0; diff --git a/port/include/config.h b/port/include/config.h index 22aa1ac4..d2dfc0bb 100644 --- a/port/include/config.h +++ b/port/include/config.h @@ -33,6 +33,39 @@ void configRegisterString(const char *key, char *value, int bufSize); const char *configGetFrameDump(void); /* "lo-hi[:step]" or NULL if unset */ int configGetInputLog(void); +/* ------------------------------------------------------------------------ + * Option enumeration (for the F10 port-layer options overlay). + * + * configForEachOption() walks every registered option; configSetOptionMeta() + * attaches display metadata (label / adjust step / enum-value names) keyed by + * the dotted key. Meta is an optional side table -- config.c stays ignorant of + * which specific keys exist; the overlay populates it at init time. + * ---------------------------------------------------------------------- */ +enum { + CONFIG_OPT_INT = 0, + CONFIG_OPT_UINT, + CONFIG_OPT_FLOAT, + CONFIG_OPT_STR, +}; + +/* key : dotted option key + * type : CONFIG_OPT_* + * ptr : &int / &unsigned / &float / char* (the live variable) + * min,max : registered clamp bounds (min==max -> unclamped) + * step : adjust step from the meta side table (0 if none) + * label : display label from the meta side table (NULL if none) + * enumNames: NULL-terminated value-name list from the meta side table (or NULL) + * ctx : opaque pointer passed straight through + */ +typedef void (*ConfigOptionCb)(const char *key, int type, void *ptr, + double min, double max, double step, + const char *label, const char *const *enumNames, + void *ctx); + +void configForEachOption(ConfigOptionCb cb, void *ctx); +void configSetOptionMeta(const char *key, const char *label, double step, + const char *const *enumNames); + #ifdef __cplusplus } #endif diff --git a/port/include/optionsoverlay.h b/port/include/optionsoverlay.h new file mode 100644 index 00000000..df4add48 --- /dev/null +++ b/port/include/optionsoverlay.h @@ -0,0 +1,50 @@ +#ifndef PORT_OPTIONSOVERLAY_H +#define PORT_OPTIONSOVERLAY_H + +/* + * F10 in-game options overlay (approach C from docs/dev/OPTIONS-MENU-PLAN.md). + * + * A self-contained port-layer immediate-mode overlay: it draws its own 2D + * display list on top of the game's frame (appended in fast3d's gfx_run, + * after the game DL) and handles its own keyboard/mouse nav. It edits the + * port-owned config.c options directly, so changes apply live for the live + * ones. When closed it renders NOTHING (zero bytes appended) -- golden dumps + * stay byte-identical. + * + * Hooks: + * video.c videoPumpEvents : F10 -> optionsOverlayToggle(); ESC closes; + * wheel -> optionsOverlayScroll() + * input.c inputComputePad : controller 0 swallowed while open; + * nav routed to optionsOverlayHandleInput() + * gfx_pc.cpp gfx_run : optionsOverlayEmit() appended after the game DL + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Toggle open/closed. On the closing edge the config is saved. */ +void optionsOverlayToggle(void); + +/* 1 while the overlay is on screen. */ +int optionsOverlayIsOpen(void); + +/* Called from inputComputePad(0) while open: reads SDL keyboard edges and + * drives the cursor / value adjustments. */ +void optionsOverlayHandleInput(void); + +/* Mouse-wheel notch -> move the selection (host event pump). */ +void optionsOverlayScroll(int dir); + +/* Build the overlay's 2D display list for this frame, or return NULL when the + * overlay is closed. Called by fast3d after running the game DL. */ +Gfx *optionsOverlayEmit(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PORT_OPTIONSOVERLAY_H */ diff --git a/port/include/video.h b/port/include/video.h index 346e2210..a8c37fd3 100644 --- a/port/include/video.h +++ b/port/include/video.h @@ -48,6 +48,10 @@ void videoResetTextureCache(void); /* Current FPS (measured). */ float videoGetFPS(void); +/* Re-apply the live-tunable [Video] knobs (VSync / FpsCap / TextureFilter) on + * the next frame start. Called by the F10 options overlay after an edit. */ +void videoRequestLiveConfig(void); + /* Snapshot live window geometry into the config vars (call before configSave * on a clean exit). No-op if the window isn't up. */ void videoSaveWindowState(void); diff --git a/port/src/config.c b/port/src/config.c index 6d41cb27..211d51fe 100644 --- a/port/src/config.c +++ b/port/src/config.c @@ -109,6 +109,81 @@ void configRegisterString(const char *key, char *value, int bufSize) o->bufSize = bufSize; } +/* ------------------------------------------------------------------------ + * Option metadata side table + enumeration (F10 options overlay). config.c + * gains no knowledge of specific keys: the overlay calls configSetOptionMeta() + * for each row it draws, and configForEachOption() joins that against the + * registered options. + * ---------------------------------------------------------------------- */ +struct OptionMeta { + char key[64]; + const char *label; + double step; + const char *const *enumNames; +}; +static struct OptionMeta metaOpts[MAX_OPTIONS]; +static int numMetaOpts = 0; + +static const struct OptionMeta *findMeta(const char *key) +{ + for (int i = 0; i < numMetaOpts; i++) { + if (strcmp(metaOpts[i].key, key) == 0) { + return &metaOpts[i]; + } + } + return NULL; +} + +void configSetOptionMeta(const char *key, const char *label, double step, + const char *const *enumNames) +{ + struct OptionMeta *m = NULL; + for (int i = 0; i < numMetaOpts; i++) { + if (strcmp(metaOpts[i].key, key) == 0) { + m = &metaOpts[i]; + break; + } + } + if (!m) { + if (numMetaOpts >= MAX_OPTIONS) return; + m = &metaOpts[numMetaOpts++]; + strncpy(m->key, key, sizeof(m->key) - 1); + m->key[sizeof(m->key) - 1] = 0; + } + m->label = label; + m->step = step; + m->enumNames = enumNames; +} + +void configForEachOption(ConfigOptionCb cb, void *ctx) +{ + if (!cb) return; + for (int i = 0; i < numIntOpts; i++) { + const struct OptionMeta *m = findMeta(intOpts[i].key); + cb(intOpts[i].key, CONFIG_OPT_INT, intOpts[i].value, + (double)intOpts[i].min, (double)intOpts[i].max, + m ? m->step : 0.0, m ? m->label : NULL, m ? m->enumNames : NULL, ctx); + } + for (int i = 0; i < numUIntOpts; i++) { + const struct OptionMeta *m = findMeta(uintOpts[i].key); + cb(uintOpts[i].key, CONFIG_OPT_UINT, uintOpts[i].value, + (double)uintOpts[i].min, (double)uintOpts[i].max, + m ? m->step : 0.0, m ? m->label : NULL, m ? m->enumNames : NULL, ctx); + } + for (int i = 0; i < numFloatOpts; i++) { + const struct OptionMeta *m = findMeta(floatOpts[i].key); + cb(floatOpts[i].key, CONFIG_OPT_FLOAT, floatOpts[i].value, + (double)floatOpts[i].min, (double)floatOpts[i].max, + m ? m->step : 0.0, m ? m->label : NULL, m ? m->enumNames : NULL, ctx); + } + for (int i = 0; i < numStrOpts; i++) { + const struct OptionMeta *m = findMeta(strOpts[i].key); + cb(strOpts[i].key, CONFIG_OPT_STR, strOpts[i].value, + 0.0, 0.0, m ? m->step : 0.0, m ? m->label : NULL, + m ? m->enumNames : NULL, ctx); + } +} + static int parseInt(const char *s) { while (*s == ' ' || *s == '\t') s++; diff --git a/port/src/input.c b/port/src/input.c index 9e85f6cb..42acbfd4 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -79,6 +79,7 @@ #include "system.h" #include "config.h" #include "input.h" +#include "optionsoverlay.h" /* N64 button bits (from PR/os.h -- duplicated here to avoid pulling os.h, * whose `u8 errno;` field collides with 's macro). */ @@ -500,6 +501,17 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) return 0; } + /* F10 options overlay: while it is open, controller 0 is fully swallowed + * (neutral pad, no stick) and the nav keys / wheel drive the overlay + * instead. Mirrors the WI-1 "cursor free in a stage -> withhold input" + * pattern. Controllers 1-3 are untouched. */ + if (idx == 0 && optionsOverlayIsOpen()) { + optionsOverlayHandleInput(); + if (stick_x) *stick_x = 0; + if (stick_y) *stick_y = 0; + return 0; + } + /* ---- keyboard + mouse: controller 0 only ---- */ if (idx == 0) { const Uint8 *ks = SDL_GetKeyboardState(NULL); diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c new file mode 100644 index 00000000..6f987c8a --- /dev/null +++ b/port/src/optionsoverlay.c @@ -0,0 +1,427 @@ +/* + * F10 in-game options overlay -- approach (C) from docs/dev/OPTIONS-MENU-PLAN.md. + * + * Port-layer only. No src/ menu code is touched: the overlay draws its own + * fast3d 2D display list (appended after the game DL in gfx_run) and edits the + * port-owned config.c variables directly. Live knobs apply immediately; the + * two that need an FBO/window rebuild (MSAA, Fullscreen) are tagged "(restart)". + * + * Text + fill helpers are the game's own (textRender / microcode_constructor / + * gDPFillRectangle) reached by extern -- same pattern input.c uses to read + * current_menu / cursor_h_pos. This is a rendering/UI view, not a logic change. + * + * Diagnostic: set GE_OPTIONSOVERLAY=1 to auto-open at boot (headless layout + * check). Env-gated, harmless when unset. + */ + +#include +#include +#include +#include + +#include + +#include +#include + +#include "platform.h" +#include "system.h" +#include "config.h" +#include "video.h" +#include "optionsoverlay.h" + +/* ---- game symbols (rendering/UI only; see input.c for the same pattern) ---- */ +struct font; +struct fontchar; +extern struct font *ptrFontBankGothic; +extern struct fontchar *ptrFontBankGothicChars; +extern Gfx *microcode_constructor(Gfx *gdl); +extern Gfx *textRender(Gfx *gdl, s32 *x, s32 *y, char *text, struct fontchar *chars, + struct font *font, u32 colour, s32 width, s32 height, + u32 yOffset, s32 lineheight); +extern void textMeasure(s32 *textheight, s32 *textwidth, char *text, + struct fontchar *chars, struct font *font, s32 lineheight); +extern s16 viGetX(void); +extern s16 viGetY(void); + +/* ------------------------------------------------------------------------ */ + +enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA }; + +static const char *const kOnOff[] = { "OFF", "ON", NULL }; +static const char *const kTexFilter[] = { "NEAREST", "BILINEAR", "3-POINT", NULL }; +static const char *const kCapture[] = { "ALWAYS GRAB", "CLICK-TO-LOCK", NULL }; +static const int kMsaaSeq[] = { 1, 2, 4, 8 }; + +struct Row { + const char *key; + const char *label; + int kind; + double step; + const char *const *names; /* ROW_TOGGLE / ROW_ENUM value names */ + int restart; /* value change needs a restart */ + double uiMin, uiMax; /* 0,0 -> use the registered clamp */ + + /* resolved from config.c at init */ + int found; + int type; /* CONFIG_OPT_* */ + void *ptr; + double cfgMin, cfgMax; +}; + +static struct Row rows[] = { + { "Video.VSync", "VSync", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, + { "Video.FpsCap", "Frame cap", ROW_SLIDER, 10, NULL, 0, 0, 360, 0,0,0,0,0 }, + { "Video.MSAA", "MSAA", ROW_MSAA, 0, NULL, 1, 0, 0, 0,0,0,0,0 }, + { "Video.TextureFilter", "Texture filter", ROW_ENUM, 1, kTexFilter, 0, 0, 0, 0,0,0,0,0 }, + { "Input.MouseAimSpeed", "Mouse aim speed", ROW_SLIDER, 1, NULL, 0, 0, 100, 0,0,0,0,0 }, + { "Input.MouseTurnSpeed", "Mouse turn speed", ROW_SLIDER, 1, NULL, 0, 0, 100, 0,0,0,0,0 }, + { "Input.MouseInvertY", "Mouse invert Y", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, + { "Input.MouseCaptureMode", "Mouse capture", ROW_TOGGLE, 1, kCapture, 0, 0, 0, 0,0,0,0,0 }, + { "Game.ScreenShakeIntensity","Screen shake", ROW_SLIDER, 0.25, NULL, 0, 0, 3, 0,0,0,0,0 }, +}; +#define NUM_ROWS ((int)(sizeof(rows) / sizeof(rows[0]))) + +static int s_inited = 0; +static volatile int s_open = 0; +static int s_sel = 0; + +/* ------------------------------------------------------------------------ */ + +static void resolveCb(const char *key, int type, void *ptr, double min, double max, + double step, const char *label, const char *const *names, + void *ctx) +{ + (void)step; (void)label; (void)names; (void)ctx; + for (int i = 0; i < NUM_ROWS; i++) { + if (strcmp(rows[i].key, key) == 0) { + rows[i].found = 1; + rows[i].type = type; + rows[i].ptr = ptr; + rows[i].cfgMin = min; + rows[i].cfgMax = max; + return; + } + } +} + +static void overlayInit(void) +{ + if (s_inited) { + return; + } + s_inited = 1; + + /* Publish display metadata so config.c / future consumers can see it, + * without config.c knowing any specific key. */ + for (int i = 0; i < NUM_ROWS; i++) { + configSetOptionMeta(rows[i].key, rows[i].label, rows[i].step, rows[i].names); + } + configForEachOption(resolveCb, NULL); + + for (int i = 0; i < NUM_ROWS; i++) { + if (!rows[i].found) { + sysLogPrintf(LOG_WARNING, "optionsoverlay: option '%s' not registered", + rows[i].key); + } + } + + const char *e = getenv("GE_OPTIONSOVERLAY"); + if (e && atoi(e) != 0) { + s_open = 1; + sysLogPrintf(LOG_INFO, "optionsoverlay: auto-opened (GE_OPTIONSOVERLAY)"); + } +} + +static double rowGet(const struct Row *r) +{ + if (!r->found || !r->ptr) { + return 0.0; + } + switch (r->type) { + case CONFIG_OPT_INT: return (double)*(int *)r->ptr; + case CONFIG_OPT_UINT: return (double)*(unsigned int *)r->ptr; + case CONFIG_OPT_FLOAT: return (double)*(float *)r->ptr; + default: return 0.0; + } +} + +static double rowLo(const struct Row *r) +{ + return (r->uiMin != r->uiMax) ? r->uiMin : r->cfgMin; +} +static double rowHi(const struct Row *r) +{ + return (r->uiMin != r->uiMax) ? r->uiMax : r->cfgMax; +} + +static void rowSet(struct Row *r, double v) +{ + double lo = rowLo(r), hi = rowHi(r); + if (lo != hi) { + if (v < lo) v = lo; + if (v > hi) v = hi; + } + switch (r->type) { + case CONFIG_OPT_INT: *(int *)r->ptr = (int)lround(v); break; + case CONFIG_OPT_UINT: *(unsigned int *)r->ptr = (unsigned int)(v < 0 ? 0 : lround(v)); break; + case CONFIG_OPT_FLOAT: *(float *)r->ptr = (float)v; break; + default: return; + } + + /* Live-apply the video knobs that need a fast3d/SDL call. Everything else + * is read straight off the pointer by its owner every frame/poll. */ + if (strncmp(r->key, "Video.", 6) == 0 && !r->restart) { + videoRequestLiveConfig(); + } +} + +static void rowAdjust(struct Row *r, int dir) +{ + if (!r->found) { + return; + } + double v = rowGet(r); + switch (r->kind) { + case ROW_TOGGLE: + rowSet(r, (v != 0.0) ? 0.0 : 1.0); + break; + case ROW_MSAA: { + int idx = 0; + for (int i = 0; i < 4; i++) { + if (kMsaaSeq[i] == (int)lround(v)) idx = i; + } + idx += dir; + if (idx < 0) idx = 0; + if (idx > 3) idx = 3; + rowSet(r, (double)kMsaaSeq[idx]); + break; + } + case ROW_ENUM: { + double lo = r->cfgMin, hi = r->cfgMax; + v += dir; + if (v < lo) v = hi; + if (v > hi) v = lo; + rowSet(r, v); + break; + } + default: /* ROW_SLIDER */ + rowSet(r, v + dir * r->step); + break; + } +} + +/* ------------------------------------------------------------------------ */ + +void optionsOverlayToggle(void) +{ + overlayInit(); + s_open = !s_open; + sysLogPrintf(LOG_INFO, "optionsoverlay: %s", s_open ? "opened" : "closed"); + if (!s_open) { + configSave(); + } +} + +int optionsOverlayIsOpen(void) +{ + if (!s_inited) { + overlayInit(); + } + return s_open; +} + +void optionsOverlayScroll(int dir) +{ + if (!s_open || dir == 0) { + return; + } + s_sel += (dir > 0) ? -1 : 1; /* wheel-up -> move up the list */ + if (s_sel < 0) s_sel = NUM_ROWS - 1; + if (s_sel >= NUM_ROWS) s_sel = 0; +} + +void optionsOverlayHandleInput(void) +{ + static int prev[6]; /* UP DOWN LEFT RIGHT RETURN MOUSE */ + if (!s_open) { + memset(prev, 0, sizeof(prev)); + return; + } + + const Uint8 *ks = SDL_GetKeyboardState(NULL); + Uint32 mb = SDL_GetMouseState(NULL, NULL); + + int cur[6]; + cur[0] = ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8]; + cur[1] = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2]; + cur[2] = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4]; + cur[3] = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6] + || ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]; + cur[4] = 0; /* reserved */ + cur[5] = (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) ? 1 : 0; + int rmb = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) ? 1 : 0; + + if (cur[0] && !prev[0]) { + s_sel = (s_sel + NUM_ROWS - 1) % NUM_ROWS; + } + if (cur[1] && !prev[1]) { + s_sel = (s_sel + 1) % NUM_ROWS; + } + if (cur[2] && !prev[2]) { + rowAdjust(&rows[s_sel], -1); + } + if ((cur[3] && !prev[3]) || (cur[5] && !prev[5])) { + rowAdjust(&rows[s_sel], +1); + } + static int prevRmb = 0; + if (rmb && !prevRmb) { + rowAdjust(&rows[s_sel], -1); + } + prevRmb = rmb; + + memcpy(prev, cur, sizeof(prev)); +} + +/* ------------------------------------------------------------------------ */ + +#define OV_BUF_CMDS 8192 +static Gfx s_buf[OV_BUF_CMDS]; + +/* Layout (game 2D pixel space = viGetX() x viGetY(), ~320x240). */ +#define OV_X0 26 +#define OV_LABEL_X 34 +#define OV_VALUE_X 176 +#define OV_BAR_X 176 +#define OV_BAR_W 84 +#define OV_NUM_X 266 +#define OV_TOP 24 +#define OV_LINE 14 + +static Gfx *fillRect(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, + u8 r, u8 g, u8 b, u8 a) +{ + gDPSetRenderMode(gdl++, G_RM_XLU_SURF, G_RM_XLU_SURF2); + gDPSetCombineMode(gdl++, G_CC_PRIMITIVE, G_CC_PRIMITIVE); + gDPSetPrimColor(gdl++, 0, 0, r, g, b, a); + gDPFillRectangle(gdl++, x0, y0, x1, y1); + return gdl; +} + +static void valueText(const struct Row *r, char *out, int n) +{ + double v = rowGet(r); + if ((r->kind == ROW_TOGGLE || r->kind == ROW_ENUM) && r->names) { + int idx = (int)lround(v); + int cnt = 0; + while (r->names[cnt]) cnt++; + if (idx >= 0 && idx < cnt) { + snprintf(out, n, "%s", r->names[idx]); + return; + } + } + if (r->kind == ROW_MSAA) { + if ((int)lround(v) <= 1) snprintf(out, n, "OFF"); + else snprintf(out, n, "%dx", (int)lround(v)); + return; + } + if (r->kind == ROW_SLIDER && r->type == CONFIG_OPT_FLOAT) { + snprintf(out, n, "%.2f", v); + return; + } + if (r->kind == ROW_SLIDER && (int)lround(v) == 0 && + strcmp(r->key, "Video.FpsCap") == 0) { + snprintf(out, n, "UNCAPPED"); + return; + } + snprintf(out, n, "%d", (int)lround(v)); +} + +static Gfx *drawText(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour) +{ + s32 w = 0, h = 0; + s32 px = x, py = y; + textMeasure(&h, &w, (char *)str, ptrFontBankGothicChars, ptrFontBankGothic, 10); + return textRender(gdl, &px, &py, (char *)str, ptrFontBankGothicChars, + ptrFontBankGothic, colour, w, h, 0, 10); +} + +Gfx *optionsOverlayEmit(void) +{ + if (!s_inited) { + overlayInit(); + } + if (!s_open) { + return NULL; /* nothing appended -> golden dumps byte-identical */ + } + + const s32 W = viGetX(); + const s32 H = viGetY(); + const s32 panelBottom = OV_TOP + (NUM_ROWS + 3) * OV_LINE; + Gfx *gdl = s_buf; + + gDPPipeSync(gdl++); + gDPSetCycleType(gdl++, G_CYC_1CYCLE); + gDPSetTexturePersp(gdl++, G_TP_NONE); + gDPSetScissor(gdl++, G_SC_NON_INTERLACE, 0, 0, W, H); + + /* ---- pass 1: all fills (G_CC_PRIMITIVE) ---- */ + gdl = fillRect(gdl, 0, 0, W, H, 0, 0, 0, 150); /* dim */ + gdl = fillRect(gdl, OV_X0 - 6, OV_TOP - 8, W - (OV_X0 - 6), panelBottom, + 8, 10, 24, 205); /* panel */ + + for (int i = 0; i < NUM_ROWS; i++) { + s32 rowY = OV_TOP + (i + 2) * OV_LINE; + if (i == s_sel) { + gdl = fillRect(gdl, OV_X0 - 3, rowY - 2, W - (OV_X0 - 3), rowY + OV_LINE - 3, + 40, 46, 96, 220); + } + if (rows[i].kind == ROW_SLIDER && rows[i].found) { + double lo = rowLo(&rows[i]), hi = rowHi(&rows[i]); + double f = (hi > lo) ? (rowGet(&rows[i]) - lo) / (hi - lo) : 0.0; + if (f < 0) f = 0; if (f > 1) f = 1; + s32 by = rowY + 2; + gdl = fillRect(gdl, OV_BAR_X, by, OV_BAR_X + OV_BAR_W, by + 6, + 60, 60, 70, 220); + gdl = fillRect(gdl, OV_BAR_X, by, OV_BAR_X + (s32)(OV_BAR_W * f), by + 6, + 210, 200, 90, 255); + } + } + + /* ---- pass 2: text ---- */ + gdl = microcode_constructor(gdl); + + gdl = drawText(gdl, OV_X0, OV_TOP, "PC OPTIONS", 0xffe040ff); + gdl = drawText(gdl, OV_X0 + 96, OV_TOP, "F10 close arrows adjust", 0x9090a0ff); + + for (int i = 0; i < NUM_ROWS; i++) { + s32 rowY = OV_TOP + (i + 2) * OV_LINE; + u32 col = (i == s_sel) ? 0xffffffff : 0xc0c0c8ff; + char val[48]; + + if (!rows[i].found) { + gdl = drawText(gdl, OV_LABEL_X, rowY, (char *)rows[i].label, 0x808080ff); + gdl = drawText(gdl, OV_VALUE_X, rowY, "(n/a)", 0x808080ff); + continue; + } + gdl = drawText(gdl, OV_LABEL_X, rowY, (char *)rows[i].label, col); + + valueText(&rows[i], val, sizeof(val)); + if (rows[i].kind == ROW_SLIDER) { + gdl = drawText(gdl, OV_NUM_X, rowY, val, col); + } else { + gdl = drawText(gdl, OV_VALUE_X, rowY, val, col); + } + if (rows[i].restart) { + gdl = drawText(gdl, OV_VALUE_X + 46, rowY, "(restart)", 0x909090ff); + } + } + + gDPPipeSync(gdl++); + gSPEndDisplayList(gdl++); + + if ((gdl - s_buf) > OV_BUF_CMDS) { + sysLogPrintf(LOG_ERROR, "optionsoverlay: DL overflow (%d)", (int)(gdl - s_buf)); + } + return s_buf; +} diff --git a/port/src/video.c b/port/src/video.c index 4bb774ba..88f2a7da 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -28,6 +28,7 @@ #include "config.h" #include "video.h" #include "input.h" +#include "optionsoverlay.h" #include "../fast3d/gfx_api.h" #include "../fast3d/gfx_sdl.h" @@ -96,6 +97,31 @@ PD_CONSTRUCTOR static void videoConfigInit(void) configRegisterInt("Window.Maximized", &cfgWinMax, 0, 1); } +/* Set by videoRequestLiveConfig() (F10 overlay, host thread); consumed on the + * scheduler thread in videoStartFrame() where the GL context is bound. */ +static volatile int liveCfgDirty = 0; + +static void videoApplyTexFilter(void) +{ + if (cfgTexFilter >= 2) { + gfx_set_texture_filter(FILTER_THREE_POINT); + gfx_set_mipmap_filter(MIPMAP_LINEAR); + } else if (cfgTexFilter == 1) { + gfx_set_texture_filter(FILTER_LINEAR); + gfx_set_mipmap_filter(MIPMAP_LINEAR); + } else { + gfx_set_texture_filter(FILTER_NONE); + gfx_set_mipmap_filter(MIPMAP_NEAREST); + } +} + +/* Re-apply the live-tunable [Video] knobs (VSync / FpsCap / TextureFilter). + * MSAA and Fullscreen are FBO/window rebuilds -> "(restart)" in the overlay. */ +void videoRequestLiveConfig(void) +{ + liveCfgDirty = 1; +} + static u32 frames = 0; /* Set by the host event pump (F12), consumed on the render thread in * videoEndFrame where a GL context is current. */ @@ -153,16 +179,7 @@ int videoInit(void) gfx_set_fix_mip_textures(cfgFixMipTex); gfx_set_wrap_fix(cfgWrapFix); - if (cfgTexFilter >= 2) { - gfx_set_texture_filter(FILTER_THREE_POINT); - gfx_set_mipmap_filter(MIPMAP_LINEAR); - } else if (cfgTexFilter == 1) { - gfx_set_texture_filter(FILTER_LINEAR); - gfx_set_mipmap_filter(MIPMAP_LINEAR); - } else { - gfx_set_texture_filter(FILTER_NONE); - gfx_set_mipmap_filter(MIPMAP_NEAREST); - } + videoApplyTexFilter(); /* The GL context is currently current on this (host main) thread, but all * rendering happens on the game's scheduler thread. WGL only allows a @@ -195,6 +212,17 @@ void videoStartFrame(void) /* Rendering runs on the game's scheduler thread; the GL context was * created on the host main thread. */ gfx_sdl_make_context_current(); + + if (liveCfgDirty) { + liveCfgDirty = 0; + wmAPI->set_swap_interval(cfgVSync ? 1 : 0); + gfx_set_target_fps(cfgFpsCap); /* 0 = uncapped */ + videoApplyTexFilter(); + sysLogPrintf(LOG_INFO, "video: live config applied " + "(vsync=%d fpscap=%d texfilter=%d)", + cfgVSync, cfgFpsCap, cfgTexFilter); + } + gfx_start_frame(); } @@ -232,20 +260,33 @@ void videoPumpEvents(void) exit(0); } else if (ev.key.keysym.sym == SDLK_F12 && !ev.key.repeat) { screenshotReq = 1; + } else if (ev.key.keysym.sym == SDLK_F10 && !ev.key.repeat) { + optionsOverlayToggle(); /* F10: port-layer options overlay */ } else if (ev.key.keysym.sym == SDLK_ESCAPE && !ev.key.repeat) { - /* WI-1: in click-to-lock mode ESC frees the captured cursor - * (and is swallowed); otherwise it falls through to input.c - * where it feeds the N64 B button (D145). */ - inputReleaseCapture(); + /* Overlay open: ESC closes it (and is swallowed). Otherwise + * WI-1: in click-to-lock mode ESC frees the captured cursor + * (and is swallowed); else it falls through to input.c where + * it feeds the N64 B button (D145). */ + if (optionsOverlayIsOpen()) { + optionsOverlayToggle(); + } else { + inputReleaseCapture(); + } } break; case SDL_MOUSEBUTTONDOWN: /* WI-1: a click in the window (re)locks the cursor in * click-to-lock mode; a no-op otherwise. */ - inputNotifyClick(); + if (!optionsOverlayIsOpen()) { + inputNotifyClick(); + } break; case SDL_MOUSEWHEEL: - inputPostWheel(ev.wheel.y); /* weapon cycle */ + if (optionsOverlayIsOpen()) { + optionsOverlayScroll(ev.wheel.y); /* move the selection */ + } else { + inputPostWheel(ev.wheel.y); /* weapon cycle */ + } break; case SDL_CONTROLLERDEVICEADDED: case SDL_CONTROLLERDEVICEREMOVED: From dfab1876e4d673b475ed732e076ddb71971bf1ef Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:43:55 -0400 Subject: [PATCH 03/10] feat(release): self-contained asset-prep tool for the alpha bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows bundle from bundle-win.sh / CI was unrunnable on its own (D179): the port loads two ROM-derived directories (data/pcmodels-/ and data/pccg-/) that are gitignored and cannot be shipped. Add tools_pc/dist/prepare-assets/prepare-assets.py — a stdlib-only wrapper (no compiler, no N64 toolchain, no network) that: - auto-detects the ROM near the bundle, verifies SHA-1 -> region (NTSC-U / PAL / NTSC-J), rejects unrecognised / byte-swapped dumps - runs d43_emit.py + d69_emit.py against a vendored input tree - writes data/pcmodels-/ and data/pccg-/ next to the exe bundle-win.sh assembles prepare-assets/ fresh at package time (emit scripts from tools_pc/, filelist.u.csv + file_resource_table.inc.c + the 512 modelFileHeader.inc.c files as vendored inputs) so it can never drift from the scripts. Vendored inputs are decomp layout/symbol metadata, not game assets. bundled README gets a one-time "prepare-assets" setup step. Verified end-to-end: from a bundle containing only exe + DLLs + a US ROM, prepare-assets.py produces all four output files byte-identical to d43_emit.py / d69_emit.py run from the repo root. Bundle 14.1 MB (< 60 MB guard). Error paths (no ROM / unknown SHA-1) tested. Resolves BACKLOG "Alpha release — asset-prep tool" steps 1-3; step 4 (clean-machine smoke test with the game actually running) still owed. --- tools_pc/bundle-win.sh | 20 +++ tools_pc/dist/README.md.in | 27 ++- .../dist/prepare-assets/prepare-assets.py | 157 ++++++++++++++++++ 3 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 tools_pc/dist/prepare-assets/prepare-assets.py diff --git a/tools_pc/bundle-win.sh b/tools_pc/bundle-win.sh index 4270224e..327fa401 100755 --- a/tools_pc/bundle-win.sh +++ b/tools_pc/bundle-win.sh @@ -78,6 +78,26 @@ for l in SDL2 zlib gcc-libs libwinpthread mingw-w64; do [ -d "$MINGW/share/licenses/$l" ] && cp -r "$MINGW/share/licenses/$l" "$OUT/licenses/$l" done +# --- asset-prep tool ------------------------------------------------- +# The port needs two ROM-derived directories (data/pcmodels-/ and +# data/pccg-/) that we cannot ship. prepare-assets.py regenerates +# them from the user's own ROM using only the Python standard library. It is +# assembled fresh from the tree here so it can never drift from the emit +# scripts. The vendored inputs are decomp layout/symbol metadata (.inc.c / +# .csv), NOT game assets (no textures, audio, models, levels, or text). +PREP="$OUT/prepare-assets" +mkdir -p "$PREP/vendor/scripts" "$PREP/vendor/assets/obseg" +cp tools_pc/dist/prepare-assets/prepare-assets.py "$PREP/" +cp tools_pc/d43_emit.py tools_pc/d69_emit.py "$PREP/" +cp scripts/filelist.u.csv "$PREP/vendor/scripts/" +cp assets/obseg/file_resource_table.inc.c "$PREP/vendor/assets/obseg/" +# d43_emit.py os.walk()s assets/ for every *modelFileHeader.inc.c — preserve paths. +( cd . && find assets -iname 'modelfileheader.inc.c' -print0 \ + | xargs -0 -I{} cp --parents {} "$PREP/vendor/" ) +nmh="$(find "$PREP/vendor/assets" -iname 'modelfileheader.inc.c' | wc -l)" +echo " + prepare-assets/ (emit scripts + $nmh model headers)" +[ "$nmh" -gt 400 ] || { echo "error: prepare-assets vendored only $nmh model headers — expected ~512" >&2; exit 1; } + # --- guard: no ROM / game data snuck in -------------------------------- if find "$OUT" -type f \( -iname '*.z64' -o -iname '*.n64' -o -iname '*.v64' \) | grep -q .; then echo "error: bundle contains a ROM image — aborting" >&2 diff --git a/tools_pc/dist/README.md.in b/tools_pc/dist/README.md.in index c07e15c4..bb344c54 100644 --- a/tools_pc/dist/README.md.in +++ b/tools_pc/dist/README.md.in @@ -28,20 +28,31 @@ NTSC-U (US): Only the US ROM is supported in this alpha. -## 2. Run +## 2. Set up (one time) 1. Unzip this folder anywhere. 2. Make a `data` folder next to `ge007.x86_64.exe`. 3. Copy your ROM into it, named exactly: `data\ge007.ntsc-final.z64` -4. Run `ge007.x86_64.exe` **from this folder**. +4. Prepare the ROM-derived data (needs Python 3.8+ on your PATH): -Configuration is written to `ge007.ini` on first run. + ``` + python prepare-assets\prepare-assets.py + ``` -> If the game exits immediately reporting missing data, your ROM's assets need -> the one-time extraction step described under "Building" in the project README -> — the alpha binary expects the same asset layout a source build produces. + This reads your ROM and writes `data\pcmodels-ntsc-final\` and + `data\pccg-ntsc-final\` next to the exe. It uses only the Python standard + library — no compiler or N64 toolchain — and takes a few seconds. Run it + again only if you replace the ROM. -## 3. Controls (defaults) +## 3. Run + +Run `ge007.x86_64.exe` **from this folder**. Configuration is written to +`ge007.ini` on first run. + +> If the game exits immediately reporting missing data, re-run step 2.4 above +> and check it printed `ALL CHECKS PASSED` for both passes. + +## 4. Controls (defaults) | Action | Keyboard / mouse | |---|---| @@ -55,7 +66,7 @@ Configuration is written to `ge007.ini` on first run. Mouse sensitivity and Y-inversion are in the `[Input]` section of `ge007.ini`. -## 4. Legal +## 5. Legal Non-commercial fan preservation / research project. **Not** affiliated with, endorsed by, or sponsored by Nintendo, Rare, Microsoft, MGM, Danjaq, or EON diff --git a/tools_pc/dist/prepare-assets/prepare-assets.py b/tools_pc/dist/prepare-assets/prepare-assets.py new file mode 100644 index 00000000..07144b7d --- /dev/null +++ b/tools_pc/dist/prepare-assets/prepare-assets.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""One-time asset preparation for the GoldenEye 007 PC port (alpha). + +The port loads PC-layout model / display-list / collision / nav data from two +directories that are derived from a *GoldenEye* ROM and therefore are NOT +shipped in this package: + + data/pcmodels-/ (pcmodels.bin + manifest.csv) + data/pccg-/ (pccg.bin + manifest.csv) + +This script regenerates them from a ROM you legally own. It calls only the +Python standard library -- no compiler, no N64 toolchain, no network. The +conversion is deterministic: the same ROM always produces byte-identical +output. + +Usage (from the folder this script lives in, or anywhere): + + python prepare-assets.py [--rom PATH] [--out DIR] [--keep-temp] + +By default it looks for the ROM at /data/*.z64 (and a few other +obvious spots), verifies its SHA-1 to pick the region, and writes the two +directories under /data/ , where is the folder that +contains this "prepare-assets" folder (i.e. next to ge007.x86_64.exe). +""" + +import argparse +import hashlib +import shutil +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +VENDOR = HERE / "vendor" + +# SHA-1 of each supported retail ROM (big-endian .z64) -> region id the +# emit scripts expect. Mirrors ge007.{u,e,j}.sha1 in the source tree. +ROM_SHA1 = { + "abe01e4aeb033b6c0836819f549c791b26cfde83": "ntsc-final", # NTSC-U (US) + "167c3c433dec1f1eb921736f7d53fac8cb45ee31": "pal-final", # PAL (EU) + "2a5dade32f7fad6c73c659d2026994632c1b3174": "jpn-final", # NTSC-J (JP) +} + +EMIT_STEPS = ( + ("d43_emit.py", "pcmodels"), # models / display lists -> data/pcmodels-/ + ("d69_emit.py", "pccg"), # collision + stan nav -> data/pccg-/ +) + + +def die(msg): + print(f"\nprepare-assets: error: {msg}\n", file=sys.stderr) + sys.exit(1) + + +def sha1_of(path): + h = hashlib.sha1() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def find_rom(explicit, bundle_root): + if explicit: + p = Path(explicit).expanduser() + if not p.is_file(): + die(f"--rom {p} : not a file") + return p + search = [] + for base in (bundle_root, bundle_root / "data", HERE, HERE / "vendor" / "data"): + search += sorted(base.glob("*.z64")) + sorted(base.glob("*.n64")) + sorted(base.glob("*.v64")) + # de-dup, keep order + seen, cand = set(), [] + for p in search: + if p.resolve() not in seen: + seen.add(p.resolve()) + cand.append(p) + if not cand: + die("no .z64 ROM found. Put your GoldenEye ROM at " + f"{bundle_root / 'data'} (create the 'data' folder) or pass --rom PATH.") + if len(cand) > 1: + print("prepare-assets: multiple ROM candidates found:") + for p in cand: + print(f" {p}") + print("prepare-assets: using the first; pass --rom PATH to choose.") + return cand[0] + + +def main(): + ap = argparse.ArgumentParser(description="Generate the GoldenEye PC port's ROM-derived asset dirs.") + ap.add_argument("--rom", help="path to your .z64 ROM (default: auto-detect near the bundle)") + ap.add_argument("--out", help="bundle root to write data/ into (default: the folder holding prepare-assets)") + ap.add_argument("--keep-temp", action="store_true", help="keep the temporary ROM copy under vendor/data/") + args = ap.parse_args() + + if not VENDOR.is_dir(): + die(f"missing {VENDOR} -- this script must stay inside the 'prepare-assets' folder from the zip.") + for script, _ in EMIT_STEPS: + if not (HERE / script).is_file(): + die(f"missing {HERE / script}") + + bundle_root = Path(args.out).expanduser().resolve() if args.out else HERE.parent + + rom = find_rom(args.rom, bundle_root) + print(f"prepare-assets: ROM {rom}") + digest = sha1_of(rom) + region = ROM_SHA1.get(digest) + if region is None: + die(f"ROM SHA-1 {digest} is not a recognised retail GoldenEye 007 image.\n" + " Supported: NTSC-U / PAL / NTSC-J, big-endian .z64.\n" + " A byte-swapped (.n64/.v64) or overdumped ROM will not work -- convert to .z64 first.") + print(f"prepare-assets: SHA-1 {digest} -> region '{region}'") + + # The emit scripts assume CWD is a tree with scripts/ assets/ data/ . + work = VENDOR + (work / "data").mkdir(exist_ok=True) + staged_rom = work / "data" / f"ge007.{region}.z64" + if not (staged_rom.exists() and staged_rom.stat().st_size == rom.stat().st_size): + shutil.copy2(rom, staged_rom) + + out_dirs = [] + for script, tag in EMIT_STEPS: + src = work / "data" / f"{tag}-{region}" + if src.exists(): + shutil.rmtree(src) + print(f"prepare-assets: running {script} {region} ...") + r = subprocess.run([sys.executable, str(HERE / script), region], cwd=work) + if r.returncode != 0: + die(f"{script} exited {r.returncode}") + if not (src / f"{tag}.bin").is_file(): + die(f"{script} did not produce {src / (tag + '.bin')}") + out_dirs.append((src, tag)) + + dest_data = bundle_root / "data" + dest_data.mkdir(parents=True, exist_ok=True) + for src, tag in out_dirs: + dest = dest_data / f"{tag}-{region}" + if dest.resolve() == src.resolve(): + continue + if dest.exists(): + shutil.rmtree(dest) + shutil.move(str(src), str(dest)) + n = sum(1 for _ in dest.rglob("*") if _.is_file()) + print(f"prepare-assets: wrote {dest} ({n} files)") + + if not args.keep_temp and staged_rom.exists() and staged_rom.resolve() != rom.resolve(): + staged_rom.unlink() + + print("\nprepare-assets: done. You can now run ge007.x86_64.exe from the bundle folder.") + print(f" (Your ROM must also be at {dest_data / f'ge007.{region}.z64'} for the game itself.)") + if region != "ntsc-final": + print(" NOTE: only the NTSC-U ROM is validated in this alpha; " + f"'{region}' is experimental.") + + +if __name__ == "__main__": + main() From 5dd9bae9c370f5de99f9ce7a758660843a103de8 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:54:20 -0400 Subject: [PATCH 04/10] port(romdata): POSIX fixed-address ROM map (mmap) for the Linux build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cart-base map at 0x10000000 (so absolute ROM-asset symbols are live host pointers) was Windows-only; the #else branch was a stub that logged "not implemented" and fell to the degraded heap-copy path. - Factor the post-map work (memcpy + pcmodels/pccg sidecar load + D55 RLE header fixup) into romdataFinishCartMap(), shared by both paths. - POSIX #else: anonymous mmap at CART_BASE with MAP_FIXED_NOREPLACE where available; on any failure munmap + fall through to the identical heap copy. - romdataDestroy() munmaps on POSIX (length tracked in mappedLen). Verified on Windows: builds 242/242, -level_09 boots crash-free (identical behaviour with/without the refactor). Linux compile + runtime verification owed — see docs/dev/LINUX-PORT.md. No behaviour change on Windows. --- docs/dev/LINUX-PORT.md | 91 +++++++++++++++++++++++++++++++ port/src/romdata.c | 121 ++++++++++++++++++++++++++--------------- 2 files changed, 168 insertions(+), 44 deletions(-) create mode 100644 docs/dev/LINUX-PORT.md diff --git a/docs/dev/LINUX-PORT.md b/docs/dev/LINUX-PORT.md new file mode 100644 index 00000000..9586411f --- /dev/null +++ b/docs/dev/LINUX-PORT.md @@ -0,0 +1,91 @@ +# Linux port — status & gap analysis + +Session M-38 (2026-09-02). Goal-2 on the roadmap (after the Windows alpha / +release artifact). This is a scoping + first-patch pass; **nothing here has run +on a Linux machine yet.** + +## Method + +Read `build-pc.sh`, `CMakeLists.txt`, `docs/building.md`, and grepped +`port/src`, `port/fast3d`, `port/include` for Windows-only headers / Win32 APIs +/ unguarded `_WIN32` branches. A local Qwen `triage` pass seeded the hunt; every +claim below was re-checked against the real files (the 27B was wrong on two of +three headline items — see "Ruled out"). + +## What is already Linux-ready (verified by reading) + +- **`port/include/platform.h`** — sets `PLATFORM_LINUX` / `PLATFORM_MACOS` / + `PLATFORM_WINDOWS` cleanly. +- **`CMakeLists.txt`** — `find_package(SDL2 REQUIRED)`, `find_package(ZLIB + REQUIRED)`, and `else()` branches already present for the non-Windows case: + `GL_LIBRARY = GL` (l.291), `EXTRA_LIBRARIES = stdc++ m dl pthread` (l.304), + `elseif(UNIX)` platform block (l.86). `WIN32_EXECUTABLE` and the MinGW zlib + fallback are correctly `if(WIN32)` / `if(MINGW)` guarded. +- **`build-pc.sh`** — already documents the Linux apt deps + (`cmake libsdl2-dev zlib1g-dev libgl1-mesa-dev`) and is a plain bash script, + no MSYS-isms in the executable body. +- **`port/src/system.c`** — `sysSleep()` has a `nanosleep()` `#else` branch + (l.76-82); `` is `PLATFORM_WINDOWS`-guarded. +- **`port/src/libultra.c`** — the OS-thread kernel has a real pthreads branch + next to the Win32 `_beginthreadex` one. +- **`port/src/crash.c`** — `MessageBoxW` / `SetThreadPriority` / `__debugbreak` + all `_WIN32`-guarded, with `PLATFORM_LINUX` counterparts. +- **`port/src/video.c`** — `GE_MKDIR` has both `_mkdir` and `mkdir(p,0777)`. +- No `__declspec`, `#pragma comment`, or hardcoded `\`-path / drive-letter + usage anywhere in `port/`. + +## Ruled out (Qwen triage false positives) + +- ~~`system.c:75` `Sleep()` has no POSIX branch~~ — it does (`nanosleep`). +- ~~CMake needs `else()` branches for zlib/pthread on Linux~~ — already there. + +## The one real code gap — FIXED this session (verification owed) + +**`port/src/romdata.c`** — the fixed-address cart map (`0x10000000`, so absolute +ROM-asset symbols are live host pointers) had a **Windows-only** implementation +(`VirtualAlloc`); the `#else` branch was a stub that logged "not implemented on +this platform" and fell straight to the heap-copy fallback. In heap-copy mode +anything that dereferences a cart address *directly* (not via +`romdataGetRom()` / the PI shims) reads wrong memory — so Linux would run, if at +all, in the same degraded mode Windows only hits when `VirtualAlloc` loses the +address race. + +**Change (M-38):** +- Factored the post-map work (memcpy + `pcmodelsLoadSidecars` + + `pccgLoadSidecars` + D55 RLE-header fixup) into `romdataFinishCartMap()`, + shared by both paths so they cannot drift. +- POSIX `#else` now does an anonymous `mmap((void*)CART_BASE, len, + PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED_NOREPLACE, -1, 0)`. + `MAP_FIXED_NOREPLACE` (Linux 4.17+) fails rather than clobbering an existing + mapping; where the macro is absent the plain hint is advisory and the + `at == CART_BASE` check catches a relocated result. On any failure it + `munmap`s and falls through to the identical heap-copy path as before. +- `romdataDestroy()` `munmap`s on POSIX (tracked length in `mappedLen`). + +**Owed verification (needs a Linux box):** +1. Compiles under GCC on Linux (`` resolves; no decomp-include-path + shadowing surprise). +2. `mmap` actually lands at `0x10000000` on a stock ASLR Linux — `0x10000000` + (256 MB) is low but usually free; if the kernel routinely refuses it, + consider linking the binary no-PIE or reserving the range via a linker + script so the loader keeps it clear. +3. `-level_09` boots crash-free with the mapped path taken (check the log says + "mapped at 0x10000000 (cart base)", not "using heap copy"). +4. Whether the heap-copy fallback is actually survivable on Linux if step 2 + fails — audit for direct `0x1xxxxxxx` derefs outside the PI shims. + +## Remaining Linux work after the romdata patch lands + +| # | Item | Size | Note | +|---|------|------|------| +| 1 | Run the Ubuntu **`validate`** CI job → upgrade it to a real **compile** job | S | `.github/workflows/ci.yml` already configures on Ubuntu; flipping it to `cmake --build` surfaces the real GCC-strictness gaps cheaply | +| 2 | First actual Linux runtime bring-up (`-level_09`) | M | expect a short tail of unguarded calls / struct-layout asserts GCC-on-Linux vs MinGW | +| 3 | `docs/building.md` — promote Linux from "untested" to "supported" once 1+2 pass | S | | +| 4 | SDL2 / GL context creation on X11 + Wayland | M | fast3d is GL 3.3 core; PD port runs on Linux so the path is known-good | +| 5 | Package: an AppImage or tarball equivalent of `bundle-win.sh` | M | reuse `prepare-assets.py` verbatim — it is already OS-agnostic | + +## Recommended next step + +Land the romdata patch behind CI (item 1) — turn the Ubuntu `validate` job into +a compile job in the same PR so the "does it build on Linux" question is +answered by the bots, then do the runtime bring-up on a real machine. diff --git a/port/src/romdata.c b/port/src/romdata.c index dac3561a..fa1596aa 100644 --- a/port/src/romdata.c +++ b/port/src/romdata.c @@ -32,6 +32,11 @@ extern int snprintf(char *str, unsigned long long maxsize, const char *format, . #define WIN32_LEAN_AND_MEAN #endif #include +#else + /* POSIX: mmap the ROM at the fixed cart address (mirrors the VirtualAlloc + * path). is a host header the decomp include path does not + * shadow. */ + #include #endif #include "system.h" @@ -50,6 +55,9 @@ extern int snprintf(char *str, unsigned long long maxsize, const char *format, . static u8 *rom = NULL; /* heap copy (fallback path) */ static u32 romSize = 0; static int mappedAtCartBase = 0; +#if !defined(PLATFORM_WINDOWS) +static unsigned long long mappedLen = 0; /* munmap() needs the length */ +#endif /* D34 (docs/internals.md): PE image base, low 32 bits zero. On N64 the * animation_data segment links at VMA 0, so game code computes record @@ -105,6 +113,35 @@ static int romHeaderValid(const u8 *h, char *err, size_t errsz) static u32 romdataBswap32(u32 v); /* defined below; used by the D55 fixup */ +/* The caller has placed a writable region of (romSize + sidecars) bytes at + * CART_BASE. Populate it, load the sidecars, apply the D55 RLE-header fixup, + * and mark the ROM live. Shared by the Windows (VirtualAlloc) and POSIX (mmap) + * fixed-address paths so the post-map logic can never drift between them. */ +static int romdataFinishCartMap(const char *tok, u8 *img, + u32 sideTotal, u32 cgTotal) +{ + memcpy((void *)(uintptr_t)CART_BASE, img, romSize); + free(img); + mappedAtCartBase = 1; + sysLogPrintf(LOG_INFO, "romdataInit: %s (%u bytes) mapped at 0x%08X " + "(cart base)%s%s", tok, romSize, CART_BASE, + sideTotal ? ", + model sidecars" : "", + cgTotal ? ", + bg/stan sidecars" : ""); + pcmodelsLoadSidecars(CART_BASE, romSize); + pccgLoadSidecars(CART_BASE + romSize + pcmodelsTotalSize()); + + /* D55: the RLE folder-menu background at `unknown2` has a big-endian w/h + * header that rle_expand_8bit() reads little-endian; match the N64 .data + * copy by swapping it in place (no-op if it already reads as LE). */ + { + extern void *unknown2; /* absolute cart address */ + u32 *hdr = (u32 *)&unknown2; + if ((u16)*hdr > 512 || (u16)(*hdr >> 16) > 512) + *hdr = romdataBswap32(*hdr); + } + return 0; +} + int romdataInit(void) { char err[128] = ""; @@ -168,51 +205,44 @@ int romdataInit(void) * sidecar extension. */ u32 cgTotal = pccgReserveSize(img); - /* Map at the cart address so absolute asset symbols are live. */ + /* Map at the cart address so absolute asset symbols are live. On + * failure both paths fall through to the heap copy below (degraded: + * anything that dereferences a cart address directly, rather than via + * romdataGetRom()/the PI shims, will read wrong memory). */ + { + u32 maplen = romSize + sideTotal + cgTotal; #if defined(PLATFORM_WINDOWS) - void *at = VirtualAlloc((LPVOID)CART_BASE, romSize + sideTotal + cgTotal, - MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); - if (at == (void *)(uintptr_t)CART_BASE) { - memcpy(at, img, romSize); - free(img); - mappedAtCartBase = 1; - sysLogPrintf(LOG_INFO, "romdataInit: %s (%u bytes) mapped at " - "0x%08X (cart base)%s%s", - tok, romSize, CART_BASE, - sideTotal ? ", + model sidecars" : "", - cgTotal ? ", + bg/stan sidecars" : ""); - pcmodelsLoadSidecars(CART_BASE, romSize); - pccgLoadSidecars(CART_BASE + romSize + pcmodelsTotalSize()); - - /* D55 (docs/internals.md): the RLE folder-menu background at - * `unknown2` (romassets_.s) is stored in the ROM with a - * big-endian w/h header (01 B8 01 2B = 440x299), but - * rle_expand_8bit() reads it little-endian. The N64 build embeds - * this asset into .data via assets/romfiles2.s from an extracted - * .bin whose header is byte-swapped (title2.c hardcodes 440x299 - * row rendering, and this is the only decodable 440x299 RLE - * stream in the ROM), so make the cart copy match it: swap the - * w/h word in place. The guard makes the fixup a no-op if a - * region's copy already reads as a plausible LE size. */ - { - extern void *unknown2; /* absolute cart address */ - u32 *hdr = (u32 *)&unknown2; - if ((u16)*hdr > 512 || (u16)(*hdr >> 16) > 512) - *hdr = romdataBswap32(*hdr); - } - return 0; - } - if (at) - VirtualFree(at, 0, MEM_RELEASE); - sysLogPrintf(LOG_WARNING, "romdataInit: could not reserve 0x%08X; " - "using heap copy — direct ROM reads will fail", - CART_BASE); + void *at = VirtualAlloc((LPVOID)CART_BASE, maplen, + MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); + if (at == (void *)(uintptr_t)CART_BASE) + return romdataFinishCartMap(tok, img, sideTotal, cgTotal); + if (at) + VirtualFree(at, 0, MEM_RELEASE); + sysLogPrintf(LOG_WARNING, "romdataInit: could not reserve 0x%08X; " + "using heap copy — direct ROM reads will fail", + CART_BASE); #else - /* Best effort: fixed-hint mmap. If the hint is refused we fall back - * to a heap copy (same limitation as above). */ - sysLogPrintf(LOG_WARNING, "romdataInit: fixed-address mapping not " - "implemented on this platform; using heap copy"); + /* POSIX: anonymous fixed-address mmap. MAP_FIXED_NOREPLACE (Linux + * 4.17+) fails instead of clobbering an existing mapping; where it + * is unavailable the plain hint is advisory and the == check below + * catches a relocated result. */ + int flags = MAP_PRIVATE | MAP_ANONYMOUS; +#ifdef MAP_FIXED_NOREPLACE + flags |= MAP_FIXED_NOREPLACE; #endif + void *at = mmap((void *)(uintptr_t)CART_BASE, maplen, + PROT_READ | PROT_WRITE, flags, -1, 0); + if (at != MAP_FAILED && at == (void *)(uintptr_t)CART_BASE) { + mappedLen = maplen; + return romdataFinishCartMap(tok, img, sideTotal, cgTotal); + } + if (at != MAP_FAILED) + munmap(at, maplen); + sysLogPrintf(LOG_WARNING, "romdataInit: could not map 0x%08X " + "(ASLR/kernel refused the fixed address); using heap " + "copy — direct ROM reads will fail", CART_BASE); +#endif + } rom = img; return 0; @@ -225,12 +255,15 @@ int romdataInit(void) void romdataDestroy(void) { -#if defined(PLATFORM_WINDOWS) if (mappedAtCartBase) { +#if defined(PLATFORM_WINDOWS) VirtualFree((LPVOID)CART_BASE, 0, MEM_RELEASE); +#else + munmap((void *)(uintptr_t)CART_BASE, (size_t)mappedLen); + mappedLen = 0; +#endif mappedAtCartBase = 0; } -#endif free(rom); rom = NULL; romSize = 0; From 2a098eb3c4e174e9ce4611be409c77db8b3a71fb Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:55:22 -0400 Subject: [PATCH 05/10] =?UTF-8?q?docs(D185):=20fresh=20d69=5Femit.py=20run?= =?UTF-8?q?=20produces=20a=20crashing=20pccg=20sidecar=20=E2=80=94=20alpha?= =?UTF-8?q?=20blocker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/findings.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 1daabd6b..a10f4fe2 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -5018,3 +5018,41 @@ golden + a Streets/Dam/Archives load. Docs only: this subsection + the §F index rows (D88.4 status, new D132 row). No code changed; the proposed diff above is not applied. No temp probe scripts left. + +## D185 — fresh `d69_emit.py` run yields a game-crashing `data/pccg-/` (M-38) + +**Status: OPEN — alpha-release blocker.** (Add to the §F index.) + +**Symptom.** `python tools_pc/d69_emit.py ntsc-final` in this repo writes +`data/pccg-ntsc-final/pccg.bin` = 3346895 B (52 sidecars, 1856-B manifest), +prints `ALL CHECKS PASSED`, and `-level_09` / `-level_20` then segfault before +frame 1 (`EXCEPTION: 0xc0000005`, faulting return address is an ASCII +model-name string). The pre-migration working sidecar +(`C:/Users/james/Source/Repos/007/data/pccg-ntsc-final/pccg.bin`, 3604378 B, +2421-B manifest) drops into this tree and `-level_09` runs crash-free. + +**Isolation done (M-38).** +- Not the romdata M-38 change: baseline (stashed) crashes identically. +- Not the build: same exe, only the `data/pccg-ntsc-final/` bytes swapped. +- `tools_pc/d69_emit.py` is byte-identical between the public repo and the + pre-migration repo `007` (only a doc-path comment differs). +- The migration commit `52539d10 "Prepare for public release"` normalised the + committed d69 inputs from CRLF to LF: `scripts/filelist.u.csv` (−812 B, one + CR/line) and `assets/obseg/file_resource_table.inc.c`. +- **Restoring CR to both inputs did NOT restore the 3604378-B output** → line + endings are not the (whole) cause. Some other part of the d69 input closure + regressed in the history rewrite, or the working sidecar predates a real + d69/​input change and 3346895 is a separate second bug. +- `d43_emit.py` is unaffected (its `find_row` tries `name`, `name[1:]`, + `name[:-1]`, `name[1:-1]` — tolerant of a mangled basename; `d69`'s + `fl_by_base.get(basename)` is exact). + +**Impact.** `docs/building.md` tells users to run `d69_emit.py`; M-38's +`prepare-assets.py` does the same. Both currently produce a broken game on a +clean checkout. The committed `data/` dirs (carried from pre-migration) hide it. + +**Next.** Diff the sidecar *name* set: pre-migration `manifest.csv` vs a fresh +run's — find which bg/stan entries d69 now drops, trace through `find_row` → +`file_resource_table.inc.c` → `filelist.u.csv`. Fix so a fresh run reproduces +3604378 B / 2421-B manifest; re-verify `-level_09`/`-20` crash-free. Do NOT +regenerate `data/pccg-ntsc-final/` before the fix (it holds the working copy). From 205004242cb1ebcad018c92e97eb717efa32f807 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:59:49 -0400 Subject: [PATCH 06/10] fix(release): prepare-assets must also run d88_emit.py (per-level stage setup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d88_emit.py appends the 21 Usetup*Z stage-setup files (object placement, AI scripts, pads/nav) to the same data/pccg-/ sidecar d69 produces. Without it the port has no setup data for any level and segfaults on load before frame 1 (this was mis-filed as D185 'd69 regression' — it was a missing pipeline step). Vendor d88_emit.py + its one local import d88_propdefs.py; run d43 -> d69 -> d88 --regen. Verified: fresh d43+d69+d88 from an assembled bundle produces pccg.bin (3604378 B, 74 rows) and pcmodels.bin byte-identical to the known-good sidecars; -level_09 and -level_20 boot crash-free against them. --- tools_pc/bundle-win.sh | 1 + .../dist/prepare-assets/prepare-assets.py | 42 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/tools_pc/bundle-win.sh b/tools_pc/bundle-win.sh index 327fa401..a36d13a6 100755 --- a/tools_pc/bundle-win.sh +++ b/tools_pc/bundle-win.sh @@ -89,6 +89,7 @@ PREP="$OUT/prepare-assets" mkdir -p "$PREP/vendor/scripts" "$PREP/vendor/assets/obseg" cp tools_pc/dist/prepare-assets/prepare-assets.py "$PREP/" cp tools_pc/d43_emit.py tools_pc/d69_emit.py "$PREP/" +cp tools_pc/d88_emit.py tools_pc/d88_propdefs.py "$PREP/" # d88 = per-level stage setup (+ its only local import) cp scripts/filelist.u.csv "$PREP/vendor/scripts/" cp assets/obseg/file_resource_table.inc.c "$PREP/vendor/assets/obseg/" # d43_emit.py os.walk()s assets/ for every *modelFileHeader.inc.c — preserve paths. diff --git a/tools_pc/dist/prepare-assets/prepare-assets.py b/tools_pc/dist/prepare-assets/prepare-assets.py index 07144b7d..912fc7ab 100644 --- a/tools_pc/dist/prepare-assets/prepare-assets.py +++ b/tools_pc/dist/prepare-assets/prepare-assets.py @@ -41,10 +41,15 @@ ROM_SHA1 = { "2a5dade32f7fad6c73c659d2026994632c1b3174": "jpn-final", # NTSC-J (JP) } +# (script, args, produces) — run in order. d88 has no output of its own: it +# appends the 21 per-level stage-setup files to the pccg dir d69 just made, +# and --regen makes that idempotent. EMIT_STEPS = ( - ("d43_emit.py", "pcmodels"), # models / display lists -> data/pcmodels-/ - ("d69_emit.py", "pccg"), # collision + stan nav -> data/pccg-/ + ("d43_emit.py", (), "pcmodels-{region}/pcmodels.bin"), + ("d69_emit.py", (), "pccg-{region}/pccg.bin"), + ("d88_emit.py", ("--regen",), "pccg-{region}/pccg.bin"), ) +OUTPUT_DIRS = ("pcmodels-{region}", "pccg-{region}") def die(msg): @@ -95,9 +100,11 @@ def main(): if not VENDOR.is_dir(): die(f"missing {VENDOR} -- this script must stay inside the 'prepare-assets' folder from the zip.") - for script, _ in EMIT_STEPS: + for script, _extra, _produces in EMIT_STEPS: if not (HERE / script).is_file(): die(f"missing {HERE / script}") + if not (HERE / "d88_propdefs.py").is_file(): + die(f"missing {HERE / 'd88_propdefs.py'} (imported by d88_emit.py)") bundle_root = Path(args.out).expanduser().resolve() if args.out else HERE.parent @@ -118,23 +125,28 @@ def main(): if not (staged_rom.exists() and staged_rom.stat().st_size == rom.stat().st_size): shutil.copy2(rom, staged_rom) - out_dirs = [] - for script, tag in EMIT_STEPS: - src = work / "data" / f"{tag}-{region}" - if src.exists(): - shutil.rmtree(src) - print(f"prepare-assets: running {script} {region} ...") - r = subprocess.run([sys.executable, str(HERE / script), region], cwd=work) + # Fresh start so a re-run is deterministic (d69 recreates its dir; d88 + # --regen edits it in place). + for d in OUTPUT_DIRS: + p = work / "data" / d.format(region=region) + if p.exists(): + shutil.rmtree(p) + + for script, extra, produces in EMIT_STEPS: + argv = [sys.executable, str(HERE / script), region, *extra] + print(f"prepare-assets: running {' '.join(argv[1:])} ...") + r = subprocess.run(argv, cwd=work) if r.returncode != 0: die(f"{script} exited {r.returncode}") - if not (src / f"{tag}.bin").is_file(): - die(f"{script} did not produce {src / (tag + '.bin')}") - out_dirs.append((src, tag)) + want = work / "data" / produces.format(region=region) + if not want.is_file(): + die(f"{script} did not produce {want}") dest_data = bundle_root / "data" dest_data.mkdir(parents=True, exist_ok=True) - for src, tag in out_dirs: - dest = dest_data / f"{tag}-{region}" + for d in OUTPUT_DIRS: + src = work / "data" / d.format(region=region) + dest = dest_data / d.format(region=region) if dest.resolve() == src.resolve(): continue if dest.exists(): From dae14c674c30947e2ed79927dacffbfef1cd1022 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:00:00 -0400 Subject: [PATCH 07/10] =?UTF-8?q?docs(D185):=20resolved=20=E2=80=94=20miss?= =?UTF-8?q?ing=20d88=20step,=20not=20a=20d69=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/findings.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/dev/findings.md b/docs/dev/findings.md index a10f4fe2..5d7adfbd 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -5056,3 +5056,21 @@ run's — find which bg/stan entries d69 now drops, trace through `find_row` → `file_resource_table.inc.c` → `filelist.u.csv`. Fix so a fresh run reproduces 3604378 B / 2421-B manifest; re-verify `-level_09`/`-20` crash-free. Do NOT regenerate `data/pccg-ntsc-final/` before the fix (it holds the working copy). + +### D185 — RESOLVED (M-38): not a d69 regression, a missing d88 step + +`d88_emit.py` **appends** the 21 `Usetup*Z` per-level stage-setup files +(object placement, AI opcode streams, pads/nav) to the same +`data/pccg-/pccg.bin` + `manifest.csv` that `d69_emit.py` writes +(52 bg/stan rows → 74 after d88). The M-38 `prepare-assets.py` first shipped +ran only d43 + d69, so every level loaded with no setup data → instant +segfault. The "working" pre-migration sidecar was simply a complete +d43+d69+d88 output; nothing regressed in d69 or its inputs. + +Fix (commit `4491db3f`): `prepare-assets.py` runs d43 → d69 → `d88 --regen`; +`bundle-win.sh` vendors `d88_emit.py` + its only local import +`d88_propdefs.py`. Verified: fresh 3-script run from an assembled bundle is +byte-identical to the known-good `pccg.bin` (3604378 B) / `pcmodels.bin`; +`-level_09` + `-level_20` boot crash-free. The CRLF→LF observation on +`filelist.u.csv` / `file_resource_table.inc.c` is real but a red herring for +this crash (d69 tolerates it; output unchanged). From 74f5b419b11fa97f6494e3d8ba4bb57d2a688d56 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:00:55 -0400 Subject: [PATCH 08/10] docs(building): add the missing d88_emit.py --regen sidecar step building.md listed only d43 + d69; d88 (per-level Usetup*Z stage setup, appended to the pccg dir) was omitted, so a source build per these instructions produced a game that crashed on first level load. --- docs/building.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/building.md b/docs/building.md index 9531f722..96b42c23 100644 --- a/docs/building.md +++ b/docs/building.md @@ -102,10 +102,12 @@ builds are named `ge007.pal-final.x86_64` / `ge007.jpn-final.x86_64`. ## 4. Generate the PC asset sidecars (required to run) -The port does **not** read model geometry or stage bg/stan data from the raw -ROM at runtime — it reads them from PC-layout *sidecar* files under `data/`, -produced offline by two converters. **Without them the game shows the intro -logos and then crashes** in `modelPromoteNodeOffsetsToPointers` (finding D179). +The port does **not** read model geometry, stage bg/stan data, or per-level +setup data from the raw ROM at runtime — it reads them from PC-layout *sidecar* +files under `data/`, produced offline by three converters. **Without them the +game shows the intro logos and then crashes** in +`modelPromoteNodeOffsetsToPointers` (finding D179) or on the first level load +(missing stage setup). Put your ROM in `data/` first (same file the game runs from): @@ -114,11 +116,13 @@ mkdir -p data cp /path/to/your/rom.z64 data/ge007.ntsc-final.z64 # or pal-final / jpn-final ``` -Then run both emit passes for that region: +Then run all three emit passes for that region, **in this order** (d88 appends +to d69's output): ```sh -python3 tools_pc/d43_emit.py ntsc-final # -> data/pcmodels-ntsc-final/{pcmodels.bin,manifest.csv} (~1.3 MB) -python3 tools_pc/d69_emit.py ntsc-final # -> data/pccg-ntsc-final/{pccg.bin,manifest.csv} (~3.6 MB) +python3 tools_pc/d43_emit.py ntsc-final # -> data/pcmodels-ntsc-final/{pcmodels.bin,manifest.csv} (~1.3 MB) +python3 tools_pc/d69_emit.py ntsc-final # -> data/pccg-ntsc-final/{pccg.bin,manifest.csv} (bg + stan) +python3 tools_pc/d88_emit.py ntsc-final --regen # appends the 21 per-level Usetup*Z stage-setup files -> ~3.6 MB ``` These are **pure-stdlib Python 3** (no MIPS toolchain, independent of the @@ -126,7 +130,11 @@ step-2 asset extraction) and read only the ROM plus files already committed to the repo (`scripts/filelist.u.csv`, `assets/obseg/file_resource_table.inc.c`, `assets/**/ModelFileHeader.inc.c`, the bg/stan `.inc.c`). Output is a deterministic function of the ROM. Re-run after any change to `d43_emit.py` / -`d69_emit.py` or the model/bg converters (`d43_*`, `d69_*`). +`d69_emit.py` / `d88_emit.py` or the model/bg converters (`d43_*`, `d69_*`, +`d88_propdefs.py`). + +> The release bundle ships `prepare-assets/prepare-assets.py`, which runs all +> three passes against your ROM automatically — see the bundled `README.md`. > `data/pcmodels-*/` and `data/pccg-*/` are gitignored ROM-derived game data — > never commit or redistribute them. From 76fb7b7a25a5a9caac0516b1b9b5d6c2a2385206 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:54:21 -0400 Subject: [PATCH 09/10] =?UTF-8?q?port:=20D184=20F10=20overlay=20v2=20?= =?UTF-8?q?=E2=80=94=20mouse=20control=20+=20resolution/fullscreen=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix invisible text: drawText passed the measured text w/h into textRender's width/height args, which are the on-screen CLIP rect (not the text size), so every glyph failed the clip test. Pass the full 2D viewport like bondview2.c's debug-text path. - Mouse UI: hover-to-highlight; left-click in the value column toggles/cycles; drag sliders; red X close box (clicking a row's label only focuses it, so selecting can't flip a toggle). inputSuspendForOverlay() forces the OS cursor free + visible while the overlay owns the mouse. - New rows: Fullscreen (live) and Resolution (windowed presets filtered to the desktop, live resize + recenter). SDL window ops are posted from the scheduler thread and drained host-side in videoPumpEvents. - Layout recomputed for the wide BankGothic font: right-aligned values, wider row pitch, all 13 rows fit the ~240-unit 2D space; nothing clips. Port-layer only. Links clean; runtime-verified on a real display (front end + -level_09): layout, hover/click/drag, live VSync/filter/fullscreen/resolution. --- port/include/input.h | 4 + port/include/video.h | 10 ++ port/src/input.c | 16 ++ port/src/optionsoverlay.c | 324 ++++++++++++++++++++++++++++++-------- port/src/video.c | 84 ++++++++++ 5 files changed, 371 insertions(+), 67 deletions(-) diff --git a/port/include/input.h b/port/include/input.h index ea57e382..693b0ccc 100644 --- a/port/include/input.h +++ b/port/include/input.h @@ -54,6 +54,10 @@ void inputNotifyClick(void); int inputReleaseCapture(void); int inputMouseCaptureActive(void); +/* F10 options overlay: force the OS cursor free + visible while the overlay + * owns the mouse. Safe to call every poll. */ +void inputSuspendForOverlay(void); + /* Queue a mouse-wheel weapon-cycle input (one short A-button press). Sign is * ignored -- GE only cycles forward on a bare A edge. */ void inputPostWheel(int notches); diff --git a/port/include/video.h b/port/include/video.h index a8c37fd3..d9bbe152 100644 --- a/port/include/video.h +++ b/port/include/video.h @@ -52,6 +52,16 @@ float videoGetFPS(void); * the next frame start. Called by the F10 options overlay after an edit. */ void videoRequestLiveConfig(void); +/* F10 options overlay -> window/fullscreen changes. The overlay input handler + * runs on the scheduler thread; SDL window ops must run on the thread that + * created the window, so these only post a request that videoPumpEvents() + * (host thread) applies. The Get* helpers are read-only and thread-safe. */ +void videoRequestWindowSize(int w, int h); +void videoRequestFullscreen(int on); +void videoGetWindowSize(int *w, int *h); +void videoGetDesktopSize(int *w, int *h); +int videoIsFullscreen(void); + /* Snapshot live window geometry into the config vars (call before configSave * on a clean exit). No-op if the window isn't up. */ void videoSaveWindowState(void); diff --git a/port/src/input.c b/port/src/input.c index 42acbfd4..122b0161 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -878,6 +878,22 @@ int inputReleaseCapture(void) int inputMouseCaptureActive(void) { return mouseCaptureMode && !mouseGrabbed; } +/* F10 options overlay: while it owns controller 0 the inputComputePad poll + * early-returns before reconcileGrab()/applyCursorVisibility(), so whatever + * grab state was live when F10 was pressed (relative mode + hidden cursor in a + * stage) would persist and the mouse UI would be unusable. Force the cursor + * free + visible every poll; the normal reconcile resumes once the overlay + * closes and the early-return no longer fires. */ +void inputSuspendForOverlay(void) +{ + if (mouseGrabbed) { + mouseGrabbed = 0; + SDL_SetRelativeMouseMode(SDL_FALSE); + mouseDX = mouseDY = 0.0; + } + SDL_ShowCursor(SDL_ENABLE); +} + void inputPostWheel(int notches) { if (notches < 0) notches = -notches; /* both directions cycle forward */ diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 6f987c8a..a195f1a6 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -28,6 +28,7 @@ #include "system.h" #include "config.h" #include "video.h" +#include "input.h" #include "optionsoverlay.h" /* ---- game symbols (rendering/UI only; see input.c for the same pattern) ---- */ @@ -46,13 +47,27 @@ extern s16 viGetY(void); /* ------------------------------------------------------------------------ */ -enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA }; +enum { ROW_TOGGLE, ROW_SLIDER, ROW_ENUM, ROW_MSAA, ROW_RES }; static const char *const kOnOff[] = { "OFF", "ON", NULL }; static const char *const kTexFilter[] = { "NEAREST", "BILINEAR", "3-POINT", NULL }; static const char *const kCapture[] = { "ALWAYS GRAB", "CLICK-TO-LOCK", NULL }; static const int kMsaaSeq[] = { 1, 2, 4, 8 }; +/* Windowed-mode resolution presets. Filtered at init to those that fit the + * desktop; the Resolution row cycles the surviving list. */ +static const int kResList[][2] = { + { 640, 480 }, { 800, 600 }, { 960, 720 }, { 1024, 768 }, + { 1152, 864 }, { 1280, 720 }, { 1280, 800 }, { 1280, 960 }, + { 1366, 768 }, { 1440, 900 }, { 1600, 900 }, { 1600, 1200 }, + { 1680, 1050 }, { 1920, 1080 }, { 1920, 1200 }, { 2560, 1440 }, + { 3200, 1800 }, { 3840, 2160 }, +}; +#define NUM_RES ((int)(sizeof(kResList) / sizeof(kResList[0]))) +static int s_resFit[NUM_RES]; /* indices into kResList that fit the desktop */ +static int s_resFitN = 0; +static int s_resSel = 0; /* index into s_resFit */ + struct Row { const char *key; const char *label; @@ -70,6 +85,8 @@ struct Row { }; static struct Row rows[] = { + { "Video.Fullscreen", "Fullscreen", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, + { "__Resolution", "Resolution", ROW_RES, 0, NULL, 0, 0, 0, 0,0,0,0,0 }, { "Video.VSync", "VSync", ROW_TOGGLE, 1, kOnOff, 0, 0, 0, 0,0,0,0,0 }, { "Video.FpsCap", "Frame cap", ROW_SLIDER, 10, NULL, 0, 0, 360, 0,0,0,0,0 }, { "Video.MSAA", "MSAA", ROW_MSAA, 0, NULL, 1, 0, 0, 0,0,0,0,0 }, @@ -86,6 +103,53 @@ static int s_inited = 0; static volatile int s_open = 0; static int s_sel = 0; +/* Layout (game 2D pixel space = viGetX() x viGetY(), ~320x240). Shared by the + * emit path and the mouse hit-testing in optionsOverlayHandleInput(). + * BankGothic caps are ~9 units tall here, so rows need ~16 units of pitch and + * values are right-aligned to the panel edge to survive the wide font. */ +#define OV_X0 20 +#define OV_LABEL_X 28 +#define OV_TOP 12 +#define OV_LINE 15 /* row pitch (13 rows must fit ~240) */ +#define OV_HDR 2 /* header rows above row 0 (title+hint) */ +#define OV_ROW_Y(i) (OV_TOP + ((i) + OV_HDR) * OV_LINE) +#define OV_RIGHT (viGetX() - OV_X0) /* right edge for right-aligned text */ +#define OV_NUM_W 36 /* reserved width for a slider's number */ +#define OV_BAR_X 150 + +/* Slider fill bar span in overlay space (shared by emit + hit-testing). */ +static void sliderBarSpan(s32 *x0, s32 *x1) +{ + *x0 = OV_BAR_X; + *x1 = OV_RIGHT - OV_NUM_W; + if (*x1 < *x0 + 16) { + *x1 = *x0 + 16; + } +} + +/* Row index the given overlay-space y falls in, or -1. */ +static int overlayRowAtY(double oy) +{ + for (int i = 0; i < NUM_ROWS; i++) { + double top = OV_ROW_Y(i) - 3; + if (oy >= top && oy < top + OV_LINE) { + return i; + } + } + return -1; +} + +/* The close box brackets the title row at the panel's right edge. */ +#define OV_CB_X0 (OV_RIGHT - 14) +#define OV_CB_X1 (OV_RIGHT + 7) +#define OV_CB_Y0 (OV_TOP - 3) +#define OV_CB_Y1 (OV_TOP + 12) +static int overlayInCloseBox(double ox, double oy) +{ + return ox >= OV_CB_X0 && ox <= OV_CB_X1 && + oy >= OV_CB_Y0 && oy <= OV_CB_Y1; +} + /* ------------------------------------------------------------------------ */ static void resolveCb(const char *key, int type, void *ptr, double min, double max, @@ -120,12 +184,41 @@ static void overlayInit(void) configForEachOption(resolveCb, NULL); for (int i = 0; i < NUM_ROWS; i++) { + if (rows[i].kind == ROW_RES) { + rows[i].found = 1; /* not config-backed; driven via video.c */ + continue; + } if (!rows[i].found) { sysLogPrintf(LOG_WARNING, "optionsoverlay: option '%s' not registered", rows[i].key); } } + /* Build the windowed-resolution preset list: presets that fit the desktop, + * plus the current window size snapped to the nearest surviving entry. */ + { + int dw = 1920, dh = 1080; + videoGetDesktopSize(&dw, &dh); + s_resFitN = 0; + for (int i = 0; i < NUM_RES; i++) { + if (kResList[i][0] <= dw && kResList[i][1] <= dh) { + s_resFit[s_resFitN++] = i; + } + } + if (s_resFitN == 0) { + s_resFit[s_resFitN++] = 0; + } + int cw = 0, ch = 0; + videoGetWindowSize(&cw, &ch); + long best = -1; + for (int k = 0; k < s_resFitN; k++) { + int i = s_resFit[k]; + long d = labs((long)kResList[i][0] - cw) + + labs((long)kResList[i][1] - ch); + if (best < 0 || d < best) { best = d; s_resSel = k; } + } + } + const char *e = getenv("GE_OPTIONSOVERLAY"); if (e && atoi(e) != 0) { s_open = 1; @@ -171,7 +264,9 @@ static void rowSet(struct Row *r, double v) /* Live-apply the video knobs that need a fast3d/SDL call. Everything else * is read straight off the pointer by its owner every frame/poll. */ - if (strncmp(r->key, "Video.", 6) == 0 && !r->restart) { + if (strcmp(r->key, "Video.Fullscreen") == 0) { + videoRequestFullscreen((int)lround(v)); + } else if (strncmp(r->key, "Video.", 6) == 0 && !r->restart) { videoRequestLiveConfig(); } } @@ -205,6 +300,17 @@ static void rowAdjust(struct Row *r, int dir) rowSet(r, v); break; } + case ROW_RES: { + if (s_resFitN <= 0 || videoIsFullscreen()) { + break; /* resolution is windowed-only */ + } + s_resSel += (dir >= 0) ? 1 : -1; + if (s_resSel < 0) s_resSel = s_resFitN - 1; + if (s_resSel >= s_resFitN) s_resSel = 0; + int i = s_resFit[s_resSel]; + videoRequestWindowSize(kResList[i][0], kResList[i][1]); + break; + } default: /* ROW_SLIDER */ rowSet(r, v + dir * r->step); break; @@ -241,46 +347,106 @@ void optionsOverlayScroll(int dir) if (s_sel >= NUM_ROWS) s_sel = 0; } +/* Set a slider row from an overlay-space x inside its value bar, snapped to + * the row's step. */ +static void sliderSetFromX(struct Row *r, double ox) +{ + double lo = rowLo(r), hi = rowHi(r); + if (hi <= lo) { + return; + } + s32 bx0, bx1; + sliderBarSpan(&bx0, &bx1); + double f = (ox - bx0) / (double)(bx1 - bx0); + if (f < 0) f = 0; + if (f > 1) f = 1; + double v = lo + f * (hi - lo); + double step = (r->step > 0.0) ? r->step : 1.0; + v = lround(v / step) * step; + rowSet(r, v); +} + void optionsOverlayHandleInput(void) { - static int prev[6]; /* UP DOWN LEFT RIGHT RETURN MOUSE */ + static int prevUp, prevDn, prevLf, prevRt, prevLmb, prevRmb; + static int dragRow = -1; + if (!s_open) { - memset(prev, 0, sizeof(prev)); + prevUp = prevDn = prevLf = prevRt = prevLmb = prevRmb = 0; + dragRow = -1; return; } - const Uint8 *ks = SDL_GetKeyboardState(NULL); - Uint32 mb = SDL_GetMouseState(NULL, NULL); + /* The overlay owns the mouse while it is open: force the OS cursor free + + * visible (a stage poll would otherwise leave it locked/hidden). */ + inputSuspendForOverlay(); - int cur[6]; - cur[0] = ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8]; - cur[1] = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2]; - cur[2] = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4]; - cur[3] = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6] - || ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]; - cur[4] = 0; /* reserved */ - cur[5] = (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) ? 1 : 0; + const Uint8 *ks = SDL_GetKeyboardState(NULL); + int mx = 0, my = 0; + Uint32 mb = SDL_GetMouseState(&mx, &my); + int lmb = (mb & SDL_BUTTON(SDL_BUTTON_LEFT)) ? 1 : 0; int rmb = (mb & SDL_BUTTON(SDL_BUTTON_RIGHT)) ? 1 : 0; - if (cur[0] && !prev[0]) { - s_sel = (s_sel + NUM_ROWS - 1) % NUM_ROWS; - } - if (cur[1] && !prev[1]) { - s_sel = (s_sel + 1) % NUM_ROWS; - } - if (cur[2] && !prev[2]) { - rowAdjust(&rows[s_sel], -1); - } - if ((cur[3] && !prev[3]) || (cur[5] && !prev[5])) { - rowAdjust(&rows[s_sel], +1); - } - static int prevRmb = 0; - if (rmb && !prevRmb) { - rowAdjust(&rows[s_sel], -1); - } - prevRmb = rmb; + int up = ks[SDL_SCANCODE_UP] || ks[SDL_SCANCODE_KP_8]; + int dn = ks[SDL_SCANCODE_DOWN] || ks[SDL_SCANCODE_KP_2]; + int lf = ks[SDL_SCANCODE_LEFT] || ks[SDL_SCANCODE_KP_4]; + int rt = ks[SDL_SCANCODE_RIGHT] || ks[SDL_SCANCODE_KP_6] + || ks[SDL_SCANCODE_RETURN] || ks[SDL_SCANCODE_KP_ENTER]; - memcpy(prev, cur, sizeof(prev)); + /* ---- keyboard / D-pad nav ---- */ + if (up && !prevUp) s_sel = (s_sel + NUM_ROWS - 1) % NUM_ROWS; + if (dn && !prevDn) s_sel = (s_sel + 1) % NUM_ROWS; + if (lf && !prevLf) rowAdjust(&rows[s_sel], -1); + if (rt && !prevRt) rowAdjust(&rows[s_sel], +1); + + /* ---- mouse ---- */ + int ww = 0, wh = 0; + videoGetWindowSize(&ww, &wh); + if (ww > 0 && wh > 0) { + double ox = (double)mx * (double)viGetX() / ww; + double oy = (double)my * (double)viGetY() / wh; + int hoverRow = overlayRowAtY(oy); + int onClose = overlayInCloseBox(ox, oy); + + if (hoverRow >= 0 && !onClose) { + s_sel = hoverRow; /* hover-to-highlight */ + } + + /* left press. A click in the label column only focuses the row; a + * click in the value/control column (>= the bar-span start) changes + * it -- so clicking to select a toggle doesn't also flip it. */ + s32 bx0, bx1; + sliderBarSpan(&bx0, &bx1); + if (lmb && !prevLmb) { + if (onClose) { + optionsOverlayToggle(); /* close + configSave */ + return; + } + if (hoverRow >= 0 && ox >= bx0) { + struct Row *r = &rows[hoverRow]; + if (r->kind == ROW_SLIDER && r->found) { + sliderSetFromX(r, ox); + dragRow = hoverRow; + } else { + rowAdjust(r, +1); /* toggle / cycle forward */ + } + } + } + /* drag a slider */ + if (lmb && dragRow >= 0 && rows[dragRow].kind == ROW_SLIDER) { + sliderSetFromX(&rows[dragRow], ox); + } + if (!lmb) { + dragRow = -1; + } + /* right press in the value column: cycle back / decrement */ + if (rmb && !prevRmb && hoverRow >= 0 && !onClose && ox >= bx0) { + rowAdjust(&rows[hoverRow], -1); + } + } + + prevUp = up; prevDn = dn; prevLf = lf; prevRt = rt; + prevLmb = lmb; prevRmb = rmb; } /* ------------------------------------------------------------------------ */ @@ -288,16 +454,6 @@ void optionsOverlayHandleInput(void) #define OV_BUF_CMDS 8192 static Gfx s_buf[OV_BUF_CMDS]; -/* Layout (game 2D pixel space = viGetX() x viGetY(), ~320x240). */ -#define OV_X0 26 -#define OV_LABEL_X 34 -#define OV_VALUE_X 176 -#define OV_BAR_X 176 -#define OV_BAR_W 84 -#define OV_NUM_X 266 -#define OV_TOP 24 -#define OV_LINE 14 - static Gfx *fillRect(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, u8 r, u8 g, u8 b, u8 a) { @@ -311,6 +467,17 @@ static Gfx *fillRect(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, static void valueText(const struct Row *r, char *out, int n) { double v = rowGet(r); + if (r->kind == ROW_RES) { + if (videoIsFullscreen()) { + snprintf(out, n, "(fullscreen)"); + } else if (s_resFitN <= 0) { + snprintf(out, n, "n/a"); + } else { + int i = s_resFit[s_resSel]; + snprintf(out, n, "%d x %d", kResList[i][0], kResList[i][1]); + } + return; + } if ((r->kind == ROW_TOGGLE || r->kind == ROW_ENUM) && r->names) { int idx = (int)lround(v); int cnt = 0; @@ -331,7 +498,7 @@ static void valueText(const struct Row *r, char *out, int n) } if (r->kind == ROW_SLIDER && (int)lround(v) == 0 && strcmp(r->key, "Video.FpsCap") == 0) { - snprintf(out, n, "UNCAPPED"); + snprintf(out, n, "OFF"); return; } snprintf(out, n, "%d", (int)lround(v)); @@ -339,11 +506,27 @@ static void valueText(const struct Row *r, char *out, int n) static Gfx *drawText(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour) { - s32 w = 0, h = 0; s32 px = x, py = y; - textMeasure(&h, &w, (char *)str, ptrFontBankGothicChars, ptrFontBankGothic, 10); + /* width/height are the on-screen CLIP rect textRenderGlyph tests against + * (clipX=start x, clipY=start y, +clipWidth/+clipHeight), NOT the text's + * own measured size -- passing the measured w/h clipped every glyph out + * (baseline+height > measured h => nothing drawn). Match the game: pass + * the full 2D viewport, like bondview2.c's debug-text path. */ return textRender(gdl, &px, &py, (char *)str, ptrFontBankGothicChars, - ptrFontBankGothic, colour, w, h, 0, 10); + ptrFontBankGothic, colour, viGetX(), viGetY(), 0, 0); +} + +static s32 measureText(const char *str) +{ + s32 h = 0, w = 0; + textMeasure(&h, &w, (char *)str, ptrFontBankGothicChars, ptrFontBankGothic, 0); + return w; +} + +/* Right-aligned: the string ends at xr. */ +static Gfx *drawTextR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 colour) +{ + return drawText(gdl, xr - measureText(str), y, str, colour); } Gfx *optionsOverlayEmit(void) @@ -357,7 +540,11 @@ Gfx *optionsOverlayEmit(void) const s32 W = viGetX(); const s32 H = viGetY(); - const s32 panelBottom = OV_TOP + (NUM_ROWS + 3) * OV_LINE; + const s32 right = OV_RIGHT; + const s32 panelTop = OV_TOP - 9; + const s32 panelBottom = OV_ROW_Y(NUM_ROWS - 1) + OV_LINE / 2 + 3; + s32 bx0, bx1; + sliderBarSpan(&bx0, &bx1); Gfx *gdl = s_buf; gDPPipeSync(gdl++); @@ -367,23 +554,24 @@ Gfx *optionsOverlayEmit(void) /* ---- pass 1: all fills (G_CC_PRIMITIVE) ---- */ gdl = fillRect(gdl, 0, 0, W, H, 0, 0, 0, 150); /* dim */ - gdl = fillRect(gdl, OV_X0 - 6, OV_TOP - 8, W - (OV_X0 - 6), panelBottom, - 8, 10, 24, 205); /* panel */ + gdl = fillRect(gdl, OV_X0 - 8, panelTop, W - (OV_X0 - 8), panelBottom, + 8, 10, 24, 210); /* panel */ + gdl = fillRect(gdl, OV_CB_X0, OV_CB_Y0, OV_CB_X1, OV_CB_Y1, + 150, 40, 40, 235); /* close */ for (int i = 0; i < NUM_ROWS; i++) { - s32 rowY = OV_TOP + (i + 2) * OV_LINE; + s32 rowY = OV_ROW_Y(i); if (i == s_sel) { - gdl = fillRect(gdl, OV_X0 - 3, rowY - 2, W - (OV_X0 - 3), rowY + OV_LINE - 3, - 40, 46, 96, 220); + gdl = fillRect(gdl, OV_X0 - 4, rowY - 3, W - (OV_X0 - 4), + rowY + OV_LINE - 4, 40, 46, 96, 220); } if (rows[i].kind == ROW_SLIDER && rows[i].found) { double lo = rowLo(&rows[i]), hi = rowHi(&rows[i]); double f = (hi > lo) ? (rowGet(&rows[i]) - lo) / (hi - lo) : 0.0; if (f < 0) f = 0; if (f > 1) f = 1; - s32 by = rowY + 2; - gdl = fillRect(gdl, OV_BAR_X, by, OV_BAR_X + OV_BAR_W, by + 6, - 60, 60, 70, 220); - gdl = fillRect(gdl, OV_BAR_X, by, OV_BAR_X + (s32)(OV_BAR_W * f), by + 6, + s32 by = rowY + 3; + gdl = fillRect(gdl, bx0, by, bx1, by + 5, 60, 60, 70, 220); + gdl = fillRect(gdl, bx0, by, bx0 + (s32)((bx1 - bx0) * f), by + 5, 210, 200, 90, 255); } } @@ -392,28 +580,30 @@ Gfx *optionsOverlayEmit(void) gdl = microcode_constructor(gdl); gdl = drawText(gdl, OV_X0, OV_TOP, "PC OPTIONS", 0xffe040ff); - gdl = drawText(gdl, OV_X0 + 96, OV_TOP, "F10 close arrows adjust", 0x9090a0ff); + gdl = drawText(gdl, OV_X0, OV_TOP + OV_LINE, "click value / drag / arrows", + 0x8890a0ff); /* hint line */ + gdl = drawText(gdl, (OV_CB_X0 + OV_CB_X1) / 2 - measureText("X") / 2, + OV_TOP, "X", 0xffffffff); /* close glyph */ for (int i = 0; i < NUM_ROWS; i++) { - s32 rowY = OV_TOP + (i + 2) * OV_LINE; + s32 rowY = OV_ROW_Y(i); u32 col = (i == s_sel) ? 0xffffffff : 0xc0c0c8ff; char val[48]; + gdl = drawText(gdl, OV_LABEL_X, rowY, (char *)rows[i].label, + rows[i].found ? col : 0x808080ff); if (!rows[i].found) { - gdl = drawText(gdl, OV_LABEL_X, rowY, (char *)rows[i].label, 0x808080ff); - gdl = drawText(gdl, OV_VALUE_X, rowY, "(n/a)", 0x808080ff); + gdl = drawTextR(gdl, right, rowY, "(n/a)", 0x808080ff); continue; } - gdl = drawText(gdl, OV_LABEL_X, rowY, (char *)rows[i].label, col); valueText(&rows[i], val, sizeof(val)); - if (rows[i].kind == ROW_SLIDER) { - gdl = drawText(gdl, OV_NUM_X, rowY, val, col); - } else { - gdl = drawText(gdl, OV_VALUE_X, rowY, val, col); - } if (rows[i].restart) { - gdl = drawText(gdl, OV_VALUE_X + 46, rowY, "(restart)", 0x909090ff); + /* value left of the bar span, "(restart)" pinned to the edge */ + gdl = drawText(gdl, bx0, rowY, val, col); + gdl = drawTextR(gdl, right, rowY, "(restart)", 0x909090ff); + } else { + gdl = drawTextR(gdl, right, rowY, val, col); } } diff --git a/port/src/video.c b/port/src/video.c index 48afc02b..d4716fb3 100644 --- a/port/src/video.c +++ b/port/src/video.c @@ -122,6 +122,85 @@ void videoRequestLiveConfig(void) liveCfgDirty = 1; } +/* --- F10 overlay: window / fullscreen changes, deferred to the host thread --- + * optionsOverlayHandleInput() runs on the scheduler thread; SDL_SetWindowSize / + * SDL_SetWindowFullscreen pump the Win32 message loop and must run on the + * window's creating thread. The overlay posts a request here; the host-thread + * event pump drains it in videoDrainWindowRequests(). */ +static volatile int winReqKind = 0; /* 0 none, 1 resize, 2 fullscreen */ +static volatile int winReqA = 0, winReqB = 0; + +void videoRequestWindowSize(int w, int h) +{ + winReqA = w; winReqB = h; winReqKind = 1; +} + +void videoRequestFullscreen(int on) +{ + winReqA = on ? 1 : 0; winReqKind = 2; +} + +void videoGetWindowSize(int *w, int *h) +{ + uint32_t ww = 0, hh = 0; int32_t x = 0, y = 0; + if (initDone && wmAPI && wmAPI->get_dimensions) { + wmAPI->get_dimensions(&ww, &hh, &x, &y); + } + if (w) *w = (int)ww; + if (h) *h = (int)hh; +} + +void videoGetDesktopSize(int *w, int *h) +{ + SDL_DisplayMode m; + memset(&m, 0, sizeof(m)); + if (SDL_GetDesktopDisplayMode(0, &m) != 0 || m.w <= 0 || m.h <= 0) { + m.w = 1920; m.h = 1080; + } + if (w) *w = m.w; + if (h) *h = m.h; +} + +int videoIsFullscreen(void) +{ + return (initDone && wmAPI && wmAPI->get_fullscreen_state) + ? (wmAPI->get_fullscreen_state() ? 1 : 0) : 0; +} + +static void videoDrainWindowRequests(void) +{ + int kind = winReqKind; + if (!kind || !wmAPI) { + winReqKind = 0; + return; + } + winReqKind = 0; + + if (kind == 1) { + int w = winReqA, h = winReqB; + int32_t px = 100, py = 100; + if (wmAPI->get_fullscreen_state && wmAPI->get_fullscreen_state()) { + if (wmAPI->set_fullscreen) wmAPI->set_fullscreen(false); + cfgFullscreen = 0; + } + if (wmAPI->get_centered_positions) { + wmAPI->get_centered_positions(w, h, &px, &py); + } + if (wmAPI->set_dimensions) { + wmAPI->set_dimensions((uint32_t)w, (uint32_t)h, px, py); + } + gfx_sdl_update_cached_size(); + cfgWinW = w; cfgWinH = h; + sysLogPrintf(LOG_INFO, "video: window -> %dx%d", w, h); + } else if (kind == 2) { + int on = winReqA; + if (wmAPI->set_fullscreen) wmAPI->set_fullscreen(on != 0); + gfx_sdl_update_cached_size(); + cfgFullscreen = on ? 1 : 0; + sysLogPrintf(LOG_INFO, "video: fullscreen %s", on ? "on" : "off"); + } +} + static u32 frames = 0; /* Set by the host event pump (F12), consumed on the render thread in * videoEndFrame where a GL context is current. */ @@ -247,6 +326,11 @@ void videoPumpEvents(void) if (!initDone) { return; } + + /* Apply any window/fullscreen change the F10 overlay posted from the + * scheduler thread (must run here, on the window's creating thread). */ + videoDrainWindowRequests(); + SDL_Event ev; while (SDL_PollEvent(&ev)) { switch (ev.type) { From 203ceaf717d98ddcec86e9a1df1aef2ac2fadeae Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:59:50 -0400 Subject: [PATCH 10/10] port: fix linux-build link error in optionsoverlay (_SHIFTL) gbi.h's gDP* DL macros use _SHIFTL/_SHIFTR without defining them; game TUs pull them in via . This is the first port/src TU to build a display list, and on GCC/ld the missing macro became an undefined symbol (MinGW's include chain happens to provide it). Define the two pure macros locally, verbatim from PR/mbi.h -- including mbi.h itself drags in N64 OS headers that shadow libc. --- port/src/optionsoverlay.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index a195f1a6..018412dc 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -22,6 +22,15 @@ #include #include +/* gbi.h's gDP* DL macros use _SHIFTL/_SHIFTR but do not define them -- game TUs + * get them from /, which also drags in N64 OS headers that + * shadow libc here. Define the two pure macros locally (verbatim from mbi.h) so + * this stays a plain port TU. Without them GCC/ld fails "undefined reference to + * _SHIFTL" (MinGW's chain happens to provide it). */ +#ifndef _SHIFTL +#define _SHIFTL(v, s, w) ((u32)(((u32)(v) & ((0x01 << (w)) - 1)) << (s))) +#define _SHIFTR(v, s, w) ((u32)(((u32)(v) >> (s)) & ((0x01 << (w)) - 1))) +#endif #include #include "platform.h"