Files
ares/tools/mamedb/vsnes.py
T
ZengGengSen 30131cd32c Fc/vs unisystem support (#2594)
## Overview

Adds Nintendo Vs. UniSystem support to the ares FC core. Games remain in
MAME
ZIP format and load through the existing Arcade path, without going
through
the iNES detection path used by regular Famicom software.

The implementation covers MAME metadata generation, parent ZIP lookup,
Vs. PPU
and palette behavior, cartridge boards, protection logic, cabinet I/O,
DIP
switches, controller wiring, Zapper input, and save states. A total of
41 Vs.
UniSystem sets have been validated. Vs. DualSystem, non-standard bootleg
hardware, and other configurations that have not yet been implemented
are
explicitly marked as unsupported instead of being started with the
UniSystem
model.

## Why `vsnes.py` is separate

MAME groups three substantially different hardware families in
`nintendo/vsnes.cpp`:

- Vs. UniSystem with one CPU and one PPU;
- Vs. DualSystem with two CPUs and two PPUs;
- non-standard bootleg hardware built around a Z80 and SN76489.

The generic MAME parser reads common data such as machine entries, ROM
regions,
and DIP switches. Those fields alone are not enough to determine which
Vs.
hardware model a game requires. The three families have different
runtime
structures and cannot share a single emulation path.

Configurations also differ between Vs. UniSystem games. The PPU,
palette,
cartridge board, protection logic, controller wiring, Zapper support,
and DIP
settings may all vary by set. The core needs explicit configuration data
rather than inferring hardware from a game title.

`vsnes.py` reuses the MAME XML and ROM-region information produced by
the
generic parser, then adds hardware classification and per-game
configuration.
It generates a deterministic `VsSystem.bml` database. At runtime,
supported
sets select the corresponding FC hardware model from this metadata,
while
DualSystem and bootleg sets are rejected with a clear explanation.

## Video

The combined video shows the attract modes of all 41 supported games.
Each
game is shown for approximately ten seconds, with its name in the
upper-right corner.



https://github.com/user-attachments/assets/46a59b2b-f64a-40ce-a870-1351d5df3948



https://github.com/user-attachments/assets/7927d1d0-4349-4f4b-9092-feeb7b0ac127



https://github.com/user-attachments/assets/d162c834-b227-497b-afe7-2f766f3ab42a



https://github.com/user-attachments/assets/12d7b58e-7cd5-4bbf-b040-9d615e654e0c



https://github.com/user-attachments/assets/a8708059-4369-48e1-be1f-42d15099d7a8


## Screenshots

The contact sheet contains one attract-mode screenshot for each game,
composed
at the original capture resolution.

<img width="3920" height="5328" alt="contact-sheet"
src="https://github.com/user-attachments/assets/e111cf05-4a49-4b0d-9719-082b2b925342"
/>

## Supported sets

The following 41 Vs. UniSystem MAME sets have been validated for loading
and
attract-mode operation:

| No. | MAME short name | Game |
| ---: | --- | --- |
| 1 | `btlecity` | Vs. Battle City |
| 2 | `cluclu` | Vs. Clu Clu Land |
| 3 | `cstlevna` | Vs. Castlevania |
| 4 | `drmario` | Vs. Dr. Mario |
| 5 | `duckhunt` | Vs. Duck Hunt (set DH3 E) |
| 6 | `excitebk` | Vs. Excitebike (set EB4-3 E) |
| 7 | `excitebkj` | Vs. Excitebike (set EB4-4 A) |
| 8 | `excitebko` | Vs. Excitebike (set EB4-3 ?) |
| 9 | `goonies` | Vs. The Goonies (set E) |
| 10 | `hogalley` | Vs. Hogan's Alley (set HA4-1 E-1) |
| 11 | `iceclimb` | Vs. Ice Climber (set IC4-4 B-1) |
| 12 | `iceclimba` | Vs. Ice Climber (set IC4-4 ?) |
| 13 | `jajamaru` | Vs. Ninja Jajamaru Kun (Japan) |
| 14 | `ladygolf` | Vs. Stroke & Match Golf (Ladies Version, set LG4 ?)
|
| 15 | `ladygolfe` | Vs. Stroke & Match Golf (Ladies Version, set LG4 E)
|
| 16 | `nvs_machrider` | Vs. Mach Rider (Endurance Course Version) |
| 17 | `nvs_machridera` | Vs. Mach Rider (Fighting Course Version, set
MR4-1 A) |
| 18 | `nvs_mightybj` | Vs. Mighty Bomb Jack (Japan) |
| 19 | `nvs_platoon` | Vs. Platoon |
| 20 | `rbibb` | Vs. Atari R.B.I. Baseball (set 1) |
| 21 | `rbibba` | Vs. Atari R.B.I. Baseball (set 2) |
| 22 | `skatekds` | Vs. Skate Kids. (Graphic hack of Super Mario Bros.)
|
| 23 | `smgolf` | Vs. Stroke & Match Golf (Men Version, set GF4-2 F) |
| 24 | `smgolfb` | Vs. Stroke & Match Golf (Men Version, set GF4-2 ?) |
| 25 | `smgolfj` | Vs. Stroke & Match Golf (Men Version) (Japan, set GF3
B) |
| 26 | `starlstr` | Vs. Star Luster |
| 27 | `suprmrio` | Vs. Super Mario Bros. (set SM4-4 E) |
| 28 | `suprmrioa` | Vs. Super Mario Bros. (set ?, harder) |
| 29 | `supxevs` | Vs. Super Xevious |
| 30 | `tkoboxng` | Vs. T.K.O. Boxing |
| 31 | `topgun` | Vs. Top Gun |
| 32 | `vsfdf` | Vs. Freedom Force |
| 33 | `vsgradus` | Vs. Gradius (US, set GR E) |
| 34 | `vsgshoe` | Vs. Gumshoe (set GM5) |
| 35 | `vspinbal` | Vs. Pinball (US, set PN4 E-1) |
| 36 | `vspinbalj` | Vs. Pinball (Japan, set PN3 B) |
| 37 | `vsskykid` | Vs. Super SkyKid |
| 38 | `vsslalom` | Vs. Slalom |
| 39 | `vssoccer` | Vs. Soccer (set SC4-2 A) |
| 40 | `vssoccera` | Vs. Soccer (set SC4-3 ?) |
| 41 | `vstetris` | Vs. Tetris |
2026-08-20 16:35:50 +01:00

369 lines
12 KiB
Python

"""Transform Nintendo Vs. System MAME XML into deterministic BML metadata."""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Tuple
SOURCE = "nintendo/vsnes.cpp"
@dataclass(frozen=True)
class Machine:
mapper: str
protection: str
input: str
not_working: bool = False
def machine(
mapper: str = "standard",
protection: str = "normal",
input_mode: str = "standard",
not_working: bool = False,
) -> Machine:
return Machine(mapper, protection, input_mode, not_working)
SUPPORTED = {
"btlecity": machine(),
"starlstr": machine(),
"cstlevna": machine("unrom"),
"cluclu": machine(),
"drmario": machine("mmc1"),
"excitebk": machine(),
"excitebko": machine(),
"excitebkj": machine(),
"goonies": machine("vrc1"),
"iceclimb": machine(protection="ice-climber"),
"iceclimba": machine(),
"nvs_machrider": machine(),
"nvs_machridera": machine(),
"rbibb": machine("namco-108", protection="rbi-baseball"),
"rbibba": machine("namco-108", protection="rbi-baseball"),
"suprmrio": machine(input_mode="swapped"),
"suprmrioa": machine(input_mode="swapped"),
"skatekds": machine(input_mode="swapped"),
"vsskykid": machine("namco-108"),
"tkoboxng": machine("namco-108", protection="tko-boxing", input_mode="swapped"),
"smgolf": machine(),
"smgolfb": machine(),
"smgolfj": machine(),
"ladygolfe": machine(),
"ladygolf": machine(),
"vspinbal": machine(),
"vspinbalj": machine(input_mode="swap-ab"),
"vsslalom": machine(),
"vssoccer": machine(),
"vssoccera": machine(),
"vsgradus": machine("vrc1"),
"nvs_platoon": machine("sunsoft-3"),
"vstetris": machine(),
"nvs_mightybj": machine(input_mode="swapped"),
"jajamaru": machine(),
"topgun": machine("unrom"),
"supxevs": machine("namco-108", protection="super-xevious"),
"duckhunt": machine(input_mode="zapper"),
"hogalley": machine(input_mode="zapper"),
"vsgshoe": machine(input_mode="zapper"),
"vsfdf": machine("namco-108", input_mode="zapper"),
}
UNSUPPORTED = {
"bnglngby": "raid-protection",
"hogalleyi": "dualsystem",
"suprmriobl": "bootleg-z80",
"suprmriobl2": "bootleg-z80",
"vstennis": "dualsystem",
"vstennisa": "dualsystem",
"vstennisb": "dualsystem",
"wrecking": "dualsystem",
"balonfgt": "dualsystem",
"vsmahjng": "dualsystem",
"vsbball": "dualsystem",
"vsbballj": "dualsystem",
"vsbballja": "dualsystem",
"vsbballjb": "dualsystem",
"iceclmrd": "dualsystem",
}
class MetadataError(ValueError):
pass
def require_classification() -> None:
overlap = SUPPORTED.keys() & UNSUPPORTED.keys()
if overlap:
raise MetadataError(f"duplicate Vs classification: {sorted(overlap)[0]}")
if len(SUPPORTED) != 41 or len(UNSUPPORTED) != 15:
raise MetadataError("expected 41 supported and 15 unsupported Vs machines")
def parse_number(value: Optional[str], field: str, machine_name: str) -> int:
if value is None:
raise MetadataError(f"missing {field} for {machine_name}")
base = 16 if value.lower().startswith("0x") else 10
return int(value, base)
def parse_hex_number(value: Optional[str], field: str, machine_name: str) -> int:
if value is None:
raise MetadataError(f"missing {field} for {machine_name}")
if value.lower().startswith("0x"):
value = value[2:]
return int(value, 16)
def normalize_port(value: Optional[str], machine_name: str) -> Optional[str]:
value = optional_text(value, "DIP port", machine_name)
if value is None:
return None
port = value[1:] if value.startswith(":") else value
return port
def required_text(value: Optional[str], field: str, machine_name: str) -> str:
if not value or "\n" in value or "\r" in value:
raise MetadataError(f"missing {field} for {machine_name}")
return value
def optional_text(value: Optional[str], field: str, machine_name: str) -> Optional[str]:
if not value:
return None
if "\n" in value or "\r" in value:
raise MetadataError(f"{field} cannot be emitted for {machine_name}")
return value
def ppu_devices_for(machine_element: ET.Element) -> List[str]:
"""Return target PPU identifiers independently of palette ROM data."""
machine_name = machine_element.get("name", "<unnamed>")
devices: List[str] = []
for device in machine_element.findall("device_ref"):
if device.get("tag") != ":ppu1":
continue
name = optional_text(device.get("name"), "PPU device identifier", machine_name)
if name is not None:
devices.append(name)
return devices
def bml_number(value: Optional[str], field: str, machine_name: str) -> Optional[str]:
text = optional_text(value, field, machine_name)
if text is None:
return None
try:
return f"{parse_number(text, field, machine_name):#x}"
except ValueError:
return text
def dips_for(machine_element: ET.Element) -> List[str]:
result: List[str] = []
machine_name = machine_element.get("name", "<unnamed>")
for dip in machine_element.findall("dipswitch"):
name = optional_text(dip.get("name"), "DIP name", machine_name)
port = normalize_port(dip.get("tag"), machine_name)
mask = bml_number(dip.get("mask"), "DIP mask", machine_name)
choices: List[Tuple[Optional[str], Optional[str], bool]] = []
for choice in dip.findall("dipvalue"):
choice_name = optional_text(
choice.get("name"), "DIP choice name", machine_name
)
value = bml_number(choice.get("value"), "DIP choice", machine_name)
choices.append((choice_name, value, choice.get("default") == "yes"))
defaults = [value for _, value, default in choices if default]
result.append(" dip\n")
if name is not None:
result.append(f" name: {name}\n")
if port is not None:
result.append(f" port: {port}\n")
if mask is not None:
result.append(f" mask: {mask}\n")
for default in defaults:
if default is not None:
result.append(f" default: {default}\n")
for choice_name, value, _ in choices:
result.append(" option\n")
if choice_name is not None:
result.append(f" name: {choice_name}\n")
if value is not None:
result.append(f" value: {value}\n")
return result
def hardware_lines(name: str, ppu_devices: Iterable[str] = ()) -> List[str]:
if name in UNSUPPORTED:
return [
" hardware\n",
" topology: unsupported\n",
f" reason: {UNSUPPORTED[name]}\n",
]
item = SUPPORTED[name]
lines = [
" hardware\n",
" topology: unisystem\n",
]
for ppu_device in ppu_devices:
lines.append(f" ppu: {ppu_device}\n")
lines.extend(
(
f" mapper: {item.mapper}\n",
f" protection: {item.protection}\n",
f" input: {item.input}\n",
)
)
if item.not_working:
lines.append(" upstream-status: not-working\n")
return lines
@dataclass(frozen=True)
class RomRecord:
name: Optional[str]
value: Optional[int]
load_type: Optional[str]
offset: int
size: int
status: str
crc: Optional[str]
sha1: Optional[str]
def parse_rom_record(rom: ET.Element, machine_name: str) -> RomRecord:
loadflag = rom.get("loadflag")
legacy_type = rom.get("type")
if loadflag and legacy_type and loadflag != legacy_type:
raise MetadataError(f"conflicting ROM load types for {machine_name}")
raw_value = optional_text(rom.get("value"), "ROM value", machine_name)
return RomRecord(
name=optional_text(rom.get("name"), "ROM name", machine_name),
value=(
parse_number(raw_value, "ROM value", machine_name)
if raw_value is not None
else None
),
load_type=optional_text(loadflag or legacy_type, "ROM load type", machine_name),
offset=parse_hex_number(rom.get("offset"), "ROM offset", machine_name),
size=parse_number(rom.get("size"), "ROM size", machine_name),
status=optional_text(rom.get("status"), "ROM status", machine_name) or "good",
crc=optional_text(rom.get("crc"), "ROM CRC", machine_name),
sha1=optional_text(rom.get("sha1"), "ROM SHA1", machine_name),
)
def bml_rom_lines(record: RomRecord) -> List[str]:
lines = [" rom\n"]
if record.name:
lines.append(f" name: {record.name}\n")
if record.value is not None:
lines.append(f" value: {record.value}\n")
if record.load_type:
lines.append(f" type: {record.load_type}\n")
lines.extend(
(
f" offset: {record.offset:#x}\n",
f" size: {record.size}\n",
)
)
if record.crc is not None:
lines.append(f" crc: {record.crc.lower()}\n")
if record.sha1 is not None:
lines.append(f" sha1: {record.sha1.lower()}\n")
if record.status != "good":
lines.append(f" status: {record.status}\n")
return lines
def rom_lines(machine_element: ET.Element) -> List[str]:
machine_name = machine_element.get("name", "<unnamed>")
lines: List[str] = []
current_region = ""
for rom in machine_element.findall("rom"):
region = required_text(rom.get("region"), "ROM region", machine_name).replace(
":", "-"
)
if not re.fullmatch(r"[A-Za-z0-9_.-]+", region):
raise MetadataError(f"ROM region cannot be emitted for {machine_name}")
if region != current_region:
lines.append(f" {region}\n")
current_region = region
record = parse_rom_record(rom, machine_name)
lines.extend(bml_rom_lines(record))
return lines
def source_machines(root: ET.Element) -> Tuple[str, Dict[str, ET.Element]]:
if root.tag != "mame":
raise MetadataError("input XML is not a MAME machine list")
mame_revision = required_text(root.get("build"), "MAME build revision", "<root>")
machines: Dict[str, ET.Element] = {}
for element in root.findall("machine"):
if element.get("sourcefile") != SOURCE:
continue
name = required_text(element.get("name"), "machine name", "<source>")
if name in machines:
raise MetadataError(f"duplicate Vs source machine: {name}")
machines[name] = element
validate_inventory(machines)
return mame_revision, machines
def process_root(root: ET.Element) -> str:
require_classification()
mame_revision, machines = source_machines(root)
output: List[str] = [
"database\n",
f" revision: {mame_revision}\n",
f" romset: {mame_revision}\n",
f" drivers: {SOURCE}\n",
]
for name in sorted(machines):
element = machines[name]
title = required_text(element.findtext("description"), "title", name)
ppu_devices = ppu_devices_for(element) if name in SUPPORTED else ()
machine_type = "bios" if element.get("isbios") == "yes" else "game"
parent = optional_text(element.get("romof"), "parent", name)
roms = rom_lines(element)
output.extend(
(
"game\n",
f" name: {name}\n",
f" title: {title}\n",
" board: nintendo/vs\n",
f" type: {machine_type}\n",
)
)
if parent:
output.append(f" parent: {parent}\n")
output.extend(hardware_lines(name, ppu_devices))
if name in SUPPORTED:
output.extend(dips_for(element))
output.extend(roms)
return "".join(output)
def validate_inventory(names: Iterable[str]) -> None:
actual = set(names)
classified = set(SUPPORTED) | set(UNSUPPORTED)
if actual == classified:
return
unknown = sorted(actual - classified)
missing = sorted(classified - actual)
details: List[str] = []
if unknown:
details.append(f"unclassified source machines: {', '.join(unknown)}")
if missing:
details.append(f"classified machines absent from source: {', '.join(missing)}")
raise MetadataError("; ".join(details))
EXPECTED_MACHINES = frozenset(SUPPORTED) | frozenset(UNSUPPORTED)