feat: file mame software lists and floppy samples

The -listxml names the software lists each machine and slot card
declares and the floppy drives it can instantiate. --devices now writes
one entry per hash/<list>.xml the release ships under the systems of its
placed machines (111 lists, 666 stay with hash/), and files each floppy
sample under the systems whose drives select its form factor
(floppy.cpp:1428-1473, 1759-1767).

Machines a registered platform declares under one of the profile's
systems seed the walk too: bbcb, cdimono1, cdimono2, dragon32, ep64 and
ep128. Unplaced device sets go from 903 to 861.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 12:24:37 +02:00
1 parent 7ac96f2b39
commit 2d74524d59
9 files changed
+1959 -26

No files matched your search

+1152 -1
View File
File diff suppressed because it is too large. Load diff
+134 -5
View File
@@ -21,6 +21,7 @@ from common import PROFILE_BACKUP_SUFFIX, write_text_atomic, yaml_load
_MAME_RELEASE_RE = re.compile(r"^0\.\d+")
_MAMEDEV = "https://github.com/mamedev/mame"
DEVICE_SECTION = " # --- Device ROM sets (generated from MAME -listxml) ---"
SOFTLIST_SECTION = " # --- Software lists (generated from MAME -listxml) ---"
_FILES_END = re.compile(r"^\S", re.M)
@@ -553,9 +554,9 @@ def _append_new_entries(text: str, files: list[dict], original: str) -> str:
lines.append(_format_contents(fe["contents"]))
if lines:
# New BIOS root sets go before the generated device section, which
# runs to the end of the files list.
at = _device_section_start(text)
# New BIOS root sets go before the generated sections, which run to
# the end of the files list.
at = generated_start(text)
if at is None:
text = text.rstrip("\n") + "\n" + "\n".join(lines) + "\n"
else:
@@ -570,6 +571,18 @@ def _device_section_start(text: str) -> int | None:
return match.start() if match else None
def _softlist_section_start(text: str) -> int | None:
"""Offset of the generated software list section header, if any."""
match = re.search(rf"^{re.escape(SOFTLIST_SECTION)}$", text, re.M)
return match.start() if match else None
def generated_start(text: str) -> int | None:
"""Offset of the first generated section: what a reader wrote ends there."""
found = [at for at in (_softlist_section_start(text), _device_section_start(text)) if at is not None]
return min(found) if found else None
def _device_contents(roms: list[dict], old: list[dict]) -> list[dict]:
"""Contents of a device set, keeping the member descriptions a reader wrote."""
written = {str(c.get("name")): c.get("description") for c in old or []}
@@ -641,7 +654,11 @@ def _set_section_systems(text: str, systems: dict[str, list[str]]) -> str:
start = _device_section_start(text)
if start is None:
return text
head, section = text[:start], text[start:]
return text[:start] + _rewrite_systems(text[start:], systems)
def _rewrite_systems(section: str, systems: dict[str, list[str]]) -> str:
"""Rewrite the system fields of the named entries of one stretch of text."""
out: list[str] = []
current = ""
in_list = False
@@ -661,7 +678,7 @@ def _set_section_systems(text: str, systems: dict[str, list[str]]) -> str:
out.append(line)
if current in systems and re.match(r"^ category:", line):
out.extend(_system_lines(systems[current]))
return head + "\n".join(out)
return "\n".join(out)
def _format_device_entry(name: str, device: dict, role: str) -> str:
@@ -762,6 +779,118 @@ def merge_mame_devices(
return report
def _softlist_section(profile: dict, lists: dict[str, list[str]], role: str) -> str:
"""The generated software list section, one entry per list and tree.
A list takes the fields of each hash/ tree the profile delivers (a
bundled tree ships with the emulator, not in a pack) and the path of
that tree. Members the section names leave the tree's own entry.
"""
trees = [
entry for entry in profile.get("files") or []
if entry.get("type") == "directory"
and str(entry.get("name")) == "hash/"
and not entry.get("bundled")
]
blocks: list[str] = []
for tree in trees:
base = str(tree.get("path") or tree["name"])
for name, systems in sorted(lists.items()):
lines = [
f" - name: {_yaml_scalar(name + '.xml')}",
f" path: {_yaml_scalar(base + name + '.xml')}",
]
if tree.get("mode"):
lines.append(f" mode: {tree['mode']}")
lines += [
" category: game_data",
" required: false",
*_system_lines(systems),
]
blocks.append("\n".join(lines))
if not blocks:
return ""
lead = [
SOFTLIST_SECTION,
" # software_list_device::parse opens <list>.xml for each software list a",
" # machine declares. Each list below is filed under the systems whose",
" # machines declare it, a list no such machine declares stays with its",
" # hash/ tree.",
f" # ref: {role}src/emu/softlist_dev.cpp:281-313,",
f" # {role}src/frontend/mame/infoxml.cpp:2184-2198",
]
return "\n".join(lead) + "\n\n" + "\n\n".join(blocks) + "\n"
def merge_mame_support(
profile_path: str,
lists: dict[str, list[str]],
samples: dict[str, list[str]],
write: bool = False,
) -> dict[str, list[str]]:
"""File the software lists and the floppy samples of a MAME profile.
*lists* maps a software list to the systems whose machines declare it,
for the lists the release ships. They are written as the generated
software list section, ahead of the device section, and a refresh
rewrites the whole section. *samples* maps a floppy sample name to the
systems whose drives load it: each written entry named <sample>.wav
under a samples/floppy/ path gets them, and one no system loads gets
none.
"""
path = Path(profile_path)
text = path.read_text(encoding="utf-8")
profile = yaml_load(text) or {}
role = mame_ref_role(profile)
original = text
start = _softlist_section_start(text)
if start is not None:
device = _device_section_start(text)
if device is not None and device > start:
end = device
else:
end_match = _FILES_END.search(text, text.index("\n", start) + 1)
end = end_match.start() if end_match else len(text)
text = text[:start] + text[end:]
section = _softlist_section(profile, lists, role)
head_end = generated_start(text)
head = text if head_end is None else text[:head_end]
written = yaml_load(head) or {}
sample_systems: dict[str, list[str]] = {}
for entry in written.get("files") or []:
name = str(entry.get("name") or "")
if "samples/floppy/" not in str(entry.get("path") or "") or not name.endswith(".wav"):
continue
wanted = samples.get(name[:-4], [])
if _entry_systems(entry) != wanted:
sample_systems[name] = wanted
head = _rewrite_systems(head, sample_systems) if sample_systems else head
rest = "" if head_end is None else text[head_end:]
if section and not rest:
files_at = re.search(r"^files:[ \t]*$", head, re.M)
if files_at is None or _FILES_END.search(head, files_at.end()):
raise ValueError(
"files: must be the last top-level key to hold a software list section"
)
patched = head.rstrip("\n") + "\n"
if section:
patched += "\n" + section
if rest:
patched += "\n" + rest
if write and patched != original:
shutil.copy2(path, path.with_suffix(PROFILE_BACKUP_SUFFIX))
write_text_atomic(str(path), patched)
return {
"lists": sorted(lists),
"samples": sorted(sample_systems),
"changed": [str(path)] if patched != original else [],
}
def _add_source_refs(text: str, refs: dict[str, str]) -> str:
"""Give each named entry a source_ref line, right after its category."""
if not refs:
+175 -11
View File
@@ -10,7 +10,10 @@ at the release tag. A set whose block cannot be told apart is left without
lines and reported.
The same document says which machines load each set, so a set is filed under
the systems of those machines (attribute_systems).
the systems of those machines (attribute_systems). It also names the software
lists each machine declares and the floppy drives it can instantiate, which
files hash/<list>.xml (attribute_softlists) and the floppy sound samples
(floppy_sample_systems) the same way.
"""
from __future__ import annotations
@@ -19,7 +22,7 @@ import html
import io
import re
import zipfile
from collections.abc import Callable, Iterator
from collections.abc import Callable, Collection, Iterable, Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from pathlib import Path
@@ -27,7 +30,7 @@ from typing import NamedTuple
from common import yaml_load
from ._hash_merge import _device_section_start
from ._hash_merge import generated_start
from .mame_parser import _collect_rom_macros, _expand_macros, _parse_rom_entries
LISTXML_CHUNK = 8 * 1024 * 1024
@@ -40,6 +43,7 @@ _BIOSSET = re.compile(rb"<biosset\b([^>]*)>")
_ROM = re.compile(rb"<rom\b([^>]*)>")
_DEVICE_REF = re.compile(rb'<device_ref\b[^>]*\bname="([^"]+)"')
_SLOT_OPTION = re.compile(rb'<slotoption\b[^>]*\bdevname="([^"]+)"')
_SOFTWARE_LIST = re.compile(rb'<softwarelist\b[^>]*\bname="([^"]+)"')
_ROM_START = re.compile(r"ROM_START\s*\(\s*(\w+)\s*\)")
_ROM_END = re.compile(r"\bROM_END\b")
@@ -112,12 +116,15 @@ class MachineLinks(NamedTuple):
*romof* is the parent the element names. *uses* are the devices it can
instantiate: every subdevice of its default configuration, each device a
selectable slot option adds, and for a device its parent ROM device,
whose set the device's ROMs are also searched in.
whose set the device's ROMs are also searched in. *softlists* are the
software lists the element declares, *sourcefile* the file defining it.
"""
romof: str
isdevice: bool
uses: tuple[str, ...]
softlists: tuple[str, ...] = ()
sourcefile: str = ""
class Release(NamedTuple):
@@ -134,8 +141,9 @@ def scan_release(stream: io.BufferedIOBase) -> Release:
driver's parent (src/frontend/mame/infoxml.cpp:761-765) or a device's
parent ROM device (856-858), device_ref every subdevice of the default
configuration (786, 1010-1015), slotoption each selectable card of a
slot (2129-2172). device_t::searchpath puts the parent ROM device after
the device itself (src/emu/device.cpp:140-152).
slot (2129-2172), softwarelist each software list device of the
configuration (2184-2198). device_t::searchpath puts the parent ROM
device after the device itself (src/emu/device.cpp:140-152).
"""
tag = ""
devices: dict[str, dict] = {}
@@ -162,7 +170,11 @@ def scan_release(stream: io.BufferedIOBase) -> Release:
if isdevice and romof:
uses.add(romof)
uses.discard(name)
machines[name] = MachineLinks(romof, isdevice, tuple(sorted(uses)))
softlists = {found.decode() for found in _SOFTWARE_LIST.findall(body)}
machines[name] = MachineLinks(
romof, isdevice, tuple(sorted(uses)), tuple(sorted(softlists)),
attrs.get("sourcefile", ""),
)
if isdevice:
roms = _device_roms(body)
if roms:
@@ -187,13 +199,14 @@ def scan_device_sets(stream: io.BufferedIOBase) -> tuple[str, dict[str, dict]]:
def profile_seeds(text: str) -> dict[str, str]:
"""Set name to system, for the bios_zip entries above the device section.
"""Set name to system, for the bios_zip entries before the generated ones.
These are the entries a reader wrote, the seeds attribute_systems starts
from. The generated section is left out: its systems derive from these,
and reading them back would carry a previous release's answer forward.
from. The generated sections are left out: their systems derive from
these, and reading them back would carry a previous release's answer
forward.
"""
start = _device_section_start(text)
start = generated_start(text)
written = yaml_load(text if start is None else text[:start]) or {}
seeds: dict[str, str] = {}
for entry in written.get("files") or []:
@@ -259,6 +272,157 @@ def attribute_systems(
return {name: sorted(found) for name, found in systems.items()}
def platform_seeds(
stated: Iterable[tuple[str, str]],
machines: dict[str, MachineLinks],
systems: Collection[str],
written: dict[str, str],
) -> tuple[dict[str, str], dict[str, list[str]]]:
"""Seeds a platform file states, and the sets it states two ways.
*stated* are (archive name, system) pairs a platform declares. A pair
seeds a set when the archive is a machine of the release, not a device,
the system is one the profile lists, and the profile has not written
the set itself. A set stated under two of the profile's systems seeds
nothing and is returned with them.
"""
found: dict[str, set[str]] = {}
listed = set(systems)
for archive, system in stated:
if not archive.endswith(".zip") or system not in listed:
continue
name = archive[:-4]
links = machines.get(name)
if name in written or links is None or links.isdevice:
continue
found.setdefault(name, set()).add(system)
seeds = {name: next(iter(s)) for name, s in found.items() if len(s) == 1}
conflicts = {name: sorted(s) for name, s in found.items() if len(s) > 1}
return seeds, conflicts
def attribute_softlists(
machines: dict[str, MachineLinks], placed: dict[str, list[str]]
) -> dict[str, list[str]]:
"""Systems whose machines declare each software list.
*placed* is what attribute_systems returns. A list goes to the systems
of every element declaring it: a machine writes the lists of its whole
configuration and a slot card its own (infoxml.cpp:2184-2198).
software_list_device::parse opens <list>.xml from the hash path
(src/emu/softlist_dev.cpp:281-313).
"""
lists: dict[str, set[str]] = {}
for name, systems in placed.items():
links = machines.get(name)
for listed in links.softlists if links else ():
lists.setdefault(listed, set()).update(systems)
return {name: sorted(found) for name, found in lists.items()}
# floppy_image_device adds its sound device under this shortname
# (src/devices/imagedev/floppy.cpp:50, 2076-2082).
FLOPPY_SOUND = "flopsnd"
_FORM_FACTOR = re.compile(r"\bm_form_factor\s*=\s*floppy_image::(FF_\w+)")
_SAMPLE_FORM_FACTOR = re.compile(
r"\bm_default_samples\.set_form_factor\s*\(\s*floppy_image::(FF_\w+)"
r"|\bm_default_samples\.add_\w+_sample\s*\(\s*\"([^\"]+)\""
)
def default_floppy_samples(text: str) -> dict[str, str]:
"""Each sample of floppy_sound_device's default list, to its form factor.
The constructor sets a form factor, then adds the samples recorded for
it (src/devices/imagedev/floppy.cpp:1656-1686).
"""
body = re.search(
r"floppy_sound_device::floppy_sound_device\s*\([^)]*\)[^{]*\{(.*?)\n\}",
blank_comments(text),
re.S,
)
samples: dict[str, str] = {}
current = ""
for match in _SAMPLE_FORM_FACTOR.finditer(body.group(1) if body else ""):
if match.group(1):
current = match.group(1)
elif current:
samples[match.group(2)] = current
return samples
def drive_form_factor(text: str, shortname: str) -> str:
"""Form factor a floppy drive type sets in setup_characteristics, or ""."""
text = blank_comments(text)
declaration = re.search(
r"DEFINE_DEVICE_TYPE\w*\s*\(\s*\w+\s*,\s*(\w+)\s*,\s*\"" + re.escape(shortname) + '"',
text,
)
if declaration is None:
return ""
body = re.search(
rf"\b{re.escape(declaration.group(1))}::setup_characteristics\s*\(\s*\)\s*\{{(.*?)\n\}}",
text,
re.S,
)
found = _FORM_FACTOR.search(body.group(1)) if body else None
return found.group(1) if found else ""
def selected_form_factor(form_factor: str, available: Collection[str]) -> str:
"""The list floppy_sound_samples::select takes for a drive's form factor.
3" falls back to 3.5", anything else to 5.25", and a form factor no list
answers takes none (src/devices/imagedev/floppy.cpp:1428-1473).
"""
while form_factor not in available:
if form_factor == "FF_3":
form_factor = "FF_35"
elif form_factor != "FF_525":
form_factor = "FF_525"
else:
return ""
return form_factor
def floppy_drives(machines: dict[str, MachineLinks]) -> list[str]:
"""Device types that instantiate the floppy sound device."""
return sorted(
name for name, links in machines.items()
if links.isdevice and FLOPPY_SOUND in links.uses
)
def floppy_sample_systems(
machines: dict[str, MachineLinks],
placed: dict[str, list[str]],
samples: dict[str, str],
form_factors: dict[str, str],
) -> dict[str, list[str]]:
"""Systems whose machines load each floppy sound sample.
Every drive starts a floppy sound device, which loads the default list
of the form factor its drive selects (floppy.cpp:1709-1738,
1759-1767). *samples* maps a sample to its form factor
(default_floppy_samples), *form_factors* a drive to its own
(drive_form_factor). A drive whose form factor is unknown is taken to
load every list.
"""
available = set(samples.values())
found: dict[str, set[str]] = {}
for drive in floppy_drives(machines):
systems = placed.get(drive)
if not systems:
continue
own = form_factors.get(drive, "")
chosen = {selected_form_factor(own, available)} if own else available
for sample, form_factor in samples.items():
if form_factor in chosen:
found.setdefault(sample, set()).update(systems)
return {name: sorted(s) for name, s in found.items()}
def source_path(sourcefile: str) -> str:
"""Repository path of a listxml sourcefile.
+76 -6
View File
@@ -23,7 +23,14 @@ from typing import Any
import yaml
from common import github_token, source_yaml_paths, write_text_atomic, yaml_load
from common import (
github_token,
list_registered_platforms,
load_platform_config,
source_yaml_paths,
write_text_atomic,
yaml_load,
)
import upstream
@@ -33,13 +40,21 @@ from ._hash_merge import (
compute_diff,
merge_mame_devices,
merge_mame_profile,
merge_mame_support,
)
from .mame_devices import (
attribute_softlists,
attribute_systems,
default_floppy_samples,
drive_form_factor,
floppy_drives,
floppy_sample_systems,
locate_sources,
open_listxml,
platform_seeds,
profile_seeds,
scan_release,
source_path,
)
from .mame_parser import parse_mame_source_tree
@@ -51,6 +66,8 @@ _CACHE_PATH = _ROOT / "data" / "mame-hashes.json"
# release by a second run while the first was still parsing it.
_CLONE_DIR = _ROOT / "tmp" / f"mame-{os.getpid()}"
_EMULATORS_DIR = _ROOT / "emulators"
_PLATFORMS_DIR = _ROOT / "platforms"
_FLOPPY_SOURCE = "src/devices/imagedev/floppy.cpp"
_REPO_URL = "https://github.com/mamedev/mame.git"
_STALE_HOURS = 24
@@ -474,6 +491,17 @@ def _write_release(args: argparse.Namespace) -> None:
print(f"{args.profile}: {len(profile['files'])} BIOS root sets at {args.release}")
def _platform_archives() -> list[tuple[str, str]]:
"""(file name, system) for every file a registered platform declares."""
stated: list[tuple[str, str]] = []
for name in list_registered_platforms(str(_PLATFORMS_DIR), include_archived=True):
config = load_platform_config(name, str(_PLATFORMS_DIR))
for system, data in (config.get("systems") or {}).items():
for entry in (data or {}).get("files") or []:
stated.append((str(entry.get("name") or ""), str(system)))
return stated
def _merge_devices(args: argparse.Namespace) -> None:
"""Document the device ROM sets of the release mame.yml is pinned on.
@@ -498,15 +526,39 @@ def _merge_devices(args: argparse.Namespace) -> None:
f"{tag} is {commit}, mame.yml upstream_commit is "
f"{profile.get('upstream_commit')}"
)
unplaced = locate_sources(
devices,
lambda path: upstream.fetch_file(repo, commit, path, upstream.CACHE_DIR),
def fetch(path: str) -> list[str] | None:
return upstream.fetch_file(repo, commit, path, upstream.CACHE_DIR)
unplaced = locate_sources(devices, fetch)
written = profile_seeds(text)
stated, conflicts = platform_seeds(
_platform_archives(), machines, profile.get("systems") or [], written
)
systems = attribute_systems(machines, profile_seeds(text))
systems = attribute_systems(machines, {**stated, **written})
for set_name, device in devices.items():
device["systems"] = systems.get(set_name, [])
report = merge_mame_devices(str(profile_path), devices, write=not args.dry_run)
shipped = upstream.list_directory(repo, commit, "hash", upstream.CACHE_DIR)
if shipped is None:
raise RuntimeError(f"hash/ of {repo.slug} at {commit} could not be listed")
present = {name[:-4] for name in shipped if name.endswith(".xml")}
declared = attribute_softlists(machines, systems)
lists = {name: found for name, found in declared.items() if name in present}
floppy = fetch(_FLOPPY_SOURCE)
if floppy is None:
raise RuntimeError(f"{_FLOPPY_SOURCE} is absent at {commit}")
samples = default_floppy_samples("\n".join(floppy))
form_factors: dict[str, str] = {}
for drive in floppy_drives(machines):
lines = fetch(source_path(machines[drive].sourcefile))
form_factors[drive] = drive_form_factor("\n".join(lines or []), drive)
sample_systems = floppy_sample_systems(machines, systems, samples, form_factors)
support = merge_mame_support(
str(profile_path), lists, sample_systems, write=not args.dry_run
)
print(f"mame-devices: {len(devices)} device ROM sets in the {tag} listxml")
print(f" {profile_path.name}:")
print(f" + {len(report['added'])} added")
@@ -518,9 +570,27 @@ def _merge_devices(args: argparse.Namespace) -> None:
print(
f" systems: {by_count[1]} sets under one system, "
f"{sum(n for k, n in by_count.items() if k > 1)} under several, "
f"{by_count[0]} loaded by no machine a profile entry places "
f"{by_count[0]} loaded by no placed machine "
"(they serve every system of the profile)"
)
print(
f" seeds: {len(written)} written in {profile_path.name}, "
f"{len(stated)} from platform files"
)
for set_name, found in sorted(conflicts.items()):
print(f" {set_name}.zip stated under {', '.join(found)}, left unseeded")
print(
f" software lists: {len(lists)} filed under their systems, "
f"{len(present) - len(lists)} declared by no placed machine stay with hash/, "
f"{len(set(declared) - present)} placed lists absent from hash/"
)
unread = sorted(drive for drive, found in form_factors.items() if not found)
print(
f" floppy samples: {len(sample_systems)} of {len(samples)} filed, "
f"{len(support['samples'])} entries rewritten"
)
for drive in unread:
print(f" {drive}: no form factor read, taken to load every list")
if unplaced:
print(f" {len(unplaced)} without source lines:")
for set_name, reason in sorted(unplaced.items()):
+22
View File
@@ -713,6 +713,28 @@ def _tree_url(repo: Repo, sha: str) -> str | None:
return f"{repo.api_base}/repos/{repo.slug}/git/trees/{sha}?recursive=1"
def list_directory(
repo: Repo, sha: str, path: str, cache_dir: str, offline: bool = False
) -> list[str] | None:
"""Names of the blobs directly under one directory at one revision.
One request for the directory alone, where the whole tree of a large
repository comes back truncated. None when the forge has no such call
or the directory does not exist there.
"""
if repo.family != "github":
return None
url = f"{repo.api_base}/repos/{repo.slug}/git/trees/{sha}:{path.strip('/')}"
payload = _api(url, cache_dir, offline)
if not isinstance(payload, dict) or payload.get("truncated"):
return None
return sorted(
entry["path"]
for entry in payload.get("tree") or []
if isinstance(entry, dict) and entry.get("type") == "blob" and entry.get("path")
)
TREE_PAGE_SIZE = 100
MAX_TREE_PAGES = 50
+340 -1
View File
@@ -18,22 +18,31 @@ from pathlib import Path
from scripts.common import yaml_load
from scripts.scraper._hash_merge import (
DEVICE_SECTION,
SOFTLIST_SECTION,
mame_ref_role,
merge_mame_devices,
merge_mame_profile,
merge_mame_support,
)
from scripts.scraper.mame_devices import (
ABSENT,
AMBIGUOUS,
NO_FILE,
MachineLinks,
attribute_softlists,
attribute_systems,
default_floppy_samples,
drive_form_factor,
find_rom_block,
floppy_drives,
floppy_sample_systems,
locate_sources,
open_listxml,
platform_seeds,
profile_seeds,
scan_device_sets,
scan_release,
selected_form_factor,
source_path,
)
@@ -429,7 +438,10 @@ class TheListxmlSaysWhoLoadsASet(unittest.TestCase):
def test_links(self):
machines = _release().machines
self.assertEqual(machines["mslugj"], MachineLinks("mslug", False, ("cart_dev",)))
self.assertEqual(
machines["mslugj"],
MachineLinks("mslug", False, ("cart_dev",), (), "snk/neogeo.cpp"),
)
self.assertEqual(machines["apple2gs"].uses, ("a2diskii", "a2ssc"))
# A device's ROMs are searched in its parent ROM device's set too.
self.assertEqual(machines["cart_dev"].uses, ("cart_base",))
@@ -633,6 +645,333 @@ class APickReadsTheEntrySystems(unittest.TestCase):
)
SUPPORT_LISTXML = b"""<?xml version="1.0"?>
<mame build="0.289 (mame0289)" debug="no" mameconfig="10">
\t<machine name="bbcb" sourcefile="acorn/bbcb.cpp">
\t\t<device_ref tag=":fdc:0:525qd" name="floppy_525_qd"/>
\t\t<softwarelist tag="flop_ls" name="bbc_flop" status="original"/>
\t</machine>
\t<machine name="bbcbus" sourcefile="acorn/bbcb.cpp" cloneof="bbcb" romof="bbcb">
\t\t<softwarelist tag="flop_ls" name="bbc_flop_us" status="original"/>
\t</machine>
\t<machine name="apple2gs" sourcefile="apple/apple2gs.cpp">
\t\t<slot name="sl6">
\t\t\t<slotoption name="disk" devname="a2disk35"/>
\t\t</slot>
\t</machine>
\t<machine name="a2disk35" sourcefile="devices/bus/a2bus/a2disk35.cpp" isdevice="yes" runnable="no">
\t\t<device_ref tag=":0:35dd" name="floppy_35_dd"/>
\t\t<softwarelist tag="flop35" name="apple2gs_flop" status="original"/>
\t</machine>
\t<machine name="pcw" sourcefile="amstrad/pcw.cpp">
\t\t<device_ref tag=":0:3" name="floppy_3_sssd"/>
\t</machine>
\t<machine name="ibm5150" sourcefile="ibm/ibmpc.cpp">
\t\t<device_ref tag=":0:8" name="floppy_8_sssd"/>
\t\t<softwarelist tag="flop" name="ibm5150" status="original"/>
\t</machine>
\t<machine name="floppy_525_qd" sourcefile="devices/imagedev/floppy.cpp" isdevice="yes" runnable="no">
\t\t<device_ref tag=":flopsnd" name="flopsnd"/>
\t</machine>
\t<machine name="floppy_35_dd" sourcefile="devices/imagedev/floppy.cpp" isdevice="yes" runnable="no">
\t\t<device_ref tag=":flopsnd" name="flopsnd"/>
\t</machine>
\t<machine name="floppy_3_sssd" sourcefile="devices/imagedev/floppy.cpp" isdevice="yes" runnable="no">
\t\t<device_ref tag=":flopsnd" name="flopsnd"/>
\t</machine>
\t<machine name="floppy_8_sssd" sourcefile="devices/imagedev/floppy.cpp" isdevice="yes" runnable="no">
\t\t<device_ref tag=":flopsnd" name="flopsnd"/>
\t</machine>
\t<machine name="flopsnd" sourcefile="devices/imagedev/floppy.cpp" isdevice="yes" runnable="no">
\t</machine>
</mame>
"""
SUPPORT_SEEDS = {"bbcb": "acorn-bbc-micro", "apple2gs": "apple2gs", "pcw": "pcw"}
FLOPPY_CPP = """\
floppy_sound_device::floppy_sound_device(const machine_config &mconfig, const char *tag)
\t: samples_device(mconfig, FLOPPYSOUND, tag, owner, clock),
\tm_spin_kind(floppy_sound_samples::QUIET)
{
\t// m_default_samples.add_spin_sample("commented_out", 0);
\tm_default_samples.set_form_factor(floppy_image::FF_35, "floppy");
\tm_default_samples.add_spin_sample("35_spin", floppy_sound_samples::START_EMPTY);
\tm_default_samples.add_seek_sample("35_seek", 2, 3);
\tm_default_samples.set_form_factor(floppy_image::FF_525, "floppy");
\tm_default_samples.add_step_sample("525_step");
}
DEFINE_DEVICE_TYPE(FLOPPY_35_DD, floppy_35_dd, "floppy_35_dd", "3.5 inch drive")
DEFINE_DEVICE_TYPE(FLOPPY_525_QD, floppy_525_qd, "floppy_525_qd", "5.25 inch drive")
DEFINE_DEVICE_TYPE(FLOPPY_3_SSSD, floppy_3_sssd, "floppy_3_sssd", "3 inch drive")
DEFINE_DEVICE_TYPE(FLOPPY_8_SSSD, floppy_8_sssd, "floppy_8_sssd", "8 inch drive")
void floppy_35_dd::setup_characteristics()
{
\tm_form_factor = floppy_image::FF_35;
}
void floppy_525_qd::setup_characteristics()
{
\tm_form_factor = floppy_image::FF_525;
}
void floppy_3_sssd::setup_characteristics()
{
\tm_form_factor = floppy_image::FF_3;
}
void floppy_8_sssd::setup_characteristics()
{
\tm_form_factor = floppy_image::FF_8;
}
"""
def _support():
machines = scan_release(io.BytesIO(SUPPORT_LISTXML)).machines
return machines, attribute_systems(machines, SUPPORT_SEEDS)
class SoftwareListsFollowTheMachinesThatDeclareThem(unittest.TestCase):
"""software_list_device::parse opens <list>.xml for each list a machine
declares: a --system pick needs the lists of that system's machines."""
def test_the_scan_keeps_lists_and_source_files(self):
machines = scan_release(io.BytesIO(SUPPORT_LISTXML)).machines
self.assertEqual(machines["bbcb"].softlists, ("bbc_flop",))
self.assertEqual(machines["a2disk35"].sourcefile, "devices/bus/a2bus/a2disk35.cpp")
def test_lists_of_machines_clones_and_slot_cards(self):
lists = attribute_softlists(*_support())
self.assertEqual(lists["bbc_flop"], ["acorn-bbc-micro"])
self.assertEqual(lists["bbc_flop_us"], ["acorn-bbc-micro"])
self.assertEqual(lists["apple2gs_flop"], ["apple2gs"])
# No entry places the PC: its list serves no system of the profile.
self.assertNotIn("ibm5150", lists)
class FloppySamplesFollowTheDrives(unittest.TestCase):
"""Every drive adds a floppy sound device that loads the default list of
the form factor its drive selects (floppy.cpp:1428-1473, 1709-1738)."""
def test_default_list_by_form_factor(self):
self.assertEqual(
default_floppy_samples(FLOPPY_CPP),
{"35_spin": "FF_35", "35_seek": "FF_35", "525_step": "FF_525"},
)
def test_drive_form_factor(self):
self.assertEqual(drive_form_factor(FLOPPY_CPP, "floppy_35_dd"), "FF_35")
self.assertEqual(drive_form_factor(FLOPPY_CPP, "floppy_3_sssd"), "FF_3")
self.assertEqual(drive_form_factor(FLOPPY_CPP, "missing"), "")
def test_select_falls_back_like_the_code(self):
available = {"FF_35", "FF_525"}
self.assertEqual(selected_form_factor("FF_35", available), "FF_35")
self.assertEqual(selected_form_factor("FF_3", available), "FF_35")
self.assertEqual(selected_form_factor("FF_8", available), "FF_525")
self.assertEqual(selected_form_factor("FF_TWIG", available), "FF_525")
self.assertEqual(selected_form_factor("FF_3", {"FF_525"}), "FF_525")
self.assertEqual(selected_form_factor("FF_35", set()), "")
def test_samples_go_to_the_systems_of_the_drives(self):
machines, systems = _support()
self.assertEqual(
floppy_drives(machines),
["floppy_35_dd", "floppy_3_sssd", "floppy_525_qd", "floppy_8_sssd"],
)
samples = default_floppy_samples(FLOPPY_CPP)
form_factors = {d: drive_form_factor(FLOPPY_CPP, d) for d in floppy_drives(machines)}
found = floppy_sample_systems(machines, systems, samples, form_factors)
self.assertEqual(found["35_spin"], ["apple2gs", "pcw"])
self.assertEqual(found["525_step"], ["acorn-bbc-micro"])
def test_an_unread_form_factor_loads_every_list(self):
machines, systems = _support()
samples = default_floppy_samples(FLOPPY_CPP)
found = floppy_sample_systems(machines, systems, samples, {})
self.assertEqual(found["525_step"], ["acorn-bbc-micro", "apple2gs", "pcw"])
class PlatformFilesSeedMachines(unittest.TestCase):
def test_a_platform_statement_seeds_a_machine(self):
machines = scan_release(io.BytesIO(SUPPORT_LISTXML)).machines
seeds, conflicts = platform_seeds(
[
("bbcb.zip", "acorn-bbc-micro"),
("bbcb.zip", "bbcmicro"), # not a system of the profile
("ibm5150.zip", "ibm-pc"), # idem
("a2disk35.zip", "apple2gs"), # a device, not a machine
("pcw.zip", "pcw"), # written by the profile
("apple2gs.zip", "apple2gs"),
("apple2gs.zip", "acorn-bbc-micro"),
("missing.zip", "pcw"),
("bbcb.rom", "acorn-bbc-micro"),
],
machines,
["acorn-bbc-micro", "apple2gs", "pcw"],
{"pcw": "pcw"},
)
self.assertEqual(seeds, {"bbcb": "acorn-bbc-micro"})
self.assertEqual(conflicts, {"apple2gs": ["acorn-bbc-micro", "apple2gs"]})
SUPPORT_PROFILE = """emulator: MAME
source: "https://github.com/libretro/mame"
upstream: "https://github.com/mamedev/mame"
systems:
- acorn-bbc-micro
- apple2gs
files:
- name: "hash/"
path: "mame/hash/"
type: directory
mode: libretro
category: game_data
required: false
- name: "hash/"
path: "hash/"
type: directory
mode: standalone
category: game_data
required: false
bundled: true
- name: "35_spin.wav"
path: "mame/samples/floppy/35_spin.wav"
mode: libretro
category: game_data
required: false
size: 4
- name: "525_step.wav"
path: "mame/samples/floppy/525_step.wav"
mode: libretro
category: game_data
system: pcw
required: false
- name: neogeo.zip
required: true
category: bios_zip
system: acorn-bbc-micro
"""
class TheSupportMergeWritesListsAndSamples(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.path = Path(self._tmp.name) / "mame.yml"
self.path.write_text(
SUPPORT_PROFILE + "\n" + DEVICE_SECTION + "\n\n - name: ym2610.zip\n"
" required: false\n category: bios_zip\n",
encoding="utf-8",
)
self.lists = {"bbc_flop": ["acorn-bbc-micro"], "apple2gs_flop": ["acorn-bbc-micro", "apple2gs"]}
self.samples = {"35_spin": ["apple2gs"]}
def tearDown(self):
self._tmp.cleanup()
def _merge(self) -> dict:
return merge_mame_support(str(self.path), self.lists, self.samples, write=True)
def _entries(self) -> list[dict]:
return yaml_load(self.path.read_text(encoding="utf-8"))["files"]
def test_one_entry_per_list_under_the_delivered_tree(self):
self._merge()
text = self.path.read_text(encoding="utf-8")
self.assertLess(text.index(SOFTLIST_SECTION), text.index(DEVICE_SECTION))
lists = [e for e in self._entries() if str(e["name"]).endswith(".xml")]
self.assertEqual(
[(e["name"], e["mode"], e.get("system"), e.get("systems")) for e in lists],
[
("apple2gs_flop.xml", "libretro", None, ["acorn-bbc-micro", "apple2gs"]),
("bbc_flop.xml", "libretro", "acorn-bbc-micro", None),
],
)
self.assertFalse(any(str(e.get("path")).startswith("hash/") and e["name"] != "hash/"
for e in self._entries()))
self.assertIn("upstream src/emu/softlist_dev.cpp:281-313", text)
def test_samples_get_the_systems_of_their_drives(self):
self._merge()
entries = {e["name"]: e for e in self._entries()}
self.assertEqual(entries["35_spin.wav"]["system"], "apple2gs")
self.assertEqual(entries["35_spin.wav"]["size"], 4)
# No drive of a profile system loads it any more: it serves them all.
self.assertNotIn("system", entries["525_step.wav"])
self.assertEqual(entries["neogeo.zip"]["system"], "acorn-bbc-micro")
def test_a_refresh_rewrites_the_section(self):
self._merge()
self.lists = {"bbc_flop": ["apple2gs"]}
self._merge()
names = [e["name"] for e in self._entries()]
self.assertNotIn("apple2gs_flop.xml", names)
self.assertEqual(names.count("bbc_flop.xml"), 1)
self.assertEqual(names[-1], "ym2610.zip")
self.lists = {}
self._merge()
self.assertNotIn(SOFTLIST_SECTION, self.path.read_text(encoding="utf-8"))
def test_a_second_run_changes_nothing(self):
self._merge()
first = self.path.read_text(encoding="utf-8")
report = self._merge()
self.assertEqual(report["changed"], [])
self.assertEqual(report["samples"], [])
self.assertEqual(self.path.read_text(encoding="utf-8"), first)
def test_new_bios_roots_and_seeds_stay_ahead_of_the_sections(self):
self._merge()
text = self.path.read_text(encoding="utf-8")
self.assertEqual(profile_seeds(text), {"neogeo": "acorn-bbc-micro"})
def test_a_pick_takes_its_lists_and_leaves_the_others(self):
from scripts.common import entry_serves_systems, expand_directory_entries
self._merge()
profile = yaml_load(self.path.read_text(encoding="utf-8"))
db = {"files": {}, "indexes": {"by_path_suffix": {}}}
for index, dest in enumerate(
["mame/hash/bbc_flop.xml", "mame/hash/apple2gs_flop.xml", "mame/hash/nes.xml"]
):
sha1 = f"{index:040d}"
db["files"][sha1] = {"path": f"bios/Other/{dest}", "name": dest.rsplit("/", 1)[1]}
db["indexes"]["by_path_suffix"][dest] = [sha1]
files = [e for e in profile["files"] if e.get("mode") != "standalone"]
files = expand_directory_entries(files, db)
picked = sorted(
str(e.get("path"))
for e in files
if str(e.get("path", "")).startswith("mame/hash/")
and entry_serves_systems(e, profile, ["apple2gs"])
)
self.assertEqual(picked, ["mame/hash/apple2gs_flop.xml", "mame/hash/nes.xml"])
class TheRepositoryProfile(unittest.TestCase):
"""mame.yml as committed: every list filed is one the hash tree holds."""
def test_generated_lists_sit_inside_the_hash_tree(self):
path = Path(__file__).resolve().parents[1] / "emulators" / "mame.yml"
text = path.read_text(encoding="utf-8")
start = text.find(SOFTLIST_SECTION)
self.assertNotEqual(start, -1)
section = yaml_load("files:\n" + text[start:text.index(DEVICE_SECTION)])["files"]
self.assertTrue(section)
for entry in section:
self.assertEqual(entry["path"], "mame/hash/" + entry["name"])
self.assertTrue(entry.get("system") or entry.get("systems"), entry["name"])
class Cli(unittest.TestCase):
def test_devices_needs_a_listxml(self):
+30
View File
@@ -598,6 +598,36 @@ class TestCompare(unittest.TestCase):
upstream._http_json, upstream._http_text = self._orig
self.tmp.cleanup()
def test_list_directory_reads_one_tree(self):
seen: list[str] = []
def answer(url):
seen.append(url)
return {
"truncated": False,
"tree": [
{"path": "b.xml", "type": "blob"},
{"path": "a.xml", "type": "blob"},
{"path": "sub", "type": "tree"},
],
}
upstream._http_json = answer
self.assertEqual(
upstream.list_directory(self.repo, "abc", "hash/", self.dir), ["a.xml", "b.xml"]
)
self.assertTrue(seen[0].endswith("/repos/o/n/git/trees/abc:hash"))
# Cached: offline, the same answer without a request.
self.assertEqual(
upstream.list_directory(self.repo, "abc", "hash", self.dir, offline=True),
["a.xml", "b.xml"],
)
self.assertEqual(len(seen), 1)
upstream._http_json = lambda url: {"truncated": True, "tree": []}
self.assertIsNone(upstream.list_directory(self.repo, "def", "hash", self.dir))
gitlab = parse_repo("https://gitlab.com/o/n")
self.assertIsNone(upstream.list_directory(gitlab, "abc", "hash", self.dir))
def test_renamed_file_carries_previous_path(self):
self.payload = {
"files": [
+13 -2
View File
@@ -117,6 +117,17 @@ and merged into a profile, on an inline listxml and driver source. Covers:
`systems:` written and rewritten by a refresh, a written entry's system
kept, and a `--system` pick reading the entry's own systems through the gap
report and the core extras
- Seeds a platform file states: a machine of the release under one of the
profile's systems, a device, an unlisted system or a set stated two ways
seeding nothing
- Support files: each software list filed under the systems whose machines
and slot cards declare it, one entry per list of the delivered `hash/`
tree, a pick keeping its lists and the unfiled ones, floppy samples filed
through the form factor each drive selects (3" falls back to 3.5", the
rest to 5.25"), a refresh rewriting the section and a second run changing
nothing
- An entry's `system:` or `systems:` outside its profile's list refused by
the semantic check
**test_fbneo_parser.** Tests the FBNeo C source parser that identifies
`BDF_BOARDROM` sets. Same inline fixture approach.
@@ -150,8 +161,8 @@ replaced by an injected fetch function.
**test_upstream.** Covers forge URL parsing for GitHub, GitLab and Forgejo,
the content-addressed cache and its atomic write, revision and tag resolution,
and tree comparison. The HTTP layer is replaced at module level, so nothing
leaves the machine.
tree comparison and the listing of one directory. The HTTP layer is replaced at
module level, so nothing leaves the machine.
### Tests that read the working tree
+17
View File
@@ -877,6 +877,23 @@ their parent ROM devices. A set loaded by machines of one system gets
system of the profile. The driver file is never used: `neogeo.cpp` declares
the MVS and the AES. The output counts the sets in each case.
A machine set no written entry lists is also seeded when a registered platform
declares `<set>.zip` under one of the profile's systems (Batocera files
`bbcb.zip` under `acorn-bbc-micro`). The set must be a machine of the release,
not a device, and a set two platforms state under two different systems seeds
nothing and is listed.
The same walk files the support trees. Each `hash/<list>.xml` the release
ships and a placed machine or slot card declares (`softwarelist`) is written
under the `Software lists (generated from MAME -listxml)` comment, ahead of the
device section, with the systems of those machines. A list no placed machine
declares stays a member of the `hash/` entry. Each floppy sound sample gets the
systems whose machines can instantiate a drive selecting its list: the form
factor comes from the drive's `setup_characteristics` in the source, 3" drives
take the 3.5" list and every other drive the 5.25" one, as
`floppy_sound_samples::select` does. `plugins/` stays unfiled: every machine
starts the Lua engine.
A plain run (pipeline step 2a) that moves `mame.yml` to a new release
downloads that release's `-listxml` into `.cache/dats/` and runs the device
merge itself, so the BIOS root sets and the device sets name one release.