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);