UNPKG

@cruncheevos/core

Version:

Parse and generate achievements and leaderboards for RetroAchievements.org

847 lines (846 loc) 30.3 kB
import { ConditionBuilder } from './define.js'; import { formatNumberAsHex, eatSymbols, isObject, invertObject, isNumber, deepFreeze, wrappedError, indexToConditionGroupName, } from './util.js'; function parseUnderflow(num) { const result = num < 0 ? num + 1 + 0xffffffff : num; if (num < -2147483648) { throw new Error(`${num} (${formatNumberAsHex(num)}) underflows into positive ${result} (${formatNumberAsHex(result)}), it's very unlikely you intended for that to happen`); } return result; } const flags = (() => { const forReadingRaw = { '': '', PauseIf: 'P', ResetIf: 'R', ResetNextIf: 'Z', AddHits: 'C', SubHits: 'D', AndNext: 'N', OrNext: 'O', Measured: 'M', 'Measured%': 'G', MeasuredIf: 'Q', Trigger: 'T', }; const forCalcRaw = { AddSource: 'A', SubSource: 'B', AddAddress: 'I', Remember: 'K', }; const toRaw = { ...forReadingRaw, ...forCalcRaw, }; const fromRaw = invertObject(toRaw); delete fromRaw['']; return { forReading: { toRaw: forReadingRaw, }, forCalc: { toRaw: forCalcRaw, }, toRaw, fromRaw, }; })(); const types = (() => { const toRaw = { Mem: '', Delta: 'd', Prior: 'p', BCD: 'b', Invert: '~', }; return { withSize: { toRaw, array: Object.keys(toRaw), fromRaw: invertObject(toRaw), }, withoutSize: { array: ['Value', 'Float', 'Recall'], }, }; })(); const sizesRegular = (() => { const toRaw = { '': '', Bit0: 'M', Bit1: 'N', Bit2: 'O', Bit3: 'P', Bit4: 'Q', Bit5: 'R', Bit6: 'S', Bit7: 'T', Lower4: 'L', Upper4: 'U', '8bit': 'H', '16bit': ' ', '24bit': 'W', '32bit': 'X', '16bitBE': 'I', '24bitBE': 'J', '32bitBE': 'G', BitCount: 'K', }; return { toRaw, fromRaw: invertObject(toRaw), }; })(); const sizesExt = (() => { const toRaw = { Float: 'F', FloatBE: 'B', Double32: 'H', Double32BE: 'I', MBF32: 'M', MBF32LE: 'L', }; return { toRaw, fromRaw: invertObject(toRaw), }; })(); const cmp = { forReading: ['=', '!=', '<', '<=', '>', '>='], forCalc: ['+', '-', '*', '/', '%', '&', '^'], isLegalForReading(cmp) { return typeof cmp === 'string' && this.forReading.includes(cmp); }, isLegalForCalc(cmp) { return typeof cmp === 'string' && this.forCalc.includes(cmp); }, isLegal(cmp) { return this.isLegalForReading(cmp) || this.isLegalForCalc(cmp); }, }; function flagNeedsComparisonOperator(def) { return flags.forReading.toRaw.hasOwnProperty(def.flag); } function flagNeedsCalculationOperator(def) { return flags.forCalc.toRaw.hasOwnProperty(def.flag); } function isBareRecall(def) { return def.lvalue.type === 'Recall' && hasRValueDefined(def) === false; } function isBareMeasured(def) { return def.flag === 'Measured' && hasRValueDefined(def) === false; } function isMeasuredLeaderboardValue(def) { return def.flag === 'Measured' && hasRValueDefined(def) && cmp.isLegalForCalc(def.cmp); } function hasRValueDefined(def) { const hasType = Boolean(def.rvalue.type); const hasSize = Boolean(def.rvalue.size); if (!hasSize && !hasType) { return false; } if (hasSize) { return hasType; } const mustHaveSizeDefined = types.withSize.toRaw.hasOwnProperty(def.rvalue.type); return mustHaveSizeDefined ? hasSize : true; } const validateAndNormalize = { value(value, placement) { if (types.withSize.array.some(x => x === value.type)) { if (Number.isInteger(value.value) === false) { throw new Error(`expected ${placement} memory address as unsigned integer, but got ` + eatSymbols `${value.value}`); } if (value.value < 0 || value.value > 0xffffffff) { throw new Error(`expected ${placement} memory address to be within the range of 0x0 .. 0xFFFFFFFF, but got ` + eatSymbols `${value.value}`); } } else if (value.type === 'Recall') { if (value.value !== 0 && (placement === 'lvalue' ? true : value.size !== undefined)) { throw new Error(`expected Recall ${placement} value to be 0, but got ` + eatSymbols `${value.value}`); } } else if (value.type === 'Value') { if (Number.isInteger(value.value) === false) { throw new Error(`expected ${placement} as integer, but got ` + eatSymbols `${value.value}`); } try { var finalValue = parseUnderflow(value.value); } catch (err) { throw wrappedError(err, `${placement}: ${err.message}`); } // the value cannot be less than 0 due to underflow if (finalValue > 0xffffffff) { throw new Error(`expected ${placement} to be within the range of 0x0 .. 0xFFFFFFFF, but got ` + eatSymbols `${value.value}`); } } else if (value.type === 'Float') { if (Number.isNaN(value.value) || Number.isFinite(value.value) === false) { throw new Error(`expected ${placement} as float, but got ` + eatSymbols `${value.value}`); } const lowerLimit = -294967040; const upperLimit = 4294967040; // TODO: questionable limits, figure out proper ones if (value.value < lowerLimit || value.value > upperLimit) { throw new Error(`expected ${placement} to be within the range of ${lowerLimit} .. ${upperLimit}, but got ` + eatSymbols `${value.value}`); } } else if (value.type !== '') { throw new Error(`expected valid ${placement} type, but got ` + eatSymbols `${value.type}`); } if (types.withoutSize.array.some(x => x === value.type)) { if (value.size) { throw new Error(`${placement} value cannot have size specified, but got ` + eatSymbols `${value.size}`); } } if (value.type === 'Value') { return { ...value, value: parseUnderflow(value.value), }; } else if (value.type === 'Recall' && placement === 'rvalue') { return { ...value, size: value.size === undefined ? '' : value.size, value: value.value === undefined ? 0 : value.value, }; } else { return value; } }, calculations(def) { if (flagNeedsCalculationOperator(def) === false) { return; } if (def.cmp) { if (cmp.isLegalForCalc(def.cmp) === false) { throw new Error(`expected an accumulation operator (${cmp.forCalc.join(' ')}), but got ` + eatSymbols `${def.cmp}`); } if (hasRValueDefined(def) === false) { throw new Error('rvalue must be fully provided if operator is specified'); } def.rvalue = validateAndNormalize.value(def.rvalue, 'rvalue'); } else if (def.cmp === '' && hasRValueDefined(def)) { throw new Error(`expected an accumulation operator (${cmp.forCalc.join(' ')}), but got ""`); } }, memoryComparisons(def) { if (flagNeedsComparisonOperator(def) === false || isBareRecall(def) || isBareMeasured(def) || isMeasuredLeaderboardValue(def)) { return; } def.rvalue = validateAndNormalize.value(def.rvalue, 'rvalue'); if (cmp.isLegalForReading(def.cmp) === false || !def.cmp) { throw new Error(`expected comparison operator (${cmp.forReading.join(' ')}), but got ` + eatSymbols `${def.cmp}`); } }, }; const validate = { enums(def) { if (flags.toRaw.hasOwnProperty(def.flag) === false) { throw new Error(eatSymbols `expected valid condition flag, but got ${def.flag}`); } for (const [value, valueSide] of [ [def.lvalue, 'lvalue'], [def.rvalue, 'rvalue'], ]) { if (value.type === 'Recall') { if (value.size !== '' && (valueSide === 'lvalue' ? true : value.size !== undefined)) { throw new Error(`expected Recall ${valueSide} size to be empty string, but got ` + eatSymbols `${value.size}`); } continue; } if (sizesRegular.toRaw.hasOwnProperty(value.size) === false && sizesExt.toRaw.hasOwnProperty(value.size) === false) { throw new Error(`expected valid ${valueSide} size, but got ` + eatSymbols `${value.size}`); } } if (def.cmp && cmp.isLegal(def.cmp) === false) { throw new Error(eatSymbols `expected an operator or lack of it, but got ${def.cmp}`); } }, hits(def) { if (Number.isInteger(def.hits) === false) { throw new Error(eatSymbols `expected hits as unsigned integer, but got ${def.hits}`); } if (def.hits < 0 || def.hits > 0xffffffff) { throw new Error(`expected hits to be within the range of 0x0 .. 0xFFFFFFFF, but got ${def.hits}`); } if (flagNeedsCalculationOperator(def) && def.hits > 0) { throw new Error(`hits value cannot be specified with ${def.flag} condition flag`); } }, }; const consume = { flag(str) { const match = str.match(regExes.flag); if (!match) { return ['', str]; } const flag = match[1]; if (flags.fromRaw.hasOwnProperty(flag.toUpperCase()) === false) { throw new Error(eatSymbols `expected a legal condition flag, but got ${match[0]}`); } return [flags.fromRaw[flag.toUpperCase()], str.slice(match[0].length)]; }, value(str) { const def = { type: 'Mem', size: '', value: 0, }; let match = null; let integersAllowed = true; if ((match = str.match(regExes.type))) { str = str.slice(match[0].length); def.type = types.withSize.fromRaw[match[1].toLowerCase()]; integersAllowed = false; } if (str.startsWith('{recall}')) { str = str.slice('{recall}'.length); def.type = 'Recall'; } else if ((match = str.match(regExes.valueFloat))) { str = str.slice(match[0].length); def.type = 'Float'; def.value = Number(match[1]); } else if ((match = str.match(regExes.memAddress))) { str = str.slice(match[0].length); if (match[1].toLowerCase() === '0x') { if ((match = str.match(regExes.sizesRegular))) { str = str.slice(match[0].length); def.size = sizesRegular.fromRaw[match[1].toUpperCase()]; } else if (str.match(regExes.hexValue)) { def.size = '16bit'; } else { throw new Error(eatSymbols `expected valid size specifier, but got ${str.slice(0, 6)}`); } } else { if ((match = str.match(regExes.sizesExt))) { str = str.slice(match[0].length); def.size = sizesExt.fromRaw[match[1].toUpperCase()]; } else { throw new Error(eatSymbols `expected valid size specifier, but got ${str.slice(0, 6)}`); } } if ((match = str.match(regExes.hexValue))) { str = str.slice(match[0].length); const value = match[1]; def.value = Number('0x' + value); } else { throw new Error(eatSymbols `expected memory address as hex number, but got ${str.slice(0, 6)}`); } } else if (integersAllowed && (match = str.match(regExes.valueHex))) { str = str.slice(match[0].length); def.type = 'Value'; def.value = parseUnderflow(parseInt(match[1].replace(regExes.hexPrefix, '0x'))); } else if (integersAllowed && (match = str.match(regExes.valueInteger))) { str = str.slice(match[0].length); def.type = 'Value'; def.value = parseUnderflow(Number(match[1])); } else { throw new Error(eatSymbols `expected proper definition, but got ${str.slice(0, 6)}`); } return [def, str]; }, cmp(str) { const match = str.match(regExes.cmp); if (!match) { throw new Error(eatSymbols `expected an operator, but got ${str.slice(0, 6)}`); } return [match[1], str.slice(match[0].length)]; }, hits(str) { const match = str.match(regExes.hits); if (!match) { throw new Error(eatSymbols `expected hits definition, but got ${str}`); } const hitsString = match[1]; if (isNumber(hitsString, { isInteger: true, isPositive: true })) { const hits = Number(hitsString); if (hits > 0xffffffff) { throw new Error(`expected hits to be within the range of 0x0 .. 0xFFFFFFFF, but got ${hitsString}`); } return [hits, str.slice(match[0].length)]; } else { throw new Error(eatSymbols `expected hits as unsigned integer, but got ${hitsString}`); } }, }; function fromString(str) { str = str.trim(); const def = { flag: '', lvalue: { type: '', size: '', value: 0, }, cmp: '', rvalue: { type: '', size: '', value: 0, }, hits: 0, }; [def.flag, str] = consume.flag(str); try { ; [def.lvalue, str] = consume.value(str); } catch (err) { throw wrappedError(err, `lvalue: ${err.message}`); } if (str) { ; [def.cmp, str] = consume.cmp(str); const comparisonOperatorExpected = flagNeedsComparisonOperator(def); const hasValidComparisonOperator = cmp.isLegalForReading(def.cmp); const hasValidOperator = hasValidComparisonOperator || cmp.isLegalForCalc(def.cmp); if (hasValidOperator === false) { if (comparisonOperatorExpected) { throw new Error(`expected comparison operator (${cmp.forReading.join(' ')}), but got ` + eatSymbols `${def.cmp}`); } else { throw new Error(`expected calculation operator (${cmp.forCalc.join(' ')}), but got ` + eatSymbols `${def.cmp}`); } } try { ; [def.rvalue, str] = consume.value(str); } catch (err) { throw wrappedError(err, `rvalue: ${err.message}`); } if (str) { ; [def.hits, str] = consume.hits(str); } // code like A:0xcafe=0 is legal despite comparison making no // sense in case of AddSource, just suppress the operator and rvalue if (comparisonOperatorExpected === false && hasValidComparisonOperator) { def.cmp = ''; def.rvalue = { type: '', size: '', value: 0, }; } } return def; } function conditionValueValueToString(def) { if (def.type === 'Value') { const diff = def.value - 0xffffffff - 1; if (diff >= -0x1000 && diff < 0) { return diff.toString(); } else { return def.value >= 100000 ? formatNumberAsHex(def.value) : def.value.toString(); } } else if (def.type) { const shouldFormatAsHex = def.type !== 'Float' || def.value >= 100000; return shouldFormatAsHex ? formatNumberAsHex(def.value) : def.value.toString(); } } function conditionDataFromArray(def) { const shouldFallbackRValue = def[4] === undefined && def[5] === undefined && def[6] === undefined && def[7] === undefined; return { flag: def[0], lvalue: { type: def[1], size: def[2], value: def[3], }, cmp: shouldFallbackRValue ? '' : def[4], rvalue: { type: shouldFallbackRValue ? '' : def[5], size: shouldFallbackRValue ? '' : def[6], value: shouldFallbackRValue ? 0 : def[7], }, hits: def[8] === undefined ? 0 : def[8], }; } function conditionValueToString(def) { let res = ''; if (def.type === 'Value') { res += def.value; } else if (def.type === 'Float') { res += 'f'; res += def.value; if (Number.isInteger(def.value)) { res += '.0'; } } else if (def.type === 'Recall') { res += '{recall}'; } else { res += types.withSize.toRaw[def.type]; if (sizesExt.toRaw.hasOwnProperty(def.size)) { res += 'f'; res += sizesExt.toRaw[def.size]; } else { res += `0x`; res += sizesRegular.toRaw[def.size]; } res += def.value.toString(16); } return res; } export function validateRegularMeasuredConditions(conditions) { conditions.forEach((group, groupIndex) => { const groupName = indexToConditionGroupName(groupIndex); group.forEach((condition, conditionIndex) => { if (isBareMeasured(condition)) { throw new Error(`${groupName}, condition ${conditionIndex + 1}: cannot have Measured condition without rvalue specified`); } if (isMeasuredLeaderboardValue(condition)) { throw new Error(`${groupName}, condition ${conditionIndex + 1}: expected comparison operator (${cmp.forReading.join(' ')}), but got ` + eatSymbols `${condition.cmp}`); } }); }); } function normalizeMergedValue(value) { if (Array.isArray(value)) { return Object.assign({}, typeof value[0] === 'string' && { type: value[0] }, typeof value[1] === 'string' && { size: value[1] }, typeof value[2] === 'number' && { value: value[2] }); } return value; } /** * This class represents a code piece that can be part of achievements, leaderboards or rich presence for RetroAchievements. * * Conditions are immutable, if you need to a make a new Condition instance based of existing one - use `with()` method. */ export class Condition { constructor(def) { if (def instanceof Condition) { return def; } if (typeof def === 'string') { Object.assign(this, fromString(def)); } else if (Array.isArray(def)) { Object.assign(this, conditionDataFromArray(def)); } else if (isObject(def)) { this.flag = def.flag; this.cmp = def.cmp; this.rvalue = { ...def.rvalue }; this.lvalue = { ...def.lvalue }; this.hits = def.hits; } else { throw new Error(eatSymbols `condition data must be an array, object or string with condition code, but got ${def}`); } validate.enums(this); this.lvalue = validateAndNormalize.value(this.lvalue, 'lvalue'); validateAndNormalize.memoryComparisons(this); validateAndNormalize.calculations(this); validate.hits(this); deepFreeze(this); } /** * Returns new Condition instance with different values merged. * * `lvalue` and `rvalue` can be specified as partial array, which can be less verbose * * @param {DeepPartial<Condition.Data>} data DeepPartial<Condition.Data> * * @example * new Condition('0=1') * .with({ cmp: '!=', rvalue: { value: 47 } }) * .toString() // 0!=47 * * new Condition('0xXcafe=0xXfeed') * .with({ rvalue: ['Delta', '16bit', 0xabcd] }) * .toString() // 0xXcafe=d0x abcd * * new Condition('0xXcafe=0xXfeed') * .with({ rvalue: ['Delta'] }) * .toString() // 0xXcafe=d0xXfeed */ with(data) { return new Condition({ ...this, ...data, lvalue: { ...this.lvalue, ...normalizeMergedValue(data.lvalue) }, rvalue: { ...this.rvalue, ...normalizeMergedValue(data.rvalue) }, }); } /** * Returns string representation of Condition * suitable for RetroAchievements and local files. * @example * new Condition(['ResetIf', 'Mem', 'Bit0', 71, '>', 'Delta', 'Bit1', 71, 3]).toString() // 'R:0xM47>d0xN47.3.' */ toString() { let res = ''; if (this.flag !== '') { res += flags.toRaw[this.flag] + ':'; } res += conditionValueToString(this.lvalue); if (hasRValueDefined(this)) { res += this.cmp; res += conditionValueToString(this.rvalue); if (this.hits) { res += '.' + this.hits + '.'; } } return res; } /** * Returns direct Array representation of Condition, * values are exactly same as properties of Condition. * * @example * new Condition(['Measured', 'Mem', '8bit', 4]).toArray() * // [ "Measured", "Mem", "8bit", 4, "", "", "", 0, 0 ] */ toArray() { return [ this.flag, this.lvalue.type, this.lvalue.size, this.lvalue.value, this.cmp, this.rvalue.type, this.rvalue.size, this.rvalue.value, this.hits, ]; } /** * Returns prettier Array representation of Condition, which is more suitable for display: * * * Everything is a string * * Values are formatted as hexadecimal if they are greater or equal to 100000 * * Negative values are formatted as decimal if they are greater or equal to -4096, otherwise formatted as hexadecimal with underflow correction * * Hits are empty string if equal to zero * * @example * new cruncheevos.Condition(['ResetIf', 'Mem', '32bit', 0xfeedcafe, '>', 'Value', '', 71]).toArrayPretty() * // [ "ResetIf", "Mem", "32bit", "0xfeedcafe", ">", "Value", "", "71", "" ] * * new cruncheevos.Condition(['', 'Value', '', -4097, '>', 'Value', '', -1]).toArrayPretty() * // [ "", "Value", "", "0xffffefff", ">", "Value", "", "-1", "" ] */ toArrayPretty() { const rValueIsRecall = this.rvalue.type === 'Recall'; const rValueIsDefined = hasRValueDefined(this); return [ this.flag, this.lvalue.type, this.lvalue.size, conditionValueValueToString(this.lvalue), this.cmp, rValueIsDefined ? this.rvalue.type : '', // prettier-ignore rValueIsRecall ? '' : rValueIsDefined ? this.rvalue.size : '', // prettier-ignore rValueIsRecall ? '' : rValueIsDefined ? conditionValueValueToString(this.rvalue) : '', this.hits > 0 ? this.hits.toString() : '', ]; } } export function normalizedConditionGroupSetFromString(str, options = {}) { const { considerLegacyValueFormat = false } = options; const conditionStrings = str .split(considerLegacyValueFormat ? '$' : /(?<!0x)S/) .map(group => (group.trim().length > 0 ? group.split('_') : [])); const parseAsLegacy = considerLegacyValueFormat && conditionStrings.every(group => group.every(conditionString => conditionString.match(regExes.flag) === null)); return conditionStrings.map((group, groupIndex) => group.map((conditionString, conditionIndex) => { if (parseAsLegacy) { if (conditionString.match(regExes.legacyTrailingFloat)) { conditionString = conditionString.replace(regExes.legacyTrailingFloat, match => 'f' + match); } const valueMatch = conditionString.match(regExes.legacyValue); if (valueMatch) { const sign = valueMatch[1] || '+'; let value = Number(valueMatch[2]); if (value < 0) { value = parseUnderflow(value); } if (value > 0x7fffffff) { value = 0x7fffffff; } if (sign === '-' && value > 0) { value = -value; } conditionString = value.toString(); } const isLastElement = conditionIndex === group.length - 1; conditionString = (isLastElement ? 'M' : 'A') + ':' + conditionString; } try { return new Condition(conditionString); } catch (err) { const groupName = indexToConditionGroupName(groupIndex); throw wrappedError(err, `${groupName}, condition ${conditionIndex + 1}: ${err.message}`); } })); } export function normalizedConditionGroupSet(def, options = {}) { const res = []; if (typeof def === 'string') { return normalizedConditionGroupSetFromString(def, options); } else if (Array.isArray(def)) { /* core [ condition 1, condition 2 ] */ const subRes = []; for (let i = 0; i < def.length; i++) { const x = def[i]; try { if (x instanceof ConditionBuilder) { subRes.push(...x); } else { subRes.push(new Condition(x)); } } catch (err) { throw wrappedError(err, `conditions[${i}]: ${err.message}`); } } res.push(subRes); } else if (def instanceof ConditionBuilder) { res.push([...def]); } else if (isObject(def)) { let coreDefined = false; const altNumbers = []; for (const key in def) { const match = key.match(/^(?:core|alt([1-9]\d*))$/); if (match) { if (match[0] === 'core') { coreDefined = true; } else if (match[1]) { altNumbers.push(Number(match[1])); } } else { throw new Error(`conditions.${key}: group name must be "core" or "alt1", "alt2"...`); } } if (!coreDefined) { throw new Error(`conditions: expected "core" group`); } altNumbers .sort((a, b) => a - b) .forEach((num, index) => { if (num !== index + 1) { throw new Error(`conditions: expected "alt${index + 1}" group, but got "alt${num}", make sure there are no gaps`); } }); const groups = ['core', ...altNumbers.map(x => `alt${x}`)]; for (const groupName of groups) { const group = def[groupName]; if (typeof group === 'string') { try { res.push(...normalizedConditionGroupSetFromString(group, options)); } catch (err) { throw wrappedError(err, `conditions.${groupName}: ${err.message}`); } } else if (group instanceof ConditionBuilder) { res.push([...group]); } else if (Array.isArray(group)) { const subRes = []; for (let i = 0; i < group.length; i++) { try { const x = group[i]; if (x instanceof ConditionBuilder) { subRes.push(...x); } else { subRes.push(new Condition(x)); } } catch (err) { throw wrappedError(err, `conditions.${groupName}[${i}]: ${err.message}`); } } res.push(subRes); } else { throw new Error(`conditions.${groupName}: expected an array of conditions or string, but got ` + eatSymbols `${group}`); } } } else { throw new Error(eatSymbols `expected conditions as object, array of arrays or string, but got ${def}`); } return res; } const regExes = (() => { const cmps = [...cmp.forCalc, ...cmp.forReading] .sort((a, b) => { return b.length - a.length; }) .map(x => x .split('') .map(x => `\\${x}`) .join('')); return { cmp: new RegExp(`^(${cmps.join('|')})`), flag: /^(.*?):/i, hits: /^\.(.*)\./, hexPrefix: /h/i, hexValue: /^([\dabcdef]+)/i, sizesRegular: new RegExp('^(' + Object.values(sizesRegular.toRaw).filter(Boolean).join('|') + ')', 'i'), sizesExt: new RegExp('^(' + Object.values(sizesExt.toRaw).filter(Boolean).join('|') + ')', 'i'), memAddress: /^(0x|f)/i, type: new RegExp('^(' + Object.values(types.withSize.toRaw).filter(Boolean).join('|') + ')', 'i'), valueHex: /^(-?h[\dabcdef]+)/i, valueInteger: /^(-?\d+)/, valueFloat: /^f(-?\d+\.\d+)/i, legacyTrailingFloat: /(-?\d+\.\d+)$/, legacyValue: /^v([-+])?([-+]?\d+)$/i, }; })();