Skip to main content

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}