Skip to content

feat: mapping wizard for controllers without a known profile - #121

Merged
nextestudios merged 1 commit into
mainfrom
feature/79-mapping-wizard
Sep 27, 2026
Merged

nextestudios merged 1 commit into
mainfrom
feature/79-mapping-wizard

Conversation

@nextestudios

Copy link
Copy Markdown
Owner

Summary

Joysticks that SDL doesn't recognize as gamepads were detected but couldn't drive the UI. They can now be mapped with a wizard that the device itself drives, with a keyboard fallback.

  • SDL backend: raw joystick button, hat and axis events for non-gamepad joysticks only (IInputSink.OnRawInput, a default interface method so the probe still compiles), plus GetRawState for neutral calibration.
  • Core (Input/Mapping):
    • ControllerMappingWizard state machine. Steps: neutral → up/down/left/right → confirm/back (follows "Confirm with") → optional buttons → test.
      • Neutral: 1 s with everything released. It measures each axis's rest position and noise and derives a deadzone between 0.2 and 0.6. An axis resting at -1, like a trigger, works as a button.
      • Inversion: stored as the axis direction.
      • One press per step: the pad must be released before the next step, and an input that's already bound is refused.
      • The pad's own Back button skips optional steps.
      • You can redo the previous step, or any step from the test screen.
      • 20 s without input (60 s in test mode) cancels safely.
    • ControllerProfileTranslator: calibrated neutral, hysteresis, hat diagonals ignored.
    • ControllerProfileSerializer: versioned JSON (format + schemaVersion). This is the security boundary:
      • at most 64 KB (streams are never read past the limit) and depth 4
      • only known fields are accepted (no extra, executable or path content), and duplicate fields are refused
      • enum names only (no numeric values), ranges checked
      • all required controls must be present, and no raw input can be bound twice
      • hidden/control characters are refused in names
  • Store: IControllerProfileStore / JsonControllerProfileStore keeps one file per device GUID (or VID/PID) under <data>\controllers. Writes are atomic (temp file, flush, move) and keep a .bak. Invalid files are skipped and reported. Also export to a folder and import from a folder.
  • Application:
    • Two ways to start the wizard: hold any button on an unmapped joystick for 2 s, or Menu → "Controles sem perfil…". The same menu also imports and exports profiles.
    • Test mode: the draft mapping already drives the Save / Redo / Cancel screen. Nothing is written before Save.
    • An existing profile is replaced only after a confirmation dialog that starts on "Cancelar". Cancel, timeout and disconnect never touch saved profiles.
    • Profiles load at startup and apply by GUID first, then by VID/PID.
  • App: InputHost applies saved profiles through the translator into the normal InputRouter path. ModalView shows the wizard.
  • Docs: GUIDE (both languages), CHANGELOG (both languages), input-and-focus.md, controller-compatibility.md, TESTING.md, test plan and ADR 0002 note.

Tested in CI vs. manual

CI (unit tests, simulated raw events only):

  • ControllerMappingWizardTests: full hat and button mapping, hold-release per step, refused reused input, skipping with Back, test-mode translation, axis neutral/deadzone calibration with an inverted axis and a trigger at rest, redo, timeout, and the translator's hysteresis and hat diagonals.
  • ControllerProfileSerializerTests (security boundary): round trip, malformed or unexpected payloads (unknown/duplicate field, newer schema, missing required control, duplicate input, numeric enum, out-of-range values, bad GUID, excessive depth), and an endless stream that is cut off at the limit.
  • ControllerMappingJourneyTests: a joystick mapped by itself, saved and used after a restart (a new store on the same folder); an existing profile kept on cancel and on a cancelled replace, then replaced with a .bak; a tampered import refused; a valid export imported elsewhere.

Not verified: real hardware. No generic USB joystick was available, so SDL's raw events on Windows, real hats and axes, and restart with a physical device are unverified. The checklist is in docs/TESTING.md ("Joystick sem perfil") and the matrix row in docs/controller-compatibility.md is still "não testado". For that reason this PR uses Refs rather than closing the issue.

Follow-up (not in this PR): a pad with only a D-pad and two buttons can't open Actions or Menu unless those optional steps are mapped. The wizard puts them first among the optional steps and the guide recommends mapping them.

Refs #79

🤖 Generated with Claude Code

Raw joystick buttons, hats and axes from SDL drive a Core wizard (neutral
calibration, deadzone, inverted axes, redo, timeout, safe cancel, test mode)
that produces a versioned JSON profile. Profiles are validated on read
(size, depth, known fields, ranges), saved atomically with a backup, applied
by device GUID or VID/PID after restart, and can be exported/imported.
Replacing an existing profile always asks first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nextestudios
nextestudios force-pushed the feature/79-mapping-wizard branch from ad0896a to 7f8b6f0 Compare September 27, 2026 03:13
@nextestudios
nextestudios merged commit 172f596 into main Sep 27, 2026
1 check passed
@nextestudios
nextestudios deleted the feature/79-mapping-wizard branch September 27, 2026 03:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant