From 3f22bc4ab400d979447b5f25ea5a73ae946ec73a Mon Sep 17 00:00:00 2001 From: "NathanNeurotic (Ripto)" <109461996+NathanNeurotic@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:11:00 -0700 Subject: [PATCH 1/3] fix(pad): pause held-key repeat across read misses and re-wire the miss counters (#340) A pad-read MISS (connected pad, no fresh sample this poll) leaves the held buttons out of global paddata for the frame while edgedata still carries the last valid sample. The baseline repeat loop treated that as a release and re-armed the full 3x initial delay (300-1500 ms depending on scroll speed), so recurring misses on real hardware starved auto-repeat entirely -- the 'hangs on the 3rd highlighted item' pattern in #340/#272. Pause the countdown for keys whose edgedata bit survives a miss poll instead; a key genuinely released during a blind window still resets on the first good read, and an emulator (which never misses) sees byte-for-byte baseline behavior. This restores the hold-repeat feel HW-validated at Beta-3442 and lost in the PR #328 revert. Also: readMisses/missBurst/missBurstMax had no writers since that revert (the Debug HUD always rendered miss:0) -- count them again, and treat a miss poll with a carried held sample as input activity so the 250-600 ms inline initializePad self-heal cannot fire mid-navigation off misses that masked the user's own presses. Co-Authored-By: Claude Fable 5 --- src/pad.c | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/pad.c b/src/pad.c index cdbe6379f2..b825977d7c 100644 --- a/src/pad.c +++ b/src/pad.c @@ -137,6 +137,13 @@ static u32 oldpaddata; // are SHOWN only when Settings -> Debug Colors is on. static pad_diag_t padDiag; +// Per-poll read outcome, reset by readPads() and filled in by each readPad(): how many pads were +// in a ready state, and how many produced a fresh sample. A poll with ready pads and zero fresh +// samples is a read MISS -- the event the repeat loop must tolerate (#340). Misses cluster under +// SIO2 load on real hardware; an emulator's pad never produces one. +static int pollPadsReady; +static int pollPadsRead; + void padGetDiag(pad_diag_t *out) { if (out) @@ -483,6 +490,7 @@ static int readPad(struct pad_data_t *pad) } if (isPadReadyState(pad->state)) { + pollPadsReady++; ret = padRead(pad->port, pad->slot, &pad->buttons); // port, slot, buttons if (ret != 0) { @@ -544,6 +552,7 @@ static int readPad(struct pad_data_t *pad) #endif if (padsRead > 0) { + pollPadsRead++; newpdata = readLeftJoy(pad, newpdata); pad->paddata = newpdata; @@ -817,11 +826,31 @@ int readPads() if (time_since_last > padDiag.pollMaxMs) padDiag.pollMaxMs = time_since_last; + pollPadsReady = 0; + pollPadsRead = 0; for (i = 0; i < pad_count; ++i) result |= readPad(&pad_data[i]); + // A read MISS: at least one pad is connected but none produced a fresh sample this poll. + // paddata is missing the held bits for the frame while edgedata still carries the last valid + // sample (readPad ORs it in unconditionally). The counters feed the Debug-Colors HUD line; + // they had no writers between the PR #328 revert and this fix, so the HUD showed miss:0 + // regardless of what the hardware was doing. + int readMissPoll = (pollPadsReady > 0 && pollPadsRead == 0); + if (readMissPoll) { + padDiag.readMisses++; + padDiag.missBurst++; + if (padDiag.missBurst > padDiag.missBurstMax) + padDiag.missBurstMax = padDiag.missBurst; + } else if (pollPadsRead > 0) { + padDiag.missBurst = 0; + } + // Stamp input activity AFTER the merge: any held button/stick re-arms the PAD_SELF_HEAL_IDLE_MS gate. - if (paddata != 0) + // A miss poll with a carried held sample counts as activity too -- the user is almost certainly + // still holding, and the misses themselves cluster exactly when an inline initializePad (250-600 ms + // GUI blackout) would hurt the most (#271/#272). + if (paddata != 0 || (readMissPoll && edgedata != 0)) lastInputActivityMs = curtime; // Rumble duration is millisecond-based because some paths poll twice in one frame. @@ -849,12 +878,18 @@ int readPads() pad->rumbleOn = 0; } - // Simple baseline repeat handling (wOPL-style): decrement per-key counters when held, - // otherwise reset to the initial delay. This removes read-miss carry/pausing behavior. + // Hold-to-repeat handling. Baseline (wOPL/official) semantics reset a key's countdown to the + // full 3x initial delay whenever the key reads unpressed -- but on a read MISS the key did not + // read at all, so that reset turned every transient SIO2 miss into a 300-1500 ms repeat stall + // while holding a direction on real hardware (#272/#340: the "hangs on the 3rd item" pattern; + // the pause was HW-validated at Beta-3442 and lost in the PR #328 revert). PAUSE the countdown + // instead when the miss poll's edgedata still carries the held key: a key genuinely released + // during a blind window resets on the first good read, when edgedata drops it. On an emulator + // (no misses) this loop is byte-for-byte the baseline behavior. for (i = 0; i < 16; ++i) { if (getKeyPressed(i + 1)) delaycnt[i] -= (int)time_since_last; - else + else if (!(readMissPoll && (edgedata & keyToPad[i + 1]))) delaycnt[i] = getKeyDelay(i + 1, 0); } From 36c3c78b3bb6032e202614b48a8e75d41f5b738c Mon Sep 17 00:00:00 2001 From: "NathanNeurotic (Ripto)" <109461996+NathanNeurotic@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:11:12 -0700 Subject: [PATCH 2/3] refactor(pad): drop the dead Left Stick Navigation toggle (#340) fafc14d0 shipped the toggle with no reader anywhere -- readLeftJoy never checked gEnableAnalogNav and guiShowControllerConfig never synced the dialog row, so the checkbox always rendered off and its edits went nowhere. Stick sensitivity already carries the real control: Off maps to a 128 deadzone which fully gates an axis out of d-pad navigation, per axis. Remove the dead row, enum id, global, and config key rather than wiring a redundant switch. Co-Authored-By: Claude Fable 5 --- include/config.h | 1 - include/dialogs.h | 1 - src/dialogs.c | 5 ----- src/opl.c | 4 ---- 4 files changed, 11 deletions(-) diff --git a/include/config.h b/include/config.h index 9726e092c5..3e9ab59875 100644 --- a/include/config.h +++ b/include/config.h @@ -92,7 +92,6 @@ enum CONFIG_INDEX { #define CONFIG_OPL_ENABLE_COVERART "enable_coverart" #define CONFIG_OPL_ENABLE_BGART "enable_bgart" #define CONFIG_OPL_ENABLE_ART_TAR "enable_art_tar" -#define CONFIG_OPL_ENABLE_ANALOG_NAV "enable_analog_nav" #define CONFIG_OPL_ART_DELAY "art_delay" #define CONFIG_OPL_WIDESCREEN "wide_screen" #define CONFIG_OPL_DEFAULT_GAME_VIEW "default_game_view" diff --git a/include/dialogs.h b/include/dialogs.h index f59f42c526..aabf7ca59d 100644 --- a/include/dialogs.h +++ b/include/dialogs.h @@ -20,7 +20,6 @@ enum UI_ITEMS { UICFG_COVERART, UICFG_ENABLE_BGART, UICFG_ENABLE_ART_TAR, - UICFG_ENABLE_ANALOG_NAV, UICFG_ART_DELAY, UICFG_WIDESCREEN, UICFG_AUTOREFRESH, diff --git a/src/dialogs.c b/src/dialogs.c index 06a9056593..1ea74c3560 100644 --- a/src/dialogs.c +++ b/src/dialogs.c @@ -1624,11 +1624,6 @@ struct UIItem diaControllerConfig[] = { {UI_ENUM, CFG_YSENSITIVITY, 1, 1, -1, 0, 0, {.intvalue = {0, 0}}}, {UI_BREAK}, - {UI_LABEL, 0, 1, 1, -1, -40, 0, {.label = {"Left Stick Navigation", -1}}}, - {UI_SPACER}, - {UI_BOOL, UICFG_ENABLE_ANALOG_NAV, 1, 1, -1, 0, 0, {.intvalue = {0, 0}}}, - {UI_BREAK}, - // Menu Rumble moved here from the old General Settings (diaConfig) by the layout restructure. {UI_LABEL, 0, 1, 1, -1, -40, 0, {.label = {NULL, _STR_RUMBLE}}}, {UI_SPACER}, diff --git a/src/opl.c b/src/opl.c index c2393a1455..4072fd27d8 100644 --- a/src/opl.c +++ b/src/opl.c @@ -174,7 +174,6 @@ int gEnableNotifications; int gEnableArt; int gEnableBGArt; int gEnableArtTar; -int gEnableAnalogNav; int gArtDelay; int gWideScreen; int gDefaultGameView; @@ -1677,7 +1676,6 @@ static void _loadConfig() configGetInt(configOPL, CONFIG_OPL_ENABLE_COVERART, &gEnableArt); configGetInt(configOPL, CONFIG_OPL_ENABLE_BGART, &gEnableBGArt); configGetInt(configOPL, CONFIG_OPL_ENABLE_ART_TAR, &gEnableArtTar); - configGetInt(configOPL, CONFIG_OPL_ENABLE_ANALOG_NAV, &gEnableAnalogNav); configGetInt(configOPL, CONFIG_OPL_ART_DELAY, &gArtDelay); if (gArtDelay != 0 && gArtDelay != 2 && gArtDelay != 5 && gArtDelay != 8) gArtDelay = 2; @@ -2143,7 +2141,6 @@ static void _saveConfig() configSetInt(configOPL, CONFIG_OPL_ENABLE_COVERART, gEnableArt); configSetInt(configOPL, CONFIG_OPL_ENABLE_BGART, gEnableBGArt); configSetInt(configOPL, CONFIG_OPL_ENABLE_ART_TAR, gEnableArtTar); - configSetInt(configOPL, CONFIG_OPL_ENABLE_ANALOG_NAV, gEnableAnalogNav); configSetInt(configOPL, CONFIG_OPL_ART_DELAY, gArtDelay); configSetInt(configOPL, CONFIG_OPL_WIDESCREEN, gWideScreen); configSetInt(configOPL, CONFIG_OPL_DEFAULT_GAME_VIEW, gDefaultGameView); @@ -3064,7 +3061,6 @@ static void setDefaults(void) // reverted at NathanNeurotic's call -- users who want the archive turn it on. The engine fixes from // #207 (uncapped-seek index integrity, no stat()-latch, toggle re-arm, the [48] filename bound) stay. gEnableArtTar = 0; - gEnableAnalogNav = 1; gArtDelay = 2; gWideScreen = 1; gDefaultGameView = GAME_VIEW_BOTH; From 7e45c529e575e9b691764b49f90a67645237ff51 Mon Sep 17 00:00:00 2001 From: "NathanNeurotic (Ripto)" <109461996+NathanNeurotic@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:32:22 -0700 Subject: [PATCH 3/3] fix(pad): make miss detection per-pad per adversarial review (#340) The first cut decided 'miss' per poll: any pad producing a fresh sample masked another pad's miss, so with two pads -- or a PADEMU ds34 alongside a native pad, exactly the USB-heavy setup that starves SIO2 the most -- the pause never engaged and the HUD counted nothing. Track the held bits of each ready pad that produced no sample (pollMissedHeld) and drive the repeat pause, the activity stamp, and the miss counters from that. Also drop a carried sample once nothing supplies its port any more (a ds34 pad that stopped reporting), so a stale press cannot pin the repeat pause or the self-heal idle gate open forever. Co-Authored-By: Claude Fable 5 --- include/pad.h | 4 ++-- src/pad.c | 51 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/include/pad.h b/include/pad.h index 9be08f108e..5878fd2951 100644 --- a/include/pad.h +++ b/include/pad.h @@ -33,8 +33,8 @@ void unloadPads(); // Colors is on (gui.c / dia.c). Field semantics live with the writers in pad.c. typedef struct { - unsigned int readMisses; // total failed ready-state pad reads - unsigned int missBurst; // current consecutive-miss run, max across pads (polls) + unsigned int readMisses; // polls where at least one ready-state pad produced no fresh sample + unsigned int missBurst; // current consecutive run of such polls (0 when all ready pads read) unsigned int missBurstMax; // session peak of the above unsigned int pollMaxMs; // worst readPads() period this session unsigned int stateFlaps; // DISCONN -> ready reconnect edges seen diff --git a/src/pad.c b/src/pad.c index b825977d7c..1b3331ec73 100644 --- a/src/pad.c +++ b/src/pad.c @@ -138,11 +138,14 @@ static u32 oldpaddata; static pad_diag_t padDiag; // Per-poll read outcome, reset by readPads() and filled in by each readPad(): how many pads were -// in a ready state, and how many produced a fresh sample. A poll with ready pads and zero fresh -// samples is a read MISS -- the event the repeat loop must tolerate (#340). Misses cluster under -// SIO2 load on real hardware; an emulator's pad never produces one. +// in a ready state, how many produced a fresh sample, and the held bits carried by pads that +// were ready but produced NO sample -- a per-pad read MISS, the event the repeat loop must +// tolerate (#340). Per-pad, not per-poll: with two pads (or a PADEMU ds34 alongside a native +// pad) another pad's successful read must not mask this pad's miss. Misses cluster under SIO2 +// load on real hardware; an emulator's pad never produces one. static int pollPadsReady; static int pollPadsRead; +static u32 pollMissedHeld; void padGetDiag(pad_diag_t *out) { @@ -565,6 +568,17 @@ static int readPad(struct pad_data_t *pad) // no successful read: baseline behavior (do not carry state) } + if (pad->state == PAD_STATE_DISCONN && padsRead == 0) { + // Nothing is supplying this port any more (a ds34 pad that stopped reporting; native + // unplugs were already zeroed on the DISCONN transition above). Without this, the last + // sample would sit in edgedata forever and hold the repeat pause / activity stamp open. + pad->paddata = 0; + } else if (isPadReadyState(pad->state) && padsRead == 0) { + // This pad MISSED: ready state, no fresh sample. Remember its carried held bits for the + // repeat loop and the activity stamp. + pollMissedHeld |= pad->paddata; + } + edgedata |= pad->paddata; return rcode; @@ -828,29 +842,32 @@ int readPads() pollPadsReady = 0; pollPadsRead = 0; + pollMissedHeld = 0; for (i = 0; i < pad_count; ++i) result |= readPad(&pad_data[i]); - // A read MISS: at least one pad is connected but none produced a fresh sample this poll. - // paddata is missing the held bits for the frame while edgedata still carries the last valid - // sample (readPad ORs it in unconditionally). The counters feed the Debug-Colors HUD line; - // they had no writers between the PR #328 revert and this fix, so the HUD showed miss:0 - // regardless of what the hardware was doing. - int readMissPoll = (pollPadsReady > 0 && pollPadsRead == 0); - if (readMissPoll) { + // A read MISS poll: at least one ready pad produced no fresh sample (per-pad accounting -- + // another pad reading fine does not hide it). During a miss the missing pad's held bits are + // absent from paddata while pollMissedHeld/edgedata carry its last valid sample. The counters + // feed the Debug-Colors HUD line; they had no writers between the PR #328 revert and this + // fix, so the HUD showed miss:0 regardless of what the hardware was doing. + if (pollPadsRead < pollPadsReady) { padDiag.readMisses++; padDiag.missBurst++; if (padDiag.missBurst > padDiag.missBurstMax) padDiag.missBurstMax = padDiag.missBurst; - } else if (pollPadsRead > 0) { + } else { + // Every ready pad read (or no pads are connected at all): no miss run in progress. padDiag.missBurst = 0; } // Stamp input activity AFTER the merge: any held button/stick re-arms the PAD_SELF_HEAL_IDLE_MS gate. - // A miss poll with a carried held sample counts as activity too -- the user is almost certainly + // A missing pad's carried held sample counts as activity too -- the user is almost certainly // still holding, and the misses themselves cluster exactly when an inline initializePad (250-600 ms - // GUI blackout) would hurt the most (#271/#272). - if (paddata != 0 || (readMissPoll && edgedata != 0)) + // GUI blackout) would hurt the most (#271/#272). Known residual: a pad wedged forever in a + // ready state with a held last sample keeps the self-heal deferred for the session -- accepted, + // since an EE-side re-init cannot fix a starved IOP-side driver anyway. + if (paddata != 0 || pollMissedHeld != 0) lastInputActivityMs = curtime; // Rumble duration is millisecond-based because some paths poll twice in one frame. @@ -883,13 +900,13 @@ int readPads() // read at all, so that reset turned every transient SIO2 miss into a 300-1500 ms repeat stall // while holding a direction on real hardware (#272/#340: the "hangs on the 3rd item" pattern; // the pause was HW-validated at Beta-3442 and lost in the PR #328 revert). PAUSE the countdown - // instead when the miss poll's edgedata still carries the held key: a key genuinely released - // during a blind window resets on the first good read, when edgedata drops it. On an emulator + // instead while a MISSING pad's carried sample still holds the key: a key genuinely released + // during a blind window resets on the first good read, when the carry drops it. On an emulator // (no misses) this loop is byte-for-byte the baseline behavior. for (i = 0; i < 16; ++i) { if (getKeyPressed(i + 1)) delaycnt[i] -= (int)time_since_last; - else if (!(readMissPoll && (edgedata & keyToPad[i + 1]))) + else if (!(pollMissedHeld & keyToPad[i + 1])) delaycnt[i] = getKeyDelay(i + 1, 0); }