diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py index 8de4cbc7..a2f69a2d 100644 --- a/tests/test_doc_surfaces.py +++ b/tests/test_doc_surfaces.py @@ -102,6 +102,31 @@ class InstallerOutputExample(unittest.TestCase): self.assertEqual(menu.groups(), (count, size)) +class OneResolutionChain(unittest.TestCase): + """architecture.md drew path_suffix as the first step, before SHA1, while + the code and verification-modes.md try it after every hash.""" + + ORDER = ("SHA1", "SHA256", "CRC32", "MD5", "Path suffix") + + def test_the_table_follows_the_code(self): + import common + + source = inspect.getsource(common.resolve_local_file) + steps = re.findall(r"# \d+[a-z]?\. (SHA1|SHA256|CRC32|MD5|Path suffix)", source) + self.assertEqual(tuple(dict.fromkeys(steps)), self.ORDER) + table = _table_rows(_section(_page("verification-modes.md"), "File resolution chain")) + methods = [row[1] for row in table[: len(self.ORDER)]] + self.assertEqual( + [next(m for m in self.ORDER if method.startswith(m)) for method in methods], + list(self.ORDER), + ) + + def test_architecture_links_instead_of_drawing_a_second_one(self): + section = _section(_page("architecture.md"), "File resolution chain") + self.assertNotIn("```mermaid", section) + self.assertIn("verification-modes.md#file-resolution-chain", section) + + class ReleaseRefPromise(unittest.TestCase): """RETROBIOS_REF was said to pin a reproducible file set, while every release deletes the previous tags and large files come from one fixed @@ -124,6 +149,30 @@ class ReleaseRefPromise(unittest.TestCase): self.assertNotIn("reproducible", paragraph, page.name) +class RequirementFieldsTable(unittest.TestCase): + """The table stopped at native_id: native_path, mirror_of, alt_md5 and + the native dict, the fields that make a platform YAML reversible, were + named nowhere a scraper author reads first.""" + + def test_every_field_and_native_key_is_listed(self): + from dataclasses import fields + + from scraper.base_scraper import BiosRequirement + + section = _section(_page("adding-a-platform.md"), "BiosRequirement fields") + listed = {row[0].strip("`") for row in _table_rows(section)} + self.assertEqual(listed, {f.name for f in fields(BiosRequirement)}) + native_row = next(row for row in _table_rows(section) if row[0] == "`native`")[2] + keys: set[str] = set() + for path in (ROOT / "scripts" / "scraper").glob("*_scraper.py"): + source = path.read_text(encoding="utf-8") + keys |= set(re.findall(r'native\["(\w+)"\]', source)) + keys |= set(re.findall(r'native(?:: dict\[str, object\])?\s*=\s*\{"(\w+)"', source)) + self.assertTrue(keys) + for key in keys: + self.assertIn(f"`{key}`", native_row) + + class ReleaseCommitCarriesTheManifests(unittest.TestCase): """The documented release commit left out install/*.json, which the release pipeline regenerates: v2026.10.10 added three by hand.""" @@ -170,6 +219,108 @@ class NoHedgedRules(unittest.TestCase): self.assertEqual(offenders, [], "\n".join(offenders)) +class PeerToneTowardPlatforms(unittest.TestCase): + """Platform lists were called wrong, stale or corrected on public pages + that their maintainers read.""" + + WORDS = re.compile( + r"(?i)\bgets wrong\b|\bstale (hash|entries|entry)\b||\bpatch their\b" + r"|, corrected\b|\bcorrected in place\b|\bdeclare incorrectly\b|\blist error\b" + ) + + def test_published_pages(self): + offenders = [ + f"{page.name}: {line[:80]}" + for page in PUBLISHED + for line in page.read_text(encoding="utf-8").splitlines() + if self.WORDS.search(line) + ] + self.assertEqual(offenders, [], "\n".join(offenders)) + + +class RecalboxSeverity(unittest.TestCase): + """The table gave verify.py a mapping from hashMatchMandatory that the + code never reads, and called a missing optional file GREEN.""" + + def test_the_verify_column_is_what_compute_severity_returns(self): + from verify import Status, compute_severity + + section = _section(_page("verification-modes.md"), "Mandatory levels (Recalbox)") + rows = _table_rows(section) + self.assertTrue(rows) + status = {"hash matches": Status.OK, "missing": Status.MISSING, "hash differs": Status.UNTESTED} + for state, mandatory, _hash_match, _colour, expected in rows: + for required in ((True, False) if mandatory == "(any)" else (mandatory == "true",)): + self.assertEqual( + compute_severity(status[state], required, "md5").upper(), expected, + f"{state} mandatory={mandatory}", + ) + self.assertNotIn("severity levels", _page("tools.md")) + + +class Md5CompositeDefinition(unittest.TestCase): + """The page described the MD5 of the members' MD5s, the code hashes the + members' contents in name order.""" + + def test_page_and_code_agree(self): + from hashing import md5_composite_of + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("b.rom", b"BBBB") + zf.writestr("a.rom", b"AAAA") + buffer.seek(0) + self.assertEqual(md5_composite_of(buffer), hashlib.md5(b"AAAABBBB").hexdigest()) + section = _section(_page("verification-modes.md"), "md5_composite (Recalbox ZIP verification)") + self.assertIn("contents of every member", section) + self.assertNotIn("inner file MD5s", section) + + +class ValidationIndexScope(unittest.TestCase): + """The page said the index reads every profile. verify and the builder + pass it only the profiles the platform resolves.""" + + def test_page_names_the_scope_the_code_uses(self): + import verify + + source = inspect.getsource(verify.verify_platform) + self.assertRegex(source, r"plat_cores = resolve_platform_cores\(config, profiles\)") + self.assertIn("_build_validation_index(platform_profiles)", source) + section = _section(_page("verification-modes.md"), "Validation index") + self.assertIn("resolve_platform_cores", section) + self.assertNotIn("reads all emulator profiles", section) + + +class CoreResolutionWrittenOnce(unittest.TestCase): + """architecture.md and adding-a-platform.md each stated the core + resolution rule, and only one knew that a list naming retroarch takes + every libretro profile. Neither said built_by profiles stay out.""" + + def test_one_statement_matching_the_platforms_and_the_code(self): + import yaml + + import common + + self.assertNotIn("cores: [list]", _page("architecture.md")) + source = inspect.getsource(common.resolve_platform_cores) + self.assertIn('not p.get("built_by")', source) + self.assertIn('"retroarch" in core_set', source) + naming = set() + for path in (ROOT / "platforms").glob("*.yml"): + config = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + cores = config.get("cores") + if isinstance(cores, list) and "retroarch" in map(str, cores): + naming.add(str(config.get("platform") or path.stem).lower()) + section = _section(_page("adding-a-platform.md"), "Required fields") + bullet = next(p for p in section.split("\n- ") if p.startswith("**Explicit list**")) + used_by = re.search(r"Used by ([^.]+)\.", bullet.replace("\n", " ")).group(1) + names = [n.strip().lower() for n in used_by.split(",")] + self.assertIn("the last\n four", bullet) + self.assertEqual(set(names[-4:]), naming) + libretro = next(p for p in section.split("\n- ") if p.startswith("**all_libretro**")) + self.assertIn("built_by", libretro) + + class ExitStatusTable(unittest.TestCase): """Code 2 was documented as 'every file failed' only, while argparse exits 2 on every refused option, and the 141 example named an option diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index b74b5379..f8966ab1 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -67,7 +67,21 @@ def validate_format(self, raw_data: str) -> bool: | `destination` | `str` | Relative path within the BIOS directory | | `required` | `bool` | Whether the platform considers this file mandatory | | `zipped_file` | `str \| None` | If set, the hash refers to a ROM inside a ZIP | -| `native_id` | `str \| None` | Original system name before normalization | +| `native_id` | `str \| None` | The platform's own system id, written as `native_system` | +| `sha256` | `str \| None` | SHA256 hash if available | +| `alt_md5` | `str \| None` | A second accepted MD5 (Batocera and ROCKNIX `altmd5`) | +| `native_path` | `str \| None` | The file reference as the platform writes it, kept when it differs from `name`. libretro writes `iplromco.dat` bare although the file lives under `keropi/`, and `ep128emu/roms/cpc464.rom` with its directory | +| `mirror_of` | `str \| None` | The native system whose entry this one repeats under another system. The platform's own file does not carry the copy, so the export skips it | +| `native` | `dict` | Fields the platform declares that the model has no place for, written back unchanged by the export: `native_name`, `core`, `note`, `description`, `hash_match_mandatory`, `mandatory_declared`, `alt_paths`, `component`, `required_label` | + +`requirement_entry()` in `base_scraper.py` is the single serializer from a +requirement to a platform YAML entry, and every scraper goes through it. +Anything an export writes back is recorded by the scraper, never rebuilt by the +exporter: several native systems can fall onto one slug (Recalbox files +`pcengine`, `pcenginecd` and `supergrafx` under `nec-pc-engine`), and a path +or a label the YAML does not hold cannot be derived. When an export needs a +value the YAML lacks, the scraper and `schemas/platform.schema.json` gain it, +then the platform is scraped again. ### System ID mapping @@ -147,16 +161,22 @@ none is edited by hand: RetroPie inherits RetroArch's file set and its scraper writes the packages it builds, read from the `rp_module_id` of each scriptmodule, and any manual change is overwritten on the next scrape. -The `cores` field determines which emulator profiles are resolved for this platform. -Three strategies exist: +The `cores` field determines which emulator profiles are resolved for this +platform (`resolve_platform_cores` in `common.py`). Alias profiles are never +resolved. Three strategies exist: - **Explicit list**: `cores: [beetle_psx, dolphin, ...]` - match by profile key name - or by any name in a profile's `cores:`. Used by Batocera, Recalbox, RetroBat, RomM, - RetroPie, EmuDeck, RetroDECK, ROCKNIX. A list that names `retroarch` (the last four) - also takes every libretro profile. -- **all_libretro**: `cores: all_libretro` - include every profile with `type: libretro` - or `type: standalone + libretro`. Used by RetroArch and Lakka. -- **Omitted**: fallback to system ID intersection. + or by any name in a profile's `cores:`. When a profile's `built_by` names this + platform, that profile answers for the name. Used by Batocera, Recalbox, RetroBat, + RomM, RetroPie, EmuDeck, RetroDECK, ROCKNIX. A list that names `retroarch` (the last + four) also takes every libretro profile, as `all_libretro` does. An empty list + (BizHawk, MiSTer FPGA) resolves no profile, and the pack holds the platform's own + files. +- **all_libretro**: `cores: all_libretro` - every profile whose `type` contains + `libretro`, except those that carry `built_by`: a build a platform compiles itself + is absent from the buildbot and is reached only by its name (Recalbox's `mame0258` + and `mame0278`). Used by RetroArch and Lakka. +- **Omitted**: fallback to system ID intersection, `built_by` profiles excluded. Two lists say how each resolved emulator is laid out. `standalone_cores` names the ones the platform launches as standalone apps: their files go to `standalone_path`. @@ -397,17 +417,13 @@ class Exporter(BaseExporter): ### Round-trip validation -```bash -python -m scripts.scraper.myplatform_scraper --output platforms/myplatform.yml -python scripts/export_native.py --platform myplatform --fetch --output-dir tmp/native -diff .cache/upstream-native/myplatform/ \ - tmp/native/myplatform/ -``` - -The export must not lose anything the platform already declares. If your +The scraper and the exporter are checked against each other as +[round-trip testing](adding-a-scraper.md#round-trip-testing) describes. The +export must not lose anything the platform already declares. If your format carries code as well as data (a script, a C# database, a manifest whose BIOS list sits beside launch configuration), say so with -`needs_original()` and patch their file rather than writing a new one. An +`needs_original()` and patch the platform's original file rather than writing +a new one. An exporter that regenerates such a file from BIOS data alone hands the maintainer something that no longer runs. diff --git a/wiki/adding-a-scraper.md b/wiki/adding-a-scraper.md index 5f898f45..584b1b8b 100644 --- a/wiki/adding-a-scraper.md +++ b/wiki/adding-a-scraper.md @@ -425,19 +425,19 @@ be missing from the round trip. # Scrape upstream -> platform YAML python -m scripts.scraper.myplatform_scraper --output platforms/myplatform.yml -# Rewrite the platform's own file, corrected +# Rewrite the platform's own file with the changes applied python scripts/export_native.py --platform myplatform --fetch \ --output-dir tmp/native # Compare with the file upstream publishes, cached by --fetch -diff .cache/upstream-native/myplatform/ \ - tmp/native/myplatform/ +diff .cache/upstream-native/myplatform/ \ + tmp/native/myplatform/ ``` -Every line in that diff should be a correction you can defend. A line that -merely reshapes their formatting means the scraper lost something the -exporter then had to invent: add it to the requirement instead, through -`native_path`, `native_system` or the `native` dict. +Every line of that diff is a change the emulator source supports. A line that +only reshapes the original formatting means the scraper lost something the +exporter then had to invent: record it in the requirement instead, through +the fields listed in [BiosRequirement fields](adding-a-platform.md#biosrequirement-fields). ### Common issues diff --git a/wiki/advanced-usage.md b/wiki/advanced-usage.md index 667c53de..0379e491 100644 --- a/wiki/advanced-usage.md +++ b/wiki/advanced-usage.md @@ -297,8 +297,8 @@ without `--target`. Truth data is ground truth generated from emulator profiles. It represents what each core actually needs based on source code analysis, independent of what platform -scrapers declare. The purpose is gap analysis: finding files that platforms miss -or declare incorrectly. +scrapers declare. The purpose is gap analysis: finding the files the code loads that a platform +list does not name, and the values that differ from the code. ### Generate truth diff --git a/wiki/architecture.md b/wiki/architecture.md index 14289f14..ab066c99 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -88,7 +88,7 @@ emulators/*.yml verify.py checks generate_pack.py resolves truth.py generates diff_truth.py export_native.py ground truth from compares truth vs rewrites each platform's - emulator profiles scraped platform own file, corrected + emulator profiles scraped platform own file, updated ``` Pipeline runs all steps in sequence: DB, provenance report, data dirs, @@ -127,14 +127,13 @@ graph LR The pack combines platform baseline (layer 1) with core requirements (layer 3). Neither too much (no files from unused cores) nor too few (no missing files for active cores). -A same-named local file that contradicts an explicit hash is recorded as an -unsafe omission on hash-verifying platforms, while existence platforms ship it and -report the divergence, because their code never reads the bytes. +A same-named local file that contradicts a declared hash is settled by the +platform's verification mode ([discrepancy detection](verification-modes.md#discrepancy-detection)). The emulator's source code serves as ground truth for what files are needed, what names they use, and what validation the emulator performs. Platform YAML -configs are scraped from upstream and are generally accurate, though they can -occasionally have gaps or stale entries. The emulator profiles complement the +configs are scraped from upstream and record what each platform declares. A +list kept by hand can lag behind an emulator release. The emulator profiles complement the platform data by documenting what the code actually loads. When the two disagree, the profile takes precedence for pack generation: files the code needs are included even if the platform does not declare them. Files the platform declares but no @@ -143,7 +142,8 @@ upstream may cover cases not yet profiled. A platform YAML is regenerated by its scraper and never edited by hand: an edit would be lost at the next scrape, and the file would stop describing the -platform. What the platform gets wrong is answered in the other two layers. +platform. Where the emulator source states something else, the profile and +`_shared.yml` carry it. Another name for the same file is an `aliases:` entry in the profile, never a second entry in a platform YAML or in `_shared.yml`. A name a platform's own launcher reads, such as the 22 names Batocera's DuckStation generator tries @@ -373,46 +373,10 @@ platform's own verification mode. ### File resolution chain -`resolve_local_file` in `common.py` tries each strategy in order, returning the -first match. Used by both `verify.py` and `generate_pack.py`. - -```mermaid -graph TD - START([resolve_local_file]) --> S0{path_suffix
exact match?} - S0 -- yes --> EXACT([exact]) - S0 -- no --> S1{SHA1
exact match?} - S1 -- yes --> EXACT - S1 -- no --> S1B{SHA256
exact match?} - S1B -- yes --> EXACT - S1B -- no --> S1C{CRC32 + size,
no stronger hash?} - S1C -- yes --> EXACT - S1C -- no --> S2{MD5 direct
or truncated?} - S2 -- yes --> MD5([md5_exact]) - S2 -- no --> S3{name + aliases
no MD5?} - S3 -- yes --> EXACT - S3 -- no --> S4{name + aliases
md5_composite /
direct MD5?} - S4 -- match --> EXACT - S4 -- name only --> HM([hash_mismatch]) - S4 -- no --> S5{zippedFile
inner ROM MD5?} - S5 -- yes --> ZE([zip_exact]) - S5 -- no --> S6{MAME clone
map lookup?} - S6 -- yes --> MC([mame_clone]) - S6 -- no --> S7{data_dir
cache scan?} - S7 -- yes --> DD([data_dir]) - S7 -- no --> S8{agnostic
fallback?} - S8 -- yes --> AG([agnostic_fallback]) - S8 -- no --> NF([not_found]) - - style START fill:#2d333b,stroke:#adbac7,color:#adbac7 - style EXACT fill:#2d333b,stroke:#adbac7,color:#adbac7 - style MD5 fill:#2d333b,stroke:#adbac7,color:#adbac7 - style HM fill:#2d333b,stroke:#adbac7,color:#adbac7 - style ZE fill:#2d333b,stroke:#adbac7,color:#adbac7 - style MC fill:#2d333b,stroke:#adbac7,color:#adbac7 - style DD fill:#2d333b,stroke:#adbac7,color:#adbac7 - style AG fill:#2d333b,stroke:#adbac7,color:#adbac7 - style NF fill:#2d333b,stroke:#adbac7,color:#adbac7 -``` +`resolve_local_file` in `common.py` turns a file entry into a local path, and +`verify.py` and `generate_pack.py` both call it. Every declared hash is tried +before a path or a name, so a path never wins over a hash. The steps, in order, +are listed under [file resolution chain](verification-modes.md#file-resolution-chain). ## Platform inheritance @@ -420,11 +384,8 @@ Platform configs support `inherits:` to share definitions. Lakka inherits from RetroArch, RetroPie inherits from RetroArch with `base_destination: BIOS`. `overrides:` allows child platforms to modify specific systems from the parent. -Core resolution (`resolve_platform_cores`) uses three strategies: - -- `cores: all_libretro` - include all profiles with `libretro` in their type -- `cores: [list]` - include only named profiles -- `cores:` absent - fallback to system ID intersection between platform and profiles +Which emulator profiles a platform resolves follows its `cores` field, as +[adding a platform](adding-a-platform.md#required-fields) describes. ## Hardware target filtering diff --git a/wiki/tools.md b/wiki/tools.md index 8164c5d4..95165752 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -129,7 +129,7 @@ Verification modes per platform: |----------|------|-------| | RetroArch, Lakka, RetroPie | existence | file present = OK | | Batocera, RetroBat | md5 | MD5 hash match, plus `checkInsideZip` for `zippedFile` entries | -| Recalbox | md5 | MD5 multi-hash, `md5_composite` for ZIPs, 3 severity levels | +| Recalbox | md5 | MD5 multi-hash, `md5_composite` for ZIPs | | EmuDeck | md5 | MD5 whitelist per system | | RetroDECK | md5 | MD5 per file via component manifests | | RomM | md5 | size + any hash (MD5/SHA1/CRC32), no ZIP inspection | @@ -203,12 +203,8 @@ Packs include platform baseline files plus files required by the platform's core When a file passes platform verification but fails emulator validation, the tool searches for a variant that satisfies both. If none exists, the platform version is kept and the discrepancy is reported. -What happens when a declared hash and the local dump disagree follows the -platform's own verification mode. A hash platform would reject the file, so it -is omitted and recorded in the pack report and the installer manifest. An -existence platform reads no bytes, so the file is packed and the divergence is -reported instead: an upstream list error must not remove a file the frontend -would have loaded. +A declared hash that the local dump contradicts is settled by the platform's +own verification mode ([discrepancy detection](verification-modes.md#discrepancy-detection)). ### Granular options @@ -292,7 +288,7 @@ python scripts/export_native.py --all --refresh-cache A list is written in the order the code looks: `priority:` first (lowest wins), then the profile's own declaration order. What a format cannot state is reported rather than written: EmuDeck's arrays are shared -between emulators its file does not name, so a hash is corrected in place +between emulators its file does not name, so a hash is updated in place and never added. `exports/.json` lists every change under `changes`, each with diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index 0900d44a..5658005e 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -57,10 +57,11 @@ the actual hash is compared against only its first N characters. ### md5_composite (Recalbox ZIP verification) -Recalbox computes `Zip::Md5Composite` for ZIP files: the MD5 of the concatenation of all -inner file MD5s (sorted by filename). `verify.py` replicates this with `md5_composite()` -from `common.py`. When a ZIP file's direct MD5 does not match, the composite is tried -before reporting a mismatch. +Recalbox hashes a ZIP with `Zip::Md5Composite` (`es-core/src/utils/Zip.cpp:71-96` in +recalbox-emulationstation at `f4cd0ae4`): one MD5 over the contents of every member, +read in filename order. The members' own MD5s are not involved. `verify.py` computes +the same value with `md5_composite()` (`scripts/hashing.py`). When a ZIP file's direct +MD5 does not match, the composite is tried before reporting a mismatch. ### Multi-hash (Recalbox) @@ -70,14 +71,24 @@ listed hash is OK. ### Mandatory levels (Recalbox) -Recalbox uses three severity levels derived from two YAML fields (`mandatory` and -`hashMatchMandatory`): +Recalbox colours each entry of `es_bios.xml` from two attributes, `mandatory` and +`hashMatchMandatory`, both true unless written `"false"` +(`es-app/src/bios/Bios.cpp:162-163` in recalbox-emulationstation at `f4cd0ae4`, +colours set by `Bios::Scan` at lines 84-108). The platform YAML carries them as +`required` and, when the attribute is written, `hash_match_mandatory`. -| mandatory | hashMatchMandatory | Color | verify.py mapping | -|-----------|--------------------|--------|-------------------| -| true | true | RED | CRITICAL | -| true | false | YELLOW | WARNING | -| false | (any) | GREEN | WARNING | +| File | mandatory | hashMatchMandatory | Recalbox | verify.py | +|------|-----------|--------------------|----------|-----------| +| hash matches | (any) | (any) | GREEN | OK | +| missing | true | (any) | RED | CRITICAL | +| missing | false | (any) | YELLOW | WARNING | +| hash differs | true | true | RED | WARNING | +| hash differs | true | false | YELLOW | WARNING | +| hash differs | false | (any) | YELLOW | WARNING | + +`verify.py` applies the MD5-mode table above and does not read +`hash_match_mandatory`, so a present file whose hash differs is a WARNING where +Recalbox shows RED for a mandatory entry with `hashMatchMandatory`. ### checkInsideZip (Batocera zippedFile) @@ -154,8 +165,15 @@ from `validation.py`. This layer uses data from emulator profiles (YAML files in ### Validation index -`_build_validation_index()` reads all emulator profiles and builds a per-filename -index of validation rules. When multiple emulators reference the same file, checks +`_build_validation_index()` (`validation.py`) builds a per-filename index of +validation rules from the profiles it is given. A platform report, its pack and +its install manifest pass only the profiles `resolve_platform_cores` resolves for +that platform (`verify.py`, `verify_platform`). A profile that does not run on the +platform has nothing to say about its pack: standalone ZEsarUX reads a 48 KB +`cpc6128.rom` of its own, and its size check reported cap32's 32 KB file in the +RetroArch pack as a mismatch while every profile was indexed. The same holds for +`hle_fallback`, which is read from the same set of profiles. A report or pack for `--emulator` or `--system` indexes the +profiles it selected. When multiple emulators reference the same file, checks are merged (union of all check types). Conflicting expected values are kept as sets (e.g., multiple accepted CRC32 values for different ROM versions). @@ -256,12 +274,8 @@ A path or filename is never allowed to mask a declared strong hash: the identity steps run first, and a path or name is accepted only when no content hash was declared or when the content agrees with the one that was. -What the pack does with a `hash_mismatch` follows the platform's own mode. An -MD5 or SHA1 platform would reject those bytes, so the file is left out and -counted as an unsafe exclusion, distinct from a file the collection does not -have. An existence platform reads no bytes at all, so the file is packed and -the divergence is printed as a discrepancy: a value in an upstream BIOS list that -differs from the emulator source must not remove a file the frontend would load. +What the pack does with a `hash_mismatch` is set by the platform's mode, as +[discrepancy detection](#discrepancy-detection) describes. ## Discrepancy detection @@ -272,10 +286,11 @@ accepted. A local file that contradicts a declared hash is handled by the platform's own mode, never by one global rule. An existence frontend never reads the -bytes, so the file is packed and the disagreement reported: a stale hash in -System.dat must not remove a file RetroArch would have loaded. An md5 or sha1 -frontend would reject it, so it is left out of the pack and counted as -`unsafe excluded`, which is distinct from `missing`. The pack builder and the +bytes, so the file is packed and the difference reported: a hash in System.dat +that differs from the local dump must not remove a file RetroArch would have +loaded. An md5 or sha1 frontend would reject it, so it is left out of the pack +and counted as `unsafe excluded`, which is distinct from `missing`. The pack +report and the installer manifest both record it. The pack builder and the pack verification apply the same rule. ### Variant search