clank-together/src/shared/annotations.js
Saty f542ad5cb2 Fixes from the second contributor report
- Static gate: a file main already holds may grow 128 KB beyond its size on
  main (Unit_C.cpp is 796 KB, Player_C.cpp 491 KB: no edit to them could
  pass the 256 KB limit); only what a submission adds counts toward the total.
- A peer-confirmed function in another unit may move closer to the original
  (a header layout fix); only moving away from it is a regression.
- A function main no longer annotates anywhere (fn_07162, removed from stpl.h
  in 0f9ebdb4 while its record still said exact) is not "removed" by every
  later submission of the unit it was recorded at.
- A library function main already annotates in a file (fn_25851 in
  BSNEncodeInterface.h) no longer refuses every edit of that file; adding
  such an annotation still is refused.
- td_withdraw / td withdraw <attemptId>: the author takes back a function
  waiting for review; it returns to their claim.
- td_submit says when held functions have no changed code in the working
  tree, instead of "you hold no tasks".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:37:10 +02:00

147 lines
6.1 KiB
JavaScript

// Parser for symbol annotations in decomp source files.
//
// Annotations carry a stable symbol ID, never an address. The coordinator's
// symbol database maps each ID to its address in every supported version of
// the target executable, so moving to a new target version never requires
// re-tagging the source.
//
// // FUNCTION: fn_00123 decompiled (or attempted) function
// // STUB: fn_00124 placeholder, not yet decompiled
// // GLOBAL: gl_00045 global or static variable
// // VTABLE: vt_00007 vtable of the following class
// // STRING: st_00310 string literal
// // SIZE: 0x24 expected sizeof() of the following class/struct
//
// An annotation applies to the next line of code that is not blank, not a
// comment and not another annotation. Several annotations may stack on one
// definition when one source body stands for several functions of the binary
// (template instances, identical copies). Text after the ID is a detail; for
// templates it names the instance: `// FUNCTION: fn_01234 <CAnimKitInst>`.
export const KIND_PREFIX = { FUNCTION: 'fn', STUB: 'fn', GLOBAL: 'gl', VTABLE: 'vt', STRING: 'st' };
export const PREFIX_KIND = { fn: 'function', gl: 'global', vt: 'vtable', st: 'string' };
export const FUNCTION_KINDS = ['FUNCTION', 'STUB'];
const ANNOTATION_RE = /^\s*\/\/\s*(FUNCTION|STUB|GLOBAL|VTABLE|STRING|SIZE)\s*:\s*(.*?)\s*$/;
const ID_RE = /^(fn|gl|vt|st)_([0-9]{5,})$/;
const SIZE_BODY_RE = /^0x([0-9a-fA-F]{1,8})$/;
// Lines that look like an annotation attempt (a keyword followed by an ID or an address), so typos
// are reported instead of ignored. Ordinary comments ("// Function declarations") are not.
const LOOKS_LIKE_RE = /^\s*\/\/\s*(FUNCTION|STUB|GLOBAL|VTABLE|STRING|SIZE|LIBRARY)\s*:?\s*((fn|gl|vt|st)_\d+|0x[0-9a-f]+)/i;
export function isSymbolId(s) {
return ID_RE.test(s);
}
export function formatSymbolId(prefix, n) {
return `${prefix}_${String(n).padStart(5, '0')}`;
}
/**
* @param {string} text file contents
* @param {string} file repo-relative path, used in results and errors
* @returns {{annotations: Array<object>, errors: Array<object>}}
*/
export function parseAnnotations(text, file = '<input>') {
const lines = text.split(/\r?\n/);
const annotations = [];
const errors = [];
let pending = [];
let inBlockComment = false;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const lineNo = i + 1;
if (inBlockComment) {
if (raw.includes('*/')) inBlockComment = false;
continue;
}
const m = ANNOTATION_RE.exec(raw);
if (m) {
const [, kind, body] = m;
if (kind === 'SIZE') {
const s = SIZE_BODY_RE.exec(body);
if (!s) errors.push({ file, line: lineNo, message: `malformed SIZE annotation: "${raw.trim()}"` });
else pending.push({ kind, size: parseInt(s[1], 16), file, line: lineNo });
} else {
const [token, ...rest] = body.split(/\s+/);
const id = ID_RE.exec(token);
if (!id) {
errors.push({ file, line: lineNo, message: `malformed ${kind} annotation, expected "// ${kind}: ${KIND_PREFIX[kind]}_00000" (a symbol ID, not an address): "${raw.trim()}"` });
} else if (id[1] !== KIND_PREFIX[kind]) {
errors.push({ file, line: lineNo, message: `${kind} annotation needs a ${KIND_PREFIX[kind]}_ ID, got ${token}` });
} else {
pending.push({ kind, id: token, detail: rest.join(' ') || null, file, line: lineNo });
}
}
continue;
}
if (LOOKS_LIKE_RE.test(raw)) {
errors.push({ file, line: lineNo, message: `unrecognized annotation (keywords are upper case, followed by ':' and a symbol ID): "${raw.trim()}"` });
continue;
}
const trimmed = raw.trim();
if (trimmed === '' || trimmed.startsWith('//')) continue;
if (trimmed.startsWith('/*')) {
if (!trimmed.includes('*/')) inBlockComment = true;
continue;
}
if (pending.length) {
for (const p of pending) {
p.targetLine = lineNo;
p.targetText = trimmed;
if (p.kind === 'GLOBAL' || p.kind === 'STRING') p.name = declaredName(trimmed);
annotations.push(p);
}
pending = [];
}
}
for (const p of pending) {
errors.push({ file, line: p.line, message: `${p.kind} annotation is not followed by any code` });
}
return { annotations, errors };
}
/** Best-effort name of the variable declared on a line like `static int g_count = 0;` */
export function declaredName(line) {
let s = line.replace(/\/\/.*$/, '');
const cut = s.search(/[=;{(]/);
if (cut >= 0) s = s.slice(0, cut);
s = s.replace(/\[[^\]]*\]/g, '').trim();
const m = /([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_~][A-Za-z0-9_]*)*)\s*$/.exec(s);
return m ? m[1] : null;
}
/**
* Checks a whole-repo annotation set against the symbol database.
* @param {Array<object>} annotations
* @param {Map<string, {kind: string, library?: boolean}>} [symbols] ID -> symbol record
*/
export function lintAnnotations(annotations, symbols) {
const errors = [];
const seen = new Map();
for (const a of annotations) {
if (a.kind === 'SIZE') continue;
const prev = seen.get(a.id);
// One function ID on several definitions is legitimate: the original linker folded identical
// functions into one. One data ID twice is suspicious, but only a warning.
if (prev && !FUNCTION_KINDS.includes(a.kind)) errors.push({ file: a.file, line: a.line, severity: 'warning', message: `${a.id} is also annotated at ${prev.file}:${prev.line}` });
else if (!prev) seen.set(a.id, a);
if (symbols) {
const sym = symbols.get(a.id);
if (!sym) errors.push({ file: a.file, line: a.line, message: `${a.id} does not exist in the symbol database (request a new ID with \`td symbol new\`)` });
else if (sym.library) errors.push({ file: a.file, line: a.line, id: a.id, rule: 'library', message: `${a.id} is marked as library code and must not be decompiled` });
}
}
return errors;
}
/** Hex without prefix; works for 64-bit image addresses (anything below 2^53). */
export function hex(n, width = 8) {
return Number(n).toString(16).padStart(width, '0');
}