rustynes_core/vs_db.rs
1//! Vs. System per-game database (v2.7.0).
2//!
3//! Closes two gaps in Vs. System support:
4//!
5//! 1. **PPU palette.** iNES-1.0 dumps carry no NES 2.0 byte-13, so the cartridge
6//! parser defaults every Vs. cart to [`VsPpuType::Rp2C03`]
7//! ([`crate::rustynes_mappers::parse`] §arcade detection). Many Vs. games used a
8//! 2C04-000x or RC2C03 PPU whose colour LUT differs from the 2C03's, so an
9//! iNES-1.0 dump renders with the wrong colours. This table supplies the
10//! correct [`VsPpuType`] keyed on the ROM's identity (see "Keys" below); the
11//! frontend applies it
12//! via [`crate::Nes::set_vs_ppu_type`] (always — the DB is authoritative for
13//! the palette).
14//!
15//! 2. **DIP-switch presets.** Vs. arcade games read an 8-bit DIP bank through
16//! the upper bits of `$4016`/`$4017` (coinage, difficulty, lives, PPU-type,
17//! etc.). The frontend's default DIP is `0`, which is not always the
18//! factory-shipment setting; this table supplies each game's documented
19//! default so the frontend can apply it when the user has not set an explicit
20//! `[vs] dip` (precedence: explicit config dip > DB > 0).
21//!
22//! ## DIP-byte encoding
23//!
24//! The `vs_dip` byte here is in **this emulator's** encoding: DIP switch 1 =
25//! bit 0 .. DIP switch 8 = bit 7. The bus overlay maps DIP1 -> `$4016` bit 3,
26//! DIP2 -> `$4016` bit 4, and DIP3..8 -> `$4017` bits 2..7 (see
27//! `SystemBus::vs_overlay_4016` / `vs_overlay_4017`). Each value below is
28//! MAME's documented `DSW0` factory default for the corresponding game
29//! (the bitwise-OR of the per-field `PORT_DIPNAME` defaults in MAME's
30//! `src/mame/nintendo/vsnes.cpp`), which is exactly the `vs_dip` byte the
31//! overlay consumes (switch 1 = bit 0). On the real dual-system boards there is
32//! a second DIP bank (`DSW1`, for the sub-CPU); this single-CPU model only
33//! exposes `DSW0`.
34//!
35//! ## Sources
36//!
37//! - DIP defaults: MAME `src/mame/nintendo/vsnes.cpp` `INPUT_PORTS_START`
38//! blocks (`PORT_DIPNAME(<mask>, <default>, ...)`).
39//! - PPU types: MAME `src/mame/nintendo/vsnes.cpp` — the per-game `ROM_START`
40//! block's `PALETTE_2C04_000x` / `PALETTE_STANDARD` macro is the authoritative
41//! PPU assignment (each `.pal` ROM is dumped from real hardware). The fceux
42//! `src/vsuni.cpp` "Games/PPU list. Information copied from MAME" table is a
43//! secondary cross-check; both agree for every game below. Verified
44//! 2026-06-11 against MAME `master`. (For dual-system carts the `ppu1`
45//! master-CPU PALETTE is used.)
46//!
47//! ## Caveats
48//!
49//! - The dual-system games (Balloon Fight / Tennis / Mahjong / Wrecking Crew)
50//! run two CPUs/PPUs, modelled since v2.0.0 beta.5 by
51//! [`crate::VsDualSystem`] (routed via [`crate::Emu::from_rom`], which
52//! consults this table's `dual_system` flag). Their `DSW0` defaults are
53//! `0x00` (Balloon Fight / Tennis / Mahjong) per MAME.
54//! - Two titles (Balloon Fight, Wrecking Crew) additionally carry a
55//! **second, COMBINED-dump entry** with a different SHA-256: the
56//! pre-existing 32 KiB-PRG entry (main-CPU program only -- boot cannot
57//! complete, the sub-CPU program is absent) coexists with a new 64 KiB-PRG
58//! entry (main half + sub half concatenated) once the sub-CPU program was
59//! located. See `docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md`. Tennis and
60//! Mahjong remain 32 KiB-only -- no sub-CPU dump has been located for
61//! either.
62//!
63//! ## Keys (v2.9.8)
64//!
65//! Each row carries two SHA-256 keys:
66//!
67//! - an **identity key**: [`crate::Nes::rom_sha256`] of the dump, the SHA-256
68//! of the bytes after its 16-byte iNES header. It is the same identity saves,
69//! states and cheats are keyed by, and it survives any edit to the header: a
70//! re-headered or corrected dump still finds its row, so it keeps its palette
71//! and DIP preset instead of falling back to the 2C03 and DIP 0. Every row
72//! whose dump is staged under `tests/roms/external/` carries one, computed
73//! from that dump; at v2.9.8 that is all 19.
74//! - an **image key**: the SHA-256 of the whole file as dumped, header
75//! included ([`crate::Nes::image_sha256`]). Until v2.9.8 it was the only
76//! key. It stays on every row, so a row added from a dump that is not staged
77//! (for which no identity can be computed) is still reachable, and it records
78//! exactly which file each row was verified against.
79//!
80//! [`lookup`] takes the [`crate::Nes`] and matches the identity key first, then
81//! the image key. The table is `&'static` and scanned linearly (19 rows,
82//! consulted once per ROM load). It is `no_std`-safe (const data only).
83
84use rustynes_mappers::VsPpuType;
85
86use crate::Nes;
87
88/// A single Vs. System per-game database entry.
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub struct VsDbEntry {
91 /// The game's factory-default DIP-switch bank, in this emulator's encoding
92 /// (switch 1 = bit 0 .. switch 8 = bit 7). MAME `DSW0` default.
93 pub vs_dip: u8,
94 /// The correct Vs. System PPU type (output palette + 2C05 quirks). Supplies
95 /// the right colour LUT for iNES-1.0 dumps that default to the 2C03.
96 pub vs_ppu_type: VsPpuType,
97 /// `true` for a Vs. **`DualSystem`** cart (two CPUs + two PPUs sharing an
98 /// inter-CPU latch — Tennis / Mahjong / Wrecking Crew / Balloon Fight).
99 /// v2.0.0 beta.5: [`crate::Emu::from_rom`] routes these to the full
100 /// two-console [`crate::VsDualSystem`] wrapper. This flag is the
101 /// load-bearing detection source for the circulating iNES-1.0 dumps,
102 /// whose headers carry no NES 2.0 byte-13 Vs. hardware type (see
103 /// `docs/audit/vs-dualsystem-design-2026-06-11.md`).
104 pub dual_system: bool,
105}
106
107/// Internal key+value record. Kept private; [`lookup`] returns the value only.
108struct Record {
109 /// [`crate::Nes::rom_sha256`] of the dump: SHA-256 of the bytes after its
110 /// 16-byte header. `None` only for a row whose dump was never staged, so
111 /// that no identity could be computed for it; such a row is reached by
112 /// [`Self::image`] alone.
113 identity: Option<[u8; 32]>,
114 /// SHA-256 of the whole dump, header included ([`crate::Nes::image_sha256`]).
115 /// The pre-v2.9.8 key, kept on every row.
116 image: [u8; 32],
117 entry: VsDbEntry,
118}
119
120const fn entry(
121 identity: Option<[u8; 32]>,
122 image: [u8; 32],
123 vs_dip: u8,
124 vs_ppu_type: VsPpuType,
125) -> Record {
126 Record {
127 identity,
128 image,
129 entry: VsDbEntry {
130 vs_dip,
131 vs_ppu_type,
132 dual_system: false,
133 },
134 }
135}
136
137/// Like [`entry`] but flags the cart as a Vs. **`DualSystem`** title.
138const fn entry_dual(
139 identity: Option<[u8; 32]>,
140 image: [u8; 32],
141 vs_dip: u8,
142 vs_ppu_type: VsPpuType,
143) -> Record {
144 Record {
145 identity,
146 image,
147 entry: VsDbEntry {
148 vs_dip,
149 vs_ppu_type,
150 dual_system: true,
151 },
152 }
153}
154
155/// The embedded database. Row order carries no meaning (it is the ascending
156/// image-key order the pre-v2.9.8 binary search needed, kept to keep history
157/// readable); no two rows share a key (enforced by a unit test).
158///
159/// Each comment records the game and the MAME `DSW0` default / PPU source.
160/// Each row's first array is its identity key, the second its image key.
161static DB: &[Record] = &[
162 // Vs. Duck Hunt (hack) -- MAME duckhunt DSW0=0x28
163 // PPU RC2C03: vsnes.cpp ROM_START(duckhunt) -> PALETTE_STANDARD (rp2c0x.pal).
164 entry(
165 Some([
166 0x6e, 0xe2, 0xbd, 0x97, 0x73, 0x96, 0xba, 0xcd, 0x6c, 0x36, 0x71, 0xa6, 0x5b, 0xd2,
167 0xcf, 0x23, 0x5a, 0xaa, 0xe0, 0x0b, 0x91, 0x06, 0x08, 0x3e, 0xc8, 0x62, 0xb8, 0x9a,
168 0x8a, 0x75, 0xfc, 0x19,
169 ]),
170 [
171 0x16, 0x0d, 0x43, 0xde, 0x97, 0x7f, 0xbc, 0x8e, 0x24, 0x11, 0x23, 0x54, 0xe7, 0x0e,
172 0x40, 0xcf, 0xf8, 0x43, 0x10, 0x16, 0x7b, 0x07, 0xfb, 0x0b, 0x65, 0xeb, 0xc8, 0x19,
173 0xfc, 0xf0, 0xca, 0x0c,
174 ],
175 0x28,
176 VsPpuType::Rp2C03,
177 ),
178 // Vs. Gradius (hack) -- MAME vsgradus DSW0=0x80
179 // PPU RP2C04-0001: vsnes.cpp ROM_START(vsgradus) -> PALETTE_2C04_0001.
180 entry(
181 Some([
182 0x7f, 0x9f, 0x79, 0x92, 0x20, 0xe2, 0x54, 0xcd, 0x3b, 0x55, 0x27, 0xc6, 0xc8, 0xe5,
183 0x09, 0xe5, 0x00, 0x08, 0xf7, 0x38, 0x95, 0xec, 0x0f, 0xd8, 0xc0, 0xf8, 0x77, 0x3c,
184 0x0f, 0x5b, 0x75, 0x92,
185 ]),
186 [
187 0x29, 0x01, 0x11, 0x92, 0x9a, 0x34, 0x10, 0x5e, 0x73, 0x56, 0x2d, 0xba, 0x99, 0x69,
188 0x01, 0x1d, 0x65, 0x2a, 0xb8, 0x17, 0x67, 0xee, 0x1c, 0x41, 0x74, 0xd2, 0x7b, 0x7c,
189 0x33, 0x95, 0xaa, 0x78,
190 ],
191 0x80,
192 VsPpuType::Rp2C04_0001,
193 ),
194 // Vs. Super Mario Bros. -- MAME suprmrio DSW0=0x10
195 // PPU RP2C04-0004: vsnes.cpp ROM_START(suprmrio) -> PALETTE_2C04_0004.
196 entry(
197 Some([
198 0xc1, 0x9d, 0x54, 0x61, 0xfa, 0x40, 0xa7, 0x0c, 0x6e, 0x50, 0xf0, 0x70, 0x96, 0x63,
199 0x80, 0x97, 0xfa, 0x85, 0x8d, 0xec, 0x83, 0x0a, 0xce, 0xf9, 0x71, 0xfb, 0x7e, 0xae,
200 0xae, 0x5f, 0x9c, 0x00,
201 ]),
202 [
203 0x2f, 0xaa, 0xb7, 0xa4, 0x83, 0xf9, 0xa3, 0x33, 0x06, 0x6c, 0x54, 0x27, 0xee, 0x78,
204 0xb8, 0xf0, 0x0a, 0xe3, 0x00, 0x31, 0x84, 0xbe, 0xd4, 0xad, 0x2f, 0xd0, 0xe0, 0xad,
205 0xa3, 0xb0, 0xb1, 0x9b,
206 ],
207 0x10,
208 VsPpuType::Rp2C04_0004,
209 ),
210 // Vs. Excitebike (hack) -- MAME excitebk DSW0=0x00
211 // PPU RP2C04-0003: vsnes.cpp ROM_START(excitebk/excitebko) -> PALETTE_2C04_0003.
212 // (US "palette 3" set; the Japanese excitebkj set is RP2C04-0004 -- a
213 // different ROM, not staged here.)
214 entry(
215 Some([
216 0x85, 0x70, 0xd6, 0xf3, 0xab, 0x7a, 0xfa, 0x49, 0x17, 0x94, 0x60, 0x5b, 0xfd, 0xa5,
217 0xd9, 0xb4, 0xa0, 0xa4, 0x38, 0xde, 0xd3, 0xb7, 0x2c, 0x5a, 0xa1, 0x19, 0x25, 0x03,
218 0x31, 0x65, 0x90, 0x81,
219 ]),
220 [
221 0x31, 0xff, 0x4e, 0x40, 0xe0, 0xac, 0x32, 0x9d, 0x20, 0x7b, 0xb6, 0xa0, 0xc3, 0xaa,
222 0x65, 0x98, 0x1e, 0xa9, 0xa3, 0x67, 0x63, 0xf6, 0x79, 0x5d, 0x14, 0xf0, 0x3f, 0x87,
223 0x4c, 0xb2, 0x35, 0xc2,
224 ],
225 0x00,
226 VsPpuType::Rp2C04_0003,
227 ),
228 // Vs. Pinball (hack) -- MAME vspinbal DSW0=0x01
229 // PPU RP2C04-0001: vsnes.cpp ROM_START(vspinbal) [US set] -> PALETTE_2C04_0001.
230 // (The Japanese vspinbalj set is RC2C03B/PALETTE_STANDARD -- not staged here.)
231 entry(
232 Some([
233 0x5c, 0x78, 0x67, 0xaa, 0xf4, 0x59, 0xce, 0x43, 0x49, 0xf1, 0x30, 0x6a, 0xb6, 0x92,
234 0xd7, 0xd2, 0xa0, 0xe9, 0xe7, 0xb3, 0x53, 0x4e, 0xc3, 0x86, 0xeb, 0x2d, 0xb2, 0x22,
235 0x37, 0x13, 0xca, 0xb2,
236 ]),
237 [
238 0x44, 0xf4, 0x34, 0x07, 0x0b, 0xf9, 0x10, 0xf4, 0xe5, 0x13, 0x5d, 0x22, 0xba, 0x65,
239 0xb8, 0xc7, 0x49, 0x2c, 0xca, 0xf3, 0x25, 0xaa, 0xc1, 0x91, 0xd0, 0xab, 0xf9, 0xac,
240 0xb3, 0xa3, 0xce, 0xc9,
241 ],
242 0x01,
243 VsPpuType::Rp2C04_0001,
244 ),
245 // Vs. Wrecking Crew (DualSystem, COMBINED 64 KiB dump: main PRG half +
246 // sub-CPU PRG half, MAME `.6d/.6c/.6b/.6a`) -- MAME wrecking DSW0=0xF8.
247 // PPU RP2C04-0002: vsnes.cpp ROM_START(wrecking) ppu1 -> PALETTE_2C04_0002.
248 // Distinct SHA-256 from the pre-existing 32 KiB-only "GVS Wrecking
249 // Crew.nes" entry below (same game, same DIP/PPU -- the dump
250 // completeness is what differs). See
251 // `docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md` for how this dump was
252 // assembled and verified.
253 entry_dual(
254 Some([
255 0x28, 0x91, 0xef, 0x4d, 0x44, 0x50, 0xd2, 0x26, 0x77, 0x94, 0x7e, 0x79, 0x8a, 0x56,
256 0x95, 0x47, 0x07, 0x5f, 0x58, 0x5f, 0xb5, 0x05, 0xd7, 0x06, 0x15, 0x4e, 0x85, 0x32,
257 0x88, 0x05, 0x08, 0xd9,
258 ]),
259 [
260 0x4c, 0xde, 0x98, 0x3b, 0x9d, 0x03, 0x40, 0x2b, 0x32, 0x4e, 0x28, 0x15, 0x18, 0x92,
261 0x68, 0x04, 0x1b, 0x31, 0x3a, 0x93, 0x77, 0xd7, 0xf0, 0x85, 0x6c, 0xf8, 0xd3, 0xa9,
262 0x79, 0x07, 0x08, 0xfe,
263 ],
264 0xF8,
265 VsPpuType::Rp2C04_0002,
266 ),
267 // Vs. Stroke & Match Golf -- MAME smgolf DSW0=0x21
268 // PPU RP2C04-0002: vsnes.cpp ROM_START(smgolf) -> PALETTE_2C04_0002.
269 entry(
270 Some([
271 0x4b, 0xda, 0x51, 0xcc, 0xcf, 0x07, 0x46, 0x77, 0xf5, 0xb9, 0x02, 0x89, 0xa8, 0x4b,
272 0xd3, 0x41, 0xb2, 0xcb, 0x7c, 0x1b, 0xbe, 0x47, 0xdc, 0x59, 0x37, 0xd6, 0x95, 0x31,
273 0x15, 0xe7, 0xbb, 0xf1,
274 ]),
275 [
276 0x50, 0x65, 0xc6, 0x9c, 0x1e, 0x8b, 0x09, 0x81, 0x8f, 0x37, 0x4d, 0xd5, 0xa3, 0xb3,
277 0x43, 0xa8, 0xde, 0x28, 0x36, 0x3a, 0xf5, 0x91, 0x60, 0x91, 0x53, 0x66, 0x33, 0x95,
278 0x52, 0x02, 0x01, 0x4c,
279 ],
280 0x21,
281 VsPpuType::Rp2C04_0002,
282 ),
283 // Vs. Tennis (DualSystem) -- MAME vstennis DSW0=0x00
284 // PPU RC2C03: vsnes.cpp ROM_START(vstennis) ppu1 -> PALETTE_STANDARD.
285 entry_dual(
286 Some([
287 0x98, 0x10, 0x7b, 0x00, 0x4d, 0x05, 0xe4, 0x5e, 0x5c, 0x5f, 0x7c, 0x62, 0x6c, 0xcf,
288 0xbf, 0x19, 0x61, 0xc5, 0x4c, 0xe5, 0x3a, 0x2b, 0x37, 0x5b, 0x2f, 0x21, 0xec, 0xae,
289 0x44, 0x9f, 0x01, 0xd0,
290 ]),
291 [
292 0x52, 0x93, 0x4e, 0x98, 0x16, 0x7d, 0xf4, 0x7d, 0xe5, 0x2a, 0xbc, 0x2c, 0x1f, 0x56,
293 0x53, 0xb5, 0x32, 0x93, 0xba, 0x66, 0x7b, 0x91, 0xd2, 0xdf, 0x2d, 0x58, 0x27, 0x41,
294 0xf5, 0x0a, 0x45, 0x9c,
295 ],
296 0x00,
297 VsPpuType::Rp2C03,
298 ),
299 // Vs. Mahjong (DualSystem) -- MAME vsmahjng DSW0=0x00
300 // PPU RC2C03: vsnes.cpp ROM_START(vsmahjng) ppu1 -> PALETTE_STANDARD.
301 entry_dual(
302 Some([
303 0x63, 0xf8, 0x55, 0x86, 0xea, 0xec, 0x6b, 0x40, 0x0b, 0x04, 0x67, 0x8e, 0xd4, 0xa9,
304 0x6c, 0x51, 0xf1, 0xb0, 0x21, 0x13, 0x20, 0x4a, 0x16, 0xab, 0x35, 0xf9, 0x3e, 0xd9,
305 0xd3, 0x09, 0xf2, 0xf6,
306 ]),
307 [
308 0x63, 0x47, 0x05, 0x57, 0xaf, 0xb7, 0xb9, 0xd5, 0x76, 0x63, 0xcc, 0xc6, 0xe9, 0xb4,
309 0xd6, 0xcd, 0x70, 0x02, 0x6e, 0xf0, 0x1a, 0x77, 0xdb, 0xb4, 0x66, 0xab, 0xa1, 0xb1,
310 0xd3, 0xe4, 0xf5, 0x12,
311 ],
312 0x00,
313 VsPpuType::Rp2C03,
314 ),
315 // Vs. Wrecking Crew (DualSystem) -- MAME wrecking DSW0=0xF8
316 // PPU RP2C04-0002: vsnes.cpp ROM_START(wrecking) ppu1 -> PALETTE_2C04_0002.
317 entry_dual(
318 Some([
319 0xcd, 0x06, 0x54, 0xbc, 0x20, 0xe4, 0x05, 0x22, 0x5a, 0x7e, 0x23, 0xca, 0xe9, 0x62,
320 0xd3, 0xb8, 0x36, 0x6b, 0xc1, 0xa0, 0x4f, 0x19, 0xc9, 0x88, 0x59, 0x7c, 0xd7, 0x2f,
321 0xc7, 0xaa, 0x25, 0xa4,
322 ]),
323 [
324 0x85, 0xd5, 0xf1, 0x74, 0xfe, 0x94, 0xcc, 0xba, 0x9d, 0x70, 0x2e, 0x01, 0xc0, 0xf7,
325 0x2d, 0xcc, 0x56, 0x9b, 0xc2, 0x44, 0x70, 0xd3, 0x4a, 0x36, 0xbb, 0xd5, 0x9a, 0xed,
326 0x9b, 0xb2, 0x9d, 0x7d,
327 ],
328 0xF8,
329 VsPpuType::Rp2C04_0002,
330 ),
331 // Vs. Balloon Fight (DualSystem, COMBINED 64 KiB dump: main PRG half +
332 // sub-CPU PRG half, MAME `.6d/.6c/.6b/.6a`) -- MAME balonfgt DSW0=0x00.
333 // PPU RP2C04-0003: vsnes.cpp ROM_START(balonfgt) ppu1 -> PALETTE_2C04_0003.
334 // Distinct SHA-256 from the pre-existing 32 KiB-only "GVS Balloon
335 // Fight.nes" entry above (same game, same DIP/PPU -- the dump
336 // completeness is what differs). See
337 // `docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md` for how this dump was
338 // assembled and verified.
339 entry_dual(
340 Some([
341 0xc4, 0x6d, 0x51, 0x57, 0x6e, 0x65, 0xae, 0xc7, 0xdf, 0xe8, 0x24, 0x59, 0x65, 0x62,
342 0x60, 0x71, 0x99, 0x9b, 0x7a, 0x73, 0x78, 0x78, 0xae, 0xac, 0x36, 0x53, 0x71, 0x7f,
343 0x1f, 0xbe, 0x94, 0xcd,
344 ]),
345 [
346 0x8b, 0x32, 0x80, 0xe6, 0x51, 0xf3, 0xf8, 0xd9, 0x9d, 0xe0, 0x46, 0xcd, 0xd2, 0x71,
347 0x0e, 0x2f, 0xf5, 0xf9, 0x4b, 0x5d, 0xd4, 0x22, 0x49, 0x82, 0xcc, 0xa4, 0xa9, 0xa7,
348 0x26, 0x44, 0x41, 0xcb,
349 ],
350 0x00,
351 VsPpuType::Rp2C04_0003,
352 ),
353 // Vs. The Goonies (hack) -- MAME goonies DSW0=0x80
354 // PPU RP2C04-0003: vsnes.cpp ROM_START(goonies) -> PALETTE_2C04_0003.
355 entry(
356 Some([
357 0x44, 0x7c, 0x57, 0x7d, 0xf5, 0x4c, 0xaf, 0x00, 0xbb, 0x28, 0xb1, 0xc3, 0xda, 0x36,
358 0x2a, 0x86, 0x76, 0x14, 0x52, 0x1e, 0x72, 0xfc, 0xd4, 0xf7, 0xdb, 0x3e, 0x58, 0x9b,
359 0xd2, 0xf5, 0xd3, 0x6b,
360 ]),
361 [
362 0xae, 0xe9, 0x8d, 0xa8, 0x5b, 0xe8, 0x10, 0x2d, 0x41, 0xbd, 0x21, 0x2a, 0xe1, 0x5d,
363 0x11, 0x40, 0x35, 0xc0, 0x8d, 0x52, 0x2b, 0xaf, 0x22, 0x2e, 0xdb, 0x12, 0x56, 0xb8,
364 0xc9, 0x3f, 0x3b, 0x7d,
365 ],
366 0x80,
367 VsPpuType::Rp2C04_0003,
368 ),
369 // Vs. T.K.O. Boxing (hack) -- MAME tkoboxng DSW0=0x00
370 // PPU RP2C04-0003: vsnes.cpp ROM_START(tkoboxng) -> PALETTE_2C04_0003.
371 entry(
372 Some([
373 0xfc, 0xee, 0xbd, 0x7a, 0x79, 0xa2, 0x42, 0x89, 0x87, 0x40, 0xf7, 0x77, 0xd9, 0x1f,
374 0x90, 0xa2, 0xe9, 0xfd, 0xd8, 0x6b, 0x51, 0x4e, 0xa9, 0x3a, 0x90, 0x9e, 0x28, 0xe0,
375 0xbe, 0xec, 0x0e, 0x0d,
376 ]),
377 [
378 0xb8, 0x15, 0x8a, 0x64, 0xa1, 0xc8, 0xb6, 0x7b, 0x53, 0x0a, 0x01, 0x06, 0x78, 0x77,
379 0x2c, 0x43, 0xb0, 0xae, 0xd7, 0x20, 0xbb, 0x28, 0xf2, 0x09, 0x4a, 0xce, 0xe2, 0xf5,
380 0x92, 0x78, 0x9c, 0xc9,
381 ],
382 0x00,
383 VsPpuType::Rp2C04_0003,
384 ),
385 // Vs. Castlevania -- MAME cstlevna DSW0=0x00
386 // PPU RP2C04-0002: vsnes.cpp ROM_START(cstlevna) -> PALETTE_2C04_0002.
387 entry(
388 Some([
389 0x37, 0xca, 0x2b, 0x68, 0x98, 0xe6, 0xc3, 0xcc, 0x96, 0xdc, 0xde, 0x5f, 0x0b, 0xf1,
390 0x72, 0x68, 0xb9, 0x07, 0xb4, 0x48, 0xa4, 0x5c, 0x58, 0xa8, 0xf1, 0xea, 0x9d, 0x2c,
391 0x34, 0xbd, 0x7c, 0x63,
392 ]),
393 [
394 0xca, 0xbc, 0x23, 0x0a, 0x7f, 0x8c, 0x36, 0x6e, 0xd4, 0x05, 0xfb, 0x83, 0x1e, 0x42,
395 0xc3, 0x58, 0xa1, 0xf1, 0x40, 0x8a, 0x42, 0x77, 0x17, 0x16, 0x1c, 0xd1, 0xdd, 0x3d,
396 0x86, 0x8d, 0x35, 0x1b,
397 ],
398 0x00,
399 VsPpuType::Rp2C04_0002,
400 ),
401 // Vs. Ice Climber (hack) -- MAME iceclimb DSW0=0x00
402 // PPU RP2C04-0004: vsnes.cpp ROM_START(iceclimb) -> PALETTE_2C04_0004.
403 entry(
404 Some([
405 0x38, 0xdb, 0x13, 0x56, 0x24, 0x3b, 0xae, 0xac, 0x8a, 0x85, 0x4d, 0x44, 0x7b, 0x5d,
406 0x42, 0xc2, 0xd5, 0x73, 0xce, 0x17, 0x52, 0xf5, 0xfc, 0x59, 0x5b, 0xb2, 0x2f, 0x86,
407 0x0d, 0xa9, 0x2f, 0x9e,
408 ]),
409 [
410 0xda, 0x2d, 0x91, 0xc8, 0x47, 0xbf, 0x59, 0x56, 0xeb, 0xe2, 0x6a, 0x0d, 0x64, 0x38,
411 0x20, 0x18, 0x9a, 0x3f, 0xa5, 0xe1, 0xdd, 0x71, 0x5d, 0xd7, 0x76, 0x77, 0x0a, 0x61,
412 0x5e, 0xf2, 0x69, 0x8e,
413 ],
414 0x00,
415 VsPpuType::Rp2C04_0004,
416 ),
417 // Vs. Excitebike -- MAME excitebk DSW0=0x00
418 // PPU RP2C04-0003: vsnes.cpp ROM_START(excitebk/excitebko) -> PALETTE_2C04_0003.
419 // (Staged dump's PRG bank-0 CRC32 = 7e54df1d = MAME `excitebko`, palette 3.)
420 entry(
421 Some([
422 0x88, 0x9a, 0x48, 0x95, 0x0d, 0x3c, 0x1d, 0x8d, 0x06, 0x75, 0x58, 0x0d, 0x37, 0x7a,
423 0xab, 0x99, 0x5b, 0x57, 0x16, 0x13, 0x4f, 0xe5, 0xdc, 0x17, 0xb8, 0x40, 0xb7, 0x65,
424 0x8f, 0x52, 0x9f, 0x40,
425 ]),
426 [
427 0xea, 0x27, 0x8a, 0x35, 0xa0, 0x50, 0x17, 0xa8, 0x04, 0x9f, 0x0b, 0xa9, 0x6e, 0x06,
428 0x0a, 0x26, 0xf5, 0x50, 0xed, 0x92, 0x02, 0xf8, 0xee, 0x62, 0x50, 0x2b, 0xef, 0x50,
429 0xcb, 0x04, 0x5b, 0x23,
430 ],
431 0x00,
432 VsPpuType::Rp2C04_0003,
433 ),
434 // Vs. Clu Clu Land -- MAME cluclu DSW0=0x10
435 // PPU RP2C04-0004: vsnes.cpp ROM_START(cluclu) -> PALETTE_2C04_0004.
436 entry(
437 Some([
438 0xc1, 0xa9, 0xb1, 0x3e, 0x18, 0xfb, 0x3a, 0x00, 0xaa, 0x36, 0x09, 0x67, 0x2a, 0x38,
439 0xca, 0x8c, 0x24, 0x65, 0x45, 0x51, 0xd9, 0x0d, 0x3d, 0x43, 0x04, 0xe9, 0xb9, 0xf7,
440 0xf8, 0x29, 0xc2, 0xd1,
441 ]),
442 [
443 0xfb, 0x43, 0x24, 0x81, 0x06, 0xa4, 0x20, 0x25, 0x90, 0x84, 0x0c, 0xca, 0x68, 0x89,
444 0x5a, 0xb4, 0xb2, 0xe9, 0x4c, 0x49, 0xf8, 0x2a, 0xa1, 0x5c, 0x7c, 0x23, 0x26, 0x99,
445 0xed, 0x7a, 0xb9, 0x0a,
446 ],
447 0x10,
448 VsPpuType::Rp2C04_0004,
449 ),
450 // Vs. Balloon Fight (DualSystem) -- MAME balonfgt DSW0=0x00
451 // PPU RP2C04-0003: vsnes.cpp ROM_START(balonfgt) ppu1 -> PALETTE_2C04_0003.
452 // (Not in the fceux vsuni.cpp PPU list -- fceux skips the DualSystem carts;
453 // MAME `balonfgt` is the authoritative source.)
454 entry_dual(
455 Some([
456 0xe3, 0x17, 0xd1, 0xdf, 0x4e, 0xd1, 0xb6, 0x41, 0x0f, 0xbd, 0x2d, 0xda, 0x65, 0x12,
457 0xc0, 0x6d, 0x39, 0xce, 0x23, 0x85, 0x22, 0x6c, 0x06, 0x43, 0xbd, 0x95, 0xc8, 0x70,
458 0xed, 0xd7, 0xfd, 0x97,
459 ]),
460 [
461 0xfd, 0xa8, 0x4d, 0x8d, 0xcd, 0xe6, 0x90, 0xb1, 0x5a, 0xcf, 0x8f, 0x11, 0xb2, 0x7d,
462 0x61, 0x3d, 0x57, 0x1a, 0x65, 0xb2, 0xb3, 0x47, 0x19, 0xc6, 0xe0, 0x3e, 0x7f, 0x00,
463 0xe3, 0xb7, 0x09, 0x6b,
464 ],
465 0x00,
466 VsPpuType::Rp2C04_0003,
467 ),
468 // Vs. The Goonies (unpatched Konami dump, `Goonies, The (VS).nes`, iNES
469 // mapper 151) -- the same game, board and PPU as the "(hack)" row above,
470 // whose PPU and DSW0 default it carries over: RP2C04-0003, DSW0=0x80.
471 // The two files differ only in 327 PRG bytes of code. v2.9.8: without
472 // this row the dump fell back to the 2C03 and drew its title red on
473 // green; with it, the copyright screen is white on black like the hack.
474 entry(
475 Some([
476 0x1f, 0x61, 0x3c, 0xe5, 0xce, 0xb1, 0xa8, 0x1f, 0xeb, 0x55, 0x98, 0xe5, 0x0a, 0x37,
477 0x8b, 0xe0, 0x78, 0xde, 0x6c, 0xd6, 0x2b, 0x4c, 0x0c, 0x3b, 0x10, 0xdb, 0x3f, 0x59,
478 0xd4, 0x2f, 0xbe, 0x14,
479 ]),
480 [
481 0xff, 0x26, 0x8f, 0xb3, 0xbb, 0x3e, 0xa2, 0x74, 0x3b, 0x6f, 0xda, 0xe0, 0x5e, 0x84,
482 0x54, 0x7c, 0xce, 0x0f, 0xbb, 0xa0, 0x94, 0x77, 0x79, 0xab, 0xe9, 0x99, 0x0a, 0x94,
483 0x37, 0x08, 0x63, 0x67,
484 ],
485 0x80,
486 VsPpuType::Rp2C04_0003,
487 ),
488];
489
490/// Look up the Vs. System per-game database entry for a loaded ROM.
491///
492/// Matches the ROM's header-independent identity ([`Nes::rom_sha256`]) first,
493/// then its whole-image hash ([`Nes::image_sha256`]); see the module's "Keys"
494/// section. Returns `None` for a ROM the table does not describe, including
495/// every non-Vs. ROM. `no_std`-safe.
496///
497/// v2.9.8 changed the parameter from a bare hash to the [`Nes`]: the old
498/// `lookup(&[u8; 32])` could only be handed one of the two hashes, and handing
499/// it the wrong one compiled and silently missed. Use [`lookup_by_hashes`]
500/// when the two hashes are available without a [`Nes`].
501#[must_use]
502pub fn lookup(nes: &Nes) -> Option<VsDbEntry> {
503 lookup_by_hashes(nes.rom_sha256(), nes.image_sha256())
504}
505
506/// [`lookup`] with the two hashes supplied directly.
507///
508/// `identity` is [`Nes::rom_sha256`] (SHA-256 of an iNES image's bytes after
509/// its 16-byte header) and `image` is [`Nes::image_sha256`] (SHA-256 of the
510/// whole image). The identity key is matched first.
511#[must_use]
512pub fn lookup_by_hashes(identity: &[u8; 32], image: &[u8; 32]) -> Option<VsDbEntry> {
513 lookup_in(DB, identity, image)
514}
515
516/// The lookup rule over an arbitrary table, so the unit tests can exercise a
517/// row without an identity key (the shipped table has none).
518fn lookup_in(table: &[Record], identity: &[u8; 32], image: &[u8; 32]) -> Option<VsDbEntry> {
519 table
520 .iter()
521 .find(|rec| rec.identity.as_ref() == Some(identity))
522 .or_else(|| table.iter().find(|rec| rec.image == *image))
523 .map(|rec| rec.entry)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 /// A hash no row carries, for probing one key at a time.
531 const NOWHERE: [u8; 32] = [0u8; 32];
532
533 #[test]
534 fn no_two_rows_share_a_key() {
535 for (i, a) in DB.iter().enumerate() {
536 for b in &DB[i + 1..] {
537 assert_ne!(a.image, b.image, "two rows share an image key");
538 if let (Some(x), Some(y)) = (a.identity, b.identity) {
539 assert_ne!(x, y, "two rows share an identity key");
540 }
541 }
542 }
543 }
544
545 /// Enumerates the table: every row is reachable, by its identity key alone
546 /// when it has one and by its image key alone in every case, and each key
547 /// reaches that row and no other. Nothing in the table is unreachable.
548 #[test]
549 fn every_row_is_reachable_by_each_of_its_keys() {
550 for rec in DB {
551 assert_eq!(lookup_by_hashes(&NOWHERE, &rec.image), Some(rec.entry));
552 if let Some(identity) = rec.identity {
553 assert_eq!(lookup_by_hashes(&identity, &NOWHERE), Some(rec.entry));
554 }
555 }
556 }
557
558 /// Every row in the shipped table was re-keyed from its staged dump in
559 /// v2.9.8. A future row added from a dump nobody has staged would carry
560 /// `None` here and lower this count, which is allowed; the count records
561 /// the state, so a change to it is a deliberate edit.
562 #[test]
563 fn all_nineteen_rows_carry_an_identity_key() {
564 assert_eq!(DB.len(), 19);
565 assert_eq!(DB.iter().filter(|r| r.identity.is_some()).count(), 19);
566 }
567
568 /// The rule itself, over a table holding one re-keyed row and one row left
569 /// on its image key: the identity finds the first whatever the image hash
570 /// says, the image hash finds the second, and the identity wins when the
571 /// two keys point at different rows.
572 #[test]
573 fn identity_matches_first_and_image_rows_stay_reachable() {
574 let rekeyed = entry(Some([1; 32]), [2; 32], 0x11, VsPpuType::Rp2C03);
575 let image_only = entry(None, [3; 32], 0x22, VsPpuType::Rp2C04_0001);
576 let table = [rekeyed, image_only];
577 let dip = |identity: &[u8; 32], image: &[u8; 32]| {
578 lookup_in(&table, identity, image).map(|e| e.vs_dip)
579 };
580 assert_eq!(
581 dip(&[1; 32], &NOWHERE),
582 Some(0x11),
583 "identity, header edited"
584 );
585 assert_eq!(
586 dip(&[1; 32], &[2; 32]),
587 Some(0x11),
588 "identity, header as dumped"
589 );
590 assert_eq!(
591 dip(&NOWHERE, &[2; 32]),
592 Some(0x11),
593 "image key of a re-keyed row"
594 );
595 assert_eq!(dip(&NOWHERE, &[3; 32]), Some(0x22), "image-only row");
596 assert_eq!(dip(&[1; 32], &[3; 32]), Some(0x11), "identity wins");
597 assert_eq!(dip(&NOWHERE, &NOWHERE), None);
598 // A `None` identity never matches anything, the all-zero hash included.
599 assert_eq!(dip(&[0; 32], &[9; 32]), None);
600 }
601
602 #[test]
603 fn lookup_hit_returns_entry() {
604 // Vs. Castlevania, by its image key (the pre-v2.9.8 key).
605 let sha = [
606 0xca, 0xbc, 0x23, 0x0a, 0x7f, 0x8c, 0x36, 0x6e, 0xd4, 0x05, 0xfb, 0x83, 0x1e, 0x42,
607 0xc3, 0x58, 0xa1, 0xf1, 0x40, 0x8a, 0x42, 0x77, 0x17, 0x16, 0x1c, 0xd1, 0xdd, 0x3d,
608 0x86, 0x8d, 0x35, 0x1b,
609 ];
610 let e = lookup_by_hashes(&NOWHERE, &sha).expect("castlevania present");
611 assert_eq!(e.vs_dip, 0x00);
612 assert_eq!(e.vs_ppu_type, VsPpuType::Rp2C04_0002);
613 }
614
615 #[test]
616 fn goonies_original_dump_uses_the_2c04_0003_palette() {
617 // The unpatched Konami dump of Vs. The Goonies (`Goonies, The
618 // (VS).nes`, mapper 151 header). Same game and PPU as the hack row
619 // above; without a row it fell back to the 2C03 and drew its title
620 // red on green.
621 let sha = [
622 0xff, 0x26, 0x8f, 0xb3, 0xbb, 0x3e, 0xa2, 0x74, 0x3b, 0x6f, 0xda, 0xe0, 0x5e, 0x84,
623 0x54, 0x7c, 0xce, 0x0f, 0xbb, 0xa0, 0x94, 0x77, 0x79, 0xab, 0xe9, 0x99, 0x0a, 0x94,
624 0x37, 0x08, 0x63, 0x67,
625 ];
626 let e = lookup_by_hashes(&NOWHERE, &sha).expect("original Goonies dump present");
627 assert_eq!(e.vs_ppu_type, VsPpuType::Rp2C04_0003);
628 assert_eq!(e.vs_dip, 0x80);
629 assert!(!e.dual_system);
630 }
631
632 #[test]
633 fn lookup_miss_returns_none() {
634 assert_eq!(lookup_by_hashes(&[0u8; 32], &[0u8; 32]), None);
635 assert_eq!(lookup_by_hashes(&[0xffu8; 32], &[0xffu8; 32]), None);
636 }
637
638 #[test]
639 fn exactly_six_dualsystem_rows_across_the_four_carts_are_flagged() {
640 // Tennis / Mahjong / Wrecking Crew / Balloon Fight are the four
641 // DualSystem *games*; every other entry (single-system) is not
642 // flagged. The flag lets the frontend warn instead of
643 // black-screening on a two-CPU cart -- and lets `Emu::from_rom`
644 // route to the two-console wrapper.
645 //
646 // The row COUNT is 6, not 4: Tennis and Mahjong have only the
647 // original 32 KiB-PRG (main-CPU-only) dump staged (no sub-CPU
648 // program has been located for either), while Balloon Fight and
649 // Wrecking Crew each additionally have a 64 KiB-PRG COMBINED dump
650 // (main half + sub half) once the sub-CPU program was located --
651 // see the "Caveats" doc section above and
652 // `docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md`. The 32 KiB-only
653 // entries for those two titles are kept: loading that specific
654 // (incomplete) dump still correctly flags `dual_system` (and thus
655 // still routes to the wrapper), it just can't complete the boot
656 // handshake -- which is expected/harmless, not a bug.
657 let dual = DB.iter().filter(|r| r.entry.dual_system).count();
658 assert_eq!(
659 dual, 6,
660 "expected exactly 6 DualSystem rows (4 games, 2 of which have both an incomplete and a complete dump entry)"
661 );
662 // And the flag survives a lookup round-trip for every flagged record.
663 for rec in DB.iter().filter(|r| r.entry.dual_system) {
664 assert!(lookup_by_hashes(&NOWHERE, &rec.image).is_some_and(|e| e.dual_system));
665 }
666 }
667}