1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
//! Mapper 69 - Sunsoft FME-7 (Sunsoft 5A/5B)
//!
//! Hardware: Sunsoft's advanced mapper with IRQ counter and optional expansion audio
//!
//! Specifications:
//! - Main: <https://www.nesdev.org/wiki/Sunsoft_FME-7>
//! - Audio: <https://www.nesdev.org/wiki/Sunsoft_5B_audio> (5B variant only)
//! - PRG-ROM: Up to 512KB with 8KB banking
//! - PRG-RAM: Up to 512KB (unusual, can be banked at $6000-$7FFF)
//! - CHR: Up to 256KB (eight 1KB switchable banks)
//! - Mirroring: Programmable (horizontal, vertical, one-screen A/B)
//! - IRQ: 16-bit CPU-cycle countdown timer
//!
//! Common boards: Sunsoft FME-7 (5A without audio, 5B with audio)
//!
//! Memory Map:
//! - $6000-$7FFF: Bank 0 (can be PRG-RAM or PRG-ROM)
//! - $8000-$9FFF: Bank 1 (PRG-ROM)
//! - $A000-$BFFF: Bank 2 (PRG-ROM)
//! - $C000-$DFFF: Bank 3 (PRG-ROM)
//! - $E000-$FFFF: Bank 4 (PRG-ROM, usually fixed to last bank)
//!
//! Registers (two-step access):
//! 1. Write command number to $8000-$9FFF
//! 2. Write parameter to $A000-$BFFF
//!
//! Commands:
//! - $00-$07: CHR bank select (1KB each)
//! - $08-$0B: PRG bank select (8KB each at $6000, $8000, $A000, $C000)
//! - $0C: Mirroring control
//! - $0D: IRQ control (enable/disable, acknowledge)
//! - $0E: IRQ counter low byte
//! - $0F: IRQ counter high byte
//!
//! Notes:
//! - Used in Gimmick! (with 5B audio), Batman: Return of the Joker, Hebereke
//! - 5B variant adds YM2149F-compatible expansion audio (3 square waves)
//!
//! Known Limitations:
//! - **Expansion audio not implemented** (5B audio chip)
use crate::nes::cartridge::BaseMapper;
use crate::nes::cartridge::{Mapper, MapperCapabilities, NametableLayout};
use crate::trace_mapper;
use crate::nes::cartridge::cpu_cycle_irq::{CpuCycleIrq, CpuCycleIrqMode};
pub struct SunsoftFme7Mapper {
base: BaseMapper,
// Register selection
command: u8,
// PRG banking (4 x 8KB switchable banks)
prg_banks: [u8; 4], // Banks for $6000-$7FFF, $8000-$9FFF, $A000-$BFFF, $C000-$DFFF
// Derived from wram_chip_enabled && prg_slot_selects_ram; kept as a field so
// read_prg/write_prg can check a single bool in the hot path. All three are
// always updated together in write_parameter ($08).
prg_ram_enabled: bool,
// Bit 7 of reg $08: WRAM chip-enable line. Set means the chip is powered.
wram_chip_enabled: bool,
// Bit 6 of reg $08: RAM/ROM select. Set means the $6000-$7FFF slot routes to RAM.
prg_slot_selects_ram: bool,
// CHR banking (8 x 1KB banks)
chr_banks: [u8; 8],
// IRQ
irq: CpuCycleIrq,
irq_counter_enabled: bool,
}
impl SunsoftFme7Mapper {
pub fn new(ctx: crate::nes::cartridge::mapper::MapperContext) -> Self {
let capabilities = MapperCapabilities {
has_irq: true,
has_chr_banking: true,
has_dynamic_mirroring: true,
max_prg_ram_kb: 8,
prg_bank_size_kb: 8,
chr_bank_size_kb: 1,
..Default::default()
};
let mut base = BaseMapper::new(&ctx, capabilities);
base.configure_prg_banking(0x2000);
base.configure_prg_6000_banking();
base.configure_chr_banking(0x0400);
let mut mapper = Self {
base,
command: 0,
prg_banks: [0, 0, 0, 0],
prg_ram_enabled: false,
wram_chip_enabled: false,
prg_slot_selects_ram: false,
chr_banks: [0, 1, 2, 3, 4, 5, 6, 7],
irq: CpuCycleIrq::new(CpuCycleIrqMode::DownUnderflow),
irq_counter_enabled: false,
};
mapper.update_banks();
mapper
}
fn update_banks(&mut self) {
// PRG: $6000-$7FFF = switchable 8KB ROM bank (when RAM not enabled)
self.base.select_prg_6000_page(self.prg_banks[0] as i16);
// PRG: $8000-$FFFF = 4 x 8KB slots
self.base.select_prg_page(0, self.prg_banks[1] as i16);
self.base.select_prg_page(1, self.prg_banks[2] as i16);
self.base.select_prg_page(2, self.prg_banks[3] as i16);
self.base.select_prg_page(3, -1); // fixed last
// CHR: 8 x 1KB slots
for i in 0..8 {
self.base.select_chr_page(i, self.chr_banks[i] as i16);
}
}
fn write_command(&mut self, value: u8) {
self.command = value & 0x0F;
trace_mapper!(1; "[fme7] Command set to ${:02X}", self.command);
}
fn write_parameter(&mut self, value: u8) {
trace_mapper!(1; "[fme7] Command ${:02X} <- ${:02X}", self.command, value);
match self.command {
0x00..=0x07 => {
// CHR bank select (1KB each)
self.chr_banks[self.command as usize] = value;
}
0x08 => {
// PRG bank 0 ($6000-$7FFF)
// Bit 7: RAM chip-enable (E) — 6264 +CE line
// Bit 6: RAM/ROM select (R) — 0 = ROM, 1 = RAM
// Bits 5-0: Bank number (applied even when RAM is selected)
// RAM accessible only when BOTH E=1 AND R=1.
let chip_enabled = (value & 0x80) != 0;
let ram_selected = (value & 0x40) != 0;
self.wram_chip_enabled = chip_enabled;
self.prg_slot_selects_ram = ram_selected;
self.prg_ram_enabled = chip_enabled && ram_selected;
self.prg_banks[0] = value & 0x3F;
}
0x09..=0x0B => {
// PRG banks 1-3 ($8000-$9FFF, $A000-$BFFF, $C000-$DFFF)
let bank_index = (self.command - 0x09 + 1) as usize;
self.prg_banks[bank_index] = value & 0x3F;
}
0x0C => {
// Mirroring
self.base.set_mirroring(match value & 0x03 {
0 => NametableLayout::Vertical,
1 => NametableLayout::Horizontal,
2 => NametableLayout::SingleScreenLower,
3 => NametableLayout::SingleScreenUpper,
_ => unreachable!(),
});
}
0x0D => {
// IRQ control
// Per NESdev: "All writes to this register acknowledge an active IRQ."
self.irq.acknowledge();
self.irq.set_enabled((value & 0x01) != 0);
self.irq_counter_enabled = (value & 0x80) != 0;
trace_mapper!(1; "[fme7] IRQ enabled={}, counter_enabled={}",
self.irq.enabled(), self.irq_counter_enabled);
}
0x0E => {
// IRQ counter low byte
self.irq
.set_counter((self.irq.counter() & 0xFF00) | (value as u16));
trace_mapper!(1; "[fme7] IRQ counter low <- ${:02X}, counter now ${:04X}",
value, self.irq.counter());
}
0x0F => {
// IRQ counter high byte
self.irq
.set_counter((self.irq.counter() & 0x00FF) | ((value as u16) << 8));
trace_mapper!(1; "[fme7] IRQ counter high <- ${:02X}, counter now ${:04X}",
value, self.irq.counter());
}
_ => {}
}
self.update_banks();
}
/// Compute the wrapped PRG-RAM byte offset for a CPU address in $6000–$7FFF.
///
/// FME-7 always drives the bank-number address lines even when RAM is selected,
/// so bank N maps to offset `N * 8KB + page_offset`. The offset wraps modulo
/// total RAM size when N >= the physical bank count.
fn banked_ram_offset(&self, addr: u16) -> usize {
let raw_offset = (self.prg_banks[0] as usize * 0x2000) + (addr as usize - 0x6000);
let ram_size = self.base.wram_size();
if ram_size > 0 {
raw_offset % ram_size
} else {
raw_offset
}
}
}
impl Mapper for SunsoftFme7Mapper {
fn base(&self) -> &BaseMapper {
&self.base
}
fn base_mut(&mut self) -> &mut BaseMapper {
&mut self.base
}
fn read_prg(&self, addr: u16) -> u8 {
match addr {
0x6000..=0x7FFF => {
if self.prg_ram_enabled {
self.base
.read_prg_ram_at_offset(self.banked_ram_offset(addr))
} else {
self.base.try_read_prg_6000(addr).unwrap_or(0)
}
}
0x8000..=0xFFFF => self.base.read_prg_rom(addr),
_ => 0,
}
}
fn read_prg_open_bus(&self, addr: u16, open_bus: u8) -> u8 {
// Per NESdev FME-7 spec: when RAM is selected but the chip is disabled
// ($40-$7F in reg $08), neither RAM nor ROM drives the bus.
if matches!(addr, 0x6000..=0x7FFF) && self.prg_slot_selects_ram && !self.wram_chip_enabled {
return open_bus;
}
self.base
.read_prg_open_bus(addr, open_bus, |a| self.read_prg(a))
}
fn write_prg(&mut self, addr: u16, value: u8) {
match addr {
0x6000..=0x7FFF => {
// PRG-RAM writes (when RAM is fully enabled: E=1 AND R=1)
if self.prg_ram_enabled {
let offset = self.banked_ram_offset(addr);
self.base.write_prg_ram_at_offset(offset, value);
}
}
0x8000..=0x9FFF => {
// Command register
self.write_command(value);
}
0xA000..=0xBFFF => {
// Parameter register
self.write_parameter(value);
}
_ => {}
}
}
fn cpu_cycle(&mut self) {
// IRQ counter decrements every CPU cycle when counter enable (bit 7) is set
// IRQ triggers on underflow only if IRQ enable (bit 0) is also set
if self.irq_counter_enabled {
self.irq.tick();
}
}
fn irq_pending(&self) -> bool {
self.irq.is_pending()
}
fn registers_snapshot(&self) -> Vec<u8> {
// Serialize Sunsoft FME-7 internal registers:
// [0]: command
// [1-4]: prg_banks[0-3]
// [5-12]: chr_banks[0-7]
// [13]: flags byte — original IRQ bits preserved at 2-4 for save-state
// compatibility; new WRAM bits placed in previously-unused positions:
// bit 0 = prg_ram_enabled (E=1 AND R=1)
// bit 1 = wram_chip_enabled (E bit) ← NEW (was unused)
// bit 2 = irq_enabled ← UNCHANGED from original
// bit 3 = irq_counter_enabled ← UNCHANGED from original
// bit 4 = irq_pending ← UNCHANGED from original
// bit 5 = prg_slot_selects_ram (R bit) ← NEW (was unused)
// [14-15]: irq_counter (little endian)
// [16]: mirroring
let mut snapshot = Vec::with_capacity(17);
snapshot.push(self.command);
snapshot.extend_from_slice(&self.prg_banks);
snapshot.extend_from_slice(&self.chr_banks);
let flags = (self.prg_ram_enabled as u8)
| ((self.wram_chip_enabled as u8) << 1)
| ((self.irq.enabled() as u8) << 2)
| ((self.irq_counter_enabled as u8) << 3)
| ((self.irq.is_pending() as u8) << 4)
| ((self.prg_slot_selects_ram as u8) << 5);
snapshot.push(flags);
snapshot.push((self.irq.counter() & 0xFF) as u8);
snapshot.push((self.irq.counter() >> 8) as u8);
snapshot.push(self.base.mirroring().to_snapshot_byte());
snapshot
}
fn restore_registers(&mut self, data: &[u8]) {
if data.len() >= 17 {
self.command = data[0];
self.prg_banks.copy_from_slice(&data[1..5]);
self.chr_banks.copy_from_slice(&data[5..13]);
let flags = data[13];
self.prg_ram_enabled = (flags & 0x01) != 0;
self.wram_chip_enabled = (flags & 0x02) != 0;
self.irq.set_enabled((flags & 0x04) != 0);
self.irq_counter_enabled = (flags & 0x08) != 0;
self.irq.set_pending((flags & 0x10) != 0);
self.prg_slot_selects_ram = (flags & 0x20) != 0;
self.irq
.set_counter((data[14] as u16) | ((data[15] as u16) << 8));
self.base
.set_mirroring(NametableLayout::from_snapshot_byte(data[16]));
self.update_banks();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nes::cartridge::mapper::{MapperContext, create_mapper};
use crate::nes::cartridge::test_helpers::banked_data;
#[test]
fn test_mapper_69_is_wired_in_factory() {
let prg_rom = banked_data(8 * 1024, 16);
let chr_rom = banked_data(1024, 8);
let mapper = create_mapper(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
assert!(mapper.is_ok(), "Mapper 69 should be implemented");
}
#[test]
fn test_prg_banking() {
let prg_rom = banked_data(8 * 1024, 16); // 16 x 8KB banks
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Set bank 1 ($8000-$9FFF) to bank 5
mapper.write_prg(0x8000, 0x09); // Command 9 = PRG bank 1
mapper.write_prg(0xA000, 0x05); // Parameter = bank 5
// Read from $8000 should return data from bank 5
assert_eq!(mapper.read_prg(0x8000), 5);
}
#[test]
fn test_chr_banking() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 64); // 64 x 1KB banks
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Set CHR bank 0 to bank 10
mapper.write_prg(0x8000, 0x00); // Command 0 = CHR bank 0
mapper.write_prg(0xA000, 0x0A); // Parameter = bank 10
// Read from $0000 should return data from bank 10
assert_eq!(mapper.read_chr(0x0000), 10);
}
#[test]
fn test_prg_ram_access() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Initially RAM is disabled: write is ignored, $6000 returns ROM bank 0
mapper.write_prg(0x6000, 0x42);
assert_eq!(mapper.read_prg(0x6000), 0); // ROM bank 0
// Enable RAM: requires BOTH bit 7 (chip-enable) AND bit 6 (RAM select)
mapper.write_prg(0x8000, 0x08); // Command 8 = PRG bank 0
mapper.write_prg(0xA000, 0xC0); // E=1, R=1 → writable RAM
// Now writes should work
mapper.write_prg(0x6000, 0x42);
assert_eq!(mapper.read_prg(0x6000), 0x42);
}
/// FME-7 has no write-protect mode: when RAM is enabled ($C0), writes always succeed.
/// Switching to $80 (E=1, R=0) exposes ROM at $6000 instead of RAM.
#[test]
fn test_prg_ram_switching_between_ram_and_rom() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Enable RAM with $C0 (E=1, R=1) and write
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0xC0); // RAM enabled + selected
mapper.write_prg(0x6000, 0x42);
assert_eq!(mapper.read_prg(0x6000), 0x42);
// Switch to ROM ($80: E=1, R=0) — $6000 maps to ROM bank 0
mapper.write_prg(0xA000, 0x80);
assert_eq!(mapper.read_prg(0x6000), 0); // ROM bank 0, not RAM
// Back to RAM ($C0): previous write preserved
mapper.write_prg(0xA000, 0xC0);
assert_eq!(mapper.read_prg(0x6000), 0x42);
}
#[test]
fn test_mirroring() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
assert_eq!(mapper.get_mirroring(), NametableLayout::Horizontal);
// Change to vertical
mapper.write_prg(0x8000, 0x0C); // Command C = mirroring
mapper.write_prg(0xA000, 0x00); // 0 = vertical
assert_eq!(mapper.get_mirroring(), NametableLayout::Vertical);
// Change to single screen lower
mapper.write_prg(0x8000, 0x0C);
mapper.write_prg(0xA000, 0x02); // 2 = single screen lower
assert_eq!(mapper.get_mirroring(), NametableLayout::SingleScreenLower);
}
#[test]
fn test_irq_countdown() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Set counter to 10
mapper.write_prg(0x8000, 0x0E); // Command E = IRQ counter low
mapper.write_prg(0xA000, 0x0A); // Counter = 10
mapper.write_prg(0x8000, 0x0F); // Command F = IRQ counter high
mapper.write_prg(0xA000, 0x00); // Counter high = 0
// Enable IRQ and counter
mapper.write_prg(0x8000, 0x0D); // Command D = IRQ control
mapper.write_prg(0xA000, 0x81); // Enable IRQ (bit 0) and counter (bit 7)
assert!(!mapper.irq_pending());
// Countdown should decrement each CPU cycle
// After 10 cycles, counter goes from 10 -> 9 -> ... -> 1 -> 0
for _ in 0..10 {
mapper.cpu_cycle();
assert!(!mapper.irq_pending());
}
// After one more cycle, counter underflows from 0 to $FFFF and IRQ triggers
mapper.cpu_cycle();
assert!(mapper.irq_pending());
}
#[test]
fn test_irq_disabled() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Set counter to 5
mapper.write_prg(0x8000, 0x0E);
mapper.write_prg(0xA000, 0x05);
mapper.write_prg(0x8000, 0x0F);
mapper.write_prg(0xA000, 0x00);
// Enable counter but not IRQ
mapper.write_prg(0x8000, 0x0D);
mapper.write_prg(0xA000, 0x80); // Counter enabled (bit 7), IRQ disabled (bit 0 = 0)
// Counter should count down even with IRQ disabled
// After 5 cycles: 5->4->3->2->1->0
for _ in 0..5 {
mapper.cpu_cycle();
assert!(!mapper.irq_pending()); // IRQ should not trigger
}
// After one more cycle, counter underflows to $FFFF but IRQ still shouldn't trigger
mapper.cpu_cycle();
assert!(!mapper.irq_pending());
// Now enable IRQ - it should trigger immediately since counter already underflowed
mapper.write_prg(0x8000, 0x0D);
mapper.write_prg(0xA000, 0x81); // Enable both counter and IRQ
// IRQ should not trigger immediately on enable (counter is already at $FFFF-1 or similar)
// But on next underflow it will trigger
assert!(!mapper.irq_pending());
}
#[test]
fn test_last_prg_bank_fixed() {
let prg_rom = banked_data(8 * 1024, 16); // 16 x 8KB banks
let chr_rom = banked_data(1024, 8);
let mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// $E000-$FFFF should read from last bank (bank 15)
assert_eq!(mapper.read_prg(0xE000), 15);
}
#[test]
fn test_fme7_registers_snapshot_restores_mirroring_and_banks() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom.clone(),
chr_rom.clone(),
NametableLayout::Horizontal,
));
// Set PRG bank 1 and CHR bank 0.
mapper.write_prg(0x8000, 0x09);
mapper.write_prg(0xA000, 0x03);
mapper.write_prg(0x8000, 0x00);
mapper.write_prg(0xA000, 0x04);
// Set mirroring to single screen upper.
mapper.write_prg(0x8000, 0x0C);
mapper.write_prg(0xA000, 0x03);
let regs = mapper.registers_snapshot();
let mut restored = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Vertical,
));
restored.restore_registers(®s);
assert_eq!(restored.get_mirroring(), NametableLayout::SingleScreenUpper);
assert_eq!(restored.read_prg(0x8000), 3);
assert_eq!(restored.read_chr(0x0000), 4);
}
// -----------------------------------------------------------------------
// WRAM snapshot/restore
// -----------------------------------------------------------------------
#[test]
fn test_wram_snapshot_round_trip() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom.clone(),
chr_rom.clone(),
NametableLayout::Horizontal,
));
// Enable RAM (command 8, bit 7 = chip-enable AND bit 6 = RAM select)
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0xC0); // E=1, R=1 → writable RAM
mapper.write_prg(0x6000, 0x11);
mapper.write_prg(0x6100, 0x22);
mapper.write_prg(0x7FFF, 0x33);
let snapshot = mapper.wram_snapshot();
assert_eq!(snapshot.len(), 8192);
assert_eq!(snapshot[0x0000], 0x11);
assert_eq!(snapshot[0x0100], 0x22);
assert_eq!(snapshot[0x1FFF], 0x33);
let mut mapper2 = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
mapper2.load_wram_snapshot(&snapshot);
// Enable RAM to read back
mapper2.write_prg(0x8000, 0x08);
mapper2.write_prg(0xA000, 0xC0); // E=1, R=1 → writable RAM
assert_eq!(mapper2.read_prg(0x6000), 0x11);
assert_eq!(mapper2.read_prg(0x6100), 0x22);
assert_eq!(mapper2.read_prg(0x7FFF), 0x33);
}
// -----------------------------------------------------------------------
// Bug-regression tests: correct register $08 and $0D behaviour per spec
// -----------------------------------------------------------------------
/// Per NESdev: bit 7 = RAM chip-enable (E), bit 6 = RAM/ROM select (R).
/// RAM is accessible only when BOTH bits are set ($C0 or higher with bits 7+6).
/// $C0 → E=1, R=1 → writable RAM at $6000.
#[test]
fn test_prg_ram_c0_value_enables_writable_ram() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0xC0); // E=1, R=1 → writable RAM
mapper.write_prg(0x6000, 0x55);
assert_eq!(mapper.read_prg(0x6000), 0x55);
}
/// $80 → E=1, R=0 → ROM at $6000 (not RAM).
#[test]
fn test_prg_ram_80_value_maps_rom_not_ram() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Pre-load known data into RAM via snapshot so the test doesn't
// depend on write-enable being correct yet.
let snapshot = vec![0xAA_u8; 8192];
mapper.load_wram_snapshot(&snapshot);
// With $80: E=1, R=0 → ROM at $6000, not RAM
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0x80);
// ROM bank 0, byte 0 = 0 (banked_data pattern), not RAM value $AA
assert_eq!(mapper.read_prg(0x6000), 0);
}
/// Per NESdev: "All writes to this register acknowledge an active IRQ."
/// Writing to $0D with bit 0 = 0 (IRQ disabled) must still clear a
/// pending IRQ — the current code's `if self.irq.enabled()` guard is wrong.
#[test]
fn test_irq_reg_0d_any_write_acknowledges() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Configure counter = 5 and fire IRQ
mapper.write_prg(0x8000, 0x0E);
mapper.write_prg(0xA000, 0x05); // counter low = 5
mapper.write_prg(0x8000, 0x0F);
mapper.write_prg(0xA000, 0x00); // counter high = 0
mapper.write_prg(0x8000, 0x0D);
mapper.write_prg(0xA000, 0x81); // enable counter (bit 7) + enable IRQ (bit 0)
for _ in 0..6 {
mapper.cpu_cycle(); // counter 5→4→3→2→1→0→underflow
}
assert!(
mapper.irq_pending(),
"IRQ should be pending before acknowledge"
);
// Write $0D = $80 (IRQ disabled, counter enabled) — must acknowledge
mapper.write_prg(0x8000, 0x0D);
mapper.write_prg(0xA000, 0x80);
assert!(
!mapper.irq_pending(),
"IRQ must be cleared by any write to $0D"
);
}
/// When 32KB of RAM is provided (4 banks of 8KB), each bank must be
/// independently addressable via bits 5-0 of register $08.
#[test]
fn test_prg_ram_banked_access_with_32kb_ram() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(
MapperContext::new_for_test(69, prg_rom, chr_rom, NametableLayout::Horizontal)
.with_prg_ram_banks(4), // 4 × 8KB = 32KB
);
// Write distinct values to bank 0 and bank 1
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0xC0); // bank 0, RAM enabled+selected
mapper.write_prg(0x6000, 0xAA);
mapper.write_prg(0xA000, 0xC1); // bank 1, RAM enabled+selected
mapper.write_prg(0x6000, 0xBB);
// Read back bank 0: must return 0xAA, not 0xBB
mapper.write_prg(0xA000, 0xC0);
assert_eq!(mapper.read_prg(0x6000), 0xAA);
// Read back bank 1: must return 0xBB
mapper.write_prg(0xA000, 0xC1);
assert_eq!(mapper.read_prg(0x6000), 0xBB);
}
/// When the mapper's bank index exceeds the physical bank count, accesses
/// must wrap modulo the RAM size — hardware drives the address lines and the
/// RAM chip ignores the out-of-range upper bits.
/// E.g. with 2 × 8KB banks (16KB): bank 2 wraps to bank 0.
#[test]
fn test_prg_ram_bank_index_wraps_at_ram_size() {
let prg_rom = banked_data(8 * 1024, 8);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(
MapperContext::new_for_test(69, prg_rom, chr_rom, NametableLayout::Horizontal)
.with_prg_ram_banks(2), // 2 × 8KB = 16KB, so bank 2 wraps to bank 0
);
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0xC0); // bank 0, RAM enabled+selected
mapper.write_prg(0x6000, 0xAA);
// Bank 2 should wrap to physical bank 0 (2 mod 2 = 0)
mapper.write_prg(0xA000, 0xC2); // bank 2
mapper.write_prg(0x6000, 0xBB); // overwrites physical bank 0
// Reading bank 0 must see BB (overwritten via bank 2)
mapper.write_prg(0xA000, 0xC0);
assert_eq!(mapper.read_prg(0x6000), 0xBB);
// Reading bank 2 must also see BB (same physical location)
mapper.write_prg(0xA000, 0xC2);
assert_eq!(mapper.read_prg(0x6000), 0xBB);
}
// --- WRAM chip-enable (E bit) open-bus tests ---
/// When E=0 (chip-enable bit 7 of reg $08 is clear), $6000-$7FFF should
/// return open bus — neither RAM nor ROM is driven onto the data bus.
/// Per NESdev FME-7 spec: E=0 → open bus; E=1,R=0 → ROM; E=1,R=1 → RAM.
#[test]
fn test_wram_chip_disabled_returns_open_bus_not_ram_data() {
let prg_rom = banked_data(8 * 1024, 16);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// Enable WRAM (E=1, R=1 = $C0) and write a sentinel value
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0xC0);
mapper.write_prg(0x6000, 0x5A);
assert_eq!(
mapper.read_prg(0x6000),
0x5A,
"precondition: RAM write visible"
);
// Disable chip-enable: E=0, R=1 ($40) — neither RAM nor ROM mapped
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0x40);
// When reading $6000-$7FFF via open-bus path
let open_bus_value: u8 = 0xFF;
let result = mapper.read_prg_open_bus(0x6000, open_bus_value);
// Then: open bus is returned, not the written RAM value
assert_eq!(result, open_bus_value, "E=0 should return open bus");
assert_ne!(result, 0x5A, "E=0 must not expose RAM data");
}
#[test]
fn test_wram_chip_disabled_with_rom_select_returns_open_bus() {
// E=0, R=1 ($40-$7F): RAM selected but chip disabled → open bus.
// Per NESdev spec: "Open bus occurs if the RAM/ROM Select Bit is 1 (RAM selected),
// but the RAM Enable Bit is 0 (disabled), i.e. any value in the range $40-$7F."
let prg_rom = banked_data(8 * 1024, 16);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
// $40: E=0, R=1 → open bus
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0x40); // E=0, R=1
let open_bus_value: u8 = 0xAB;
assert_eq!(
mapper.read_prg_open_bus(0x6000, open_bus_value),
open_bus_value,
"E=0,R=1 ($40) should return open bus"
);
assert_eq!(
mapper.read_prg_open_bus(0x7FFF, open_bus_value),
open_bus_value,
"E=0,R=1 open bus at $7FFF"
);
// $00: E=0, R=0 → ROM bank at $6000 (NOT open bus)
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0x00); // E=0, R=0 → ROM
let result = mapper.read_prg_open_bus(0x6000, open_bus_value);
assert_ne!(
result, open_bus_value,
"E=0,R=0 ($00) should map ROM not return open bus"
);
}
#[test]
fn test_wram_rom_select_still_reads_rom_when_chip_enabled() {
// E=1, R=0 ($80): chip enabled but ROM selected — $6000 maps to ROM bank
// This verifies that the E=1/R=0 path still returns ROM, not open bus.
let prg_rom = banked_data(8 * 1024, 16);
let chr_rom = banked_data(1024, 8);
let mut mapper = SunsoftFme7Mapper::new(MapperContext::new_for_test(
69,
prg_rom,
chr_rom,
NametableLayout::Horizontal,
));
mapper.write_prg(0x8000, 0x08);
mapper.write_prg(0xA000, 0x80); // E=1, R=0 → ROM bank 0
let open_bus_value: u8 = 0xFF;
// ROM bank 0 byte 0 is known (banked_data fills each bank with its index)
let result = mapper.read_prg_open_bus(0x6000, open_bus_value);
// Should NOT return open bus — chip is enabled, ROM is mapped
assert_ne!(
result, open_bus_value,
"E=1,R=0 should NOT return open bus — ROM is mapped"
);
}
}