mirror of
https://github.com/ares-emulator/ares.git
synced 2026-10-12 02:11:04 +00:00
## 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 |
369 lines
12 KiB
Python
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)
|