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 |
192 lines
7.1 KiB
Python
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
|