mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-11-07 13:39:55 +00:00
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:
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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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 مش معروف.');
|
||||
});
|
||||
@@ -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(''));
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
Reference in new issue
Block a user