From 68b9f108ab5ab23e2891297fed5dfbb35f777d89 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 13 Jul 2026 11:14:26 +0200 Subject: [PATCH] feat(gamepad/apple): send Share/Create as BTN_MISC1 + pin wire bits to the C ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G5: buttonMask mapped the dedicated share/create/capture element onto BTN_BACK, the same bit as View (buttonOptions). On an Xbox-Series pad those are two distinct physical buttons, so Share was indistinguishable from View on the host and never delivered the capture bit the host already decodes (DualSense mute / Steam quick-access). Route it to BTN_MISC1 instead, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. Adds `misc1` to GamepadWire and allButtons so a held capture button is released on flush like the others. (On-glass verify owed on a real Xbox-Series pad; a clone pad that exposes one button as both buttonOptions and Share now emits back+misc1 for it — harmless on a plain xpad session and rare otherwise.) G22 (partial): define paddle1..4 for wire completeness, but leave them out of buttonMask/allButtons until the GameController paddleButton1..4 ↔ BTN_PADDLE physical correspondence is confirmed on a real Elite pad. G15: replace the 3-bit spot-check with an exhaustive assertion of every GamepadWire button/axis constant against the generated C ABI header (punktfunk_core.h), so any Swift-side drift from punktfunk_core::input::gamepad fails CI. swift build + full PunktfunkKit suite green (124 passed, 5 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 14 +++--- .../PunktfunkKit/Gamepad/GamepadWire.swift | 18 +++++++- .../PunktfunkKitTests/GamepadWireTests.swift | 45 ++++++++++++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 44da56d5..fa251b71 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -305,11 +305,15 @@ public final class GamepadCapture { if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } - // The share/create/capture element (Xbox Series share, a clone pad's screenshot button — - // e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose - // the create button BOTH as buttonOptions and as the share element this OR is harmless — - // same wire bit. - if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back } + // The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone + // pad's screenshot button — e.g. the GameSir G8's, below its d-pad) → the wire's capture + // bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad + // this is a button physically DISTINCT from View (buttonOptions, above), so it must not + // collapse onto back — the host reads MISC1 as its own control (DualSense mute / Steam + // quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and + // this share element now emits back+misc1 for it — harmless on a plain xpad session (no + // misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad. + if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift index 0d90c7cc..b2fae0d0 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift @@ -26,11 +26,27 @@ public enum GamepadWire { public static let y: UInt32 = 0x8000 /// DualSense touchpad click (Moonlight's extended-button bit position). public static let touchpadClick: UInt32 = 0x10_0000 + /// Misc / capture button — Xbox-Series Share, DualSense Create, Steam-Deck quick-access + /// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to + /// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button. + public static let misc1: UInt32 = 0x0020_0000 + /// Back-grip paddles (Xbox Elite P1–P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in + /// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5). + /// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not + /// read them yet — the GameController `paddleButton1..4` ↔ BTN_PADDLE physical correspondence + /// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so + /// they are intentionally absent from `allButtons` until that forwarding lands. + public static let paddle1: UInt32 = 0x0001_0000 + public static let paddle2: UInt32 = 0x0002_0000 + public static let paddle3: UInt32 = 0x0004_0000 + public static let paddle4: UInt32 = 0x0008_0000 + /// Every button `buttonMask`/`sendGuide` can set — walked by `sync`'s transition diff and by + /// `flush` on release. Paddles are excluded until their capture lands (see above). public static let allButtons: [UInt32] = [ dpadUp, dpadDown, dpadLeft, dpadRight, start, back, leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, - a, b, x, y, touchpadClick, + a, b, x, y, touchpadClick, misc1, ] public static let axisLSX: UInt32 = 0 diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift index eee4c8c8..f93e8f38 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift @@ -27,11 +27,16 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.x, 0x4000) XCTAssertEqual(GamepadWire.y, 0x8000) XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000) + XCTAssertEqual(GamepadWire.misc1, 0x0020_0000) // Every button is enumerated exactly once (releaseAll walks this list). let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 } - XCTAssertEqual(combined, 0x0010_F7FF) - XCTAssertEqual(GamepadWire.allButtons.count, 16) + XCTAssertEqual(combined, 0x0030_F7FF) + XCTAssertEqual(GamepadWire.allButtons.count, 17) XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count) + // Paddles are defined but not yet forwarded, so they stay out of allButtons for now. + for paddle in [GamepadWire.paddle1, GamepadWire.paddle2, GamepadWire.paddle3, GamepadWire.paddle4] { + XCTAssertFalse(GamepadWire.allButtons.contains(paddle)) + } // Axis ids. XCTAssertEqual(GamepadWire.axisLSX, 0) XCTAssertEqual(GamepadWire.axisLSY, 1) @@ -41,6 +46,42 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.axisRT, 5) } + func testButtonBitsMatchTheCABIVerbatim() { + // Assert EVERY wire constant against the generated C ABI header (punktfunk_core.h, the same + // source `punktfunk_core::input::gamepad` emits), so a Swift-side edit that drifts from the + // Rust contract fails CI — not just the handful spot-checked above. (Cross-cutting review + // finding G15: the button values were re-declared per client with only a 3-of-19 check.) + XCTAssertEqual(GamepadWire.dpadUp, UInt32(PUNKTFUNK_BTN_DPAD_UP)) + XCTAssertEqual(GamepadWire.dpadDown, UInt32(PUNKTFUNK_BTN_DPAD_DOWN)) + XCTAssertEqual(GamepadWire.dpadLeft, UInt32(PUNKTFUNK_BTN_DPAD_LEFT)) + XCTAssertEqual(GamepadWire.dpadRight, UInt32(PUNKTFUNK_BTN_DPAD_RIGHT)) + XCTAssertEqual(GamepadWire.start, UInt32(PUNKTFUNK_BTN_START)) + XCTAssertEqual(GamepadWire.back, UInt32(PUNKTFUNK_BTN_BACK)) + XCTAssertEqual(GamepadWire.leftStickClick, UInt32(PUNKTFUNK_BTN_LS_CLICK)) + XCTAssertEqual(GamepadWire.rightStickClick, UInt32(PUNKTFUNK_BTN_RS_CLICK)) + XCTAssertEqual(GamepadWire.leftShoulder, UInt32(PUNKTFUNK_BTN_LB)) + XCTAssertEqual(GamepadWire.rightShoulder, UInt32(PUNKTFUNK_BTN_RB)) + XCTAssertEqual(GamepadWire.guide, UInt32(PUNKTFUNK_BTN_GUIDE)) + XCTAssertEqual(GamepadWire.a, UInt32(PUNKTFUNK_BTN_A)) + XCTAssertEqual(GamepadWire.b, UInt32(PUNKTFUNK_BTN_B)) + XCTAssertEqual(GamepadWire.x, UInt32(PUNKTFUNK_BTN_X)) + XCTAssertEqual(GamepadWire.y, UInt32(PUNKTFUNK_BTN_Y)) + XCTAssertEqual(GamepadWire.touchpadClick, UInt32(PUNKTFUNK_BTN_TOUCHPAD)) + XCTAssertEqual(GamepadWire.misc1, UInt32(PUNKTFUNK_GAMEPAD_BTN_MISC1)) + XCTAssertEqual(GamepadWire.paddle1, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE1)) + XCTAssertEqual(GamepadWire.paddle2, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE2)) + XCTAssertEqual(GamepadWire.paddle3, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE3)) + XCTAssertEqual(GamepadWire.paddle4, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE4)) + // Axis ids and pad count share the same header. + XCTAssertEqual(GamepadWire.axisLSX, UInt32(PUNKTFUNK_AXIS_LS_X)) + XCTAssertEqual(GamepadWire.axisLSY, UInt32(PUNKTFUNK_AXIS_LS_Y)) + XCTAssertEqual(GamepadWire.axisRSX, UInt32(PUNKTFUNK_AXIS_RS_X)) + XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y)) + XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT)) + XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT)) + XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS)) + } + func testPadIndexRidesFlagsOnEveryPerPadEvent() { // The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis. let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3)