[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.
This commit is contained in:
kuba 2026-09-26 13:19:39 +02:00 committed by GitHub
parent 1759e18b77
commit ac8ffc3f3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 39 additions and 4 deletions

View File

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

View File

@ -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();
}
}
}