From 55e2c8328e2555c7ea6745ae6d9fc0c2aedb70ac Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:02:51 +0100 Subject: [PATCH] feat: pack readme names spellings left out A pack folded for Windows and macOS keeps one spelling of each case twin. Extracted on a Steam Deck or Linux, the core asking for the other spelling finds nothing, and only the installer added it. Where the platform also runs on a filesystem that keeps case, the README now lists each spelling left out beside the one it holds: 12 for EmuDeck and 10 for RetroArch, the manifest's case_variants. --- scripts/generate_pack.py | 57 +++++++++++++++- scripts/packreadme.py | 19 ++++++ tests/test_case_insensitive_platforms.py | 82 ++++++++++++++++++++++++ wiki/architecture.md | 2 +- 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index ba948fc7a..3e3c2754c 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -608,6 +608,17 @@ def _pack_data_directories( return added +def _case_twin_pairs( + twins: list[str], seen_destinations: set[str], base_dest: str, flatten: bool, +) -> list[tuple[str, str]]: + """Each spelling left out, with the spelling the pack holds.""" + held = { + _flat(dest, base_dest, flatten).lower(): _flat(dest, base_dest, flatten) + for dest in seen_destinations + } + return sorted({(twin, held[twin.lower()]) for twin in twins if twin.lower() in held}) + + def _report_pack_counts( zip_path: str, file_status: dict, @@ -879,6 +890,8 @@ def generate_pack( user_provided = [] seen_destinations: set[str] = set() seen_lower: set[str] = set() # only used when case_insensitive=True + # Spellings left out as case twins of a packed file, for the README. + case_twins: list[str] = [] seen_parents: set[str] = ( set() ) # parent dirs of added files (path conflict detection) @@ -1185,6 +1198,8 @@ def generate_pack( ) if already_packed: + if dedup_key not in seen_destinations: + case_twins.append(_flat(full_dest, base_dest, flatten)) continue if within is not None and not _same_bytes( local_path, within.get(_flat(full_dest, base_dest, flatten)) @@ -1267,10 +1282,13 @@ def generate_pack( # Taken, a case twin on a case-insensitive filesystem, or a # file/directory conflict (SGB1.sfc vs SGB1.sfc/): verify replays # the same rule (packextras.hidden_extras). - if placement_block( + block = placement_block( full_dest, seen_destinations, seen_lower, seen_parents, case_insensitive, - ): + ) + if block: + if block == "case_twin" and extra_resolver.resolve(fe, dest)[0]: + case_twins.append(_flat(full_dest, base_dest, flatten)) continue # The emulator's own check picks the copy, as for a declared file @@ -1364,6 +1382,37 @@ def generate_pack( if _registry_path.exists(): with open(_registry_path) as _rf: _pack_registry = (yaml_load(_rf) or {}).get("platforms", {}) + keeps_case = case_insensitive and any( + d.get("os") not in CASE_FOLDING_OS + for d in (_pack_registry.get(platform_name, {}).get("install", {}) or {}) + .get("detect", []) + ) + if keeps_case and source != "platform" and precomputed_extras is None: + # The spellings a folded platform counts as declared never reach + # the extras: collected as the filesystem that keeps case sees + # them, as the manifest's case_variants are (Mesen-CE's bios.col + # beside the declared BIOS.col). + unfolded = _collect_emulator_extras( + {**config, "case_insensitive_fs": False}, emulators_dir, db, set(), + base_dest, emu_profiles, target_cores=target_cores, + include_all=(source == "truth"), + ) + if system_filter: + unfolded = _extras_for_systems(unfolded, system_filter) + prefix = _detect_extras_prefix(config, base_dest) + for fe in unfolded: + if required_only and fe.get("required") is False: + continue + if region_drops and extra_region_key(fe, declared_root(config)) in region_drops: + continue + full = extra_full_destination(fe, prefix) + if ( + full and full not in seen_destinations and full.lower() in seen_lower + and extra_resolver.resolve( + fe, sanitize_pack_path(fe.get("destination", fe["name"])) + )[0] + ): + case_twins.append(_flat(full, base_dest, flatten)) readme_text = _build_readme( platform_name, platform_display, base_dest, total_files, num_systems, source=source, @@ -1389,6 +1438,10 @@ def generate_pack( ) if save_count else "" ), save_by_system=bool(save_count and save_layout(config).by_system), + case_twins=( + _case_twin_pairs(case_twins, seen_destinations, base_dest, flatten) + if keeps_case else [] + ), ) _write_generated_member(zf, "README.txt", readme_text) diff --git a/scripts/packreadme.py b/scripts/packreadme.py index 06ee483a3..a9a12879b 100644 --- a/scripts/packreadme.py +++ b/scripts/packreadme.py @@ -207,6 +207,7 @@ def _build_readme( save_folder: str = "", save_where: str = "", save_by_system: bool = False, + case_twins: list[tuple[str, str]] | None = None, ) -> str: """Build a personalized step-by-step README for each platform pack. @@ -216,6 +217,10 @@ def _build_readme( frontend's save directory, empty when the pack holds none, and ``save_where`` that directory's default place (save_place). ``save_by_system`` is set when the launcher hands each system a folder. + ``case_twins`` pairs each spelling the pack leaves out, a case twin of a + packed file, with the one it holds: given for a platform that also runs + where names keep their case (EmuDeck and RetroArch on Linux), where the + core asking for the other spelling finds nothing. """ narrowings = narrowings or [] sep = "=" * 50 @@ -245,6 +250,20 @@ def _build_readme( + (pack_manual(platform_name) or _generic_manual(bios_paths)) ) + if case_twins: + listed = "".join( + f" {missing} (the pack has {held})\n" for missing, held in case_twins + ) + guide += ( + "NAMES THAT DIFFER ONLY BY CASE\n\n" + " Some cores open the same file under another spelling. This\n" + " pack holds one spelling of each, all Windows and macOS can\n" + " store in one folder. Linux, SteamOS and Android keep both:\n" + " there, run the installer, which adds these spellings, or\n" + " copy each file under the name below.\n\n" + f"{listed}\n" + ) + if save_folder: guide += _save_section( platform_display, save_folder, save_where, save_by_system, diff --git a/tests/test_case_insensitive_platforms.py b/tests/test_case_insensitive_platforms.py index cf88467dd..0653505a3 100644 --- a/tests/test_case_insensitive_platforms.py +++ b/tests/test_case_insensitive_platforms.py @@ -7,7 +7,12 @@ went red. The flag lived in retrobat.yml and vanished at the next scrape. from __future__ import annotations +import contextlib +import hashlib +import io +import os import sys +import tempfile import unittest from pathlib import Path @@ -29,5 +34,82 @@ class CaseInsensitiveWhereTheFilesystemIs(unittest.TestCase): self.assertTrue(config.get("case_insensitive_fs")) +class ThePackReadmeNamesTheSpellingsItLeavesOut(unittest.TestCase): + """The EmuDeck pack is folded for Windows and extracted on a Steam Deck, + where the core asking for bios.col finds only BIOS.col. Only the + installer adds that spelling (case_variants), and the pack's README said + nothing about it.""" + + def setUp(self): + import common + import generate_db + import generate_pack + + self.gp = generate_pack + tmp = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") + self.addCleanup(tmp.cleanup) + previous = os.getcwd() + os.chdir(tmp.name) + self.addCleanup(os.chdir, previous) + Path("bios/Coleco").mkdir(parents=True) + payload = b"coleco bios" + Path("bios/Coleco/BIOS.col").write_bytes(payload) + sha1 = hashlib.sha1(payload).hexdigest() + files = {sha1: {"path": "bios/Coleco/BIOS.col", "name": "BIOS.col", + "size": len(payload), "sha1": sha1, + "md5": hashlib.md5(payload).hexdigest(), + "sha256": hashlib.sha256(payload).hexdigest(), + "crc32": "00000001"}} + self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})} + Path("platforms").mkdir() + Path("emulators").mkdir() + self.config = { + "platform": "P", "verification_mode": "existence", + "case_insensitive_fs": True, "cores": ["mesence"], + "systems": {"coleco": {"files": [{"name": "BIOS.col"}]}}, + } + Path("emulators/mesence.yml").write_text(yaml.dump({ + "emulator": "mesence", "type": "libretro", "systems": ["coleco"], + "files": [{"name": "bios.col", "required": True}], + })) + common._platform_config_cache.clear() + common._emulator_profiles_cache.clear() + self.addCleanup(common._platform_config_cache.clear) + self.addCleanup(common._emulator_profiles_cache.clear) + + def _readme(self, systems: list[str]) -> str: + import common + + Path("platforms/p.yml").write_text(yaml.dump(self.config)) + Path("platforms/_registry.yml").write_text(yaml.dump({"platforms": {"p": { + "status": "active", + "install": {"detect": [{"os": os_name} for os_name in systems]}, + }}})) + common._platform_config_cache.clear() + texts: dict[str, str] = {} + real = self.gp._write_generated_member + + def capture(zf, arcname, text): + texts[arcname] = text + return real(zf, arcname, text) + + self.gp._write_generated_member = capture + self.addCleanup(setattr, self.gp, "_write_generated_member", real) + with contextlib.redirect_stdout(io.StringIO()): + self.gp.generate_pack( + "p", "platforms", self.db, "bios", "", emulators_dir="emulators", + offline=True, record=self.gp.PackRecord(), + ) + return texts["README.txt"] + + def test_a_platform_also_on_linux_names_them(self): + readme = self._readme(["windows", "linux"]) + self.assertIn("NAMES THAT DIFFER ONLY BY CASE", readme) + self.assertIn(" bios.col (the pack has BIOS.col)", readme) + + def test_a_platform_only_on_windows_does_not(self): + self.assertNotIn("DIFFER ONLY BY CASE", self._readme(["windows"])) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/architecture.md b/wiki/architecture.md index fea8231df..75f4a9e89 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -405,7 +405,7 @@ resolution. | MAME BIOS ZIPs | `contents` field documents inner structure | | Trees addressed by path (RPG Maker, ScummVM, tzdata, c64-emu and the rest of `NODEDUP_DIRS`) | excluded from dedup to keep files that are equal by design | | `strip_components` in data dirs | flattens cache prefix to match expected path | -| case-insensitive dedup | prevents `font.rom` + `FONT.ROM` conflicts on Windows/macOS | +| case-insensitive dedup | prevents `font.rom` + `FONT.ROM` conflicts on Windows/macOS. Where the platform also runs on a filesystem that keeps case (EmuDeck and RetroArch on Linux), the pack README lists each spelling it leaves out, and the installer adds them (`case_variants`) | | frozen snapshot cores | `.info` may reflect current version while code is pinned to an old one. Only the frozen source at the pinned tag is reliable (e.g. desmume2015, mame2003) | ### File resolution chain