From 93371b17828b41b198dfb105fb65f3bbf669a794 Mon Sep 17 00:00:00 2001 From: Beman Date: Mon, 5 Oct 2026 01:07:18 +1000 Subject: [PATCH] Preserve ordered mouse re-press after delayed release (cherry picked from commit 50ec9191c2e082a3c68c15b370059f8916b75ff1) --- docs/LOCAL_MOUSE_POLICY.md | 12 +++- runtime/src/local_mouse_policy.cpp | 9 +++ runtime/tests/test_local_mouse_policy.cpp | 67 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/LOCAL_MOUSE_POLICY.md b/docs/LOCAL_MOUSE_POLICY.md index b5d0cade..d43587c3 100644 --- a/docs/LOCAL_MOUSE_POLICY.md +++ b/docs/LOCAL_MOUSE_POLICY.md @@ -45,6 +45,11 @@ routing/removal, invalid data, capture failure and >250 ms host stalls release capture and reset the policy. Stale, backwards and future motion cannot extend a pulse. Releases remain immediate even with anomalous timestamps; their watermark cannot admit old motion after a cleared held target. A +valid ordered LEFT release while capture remains eligible uses its actual +event timestamp as the acquisition boundary. A later physical press in the +same delayed SDL drain can therefore acquire a neutral new session. Lifecycle, +stall and malformed-release resets retain the processing-time barrier; a release +after such a reset cannot lower it to admit queued input. A stall's backlog cannot acquire capture again. Eligibility recovery needs a release/repress of LEFT and fresh motion. Reset retains physical LEFT down-state, so holding it across an interruption cannot recapture. Debug input injection, headless, netplay, replay/resimulation and @@ -81,6 +86,9 @@ pure policy tests do not exercise the OS or game dispatcher. The reviewed implementation base is release framework commit 065888f50f9131839bcbbc8814debf58b4624b16, also the canonical `release/ape-v0.5.0-hle-support` branch. Current upstream master has differing -existing mod APIs and needs separate compatibility validation. Final game-pin -reachability/CI and physical interruption checks are pending; this is not a +existing mod APIs and needs separate compatibility validation. The linked game +draft pins this runtime draft's reachable head through its configured framework +upstream; final-pin checkout, build and local test validation are recorded with +the drafts. Hosted CI status and physical interruption checks are reported +separately; this is not a public-quality or merge-readiness claim. diff --git a/runtime/src/local_mouse_policy.cpp b/runtime/src/local_mouse_policy.cpp index 0c23535e..ba76fa4b 100644 --- a/runtime/src/local_mouse_policy.cpp +++ b/runtime/src/local_mouse_policy.cpp @@ -86,9 +86,18 @@ bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, if (!down) { // Release is unconditional: a future/backwards event time cannot // leave capture or the reducer's separate direction hold active. + // A valid ordered release while still eligible owns its event + // boundary, rather than the later time at which SDL drains it. + // After an interruption or malformed release, retain reset()'s + // processing-time barrier so queued input cannot rearm capture. + const bool ordered_release = captured_ && allowed() && + t <= now && now - t <= StallNs && t >= release_clock_ && + (!event_valid_ || t >= event_clock_) && + (!clock_valid_ || (now >= clock_ && now - clock_ <= StallNs)); const bool owned = activation_owned_; activation_owned_ = false; if (captured_ || owned) reset(); + if (ordered_release) release_clock_ = t; return owned; } // reset() retains physical down-state. A held button after a menu, diff --git a/runtime/tests/test_local_mouse_policy.cpp b/runtime/tests/test_local_mouse_policy.cpp index 9a34267f..de7c77de 100644 --- a/runtime/tests/test_local_mouse_policy.cpp +++ b/runtime/tests/test_local_mouse_policy.cpp @@ -173,6 +173,73 @@ int main() { CHECK(policy.control(t+15*Ms,t+15*Ms,psx::MouseControl::Left,false,false)); t+=20*Ms; + // SDL may drain several ordered edges after their physical timestamps. + // A normal release must not reject a later re-press in that same drain. + for (uint64_t delay : {10*Ms, 50*Ms, 200*Ms}) { + psx::LocalMousePolicy batch({nullptr,capture,suppress,notice}); + CHECK(batch.install(&callbacks)); + const uint64_t base=1000*Ms+delay*10; + batch.update(base+10*Ms,host); + CHECK(batch.control(base+10*Ms,base+10*Ms,psx::MouseControl::Left,true,false)); + batch.motion(base+15*Ms,base+15*Ms,30,0); + batch.control(base+16*Ms,base+16*Ms,psx::MouseControl::Right,true,false); + batch.motion(base+17*Ms,base+17*Ms,30,0); CHECK(active && held); + batch.update(base+20*Ms+delay,host); + CHECK(batch.control(base+20*Ms,base+20*Ms+delay,psx::MouseControl::Left,false,false)); + CHECK(!batch.captured() && !active && !held); + x=99;y=77;batch.sample(base+20*Ms+delay,x,y);CHECK(x==99 && y==77); + batch.update(base+21*Ms+delay,host); + CHECK(batch.control(base+25*Ms,base+21*Ms+delay,psx::MouseControl::Left,true,false)); + CHECK(batch.captured() && !active && !held); + batch.sample(base+21*Ms+delay,x,y);CHECK(x==128 && y==128); + batch.motion(base+26*Ms,base+22*Ms+delay,30,0);CHECK(active && !held); + batch.control(base+27*Ms,base+23*Ms+delay,psx::MouseControl::Right,false,false); + CHECK(active && !held); // A physically held RIGHT cannot latch or cancel this flick. + CHECK(batch.control(base+28*Ms,base+24*Ms+delay,psx::MouseControl::Left,false,false)); + CHECK(!batch.captured() && !active && !held); + batch.clear(); + } + + // A release after an interruption cannot lower its processing-time barrier. + for (int gate=0;gate<4;++gate) { + psx::LocalMousePolicy batch({nullptr,capture,suppress,notice}); + CHECK(batch.install(&callbacks)); + const uint64_t base=5000*Ms+gate*1000*Ms; + batch.update(base+10*Ms,host); + CHECK(batch.control(base+10*Ms,base+10*Ms,psx::MouseControl::Left,true,false)); + batch.motion(base+15*Ms,base+15*Ms,30,0); + auto off=host;const uint64_t barrier=base+(gate==3?300:30)*Ms; + if(gate==0)off.focused=false;if(gate==1)guest=false;if(gate==2)off.native_right=true; + batch.update(barrier,off);CHECK(!batch.captured() && !active); + guest=true;batch.update(barrier+Ms,host); + CHECK(batch.control(base+20*Ms,barrier+2*Ms,psx::MouseControl::Left,false,false)); + CHECK(!batch.control(base+25*Ms,barrier+3*Ms,psx::MouseControl::Left,true,false)); + CHECK(!batch.captured() && !active && !held); + batch.control(barrier+4*Ms,barrier+4*Ms,psx::MouseControl::Left,false,false); + batch.update(barrier+5*Ms,host); + CHECK(batch.control(barrier+5*Ms,barrier+5*Ms,psx::MouseControl::Left,true,false)); + batch.sample(barrier+5*Ms,x,y);CHECK(x==128 && y==128); + batch.motion(barrier+6*Ms,barrier+6*Ms,30,0);CHECK(active); + batch.clear(); + } + + // Malformed releases are still immediate, but cannot admit a queued press. + for (uint64_t bad : {UINT64_MAX, uint64_t(14*Ms), uint64_t(0)}) { + psx::LocalMousePolicy batch({nullptr,capture,suppress,notice}); + CHECK(batch.install(&callbacks));batch.update(10*Ms,host); + CHECK(batch.control(10*Ms,10*Ms,psx::MouseControl::Left,true,false)); + batch.motion(15*Ms,15*Ms,30,0);batch.update(30*Ms,host); + CHECK(batch.control(bad,30*Ms,psx::MouseControl::Left,false,false)); + CHECK(!batch.captured() && !active && !held); + batch.update(31*Ms,host); + CHECK(!batch.control(25*Ms,31*Ms,psx::MouseControl::Left,true,false)); + CHECK(!batch.captured()); + batch.control(32*Ms,32*Ms,psx::MouseControl::Left,false,false); + batch.update(33*Ms,host); + CHECK(batch.control(33*Ms,33*Ms,psx::MouseControl::Left,true,false)); + batch.sample(33*Ms,x,y);CHECK(x==128 && y==128);batch.clear(); + } + policy.reset();host.hold_conflict=false;capture_ok=false;CHECK(acquire());CHECK(!policy.captured()); const unsigned failure_notices=notices;CHECK(acquire());CHECK(notices==failure_notices); policy.clear(); CHECK(!policy.installed() && !policy.captured());