From ac8ffc3f3a96f5012505ed9f6cd07781cf5f04c4 Mon Sep 17 00:00:00 2001 From: kuba Date: Sat, 26 Sep 2026 13:19:39 +0200 Subject: [PATCH] [Pad] Reject pad handle 0 after the title opens a pad (#912) Unreal Engine polls scePadReadState for all four user slots and passes handle 0 for slots it never opened. Accepting handle 0 as the primary pad reported three phantom controllers that repeated the primary pad's buttons, and Poppy Playtime ignored all menu input. Handle 0 stays valid until the title opens a pad, for Monster Truck Championship's early polling. --- src/SharpEmu.Libs/Pad/PadExports.cs | 16 ++++++++--- .../Pad/PadExportsTests.cs | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index a328691..6668fed 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -24,10 +24,17 @@ public static class PadExports private const int ControllerInformationSize = 0x1C; private const int PadDataSize = 0x78; - // Real firmware hands out small non-negative handles; 0 is valid. Some titles - // (Monster Truck Championship) read pad state with handle 0, and rejecting it - // leaves their controller/FFB init path polling a never-valid state forever. - private static bool IsPrimaryPadHandle(int handle) => handle is 0 or PrimaryPadHandle; + // Monster Truck Championship reads pad state with handle 0 before it opens a pad, + // and rejecting that leaves its controller/FFB init path polling forever. After a + // title opens a pad, handle 0 is a slot it never opened: Unreal Engine polls all four + // user slots and treats every valid handle as a connected controller, so accepting + // 0 there reports phantom pads that repeat the primary pad's buttons. + private static int _padOpened; + + private static bool IsPrimaryPadHandle(int handle) => + handle == PrimaryPadHandle || (handle == 0 && Volatile.Read(ref _padOpened) == 0); + + internal static void ResetOpenedPadForTests() => Volatile.Write(ref _padOpened, 0); private static readonly long InputSampleIntervalTicks = Math.Max(1, Stopwatch.Frequency / 1000); [ThreadStatic] @@ -128,6 +135,7 @@ public static class PadExports : "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense or Xbox controller will be used automatically when plugged in."); } + Volatile.Write(ref _padOpened, 1); return ctx.SetReturn(PrimaryPadHandle); } diff --git a/tests/SharpEmu.Libs.Tests/Pad/PadExportsTests.cs b/tests/SharpEmu.Libs.Tests/Pad/PadExportsTests.cs index 5cf8852..df447c3 100644 --- a/tests/SharpEmu.Libs.Tests/Pad/PadExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Pad/PadExportsTests.cs @@ -73,4 +73,31 @@ public sealed class PadExportsTests _ctx[CpuRegister.Rsi] = Base + 0x100; Assert.Equal(InvalidHandle, PadExports.PadGetTriggerEffectState(_ctx)); } + + [NativeX64Fact] + public void ReadState_RejectsHandleZeroOnceAPadIsOpen() + { + const ulong dataAddress = Base + 0x200; + try + { + PadExports.PadInit(_ctx); + _ctx[CpuRegister.Rdi] = 0x10000000; + _ctx[CpuRegister.Rsi] = 0; + _ctx[CpuRegister.Rdx] = 0; + _ctx[CpuRegister.Rcx] = 0; + Assert.Equal(1, PadExports.PadOpen(_ctx)); + + _ctx[CpuRegister.Rdi] = 0; + _ctx[CpuRegister.Rsi] = dataAddress; + Assert.Equal(InvalidHandle, PadExports.PadReadState(_ctx)); + + _ctx[CpuRegister.Rdi] = 1; + _ctx[CpuRegister.Rsi] = dataAddress; + Assert.Equal(0, PadExports.PadReadState(_ctx)); + } + finally + { + PadExports.ResetOpenedPadForTests(); + } + } }