From fd82dda7177ebe7ac2b98c3959ab3c6e6a6e7c19 Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:38:16 -0400 Subject: [PATCH] D343 M2: GE-styled options panel on file select; D344 docs The file-select Options label opens a panel drawn in the same hook: sections as tabs, rows from the F10 overlay's table via an exported row API, the game's crosshair as pointer (input.c passes the stick, hands the buttons to the panel). Quit to desktop now goes through videoRequestQuit. Also repairs two escaped-newline typos in the D343 docs. --- docs/dev/findings-index.csv | 1 + docs/dev/findings.md | 36 ++- docs/porting-notes.md | 9 +- port/include/frontoptions.h | 30 ++ port/include/optionsoverlay.h | 21 +- port/src/frontoptions.c | 548 ++++++++++++++++++++++++++++++++++ port/src/input.c | 15 +- port/src/optionsoverlay.c | 156 +++++----- src/game/front.c | 4 +- 9 files changed, 728 insertions(+), 92 deletions(-) create mode 100644 port/include/frontoptions.h create mode 100644 port/src/frontoptions.c diff --git a/docs/dev/findings-index.csv b/docs/dev/findings-index.csv index b995ec44..425ecc8c 100644 --- a/docs/dev/findings-index.csv +++ b/docs/dev/findings-index.csv @@ -221,3 +221,4 @@ D339,OPEN,"#92 (low-end ~30 fps, Celeron N3060 / HD 400) —","OPEN (2026-09-26, D341,FIXED,"Intro gun-barrel blood drip draws as static blocks/lines instead of dripping (user, v0.4.0, 2026-09-26; death blood fine).","FIXED (M-201, user-verified 2026-09-27):" D342,FIXED,"File select: folder bodies and Bond photos vanish after selecting a file and backing out (user, v0.4.0, 2026-09-26).","FIXED (M-201, user-verified):" D343,PARTIAL,"File select: PC ""Options"" label opens the settings overlay (v0.4.0 feature, M1 of the route-B options screen, 2026-09-27); also fixes F10 on file select jumpin…","LANDED (M1): one Rule-2 #ifdef PORT draw hook (user sign-off 2026-09-27); headless + user-verified (mouse, F10, >30 s, widescreen); physical controller check owed." +D344,OPEN,Host PC bugchecks (BSOD 0x119 VIDEO_SCHEDULER_INTERNAL_ERROR) during game test runs; probable cause:,FIX WRITTEN + BUILT (orderly quit + GE_QUITFRAME); runtime verification pending the user's OK (runs paused). diff --git a/docs/dev/findings.md b/docs/dev/findings.md index 52a591c2..5942ccb0 100644 --- a/docs/dev/findings.md +++ b/docs/dev/findings.md @@ -629,6 +629,7 @@ covers D24–D69; the log continues in §H (D32 procedure, D70–D121). | D341 | **Intro gun-barrel blood drip draws as static blocks/lines instead of dripping (user, v0.4.0, 2026-09-26; death blood fine).** — full `## D341` entry at file tail | FIXED (M-201, user-verified 2026-09-27): two fast3d defects — the 1-cycle blood texrect sampled a stale tile-1 declaration via the D236 LOD rule, and the in-place-regenerated dyn-pool texture hit a stale address-keyed cache entry. | | D342 | **File select: folder bodies and Bond photos vanish after selecting a file and backing out (user, v0.4.0, 2026-09-26).** — full `## D342` entry at file tail | FIXED (M-201, user-verified): scratch placed at the N64 offset +0xA000 of `ptr_logo_and_walletbond_DL` landed inside the larger PC wallet model; moved past its 0x17000 reservation. | | D343 | **File select: PC "Options" label opens the settings overlay (v0.4.0 feature, M1 of the route-B options screen, 2026-09-27); also fixes F10 on file select jumping to the legal screen after 30 s.** — full `## D343` entry at file tail | LANDED (M1): one Rule-2 `#ifdef PORT` draw hook (user sign-off 2026-09-27); headless + user-verified (mouse, F10, >30 s, widescreen); physical controller check owed. | +| D344 | **Host PC bugchecks (BSOD 0x119 VIDEO_SCHEDULER_INTERNAL_ERROR) during game test runs; probable cause: quit called exit() while the render thread could be inside the GL driver (2026-09-27).** — full `## D344` entry at file tail | FIX WRITTEN + BUILT (orderly quit + GE_QUITFRAME); runtime verification pending the user's OK (runs paused). | Phase 2 replaced the Phase-1 demo loop with the real `mainproc()` on real OS threads, compiled GE's real `src/sched.c`, and brought in PD's fast3d software @@ -13543,8 +13544,7 @@ Implemented as `Input.PdMouseAim` (**default off**, because it changes aim feel) - Hot = cursor over the text (+4 px), overlay closed, no pending menu change (`menu_update == -1`), and no erase confirmation open (`folder_selected_for_deletion < 0`). The label is clear of every folder and Copy/Erase hit box, so a press on it only triggers front.c's "clear the Copy/Erase mode" path. - The click is A/Z/Start via `joyGetButtonsPressedThisFrame` (the game's interface tick runs before the constructor each frame, so the latched press is valid). A left click is A in menus. It plays `DOOR_LOCK_SFX` (222, Copy/Erase's sound). - **Threads:** the hook runs on the game's main thread, but the overlay's open flag is read by the scheduler thread (input poll + `gfx_run`). So the hook only posts an `SDL_atomic_t` request, which `optionsOverlayPollRequests()` applies from `inputComputePad(0)` (the same shape as the D314 wheel queue). -- `textMeasure` returns a height of 0 for text without a trailing newline, which collapsed the first hit box to one row. The height is now measured from `"Options -"`. +- `textMeasure` returns a height of 0 for text without a trailing newline, which collapsed the first hit box to one row. The height is now measured from `"Options\n"`. **Bug fixed along the way (existing since F10 landed):** while the overlay is open it gives front.c a neutral pad, so `interface_menu05_fileselect`'s idle timer (`g_MenuTimer`, `front.c:~2304`) never resets. After 30 s the screen went to the legal screen and the intro attract loop. The hook now sets `g_MenuTimer = 0` while the overlay is open, which is the same write front.c makes when a button is pressed. This is scoped by construction: the hook only runs on MENU_FILE_SELECT, the one screen where `g_MenuTimer` is an idle timer. Elsewhere it is the intro/cast-roll animation clock and must keep running, so a blanket "front-end menu" gate would have frozen the intro logos under F10. @@ -13562,3 +13562,35 @@ Implemented as `Input.PdMouseAim` (**default off**, because it changes aim feel) **Status: LANDED (M1), user-verified except a physical controller.** +**M2 (2026-09-27): GE-styled options panel.** The label now opens a panel drawn in the same hook (`port/src/frontoptions.c`, new; the label code moved there from `optionsoverlay.c`): +- A dark sheet over the folders with gold rules, "PC OPTIONS" in Bank Gothic, and one Zurich tab per section of the F10 overlay's row table (Display / View / Mouse/Aim / Controller / Game), spread to fit the panel. Rows show label + value, slider bars, and "(restart)" for MSAA; a footer has "L / R: section" and a clickable Back. The bottom bar (SELECT FILE / Copy / Erase) and the game's own crosshair stay visible. +- Rows, values and live-apply come from a small exported row API in `optionsoverlay.c` (`optionsRow*`), so F10 and the panel edit the same settings. The panel keeps its own page and selection state. +- **Input:** while the panel is open on file select, `input.c` passes the stick through (so the mouse or stick moves front.c's crosshair, the panel's pointer, exactly as in GE's menus) and hands the buttons to `frontOptionsFeedPad()`; front.c gets 0. Edges are published with atomics; all UI state lives on the game thread, in the hook. Controls: the crosshair highlights rows and tabs; A/Z/Start (click, Space) acts on the highlighted row (toggle/cycle/step, or set a slider from the crosshair's x and drag); D-pad or C up/down moves the highlight; D-pad or C left/right (keyboard A/D) adjusts with hold-repeat; L/R switch section; B (right-click, Esc) or Back closes and saves the config. +- Two bugs found and fixed by headless scripts: (1) A acted on the row under an idle crosshair instead of the D-pad's highlighted row; (2) the press that opened the panel leaked in as a first press and toggled Fullscreen. The panel now ignores buttons until they are released once. +- The hook calls `microcode_constructor()` after the panel, so the Copy/Erase icons and the cursor drawn after it keep their render state. +- **Verified headless (self-quitting runs, D344):** panel render at 640x480; tab/section switching, D-pad select + A toggle (Mouse invert Y OFF to ON, nothing else changed), B close; label click opens it; open for 2450 frames with no legal-screen drop; clean exit every run. Owed: user playtest (mouse, keyboard, pad) and widescreen captures of the panel. + + +## D344 — Host PC bugchecks (0x119) during game runs: unclean multi-threaded quit (2026-09-27) + +**Report:** the maintainer's PC blue-screened twice while headless test runs were going (10:17 and 10:22), and once before (2026-09-14 14:38, during M-123 captures). All three are bugcheck **0x119 VIDEO_SCHEDULER_INTERNAL_ERROR, parameter 1 = 0x3000**, raised by the Windows GPU scheduler (dxgkrnl) on an invalid driver state. No minidump was written (volmgr 161, "Dump file creation failed"). Host: RTX 5090 (driver 2026-09-03) + AMD iGPU + Parsec virtual display + Microsoft Remote Display adapter; hardware-accelerated GPU scheduling on (`HwSchMode = 2`). The first crash hit while `data/ge007.ini` was being restored, leaving it as 1465 zero bytes (restored from backup). + +**Evidence (event logs only; runs were stopped):** +- Both of today's crashes fell within seconds of the harness ending a run with `timeout`: the ini restore that runs right after the kill never reached disk, and the second reboot came about 30 s after its kill was due. +- A killed run is not an abrupt kill: SDL turns the signal into `SDL_QUIT`, the logs end with `config: wrote data\ge007.ini`, and the process then ends in `abort()` (WER holds thousands of ge007 `0x40000015` reports at shifting in-binary offsets, plus older `0xC0000374` heap-corruption reports; Windows has applied the Fault Tolerant Heap to ge007). The teardown is not clean even when nothing crashes. + +**Probable cause (medium confidence, no dump):** the GL context is created on the host thread but bound to the scheduler thread, which does all rendering and `SDL_GL_SwapWindow` (`gfx_sdl2.cpp`). Quit was `exit(0)` straight from whichever SDL pump dequeued the event: the host thread (`video.c`, three sites) or fast3d's render-thread pump (`gfx_sdl2.cpp`, three sites), plus the overlay's "Quit to desktop". From the host thread, process teardown could run while the render thread was mid-frame inside the NVIDIA driver. A user-mode program shouldn't be able to bugcheck the machine, so the driver/HAGS stack is at fault too, but the port was feeding it a hazard on every exit. + +**Fix (port-only, `port/src/video.c`, `port/fast3d/gfx_sdl2.cpp`, `port/src/optionsoverlay.c`):** +- Every quit site calls `videoRequestQuit(why)` instead of `exit(0)`. +- The render thread brackets each frame with an `s_inFrame` atomic. At a frame boundary after a quit request (`videoStartFrame` before binding, or `videoEndFrame` after the swap) it runs `gfx_sdl_park_for_exit()` (`glFinish` + unbind the context) and parks for good. +- The host thread (`videoHostExitIfRequested`, each pump) waits until the render thread is parked or outside a frame (at most 2 s), logs `video: exiting (render parked|idle|STILL IN FRAME (timeout) after N ms)`, then `exit(0)`, so atexit still saves the config. +- Ordering: the render thread sets `s_inFrame` before reading `s_quitReq`; the host sets `s_quitReq` before reading `s_inFrame` (SDL atomics are sequentially consistent), so no frame can start after the host decides to exit. +- **Harness:** `GE_QUITFRAME=` requests the same orderly quit after frame n. `tools_pc/golden_gate.sh` now quits 30 frames after its last capture; `timeout` stays as a backstop only. `verify.sh`'s sweep still uses `timeout` (follow-up). + +**Not changed (noted):** fast3d's render-thread SDL pump still drains the shared event queue alongside the host pump. On Windows only the window's creating thread should pump; worth revisiting. The audio device is still open at exit. + +**Verification owed (runs paused at the user's request):** with the user's OK (ideally HAGS off first), run `tools_pc/golden_gate.sh` and a few `GE_QUITFRAME` runs. Check each log ends with `video: exiting (render parked ...)`, check no new ge007 `0x40000015` WER reports appear, and check a window-close / Alt+F4 by hand. A crash dump (fix the host's dump creation) would confirm or rule out the cause. + +**Status: FIX WRITTEN + BUILT, unverified at runtime.** + diff --git a/docs/porting-notes.md b/docs/porting-notes.md index 0d8896ba..0c789f96 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -1280,6 +1280,12 @@ GE on N64 renders gameplay at ~20–30 fps, so `g_ClockTimer` (= `speedgraphfram ## E. Process / method notes +- **Never `exit()` while another thread may be inside the GL driver (D344).** + The GL context lives on the render (scheduler) thread; quit events arrive on + other threads. Request the quit (`videoRequestQuit`), let the render thread + `glFinish` + unbind at a frame boundary and park, then exit from the host. + An unclean GL teardown bugchecked the maintainer's PC (0x119, HAGS on). + Harness runs should end with `GE_QUITFRAME=`, not a `timeout` kill. - **Headless front-end scripts: turn the mouse off (D343).** In menus the pointer follows the real OS mouse (WI-2 absolute tracking), so the desktop mouse moves the in-game cursor mid-`GE_INPUTSCRIPT`. Put @@ -1287,8 +1293,7 @@ GE on N64 renders gameplay at ~20–30 fps, so `g_ClockTimer` (= `speedgraphfram about one per rendered frame on the file select screen; calibrate with a log, not by assumption. - **`textMeasure` height is 0 without a trailing newline (D343).** It only - counts completed lines. Measure `"text -"` when you need a height (front.c's + counts completed lines. Measure `"text\n"` when you need a height (front.c's folder text does this); the width is unaffected. - **Periodic audio glitches: measure the host audio device before touching the pipeline (D322).** A clock-regular overflow/garble pattern (~5 min period, diff --git a/port/include/frontoptions.h b/port/include/frontoptions.h new file mode 100644 index 00000000..394df844 --- /dev/null +++ b/port/include/frontoptions.h @@ -0,0 +1,30 @@ +#ifndef PORT_FRONTOPTIONS_H +#define PORT_FRONTOPTIONS_H + +/* + * D343: PC options on the file-select screen (port/src/frontoptions.c). + * + * Hooks: + * front.c constructor_menu05_fileselect : optionsFileSelectLabel() draws the + * "Options" label, or the options panel while it is open + * input.c inputComputePad(0) : while open on file select, the + * buttons go to frontOptionsFeedPad() and the game gets 0 (the + * stick still moves the game's crosshair) + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +Gfx *optionsFileSelectLabel(Gfx *gdl); /* game thread */ +int frontOptionsIsOpen(void); /* any thread */ +void frontOptionsFeedPad(unsigned held); /* input thread */ + +#ifdef __cplusplus +} +#endif + +#endif /* PORT_FRONTOPTIONS_H */ diff --git a/port/include/optionsoverlay.h b/port/include/optionsoverlay.h index 566652f6..12d04f3c 100644 --- a/port/include/optionsoverlay.h +++ b/port/include/optionsoverlay.h @@ -43,14 +43,19 @@ void optionsOverlayScroll(int dir); * overlay is closed. Called by fast3d after running the game DL. */ Gfx *optionsOverlayEmit(void); -/* D343: file-select "Options" label. Called from the one #ifdef PORT hook in - * constructor_menu05_fileselect (game thread): draws the label, and on a - * click posts an open request. */ -Gfx *optionsFileSelectLabel(Gfx *gdl); - -/* Applies a pending label-open request. Called from the input poll - * (scheduler thread, controller 0) before the overlay's swallow gate. */ -void optionsOverlayPollRequests(void); +/* D343 (M2): row API for the file-select options screen (frontoptions.c). + * Index i runs over the same row table as the F10 overlay; headers split it + * into the screen's sections. */ +int optionsRowCount(void); +int optionsRowIsHeader(int i); +int optionsRowIsShown(int i); +const char *optionsRowLabel(int i); +int optionsRowIsSlider(int i); +int optionsRowNeedsRestart(int i); +double optionsRowFraction(int i); +void optionsRowSetFraction(int i, double f); +void optionsRowValueText(int i, char *out, int n); +void optionsRowAdjust(int i, int dir); #ifdef __cplusplus } diff --git a/port/src/frontoptions.c b/port/src/frontoptions.c new file mode 100644 index 00000000..49f7336c --- /dev/null +++ b/port/src/frontoptions.c @@ -0,0 +1,548 @@ +/* + * frontoptions.c -- D343: PC options on the file-select screen. + * + * GE's front end has no generic menu system and no options screen, so this is + * the port's own screen, drawn in GE's style (route B, + * docs/dev/OPTIONS-MENU-PLAN.md). It has two parts: + * + * - an "Options" label next to the file-select menu, and + * - a settings panel over the folders, one tab per section of the F10 + * overlay's row table (Display / View / Mouse & Aim / Controller / Game). + * + * Both are drawn from the single #ifdef PORT hook in + * constructor_menu05_fileselect (src/game/front.c), inside GE's own frame, so + * they fade with screen transitions and get the D335 pillarbox for free. The + * game's crosshair is drawn after the hook and works as the pointer, exactly as + * in every other GE menu: while the panel is open input.c still passes the + * stick through (mouse and stick move the crosshair) but hands the BUTTONS to + * frontOptionsFeedPad() instead of the game. + * + * Threads: the hook runs on the game's main thread and owns all of this + * file's UI state. input.c (scheduler thread) only publishes button edges + * through atomics. The settings themselves go through optionsoverlay.c's row + * API (rowSet does the live-apply requests, which are thread-safe posts). + */ + +#include +#include + +#include + +#include +#include + +#include "platform.h" +#include "system.h" +#include "config.h" +#include "envflag.h" +#include "optionsoverlay.h" +#include "frontoptions.h" + +/* ---- game symbols (see optionsoverlay.c / input.c for the same pattern) ---- */ +struct font; +struct fontchar; +extern struct font *ptrFontZurichBold; +extern struct fontchar *ptrFontZurichBoldChars; +extern struct font *ptrFontBankGothic; +extern struct fontchar *ptrFontBankGothicChars; +extern Gfx *microcode_constructor(Gfx *gdl); +extern Gfx *microcode_constructor_related_to_menus(Gfx *gdl, s32 ulx, s32 uly, + s32 lrx, s32 lry, u32 color); +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); +extern float cursor_h_pos; +extern float cursor_v_pos; +extern s32 folder_selected_for_deletion; +extern int menu_update; /* MENU enum (signed, -1 = none) */ +extern s32 g_MenuTimer; +extern u16 joyGetButtonsPressedThisFrame(s8 contpadnum, u16 mask); +extern ALBank *g_musicSfxBufferPtr; /* music.h; sndPlaySfx comes via snd.h */ + +/* N64 controller bits (os.h is not included here, see porting-notes C2). */ +#define FO_A 0x8000 +#define FO_B 0x4000 +#define FO_Z 0x2000 +#define FO_START 0x1000 +#define FO_UP 0x0800 +#define FO_DOWN 0x0400 +#define FO_LEFT 0x0200 +#define FO_RIGHT 0x0100 +#define FO_L 0x0020 +#define FO_R 0x0010 +#define FO_CUP 0x0008 +#define FO_CDOWN 0x0004 +#define FO_CLEFT 0x0002 +#define FO_CRIGHT 0x0001 +#define FO_CONFIRM (FO_A | FO_Z | FO_START) /* as front.c */ + +#define FO_MENU_INVALID (-1) +#define FO_SFX_CLICK 222 /* DOOR_LOCK_SFX: Copy/Erase's click */ +#define FO_SFX_BACK 118 /* GUN_M60AMMGUN_3_SFX: Copy/Erase's cancel */ + +/* ---- colours (RRGGBBAA) ---- */ +#define COL_WHITE 0xFFFFFFFFu +#define COL_GOLD 0xEBD879FFu /* GE's folder text colour */ +#define COL_GREY 0x9A9A9AFFu +#define COL_DIM 0x6A6A6AFFu + +/* ---- layout, in the front end's 440x330 canvas ---- */ +#define LABEL_RIGHT 392 /* the Erase text's right edge */ +#define LABEL_Y 24 /* dark strip above the right-hand folder */ +#define HIT_PAD 4 + +#define PNL_X0 24 +#define PNL_X1 416 +#define PNL_Y0 14 +#define PNL_Y1 268 +#define TITLE_X 38 +#define TITLE_Y 22 +#define TAB_Y 44 +#define TAB_GAP 16 +#define TAB_X1 (PNL_X1 - 12) /* right edge the tab row must fit in */ +#define ROWS_Y 68 +#define ROW_H 18 +#define LBL_X 40 +#define VAL_R 402 +#define BAR_X0 236 +#define BAR_X1 346 +#define FOOT_Y 248 + +static const char kLabel[] = "Options"; /* ASCII only: issue #87 / D295 */ +static const char kLabelNL[] = "Options\n"; +static const char kBack[] = "Back"; + +/* ---- state shared with input.c ---- */ +static SDL_atomic_t s_open; /* 1 while the panel is open */ +static SDL_atomic_t s_pressed; /* button edges since the last frame */ +static SDL_atomic_t s_held; /* buttons held at the last poll */ + +/* ---- UI state (game thread only) ---- */ +#define MAX_PAGES 8 +#define MAX_PROWS 24 +static int s_page = 0; +static int s_sel = -1; /* index into the current page's rows */ +static int s_dragRow = -1; /* row index (global) being dragged */ +static float s_lastCurX = -1.0f, s_lastCurY = -1.0f; +static int s_repeatDir = 0, s_repeatTimer = 0; +static int s_waitRelease = 0; /* ignore buttons until all are released */ + +static int s_pageHdr[MAX_PAGES]; /* global row index of each header */ +static int s_pageN = 0; +static int s_rowIdx[MAX_PROWS]; /* current page's visible rows (global) */ +static int s_rowN = 0; + +/* Tab geometry, filled when drawn and hit-tested next frame. */ +static s32 s_tabX0[MAX_PAGES], s_tabX1[MAX_PAGES]; +static s32 s_backX0, s_backX1; + +/* ------------------------------------------------------------------------ */ + +int frontOptionsIsOpen(void) +{ + return SDL_AtomicGet(&s_open) != 0; +} + +/* input.c, scheduler thread: called with the pad-0 buttons while the panel is + * open (the game gets 0), or with 0 while something else owns the pad. */ +void frontOptionsFeedPad(unsigned held) +{ + unsigned prev = (unsigned)SDL_AtomicSet(&s_held, (int)held); + unsigned edge = held & ~prev; + if (edge) { + int old; + do { + old = SDL_AtomicGet(&s_pressed); + } while (!SDL_AtomicCAS(&s_pressed, old, old | (int)edge)); + } +} + +static void playSfx(int id) +{ + sndPlaySfx((struct ALBankAlt_s *)g_musicSfxBufferPtr, (s16)id, NULL); +} + +static s32 measureW(const char *str, int bank) +{ + s32 h = 0, w = 0; + if (bank) textMeasure(&h, &w, (char *)str, ptrFontBankGothicChars, ptrFontBankGothic, 0); + else textMeasure(&h, &w, (char *)str, ptrFontZurichBoldChars, ptrFontZurichBold, 0); + return w; +} + +static Gfx *text(Gfx *gdl, s32 x, s32 y, const char *str, u32 colour, int bank) +{ + if (bank) { + return textRender(gdl, &x, &y, (char *)str, ptrFontBankGothicChars, + ptrFontBankGothic, colour, viGetX(), viGetY(), 0, 0); + } + return textRender(gdl, &x, &y, (char *)str, ptrFontZurichBoldChars, + ptrFontZurichBold, colour, viGetX(), viGetY(), 0, 0); +} + +static Gfx *textR(Gfx *gdl, s32 xr, s32 y, const char *str, u32 colour) +{ + return text(gdl, xr - measureW(str, 0), y, str, colour, 0); +} + +/* The game's own menu box helper (the folder tabs' highlight). */ +static Gfx *box(Gfx *gdl, s32 x0, s32 y0, s32 x1, s32 y1, u32 rgba) +{ + return microcode_constructor_related_to_menus(gdl, x0, y0, x1, y1, rgba); +} + +static int inRect(float x, float y, s32 x0, s32 y0, s32 x1, s32 y1) +{ + return x >= (float)x0 && x <= (float)x1 && y >= (float)y0 && y <= (float)y1; +} + +/* ------------------------------------------------------------------------ */ + +static void buildPages(void) +{ + int n = optionsRowCount(); + s_pageN = 0; + for (int i = 0; i < n && s_pageN < MAX_PAGES; i++) { + if (optionsRowIsHeader(i)) { + s_pageHdr[s_pageN++] = i; + } + } + if (s_page >= s_pageN) { + s_page = 0; + } + + /* Visible rows of the current page (auto toggles can hide % rows). */ + s_rowN = 0; + if (s_pageN > 0) { + for (int i = s_pageHdr[s_page] + 1; i < n && !optionsRowIsHeader(i); i++) { + if (optionsRowIsShown(i) && s_rowN < MAX_PROWS) { + s_rowIdx[s_rowN++] = i; + } + } + } + if (s_sel >= s_rowN) { + s_sel = s_rowN - 1; + } +} + +static void setPage(int page) +{ + if (s_pageN <= 0) { + return; + } + s_page = (page + s_pageN) % s_pageN; + s_sel = 0; + s_dragRow = -1; + buildPages(); +} + +static void openPanel(void) +{ + SDL_AtomicSet(&s_pressed, 0); + SDL_AtomicSet(&s_open, 1); + /* The press that opened the panel is usually still held when input.c + * starts routing buttons here, and would register as a fresh press on + * the first row. Ignore everything until the buttons are released. */ + s_waitRelease = 1; + s_sel = 0; + s_dragRow = -1; + s_lastCurX = cursor_h_pos; + s_lastCurY = cursor_v_pos; + buildPages(); + sysLogPrintf(LOG_INFO, "frontoptions: opened"); +} + +static void closePanel(void) +{ + SDL_AtomicSet(&s_open, 0); + s_dragRow = -1; + configSave(); + sysLogPrintf(LOG_INFO, "frontoptions: closed"); +} + +static int rowY(int k) +{ + return ROWS_Y + k * ROW_H; +} + +/* Row under the cursor (page-local index), or -1. */ +static int hoverRow(void) +{ + for (int k = 0; k < s_rowN; k++) { + if (inRect(cursor_h_pos, cursor_v_pos, PNL_X0 + 6, rowY(k) - 2, + PNL_X1 - 6, rowY(k) + ROW_H - 3)) { + return k; + } + } + return -1; +} + +static double barFraction(float x) +{ + return ((double)x - BAR_X0) / (double)(BAR_X1 - BAR_X0); +} + +/* One frame of panel input (game thread). */ +static void panelInput(void) +{ + unsigned pressed = (unsigned)SDL_AtomicSet(&s_pressed, 0); + unsigned held = (unsigned)SDL_AtomicGet(&s_held); + + if (s_waitRelease) { + if (held != 0) { + return; + } + s_waitRelease = 0; + pressed = 0; + } + + buildPages(); + + /* Hover follows the crosshair while it moves; the D-pad moves the + * selection without moving the crosshair. */ + int hov = hoverRow(); + if (cursor_h_pos != s_lastCurX || cursor_v_pos != s_lastCurY) { + if (hov >= 0) s_sel = hov; + s_lastCurX = cursor_h_pos; + s_lastCurY = cursor_v_pos; + } + if (pressed & (FO_UP | FO_CUP)) { if (s_sel > 0) s_sel--; } + if (pressed & (FO_DOWN | FO_CDOWN)) { if (s_sel < s_rowN - 1) s_sel++; } + + if (pressed & FO_B) { + playSfx(FO_SFX_BACK); + closePanel(); + return; + } + if (pressed & FO_L) { setPage(s_page - 1); playSfx(FO_SFX_CLICK); } + if (pressed & FO_R) { setPage(s_page + 1); playSfx(FO_SFX_CLICK); } + + if (pressed & FO_CONFIRM) { + for (int p = 0; p < s_pageN; p++) { + if (inRect(cursor_h_pos, cursor_v_pos, s_tabX0[p] - 4, TAB_Y - 3, + s_tabX1[p] + 4, TAB_Y + 14)) { + if (p != s_page) setPage(p); + playSfx(FO_SFX_CLICK); + return; + } + } + if (inRect(cursor_h_pos, cursor_v_pos, s_backX0 - 4, FOOT_Y - 3, + s_backX1 + 4, FOOT_Y + 14)) { + playSfx(FO_SFX_BACK); + closePanel(); + return; + } + /* Act on the highlighted row. The highlight follows the crosshair + * whenever it moves, so a click lands on the row under it; a pad + * user's D-pad choice isn't overridden by an idle crosshair. */ + int k = s_sel; + if (k >= 0 && k < s_rowN) { + int i = s_rowIdx[k]; + if (hov == k && optionsRowIsSlider(i) && + cursor_h_pos >= BAR_X0 - 4 && cursor_h_pos <= BAR_X1 + 4) { + optionsRowSetFraction(i, barFraction(cursor_h_pos)); + s_dragRow = i; + } else { + optionsRowAdjust(i, +1); /* toggle / cycle / step (wraps) */ + } + playSfx(FO_SFX_CLICK); + buildPages(); /* an auto toggle may hide rows */ + } + } + + /* Drag a slider while the confirm button is held. */ + if (s_dragRow >= 0) { + if (held & FO_CONFIRM) { + optionsRowSetFraction(s_dragRow, barFraction(cursor_h_pos)); + } else { + s_dragRow = -1; + } + } + + /* Left/right adjust the selected row, with hold-to-repeat. */ + int dir = 0; + if (held & (FO_LEFT | FO_CLEFT)) dir = -1; + if (held & (FO_RIGHT | FO_CRIGHT)) dir = +1; + if (dir != 0 && s_sel >= 0 && s_sel < s_rowN) { + int fire = 0; + if (dir != s_repeatDir) { + fire = 1; + s_repeatTimer = 18; + } else if (--s_repeatTimer <= 0) { + fire = 1; + s_repeatTimer = 4; + } + if (fire) { + optionsRowAdjust(s_rowIdx[s_sel], dir); + buildPages(); + } + } + s_repeatDir = dir; +} + +/* ------------------------------------------------------------------------ */ + +/* Tab name for a section header: "MOUSE / AIM" -> "MOUSE/AIM". */ +static void tabName(const char *hdr, char *out, int n) +{ + int j = 0; + for (int i = 0; hdr[i] && j < n - 1; i++) { + if (hdr[i] == ' ' && (hdr[i + 1] == '/' || (i > 0 && hdr[i - 1] == '/'))) { + continue; + } + out[j++] = hdr[i]; + } + out[j] = '\0'; +} + +static Gfx *drawPanel(Gfx *gdl) +{ + char val[48]; + + /* Backing: a near-opaque dark sheet over the folders, gold rule lines. */ + gdl = box(gdl, PNL_X0, PNL_Y0, PNL_X1, PNL_Y1, 0x0C0C0CEE); + gdl = box(gdl, PNL_X0, PNL_Y0, PNL_X1, PNL_Y0 + 1, 0xEBD87999); + gdl = box(gdl, PNL_X0, PNL_Y1 - 1, PNL_X1, PNL_Y1, 0xEBD87999); + gdl = box(gdl, PNL_X0 + 10, TAB_Y + 18, PNL_X1 - 10, TAB_Y + 19, 0xEBD87955); + + gdl = microcode_constructor(gdl); + gdl = text(gdl, TITLE_X, TITLE_Y, "PC OPTIONS", COL_GOLD, 1); + + /* Tabs: section names from the row headers, spread to fit the panel. */ + { + char names[MAX_PAGES][24]; + s32 widths[MAX_PAGES], total = 0, gap = TAB_GAP; + for (int p = 0; p < s_pageN; p++) { + tabName(optionsRowLabel(s_pageHdr[p]), names[p], sizeof(names[p])); + widths[p] = measureW(names[p], 0); + total += widths[p]; + } + if (s_pageN > 1) { + gap = (TAB_X1 - TITLE_X - total) / (s_pageN - 1); + if (gap > TAB_GAP) gap = TAB_GAP; + if (gap < 4) gap = 4; + } + s32 x = TITLE_X; + for (int p = 0; p < s_pageN; p++) { + s32 w = widths[p]; + int hot = inRect(cursor_h_pos, cursor_v_pos, x - 4, TAB_Y - 3, x + w + 4, TAB_Y + 14); + s_tabX0[p] = x; + s_tabX1[p] = x + w; + if (p == s_page) { + gdl = box(gdl, x, TAB_Y + 14, x + w, TAB_Y + 16, 0xEBD879FF); + gdl = microcode_constructor(gdl); + } + gdl = text(gdl, x, TAB_Y, names[p], + p == s_page ? COL_GOLD : (hot ? COL_WHITE : COL_GREY), 0); + x += w + gap; + } + } + + /* Rows. */ + for (int k = 0; k < s_rowN; k++) { + int i = s_rowIdx[k]; + int y = rowY(k); + int sel = (k == s_sel); + u32 col = sel ? COL_GOLD : COL_WHITE; + + if (sel) { + gdl = box(gdl, PNL_X0 + 8, y - 2, PNL_X1 - 8, y + ROW_H - 4, 0xEBD8792A); + gdl = microcode_constructor(gdl); + } + gdl = text(gdl, LBL_X, y, optionsRowLabel(i), col, 0); + + optionsRowValueText(i, val, sizeof(val)); + if (optionsRowIsSlider(i)) { + s32 fx = BAR_X0 + (s32)((BAR_X1 - BAR_X0) * optionsRowFraction(i) + 0.5); + gdl = box(gdl, BAR_X0, y + 5, BAR_X1, y + 9, 0x404040FF); + gdl = box(gdl, BAR_X0, y + 5, fx, y + 9, sel ? 0xEBD879FF : 0xB0A060FF); + gdl = microcode_constructor(gdl); + } else if (optionsRowNeedsRestart(i)) { + gdl = text(gdl, BAR_X0, y, "(restart)", COL_DIM, 0); + } + if (val[0]) { + gdl = textR(gdl, VAL_R, y, val, col); + } + } + + /* Footer: hints + Back. */ + gdl = text(gdl, TITLE_X, FOOT_Y, "L / R: section", COL_DIM, 0); + { + s32 w = measureW(kBack, 0); + s_backX1 = VAL_R; + s_backX0 = VAL_R - w; + int hot = inRect(cursor_h_pos, cursor_v_pos, s_backX0 - 4, FOOT_Y - 3, s_backX1 + 4, FOOT_Y + 14); + gdl = text(gdl, s_backX0, FOOT_Y, kBack, hot ? COL_GOLD : COL_WHITE, 0); + } + return microcode_constructor(gdl); /* leave the text state the icons expect */ +} + +/* ------------------------------------------------------------------------ */ + +/* The one #ifdef PORT hook in constructor_menu05_fileselect (game thread), + * called after the Copy/Erase labels and before their icons + the cursor. */ +Gfx *optionsFileSelectLabel(Gfx *gdl) +{ + s32 h = 0, w = 0, unusedw = 0; + s32 x, y; + int open = frontOptionsIsOpen(); + int hot; + + static int autoOpenChecked = 0; /* GE_FRONTOPTIONS=1: open at first draw (headless) */ + if (!autoOpenChecked) { + autoOpenChecked = 1; + if (GE_ENVFLAG("GE_FRONTOPTIONS")) { + openPanel(); + open = 1; + } + } + + /* A screen that pads the controller away from front.c must also hold its + * idle timer, or file select drops to the legal screen after 30 s. This + * hook only runs on MENU_FILE_SELECT, the one screen where g_MenuTimer is + * an idle timer (elsewhere it is the intro / cast-roll clock). Same write + * front.c makes when a button is pressed. */ + if (open || optionsOverlayIsOpen()) { + g_MenuTimer = 0; + } + + if (open) { + panelInput(); + open = frontOptionsIsOpen(); + } + + /* textMeasure only counts height for completed lines, so measure the + * height with a trailing newline (front.c's folder text does the same). */ + textMeasure(&unusedw, &w, (char *)kLabel, ptrFontZurichBoldChars, ptrFontZurichBold, 0); + textMeasure(&h, &unusedw, (char *)kLabelNL, ptrFontZurichBoldChars, ptrFontZurichBold, 0); + x = LABEL_RIGHT - w; + y = LABEL_Y; + + hot = !open + && !optionsOverlayIsOpen() + && menu_update == FO_MENU_INVALID + && folder_selected_for_deletion < 0 + && inRect(cursor_h_pos, cursor_v_pos, x - HIT_PAD, y - HIT_PAD, + x + w + HIT_PAD, y + h + HIT_PAD); + + if (hot && joyGetButtonsPressedThisFrame(0, FO_CONFIRM)) { + playSfx(FO_SFX_CLICK); + openPanel(); + open = 1; + } + + if (open) { + gdl = drawPanel(gdl); + return gdl; /* the panel's title replaces the label */ + } + + /* Same font and white as Copy/Erase; gold (the folder text colour) while + * the cursor is over it. Text state is still the Copy/Erase text's. */ + return textRender(gdl, &x, &y, (char *)kLabel, ptrFontZurichBoldChars, + ptrFontZurichBold, hot ? COL_GOLD : COL_WHITE, + viGetX(), viGetY(), 0, 0); +} diff --git a/port/src/input.c b/port/src/input.c index f7840b72..968f4170 100644 --- a/port/src/input.c +++ b/port/src/input.c @@ -81,6 +81,7 @@ #include "input.h" #include "envflag.h" #include "optionsoverlay.h" +#include "frontoptions.h" /* D194 absolute aim: read-only access to the live camera (struct player). * Game header pulled in through the same shim path every other compiled game * file uses; we only READ vv_theta/vv_verta/speedtheta/speedverta/aspect. */ @@ -121,6 +122,7 @@ extern s32 g_ClockTimer; * MENU_INVALID (-1) means the front end never ran (bare -level_XX boot): * treat that as in-game so direct-launch mouse-look is unaffected. */ extern int current_menu; +#define GE_MENU_FILE_SELECT 5 #define GE_MENU_RUN_STAGE 11 #define GE_MENU_INVALID (-1) /* D169: the front-end cursor lives in the game's virtual-screen field, which @@ -933,10 +935,6 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) return 0; } - /* D343: apply a pending open from the file-select "Options" label (posted - * on the game thread; the overlay's open flag belongs to this thread). */ - if (idx == 0) optionsOverlayPollRequests(); - /* F10 options overlay: while it is open, controller 0 is fully swallowed * (neutral pad, no stick) and the nav keys / wheel / gamepad drive the * overlay instead. Mirrors the WI-1 "cursor free in a stage -> withhold @@ -949,6 +947,7 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) SDL_CONTROLLER_BUTTON_BACK) : 0; if (selNow && !padSelectPrev) optionsOverlayToggle(); padSelectPrev = selNow; + frontOptionsFeedPad(0); /* D343: the options panel gets nothing meanwhile */ optionsOverlayHandleInput(); if (stick_x) *stick_x = 0; if (stick_y) *stick_y = 0; @@ -1482,6 +1481,14 @@ unsigned inputComputePad(int idx, signed char *stick_x, signed char *stick_y) sy = scriptCurSY; } + /* D343: the file-select options panel is open. The stick still reaches + * the game (it moves front.c's crosshair, which is the panel's pointer); + * the buttons go to the panel instead, so front.c sees no presses. */ + if (idx == 0 && current_menu == GE_MENU_FILE_SELECT && frontOptionsIsOpen()) { + frontOptionsFeedPad(button); + button = 0; + } + if (sx > STICK_MAX) sx = STICK_MAX; if (sx < -STICK_MAX) sx = -STICK_MAX; if (sy > STICK_MAX) sy = STICK_MAX; diff --git a/port/src/optionsoverlay.c b/port/src/optionsoverlay.c index 7272d365..64c2e3ed 100644 --- a/port/src/optionsoverlay.c +++ b/port/src/optionsoverlay.c @@ -666,7 +666,7 @@ static void rowAdjust(struct Row *r, int dir) * without OS window chrome or a keyboard. */ sysLogPrintf(LOG_INFO, "optionsoverlay: quit to desktop requested"); configSave(); - exit(0); + videoRequestQuit("Quit to desktop"); /* D344: never exit() off the host thread */ break; default: /* ROW_SLIDER */ rowSet(r, v + dir * r->step); @@ -1099,87 +1099,95 @@ Gfx *optionsOverlayEmit(void) } /* ------------------------------------------------------------------------ */ -/* D343: "Options" label on the file-select screen (route B entry point, */ -/* docs/dev/OPTIONS-MENU-PLAN.md §4). Drawn from the single #ifdef PORT hook */ -/* in constructor_menu05_fileselect, so it is part of GE's own front-end */ -/* frame (fades with it, D335 pillarbox applies). Clicking it opens the F10 */ -/* overlay. */ -/* */ -/* Thread map: the hook runs on the game's main thread; the overlay's open */ -/* flag is read by the scheduler thread (input poll + gfx_run). So the hook */ -/* only posts a request, which optionsOverlayPollRequests() applies from the */ -/* input poll (same shape as the D314 wheel queue). */ +/* D343 (M2): row API for the file-select options screen (frontoptions.c). */ +/* The screen shares this file's rows, value text and live-apply logic; it */ +/* keeps its own page/selection state. Both UIs call these from one thread */ +/* at a time: the screen gets no input while the F10 overlay is open. */ /* ------------------------------------------------------------------------ */ -extern struct font *ptrFontZurichBold; -extern struct fontchar *ptrFontZurichBoldChars; -extern float cursor_h_pos; -extern float cursor_v_pos; -extern s32 folder_selected_for_deletion; -extern int menu_update; /* MENU enum (signed, -1 = none) */ -extern s32 g_MenuTimer; -extern u16 joyGetButtonsPressedThisFrame(s8 contpadnum, u16 mask); -extern ALBank *g_musicSfxBufferPtr; /* music.h; sndPlaySfx comes via snd.h */ - -#define FSOPT_MENU_INVALID (-1) -#define FSOPT_PRESS_MASK (0x8000 | 0x2000 | 0x1000) /* A | Z | START, as front.c */ -#define FSOPT_SFX_DOOR_LOCK 222 /* DOOR_LOCK_SFX: Copy/Erase's own click sound */ -#define FSOPT_TEXT_RIGHT 392 /* right edge of the Erase label's text */ -#define FSOPT_TEXT_Y 24 /* dark strip above the right-hand folder */ -#define FSOPT_HIT_PAD 4 - -static const char kFsOptLabel[] = "Options"; /* ASCII only: issue #87 / D295 */ -static const char kFsOptLabelNL[] = "Options\n"; -static SDL_atomic_t s_openRequest; - -void optionsOverlayPollRequests(void) +static struct Row *rowAt(int i) { - if (SDL_AtomicSet(&s_openRequest, 0) && !optionsOverlayIsOpen()) { - optionsOverlayToggle(); - } + overlayInit(); + return (i >= 0 && i < NUM_ROWS) ? &rows[i] : NULL; } -Gfx *optionsFileSelectLabel(Gfx *gdl) +int optionsRowCount(void) { - s32 h = 0, w = 0; - s32 x, y; - int hot; - - /* The overlay swallows pad 0, so front.c's idle timer never resets and - * the file select screen jumped to the legal screen after 30 s. Hold it - * at 0 while the overlay is open -- the same write front.c makes when a - * button is pressed. This hook only runs on MENU_FILE_SELECT, the one - * screen where g_MenuTimer is an idle timer (elsewhere it is the intro / - * cast-roll clock, which must keep running). */ - if (optionsOverlayIsOpen()) { - g_MenuTimer = 0; + overlayInit(); + return NUM_ROWS; +} + +int optionsRowIsHeader(int i) +{ + struct Row *r = rowAt(i); + return r && r->kind == ROW_HEADER; +} + +/* Registered and not hidden by its "auto" toggle. */ +int optionsRowIsShown(int i) +{ + struct Row *r = rowAt(i); + return r && r->found && !(r->hidePtr && *r->hidePtr); +} + +const char *optionsRowLabel(int i) +{ + struct Row *r = rowAt(i); + return r ? r->label : ""; +} + +int optionsRowIsSlider(int i) +{ + struct Row *r = rowAt(i); + return r && r->kind == ROW_SLIDER && rowHi(r) > rowLo(r); +} + +int optionsRowNeedsRestart(int i) +{ + struct Row *r = rowAt(i); + return r && r->restart; +} + +double optionsRowFraction(int i) +{ + struct Row *r = rowAt(i); + if (!r || rowHi(r) <= rowLo(r)) { + return 0.0; } + double f = (rowGet(r) - rowLo(r)) / (rowHi(r) - rowLo(r)); + return f < 0.0 ? 0.0 : (f > 1.0 ? 1.0 : f); +} - s32 unusedw = 0; - - /* textMeasure only counts height for completed lines, so measure the - * height with a trailing newline (front.c's folder text does the same). */ - textMeasure(&unusedw, &w, (char *)kFsOptLabel, ptrFontZurichBoldChars, ptrFontZurichBold, 0); - textMeasure(&h, &unusedw, (char *)kFsOptLabelNL, ptrFontZurichBoldChars, ptrFontZurichBold, 0); - x = FSOPT_TEXT_RIGHT - w; - y = FSOPT_TEXT_Y; - - hot = !optionsOverlayIsOpen() - && menu_update == FSOPT_MENU_INVALID - && folder_selected_for_deletion < 0 - && cursor_h_pos >= (float)(x - FSOPT_HIT_PAD) - && cursor_h_pos <= (float)(x + w + FSOPT_HIT_PAD) - && cursor_v_pos >= (float)(y - FSOPT_HIT_PAD) - && cursor_v_pos <= (float)(y + h + FSOPT_HIT_PAD); - - if (hot && joyGetButtonsPressedThisFrame(0, FSOPT_PRESS_MASK)) { - sndPlaySfx((struct ALBankAlt_s *)g_musicSfxBufferPtr, FSOPT_SFX_DOOR_LOCK, NULL); - SDL_AtomicSet(&s_openRequest, 1); +void optionsRowSetFraction(int i, double f) +{ + struct Row *r = rowAt(i); + if (!r || !r->found || rowHi(r) <= rowLo(r)) { + return; } + if (f < 0.0) f = 0.0; + if (f > 1.0) f = 1.0; + double step = (r->step > 0.0) ? r->step : 1.0; + double v = rowLo(r) + f * (rowHi(r) - rowLo(r)); + rowSet(r, lround(v / step) * step); +} - /* Same font and white as Copy/Erase; gold (the folder text colour) while - * the cursor is over it. */ - return textRender(gdl, &x, &y, (char *)kFsOptLabel, ptrFontZurichBoldChars, - ptrFontZurichBold, hot ? 0xEBD879FF : 0xFFFFFFFF, - viGetX(), viGetY(), 0, 0); +void optionsRowValueText(int i, char *out, int n) +{ + struct Row *r = rowAt(i); + if (!r || n <= 0) { + return; + } + if (r->kind == ROW_ACTION) { + out[0] = '\0'; /* the label is the button */ + return; + } + valueText(r, out, n); +} + +void optionsRowAdjust(int i, int dir) +{ + struct Row *r = rowAt(i); + if (r) { + rowAdjust(r, dir); + } } diff --git a/src/game/front.c b/src/game/front.c index 90567f76..aea06b3a 100644 --- a/src/game/front.c +++ b/src/game/front.c @@ -2795,8 +2795,8 @@ Gfx *constructor_menu05_fileselect(Gfx *DL) folder_option_ERASE_bound.right = (f32) (textsize.p[0] + textpos.p[1]); #ifdef PORT - /* D343: PC "Options" label (opens the port settings screen). Draw - * only; the hit test and the open live in port/src/optionsoverlay.c. */ + /* D343: PC "Options" label, or the PC options panel while it is + * open. All logic lives in port/src/frontoptions.c. */ { extern Gfx *optionsFileSelectLabel(Gfx *gdl); DL = optionsFileSelectLabel(DL);