Skip to content

Gamepad buttons stick: the third argument is a value, not an action - #124

Open
SLYAPA wants to merge 2 commits into
luohoa97:mainfrom
SLYAPA:fix/gamepad-button-value
Open

SLYAPA wants to merge 2 commits into
luohoa97:mainfrom
SLYAPA:fix/gamepad-button-value

Conversation

@SLYAPA

@SLYAPA SLYAPA commented Oct 25, 2026 •

Copy link
Copy Markdown

Press A once with a gamepad in a Roblox game and the character jumps forever; B, X and Y behave the same way. That is what #113 describes ("the buttons start pressing on their own"), and I could reproduce it with an 8BitDo in Bee Swarm.

The cause is the third argument to nativeGamepadButtonEvent. It is the button's value - 1 while held, 0 once released - not Android's KeyEvent action, which is what Cordial was sending. The engine keeps the argument as the event's value and its input pipeline reads a non-zero value as held, so every button registered on its release and then stayed held until its next press. A mobile jump button repeats while it is held, which is why the character never comes down.

How I got there: on engine 2.741.0.1061 (the flag at 0x7334cb0 reads 0, so the legacy path runs) I opened the Roblox in-game menu and sent a lone action=0, which did nothing, and then a lone action=1, which toggled the menu. Same result twice, for B and for Start. With the conversion in place the menu opens on the press instead, the in-game Token shop opens on the press, and a real 8BitDo in Bee Swarm produced 24 balanced events - every press action=0 value=1, every release action=1 value=0.

The conversion sits in deliver_gamepad_button, the one place buttons are delivered, so callers keep speaking Android's action and devctl padbutton keeps taking down|up. A unit test pins it.

One caveat: the legacy path also derives a 2*isDown flag from the argument, and this inverts that. Nothing observable follows the flag, and path A (flag on) uses only the value, so I treat the value as the contract in both. I only measured one engine build.

The second commit is independent and can be dropped: it tracks which buttons a pad is holding and releases them when Cordial drops the pad, and replays the pressed records of a pad's init burst. Without it, switching controllers off in Settings leaves the engine believing the buttons are still down, which reads as the same bug.

nativeGamepadButtonEvent's third argument is the button's value, not
KeyEvent's action: 1 is held, 0 is released. The engine stores it as the
event's value and its input pipeline reads a non-zero value as held.

Cordial sent the action, so every button registered on its release and stayed
held until its next press. A game that reads a held jump button -- a mobile
jump control, which is what the Android client draws -- repeats the jump for
as long as the button is held, so one A press left the character jumping
without end. That is luohoa97#113's "the buttons start pressing on their own", and the
8BitDo report behind it.

Measured on engine 2.741.0.1061 (the flag at 0x7334cb0 reads 0, so the legacy
path runs): with the Roblox in-game menu open, a lone action=0 left the menu
untouched and a lone action=1 toggled it, twice over, for B and for Start. On
the patched build the menu opens on the press and the in-game Token shop opens
on the press, and a real 8BitDo in Bee Swarm produced 24 balanced events, every
press action=0 value=1 and every release action=1 value=0.

The conversion lives in deliver_gamepad_button, the single delivery point, so
callers keep speaking Android's action and devctl padbutton keeps taking
down|up. The legacy path also derives a 2*isDown flag from the argument, which
this inverts; nothing observable follows that flag, and path A (flag on) uses
only the value, so the value is the contract in both.
A pad that is unplugged, or a joydev node that disappears, left the engine
believing every button it had down was still down. An unplug gets its releases
from the kernel, which emits one per held key when a device is destroyed
(measured: A held on a destroyed uinput pad arrives as key=96, action=1, before
ENODEV). Switching controllers off in Settings only closes the file, and then
nothing sends the release at all: the engine keeps the button pressed for the
rest of the run, which in a game reads as the button pressing itself long after
it was let go.

Track the buttons each pad is holding in a bitmask (Pad.held) and, on withdraw,
send one release per named button before the disconnect. The mask follows the
event that was actually sent, so a button the layout cannot name is never
released later either. Indices past 31 are not tracked; no pad here declares
more than sixteen buttons.

A pad opened under a finger is also out of step, in the other direction: its
init burst is the pad's current state, and the pressed records in it were
dropped with the rest of the burst. Replay the ones that are down after the
announcement, and only those -- every pad opens with most buttons up, and
eleven no-op releases per plug-in are noise in the trace, not information.
Axes are left out on purpose: a stick that is already off centre corrects
itself on its next event, and a trigger at rest reads full-scale on some pads.

Four unit tests: the mask follows press and release, release_held sends one up
per named button and clears, a button held at withdraw is released before the
disconnect, and an init burst replays only the buttons that are down.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant