Files
BFM-decomp/tools/wave/adapt_wave.js
T
Drew T dcba5d0f4f feat(phase-30 S49): the micro-adapt lane — adapt cards + adapt_wave.js (wave 7a)
- family_cousins.py --adapt-cards: per seeded-unit member, drift classified vs the seed
  (LI-ONLY 27 / SMALL-EDIT 491 / MIXED 310 excluded); cards carry the seed C location + the
  aligned diff blocks with the member's raw words + disasm (the new constant is readable in
  the card). 518 cards / 1,101 instances / 23,820 ins; 514 haiku-band.
- tools/wave/adapt_wave.js: the EDIT-contract wave (crack_wave contracts preserved: per-agent
  dirs, sha1-last, UNVERIFIED != refuted); symbol surface from the TARGET .s; haiku<=60/sonnet.
- regen chain absorbed the 48 lane-A banks (A-prop open ins -6,475 == the report's instr
  delta exactly — two independent derivations agree); pilot slate .run/wave7a_pilot.json (30).
- R37: pilot before scaling to the 518-card pool.
2026-08-12 22:03:04 -06:00

153 lines
8.8 KiB
JavaScript

export const meta = {
name: 'p30-s49-adapt-wave',
description: 'Micro-adapt wave: edit a proven matched body onto a near-identical cousin (LI-ONLY / SMALL-EDIT cards)',
phases: [
{ title: 'Adapt', detail: 'cheap-tier seeded edits — the diff names the <=6 tokens that change' },
{ title: 'Verify', detail: 'independent re-gate + existence proof, sha1 re-checked LAST' },
],
}
// ⚠ TEMPLATE-LITERAL MINEFIELD (same as crack_wave.js): prompts are backtick strings — a markdown
// backtick or a literal dollar-brace in prose TERMINATES the string and the workflow dies at parse
// time. Use "double quotes" for inline code in prose, escape any literal \` and \${.
//
// Cards come from: tools/family_cousins.py --adapt-cards -> .run/adapt_cards.json
// Each card: {name, binary, addr, nins, sub, reach, jr, klass, sim,
// seed:{name,binary,addr,nins,kind,path}, diff:[{kind,member_at,member_words,
// member_disasm,seed_at,seed_words,seed_disasm}], n_blocks, n_tokens}
// Invoke: Workflow({scriptPath:"tools/wave/adapt_wave.js", args:[{wave:"wave7a"}, ...cards]})
const REPO = '/home/musashi/bfm-decomp'
const ALL = typeof args === 'string' ? JSON.parse(args) : args
const CARDS = ALL.filter(c => c.name && c.seed)
const WAVE = (ALL.find(c => c.wave) || {}).wave || 'wave7a'
// The edit is small regardless of body size, but a long body copy still wants the mid tier.
const modelFor = c => (c.nins <= 60 ? 'haiku' : 'sonnet')
const ADAPT_SCHEMA = {
type: 'object',
required: ['fn', 'verdict', 'draft_path', 'notes'],
properties: {
fn: {type: 'string'},
verdict: {type: 'string', enum: ['MATCH', 'NEAR', 'FAIL']},
draft_path: {type: 'string'},
diff_ins: {type: 'integer'},
sha1: {type: 'string', description: 'sha1sum of the draft you are leaving on disk'},
notes: {type: 'string', description: 'the exact edit you made (constant/statement), the symbol renames, integration surface; exact residual if NEAR'},
},
}
const VERDICT_SCHEMA = {
type: 'object',
required: ['fn', 'confirmed', 'evidence'],
properties: {
fn: {type: 'string'},
confirmed: {type: 'boolean'},
file_exists: {type: 'boolean'},
sha1_after: {type: 'string', description: 'sha1sum re-run LAST, after the gate'},
evidence: {type: 'string'},
},
}
function diffBlock(c) {
return c.diff.map((d, i) =>
` block ${i + 1} [${d.kind}] at member instr ${d.member_at} / seed instr ${d.seed_at}:
member words: ${d.member_words.join(' ')} (${d.member_disasm.join(' | ')})
seed words : ${d.seed_words.join(' ')} (${d.seed_disasm.join(' | ')})`).join('\n')
}
function adaptPrompt(c) {
const seedWhere = c.seed.kind === 'macro'
? `${c.seed.path} — the body is the DEFINE_${c.seed.name}() macro (read it there; it is the byte-proven body)`
: `${c.seed.path} — the plain definition of ${c.seed.name}`
return `You are ADAPTING a proven byte-matched body onto a near-identical sibling function — Brave Fencer Musashi matching decomp (PS1, gcc-2.7.2 + maspsx). Repo: ${REPO}. This is an EDIT task, not a from-scratch crack.
TARGET: ${c.name} in ${c.binary} — ${c.nins} instructions${c.jr ? ' (mid-function jr / jump table)' : ''}. Asm truth: ${c.sub}/${c.name}.s
SEED (${(c.sim * 100).toFixed(1)}% skeleton-similar, already byte-matched as ${c.seed.name} in ${c.seed.binary}): ${seedWhere}
THE MEASURED DIFF — the target differs from the seed at exactly ${c.n_blocks} site(s), ${c.n_tokens} instruction(s) total (class ${c.klass}):
${diffBlock(c)}
METHOD:
1. Read the seed body. Copy it to ${REPO}/.run/${WAVE}/${c.name}/${c.name}.c as the definition of ${c.name}.
2. RENAME the symbol surface to the TARGET's own: every callee "func_XXXXXXXX", data "D_XXXXXXXX",
and jump-table label must come FROM THE TARGET .s (${c.sub}/${c.name}.s) — the .s is the symbol
ground truth. Do not keep the seed binary's symbols. Positional correspondence: the bodies align
1:1 outside the diff blocks above.
3. Apply the minimal SEMANTIC edit the diff implies. An insert/delete of lui/ori/addiu is almost
always a changed CONSTANT — decode the target's words above (lui hi16 + ori/addiu lo16) and use
the TARGET's value in the C literal. An inserted load/store or branch token usually means one
added or removed statement/condition. Make the smallest edit that explains the diff.
4. Iterate: cd ${REPO} && python3 tools/match_one.py ${c.name} --c .run/${WAVE}/${c.name}/${c.name}.c --asm-subdir ${c.sub}
5. On MATCH: sha1sum the draft, report it, STOP. If two honest attempts leave a residual, report
NEAR with the exact remaining diff — NEAR is a resumable state, not a failure.
OUTPUT DISCIPLINE (a previous wave lost 21 verified drafts to a shared directory):
- Your ONLY output dir is ${REPO}/.run/${WAVE}/${c.name}/ — create it, work there.
- Final draft MUST be exactly ${REPO}/.run/${WAVE}/${c.name}/${c.name}.c
- NEVER delete, move or modify ANY file outside your own directory. Sibling agents are working in
adjacent directories RIGHT NOW. Do NOT clean up at the end.
- Never write to src/, config/, include/ or any tracked file. No make. No git.
RULES: gcc-2.7.2 emits no "error:" prefix — read the raw compiler text. match_one MATCH is a
CANDIDATE (cookbook §52b) — state your integration surface in notes: which callees/globals you
declare and whether the host TU declares them differently (§161c). Match register allocation
exactly (G3). If the seed body is a DEFINE_ macro, expand it to a plain function definition in your
draft — do not emit a macro.`
}
function verifyPrompt(r, c) {
return `ADVERSARIAL verification, Brave Fencer Musashi decomp. Repo: ${REPO}.
${r.fn} (${c.binary}, ${c.nins} ins, ADAPTED from ${c.seed.name}) claimed MATCH at ${r.draft_path}; claimed sha1 ${r.sha1 || '(none)'}.
REFUTE it. Default confirmed=false. In this order:
1. "ls -l" and "sha1sum" the draft — it must EXIST.
2. Gate it yourself: cd ${REPO} && python3 tools/match_one.py ${r.fn} --c ${r.draft_path} --asm-subdir ${c.sub}
("FAIL: no C file" means the draft is MISSING — that is a refutation, never a pass.)
3. The draft must DEFINE ${r.fn} as a plain function (no INCLUDE_ASM, no macro emission, no inline
asm standing in for compiled code), and its symbol surface must come from the TARGET .s, not the
seed binary (spot-check two callee/data names against ${c.sub}/${r.fn}.s).
4. Re-run "sha1sum" LAST, after everything else, and report it as sha1_after. If the file vanished
or changed since step 1, say so loudly.
Do NOT edit or delete anything. Report failures; do not fix them.`
}
phase('Adapt')
const results = await pipeline(
CARDS,
c => agent(adaptPrompt(c), {label: `adapt:${c.name}`, phase: 'Adapt', model: modelFor(c), schema: ADAPT_SCHEMA})
.then(r => (r ? {...r, c} : null)),
r => {
if (!r || r.verdict !== 'MATCH' || !r.draft_path) return r
return agent(verifyPrompt(r, r.c), {label: `verify:${r.fn}`, phase: 'Verify', schema: VERDICT_SCHEMA})
.then(v => ({...r, verdict_check: v}))
}
)
const rows = results.filter(Boolean)
// A DEAD ORACLE IS NOT A NEGATIVE VERDICT (P30 S48): UNVERIFIED is its own outcome — re-verify,
// never bank, never discard.
const claimed = rows.filter(r => r.verdict === 'MATCH')
const unverified = claimed.filter(r => !r.verdict_check)
const confirmed = claimed.filter(r => r.verdict_check && r.verdict_check.confirmed
&& r.verdict_check.file_exists !== false)
const refuted = claimed.filter(r => r.verdict_check && !confirmed.includes(r))
const near = rows.filter(r => r.verdict === 'NEAR')
const fail = rows.filter(r => r.verdict === 'FAIL')
log(`adapt wave: confirmed ${confirmed.length} / refuted ${refuted.length} / UNVERIFIED ${unverified.length}`
+ ` / near ${near.length} / fail ${fail.length} of ${rows.length}`)
return {
counts: {cards: CARDS.length, returned: rows.length, confirmed: confirmed.length,
refuted: refuted.length, unverified: unverified.length,
near: near.length, fail: fail.length},
unverified: unverified.map(r => ({fn: r.fn, binary: r.c.binary, draft_path: r.draft_path,
sha1: r.sha1, note: 'VERIFIER NEVER RAN — re-verify, do not discard'})),
confirmed: confirmed.map(r => ({fn: r.fn, binary: r.c.binary, reach: r.c.reach, nins: r.c.nins,
klass: r.c.klass, seed: r.c.seed.name, model: modelFor(r.c),
draft_path: r.draft_path, sha1: r.sha1, notes: r.notes})),
refuted: refuted.map(r => ({fn: r.fn, draft_path: r.draft_path,
evidence: r.verdict_check && r.verdict_check.evidence})),
near: near.map(r => ({fn: r.fn, binary: r.c.binary, diff_ins: r.diff_ins, notes: r.notes,
draft_path: r.draft_path})),
fail: fail.map(r => ({fn: r.fn, binary: r.c.binary, notes: r.notes})),
}