Bind annotations to functions by their debug-info definition line

The listing assigned each compiled function to the file of its first
source-line marker. When the optimizer schedules code inlined from a
header first (std::map::find, std::string::c_str, ...), that marker is
the header's, so a correctly compiled function was reported as "no
compiled function found after this annotation".

Checks now compile with /Z7, which puts CodeView debug info in the
object without changing codegen, and CoffObject.functionLines() reads
each function's definition file and line from its line table. The
binder uses that location and keeps the listing as the fallback for
objects without debug info; the listing still provides instruction
text and codeHash.
This commit is contained in:
ragnarok-clanker 2026-10-26 11:24:33 +01:00
commit 44df09abbb
5 changed files with 174 additions and 8 deletions

View file

@ -1,11 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fixture, FN_ADDR, CALLEE_ADDR, GLOBAL_ADDR } from './helpers.js';
import { fixture, buildCoff, buildDebugS, 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, sameOriginal } from '../src/shared/compare.js';
import { compareObject, bindAnnotations, STATUS, lcsRatio, sameOriginal } from '../src/shared/compare.js';
import { reconcile } from '../src/shared/reconcile.js';
const FILE = 'src/player.cpp';
@ -122,6 +122,68 @@ test('annotation with an ID that has no address in this version', () => {
assert.equal(fn.status, STATUS.NO_ADDRESS);
});
// The optimizer may schedule code inlined from a header first: the listing then shows the
// header's line before the function's own. Where the function is defined comes from its debug info.
function inlinedHeaderFirst() {
const listing = parseListing(
[
'; File z:\\job\\src\\player.cpp',
'?Update@Player@@QAEXXZ PROC\t\t\t\t; Player::Update, COMDAT',
'; File z:\\toolchain\\include\\xtree',
'; 1597 : _Tree_find_result<_Nodeptr> _Result{};',
' 00000\t8b 49 04\t mov\t ecx, DWORD PTR [ecx+4]',
'; File z:\\job\\src\\player.cpp',
'; 5 : {',
' 00003\tc3\t\t ret\t 0',
'?Update@Player@@QAEXXZ ENDP\t\t\t\t; Player::Update',
'END',
].join('\r\n'),
);
const { annotations } = parseAnnotations(fixture().source, FILE);
return { listing, annotations };
}
test('an annotation binds to its function when inlined header code comes first', () => {
const { listing, annotations } = inlinedHeaderFirst();
const bind = (locations) => bindAnnotations({ listing, file: FILE, annotations, locations });
assert.equal(bind(new Map()).byAnn.size, 0, 'the listing alone puts the function in xtree');
const { byAnn, errors } = bind(new Map([['?Update@Player@@QAEXXZ', { file: 'z:\\job\\src\\player.cpp', line: 4 }]]));
assert.deepEqual(errors, []);
assert.deepEqual([...byAnn.values()], ['?Update@Player@@QAEXXZ']);
});
test('a function inlining code of the annotated file does not take its annotation', () => {
const { listing, annotations } = inlinedHeaderFirst();
// Defined in a header, it contains player.cpp's line 4 only through inlining.
const { byAnn } = bindAnnotations({ listing, file: FILE, annotations, locations: new Map([['?Update@Player@@QAEXXZ', { file: 'z:\\job\\src\\player.h', line: 30 }]]) });
assert.equal(byAnn.size, 0);
});
test('COFF reader takes each function\'s definition line from its CodeView line table', () => {
const debug = buildDebugS([
{ symbolIndex: 1, file: 'z:\\job\\src\\player.cpp', lines: [[0, 4], [3, 6], [8, 7]] },
{ symbolIndex: 2, file: 'z:\\job\\src\\other.cpp', lines: [[0, 12]] },
]);
const obj = new CoffObject(
buildCoff({
sections: [
{ name: '.text$mn', data: Buffer.alloc(16, 0xc3), characteristics: 0x60501020 },
{ name: '.debug$S', data: debug.data, relocs: debug.relocs, characteristics: 0x42100040 },
],
symbols: [
{ name: '.text$mn', section: 1, storageClass: 3 },
{ name: '?Update@Player@@QAEXXZ', section: 1, type: 0x20, storageClass: 2 },
{ name: '?Other@@YAXXZ', section: 1, value: 8, type: 0x20, storageClass: 2 },
],
}),
);
assert.deepEqual(Object.fromEntries(obj.functionLines()), {
'?Update@Player@@QAEXXZ': { file: 'z:\\job\\src\\player.cpp', line: 4 },
'?Other@@YAXXZ': { file: 'z:\\job\\src\\other.cpp', line: 12 },
});
assert.equal(new CoffObject(fixture().obj).functionLines().size, 0, 'no debug info, no locations');
});
test('lcsRatio', () => {
assert.equal(lcsRatio(Buffer.from('abcd'), Buffer.from('abcd')), 1);
assert.equal(lcsRatio(Buffer.from('abcd'), Buffer.from('abxd')), 0.75);

View file

@ -71,6 +71,50 @@ export function buildCoff({ sections, symbols }) {
return Buffer.concat([header, ...secHeaders, ...body, ...symTable, strTable]);
}
/**
* A CodeView (C13) .debug$S section with one line table per function, as /Z7 writes it.
* @param {Array<{symbolIndex: number, file: string, lines: Array<[offset: number, line: number]>}>} functions
* @returns {{data: Buffer, relocs: Array<{offset, symbolIndex, type}>}}
*/
export function buildDebugS(functions) {
const files = [...new Set(functions.map((f) => f.file))];
const strings = Buffer.concat([Buffer.alloc(1), ...files.map((f) => Buffer.from(f + '\0', 'latin1'))]);
const nameOffset = (f) => 1 + files.slice(0, files.indexOf(f)).reduce((n, s) => n + s.length + 1, 0);
// File checksum entries: name offset, checksum size 0, kind 0, padded to 4 bytes.
const checksums = Buffer.concat(files.map((f) => { const b = Buffer.alloc(8); b.writeUInt32LE(nameOffset(f), 0); return b; }));
const fileId = (f) => files.indexOf(f) * 8;
const parts = [Buffer.from([4, 0, 0, 0])];
const relocs = [];
let at = 4;
const subsection = (type, body) => {
const head = Buffer.alloc(8);
head.writeUInt32LE(type, 0);
head.writeUInt32LE(body.length, 4);
const pad = Buffer.alloc((4 - (body.length % 4)) % 4);
parts.push(head, body, pad);
const bodyAt = at + 8;
at += 8 + body.length + pad.length;
return bodyAt;
};
for (const fn of functions) {
const body = Buffer.alloc(12 + 12 + fn.lines.length * 8);
body.writeUInt32LE(Math.max(...fn.lines.map(([o]) => o)) + 1, 8);
body.writeUInt32LE(fileId(fn.file), 12);
body.writeUInt32LE(fn.lines.length, 16);
body.writeUInt32LE(12 + fn.lines.length * 8, 20);
fn.lines.forEach(([offset, line], i) => {
body.writeUInt32LE(offset, 24 + i * 8);
body.writeUInt32LE((line | 0x80000000) >>> 0, 28 + i * 8);
});
const bodyAt = subsection(0xf2, body);
relocs.push({ offset: bodyAt, symbolIndex: fn.symbolIndex, type: 0x0b }, { offset: bodyAt + 4, symbolIndex: fn.symbolIndex, type: 0x0a });
}
subsection(0xf3, strings);
subsection(0xf4, checksums);
return { data: Buffer.concat(parts), relocs };
}
/**
* @param {{imageBase?: number, sections: Array<{name, rva, data, characteristics?}>, baseRelocs?: number[]}} p
* baseRelocs are virtual addresses of HIGHLOW fixups.