Engine Library¶
The PadForge.Engine assembly: data types, interfaces, and enums shared by the input pipeline and the WPF UI. No UI dependencies. Targets net10.0-windows.
The HIDMaestro SDK surface, OpenXInput shim, thread-pool lifecycle, and bubble-down cascade live on HIDMaestro Deep Dive. If anything here drifts from the live source, the live source wins.
graph TB
subgraph "Data Models. PadForge.Engine.Data"
PS[PadSetting<br/>mapping config · deadzones · curves]
US[UserSetting<br/>device-to-slot linkage]
UD[UserDevice<br/>physical device record]
MT[MappingTranslation<br/>cross-layout Copy From]
VJM[RawMappingEntry<br/>custom axis/button/POV maps]
end
subgraph "Output State Types. PadForge.Engine"
GP[Gamepad<br/>XInput-layout struct]
VRS[RawHidState<br/>up to 8 axes · 128 buttons · 4 POVs]
KRS[KbmRawState<br/>256 VK codes · mouse deltas]
MRS[MidiRawState<br/>128 notes · 128 CCs]
VRR[VrRawState<br/>left + right hand pair]
end
subgraph "Device Wrappers. PadForge.Engine"
ISDI[ISdlInputDevice<br/>common interface]
SDW[SdlDeviceWrapper<br/>joystick/gamepad · rumble · haptic · sensors]
SKW[SdlKeyboardWrapper<br/>per-device keyboard]
SMW[SdlMouseWrapper<br/>per-device mouse]
WCD[WebControllerDevice<br/>browser gamepad]
TOD[TouchpadOverlayDevice<br/>on-screen touchpad window]
end
subgraph "Force Feedback"
FFS[ForceFeedbackState<br/>per-device FFB tracking]
VIB[Vibration<br/>left + right motor]
end
subgraph "Interfaces"
IVC[IVirtualController<br/>Connect · Submit · Disconnect]
end
US -->|references| PS
US -->|references| UD
PS -->|contains| VJM
PS -->|uses| MT
UD -->|runtime: Device| ISDI
SDW -.->|implements| ISDI
SKW -.->|implements| ISDI
SMW -.->|implements| ISDI
WCD -.->|implements| ISDI
TOD -.->|implements| ISDI
IVC -->|accepts| GP
IVC -->|accepts| VRS
IVC -->|accepts| KRS
IVC -->|accepts| MRS
IVC -->|accepts| VRR
FFS -->|outputs| VIB
style GP fill:#e1f5fe
style PS fill:#e8f5e9
style SDW fill:#f3e5f5
style IVC fill:#fff3e0
style FFS fill:#fce4ec
Project file: PadForge.Engine/PadForge.Engine.csproj
| Namespace | Contents |
|---|---|
PadForge.Engine |
Output state types, device wrappers, force-feedback types, common interfaces, PrecisionTouchpadReader |
PadForge.Engine.Data |
XML-persisted data models (PadSetting, UserSetting, UserDevice, MappingSet, etc.) |
PadForge.Engine.Common |
InputHookManager (LL hook host), (v3.6) ConsumerUsageTable, IdleInputDetector, GlobalHotkeyParser, LfeOutputState (#234), RelativeVelocityWindow, ShiftCycleStepper, (v4.4) HeadPose (#355 head-tracking wire decode), MachineIdentity (#343 SMBIOS identity), VendorReportLearner (+ VendorButtonDefinition / VendorButtonCandidate / VendorButtonKind, #343), HandheldChords (HandheldChordDefinition, ChordDecision, HandheldChordEngine, #343), (v4.5) OpenXrHeadPose (#403 headset pose into the Head Tracker convention), (v5.0) MotionRowsModel + MotionRowValues (#475) |
PadForge.Engine.Common.Mapping |
(v3.2) Multi-source mapping helpers: CombineHelper, SourceEvaluator, SourceCoercion, SourceKindRuntime, TargetKind, MappingExpression |
PadForge.Engine.Haptics |
(v3.6) HD haptic tone path (#147): HapticToneEncoder (per-family wire bytes), HapticToneReducer (PCM to tone), WiiSpeakerAdpcm (Yamaha 4-bit ADPCM, off the live path). (v4.4) TritonPcmEncoder (Steam Controller 2026 PCM stream reports, #381) |
PadForge.Engine.Touchpad |
(v3.3) Touchpad gesture pipeline: GestureRecognizer (Tier 1/2/3 detector), ShapeRecognizer (canonical $Q point-cloud matcher), ShapeTemplate, AngularMarginRecognizer, InBoxShapeTemplates, TouchpadCustomGesture, TouchpadGestureContext, TouchpadGestureSettings, TouchpadSettingsEntry, TouchpadGestureAutoArm, (v4.1) SwipeHapticsEvaluator (swipe-haptic distance detents, #219) |
PadForge.Engine.Mouse |
(v4) Mouse-gesture pipeline (#200): MouseGestureRecognizer (per-button flick classifier), MouseGestureContext, MouseGestureSettings, MouseGestureSettingsEntry |
PadForge.Engine.Menus |
(v4.1) Radial / touch menus (#9 B-17): MenuDefinitionEntry (+ MenuItemDefinition and enums MenuKind / MenuFireType in the same file), MenuSelectionMath, MenuEvaluator, MenuRuntimeState |
PadForge.Engine.RemoteLink |
(v4) Device sharing between PCs (#138): LinkDiscovery, LinkServer, LinkConnection (+ ILinkControlChannel), LinkSession, LinkHandshake, PeerCrypto, PeerIdentity, IdentityProtector, PeerTrust / PeerTrustStore, RemotePeerDevice, CustomInputStateCodec, OutputEffectCodec, AntiReplayWindow, TcpControlChannel, and the (#294 internet) lane: LinkCode, IrohRelayClient, StunClient, NatProfile, PortPredictor, HolePuncher, PunchedConnection, RendezvousProtocol, UdpControlChannel, plus the Dht/ folder. See Remote Link Internals |
PadForge.Engine.Common.OpenXr |
(v4.5) OpenXR headset and motion controller input (#403): OpenXrSession, OpenXrInterop (structs, constants and the negotiation entry point), OpenXrActions (action set and suggested bindings), OpenXrHeadPoseSource, OpenXrRuntimeCatalog, OpenXrHandState (+ OpenXrHand). PadForge talks to the runtime directly rather than through the Khronos loader. See OpenXR Input Internals |
PadForge.Engine.Common.Logitech |
(v4.5) Logitech G-key input (#454): LogitechGKeyInterop (the packed event word), LogitechGKeyMap (the fixed 102-button layout), LogitechGKeyCatalog (library search), LogitechGKeySource (load, callback, teardown). See Logitech G-Keys Internals |
PadForge.Engine.Tablets |
Windows pen and drawing tablet input: WindowsTabletDevice, an Engine-side ISdlInputDevice implementer, and its reader |
SDL3 |
P/Invoke |
Table of Contents¶
- Gamepad (GamepadTypes.cs)
- TouchpadState (GamepadTypes.cs)
- RawHidState (GamepadTypes.cs)
- CustomControllerLayout (CustomControllerLayout.cs)
- KbmRawState (GamepadTypes.cs)
- MidiRawState (GamepadTypes.cs)
- VrRawState (VrRawState.cs)
- VirtualControllerType (VirtualControllerTypes.cs)
- IVirtualController (VirtualControllerTypes.cs)
- CustomInputState (CustomInputState.cs)
- ISdlInputDevice (ISdlInputDevice.cs)
- SdlDeviceWrapper (SdlDeviceWrapper.cs)
- HapticEffectStrategy (SdlDeviceWrapper.cs)
- SdlKeyboardWrapper (SdlKeyboardWrapper.cs)
- SdlMouseWrapper (SdlMouseWrapper.cs)
- ConsumerControlWrapper (ConsumerControlWrapper.cs)
- ConsumerUsageTable (ConsumerUsageTable.cs)
- WebControllerDevice (WebControllerDevice.cs)
- TouchpadOverlayDevice (TouchpadOverlayDevice.cs)
- DeviceObjectItem (DeviceObjectItem.cs)
- InputTypes (InputTypes.cs)
- ForceFeedbackState (ForceFeedbackState.cs)
- FfbEffectTypes (ForceFeedbackState.cs)
- Vibration (ForceFeedbackState.cs)
- ConditionAxisData (ForceFeedbackState.cs)
- InputHookManager (InputHookManager.cs)
- RawInputListener (RawInputListener.cs)
- IdleInputDetector (IdleInputDetector.cs)
- PrecisionTouchpadReader (PrecisionTouchpadReader.cs)
- HeadPose (HeadPose.cs)
- MachineIdentity (MachineIdentity.cs)
- VendorReportLearner (VendorReportLearner.cs)
- MotionRowsModel (MotionRowsModel.cs)
- HapticToneEncoder (Haptics/HapticToneEncoder.cs)
- TritonPcmEncoder (Haptics/TritonPcmEncoder.cs)
- HapticToneReducer (Haptics/HapticToneReducer.cs)
- WiiSpeakerAdpcm (Haptics/WiiSpeakerAdpcm.cs)
- GestureRecognizer (Touchpad/GestureRecognizer.cs)
- ShapeRecognizer (Touchpad/ShapeRecognizer.cs)
- AngularMarginRecognizer (Touchpad/AngularMarginRecognizer.cs)
- ShapeTemplate (Touchpad/ShapeTemplate.cs)
- InBoxShapeTemplates (Touchpad/InBoxShapeTemplates.cs)
- TouchpadCustomGesture (Touchpad/TouchpadCustomGesture.cs)
- TouchpadGestureContext (Touchpad/TouchpadGestureContext.cs)
- TouchpadGestureSettings (Touchpad/TouchpadGestureSettings.cs)
- TouchpadSettingsEntry (Touchpad/TouchpadSettingsEntry.cs)
- SwipeHapticsEvaluator (Touchpad/SwipeHapticsEvaluator.cs)
- MouseGestureRecognizer (Mouse/MouseGestureRecognizer.cs)
- MouseGestureContext (Mouse/MouseGestureContext.cs)
- MouseGestureSettings (Mouse/MouseGestureSettings.cs)
- MouseGestureSettingsEntry (Mouse/MouseGestureSettingsEntry.cs)
- MenuDefinitionEntry (Menus/MenuDefinitionEntry.cs)
- MenuSelectionMath (Menus/MenuSelectionMath.cs)
- MenuEvaluator (Menus/MenuEvaluator.cs)
- PadSetting (Data/PadSetting.cs)
- RawMappingEntry (Data/PadSetting.cs)
- UserSetting (Data/UserSetting.cs)
- UserDevice (Data/UserDevice.cs)
- DeadZoneShape (Data/DeadZoneShape.cs)
- MappingTranslation (Data/MappingTranslation.cs)
- SDL3 P/Invoke (SDL3Minimal.cs)
Gamepad¶
File: PadForge.Engine/Common/GamepadTypes.cs
Namespace: PadForge.Engine
Minimal struct matching the XInput XINPUT_GAMEPAD layout. Output of the mapping pipeline (Step 3 → Step 4 → Step 5).
public struct Gamepad
{
// Fields
public ushort Buttons; // Bitmask of button flags
public ushort LeftTrigger; // 0-65535
public ushort RightTrigger; // 0-65535
public short ThumbLX; // -32768 to 32767
public short ThumbLY; // -32768 to 32767
public short ThumbRX; // -32768 to 32767
public short ThumbRY; // -32768 to 32767
// Out-of-mask extras: all 16 XInput-equivalent bits in Buttons are taken.
public bool Share; // Xbox Series Share (HM bit 12)
public bool MicMute; // DualSense mic mute. SDL misc1, HM HMButton.Misc1
public bool LeftPaddle; // DualSense Edge BACK paddles, wire bits 0x40 / 0x80
public bool RightPaddle;
public bool LeftFunction; // Edge front Fn buttons, 0x10 / 0x20, SDL LEFT/RIGHT_PADDLE2
public bool RightFunction;
// Methods
public bool IsButtonPressed(ushort flag);
public void SetButton(ushort flag, bool pressed);
public void Clear();
}
Share and the five that follow it are standalone bool fields, not Buttons bits. All 16 XInput-equivalent bits are used, so anything past the XInput vocabulary rides outside the mask. HIDMaestro exposes Share as HMButton.Share (bit 12) on Xbox Series profiles, the DualSense mic mute as HMButton.Misc1, and the DualSense Edge paddle and Fn pairs through the second pair HM v1.5.1 added (HM#48).
Button Flag Constants¶
| Constant | Value | Description |
|---|---|---|
DPAD_UP |
0x0001 |
D-pad up |
DPAD_DOWN |
0x0002 |
D-pad down |
DPAD_LEFT |
0x0004 |
D-pad left |
DPAD_RIGHT |
0x0008 |
D-pad right |
START |
0x0010 |
Start button |
BACK |
0x0020 |
Back button |
LEFT_THUMB |
0x0040 |
Left stick click |
RIGHT_THUMB |
0x0080 |
Right stick click |
LEFT_SHOULDER |
0x0100 |
Left bumper |
RIGHT_SHOULDER |
0x0200 |
Right bumper |
GUIDE |
0x0400 |
Guide/home button |
TOUCHPAD |
0x0800 |
Touchpad click. Output-side bitmask in Gamepad.Buttons. Mirrors CustomInputState.Buttons[16] on the input side (= SDL_GAMEPAD_BUTTON_TOUCHPAD). |
A |
0x1000 |
A button |
B |
0x2000 |
B button |
X |
0x4000 |
X button |
Y |
0x8000 |
Y button |
Methods¶
| Method | Signature | Description |
|---|---|---|
IsButtonPressed |
bool IsButtonPressed(ushort flag) |
true if the button flag bit is set in Buttons |
SetButton |
void SetButton(ushort flag, bool pressed) |
Sets or clears a button flag bit via bitwise OR/AND |
Clear |
void Clear() |
Resets all fields to zero, including Share to false |
TouchpadState¶
File: PadForge.Engine/Common/GamepadTypes.cs
Namespace: PadForge.Engine
Two-finger touchpad surface state for PlayStation slots. Step 5's HMaestroVirtualController.SubmitGamepadState overload takes a TouchpadState alongside the Gamepad struct so games see touchpad finger positions on the DS4 / DualSense extended report.
public struct TouchpadState
{
public float X0; // Finger 0 X (0.0–1.0, left → right)
public float Y0; // Finger 0 Y (0.0–1.0, top → bottom)
public float X1; // Finger 1 X
public float Y1; // Finger 1 Y
public bool Down0; // Finger 0 contact state
public bool Down1; // Finger 1 contact state
public bool Click; // Touchpad click button
public byte PacketCounter; // Increments on each finger down/up edge (DS4_TOUCH encoding)
}
X / Y coordinates are normalized [0, 1] across the active touch surface. PacketCounter increments only on finger-state transitions (not every frame) so the DS4 / DualSense touch encoder can fire its own internal touch-event accounting.
RawHidState¶
File: PadForge.Engine/Common/GamepadTypes.cs
Namespace: PadForge.Engine
Raw output state for Extended-category and Nintendo virtual controllers and custom HID descriptors. Bypasses the fixed Gamepad struct to support configurable axis, button, and POV counts. Step 5 forwards this directly to HIDMaestro via HMaestroVirtualController.SubmitRawHidState, which since 4.1.0 also carries a MotionSnapshot argument for the gyro-passthrough IMU channel (HM v1.3.18). The struct was named ExtendedRawState before the 4.1.0 raw-surface grammar rename.
public struct RawHidState
{
public short[] Axes; // Up to 8 axes (signed short range -32768..32767)
public uint[] Buttons; // Button state as 4 x 32-bit words = 128 buttons max
public int[] Povs; // Up to 4 POV hat switches (-1=centered, 0-35900=direction)
public short[] HardwareAxes; // Pre-tuning snapshot of Axes (#174). Runtime-only, null unless populated
public static RawHidState Create(int nAxes, int nButtons, int nPovs);
public void SetButton(int index, bool pressed);
public bool IsButtonPressed(int index);
public void Clear();
public void ClearToRest(in CustomControllerLayout layout);
}
Methods¶
| Method | Signature | Description |
|---|---|---|
Create |
static RawHidState Create(int nAxes, int nButtons, int nPovs) |
Factory. Clamps axes to 8, buttons to 128 (stored as (N+31)/32 uint words), POVs to 4. All zeroed. |
SetButton |
void SetButton(int index, bool pressed) |
Sets button by 0-based index (word = index/32, bit = index%32). No-op if out of range. |
IsButtonPressed |
bool IsButtonPressed(int index) |
true if button at index is set. false if out of range. |
Clear |
void Clear() |
Resets axes to 0, buttons to 0, POVs to −1 (centered). HardwareAxes is the pre-tuning mirror of Axes and clears with it, so a cleared state never carries the last stick sample forward. |
ClearToRest |
void ClearToRest(in CustomControllerLayout layout) |
Clear(), then writes short.MinValue into every axis the layout calls a trigger slot (HardwareAxes too). Use this, not Clear, on the raw surface: zero is the middle of a trigger's travel, so a slot cleared with Clear reports both triggers half pulled. |
Button Storage¶
Buttons use a 128-bit bitmask stored as uint[4] (32 buttons per word).
POV Values¶
Povs is int[]. Hundredths of degrees: 0=N, 4500=NE, 9000=E, 13500=SE, 18000=S, 22500=SW, 27000=W, 31500=NW, −1 = centered.
HardwareAxes¶
(v4) Pre-tuning snapshot of Axes, taken before center offset, boundary reshape, deadzone, and curve (#174 stick boundary calibration). The calibration capture and the preview's cold dot read the frame the samples were recorded in. Runtime-only and absent from every wire / persistence mirror. Null when the producer did not populate it, in which case consumers fall back to Axes.
CustomControllerLayout¶
File: PadForge.Engine/Common/CustomControllerLayout.cs
Namespace: PadForge.Engine
Per-slot HID-descriptor shape for the Extended (custom DirectInput) virtual controller path. Replaces the v2 ExtendedDeviceConfig struct that used to live inside ExtendedVirtualController. The Step 3 → Step 5 pipeline reads these counts to translate per-axis / button / POV mappings into raw HID report indices.
public struct CustomControllerLayout
{
public int Axes; // Total axis report fields (sticks*2 + triggers)
public int Buttons; // Total button report fields
public int Povs; // Total POV (hat) report fields
public int Sticks; // Number of thumbsticks (each consumes 2 of Axes)
public int Triggers; // Number of triggers (each consumes 1 of Axes)
public int TriggerClickButtonMask; // bit i = raw button i is a LeftTriggerClick/RightTriggerClick role (e.g. ZL/ZR), derived at slot sync from the HM profile layout
public bool IsTriggerSlot(int axisIndex);
}
IsTriggerSlot resolves the interleaved-then-trailing axis layout that ExtendedSlotConfig.ComputeAxisLayout produces. Sticks and triggers need different rest-state and combine rules. Centralizing the index → role formula here keeps Step 3 (mapping), Step 4 (multi-device merge), and the deadzone pipeline in agreement even when the layout edits.
KbmRawState¶
File: PadForge.Engine/Common/GamepadTypes.cs
Namespace: PadForge.Engine
Raw keyboard + mouse output state for KeyboardMouseVirtualController. Key states packed into 4 × 64-bit words covering 256 Windows VK codes. Mouse axes are signed shorts (delta per frame).
public struct KbmRawState
{
// Key state (256 VK codes packed into 4 ulongs)
public ulong Keys0; // VK 0-63
public ulong Keys1; // VK 64-127
public ulong Keys2; // VK 128-191
public ulong Keys3; // VK 192-255
// Mouse output
public short MouseDeltaX; // Mouse X delta (signed, pixels per frame)
public short MouseDeltaY; // Mouse Y delta (signed, pixels per frame)
public short ScrollDelta; // Mouse scroll delta (positive = up)
public byte MouseButtons; // Bit 0=LMB, 1=RMB, 2=MMB, 3=X1, 4=X2
// Pre-deadzone values (for UI stick/trigger preview)
public short PreDzMouseDeltaX; // Mouse X before center offset + deadzone
public short PreDzMouseDeltaY; // Mouse Y before center offset + deadzone
public short PreDzScrollDelta; // Scroll before deadzone
// Horizontal tilt-wheel (#154)
public short ScrollDeltaH; // Horizontal scroll (+ = right), sent as MOUSEEVENTF_HWHEEL
public short PreDzScrollDeltaH; // Horizontal scroll before deadzone
// Absolute pointer aim (#146 Wii IR)
public float MouseAbsX; // Normalized [-1..+1] screen X
public float MouseAbsY; // Normalized [-1..+1] screen Y
public bool MouseAbsValid; // Any-axis OR: true while the pointer is tracking
public bool MouseAbsXValid; // Per-axis validity (mixed IR/stick mappings)
public bool MouseAbsYValid;
// Exact-count mouse lanes (bypass the MouseDelta velocity lane's scale and clamp)
public int MouseFlickX; // Flick stick counts this frame (#225), calibrated by counts-per-360, + = rightward turn
public float MouseGyroX; // Gyro mouse motion in exact counts this poll (fractional, remainder carried)
public float MouseGyroY;
public float MouseTouchX; // Touchpad mouse motion in exact counts this poll
public float MouseTouchY;
public float MouseStickCoastX; // Stick trackball coast in exact counts this poll (#291), KBM sign convention
public float MouseStickCoastY;
// Methods
public bool GetKey(byte vk);
public void SetKey(byte vk, bool pressed);
public bool GetMouseButton(int index);
public void SetMouseButton(int index, bool pressed);
public void ClearKeyboard();
public void ClearMouse();
public void Clear();
public KbmRawState WithSurfaces(bool keyboardEnabled, bool mouseEnabled);
public static KbmRawState Combine(KbmRawState a, KbmRawState b);
}
Methods¶
| Method | Signature | Description |
|---|---|---|
GetKey |
bool GetKey(byte vk) |
true if VK code bit is set (word = vk/64, bit = vk%64). |
SetKey |
void SetKey(byte vk, bool pressed) |
Sets or clears a VK code bit. |
GetMouseButton |
bool GetMouseButton(int index) |
true if mouse button bit is set (0=LMB, 1=RMB, 2=MMB, 3=X1, 4=X2). Right is 1 and middle is 2, the order Step 3 writes and KeyboardMouseVirtualController sends. The input side differs: SdlMouseWrapper names its buttons Left Click, Middle Click, Right Click, X1, X2. |
SetMouseButton |
void SetMouseButton(int index, bool pressed) |
Sets or clears a mouse button bit. |
ClearKeyboard |
void ClearKeyboard() |
Zeros the four key words. |
ClearMouse |
void ClearMouse() |
Zeros every mouse lane: deltas, both scroll axes, mouse buttons, pre-deadzone fields, the absolute-pointer fields, and the flick / gyro / touch / coast count lanes. |
Clear |
void Clear() |
ClearKeyboard() plus ClearMouse(). |
WithSurfaces |
KbmRawState WithSurfaces(bool keyboardEnabled, bool mouseEnabled) |
A copy with the half the slot does not drive cleared (#408), so a Mouse-only slot emits no keystrokes and a Keyboard-only slot no mouse motion. The mappings stay untouched. |
Combine |
static KbmRawState Combine(KbmRawState a, KbmRawState b) |
Merges two KBM states. Keys and mouse buttons OR'd. Deltas and both scroll axes take largest absolute magnitude. Absolute-pointer coordinates take the tracking side per axis, and the MouseAbs*Valid flags OR. MouseFlickX takes the larger magnitude. MouseGyro*, MouseTouch*, and MouseStickCoast* sum, so two gyros aimed at one slot each contribute their real motion. |
MidiRawState¶
File: PadForge.Engine/Common/GamepadTypes.cs
Namespace: PadForge.Engine
Dynamic-sized MIDI output state for MidiVirtualController. CC values: 0–127 (MIDI range). Notes: boolean (on/off).
public struct MidiRawState
{
public byte[] CcValues; // CC values 0-127 per CC slot
public bool[] Notes; // Note on/off per note slot
public static MidiRawState Create(int ccCount, int noteCount);
public void Clear();
public static MidiRawState Combine(MidiRawState a, MidiRawState b);
public static MidiRawState CombineInto(MidiRawState a, MidiRawState b, MidiRawState dest);
}
Methods¶
| Method | Signature | Description |
|---|---|---|
Create |
static MidiRawState Create(int ccCount, int noteCount) |
Allocates arrays. CC values initialized to 0. |
Clear |
void Clear() |
Resets CCs to 64 (center), notes to false. |
Combine |
static MidiRawState Combine(MidiRawState a, MidiRawState b) |
Merges two states. CCs take the value furthest from center (64), notes OR'd. Allocates. |
CombineInto |
static MidiRawState CombineInto(MidiRawState a, MidiRawState b, MidiRawState dest) |
The same merge written into a caller-owned dest, so a repeated combine reuses one buffer. dest may be a (both loops read index i and write index i, with no cross-index reads). Passing default allocates, which is what Combine does. dest must never be a device's published state or anything another slot or thread reads. |
VrRawState¶
File: PadForge.Engine/Common/VrRawState.cs
Namespace: PadForge.Engine
The combined VR output for one slot: the left plus right hand pair that a single virtual VR controller drives (#49). All value fields on purpose, so a struct assign copies. The MIDI array-aliasing trap cannot apply here.
public struct VrHandRaw
{
public byte Buttons; // Mirrors HMVRButton bits EXACTLY, so the wrapper's conversion is a cast
public short Trigger; // One-sided 0..32767
public short Grip; // One-sided 0..32767
public short StickX; // Bipolar -32768..32767
public short StickY;
}
public struct VrRawState
{
public VrHandRaw Left;
public VrHandRaw Right;
public void Clear();
public void Merge(in VrRawState other);
}
Buttons bits are System = 1, A = 2, ATouch = 4, B = 8, BTouch = 16, TriggerClick = 32, GripClick = 64, StickClick = 128.
Merge follows the gamepad-merge convention: buttons OR together, axes keep the larger deflection. The magnitude compare widens to int before Math.Abs, because the short overload throws OverflowException at short.MinValue, which is exactly what a fully deflected axis (or any digital source mapped to axis-negative) produces. Two devices on a VR slot plus one full deflection therefore threw out of Step 4 every poll and cleared the slot's whole combined output about a thousand times a second.
VrLayout in the same file holds the mapping-key vocabulary shared by the Step 3 mapper, the layout translation, and the mapping UI, following the MIDI / KBM dictionary-lane convention ("VrLTrigger", "VrRStickXNeg", and so on). LeftButtonKeys / RightButtonKeys are indexed by HMVRButton bit position: index i is button bit 1 << i.
VirtualControllerType¶
File: PadForge.Engine/Common/VirtualControllerTypes.cs
Namespace: PadForge.Engine
public enum VirtualControllerType
{
[XmlEnum("Microsoft")] Xbox = 0,
[XmlEnum("Sony")] PlayStation = 1,
Extended = 2,
Midi = 3,
KeyboardMouse = 4,
Nintendo = 5,
Vr = 6
}
VirtualControllerGroups.InOrder publishes the same seven values in fixed sidebar / dashboard order. Groups are independent: an operation on one must never touch another.
Numeric values are persisted, so never reorder them and append new members at the tail. They are also preserved across the rename so legacy PadForge.xml files keep loading. Slot types persist as integers (SlotControllerTypes is an int[]). The [XmlEnum] attributes on Xbox and PlayStation keep the prior on-disk names: XmlSerializer reads and writes Xbox as Microsoft and PlayStation as Sony, so settings files written with those identifiers still deserialize.
IVirtualController¶
File: PadForge.Engine/Common/VirtualControllerTypes.cs
Namespace: PadForge.Engine
Abstraction for virtual controller operations. v3 collapsed Xbox, PlayStation, and Extended onto a single concrete class backed by HIDMaestro, and Nintendo joined them. MIDI, KB+M, and VR are separate classes.
| Class | Backend |
|---|---|
HMaestroVirtualController |
HIDMaestro SDK (HMContext, HMProfile, HMController). Handles Xbox, PlayStation, Extended, and Nintendo. Profile selected at construction. |
MidiVirtualController |
Windows MIDI Services |
KeyboardMouseVirtualController |
Win32 SendInput |
HMaestroVRController |
HIDMaestro's native OpenVR driver via HMVRController (#49). Drives both SteamVR hands from one slot. Takes SubmitVrState(in VrRawState) rather than the gamepad path, and its SubmitGamepadState is a no-op kept for the interface |
HMaestroVirtualController.Type reports the user-facing category so per-type counting in InputService keeps working without inspecting profile metadata.
public interface IVirtualController : IDisposable
{
VirtualControllerType Type { get; }
bool IsConnected { get; }
int FeedbackPadIndex { get; set; }
void Connect();
void Disconnect();
void SubmitGamepadState(Gamepad gp);
void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates);
}
Members¶
| Member | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Virtual controller type |
IsConnected |
bool |
Whether the VC is connected |
FeedbackPadIndex |
int |
Slot index for feedback callbacks into VibrationStates[]. Rewritten by RerouteVirtualControllersForReorder when a reorder moves a surviving VC's pad-index pointer, so the rumble callback still lands on the right slot |
Connect() |
void |
Creates and plugs in the VC |
Disconnect() |
void |
Unplugs and destroys the VC |
SubmitGamepadState(Gamepad) |
void |
Sends gamepad state to the VC |
RegisterFeedbackCallback(int, Vibration[]) |
void |
Registers a callback writing rumble to VibrationStates[] at the given index |
CustomInputState¶
File: PadForge.Engine/Common/CustomInputState.cs
Namespace: PadForge.Engine
API-agnostic snapshot of a device's full input state at one point in time.
public class CustomInputState
{
// Constants
public const int MaxAxis = 24;
public const int MaxSliders = 8;
public const int MaxPovs = 4;
public const int MaxButtons = 256;
// Fields
public int[] Axis; // 0-65535, center = 32768 (not 32767)
public int[] Sliders; // 0-65535
public int[] Povs; // centidegrees 0-35900, or -1 for centered
public bool[] Buttons; // true = pressed. Index 16 = SDL_GAMEPAD_BUTTON_TOUCHPAD
public float[] Gyro; // [X, Y, Z] radians per second
public float[] Accel; // [X, Y, Z] meters per second squared
public float[] AccelAux; // [X, Y, Z] m/s^2 aux/left accelerometer (#199 Nunchuk / left Joy-Con)
public float[] GyroAux; // [pitch, yaw, roll] rad/s aux gyro (#252 left Joy-Con of a pair)
public TouchpadInputState[] Touchpads; // per-pad contacts, replaced TouchpadFingers[6]/TouchpadDown[2] in v3.3 (multi-pad, e.g. Steam Controller)
public MidiInputState Midi; // MIDI note/CC state for MIDI-input devices (#128), null until a MIDI read
public int BatteryPercent; // 0..100 or -1 if unknown. Refreshed periodically, not per-frame.
public bool BatteryCharging; // True when the source pad reports charging or fully charged.
// 3.6.0 pointer / mouse sources (value-type fields, no per-frame allocation)
public WiiIrState Ir; // Wii Remote IR pointer (#146): X, Y in [-1..+1], Detected and Calibrated flags
public float JoyConIrIntensity; // Right Joy-Con NIR camera average intensity 0..1 (#151)
public float RingConStrain; // Ring-Con flex -1..+1, squeeze positive (hifihedgehog/SDL#33)
public float JoyCon2MouseDX; // Joy-Con 2 optical mouse X delta since last poll (#154)
public float JoyCon2MouseDY; // Joy-Con 2 optical mouse Y delta since last poll (#154)
// v4 mouse-gesture source (#200): unclamped Raw Input counts
public int MouseRawDX; // Raw mouse X counts since last poll (before Axis[0] clamp)
public int MouseRawDY; // Raw mouse Y counts since last poll (before Axis[1] clamp)
// v4.1+ capsense and NFC (nullable, allocated only when the capability exists)
public bool[] CapSense; // SDL_GetGamepadCapSense channels: 0 left stick top, 1 right stick top, 2 left grip, 3 right grip
public bool[] NfcTag; // Switch reader tag buttons (#241). Index 0 = "Any NFC Tag", index N = registry button N
// Constructor
public CustomInputState();
// Methods
public CustomInputState Clone();
public void CopyInto(CustomInputState dst);
public void ResetForReuse();
public static void GetAxisMask(DeviceObjectItem[] items, int numAxes,
out int axisMask, out int actuatorMask, out int actuatorCount);
}
Constructor¶
| Constructor | Description |
|---|---|
CustomInputState() |
Zeroed arrays at default sizes. POVs init to −1 (centered). Gyro/Accel/AccelAux/GyroAux are float[3]. Touchpads, Midi, CapSense, and NfcTag start null and are allocated lazily by the read that first needs them. BatteryPercent defaults to −1 (unknown), BatteryCharging to false. |
Methods¶
| Method | Signature | Description |
|---|---|---|
Clone |
CustomInputState Clone() |
Deep copy through CopyInto: all arrays (Axis, Sliders, Povs, Buttons, Gyro, Accel, AccelAux, GyroAux, Touchpads, CapSense, NfcTag) plus the Midi state, the pointer / mouse fields (Ir, JoyConIrIntensity, JoyCon2MouseDX/DY, MouseRawDX/DY), and the scalar Battery fields. |
CopyInto |
void CopyInto(CustomInputState dst) |
The one full-field copy. Reallocates dst's nested Touchpads, CapSense, NfcTag, or Midi only when the shape changes. A reflection round-trip test (CustomInputStateMirrorTests) fails when a new field skips it. |
ResetForReuse |
void ResetForReuse() |
Returns the instance to fresh-constructed values without dropping nested allocations, which are cleared in place. PooledInputStatePair (same file) hands out two alternating instances through it for per-tick device reads. |
GetAxisMask |
static void GetAxisMask(DeviceObjectItem[], int, out int, out int, out int) |
Scans device objects to build axis and FFB actuator bitmasks. Bit N = axis/actuator N exists. |
Value Conventions¶
| Array | Range | Center | Description |
|---|---|---|---|
Axis |
0–65535 | 32768 | 0–5 = X, Y, Z, Rx, Ry, Rz. 6–23 = additional |
Sliders |
0–65535 | 32768 | Overflow or dedicated slider controls |
Povs |
0–35900 or −1 | −1 | Centidegrees. −1 = centered |
Buttons |
bool | false | 256 max (covers full Windows VK range) |
Gyro |
float[3] | 0.0 | Radians/s. Gyro-capable devices only |
Accel |
float[3] | 0.0 | m/s². Accelerometer-capable devices only |
AccelAux |
float[3] | 0.0 | (v4) m/s² auxiliary/left accelerometer (#199). SDL_SENSOR_ACCEL_L: the Nunchuk's own sensor, or the left half of a combined Joy-Con pair. Zeroed without the sensor |
Touchpads |
TouchpadInputState[] | null | Per-pad contacts. One entry per physical pad (Steam Controller reports more than one). Each carries per-finger X/Y (0.0–1.0) and contact state. Replaced TouchpadFingers[6] / TouchpadDown[2] in v3.3 |
Midi |
MidiInputState | null | MIDI note / CC state for MIDI-input devices (#128). Allocated on the first MIDI read |
BatteryPercent |
int | -1 | SDL3-reported charge level. 0-100 = percentage, -1 = unknown. Not refreshed every frame. |
BatteryCharging |
bool | false | true when the source pad reports charging or fully charged. Drives the lightbar Battery mode |
Ir |
WiiIrState |
Detected=false |
(v3.6) Wii Remote IR-camera pointer (#146). X / Y normalized to the [−1..+1] stick range from the two sensor-bar dots, valid only when Detected. The aim is twist-compensated, and Calibrated marks an aim a light-gun window already placed on the screen, a calibrated Wii Remote's (#485) or a GunCon 2's, which the IR read adds no bar offset to. Value type, rebuilt each tick. |
JoyConIrIntensity |
float | 0.0 | (v3.6) Right Joy-Con NIR camera average intensity 0..1 (#151). Covered reads bright (high), uncovered dark (low). 0 when the camera is off. Excluded from the idle test. |
RingConStrain |
float | 0.0 | Ring-Con flex, −1..+1 (hifihedgehog/SDL#33): positive while the ring is squeezed, negative while it is pulled, 0 at rest and while no Ring-Con polls. The fork posts the raw strain on the right Joy-Con's joystick axis 7 and its first nonzero reading after polling starts as SDL.joystick.switch.ringcon_rest. SdlDeviceWrapper subtracts the rest and divides by RingConFullScale (0x0800, eight steps of the strain's high byte). A strain or a rest whose high byte is 0 reads 0. Counts as activity in the idle test past RingConSlop. |
JoyCon2MouseDX |
float | 0.0 | (v3.6) Joy-Con 2 optical mouse X delta since the previous poll (#154), or a WingMan Warrior's spin dial counts (hifihedgehog/SDL#33). +X = right. 0 when idle or absent. |
JoyCon2MouseDY |
float | 0.0 | (v3.6) Joy-Con 2 optical mouse Y delta since the previous poll (#154). +Y = toward the user (down). 0 when idle or absent. |
MouseRawDX |
int | 0 | (v4) Unclamped Raw Input mouse X counts since the previous poll (#200). Feeds the mouse-gesture recognizer, which needs the counts before Axis[0] clamps them to the stick range. 0 when idle or non-mouse. |
MouseRawDY |
int | 0 | (v4) Unclamped Raw Input mouse Y counts since the previous poll (#200). 0 when idle or non-mouse. |
GyroAux |
float[3] | 0.0 | Auxiliary gyro, rad/s, SDL native frame (#252). SDL delivers it as SDL_SENSOR_GYRO_L, which only the Switch drivers register: the LEFT Joy-Con of a combined pair, gen 1 and gen 2, whose primary Gyro is the right half. Unlike AccelAux this never carries a Nunchuk, because the Nunchuk has no gyro |
CapSense |
bool[] | null | Capacitive touch channels from SDL_GetGamepadCapSense (SDL 3.6.0). Index 0 left stick top, 1 right stick top, 2 left grip, 3 right grip. Allocated on the first read, and only when SDL_GamepadHasCapSense reported at least one channel at device open |
NfcTag |
bool[] | null | Switch right Joy-Con / Pro reader tag buttons (#241, fork SDL#15). Index 0 = "Any NFC Tag", index N = the tag whose stable NfcTagRegistry button is N. Null until NFC first arms on a reader-capable device, then retained and cleared all-false across disarm rather than re-nulled |
ISdlInputDevice¶
File: PadForge.Engine/Common/ISdlInputDevice.cs
Namespace: PadForge.Engine
Common interface for all SDL-based input device wrappers (joystick/gamepad, keyboard, mouse, web controller). Lets the pipeline (Steps 2–5) read state from any device type uniformly.
public interface ISdlInputDevice : IDisposable
{
// Identity
uint SdlInstanceId { get; }
string Name { get; }
Guid InstanceGuid { get; }
Guid ProductGuid { get; }
string DevicePath { get; }
string SerialNumber { get; }
string SdlGuid { get; }
ushort VendorId { get; }
ushort ProductId { get; }
// Capabilities
int NumAxes { get; }
int RawAxisCount => NumAxes; // default; SdlDeviceWrapper overrides (#193)
bool HasExtraGenericAxes => false; // raw axes beyond the standard six (#193)
int NumButtons { get; }
int RawButtonCount { get; }
int[] SupportedButtonIndices { get; } // sparse list of exposed button positions
int[] SupportedAxisIndices => null; // sparse axis positions, null means dense
int NumHats { get; }
IntPtr GamepadHandle { get; } // SDL_Gamepad pointer, Zero if not gamepad-opened
bool HasRumble { get; }
bool HasRumbleTriggers { get; } // per-trigger impulse motors (Xbox One+)
bool HasHaptic { get; }
bool HasGyro { get; }
bool HasAccel { get; }
bool HasAccelAux => false; // aux/left accelerometer (#199)
bool HasGyroAux => false; // aux/left gyroscope (#252)
bool HasTouchpad { get; }
int NumTouchpads => HasTouchpad ? 1 : 0; // per-device pad count (SDL wrapper overrides)
int[] TouchpadFingerCounts => Array.Empty<int>(); // per-pad finger counts
bool? TouchpadPressureSupported => null; // null = unknown
bool? TouchpadClickSupported => null; // null = unknown
bool IsAttached { get; }
// Haptic
HapticEffectStrategy HapticStrategy { get; }
IntPtr HapticHandle { get; }
uint HapticFeatures { get; }
int NumHapticAxes { get; }
// State reading
CustomInputState GetCurrentState(bool forceRaw = false);
DeviceObjectItem[] GetDeviceObjects();
int GetInputDeviceType();
// Force feedback
bool SetRumble(ushort low, ushort high, uint durationMs = uint.MaxValue);
bool StopRumble();
}
Properties¶
| Property | Type | Description |
|---|---|---|
SdlInstanceId |
uint |
SDL instance ID (unique per connection session, 0 = invalid) |
Name |
string |
Human-readable device name |
InstanceGuid |
Guid |
Deterministic GUID for settings matching (from path/serial/VID+PID) |
ProductGuid |
Guid |
Product GUID from VID/PID for device family identification |
DevicePath |
string |
Device path (may be empty) |
SerialNumber |
string |
Serial number, e.g., Bluetooth MAC (may be empty) |
SdlGuid |
string |
SDL joystick GUID (32 hex chars) for gamecontrollerdb matching |
VendorId |
ushort |
USB Vendor ID |
ProductId |
ushort |
USB Product ID |
NumAxes |
int |
Axis count (6 for gamepads) |
RawAxisCount |
int |
Total raw joystick axes before the gamepad layout caps NumAxes to 6. Default-interface member returning NumAxes. Only SdlDeviceWrapper overrides it (#193) |
HasExtraGenericAxes |
bool |
True when raw axes beyond the standard six surface as "Axis N" sources (#193). Default-interface member, false for everything but SdlDeviceWrapper |
NumButtons |
int |
Button count (22 standardized slots for SDL gamepads: 0–10 XInput, 11 Misc1, 12–15 paddles, 16 touchpad click, 17–21 Misc2–6. Raw count for joysticks) |
RawButtonCount |
int |
Raw joystick button count before gamepad remapping. May exceed NumButtons |
SupportedButtonIndices |
int[] |
Sparse list of button positions the device actually exposes. Lets the preview skip positions the device lacks (e.g., paddles) |
SupportedAxisIndices |
int[] |
Sparse list of axis positions the device actually has. Default-interface member returning null, which means dense (every index below the axis count is real). Exists because NumAxes is the standardized 6-slot gamepad space, not a count of the pad's physical axes |
NumHats |
int |
POV hat count (1 for gamepads) |
GamepadHandle |
IntPtr |
Native SDL_Gamepad pointer, IntPtr.Zero if not opened as a Gamepad. Used by the DualSense passthrough dispatcher for SDL_SendGamepadEffect |
HasRumble |
bool |
Supports simple rumble |
HasRumbleTriggers |
bool |
Per-trigger ("impulse") rumble motors (Xbox One / Elite / Series). Driven by SDL_PROP_JOYSTICK_CAP_TRIGGER_RUMBLE_BOOLEAN |
HasHaptic |
bool |
Has an SDL haptic handle open |
HasGyro |
bool |
Has gyroscope sensor |
HasAccel |
bool |
Has accelerometer sensor |
HasAccelAux |
bool |
Has an auxiliary/left accelerometer (#199 Nunchuk / left Joy-Con, SDL_SENSOR_ACCEL_L). Default-interface member, false except on the SDL wrapper and the Remote Link peer mirror |
HasGyroAux |
bool |
Has an auxiliary/left gyroscope (#252, SDL_SENSOR_GYRO_L). Only the Switch drivers register it, for the left Joy-Con of a combined pair. Default-interface member, false elsewhere |
HasTouchpad |
bool |
Has at least one touchpad surface |
NumTouchpads |
int |
Distinct touchpad surfaces (Steam Controller 2026 / Deck = 2, DualSense / DS4 = 1). Default-interface member returning HasTouchpad ? 1 : 0. The SDL wrapper overrides with the real count |
TouchpadFingerCounts |
int[] |
Per-touchpad finger count from SDL_GetNumGamepadTouchpadFingers. Default-interface member returning empty. The SDL wrapper overrides |
TouchpadPressureSupported / TouchpadClickSupported |
bool? |
Whether the touch surface reports pressure and a click. Default-interface members returning null (unknown). WindowsTabletDevice overrides both, and UserDevice stores them as CapTouchpadPressure / CapTouchpadClick |
IsAttached |
bool |
Handle still valid and connected |
HapticStrategy |
HapticEffectStrategy |
Best haptic strategy chosen at open time |
HapticHandle |
IntPtr |
SDL haptic handle (IntPtr.Zero if none) |
HapticFeatures |
uint |
Bitmask of SDL_HAPTIC_* flags |
NumHapticAxes |
int |
Haptic axes (1 = wheel, 2+ = joystick) |
Methods¶
| Method | Signature | Description |
|---|---|---|
GetCurrentState |
CustomInputState GetCurrentState(bool forceRaw = false) |
Reads input state. forceRaw=true bypasses gamepad remapping. |
GetDeviceObjects |
DeviceObjectItem[] GetDeviceObjects() |
Returns metadata for each axis, hat, and button. Button count uses Math.Max(NumButtons, RawButtonCount). |
GetInputDeviceType |
int GetInputDeviceType() |
Returns an InputDeviceType constant. |
SetRumble |
bool SetRumble(ushort low, ushort high, uint durationMs) |
Sends rumble. Default duration uint.MaxValue, which SDL caps at 0xFFFF ms (about 65.5 s). |
StopRumble |
bool StopRumble() |
Stops all rumble (SetRumble(0, 0, 0)). |
SdlDeviceWrapper¶
File: PadForge.Engine/Common/SdlDeviceWrapper.cs
Namespace: PadForge.Engine
Wraps an SDL joystick (and optionally its Gamepad overlay) for unified device access: open/close, state polling, rumble, GUID construction, and object enumeration. Implements ISdlInputDevice.
Properties (beyond ISdlInputDevice)¶
| Property | Type | Default | Description |
|---|---|---|---|
Joystick |
IntPtr |
IntPtr.Zero |
Raw SDL joystick handle. Always valid when open. |
GameController |
IntPtr |
IntPtr.Zero |
SDL Gamepad handle. Zero if not a gamepad. |
Haptic |
IntPtr |
IntPtr.Zero |
SDL haptic handle. Non-zero when haptic FFB available. |
JoystickType |
SDL_JoystickType |
UNKNOWN |
SDL joystick type classification |
IsGameController |
bool |
(computed) | true if opened as an SDL Gamepad |
HasIrCamera |
bool |
(set at open) | Wii Remote IR camera present: a configuration WiiRemoteIdentity names (the bare remote, or the remote with a Nunchuk or Classic Controller) whose raw axis count reaches the four IR axes. Drives the IR Pointer joystick-direct read (#146). A decoded extension (guitar, drums, turntable, TaTaCon, tablets, Shinkansen controller, hifihedgehog/SDL#33) gets no IR axes. The drum kit, turntable and Shinkansen controller report their own controls on axes 6 and up, so an axis count alone would read those as IR dots. |
IsBalanceBoard |
bool |
(set at open) | Wii Balance Board, told apart from a Wii Remote by its SDL name. Keeps the board out of HasIrCamera. Its four corner load cells arrive on the stick axes through the standard decode (#146). |
HasJoyConIr |
bool |
(set at open) | Right Joy-Con NIR camera, on a standalone right Joy-Con (PID 0x2007) or a combined gen-1 pair (PID 0x2008, #275). Drives the IR Brightness read (#151). |
HasRingCon |
bool |
(set at open) | HasJoyConIr plus an eighth raw axis, the Ring-Con strain the fork adds as axis 7 (hifihedgehog/SDL#33). Drives the RingConStrain read, so an SDL3.dll without the Ring-Con leaves it false. |
IsGunCon2 |
bool |
(set at open) | Namco GunCon 2, 0B9A:016A, opened with at least two raw axes (hifihedgehog/SDL#33). The fork posts the raw beam counts on axes 0 and 1. ApplyGunCon2 scales them by GunCon2Calibration to −1..+1, writes the aim to Axis[0] / Axis[1] so a stick mapping aims with no setup, and writes it to Ir divided by the IR pointer's margin stretch, which the IR read restores. X 10 or less or Y 5 or less is off screen (GunCon2OnScreen): the stick centers and Ir.Detected clears. |
GunCon2Calibration |
GunCon2Calibration |
GunCon2Calibration.Default |
The beam counts at the picture's edges this gun scales by: X 175..720 and Y 20..240 (beardypig's guncon2 driver defaults) until UserDevice.LoadFromSdlDevice hands it the record's GunCalibration, or the Devices page's calibration screen replaces it. Immutable and swapped with Volatile, so the poll thread reads it without a lock. |
WiiPointerCalibration |
GunCon2Calibration |
null |
A Wii Remote's light-gun window (#485), in pointer counts: the twist-compensated aim times WiiPointerCountsX (512) across and WiiPointerCountsY (384) down. null leaves the default aim range. UserDevice.LoadFromSdlDevice hands it the record's GunCalibration, and the Devices page's save replaces it. StepIrPointer maps the aim through it (ApplyWiiPointerCalibration) and marks the result Ir.Calibrated. Immutable and swapped with Volatile. |
HasSpinDial |
bool |
(set at open) | Logitech WingMan Warrior (SpinDialIdentity, by the serial driver's name) with a joystick ball. ReadSpinDial takes ball 0's motion through SDL_GetJoystickBall, which returns the counts since the last read and clears them, into JoyCon2MouseDX / DY unscaled. |
HasJoyCon2Mouse |
bool |
(set at open) | Switch 2 Joy-Con optical mouse. Drives the Mouse Motion read (#154). |
HasSwitch2Magnetometer |
bool |
(set at open) | Switch 2 BLE magnetometer (#271 item 5). Its samples land on wrapper-local Switch2MagX/Y/Z, deliberately not on CustomInputState, because the Remote Link block mask is full. The compass fusion consumes them through an App-layer provider. |
HasNfcReader |
bool |
(set at open) | The hardware can read NFC tags. It says nothing about power: the reader is energized only while NFC is armed and the Switch NFC hint is set. Read via SDL_GetGamepadNfcTagUid. |
HasAccelAux / HasGyroAux |
bool |
(set at open) | Aux (left-side) sensors, #199 and #252. |
SdlDevicePath |
string |
"" |
The SDL path as SDL reported it. BuildInstanceGuid hashes this one, so a path enriched afterward never changes the identity. |
Backend |
string |
(computed) | Read from the SDL GUID's driver-signature byte: xinput, hidapi, gameinput (the Xbox One instruments SDL's GameInput driver lists by default), virtual (an SDL virtual joystick, the kind PadForge's own readers attach) or dinput (#395). Any other byte reads as 0x and its hex value, and a GUID too short to carry the byte reads unknown. |
GetCurrentState reads the IR pointer, the NIR scalar, and the Joy-Con 2 mouse counters straight off the raw joystick axes (ReadIrPointer / ReadJoyConIr / ReadJoyCon2Mouse) into the matching CustomInputState fields, alongside the standard gamepad decode. ReadIrPointer hands the dots, the remote's accelerometer and its B to StepIrPointer, which corrects the aim for twist, latches the light-gun calibration shot and applies WiiPointerCalibration (Wii Controllers Internals). ReadSwitch2Magnetometer fills the wrapper-local magnetometer fields, and ReadNfcTag polls SDL_GetGamepadNfcTagUid only while NFC is armed. The Balance Board's corners need no extra read. Capsense channels come from SDL 3.6.0's SDL_GamepadHasCapSense / SDL_GetGamepadCapSense and are probed once at open into a private _capSenseChannels array, so a capsense-less device pays nothing per frame and never allocates CustomInputState.CapSense.
Public Methods¶
| Method | Signature | Description |
|---|---|---|
Open |
bool Open(uint instanceId) |
Opens SDL device. Tries Gamepad first, falls back to Joystick. Populates all properties. |
GetCurrentState |
CustomInputState GetCurrentState(bool forceRaw = false) |
Routes to GetGamepadState() (remapped) or GetJoystickState() (raw) based on device type and forceRaw. |
GetDeviceObjects |
DeviceObjectItem[] GetDeviceObjects() |
Builds DeviceObjectItem[] for each axis, hat, button. Uses Math.Max(NumButtons, RawButtonCount) for button count so extra raw buttons (beyond the 22 standardized gamepad slots) are included with generic "Button N" names. First 6 axes use standard GUIDs. Axes 6–23 surface as "Axis N" with a non-Slider axis GUID, and only raw axes 24 and up use Slider. On a gamepad, standard axis and button positions the device lacks (SDL_GamepadHasAxis / SDL_GamepadHasButton) are left out. |
GetInputDeviceType |
int GetInputDeviceType() |
Maps SDL_JoystickType to InputDeviceType. |
SetRumble |
bool SetRumble(ushort lowFreq, ushort highFreq, uint durationMs) |
Sends rumble via SDL_RumbleJoystick. false if unsupported. A Steam Deck's motors are scaled by 54394/65535 first (the #179 firmware headroom), and a Padix PSX converter returns false because PadForge drives its motors itself (#440). |
SetPlayerIndex |
bool SetPlayerIndex(int playerIndex) |
(#191) Pushes the player index into SDL so drivers with player LEDs light them. Nintendo devices and the USB DualShock 3 only. Every other device returns false, because SDL's PS4/PS5 drivers would fight the Sony sole writer. |
BounceMotionSensors |
void BounceMotionSensors() |
Turns every open motion sensor off and back on, through the API that opened the device. The fork powers a Wii Remote's IR camera at that enable edge, so a bounce is one more camera start, which the IR camera check uses (StepIrCameraCheck). |
OverrideInstanceGuid |
void OverrideInstanceGuid(Guid sessionGuid) |
Replaces InstanceGuid with a session-scoped one. The device resolver calls it only when two live devices report the same serial and so hash to the same identity. |
SetHomeLedBrightness |
bool SetHomeLedBrightness(int percent) |
(4.1.0, #226) Switch HOME-button LED brightness via SDL_SetJoystickLED with an equal-RGB byte. SDL's Switch driver recovers max(r,g,b) as a 0–100 brightness and issues subcommand 0x38. Devices without the LED refuse inside SDL's own type check. The subcommand ACK wait blocks ~30–100 ms while SDL's global joystick lock is held, so call from a dedicated worker (SwitchHomeLedSetter), never the poll or UI thread. |
StopRumble |
bool StopRumble() |
SetRumble(0, 0, 0). |
Static Methods¶
| Method | Signature | Description |
|---|---|---|
BuildProductGuid |
static Guid BuildProductGuid(ushort vid, ushort pid) |
Synthetic GUID from VID+PID. bytes[0–1]=VID LE, [2–3]=PID LE, [4–15]=0x00. |
BuildInstanceGuid |
static Guid BuildInstanceGuid(string devicePath, ushort vid, ushort pid, uint instanceId, string serial = null, string sdlGuid = null) |
Deterministic GUID: MD5 over an identifier string picked by the branch table below. |
HomeLedPercentToByte |
internal static byte HomeLedPercentToByte(int percent) |
(4.1.0) 0–100 percent to the equal-RGB LED byte: ceil(percent * 255 / 100). Ceiling is deliberate. SDL recovers percent as (int)((v / 255.0f) * 100.0f), and the ceiling makes that round-trip exact for every percent where plain rounding slips low on some values (99 to 98). Unit-tested (GuideLedTests). |
HatToCentidegrees |
static int HatToCentidegrees(byte hat) |
SDL hat bitmask to centidegrees (−1 for centered). |
DpadToCentidegrees |
static int DpadToCentidegrees(bool up, bool down, bool left, bool right) |
4 D-pad booleans to centidegrees (supports 8-way diagonals). |
Instance GUID construction¶
BuildInstanceGuid picks one identifier string, then MD5-hashes it into the GUID. The branches, in order:
| Order | Condition | Identifier | Why |
|---|---|---|---|
| 1 | serial non-empty |
serial:{VID:X4}:{PID:X4}:{serial} |
A serial (a Bluetooth MAC, say) is stable across reboots and re-pairs. |
| 2 | devicePath starts with XInput# |
pnp:{physicalPath}, or pnp:{physicalPath}:slot{n} when more than one candidate matched |
SDL's XInput#N path is slot-dependent and its GUID derives from a name carrying the slot number, so neither survives a slot reshuffle. StableXInputInstance.FindAll(vid, pid) resolves the physical PnP child (the BT MAC or USB hub and port), which is slot-independent. |
| 2a | XInput path, no PnP candidate, sdlGuid present and not all zeros |
sdlguid:{sdlGuid} |
|
| 2b | XInput path, nothing else | {devicePath}:{VID:X4}:{PID:X4} |
|
| 3 | devicePath non-empty |
{devicePath}:{VID:X4}:{PID:X4} |
VID and PID are folded in so two different controllers reaching the same path get separate identities. |
| 4 | sdlGuid present and not all zeros |
sdlguid:{sdlGuid} |
An SDL virtual joystick (the DS3 bridge) has no path and no serial. SDL's joystick GUID is stable across reconnects where the instance id is not. |
| 5 | fallback | sdl:{VID:X4}:{PID:X4}:{instanceId} |
Session-specific, last resort. |
Two same-serial transports are therefore one record: the serial branch ignores the path, so the same pad seen over USB and over Bluetooth hashes to one identity rather than two.
Same-model duplicates without a serial go down branch 2. StableXInputInstance.FindAll returns both candidates in deterministic order, ParseXInputSlot reads the slot index out of the XInput#N path, and that index selects a candidate (clamped to the last) and is appended as :slot{n} so the two never hash to the same GUID. Each PadForge entry then binds to a slot. When xinputhid reshuffles which physical sits in which slot, per-pad settings follow the slot, because XInput exposes no per-physical signal for same-model duplicates.
Gamepad State Reading¶
GetGamepadState() reads through SDL's gamecontrollerdb mapping layer, producing a standardized layout:
| Output | Indices |
|---|---|
| Axes | [0]=LX, [1]=LY, [2]=LT, [3]=RX, [4]=RY, [5]=RT. [6]–[23] carry extra raw joystick axes when HasExtraGenericAxes (#193) |
| Buttons | [0]=A, [1]=B, [2]=X, [3]=Y, [4]=LB, [5]=RB, [6]=Back, [7]=Start, [8]=LS, [9]=RS, [10]=Guide, [11]=Misc1, [12]=RPaddle1, [13]=LPaddle1, [14]=RPaddle2, [15]=LPaddle2, [16]=Touchpad click, [17]–[21]=Misc2–6 |
| POV[0] | Synthesized from gamepad D-pad buttons |
| Sensors | Gyro and Accel populated if available |
Guide suppression: When Back+Start+Guide are all pressed, Guide is suppressed (Windows/XInput synthesizes Guide from this combo).
Extra raw buttons: Raw joystick buttons at index 22 and above are appended, excluding indices consumed by the gamepad mapping (ParseMappedButtonIndices()). The DualSense touchpad click is not one of them: it lands at Buttons[16] through the gamepad API.
Joystick State Reading¶
GetJoystickState() reads raw joystick input (no gamepad remapping):
- Axes: SDL signed (−32768..32767) converted to unsigned (0..65535) via - short.MinValue. First MaxAxis go to Axis[], overflow to Sliders[].
- Hats: SDL bitmask to centidegrees via HatToCentidegrees.
- Buttons: Uses RawButtonCount (not NumButtons) for full raw coverage.
- Sensors: Every sensor the open enabled, through SDL_GetJoystickSensorData, for a device opened as a joystick only and for a gamepad read raw through Force Raw. The gamepad sensor calls are these calls on the gamepad's own joystick, which is the handle the wrapper holds.
HID Product String Fallback¶
SDL3 may return a raw VID/PID string (e.g., "0x16c0/0x05e1") for unknown devices. IsRawVidPidName() detects this. TryGetHidProductString() queries the HID product string via CreateFile + HidD_GetProductString P/Invoke.
Haptic Open Strategy¶
OpenHaptic() opens SDL_OpenHapticFromJoystick and selects the best strategy:
1. LeftRight. Best for dual-motor
2. Sine. Periodic fallback
3. Constant. Last resort
Devices with both simple rumble and LeftRight haptic prefer simple rumble (more reliable for gamepads). Gain set to 100 if SDL_HAPTIC_GAIN is supported.
Every exit writes one HAPTICDIAG open VVVV:PPPP ... line to SdlDiagLog, carrying the VID:PID plus the haptic axis count and the spring and constant feature bits (#282 needs all three). A null handle from SDL_OpenHapticFromJoystick logs none (device exposes no SDL haptic interface), not FAILED: most gamepads have no SDL haptic interface at all, a DualSense rumbles through the Sony sole-writer path and never reaches this subsystem, and wording it as a failure put two error-class lines into every diagnostics harvest.
HapticEffectStrategy¶
File: PadForge.Engine/Common/SdlDeviceWrapper.cs
Namespace: PadForge.Engine
public enum HapticEffectStrategy
{
None, // No haptic support
LeftRight, // Best: SDL_HAPTIC_LEFTRIGHT (dual-motor)
Sine, // Periodic effect (period varies by motor)
Constant // Fallback: constant level from dominant motor
}
SdlKeyboardWrapper¶
File: PadForge.Engine/Common/SdlKeyboardWrapper.cs
Namespace: PadForge.Engine
Wraps a keyboard device for unified input via ISdlInputDevice. State read from Raw Input (per-device) via RawInputListener.
Properties¶
| Property | Type | Value/Description |
|---|---|---|
NumAxes |
int |
0 |
NumButtons |
int |
Up to 256 (min of 256 and MaxButtons) |
RawButtonCount |
int |
0 |
NumHats |
int |
0 |
HasRumble |
bool |
false |
HasHaptic |
bool |
false |
HasGyro |
bool |
false |
HasAccel |
bool |
false |
RawInputHandle |
IntPtr |
The Raw Input device handle for per-device state reading |
Methods¶
| Method | Signature | Description |
|---|---|---|
Open |
bool Open(RawInputListener.DeviceInfo deviceInfo) |
Opens from Raw Input enumeration. Builds GUID from device path. Path hash is used as the pseudo SDL instance ID. |
GetCurrentState |
CustomInputState GetCurrentState(bool forceRaw) |
Reads from RawInputListener.GetKeyboardState, merges hooked state via InputHookManager.MergeHookedKeyState (suppressed keys bypass Raw Input). |
GetDeviceObjects |
DeviceObjectItem[] |
256 button items with ObjectGuid.Key GUIDs, one per index in SupportedButtonIndices. Names from SDL.VirtualKeyName. Built by KeyObjects, which a keyboard shared over Remote Link uses too. |
GetInputDeviceType |
int |
InputDeviceType.Keyboard (19). |
SetRumble / StopRumble |
Always false. |
SdlMouseWrapper¶
File: PadForge.Engine/Common/SdlMouseWrapper.cs
Namespace: PadForge.Engine
Wraps a mouse device for unified input via ISdlInputDevice. State read from Raw Input (per-device) via RawInputListener.
Constants¶
| Constant | Value | Description |
|---|---|---|
MouseButtons |
5 | Left, Middle, Right, X1, X2 |
MouseAxes |
3 | X Motion, Y Motion, Scroll |
AxisCenter |
32767 | Center value for mouse axis output |
MotionScale |
2048f | Scale for mouse motion. Applied as MotionScale / 1000 to the windowed counts per second |
ScrollScale |
128f | Multiplier for the windowed scroll sum |
Properties¶
| Property | Type | Value/Description |
|---|---|---|
NumAxes |
int |
3 (X Motion, Y Motion, Scroll) |
NumButtons |
int |
5 (Left, Middle, Right, X1, X2) |
RawButtonCount |
int |
0 |
NumHats |
int |
0 |
HasRumble |
bool |
false |
RawInputHandle |
IntPtr |
The Raw Input device handle |
Methods¶
| Method | Signature | Description |
|---|---|---|
Open |
bool Open(RawInputListener.DeviceInfo deviceInfo) |
Opens from Raw Input enumeration. |
UpdateHandle |
void UpdateHandle(IntPtr newHandle) |
Swaps in a new Raw Input handle when the same physical mouse re-enumerates (after PTP registration, say). |
GetCurrentState |
CustomInputState GetCurrentState(bool forceRaw) |
Reads deltas via ConsumeMouseDelta, scroll via ConsumeMouseScroll, buttons via GetMouseButtons + MergeHookedMouseState. The deltas feed a RelativeVelocityWindow (25 ms, #331): X and Y = AxisCenter + countsPerSecond * MotionScale / 1000, scroll = AxisCenter + windowSum * ScrollScale, each clamped to 0–65535. The unwindowed per-poll deltas go to MouseRawDX / MouseRawDY. |
GetDeviceObjects |
DeviceObjectItem[] |
3 RelativeAxis (X, Y, Scroll) + 5 PushButton (L, M, R, X1, X2). Built by MouseObjects, which a mouse shared over Remote Link uses too. |
GetInputDeviceType |
int |
InputDeviceType.Mouse (18). |
ConsumerControlWrapper¶
File: PadForge.Engine/Common/ConsumerControlWrapper.cs
Namespace: PadForge.Engine
(v3.6, #168) Exposes a Windows Consumer Control HID collection (media remotes, headset strips, keyboard media rows) as an ISdlInputDevice. Buttons only, no axes. State read from Raw Input (per-device) via RawInputListener.GetConsumerState. Structurally mirrors SdlKeyboardWrapper, but there is no low-level hook for consumer usages, so there is no InputHookManager merge.
Properties¶
| Property | Type | Value/Description |
|---|---|---|
Name |
string |
"Consumer Control" until Open sets the device name |
NumAxes |
int |
0 |
NumButtons |
int |
ConsumerUsageTable.TotalSlots (fixed block + dynamic slack) |
RawButtonCount |
int |
0 |
NumHats |
int |
0 |
SupportedButtonIndices |
int[] |
Every slot, densely (0 to TotalSlots - 1). An empty array would claim the device has no buttons |
HasRumble / HasHaptic / HasGyro / HasAccel / HasTouchpad |
bool |
all false |
RawInputHandle |
IntPtr |
The Raw Input device handle for per-device state reading |
Methods¶
| Method | Signature | Description |
|---|---|---|
Open |
bool Open(RawInputListener.DeviceInfo deviceInfo) |
Opens from a Raw Input enumeration result, including the "All Consumer Controls (Merged)" aggregate. Builds GUID from device path (MD5). Path hash is the pseudo SDL instance ID. |
GetCurrentState |
CustomInputState GetCurrentState(bool forceRaw = false) |
Reads from RawInputListener.GetConsumerState into Buttons. |
GetDeviceObjects |
DeviceObjectItem[] GetDeviceObjects() |
One PushButton item per slot with ObjectGuid.Key. Fixed-block names come from ConsumerUsageTable.Fixed. Dynamic slots resolve their live usage via RawInputListener.GetDynamicSlotUsage, else "Consumer Slot N". |
GetInputDeviceType |
int GetInputDeviceType() |
InputDeviceType.ConsumerControl (29). |
IsAttached |
bool (property) |
Matches by device path against RawInputListener.EnumerateConsumerControls, re-syncing the handle if it changed. |
SetRumble / StopRumble |
Always false. |
ConsumerUsageTable¶
File: PadForge.Engine/Common/ConsumerUsageTable.cs
Namespace: PadForge.Engine.Common
(v3.6, #168) The canonical Consumer Control usage table. Usage IDs are from the HID Usage Tables, Consumer Page (0x0C). The index in Fixed is the button index the mapping layer sees, so the table is append-only: reordering or removing a row silently retargets every saved "Button N" mapping on a consumer device. Names are invariant English. MappingDisplayResolver.LocalizeObjectName carries the per-locale strings (DevObj_Consumer* keys).
Fixed holds 36 named usages: Power, menu navigation (Menu / OK / Up / Down / Left / Right / Escape), media transport (Play / Pause / Record / Fast Forward / Rewind / Next / Previous / Stop / Eject / Play-Pause), Voice Command, Mute / Volume Up / Volume Down, Quit, Channel Up / Channel Down, app keys (Media Player, Email, Calculator, File Browser), and the browser row (Search / Home / Back / Forward / Stop / Refresh / Bookmarks).
Usages a device reports that are not in the table get a session-dynamic slot after the fixed block (up to DynamicSlack = 16), displayed as "Consumer 0xNNNN". Dynamic indices are not stable across sessions.
public static class ConsumerUsageTable
{
public readonly struct Entry { public readonly ushort Usage; public readonly string Name; }
public static readonly Entry[] Fixed; // 36 named usages, append-only
public const int DynamicSlack = 16; // session-dynamic slots after the fixed block
public static int TotalSlots => Fixed.Length + DynamicSlack;
public static int IndexOf(ushort usage); // fixed index, or -1 if untabled
public static string DynamicName(ushort usage); // "Consumer 0xNNNN"
}
WebControllerDevice¶
File: PadForge.Engine/Common/WebControllerDevice.cs
Namespace: PadForge.Engine
Virtual input device for a browser-connected gamepad. Implements ISdlInputDevice for standard pipeline integration. State written by WebSocket thread, read by polling thread via volatile reference swaps.
Constants¶
| Constant | Value | Description |
|---|---|---|
WebVendorId |
0xBEEF |
Distinctive VID to avoid HIDMaestro filter false positives |
WebProductId |
0xCA7E |
Distinctive PID |
WebProductGuidBase |
{BEBC0000-0000-0000-0000-CAFEFACE0001} |
Base ProductGuid. The instance ProductGuid is this MD5-mixed with the layout key ("xbox360", "ds4", "switchpro", "touchpad", "menus", and the other web layouts), so different layouts read as different products. |
MenusLayoutKey |
"menus" |
The Web Menus layout (#471). A device built with it is a menu surface. |
MaxHeldMenuCells |
32 |
The most tiles one Web Menus phone can hold at once. A press beyond it is dropped. |
Capabilities¶
| Property | Value |
|---|---|
| Axes | 6 (LX, LY, LT, RX, RY, RT. 0–65535 range). 0 for touchpad-only clients. A pad built in the web editor reports only its own axes through SupportedAxisIndices (SetCustomSurface) |
| Buttons | 11 gamepad slots (standard Xbox layout: A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide). 17 when the layout has a touchpad, with Buttons[16] = touchpad click. Extended surfaces raise it to max(extendedMax + 1, 17 if touchpad). |
| POV Hats | 1. 0 for touchpad-only clients, and for a built pad without a D-pad widget |
| HasRumble | Client-reported (browser Vibration API). Defaults true, fires CapabilitiesChanged when the client says otherwise. |
| HasHaptic | false |
| HasGyro | false until EnableMotionCaps() flips it on the first motion message (#296), then true |
| HasAccel | Flips together with HasGyro |
A Web Menus surface (#471, IsMenuSurface) reports 0 axes, 0 buttons and 0 hats, empty SupportedAxisIndices and SupportedButtonIndices, no device objects, InputDeviceType.WebMenus, and HasRumble false. Its input is tile presses (below).
Constructor¶
public WebControllerDevice(string clientId, string displayName, bool isTouchpad = false, string layoutKey = "xbox360")
Creates a web controller. clientId is a unique browser localStorage identifier. InstanceGuid derived from client ID via MD5. SdlInstanceId comes from SyntheticInstanceId.From(clientId) (FNV-1a in the reserved 0x80000000 band, stable across sessions). Stick axes init to center (32767), trigger axes to 0, except on a touchpad or Web Menus client, which has no axes. isTouchpad reports the device as a touchpad. layoutKey ("xbox360" / "ds4" / "touchpad") is MD5-mixed into ProductGuid so different layouts read as different products.
Events¶
| Event | Signature | Description |
|---|---|---|
RumbleRequested |
Action<ushort, ushort> |
Fired on SetRumble. Parameters: (lowFreq, highFreq), 0–65535. |
CapabilitiesChanged |
Action |
Fired when a capability flips after connect: motion on the first motion message, a touch surface on the first touch message, or the client reporting no rumble. The connect-time UserDevice snapshot needs the re-sync. |
LedChanged |
Action<byte, byte, byte> |
Lightbar color for the browser to draw (#296), change-detected in SetLed. |
PlayerIndexChanged |
Action<int> |
1-based player number for the browser's player pips (#296), change-detected in SetPlayerNumber. ResendIdentity re-emits both after a reconnect. |
MenusFeedChanged |
Action<string> |
A Web Menus snapshot to send (#471), raised by SetMenusFeed only when the JSON changed. |
ProfileRequested |
Action<string> |
A Web Menus phone asked for a profile id (#471), raised by RequestProfile. |
State Update Methods¶
| Method | Signature | Description |
|---|---|---|
UpdateAxis |
void UpdateAxis(int code, int value) |
Sets axis (0=LX, 1=LY, 2=LT, 3=RX, 4=RY, 5=RT). Thread-safe. On a built pad, an axis outside the declared surface joins it first and fires CapabilitiesChanged once. |
UpdateButton |
void UpdateButton(int code, bool pressed) |
Sets a button by slot (0=A through 10=Guide, 11–21 the extended slots, 16 the touchpad click). Any index below 256 is accepted. Thread-safe. |
UpdatePov |
void UpdatePov(int value) |
Sets POV hat (centidegrees or −1). An angle outside 0–35999 reads as centered. Thread-safe. |
SetExtendedButtons |
void SetExtendedButtons(int[] codes) |
Declares the extended slots (11–21, except 16) the active layout offers, such as a Switch Pro's Capture or a Steam Deck's grips. |
SetCustomSurface |
void SetCustomSurface(int[] axes, int[] buttons, bool hasPov) |
Declares the exact axes, buttons, and hat a pad built in the web editor carries, so the picker and the Devices preview show nothing else. |
NeutralizeAll |
void NeutralizeAll() |
Releases every button, returns the axes to rest, and centers the hat (#402). The server calls it when a forwarded pad's session expires. |
UpdateTouchpadFinger |
void UpdateTouchpadFinger(int finger, float x, float y, bool down) |
Single-pad two-finger virtual touchpad from phone clients. Contact IDs are synthesized on rising / falling edges. Copy-on-write under the state lock. |
UpdateMotion |
void UpdateMotion(float gx, float gy, float gz, float ax, float ay, float az) |
Gyro rates + accelerometer from the phone's DeviceMotionEvent (#296). Copy-on-write. Rates go stale (zero) after 500 ms without a sample, the accelerometer keeps its last value. EnableMotionCaps() flips HasGyro / HasAccel on the first message and notifies once. |
SetConnected |
void SetConnected(bool connected) |
Sets connection state (volatile write). A disconnect also releases every held Web Menus tile, and so does NeutralizeAll. |
Web Menus methods (#471)¶
| Method | Signature | Description |
|---|---|---|
SetMenuCell |
void SetMenuCell(int slot, int menuId, int cell, bool down) |
A tile pressed or released, from the socket thread. Menu surfaces only, slot and cell 0–63, at most MaxHeldMenuCells held. Each new press takes a number from one counter shared by every phone, and a repeated down on a held tile is the same press. The engine validates the press against the slot's menus before anything fires. |
ReadMenuPresses |
void ReadMenuPresses(long nowMs, int minPulseMs, List<(int Slot, int MenuId, int Cell, long Seq)> into) |
Adds every held tile with its press number, and every released one until minPulseMs after its press, so a tap between two polls still fires once. A released tile past that is forgotten. |
ReleaseAllMenuCells |
void ReleaseAllMenuCells() |
Lets go of every tile. |
SetMenusFeed |
void SetMenusFeed(string json) |
Stores the page's snapshot and raises MenusFeedChanged when it differs from the last one. |
ResendMenusFeed |
void ResendMenusFeed() |
Forgets the stored snapshot, so the next build sends a whole one. The builder on the UI timer stays the only sender, so a snapshot cannot overtake a newer one. |
RequestProfile |
void RequestProfile(string profileId) |
Menu surfaces only, ids up to 128 characters. Raises ProfileRequested. |
TouchpadOverlayDevice¶
File: PadForge.Engine/Common/TouchpadOverlayDevice.cs
Namespace: PadForge.Engine
(v3.2) Virtual input device that backs the on-screen touchpad overlay. Implements ISdlInputDevice so the overlay shows up on the Devices page like any other device (as InputDeviceType.Touchpad) and can be assigned to PlayStation slots. The window reads its position / size / monitor / opacity from AppSettingsData.TouchpadOverlay* fields.
| Property | Value |
|---|---|
Name |
"Touchpad Overlay" |
VendorId / ProductId |
0xBEEF / 0xCA7F |
OverlayInstanceGuid |
BEBC0001-0000-0000-0000-CAFEFACE0002 (fixed) |
OverlayProductGuid |
BEBC0000-0000-0000-0000-CAFEFACE0002 (fixed) |
NumAxes / NumHats |
0 / 0 |
NumButtons / RawButtonCount |
17 (touchpad click lives at Buttons[16]) |
SupportedButtonIndices |
[16] (sparse, only the touchpad click is populated) |
HasTouchpad |
true |
HasRumble / HasGyro / HasAccel |
all false |
DevicePath |
"overlay://touchpad" |
The overlay window feeds touch state in through UpdateState(TouchpadState) (the mouse-drag fallback, slot 0 only) or UpdateStateMulti(TouchpadInputState, bool click) (up to OverlayMaxFingers = 5 contacts from a touch display). The device exposes the resulting CustomInputState (Touchpads[] / Buttons[16]) through the standard GetCurrentState interface so Step 2 reads it the same way it reads SDL devices. There is only ever one overlay device per session (SdlInstanceId = 0xFFFFFFFE).
DeviceObjectItem¶
File: PadForge.Engine/Common/DeviceObjectItem.cs
Namespace: PadForge.Engine
Describes a single input object (axis, button, hat, slider) on a device. Used by mapping UI and pipeline.
public class DeviceObjectItem
{
// Identity
public string Name { get; set; } // Default: ""
public Guid ObjectTypeGuid { get; set; } // Default: Guid.Empty
public DeviceObjectTypeFlags ObjectType { get; set; } // Default: All
// Position
public int InputIndex { get; set; } // Default: 0
public int Offset { get; set; } // Default: 0
// Computed helpers (read-only)
public bool IsAxis { get; }
public bool IsButton { get; }
public bool IsPov { get; }
public bool IsSlider { get; }
public override string ToString(); // "{Name} ({TypeLabel}, Index {InputIndex})"
}
Properties¶
| Property | Type | Default | Description |
|---|---|---|---|
Name |
string |
"" |
Display name (e.g., "X Axis", "Button 3") |
ObjectTypeGuid |
Guid |
Guid.Empty |
Well-known GUID from ObjectGuid |
ObjectType |
DeviceObjectTypeFlags |
All |
Classification flags |
InputIndex |
int |
0 |
Zero-based index into CustomInputState arrays |
Offset |
int |
0 |
Byte offset (synthetic for SDL, mapping compatibility) |
Computed Properties¶
| Property | Logic |
|---|---|
IsAxis |
(ObjectType & Axis) != 0 |
IsButton |
(ObjectType & Button) != 0 |
IsPov |
(ObjectType & PointOfViewController) != 0 |
IsSlider |
ObjectTypeGuid == ObjectGuid.Slider |
InputTypes¶
File: PadForge.Engine/Common/InputTypes.cs
Namespace: PadForge.Engine
DeviceObjectTypeFlags¶
[Flags]
public enum DeviceObjectTypeFlags : int
{
All = 0,
RelativeAxis = 1,
AbsoluteAxis = 2,
Axis = 3, // RelativeAxis | AbsoluteAxis
PushButton = 4,
Button = 12,
PointOfViewController = 16,
ForceFeedbackActuator = 0x01000000
}
ObjectGuid¶
Well-known GUIDs for device object types, matching DirectInput GUID constants.
| Field | GUID | Description |
|---|---|---|
XAxis |
{A36D02E0-C9F3-11CF-BFC7-444553540000} |
GUID_XAxis |
YAxis |
{A36D02E1-C9F3-11CF-BFC7-444553540000} |
GUID_YAxis |
ZAxis |
{A36D02E2-C9F3-11CF-BFC7-444553540000} |
GUID_ZAxis |
RxAxis |
{A36D02F4-C9F3-11CF-BFC7-444553540000} |
GUID_RxAxis |
RyAxis |
{A36D02F5-C9F3-11CF-BFC7-444553540000} |
GUID_RyAxis |
RzAxis |
{A36D02E3-C9F3-11CF-BFC7-444553540000} |
GUID_RzAxis |
Slider |
{A36D02E4-C9F3-11CF-BFC7-444553540000} |
GUID_Slider |
Button |
{A36D02F0-C9F3-11CF-BFC7-444553540000} |
GUID_Button |
Key |
{55728220-D33C-11CF-BFC7-444553540000} |
GUID_Key |
PovController |
{A36D02F2-C9F3-11CF-BFC7-444553540000} |
GUID_POV |
Unknown |
Guid.Empty |
GUID_Unknown |
InputDeviceType¶
Integer constants. 18–24 match the DirectInput device type values (DI8DEVTYPE_MOUSE through DI8DEVTYPE_1STPERSON). Supplemental is 25 here, where DirectInput's DI8DEVTYPE_SUPPLEMENTAL is 28. 26 and up are PadForge extensions. Used in UserDevice.CapType, which serializes as an int in PadForge.xml, so the list is append-only.
| Constant | Value | Description |
|---|---|---|
Mouse |
18 | Mouse |
Keyboard |
19 | Keyboard |
Joystick |
20 | Joystick |
Gamepad |
21 | Gamepad |
Driving |
22 | Steering wheel |
Flight |
23 | Flight stick |
FirstPerson |
24 | First-person device |
Supplemental |
25 | Supplemental device (guitar, drum, dance pad) |
Touchpad |
26 | Precision touchpad |
Midi |
27 | MIDI controller (#128) |
Nfc |
28 | NFC reader (#150) |
ConsumerControl |
29 | Consumer Control / media keys (#168) |
HeadsetMotion |
30 | Sony headset head-tracker IMU over Bluetooth Classic HID (#188). The WH-1000XM5 family exposing the Android Head Tracker sensor collection as a gyro / accel motion source |
Microphone |
31 | Standalone Windows capture endpoint (#317). Its buttons are registered voice phrases. A mic-bearing controller carries its phrases on its own device instead |
HandheldButtons |
32 | Handheld PC hidden buttons (#343). One per-machine row whose buttons are learned on the machine, delivered by the firmware either as keyboard chords through the low-level hooks or as bits and codes in a vendor HID input report |
SystemMotion |
33 | The machine's own gyroscope and accelerometer through the Windows sensor stack (#343), for handhelds whose IMU sits in the tablet rather than in the controller halves |
HeadTracker |
34 | A head pose from OpenTrack's UDP output, the FreeTrack 2.0 shared memory, or a VR headset through an OpenXR runtime (#355, #403). Six absolute axes, yaw / pitch / roll plus the three translations, centered at rest. Decoded by HeadPose |
Tablet |
35 | A Windows pen or drawing tablet. Barrel buttons, eraser, inversion and in-range as named buttons. The contact rides the touchpad lane, so the row carries no axes |
VrController |
36 | One VR motion controller read through an OpenXR runtime (#403). Ten axes, six pose in the head row's order plus thumbstick, trigger and grip, and four buttons. Left and right are separate rows |
LogitechGKeys |
37 | The G-keys on a Logitech keyboard and a Logitech mouse's buttons 6 through 20, read through the G-key SDK (#454). 102 buttons, no axes |
AnalogKeyboard |
38 | An analog keyboard (#468), Hall-effect or analog optical, read over its vendor interface. Every key's press depth rides CustomInputState.AnalogKeys, not the numbered arrays |
WebMenus |
39 | A phone on the web controller's Web Menus layout (#471). No axes or buttons. A tap fires a menu cell directly |
PeripheralLighting |
40 | Output a vendor's software delivers (#494): the Razer Chroma and Logitech LIGHTSYNC rows, and the row of a lit device no mouse or keyboard row reaches, such as a headset or a light strip Windows lists as a HID LampArray. No inputs. The row is assigned for its Lighting tab |
PeripheralHaptics |
41 | The Razer Sensa row, which plays rumble on Sensa HD devices (#494). No inputs. The row is assigned for its Force Feedback tab |
KeyboardScreenKeys |
42 | One keyboard screen's row (#519), with the keys beside that screen: the four under a Logitech monochrome screen and its applet key (5 buttons), or the G19's seven display keys and its settings key (8). The Arx Control and S.T.R.I.K.E. 7 rows carry no keys |
NovintFalcon |
43 | A Novint Falcon opened through Force Dimension's library (#521): the handle's three axes and the grip's four buttons |
GameTelemetry |
44 | The Game Telemetry row (#531): Speed, RPM, Throttle, Brake, Clutch, Handbrake and Steering, one axis per custom variable, and Driving, Reverse, Neutral and Gear 1 to Gear 18 as buttons |
AnswersAnyDeviceSources¶
InputDeviceType carries one predicate beside its constants:
It returns false for HeadTracker, Nfc, Microphone, HandheldButtons, ConsumerControl, Tablet, VrController, LogitechGKeys, AnalogKeyboard, WebMenus, PeripheralLighting, PeripheralHaptics, KeyboardScreenKeys, NovintFalcon and GameTelemetry.
Those rows publish their own vocabulary through the numbered Axis and Button arrays, or leave the arrays at zero, so a mapping whose DeviceGuid is empty, the "(Any Device)" wildcard, must not read them. A resting head tracker otherwise held both triggers at half pull, which is what discussion #431 reported. Three call sites honor it: the device-free macro trigger path, the mapping-set evaluator, and the menu runtime.
The rule also holds for each input. A row that answers the wildcard still reads rest for a source that takes a numbered axis the device does not have. A device holds 0 where it has no axis, and 0 reads as full deflection on a stick read, so a touchpad, keyboard or motion sensor beside a controller pinned an "(Any Device)" stick row, won Step 4's merge and engaged a device-free stick menu.
SourceCoercion.NumberedAxesRead names the axes one descriptor takes: "Axis N" and its Gamepad aliases, "Slider N" as overflow axis 24 + N, and a stick ring's or flick stick's pair. It keeps the answer per descriptor, so the poll allocates nothing. A source takes its Descriptor when its kind reads it. Incremental and Ramped never do, reading ParamUp and ParamDown, and the motion kinds read gravity or the shake envelope on a stick target but fall to the Direct read, which takes Descriptor, on a button or trigger target. It also takes its two gate legs, which read the same device. On a stick target a steering source (WindingStick, AngleToAxisX, AngleToAxisY) reads Descriptor and ParamYDescriptor as one stick, and on a button or trigger target it falls to the Direct read of Descriptor. A Direct stick read with a Steam Circle deadzone (shape 2) takes the stick axis's partner too (SourceCoercion.CompanionAxisRead). The steering reader and the button and trigger reads never take the partner.
UserDevice.HasAxis answers the way the mapping picker lists a device's axes. Force Raw Joystick Mode on a gamepad-opened device reads the joystick's own axes, the six from 0 or every raw axis with generic extras, so those answer. Otherwise the device's object list answers when it names anything. A row without one answers an axis from the recorded CapAxisIndices, then CapAxeCount, and a slider from its raw axis count. A native Precision Touchpad row records a count of 0 and has no axes. A row with no wrapper whose capabilities were never loaded has every axis, the default profile's fallback for a device it has never seen. The twelve pressure axes of a Bliss-Box port read raw answer whatever the port holds (BlissBoxRuntime.PressureAxis): the merge leaves them at 0 without a DualShock 2, where a DualShock 2 rests them too, and the object list names them only after the UI thread refreshes it, or never on a peer's copy.
An axis the device does not list still reads while it holds anything but 0 in the state the read takes (InputManager.DeviceHasAxis). A mouse's Axis 2 never reads through the wildcard, though the mouse lists it: it is the wheel, resting at center, and the wildcard reads index 2 as the Left Trigger, so a mouse on the slot held an (Any Device) Left Trigger at half pull. The wheel still reads through its own Mouse Scroll source. A gamepad read writes a stick it lacks at center, a web pad starts every axis at rest, and a Remote Link copy starts at the codec's neutral, so only a 0 there is the filler of a device without the axis. A wheel whose SDL mapping binds leftx and no lefty steers through a stick ring and a Steam Circle deadzone with its Axis 1 at center. A built web pad adds an axis its page sends after connecting to its list before the value lands (WebControllerDevice.UpdateAxis), so a stick added and saved without reconnecting still reads when pushed to full deflection, where its value is 0. A Remote Link copy takes the new list at the owner's next device-list push, which runs every two seconds. Until then, and whatever its list says, a copy of a gamepad reads its stick axes (0, 1, 3 and 4) below the owner's axis count (InputManager.PeerStickAxis), because every gamepad read fills them with input or a rest: SDL's gamepad read writes a missing stick at center, a raw read reads its whole span, and a web pad starts its sticks centered.
The Gamepad names add SDL's rule on top (InputManager.SpeaksGamepadLayout): a gamepad role exists only on a device read in SDL's gamepad layout. A source that names the family in a read it takes on the pass device (its input where its kind reads one, a gate, a steering kind's axis alias) answers only on an SDL device opened as SDL's gamepad (GamepadHandle set, an XInput arcade stick or wheel included), a Bliss-Box port read raw with a placed controller in it, or another device typed Gamepad (the web pad). A Remote Link copy has no handle and answers by the layout its list entry carries (RemotePeerDeviceInfo.GamepadLayout, the owner's InSdlGamepadLayout): a copy of a wheel, flight stick, arcade stick or instrument pad SDL opened as its gamepad answers, and a copy of a gamepad its owner reads outside the layout answers none. An entry that carries no layout leaves the copy to its type, so a gamepad's copy answers, and a copy of a pad its owner reads in Force Raw Joystick Mode arrives typed a joystick (InputService.ExposedDeviceType) and answers none. A keyboard, a mouse, a touchpad, a joystick SDL has no mapping for and a pad in Force Raw Joystick Mode answer none, while a numbered wildcard read still reaches them. A steering kind reads a ring as centered, so a ring there asks for nothing (ReadsGamepadFamily). An Incremental or Ramped source's keys read the controllers they name, and an "(Any Device)" key tests the family on each device it reads (InputManager.KeyHeld), so the pass device's layout asks nothing of them.
A Bliss-Box port read raw numbers its inputs its own way. While a read runs against it, the family resolves through the placement of the controller identified in it (SourceCoercion.GamepadPlacementProvider, which the app sets to InputManager.GamepadPlacementFor over BlissBoxRuntime.GamepadMapFor), so "Gamepad A" reads the input the port's default A row reads and a position the placement leaves empty reads nothing. The evaluator entry points (SourceEvaluator.EvaluateFor* and SourceCoercion.EvaluateFor*) open the scope for the device a read takes, a pinned source's own or else the evaluated one (SourceCoercion.ReadingDevice). An empty guid keeps the scope around it, which the wildcard activator reads open per device (ReadActivatorInputOn). HasAxesFor checks a family source's axes under the device's placement. The axis and steering memos skip family names while a placement is in force, the ring reader takes the placed stick axes, and a gate keeps a family name as written, so each read resolves it for its own device. BlissBoxControllers.GamepadMap serves one map per controller type, firmware major and pressure base.
Every wildcard read applies it: one-source and multi-source rows, stick trim, device-free menus (their stick and click reads), device-free macro descriptor and axis triggers and the trigger buttons they consume, the flick-stick mouse lane, and the Web Menus phone where it stands in for a menu cell. A source or macro entry that names its device reads it as it is. An "(Any Device)" layer activator advances only from a device with the axes its input, gates and a same-device chord half read, a Cycle's Previous button is picked across the slot the way its Next is, and an activator no device on the slot can read settles released, as a pinned one does when its device leaves.
One exception rides on top (#471). A menu cell source (Menu {id} Item {k}) asks the menu runtime which cells fired and never reads the pass device's arrays. So on a slot where no online device answers the wildcard, the slot's Web Menus phone reads its "(Any Device)" menu cell sources. A row read on every pass reads the cell on the phone's own pass. A row read once per frame (a multi-source row, a stick trim, a row modifier) reads it as the phone whichever pass claims the row, since another device that answers nothing can come first. The device-free macro trigger path and its Consume Trigger Buttons set read one under the phone's identity. A slot with a controller on it reads the cell on the controller's pass, as before phones existed.
An Incremental or Ramped source with no controller of its own is the other exception. Its keys read the controllers they name, nothing of the pass device, so on a slot where no online device answers the wildcard every pass that has its gates' axes reads it (InputManager.KeysStandIn). A row read once a frame reads an ungated one on the pass in hand and a gated one on each answering device that has its gates' axes (ReadsKeysOnPassInHand). Keys on two controllers that never answer, a pair of VR controllers, leave such a source with no pass otherwise.
MapType¶
ForceFeedbackState¶
File: PadForge.Engine/Common/ForceFeedbackState.cs
Namespace: PadForge.Engine
Per-device force feedback (rumble) state with change detection. Only sends to hardware when motor values differ. Uses a uint.MaxValue duration to mimic XInput's "set and forget" model. SDL caps every duration at 0xFFFF ms, about 65.5 s, so a level held unchanged for longer than that stops.
Public Properties¶
| Property | Type | Description |
|---|---|---|
LeftMotorSpeed |
ushort |
Last sent left (low-freq) motor speed, 0–65535. Read-only. |
RightMotorSpeed |
ushort |
Last sent right (high-freq) motor speed, 0–65535. Read-only. |
LeftTriggerMotorSpeed |
ushort |
Last sent left impulse-trigger motor speed, 0–65535 (Xbox One+ raw HID lane). Read-only. |
RightTriggerMotorSpeed |
ushort |
Last sent right impulse-trigger motor speed, 0–65535. Read-only. |
IsActive |
bool |
Whether FFB is active on the device. Read-only. |
Private Fields (Change Detection Cache)¶
| Field | Type | Description |
|---|---|---|
_cachedLeftMotorSpeed |
ushort |
Last sent left motor speed |
_cachedRightMotorSpeed |
ushort |
Last sent right motor speed |
_cachedLeftTriggerMotorSpeed |
ushort |
Last sent left impulse-trigger speed |
_cachedRightTriggerMotorSpeed |
ushort |
Last sent right impulse-trigger speed |
_scalarNeedsWrite |
bool |
Forces the next scalar write even at unchanged values, set when a directional effect or the auto-center spring took the haptic slot |
_directWriteNeedsRetry |
bool |
Set by MarkDirectWriteFailed so the next direct-writer snapshot reports a change and retries |
_hapticEffectId |
int |
SDL haptic effect ID (-1 = none) |
_hapticEffectCreated |
bool |
Whether a haptic effect has been created |
_cachedDirectional |
DirectionalSnapshot? |
Last directional or condition effect delivered: effect type, signed magnitude, direction, period, device and overall gain, both motor speeds, and a copy of up to two condition axes. Cached only after a successful write |
_lastEmitFailed / _lastDirFailed |
bool |
Throttle the HAPTICDIAG failure lines to one per failure run |
_autoCenterActive |
bool |
Software auto-center spring engaged (generic SDL wheels) |
_autoCenterCoeff |
short |
Last sent auto-center spring coefficient |
Public Methods¶
| Method | Signature | Description |
|---|---|---|
SetDeviceForces |
void SetDeviceForces(UserDevice ud, ISdlInputDevice device, PadSetting ps, Vibration v) |
Main entry. Reads gain from PadSetting. Routes to directional haptic when HasDirectionalData or HasConditionData and device supports haptic, or scalar rumble otherwise. Only sends when values change. |
StopDeviceForces |
void StopDeviceForces(ISdlInputDevice device) |
Stops all rumble/haptic and resets cached state. |
TryRecordXboxImpulseSnapshot |
bool TryRecordXboxImpulseSnapshot(ushort leftMotor, ushort rightMotor, ushort leftTrigger, ushort rightTrigger) |
Change-detection bookkeeping for the Xbox One+ skip-SDL path (XboxImpulseHidWriter). Returns true when any of the four speeds differs from the last write, or a failed write was marked (caller issues a fresh HID write). Always updates the public motor-speed properties and IsActive. |
TryRecordMotorSnapshot |
bool TryRecordMotorSnapshot(ushort leftMotor, ushort rightMotor) |
Two-motor form for direct writers without trigger motors (the Padix PSX converter). Triggers record as zero. |
MarkDirectWriteFailed |
void MarkDirectWriteFailed() |
Marks the snapshot just recorded as undelivered, so the next call returns true at the same values. |
Static helpers for the native wheel writers: ComputeWheelSteeringLevel (gain-scaled magnitude projected onto the steering axis, sampled through the periodic waveform), ComputeWheelSteeringPeak (the same projection without the waveform, for firmware periodic effects), ComputeWheelRumbleLevel (rumble turned into a steering-axis buzz, 120 ms period for the heavy channel and 40 ms for the light one), IsPeriodicEffect, and IsGenericWheelSpringCapable (haptic handle, spring support, and InputDeviceType.Driving, shared with the Wheel tab's gate).
Private Methods¶
| Method | Description |
|---|---|
SetDirectionalHapticForces(device, v, overallGain) |
Directional constant/periodic force. Single-axis (wheels): projects via sin(angle). Multi-axis: full 2D polar. Falls back to scalar if unsupported. |
SetConditionHapticForces(device, v, overallGain) |
Condition effects (spring/damper/friction/inertia) with per-axis coefficients. Scales HID (−10000..+10000) to SDL (−32767..+32767). |
TryApplyAutoCenterSpring(device, ps) |
Software centering spring for a generic SDL wheel, from the Wheel tab's Auto Centering Strength (AutoCenterStrength, 0–100 to a 0–32767 coefficient). Runs only when the game sends no directional effect. |
SetHapticForces(device, left, right) |
Scalar haptic fallback. Translates dual-motor to SDL effect per HapticEffectStrategy. |
ApplyHapticEffect(device, ref effect) |
Creates on first call, updates in-place after. Avoids create/destroy churn. A rejected update destroys the effect and creates a new one. |
StopAndDestroyHapticEffect(device) |
Stops and destroys active haptic effect. Resets effect state. |
Scalar Haptic Strategy Mapping¶
| Strategy | SDL Effect | Large Motor | Small Motor |
|---|---|---|---|
| LeftRight | SDL_HAPTIC_LEFTRIGHT |
large_magnitude = left |
small_magnitude = right |
| Sine | SDL_HAPTIC_SINE |
magnitude = max/2, period = 120 |
period = 40 |
| Constant | SDL_HAPTIC_CONSTANT |
level = max/2 |
N/A |
FfbEffectTypes¶
File: PadForge.Engine/Common/ForceFeedbackState.cs
Namespace: PadForge.Engine
FFB effect type constants matching the HID PID effect-type values used in HIDMaestro's PID descriptor path. Defined in Engine so both Engine and App can reference them.
| Constant | Value | Description |
|---|---|---|
None |
0 | No effect |
Const |
1 | Constant force |
Ramp |
2 | Ramp force |
Square |
3 | Square wave periodic |
Sine |
4 | Sine wave periodic |
Triangle |
5 | Triangle wave periodic |
SawUp |
6 | Sawtooth up periodic |
SawDown |
7 | Sawtooth down periodic |
Spring |
8 | Spring condition |
Damper |
9 | Damper condition |
Inertia |
10 | Inertia condition |
Friction |
11 | Friction condition |
Vibration¶
File: PadForge.Engine/Common/ForceFeedbackState.cs
Namespace: PadForge.Engine
Vibration/FFB state for a virtual controller slot. Carries scalar motor speeds (rumble) and directional FFB data (haptic joysticks/wheels).
public class Vibration
{
// Scalar fields (HIDMaestro XInput / HID rumble callback path)
public ushort LeftMotorSpeed { get; set; } // 0-65535, low-frequency heavy rumble
public ushort RightMotorSpeed { get; set; } // 0-65535, high-frequency light buzz
// Impulse-trigger motors (Xbox One+), driven by XINPUT_VIBRATION_EX /
// GameInput's per-trigger vibration API. 0 on devices without them.
public ushort LeftTriggerMotorSpeed { get; set; } // 0-65535
public ushort RightTriggerMotorSpeed { get; set; } // 0-65535
// Directional FFB fields (HIDMaestro PID/FFB callback for haptic devices)
public bool HasDirectionalData { get; set; }
public uint EffectType { get; set; } // FfbEffectTypes constant
public short SignedMagnitude { get; set; } // -10000 to +10000
public ushort Direction { get; set; } // Polar 0-32767 (0=North)
public uint Period { get; set; } // ms, for periodic effects
public byte DeviceGain { get; set; } = 255; // 0-255, device-level gain
// Condition effect fields (spring/damper/friction/inertia)
public bool HasConditionData { get; set; }
public ConditionAxisData[] ConditionAxes { get; set; }
public int ConditionAxisCount { get; set; } // 1 for wheels, 2 for joysticks
// Constructors
public Vibration();
public Vibration(ushort leftMotor, ushort rightMotor);
}
Fields¶
| Field | Type | Default | Description |
|---|---|---|---|
LeftMotorSpeed |
ushort |
0 | Left (low-freq) motor speed. Set by HIDMaestro OutputReceived callback. |
RightMotorSpeed |
ushort |
0 | Right (high-freq) motor speed. Set by HIDMaestro OutputReceived callback. |
LeftTriggerMotorSpeed |
ushort |
0 | Left impulse-trigger motor (Xbox One+), from XINPUT_VIBRATION_EX / GameInput's per-trigger API. 0 on pads without impulse motors |
RightTriggerMotorSpeed |
ushort |
0 | Right impulse-trigger motor. Same contract |
HasDirectionalData |
bool |
false |
Directional FFB data available (HIDMaestro PID descriptor path) |
EffectType |
uint |
0 | FfbEffectTypes constant |
SignedMagnitude |
short |
0 | −10000 to +10000. Negative = opposite direction. |
Direction |
ushort |
0 | Polar HID units 0–32767 (0=N, ~8192=E, ~16384=S, ~24576=W) |
Period |
uint |
0 | Period in ms (periodic effects) |
DeviceGain |
byte |
255 | Device-level gain 0–255, on top of per-effect gain |
HasConditionData |
bool |
false |
Per-axis condition data available |
ConditionAxes |
ConditionAxisData[] |
null |
Per-axis coefficients (0=X, 1=Y) |
ConditionAxisCount |
int |
0 | Valid entries (1 = wheel, 2 = joystick) |
ConditionAxisData¶
File: PadForge.Engine/Common/ForceFeedbackState.cs
Namespace: PadForge.Engine
Per-axis condition parameters for spring/damper/friction/inertia effects.
public struct ConditionAxisData
{
public short PositiveCoefficient; // 0–10000, force when displacement > center
public short NegativeCoefficient; // 0–10000, force when displacement < center
public short Offset; // -10000 to +10000, center offset
public uint DeadBand; // 0–10000, dead band around center
public uint PositiveSaturation; // 0–10000
public uint NegativeSaturation; // 0–10000
}
InputHookManager¶
File: PadForge.Engine/Common/InputHookManager.cs
Namespace: PadForge.Engine.Common
Manages WH_KEYBOARD_LL and WH_MOUSE_LL low-level hooks to suppress mapped keyboard/mouse inputs. A key or button PadForge's own virtual HID keyboard or mouse pressed is never consumed or read as a physical press: the hooks ask OwnHidOutput first, since such a device carries no output tag. IsExtendedKey also covers the browser, volume, media and launch keys (VK 0xA6 to 0xB7), whose scan codes carry the E0 prefix. Only suppresses inputs in the active suppression sets. The same hooks fire global hotkeys and feed the handheld chord engine (#343). The keyboard hook decides through ConsumeKey, which numbers the event with RawInputListener.KeyIndex from its vkCode, scanCode and LLKHF_EXTENDED. Numpad Enter reaches the hook as VK_RETURN with LLKHF_EXTENDED and a mapping names it 0x88, so the hook used to miss a consumed Numpad Enter and swallow Numpad Enter with a consumed Enter (#486).
Every key and mouse event PadForge sends through SendInput as output, from the Keyboard + Mouse virtual controller and the macro emitters, carries OutputTag, 0x50464F55 ("PFOU"), in dwExtraInfo. Absolute cursor positioning uses SetCursorPos, outside this SendInput tagging path. HandleKeyboardEvent and HandleMouseButton, the hooks' whole decision split from the callbacks, pass a tagged event before anything counts it as physical: no physical-key tracking, hotkeys, chords or consumption. Consuming a key used to swallow PadForge's own output of that key. A ReplayTag event is a physical key replayed, and it still takes consumption.
public class InputHookManager : IDisposable
{
void Start();
void Stop();
void SetSuppressedKeys(HashSet<int> vkCodes);
void SetSuppressedMouseButtons(HashSet<int> buttons);
bool HasAnySuppression { get; }
int RegisterGlobalHotkey(int[] vkCodes, Action callback);
void UnregisterGlobalHotkey(int id);
void ClearGlobalHotkeys();
static void MergeHookedKeyState(bool[] dest, int count);
static void MergeHookedMouseState(bool[] dest, int count);
// Handheld chords (#343)
static HandheldChordEngine ChordEngine { get; set; }
static readonly IntPtr ReplayTag; // 0x50464843, "PFHC"
static readonly IntPtr OutputTag; // 0x50464F55, "PFOU"
static event Action ChordWorkPending;
static void InjectReplay(int code, bool down, int ident = 0);
static void InjectWinMask();
static bool IsExtendedKey(int vk);
static (ushort Vk, bool Extended) OutputKey(int index);
}
Methods¶
| Method | Signature | Description |
|---|---|---|
Start |
void Start() |
Creates background thread with GetMessage loop, installs both hooks. Blocks until installed (5s timeout). |
Stop |
void Stop() |
Posts WM_QUIT to hook thread, joins (2s timeout), clears state. |
SetSuppressedKeys |
void SetSuppressedKeys(HashSet<int> vkCodes) |
Updates the keys to suppress, as keyboard state indices (RawInputListener.KeyIndex), the numbers a keyboard mapping's Button N carries. Clears state for removed keys. Volatile reference swap. |
SetSuppressedMouseButtons |
void SetSuppressedMouseButtons(HashSet<int> buttons) |
Updates mouse button IDs to suppress (0=L, 1=M, 2=R, 3=X1, 4=X2). Clears state for removed buttons. Volatile reference swap. |
HasAnySuppression |
bool (property) |
true if any keys or mouse buttons suppressed. |
RegisterGlobalHotkey |
int RegisterGlobalHotkey(int[] vkCodes, Action callback) |
Registers a combo from GlobalHotkeyParser.Parse. The callback fires once on the rising edge when every key is held, with modifiers matching either side. Never suppresses the keystroke. Returns the id UnregisterGlobalHotkey takes. |
UnregisterGlobalHotkey / ClearGlobalHotkeys |
void UnregisterGlobalHotkey(int id) / void ClearGlobalHotkeys() |
Removes one registration, or all of them on engine teardown. |
MergeHookedKeyState |
static void MergeHookedKeyState(bool[] dest, int count) |
Merges suppressed-key state into dest (hook state is authoritative). Called by SdlKeyboardWrapper. |
MergeHookedMouseState |
static void MergeHookedMouseState(bool[] dest, int count) |
Same for mouse buttons. Called by SdlMouseWrapper. |
InjectReplay / InjectWinMask |
static void InjectReplay(int code, bool down, int ident = 0) / static void InjectWinMask() |
SendInput a key or mouse button stamped with ReplayTag in dwExtraInfo, so the hook skips the chord engine for it and sends it down the normal consumption path, where a mapped key is still swallowed. InjectWinMask taps VK 0xFF so releasing a swallowed chord's Win key does not open Start. Called from the hook callback for a replay an event ends, which must land before that event goes on, and from the replay worker for the timed ones. ident is the physical key the hook saw, its scan code with 0x100 for LLKHF_EXTENDED (ReplayIdentity), and ReplayKey builds wScan and KEYEVENTF_EXTENDEDKEY from it. Numpad Enter and Enter share VK_RETURN, so a held Numpad Enter used to replay as Enter. |
OutputKey |
static (ushort Vk, bool Extended) OutputKey(int index) |
The key an authored output index sends: the index as its own VK with the extended-key table, except Numpad Enter's 0x88, which goes out as VK_RETURN with the extended flag. The Keyboard + Mouse virtual controller and the macro key emitter both send through it. |
Hook Callbacks¶
- Keyboard: Intercepts
WM_KEYDOWN/UP,WM_SYSKEYDOWN/UPand hands each toHandleKeyboardEvent, which passes anOutputTagevent at once. Tracks physical key state and checks the global hotkeys first, then offers the key to the chord engine, which can swallow it. Returns(IntPtr)1to suppress,CallNextHookExto pass through. Captures state into_hookedKeyState[]before suppressing (LL hook runs beforeWM_INPUT). - Mouse: Intercepts button messages (
WM_[LR/M/X]BUTTONDOWN/UP). Converts viaMouseMessageToButtonId()and hands each toHandleMouseButton, which passes anOutputTagevent at once. Offers the button to the chord engine when one is set, then captures suppressed buttons into_hookedMouseState[]. - Events injected by other software (
LLKHF_INJECTED/LLMHF_INJECTED) and PadForge's ownReplayTagevents skip the chord engine.
Button ID Mapping¶
| Mouse Message | Button ID |
|---|---|
WM_LBUTTONDOWN/UP |
0 (Left) |
WM_MBUTTONDOWN/UP |
1 (Middle) |
WM_RBUTTONDOWN/UP |
2 (Right) |
WM_XBUTTONDOWN/UP (XBUTTON1) |
3 |
WM_XBUTTONDOWN/UP (XBUTTON2) |
4 |
| Other (move, wheel) | -1 (pass through) |
P/Invoke¶
| Function | DLL | Purpose |
|---|---|---|
SetWindowsHookExW |
user32.dll | Install low-level hook |
UnhookWindowsHookEx |
user32.dll | Remove hook |
CallNextHookEx |
user32.dll | Pass input to next hook |
GetModuleHandleW |
kernel32.dll | Get module handle for hook registration |
GetMessageW |
user32.dll | Message pump loop |
PostThreadMessageW |
user32.dll | Post WM_QUIT to hook thread |
GetCurrentThreadId |
kernel32.dll | Get hook thread ID |
SendInput |
user32.dll | Inject chord replays and the Win mask |
MapVirtualKeyW |
user32.dll | Scan code for an injected key |
OwnHidOutput¶
File: PadForge.Engine/Common/OwnHidOutput.cs
Namespace: PadForge.Engine.Common
What PadForge's own virtual HID keyboards and mice hold, so the low-level hooks let that output through as they let SendInput output through. SendInput output carries InputHookManager.OutputTag, but a virtual HID keyboard reaches the hooks the way a real one does: no tag, no injected flag, and no device in KBDLLHOOKSTRUCT or MSLLHOOKSTRUCT. A key a slot sends that is also a consumed source key would be swallowed and recorded as a physical press the slot then reads back, held down by the key's own repeats. So a sender (KbmHidKeyboard, KbmHidMouse) records each key and button before the report that presses it, and each release before the report that lets go, and the hooks ask here first (IsOwnKey, IsOwnButton).
A key matches by the hook's key index (RawInputListener.KeyIndex) or by its scan code and E0 flag, whichever the event agrees with: with Num Lock off Windows reads a keypad key as an arrow or Home, which changes the code and keeps the scan code. A key down counts as PadForge's from the press until Windows delivers the release, which covers the repeats Windows makes for a held HID key, including one that lands while the release is on its way. A key up is PadForge's once for each release a sender sent. A release Windows has not delivered in two seconds (PendingWindowMs) is dropped, so it cannot later take a physical release for PadForge's. While nothing is recorded, a hook event costs no lock (s_any).
RawInputListener¶
File: PadForge.Engine/Common/RawInputListener.cs
Namespace: PadForge.Engine
Receives keyboard and mouse input via Windows Raw Input API, even when unfocused (RIDEV_INPUTSINK). Creates a hidden message-only window (HWND_MESSAGE) on a background thread. State tracked per-device via RAWINPUT.header.hDevice for multi-device isolation.
DeviceInfo Struct¶
public struct DeviceInfo
{
public IntPtr Handle; // Raw Input device handle
public string Name; // Device display name
public string DevicePath; // Device interface path
public uint Type; // 0 = mouse, 1 = keyboard, 2 = HID (Consumer Control)
public ushort VendorId; // USB VID
public ushort ProductId; // USB PID
}
Static Fields¶
| Field | Type | Description |
|---|---|---|
AggregateKeyboardHandle |
IntPtr |
Sentinel new IntPtr(-99). Aggregates all keyboards. |
AggregateMouseHandle |
IntPtr |
Sentinel new IntPtr(-98). Aggregates all mice. |
AggregateConsumerHandle |
IntPtr |
Sentinel new IntPtr(-97). OR-merged state of every Consumer Control collection (#168). |
Public Methods¶
| Method | Signature | Description |
|---|---|---|
Start |
static void Start() |
Starts the PadForge.RawInputListener message-pump thread, which creates the window and registers Raw Input. Returns without waiting for it. |
Stop |
static void Stop() |
Posts WM_QUIT, joins thread (2 s), and clears the per-device state. |
EnumerateKeyboards |
static DeviceInfo[] EnumerateKeyboards() |
All connected keyboards via GetRawInputDeviceList, with the "All Keyboards (Merged)" aggregate entry first. |
EnumerateMice |
static DeviceInfo[] EnumerateMice() |
All connected mice, with the "All Mice (Merged)" aggregate entry first. Releases buttons still held by a mouse that is gone. |
GetDeviceVidPid |
static void GetDeviceVidPid(IntPtr hDevice, string devicePath, out ushort vendorId, out ushort productId) |
HidD_GetAttributes first, then RIDI_DEVICEINFO, then the VID_xxxx&PID_xxxx in the path. |
ExtractFriendlyName |
static string ExtractFriendlyName(string devicePath, uint type) |
HID product string, then the registry names, then a VID:PID label, then "Keyboard" / "Mouse" / "Consumer Control". |
HasMouseState / ResolveMouseHandle |
static bool HasMouseState(IntPtr hDevice) / static IntPtr ResolveMouseHandle(string devicePath) |
Whether a mouse handle has reported, and the live handle for a device path (handles change after a PTP registration). |
GetKeyboardState |
static void GetKeyboardState(IntPtr hDevice, bool[] dest, int count) |
Copies per-device key states. Aggregate handle for combined output. |
ConsumeMouseDelta |
static void ConsumeMouseDelta(IntPtr hDevice, out int dx, out int dy) |
Returns and resets accumulated mouse delta. |
ConsumeMouseScroll |
static int ConsumeMouseScroll(IntPtr hDevice) |
Returns and resets scroll delta. |
GetMouseButtons |
static void GetMouseButtons(IntPtr hDevice, bool[] dest) |
Copies per-device button states (5: L, M, R, X1, X2). |
EnumerateConsumerControls |
static DeviceInfo[] EnumerateConsumerControls() |
Every Consumer Control HID collection (#168), plus the merged aggregate entry. |
GetConsumerState |
static void GetConsumerState(IntPtr hDevice, bool[] dest, int count) |
Copies per-device consumer-usage states. Pass AggregateConsumerHandle for the OR-merged view. |
GetDynamicSlotUsage |
static ushort GetDynamicSlotUsage(int slot) |
The live usage ID currently occupying a session-dynamic consumer slot, for naming it in the picker. |
Input Processing¶
- Keyboard (
RIM_TYPEKEYBOARD): ReadsRAWKEYBOARD.VKeyand stores each key atRawInputListener.KeyIndex: a modifier reported neutral at its left or right code (Shift by scan code 0x2A or 0x36, Ctrl and Alt byRI_KEY_E0), Numpad Enter (VK_RETURN withRI_KEY_E0) atNumpadEnterKey, 0x88, apart from Enter, and every other key at its own code. The low-level hook numbers what it swallows through the same table (#486). Per-device state inConcurrentDictionary<IntPtr, bool[]>. A record that is no key changes nothing: PadForge's own output (ExtraInformationequal toInputHookManager.OutputTagValue), an overrun (MakeCode0xFF), and a record with no VKey (0xFF), half of an escaped sequence such as the fake Shift around PrintScreen. The make code loses its break bit beforeKeyIndex, since the On-Screen Keyboard can set it. RawInputDemo's keyboard handler does both (RawInputDeviceKeyboard.cpplines 228-244). The iCade driver still sees every record first. - Mouse (
RIM_TYPEMOUSE): AccumulateslLastX/lLastYdeltas. Tracks buttons viausButtonFlags. Scroll viaRI_MOUSE_WHEEL. A record whoseulExtraInformationisOutputTagValueis PadForge's own output and changes nothing (ApplyMouseRecord). Raw Input reports aSendInputevent'sdwExtraInfothere, with device handle 0. - Scroll:
usButtonDatais a signedshort. Accumulated per-device, consumed byConsumeMouseScroll. - PadForge's own virtual devices:
IsOwnVirtualDevicePath, which the app sets to HidHide's HIDMaestro test, marks a device PadForge made through HIDMaestro, such as a Keyboard + Mouse slot's virtual keyboard or mouse. Its records reach no keyboard, mouse or consumer state, merged or not, so a slot fed by All Keyboards cannot type into itself. The answer is cached per device handle, and a listing drops the handles it no longer has, so a handle Windows reuses is asked again.SetOwnVirtualDeviceForTestssets an answer for a test. - Absolute-mode skip: when
RAWMOUSE.usFlagshasMOUSE_MOVE_ABSOLUTE(bit 0) set,lLastX/lLastYare absolute coordinates in 0..65535 over the active region, not deltas. RDP virtual mice, Wacom tablets in absolute mode, and some KVMs send these. Treating them as deltas would inject 0..65535-magnitude jumps into the gamepad-mapping aim and scroll paths, so the reader drops the delta for absolute events and still records the same report's buttons and wheel. - Consumer Control (
RIM_TYPEHID, #168): each report carries the full set of held usages, read withHidP_GetUsages, so the device's slot array is rebuilt from every report rather than edited.
IdleInputDetector¶
File: PadForge.Engine/Common/IdleInputDetector.cs
Namespace: PadForge.Engine.Common
(v3.6, #162) Pure idle test for the idle-disconnect countdown. It follows the idle rule of DS4Windows' isDS4Idle(), read as documentation, generalized to PadForge's normalized state. No state, no side effects. Two entry points:
| Method | Signature | Description |
|---|---|---|
IsGamepadIdle |
static bool IsGamepadIdle(CustomInputState s, CustomInputState previous = null) |
Absolute test for gamepad-typed devices (auto-map axis convention: sticks on axes 0/1/3/4 centered at 32767, triggers on axes 2/5 at rest 0). Idle when no button is pressed, no POV is deflected, both sticks sit inside the stick slop band, both triggers sit under the trigger slop, no touchpad finger is down or clicked, the IR pointer / Joy-Con 2 mouse / Raw Input mouse are inactive, and no NFC tag is held. When previous is supplied, extra axes past 5 (#193) and sliders also take the change-detection test. |
IsUnchanged |
static bool IsUnchanged(CustomInputState current, CustomInputState previous) |
Generic change-detection test for devices whose axis layout and rest positions are unknown (raw joysticks, wheels, remotes). Idle means "nothing moved since the previous poll" within a small slop. MIDI notes, CCs, and pitch bend count as movement (#128), and so do touch and the pointer / mouse sources. Known limit: an axis held rock-steady off-rest reads idle. |
Constants¶
| Constant | Value | Description |
|---|---|---|
StickSlop |
16384 | Stick slop around 32767 center. The DS4Windows 64-of-128 half-range fraction scaled to the 32767 half-range. |
TriggerSlop |
1024 | Trigger slop above 0 rest. Absorbs worn-pot jitter. |
DeltaSlop |
1024 | Axis / slider delta slop for the change-detection test. |
Motion sensors (gyro / accel) are deliberately ignored, as DS4Windows ignores them: gyro noise never settles and would defeat the countdown forever. JoyConIrIntensity is excluded for the same reason (passive ambient-light scalar, never settles). The Wii IR pointer (Ir.Detected, #146), the Joy-Con 2 mouse deltas (JoyCon2MouseDX/DY, #154), and the Raw Input mouse counts (MouseRawDX/DY, #200) count as activity, so a user aiming or moving only those sources is not disconnected mid-use. So does a Ring-Con flex past RingConSlop (0.25, two of the eight high-byte steps that read full scale), because a player exercising with the ring alone presses no button for minutes.
PrecisionTouchpadReader¶
File: PadForge.Engine/Common/PrecisionTouchpadReader.cs
Namespace: PadForge.Engine
Reads Windows Precision Touchpad (PTP) devices via Raw Input. Each enumerated PTP device shows up as a UserDevice with CapType = Touchpad and Device == null (data flows through this reader rather than an ISdlInputDevice wrapper). The reader runs its own hidden message-only window on a background thread, registers for digitizer top-level collection 0x0D / 0x05 with RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, and uses the HidP_* API family to parse contacts from each report. On WM_INPUT_DEVICE_CHANGE with GIDC_REMOVAL it frees the removed touchpad's cached HID buffers and drops its state, so an unplugged touchpad stops enumerating.
Constants¶
| Constant | Value | Purpose |
|---|---|---|
PtpMaxFingers |
5 |
Per-device contact ceiling. Matches the PTP-spec maximum and the canonical Windows-certified hardware bound. |
StaleThresholdTicks |
100 ms | If no WM_INPUT arrives within this window, all contacts and the in-progress frame are cleared on the next ReadInto. |
PtpDeviceState¶
Per-device state, keyed by Raw Input hDevice:
| Field | Type | Purpose |
|---|---|---|
X, Y, Down |
float[5] / bool[5] |
Per-slot contact position and touching flag. The gesture engine reads these via TouchpadInputState. |
LastFrameDown, CurrentContactId |
bool[5] / int[5] |
Persistent per-slot rising-edge tracking so the engine sees one continuous contact ID across the lifetime of a finger touching the slot. |
SlotToHidId |
int[5] |
HID contact ID currently occupying each engine slot, or -1 for free. Carries across frames. See "Stable slot assignment" below. |
FrameExpected, FrameSeen, FrameLifted |
int |
Multi-report frame-assembly bookkeeping. FrameLifted counts the frame's contacts that reported tip-switch 0. |
FrameBufX, FrameBufY, FrameBufId |
parallel arrays | Per-fragment scratch buffer for the contacts seen so far in the in-progress frame. |
Name, DevicePath, VendorId, ProductId, LastReportTicks |
various | Device identity + staleness timestamp. |
Spec-mandatory behaviors¶
These four behaviors are required by the Microsoft Precision Touchpad spec and are the difference between a reader that works at 2 fingers and one that works through 5.
Tip-switch (digitizer usage 0x42)¶
The PTP spec sends one final report for each contact with tip-switch = 0 at lift, then drops the contact slot from subsequent reports. Without checking the bit, a lifted contact reads as still touching, inflates the apparent contact count, and corrupts the engine-side path the gesture recognizer builds.
ReadTipSwitch calls HidP_GetUsages on each per-finger link collection and scans the returned usage list for HID_USAGE_DIGITIZER_TIP_SWITCH. Touching iff the usage is present. HID-call failure falls back to "treat as touching" so non-conformant devices that don't expose the usage retain the legacy behavior.
Multi-report frame assembly¶
Most certified PTP hardware caps each HID report at 2 contacts. A 5-finger frame arrives as three reports (2 + 2 + 1). The PTP spec carries the total contact count on the first report's contact-count usage. Continuation reports carry zero.
The reader accumulates contacts into FrameBuf* across reports and only commits ds.Down when FrameSeen + FrameLifted reaches FrameExpected. A lifted contact is counted but not buffered, because the frame's contact count includes it. Out-of-spec devices that never set contact-count (FrameExpected stays 0) fall back to per-report commit. Each fragment's contact append is bounded by FrameExpected - FrameSeen so that a descriptor with more contact link-collections than the frame actually carries (empty slots parse as zero-X/Y "contacts" with stale IDs) doesn't inflate the buffer past the spec-declared total.
Stable slot assignment by HID contact ID¶
Each contact in the assembled frame buffer carries the HID contact ID parsed from the report. Commit-time slot assignment runs in two passes:
- Pass 1, existing IDs keep their slots. For each buffered contact, scan
SlotToHidIdfor a matching ID. A contact that matches stays in the slot it already had. - Pass 2, new IDs claim free slots. For each unassigned contact, scan
SlotToHidIdfor-1. The first free slot is claimed for this contact's HID ID.
Unclaimed slots get released (SlotToHidId[s] = -1) and the ReadDeviceState synth-cid pass turns the cleared Down[s] into a wasDown→!isDown transition that terminates the path cleanly.
Without slot stability, when a low-slot finger lifts, the remaining contacts shift down in buffer-arrival order on the next frame. Engine slot 0's continuous-touch path gets extended with a different physical finger's coordinates, the resulting position jump looks like a swipe, and the tap fails to fire.
Staleness clear¶
If no WM_INPUT report arrives for the device within StaleThresholdTicks, the next ReadInto clears ds.Down, resets SlotToHidId to -1, and zeros the in-progress frame state. Prevents an orphaned partial frame from bleeding into the next touch session.
Public API¶
| Method | Signature | Description |
|---|---|---|
Start |
void Start() |
Spawns the message-pump thread and registers for digitizer Raw Input. |
Stop |
void Stop() |
Posts WM_QUIT, joins thread. |
IsAvailable |
bool { get; } |
True once the reader's thread has registered for digitizer Raw Input. |
GetDevices |
(IntPtr, string, string, ushort, ushort)[] GetDevices() |
Snapshots known devices. Called from Step 1 enumeration. |
ReadInto |
void ReadInto(IntPtr hDevice, CustomInputState state) |
Per-device read. Allocates state.Touchpads[0] if absent. |
ReadInto |
void ReadInto(CustomInputState state) |
Aggregate read for the "All Touchpads (Merged)" pseudo-device: the first device's state. |
Interaction with InputManager¶
Step 2 (UpdateInputStates) reads PTP devices via the path:
if (ud.IsTouchpad && ud.Device == null && _ptpReader != null && _ptpReader.IsAvailable)
{
newState = ud.PtpStatePool.Next();
if (ud.InstanceGuid == PtpMergedGuid)
_ptpReader.ReadInto(newState);
else
{
IntPtr ptpHandle = FindPtpHandle(ud.InstanceGuid);
if (ptpHandle != IntPtr.Zero)
_ptpReader.ReadInto(ptpHandle, newState);
}
}
The picker fallback in MappingDisplayResolver.AddTouchpadGestureChoices uses PtpMaxFingers for an ud.IsTouchpad row that has no persisted per-pad finger count (a system touchpad has no wrapper to fill one), so 3/4/5-finger gestures surface in the dropdown even when no live state is available at picker-build time.
HeadPose¶
File: PadForge.Engine/Common/HeadPose.cs
Namespace: PadForge.Engine.Common
(4.4.0, #355) Decoding and scaling for head-tracking sources. No Windows calls, so both wire contracts replay from bytes in tests.
The pose stays in OpenTrack's own convention: six doubles ordered TX, TY, TZ, Yaw, Pitch, Roll, translation in centimeters and rotation in degrees.
| Constant | Value | Meaning |
|---|---|---|
OpenTrackUdpBytes |
48 |
OpenTrack "UDP over network" datagram: sizeof(double[6]). |
FreeTrackHeapBytes |
108 |
FreeTrack 2.0 heap: FTData (92 bytes) + GameID + an 8-byte table + GameID2. |
TX / TY / TZ / Yaw / Pitch / Roll |
0..5 | Pose indices, OpenTrack's enum Axis. |
PoseCount |
6 |
|
AxisYaw / AxisPitch / AxisRoll / AxisX / AxisY / AxisZ |
0..5 | Device axis indices on the Head Tracker row. Note the order differs from the pose order: rotation first, then translation. |
AxisCount |
6 |
|
AxisCenter |
32768 |
Rest value of a centered axis, matching CustomInputState's center. |
| Method | Signature | Description |
|---|---|---|
TryDecodeOpenTrackUdp |
static bool TryDecodeOpenTrackUdp(ReadOnlySpan<byte> datagram, Span<double> pose) |
Six little-endian doubles. A datagram carrying NaN or infinity is rejected whole, the rule OpenTrack's own UDP tracker applies. Longer datagrams are read for their first 48 bytes. |
TryDecodeFreeTrackHeap |
static bool TryDecodeFreeTrackHeap(ReadOnlySpan<byte> heap, out uint dataId, Span<double> pose) |
Inverts OpenTrack's FreeTrack writer: yaw and pitch are negated radians, roll is radians, translation is millimeters. FTData offsets are DataID 0, CamWidth 4, CamHeight 8, Yaw 12, Pitch 16, Roll 20, X 24, Y 28, Z 32. dataId increments once per written pose, so an unchanged value means no new pose. |
ToAxis |
static int ToAxis(double value, double range) |
A pose value against its full-deflection range, into the unsigned axis space: −range reads 0, rest reads 32768, +range reads 65535, past either end clamps. A non-positive range or a NaN reads rest. |
FillAxes |
static void FillAxes(ReadOnlySpan<double> pose, double rotationRange, double translationRange, Span<int> axes) |
Fills the six device axes. Pitch and Y are stored in stick orientation, up at the low end, so mapping one onto a stick's Y axis needs no inversion. Yaw and X read high to the right. |
FillAxesPerAxis |
static void FillAxesPerAxis(ReadOnlySpan<double> pose, Func<int, double> rangeFor, Span<int> axes) |
(4.5.0, #403) The same fill with one range per axis, asked in this type's axis order: degrees for yaw, pitch and roll, centimeters for X, Y and Z. Head elevation wants a span of a few centimeters where leaning wants twenty. |
CenterAxes |
static void CenterAxes(Span<int> axes) |
Every device axis at rest. |
MachineIdentity¶
File: PadForge.Engine/Common/MachineIdentity.cs
Namespace: PadForge.Engine.Common
(4.4.0, #343) The machine's SMBIOS identity, read from the registry mirror the kernel keeps under HKLM\HARDWARE\DESCRIPTION\System\BIOS. The same strings Handheld Companion and InputPlumber match their device tables on, read without WMI so poll-side code never pays for a COM round trip.
| Member | Type | Description |
|---|---|---|
Manufacturer / ProductName / Family / BoardProduct / Sku |
string (init) |
The five SMBIOS strings. Missing values read as "". |
Key |
string |
Normalized manufacturer and product name joined with a bar (Norm(Manufacturer), |, Norm(ProductName)). The stable key a handheld definition is filed under. |
DisplayName |
string |
Devices-page name: Family when ProductName is a bare model code (12 characters or fewer, no spaces, letters and digits mixed, as in "83RU" or "LNVNB161216"), ProductName otherwise, then Family, then "This PC". |
Norm |
static string Norm(string s) |
Trim and upper-case invariant. |
Current |
static MachineIdentity |
Reads once and caches. Never throws. |
VendorReportLearner¶
File: PadForge.Engine/Common/VendorReportLearner.cs
Namespace: PadForge.Engine.Common
(4.4.0, #343) The press-it-to-learn-it core for hidden handheld buttons that arrive in vendor HID input reports. Pure functions over captured report buffers, so the whole contract replays from a byte capture without the hardware. There are no per-model tables.
Three phases. NoiseMask marks every bit that changed on its own while the device sat idle (IMU words, counters, timestamps) so none of them can become a button. FindBits diffs the press against the idle baseline outside that mask and keeps only bits that flip back on release. FindValues covers reports carrying a value byte per event rather than a state bit.
public enum VendorButtonKind { Bit = 0, Value = 1 }
public sealed class VendorButtonDefinition
{
public string Name { get; set; }
public int Button { get; set; } // stable raw-button index on the vendor device row
public byte ReportId { get; set; } // 0 when the collection has none
public int ByteIndex { get; set; } // offset INCLUDING the report id byte at index 0
public byte Mask { get; set; }
public byte Value { get; set; }
public VendorButtonKind Kind { get; set; }
public bool Evaluate(ReadOnlySpan<byte> report);
}
public readonly struct VendorButtonCandidate
{
public readonly int ByteIndex;
public readonly byte Mask;
public readonly byte Value;
public readonly VendorButtonKind Kind;
}
Bit means the bits under Mask at ByteIndex read Value while pressed. Every documented handheld is active-high (Legion Go byte 20, GPD Win 5 bytes 7 to 9, Zotac wheels), but the pressed pattern is what gets stored, so an active-low bit learns as itself instead of inverted. Value means the byte equals Value while pressed, the ROG Ally 0x5A shape, and releases when the byte reads anything else or when the report stops arriving for ValueHoldMs.
| Member | Signature | Description |
|---|---|---|
ValueHoldMs |
const int = 150 |
How long a Value-kind button holds after its last matching report, since event-style firmware may send no release. |
NoiseMask |
static byte[] NoiseMask(IReadOnlyList<byte[]> idleSamples) |
Bit-level volatility mask: a bit is set where any idle sample disagreed with the first. Unequal-length reports compare over the shorter prefix, and the tail is treated as volatile. |
MinPressRun |
static int MinPressRun(int pressSamples) |
Consecutive flipped samples a press must show: Math.Max(Math.Min(2, pressSamples), pressSamples / 40), about 2.5% of the window. A single captured sample is its own run. |
FindBits |
static List<VendorButtonCandidate> FindBits(byte[] idle, byte[] noise, IReadOnlyList<byte[]> press, IReadOnlyList<byte[]> release) |
Bit candidates outside the noise mask that read the opposite of idle for at least MinPressRun consecutive press samples and are back at baseline by the last sample seen. One candidate per byte, carrying the combined mask of qualifying bits. |
FindValues |
static List<VendorButtonCandidate> FindValues(byte[] idle, byte[] noise, IReadOnlyList<byte[]> press, IReadOnlyList<byte[]> release) |
Value candidates: the first press sample differing from idle carries a value no release sample carries. A value still present after release is a mode byte, not a button. Single-bit differences are left to FindBits. |
Learn |
static List<VendorButtonCandidate> Learn(byte[] idle, byte[] noise, IReadOnlyList<byte[]> press, IReadOnlyList<byte[]> release) |
Returns the Bit candidates when FindBits finds any, otherwise the FindValues candidates. A byte whose press flipped more than one bit is promoted to exact-equality matching, because the ROG Ally writes 166 for one button and 167 for its neighbor and a mask match on 0xA6 would fire on both. More than one candidate of a kind means the press changed several things at once, and the caller shows them for the user to pick or to press again. |
Chord-delivered hidden buttons take the other path, HandheldChords (HandheldChordDefinition, ChordDecision, HandheldChordEngine) in the same namespace, where the firmware types a key combination instead of writing a vendor report.
MotionRowsModel¶
File: PadForge.Engine/Common/MotionRowsModel.cs
Namespace: PadForge.Engine.Common
(v5.0, #475) The orientation the Motion Pitch, Yaw and Roll rows drive, which the virtual controller's gyro and accelerometer are read from. No Windows calls. The engine's motion stage keeps one per slot on the polling thread. See Input Pipeline.
| Constant | Value | Meaning |
|---|---|---|
LeanAcceleration |
7 × 7 × 2π, about 307.9 rad/s² | Dolphin's Tilt acceleration, (7 × 2π)² / 2π |
MaxRateDps |
1600 |
The fastest the lean moves, and the top of the Speed range |
SliceSeconds |
0.005 |
Longest model step, Dolphin's 200 Hz update |
MaxFrameMicroseconds |
250_000 |
A longer frame interval restarts the clock without turning |
| Member | Signature | Description |
|---|---|---|
Axis |
struct |
One row's reading: Value (-1 to +1, positive down on a Y axis and right on an X axis), Angle, SpeedDps, MinSpeedDps, AngleDeg, Deadzone (a fraction of full deflection) |
Orientation |
Quaternion |
Body to world, the Speed rotation times Lean |
Lean |
Quaternion |
The Angle lean alone |
GyroDps |
Vector3 |
The last frame's body rate in degrees per second, the mean of its slices |
Step |
void Step(in Axis pitch, in Axis yaw, in Axis roll, long timestampUs) |
Advances one frame |
Level |
void Level() |
Returns the Speed rotation to level without reporting it as rotation. A held lean stays |
RestartClock |
void RestartClock() |
The next frame starts the clock instead of integrating the gap |
Reset |
void Reset() |
Back to rest |
Shape |
static float Shape(float value, float deadzone) |
The deadzone with the range past it rescaled. A non-finite value reads 0 |
LeanOf |
static Quaternion LeanOf(float pitch, float roll) |
Pitch about X, then roll about Z inside it |
BodyRotation |
static Vector3 BodyRotation(Quaternion from, Quaternion to) |
The body-frame rotation vector between two orientations, radians, on the shorter arc |
ToBody |
static Vector3 ToBody(Quaternion orientation, Vector3 world) |
A world vector in the body frame |
Step clamps its settings: the speeds to 0 to 1600°/s with the Start Speed no higher than the Top Speed, the lean to 0 to 90°, and the deadzone to 0 to 0.9. A non-finite setting reads as 0. MotionRowValues, in the same file, carries one device's three row values for one Step 3 pass and the pass (Frame) that computed them. Beside each value it carries the departed layer's row when a menu commit made by leaving that layer gave the value (PitchRow, YawRow, RollRow, null when the engaged layer's row gave it), so the motion stage shapes the value with that row's settings.
HapticToneEncoder¶
File: PadForge.Engine/Haptics/HapticToneEncoder.cs
Namespace: PadForge.Engine.Haptics
(v3.6, #147) Pure tone-encoder cores for HD haptic tones on controllers whose haptics are LRAs. Turns a (frequency, amplitude) request into the exact wire bytes each device family's actuators expect. Deterministic and side-effect free so it unit-tests against the reference implementations without hardware. An LRA plays a tone with an amplitude envelope, not PCM, and a pad with two of them still plays the one mono tone. Beeps, alerts, and melodic cues land. Speech and music do not.
| Family | Report | Encoder | Notes |
|---|---|---|---|
| Joy-Con / Switch Pro | 0x10 rumble payload | EncodeJoyConRumble(freqHz, amp) |
Closed-form log2 encoding. FoldJoyConFrequency octave-folds out-of-band notes into [41, 626] Hz. Float32 math (MathF) to stay bit-faithful to the on-hardware reference. |
| Steam Controller 2015 / Deck | 0x8F feature blob | EncodeSteamClassic(freqHz, durationSeconds, haptic) |
Square-wave, pitch-only. The Deck's built-in controller reuses this exact path (no separate Deck encoder). |
| Steam Controller 2026 (Triton) | 0x83 LFO-tone output | EncodeTritonTone(haptic, freqHz, amp) |
10-byte output report. Byte 2 is a signed int8 gain_db (0 dB at amp=1, floored at −40, never positive). Actuator index 0/1/3/4 over BLE. Wired (PID 0x1302) and the puck relay drive the TritonActuatorsWired {0, 3} pad+grip pair, because four simultaneous actuators garble on serialized delivery (bench 2026-07-27). Grips driven through the per-note trackpad→grip frequency map so both sound the same pitch. |
Supporting members: MidiNoteToFrequency (melodic cue helper), JoyConNeutral (the 4-byte neutral rumble payload), JoyConFreqMin / JoyConFreqMax / JoyConAmpMax (41 Hz, 626 Hz, 0.8), SteamMagicPeriodRatio (495483.0), the SC2015 pulse-train lane (EncodeSteamClassicPulse, SteamPulsePadLeft = 0x01, SteamPulsePadRight = 0x00, SteamPulseOffTimeUs = 600, SteamPulseOnTimeUs(amp)), TritonActuators / TritonActuatorsWired / TritonIsGrip / TritonGripDriveHz (Triton actuator addressing and grip drive), EncodeTritonRumbleClear (0x80 zero-rumble sent before arming a fresh tone over BLE only, never on the wired pad), the Triton tick lane (EncodeTritonTickCommand, TritonSideLeft = 0x01, TritonSideRight = 0x02, TritonTickBaseGainDb = −50, TritonTickGainDb(amp)), and AmpToGainDb (shared amplitude-to-dB map). Switch 2 was dropped from the tone scope: no reference plays an audible tone on a Switch 2 actuator.
TritonPcmEncoder¶
File: PadForge.Engine/Haptics/TritonPcmEncoder.cs
Namespace: PadForge.Engine.Haptics
(4.4.0, #381, asked in discussion #371) Steam Controller 2026 (Triton) PCM haptic stream encoding. The firmware takes continuous PCM through a streaming interface that sits beyond the 0x80–0x85 tone family Valve's SDL driver exposes: output report 0x86 configures the stream, 0x88 carries stereo sample data, and input report 0x44 reports per-actuator stream state. Pure byte assembly, unit-tested without hardware. Transport, pacing, and lifecycle live in the App-side HapticToneService, with TritonPcmSupport (also App-side, PadForge.App/Common/Input/TritonPcmSupport.cs) deciding which pads and transports get the stream.
| Constant | Value | Meaning |
|---|---|---|
ReportConfig |
0x86 |
Output report: stream configure / enable / disable. Output-report namespace only. The same 0x86 as the type byte inside feature report 0x01 is the factory-reset command, and this encoder never builds feature reports. |
ReportStereoData |
0x88 |
Output report: stereo PCM data, de-interleaved. |
TargetInternalBoth |
2 |
0x86 target: both internal (grip) actuators. The 0x86 target table is not the 0x83 tone side table, so no enum is shared across the two reports. |
TargetTrackpadBoth |
5 |
0x86 target: both trackpad actuators. |
ModeWired8k16 |
0 |
8 kHz stereo signed 16-bit, the wired pad's format. |
ModePuck8kMuLaw |
8 |
8 kHz stereo G.711 mu-law, the dongle (Puck) format. The dongle's USB interrupt interval halves its bandwidth, so 16-bit is blocked on wireless. |
SampleRate |
8000 |
Stream sample rate for both shipped modes. |
FramesPerPacket16 |
15 |
Stereo frames per 0x88 report in 16-bit mode (60 payload bytes, 30 per channel). |
FramesPerPacketMuLaw |
31 |
Stereo frames per 0x88 report in the 8-bit modes (62 payload bytes, 31 per channel). |
PacketLength |
64 |
Fixed 0x88 wire length: [0] = 0x88, [1] = bytes per channel, left area 2..32, right area 33..63. The right area always starts at 33, even in 16-bit mode where only 30 of its 31 bytes carry samples. |
| Method | Signature | Description |
|---|---|---|
PacketPeriodMicroseconds |
static int PacketPeriodMicroseconds(bool muLaw) |
Packet period at 8 kHz for the mode's frame count: 1875 for 16-bit, 3875 for mu-law. |
FramesPerPacket |
static int FramesPerPacket(bool muLaw) |
Frames carried per packet for the mode. |
EncodeStreamCommand |
static byte[] EncodeStreamCommand(bool enable, byte target, byte mode) |
The 4-byte 0x86 command [0x86, operation, target, mode], operation 1 = disable and 2 = enable. A disable sends mode 0. The arm sequence is disable both targets, wait 10 ms, then enable both with the mode, because reconfiguring a running stream is rejected (0x44 bit 6). The transport pads to the interface's OutputReportByteLength. |
EncodeStereoPacket |
static byte[] EncodeStereoPacket(ReadOnlySpan<short> interleaved, int frameCount, bool muLaw) |
One full 0x88 packet from interleaved s16 frames (L, R, L, R, ...). frameCount may be short on a burst's final packet. The tail of each channel area fills with the mode's true silence value, 0x00 for 16-bit and 0xFF for mu-law. The length byte stays the mode's full per-channel count so every packet represents a whole period, silence included, and the stream never starves. |
EncodeStereoPacketInto |
static void EncodeStereoPacketInto(Span<byte> b, ReadOnlySpan<short> interleaved, int frameCount, bool muLaw) |
Allocation-free variant for the streaming hot path. The buffer must be at least PacketLength bytes. |
MuLawEncode |
static byte MuLawEncode(short sample) |
G.711 mu-law compression of one s16 sample: bias 0x84, clip 32635, complemented output. Silence encodes to 0xFF, positive full scale to 0x80, negative full scale to 0x00. Input −32768 clamps to −32767 first, since the C references negate an int16 in place and overflow on that one value. |
FloatToS16 |
static short FloatToS16(float v) |
Float sample (nominal [−1, 1]) to s16 with hard clipping. A NaN or infinity escaping a filter becomes silence rather than full-scale noise. |
Mu-law silence is 0xFF, not zero. TritonLib pads mu-law tails with zero, which decodes to −8031, near full-scale negative, and clicks at every track end.
HapticToneReducer¶
File: PadForge.Engine/Haptics/HapticToneReducer.cs
Namespace: PadForge.Engine.Haptics
(v3.6, #147) Reduces a stream of mono float audio to one (dominant frequency Hz, amplitude 0..1) per rumble tick, the PCM-to-tone step an LRA needs before HapticToneEncoder turns it into wire bytes. Amplitude is windowed RMS. Pitch is the normalized autocorrelation peak over the playable lag range (the first rise-then-fall above a voiced threshold, not the global max, to avoid the near-min-lag plateau). Standard DSP, not copied from a reference repo. Allocation-light (a reused per-lag score buffer, no per-tick allocation).
public sealed class HapticToneReducer
{
public const float SilenceRms = 0.02f; // below this the window is silence (amp 0)
public HapticToneReducer(int rate);
public (float Hz, float Amp) Push(float[] samples, int count);
}
Detects ~40 Hz to ~1300 Hz over an ~83 ms ring. Near-silent or unvoiced windows hold the last detected pitch and report the true loudness (0 for silence) so an unvoiced burst does not jump the coil.
WiiSpeakerAdpcm¶
File: PadForge.Engine/Haptics/WiiSpeakerAdpcm.cs
Namespace: PadForge.Engine.Haptics
(v3.6, #146) Yamaha 4-bit ADPCM codec for the Wii Remote speaker. The expand-nibble math (DiffLookup / IndexScale / clip) follows the public WiiBrew / Dolphin algorithm, with Dolphin's Speaker.cpp read as documentation for the table and formula. Two samples pack per byte, low nibble first (the order real Wii speaker hardware consumes, hardware-verified via the ffmpeg adpcm_yamaha + WiimoteLib playback path).
Off the live path. The live
WiiSpeakerServiceships 8-bit PCM (memoryless, tolerant of the SDL-shared BT link), not this differential ADPCM. This codec is kept compiled and unit-tested (HapticEncoderTests) as the verified reference implementation only.
public static class WiiSpeakerAdpcm
{
public struct State { public int Predictor; public int Step; public static State Initial { get; } }
public static short ExpandNibble(ref State s, int nibble);
public static short[] Decode(byte[] adpcm); // whole-stream, resets state
public static short[] Decode(byte[] adpcm, ref State s); // streaming, carries state
public static byte[] Encode(short[] pcm); // whole-cue, resets state
public static byte[] Encode(short[] pcm, ref State s); // streaming, throws on odd-length chunk
}
The encoder is the original inverse of the decoder: per target PCM sample it tries all 16 nibbles and keeps the one whose reconstructed predictor lands closest, then advances state with the exact decode formulas. The streaming Encode throws on an odd-length chunk, since a trailing padding nibble desyncs the decoder for the rest of the stream.
PadSetting¶
File: PadForge.Engine/Data/PadSetting.cs
Namespace: PadForge.Engine.Data
Complete mapping configuration for a device-to-slot assignment. All mapping properties are string descriptors: "Button N", "Axis N", "IHAxis N", "POV N Dir", "Slider N", or "" (unmapped). Declared partial.
Stored separately from UserSettings, linked via PadSettingChecksum. Multiple UserSettings can share one PadSetting. Numeric settings stored as strings for XML consistency.
Identity¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
PadSettingChecksum |
string |
[XmlElement] |
"" |
Checksum from all mapping/setting properties. Links to UserSettings. |
Button Mappings¶
| Property | Type | Serialization | Default |
|---|---|---|---|
ButtonA |
string |
[XmlElement] |
"" |
ButtonB |
string |
[XmlElement] |
"" |
ButtonX |
string |
[XmlElement] |
"" |
ButtonY |
string |
[XmlElement] |
"" |
LeftShoulder |
string |
[XmlElement] |
"" |
RightShoulder |
string |
[XmlElement] |
"" |
ButtonBack |
string |
[XmlElement] |
"" |
ButtonStart |
string |
[XmlElement] |
"" |
ButtonGuide |
string |
[XmlElement] |
"" |
ButtonShare |
string |
[XmlElement] |
"" |
ButtonMute |
string |
[XmlElement] |
"" |
LeftPaddle |
string |
[XmlElement] |
"" |
RightPaddle |
string |
[XmlElement] |
"" |
LeftFunction |
string |
[XmlElement] |
"" |
RightFunction |
string |
[XmlElement] |
"" |
LeftThumbButton |
string |
[XmlElement] |
"" |
RightThumbButton |
string |
[XmlElement] |
"" |
ButtonShare surfaces only on Xbox Series profiles, ButtonMute on DualSense and DualSense Edge profiles, and the paddle and Fn pairs on DualSense Edge profiles.
D-Pad Mappings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
DPad |
string |
[XmlElement] |
"" |
Combined D-Pad mapping. POV descriptor auto-extracts all 4 directions. Individual overrides take priority. |
DPadUp |
string |
[XmlElement] |
"" |
|
DPadDown |
string |
[XmlElement] |
"" |
|
DPadLeft |
string |
[XmlElement] |
"" |
|
DPadRight |
string |
[XmlElement] |
"" |
Trigger Mappings and Settings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftTrigger |
string |
[XmlElement] |
"" |
Mapping descriptor |
RightTrigger |
string |
[XmlElement] |
"" |
Mapping descriptor |
LeftTriggerDeadZone |
string |
[XmlElement] |
"0" |
0–100% |
RightTriggerDeadZone |
string |
[XmlElement] |
"0" |
0–100% |
LeftTriggerAntiDeadZone |
string |
[XmlElement] |
"0" |
0–100% |
RightTriggerAntiDeadZone |
string |
[XmlElement] |
"0" |
0–100% |
LeftTriggerMaxRange |
string |
[XmlElement] |
"100" |
1–100% |
RightTriggerMaxRange |
string |
[XmlElement] |
"100" |
1–100% |
LeftTriggerSensitivityCurve |
string |
[XmlElement] |
"0" |
−100 to 100 (0=linear, +100=exp, −100=log) |
RightTriggerSensitivityCurve |
string |
[XmlElement] |
"0" |
−100 to 100 |
Thumbstick Axis Mappings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftThumbAxisX |
string |
[XmlElement] |
"" |
|
LeftThumbAxisY |
string |
[XmlElement] |
"" |
|
RightThumbAxisX |
string |
[XmlElement] |
"" |
|
RightThumbAxisY |
string |
[XmlElement] |
"" |
|
LeftThumbAxisXNeg |
string |
[XmlElement] |
"" |
Negative direction (buttons mapped to bidirectional axes) |
LeftThumbAxisYNeg |
string |
[XmlElement] |
"" |
|
RightThumbAxisXNeg |
string |
[XmlElement] |
"" |
|
RightThumbAxisYNeg |
string |
[XmlElement] |
"" |
Deadzone Settings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftThumbDeadZoneX |
string |
[XmlElement] |
"0" |
Left stick deadzone X (0–100%) |
LeftThumbDeadZoneY |
string |
[XmlElement] |
"0" |
Left stick deadzone Y |
RightThumbDeadZoneX |
string |
[XmlElement] |
"0" |
Right stick deadzone X |
RightThumbDeadZoneY |
string |
[XmlElement] |
"0" |
Right stick deadzone Y |
LeftThumbDeadZoneShape |
string |
[XmlElement] |
"2" |
DeadZoneShape enum value. 2 = ScaledRadial. |
RightThumbDeadZoneShape |
string |
[XmlElement] |
"2" |
DeadZoneShape enum value |
LeftThumbAntiDeadZone |
string |
[XmlElement] |
"0" |
Legacy unified (0–100%). Prefer per-axis X/Y. |
RightThumbAntiDeadZone |
string |
[XmlElement] |
"0" |
Legacy unified |
LeftThumbAntiDeadZoneX |
string |
[XmlElement] |
"0" |
Left stick anti-deadzone X (0–100%) |
LeftThumbAntiDeadZoneY |
string |
[XmlElement] |
"0" |
Left stick anti-deadzone Y |
RightThumbAntiDeadZoneX |
string |
[XmlElement] |
"0" |
Right stick anti-deadzone X |
RightThumbAntiDeadZoneY |
string |
[XmlElement] |
"0" |
Right stick anti-deadzone Y |
LeftThumbLinear |
string |
[XmlElement] |
"0" |
Response curve (0–100%). 0=default, 100=fully linear. |
RightThumbLinear |
string |
[XmlElement] |
"0" |
Sensitivity Curve Settings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftThumbSensitivityCurveX |
string |
[XmlElement] |
"0" |
−100 to 100 (0=linear, +100=exp, −100=log) |
LeftThumbSensitivityCurveY |
string |
[XmlElement] |
"0" |
|
RightThumbSensitivityCurveX |
string |
[XmlElement] |
"0" |
|
RightThumbSensitivityCurveY |
string |
[XmlElement] |
"0" |
Max Range Settings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftThumbMaxRangeX |
string |
[XmlElement] |
"100" |
Left stick X max range (1–100%). Symmetric/positive direction. |
LeftThumbMaxRangeY |
string |
[XmlElement] |
"100" |
|
RightThumbMaxRangeX |
string |
[XmlElement] |
"100" |
|
RightThumbMaxRangeY |
string |
[XmlElement] |
"100" |
|
LeftThumbMaxRangeXNeg |
string |
[XmlElement] |
null |
Left stick X negative (left). Null = inherit symmetric. |
LeftThumbMaxRangeYNeg |
string |
[XmlElement] |
null |
Left stick Y negative (down) direction |
RightThumbMaxRangeXNeg |
string |
[XmlElement] |
null |
|
RightThumbMaxRangeYNeg |
string |
[XmlElement] |
null |
Stick Center Offset Calibration¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftThumbCenterOffsetX |
string |
[XmlElement] |
"0" |
−100 to 100%. Corrects stick drift before deadzone. |
LeftThumbCenterOffsetY |
string |
[XmlElement] |
"0" |
|
RightThumbCenterOffsetX |
string |
[XmlElement] |
"0" |
|
RightThumbCenterOffsetY |
string |
[XmlElement] |
"0" |
Stick Boundary Calibration (#174)¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftThumbBoundaryMap |
string |
[XmlElement] |
"" |
Measured left-stick boundary map: space-separated per-angle radii scaled by 100. Empty = uncalibrated, no reshaping. Reshaping runs before center offset, deadzone, and curve. |
RightThumbBoundaryMap |
string |
[XmlElement] |
"" |
Measured right-stick boundary map. Empty = uncalibrated. |
Wii Pointer (#203)¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
PointerMode |
string |
[XmlElement] |
"Mouse" |
Wii IR pointer cursor drive. "Mouse" = absolute aim, "FpsMouse" = center-offset velocity, "Mouse43" / "Mouse169" = cursor confined to an aspect region with border pin. Per (device, slot). Shapes the cursor drive only. The "IR Pointer X/Y" mapping sources read raw regardless. |
PointerFpsSpeed |
string |
[XmlElement] |
"35" |
FPS Mouse speed, pixels per 10 ms at full deflection. |
IrSensorBarPos |
string |
[XmlElement] |
"0" |
Where the sensor bar sits: 0 = centered, 1 = above, 2 = below. |
IrSensorBarComp |
string |
[XmlElement] |
"0" |
Vertical compensation for the bar position, 0–0.5 of the pointer range. |
IrSmoothing |
string |
[XmlElement] |
"0" |
Pointer smoothing 0–1. 0 = raw. |
Motion Grip (#392)¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
MotionGrip |
string |
[XmlElement] |
"Pointing" |
How the pad is held. "Pointing" (identity), "Sideways" (top edge left, face up), "WiiWheel" (top edge left, face toward the player, the wheel shell's hold), or "Upright" (top edge up). Rotates the body gyro, accelerometer, and gravity into the frame a game expects, for mapping sources and for the motion the virtual controller reports. Per (device, slot), because the hold belongs to the game rather than to the pad, and profiles carry PadSettings. |
The rotation itself lives in SourceCoercion (PadForge.Engine/Common/Mapping/SourceCoercion.cs), which also owns the four grip name constants GripPointing / GripSideways / GripWiiWheel / GripUpright:
| Member | Signature | Behavior |
|---|---|---|
RotateForGrip |
static (float x, float y, float z) RotateForGrip(string grip, float x, float y, float z) |
Sideways maps (x, y, z) to (z, y, -x), Wii Wheel to (z, x, y), Upright to (x, -z, y). An unknown or empty grip is the identity. All three are proper rotations (determinant +1), so one table serves gyro, accelerometer, and the gravity estimate alike and the snapshot stays frame-consistent. |
GripAxis |
internal static (int source, float sign) GripAxis(string grip, int axis) |
The per-axis form: the source axis and sign that produce a requested output axis, so a single-axis read can debias the source axis before applying the sign. Pinned against RotateForGrip by test. |
GripPov |
static int GripPov(string deviceGuid, int slotIndex, int centidegrees) |
Rotates a hat reading into the held frame. The D-pad is a vector in the same body frame as the sensors, so the grip that turns the gyro turns the hat. Sideways and Wii Wheel both subtract 9000 centidegrees modulo 36000, which is Dolphin's sideways D-pad table, and the angle arithmetic carries the diagonals. Upright and a centered (negative) reading pass through. |
ApplyMotionGrip |
static void ApplyMotionGrip(string deviceGuid, int slotIndex, ref float x, ref float y, ref float z) |
Rotates a body-sensor triple in place by the (device, slot) grip. The App's snapshot builder and the Gyro tab's live readout call this for the accelerometer copy. The gyro copy already flows through the calibrated read. |
ReadGravity |
internal static (float gx, float gy, float gz) ReadGravity(string deviceGuid, int slotIndex, bool aux) |
The gravity estimate every Player Space, World Space, lean, and tilt read consumes, rotated by the grip for the body sensor. The aux sensor (Nunchuk, left Joy-Con) is a separate body in the other hand and keeps its own frame. The no-data sentinel (0, 0, -1) returns unrotated, so a sideways grip does not invent a different resting default before the first real sample. |
Pitch and Roll Simulation (#474)¶
A DualShock 3's gyro senses yaw only. With GyroSimulation on, the pitch and roll a gyro read returns come from the accelerometer.
| Member | Signature | Behavior |
|---|---|---|
SimulatedGyro.MissingAxes |
static Axes MissingAxes(ushort vendorId, ushort productId, bool hasGyro, bool hasAccel) |
Axes.Pitch and Axes.Roll for a DualShock 3 (054C:0268) with both sensors, Axes.None for every other device. Gates the card, the runtime, and the slot summary's SIM token. |
AccelRateEstimator.Update |
void Update(Vector3 acceleration, float deltaSeconds, float smoothingSeconds) |
The rotation rate the accelerometer can see, in SDL's sensor frame: normalize(S₂ × S₁) · angle / Δt between consecutive smoothed gravity directions (GamepadMotionHelpers GamepadMotion.hpp:962-970). The smoothing advances on every poll with the poll's own time. A sample more than 0.25 g from the pad's resting length reads zero and holds the smoothing, and more than 0.25 s of that, net of the time back inside, starts the estimate over. |
SourceCoercion.SimulatedGyroProvider |
static Func<string, int, SimulatedGyroSample?> |
The App's per-(device, slot) estimate, InputManager.ReadGyroSimulation. ReadCalibratedGyroRate asks it after the grip picks the source axis, for the body sensor only, and returns its rate without the stored bias. |
SourceCoercion.ApplySimulatedGyro |
static void ApplySimulatedGyro(string deviceGuid, int slotIndex, ref float pitch, ref float yaw, ref float roll) |
The same substitution for the Gyro tab's readout, before its grip call. |
Gyro Tuning (v3.3)¶
Per device, per slot, so each binding config carries its own gyro feel and its own bias calibration.
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
GyroSensitivityH / GyroSensitivityV |
string |
[XmlElement] |
"1.0" |
Horizontal (yaw and roll) and vertical (pitch) multipliers |
GyroSensitivityUnits |
string |
[XmlElement] |
"Multiplier" |
Multiplier or DegPerScreenTurn. Changes only how the slider shows the stored multiplier |
GyroDeadZoneDegPerSec |
string |
[XmlElement] |
"3.0" |
Subtract-style deadzone in degrees per second |
GyroSmoothingAlpha |
string |
[XmlElement] |
"0" |
EMA smoothing, 0 (off) to 0.95 |
GyroTighteningThresholdDegPerSec / GyroSmoothingThresholdDegPerSec / GyroSmoothingWindowMs |
string |
[XmlElement] |
"3.0" / "8.0" / "50" |
Dual-threshold smoothing: below the tightening threshold the input is replaced by the window average, above the smoothing threshold it passes raw, and a linear blend runs between |
GyroAcceleration |
string |
[XmlElement] |
"0" |
Rate-dependent gain 0–2: output = input × (1 + accel × |input|) |
GyroOutputCurve |
string |
[XmlElement] |
"Linear" |
Linear, Aggressive, Relaxed, Wide, or ExtraWide |
GyroSpace |
string |
[XmlElement] |
"Local" |
Local, Player, or World |
GyroPlayerSpaceYawRelaxFactor |
string |
[XmlElement] |
"1.41" |
Player Space yaw relaxation |
GyroWorldSpaceSideReductionThreshold |
string |
[XmlElement] |
"0.125" |
World Space side reduction, 0–1 |
GyroRealWorldCalibration |
string |
[XmlElement] |
"0" |
In-game degrees per physical degree. 0 = off |
GyroEasyAimStickThreshold |
string |
[XmlElement] |
"0" |
0–100%. 0 = always on. Above 0, gyro output zeroes while the engage stick deflects less than the threshold |
GyroEngageStickSide |
string |
[XmlElement] |
"Right" |
Right, Left, or Either (#120) |
GyroEngageStickDirection |
string |
[XmlElement] |
"Full" |
Full, X, Y, XNeg, XPos, YNeg, or YPos (#120) |
GyroAimEngageButton / GyroAimEngageDeviceGuid |
string |
[XmlElement] |
"" |
Engage button descriptor and the device it reads. Empty = always on |
GyroAimEngageMode |
string |
[XmlElement] |
"Hold" |
Hold or Toggle |
GyroInvertPitch / GyroInvertYaw |
string |
[XmlElement] |
"0" |
Invert the projected pitch or yaw lane |
GyroInvertRoll |
string |
[XmlElement] |
"" |
Roll invert (#321). Empty marks a profile saved before the yaw / roll split and resolves to the yaw value through the [XmlIgnore] GyroInvertRollEffective |
GyroApplyTuningToPassthrough |
string |
[XmlElement] |
"0" |
"1" sends the tuned gyro to the virtual controller's motion report and the DSU broadcast. "0" sends the calibrated reading |
GyroBiasPitch / GyroBiasYaw / GyroBiasRoll |
string |
[XmlElement] |
"0" |
At-rest bias in rad/s |
GyroAuxBiasPitch / GyroAuxBiasYaw / GyroAuxBiasRoll |
string |
[XmlElement] |
"0" |
Aux gyro bias (#252) |
GyroCalibratedAtUtc |
string |
[XmlElement] |
"" |
ISO-8601 time of the last calibration. Empty = uncalibrated, and InputService calibrates on first sight |
GyroCompassYaw |
string |
[XmlElement] |
"0" |
"1" anchors yaw to the Switch 2 magnetometer (#271) |
GyroSimulation |
string |
[XmlElement] |
"0" |
"1" fills a DualShock 3's pitch and roll from the accelerometer (#474) |
GyroSimulationSmoothingMs |
string |
[XmlElement] |
"100" |
The simulation's smoothing time constant, 20 to 250 ms |
GyroCalibratedDevice |
string |
[XmlElement] |
"" |
Whom the stored bias belongs to (#474): the device row's instance GUID, or on a DualShock 3 whose address PadForge knows, the pad as address/source, where the source is direct for PadForge's own connection or node: and the driver release for a pad served through DsHidMini, with /cal when DsHidMini holds the pad's EEPROM calibration and zeroes the yaw against it (its node's calibration source, read when the pad arrives and again two seconds later, because DsHidMini writes a Bluetooth connection's source one second after the link starts). A DualShock 3 shared over Remote Link carries its owner's identity. A DualShock 3 subtracts a stored bias only when this matches. Other devices ignore the field and subtract any finite bias within 0.15 rad/s per axis |
MagBiasX / MagBiasY / MagBiasZ / MagFieldNorm |
string |
[XmlElement] |
"0" |
Magnetometer hard-iron bias and field magnitude from the figure-8 calibration. A zero norm keeps the compass off |
Touchpad, Motion, and Gesture Settings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
TouchpadX1 / TouchpadY1 / TouchpadX2 / TouchpadY2 / TouchpadContact1 / TouchpadContact2 / TouchpadClick |
string |
[XmlElement] |
"" |
PlayStation touchpad output mappings |
MotionGyro / MotionAccel |
string |
[XmlElement] |
"" |
"Motion Gyro" / "Motion Accel" while this device feeds the slot's motion rows, empty otherwise. SettingsService.EnsureMotionRowsForAllSlots mirrors the MappingSet rows into them |
TouchpadSettings |
TouchpadSettingsEntry[] |
[XmlArray("TouchpadSettings")] [XmlArrayItem("Settings")] |
null |
Gesture settings per assigned device and touchpad index. Null means every pad uses TouchpadGestureSettings.Default() |
MouseGestureSettings |
MouseGestureSettingsEntry[] |
[XmlArray("MouseGestureSettings")] [XmlArrayItem("Settings")] |
null |
Mouse-gesture settings per device (#200). Null means MouseGestureSettings.Default() |
Model3DAppearances |
string |
[XmlElement] |
"" |
Legacy appearance map, kept for old settings and clipboard copies. Appearance now belongs to the slot |
Force Feedback Settings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
ForceType |
string |
[XmlElement] |
"1" |
0=Off, 1=SDL Rumble |
ForceOverall |
string |
[XmlElement] |
"100" |
Overall strength 0–100%. Multiplier for both motors. |
ForceSwapMotor |
string |
[XmlElement] |
"0" |
"0"=no swap, "1"=swap left/right motors |
LeftMotorStrength |
string |
[XmlElement] |
"100" |
Left (low-freq) motor strength 0–100% |
RightMotorStrength |
string |
[XmlElement] |
"100" |
Right (high-freq) motor strength 0–100% |
TriggerRumbleFold |
string |
[XmlElement] |
"0" |
"1" folds the game's trigger-motor channels into the body motors on a device without trigger motors, each side by max (#271) |
ImpulseOverallGain |
string |
[XmlElement] |
"100" |
Overall impulse-trigger gain 0–100% (Xbox One+) |
ImpulseLeftStrength / ImpulseRightStrength |
string |
[XmlElement] |
"100" |
Per-trigger impulse motor strength 0–100% |
ImpulseSwapTriggers |
string |
[XmlElement] |
"0" |
"1" swaps the two impulse motors |
AtVibrationToImpulseEnabled |
string |
[XmlElement] |
"0" |
"1" plays a game's vibration-class DualSense trigger programs on this device's impulse triggers (#271). Resistance-class programs translate to nothing |
ConstantForceEnabled |
string |
[XmlElement] |
"0" |
"1" holds a constant force at ConstantForceX / ConstantForceY (each −1.0 to +1.0). A non-zero game force overrides it, then it resumes |
ConstantForceX / ConstantForceY |
string |
[XmlElement] |
"0" |
|
ConstantTriggerForceEnabled |
string |
[XmlElement] |
"0" |
The impulse-trigger twin: while game trigger rumble is silent, ConstantTriggerForceLeft / ConstantTriggerForceRight (0..1) drive the trigger motors |
ConstantTriggerForceLeft / ConstantTriggerForceRight |
string |
[XmlElement] |
"0" |
|
RotationRange |
string |
[XmlElement] |
"900" |
Wheel rotation range in degrees, applied by the native Logitech / Fanatec / Thrustmaster writers within the wheel's own range (40–2520 across models) |
RotationRangeEnabled |
string |
[XmlElement] |
"1" |
"0" leaves the range to the wheel and its own software: no range is sent (#512) |
AutoCenterStrength |
string |
[XmlElement] |
"0" |
Wheel auto-center spring 0–100% (0 = off). Also drives the software spring for generic SDL wheels |
WheelRpmLeds |
string |
[XmlElement] |
"0" |
"1" drives a Logitech or Fanatec wheel's RPM LEDs from racing-game telemetry |
Novint Falcon (#521)¶
Kept per slot, so a profile carries them per game. See Novint Falcon Internals.
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
FalconFeel |
string |
[XmlElement] |
"0" |
0 = Joystick (a centering spring, the stick follows the handle's position), 1 = Aim (a free handle, the stick follows its speed) |
FalconSpring |
string |
[XmlElement] |
"100" |
The Joystick feel's spring, % of 3 N at full deflection (0–200) |
FalconKickButton |
string |
[XmlElement] |
"1" |
The grip button (1–4, 0 none) whose hold, and 250 ms after, turns rumble into a push toward the player |
FalconKick / FalconShake |
string |
[XmlElement] |
"100" |
Kick and shake strength, % (0–200) |
FalconBraceButton |
string |
[XmlElement] |
"0" |
The Aim feel's brace button (1–4, 0 none): the aim at 0.3 while held |
FalconPreloadButton |
string |
[XmlElement] |
"0" |
The Aim feel's preload button (1–4, 0 none): 1.6 N along In/Out while held |
FalconGravity |
string |
[XmlElement] |
"100" |
Gravity compensation, % of standard gravity (0–200) |
FalconForceCap |
string |
[XmlElement] |
"8" |
The force cap in newtons (1–8), 8 being the Falcon's rated force |
Steering At-Lock Feedback (#94)¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
SteeringAngleRumbleEnabled |
string |
[XmlElement] |
"0" |
"1" turns a mapped virtual stick axis into continuous rumble, on the motors SteeringAngleRumbleMotor picks |
SteeringAngleRumbleAxis |
string |
[XmlElement] |
"0" |
0 = LX, 1 = LY, 2 = RX, 3 = RY |
SteeringAngleRumbleStrength |
string |
[XmlElement] |
"50" |
0–100% |
SteeringAngleRumbleDeadzone |
string |
[XmlElement] |
"2" |
0–25% |
SteeringAngleRumbleMotor |
string |
[XmlElement] |
"0" |
0 = split by direction (left motor for negative deflection, right for positive), 1 = left motor for both, 2 = right motor for both (#510). SteeringAngleRumble.Compute reads any other value as 0 |
SteeringLockRumbleEnabled / SteeringLockTriggerVibEnabled / SteeringLockLightbarEnabled / SteeringLockATResistanceEnabled |
string |
[XmlElement] |
"0" |
Pulses on reaching full lock: rumble, impulse triggers, lightbar (DualSense / DS4), and an adaptive-trigger resistance ramp (DualSense) |
SteeringLockPulseMs |
string |
[XmlElement] |
"80" |
Rumble and trigger pulse length |
SteeringLockLightbarColor |
string |
[XmlElement] |
"#FF0000" |
Lightbar pulse color when the source is Fixed |
SteeringLockLightbarColorSource |
string |
[XmlElement] |
"Fixed" |
Fixed, RandomHue, or PaletteStep |
SteeringLockLightbarPaletteCsv |
string |
[XmlElement] |
"" |
RRGGBB palette for PaletteStep |
SteeringLockLightbarHoldMs / SteeringLockLightbarFadeMs |
string |
[XmlElement] |
"80" / "250" |
Lightbar hold, then fade back |
Trigger Rumble Routing (#102)¶
Routes the main-motor rumble into the trigger channel (Xbox impulse triggers, DualSense AT vibration), gated by a per-trigger activator.
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
LeftTriggerRouteSource / RightTriggerRouteSource |
string |
[XmlElement] |
"None" |
None, MainLeft, MainRight, MaxOfBoth, or SumOfBoth |
LeftTriggerRouteMode / RightTriggerRouteMode |
string |
[XmlElement] |
"Duplicate" |
Duplicate keeps the main motor running, Redirect silences it |
LeftTriggerRouteScale / RightTriggerRouteScale |
string |
[XmlElement] |
"100" |
0–200% of the source amplitude |
LeftTriggerRouteActivator / RightTriggerRouteActivator |
string |
[XmlElement] |
"" |
Activator descriptor. Empty = always engaged |
LeftTriggerRouteActivatorDeviceGuid / RightTriggerRouteActivatorDeviceGuid |
string |
[XmlElement] |
"" |
Device the activator reads |
LeftTriggerRouteActivatorMode / RightTriggerRouteActivatorMode |
string |
[XmlElement] |
"Hold" |
Hold, Toggle, ReleaseToEngage, or AlwaysOn |
Audio Rumble Settings¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
AudioRumbleEnabled |
string |
[XmlElement] |
"0" |
Enable audio bass rumble. "0"=off, "1"=on. |
AudioRumbleSensitivity |
string |
[XmlElement] |
"4" |
Bass detection sensitivity (1–20) |
AudioRumbleCutoffHz |
string |
[XmlElement] |
"80" |
Low-pass cutoff Hz (40–200) |
AudioRumbleLeftMotor |
string |
[XmlElement] |
"100" |
Left motor strength for audio rumble (0–100%) |
AudioRumbleRightMotor |
string |
[XmlElement] |
"100" |
Right motor strength for audio rumble (0–100%) |
AudioRumbleTriggersEnabled |
string |
[XmlElement] |
"0" |
Audio-driven impulse-trigger rumble (Xbox One+), a separate filter chain with its own settings |
AudioRumbleTriggersSensitivity |
string |
[XmlElement] |
"4" |
1–20 |
AudioRumbleTriggersCutoffHz |
string |
[XmlElement] |
"80" |
40–200 Hz |
AudioRumbleLeftTrigger / AudioRumbleRightTrigger |
string |
[XmlElement] |
"100" |
Per-trigger scale 0–100% |
Axis Configuration¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
AxisToButtonThreshold |
string |
[XmlElement] |
"50" |
Threshold 0–100% for axis-as-button |
MappingDeadZoneEntries |
RawMappingEntry[] |
[XmlArray("MappingDeadZones")] [XmlArrayItem("Map")] |
null |
Per-mapping axis-to-button thresholds. Keys = target names, values = 0–100%. |
LeftThumbAxisXInvert |
string |
[XmlElement] |
"0" |
Invert left stick X. "0" or "1". |
LeftThumbAxisYInvert |
string |
[XmlElement] |
"0" |
|
RightThumbAxisXInvert |
string |
[XmlElement] |
"0" |
|
RightThumbAxisYInvert |
string |
[XmlElement] |
"0" |
Extended Custom Mappings (Dictionary-based)¶
For Extended and Nintendo slots on the raw HID surface (arbitrary axis/button/POV counts). Keys: "RawAxis0", "RawAxis0Neg", "RawBtn0", "RawPov0Up". Values: mapping descriptors.
| Property | Type | Serialization | Description |
|---|---|---|---|
RawMappingEntries |
RawMappingEntry[] |
[XmlArray("ExtendedMappings")] [XmlArrayItem("Map")] |
Serializable array for XML persistence. The XML element name kept its pre-4.1.0 spelling for round-trip compatibility |
| Method | Signature | Description |
|---|---|---|
GetRawMapping |
string GetRawMapping(string key) |
Gets a raw-surface mapping value by key ("RawAxis0", "RawBtn5", "RawPov0Up"). Returns "" if not found. Reads under _rawDictLock because the poll thread reads while the UI thread writes. |
SetRawMapping |
void SetRawMapping(string key, string value) |
Sets a raw-surface mapping value. Empty/null removes the key. |
FlushRawMappings |
void FlushRawMappings() |
Flushes in-memory dictionary back to serializable array. |
MIDI Custom Mappings (Dictionary-based)¶
Same pattern as Extended. Keys: "MidiCC0", "MidiCC0Neg", "MidiNote0", etc.
| Property | Type | Serialization | Description |
|---|---|---|---|
MidiMappingEntries |
RawMappingEntry[] |
[XmlArray("MidiMappings")] [XmlArrayItem("Map")] |
Serializable array |
| Method | Signature | Description |
|---|---|---|
GetMidiMapping |
string GetMidiMapping(string key) |
Gets a MIDI mapping value. |
SetMidiMapping |
void SetMidiMapping(string key, string value) |
Sets a MIDI mapping value. |
FlushMidiMappings |
void FlushMidiMappings() |
Flushes dictionary to array. |
KBM Custom Mappings (Dictionary-based)¶
Keys: "KbmKey41" (VK_A), "KbmMouseX", "KbmMouseXNeg", "KbmMBtn0", "KbmScroll", etc.
| Property | Type | Serialization | Description |
|---|---|---|---|
KbmMappingEntries |
RawMappingEntry[] |
[XmlArray("KbmMappings")] [XmlArrayItem("Map")] |
Serializable array |
| Method | Signature | Description |
|---|---|---|
GetKbmMapping |
string GetKbmMapping(string key) |
Gets a KBM mapping value. |
SetKbmMapping |
void SetKbmMapping(string key, string value) |
Sets a KBM mapping value. |
FlushKbmMappings |
void FlushKbmMappings() |
Flushes dictionary to array. |
VR Custom Mappings (Dictionary-based)¶
(#49) Same pattern as Extended/MIDI/KBM, for VR slots. Keys: the VrLayout vocabulary ("VrLTrigger", "VrRStickXNeg", "VrLA", "VrLATouch", ...). Values: mapping descriptors.
| Property | Type | Serialization | Description |
|---|---|---|---|
VrMappingEntries |
RawMappingEntry[] |
[XmlArray("VrMappings")] [XmlArrayItem("Map")] |
Serializable array |
| Method | Signature | Description |
|---|---|---|
GetVrMapping |
string GetVrMapping(string key) |
Gets a VR mapping value. Reads under the VR dict lock. |
SetVrMapping |
void SetVrMapping(string key, string value) |
Sets a VR mapping value. Empty/null removes the key. |
FlushVrMappings |
void FlushVrMappings() |
Flushes dictionary to array. |
Per-Mapping Deadzones (Dictionary-based)¶
Same pattern as Extended/MIDI/KBM mappings. Keys = target mapping names (e.g. "LeftThumbAxisX"), values = 0–100% threshold for axis-to-button activation. Default removal values: "0" or "50".
| Method | Signature | Description |
|---|---|---|
GetMappingDeadZone |
string GetMappingDeadZone(string key) |
Gets deadzone for a target. Returns "" if not found. |
SetMappingDeadZone |
void SetMappingDeadZone(string key, string value) |
Sets or removes a deadzone entry. Removes at "0" or "50". |
FlushMappingDeadZones |
void FlushMappingDeadZones() |
Syncs in-memory dictionary to MappingDeadZoneEntries array for serialization. |
Per-Mapping Bidirectional (Dictionary-based)¶
Twin of the deadzone family. Bidirectional is the third independent axis-to-button flag and lives in its own dictionary so the descriptor string stays unchanged. Keys = target mapping names, values "1" / "0" / "" (missing = false).
| Property | Type | Serialization | Description |
|---|---|---|---|
MappingBidirectionalEntries |
RawMappingEntry[] |
[XmlArray("MappingBidirectional")] [XmlArrayItem("Map")] |
Serializable array |
| Method | Signature | Description |
|---|---|---|
GetMappingBidirectional |
string GetMappingBidirectional(string key) |
Gets the flag for a target. Returns "" if not found. |
SetMappingBidirectional |
void SetMappingBidirectional(string key, string value) |
Sets or removes the flag. Empty, null, or "0" removes the key. |
FlushMappingBidirectional |
void FlushMappingBidirectional() |
Syncs in-memory dictionary to MappingBidirectionalEntries. |
Computed Properties¶
| Property | Type | Serialization | Description |
|---|---|---|---|
HasAnyMapping |
bool |
[XmlIgnore] |
true if any mapping property has a non-empty descriptor. |
Clipboard-Only Payload Properties¶
Opaque payloads the App-side Copy path fills and the Paste path consumes. ToJson / FromJson round-trip them verbatim so the Engine keeps no App ViewModel references. All are [XmlIgnore] + [JsonIgnore], so none reach the on-disk XML.
| Property | Type | ToJson key | Description |
|---|---|---|---|
SlotDeviceConfigsJson |
string |
__SlotDeviceConfigs |
Per-(slot, device) bag: lighting, adaptive triggers, Mic LED, Player LED, audio-reactive, palette, tone filter. Was __SlotPlayStationConfigs before the DeviceSlotConfig rename. |
SlotExtendedConfigJson |
string |
__SlotExtendedConfig |
Extended custom layout snapshot (thumbstick / trigger / POV / button counts, OEM / Product strings, FFB toggle). |
SlotMidiConfigJson |
string |
__SlotMidiConfig |
MIDI slot layout snapshot (channel, velocity, CC + note ranges). |
SlotKbmConfigJson |
string |
__SlotKbmConfig |
KBM slot config: SOCD mode + key pairs (#205). |
SlotShiftActivatorsJson |
string |
__SlotShiftActivators |
Slot shift authoring: ShiftActivators + Base flyout appearance (#119). |
SlotMenusJson |
string |
__SlotMenus |
(4.1.0) Slot radial / touch menu definitions (#9 B-17, MappingSet.Menus), so Copy / Paste carries the Menus-tab state like the shift authoring above. |
SlotSetExtrasJson |
string |
__SlotSetExtras |
(4.3.2) The rest of the slot's own MappingSet state: rumble-audio config (#236), gamepad SOCD authoring (#240), Keep Awake (#270). The KBM SOCD twin lives on KbmSlotConfig and crosses via SlotKbmConfigJson. |
SlotMacrosJson |
string |
__SlotMacros |
(4.3.2) The slot's macros, in the same envelope the Macros tab's own clipboard uses (#112), so a slot Copy / Paste and a macro Copy / Paste agree on wire shape and re-scoping rules. |
SlotPerDeviceSettingsJson |
string |
__SlotPerDeviceSettings |
Every device's PadSetting on the source slot (PerDeviceSettingsEntry[]), so all devices' per-device tuning round-trips through Copy / Paste and Copy From. |
DeviceScopedMultiSourceRows |
List<MappingRow> |
__MultiSourceRows |
This device's slice of the slot's multi-source rows (#61). |
SlotMultiSourceRows |
List<MappingRow> |
__SlotRows |
Whole-slot snapshot of every multi-source row, source DeviceGuids preserved. |
Methods¶
| Method | Signature | Description |
|---|---|---|
MigrateAntiDeadZones |
void MigrateAntiDeadZones() |
Migrates legacy unified anti-deadzone to per-axis X/Y when both per-axis values are empty or zero, then zeros the legacy value. Call after deserialization. |
MigrateMaxRangeDirections |
void MigrateMaxRangeDirections() |
Copies symmetric max range to null/empty negative-direction properties. |
ComputeChecksum |
string ComputeChecksum() |
8-char hex checksum (first 4 bytes of MD5) from all properties. Keys sorted for determinism. |
UpdateChecksum |
void UpdateChecksum() |
Computes and stores checksum in PadSettingChecksum. |
ClearMappingDescriptors |
void ClearMappingDescriptors(IReadOnlyDictionary<string, string> seed = null, IReadOnlyDictionary<string, string> deadZoneSeed = null, IReadOnlyDictionary<string, string> bidirectionalSeed = null) |
Replaces every mapping descriptor (standard, raw, MIDI, KBM, VR) and the per-mapping deadzone and bidirectional entries. Preserves per-device tuning such as stick deadzones and FFB. With a seed, each descriptor is assigned its seeded value (or "" when absent) in a single pass instead of being blanked for the caller to refill, and deadZoneSeed / bidirectionalSeed do the same for their entries. That matters because the ~1 kHz poll thread reads these properties directly, so a blank-then-refill leaves a window in which a tick sees empty descriptors and drives the pad to neutral. Callers on a frequent path must pass a seed. Pass null only to clear everything. |
GetAllMappingDescriptors |
List<string> GetAllMappingDescriptors() |
All non-empty mapping descriptor strings. |
ToJson |
string ToJson(VirtualControllerType outputType = VirtualControllerType.Xbox, bool isExtended = false) |
JSON for clipboard. Embeds __OutputType / __IsExtended layout metadata, the mapping dicts (__ExtendedMappings, __MidiMappings, __KbmMappings, __VrMappings, __MappingDeadZones, __MappingBidirectional), the typed touchpad and mouse-gesture sub-trees (__TouchpadSettings, __MouseGestureSettings), and the clipboard-only per-slot payloads written only when set on the source: __SlotDeviceConfigs (was __SlotPlayStationConfigs before the DeviceSlotConfig rename), __SlotExtendedConfig, __SlotMidiConfig, __SlotKbmConfig (#205 SOCD), __SlotShiftActivators (#119 shift authoring), __SlotMenus (#9 B-17 menus), __SlotSetExtras (#236 / #240 / #270), __SlotMacros (#112), __SlotPerDeviceSettings, __MultiSourceRows (device-scoped rows), and __SlotRows (whole-slot rows). |
FromJson |
static PadSetting FromJson(string json) |
Deserializes JSON. Returns null on invalid input. |
FromJson |
static PadSetting FromJson(string json, out VirtualControllerType, out bool) |
Same, also returns the source layout metadata so cross-layout paste can translate. Reattaches the typed TouchpadSettings / MouseGestureSettings and the clipboard-only payloads listed above. Still accepts the legacy __SlotPlayStationConfigs key (mapped to SlotDeviceConfigsJson) for payloads copied by pre-v4 builds. ToJson no longer writes it. |
CopyFrom |
void CopyFrom(PadSetting source) |
Reflection copy of every CopyablePropertyNames entry. Deep-copies mapping arrays and the TouchpadSettings and MouseGestureSettings typed sub-trees. Invalidates cached dicts. |
CopyFromTranslated |
void CopyFromTranslated(PadSetting source, VirtualControllerType srcType, bool srcIsExtended, VirtualControllerType tgtType, bool tgtIsExtended) |
Cross-layout copy via MappingTranslation. Translates mapping properties by canonical position. |
CloneDeep |
PadSetting CloneDeep() |
Deep copy including checksum. |
RawMappingEntry¶
File: PadForge.Engine/Data/PadSetting.cs
Namespace: PadForge.Engine.Data
Key-value entry for raw-surface, MIDI, KBM, and VR mapping plus per-mapping deadzone and bidirectional XML persistence. Shared by all six dictionary-based lanes.
public class RawMappingEntry
{
[XmlAttribute] public string Key { get; set; } = "";
[XmlAttribute] public string Value { get; set; } = "";
}
UserSetting¶
File: PadForge.Engine/Data/UserSetting.cs
Namespace: PadForge.Engine.Data
Links a physical device to a virtual controller slot and mapping. One per device-to-slot assignment. Implements INotifyPropertyChanged.
Serialized Properties¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
InstanceGuid |
Guid |
[XmlElement] |
Physical device instance GUID | |
InstanceName |
string |
[XmlElement] |
"" |
Instance name (for offline display) |
ProductGuid |
Guid |
[XmlElement] |
Product GUID for matching across sessions | |
ProductName |
string |
[XmlElement] |
"" |
Product name |
MapTo |
int |
[XmlElement] |
-1 |
VC slot index (0–15). −1 = unmapped. Raises PropertyChanged. |
PadSettingChecksum |
string |
[XmlElement] |
"" |
Links to a PadSetting |
IsEnabled |
bool |
[XmlElement] |
true |
Whether this mapping is enabled. Disabled = skipped in pipeline. |
DateCreated |
DateTime |
[XmlElement] |
DateTime.Now |
Creation timestamp |
DateUpdated |
DateTime |
[XmlElement] |
DateTime.Now |
Last modification timestamp |
Runtime-Only Fields (Not Serialized)¶
| Property | Type | Serialization | Description |
|---|---|---|---|
InstanceGuidString |
string |
[XmlIgnore] |
Cached InstanceGuid.ToString() for the ~1 kHz Step 3 descriptor reads. Reset when InstanceGuid is assigned. |
OutputState |
Gamepad |
[XmlIgnore] |
Mapped output from Step 3. Written by background thread. |
RawMappedState |
Gamepad |
[XmlIgnore] |
Pre-processing state (axis-selected, Y-negated, before DZ/ADZ/linear/range). For UI preview. |
RawHidOutputState |
RawHidState |
[XmlIgnore] |
Mapped raw output for Extended slots. Forwarded to HIDMaestro via HMaestroVirtualController.SubmitRawHidState. |
MidiRawOutputState |
MidiRawState |
[XmlIgnore] |
Mapped MIDI raw output for MIDI slots. |
KbmRawOutputState |
KbmRawState |
[XmlIgnore] |
Mapped KBM raw output for KeyboardMouse slots. |
VrRawOutputState |
VrRawState |
[XmlIgnore] |
Mapped VR hand-pair output for VR slots (#49). Merged in Step 4, submitted in Step 5. |
RawHidScratch / MidiRawScratch |
RawHidState / MidiRawState |
[XmlIgnore] |
Poll-thread-owned scratch the Extended and MIDI mappers build into. A fresh copy is published to the matching *OutputState only on content change, because published arrays are read cross-thread and must stay immutable after publish. |
TouchpadOutputState |
TouchpadState |
[XmlIgnore] |
PlayStation touchpad output for this device. Written by the background thread (Step 3), read by Step 4. |
MotionRowsOutputState |
MotionRowValues |
[XmlIgnore] |
(#475) This device's Motion Pitch, Yaw and Roll values for the Step 3 pass stamped in its Frame. Read by Step 4, which drops a value from an earlier pass. |
_cachedPadSetting |
PadSetting |
[XmlIgnore] (internal) |
Cached PadSetting reference set by SettingsManager. |
Methods¶
| Method | Signature | Description |
|---|---|---|
GetPadSetting |
PadSetting GetPadSetting() |
Returns cached PadSetting. |
SetPadSetting |
void SetPadSetting(PadSetting ps) |
Sets cached PadSetting. Called by SettingsManager on load/sync. |
UserDevice¶
File: PadForge.Engine/Data/UserDevice.cs
Namespace: PadForge.Engine.Data
Data model for a physical input device. Serializable properties (settings-persisted) and runtime-only fields (pipeline). Partial class. Implements INotifyPropertyChanged.
Serialized Identity Properties¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
InstanceGuid |
Guid |
[XmlElement] |
Deterministic GUID from SdlDeviceWrapper.BuildInstanceGuid (serial, device path, or SDL GUID) |
|
InstanceName |
string |
[XmlElement] |
"" |
Instance name (e.g., "Xbox Controller") |
ProductGuid |
Guid |
[XmlElement] |
Product GUID built from VID and PID (SdlDeviceWrapper.BuildProductGuid, no DirectInput PIDVID signature) |
|
ProductName |
string |
[XmlElement] |
"" |
Product name |
VendorId |
ushort |
[XmlElement] |
0 | USB Vendor ID |
ProdId |
ushort |
[XmlElement] |
0 | USB Product ID |
DevicePath |
string |
[XmlElement] |
"" |
Device file system path |
SerialNumber |
string |
[XmlElement] |
"" |
Device serial number (e.g., Bluetooth MAC) |
SdlGuid |
string |
[XmlElement] |
"" |
SDL joystick GUID (32 hex chars) for gamecontrollerdb matching |
Serialized Capability Properties¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
CapAxeCount |
int |
[XmlElement] |
0 | Number of axes |
CapButtonCount |
int |
[XmlElement] |
0 | Button count (gamepad-mapped for gamepads) |
RawButtonCount |
int |
[XmlElement] |
0 | Raw button count before gamepad remapping |
CapButtonIndices |
int[] |
(default) | null |
Sparse button positions the device populates, as SDL places them in the 22-slot space, recorded while online so the offline listing shows the same numbers (discussion #344). Null/empty on pre-field saves, callers fall back to dense. |
CapAxisIndices |
int[] |
(default) | null |
Axis twin of CapButtonIndices, mirrored from SdlDeviceWrapper.SupportedAxisIndices: SDL_GamepadHasAxis for the standard six, real extras past them. A Move Navigation has three of the first six and seven of the ten extras. Null/empty on pre-field saves, callers fall back to dense. |
RawAxisCount |
int |
(default) | 0 | Total raw joystick axes before the gamepad layout caps CapAxeCount (#193) |
HasExtraGenericAxes |
bool |
(default) | false |
Device carries raw axes past the standard six that should surface as generic "Axis N" sources (#193). Not derivable from the counts: it excludes devices whose extras are already sensor sources. |
CapPovCount |
int |
[XmlElement] |
0 | Number of POV hat switches |
CapType |
int |
[XmlElement] |
0 | InputDeviceType constant |
HasGyro |
bool |
[XmlElement] |
false |
Gyroscope support |
HasAccel |
bool |
[XmlElement] |
false |
Accelerometer support |
HasAccelAux |
bool |
[XmlElement] |
false |
Auxiliary/left accelerometer (#199 Nunchuk / left Joy-Con, SDL_SENSOR_ACCEL_L). Mirrors ISdlInputDevice.HasAccelAux. |
HasGyroAux |
bool |
[XmlElement] |
false |
Auxiliary/left gyroscope (#252): the left half of a combined Joy-Con pair, never a Nunchuk. Mirrors ISdlInputDevice.HasGyroAux. |
HasTouchpad |
bool |
[XmlElement] |
false |
At least one touchpad surface (DS4 / DualSense / Steam Deck) |
CapTouchpadCount |
int |
[XmlElement] |
0 | Touchpad surface count (Steam Controller 2026 / Deck = 2, DualSense / DS4 = 1). 0 on pre-field saves, callers fall back to HasTouchpad. |
CapTouchpadFingerCounts |
int[] |
(default) | null |
Per-touchpad finger count. Index aligns with touchpad index. Null/empty on pre-field saves. |
CapTouchpadPressure / CapTouchpadClick |
bool? |
(default, written only when set) | null |
The device's TouchpadPressureSupported / TouchpadClickSupported. Null = unknown |
HasRumbleTriggers |
bool |
[XmlElement] |
false |
Per-trigger ("impulse") rumble motors (Xbox One / Elite / Series) |
Serialized Metadata¶
| Property | Type | Serialization | Default | Description |
|---|---|---|---|---|
DateCreated |
DateTime |
[XmlElement] |
DateTime.Now |
First creation timestamp. Vestigial. Serialized but never read by any consumer. |
DateUpdated |
DateTime |
[XmlElement] |
DateTime.Now |
Last update timestamp. Vestigial. Serialized but never read by any consumer. |
IsEnabled |
bool |
[XmlElement] |
true |
Record-level flag, shown as "Disabled" in the Devices list. It gates no input, and no control writes it. The per-slot UserSetting.IsEnabled is the flag that gates an assignment |
IsHidden |
bool |
[XmlElement] |
false |
Whether device is hidden from UI |
DisplayName |
string |
[XmlElement] |
"" |
User-assigned name (overrides InstanceName) |
HidHideEnabled |
bool |
[XmlElement] |
false |
Hide device from games via HidHide when assigned |
ConsumeInputEnabled |
bool |
[XmlElement] |
false |
Suppress mapped KB/mouse inputs via hooks |
ForceRawJoystickMode |
bool |
[XmlElement] |
false |
Bypass SDL gamepad remapping |
IdleDisconnectSeconds |
int |
[XmlElement] |
0 | Idle-disconnect countdown (#162). When Bluetooth-connected and idle this long, the host radio drops the link so the pad sleeps. 0 disables. No effect over USB. No charging gate. |
QuickChargeEnabled |
bool |
[XmlElement] |
false |
Quick Charge (#372): when a Bluetooth-connected pad starts reporting that it charges, PadForge drops the Bluetooth link so the pad only charges |
GunCalibration |
string |
[XmlElement] |
"" |
A GunCon 2's calibration (hifihedgehog/SDL#33), "minX,maxX,minY,maxY" in beam counts. Empty or unusable text reads as GunCon2Calibration.Default. LoadFromSdlDevice hands it to the gun's wrapper on every connect. A Wii Remote keeps its light-gun window here too (#485), in pointer counts, and empty or unusable text leaves it on its default aim range. |
HidHideInstanceIds |
List<string> |
[XmlArray] [XmlArrayItem("Id")] |
new() |
Cached HID instance IDs for HidHide (persisted for offline devices) |
DeviceObjects |
DeviceObjectItem[] |
(default) | null |
Axis, hat, and button metadata. Populated in Step 1. No serialization attribute, so it round-trips as a <DeviceObjects> wrapper of <DeviceObjectItem> children. Serialized for offline dropdown persistence so mapping UI can show source descriptors when the device is disconnected. |
Runtime-Only Fields (Not Serialized)¶
| Property | Type | Serialization | Description |
|---|---|---|---|
Device |
ISdlInputDevice |
[XmlIgnore] |
Live device handle. Set in Step 1. |
IsOnline |
bool |
[XmlIgnore] |
Connected and opened. |
InstanceGuidString |
string |
[XmlIgnore] |
Cached InstanceGuid.ToString() for the poll loop. |
OutputSync |
object |
[XmlIgnore] |
Lock that serializes a remote peer's relayed output frame and the feedback pass stopping the device (#416). |
InputState |
CustomInputState |
[XmlIgnore] |
Current state snapshot (Step 2, atomic ref). |
InputStateSeq |
long (field) |
[XmlIgnore] |
Publish counter Step 2 increments on every InputState republish. With pooled buffers a reference can recur with new content, so consumers compare this instead. |
PtpStatePool |
PooledInputStatePair (field) |
[XmlIgnore] |
State buffers for the Precision Touchpad read path, which has no wrapper. |
OldInputState |
CustomInputState |
[XmlIgnore] |
Previous state for change detection. |
LastActiveTick |
long |
[XmlIgnore] |
Last TickCount64 this device was non-idle, for the #162 idle countdown. Polling thread only. 0 = not tracked yet this connection. |
IdleTrackedConnection |
object |
[XmlIgnore] |
Wrapper the idle countdown last stamped against. A mismatch marks a fresh connection and restarts the countdown. Polling thread only. |
LastIdleCheckTick |
long |
[XmlIgnore] |
Last tick the #162 countdown ran, so it checks about once a second instead of at poll rate. Polling thread only. |
QuickChargePrevCharging / LastQuickChargeCheckTick |
bool / long |
[XmlIgnore] |
Quick Charge edge memory and its ~1 Hz check tick (#372). The first observation of a record seeds the edge memory and never counts as an edge. Polling thread only. |
ActuatorCount |
int |
[XmlIgnore] |
FFB actuator axis count. |
ForceFeedbackState |
ForceFeedbackState |
[XmlIgnore] |
Per-device FFB state. |
Computed Convenience Properties¶
| Property | Type | Serialization | Description |
|---|---|---|---|
IsMouse |
bool |
[XmlIgnore] |
CapType == InputDeviceType.Mouse |
IsKeyboard |
bool |
[XmlIgnore] |
CapType == InputDeviceType.Keyboard |
IsTouchpad |
bool |
[XmlIgnore] |
CapType == InputDeviceType.Touchpad |
IsTablet |
bool |
[XmlIgnore] |
CapType == InputDeviceType.Tablet |
IsConsumerControl |
bool |
[XmlIgnore] |
CapType == InputDeviceType.ConsumerControl (#168) |
SupportsTouchpadPressure / SupportsTouchpadClick |
bool |
[XmlIgnore] |
The stored CapTouchpadPressure / CapTouchpadClick, or when unknown, true for everything but a tablet and a wrapper-less system touchpad |
HasIrCamera |
bool |
[XmlIgnore] |
Wii Remote IR camera. Identity-derived from VID 0x057E and the exact SDL names of the three configurations that carry it, "Nintendo Wii Remote", "Nintendo Wii Remote with Nunchuk" and "Nintendo Wii Remote with Classic Controller" (WiiRemoteIdentity.CarriesIrCamera), correct online or offline (#146). Gates the "IR Pointer X/Y" sources. |
IsBalanceBoard |
bool |
[XmlIgnore] |
Wii Balance Board. VID 0x057E + name contains "Balance Board" (#146). Gates the corner-load sources. |
HasJoyConIr |
bool |
[XmlIgnore] |
Right Joy-Con NIR camera. VID 0x057E + exact name "Nintendo Switch Joy-Con (R)" (#151), or PID 0x2008 (combined gen-1 pair, whose right half posts the camera, #275). The PID leg exists because SDL names the gen-1 and gen-2 pairs identically. Gates the "IR Brightness" source. |
HasRingCon |
bool |
[XmlIgnore] |
Same identity as HasJoyConIr, since the fork reads a Ring-Con through the same right Joy-Con (hifihedgehog/SDL#33). Gates the "Ring-Con Squeeze" and "Ring-Con Pull" sources. |
IsGunCon2 |
bool |
[XmlIgnore] |
VID 0x0B9A + PID 0x016A (hifihedgehog/SDL#33), so a relayed gun answers too. The picker then offers the IR pointer sources as "Gun Aim X", "Gun Aim Y" and "Gun Offscreen". |
HasJoyCon2Mouse |
bool |
[XmlIgnore] |
Joy-Con 2 (L or R) optical mouse. VID 0x057E + exact match against the BLE driver's Joy-Con 2 names (#154). Gates the "Mouse Motion X/Y" sources. |
HasSpinDial |
bool |
[XmlIgnore] |
Logitech WingMan Warrior, by the serial driver's name "Logitech WingMan Warrior" (SpinDialIdentity, hifihedgehog/SDL#33). A hint-named COM port carries no USB IDs, so the name is the identity. Gates "Mouse Motion X" alone, since the dial has one axis. |
HasVoicePhrases |
bool |
[XmlIgnore] |
Voice phrases ride this pad's own surface (#317). VID 0x054C and PID 0x0CE6 / 0x0DF2 (DualSense / DualSense Edge), the pads with an embedded microphone. Standalone microphone devices are not gated by this: they expose phrases as named raw buttons directly, the PC/SC-reader pattern. |
HasNfcReader |
bool |
[XmlIgnore] |
Switch NFC reader (#241). Computed, not stored: VID 0x057E and PID 0x2007 (right Joy-Con), 0x2008 (combined pair, whose right half carries the MCU), or 0x2009 (Pro Controller). Switch 2 controllers are deliberately excluded, because no reference reads their NFC on PC over any transport. Gates the picker offering the "Any NFC Tag" and per-tag sources. |
HasForceFeedback |
bool |
[XmlIgnore] |
True when ActuatorCount is above 0, or when a live Device reports HasRumble or HasHaptic. Null-safe on Device. |
ResolvedName |
string |
[XmlIgnore] |
DisplayName if set, then InstanceName, then ProductName. "(Unknown Device)" only when ProductName is null |
StatusText |
string |
[XmlIgnore] |
"Disabled", "Online", or "Offline" |
Methods¶
| Method | Signature | Description |
|---|---|---|
LoadInstance |
void LoadInstance(Guid instanceGuid, string instanceName, Guid productGuid, string productName) |
Sets identity properties. |
LoadCapabilities |
void LoadCapabilities(int axeCount, int buttonCount, int povCount, int type) |
Sets capability properties. |
LoadFromSdlDevice |
void LoadFromSdlDevice(SdlDeviceWrapper) |
Loads identity + capabilities from the SDL wrapper (delegates to the shared LoadFromDevice). |
LoadFromKeyboardDevice |
void LoadFromKeyboardDevice(SdlKeyboardWrapper) |
Loads from keyboard wrapper. |
LoadFromConsumerDevice |
void LoadFromConsumerDevice(ConsumerControlWrapper) |
Loads from a Consumer Control wrapper (#168), same delegation as keyboard. |
LoadFromMouseDevice |
void LoadFromMouseDevice(SdlMouseWrapper) |
Loads from mouse wrapper. |
LoadFromWebDevice |
void LoadFromWebDevice(WebControllerDevice) |
Loads from web controller. |
LoadFromExternalDevice |
void LoadFromExternalDevice(ISdlInputDevice) |
Loads from any App-registered ISdlInputDevice (MIDI endpoints, other non-SDL sources). |
LoadFromOverlayDevice |
void LoadFromOverlayDevice(TouchpadOverlayDevice) |
Loads from the on-screen touchpad overlay device. |
ClearRuntimeState |
void ClearRuntimeState() |
Clears runtime fields. Preserves serialized properties. |
NotifyStateChanged |
void NotifyStateChanged() |
Raises PropertyChanged for IsOnline, StatusText, InputState. |
ToString |
string |
Returns "{ResolvedName} [{InstanceGuid:N}]". |
DeadZoneShape¶
File: PadForge.Engine/Data/DeadZoneShape.cs
Namespace: PadForge.Engine.Data
Deadzone algorithm for thumbstick axes.
public enum DeadZoneShape
{
Axial = 0, // Independent per-axis (square/cross shape). Legacy behavior.
Radial = 1, // Circular/elliptical magnitude check, no output rescaling.
ScaledRadial = 2, // Circular/elliptical with output rescaling (industry standard). DEFAULT.
SlopedAxial = 3, // Axis-dependent thresholds: DZ grows as other axis increases.
SlopedScaledAxial = 4, // Sloped axis-dependent with output rescaling.
Hybrid = 5, // Scaled Radial followed by Sloped Scaled Axial (best hybrid).
}
MappingTranslation¶
File: PadForge.Engine/Data/MappingTranslation.cs
Namespace: PadForge.Engine.Data
Translates mapping property names between virtual controller layouts using positional equivalence.
Key Types¶
public enum ControlCategory { Button, Axis, AxisNeg, DPad }
public record MappingSlot(ControlCategory Category, int Position);
MappingSlot represents a canonical position (e.g., "3rd button", "1st axis negative"). Translation converts source property name to MappingSlot, then to the target layout's property name.
Public Methods¶
| Method | Signature | Description |
|---|---|---|
GetPosition |
static MappingSlot GetPosition(string propertyName, VirtualControllerType type, bool isExtended) |
Property name to canonical MappingSlot |
GetPropertyName |
static string GetPropertyName(MappingSlot slot, VirtualControllerType type, bool isExtended) |
Canonical MappingSlot to property name |
IsSameLayout |
static bool IsSameLayout(VirtualControllerType srcType, bool srcIsExtended, VirtualControllerType tgtType, bool tgtIsExtended) |
true if source and target share property names |
GetLayoutLabel |
static string GetLayoutLabel(VirtualControllerType type, bool isExtended) |
Display label (e.g., "Xbox", "Extended", "MIDI", "KB+M") |
Supported Layouts¶
| Layout | Property Name Examples | Notes |
|---|---|---|
| Gamepad (Xbox / PlayStation / Extended gamepad preset) | ButtonA, LeftThumbAxisX, DPadUp |
Xbox and PlayStation share property names. Buttons: A=0..Guide=10, then Share 11, Mute 12, the paddles 13–14, and the Fn pair 15–16. Axes: LX=0..RT=5. |
Raw surface (Extended Custom and Nintendo, isExtended=true) |
RawBtn0, RawAxis2, RawAxis2Neg, RawPov0Up |
Indexed by position. POV 0 only maps to D-Pad. |
| MIDI | MidiNote0, MidiCC3, MidiCC3Neg |
No D-Pad support (returns null). |
| KB+M | KbmMBtn0, KbmMouseX, KbmMouseXNeg, KbmKey20, KbmScroll |
Button positions 0–4 are the mouse buttons (LMB, RMB, MMB, X1, X2), then Space, E, R, G, Q, and C at 5–10. Other keys have no position. 4 mouse axes (KbmMouseX, KbmMouseY, KbmScroll, KbmScrollH). D-Pad mapped to arrow keys. |
| VR (#49) | VrRA, VrLStickX, VrLStickXNeg, VrLGrip |
One slot is the left plus right hand pair. Buttons 0-15, axes mirror the gamepad order (LX0 LY1 LT2 RX3 RY4 RT5) with the two grips as axes 6 and 7. No D-Pad. |
Internal Layout Kinds¶
- Xbox, PlayStation, and Extended gamepad preset all resolve to
LayoutKind.Gamepad. - Extended or Nintendo with
isExtended=true(raw HID surface) resolves toLayoutKind.Extended.GetLayoutLabelprints that kind as "Nintendo" for a Nintendo slot and "Extended" otherwise. IsSameLayoutcompares resolvedLayoutKindvalues.
SDL3 P/Invoke¶
File: PadForge.Engine/Common/SDL3Minimal.cs
Namespace: SDL3
Minimal SDL3 P/Invoke declarations for joystick, gamepad, keyboard, mouse, and haptic. Only functions used by PadForge are declared. Native library: "SDL3".
Init Flags¶
| Constant | Value | Description |
|---|---|---|
SDL_INIT_VIDEO |
0x00000020 |
Required for keyboard/mouse |
SDL_INIT_JOYSTICK |
0x00000200 |
Joystick subsystem |
SDL_INIT_HAPTIC |
0x00001000 |
Haptic subsystem |
SDL_INIT_GAMEPAD |
0x00002000 |
Gamepad subsystem (was SDL_INIT_GAMECONTROLLER) |
Hat Constants¶
| Constant | Value |
|---|---|
SDL_HAT_CENTERED |
0x00 |
SDL_HAT_UP |
0x01 |
SDL_HAT_RIGHT |
0x02 |
SDL_HAT_DOWN |
0x04 |
SDL_HAT_LEFT |
0x08 |
SDL_HAT_RIGHTUP |
0x03 |
SDL_HAT_RIGHTDOWN |
0x06 |
SDL_HAT_LEFTUP |
0x09 |
SDL_HAT_LEFTDOWN |
0x0C |
Hint Strings¶
| Constant | Value | Description |
|---|---|---|
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS |
"SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS" |
Allow events when app not focused |
SDL_HINT_JOYSTICK_XINPUT |
"SDL_JOYSTICK_XINPUT" |
Set to "1" at init, but SDL 3 reads no hint by this name. XInput stays on through SDL_HINT_XINPUT_ENABLED ("SDL_XINPUT_ENABLED"), which defaults to on |
SDL_HINT_HIDAPI_IGNORE_DEVICES |
"SDL_HIDAPI_IGNORE_DEVICES" |
Comma-separated 0xVVVV/0xPPPP list hidapi must never enumerate or probe (#235) |
SDL_HINT_JOYSTICK_BLACKLIST_DEVICES |
"SDL_JOYSTICK_BLACKLIST_DEVICES" |
Joystick-layer blacklist. Set to InputManager.JoystickBlacklistDevices to suppress the PS Move family junk rows (#277) |
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 |
"SDL_JOYSTICK_HIDAPI_SWITCH2" |
Switch 2 controller support |
SDL_HINT_JOYSTICK_HIDAPI_WII |
"SDL_JOYSTICK_HIDAPI_WII" |
Wii Remote / Nunchuk / Classic / Wii U Pro (#116) |
SDL_HINT_JOYSTICK_BLE_SWITCH2 |
"SDL_JOYSTICK_BLE_SWITCH2" |
The fork's BLE GATT driver for Switch 2 controllers |
SDL_HINT_JOYSTICK_BLE_SWITCH2_MOUSE |
"SDL_JOYSTICK_BLE_SWITCH2_MOUSE" |
Joy-Con 2 optical-mouse counters on axes 6/7 (#154) |
SDL_HINT_JOYSTICK_BLE_SWITCH2_MAGNETOMETER |
"SDL_JOYSTICK_BLE_SWITCH2_MAGNETOMETER" |
Switch 2 magnetometer axes (#271 item 5) |
SDL_HINT_JOYSTICK_HIDAPI_SWITCH_SHAPED_RUMBLE |
"SDL_JOYSTICK_HIDAPI_SWITCH_SHAPED_RUMBLE" |
The fork's frequency-shaped classic Switch rumble (#271 item 4) |
SDL_HINT_JOYSTICK_HIDAPI_JOYCON_IR_SENSOR |
"SDL_JOYSTICK_HIDAPI_JOYCON_IR_SENSOR" |
Right Joy-Con NIR camera (#151). Set on demand only, because the camera and the NFC reader share one MCU |
SDL_HINT_JOYSTICK_HIDAPI_SWITCH_NFC |
"SDL_JOYSTICK_HIDAPI_SWITCH_NFC" |
Switch NFC reader (#241). Also demand-gated |
SDL_HINT_JOYSTICK_HIDAPI_PS3_SIXAXIS_DRIVER |
"SDL_JOYSTICK_HIDAPI_PS3_SIXAXIS_DRIVER" |
Claims a DS3 in DsHidMini SixaxisCompatible mode (#194). Never set alongside SDL_JOYSTICK_HIDAPI_PS3 |
SDL_HINT_JOYSTICK_HIDAPI_FLYDIGI |
"SDL_JOYSTICK_HIDAPI_FLYDIGI" |
Flydigi enhanced-protocol driver switch (#395). Written under the joystick lock and replayed before every SDL_Init |
SDL_HINT_VIDEO_ALLOW_SCREENSAVER |
"SDL_VIDEO_ALLOW_SCREENSAVER" |
Allow screensaver |
Property and Buffer Constants¶
| Constant | Value | Description |
|---|---|---|
SDL_PROP_JOYSTICK_CAP_RUMBLE_BOOLEAN |
"SDL.joystick.cap.rumble" |
Rumble capability, read through SDL_GetBooleanProperty |
SDL_PROP_JOYSTICK_CAP_TRIGGER_RUMBLE_BOOLEAN |
"SDL.joystick.cap.trigger_rumble" |
Impulse-trigger capability |
SDL_LOG_PRIORITY_DEBUG |
3 |
Priority passed to SDL_SetLogPriorities for the diagnostics ring |
NfcTagUidBufferLength |
21 |
UID buffer: 10 bytes as 20 hex characters plus the terminator |
Enums¶
SDL_JoystickType:
| Value | Name |
|---|---|
| 0 | SDL_JOYSTICK_TYPE_UNKNOWN |
| 1 | SDL_JOYSTICK_TYPE_GAMEPAD |
| 2 | SDL_JOYSTICK_TYPE_WHEEL |
| 3 | SDL_JOYSTICK_TYPE_ARCADE_STICK |
| 4 | SDL_JOYSTICK_TYPE_FLIGHT_STICK |
| 5 | SDL_JOYSTICK_TYPE_DANCE_PAD |
| 6 | SDL_JOYSTICK_TYPE_GUITAR |
| 7 | SDL_JOYSTICK_TYPE_DRUM_KIT |
| 8 | SDL_JOYSTICK_TYPE_ARCADE_PAD |
| 9 | SDL_JOYSTICK_TYPE_THROTTLE |
| 10 | SDL_JOYSTICK_TYPE_COUNT |
SDL_PowerState:
| Value | Name |
|---|---|
| -1 | SDL_POWERSTATE_ERROR |
| 0 | SDL_POWERSTATE_UNKNOWN |
| 1 | SDL_POWERSTATE_ON_BATTERY |
| 2 | SDL_POWERSTATE_NO_BATTERY |
| 3 | SDL_POWERSTATE_CHARGING |
| 4 | SDL_POWERSTATE_CHARGED |
Structs¶
SDL_GUID: one byte[16] data field, marshalled ByValArray. SDL_GUIDToString renders it, and the GetJoystickGUIDString(IntPtr joystick) helper wraps the pair into a 32-character string.
SDL_HapticDirection (16 bytes): type (byte), dir0, dir1, dir2 (int).
SDL_HapticLeftRight (12 bytes): type, length, large_magnitude, small_magnitude.
SDL_HapticConstant (40 bytes): type, direction, length, delay, button, interval, level, attack_length, attack_level, fade_length, fade_level.
SDL_HapticPeriodic (44 bytes): type, direction, length, delay, button, interval, period, magnitude, offset, phase, attack_length, attack_level, fade_length, fade_level.
SDL_HapticCondition (68 bytes): type, direction, length, delay, button, interval, per-axis arrays (3 axes): right_sat[0-2], left_sat[0-2], right_coeff[0-2], left_coeff[0-2], deadband[0-2], center[0-2].
SDL_HapticRamp (44 bytes): type, direction, length, delay, button, interval, start, end, attack_length, attack_level, fade_length, fade_level.
SDL_HapticEffect (72 bytes, explicit layout): Union overlaying type, leftright, constant, periodic, condition, ramp all at FieldOffset(0).
SDL_VirtualJoystickDesc (136 bytes on x64) with SDL_VirtualJoystickSensorDesc and SDL_VirtualJoystickTouchpadDesc (8 bytes each): the virtual-joystick description for the Bluetooth DS3 bridge. Its callbacks are function pointers (VJUpdate, VJRumble, VJSetLED, and the rest), and the managed delegates must stay alive while the joystick is attached.
Haptic Constants¶
| Constant | Value | Description |
|---|---|---|
SDL_HAPTIC_CONSTANT |
1 << 0 |
Constant force |
SDL_HAPTIC_SINE |
1 << 1 |
Sine wave |
SDL_HAPTIC_SQUARE |
1 << 2 |
Square wave |
SDL_HAPTIC_TRIANGLE |
1 << 3 |
Triangle wave |
SDL_HAPTIC_SAWTOOTHUP |
1 << 4 |
Sawtooth up |
SDL_HAPTIC_SAWTOOTHDOWN |
1 << 5 |
Sawtooth down |
SDL_HAPTIC_RAMP |
1 << 6 |
Ramp |
SDL_HAPTIC_SPRING |
1 << 7 |
Spring condition |
SDL_HAPTIC_DAMPER |
1 << 8 |
Damper condition |
SDL_HAPTIC_INERTIA |
1 << 9 |
Inertia condition |
SDL_HAPTIC_FRICTION |
1 << 10 |
Friction condition |
SDL_HAPTIC_LEFTRIGHT |
1 << 11 |
Left/right dual-motor |
SDL_HAPTIC_CUSTOM |
1 << 15 |
Custom effect |
SDL_HAPTIC_GAIN |
1 << 16 |
Gain control supported |
SDL_HAPTIC_AUTOCENTER |
1 << 17 |
Auto-center supported |
SDL_HAPTIC_INFINITY |
0xFFFFFFFF |
Infinite duration |
SDL_HAPTIC_POLAR |
0 (byte) | Polar direction type |
SDL_HAPTIC_CARTESIAN |
1 (byte) | Cartesian direction type |
SDL_HAPTIC_SPHERICAL |
2 (byte) | Spherical direction type |
SDL_HAPTIC_STEERING_AXIS |
3 (byte) | Steering axis direction type |
Gamepad Axis Constants¶
| Constant | Value | Description |
|---|---|---|
SDL_GAMEPAD_AXIS_LEFTX |
0 | Left stick X |
SDL_GAMEPAD_AXIS_LEFTY |
1 | Left stick Y |
SDL_GAMEPAD_AXIS_RIGHTX |
2 | Right stick X |
SDL_GAMEPAD_AXIS_RIGHTY |
3 | Right stick Y |
SDL_GAMEPAD_AXIS_LEFT_TRIGGER |
4 | Left trigger |
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER |
5 | Right trigger |
SDL_GAMEPAD_AXIS_COUNT |
6 | Total axis count |
Gamepad Button Constants¶
| Constant | Value | Description |
|---|---|---|
SDL_GAMEPAD_BUTTON_SOUTH |
0 | A |
SDL_GAMEPAD_BUTTON_EAST |
1 | B |
SDL_GAMEPAD_BUTTON_WEST |
2 | X |
SDL_GAMEPAD_BUTTON_NORTH |
3 | Y |
SDL_GAMEPAD_BUTTON_BACK |
4 | Back/Select |
SDL_GAMEPAD_BUTTON_GUIDE |
5 | Guide/Home |
SDL_GAMEPAD_BUTTON_START |
6 | Start |
SDL_GAMEPAD_BUTTON_LEFT_STICK |
7 | Left stick click |
SDL_GAMEPAD_BUTTON_RIGHT_STICK |
8 | Right stick click |
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER |
9 | Left bumper |
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER |
10 | Right bumper |
SDL_GAMEPAD_BUTTON_DPAD_UP |
11 | D-pad up |
SDL_GAMEPAD_BUTTON_DPAD_DOWN |
12 | D-pad down |
SDL_GAMEPAD_BUTTON_DPAD_LEFT |
13 | D-pad left |
SDL_GAMEPAD_BUTTON_DPAD_RIGHT |
14 | D-pad right |
SDL_GAMEPAD_BUTTON_MISC1 |
15 | Share / Capture / extra button (Xbox Series Share, Switch Capture, PS5 Mic) |
SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1 |
16 | Elite / DualSense Edge paddle (upper right) |
SDL_GAMEPAD_BUTTON_LEFT_PADDLE1 |
17 | Elite / Edge paddle (upper left) |
SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2 |
18 | Elite / Edge paddle (lower right) |
SDL_GAMEPAD_BUTTON_LEFT_PADDLE2 |
19 | Elite / Edge paddle (lower left) |
SDL_GAMEPAD_BUTTON_TOUCHPAD |
20 | Touchpad click (DS4 / DualSense / Steam Controller) |
SDL_GAMEPAD_BUTTON_MISC2 |
21 | Additional device-specific button |
SDL_GAMEPAD_BUTTON_MISC3 |
22 | Additional device-specific button |
SDL_GAMEPAD_BUTTON_MISC4 |
23 | Additional device-specific button |
SDL_GAMEPAD_BUTTON_MISC5 |
24 | Additional device-specific button |
SDL_GAMEPAD_BUTTON_MISC6 |
25 | Additional device-specific button |
SDL_GAMEPAD_BUTTON_COUNT |
26 | Total button count |
Sensor Type Constants¶
| Constant | Value | Description |
|---|---|---|
SDL_SENSOR_ACCEL |
1 | Accelerometer |
SDL_SENSOR_GYRO |
2 | Gyroscope |
SDL_SENSOR_ACCEL_L |
3 | Left accelerometer |
SDL_SENSOR_GYRO_L |
4 | Left gyroscope |
SDL_SENSOR_ACCEL_R |
5 | Right accelerometer |
SDL_SENSOR_GYRO_R |
6 | Right gyroscope |
Capsense Constants (SDL 3.6.0)¶
| Constant | Value | Description |
|---|---|---|
SDL_GAMEPAD_CAPSENSE_LEFT_STICK |
0 | Left stick top |
SDL_GAMEPAD_CAPSENSE_RIGHT_STICK |
1 | Right stick top |
SDL_GAMEPAD_CAPSENSE_LEFT_GRIP |
2 | Left grip |
SDL_GAMEPAD_CAPSENSE_RIGHT_GRIP |
3 | Right grip |
SDL_GAMEPAD_CAPSENSE_COUNT |
4 | Channel count |
Mouse Button Masks¶
| Constant | Value | Description |
|---|---|---|
SDL_BUTTON_LMASK |
1 << 0 |
Left button |
SDL_BUTTON_MMASK |
1 << 1 |
Middle button |
SDL_BUTTON_RMASK |
1 << 2 |
Right button |
SDL_BUTTON_X1MASK |
1 << 3 |
X1 button |
SDL_BUTTON_X2MASK |
1 << 4 |
X2 button |
VirtualKeyName Array¶
string[256] array of human-readable Windows VK code names. Built by BuildVirtualKeyNames(). Covers standard keys, modifiers, F1–F24, numpad, OEM keys, and "Numpad Enter" at 0x88, the keyboard state's own index for it. Used by SdlKeyboardWrapper.GetDeviceObjects() for button naming.
Core Function Categories¶
Lifecycle: SDL_Init, SDL_Quit, SDL_EnableScreenSaver, SDL_GetError, SDL_SetHint, SDL_GetHint, SDL_free, SDL_GetTicksNS
Joystick Enumeration: SDL_GetJoysticks, SDL_GetJoystickVendorForID, SDL_GetJoystickProductForID, SDL_GetJoystickProductVersionForID, SDL_GetJoystickTypeForID, SDL_GetJoystickNameForID, SDL_GetJoystickPathForID, SDL_IsGamepad
Gamepad Mappings: SDL_AddGamepadMappingsFromFile, SDL_AddGamepadMapping, GetGamepadMapping
Joystick Instance: SDL_OpenJoystick, SDL_CloseJoystick, SDL_GetJoystickID, SDL_JoystickConnected
Gamepad Instance: SDL_OpenGamepad, SDL_CloseGamepad, SDL_GetGamepadJoystick
Gamepad State: SDL_GetGamepadAxis, SDL_GetGamepadButton, SDL_GamepadHasAxis, SDL_GamepadHasButton
Touchpad: SDL_GetNumGamepadTouchpads, SDL_GetNumGamepadTouchpadFingers, SDL_GetGamepadTouchpadFinger
Fork-only surface: SDL_GetGamepadNfcTagUid and its non-throwing SDL_TryGetGamepadNfcTagUid wrapper (#241), which returns false for good after the first call on a DLL without the export
Device extras: SDL_GamepadHasCapSense / SDL_GetGamepadCapSense (stick-top and grip capacitive touch, SDL 3.6.0), SDL_SendGamepadEffect (Sony effect passthrough), SDL_SetJoystickLED (Switch HOME LED), SDL_SetJoystickPlayerIndex, SDL_RumbleGamepadTriggers
Virtual joystick (the bridged Bluetooth DS3 lane): SDL_AttachVirtualJoystick, SDL_DetachVirtualJoystick, SDL_IsJoystickVirtual, SDL_SetJoystickVirtualAxis / Button / Hat, SDL_SendJoystickVirtualSensorData
Diagnostics and locking: SDL_SetLogOutputFunction, SDL_SetLogPriorities, SDL_LockJoysticks, SDL_UnlockJoysticks
Joystick State: SDL_UpdateJoysticks, SDL_PumpEvents, SDL_GetJoystickAxis, SDL_GetJoystickButton, SDL_GetJoystickHat, SDL_GetNumJoystickAxes, SDL_GetNumJoystickButtons, SDL_GetNumJoystickHats
Joystick Properties: SDL_GetJoystickName, SDL_GetJoystickVendor, SDL_GetJoystickProduct, SDL_GetJoystickProductVersion, SDL_GetJoystickType, SDL_GetJoystickPath, SDL_GetJoystickSerial, SDL_GetJoystickGUID, SDL_GUIDToString, SDL_GetJoystickProperties, SDL_GetBooleanProperty, SDL_GetNumberProperty, SDL_GetStringProperty, SDL_GetGamepadPowerInfo
Sensors: SDL_GamepadHasSensor, SDL_SetGamepadSensorEnabled, SDL_GetGamepadSensorData
Rumble: SDL_RumbleJoystick
Haptic: SDL_OpenHapticFromJoystick, SDL_CloseHaptic, SDL_GetHapticFeatures, SDL_CreateHapticEffect, SDL_UpdateHapticEffect, SDL_RunHapticEffect, SDL_StopHapticEffect, SDL_DestroyHapticEffect, SDL_SetHapticGain, SDL_GetNumHapticAxes
Keyboard: SDL_GetKeyboards, SDL_GetKeyboardNameForID, SDL_GetKeyboardState
Mouse: SDL_GetMice, SDL_GetMouseNameForID, SDL_GetMouseState, SDL_GetRelativeMouseState
Version: SDL_GetVersion, SDL_Linked_Version (returns (major, minor, patch) tuple)
GestureRecognizer¶
File: PadForge.Engine/Touchpad/GestureRecognizer.cs
Namespace: PadForge.Engine.Touchpad
The per-tick touchpad recognizer. Reads one device's current TouchpadInputState against that pad's TouchpadGestureSettings and a persistent TouchpadGestureContext, then populates the context's FiredGesturesThisFrame set with gesture-descriptor names. Static class with one entry point.
Entry point¶
public static void Update(
int padIdx,
TouchpadGestureContext ctx,
TouchpadInputState pad,
TouchpadGestureSettings settings,
long nowMs,
IReadOnlyList<ShapeTemplate> shapeTemplates = null)
Walks the state machine Idle → Accumulating → Cooldown → Idle, running end-of-gesture recognition on the lift that ends Accumulating. GestureState.Recognizing is declared but never entered. Path tracking runs whenever either gesture recognition or joystick output is enabled. Both off skips the tick.
Three tiers¶
| Tier | When it runs | What it fires |
|---|---|---|
| 1: direction-based | Every tick while a finger is down | 4-way / 8-way swipes (end-of-gesture), radial-zone fires (mid-gesture, one-finger), touch spots (mid-gesture, held Left / Right / Top / Multitouch), tap / double-tap / triple-tap (end-of-gesture), long-press (mid-gesture, one-finger, recent-stillness gate) |
| 2: multi-finger continuous | Every tick while ≥2 fingers are down | Pinch / spread (one-shot threshold), rotate (one-shot threshold), continuous PinchAxis and RotateAxis, two-finger end-of-gesture swipe + tap |
| 3: shape templates | End-of-gesture only | ShapeRecognizer (point-cloud) + AngularMarginRecognizer (per-segment angle) run in parallel on single-finger templates and keep the higher-confidence match. Multi-finger templates use ShapeRecognizer alone. |
Long-press metric¶
DetectLongPress uses a recent-stillness window rather than max-from-touchdown. The bounding-box span of the last 25% of the path must stay below LongPressMaxMotion. Without this, users settling a finger into position (a common DualSense pattern where the contact patch shifts the reported position by a few percent during the first hundred ms) failed the max-distance check even when the finger was now perfectly stable.
LongPress / RadialZones coexistence¶
DetectLongPress does NOT clear the path after firing. DetectRadialZones reads path[0] each tick to compute the angle from touchdown, and a cleared path collapses start ≈ end so the next radial tick sees dist < RadialCenterDeadzone and releases the held zone. End-of-gesture detection checks for the LongPress entry in FiredGesturesThisFrame and skips swipe / tap / shape recognition instead.
Touch spots¶
DetectTouchSpots (gated by EnableTouchSpots) fires held-state keys over where the pad is being touched, following the DS4Windows ladder. 2+ fingers asserts TouchMulti. A single finger in the top quarter asserts TouchTop, otherwise the finger's x-position splits TouchLeft / TouchRight at 2/5 of the width. At most one spot is held at a time. Moving across a boundary releases the old key and presses the new one. Unlike radial zones (which latch to fire on release), touch spots release on finger lift, before the cooldown latch, so a mapped button lets go the instant the finger leaves the pad. Keys ride FiredGesturesThisFrame as Touchpad {padIdx} {spot}.
Per-slot fan-out¶
The same physical pad in multiple slots ticks through Update once per slot with that slot's own TouchpadGestureContext and TouchpadGestureSettings. The InputManager wiring is keyed by (slot, deviceGuid, padIdx). Fires from slot 0 don't bleed into slot 1's mapping rows.
ShapeRecognizer¶
File: PadForge.Engine/Touchpad/ShapeRecognizer.cs
Namespace: PadForge.Engine.Touchpad
C# re-derivation of the canonical $Q point-cloud recognizer (Magrofuoco / Vatavu / Anthony / Wobbrock, MobileHCI 2018), BSD 3-Clause. Faithful port of the reference JavaScript implementation. Used by GestureRecognizer Tier 3 when the last finger lifts.
Constants¶
| Constant | Value | Purpose |
|---|---|---|
DefaultResampleCount |
32 | Resampled points per template / candidate. |
DefaultLookupTableSize |
64 | LUT grid resolution. |
MaxIntCoord (private) |
1024 | Lookup-table integer-coordinate ceiling, matching the canonical JS reference. |
Public API¶
public static string Match(
Vector2[] candidate,
IReadOnlyList<ShapeTemplate> templates,
int fingerCount,
float threshold,
out float bestScore)
Returns the matched template name, or null when no template's distance lands at or under its threshold (the template's ThresholdOverride when set, threshold otherwise). Disabled templates and templates whose FingerCount doesn't match fingerCount are skipped. The bestScore out-param is the actual best distance (regardless of threshold). MatchByFingerCount builds the candidate cloud from live finger paths and calls Match.
public static Vector2[] BuildCloud(
IReadOnlyList<IReadOnlyList<Vector2>> fingers,
int perFinger)
public static ushort[] BuildLookupTable(
Vector2[] cloud,
int lutSize = DefaultLookupTableSize)
BuildCloud resamples each finger's path to perFinger points, concatenates them, and normalizes the combined cloud (scale-to-unit, translate-to-origin). BuildLookupTable builds the $Q lower-bound LUT for a normalized cloud. There is no single Build-to-ShapeTemplate entry point: callers set the results on a ShapeTemplate directly (PointCloud from BuildCloud, LookupTable from BuildLookupTable).
Algorithm¶
Match builds the candidate's LUT once per call, then iterates templates. For each template:
ComputeLowerBound: closed-form SAT-based lower bounds onCloudDistance, one per starting index, in each direction. A starting index whose bound is not below the current best is skipped.CloudMatch(candidate, candidateLut, template, minSoFar, effThreshold): runsCloudDistancein both directions (template→candidate and candidate→template) from starting indices stepped byfloor(sqrt(n)), takes the minimum. Pruning never runs tighter than the template's own threshold, and a template that never beats the pruning floor reports no score. Matches the canonical implementation.CloudDistance(pts1, pts2, start, minSoFar): greedy nearest-unmatched matching, with an unmatched-index list standing in formatched[]. Weight starts atnand decrements per step, biasing the score toward the earliest correspondences. Early-abandons when the running sum exceeds the current best.
The matched[] tracking is mandatory. An earlier PadForge revision dropped it on the assumption that the LUT replaced it, and an M-shape custom gesture matched a horizontal swipe.
Threshold semantics¶
Lower threshold = stricter match (fewer false positives). Default GestureMatchThreshold is 3.0, preserved across the $P → $Q migration so user-tuned values transfer.
AngularMarginRecognizer¶
File: PadForge.Engine/Touchpad/AngularMarginRecognizer.cs
Namespace: PadForge.Engine.Touchpad
Per-segment angle-direction matcher. It follows the algorithm GestureSign's PointPatternAnalyzer describes. GestureSign is GPL-2.0 and was read for the algorithm only: the C# is original to PadForge and carries no GestureSign code. Runs alongside ShapeRecognizer on single-finger templates. The higher-confidence match wins.
Circular-variance gate¶
One threshold partitions both sides. A path whose circular variance (1 - R) falls below LineLikeVarianceGate = 0.1 is line-like, and a line-like candidate is matched only against line-like templates, a non-line-like candidate only against non-line-like templates. The gate stops a horizontal swipe from scoring well against a corner-rich M-template, where the mean angular delta lands near pi/3 and the score clears the accept threshold. 0.1 leaves a clear gap in practice: realistic wobbly swipes land under 0.05, corner-rich templates (M, Z, Square, Triangle, Checkmark) above 0.2, and circles near 1.0.
R is the mean resultant length of the per-segment unit direction vectors.
Closed-path detection¶
Templates flagged AngularIsClosed (carried to AngularTemplate.IsClosed) score the candidate at every cyclic starting point (BestRotationalScore), so a closed shape matches whichever corner or edge the trace starts from. Open shapes (Z, Checkmark) stay start-anchored, because their first segment's orientation is part of their identity.
Direction agnosticism¶
Templates flagged AngularIsDirectionAgnostic score the candidate against both forward and reversed traversals and keep the better. Used for shapes that read the same drawn clockwise or counterclockwise: the in-box Square and Triangle. Circle and CircleCCW stay directional on purpose, as separate gestures.
ShapeTemplate¶
File: PadForge.Engine/Touchpad/ShapeTemplate.cs
Namespace: PadForge.Engine.Touchpad
Preprocessed template ready for matching by ShapeRecognizer and AngularMarginRecognizer. A plain field class: its builders (InBoxShapeTemplates at startup, custom gestures at profile load) do the heavy work (resample / scale / translate / LUT-build / angular-signature precompute) once, so the per-tick Match path stays cheap.
| Field | Type | Purpose |
|---|---|---|
Name |
string |
Descriptor suffix (e.g. Circle, CircleCCW, custom name) |
FingerCount |
int |
Number of simultaneous fingers expected (1 for in-box, 1..5 for custom) |
PointCloud |
Vector2[] |
FingerCount × DefaultResampleCount points, the larger bounding-box dimension scaled to 1, centroid at the origin |
LookupTable |
ushort[] |
Closest-point integer-grid LUT for $Q lower-bound short-circuit |
LookupTableSize |
int |
LUT grid resolution (typically DefaultLookupTableSize) |
ThresholdOverride |
float |
Per-template threshold override. 0 = use the slot's GestureMatchThreshold |
Enabled |
bool |
Per-gesture enable toggle (only meaningful for custom templates) |
IsCustom |
bool |
True for user-recorded gestures, false for in-box shapes |
AngularSignature |
double[] |
Per-segment direction angles for AngularMarginRecognizer. Null for multi-finger templates |
AngularIsClosed |
bool |
Angular scoring tries every cyclic starting point |
AngularIsDirectionAgnostic |
bool |
Match both forward and reversed candidate traversals |
InBoxShapeTemplates¶
File: PadForge.Engine/Touchpad/InBoxShapeTemplates.cs
Namespace: PadForge.Engine.Touchpad
Procedural builders for the in-box shapes shipped with every profile. Six templates total: Circle (clockwise), CircleCCW, Square, Triangle, Z, Checkmark. The picker exposes Circle as two separate descriptors so the two directions can drive different mappings.
Add(...) builds each template inline: generates the canonical Vector2 path, normalizes it to a cloud via ShapeRecognizer.BuildCloud, attaches the LUT from ShapeRecognizer.BuildLookupTable, sets the angular flags appropriate to the shape, and appends to the catalog. Build() returns a fresh list on every call: InputManager seeds its catalog with it, and InputService rebuilds the catalog, in-box shapes plus the profile's custom gestures, when a profile applies or a custom gesture changes. No XML.
Names is a static IReadOnlyList<string> the picker walks to surface in-box shape descriptors.
TouchpadCustomGesture¶
File: PadForge.Engine/Touchpad/TouchpadCustomGesture.cs
Namespace: PadForge.Engine.Touchpad
XML-serializable representation of a user-recorded custom gesture. Stored in the profile's gesture library and compiled to a ShapeTemplate at profile load.
| Field | Type | Serialization | Purpose |
|---|---|---|---|
Name |
string |
[XmlAttribute] |
Suffix on the Touchpad N Custom_<name> descriptor |
DeviceClass |
string |
[XmlAttribute] |
Device-class filter. "any" (default) matches every touchpad device, otherwise a class label such as dualsense, ds4, steamdeck, steamcontroller, triton, precisiontouchpad, or overlay |
TouchpadIndex |
int |
[XmlAttribute] |
Pad index filter on multi-pad devices. -1 (default) = any pad |
Threshold |
float |
[XmlAttribute] |
Per-gesture recognition threshold. 0 = use the pad's GestureMatchThreshold |
Enabled |
bool |
[XmlAttribute] |
Per-gesture disable toggle |
FingerPaths |
List<FingerPath> |
[XmlElement("FingerPath")] |
One recorded path per finger. Each FingerPath holds Points, serialized as <P X="" Y="" T=""> elements: normalized 0..1 position plus a millisecond timestamp. The finger count is the number of paths (1..5) |
ToTemplate() builds the ShapeTemplate: it normalizes the finger paths with ShapeRecognizer.BuildCloud, builds the LUT with ShapeRecognizer.BuildLookupTable, precomputes the angular signature for single-finger gestures, names the template Custom_<Name>, and copies Threshold into ThresholdOverride along with Enabled. It returns null for a gesture with no finger paths.
TouchpadGestureContext¶
File: PadForge.Engine/Touchpad/TouchpadGestureContext.cs
Namespace: PadForge.Engine.Touchpad
Per-(slot, deviceGuid, padIdx) runtime context for the gesture recognizer. Held by InputManager.GestureContexts and lazily allocated on first tick.
Lifecycle state¶
| State | Meaning |
|---|---|
Idle |
No fingers in contact. Waiting for a finger-down. |
Accumulating |
≥1 finger in contact. Path is growing. Tier 1 / Tier 2 mid-gesture detectors may fire. |
Recognizing |
Declared, never entered. The lift that ends Accumulating runs end-of-gesture recognition and moves straight to Cooldown. |
Cooldown |
Post-gesture quiet period (CooldownMs). Prevents bounce-fire. |
Per-finger path storage¶
FingerPaths is List<List<Vector2>>, indexed by the order fingers touched down (not by hardware slot index). A finger lifting and a new one landing in the same slot opens a fresh path so the gesture engine doesn't stitch unrelated contacts together. Cleared on the lift that ends each gesture, as it enters Cooldown.
FingerStartTimestampsMs / FingerContactIds / FingerSlotIndices / FingerPathLive / FingerPathLastLiveMs parallel FingerPaths so each entry's touchdown time, originating HID contact ID, hardware slot index, and live state are recoverable.
Per-frame fire set¶
FiredGesturesThisFrame is a HashSet<string> of gesture-descriptor names fired this tick. The name is historical. Fires actually latch across the cooldown window so downstream readers (mapping evaluator → button output → macro trigger) see a stable fire long enough to pick up the rising edge at any reasonable polling rate. Cleared on cooldown expiry and when a fresh gesture starts, not on every tick. Held sources (radial zones, touch spots) add and remove their own keys.
Continuous-axis state¶
CurrentPinchAxis and CurrentRotateAxis hold the live bipolar -1..+1 values for the PinchAxis / RotateAxis mapping sources. Captured baselines (TwoFingerInitialDistance, TwoFingerInitialAngle) anchor pinch and rotate to the session's opening geometry. FiredPinchThisSession / FiredSpreadThisSession / FiredRotateCWThisSession / FiredRotateCCWThisSession are one-shot-per-session latches.
Radial-zone state¶
CurrentRadialZone is the most-recently-fired zone index (-1 = none held). Re-entering the same zone doesn't re-fire. Crossing to a different zone releases the old fire and presses the new one.
TouchpadGestureSettings¶
File: PadForge.Engine/Touchpad/TouchpadGestureSettings.cs
Namespace: PadForge.Engine.Touchpad
Per-(slot, deviceGuid, padIdx) toggles and thresholds. Stored as a nested XML element on the slot's PadSetting keyed by (deviceGuid, padIdx) so the same pad on two slots can carry two independent configurations.
Master toggles¶
| Property | Default | Purpose |
|---|---|---|
Enabled |
false |
Master gesture-engine switch. Off skips the recognizer entirely. |
Mode |
"Both" |
"InBoxOnly", "CustomOnly", or "Both". Filters which template catalog runs. |
CooldownMs |
100 |
Minimum time between consecutive fires from this pad. |
Per-feature toggles (all default false)¶
EnableFourWaySwipes, EnableEightWaySwipes, EnableRadialZones, EnableTouchSpots, EnableTaps, EnableLongPress, EnableTwoFingerSwipes, EnablePinchSpread, EnableRotate, EnableThreeFingerGestures, EnableFourFingerGestures, EnableFiveFingerGestures, EnableShapeGestures, EnableJoystickOutput.
Numeric thresholds¶
| Property | Default | Unit |
|---|---|---|
SwipeDistanceThreshold |
0.15 |
0..1 of pad span |
SwipeTimeWindowMs |
500 |
ms from touchdown |
RadialZoneCount |
8 |
4 / 6 / 8 / 12 (UI restricts) |
RadialCenterDeadzone |
0.30 |
0..1 |
TapTimeWindowMs |
350 |
ms total gesture duration |
TapMaxMotion |
0.04 |
0..1 per-finger max delta |
MultiTapGapMs |
300 |
ms between taps for double / triple counting |
LongPressTimeWindowMs |
500 |
ms hold |
LongPressMaxMotion |
0.05 |
0..1, applied to the bounding-box span of the last 25% of the path |
TwoFingerSwipeAngularTolerance |
25 |
degrees |
PinchThreshold |
0.25 |
relative distance change |
RotateThresholdDegrees |
20 |
absolute rotation |
GestureMatchThreshold |
3.0 |
$Q distance, lower = stricter |
Joystick / D-pad output¶
EnableJoystickOutput, JoystickMaxRadius, JoystickInnerDeadzone, JoystickDPadMode ("Off" / "FourWay" / "EightWay"), JoystickDPadActivationThreshold. Independent of the gesture-engine master toggle so users who want only stick / D-pad output can leave gestures disabled.
Mouse output¶
MouseSensitivityX, MouseSensitivityY, MouseInvertX, MouseInvertY. Per-axis multiplier (both default 1.0, the calibrated baseline where a full horizontal pad sweep moves the cursor about 1920 pixels) and per-axis invert. The Touchpad tab's slider spans 0.05 to 10. Applied when a touchpad-finger source is bound to a KBM virtual controller mouse axis.
The same class carries the rest of the touchpad-to-pointer lane: the trackball momentum family (MouseMomentum, MouseMomentumDecay 0.90, MouseMomentumMaxSpeed, MouseMomentumMinLift 0.286, MouseMomentumFlingGain 1.0, MouseMomentumStacking), MouseJitterReduction (default true), MouseAcceleration, the pointer-response block (PointerResponse default "Simple", TrackpadThresholdMmPerSec 130, TrackpadPadWidthMm 69), the pointer-region block (PointerRegionSizeX/Y 1.0, PointerRegionCenterX/Y 0.5, PointerRegionAuthored, RegionSchema, PointerStretchX/Y), and the swipe-haptics pair (EnableSwipeHaptics, SwipeHapticsIntensity 0.5).
TouchpadSettingsEntry¶
File: PadForge.Engine/Touchpad/TouchpadSettingsEntry.cs
Namespace: PadForge.Engine.Touchpad
XML-serializable wrapper that pairs a TouchpadGestureSettings instance with its (DeviceGuid, TouchpadIndex) key. Lives under PadSetting.TouchpadSettings as a TouchpadSettingsEntry[] so a single slot can carry independent toggles + thresholds for each touchpad surface it sees (DualSense's one pad, a Steam Controller's two pads, a Steam Deck's two pads, plus a PTP system touchpad sharing the slot all at once).
| Property | Type | Serialization | Purpose |
|---|---|---|---|
DeviceGuid |
string |
[XmlAttribute] |
Instance GUID of the device this entry's settings apply to. |
TouchpadIndex |
int |
[XmlAttribute] |
Touchpad index within the device. 0 for single-pad devices, 0..N-1 for multi-pad devices like the original Steam Controller (2 pads) or Steam Deck (2 pads). |
Settings |
TouchpadGestureSettings |
child element | The actual settings bundle. Round-trips its own [XmlAttribute]-tagged fields as nested attributes. Forward-compatible: missing properties take their defaults from TouchpadGestureSettings.Default(). |
The runtime engine reads entries via InputManager.TouchpadGestureSettingsProvider, an instance Func<int, Guid, int, TouchpadGestureSettings> property keyed by (slotIndex, deviceGuid, touchpadIndex). The App layer binds the provider against the active profile's PadSetting.TouchpadSettings collection at engine start.
SwipeHapticsEvaluator¶
File: PadForge.Engine/Touchpad/SwipeHapticsEvaluator.cs
Namespace: PadForge.Engine.Touchpad
(4.1.0, #219) Distance-detent evaluator for swipe-haptic ticks. SwipeHapticsState (same file) is the per-(slot, device, pad) travel accumulator, owned by the polling thread with no cross-thread access. Semantics mirror SteamlessController's trackpad-haptics block (ControllerManager.cpp), the working Steam-Input-feel implementation for the Steam Controller 2026, generalized from its single-finger pads to one accumulator per finger slot (DS4 / DualSense track two fingers).
Update(SwipeHapticsState, TouchpadInputState, float tickDistance = DefaultTickDistance) advances the accumulator with one pad snapshot and returns the number of ticks earned this frame (0 almost always, 1+ while a finger is moving):
- Euclidean finger travel accumulates per frame. One tick per
DefaultTickDistance=5000f / 65536fof the pad span (~0.076, SteamlessController'sTRACKPAD_HAPTIC_TICK_DISTANCEof 5000 raw units on the int16 axis span). Not a user knob. Neither reference exposes it. - A fast frame can earn multiple detents (a while-loop drains the accumulated travel).
- The first frame of a touch seeds the baseline and never ticks. A same-slot contact-ID change reseeds like a fresh touch (PadForge's
TouchpadInputStatetracks contact identity, a deliberate divergence from the reference). - A held pad click follows the finger but never ticks, and reseeds so click travel doesn't convert to a move tick on release.
- A lifted finger drops its accumulator. The next touch starts fresh.
The InputManager walk routes earned ticks to the device's haptic lane: Steam Controller family pads get a per-side actuator tick through HapticToneService.QueueTouchpadPulse (pad 0 = left actuator, pad 1 = right), Sony pads raise a TouchpadPulseService burst that the rumble dispatcher mixes into the motors. Both app-side services are documented on Services Layer.
MouseGestureRecognizer¶
File: PadForge.Engine/Mouse/MouseGestureRecognizer.cs
Namespace: PadForge.Engine.Mouse
Per-button flick recognizer (#200), Logi Options+ semantics generalized to every mouse button. While a selected gesture button is held, that button's own session accumulates raw mouse displacement. At the button's release the net movement classifies: dominant axis at or past the flick threshold fires exactly one of Left / Right / Up / Down for that button, and net motion below the threshold fires that button's Click. Each button is an independent session with its own cooldown, so different gesture buttons can drive different mapping combos. Since 4.1.0, session index 5 is the Custom activation (#216): a recorded cross-device input (a keyboard key, a gamepad button, or an axis past the button threshold) arms it instead of a mouse button, then it classifies identically. ComposePressedMask(mouseButtonsMask, settings, slotIndex) builds the per-tick pressed mask Update consumes: the raw mouse buttons clamped to the five physical indices, plus the Custom bit when the recorded input is held (read through SourceCoercion.ButtonHeldProvider, consulted only when the Custom bit is selected AND a descriptor is recorded, so an unconfigured Custom button stays inert instead of inheriting the engage family's empty-descriptor pass-through).
Entry point¶
public static void Update(
MouseGestureContext ctx,
MouseGestureSettings settings,
int pressedButtonsMask,
double dxCounts,
double dyCounts,
long nowMs)
The mapping descriptor is Mouse Gesture {buttonIndex} {Gesture}, with buttonIndex 0–5 (index 5 = Custom). Fired keys latch in MouseGestureContext.FiredGesturesThisFrame as {buttonIndex} {Gesture} (for example 3 Left) across the cooldown window, mirroring the touchpad lane's indexed-pulse contract. The Keys table precomposes every {buttonIndex} {Gesture} string once, so the 1 kHz path allocates nothing.
Delta source¶
dxCounts / dyCounts arrive as raw counts recovered from the already-published centered axes (screen convention: +X right, +Y down). The recognizer never consumes Raw Input deltas itself. The SdlMouseWrapper consume-and-zero read owns that source, and a second reader would double-drain it.
MouseGestureContext¶
File: PadForge.Engine/Mouse/MouseGestureContext.cs
Namespace: PadForge.Engine.Mouse
Per-(slot, device) recognizer state. Held by the InputManager walk, allocated lazily, and mutated only by MouseGestureRecognizer.Update on the polling thread. MouseButtonCount is 5 (Left / Middle / Right / X1 / X2), the only indices that may be armed from the mouse's own button state. ButtonCount is 6: CustomButtonIndex (5) is the Custom session (#216), armed exclusively from the settings' recorded cross-device descriptor, never from the mouse itself, so a sixth-plus physical mouse button can never bleed into it.
| Field | Type | Purpose |
|---|---|---|
ButtonWasDown |
bool[6] |
Per-button held state last tick, for edge detection. |
AccumDx / AccumDy |
double[6] |
Per-button net displacement accumulated while that button is held, in raw counts. |
CooldownUntilTimestampMs |
long[6] |
Per-button end of the current fired pulse. 0 = none held. |
FiredGesturesThisFrame |
HashSet<string> |
Keys asserted this window in {buttonIndex} {Gesture} form. NOT cleared per tick. A button's keys clear at that button's cooldown expiry or fresh press. |
Reset() zeroes every per-button array and clears the fired set. Called when the settings turn off mid-session so no key sticks.
MouseGestureSettings¶
File: PadForge.Engine/Mouse/MouseGestureSettings.cs
Namespace: PadForge.Engine.Mouse
Per-(slot, device) toggles and thresholds. Every field is an [XmlAttribute] with its default in the initializer. Twin of TouchpadGestureSettings in shape.
| Property | Default | Purpose |
|---|---|---|
Enabled |
false |
Master switch. Off keeps the picker entries and macro triggers visible but inert. |
GestureButtons |
1 << 3 (X1 only) |
Bitmask over raw button indices that arm the recognizer while held: bit 0 Left, bit 1 Middle, bit 2 Right, bit 3 X1, bit 4 X2, plus bit 5 = the Custom activation (#216), armed by the recorded cross-device input below instead of a mouse button. Each selected button runs its own session. Defaults to X1 because v1 does not suppress the button's own click while gesturing. |
CustomEngageButton |
"" |
(4.1.0, #216) Cross-device descriptor that arms the Custom session while held: a keyboard key, a gamepad button, or an axis past the button threshold (a wheel pedal). Recorded through the same freeform recorder the Aim Engage button uses. Empty = the Custom session never arms, even with its bit selected. The engage family's empty-descriptor pass-through convention does NOT apply here (an unconfigured gesture button must stay inert, not always-armed). |
CustomEngageDeviceGuid |
"" |
(4.1.0, #216) Device GUID owning CustomEngageButton. Same cross-device pair shape as PadSetting.GyroAimEngageDeviceGuid. |
FlickThresholdCounts |
150 |
Net displacement (raw counts, dominant axis) the flick must reach by release to classify as a direction. Below it, the release fires Click. |
CooldownMs |
100 |
How long a fired gesture stays asserted so slower consumers (30 Hz recorder, UI) catch the pulse. |
Clone() copies member-by-member. Default() returns a fresh instance.
MouseGestureSettingsEntry¶
File: PadForge.Engine/Mouse/MouseGestureSettingsEntry.cs
Namespace: PadForge.Engine.Mouse
One (device, settings) pair inside PadSetting.MouseGestureSettings. Twin of TouchpadSettingsEntry minus the pad index, since a mouse has one motion surface.
| Property | Type | Serialization | Purpose |
|---|---|---|---|
DeviceGuid |
string |
[XmlAttribute] |
Instance GUID of the device this entry's settings apply to. |
Settings |
MouseGestureSettings |
child element | The settings bundle. Defaults to MouseGestureSettings.Default(). |
MenuDefinitionEntry¶
File: PadForge.Engine/Menus/MenuDefinitionEntry.cs
Namespace: PadForge.Engine.Menus
(4.1.0, #9 B-17) XML-serializable radial / touch menu model. Lives on the slot's MappingSet.Menus as [XmlElement("Menu")] entries, the same slot-scoped home ShiftActivators use, so menus ride profile capture / apply and Workshop materialization through the existing per-slot plumbing. An empty DeviceGuid means "any device on the slot", the device-free form Workshop imports use.
Two enums share the file:
MenuKind:Radial(0) /Grid(1). Values mirror Steam Input's two menu group modes (radial_menu/touch_menu).MenuFireType:Click(0) /ClickRelease(1) /TouchRelease(2) /Always(3). Values are Steam Input's serializedtouchmenu_button_fire_type. Semantics under MenuEvaluator.
All fields are [XmlAttribute] except Items:
| Property | Default | Purpose |
|---|---|---|
DeviceGuid |
"" |
Which assigned device drives the menu. Empty = any device on the slot (Workshop-import form). |
MenuId |
1 |
Stable id unique within the owning slot's menu list. Rides the fired descriptor grammar Menu {MenuId} Item {k}. |
Name |
"" |
Display name. |
Kind |
Radial |
Layout family. |
HostDescriptor |
"Gamepad RightStick" |
The input surface driving the menu: an abstract stick (Gamepad LeftStick / Gamepad RightStick), a touchpad (Touchpad 0..Touchpad 2), or "Custom" with the two raw axis descriptors below. Sticks engage on deflection past EngageDeadzonePercent. Touchpads engage on touch. |
HostHalf |
0 |
Horizontal half-window for single-physical-pad hosts (DS4 / DualSense trackpad halves): 0 = whole surface, 1 = left half, 2 = right half. Sticks are always 0. |
CustomXDescriptor / CustomYDescriptor |
"" |
Raw axis descriptors ("Axis 3", "Slider 0", ...) that steer the menu when HostDescriptor is "Custom". This is what makes joysticks, wheels, and other non-gamepad devices first-class openers, since "Left/Right Stick" means nothing on them. Engage follows the same deadzone rule as sticks. |
ClickDescriptor |
"" |
Assignable Click input for the Click / Click Release fire modes, any button-family descriptor. Empty = the host's default (stick click for stick hosts, pad click for touchpads, none for Custom). |
LayerMask |
"" |
Shift layer the menu belongs to. Empty or "Base" = always available. Anything else engages the menu only while that layer is held (imported mode-shift menus). |
FireType |
Click |
When a hovered item fires. |
CellCount |
4 |
Grid: total cell count (Steam's touch_menu_button_count). Radial: ring slot count, center excluded. |
HasCenter |
false |
Radial only: item index 0 exists as the center cell, selected while the surface rests inside the deadzone. |
ShowLabels |
true |
Overlay: render item labels (Steam touch_menu_show_labels). |
PosXPercent / PosYPercent |
50 / 50 |
Overlay center position as percent of the work area (Steam touch_menu_position_x / _y). |
ScalePercent |
100 |
Overlay size percent (Steam touch_menu_scale). |
OpacityPercent |
90 |
Overlay opacity percent (Steam touch_menu_opacity). |
EngageDeadzonePercent |
25 |
Stick engage / radial center deadzone as percent of full deflection. The default sits between sc-controller's engage (1/3 deflection) and cancel (1/8) thresholds. Imported groups carrying deadzone_inner_radius override it. |
SensitivityPercent |
100 |
In-Menu Sensitivity percent (Steam sensitivity, translator v26): scales the hover vector before selection, so a higher value reaches the ring or crosses the engage deadzone with less deflection. 100 = identity. Absent in older files = 100. |
Enabled |
true |
Per-menu switch. |
LayerHoldsOpen |
false |
(4.5.0, #413) Stay-open mode. When LayerMask names a real layer, engaging that layer opens the menu and leaving it closes the menu, and the host surface only steers the hover. Empty or "Base" cannot hold a menu open, so those fall back to ordinary surface engagement. |
ShowOnWebController |
false |
(#471) Offer this Touch Grid menu to Web Menus phones on the slot, one page per menu. Ignored on a radial menu. Carried by Clone. |
Items |
empty list | [XmlElement("Item")] list of MenuItemDefinition. |
Clone() deep-copies, item list included. Every clone site (profile apply, slot copy, editor round-trips) must use it so item lists never alias.
MenuItemDefinition¶
One cell of a menu, declared in the same file. All eight properties are [XmlAttribute]:
| Property | Purpose |
|---|---|
Index |
Steam-compatible slot index. Grid cells are 0-based positions. Radial index 0 is the center button, 1..N the ring slots clockwise from the top. |
Label |
Display label rendered by the overlay. |
VirtualKey |
Direct key binding: Win32 virtual-key code, 0 = none. |
XboxButtons |
Direct virtual-controller binding: Xbox button bitmask (Gamepad.* constants), 0 = none. |
ExtendedButton |
Direct binding for Extended slots: 1-based raw button number in the slot's custom layout, 0 = none. Extended output is raw HID up to 128 buttons, where an Xbox mask has no meaning. |
MacroName |
(4.4.0, #390) Macro cell: the name of a saved macro on the same slot, triggered while the cell fires. The cell is an additional trigger source, so the macro's own trigger mode keeps its semantics. A While Held macro runs while a Click cell is held, and the one-shot fire types present their commit pulse as one clean edge. A name the slot no longer declares is an inert no-op, the #377 stale-mask convention. Renames retag through the macro name-change hook. Schema append-only, absent in older files = empty. |
Icon |
(4.4.0, #390) Cell icon reference in one of four forms. An emoji or any other single character (#471) is the character itself, one text element under StringInfo (MenuIconResolver.IsGlyph), and draws as text. The three picture forms resolve through the App-side MenuIconResolver in this order: a pficon://Package/entry icon-pack reference, a loose image file path (exe-relative preferred), or a bare Steam binding-icon file name (the binding string's third comma field, "ghost_050_menu_0030.png") resolved against the local Steam client's tenfoot\resource\images\library\controller\binding_icons. The overlay falls back to the text label when nothing resolves. IsValidIconName gates only the Workshop translator's carry, which stores bare *.png names of at most 128 characters with no separators or drive colons and only letters, digits, _, -, and .. |
IconScalePercent |
(4.5.0, #413) Icon size as percent of the menu's normal icon box, default 100. The editor and the overlay clamp it to 25..200, and clearing the icon resets it to 100. Absent in older files = 100. |
Bindings come in two shapes. Imported Workshop menus leave the direct-binding fields at 0 and deliver through mapping rows / macros keyed on the item's fired descriptor (Menu {id} Item {k}). Hand-authored items may instead carry ONE direct binding that the menu runtime fires itself, so authoring a simple item never requires a hidden row.
MenuSelectionMath¶
File: PadForge.Engine/Menus/MenuSelectionMath.cs
Namespace: PadForge.Engine.Menus
Pure selection math for radial / grid menus. Inputs are in the SDL frame both sticks and touchpads use: +X right, +Y down.
| Method | Behavior |
|---|---|
RadialIndexFromVector(dx, dy, ringSlots, hasCenter, deadzone) |
Ring slot for a deflection vector: 0 = center (inside the deadzone, only when hasCenter), -1 = nothing hovered (center without a center item, or no ring slots), 1..N = ring cells clockwise from the top. The wedge math is the shipped #88 radial-zone convention (GestureRecognizer.DetectRadialZones): 0 degrees = up, angles increase clockwise, N equal wedges with wedge k centered at k · 360/N. Steam's radial serialization uses exactly this indexing (touch_menu_button_0 is the center button). |
GridShape(cellCount) |
(Columns, Rows) for a cell count: near-square, wider than tall, matching Steam's rectangular counts (2 = 2x1, 4 = 2x2, 9 = 3x3, 12 = 4x3, 16 = 4x4). Steam's hex arrangements (5 / 7 / 13) render rectangular here with trailing empty cells, a named approximation. |
GridIndexFromPosition(nx, ny, cellCount) |
Cell index for a normalized 0..1 top-left-origin position. Positions past the last (partial) row clamp to the last cell so the whole surface always selects something. Out-of-range counts return -1. |
MenuEvaluator¶
File: PadForge.Engine/Menus/MenuEvaluator.cs
Namespace: PadForge.Engine.Menus
The per-menu commit state machine. MenuRuntimeState (same file) is one menu's runtime state, one instance per (slot, device, menu id), owned by the poll thread: Engaged / Clicked (last-frame edges), PhysicalEngaged (stay-open menus only: the surface was physically engaged last frame while the menu was open), HoveredIndex (-1 = none), AssertedIndex (item held by the hold-shaped fire types), PulsedIndex + PulseUntilMs (item fired by the one-shot fire types), PulseSeq (counts every commit pulse and never resets, so a reader can tell a pulse armed this frame from one still running), StepIndex (button-pair grid stepped selection, −1 = nothing selected yet, persisting across presses), PrevStepMask (previous frame's direction bools for the stepping edge detector, bit 0 up, 1 down, 2 left, 3 right), and Reset().
Update(state, def, surfaceActive, clicked, dx, dy, nx, ny, nowMs) advances one surface-mode menu per poll frame. surfaceActive = physically engaged AND the hosting layer active, so a layer ending lands as a release edge, which is exactly Steam's mode-shift-end commit. ComputeSurfaceActive(def, physical, layerOk) decides whether a menu is open: the layer gate alone for a stay-open menu (LayerHoldsOpen on a real layer, #413), the layer gate AND physical engagement otherwise. Stay-open menus advance through UpdateLayerEngaged(state, def, menuActive, physical, clicked, centerAtRest, dx, dy, nx, ny, nowMs) instead, where Touch Release commits on the physical lift or re-center while the menu stays open, and the layer ending commits only an interaction still in progress. Hover comes from MenuSelectionMath: radial menus use the center-relative deflection (dx, dy), grids the absolute normalized position (nx, ny). Fire-type semantics are grounded on Valve's shipped configurator strings ("Touch Menu Activation Style" / "Radial Menu Button Type"):
| Fire type | Shape | Behavior |
|---|---|---|
Click |
hold | The hovered item is asserted while the hosting surface is clicked (pad click / stick click). |
ClickRelease |
one-shot | The hovered item fires once when the click releases while still engaged. |
TouchRelease |
one-shot | Disengage (touch lift, stick back inside the deadzone, or the hosting layer ending) commits the last hovered item. Disengaging with nothing hovered (dead center, no center item) dismisses silently. |
Always |
hold | The hovered item is asserted the whole time it is hovered. |
One-shot commits pulse for CommitPulseMs = 100 ms, matching the gesture engine's fired-set latch, so downstream readers see a stable fire long enough to pick up the rising edge at any reasonable polling rate. The pulse and the 250 ms stale windows run on MenuClock (PadForge.Engine.Menus), the system tick count unless a test sets its internal Source. IsItemFired(state, index, nowMs) is the read side: true while the item is asserted by a hold-shaped fire type or within a one-shot commit's pulse window.
StepButtonPairGrid(state, def, layerActive, up, down, left, right, nowMs) is the second entry point, for a button-pair grid (a hotbar). Direction presses step a persistent selection instead of composing a hover vector, because four bools can only ever reach a grid's edge cells. Rising Right or Down moves +1, rising Left or Up moves −1, clamped to the cell range, and entering a cell commits it as a one-shot pulse. Selection persists while the menu stays live so repeated presses walk the bar, and layerActive going false freezes it rather than resetting it. A hotbar remembers its position.
The App-side runtime (InputManager.MenuRuntime.cs) ticks these contexts from Step 2 beside the gesture contexts and exposes fires through SourceCoercion.MenuItemFiredProvider, read by mapping rows, shift activators, and macro descriptor triggers. Items carrying a direct binding deliver through CollectMenuDirectOutputs in the Step 4b pass. When a step arms a new pulse (PulseSeq moved) while the menu's own named layer gate is closed, the commit came from that layer ending, and the runtime records it with the layer for the length of the pulse, so the departed layer's rows and macros, and the activators it hosts, read it (see Input Pipeline).
See Also¶
- Architecture Overview: Solution structure, how Engine and App assemblies relate
- Input Pipeline: 6-step pipeline consuming
CustomInputState,Gamepad,PadSetting - SDL3 Integration: SDL3 P/Invoke details,
SdlDeviceWrapperusage, haptic strategies - Virtual Controllers:
IVirtualControllerimplementations consumingGamepad,RawHidState,KbmRawState,MidiRawState,VrRawState - Remote Link Internals: the
PadForge.Engine.RemoteLinknamespace, its codecs, and the crypto handshake - Settings and Serialization:
PadSettingXML persistence,UserDevice/UserSettingserialization, v3.2MappingSet/MappingRow/MappingSource/ShiftActivator/MappingSetMigratorDTOs - HIDMaestro Deep Dive:
HMaestroVirtualControllerlifecycle, FFB through HM PID descriptors, OpenXInput shim
Last updated for PadForge 5.2.0.