Merge pull request #11860 from rooklz/fix/input-mapper-settings-roundtrip

fix(tools): round-trip input mapping settings in the input mapper
This commit is contained in:
Dean authored and GitHub committed 2026-11-05 06:36:17 +13:00
commit 9ab6a281ea
6 files changed
+211 -12

No files matched your search

@@ -80,6 +80,9 @@ void settingsParse() {
bool crossOnF = false;
for (const auto& binding : configuration.bindings) crossOnF = crossOnF || (binding.control == Pad::InputControl::Button && binding.button == Pad::PadButton::Cross && binding.key == SDL_SCANCODE_F);
require(crossOnF, "bindings are read alongside settings");
useConfig("LeftStickDeadzone = -0\nRightStickDeadzone = -00\n");
const auto signedZero = Pad::LoadInputMapping();
require(signedZero.settings.leftStickDeadzonePercent == 0 && signedZero.settings.rightStickDeadzonePercent == 0, "a signed zero deadzone reads as 0");
}
void settingsReject() {
+13 -9
View File
@@ -7,7 +7,9 @@ import {createDropdown} from './dropdown.js';
const byId = id => document.getElementById(id);
let entries = [];
let savedConfiguration = '[]';
let settings = [];
const snapshot = () => JSON.stringify([entries, settings]);
let savedConfiguration = snapshot();
let captureTarget;
let captureButton;
let selectedGroup = groups[0].name;
@@ -111,7 +113,7 @@ function element(tag, text, className) {
}
function isDirty() {
return JSON.stringify(entries) !== savedConfiguration;
return snapshot() !== savedConfiguration;
}
async function confirmReplacement() {
@@ -285,7 +287,7 @@ function filterActions() {
function updateOutput() {
const result = validate(entries);
const valid = result.errors.length === 0;
byId('preview').value = valid ? serialize(entries) : '';
byId('preview').value = valid ? serialize(entries, settings) : '';
byId('count').textContent = `${new Set(result.bindings.map(binding => binding.action)).size} / ${actions.length}`;
byId('download').disabled = !valid;
byId('copy').disabled = !valid;
@@ -346,7 +348,8 @@ byId('search').addEventListener('input', filterActions);
byId('reset').addEventListener('click', async () => {
if (!await confirmReplacement()) return;
entries = [];
savedConfiguration = '[]';
settings = [];
savedConfiguration = snapshot();
importReport = undefined;
byId('import-report').hidden = true;
render();
@@ -370,7 +373,8 @@ byId('file').addEventListener('change', async event => {
}
if (!await confirmReplacement()) return;
entries = result.entries;
savedConfiguration = JSON.stringify(entries);
settings = result.settings;
savedConfiguration = snapshot();
render();
setStatus('imported', {name: file.name});
} catch (error) {
@@ -378,7 +382,7 @@ byId('file').addEventListener('change', async event => {
}
});
byId('download').addEventListener('click', () => {
const content = serialize(entries);
const content = serialize(entries, settings);
const url = URL.createObjectURL(new Blob([content], {type: 'text/plain;charset=utf-8'}));
const link = element('a');
link.href = url;
@@ -387,13 +391,13 @@ byId('download').addEventListener('click', () => {
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
savedConfiguration = JSON.stringify(entries);
savedConfiguration = snapshot();
setStatus('downloaded');
});
byId('copy').addEventListener('click', async () => {
try {
const content = serialize(entries);
const copiedConfiguration = JSON.stringify(entries);
const content = serialize(entries, settings);
const copiedConfiguration = snapshot();
await navigator.clipboard.writeText(content);
savedConfiguration = copiedConfiguration;
setStatus('copied');
+5
View File
@@ -1374,6 +1374,11 @@ export function diagnostic(locale, message) {
.replace('This physical key cannot be captured in the current INI format. Select an SDL key from the list.', 'Den här fysiska tangenten kan inte fångas i det aktuella INI-formatet. Välj en SDL-tangent i listan.');
if (locale !== 'ar-EG') return message;
return message
.replace(/(\w+) is set more than once\./gu, '$1 متكرر ومش لازم يتحدد أكتر من مرة.')
.replace(/(\w+) must be a whole percentage from 0 to 90\./gu, '$1 لازم يكون رقم صحيح من 0 لـ 90.')
.replace(/(\w+) must be a number greater than 0 and at most 10\./gu, '$1 لازم يكون رقم أكبر من 0 وبحد أقصى 10.')
.replace(/(\w+) must be a number from ([\d.]+) to ([\d.]+)\./gu, '$1 لازم يكون رقم من $2 لـ $3.')
.replace(/(\w+): unknown input setting\./gu, 'الإعداد $1 مش معروف.')
.replace('Configuration must be a list of bindings.', 'الإعدادات لازم تكون قائمة روابط أزرار.')
.replace('Unknown or empty action.', 'الحركة مش معروفة أو فاضية.')
.replace('Input type must be KEY, MOUSE or WHEEL.', 'نوع الإدخال لازم يكون KEY أو MOUSE أو WHEEL.')
+8
View File
@@ -46,3 +46,11 @@ test('capture diagnostics are fully translated', () => {
assert.equal(diagnostic('sv', message), 'Döda tangenter och kombinationstangenter är tvetydiga. Välj en SDL-tangent i listan.');
assert.equal(diagnostic('ar-EG', message), 'زرار تركيب الحروف مش واضح في التسجيل. اختار زرار SDL من القائمة.');
});
test('Egyptian diagnostics translate setting refusals with their names and ranges', () => {
assert.equal(diagnostic('ar-EG', 'MouseSensitivity is set more than once.'), 'MouseSensitivity متكرر ومش لازم يتحدد أكتر من مرة.');
assert.equal(diagnostic('ar-EG', 'LeftStickDeadzone must be a whole percentage from 0 to 90.'), 'LeftStickDeadzone لازم يكون رقم صحيح من 0 لـ 90.');
assert.equal(diagnostic('ar-EG', 'MouseSensitivity must be a number greater than 0 and at most 10.'), 'MouseSensitivity لازم يكون رقم أكبر من 0 وبحد أقصى 10.');
assert.equal(diagnostic('ar-EG', 'MouseCurve must be a number from 0.2 to 5.'), 'MouseCurve لازم يكون رقم من 0.2 لـ 5.');
assert.equal(diagnostic('ar-EG', 'MadeUp: unknown input setting.'), 'الإعداد MadeUp مش معروف.');
});
+94 -1
View File
@@ -4,7 +4,7 @@ import {readFileSync} from 'node:fs';
import {actions, defaults, mouseValues, padButtons, typesForAction} from '../mapping.js';
import {exportableKeys, keyNames} from '../keys.js';
import {captureKey} from '../capture.js';
import {parseIni, serialize, validate} from '../validation.js';
import {parseIni, serialize, settings, validate, validateSettings} from '../validation.js';
const binding = (action, type = 'KEY', value = 'F') => ({action, type, value});
@@ -32,6 +32,99 @@ test('built-in conflict catalogue agrees with the current native bindings', () =
assert.deepEqual(native, defaults);
});
test('setting names and order agree with the current native parser', () => {
const source = readFileSync(new URL('../../../core/libs/prx/libSceVideoOut/src/InputMapping.cpp', import.meta.url), 'utf8');
const definitions = [...source.matchAll(/InputSettingName\{"(\w+)", InputSetting::\w+\}/gu)].map(match => match[1]);
assert.deepEqual(settings.map(setting => setting.name), definitions);
assert.equal(new Set(definitions).size, definitions.length);
});
test('settings from a documented configuration import and round trip', () => {
const source = '\uFEFF# tuned\r\nCross = KEY:Return\r\nLeftStickDeadzone = 12\r\nMouseSensitivity = 2.50\r\nStickModifierScale = 0.4\r\nTriggerRampMs = 250\r\n';
const imported = parseIni(source);
assert.deepEqual(imported.errors, []);
assert.deepEqual(imported.entries, [binding('Cross', 'KEY', 'Return')]);
assert.deepEqual(imported.settings, [
{name: 'LeftStickDeadzone', value: '12'},
{name: 'MouseSensitivity', value: '2.50'},
{name: 'StickModifierScale', value: '0.4'},
{name: 'TriggerRampMs', value: '250'}
]);
assert.equal(serialize(imported.entries, imported.settings),
'Cross = KEY:Return\nLeftStickDeadzone = 12\nMouseSensitivity = 2.50\nStickModifierScale = 0.4\nTriggerRampMs = 250\n');
assert.equal(serialize(imported.entries), 'Cross = KEY:Return\n');
});
test('settings-only files import without bindings', () => {
const imported = parseIni('MouseCurve = 1.3\nMouseDecayMs = 33\n');
assert.deepEqual(imported.errors, []);
assert.deepEqual(imported.entries, []);
assert.equal(serialize(imported.entries, imported.settings), 'MouseCurve = 1.3\nMouseDecayMs = 33\n');
});
test('setting names canonicalize like the native parser', () => {
const imported = parseIni('leftstickdeadzone = 8\nMOUSESENSITIVITY = 1e0\n');
assert.deepEqual(imported.errors, []);
assert.deepEqual(imported.settings, [{name: 'LeftStickDeadzone', value: '8'}, {name: 'MouseSensitivity', value: '1e0'}]);
});
test('the mouse stick target accepts the native LEFT and RIGHT spellings', () => {
const imported = parseIni('MouseStickTarget = Left\n');
assert.deepEqual(imported.errors, []);
assert.deepEqual(imported.settings, [{name: 'MouseStickTarget', value: 'Left'}]);
assert.equal(serialize(imported.entries, imported.settings), 'MouseStickTarget = Left\n');
assert.deepEqual(validateSettings([{name: 'MouseStickTarget', value: 'RIGHT'}]).settings, [{name: 'MouseStickTarget', value: 'RIGHT'}]);
assert.equal(validateSettings([{name: 'MouseStickTarget', value: 'Both'}]).errors[0].message, 'MouseStickTarget must be LEFT or RIGHT.');
assert.equal(validateSettings([{name: 'MouseStickTarget', value: '1'}]).errors[0].message, 'MouseStickTarget must be LEFT or RIGHT.');
});
test('a signed zero deadzone imports like the native parser', () => {
const imported = parseIni('LeftStickDeadzone = -0\nRightStickDeadzone = -00\n');
assert.deepEqual(imported.errors, []);
assert.deepEqual(imported.settings, [{name: 'LeftStickDeadzone', value: '-0'}, {name: 'RightStickDeadzone', value: '-00'}]);
assert.equal(serialize(imported.entries, imported.settings), 'LeftStickDeadzone = -0\nRightStickDeadzone = -00\n');
});
test('out-of-range and malformed setting values report their line', () => {
const cases = {
'LeftStickDeadzone = 91': 'LeftStickDeadzone must be a whole percentage from 0 to 90.',
'LeftStickDeadzone = 12.5': 'LeftStickDeadzone must be a whole percentage from 0 to 90.',
'LeftStickDeadzone = +12': 'LeftStickDeadzone must be a whole percentage from 0 to 90.',
'LeftStickDeadzone = -1': 'LeftStickDeadzone must be a whole percentage from 0 to 90.',
'MouseSensitivity = 0': 'MouseSensitivity must be a number greater than 0 and at most 10.',
'MouseSensitivity = 10.1': 'MouseSensitivity must be a number greater than 0 and at most 10.',
'MouseSensitivity = abc': 'MouseSensitivity must be a number greater than 0 and at most 10.',
'MouseSensitivity = 1e999': 'MouseSensitivity must be a number greater than 0 and at most 10.',
'MouseSensitivity = 0x1': 'MouseSensitivity must be a number greater than 0 and at most 10.',
'MouseSensitivity = +2.5': 'MouseSensitivity must be a number greater than 0 and at most 10.',
'MouseCurve = 0.1': 'MouseCurve must be a number from 0.2 to 5.',
'MouseDecayMs = 0': 'MouseDecayMs must be a number from 1 to 1000.',
'StickModifierScale = 1.1': 'StickModifierScale must be a number from 0.05 to 1.'
};
for (const [line, message] of Object.entries(cases)) {
const result = parseIni(line);
assert.deepEqual(result.errors, [{line: 1, message}], line);
assert.deepEqual(result.settings, [], line);
}
for (const line of ['MouseSensitivity = 10', 'MouseCurve = 1e0', 'MouseDeadzoneOffset = 0', 'KeyboardRampMs = 2000', 'LeftStickDeadzone = 0', 'RightStickDeadzone = 90']) {
assert.deepEqual(parseIni(line).errors, [], line);
}
});
test('a setting set more than once is refused like the native parser', () => {
const result = parseIni('Cross = KEY:F\nMouseSensitivity = 1\nMouseSensitivity = 2\n');
assert.deepEqual(result.errors, [{line: 3, message: 'MouseSensitivity is set more than once.'}]);
assert.deepEqual(result.settings, [{name: 'MouseSensitivity', value: '1'}]);
});
test('serialize refuses invalid settings it is handed', () => {
for (const configuration of [[{name: 'MouseSensitivity', value: '0'}], [{name: 'MadeUp', value: '1'}], [{name: 'MouseCurve', value: '9'}], [{name: 'MouseCurve', value: '1'}, {name: 'MouseCurve', value: '2'}], null]) {
assert.throws(() => serialize([], configuration));
assert.ok(validateSettings(configuration).errors.length >= 1);
}
assert.deepEqual(validateSettings([{name: 'mousecurve', value: ' 2 '}]), {errors: [], settings: [{name: 'MouseCurve', value: '2'}]});
});
test('every action serializes with a supported key', () => {
const input = actions.map(action => binding(action));
assert.equal(serialize(input), actions.map(action => `${action} = KEY:F\n`).join(''));
+88 -2
View File
@@ -1,6 +1,74 @@
import {actions, canonicalValue, defaults, mouseValues, typesForAction, wheelValues} from './mapping.js';
import {keyNames} from './keys.js';
export const settings = [
{name: 'LeftStickDeadzone', kind: 'percent'},
{name: 'RightStickDeadzone', kind: 'percent'},
{name: 'MouseSensitivity', kind: 'sensitivity'},
{name: 'MouseVerticalScale', minimum: 0.1, maximum: 10},
{name: 'MouseCurve', minimum: 0.2, maximum: 5},
{name: 'MouseDeadzoneOffset', minimum: 0, maximum: 0.9},
{name: 'MouseMaxSpeed', minimum: 0.1, maximum: 10},
{name: 'MouseSmoothingMs', minimum: 0, maximum: 500},
{name: 'MouseDecayMs', minimum: 1, maximum: 1000},
{name: 'StickModifierScale', minimum: 0.05, maximum: 1},
{name: 'KeyboardRampMs', minimum: 0, maximum: 2000},
{name: 'TriggerRampMs', minimum: 0, maximum: 2000},
{name: 'MouseStickTarget', kind: 'stickTarget'}
];
const numberPattern = /^-?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/u;
export function settingValue(setting, value) {
const text = typeof value === 'string' ? value.trim() : '';
if (setting.kind === 'percent') {
if (!/^-?\d+$/u.test(text) || Number(text) < 0 || Number(text) > 90) return `${setting.name} must be a whole percentage from 0 to 90.`;
return '';
}
const parsed = numberPattern.test(text) ? Number(text) : undefined;
if (setting.kind === 'sensitivity') {
if (parsed === undefined || !Number.isFinite(parsed) || !(parsed > 0) || parsed > 10) return `${setting.name} must be a number greater than 0 and at most 10.`;
return '';
}
if (setting.kind === 'stickTarget') {
if (canonicalValue(text, ['LEFT', 'RIGHT']) === undefined) return `${setting.name} must be LEFT or RIGHT.`;
return '';
}
if (parsed === undefined || !Number.isFinite(parsed) || parsed < setting.minimum || parsed > setting.maximum) {
return `${setting.name} must be a number from ${setting.minimum} to ${setting.maximum}.`;
}
return '';
}
export function findSetting(name) {
const canonical = canonicalValue(name, settings.map(setting => setting.name));
return canonical === undefined ? undefined : settings.find(setting => setting.name === canonical);
}
export function validateSettings(configuration) {
const errors = [];
const accepted = [];
const seen = new Set();
if (!Array.isArray(configuration)) return {errors: [{index: 0, message: 'Settings must be a list of names and values.'}], settings: accepted};
for (const [index, item] of configuration.entries()) {
const setting = findSetting(item?.name);
if (setting === undefined) {
errors.push({index, message: `${item?.name ?? 'Setting'}: unknown input setting.`});
continue;
}
const failure = settingValue(setting, item?.value);
if (seen.has(setting.name)) {
errors.push({index, message: `${setting.name} is set more than once.`});
} else if (failure) {
errors.push({index, message: failure});
} else {
seen.add(setting.name);
accepted.push({name: setting.name, value: item.value.trim()});
}
}
return {errors, settings: accepted};
}
export function validate(entries) {
const errors = [];
const warnings = [];
@@ -49,21 +117,28 @@ export function validate(entries) {
return {errors, warnings, bindings};
}
export function serialize(entries) {
export function serialize(entries, configuration = []) {
const result = validate(entries);
if (result.errors.length) throw new Error(result.errors.map(error => error.message).join('\n'));
const checked = validateSettings(configuration);
if (checked.errors.length) throw new Error(checked.errors.map(error => error.message).join('\n'));
const lines = [];
for (const action of actions) {
for (const binding of result.bindings.filter(binding => binding.action === action)) {
lines.push(`${action} = ${binding.type}:${binding.value}`);
}
}
for (const setting of settings) {
const found = checked.settings.find(item => item.name === setting.name);
if (found) lines.push(`${found.name} = ${found.value}`);
}
return lines.length ? `${lines.join('\n')}\n` : '';
}
export function parseIni(text) {
if (typeof text !== 'string') throw new TypeError('INI content must be text.');
const entries = [];
const configuration = [];
const errors = [];
const warnings = [];
const identities = new Set();
@@ -72,6 +147,17 @@ export function parseIni(text) {
if (!content) return;
const separator = content.indexOf('=');
const source = content.slice(separator + 1);
const setting = findSetting(content.slice(0, separator));
if (separator !== -1 && setting !== undefined) {
if (configuration.some(item => item.name === setting.name)) {
errors.push({line: index + 1, message: `${setting.name} is set more than once.`});
} else {
const failure = settingValue(setting, source);
if (failure) errors.push({line: index + 1, message: failure});
else configuration.push({name: setting.name, value: source.trim()});
}
return;
}
const colon = source.indexOf(':');
if (separator === -1 || colon === -1) {
errors.push({line: index + 1, message: 'Expected Action = TYPE:VALUE.'});
@@ -89,5 +175,5 @@ export function parseIni(text) {
else entries.push(binding);
identities.add(identity);
});
return {entries, errors, warnings};
return {entries, settings: configuration, errors, warnings};
}