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.
@@ -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
- const { body, equs, residue, instrCount, seamCount, entry: bodyEntry } = translateBody(da65Asm);
379
-
380
- // Optionally translate the NES NMI handler as a second body (phase-2 runtime).
381
- let nmiBody = null;
382
- let nmiEntry = null;
383
- let nmiEqus = [];
384
- let nmiResidue = [];
385
- let nmiInstr = 0;
386
- let nmiSeam = 0;
387
- if (opts.withRuntime && opts.nmiDa65Asm) {
388
- const t = translateBody(opts.nmiDa65Asm);
389
- nmiBody = t.body;
390
- nmiEntry = t.entry;
391
- nmiEqus = t.equs;
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
  /**
@@ -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
- const slot = index != null ? index : host.listActiveCheats().length;
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: (reencodedFrom
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 all cheats; make a new code."),
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 slot). Reuse a slot to replace it."),
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}'`);