import type { ControllerAxisBinding, ControllerButtonBinding, ControllerButtonIndicesConfig, ControllerDpadFallback, ResolvedControllerAxisBinding, ResolvedControllerBindingsConfig, ResolvedControllerDiscreteBinding, } from '../../types/runtime'; import { isControllerFaceButtonLabels, isControllerScreenAction, parseControllerDiscreteBinding, parseControllerReviewBinding, type ControllerScreenBindings, type ControllerScreenDefinition, } from '../../shared/controller-review'; import { CONTROLLER_SCREENS } from '../../shared/controller-screens'; import { ResolveContext } from './context'; import { asBoolean, asNumber, asString, isObject } from './shared'; const FACE_BUTTON_LABELS_EXPECTED = "Expected 'xbox', 'nintendo', or 'playstation'."; const CONTROLLER_BUTTON_BINDINGS = [ 'none', 'select', 'buttonSouth', 'buttonEast', 'buttonNorth', 'buttonWest', 'leftShoulder', 'rightShoulder', 'leftStickPress', 'rightStickPress', 'leftTrigger', 'rightTrigger', ] as const; const CONTROLLER_AXIS_BINDINGS = [ 'leftStickX', 'leftStickY', 'rightStickX', 'rightStickY', ] as const; const CONTROLLER_AXIS_INDEX_BY_BINDING: Record = { leftStickX: 0, leftStickY: 1, rightStickX: 3, rightStickY: 4, }; const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record< Exclude, keyof Required > = { select: 'select', buttonSouth: 'buttonSouth', buttonEast: 'buttonEast', buttonNorth: 'buttonNorth', buttonWest: 'buttonWest', leftShoulder: 'leftShoulder', rightShoulder: 'rightShoulder', leftStickPress: 'leftStickPress', rightStickPress: 'rightStickPress', leftTrigger: 'leftTrigger', rightTrigger: 'rightTrigger', }; const CONTROLLER_AXIS_FALLBACK_BY_SLOT = { leftStickHorizontal: 'horizontal', leftStickVertical: 'vertical', rightStickVertical: 'none', } as const satisfies Record; const CONTROLLER_BUTTON_INDEX_KEYS = [ 'select', 'buttonSouth', 'buttonEast', 'buttonNorth', 'buttonWest', 'leftShoulder', 'rightShoulder', 'leftStickPress', 'rightStickPress', 'leftTrigger', 'rightTrigger', ] as const; const CONTROLLER_DISCRETE_BINDING_KEYS = [ 'toggleLookup', 'closeLookup', 'toggleKeyboardOnlyMode', 'mineCard', 'quitMpv', 'previousAudio', 'nextAudio', 'playCurrentAudio', 'toggleMpvPause', 'holdSecondarySub', 'previousEntry', 'nextEntry', 'toggleCharacterNavigation', 'previousSubtitle', 'nextSubtitle', 'replaySubtitle', 'playNextSubtitle', ] as const; const CONTROLLER_AXIS_BINDING_KEYS = [ 'leftStickHorizontal', 'leftStickVertical', 'rightStickVertical', ] as const; const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']); type ControllerBindingsTarget = Required; type ControllerButtonIndicesTarget = Required; function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback { return value === 'none' || value === 'horizontal' || value === 'vertical'; } function resolveLegacyDiscreteBinding( value: ControllerButtonBinding, buttonIndices: Required, ): ResolvedControllerDiscreteBinding { if (value === 'none') { return { kind: 'none' }; } return { kind: 'button', buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]], }; } function resolveLegacyAxisBinding( value: ControllerAxisBinding, slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, ): ResolvedControllerAxisBinding { return { kind: 'axis', axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value], dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], }; } function parseAxisBindingObject( value: unknown, slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, ): ResolvedControllerAxisBinding | null { if (isObject(value) && value.kind === 'none') { return { kind: 'none' }; } if (!isObject(value) || value.kind !== 'axis') return null; if ( typeof value.axisIndex !== 'number' || !Number.isInteger(value.axisIndex) || value.axisIndex < 0 ) { return null; } if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) { return null; } return { kind: 'axis', axisIndex: value.axisIndex, dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], }; } function applyControllerButtonIndices( source: unknown, target: ControllerButtonIndicesTarget, pathPrefix: string, warn: ResolveContext['warn'], ): void { if (!isObject(source)) return; for (const key of CONTROLLER_BUTTON_INDEX_KEYS) { const value = asNumber(source[key]); if (value !== undefined && value >= 0 && Number.isInteger(value)) { target[key] = value; } else if (source[key] !== undefined) { warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.'); } } } function applyControllerBindings( source: unknown, target: ControllerBindingsTarget, buttonIndices: ControllerButtonIndicesTarget, pathPrefix: string, warn: ResolveContext['warn'], ): void { if (!isObject(source)) return; for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) { const bindingValue = source[key]; const legacyValue = asString(bindingValue); if ( legacyValue !== undefined && CONTROLLER_BUTTON_BINDINGS.includes( legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number], ) ) { target[key] = resolveLegacyDiscreteBinding( legacyValue as ControllerButtonBinding, buttonIndices, ); continue; } const parsedObject = parseControllerDiscreteBinding(bindingValue); if (parsedObject) { target[key] = parsedObject; } else if (bindingValue !== undefined) { warn( `${pathPrefix}.${key}`, bindingValue, target[key], "Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis', and an optional button or axis 'hold'.", ); } } for (const key of CONTROLLER_AXIS_BINDING_KEYS) { const bindingValue = source[key]; const legacyValue = asString(bindingValue); if ( legacyValue !== undefined && CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number]) ) { target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key); continue; } if (legacyValue === 'none') { target[key] = { kind: 'none' }; continue; } const parsedObject = parseAxisBindingObject(bindingValue, key); if (parsedObject) { target[key] = parsedObject; } else if (bindingValue !== undefined) { warn( `${pathPrefix}.${key}`, bindingValue, target[key], "Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.", ); } } } function applyControllerScreenBindings( screen: ControllerScreenDefinition, source: unknown, target: ControllerScreenBindings, pathPrefix: string, warn: ResolveContext['warn'], ): void { if (source === undefined) return; if (!isObject(source)) { warn(pathPrefix, source, target, `Expected an object keyed by ${screen.describe} action.`); return; } for (const [action, value] of Object.entries(source)) { if (!isControllerScreenAction(screen, action)) { warn(`${pathPrefix}.${action}`, value, undefined, `Unknown ${screen.describe} action.`); continue; } const parsed = parseControllerReviewBinding(value); if (parsed) { target[action] = parsed; } else { warn( `${pathPrefix}.${action}`, value, target[action], 'Expected {"kind": "none"} or {"press": input, "hold"?: input} with button, axis, face or dpad inputs.', ); } } } export function applyControllerConfig(context: ResolveContext): void { const { src, resolved, warn } = context; if (!isObject(src.controller)) return; const enabled = asBoolean(src.controller.enabled); if (enabled !== undefined) { resolved.controller.enabled = enabled; } else if (src.controller.enabled !== undefined) { warn( 'controller.enabled', src.controller.enabled, resolved.controller.enabled, 'Expected boolean.', ); } const preferredGamepadId = asString(src.controller.preferredGamepadId); if (preferredGamepadId !== undefined) { resolved.controller.preferredGamepadId = preferredGamepadId; } const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel); if (preferredGamepadLabel !== undefined) { resolved.controller.preferredGamepadLabel = preferredGamepadLabel; } const smoothScroll = asBoolean(src.controller.smoothScroll); if (smoothScroll !== undefined) { resolved.controller.smoothScroll = smoothScroll; } else if (src.controller.smoothScroll !== undefined) { warn( 'controller.smoothScroll', src.controller.smoothScroll, resolved.controller.smoothScroll, 'Expected boolean.', ); } const triggerInputMode = asString(src.controller.triggerInputMode); if ( triggerInputMode === 'auto' || triggerInputMode === 'digital' || triggerInputMode === 'analog' ) { resolved.controller.triggerInputMode = triggerInputMode; } else if (src.controller.triggerInputMode !== undefined) { warn( 'controller.triggerInputMode', src.controller.triggerInputMode, resolved.controller.triggerInputMode, "Expected 'auto', 'digital', or 'analog'.", ); } const faceButtonLabels = src.controller.faceButtonLabels; if (isControllerFaceButtonLabels(faceButtonLabels)) { resolved.controller.faceButtonLabels = faceButtonLabels; } else if (faceButtonLabels !== undefined) { warn( 'controller.faceButtonLabels', faceButtonLabels, resolved.controller.faceButtonLabels, FACE_BUTTON_LABELS_EXPECTED, ); } const boundedNumberKeys = [ 'scrollPixelsPerSecond', 'horizontalJumpPixels', 'repeatDelayMs', 'repeatIntervalMs', ] as const; for (const key of boundedNumberKeys) { const value = asNumber(src.controller[key]); if (value !== undefined && Math.floor(value) > 0) { resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key]; } else if (src.controller[key] !== undefined) { warn( `controller.${key}`, src.controller[key], resolved.controller[key], 'Expected positive number.', ); } } const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const; for (const key of deadzoneKeys) { const value = asNumber(src.controller[key]); if (value !== undefined && value >= 0 && value <= 1) { resolved.controller[key] = value as (typeof resolved.controller)[typeof key]; } else if (src.controller[key] !== undefined) { warn( `controller.${key}`, src.controller[key], resolved.controller[key], 'Expected number between 0 and 1.', ); } } applyControllerButtonIndices( src.controller.buttonIndices, resolved.controller.buttonIndices, 'controller.buttonIndices', warn, ); applyControllerBindings( src.controller.bindings, resolved.controller.bindings, resolved.controller.buttonIndices, 'controller.bindings', warn, ); for (const screen of CONTROLLER_SCREENS) { applyControllerScreenBindings( screen, src.controller[screen.configKey], resolved.controller[screen.configKey], `controller.${screen.configKey}`, warn, ); } if (isObject(src.controller.profiles)) { for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) { if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) { warn( `controller.profiles.${profileId}`, rawProfile, undefined, 'Reserved profile id is not allowed.', ); continue; } if (!isObject(rawProfile)) { warn( `controller.profiles.${profileId}`, rawProfile, undefined, 'Expected controller profile object.', ); continue; } const label = asString(rawProfile.label); if (rawProfile.label !== undefined && label === undefined) { warn( `controller.profiles.${profileId}.label`, rawProfile.label, profileId, 'Expected string.', ); } const profile = { label: label ?? profileId, buttonIndices: structuredClone(resolved.controller.buttonIndices), bindings: structuredClone(resolved.controller.bindings), faceButtonLabels: resolved.controller.faceButtonLabels, reviewBindings: structuredClone(resolved.controller.reviewBindings), fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings), }; // Set per controller from Alt+C; falls back to controller.faceButtonLabels. if (isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) { profile.faceButtonLabels = rawProfile.faceButtonLabels; } else if (rawProfile.faceButtonLabels !== undefined) { warn( `controller.profiles.${profileId}.faceButtonLabels`, rawProfile.faceButtonLabels, profile.faceButtonLabels, FACE_BUTTON_LABELS_EXPECTED, ); } applyControllerButtonIndices( rawProfile.buttonIndices, profile.buttonIndices, `controller.profiles.${profileId}.buttonIndices`, warn, ); applyControllerBindings( rawProfile.bindings, profile.bindings, profile.buttonIndices, `controller.profiles.${profileId}.bindings`, warn, ); for (const screen of CONTROLLER_SCREENS) { applyControllerScreenBindings( screen, rawProfile[screen.configKey], profile[screen.configKey], `controller.profiles.${profileId}.${screen.configKey}`, warn, ); } resolved.controller.profiles[profileId] = profile; } } }