mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
Dolphin reads its User folder at $XDG_DATA_HOME/dolphin-emu, else ~/.local/share/dolphin-emu (Source/Core/UICommon/UICommon.cpp:449-477). The profile names both, so a launcher handing that folder places the layout: - EmuELEC starts Dolphin with XDG_DATA_HOME=/storage/roms/dolphin (dolphinSA/scripts/dolphin.sh:28): its 30 files move from the BIOS root to dolphin/dolphin-emu/ of the install tree. - RetroDECK links User/Triforce to bios/Triforce (dolphin/component_prepare.sh:30, 49), where its list already puts segaboot.gcm. The rest of the User folder sits in the Flatpak's data directory and is named as read outside the BIOS folder. An entry only the standalone build reads, path and no standalone_path, now takes that path as its layout path (Load/Firmware/rtl_bt/), and the pass copying a name the platform declares elsewhere reaches an unprefixed path: gba_bios.bin lands in dolphin/dolphin-emu/GBA/.
812 lines
40 KiB
Python
812 lines
40 KiB
Python
"""A platform's launcher moves where an emulator's file lands.
|
|
|
|
A profile's ``path`` is where the emulator's code reads a file. UAE4All2
|
|
reads kickstarts/<name> below its working directory, and RetroPie starts it
|
|
in its install directory, whose kickstarts/ it links to BIOS/amiga. The
|
|
pack, the manifest and verify all put the file in amiga/ on that platform,
|
|
and a platform with no such record keeps the profile's layout.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
|
|
|
import hashlib
|
|
import io
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
from common import ( # noqa: E402
|
|
_emulator_profiles_cache,
|
|
load_emulator_profiles,
|
|
load_platform_config,
|
|
platform_layouts,
|
|
standalone_set,
|
|
)
|
|
from generate_pack import generate_manifest, generate_pack # noqa: E402
|
|
from verify import verify_platform # noqa: E402
|
|
|
|
WORKING_DIRECTORY = {
|
|
"emulator": "kick", "build": "standalone", "kind": "passes_path", "names": [],
|
|
"search_dir": "$md_inst", "passes_to": "working directory of $romdir/amiga/+Start.sh",
|
|
"source_ref": "kick.sh:90",
|
|
}
|
|
LINK = {
|
|
"emulator": "kick", "build": "standalone", "kind": "links", "names": [],
|
|
"search_dir": "$biosdir/amiga", "passes_to": "$md_inst/kickstarts is a symlink to it",
|
|
"source_ref": "kick.sh:79",
|
|
}
|
|
|
|
|
|
class ALinkMovesTheDestination(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_emulator_profiles_cache.clear()
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
|
self.addCleanup(shutil.rmtree, self.root, True)
|
|
bios = self.root / "bios" / "Commodore" / "Amiga"
|
|
bios.mkdir(parents=True)
|
|
files, by_md5, by_name = {}, {}, {}
|
|
for name, data in (("kick13.rom", b"KICKSTART 1.3"), ("kick.rom", b"CUSTOM KICKSTART")):
|
|
(bios / name).write_bytes(data)
|
|
sha1 = hashlib.sha1(data).hexdigest()
|
|
md5 = hashlib.md5(data).hexdigest()
|
|
files[sha1] = {"path": str(bios / name), "name": name, "sha1": sha1,
|
|
"md5": md5, "crc32": "", "size": len(data)}
|
|
by_md5[md5] = sha1
|
|
by_name[name] = [sha1]
|
|
self.db = {
|
|
"files": files,
|
|
"indexes": {"by_md5": by_md5, "by_name": by_name, "by_crc32": {},
|
|
"by_path_suffix": {}},
|
|
}
|
|
kick13 = by_name["kick13.rom"][0]
|
|
self.platforms = self.root / "platforms"
|
|
self.emulators = self.root / "emulators"
|
|
self.platforms.mkdir()
|
|
self.emulators.mkdir()
|
|
for name, launchers in (("linkplat", [WORKING_DIRECTORY, LINK]), ("plainplat", [])):
|
|
(self.platforms / f"{name}.yml").write_text(yaml.safe_dump({
|
|
"platform": name, "version": "1", "verification_mode": "existence",
|
|
"base_destination": "BIOS", "cores": ["kick"], "standalone_cores": ["kick"],
|
|
"launchers": launchers,
|
|
"systems": {"commodore-amiga": {"files": []}},
|
|
}))
|
|
(self.platforms / "_registry.yml").write_text(yaml.safe_dump({"platforms": {
|
|
"linkplat": {"config": "linkplat.yml"}, "plainplat": {"config": "plainplat.yml"},
|
|
}}))
|
|
(self.emulators / "kick.yml").write_text(yaml.safe_dump({
|
|
"emulator": "Kick", "type": "standalone", "cores": ["kick"],
|
|
"systems": ["commodore-amiga"],
|
|
"standalone_directories": ["getcwd"],
|
|
"files": [
|
|
{"name": "kick13.rom", "path": "kickstarts/kick13.rom",
|
|
"sha1": kick13, "required": False},
|
|
# Read in the working directory itself, which the platform
|
|
# links nowhere in the BIOS tree.
|
|
{"name": "kick.rom", "path": "kick.rom", "required": False},
|
|
],
|
|
}))
|
|
|
|
def pack_names(self, platform: str, name: str = "kick13.rom") -> list[str]:
|
|
with redirect_stdout(io.StringIO()):
|
|
zip_path = generate_pack(
|
|
platform, str(self.platforms), self.db, str(self.root / "bios"),
|
|
str(self.root / "out" / platform), emulators_dir=str(self.emulators),
|
|
offline=True,
|
|
)
|
|
with zipfile.ZipFile(zip_path) as zf:
|
|
return [n for n in zf.namelist() if n.rsplit("/", 1)[-1] == name]
|
|
|
|
def manifest(self, platform: str) -> dict:
|
|
with redirect_stdout(io.StringIO()):
|
|
return generate_manifest(
|
|
platform, str(self.platforms), self.db, str(self.root / "bios"),
|
|
str(self.platforms / "_registry.yml"),
|
|
emulators_dir=str(self.emulators), offline=True,
|
|
)
|
|
|
|
def manifest_dests(self, platform: str, name: str = "kick13.rom") -> list[str]:
|
|
return [f["dest"] for f in self.manifest(platform)["files"]
|
|
if f["dest"].rsplit("/", 1)[-1] == name]
|
|
|
|
def verify_entries(self, platform: str, name: str = "kick13.rom") -> list[dict]:
|
|
config = load_platform_config(platform, str(self.platforms))
|
|
result = verify_platform(config, self.db, str(self.emulators))
|
|
return [u for u in result["undeclared_files"] if u["name"] == name]
|
|
|
|
def verify_paths(self, platform: str) -> list[str]:
|
|
return [u["path"] for u in self.verify_entries(platform)]
|
|
|
|
def test_the_link_moves_it_in_pack_manifest_and_verify(self) -> None:
|
|
self.assertEqual(self.pack_names("linkplat"), ["amiga/kick13.rom"])
|
|
self.assertEqual(self.manifest_dests("linkplat"), ["amiga/kick13.rom"])
|
|
self.assertEqual(self.verify_paths("linkplat"), ["amiga/kick13.rom"])
|
|
|
|
def test_a_platform_without_the_record_is_unchanged(self) -> None:
|
|
self.assertEqual(self.pack_names("plainplat"), ["kickstarts/kick13.rom"])
|
|
self.assertEqual(self.manifest_dests("plainplat"), ["kickstarts/kick13.rom"])
|
|
self.assertEqual(self.verify_paths("plainplat"), ["kickstarts/kick13.rom"])
|
|
|
|
def test_a_file_outside_every_binding_is_named_never_placed(self) -> None:
|
|
self.assertEqual(self.pack_names("linkplat", "kick.rom"), [])
|
|
manifest = self.manifest("linkplat")
|
|
self.assertEqual(self.manifest_dests("linkplat", "kick.rom"), [])
|
|
omitted = [o for o in manifest["omitted_files"] if o["name"] == "kick.rom"]
|
|
self.assertEqual(len(omitted), 1)
|
|
self.assertEqual(
|
|
(omitted[0]["dest"], omitted[0]["root"], omitted[0]["reason"], omitted[0]["cores"]),
|
|
("kick.rom", "emulator", "outside_bios_folder", ["kick"]),
|
|
)
|
|
(entry,) = self.verify_entries("linkplat", "kick.rom")
|
|
self.assertFalse(entry["in_pack"])
|
|
self.assertIn("outside the BIOS tree", entry["omitted"])
|
|
|
|
def test_a_root_file_of_another_core_is_not_claimed_by_it(self) -> None:
|
|
# Another core reads kick.rom at the BIOS root: kick's own kick.rom,
|
|
# in its working directory, is a different place.
|
|
for name in ("linkplat", "plainplat"):
|
|
path = self.platforms / f"{name}.yml"
|
|
data = yaml.safe_load(path.read_text())
|
|
data["cores"] = ["kick", "other"]
|
|
path.write_text(yaml.safe_dump(data))
|
|
(self.emulators / "other.yml").write_text(yaml.safe_dump({
|
|
"emulator": "Other", "type": "libretro", "cores": ["other"],
|
|
"systems": ["commodore-amiga"],
|
|
"files": [{"name": "kick.rom", "path": "kick.rom", "required": False}],
|
|
}))
|
|
_emulator_profiles_cache.clear()
|
|
manifest = self.manifest("linkplat")
|
|
(held,) = [f for f in manifest["files"] if f["dest"] == "kick.rom"]
|
|
self.assertEqual(held["cores"], ["other"])
|
|
omitted = [o for o in manifest["omitted_files"] if o["name"] == "kick.rom"]
|
|
self.assertEqual([(o["root"], o["cores"]) for o in omitted], [("emulator", ["kick"])])
|
|
|
|
def test_two_builds_in_one_folder_are_one_omission(self) -> None:
|
|
# RetroPie builds amiberry 5.7.2 and 6.3.3, one per board, both in
|
|
# $md_inst of its amiberry package: one place, read by both.
|
|
data = yaml.safe_load((self.emulators / "kick.yml").read_text())
|
|
for key in ("kick-1", "kick-2"):
|
|
(self.emulators / f"{key}.yml").write_text(
|
|
yaml.safe_dump({**data, "built_by": ["linkplat"]}))
|
|
_emulator_profiles_cache.clear()
|
|
omitted = [o for o in self.manifest("linkplat")["omitted_files"]
|
|
if o["name"] == "kick.rom"]
|
|
self.assertEqual([(o["root"], o["cores"]) for o in omitted],
|
|
[("emulator", ["kick-1", "kick-2"])])
|
|
|
|
def test_the_installer_accepts_the_omission(self) -> None:
|
|
import install
|
|
|
|
manifest = self.manifest("linkplat")
|
|
omitted = next(o for o in manifest["omitted_files"] if o["name"] == "kick.rom")
|
|
install._check_omission(omitted, omitted["dest"])
|
|
# Two emulators reading kick.rom in their own folders read two files.
|
|
self.assertNotEqual(
|
|
install._entry_key(omitted), install._entry_key({**omitted, "cores": ["other"]})
|
|
)
|
|
self.assertNotEqual(install._entry_key(omitted), install._entry_key({"dest": "kick.rom"}))
|
|
self.assertEqual(install.OMISSION_REASONS[omitted["reason"]], "read outside the BIOS folder")
|
|
with self.assertRaises(ValueError):
|
|
install._check_root({"root": "emulator"}, "kick.rom")
|
|
install._check_root(omitted, "kick.rom", omitted=True)
|
|
|
|
def test_without_relocation_the_root_file_stays_at_the_root(self) -> None:
|
|
self.assertEqual(self.pack_names("plainplat", "kick.rom"), ["kick.rom"])
|
|
|
|
def test_the_link_alone_binds_nothing(self) -> None:
|
|
# Without the working directory the link's $md_inst is not the
|
|
# directory the profile's paths are relative to.
|
|
config = {"standalone_cores": ["kick"], "launchers": [LINK]}
|
|
profile = load_emulator_profiles(str(self.emulators))["kick"]
|
|
(view, standalone), = platform_layouts("kick", profile, standalone_set(config))
|
|
self.assertTrue(standalone)
|
|
self.assertNotIn("standalone_path", view["files"][0])
|
|
|
|
|
|
class UAE4AllOnRetroPie(unittest.TestCase):
|
|
"""The real profile against the real launcher records."""
|
|
|
|
def test_kickstarts_land_in_amiga(self) -> None:
|
|
profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["uae4all"]
|
|
config = load_platform_config("retropie", str(REPO_ROOT / "platforms"))
|
|
(view, standalone), = platform_layouts("uae4all", profile, standalone_set(config))
|
|
self.assertTrue(standalone)
|
|
for entry in view["files"]:
|
|
if entry["path"].startswith("kickstarts/"):
|
|
self.assertEqual(
|
|
entry["standalone_path"], "amiga/" + entry["path"].removeprefix("kickstarts/")
|
|
)
|
|
else:
|
|
# kick.rom and rom.key in the working directory, $md_inst
|
|
self.assertEqual(entry.get("unplaced"), "outside_bios_folder", entry["path"])
|
|
|
|
|
|
|
|
class FsUaeOnRetroPie(unittest.TestCase):
|
|
"""RetroPie lists KS 1.3 as kick34005.A500, and its fs-uae launcher
|
|
passes $biosdir/kick13.rom (fs-uae.sh:23-26). The listed copy settles
|
|
fs-uae's entry by content, and the launcher's name was filled only as
|
|
a copy of UAE4All2's amiga/kick13.rom: a board without UAE4All2 lost it."""
|
|
|
|
def test_the_launcher_name_is_filled_without_another_core(self) -> None:
|
|
import json
|
|
|
|
from common import load_target_config
|
|
|
|
db_path = REPO_ROOT / "database.json"
|
|
if not db_path.exists():
|
|
self.skipTest("database.json is not built")
|
|
db = json.loads(db_path.read_text(encoding="utf-8"))
|
|
cores = load_target_config("retropie", "x86", str(REPO_ROOT / "platforms"))
|
|
self.assertNotIn("uae4all", cores)
|
|
with redirect_stdout(io.StringIO()):
|
|
manifest = generate_manifest(
|
|
"retropie", str(REPO_ROOT / "platforms"), db, str(REPO_ROOT / "bios"),
|
|
str(REPO_ROOT / "platforms" / "_registry.yml"),
|
|
emulators_dir=str(REPO_ROOT / "emulators"), offline=True,
|
|
target_cores=set(cores), target_name="x86",
|
|
)
|
|
held = [(f["dest"], f["cores"]) for f in manifest["files"]
|
|
if f["dest"].rsplit("/", 1)[-1] == "kick13.rom"]
|
|
self.assertEqual(held, [("kick13.rom", ["fs-uae"])])
|
|
|
|
|
|
class XboxOnRetroBat(unittest.TestCase):
|
|
"""RetroBat's launcher writes xemu's eeprom.bin and disk image into
|
|
saves/<system> and cxbx-reloaded's EEPROM.bin into its emulator folder,
|
|
and each Cxbx build reads its data from the folder it runs from
|
|
(Xemu.Generator.cs:82-113, Cxbx.Generator.cs:99-115, 158, 216-223)."""
|
|
|
|
def test_nothing_lands_at_the_bios_root(self) -> None:
|
|
import json
|
|
|
|
db_path = REPO_ROOT / "database.json"
|
|
if not db_path.exists():
|
|
self.skipTest("database.json is not built")
|
|
db = json.loads(db_path.read_text(encoding="utf-8"))
|
|
with redirect_stdout(io.StringIO()):
|
|
manifest = generate_manifest(
|
|
"retrobat", str(REPO_ROOT / "platforms"), db, str(REPO_ROOT / "bios"),
|
|
str(REPO_ROOT / "platforms" / "_registry.yml"),
|
|
emulators_dir=str(REPO_ROOT / "emulators"), offline=True,
|
|
)
|
|
at_root = {f["dest"].lower() for f in manifest["files"] if not f.get("root")}
|
|
# The launcher writes the two EEPROMs and the disk image, mounts an
|
|
# .iso with its own tool, and each Cxbx build reads keys.bin from
|
|
# its own folder. wpcap.dll comes with the Npcap driver.
|
|
self.assertFalse(at_root & {
|
|
"eeprom.bin", "xbox_hdd.qcow2", "dokan2.dll", "keys.bin", "wpcap.dll",
|
|
})
|
|
placed = {(f.get("root"), f["dest"]) for f in manifest["files"]}
|
|
self.assertIn(("emulators", "cxbx-reloaded/keys.bin"), placed)
|
|
self.assertIn(("emulators", "chihiro/EEPROM.bin"), placed)
|
|
self.assertNotIn(("emulators", "cxbx-reloaded/EEPROM.bin"), placed)
|
|
|
|
|
|
PROGRAM = b"PROGRAM BINARY"
|
|
PROGRAM_DATA = b"PROGRAM DATA"
|
|
|
|
|
|
class AProgramRunFromItsFolder(unittest.TestCase):
|
|
"""A program that reads its files beside its executable.
|
|
|
|
The profile gives the layout of the program's archive (prog/prog,
|
|
prog/prog.dat) and declares the executable directory. A platform that
|
|
runs prog/prog from its BIOS folder keeps that layout, one that runs it
|
|
from roms/prog places the program folder below its roms folder, and one
|
|
that runs it from an emulator folder names its files as not placeable.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
_emulator_profiles_cache.clear()
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
|
self.addCleanup(shutil.rmtree, self.root, True)
|
|
bios = self.root / "bios" / "Maker" / "Prog"
|
|
bios.mkdir(parents=True)
|
|
files, by_md5, by_name = {}, {}, {}
|
|
for name, data in (("prog", PROGRAM), ("prog.dat", PROGRAM_DATA)):
|
|
(bios / name).write_bytes(data)
|
|
sha1 = hashlib.sha1(data).hexdigest()
|
|
md5 = hashlib.md5(data).hexdigest()
|
|
files[sha1] = {"path": str(bios / name), "name": name, "sha1": sha1,
|
|
"md5": md5, "crc32": "", "size": len(data)}
|
|
by_md5[md5] = sha1
|
|
by_name[name] = [sha1]
|
|
self.db = {"files": files, "indexes": {
|
|
"by_md5": by_md5, "by_name": by_name, "by_crc32": {}, "by_path_suffix": {}}}
|
|
self.platforms = self.root / "platforms"
|
|
self.emulators = self.root / "emulators"
|
|
self.platforms.mkdir()
|
|
self.emulators.mkdir()
|
|
run = {"emulator": "prog", "build": "standalone", "kind": "fixed_name",
|
|
"passes_to": "exec", "source_ref": "start_prog.sh:9"}
|
|
launchers = {
|
|
"biosplat": [{**run, "names": ["prog/prog"], "search_dir": "/userdata/bios"}],
|
|
"romsplat": [{**run, "names": ["prog"], "search_dir": "/storage/roms/prog"}],
|
|
"exeplat": [{**run, "names": ["prog"], "search_dir": "<emulator folder>"}],
|
|
"emuplat": [{**run, "names": ["prog"], "search_dir": '<GetPath("prog")>'}],
|
|
"plainplat": [],
|
|
}
|
|
for name, records in launchers.items():
|
|
(self.platforms / f"{name}.yml").write_text(yaml.safe_dump({
|
|
"platform": name, "version": "1", "verification_mode": "existence",
|
|
"base_destination": "bios", "cores": ["prog"], "standalone_cores": ["prog"],
|
|
"launchers": records,
|
|
"systems": {"prog-system": {"files": []}},
|
|
}))
|
|
(self.platforms / "_registry.yml").write_text(yaml.safe_dump({"platforms": {
|
|
**{name: {"config": f"{name}.yml"} for name in launchers},
|
|
"emuplat": {"config": "emuplat.yml", "emulators_directory": {
|
|
"launcher_spelling": '<GetPath("{key}")>', "bios_folder": "bios",
|
|
"searched": ["frontend/{key}", "emulators/{key}"],
|
|
"default": "emulators/{key}", "config": "frontend/launcher.cfg"}},
|
|
}}))
|
|
# The roms root as the OS's own source records it (romfolders).
|
|
(self.platforms / "romfolders").mkdir()
|
|
(self.platforms / "romfolders" / "romsplat.yml").write_text(yaml.safe_dump({
|
|
"setup": "romsplat", "platform": "romsplat",
|
|
"roms_root": {"path": "/storage/roms", "from_bios": "..", "source_ref": "os x.mk:1"},
|
|
"folders": [],
|
|
}))
|
|
# A list that installs into the roms root itself, its BIOS folder
|
|
# one of the folders there (AmberELEC's /storage/roms).
|
|
(self.platforms / "rootplat.yml").write_text(yaml.safe_dump({
|
|
"platform": "rootplat", "version": "1", "verification_mode": "existence",
|
|
"base_destination": "", "cores": ["prog"], "standalone_cores": ["prog"],
|
|
"launchers": [{**run, "names": ["prog"], "search_dir": "/storage/roms/prog"}],
|
|
"systems": {"prog-system": {"files": [
|
|
{"name": "own.bin", "destination": "bios/own.bin", "required": False},
|
|
]}},
|
|
}))
|
|
registry = yaml.safe_load((self.platforms / "_registry.yml").read_text())
|
|
registry["platforms"]["rootplat"] = {"config": "rootplat.yml"}
|
|
(self.platforms / "_registry.yml").write_text(yaml.safe_dump(registry))
|
|
(self.platforms / "romfolders" / "rootplat.yml").write_text(yaml.safe_dump({
|
|
"setup": "rootplat", "platform": "rootplat",
|
|
"roms_root": {"path": "/storage/roms", "from_bios": "..", "source_ref": "os x.mk:1"},
|
|
"folders": [],
|
|
}))
|
|
(self.emulators / "prog.yml").write_text(yaml.safe_dump({
|
|
"emulator": "Prog", "type": "standalone", "cores": ["prog"],
|
|
"systems": ["prog-system"],
|
|
"standalone_directories": ["executable directory"],
|
|
"files": [
|
|
{"name": "prog", "path": "prog/prog", "sha1": by_name["prog"][0],
|
|
"required": True},
|
|
{"name": "prog.dat", "path": "prog/prog.dat",
|
|
"sha1": by_name["prog.dat"][0], "required": True},
|
|
],
|
|
}))
|
|
|
|
def pack(self, platform: str) -> list[str]:
|
|
with redirect_stdout(io.StringIO()):
|
|
zip_path = generate_pack(
|
|
platform, str(self.platforms), self.db, str(self.root / "bios"),
|
|
str(self.root / "out" / platform), emulators_dir=str(self.emulators),
|
|
offline=True,
|
|
)
|
|
with zipfile.ZipFile(zip_path) as zf:
|
|
return sorted(n for n in zf.namelist() if n.split("/")[-1].startswith("prog"))
|
|
|
|
def manifest(self, platform: str) -> dict:
|
|
with redirect_stdout(io.StringIO()):
|
|
return generate_manifest(
|
|
platform, str(self.platforms), self.db, str(self.root / "bios"),
|
|
str(self.platforms / "_registry.yml"),
|
|
emulators_dir=str(self.emulators), offline=True,
|
|
)
|
|
|
|
def verify(self, platform: str) -> list[dict]:
|
|
config = load_platform_config(platform, str(self.platforms))
|
|
result = verify_platform(config, self.db, str(self.emulators))
|
|
return [u for u in result["undeclared_files"] if u["name"].startswith("prog")]
|
|
|
|
def test_run_from_the_bios_folder_keeps_the_layout(self) -> None:
|
|
for platform in ("biosplat", "plainplat"):
|
|
with self.subTest(platform=platform):
|
|
self.assertEqual(self.pack(platform), ["prog/prog", "prog/prog.dat"])
|
|
self.assertEqual(
|
|
sorted(f["dest"] for f in self.manifest(platform)["files"]),
|
|
["prog/prog", "prog/prog.dat"],
|
|
)
|
|
|
|
def test_run_from_the_roms_folder_is_installed_there(self) -> None:
|
|
self.assertEqual(self.pack("romsplat"), [])
|
|
manifest = self.manifest("romsplat")
|
|
self.assertEqual(
|
|
sorted((f["dest"], f.get("root")) for f in manifest["files"]),
|
|
[("prog/prog", "roms"), ("prog/prog.dat", "roms")],
|
|
)
|
|
self.assertEqual(manifest["roms_directory"], {"bios_folder": "bios", "from_bios": ".."})
|
|
entries = self.verify("romsplat")
|
|
self.assertEqual(sorted(u["path"] for u in entries), ["prog/prog", "prog/prog.dat"])
|
|
self.assertTrue(all(u["in_pack"] is False for u in entries))
|
|
|
|
def test_a_roms_root_inside_the_install_tree_is_a_plain_dest(self) -> None:
|
|
self.assertEqual(self.pack("rootplat"), ["prog/prog", "prog/prog.dat"])
|
|
manifest = self.manifest("rootplat")
|
|
self.assertEqual(
|
|
sorted((f["dest"], f.get("root")) for f in manifest["files"] if "prog" in f["dest"]),
|
|
[("prog/prog", None), ("prog/prog.dat", None)],
|
|
)
|
|
self.assertNotIn("roms_directory", manifest)
|
|
|
|
def test_run_from_an_emulator_folder_is_named(self) -> None:
|
|
self.assertEqual(self.pack("exeplat"), [])
|
|
manifest = self.manifest("exeplat")
|
|
self.assertEqual([f["dest"] for f in manifest["files"]], [])
|
|
self.assertEqual(
|
|
sorted((o["dest"], o["root"], o["reason"]) for o in manifest["omitted_files"]),
|
|
[("prog/prog", "emulator", "outside_bios_folder"),
|
|
("prog/prog.dat", "emulator", "outside_bios_folder")],
|
|
)
|
|
|
|
def test_the_installer_finds_the_roms_folder_above_the_bios_folder(self) -> None:
|
|
import install
|
|
|
|
manifest = self.manifest("romsplat")
|
|
install._check_relative_directories(manifest)
|
|
files = list(manifest["files"])
|
|
card = self.root / "card" / "roms" / "bios"
|
|
kept, left = install.place_relative_entries(files, card, manifest, "romsplat", say=False)
|
|
self.assertEqual((len(kept), left), (2, []))
|
|
self.assertEqual(
|
|
install._entry_path(card, kept[0]),
|
|
(card.parent / kept[0]["dest"]).resolve()
|
|
if install._entry_path(card, kept[0]).is_absolute() else card.parent / kept[0]["dest"],
|
|
)
|
|
other = self.root / "elsewhere"
|
|
kept, left = install.place_relative_entries(files, other, manifest, "romsplat", say=False)
|
|
self.assertEqual(kept, [])
|
|
self.assertEqual({o["reason"] for o in left}, {"no_roms_directory"})
|
|
with self.assertRaises(ValueError):
|
|
install._check_relative_directories({"files": files})
|
|
# A roms root beside the BIOS folder (Batocera's ../roms).
|
|
beside = {**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": "../roms"}}
|
|
install._check_relative_directories(beside)
|
|
disk = self.root / "userdata" / "bios"
|
|
install.place_relative_entries(files, disk, beside, "romsplat", say=False)
|
|
self.assertEqual(install._entry_place(disk, kept_entry := files[0])[0],
|
|
self.root / "userdata" / "roms")
|
|
self.assertEqual(kept_entry["root"], "roms")
|
|
for climbing in ("roms", "../roms/..", "/storage/roms"):
|
|
with self.subTest(from_bios=climbing), self.assertRaises(ValueError):
|
|
install._check_relative_directories(
|
|
{**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": climbing}}
|
|
)
|
|
|
|
def test_run_from_an_emulator_folder_the_registry_describes(self) -> None:
|
|
self.assertEqual(self.pack("emuplat"), [])
|
|
manifest = self.manifest("emuplat")
|
|
self.assertEqual(
|
|
sorted((f["dest"], f.get("root")) for f in manifest["files"]),
|
|
[("prog/prog", "emulators"), ("prog/prog.dat", "emulators")],
|
|
)
|
|
self.assertEqual(manifest["emulators_directory"]["default"], "emulators/{key}")
|
|
self.assertTrue(all(u["in_pack"] is False for u in self.verify("emuplat")))
|
|
|
|
def test_the_installer_follows_the_launcher_search(self) -> None:
|
|
import install
|
|
|
|
manifest = self.manifest("emuplat")
|
|
install._check_emulators_directory(manifest)
|
|
files = list(manifest["files"])
|
|
home = self.root / "Frontend"
|
|
bios = home / "bios"
|
|
bios.mkdir(parents=True)
|
|
# Not the platform's folder: no launcher config beside it.
|
|
kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
|
|
self.assertEqual((kept, {o["reason"] for o in left}), ([], {"no_emulators_directory"}))
|
|
(home / "frontend").mkdir()
|
|
(home / "frontend" / "launcher.cfg").write_text("other=x\n")
|
|
kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
|
|
self.assertEqual((len(kept), left), (2, []))
|
|
entry = next(f for f in kept if f["dest"] == "prog/prog")
|
|
self.assertEqual(install._entry_place(bios, entry), (home / "emulators" / "prog", "prog"))
|
|
# The first searched place that exists wins.
|
|
(home / "frontend" / "prog").mkdir()
|
|
install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
|
|
self.assertEqual(install._entry_place(bios, entry)[0], home / "frontend" / "prog")
|
|
# A key the launcher config sets moves the folder: left out.
|
|
(home / "frontend" / "launcher.cfg").write_text("prog=D:\\Prog\n")
|
|
kept, left = install.place_emulator_entries(files, bios, manifest, "emuplat", say=False)
|
|
self.assertEqual((kept, len(left)), ([], 2))
|
|
|
|
|
|
class EachProgramItsOwnProfile(unittest.TestCase):
|
|
"""PICO-8 and Voxatron are two programs: a platform running one gets its files only."""
|
|
|
|
def test_only_the_platforms_that_run_voxatron_get_it(self) -> None:
|
|
from common import list_registered_platforms, resolve_platform_cores
|
|
|
|
profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
|
runs = {
|
|
name: {k for k in ("lexaloffle", "voxatron")
|
|
if k in resolve_platform_cores(
|
|
load_platform_config(name, str(REPO_ROOT / "platforms")), profiles)}
|
|
for name in list_registered_platforms(str(REPO_ROOT / "platforms"))
|
|
}
|
|
self.assertEqual(
|
|
{name for name, found in runs.items() if "voxatron" in found}, {"batocera", "knulli"}
|
|
)
|
|
self.assertTrue(all("lexaloffle" in runs[name] for name in (
|
|
"batocera", "knulli", "rocknix", "retrodeck", "retrobat", "recalbox")))
|
|
|
|
def test_a_standalone_emulator_is_run(self) -> None:
|
|
from common import resolve_platform_cores
|
|
|
|
profiles = {"emu": {"emulator": "Emu", "type": "standalone", "cores": ["emu"],
|
|
"systems": ["sys"], "files": []}}
|
|
config = {"cores": ["other"], "standalone_cores": ["emu"], "systems": {}}
|
|
self.assertEqual(resolve_platform_cores(config, profiles), {"emu"})
|
|
|
|
|
|
BIOS_SETTING = {
|
|
"emulator": "ps2emu", "build": "standalone", "kind": "passes_path", "names": [],
|
|
"search_dir": "/userdata/bios/ps2",
|
|
"passes_to": "inis/PCSX2.ini [Folders] Bios = ../../../bios/ps2, relative to the data root",
|
|
"source_ref": "ps2emuGenerator.py:219",
|
|
}
|
|
# The same folder through a scans_dir record, and a record setting the image
|
|
# name alone, which hands no folder.
|
|
BIOS_SCAN = {**BIOS_SETTING, "kind": "scans_dir", "passes_to": "PCSX2.ini [Folders] Bios"}
|
|
BIOS_NAME = {**BIOS_SETTING, "passes_to": "PCSX2.ini [Filenames] BIOS"}
|
|
|
|
|
|
class ASettingSetsTheBiosFolder(unittest.TestCase):
|
|
"""KNULLI sets AetherSX2's [Folders] Bios to bios/ps2.
|
|
|
|
The profile reads its BIOS images by content from bios/ under its data
|
|
root, and names that setting in standalone_directories. Every image the
|
|
scan finds goes to ps2/ in the pack, the manifest and verify, and a file
|
|
the layout keeps outside its bios/ folder is named, never packed.
|
|
"""
|
|
|
|
def setUp(self) -> None:
|
|
_emulator_profiles_cache.clear()
|
|
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
|
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
|
self.addCleanup(shutil.rmtree, self.root, True)
|
|
files, by_md5, by_name = {}, {}, {}
|
|
for folder, name, data in (
|
|
("Sony/PlayStation 2", "img-a.bin", b"PS2 IMAGE A....."),
|
|
("Sony/PlayStation 2", "img-b.bin", b"PS2 IMAGE B....."),
|
|
("Other/ps2emu/arcade", "boot.zip", b"BOOT PROGRAMS"),
|
|
):
|
|
where = self.root / "bios" / folder
|
|
where.mkdir(parents=True, exist_ok=True)
|
|
(where / name).write_bytes(data)
|
|
sha1 = hashlib.sha1(data).hexdigest()
|
|
md5 = hashlib.md5(data).hexdigest()
|
|
files[sha1] = {"path": str(where / name), "name": name, "sha1": sha1,
|
|
"md5": md5, "crc32": "", "size": len(data)}
|
|
by_md5[md5] = sha1
|
|
by_name[name] = [sha1]
|
|
self.db = {
|
|
"files": files,
|
|
"indexes": {"by_md5": by_md5, "by_name": by_name, "by_crc32": {},
|
|
"by_path_suffix": {}},
|
|
}
|
|
self.platforms = self.root / "platforms"
|
|
self.emulators = self.root / "emulators"
|
|
self.platforms.mkdir()
|
|
self.emulators.mkdir()
|
|
platforms = {"setplat": [BIOS_SETTING], "scanplat": [BIOS_SCAN],
|
|
"nameplat": [BIOS_NAME], "plainplat": []}
|
|
for name, launchers in platforms.items():
|
|
(self.platforms / f"{name}.yml").write_text(yaml.safe_dump({
|
|
"platform": name, "version": "1", "verification_mode": "existence",
|
|
"base_destination": "bios", "cores": ["ps2emu"],
|
|
"standalone_cores": ["ps2emu"], "launchers": launchers,
|
|
"systems": {"sony-playstation-2": {"files": []}},
|
|
}))
|
|
(self.platforms / "_registry.yml").write_text(yaml.safe_dump({"platforms": {
|
|
name: {"config": f"{name}.yml"} for name in platforms
|
|
}}))
|
|
(self.emulators / "ps2emu.yml").write_text(yaml.safe_dump({
|
|
"emulator": "PS2Emu", "type": "standalone", "bios_mode": "agnostic",
|
|
"cores": ["ps2emu"], "systems": ["sony-playstation-2"],
|
|
"bios_directory": "bios/",
|
|
"standalone_directories": ["[Folders] Bios = bios"],
|
|
"files": [
|
|
{"name": "img-a.bin", "path": "bios/img-a.bin", "required": True,
|
|
"min_size": 16, "max_size": 16, "validation": ["size"]},
|
|
# Read below the data root, never below its bios/ folder.
|
|
{"name": "boot.zip", "standalone_path": "arcade/boot.zip",
|
|
"mode": "standalone", "required": False},
|
|
],
|
|
}))
|
|
|
|
def pack_names(self, platform: str) -> list[str]:
|
|
with redirect_stdout(io.StringIO()):
|
|
zip_path = generate_pack(
|
|
platform, str(self.platforms), self.db, str(self.root / "bios"),
|
|
str(self.root / "out" / platform), emulators_dir=str(self.emulators),
|
|
offline=True,
|
|
)
|
|
with zipfile.ZipFile(zip_path) as zf:
|
|
return sorted(n for n in zf.namelist() if n.endswith((".bin", ".zip")))
|
|
|
|
def manifest(self, platform: str) -> dict:
|
|
with redirect_stdout(io.StringIO()):
|
|
return generate_manifest(
|
|
platform, str(self.platforms), self.db, str(self.root / "bios"),
|
|
str(self.platforms / "_registry.yml"),
|
|
emulators_dir=str(self.emulators), offline=True,
|
|
)
|
|
|
|
def verify_dests(self, platform: str) -> list[str]:
|
|
config = load_platform_config(platform, str(self.platforms))
|
|
result = verify_platform(config, self.db, str(self.emulators))
|
|
return sorted(d for d in result["pack_destinations"] if d.endswith((".bin", ".zip")))
|
|
|
|
def assert_placed(self, platform: str, expected: list[str]) -> None:
|
|
self.assertEqual(self.pack_names(platform), expected)
|
|
self.assertEqual(
|
|
sorted(f["dest"] for f in self.manifest(platform)["files"]), expected
|
|
)
|
|
self.assertEqual(self.verify_dests(platform), expected)
|
|
|
|
def test_every_scanned_image_lands_in_the_folder_set(self) -> None:
|
|
self.assert_placed("setplat", ["ps2/img-a.bin", "ps2/img-b.bin"])
|
|
|
|
def test_a_scans_dir_record_sets_it_too(self) -> None:
|
|
self.assert_placed("scanplat", ["ps2/img-a.bin", "ps2/img-b.bin"])
|
|
|
|
def test_the_image_name_setting_hands_no_folder(self) -> None:
|
|
self.assert_placed("nameplat", ["img-a.bin", "img-b.bin"])
|
|
|
|
def test_a_platform_without_the_record_keeps_the_layout(self) -> None:
|
|
self.assert_placed("plainplat", ["img-a.bin", "img-b.bin"])
|
|
|
|
def test_a_file_outside_the_bios_folder_is_named(self) -> None:
|
|
omitted = [o for o in self.manifest("setplat")["omitted_files"]
|
|
if o["name"] == "boot.zip"]
|
|
self.assertEqual(
|
|
[(o["dest"], o["root"], o["reason"], o["cores"]) for o in omitted],
|
|
[("arcade/boot.zip", "emulator", "outside_bios_folder", ["ps2emu"])],
|
|
)
|
|
|
|
def test_the_ini_key_carries_its_section(self) -> None:
|
|
from launchers import ini_keys
|
|
|
|
self.assertEqual(
|
|
ini_keys("PCSX2.ini [Folders] Bios = /storage/roms/bios/armsx2, [Filenames] BIOS"),
|
|
{"[Folders] Bios", "[Filenames] BIOS"},
|
|
)
|
|
self.assertEqual(ini_keys("command line -rompath"), frozenset())
|
|
|
|
|
|
class AetherSX2OnKnulli(unittest.TestCase):
|
|
"""The real profile against KNULLI's aethersx2Generator.py:219 record."""
|
|
|
|
def test_the_bios_images_land_in_ps2(self) -> None:
|
|
profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["aethersx2"]
|
|
config = load_platform_config("knulli", str(REPO_ROOT / "platforms"))
|
|
(view, standalone), = platform_layouts("aethersx2", profile, standalone_set(config))
|
|
self.assertTrue(standalone)
|
|
for entry in view["files"]:
|
|
path = entry.get("path") or ""
|
|
if path.startswith("bios/"):
|
|
self.assertEqual(entry["standalone_path"], "ps2/" + path.removeprefix("bios/"))
|
|
elif path:
|
|
self.assertEqual(entry.get("unplaced"), "outside_bios_folder", path)
|
|
|
|
|
|
class Quasi88OnRetroPie(unittest.TestCase):
|
|
"""RetroPie writes -romdir $biosdir/pc88 into quasi88.ini (quasi88.sh:67)."""
|
|
|
|
def test_the_roms_land_in_pc88(self) -> None:
|
|
profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["quasi88kai"]
|
|
config = load_platform_config("retropie", str(REPO_ROOT / "platforms"))
|
|
(view, standalone), = platform_layouts("quasi88kai", profile, standalone_set(config))
|
|
self.assertTrue(standalone)
|
|
paths = [e["path"] for e in view["files"]]
|
|
self.assertTrue(paths and all(p.startswith("rom/") for p in paths))
|
|
self.assertEqual(
|
|
[e["standalone_path"] for e in view["files"]],
|
|
["pc88/" + p.removeprefix("rom/") for p in paths],
|
|
)
|
|
|
|
|
|
class FlycastOnEmuELEC(unittest.TestCase):
|
|
"""EmuELEC links /storage/.local/share/flycast to bios/dc (flycast.sh:11-14),
|
|
and root's home is /storage (busybox profile.d/98-busybox.conf:4)."""
|
|
|
|
def test_the_home_directory_is_spelled_as_home(self) -> None:
|
|
from launchers import normalize_handle
|
|
|
|
self.assertEqual(
|
|
normalize_handle("/storage/.local/share/flycast", "/storage"), "~/.local/share/flycast"
|
|
)
|
|
self.assertEqual(normalize_handle("/storage", "/storage/"), "~")
|
|
self.assertEqual(normalize_handle("/storagex/a", "/storage"), "/storagex/a")
|
|
self.assertEqual(
|
|
normalize_handle("/storage/.local/share/flycast"), "/storage/.local/share/flycast"
|
|
)
|
|
|
|
def test_the_bios_lands_in_dc(self) -> None:
|
|
profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["flycast"]
|
|
config = load_platform_config("emuelec", str(REPO_ROOT / "platforms"))
|
|
layouts = platform_layouts("flycast", profile, standalone_set(config))
|
|
(view,) = [v for v, standalone in layouts if standalone]
|
|
self.assertTrue(view["files"])
|
|
for entry in view["files"]:
|
|
self.assertEqual(entry["standalone_path"], "dc/" + entry["layout_path"])
|
|
|
|
|
|
def standalone_view(emulator: str, platform: str) -> dict:
|
|
profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))[emulator]
|
|
config = load_platform_config(platform, str(REPO_ROOT / "platforms"))
|
|
(view,) = [v for v, standalone in platform_layouts(emulator, profile, standalone_set(config))
|
|
if standalone]
|
|
return view
|
|
|
|
|
|
class Eka2l1OnRetroBat(unittest.TestCase):
|
|
"""RetroBat writes data-storage = bios/eka2l1/data (Eka2l1.Generator.cs:153, 156)."""
|
|
|
|
def test_the_device_rom_lands_in_eka2l1_data(self) -> None:
|
|
view = standalone_view("eka2l1", "retrobat")
|
|
(rom,) = [e for e in view["files"] if e["name"] == "SYM.ROM"]
|
|
self.assertEqual(rom["standalone_path"], "eka2l1/data/roms/nem-4/SYM.ROM")
|
|
# Shipped beside the executable, in RetroBat's emulator folder.
|
|
for entry in view["files"]:
|
|
if entry.get("mode") == "standalone":
|
|
self.assertEqual(entry.get("unplaced"), "outside_bios_folder", entry["name"])
|
|
|
|
|
|
class CemuOnRetroDECK(unittest.TestCase):
|
|
"""RetroDECK sets mlc_path to $bios_path/cemu (cemu/component_prepare.sh:14, 29)
|
|
and keeps Cemu's user data in its Flatpak's $XDG_DATA_HOME/Cemu."""
|
|
|
|
def test_the_mlc_lands_in_cemu(self) -> None:
|
|
view = standalone_view("cemu", "retrodeck")
|
|
mlc = [e for e in view["files"] if (e.get("layout_path") or "").startswith("mlc01/")]
|
|
self.assertTrue(mlc)
|
|
for entry in mlc:
|
|
self.assertEqual(
|
|
entry["standalone_path"], "cemu/" + entry["layout_path"].removeprefix("mlc01/")
|
|
)
|
|
(otp,) = [e for e in view["files"] if e["name"] == "otp.bin"]
|
|
self.assertEqual(otp.get("unplaced"), "outside_bios_folder")
|
|
|
|
|
|
class DolphinUserDirectory(unittest.TestCase):
|
|
"""EmuELEC runs Dolphin with XDG_DATA_HOME=/storage/roms/dolphin (dolphin.sh:28),
|
|
RetroDECK links $XDG_DATA_HOME/dolphin-emu/Triforce to bios/Triforce
|
|
(dolphin/component_prepare.sh:30, 49)."""
|
|
|
|
def test_emuelec_installs_the_user_directory_below_its_roms(self) -> None:
|
|
view = standalone_view("dolphin", "emuelec")
|
|
placed = {e["name"]: e["standalone_path"] for e in view["files"] if e.get("layout_path")}
|
|
self.assertEqual(placed["segaboot.gcm"], "dolphin/dolphin-emu/Triforce/segaboot.gcm")
|
|
# A standalone-only entry's path is its place in the User directory.
|
|
self.assertEqual(
|
|
placed["rtl8723a_fw.bin"], "dolphin/dolphin-emu/Load/Firmware/rtl_bt/rtl8723a_fw.bin"
|
|
)
|
|
self.assertTrue(all(e.get("unprefixed") for e in view["files"] if e.get("layout_path")))
|
|
|
|
def test_retrodeck_places_triforce_alone(self) -> None:
|
|
view = standalone_view("dolphin", "retrodeck")
|
|
placed = {e["name"]: e["standalone_path"] for e in view["files"] if e.get("layout_path")}
|
|
self.assertEqual(placed, {"segaboot.gcm": "Triforce/segaboot.gcm"})
|
|
(ipl, *_) = [e for e in view["files"] if e["name"] == "IPL.bin"]
|
|
self.assertEqual(ipl.get("unplaced"), "outside_bios_folder")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|