Skip to content

Commit 495fa69

Browse files
trekawekclaude
andcommitted
Add the Sachen MMC multicart mapper and unknown-type fallbacks
- Sachen MMC1/MMC2 multicarts (the 4B-xxx 4-in-1 collections and the Rocket Games two-in-one carts) get a mapper with the base/mask/bank register scheme: the switchable window's bank is (base & mask) | (bank & ~mask), the fixed window maps the base bank. Detected by their scrambled boot logo (MMC2 carts hide a valid CGB half-logo at a later bank's header, which is also their power-on base bank). The real cart's header-descrambling lockout is not modelled; the boot falls back to the post-boot presets. 4 more of the 8 attached 4B collections now reach their menus and play (issues #73/#74/#75). - An unknown cartridge-type byte (Pocket Voice's 0xBE and friends) falls back to MBC5 banking instead of refusing to load; Pocket Voice V2.0 now boots to its recorder UI (issue #71). - The Wisdom Tree heuristic no longer grabs type-0 dumps under 128 KB: those are treated as plain 32 KB ROMs like on hardware (issue #76). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1wLWscyUGS7CFwc3CjJR8
1 parent b6e9174 commit 495fa69

3 files changed

Lines changed: 139 additions & 4 deletions

File tree

core/src/main/java/eu/rekawek/coffeegb/core/memory/cart/Cartridge.java

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public Cartridge(Rom rom, Battery battery) {
3535
addressSpace = new Mmm01(rom, battery, false);
3636
} else if (isDuzMulticart(rom)) {
3737
addressSpace = new DuzMulticart(rom, battery);
38+
} else if (sachenBase(rom) >= 0) {
39+
addressSpace = new SachenMmc(rom, sachenBase(rom));
3840
} else if (type.isHuc1()) {
3941
addressSpace = new Huc1(rom, battery);
4042
} else if (type.isHuc3()) {
@@ -60,9 +62,10 @@ public Cartridge(Rom rom, Battery battery) {
6062
// bad Nintendo logo (the ASIC swaps it after the boot check on hardware) and
6163
// banking registers at 0x7FE0/0x7FE1 (issue #66)
6264
addressSpace = new Datel(rom, battery);
63-
} else if (rom.getRom().length > 0x8000) {
64-
// "ROM only" carts larger than 32 KB are Wisdom Tree style unlicensed
65-
// mappers with a single switchable 32 KB window (issue #61)
65+
} else if (rom.getRom().length >= 0x20000) {
66+
// "ROM only" carts of 128 KB and more are Wisdom Tree style unlicensed
67+
// mappers with a single switchable 32 KB window (issue #61); smaller
68+
// oversized type-0 dumps are treated as plain 32 KB ROMs like on hardware
6669
addressSpace = new WisdomTree(rom);
6770
} else {
6871
addressSpace = new BasicRom(rom);
@@ -122,6 +125,38 @@ private static boolean isDuzMulticart(Rom rom) {
122125
return false;
123126
}
124127

128+
/**
129+
* Detects the Sachen MMC1/MMC2 multicarts (issues #73/#74/#75) and returns the
130+
* power-on base bank, or -1 for regular carts. The 4B collections have a header
131+
* logo that starts with the first logo byte but is otherwise scrambled (the real
132+
* mapper unscrambles it for the boot ROM); the MMC2 carts hide a valid (CGB,
133+
* half-checked) logo at the header of a later bank, which is also their boot base.
134+
*/
135+
private static int sachenBase(Rom rom) {
136+
int[] data = rom.getRom();
137+
if (hasValidLogo(rom) || data.length < 0x10000) {
138+
return -1;
139+
}
140+
// MMC2: a bank >0 carries the real (half) logo
141+
for (int bank = 1; bank * 0x4000 + 0x134 <= data.length; bank++) {
142+
boolean match = true;
143+
for (int i = 0; i < NINTENDO_LOGO.length / 2; i++) {
144+
if (data[bank * 0x4000 + 0x104 + i] != NINTENDO_LOGO[i]) {
145+
match = false;
146+
break;
147+
}
148+
}
149+
if (match) {
150+
return bank;
151+
}
152+
}
153+
// MMC1 (4B-xxx): scrambled logo that keeps the first byte
154+
if (data[0x104] == 0xce && data[0x105] != 0xed) {
155+
return 0;
156+
}
157+
return -1;
158+
}
159+
125160
private static boolean hasValidLogo(Rom rom) {
126161
int[] data = rom.getRom();
127162
for (int i = 0; i < NINTENDO_LOGO.length; i++) {

core/src/main/java/eu/rekawek/coffeegb/core/memory/cart/Rom.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,17 @@ public Rom(byte[] romByteArray, File romFile) throws IOException {
4747
rom[i] = romByteArray[i] & 0xFF;
4848
}
4949

50-
cartridgeType = CartridgeType.getById(rom[0x0147]);
50+
CartridgeType type;
51+
try {
52+
type = CartridgeType.getById(rom[0x0147]);
53+
} catch (IllegalArgumentException e) {
54+
// unknown/custom mapper byte (unlicensed carts like Pocket Voice, Sachen):
55+
// fall back to MBC5 banking instead of refusing to load (issue #71)
56+
LOG.warn("Unsupported cartridge type {}, falling back to MBC5",
57+
Integer.toHexString(rom[0x0147]));
58+
type = CartridgeType.getById(0x19);
59+
}
60+
cartridgeType = type;
5161
title = getTitle(rom);
5262
LOG.debug("Cartridge {}, type: {}", title, cartridgeType);
5363
gameboyColorFlag = GameboyColorFlag.getFlag(rom[0x0143]);
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package eu.rekawek.coffeegb.core.memory.cart.type;
2+
3+
import eu.rekawek.coffeegb.core.memento.Memento;
4+
import eu.rekawek.coffeegb.core.memory.cart.MemoryController;
5+
import eu.rekawek.coffeegb.core.memory.cart.Rom;
6+
7+
/**
8+
* The Sachen MMC1/MMC2 multicart mapper ("4B-xxx" 4-in-1 collections and the two-game
9+
* MMC2 carts, issues #73/#74/#75). Three registers control the mapping:
10+
*
11+
* <ul>
12+
* <li>{@code 0x2000-0x3FFF} - ROM bank of the switchable 0x4000-0x7FFF window;</li>
13+
* <li>{@code 0x4000-0x5FFF} - ROM bank mask: bits set in the mask come from the base
14+
* register, the rest from the bank register (the multicart "cage");</li>
15+
* <li>{@code 0x6000-0x7FFF} - base ROM bank, also mapped at 0x0000-0x3FFF.</li>
16+
* </ul>
17+
*
18+
* <p>The header-scrambling lockout of the real cart (which presents a fake logo to the
19+
* boot ROM) is not modelled; the FAST_FORWARD boot timeout falls back to the post-boot
20+
* presets instead. MMC2 carts boot with a base of 0x10, where their (CGB, half-size)
21+
* boot logo lives.
22+
*/
23+
public class SachenMmc implements MemoryController {
24+
25+
private final int[] rom;
26+
27+
private final int romBanks;
28+
29+
private int bank = 1;
30+
31+
private int mask;
32+
33+
private int base;
34+
35+
public SachenMmc(Rom rom, int initialBase) {
36+
this.rom = rom.getRom();
37+
this.romBanks = Math.max(2, this.rom.length / 0x4000);
38+
this.base = initialBase;
39+
}
40+
41+
@Override
42+
public boolean accepts(int address) {
43+
return address >= 0x0000 && address < 0x8000;
44+
}
45+
46+
@Override
47+
public void setByte(int address, int value) {
48+
if (address >= 0x2000 && address < 0x4000) {
49+
bank = value & 0xff;
50+
} else if (address >= 0x4000 && address < 0x6000) {
51+
mask = value & 0xff;
52+
} else if (address >= 0x6000 && address < 0x8000) {
53+
base = value & 0xff;
54+
}
55+
}
56+
57+
@Override
58+
public int getByte(int address) {
59+
if (address < 0x4000) {
60+
return rom[(base % romBanks) * 0x4000 + address];
61+
} else if (address < 0x8000) {
62+
int b = bank == 0 ? 1 : bank;
63+
int effective = ((base & mask) | (b & ~mask)) % romBanks;
64+
return rom[effective * 0x4000 + (address - 0x4000)];
65+
}
66+
return 0xff;
67+
}
68+
69+
@Override
70+
public void flushRam() {
71+
}
72+
73+
@Override
74+
public Memento<MemoryController> saveToMemento() {
75+
return new SachenMemento(bank, mask, base);
76+
}
77+
78+
@Override
79+
public void restoreFromMemento(Memento<MemoryController> memento) {
80+
if (!(memento instanceof SachenMemento mem)) {
81+
throw new IllegalArgumentException("Invalid memento type");
82+
}
83+
this.bank = mem.bank;
84+
this.mask = mem.mask;
85+
this.base = mem.base;
86+
}
87+
88+
private record SachenMemento(int bank, int mask, int base) implements Memento<MemoryController> {
89+
}
90+
}

0 commit comments

Comments
 (0)