Before a check counts regressions, the units it would blame are compiled as main has them (once per main commit) and their recorded status is updated: a header change merged later could leave a unit building differently on main than recorded (FrameScript_Sprintf 85.9% vs 86.1%, Spell_C_NeedsCooldownEvent recorded exact, built at 89.7%), and every recompile of the unit was counted as a regression. A file-local static is resolved by object and name (Foo_Scripts.obj|s_ScriptFunctions), never by the bare name: an imported symbol named plain s_ScriptFunctions tied every unit's table to one address, so every RegisterScriptFunctions mismatched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
6.3 KiB
JavaScript
146 lines
6.3 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { fixture, FN_ADDR, CALLEE_ADDR, GLOBAL_ADDR } from './helpers.js';
|
|
import { PEImage } from '../src/shared/pe.js';
|
|
import { CoffObject } from '../src/shared/coff.js';
|
|
import { parseListing } from '../src/shared/listing.js';
|
|
import { parseAnnotations } from '../src/shared/annotations.js';
|
|
import { compareObject, STATUS, lcsRatio } from '../src/shared/compare.js';
|
|
import { reconcile } from '../src/shared/reconcile.js';
|
|
|
|
const FILE = 'src/player.cpp';
|
|
|
|
function symbols(extra = []) {
|
|
return new Map(
|
|
[
|
|
{ id: 'fn_00001', kind: 'function', addr: FN_ADDR, size: 15 },
|
|
{ id: 'fn_00002', kind: 'function', addr: CALLEE_ADDR, size: 1 },
|
|
{ id: 'gl_00001', kind: 'global', addr: GLOBAL_ADDR, size: 4 },
|
|
...extra,
|
|
].map((s) => [s.id, s]),
|
|
);
|
|
}
|
|
|
|
function run({ objPatch, nameIndex = new Map(), syms = symbols() } = {}) {
|
|
const f = fixture({ objPatch });
|
|
const { annotations } = parseAnnotations(f.source, FILE);
|
|
const result = compareObject({
|
|
obj: new CoffObject(f.obj),
|
|
listing: parseListing(f.listing),
|
|
file: FILE,
|
|
annotations,
|
|
symbols: syms,
|
|
nameIndex,
|
|
pe: new PEImage(f.pe),
|
|
});
|
|
return { result, fn: result.functions[0] };
|
|
}
|
|
|
|
test('PE reader maps addresses and base relocations', () => {
|
|
const pe = new PEImage(fixture().pe);
|
|
assert.equal(pe.imageBase, 0x400000);
|
|
assert.equal(pe.read(FN_ADDR, 3).toString('hex'), '558bec');
|
|
assert.ok(pe.baseRelocations().has(FN_ADDR + 9));
|
|
assert.ok(pe.isCode(FN_ADDR));
|
|
assert.ok(!pe.isCode(GLOBAL_ADDR));
|
|
});
|
|
|
|
test('COFF reader extracts functions and relocations', () => {
|
|
const fns = new CoffObject(fixture().obj).functions();
|
|
assert.equal(fns.length, 1);
|
|
assert.equal(fns[0].name, '?Update@Player@@QAEXXZ');
|
|
assert.equal(fns[0].bytes.length, 15);
|
|
assert.deepEqual(fns[0].relocs.map((r) => [r.offset, r.symbol.name]), [[4, '?Callee@@YAXXZ'], [9, '?g_val@@3HA']]);
|
|
});
|
|
|
|
test('exact match when every referenced symbol is known', () => {
|
|
const { fn } = run({ nameIndex: new Map([['?Callee@@YAXXZ', 'fn_00002'], ['?g_val@@3HA', 'gl_00001']]) });
|
|
assert.equal(fn.status, STATUS.EXACT);
|
|
assert.equal(fn.score, 1);
|
|
assert.equal(fn.symbol, '?Update@Player@@QAEXXZ');
|
|
assert.equal(fn.demangled, 'Player::Update');
|
|
});
|
|
|
|
test('unknown references are resolved through the symbol database addresses', () => {
|
|
const { result, fn } = run();
|
|
assert.equal(fn.status, STATUS.UNRESOLVED);
|
|
const check = { units: [result], annotationErrors: [] };
|
|
const rec = reconcile(check, symbols());
|
|
assert.equal(result.functions[0].status, STATUS.EXACT);
|
|
assert.deepEqual(rec.learned.map((l) => [l.decorated, l.id]).sort(), [['?Callee@@YAXXZ', 'fn_00002'], ['?Update@Player@@QAEXXZ', 'fn_00001'], ['?g_val@@3HA', 'gl_00001']]);
|
|
assert.deepEqual(rec.conflicts, []);
|
|
});
|
|
|
|
test('a reference to data with no symbol stays unresolved and is reported', () => {
|
|
const syms = symbols();
|
|
syms.delete('gl_00001');
|
|
const { result } = run({ syms });
|
|
const rec = reconcile({ units: [result], annotationErrors: [] }, syms);
|
|
assert.equal(result.functions[0].status, STATUS.UNRESOLVED);
|
|
assert.deepEqual(rec.unknown, [{ decorated: '?g_val@@3HA', addr: GLOBAL_ADDR }]);
|
|
});
|
|
|
|
test('calling the wrong function is a mismatch', () => {
|
|
const syms = symbols([{ id: 'fn_00003', kind: 'function', addr: 0x401200, size: 1, decorated: '?Callee@@YAXXZ' }]);
|
|
const { fn } = run({ syms, nameIndex: new Map([['?Callee@@YAXXZ', 'fn_00003']]) });
|
|
assert.equal(fn.status, STATUS.MISMATCH);
|
|
assert.match(fn.relocations[0].reason, /fn_00003/);
|
|
});
|
|
|
|
test('reconcile flags a decorated name that is already bound elsewhere', () => {
|
|
const syms = symbols([{ id: 'fn_00003', kind: 'function', addr: 0x401200, size: 1, decorated: '?Callee@@YAXXZ' }]);
|
|
const { result } = run({ syms });
|
|
const rec = reconcile({ units: [result], annotationErrors: [] }, syms);
|
|
assert.equal(rec.conflicts.length, 1);
|
|
assert.equal(result.functions[0].status, STATUS.MISMATCH);
|
|
});
|
|
|
|
test('byte differences are reported with the instruction at that offset', () => {
|
|
const { fn } = run({ objPatch: (b) => (b[13] = 0x90) });
|
|
assert.equal(fn.status, STATUS.MISMATCH);
|
|
assert.equal(fn.diffRegions.length, 1);
|
|
assert.equal(fn.diffRegions[0].offset, 13);
|
|
assert.equal(fn.diffRegions[0].ours, '90');
|
|
assert.equal(fn.diffRegions[0].original, '5d');
|
|
assert.match(fn.diffRegions[0].ourInstruction, /pop ebp/);
|
|
assert.equal(fn.diffRegions[0].sourceLine, 8);
|
|
assert.ok(fn.score > 0.9 && fn.score < 1);
|
|
});
|
|
|
|
test('size mismatches fall back to a similarity score', () => {
|
|
const syms = symbols();
|
|
syms.get('fn_00001').size = 20;
|
|
const { fn } = run({ syms });
|
|
assert.equal(fn.status, STATUS.MISMATCH);
|
|
assert.ok(fn.score > 0.5 && fn.score < 1);
|
|
});
|
|
|
|
test('annotation with an ID that has no address in this version', () => {
|
|
const syms = symbols();
|
|
syms.get('fn_00001').addr = null;
|
|
const { fn } = run({ syms });
|
|
assert.equal(fn.status, STATUS.NO_ADDRESS);
|
|
});
|
|
|
|
test('lcsRatio', () => {
|
|
assert.equal(lcsRatio(Buffer.from('abcd'), Buffer.from('abcd')), 1);
|
|
assert.equal(lcsRatio(Buffer.from('abcd'), Buffer.from('abxd')), 0.75);
|
|
assert.equal(lcsRatio(Buffer.alloc(0), Buffer.alloc(0)), 1);
|
|
});
|
|
|
|
// Every unit's s_ScriptFunctions is its own table: a file-local static is known by object and name.
|
|
test('a file-local static resolves by its unit, never by the bare name', () => {
|
|
const f = fixture();
|
|
const { annotations } = parseAnnotations(f.source, FILE);
|
|
const obj = new CoffObject(f.obj);
|
|
const real = obj.dataSymbols.bind(obj);
|
|
obj.dataSymbols = () => [...real(), { name: '?g_val@@3HA', section: 2, storageClass: 3 }];
|
|
const syms = symbols([{ id: 'gl_09999', kind: 'global', addr: 0x405000, size: 4, decorated: '?g_val@@3HA' }]);
|
|
const compare = (nameIndex) =>
|
|
compareObject({ obj, listing: parseListing(f.listing), file: FILE, annotations, symbols: syms, nameIndex, pe: new PEImage(f.pe) }).functions[0];
|
|
// Another unit's static of the same name (at another address) does not make this one wrong.
|
|
const other = compare(new Map([['?Callee@@YAXXZ', 'fn_00002'], ['?g_val@@3HA', 'gl_09999']]));
|
|
assert.notEqual(other.status, STATUS.MISMATCH);
|
|
const own = compare(new Map([['?Callee@@YAXXZ', 'fn_00002'], ['?g_val@@3HA', 'gl_09999'], ['src/player.obj|?g_val@@3HA', 'gl_00001']]));
|
|
assert.equal(own.status, STATUS.EXACT);
|
|
});
|