mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 12:19:13 -05:00
fix: link a system to its maker's folder
The database keeps one path per content, so a system placed by the copy deduplication kept linked to another maker: nintendo-nes and nintendo-fds to Arcade > FBNeo, sega-mastersystem and sinclair-zx-spectrum to Other > zesarux. Every folder holding a copy now votes, an id that spells one console folder links to it, and a maker's folder outranks the folders that file copies by emulator.
This commit is contained in:
1 parent
0242dc77ee
commit
45db77cfd0
2 files changed
+206
-24
No files matched your search
+123
-24
@@ -125,19 +125,95 @@ CLS_LABELS = {
|
||||
}
|
||||
|
||||
|
||||
def _copy_places(db: dict) -> dict[str, set[tuple[str, str, str]]]:
|
||||
"""sha1 -> every (manufacturer, console, file name) holding a copy.
|
||||
|
||||
The database keeps one path per content, deduplication having left one
|
||||
copy. by_path_suffix still records the other folders a copy is filed in,
|
||||
under the name that folder gives it.
|
||||
"""
|
||||
files = db.get("files", {})
|
||||
bios_dir = Path(str(db.get("bios_dir") or "bios"))
|
||||
places: dict[str, set[tuple[str, str, str]]] = {}
|
||||
for sha1, entry in files.items():
|
||||
parts = str(entry.get("path", "")).split("/")
|
||||
if len(parts) >= 3 and parts[0] == "bios":
|
||||
places.setdefault(sha1, set()).add((parts[1], parts[2], parts[-1]))
|
||||
for tail, sha1s in db.get("indexes", {}).get("by_path_suffix", {}).items():
|
||||
parts = tail.split("/")
|
||||
if len(parts) < 3 or parts[1].startswith(".") or len(sha1s) != 1:
|
||||
continue
|
||||
if sha1s[0] in files and (bios_dir / tail).is_file():
|
||||
places.setdefault(sha1s[0], set()).add((parts[0], parts[1], parts[-1]))
|
||||
return places
|
||||
|
||||
|
||||
# bios/Other/<emulator> and bios/Arcade/<emulator or board> file a copy by
|
||||
# who loads it, not by machine. A copy also filed under a machine's maker
|
||||
# places the system there.
|
||||
_BUCKET_FOLDERS = frozenset({"Other", "Arcade"})
|
||||
|
||||
|
||||
def _slug(manufacturer: str) -> str:
|
||||
return manufacturer.lower().replace(" ", "-")
|
||||
|
||||
|
||||
def _names_manufacturer(sys_id: str, manufacturer: str) -> bool:
|
||||
slug = _slug(manufacturer)
|
||||
return sys_id == slug or sys_id.startswith(f"{slug}-")
|
||||
|
||||
|
||||
def _copies_by_path(db: dict) -> dict[str, set[tuple[str, str, str]]]:
|
||||
"""The copy places of each content, keyed by the path the database keeps."""
|
||||
files = db.get("files", {})
|
||||
return {
|
||||
str(files[sha1].get("path", "")): where
|
||||
for sha1, where in _copy_places(db).items()
|
||||
}
|
||||
|
||||
|
||||
def _system_folder(
|
||||
files: list[dict], db: dict, name_consoles: dict[str, set[tuple[str, str]]]
|
||||
files: list[dict],
|
||||
db: dict,
|
||||
name_consoles: dict[str, set[tuple[str, str]]],
|
||||
sys_id: str = "",
|
||||
copies: dict[str, set[tuple[str, str, str]]] | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
"""The bios/<manufacturer>/<console> folder a system's files live in."""
|
||||
"""The bios/<manufacturer>/<console> folder a system's files live in.
|
||||
|
||||
A content votes for every folder holding a copy under the name the system
|
||||
declares, not only the one deduplication kept: disksys.rom is kept as
|
||||
Arcade/FBNeo/fdsbios.nes and filed again under Nintendo/Famicom Disk
|
||||
System. A copy filed under another name, outside the manufacturer the id
|
||||
names, is another machine's file and votes only when no copy carries the
|
||||
declared name. An id that names its manufacturer (nintendo-fds) keeps the
|
||||
votes cast inside that manufacturer when there are any, and a maker's
|
||||
folder outranks the folders that file copies by emulator.
|
||||
"""
|
||||
if copies is None:
|
||||
copies = _copies_by_path(db)
|
||||
votes: dict[tuple[str, str], int] = {}
|
||||
for fe in files:
|
||||
local, status = resolve_local_file(fe, db)
|
||||
parts = (local or "").split("/")
|
||||
if len(parts) >= 4 and parts[0] == "bios" and resolution_is_hash_exact(status):
|
||||
place = (parts[1], parts[2])
|
||||
if not local or not resolution_is_hash_exact(status):
|
||||
continue
|
||||
held = copies.get(local, set())
|
||||
if not held:
|
||||
parts = local.split("/")
|
||||
if len(parts) >= 4 and parts[0] == "bios":
|
||||
held = {(parts[1], parts[2], parts[-1])}
|
||||
declared = str(fe.get("name", "")).casefold()
|
||||
named = {
|
||||
(m, c) for m, c, base in held
|
||||
if base.casefold() == declared or _names_manufacturer(sys_id, m)
|
||||
}
|
||||
for place in named or {(m, c) for m, c, _ in held}:
|
||||
votes[place] = votes.get(place, 0) + 1
|
||||
if votes:
|
||||
return max(sorted(votes), key=lambda place: votes[place])
|
||||
own = [place for place in votes if _names_manufacturer(sys_id, place[0])]
|
||||
makers = [place for place in votes if place[0] not in _BUCKET_FOLDERS]
|
||||
candidates = own or makers or list(votes)
|
||||
return max(sorted(candidates), key=lambda place: votes[place])
|
||||
for fe in files:
|
||||
places = name_consoles.get(fe.get("name", ""), set())
|
||||
if len(places) == 1:
|
||||
@@ -145,6 +221,32 @@ def _system_folder(
|
||||
return None
|
||||
|
||||
|
||||
def _folders_by_name(manufacturers: dict) -> dict[str, set[tuple[str, str]]]:
|
||||
"""Normalized system name -> the console folders that spell it.
|
||||
|
||||
nintendo-nes, nes and the NES folder normalize alike, as do
|
||||
sega-mastersystem and Sega/Master System.
|
||||
"""
|
||||
from common import _norm_system_id
|
||||
|
||||
index: dict[str, set[tuple[str, str]]] = {}
|
||||
for mfr, consoles in manufacturers.items():
|
||||
for console in consoles:
|
||||
spelled = console.lower().replace(" ", "-")
|
||||
for key in {
|
||||
_norm_system_id(f"{_slug(mfr)}-{spelled}"), _norm_system_id(spelled)
|
||||
}:
|
||||
index.setdefault(key, set()).add((mfr, console))
|
||||
return index
|
||||
|
||||
|
||||
def _folder_named(sys_id: str, by_name: dict[str, set[tuple[str, str]]]) -> tuple[str, str] | None:
|
||||
from common import _norm_system_id
|
||||
|
||||
places = by_name.get(_norm_system_id(sys_id), set())
|
||||
return next(iter(places)) if len(places) == 1 else None
|
||||
|
||||
|
||||
def _system_pages_from_data(
|
||||
manufacturers: dict,
|
||||
coverages: dict,
|
||||
@@ -189,21 +291,28 @@ def _system_pages_from_data(
|
||||
console_norm[key] = entry
|
||||
|
||||
# Map system_id -> (manufacturer, console) via platform file entries
|
||||
copies = _copies_by_path(db)
|
||||
folders_by_name = _folders_by_name(manufacturers)
|
||||
pages: dict[str, tuple[str, str]] = {}
|
||||
for cov in coverages.values():
|
||||
config = cov["config"]
|
||||
for sys_id, system in config.get("systems", {}).items():
|
||||
if sys_id in pages:
|
||||
continue
|
||||
# Strategy 1: the folder holding the files the system declares,
|
||||
# Strategy 1: the one console folder the id spells (nintendo-nes,
|
||||
# NES). A copy of the disk system BIOS in the NES list does not
|
||||
# move the NES to the folder that copy sits in.
|
||||
# Strategy 2: the folder holding the files the system declares,
|
||||
# found by content, and most votes win. A bare name stands in only
|
||||
# where the collection files it in a single console.
|
||||
place = _system_folder(system.get("files", []), db, name_consoles)
|
||||
place = _folder_named(sys_id, folders_by_name) or _system_folder(
|
||||
system.get("files", []), db, name_consoles, sys_id, copies
|
||||
)
|
||||
if place is not None:
|
||||
mfr, console = place
|
||||
pages[sys_id] = (mfr.lower().replace(" ", "-"), console)
|
||||
continue
|
||||
# Strategy 2: fuzzy match system_id against console directory names
|
||||
# Strategy 3: fuzzy match system_id against console directory names
|
||||
if sys_id in console_norm:
|
||||
pages[sys_id] = console_norm[sys_id]
|
||||
else:
|
||||
@@ -228,7 +337,9 @@ def _system_pages_from_data(
|
||||
if isinstance(entry, dict)
|
||||
and (entry.get("system") == sys_id or len(systems) == 1)
|
||||
]
|
||||
place = _system_folder(files, db, name_consoles)
|
||||
place = _folder_named(sys_id, folders_by_name) or _system_folder(
|
||||
files, db, name_consoles, sys_id, copies
|
||||
)
|
||||
if place is not None:
|
||||
mfr, console = place
|
||||
pages[sys_id] = (mfr.lower().replace(" ", "-"), console)
|
||||
@@ -2151,21 +2262,9 @@ def _group_by_manufacturer(db: dict) -> dict[str, dict[str, list]]:
|
||||
read from the full paths by_path_suffix records.
|
||||
"""
|
||||
files = db.get("files", {})
|
||||
bios_dir = Path(str(db.get("bios_dir") or "bios"))
|
||||
places: dict[str, set[tuple[str, str]]] = {}
|
||||
for sha1, entry in files.items():
|
||||
parts = str(entry.get("path", "")).split("/")
|
||||
if len(parts) >= 3 and parts[0] == "bios":
|
||||
places.setdefault(sha1, set()).add((parts[1], parts[2]))
|
||||
for tail, sha1s in db.get("indexes", {}).get("by_path_suffix", {}).items():
|
||||
parts = tail.split("/")
|
||||
if len(parts) < 3 or parts[1].startswith(".") or len(sha1s) != 1:
|
||||
continue
|
||||
if sha1s[0] in files and (bios_dir / tail).is_file():
|
||||
places.setdefault(sha1s[0], set()).add((parts[0], parts[1]))
|
||||
manufacturers: dict[str, dict[str, list]] = {}
|
||||
for sha1, where in places.items():
|
||||
for manufacturer, console in sorted(where):
|
||||
for sha1, where in _copy_places(db).items():
|
||||
for manufacturer, console in sorted({(m, c) for m, c, _ in where}):
|
||||
manufacturers.setdefault(manufacturer, {}).setdefault(console, []).append(files[sha1])
|
||||
return manufacturers
|
||||
|
||||
|
||||
@@ -147,6 +147,89 @@ class AnIdNamesOneCardPerPage(unittest.TestCase):
|
||||
self.assertEqual(page.count("rb-sys-file"), 2)
|
||||
|
||||
|
||||
class ACopyFiledByEmulatorDoesNotMoveAMachine(unittest.TestCase):
|
||||
"""The database keeps one path per content, and the system links followed
|
||||
it: nintendo-nes and nintendo-fds led to Arcade > FBNeo, where
|
||||
disksys.rom is kept as fdsbios.nes, sega-mastersystem and
|
||||
sinclair-zx-spectrum to Other > zesarux."""
|
||||
|
||||
def _db(self, tmp: str) -> dict:
|
||||
for rel in ("Arcade/FBNeo/fdsbios.nes", "Nintendo/Famicom Disk System/disksys.rom",
|
||||
"Nintendo/NES/other.rom"):
|
||||
(Path(tmp) / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(Path(tmp) / rel).write_bytes(b"x")
|
||||
return {
|
||||
"bios_dir": tmp,
|
||||
"files": {
|
||||
"d" * 40: {"path": "bios/Arcade/FBNeo/fdsbios.nes", "sha1": "d" * 40,
|
||||
"name": "fdsbios.nes"},
|
||||
"e" * 40: {"path": "bios/Nintendo/NES/other.rom", "sha1": "e" * 40,
|
||||
"name": "other.rom"},
|
||||
},
|
||||
"indexes": {
|
||||
"by_sha1": {}, "by_md5": {}, "by_name": {},
|
||||
"by_path_suffix": {
|
||||
"Arcade/FBNeo/fdsbios.nes": ["d" * 40],
|
||||
"Nintendo/Famicom Disk System/disksys.rom": ["d" * 40],
|
||||
"Nintendo/NES/other.rom": ["e" * 40],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def test_the_copy_under_the_declared_name_places_the_system(self):
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
db = self._db(tmp)
|
||||
files = [{"name": "disksys.rom", "sha1": "d" * 40}]
|
||||
self.assertEqual(
|
||||
gs._system_folder(files, db, {}, "nintendo-fds"),
|
||||
("Nintendo", "Famicom Disk System"),
|
||||
)
|
||||
# A file named otherwise still finds the maker's copy before the
|
||||
# emulator's folder.
|
||||
files = [{"name": "FDS_disksys-nintendo.rom", "sha1": "d" * 40}]
|
||||
self.assertEqual(
|
||||
gs._system_folder(files, db, {}, "fds"),
|
||||
("Nintendo", "Famicom Disk System"),
|
||||
)
|
||||
|
||||
def test_an_id_that_spells_a_folder_links_to_it(self):
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
db = self._db(tmp)
|
||||
manufacturers = gs._group_by_manufacturer(db)
|
||||
config = {"systems": {"nintendo-nes": {"files": [
|
||||
{"name": "FDS_disksys-nintendo.rom", "sha1": "d" * 40}]}}}
|
||||
pages = gs._system_pages_from_data(
|
||||
manufacturers, {"p": {"config": config}}, db)
|
||||
self.assertEqual(pages["nintendo-nes"], ("nintendo", "NES"))
|
||||
|
||||
def test_repository_systems(self):
|
||||
if not (REPO_ROOT / "database.json").exists():
|
||||
self.skipTest("database.json is not built")
|
||||
previous = os.getcwd()
|
||||
os.chdir(REPO_ROOT)
|
||||
self.addCleanup(os.chdir, previous)
|
||||
from common import load_database, load_emulator_profiles
|
||||
|
||||
db = load_database("database.json")
|
||||
manufacturers = gs._group_by_manufacturer(db)
|
||||
pages = gs._system_pages_from_data(
|
||||
manufacturers, {}, db, load_emulator_profiles("emulators", skip_aliases=False))
|
||||
expected = {
|
||||
"nintendo-nes": ("nintendo", "NES"),
|
||||
"nintendo-fds": ("nintendo", "Famicom Disk System"),
|
||||
"sega-mastersystem": ("sega", "Master System"),
|
||||
"sinclair-zx-spectrum": ("sinclair", "ZX Spectrum"),
|
||||
"commodore-vic20": ("commodore", "VIC-20"),
|
||||
}
|
||||
for sys_id, page in expected.items():
|
||||
with self.subTest(system=sys_id):
|
||||
self.assertEqual(pages.get(sys_id), page)
|
||||
|
||||
|
||||
class SystemEmulatorsWhenNoIdMatches(unittest.TestCase):
|
||||
"""RetroArch files NAOMI and Atomiswave as sega-dreamcast-arcade and the
|
||||
Enterprise as enterprise-64-128, ids no profile uses: the emulator cell
|
||||
|
||||
Reference in new issue
Block a user