romdevtools 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/package.json +1 -1
- package/src/analysis/recompile/emit-65816.js +105 -0
- package/src/analysis/recompile/emit-m68k.js +295 -0
- package/src/analysis/recompile/index.js +169 -0
- package/src/analysis/recompile/ir.js +123 -0
- package/src/analysis/recompile/lift-6502.js +176 -0
- package/src/analysis/recompile-65816.js +15 -66
- package/src/mcp/tools/cheats.js +73 -4
- package/src/mcp/tools/disasm.js +46 -26
- package/src/mcp/tools/index.js +12 -2
- package/src/mcp/tools/watch-memory.js +93 -20
- package/src/toolchains/cc65/cc65.js +11 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Generic recompile IR — the spine of the source/target-agnostic port engine.
|
|
2
|
+
//
|
|
3
|
+
// The port engine has two halves: a LIFTER turns one source-CPU instruction into
|
|
4
|
+
// IR node(s); an EMITTER turns IR node(s) into target-CPU assembly. Adding a
|
|
5
|
+
// platform PAIR is then one lifter + one emitter (registered by ISA name), not an
|
|
6
|
+
// N×N matrix of hand-written transpilers. NES→SNES is the first pair; the IR is
|
|
7
|
+
// what lets NES→Genesis (and any other pair whose ISAs have a lifter+emitter)
|
|
8
|
+
// reuse the SAME pipeline instead of a second bespoke recompiler.
|
|
9
|
+
//
|
|
10
|
+
// DESIGN PRINCIPLE: the IR is DELIBERATELY SMALL and HONEST. It models only the
|
|
11
|
+
// instruction classes a mechanical port actually needs, and REFUSES (rather than
|
|
12
|
+
// guesses) anything whose semantics don't carry across ISAs. A refused node is
|
|
13
|
+
// surfaced as residue, never silently mistranslated — the same contract the
|
|
14
|
+
// original 6502→65816 recompiler held. We are NOT building a full optimizing
|
|
15
|
+
// compiler IR (SSA, types, dataflow); we're building a normalized transfer format
|
|
16
|
+
// for "this instruction loads/stores/branches/calls/touches-hardware".
|
|
17
|
+
//
|
|
18
|
+
// IR node shape (a plain object; `op` is the discriminant):
|
|
19
|
+
// { op:'label', name } — a code label / branch target
|
|
20
|
+
// { op:'reg', mnemonic, operand, raw } — a register/ALU op that maps
|
|
21
|
+
// 1:1 across the source & target's compatible register file. `kind` tags the
|
|
22
|
+
// abstract operation so an emitter that ISN'T 1:1 can translate it.
|
|
23
|
+
// { op:'load', reg, addr, mode } — reg ← mem
|
|
24
|
+
// { op:'store', reg, addr, mode } — mem ← reg
|
|
25
|
+
// { op:'alu', fn, reg, operand, mode } — reg = reg fn operand
|
|
26
|
+
// { op:'branch', cond, target } — conditional relative branch
|
|
27
|
+
// { op:'jump', target } — unconditional jump
|
|
28
|
+
// { op:'call', target } — subroutine call (push return)
|
|
29
|
+
// { op:'ret', kind:'sub'|'interrupt' } — return
|
|
30
|
+
// { op:'hwreg', access:'read'|'write', reg, addr, via } — a hardware-MMIO access
|
|
31
|
+
// (the "seam"): the target can't do the source's MMIO, so this becomes a
|
|
32
|
+
// call into the target's runtime shim. `reg` is the source register-file
|
|
33
|
+
// address (e.g. NES $2000); `via` is the CPU register carrying the value.
|
|
34
|
+
// { op:'passthrough', text, raw } — emitter writes `text` as-is
|
|
35
|
+
// (used by a 1:1 emitter where the source mnemonic IS a valid target mnemonic)
|
|
36
|
+
// { op:'refuse', reason, raw } — not mechanically translatable;
|
|
37
|
+
// becomes residue. NEVER emitted as code.
|
|
38
|
+
//
|
|
39
|
+
// Every node may carry `label` (a leading code label) and `addr` (the source CPU
|
|
40
|
+
// address, for diagnostics). Plain JS ESM + JSDoc.
|
|
41
|
+
|
|
42
|
+
/** The IR op discriminants. */
|
|
43
|
+
export const IR = Object.freeze({
|
|
44
|
+
LABEL: "label",
|
|
45
|
+
REG: "reg",
|
|
46
|
+
LOAD: "load",
|
|
47
|
+
STORE: "store",
|
|
48
|
+
ALU: "alu",
|
|
49
|
+
BRANCH: "branch",
|
|
50
|
+
JUMP: "jump",
|
|
51
|
+
CALL: "call",
|
|
52
|
+
RET: "ret",
|
|
53
|
+
HWREG: "hwreg",
|
|
54
|
+
PASSTHROUGH: "passthrough",
|
|
55
|
+
REFUSE: "refuse",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Abstract operation kinds an emitter can switch on when it ISN'T a 1:1 textual
|
|
60
|
+
* passthrough (e.g. emitting m68k from 6502, where `lda`→`move.b` and the named
|
|
61
|
+
* 8-bit accumulator becomes a chosen data register). A lifter tags each `reg`/
|
|
62
|
+
* `alu`/`load`/`store`/`branch` node with one of these so the emitter never has
|
|
63
|
+
* to know the SOURCE mnemonic — only the abstract intent.
|
|
64
|
+
*/
|
|
65
|
+
export const ABSTRACT = Object.freeze({
|
|
66
|
+
// data movement
|
|
67
|
+
LOAD_ACC: "load_acc", LOAD_X: "load_x", LOAD_Y: "load_y",
|
|
68
|
+
STORE_ACC: "store_acc", STORE_X: "store_x", STORE_Y: "store_y",
|
|
69
|
+
TRANSFER: "transfer",
|
|
70
|
+
PUSH: "push", PULL: "pull",
|
|
71
|
+
// arithmetic / logic (on the accumulator unless noted)
|
|
72
|
+
ADD: "add", SUB: "sub", AND: "and", OR: "or", XOR: "xor",
|
|
73
|
+
INC: "inc", DEC: "dec", SHL: "shl", SHR: "shr", ROL: "rol", ROR: "ror",
|
|
74
|
+
CMP: "cmp", BIT: "bit",
|
|
75
|
+
// index reg inc/dec/compare
|
|
76
|
+
INX: "inx", INY: "iny", DEX: "dex", DEY: "dey",
|
|
77
|
+
CPX: "cpx", CPY: "cpy",
|
|
78
|
+
// flags / nop
|
|
79
|
+
SET_FLAG: "set_flag", CLR_FLAG: "clr_flag", NOP: "nop",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Branch conditions (ISA-neutral). A lifter maps its native branches onto these;
|
|
83
|
+
* an emitter maps these onto the target's branch instructions. */
|
|
84
|
+
export const COND = Object.freeze({
|
|
85
|
+
EQ: "eq", NE: "ne", CC: "cc", CS: "cs", // zero/carry
|
|
86
|
+
MI: "mi", PL: "pl", VC: "vc", VS: "vs", // sign/overflow
|
|
87
|
+
ALWAYS: "always",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── node constructors (keep call sites terse + consistent) ──────────────────
|
|
91
|
+
export const irLabel = (name) => ({ op: IR.LABEL, name });
|
|
92
|
+
export const irPassthrough = (text, raw, label) => ({ op: IR.PASSTHROUGH, text, raw, label });
|
|
93
|
+
export const irRefuse = (reason, raw, label) => ({ op: IR.REFUSE, reason, raw, label });
|
|
94
|
+
export const irReg = (kind, mnemonic, operand, raw, label) => ({ op: IR.REG, kind, mnemonic, operand, raw, label });
|
|
95
|
+
export const irBranch = (cond, target, raw, label) => ({ op: IR.BRANCH, cond, target, raw, label });
|
|
96
|
+
export const irJump = (target, raw, label) => ({ op: IR.JUMP, target, raw, label });
|
|
97
|
+
export const irCall = (target, raw, label) => ({ op: IR.CALL, target, raw, label });
|
|
98
|
+
export const irRet = (kind, raw, label) => ({ op: IR.RET, kind, raw, label });
|
|
99
|
+
export const irHwReg = (access, reg, via, raw, label) => ({ op: IR.HWREG, access, reg, via, raw, label });
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate an IR program (array of nodes) — a cheap structural check so a buggy
|
|
103
|
+
* lifter fails loudly here, not deep in an emitter. Returns the array unchanged
|
|
104
|
+
* or throws. Not a type system; just a guard that every node has a known `op`.
|
|
105
|
+
* @param {Array<object>} nodes
|
|
106
|
+
*/
|
|
107
|
+
export function validateIR(nodes) {
|
|
108
|
+
if (!Array.isArray(nodes)) throw new Error("IR program must be an array of nodes");
|
|
109
|
+
const ops = new Set(Object.values(IR));
|
|
110
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
111
|
+
const n = nodes[i];
|
|
112
|
+
if (!n || typeof n !== "object" || !ops.has(n.op)) {
|
|
113
|
+
throw new Error(`IR node ${i} has an unknown op: ${JSON.stringify(n)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return nodes;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Collect the residue (refused nodes) from an IR program — what the engine
|
|
120
|
+
* could NOT mechanically translate, surfaced to the caller instead of guessed. */
|
|
121
|
+
export function collectResidue(nodes) {
|
|
122
|
+
return nodes.filter((n) => n.op === IR.REFUSE).map((n) => ({ reason: n.reason, line: (n.raw || "").trim() }));
|
|
123
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// 6502 → IR lifter. Turns one da65 6502 disassembly into the generic recompile
|
|
2
|
+
// IR (ir.js), tagging each instruction with an ABSTRACT op so a non-1:1 target
|
|
3
|
+
// emitter (m68k, z80, …) can translate the INTENT without knowing 6502. The 1:1
|
|
4
|
+
// emitter (65816 emulation mode) ignores the abstract tag and re-emits the
|
|
5
|
+
// mnemonic verbatim — both consume the same IR.
|
|
6
|
+
//
|
|
7
|
+
// This is the existing recompile-65816 classification logic, refactored to emit
|
|
8
|
+
// IR nodes instead of 65816 text. Same documented-opcode set, same seam
|
|
9
|
+
// detection, same refuse rules — but now source-agnostic at the boundary.
|
|
10
|
+
//
|
|
11
|
+
// Plain JS ESM + JSDoc.
|
|
12
|
+
|
|
13
|
+
import { parseDa65Line } from "../recompile-65816.js";
|
|
14
|
+
import { NES_REGISTERS } from "../../platforms/common/registers.js";
|
|
15
|
+
import {
|
|
16
|
+
ABSTRACT, COND,
|
|
17
|
+
irLabel, irReg, irBranch, irJump, irCall, irRet, irHwReg, irRefuse,
|
|
18
|
+
} from "./ir.js";
|
|
19
|
+
|
|
20
|
+
/** The 151 documented 6502 mnemonics — anything else (an undocumented opcode da65
|
|
21
|
+
* rendered, or a data byte caught in the stream) is REFUSED, not guessed. */
|
|
22
|
+
export const DOCUMENTED_6502 = new Set([
|
|
23
|
+
"adc", "and", "asl", "bcc", "bcs", "beq", "bit", "bmi", "bne", "bpl", "brk",
|
|
24
|
+
"bvc", "bvs", "clc", "cld", "cli", "clv", "cmp", "cpx", "cpy", "dec", "dex",
|
|
25
|
+
"dey", "eor", "inc", "inx", "iny", "jmp", "jsr", "lda", "ldx", "ldy", "lsr",
|
|
26
|
+
"nop", "ora", "pha", "php", "pla", "plp", "rol", "ror", "rti", "rts", "sbc",
|
|
27
|
+
"sec", "sed", "sei", "sta", "stx", "sty", "tax", "tay", "tsx", "txa", "txs",
|
|
28
|
+
"tya",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** mnemonic → abstract op, so an emitter switches on INTENT not the 6502 name.
|
|
32
|
+
* (Branches/jumps/calls/returns/hwreg are handled structurally below, not here.) */
|
|
33
|
+
const ABSTRACT_OF = {
|
|
34
|
+
lda: ABSTRACT.LOAD_ACC, ldx: ABSTRACT.LOAD_X, ldy: ABSTRACT.LOAD_Y,
|
|
35
|
+
sta: ABSTRACT.STORE_ACC, stx: ABSTRACT.STORE_X, sty: ABSTRACT.STORE_Y,
|
|
36
|
+
tax: ABSTRACT.TRANSFER, tay: ABSTRACT.TRANSFER, txa: ABSTRACT.TRANSFER,
|
|
37
|
+
tya: ABSTRACT.TRANSFER, tsx: ABSTRACT.TRANSFER, txs: ABSTRACT.TRANSFER,
|
|
38
|
+
pha: ABSTRACT.PUSH, php: ABSTRACT.PUSH, pla: ABSTRACT.PULL, plp: ABSTRACT.PULL,
|
|
39
|
+
adc: ABSTRACT.ADD, sbc: ABSTRACT.SUB, and: ABSTRACT.AND, ora: ABSTRACT.OR,
|
|
40
|
+
eor: ABSTRACT.XOR, inc: ABSTRACT.INC, dec: ABSTRACT.DEC, asl: ABSTRACT.SHL,
|
|
41
|
+
lsr: ABSTRACT.SHR, rol: ABSTRACT.ROL, ror: ABSTRACT.ROR, cmp: ABSTRACT.CMP,
|
|
42
|
+
bit: ABSTRACT.BIT, inx: ABSTRACT.INX, iny: ABSTRACT.INY, dex: ABSTRACT.DEX,
|
|
43
|
+
dey: ABSTRACT.DEY, cpx: ABSTRACT.CPX, cpy: ABSTRACT.CPY,
|
|
44
|
+
clc: ABSTRACT.CLR_FLAG, sec: ABSTRACT.SET_FLAG, cli: ABSTRACT.CLR_FLAG,
|
|
45
|
+
sei: ABSTRACT.SET_FLAG, clv: ABSTRACT.CLR_FLAG, cld: ABSTRACT.CLR_FLAG,
|
|
46
|
+
nop: ABSTRACT.NOP,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** 6502 conditional-branch mnemonic → ISA-neutral condition. */
|
|
50
|
+
const BRANCH_COND = {
|
|
51
|
+
beq: COND.EQ, bne: COND.NE, bcc: COND.CC, bcs: COND.CS,
|
|
52
|
+
bmi: COND.MI, bpl: COND.PL, bvc: COND.VC, bvs: COND.VS,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detect a hardware-register (MMIO) access — the seam. Returns the register's low
|
|
57
|
+
* address if the operand targets a NES PPU/APU register, else null. Only absolute
|
|
58
|
+
* operands count; immediates and zero-page never hit the register file.
|
|
59
|
+
* @param {string|undefined} operand
|
|
60
|
+
*/
|
|
61
|
+
export function seamRegister(operand) {
|
|
62
|
+
if (!operand) return null;
|
|
63
|
+
const m = operand.match(/^\$([0-9A-Fa-f]{3,4})(?:\s*,\s*[xy])?$/);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const addr = parseInt(m[1], 16);
|
|
66
|
+
if (addr in NES_REGISTERS) return addr;
|
|
67
|
+
if ((addr >= 0x2000 && addr <= 0x2007) || (addr >= 0x4000 && addr <= 0x4017)) return addr;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Lift one parsed da65 6502 line to an IR node (or null for blank/directive/equ —
|
|
73
|
+
* those are handled by the orchestrator, which collects equs separately). The
|
|
74
|
+
* leading label, if any, rides on the returned node so branch targets resolve.
|
|
75
|
+
* @param {ReturnType<typeof parseDa65Line>} p
|
|
76
|
+
* @returns {object|null}
|
|
77
|
+
*/
|
|
78
|
+
function liftInstr(p) {
|
|
79
|
+
const label = p.label;
|
|
80
|
+
const mnem = p.mnemonic;
|
|
81
|
+
const operand = p.operand;
|
|
82
|
+
|
|
83
|
+
// Refuse the non-mechanical constructs up front (same as the original).
|
|
84
|
+
if (mnem === "sed") return irRefuse("decimal-mode (sed): BCD edge-flag semantics differ across ISAs", p.raw, label);
|
|
85
|
+
if (mnem === "jmp" && operand && operand.startsWith("(")) {
|
|
86
|
+
return irRefuse("indirect jump (jmp (addr)): target is computed — resolve with breakpoint({on:'jumptable'})", p.raw, label);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Hardware seam: any PPU/APU register access becomes an IR hwreg node.
|
|
90
|
+
const reg = seamRegister(operand);
|
|
91
|
+
if (reg != null) {
|
|
92
|
+
const isStore = mnem === "sta" || mnem === "stx" || mnem === "sty";
|
|
93
|
+
const isLoad = mnem === "lda" || mnem === "ldx" || mnem === "ldy" || mnem === "bit";
|
|
94
|
+
if (isStore) return irHwReg("write", reg, mnem, p.raw, label);
|
|
95
|
+
if (isLoad) return irHwReg("read", reg, mnem, p.raw, label);
|
|
96
|
+
return irRefuse(`hardware-register read-modify-write (${mnem} ${operand}): not in the mechanical set`, p.raw, label);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Control flow → structural IR nodes.
|
|
100
|
+
if (mnem === "jsr") return irCall(operand, p.raw, label);
|
|
101
|
+
if (mnem === "jmp") return irJump(operand, p.raw, label);
|
|
102
|
+
if (mnem === "rts") return irRet("sub", p.raw, label);
|
|
103
|
+
if (mnem === "rti") return irRet("interrupt", p.raw, label);
|
|
104
|
+
if (mnem === "brk") return irRefuse("brk: software interrupt needs explicit vector handling in v1", p.raw, label);
|
|
105
|
+
if (mnem in BRANCH_COND) return irBranch(BRANCH_COND[mnem], operand, p.raw, label);
|
|
106
|
+
|
|
107
|
+
// A documented data/ALU/transfer op → a `reg` node tagged with its abstract op.
|
|
108
|
+
if (mnem in ABSTRACT_OF) {
|
|
109
|
+
return irReg(ABSTRACT_OF[mnem], mnem, operand, p.raw, label);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Unknown mnemonic = undocumented opcode or a parse miss.
|
|
113
|
+
return irRefuse(`unrecognized/undocumented opcode '${mnem}' — not a documented 6502 instruction`, p.raw, label);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Lift a block of da65 6502 asm text into an IR program. Returns the IR node list,
|
|
118
|
+
* the collected equ definitions (address aliases), and a flag for whether the
|
|
119
|
+
* first instruction was anchored (so the orchestrator can fix the entry label).
|
|
120
|
+
* @param {string} da65Asm
|
|
121
|
+
* @returns {{ ir: Array<object>, equs: string[], instrCount: number, seamCount: number, entry: string|null }}
|
|
122
|
+
*/
|
|
123
|
+
export function lift6502(da65Asm) {
|
|
124
|
+
const lines = da65Asm.split("\n");
|
|
125
|
+
const ir = [];
|
|
126
|
+
const equs = [];
|
|
127
|
+
let instrCount = 0;
|
|
128
|
+
let seamCount = 0;
|
|
129
|
+
let entry = null;
|
|
130
|
+
const ENTRY_LABEL = "RECOMPILE_ENTRY";
|
|
131
|
+
|
|
132
|
+
for (const raw of lines) {
|
|
133
|
+
const p = parseDa65Line(raw);
|
|
134
|
+
switch (p.kind) {
|
|
135
|
+
case "blank":
|
|
136
|
+
case "comment":
|
|
137
|
+
case "directive":
|
|
138
|
+
break;
|
|
139
|
+
case "equ":
|
|
140
|
+
equs.push(`${p.label} = ${p.operand}`);
|
|
141
|
+
break;
|
|
142
|
+
case "label":
|
|
143
|
+
ir.push(irLabel(p.label));
|
|
144
|
+
break;
|
|
145
|
+
case "data":
|
|
146
|
+
ir.push(irRefuse("data/.byte in code stream (data table or undocumented opcode)", p.raw));
|
|
147
|
+
break;
|
|
148
|
+
case "instr": {
|
|
149
|
+
const node = liftInstr(p);
|
|
150
|
+
if (!node) break;
|
|
151
|
+
// Anchor the entry to the FIRST real instruction (lifted node), labeled or
|
|
152
|
+
// not — da65 only labels branch targets, so a fall-through opener is
|
|
153
|
+
// unlabeled and would otherwise let the reset skip the routine's setup.
|
|
154
|
+
if (entry == null && node.op !== "refuse") {
|
|
155
|
+
if (node.label) {
|
|
156
|
+
entry = node.label;
|
|
157
|
+
} else {
|
|
158
|
+
entry = ENTRY_LABEL;
|
|
159
|
+
ir.push(irLabel(ENTRY_LABEL));
|
|
160
|
+
}
|
|
161
|
+
} else if (entry == null && node.op === "refuse") {
|
|
162
|
+
// even a refused opener anchors the entry so the vector lands at the top
|
|
163
|
+
if (node.label) entry = node.label;
|
|
164
|
+
else { entry = ENTRY_LABEL; ir.push(irLabel(ENTRY_LABEL)); }
|
|
165
|
+
}
|
|
166
|
+
if (node.op === "hwreg") seamCount++;
|
|
167
|
+
if (node.op !== "refuse") instrCount++;
|
|
168
|
+
ir.push(node);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { ir, equs, instrCount, seamCount, entry };
|
|
176
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// Plain JS ESM + JSDoc. See internal-romdev/PORTING_MENTAL_MODELS.md Part 4.
|
|
23
23
|
|
|
24
24
|
import { NES_REGISTERS } from "../platforms/common/registers.js";
|
|
25
|
+
import { recompile } from "./recompile/index.js";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* The 151 documented 6502 mnemonics. Anything da65 emits outside this set — or
|
|
@@ -375,73 +376,21 @@ export function sliceFirstRoutine(da65Asm) {
|
|
|
375
376
|
* seamCount: number, stubbed: string[] }}
|
|
376
377
|
*/
|
|
377
378
|
export function recompileNesToSnes(da65Asm, opts = {}) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
nmiResidue = t.residue;
|
|
393
|
-
nmiInstr = t.instrCount;
|
|
394
|
-
nmiSeam = t.seamCount;
|
|
395
|
-
// The two bodies may both define an entry named RECOMPILE_ENTRY (the synthetic
|
|
396
|
-
// fall-through label). Rename the NMI's so they don't collide in one image.
|
|
397
|
-
if (nmiEntry === "RECOMPILE_ENTRY") {
|
|
398
|
-
nmiBody = nmiBody.replace(/\bRECOMPILE_ENTRY\b/g, "RECOMPILE_NMI_ENTRY");
|
|
399
|
-
nmiEntry = "RECOMPILE_NMI_ENTRY";
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Equs (zero-page/external address aliases like `L001E = $001E`) are shared
|
|
404
|
-
// between the two bodies — emit the UNION once, de-duplicated, in the reset
|
|
405
|
-
// body's prefix; the NMI body carries none (a duplicate `=` is an asar error).
|
|
406
|
-
const seenEqu = new Set();
|
|
407
|
-
const allEqus = [...equs, ...nmiEqus].filter((e) => {
|
|
408
|
-
const name = e.split(/\s*=/)[0].trim();
|
|
409
|
-
if (seenEqu.has(name)) return false;
|
|
410
|
-
seenEqu.add(name);
|
|
411
|
-
return true;
|
|
412
|
-
});
|
|
413
|
-
const fullBody = (allEqus.length ? allEqus.join("\n") + "\n" : "") + body;
|
|
414
|
-
const fullNmiBody = nmiBody; // equs already hoisted above
|
|
415
|
-
// entry: caller override > the first-instruction label translateBody fixed >
|
|
416
|
-
// first label in the body (legacy fallback) > a synthetic name. bodyEntry is
|
|
417
|
-
// the correct one — it anchors the reset vector to the routine's OPENING
|
|
418
|
-
// instruction, not a later branch target.
|
|
419
|
-
const entry = opts.entry || bodyEntry || entryLabel(fullBody) || "RECOMPILE_ENTRY";
|
|
420
|
-
|
|
421
|
-
// Stub callees undefined across BOTH bodies (so a routine the reset calls that
|
|
422
|
-
// happens to be defined inside the NMI body — or vice versa — is not stubbed).
|
|
423
|
-
const stubUndefined = opts.stubUndefined !== false;
|
|
424
|
-
const combined = fullBody + (fullNmiBody ? "\n" + fullNmiBody : "");
|
|
425
|
-
const stubbed = stubUndefined ? findUndefinedLabels(combined, allEqus) : [];
|
|
426
|
-
const stubsAsm = stubbed.length ? "\n" + emitStubs(stubbed) : "";
|
|
427
|
-
const withStubs = fullBody + stubsAsm;
|
|
428
|
-
|
|
429
|
-
const mainAsm = emitMainAsm({
|
|
430
|
-
body: withStubs,
|
|
431
|
-
resetLabel: entry,
|
|
432
|
-
withShim: !!opts.withShim,
|
|
433
|
-
withRuntime: !!opts.withRuntime,
|
|
434
|
-
nmiBody: fullNmiBody,
|
|
379
|
+
// Delegate to the GENERIC engine (lift 6502 → IR → emit 65816). This is now a
|
|
380
|
+
// thin source/target-pinned wrapper kept for back-compat with the disasm tool +
|
|
381
|
+
// existing tests; the actual translation lives in analysis/recompile/. The
|
|
382
|
+
// generic path produces the same image (the 65816 emitter reproduces this
|
|
383
|
+
// module's emitMainAsm/emitSeam output) — see analysis/recompile/index.js.
|
|
384
|
+
// `nmiDa65Asm` is the old arg name; the generic engine calls it `nmiSourceAsm`.
|
|
385
|
+
return recompile(da65Asm, {
|
|
386
|
+
source: "nes",
|
|
387
|
+
target: "snes",
|
|
388
|
+
entry: opts.entry,
|
|
389
|
+
stubUndefined: opts.stubUndefined,
|
|
390
|
+
withShim: opts.withShim,
|
|
391
|
+
withRuntime: opts.withRuntime,
|
|
392
|
+
nmiSourceAsm: opts.nmiDa65Asm,
|
|
435
393
|
});
|
|
436
|
-
const seamAsm = emitSeam();
|
|
437
|
-
return {
|
|
438
|
-
mainAsm, seamAsm,
|
|
439
|
-
residue: [...residue, ...nmiResidue],
|
|
440
|
-
entry, nmiEntry,
|
|
441
|
-
instrCount: instrCount + nmiInstr,
|
|
442
|
-
seamCount: seamCount + nmiSeam,
|
|
443
|
-
stubbed,
|
|
444
|
-
};
|
|
445
394
|
}
|
|
446
395
|
|
|
447
396
|
/**
|
package/src/mcp/tools/cheats.js
CHANGED
|
@@ -204,18 +204,38 @@ export async function cheatsApplyCore({ code, desc, path: romPath, index, enable
|
|
|
204
204
|
const { code: codeToApply, appliedAs, reencodedFrom } = resolveCheatCodeForApply(rawCode, plat);
|
|
205
205
|
|
|
206
206
|
const unencodable = appliedAs === "rom-unencodable";
|
|
207
|
-
|
|
207
|
+
// Slot selection: an explicit `index` wins. Otherwise, if an active cheat
|
|
208
|
+
// already targets the SAME address, REUSE its slot — applying `005C:03` over
|
|
209
|
+
// an active `005C:02` should REPLACE the freeze, not stack a second one that
|
|
210
|
+
// fights for the same byte (v0.41.0 feedback 213831 #3). Only append a new
|
|
211
|
+
// slot when nothing targets this address yet.
|
|
212
|
+
const newAddr = cheatAddress(codeToApply);
|
|
213
|
+
let replacedSlot = null;
|
|
214
|
+
let slot;
|
|
215
|
+
if (index != null) {
|
|
216
|
+
slot = index;
|
|
217
|
+
} else if (newAddr != null) {
|
|
218
|
+
const existing = host.listActiveCheats().find((c) => cheatAddress(c.code) === newAddr);
|
|
219
|
+
if (existing) { slot = existing.index; replacedSlot = existing.index; }
|
|
220
|
+
else slot = host.listActiveCheats().length;
|
|
221
|
+
} else {
|
|
222
|
+
slot = host.listActiveCheats().length;
|
|
223
|
+
}
|
|
208
224
|
// Still install it (the raw poke may be what the user wants), but warn.
|
|
209
225
|
host.setCheat(slot, codeToApply, enabled);
|
|
210
226
|
return {
|
|
211
227
|
applied: enabled,
|
|
212
228
|
slot,
|
|
229
|
+
...(replacedSlot != null ? { replacedSameAddress: true } : {}),
|
|
213
230
|
code: codeToApply,
|
|
214
231
|
appliedAs,
|
|
215
232
|
...(reencodedFrom ? { reencodedFrom } : {}),
|
|
216
233
|
...(resolvedDesc ? { desc: resolvedDesc } : {}),
|
|
217
234
|
active: host.listActiveCheats(),
|
|
218
|
-
note: (
|
|
235
|
+
note: (replacedSlot != null
|
|
236
|
+
? `REPLACED the active cheat on the same address (slot ${replacedSlot}) — applying a new freeze on an address that already had one swaps it rather than stacking two that fight over the byte. To remove a single freeze without clearing the rest, use cheats({op:'remove', code|slot}). `
|
|
237
|
+
: "") +
|
|
238
|
+
(reencodedFrom
|
|
219
239
|
? `Raw code ${reencodedFrom} names a ROM address — re-encoded to the native ROM-patch device (${codeToApply}) so the core installs a read-intercept (a raw ADDR:VAL on a ROM address is treated as a RAM poke and would silently no-op). `
|
|
220
240
|
: unencodable
|
|
221
241
|
? `WARNING: this raw code names a ROM address but couldn't be re-encoded to a ROM-patch code (a ROM patch needs a COMPARE byte — pass ADDR:VAL:COMPARE). As applied it's a RAM poke and will likely NO-OP on this read-only ROM address. Read the current byte and supply it as the compare, or use makeCheat. `
|
|
@@ -226,6 +246,53 @@ export async function cheatsApplyCore({ code, desc, path: romPath, index, enable
|
|
|
226
246
|
};
|
|
227
247
|
}
|
|
228
248
|
|
|
249
|
+
/** The CPU/RAM address a cheat code targets, or null if undecodable. Used to
|
|
250
|
+
* detect same-address freezes (replace, not stack) + for op:'remove' by code. */
|
|
251
|
+
function cheatAddress(code) {
|
|
252
|
+
if (typeof code !== "string") return null;
|
|
253
|
+
// raw ADDR:VAL[:COMPARE] — the address is the first field.
|
|
254
|
+
if (code.includes(":")) {
|
|
255
|
+
const a = parseInt(code.split(":")[0], 16);
|
|
256
|
+
return Number.isNaN(a) ? null : (a & 0xffff);
|
|
257
|
+
}
|
|
258
|
+
// a device code (Game Genie / PAR / GameShark) — decode it platform-agnostically.
|
|
259
|
+
try {
|
|
260
|
+
const d = decodeCode(code, null);
|
|
261
|
+
return d && d.address != null ? (d.address & 0xffff) : null;
|
|
262
|
+
} catch { return null; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** op:'remove' — disable ONE active cheat (by slot index or by code/address),
|
|
266
|
+
* leaving the rest in place. The single-slot complement to op:'clear' (nuke-all).
|
|
267
|
+
* v0.41.0 feedback 213831 #3. */
|
|
268
|
+
export async function cheatsRemoveCore({ slot, code }, sessionKey) {
|
|
269
|
+
const host = getHost(sessionKey);
|
|
270
|
+
if (!host.listActiveCheats) return { removed: false, reason: "no cheat interface on this core", active: [] };
|
|
271
|
+
const active = host.listActiveCheats();
|
|
272
|
+
let target = null;
|
|
273
|
+
if (slot != null) {
|
|
274
|
+
target = active.find((c) => c.index === slot);
|
|
275
|
+
} else if (code != null) {
|
|
276
|
+
// match by exact code OR by the address the code targets (so a user can
|
|
277
|
+
// remove '005C:02' by passing '005C:03', '$5C', or the device code).
|
|
278
|
+
const addr = cheatAddress(code);
|
|
279
|
+
target = active.find((c) => c.code === code) || (addr != null ? active.find((c) => cheatAddress(c.code) === addr) : null);
|
|
280
|
+
} else {
|
|
281
|
+
throw new Error("cheats({op:'remove'}): provide `slot` (index) or `code` (the cheat to remove).");
|
|
282
|
+
}
|
|
283
|
+
if (!target) {
|
|
284
|
+
return { removed: false, reason: `no active cheat matching ${slot != null ? `slot ${slot}` : `code '${code}'`}`, active };
|
|
285
|
+
}
|
|
286
|
+
host.setCheat(target.index, target.code, false); // disable that slot only
|
|
287
|
+
return {
|
|
288
|
+
removed: true,
|
|
289
|
+
slot: target.index,
|
|
290
|
+
code: target.code,
|
|
291
|
+
active: host.listActiveCheats(),
|
|
292
|
+
note: "Disabled that one cheat (volatile); the rest stay active. Use op:'clear' to remove ALL, op:'apply' to add/swap.",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
229
296
|
/** op:'clear' — remove ALL active cheats (volatile core-side reset). */
|
|
230
297
|
export async function cheatsClearCore(_args, sessionKey) {
|
|
231
298
|
const host = getHost(sessionKey);
|
|
@@ -319,7 +386,7 @@ export function registerCheatTools(server, z, sessionKey) {
|
|
|
319
386
|
"round-trip `verified`. RAM cheat = address+value; ROM/code cheat = also `compare` (the byte currently there — " +
|
|
320
387
|
"read it first).",
|
|
321
388
|
{
|
|
322
|
-
op: z.enum(["lookup", "search", "apply", "clear", "make"]).describe("lookup THIS game's DB cheats; search the DB by game name; apply a cheat live; clear
|
|
389
|
+
op: z.enum(["lookup", "search", "apply", "remove", "clear", "make"]).describe("lookup THIS game's DB cheats; search the DB by game name; apply a cheat live (replaces an active cheat on the SAME address); remove ONE active cheat (by slot/code); clear ALL cheats; make a new code."),
|
|
323
390
|
// lookup
|
|
324
391
|
path: z.string().optional().describe("op=lookup/apply(+desc): absolute ROM path. lookup sniffs platform+name from it."),
|
|
325
392
|
filter: z.string().optional().describe("op=lookup: case-insensitive substring filter on cheat descriptions."),
|
|
@@ -330,8 +397,9 @@ export function registerCheatTools(server, z, sessionKey) {
|
|
|
330
397
|
// apply
|
|
331
398
|
code: z.string().optional().describe("op=apply: raw cheat code (ADDR:VAL or a Game Genie code). Provide code OR desc."),
|
|
332
399
|
desc: z.string().optional().describe("op=apply: description of a matched cheat (requires `path`). First substring match is applied."),
|
|
333
|
-
index: z.number().int().min(0).optional().describe("op=apply: cheat slot (default: next free
|
|
400
|
+
index: z.number().int().min(0).optional().describe("op=apply: cheat slot (default: reuse the slot of an active cheat on the same address, else next free). Reuse a slot to replace it."),
|
|
334
401
|
enabled: z.boolean().default(true).describe("op=apply: false disables the slot instead of enabling."),
|
|
402
|
+
slot: z.number().int().min(0).optional().describe("op=remove: the active-cheat slot to disable (from apply's `slot` / active[].index). Provide slot OR code."),
|
|
335
403
|
// make / search / lookup share `platform`
|
|
336
404
|
platform: z.enum([...MAKE_CHEAT_PLATFORMS]).optional().describe("op=lookup: override platform detection. op=search: OPTIONAL — omit to search ALL platforms (each match returns its own `platform`); pass one only to scope the search. op=make: REQUIRED — the target platform (all 14 tier-1)."),
|
|
337
405
|
address: z.number().int().min(0).optional().describe("op=make: address to cheat (RAM addr, or the ROM addr to patch)."),
|
|
@@ -345,6 +413,7 @@ export function registerCheatTools(server, z, sessionKey) {
|
|
|
345
413
|
case "lookup": return jsonContent(await cheatsLookupCore(args));
|
|
346
414
|
case "search": return jsonContent(await cheatsSearchCore(args));
|
|
347
415
|
case "apply": return attachObserverFrame(jsonContent(await cheatsApplyCore(args, sessionKey)), getHost(sessionKey), "cheat applied");
|
|
416
|
+
case "remove": return jsonContent(await cheatsRemoveCore(args, sessionKey));
|
|
348
417
|
case "clear": return jsonContent(await cheatsClearCore(args, sessionKey));
|
|
349
418
|
case "make": return jsonContent(await cheatsMakeCore(args));
|
|
350
419
|
default: throw new Error(`cheats: unknown op '${args.op}'`);
|