Coverage for src/ph/retrobios.py: 97.3%
390 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
1"""Offline RetroBIOS requirements, verification, and explicit firmware retrieval."""
3import hashlib
4import json
5import logging
6import os
7import shutil
8import urllib.error
9import urllib.request
10from collections.abc import Callable, Iterable, Mapping, Sequence
11from concurrent.futures import ThreadPoolExecutor, as_completed
12from dataclasses import dataclass
13from enum import StrEnum
14from pathlib import Path, PurePosixPath
15from time import time
16from typing import Any
17from urllib.parse import quote
19import yaml
21from ph.cache_policy import DEFAULT_CATALOGUE_TTL_DAYS, catalogue_ttl_seconds
22from ph.models import Platform
23from ph.organizer import ROM_AND_SHARED_BIOS, ROM_LOCAL_BIOS
25LOGGER = logging.getLogger(__name__)
27RETROBIOS_REPOSITORY = "https://github.com/Abdess/retrobios"
28RETROBIOS_RAW_BASE = "https://raw.githubusercontent.com/Abdess/retrobios"
29MAX_FIRMWARE_BYTES = 64 * 1024 * 1024
30MAX_CATALOGUE_BYTES = 16 * 1024 * 1024
31RETROBIOS_CACHE_FILENAME = "catalogue.json"
32RETROBIOS_API_URL = "https://api.github.com/repos/Abdess/retrobios/commits/main"
34type BiosProgress = Callable[[str, int, int | None], None]
35type CancellationCheck = Callable[[], bool]
38class BiosError(RuntimeError):
39 """A BIOS catalogue, verification, or installation operation failed."""
42class BiosDownloadCancelled(BiosError):
43 """The user cancelled a RetroBIOS download."""
46class BiosState(StrEnum):
47 """Verification state of a BIOS requirement on one ROM card."""
49 VALID = "valid"
50 MISSING = "missing"
51 INVALID = "invalid"
54@dataclass(frozen=True, slots=True)
55class BiosRequirement:
56 """One firmware file described by the pinned RetroBIOS catalogue."""
58 name: str
59 destination: str
60 required: bool
61 size: int | None
62 sha256: str | None
63 sha1: str | None
64 md5: str | None
65 source_path: str | None
66 description: str | None = None
67 note: str | None = None
68 region: str | None = None
69 hle_fallback: bool = False
72@dataclass(frozen=True, slots=True)
73class BiosSystem:
74 """RetroBIOS metadata relevant to one emulated system."""
76 system_id: str
77 name: str
78 core: str | None
79 docs: str | None
80 requirements: tuple[BiosRequirement, ...]
83@dataclass(frozen=True, slots=True)
84class BiosCheck:
85 """Verification result for one BIOS requirement."""
87 requirement: BiosRequirement
88 state: BiosState
89 paths: tuple[Path, ...]
92# ROM-folder/platform identifiers differ from RetroBIOS system identifiers.
93# Keep the mapping explicit so a similarly named but incompatible system is never
94# selected by fuzzy matching.
95RETROBIOS_SYSTEM_BY_PLATFORM: Mapping[str, str] = {
96 "3do": "3do",
97 "amiga": "commodore-amiga",
98 "amiga-cd32": "commodore-amiga",
99 "amstrad-cpc": "amstrad-cpc",
100 "arcade": "arcade",
101 "atari-800": "atari-400-800",
102 "atari-5200": "atari-5200",
103 "atari-7800": "atari-7800",
104 "atari-lynx": "atari-lynx",
105 "atari-st": "atari-st",
106 "atomiswave": "sega-dreamcast-arcade",
107 "colecovision": "coleco-colecovision",
108 "commodore-128": "commodore-c128",
109 "cps-1": "arcade",
110 "cps-2": "arcade",
111 "cps-3": "arcade",
112 "dos": "dos",
113 "doom": "doom",
114 "dreamcast": "sega-dreamcast",
115 "enterprise": "enterprise-64-128",
116 "fairchild-channel-f": "fairchild-channel-f",
117 "famicom-disk-system": "nintendo-fds",
118 "game-boy": "nintendo-gb",
119 "game-boy-advance": "nintendo-gba",
120 "game-boy-color": "nintendo-gbc",
121 "game-gear": "sega-game-gear",
122 "genesis": "sega-mega-drive",
123 "intellivision": "mattel-intellivision",
124 "mame-2003": "arcade",
125 "mame-2010": "arcade",
126 "master-system": "sega-master-system",
127 "msx": "microsoft-msx",
128 "msx2": "microsoft-msx",
129 "naomi": "sega-dreamcast-arcade",
130 "neo-geo": "arcade",
131 "neo-geo-cd": "snk-neogeo-cd",
132 "nintendo-64dd": "nintendo-64dd",
133 "nintendo-ds": "nintendo-ds",
134 "nintendo": "nintendo-nes",
135 "odyssey-2": "magnavox-odyssey2",
136 "pc-98": "nec-pc-98",
137 "pc-engine": "nec-pc-engine",
138 "pc-engine-cd": "nec-pc-engine",
139 "pc-fx": "nec-pc-fx",
140 "playstation": "sony-playstation",
141 "pokemon-mini": "nintendo-pokemon-mini",
142 "ps-portable": "sony-psp",
143 "psp-minis": "sony-psp",
144 "satellaview": "nintendo-satellaview",
145 "scummvm": "scummvm",
146 "sega-cd": "sega-mega-cd",
147 "saturn": "sega-saturn",
148 "sharp-x1": "sharp-x1",
149 "sharp-x68000": "sharp-x68000",
150 "sufami-turbo": "nintendo-sufami-turbo",
151 "super-cassette-vision": "epoch-scv",
152 "super-game-boy": "nintendo-sgb",
153 "super-nintendo": "nintendo-snes",
154 "ti-99": "ti-83",
155 "videopac": "philips-videopac",
156 "videoton-tvc": "videoton-tvc",
157 "vircon32": "vircon32",
158 "wolfenstein": "wolfenstein-3d",
159 "zx-spectrum": "sinclair-zx-spectrum",
160}
163class RetroBiosCatalog:
164 """Read the release-pinned RetroBIOS manifest shipped with the application."""
166 def __init__(
167 self,
168 revision: str,
169 systems: Mapping[str, BiosSystem],
170 generated_at: str | None = None,
171 retroarch_version: str | None = None,
172 fetched_at: float | None = None,
173 ttl_seconds: int = catalogue_ttl_seconds(DEFAULT_CATALOGUE_TTL_DAYS),
174 ) -> None:
175 self.revision = revision
176 self.systems = systems
177 self.generated_at = generated_at
178 self.retroarch_version = retroarch_version
179 self.fetched_at = fetched_at
180 self.ttl_seconds = ttl_seconds
182 @classmethod
183 def from_json(
184 cls,
185 content: str,
186 ttl_seconds: int = catalogue_ttl_seconds(DEFAULT_CATALOGUE_TTL_DAYS),
187 ) -> RetroBiosCatalog:
188 """Parse a generated manifest while rejecting unsafe or malformed entries."""
190 try:
191 payload = json.loads(content)
192 except json.JSONDecodeError as error:
193 raise BiosError(f"The bundled RetroBIOS catalogue is invalid: {error}") from error
194 if not isinstance(payload, dict) or payload.get("schema_version") != 1:
195 raise BiosError("The bundled RetroBIOS catalogue has an unsupported format.")
196 revision = payload.get("source_commit")
197 raw_systems = payload.get("systems")
198 if (
199 not isinstance(revision, str)
200 or len(revision) != 40
201 or not isinstance(raw_systems, dict)
202 ):
203 raise BiosError("The bundled RetroBIOS catalogue is incomplete.")
204 systems: dict[str, BiosSystem] = {}
205 for system_id, raw_system in raw_systems.items():
206 if not isinstance(system_id, str) or not isinstance(raw_system, dict):
207 continue
208 raw_requirements = raw_system.get("files", ())
209 if not isinstance(raw_requirements, list):
210 continue
211 requirements = tuple(
212 requirement
213 for item in raw_requirements
214 if (requirement := _parse_requirement(item)) is not None
215 )
216 systems[system_id] = BiosSystem(
217 system_id=system_id,
218 name=_optional_string(raw_system.get("name")) or system_id,
219 core=_optional_string(raw_system.get("core")),
220 docs=_optional_string(raw_system.get("docs")),
221 requirements=requirements,
222 )
223 return cls(
224 revision,
225 systems,
226 _optional_string(payload.get("source_generated_at")),
227 _optional_string(payload.get("retroarch_version")),
228 _optional_float(payload.get("fetched_at")),
229 ttl_seconds,
230 )
232 def cache_age_seconds(self) -> float | None:
233 """Return the local catalogue age when its download time is known."""
235 return None if self.fetched_at is None else max(0.0, time() - self.fetched_at)
237 def cache_is_stale(self) -> bool:
238 """Return whether this catalogue is missing a timestamp or exceeds its lifetime."""
240 age = self.cache_age_seconds()
241 return age is None or age > self.ttl_seconds
243 def system_for(self, platform: Platform) -> BiosSystem | None:
244 """Return the exact RetroBIOS system mapped to a local platform."""
246 system_id = RETROBIOS_SYSTEM_BY_PLATFORM.get(platform.slug)
247 return self.systems.get(system_id) if system_id is not None else None
249 def requirements_for(
250 self,
251 platform: Platform,
252 region: str | None = None,
253 *,
254 required_only: bool = False,
255 ) -> tuple[BiosRequirement, ...]:
256 """Return requirements, narrowing region-specific mandatory alternatives."""
258 system = self.system_for(platform)
259 if system is None:
260 return ()
261 requirements = system.requirements
262 if required_only:
263 requirements = tuple(item for item in requirements if item.required)
264 normalized_region = _normalize_region(region)
265 if normalized_region is None:
266 return requirements
267 regional = tuple(item for item in requirements if item.required and item.region)
268 matching = tuple(
269 item for item in regional if _normalize_region(item.region) == normalized_region
270 )
271 if not matching:
272 return requirements
273 nonregional = tuple(item for item in requirements if not item.required or not item.region)
274 return nonregional + matching
276 def source_url(self, requirement: BiosRequirement) -> str | None:
277 """Build the immutable raw GitHub URL for a downloadable requirement."""
279 if requirement.source_path is None:
280 return None
281 return f"{RETROBIOS_RAW_BASE}/{self.revision}/{quote(requirement.source_path, safe='/')}"
284class RetroBiosRepository:
285 """Persist an atomic local catalogue downloaded only on first use or request."""
287 def __init__(
288 self,
289 cache_directory: Path,
290 timeout_seconds: float,
291 ttl_seconds: int = catalogue_ttl_seconds(DEFAULT_CATALOGUE_TTL_DAYS),
292 ) -> None:
293 self.cache_path = cache_directory / "retrobios" / RETROBIOS_CACHE_FILENAME
294 self.timeout_seconds = timeout_seconds
295 self.ttl_seconds = ttl_seconds
297 def load(self) -> RetroBiosCatalog | None:
298 """Load the existing cache without contacting GitHub."""
300 try:
301 content = self.cache_path.read_text(encoding="utf-8")
302 except FileNotFoundError:
303 return None
304 except OSError as error:
305 raise BiosError(f"Could not read the RetroBIOS catalogue: {error}") from error
306 return RetroBiosCatalog.from_json(content, self.ttl_seconds)
308 def ensure(
309 self,
310 progress: BiosProgress | None = None,
311 cancelled: CancellationCheck | None = None,
312 ) -> RetroBiosCatalog:
313 """Use the cache when present, downloading it once when missing."""
315 cached = self.load()
316 return cached if cached is not None else self.update(progress, cancelled)
318 def update(
319 self,
320 progress: BiosProgress | None = None,
321 cancelled: CancellationCheck | None = None,
322 ) -> RetroBiosCatalog:
323 """Download, validate, and atomically replace the cached catalogue."""
325 if progress is not None:
326 progress("Finding the latest RetroBIOS revision", 0, 1)
327 api_payload = _download_json(
328 RETROBIOS_API_URL,
329 self.timeout_seconds,
330 cancelled,
331 max_bytes=1024 * 1024,
332 )
333 revision = api_payload.get("sha") if isinstance(api_payload, dict) else None
334 if (
335 not isinstance(revision, str)
336 or len(revision) != 40
337 or any(character not in "0123456789abcdef" for character in revision.casefold())
338 ):
339 raise BiosError("GitHub returned an invalid RetroBIOS revision.")
341 platform_url = f"{RETROBIOS_RAW_BASE}/{revision}/platforms/retroarch.yml"
342 platform = _download_yaml(platform_url, self.timeout_seconds, cancelled)
343 systems = platform.get("systems")
344 if not isinstance(systems, dict):
345 raise BiosError("RetroBIOS returned an invalid RetroArch platform catalogue.")
346 cores = sorted(
347 {
348 core
349 for system in systems.values()
350 if isinstance(system, dict)
351 and isinstance((core := system.get("core")), str)
352 and core
353 }
354 )
355 total_steps = len(cores) + 2
356 if progress is not None:
357 progress("Downloading RetroBIOS core profiles", 1, total_steps)
358 profiles: dict[str, dict[str, Any]] = {}
360 def download_profile(core: str) -> tuple[str, dict[str, Any]]:
361 profile_url = f"{RETROBIOS_RAW_BASE}/{revision}/emulators/{quote(core)}.yml"
362 return core, _download_yaml(profile_url, self.timeout_seconds, cancelled)
364 with ThreadPoolExecutor(max_workers=8, thread_name_prefix="retrobios-metadata") as executor:
365 futures = {executor.submit(download_profile, core): core for core in cores}
366 for completed, future in enumerate(as_completed(futures), start=2):
367 if cancelled is not None and cancelled(): 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true
368 for pending in futures:
369 pending.cancel()
370 raise BiosDownloadCancelled("RetroBIOS catalogue update cancelled.")
371 core, profile = future.result()
372 profiles[core] = profile
373 if progress is not None: 373 ↛ 366line 373 didn't jump to line 366 because the condition on line 373 was always true
374 progress("Downloading RetroBIOS core profiles", completed, total_steps)
376 database_url = f"{RETROBIOS_RAW_BASE}/{revision}/database.json"
377 database = _download_json(
378 database_url,
379 self.timeout_seconds,
380 cancelled,
381 max_bytes=MAX_CATALOGUE_BYTES,
382 )
383 manifest = _build_manifest(revision, platform, profiles, database)
384 serialized = json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
385 catalogue = RetroBiosCatalog.from_json(serialized, self.ttl_seconds)
386 temporary = self.cache_path.with_name(self.cache_path.name + ".tmp")
387 try:
388 self.cache_path.parent.mkdir(parents=True, exist_ok=True)
389 temporary.write_text(serialized, encoding="utf-8")
390 os.replace(temporary, self.cache_path)
391 except OSError as error:
392 temporary.unlink(missing_ok=True)
393 raise BiosError(f"Could not save the RetroBIOS catalogue: {error}") from error
394 LOGGER.info(
395 "RetroBIOS catalogue cached revision=%s systems=%d path=%s",
396 revision,
397 len(catalogue.systems),
398 self.cache_path,
399 )
400 return catalogue
403def _download_yaml(
404 url: str,
405 timeout_seconds: float,
406 cancelled: CancellationCheck | None,
407) -> dict[str, Any]:
408 content = _download_bytes(
409 url,
410 timeout_seconds,
411 cancelled,
412 max_bytes=2 * 1024 * 1024,
413 )
414 try:
415 payload = yaml.safe_load(content)
416 except yaml.YAMLError as error:
417 raise BiosError(f"RetroBIOS returned invalid YAML metadata: {error}") from error
418 if not isinstance(payload, dict): 418 ↛ 420line 418 didn't jump to line 420 because the condition on line 418 was always true
419 raise BiosError("RetroBIOS returned incomplete YAML metadata.")
420 return payload
423def _download_json(
424 url: str,
425 timeout_seconds: float,
426 cancelled: CancellationCheck | None,
427 *,
428 max_bytes: int,
429) -> dict[str, Any]:
430 content = _download_bytes(url, timeout_seconds, cancelled, max_bytes=max_bytes)
431 try:
432 payload = json.loads(content)
433 except (UnicodeDecodeError, json.JSONDecodeError) as error:
434 raise BiosError(f"RetroBIOS returned invalid JSON metadata: {error}") from error
435 if not isinstance(payload, dict): 435 ↛ 437line 435 didn't jump to line 437 because the condition on line 435 was always true
436 raise BiosError("RetroBIOS returned incomplete JSON metadata.")
437 return payload
440def _download_bytes(
441 url: str,
442 timeout_seconds: float,
443 cancelled: CancellationCheck | None,
444 *,
445 max_bytes: int,
446) -> bytes:
447 request = urllib.request.Request(
448 url,
449 headers={
450 "Accept": "application/vnd.github+json",
451 "User-Agent": "pocket-harbor/retrobios",
452 },
453 )
454 content = bytearray()
455 try:
456 with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
457 while chunk := response.read(1024 * 128):
458 if cancelled is not None and cancelled():
459 raise BiosDownloadCancelled("RetroBIOS catalogue update cancelled.")
460 content.extend(chunk)
461 if len(content) > max_bytes:
462 raise BiosError("RetroBIOS metadata exceeded the safety limit.")
463 except BiosError:
464 raise
465 except (OSError, urllib.error.URLError) as error:
466 raise BiosError(f"Could not reach RetroBIOS: {error}") from error
467 return bytes(content)
470def _build_manifest(
471 revision: str,
472 platform: dict[str, Any],
473 profiles: Mapping[str, dict[str, Any]],
474 database: dict[str, Any],
475) -> dict[str, Any]:
476 systems = platform.get("systems")
477 if not isinstance(systems, dict):
478 raise BiosError("RetroBIOS platform metadata does not contain systems.")
479 generated_systems: dict[str, Any] = {}
480 for system_id, system_data in systems.items():
481 if not isinstance(system_id, str) or not isinstance(system_data, dict):
482 continue
483 core = _optional_string(system_data.get("core"))
484 profile = profiles.get(core) if core is not None else None
485 profile_files = _profile_files(profile)
486 generated_files: list[dict[str, Any]] = []
487 raw_files = system_data.get("files", ())
488 for file_data in raw_files if isinstance(raw_files, list) else ():
489 if not isinstance(file_data, dict) or not isinstance(file_data.get("name"), str):
490 continue
491 profile_file = profile_files.get(str(file_data["name"]).casefold(), {})
492 stored_file = _database_file(database, file_data) or {}
493 generated_files.append(
494 {
495 "name": file_data["name"],
496 "destination": file_data.get("destination", file_data["name"]),
497 "required": (
498 profile_file.get("required", False)
499 if profile is not None
500 else file_data.get("required", False)
501 ),
502 "size": file_data.get("size", stored_file.get("size")),
503 "sha256": stored_file.get("sha256"),
504 "sha1": file_data.get("sha1", stored_file.get("sha1")),
505 "md5": file_data.get("md5", stored_file.get("md5")),
506 "source_path": stored_file.get("path"),
507 "description": profile_file.get("description"),
508 "note": profile_file.get("note"),
509 "region": profile_file.get("region"),
510 "hle_fallback": profile_file.get("hle_fallback", False),
511 }
512 )
513 generated_systems[system_id] = {
514 "name": system_data.get("native_id", system_id),
515 "core": core,
516 "docs": system_data.get("docs"),
517 "files": generated_files,
518 }
519 return {
520 "schema_version": 1,
521 "fetched_at": time(),
522 "source_repository": RETROBIOS_REPOSITORY,
523 "source_commit": revision,
524 "source_generated_at": database.get("generated_at"),
525 "retroarch_version": platform.get("version"),
526 "systems": generated_systems,
527 }
530def _profile_files(profile: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
531 raw_files = profile.get("files", ()) if profile is not None else ()
532 if not isinstance(raw_files, list):
533 return {}
534 return {
535 str(item["name"]).casefold(): item
536 for item in raw_files
537 if isinstance(item, dict) and isinstance(item.get("name"), str)
538 }
541def _database_file(
542 database: dict[str, Any],
543 file_data: dict[str, Any],
544) -> dict[str, Any] | None:
545 raw_files = database.get("files", {})
546 if not isinstance(raw_files, dict):
547 return None
548 sha1 = _optional_string(file_data.get("sha1"))
549 if sha1 is not None:
550 candidate = raw_files.get(sha1.casefold())
551 if isinstance(candidate, dict): 551 ↛ 553line 551 didn't jump to line 553 because the condition on line 551 was always true
552 return candidate
553 indexes = database.get("indexes", {})
554 by_name = indexes.get("by_name", {}) if isinstance(indexes, dict) else {}
555 name = _optional_string(file_data.get("name"))
556 keys = by_name.get(name.casefold(), ()) if isinstance(by_name, dict) and name else ()
557 for key in keys if isinstance(keys, list) else ():
558 candidate = raw_files.get(key)
559 if not isinstance(candidate, dict):
560 continue
561 expected_size = file_data.get("size")
562 if expected_size is None or candidate.get("size") == expected_size:
563 return candidate
564 return None
567def audit_bios(
568 requirements: Iterable[BiosRequirement],
569 platform: Platform,
570 roms_directory: Path,
571 bios_directory: str = "bios",
572) -> tuple[BiosCheck, ...]:
573 """Verify BIOS files on the selected ROM card using pinned checksums."""
575 checks: list[BiosCheck] = []
576 for requirement in requirements:
577 paths = bios_destinations(requirement, platform, roms_directory, bios_directory)
578 existing = tuple(path for path in paths if path.is_file())
579 validity = tuple(verify_bios_file(path, requirement) for path in existing)
580 if len(existing) == len(paths) and all(validity):
581 state = BiosState.VALID
582 elif any(not valid for valid in validity):
583 state = BiosState.INVALID
584 else:
585 state = BiosState.MISSING
586 checks.append(BiosCheck(requirement, state, paths))
587 return tuple(checks)
590def audit_bios_roots(
591 requirements: Iterable[BiosRequirement],
592 platform: Platform,
593 roms_directories: Sequence[Path],
594 preferred_directory: Path,
595 bios_directory: str = "bios",
596) -> tuple[BiosCheck, ...]:
597 """Treat a valid BIOS on either active memory card as already available."""
599 roots = tuple(dict.fromkeys((preferred_directory, *roms_directories)))
600 preferred_checks = audit_bios(requirements, platform, preferred_directory, bios_directory)
601 checks_by_root = tuple(
602 audit_bios(requirements, platform, root, bios_directory) for root in roots
603 )
604 combined: list[BiosCheck] = []
605 for index, preferred in enumerate(preferred_checks):
606 valid = next(
607 (
608 checks[index]
609 for checks in checks_by_root
610 if index < len(checks) and checks[index].state is BiosState.VALID
611 ),
612 None,
613 )
614 combined.append(valid or preferred)
615 return tuple(combined)
618def bios_destinations(
619 requirement: BiosRequirement,
620 platform: Platform,
621 roms_directory: Path,
622 bios_directory: str = "bios",
623) -> tuple[Path, ...]:
624 """Resolve shared and exceptional ROM-local BIOS destinations."""
626 relative = PurePosixPath(requirement.destination.replace("\\", "/"))
627 if (
628 relative.is_absolute()
629 or not relative.parts
630 or any(part in {"", ".", ".."} for part in relative.parts)
631 ):
632 raise BiosError(f"RetroBIOS contains an unsafe destination: {requirement.destination}")
633 folder = platform.rom_folder or ""
634 filename = relative.name.casefold()
635 shared = roms_directory / bios_directory / Path(*relative.parts)
636 local = roms_directory / folder / Path(*relative.parts)
637 if filename in ROM_LOCAL_BIOS.get(platform.slug, frozenset()):
638 return (local,)
639 if filename in ROM_AND_SHARED_BIOS.get(platform.slug, frozenset()):
640 return (shared, local)
641 return (shared,)
644def verify_bios_file(path: Path, requirement: BiosRequirement) -> bool:
645 """Validate size and the strongest checksum published by RetroBIOS."""
647 try:
648 if requirement.size is not None and path.stat().st_size != requirement.size:
649 return False
650 algorithm, expected = _strongest_checksum(requirement)
651 if algorithm is None or expected is None:
652 return requirement.size is not None
653 digest = hashlib.new(algorithm)
654 with path.open("rb") as stream:
655 while chunk := stream.read(1024 * 256):
656 digest.update(chunk)
657 return digest.hexdigest().casefold() == expected.casefold()
658 except OSError:
659 return False
662def install_bios(
663 catalog: RetroBiosCatalog,
664 requirement: BiosRequirement,
665 platform: Platform,
666 roms_directory: Path,
667 timeout_seconds: float,
668 progress: BiosProgress | None = None,
669 cancelled: CancellationCheck | None = None,
670 bios_directory: str = "bios",
671) -> tuple[Path, ...]:
672 """Download one explicitly approved BIOS and atomically install verified copies."""
674 url = catalog.source_url(requirement)
675 if url is None:
676 raise BiosError(f"RetroBIOS does not provide a downloadable copy of {requirement.name}.")
677 if requirement.size is not None and requirement.size > MAX_FIRMWARE_BYTES:
678 raise BiosError(f"RetroBIOS firmware is too large to install safely: {requirement.name}")
679 destinations = bios_destinations(requirement, platform, roms_directory, bios_directory)
680 valid = tuple(path for path in destinations if verify_bios_file(path, requirement))
681 if len(valid) == len(destinations):
682 return valid
684 primary = destinations[0]
685 partial = primary.with_name(primary.name + ".part")
686 try:
687 primary.parent.mkdir(parents=True, exist_ok=True)
688 request = urllib.request.Request(
689 url,
690 headers={"User-Agent": "pocket-harbor/retrobios"},
691 )
692 with (
693 urllib.request.urlopen(request, timeout=timeout_seconds) as response,
694 partial.open("wb") as output,
695 ):
696 header = response.headers.get("Content-Length")
697 total = int(header) if header and header.isdigit() else requirement.size
698 downloaded = 0
699 while chunk := response.read(1024 * 128):
700 if cancelled is not None and cancelled():
701 raise BiosDownloadCancelled("BIOS download cancelled.")
702 downloaded += len(chunk)
703 if downloaded > MAX_FIRMWARE_BYTES: 703 ↛ 704line 703 didn't jump to line 704 because the condition on line 703 was never true
704 raise BiosError("RetroBIOS download exceeded the firmware safety limit.")
705 output.write(chunk)
706 if progress is not None:
707 progress(requirement.name, downloaded, total)
708 if cancelled is not None and cancelled():
709 raise BiosDownloadCancelled("BIOS download cancelled.")
710 if not verify_bios_file(partial, requirement):
711 raise BiosError(f"Downloaded {requirement.name} does not match the RetroBIOS checksum.")
712 os.replace(partial, primary)
713 installed = [primary]
714 for destination in destinations[1:]:
715 if verify_bios_file(destination, requirement):
716 installed.append(destination)
717 continue
718 destination.parent.mkdir(parents=True, exist_ok=True)
719 copy_partial = destination.with_name(destination.name + ".part")
720 try:
721 shutil.copyfile(primary, copy_partial)
722 os.replace(copy_partial, destination)
723 except OSError:
724 copy_partial.unlink(missing_ok=True)
725 raise
726 installed.append(destination)
727 LOGGER.info(
728 "Installed RetroBIOS firmware platform=%s file=%s destinations=%s",
729 platform.alias,
730 requirement.name,
731 installed,
732 )
733 return tuple(installed)
734 except BiosDownloadCancelled:
735 partial.unlink(missing_ok=True)
736 LOGGER.info("RetroBIOS download cancelled file=%s", requirement.name)
737 raise
738 except BiosError:
739 partial.unlink(missing_ok=True)
740 raise
741 except (OSError, ValueError, urllib.error.URLError) as error:
742 partial.unlink(missing_ok=True)
743 LOGGER.error("RetroBIOS download failed file=%s: %s", requirement.name, error)
744 raise BiosError(f"Could not install {requirement.name} from RetroBIOS: {error}") from error
747def unresolved(checks: Sequence[BiosCheck]) -> tuple[BiosCheck, ...]:
748 """Return missing or invalid BIOS checks."""
750 return tuple(check for check in checks if check.state is not BiosState.VALID)
753def _parse_requirement(payload: object) -> BiosRequirement | None:
754 if not isinstance(payload, dict):
755 return None
756 name = _optional_string(payload.get("name"))
757 destination = _optional_string(payload.get("destination"))
758 if name is None or destination is None:
759 return None
760 raw_size = payload.get("size")
761 size = raw_size if isinstance(raw_size, int) and not isinstance(raw_size, bool) else None
762 return BiosRequirement(
763 name=name,
764 destination=destination,
765 required=payload.get("required") is True,
766 size=size,
767 sha256=_optional_string(payload.get("sha256")),
768 sha1=_optional_string(payload.get("sha1")),
769 md5=_optional_string(payload.get("md5")),
770 source_path=_optional_string(payload.get("source_path")),
771 description=_optional_string(payload.get("description")),
772 note=_optional_string(payload.get("note")),
773 region=_optional_string(payload.get("region")),
774 hle_fallback=payload.get("hle_fallback") is True,
775 )
778def _optional_string(value: object) -> str | None:
779 return value if isinstance(value, str) and value else None
782def _optional_float(value: object) -> float | None:
783 return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else None
786def _strongest_checksum(requirement: BiosRequirement) -> tuple[str | None, str | None]:
787 if requirement.sha256:
788 return "sha256", requirement.sha256
789 if requirement.sha1:
790 return "sha1", requirement.sha1
791 if requirement.md5:
792 return "md5", requirement.md5
793 return None, None
796def _normalize_region(region: str | None) -> str | None:
797 if not region:
798 return None
799 normalized = region.casefold()
800 if any(value in normalized for value in ("usa", "north america", "ntsc-u", "canada")):
801 return "ntsc-u"
802 if any(value in normalized for value in ("europe", "australia", "pal")):
803 return "pal"
804 if any(value in normalized for value in ("japan", "ntsc-j")):
805 return "ntsc-j"
806 return None