fix: name long emulator packs within a file name

--system snes joined 23 emulator names into the ZIP name and died
with ENAMETOOLONG before writing anything. The system pack now passes
its own name in, and an --emulator selection too long to join keeps
its first name, a count and a digest of the selection.
This commit is contained in:
Abdessamad Derraz committed 2026-10-17 01:57:53 +02:00
1 parent bf47d3d998
commit 29fb1a58e4
2 files changed
+63 -16

No files matched your search

+43 -16
View File
@@ -1209,6 +1209,25 @@ def _same_bytes(path: str, other: str | None) -> bool:
return compute_hashes(path)["sha1"] == compute_hashes(other)["sha1"]
# A file name holds 255 bytes on every filesystem a pack is written to;
# the margin leaves room for the split part suffix and a scratch prefix.
MAX_PACK_NAME_BYTES = 200
def _bounded_pack_name(display_names: list[str], keys: list[str], suffix: str) -> str:
"""The emulator pack's file name, its emulators joined while they fit.
Past the limit the name keeps the first emulator, says how many more the
pack holds, and carries a digest of the selection so two different
selections never share a file.
"""
joined = "_".join(display_names) + suffix
if len(joined.encode("utf-8")) <= MAX_PACK_NAME_BYTES:
return joined
digest = hashlib.sha1("\n".join(sorted(keys)).encode("utf-8")).hexdigest()[:8]
return f"{display_names[0]}_and_{len(display_names) - 1}_more_{digest}{suffix}"
def generate_emulator_pack(
profile_names: list[str],
emulators_dir: str,
@@ -1221,8 +1240,14 @@ def generate_emulator_pack(
regions: list[str] | None = None,
offline: bool | None = None,
platforms_dir: str | None = None,
pack_name: str | None = None,
) -> str | None:
"""Generate a ZIP pack for specific emulator profiles."""
"""Generate a ZIP pack for specific emulator profiles.
`pack_name` is the caller's own file name: the system pack names its
archive after the system, and joining 23 SNES emulator names went past
the 255 bytes a file name may hold.
"""
all_profiles = load_emulator_profiles(emulators_dir, skip_aliases=False)
if zip_contents is None:
zip_contents = build_zip_contents_index(db)
@@ -1242,7 +1267,9 @@ def generate_emulator_pack(
"full", regions, None, False, required_only, standalone
)
)
zip_name = "_".join(display_names) + f"{narrow_tags}_BIOS_Pack.zip"
zip_name = pack_name or _bounded_pack_name(
display_names, [n for n, _p in selected], f"{narrow_tags}_BIOS_Pack.zip"
)
zip_path = os.path.join(output_dir, zip_name)
os.makedirs(output_dir, exist_ok=True)
@@ -1595,6 +1622,18 @@ def generate_system_pack(
# Built in a scratch directory: under its emulator name in output_dir it
# overwrote, then carried away, an --emulator pack already there.
os.makedirs(output_dir, exist_ok=True)
# Every dimension goes through the one list: keeping only the region
# tag made --system X --required-only overwrite the pack built without it.
tags = "".join(
tag
for tag, _label in _narrowings(
"full", regions, None, False, required_only, standalone=standalone
)
)
# The selection mode is part of the name: `--system sega-triforce`
# and `--emulator triforce` both wrote Triforce_BIOS_Pack.zip, and
# the second run replaced the first without a word.
final_name = f"{sys_display}_System{tags}_BIOS_Pack.zip"
scratch = tempfile.mkdtemp(prefix=".system-", dir=output_dir)
try:
result = generate_emulator_pack(
@@ -1609,23 +1648,11 @@ def generate_system_pack(
regions=regions,
offline=offline,
platforms_dir=platforms_dir,
pack_name=final_name,
)
if not result:
return None
# Every dimension goes through the one list: keeping only the region
# tag made --system X --required-only overwrite the pack built without it.
tags = "".join(
tag
for tag, _label in _narrowings(
"full", regions, None, False, required_only, standalone=standalone
)
)
# The selection mode is part of the name: `--system sega-triforce`
# and `--emulator triforce` both wrote Triforce_BIOS_Pack.zip, and
# the second run replaced the first without a word.
new_path = os.path.join(
output_dir, f"{sys_display}_System{tags}_BIOS_Pack.zip"
)
new_path = os.path.join(output_dir, final_name)
os.replace(result, new_path)
return new_path
finally:
+20
View File
@@ -2187,6 +2187,26 @@ class ContributorsSurviveAFailedRequest(unittest.TestCase):
)
class APackNameFitsAFileName(unittest.TestCase):
"""--system snes joined 23 emulator names and died on ENAMETOOLONG
before writing anything, while verify --system snes reported."""
def test_a_long_selection_is_bounded_and_told_apart(self):
from scripts.generate_pack import MAX_PACK_NAME_BYTES, _bounded_pack_name
names = [f"Emulator_Number_{i}" for i in range(23)]
long_name = _bounded_pack_name(names, names, "_BIOS_Pack.zip")
self.assertLessEqual(len(long_name.encode()), MAX_PACK_NAME_BYTES)
self.assertTrue(long_name.startswith("Emulator_Number_0_and_22_more_"))
other = _bounded_pack_name(names, names[:-1] + ["x"], "_BIOS_Pack.zip")
self.assertNotEqual(long_name, other)
self.assertEqual(_bounded_pack_name(["A", "B"], ["a", "b"], ".zip"), "A_B.zip")
def test_the_system_pack_names_itself(self):
source = (ROOT / "scripts" / "generate_pack.py").read_text(encoding="utf-8")
self.assertIn("pack_name=final_name", source)
class TheDatabaseCacheIsWrittenWhole(unittest.TestCase):
"""A crash while serializing left a truncated db_cache.json behind, and
the next run rehashed the whole collection from scratch."""