fix: stabilize handheld controller mapping

This commit is contained in:
Alexander Sellite 2026-08-08 14:23:28 -04:00
parent 6fac52dd96
commit 26c9308fcf
3 changed files with 121 additions and 43 deletions

View file

@ -13,19 +13,19 @@ const MAX_PROFILE_BYTES: int = 1024 * 1024
const CAPTURE_AXIS_THRESHOLD: float = 0.55
const CAPTURE_AXIS_RELEASE_THRESHOLD: float = 0.30
const ACTIVE_DEVICE_AXIS_THRESHOLD: float = 0.35
const MUOS_MAPPING_REVISION: String = "muos-v3"
const MUOS_MAPPING_REVISION: String = "muos-v4"
const MUOS_CONTROLLER_NAMES: Array[String] = [
"muOS-Keys",
"Deeplay-keys",
]
const MUOS_MAPPING_BINDINGS: String = (
"a:b4,b:b3,x:b5,y:b6,"
+ "leftshoulder:b7,rightshoulder:b8,"
+ "lefttrigger:b13,righttrigger:b14,"
+ "guide:b11,start:b10,back:b9,"
"a:b0,b:b1,x:b3,y:b2,"
+ "leftshoulder:b4,rightshoulder:b5,"
+ "lefttrigger:b10,righttrigger:b11,"
+ "guide:b8,start:b7,back:b6,"
+ "dpup:h0.1,dpleft:h0.8,dpright:h0.2,dpdown:h0.4,"
+ "leftx:a0,lefty:a1,leftstick:b12,"
+ "rightx:a2,righty:a3,rightstick:b15,platform:Linux,"
+ "leftx:a0,lefty:a1,leftstick:b9,"
+ "rightx:a2,righty:a3,rightstick:b12,platform:Linux,"
)
const ROLE_A: StringName = &"a"
const ROLE_B: StringName = &"b"
@ -739,8 +739,8 @@ static func should_install_muos_compatibility_mapping(
controller_name: String,
is_known: bool,
) -> bool:
# Current SDL databases should remain authoritative. This fallback exists
# only for legacy muOS virtual controllers that SDL cannot identify.
# Never update an already-recognized virtual controller at runtime. Godot
# can stop delivering its standardized input events after that replacement.
return is_muos_controller_name(controller_name) and not is_known

View file

@ -18,6 +18,8 @@ func _run() -> void:
root.add_child(manager)
await process_frame
var failure: String = _validate_manager(manager)
if failure.is_empty():
failure = _validate_portmaster_launcher()
if failure.is_empty():
failure = _validate_auto_map(manager)
if failure.is_empty():
@ -40,19 +42,19 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String:
)
)
for expected_binding: String in [
"a:b4",
"b:b3",
"x:b5",
"y:b6",
"leftshoulder:b7",
"rightshoulder:b8",
"lefttrigger:b13",
"righttrigger:b14",
"back:b9",
"start:b10",
"guide:b11",
"leftstick:b12",
"rightstick:b15",
"a:b0",
"b:b1",
"x:b3",
"y:b2",
"leftshoulder:b4",
"rightshoulder:b5",
"lefttrigger:b10",
"righttrigger:b11",
"back:b6",
"start:b7",
"guide:b8",
"leftstick:b9",
"rightstick:b12",
"leftx:a0",
"righty:a3",
]:
@ -79,7 +81,7 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String:
"muOS-Keys",
true,
):
return "recognized muOS controller would override its SDL mapping"
return "recognized muOS controller would be replaced at runtime"
if ControllerMappingManagerType.should_install_muos_compatibility_mapping(
"ordinary controller",
false,
@ -194,6 +196,30 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String:
return ""
func _validate_portmaster_launcher() -> String:
const launcher_path: String = "res://scripts/portmaster/NETfishing.sh"
if not FileAccess.file_exists(launcher_path):
return "PortMaster launcher template is missing"
var launcher: String = FileAccess.get_file_as_string(launcher_path)
for expected_fragment: String in [
"19004ca6010000000100000000010000",
"19000000010000000100000000010000",
"a:b0,b:b1,x:b3,y:b2",
"leftshoulder:b4,rightshoulder:b5",
"lefttrigger:b10,righttrigger:b11",
"guide:b8,start:b7,back:b6",
"leftstick:b9",
"rightstick:b12",
"netfishing_controllerconfig=\"$GODOT_MUOS_MAPPING\"",
"SDL_GAMECONTROLLERCONFIG=\"$netfishing_controllerconfig\" \\",
]:
if expected_fragment not in launcher:
return "PortMaster launcher omitted " + expected_fragment
if "$'\\n'" in launcher:
return "PortMaster launcher appends mappings across a Weston-unsafe newline"
return ""
func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
var panel := ControllerMappingPanelType.new()
root.add_child(panel)
@ -258,13 +284,35 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
return "manual remapping still dictates a specific physical input"
if "any button" not in panel._progress_label.text.to_lower():
return "manual remapping does not request a generic controller input"
var cancel_button := InputEventJoypadButton.new()
cancel_button.device = manager.get_active_device_id()
cancel_button.button_index = JOY_BUTTON_B
cancel_button.pressed = true
manager.controller_input_observed.emit(cancel_button)
if panel.is_capturing():
return "mapped controller back input did not cancel capture"
panel._cancel_capture()
panel._begin_auto_map()
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
var lone_button := InputEventJoypadButton.new()
lone_button.device = manager.get_active_device_id()
lone_button.button_index = JOY_BUTTON_B
lone_button.pressed = true
manager.controller_input_observed.emit(lone_button)
if not panel._auto_map_active or panel._auto_map_index != 1:
return "a single controller button cancelled auto-map"
panel._cancel_capture()
panel._begin_auto_map()
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
var left_bumper := InputEventJoypadButton.new()
left_bumper.device = manager.get_active_device_id()
left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER
left_bumper.pressed = true
manager.controller_input_observed.emit(left_bumper)
var right_bumper := InputEventJoypadButton.new()
right_bumper.device = manager.get_active_device_id()
right_bumper.button_index = JOY_BUTTON_RIGHT_SHOULDER
right_bumper.pressed = true
manager.controller_input_observed.emit(right_bumper)
panel._process(ControllerMappingPanelType.CANCEL_COMBO_HOLD_SECONDS - 0.01)
if not panel._auto_map_active:
return "bumper combo cancelled auto-map before the hold threshold"
panel._process(0.02)
if panel._auto_map_active or panel.is_capturing():
return "sustained bumper combo did not cancel auto-map"
panel._begin_auto_map()
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
var held_left := InputEventJoypadMotion.new()

View file

@ -8,6 +8,7 @@ const ControllerMappingManagerType = preload(
)
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
const CAPTURE_NEUTRAL_SECONDS: float = 0.22
const CANCEL_COMBO_HOLD_SECONDS: float = 1.25
var _mapping_manager: ControllerMappingManagerType
var _binding_buttons: Dictionary = {}
@ -24,6 +25,9 @@ var _auto_map_draft: Dictionary = {}
var _capture_device_id: int = 0
var _waiting_for_neutral: bool = false
var _neutral_elapsed: float = 0.0
var _cancel_left_bumper_pressed: bool = false
var _cancel_right_bumper_pressed: bool = false
var _cancel_combo_elapsed: float = 0.0
func _ready() -> void:
@ -112,6 +116,14 @@ func _process(delta: float) -> void:
or _capturing_role.is_empty()
):
return
if _cancel_left_bumper_pressed and _cancel_right_bumper_pressed:
_cancel_combo_elapsed += delta
_progress_label.text = "keep holding both bumpers to cancel"
if _cancel_combo_elapsed >= CANCEL_COMBO_HOLD_SECONDS:
_cancel_capture()
_progress_label.text = "controller mapping cancelled"
return
_cancel_combo_elapsed = 0.0
if _waiting_for_neutral:
if not _mapping_manager.are_capture_inputs_neutral(_capture_device_id):
_neutral_elapsed = 0.0
@ -152,18 +164,7 @@ func _on_controller_input_observed(event: InputEvent) -> void:
return
if event_device != _capture_device_id:
return
if (
button_event != null
and button_event.pressed
and _capturing_role != ControllerMappingManagerType.ROLE_B
and _mapping_manager.event_matches_role(
event,
ControllerMappingManagerType.ROLE_B,
)
):
_cancel_capture()
_progress_label.text = "controller mapping cancelled"
return
_track_cancel_combo_button(button_event)
if _waiting_for_neutral:
return
_try_capture_event(event)
@ -332,6 +333,7 @@ func _begin_auto_map() -> void:
_auto_map_active = true
_auto_map_index = 0
_auto_map_draft = {}
_reset_cancel_combo()
_capture_device_id = _mapping_manager.get_active_device_id()
_set_capture_role(ControllerMappingManagerType.ROLE_ORDER[_auto_map_index])
@ -340,6 +342,7 @@ func _begin_manual_capture(role: StringName) -> void:
_auto_map_active = false
_auto_map_index = -1
_auto_map_draft.clear()
_reset_cancel_combo()
_capture_device_id = _mapping_manager.get_active_device_id()
_set_capture_role(role)
@ -356,10 +359,14 @@ func _refresh_capture_prompt() -> void:
if _capturing_role.is_empty():
return
if _auto_map_active:
_progress_label.text = "step %d of %d: %s" % [
_progress_label.text = (
"step %d of %d: %s\n"
+ "hold both bumpers for %.2f seconds to cancel"
) % [
_auto_map_index + 1,
ControllerMappingManagerType.ROLE_ORDER.size(),
_mapping_manager.get_role_prompt(_capturing_role),
CANCEL_COMBO_HOLD_SECONDS,
]
return
var input_instruction: String = "press any controller button"
@ -408,11 +415,34 @@ func _cancel_capture() -> void:
_auto_map_draft.clear()
_waiting_for_neutral = false
_neutral_elapsed = 0.0
_reset_cancel_combo()
_set_action_buttons_disabled(false)
if _progress_label != null:
_progress_label.text = ""
func _track_cancel_combo_button(button_event: InputEventJoypadButton) -> void:
if button_event == null:
return
match button_event.button_index:
JOY_BUTTON_LEFT_SHOULDER:
_cancel_left_bumper_pressed = button_event.pressed
JOY_BUTTON_RIGHT_SHOULDER:
_cancel_right_bumper_pressed = button_event.pressed
_:
return
if not (
_cancel_left_bumper_pressed and _cancel_right_bumper_pressed
):
_cancel_combo_elapsed = 0.0
func _reset_cancel_combo() -> void:
_cancel_left_bumper_pressed = false
_cancel_right_bumper_pressed = false
_cancel_combo_elapsed = 0.0
func _set_action_buttons_disabled(disabled: bool) -> void:
if _auto_map_button != null:
_auto_map_button.disabled = disabled