Skip to content

Commit 318c070

Browse files
timboettigerclaude
andcommitted
mk3: gate PAR-NMI BIOS window with in_par_nmi latch
v0.6.1-beta2 expanded the PAR-NMI BIOS window to the full handler range $AE12-$B3F6 but left it open for all of Cheats Active mode. That range sits inside the game's own LoROM $8000-$FFFF space in every bank, so the BIOS shadowed 1509 bytes of game ROM during normal execution -- the game froze right after the Nintendo logo. Gate the window on a new in_par_nmi latch in mk3_nmi_hook: SET on the NMI vector low-byte read ($00:FFEA) when armed CLEAR on the read of $00:6180 -- the handler's final jmp ($6180) at $B09D, which the disassembly confirms is the only access to $6180 in the whole handler (and DP $80 is never used, so no aliasing to $6180 via the $6100 direct page). The BIOS is now swapped in only for the ~1 ms the handler actually runs each frame; the rest of the time the game ROM is fully visible. This keeps the full handler (incl. the LED 2 / trainer-count code at $B043 and $B35F) mapped so the trainer works, without breaking game boot. Control C is no longer used as a gate (it would close at $B08B, before the handler's exit tail finishes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0610a1b commit 318c070

4 files changed

Lines changed: 71 additions & 36 deletions

File tree

‎pkg/Cores/timboettiger.ProActionReplayMK3_SNES/core.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"description": "Super Nintendo with integrated Pro Action Replay MK3.",
88
"author": "timboettiger",
99
"url": "https://github.com/timboettiger/openfpga-SNES-pro-action-replay-mk3",
10-
"version": "0.6.1-beta2",
10+
"version": "0.6.1-beta3",
1111
"date_release": "2026-06-08"
1212
},
1313
"framework": {

‎rtl/chip/mk3/mk3_mapper.sv‎

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,28 @@
1111
// $00-$3F:$8000-$FFFF -> ROM (BIOS or game)
1212
// $80-$BF:$8000-$FFFF -> mirror of $00-$3F
1313
//
14-
// PAR-NMI window: in Cheats Active mode the BIOS is also visible at
15-
// $00/$80:AE12-$B3F6 -- the exact byte range the PAR-NMI handler may fetch.
16-
// The MK3 NMI hook redirects the SNES NMI vector to $80:AE12 (slots 5/6 are
17-
// programmed with #$AE12 at $80:912B); the handler then runs its entry, the
18-
// combo decoder ($AE99+), the per-frame LED engine ($AFD0-$B083), the
19-
// NMI exit ($B083-$B09D), and the cheat-apply / trainer-count helpers
14+
// PAR-NMI window: while the CPU is inside the PAR-NMI handler the BIOS is
15+
// visible at $00/$80:AE12-$B3F6 -- the exact byte range the handler may
16+
// fetch. The MK3 NMI hook redirects the SNES NMI vector to $80:AE12 (slots
17+
// 5/6 are programmed with #$AE12 at $80:912B); the handler then runs its
18+
// entry, the combo decoder ($AE99+), the per-frame LED engine ($AFD0-$B083),
19+
// the NMI exit ($B083-$B09D), and the cheat-apply / trainer-count helpers
2020
// ($B0A0-$B3F6) that the SRAM trampoline re-enters. ROM addresses outside
2121
// this range (below $AE12 or from $B3F7 onward) belong to unrelated routines
2222
// and stay on the game-ROM path.
2323
//
24-
// Gating: window is open whenever the effective mode is Cheats Active. We do
25-
// NOT additionally gate on Control C bit 0, even though that bit nominally
26-
// indicates "BIOS execution": the ROM writes Control C = 1 at $B08B, but
27-
// $B08F-$B09D still need to fetch BIOS (stack pops + final `jmp ($6180)`).
28-
// A Control C gate would close mid-handler and crash on the exit. Leaving
29-
// the window unconditionally open for the 1509-byte slice is the small price
30-
// for a clean PAR-NMI exit; game ROMs that happen to use this range are
31-
// inherently incompatible with the MK3 anyway.
24+
// CRITICAL: the window is gated by `in_par_nmi` (from mk3_nmi_hook), NOT left
25+
// open for all of Cheats Active mode. The $AE12-$B3F6 range sits inside the
26+
// LoROM $8000-$FFFF game window of every bank, so leaving it BIOS-mapped all
27+
// the time would shadow 1509 bytes of game ROM in every bank and the game
28+
// would crash on the first access there. `in_par_nmi` is set on the NMI
29+
// vector fetch and cleared on the handler's final jmp ($6180), so the BIOS is
30+
// swapped in only for the ~1 ms the handler actually runs each frame.
31+
//
32+
// We deliberately do NOT gate on Control C: the ROM writes Control C = 1 at
33+
// $B08B, but $B08F-$B09D still need BIOS (stack pops + the final jmp). The
34+
// in_par_nmi latch spans the whole handler including that exit tail and only
35+
// drops at the $6180 read, so the exit fetches stay on BIOS.
3236
//
3337
// Full rationale and the per-range breakdown live in §8.2 / §19.6 / §20 of
3438
// the action-replay-mk-iii preservaction documentation.
@@ -41,7 +45,7 @@ module mk3_mapper (
4145
input logic [1:0] switch_pos, // from Pocket bridge: 0=NoCheats 1=CheatsActive 2=MK3Menu
4246
input logic control_b, // from mk3_io: sticky bit
4347
input logic [7:0] control_a, // from mk3_io: bit 4 = peek game ROM
44-
input logic [7:0] control_c, // from mk3_io: bit 0 = BIOS/PAR-NMI vs game execution
48+
input logic in_par_nmi, // from mk3_nmi_hook: 1 while inside the PAR-NMI handler
4549

4650
input logic [23:0] cpu_addr,
4751

@@ -116,17 +120,13 @@ module mk3_mapper (
116120
| (is_lorom_mirror & ~control_a[4]);
117121
is_game_path = is_lorom_mirror & control_a[4];
118122
end else if (mode == 2'd1) begin
119-
// Cheats Active: game ROM, but the PAR-NMI handler window
120-
// ($xx:AE12-$B3F6) always shows BIOS. Control C is NOT used as a
121-
// gate here: the ROM writes Control C = 1 at $B08B, *before* the
122-
// NMI exit finishes ($B08F-$B09D still need BIOS bytes -- stack
123-
// pops and the final `jmp ($6180)`). Gating on Control C would
124-
// close the window mid-routine and the CPU would read game ROM
125-
// for the last 18 bytes, crashing the handler. Control C stays
126-
// available to other consumers (debug / future use); leaving the
127-
// window permanently open in this small range is the price for
128-
// a clean NMI exit.
123+
// Cheats Active: game ROM everywhere, EXCEPT while the PAR-NMI
124+
// handler is running -- then the $AE12-$B3F6 window shows BIOS.
125+
// Gating on in_par_nmi (not just the address range) is essential:
126+
// this range overlaps the game's own LoROM $8000-$FFFF space, so
127+
// an always-open window would shadow game ROM and crash the game.
129128
is_mk3_bios_path = is_nmi_window
129+
& in_par_nmi
130130
& (is_fastrom_range | is_lorom_mirror);
131131
is_game_path = (is_fastrom_range | is_lorom_mirror)
132132
& ~is_mk3_bios_path;

‎rtl/chip/mk3/mk3_nmi_hook.sv‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@
66
// MK3 code ahead of the game's handler. Only the vector reads are hooked; the
77
// handler itself lives in MK3 SRAM. The emulation-mode vector ($00:FFFA) is
88
// not hooked (MK3 enables NMI only in native mode).
9+
//
10+
// This module also tracks whether the CPU is currently *inside* the PAR-NMI
11+
// handler, via the `in_par_nmi` latch. The mapper uses it to open the BIOS
12+
// window at $AE12-$B3F6 only while the handler runs, so the BIOS never
13+
// shadows the game ROM during normal gameplay (see mk3_mapper.sv).
14+
//
15+
// SET on the NMI vector low-byte read ($00:FFEA) when the hook is armed:
16+
// that read is the unambiguous "NMI is dispatching to $AE12" signal.
17+
// CLEAR on the read of $00:6180, which in the entire handler ($AE12-$B3F6)
18+
// happens exactly once -- the final `jmp ($6180)` at $B09D that hands
19+
// control back to the game's own NMI handler. Verified against the
20+
// BIOS disassembly: $6180 is touched by nothing else in range, and
21+
// the handler never accesses direct-page $80 (DP base $6100 would
22+
// alias it to $6180), so this edge is unique and exact.
923
//----------------------------------------------------------------------------
1024

1125
module mk3_nmi_hook (
@@ -20,7 +34,9 @@ module mk3_nmi_hook (
2034
input logic [31:0] slot6, // bits 7:0 = NMI handler MSB byte
2135

2236
output logic hit,
23-
output logic [7:0] override_byte
37+
output logic [7:0] override_byte,
38+
39+
output logic in_par_nmi // 1 while the CPU is inside the PAR-NMI handler
2440
);
2541

2642
// Slot armed = non-zero. Don't hook the vector for unprogrammed (zero)
@@ -39,4 +55,21 @@ module mk3_nmi_hook (
3955
override_byte = is_nmi_lo ? slot5[7:0] : slot6[7:0];
4056
end
4157

58+
// PAR-NMI presence latch. SET wins over CLEAR (they target different
59+
// addresses, so they never collide, but the priority is explicit anyway).
60+
logic in_par_nmi_r;
61+
wire par_nmi_enter = is_nmi_lo; // $00:FFEA read, hook armed
62+
wire par_nmi_leave = (cpu_addr == 24'h00_6180); // final jmp ($6180) at $B09D
63+
64+
always_ff @(posedge clk or negedge rst_n) begin
65+
if (!rst_n) in_par_nmi_r <= 1'b0;
66+
else if (par_nmi_enter) in_par_nmi_r <= 1'b1;
67+
else if (par_nmi_leave) in_par_nmi_r <= 1'b0;
68+
// Safety net: if the hook is disabled (left Cheats Active), drop the
69+
// latch so a stale value can't keep the window open.
70+
else if (!enable) in_par_nmi_r <= 1'b0;
71+
end
72+
73+
assign in_par_nmi = in_par_nmi_r;
74+
4275
endmodule

‎rtl/chip/mk3/mk3_snes_top.sv‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ module mk3_snes_top (
9797
logic [7:0] intercept_data;
9898
logic nmi_hit;
9999
logic [7:0] nmi_data;
100+
logic in_par_nmi; // 1 while inside the PAR-NMI handler (from nmi_hook)
100101

101102
logic force_cb, clear_cb;
102103
logic [1:0] fsm_state;
@@ -201,7 +202,7 @@ module mk3_snes_top (
201202
.switch_pos (bridge_switch_pos),
202203
.control_b (control_b),
203204
.control_a (control_a),
204-
.control_c (control_c),
205+
.in_par_nmi (in_par_nmi),
205206
.cpu_addr (cpu_addr),
206207
.sel_mk3_bios (sel_mk3_bios),
207208
.sel_game_rom (sel_game_rom),
@@ -244,7 +245,8 @@ module mk3_snes_top (
244245
.slot5 (slot5),
245246
.slot6 (slot6),
246247
.hit (nmi_hit),
247-
.override_byte (nmi_data)
248+
.override_byte (nmi_data),
249+
.in_par_nmi (in_par_nmi)
248250
);
249251

250252
// -----------------------------------------------------------------
@@ -307,12 +309,12 @@ module mk3_snes_top (
307309
assign dbg_effective_mode = effective_mode;
308310

309311
// -----------------------------------------------------------------
310-
// control_c ($206000) and control_d ($008000) are both latched in mk3_io
311-
// but not used as window gates here. The PAR-NMI BIOS window in the
312-
// mapper is decoded directly from cpu_addr (range $AE12-$B3F6 in Cheats
313-
// Active mode); see mk3_mapper.sv for why a Control C gate would close
314-
// the window mid-handler. control_c is still passed through (debug /
315-
// future) and could be re-wired if a clean Datel-IC-style "BIOS owns
316-
// bus" semantics with verified exit timing is ever required.
312+
// control_c ($206000) and control_d ($008000) are latched in mk3_io but
313+
// currently unused. The PAR-NMI BIOS window in the mapper is gated by
314+
// in_par_nmi (from mk3_nmi_hook), which spans the whole handler including
315+
// the exit tail -- a Control C gate would close the window at $B08B,
316+
// before the exit finishes. control_c/control_d stay exported for debug /
317+
// future use.
318+
wire _unused_ctrl = &{1'b0, control_c, control_d};
317319

318320
endmodule

0 commit comments

Comments
 (0)