Files
ares/tools/mamedb/mame.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

192 lines
7.1 KiB
Python

"""Generic acquisition of machine metadata from a local MAME executable."""
from __future__ import annotations
import concurrent.futures
import re
import shutil
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set, Tuple
class MameError(RuntimeError):
"""A normalized executable, process, inventory, or XML acquisition error."""
def resolve_executable(value: Optional[str]) -> Path:
if value is None:
discovered = shutil.which("mame") or shutil.which("mame.exe")
if discovered is None:
raise MameError(
"MAME could not be found on PATH; pass its executable or directory"
)
return Path(discovered)
supplied = Path(value).expanduser()
if supplied.is_dir():
for filename in ("mame", "mame.exe"):
candidate = supplied / filename
if candidate.is_file():
return candidate
raise MameError(f"MAME executable not found in directory: {supplied}")
if not supplied.is_file():
raise MameError(f"MAME executable not found: {supplied}")
return supplied
def _decode_output(output: object, context: str) -> str:
if isinstance(output, str):
return output
if not isinstance(output, bytes):
raise MameError(f"MAME returned invalid output for {context}")
try:
return output.decode("utf-8-sig")
except UnicodeDecodeError as error:
raise MameError(
f"MAME returned non-UTF-8 output for {context}: {error}"
) from error
def _decode_stderr(output: object, fallback: bytes) -> str:
"""Normalize real and mocked subprocess stderr without masking failures."""
if isinstance(output, str):
return output
if isinstance(output, bytes):
return output.decode("utf-8", errors="replace")
return fallback.decode("utf-8", errors="replace")
def _run(executable: Path, arguments: List[str], context: str) -> str:
command = [str(executable), *arguments]
try:
with tempfile.TemporaryFile() as stderr_file:
completed = subprocess.run(
command, stdout=subprocess.PIPE, stderr=stderr_file, check=False
)
_ = stderr_file.seek(0)
stderr_bytes = stderr_file.read(65536)
except OSError as error:
raise MameError(
f"could not run MAME command for {context}: {command!r}: {error}"
) from error
stderr = _decode_stderr(completed.stderr, stderr_bytes)
if completed.returncode != 0:
detail = " ".join(stderr.split())
if len(detail) > 1000:
detail = detail[:997] + "..."
message = (
f"MAME command failed for {context} with exit code "
f"{completed.returncode}: {command!r}"
)
if detail:
message += f": {detail}"
raise MameError(message)
return _decode_output(completed.stdout, context)
def _inventory(output: str, source: str) -> List[str]:
names: List[str] = []
seen: Set[str] = set()
for line in output.splitlines():
fields = line.split()
if len(fields) < 2 or fields[-1].replace("\\", "/") != source:
continue
name = fields[0]
if not re.fullmatch(r"[A-Za-z0-9_]+", name):
raise MameError(
f"invalid {source} machine name in MAME -listsource: {name!r}"
)
if name in seen:
raise MameError(f"duplicate {source} machine in MAME -listsource: {name}")
seen.add(name)
names.append(name)
if not names:
raise MameError(f"MAME -listsource contains no machines for {source}")
return sorted(names)
def _check_inventory(actual: Iterable[str], expected: Iterable[str]) -> None:
actual_names = set(actual)
expected_names = set(expected)
if actual_names == expected_names:
return
details: List[str] = []
unknown = sorted(actual_names - expected_names)
missing = sorted(expected_names - actual_names)
if unknown:
details.append(f"unexpected source machines: {', '.join(unknown)}")
if missing:
details.append(f"expected machines absent from source: {', '.join(missing)}")
raise MameError("; ".join(details))
def _query_machine(executable: Path, source: str, name: str) -> Tuple[str, ET.Element]:
output = _run(executable, ["-listxml", name], f"machine {name}")
try:
root = ET.fromstring(output)
except ET.ParseError as error:
raise MameError(f"invalid MAME XML for machine {name}: {error}") from error
if root.tag != "mame":
raise MameError(f"MAME -listxml target {name} did not return a mame root")
revision = root.get("build")
if not revision:
raise MameError(f"MAME -listxml target {name} has no build revision")
named = [item for item in root.findall("machine") if item.get("name") == name]
selected = [item for item in named if item.get("sourcefile") == source]
if len(selected) != 1:
if named and not selected:
sources = sorted({item.get("sourcefile", "<missing>") for item in named})
raise MameError(
"MAME -listxml target {} has wrong source: {}; expected {}".format(
name, ", ".join(sources), source
)
)
condition = "missing" if not selected else "duplicate"
raise MameError(
f"{condition} requested {source} machine in MAME -listxml target {name}"
)
return revision, selected[0]
def load_source(
executable: Path, source: str, expected_machines: Iterable[str], jobs: int = 4
) -> ET.Element:
"""Load one complete source-driver inventory into a deterministic XML root."""
if jobs < 1:
raise MameError("--jobs must be at least 1")
names = _inventory(_run(executable, ["-listsource"], source), source)
_check_inventory(names, expected_machines)
results: Dict[str, Tuple[str, ET.Element]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor:
futures = {
executor.submit(_query_machine, executable, source, name): name
for name in names
}
for future in concurrent.futures.as_completed(futures):
name = futures[future]
try:
results[name] = future.result()
except MameError:
for pending in futures:
_ = pending.cancel()
raise
except Exception as error:
for pending in futures:
_ = pending.cancel()
raise MameError(
f"worker failed for MAME -listxml machine {name}: {error}"
) from error
revisions = {revision for revision, _ in results.values()}
if len(revisions) != 1:
details = ", ".join(f"{name}={results[name][0]}" for name in sorted(results))
raise MameError(
f"MAME build revision mismatch across machine queries: {details}"
)
root = ET.Element("mame", build=next(iter(revisions)))
for name in sorted(results):
root.append(results[name][1])
return root