Skip to content

Add configurable analog-trigger → stick mapping (closes #1006)#1110

Open
jonnylitten wants to merge 5 commits into
ndeadly:masterfrom
jonnylitten:pr/trigger-stick-mapping
Open

Add configurable analog-trigger → stick mapping (closes #1006)#1110
jonnylitten wants to merge 5 commits into
ndeadly:masterfrom
jonnylitten:pr/trigger-stick-mapping

Conversation

@jonnylitten
Copy link
Copy Markdown

Generalises the proof-of-concept analog-trigger remap discussed in #1006 into a fully configurable feature with per-game and per-controller overrides.

What it does

The existing per-controller HID conversion converts analog triggers into the digital ZR/ZL bits and discards the analog value. Games like Grid Autosport read throttle / brake off the right-stick analog Y axis rather than ZR/ZL, so handing them the digital bit gives on/off throttle with no graduated control. This PR adds an opt-in post-hook to EmulatedSwitchController::UpdateControllerState that maps a controller's raw analog trigger value back onto the right-stick Y axis before the report is dispatched.

Configuration

New [trigger_map] section in missioncontrol.ini:

[trigger_map]
mode = rstick_y_split    ; off | rstick_y_split  (default off)
zr_threshold = off       ; 0..100 or "off" — digital ZR fire threshold while mapping active
zl_threshold = off       ; same for ZL
deadzone = 0             ; 0..100 % on raw trigger
invert_y = false
stick_y_to_buttons_threshold = off  ; 0..100 — repurpose physical stick Y as digital ZR/ZL

Per-game overrides: /config/MissionControl/titles/<16-hex-program-id>.ini. Per-controller: /config/MissionControl/controllers/<lowercase-MAC>.ini. Both directories are re-scanned on every title switch (using the existing mcmitm_process_monitor API) so adding a game profile takes effect on next launch without a sysmodule restart. Precedence: per-title > per-controller > global, profile-level (no field merging).

Architecture

A new TriggerMapper (mc_mitm/source/controllers/trigger_mapper.{hpp,cpp}) holds the resolved-profile state and runs after ProcessInputData in EmulatedSwitchController::UpdateControllerState. Per-controller subclasses opt in by (1) setting m_supports_trigger_map = true in their constructor or Initialize, and (2) populating m_left_trigger_raw / m_right_trigger_raw (u16, 0–0xFFFF) in their MapInputReportXxx from the controller's native trigger format. Subclasses that don't opt in are unaffected — Apply early-returns and the existing ZR/ZL threshold path is unchanged.

Testing status

Hardware-verified on DualShock 4 (via a GameSir G8 Galileo Plus emulating a DS4, in handheld mode). The [trigger_map] section parses correctly and the directory hot-reload behaves correctly across title switches.

Only DS4 is opted in in this PR — happy to fan out to other controllers in a follow-up once the foundation lands, with your preference on which to enable.

Commits

Structured as five reviewable commits: (1) core feature + DS4 wiring + global config, (2) layered per-title/per-controller profiles, (3) stick-Y → digital ZR/ZL repurposing, (4) hot-reload on title switch, (5) README docs.

If you'd prefer narrower scope (e.g. just the core feature for now, follow-ups for layered config and stick-Y), happy to split.

jonnylitten and others added 5 commits May 11, 2026 18:20
Adds a TriggerMapper module that runs after each emulated controller's
ProcessInputData, with a rstick_y_split mode that drives the right-stick
Y axis from the analog triggers (RT -> +Y, LT -> -Y). Per-controller
opt-in via a base-class flag so controllers without analog triggers
aren't affected. DS4 is wired up; other controllers can opt in by
populating m_left/right_trigger_raw and setting m_supports_trigger_map.

The digital ZL/ZR thresholds default to "off" because in this mode the
trigger is exclusively an analog-stick input — Grid Autosport and
Mario Sunshine read the analog axis, not ZR/ZL — but they remain
configurable for users who want e.g. a near-full pull to also fire
the digital button. Configuration lives in a new [trigger_map] section
of missioncontrol.ini.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends TriggerMapper with per-title and per-controller overrides on top of
the global default. Profiles live in:
  sdmc:/config/MissionControl/controllers/<lowercase-mac>.ini
  sdmc:/config/MissionControl/titles/<16-hex-program-id>.ini
each containing the same [trigger_map] section the global config supports.

Resolution is profile-level (not field-merged): per-title beats per-controller
beats global, and the first match is used wholesale. The active title is read
from the existing mcmitm_process_monitor (no new event subscription needed),
so per-title profiles take effect on the next packet after the title switch.

Files in the override directories are scanned eagerly at boot during
LoadConfiguration, with malformed names or unparseable .ini files silently
skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In rstick_y_split mode the Y axis of the right stick is overwritten every
packet by the trigger-derived value, so the physical stick's up/down motion
is otherwise dead input. New stick_y_to_buttons_threshold field captures the
physical Y *before* the overwrite and fires ZR (up past threshold) / ZL (down)
digitally. Defaults off so existing profiles are unaffected; useful in racing
games where you want the same thumb that's holding the throttle stick to also
do gear shifts via stick taps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…title switch

LoadDirectoryProfiles previously ran only at boot, so a new
/config/MissionControl/titles/<program-id>.ini dropped onto the SD card after
the sysmodule was already running had no effect until the next reboot.

This commit adds a dedicated low-priority background thread that waits on the
existing mcmitm_process_monitor process-switch event and re-runs
LoadDirectoryProfiles on each title transition. All filesystem I/O happens on
that thread; the bluetooth input path (TriggerMapper::Apply) only takes the
mutex and reads the resolved profile. State is protected by os::SdkMutex
(matching the rest of the mc.mitm sysmodule's mutex convention).

Hardware-verified on DS4 — clean cold boot, stable operation, and new ini
files dropped onto the SD card while the sysmodule is running are picked up
on the next title switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… overrides

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jonnylitten jonnylitten force-pushed the pr/trigger-stick-mapping branch from d7c80f5 to 185680c Compare May 11, 2026 23:09
@jonnylitten
Copy link
Copy Markdown
Author

jonnylitten commented May 11, 2026

Force-pushed an updated commit for the hot-reload feature — the previous version was buggy, and I want to be thorough about it:

The original hot-reload commit (3cb6497) drove the directory rescan from inside TriggerMapper::Apply, which runs on the bluetooth input thread, and used std::mutex. On hardware that caused intermittent mc_mitm fatal_errors (result code 0xFFD) — both on first cold boot and during normal operation. Reverted on my fork; would not have wanted you to merge it.

The replacement (b639b53) does it the right way:

  • Dedicated low-priority background thread launched from LoadConfiguration via a new TriggerMapper::StartHotReloadThread()
  • The thread waits on mc::GetProcessSwitchEvent() (the existing event you already signal in mcmitm_process_monitor::CheckForProcessSwitch) and re-runs LoadDirectoryProfiles on each title transition
  • All fs::OpenDirectory / fs::ReadDirectory / file parsing happens on that thread — Apply only takes the mutex and reads the resolved profile, no filesystem I/O on the bluetooth input path
  • State is protected by os::SdkMutex to match the rest of the sysmodule

Hardware-verified on DS4 (the same setup as the rest of the PR): clean cold boot, stable through gameplay, and a freshly-dropped titles/<programID>.ini is picked up on the next time you launch that game without any sysmodule restart.

Apologies for the churn during your review window. Rest of the PR (slices 1 / 2 / 2.5 + the README docs commit) is unchanged.

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