diff --git a/schemas/core-probe-record.schema.json b/schemas/core-probe-record.schema.json index 97687ec90..11e3232e7 100644 --- a/schemas/core-probe-record.schema.json +++ b/schemas/core-probe-record.schema.json @@ -1004,11 +1004,18 @@ } } } + }, + "bios_patterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Log lines by which the core says it loaded the console's own BIOS: they add the bios stage when its screens are hidden." } } } ], - "description": "What the verdicts were judged against: the reference frames of this content and core, the core's error patterns and error screens, from the oracle file at its sha256. Null when the run had no oracle entry." + "description": "What the verdicts were judged against: the reference frames of this content and core, the core's error patterns, error screens and BIOS load patterns, from the oracle file at its sha256. Null when the run had no oracle entry." } } } diff --git a/scripts/campaign.py b/scripts/campaign.py index de699b58d..ae0cd8eb4 100644 --- a/scripts/campaign.py +++ b/scripts/campaign.py @@ -903,6 +903,7 @@ class Campaign: self.store = ContentStore(self) self.content = load_content(paths.content_file) self.oracle_sha = file_sha256(paths.oracle) if paths.oracle.is_file() else "" + self.oracle_data = core_probe.load_oracle(paths.oracle)[0] self.deadline = 0.0 self.current: Pass | None = None self.dirty_at_start: set[str] = set() @@ -1055,9 +1056,14 @@ class Campaign: def remember(self, emulator: str, key: str, result: ProbeResult, core: Core | None, digest: str) -> None: + judged = None + if result.record and result.record["inputs"].get("content"): + judged = [result.record["inputs"]["content"]["sha1"], result.record["core"]["library_name"]] entry = {"status": result.status, "at": now_iso(), "profile_digest": digest, "core_sha256": core.sha256 if core else None, - "record_version": mr.RECORD_VERSION, "oracle_sha256": self.oracle_sha} + "record_version": mr.RECORD_VERSION, "oracle_sha256": self.oracle_digest(judged)} + if judged: + entry["oracle_key"] = judged if result.reason: entry["reason"] = result.reason if result.log: @@ -1073,9 +1079,27 @@ class Campaign: self.state["checks"].setdefault(emulator, {})[key] = { "status": PENDING, "reason": reason, "at": now_iso(), "profile_digest": digest} + def oracle_digest(self, key: list[str] | None) -> str: + """What a check's verdicts depend on in the oracle: the entries of its + content and core, else the whole file. Another core's references + never make a check run again.""" + if not key: + return self.oracle_sha + return mr.value_sha1(core_probe.oracle_for(self.oracle_data, *key)) + def needed(self, emulator: str, key: str, core: Core, digest: str) -> bool: - return should_run(self.state["checks"].get(emulator, {}).get(key), core.sha256, digest, - self.oracle_sha) + previous = self.state["checks"].get(emulator, {}).get(key) + judged = (previous or {}).get("oracle_key") + if previous and not judged and previous.get("record"): + record = self.read_record(emulator, previous["record"]) or {} + if (record.get("inputs") or {}).get("content"): + judged = [record["inputs"]["content"]["sha1"], record["core"]["library_name"]] + previous["oracle_key"] = judged + # Judged against the entries the record kept, not the file it named. + kept = {k: v for k, v in (record["inputs"].get("oracle") or {}).items() + if k not in ("file", "sha256")} + previous["oracle_sha256"] = mr.value_sha1({"bios_patterns": [], **kept}) + return should_run(previous, core.sha256, digest, self.oracle_digest(judged)) def check(self, emulator: str, key: str, core: Core, digest: str, argv: list[str], log_name: str) -> dict | None: @@ -1134,8 +1158,8 @@ class Campaign: keys[f"negative:{dest}"] = self.check_key(core, content, f"negative:{dest}") if self.settings.bench: keys["bench"] = self.check_key(core, content, "bench") - if not any(self.needed(emulator, key, core, digest) for key in keys.values()): - return + # The rules run on every pass, from the records kept when nothing + # needs to run again: a change of the table applies without a run. try: folder = self.store.get(content) load = load_file(folder, content.load) @@ -1174,7 +1198,7 @@ class Campaign: for label in labels[3:]: self.log(f"{emulator}: {rules.rule_region(rules.run_of(record, label), present, entry, content.region)}") if dest in self.opened(files_record, 0) or dest in self.opened(record, 0): - verdicts = rules.rule_need(none, present, entry) + verdicts = rules.rule_need(none, present, entry, rules.read(none, present, zeros, half)) validation = rules.rule_validation(zeros, half, none, present, entry) if validation: verdicts.append(validation) diff --git a/scripts/campaign_rules.py b/scripts/campaign_rules.py index ff1ec0d4b..dc21258c3 100644 --- a/scripts/campaign_rules.py +++ b/scripts/campaign_rules.py @@ -11,7 +11,7 @@ BIOS at all, `present` with the file alone, and wrong files staged under its name (zeros of its size, its first half, another region's dump). runs field settled - none boots hle_fallback true + none boots, the file changes the run hle_fallback true none fails, present boots required true hle_fallback false zeros refused, present boots validation a content check @@ -123,9 +123,20 @@ def agrees(entry: dict, verdict: Verdict) -> bool: return current(entry, verdict.field) == verdict.value -def rule_need(none: Run, present: Run, entry: dict) -> list[Verdict]: - """No BIOS at all against the file alone: required and hle_fallback.""" +def read(none: Run, *runs: Run) -> bool: + """True when the file changes the run: alone or as a wrong file, one + variant ends otherwise than the run with no BIOS, or reaches other + stages. A core can open a file without using it for this content (a + scan that hashes every file of the folder).""" + return any(run.verdict != none.verdict or run.stages != none.stages for run in runs if run) + + +def rule_need(none: Run, present: Run, entry: dict, used: bool = True) -> list[Verdict]: + """No BIOS at all against the file alone: required and hle_fallback. A + file the content never uses settles nothing.""" name = entry["name"] + if none.verdict == BOOTS and not used: + return [] if none.verdict == BOOTS: out = [Verdict("boots_without_file", "hle_fallback", True, f"the core boots the content with no BIOS at all, {name} included", diff --git a/scripts/core_probe.py b/scripts/core_probe.py index 0c6e91f45..bb35bf43e 100644 --- a/scripts/core_probe.py +++ b/scripts/core_probe.py @@ -1173,8 +1173,9 @@ DEFAULT_ORACLE = ROOT / "tests" / "campaign" / "oracle.yml" def oracle_for(data: dict, content_sha1: str | None, library_name: str | None) -> dict: """The oracle entries that judge one content on one core: its reference - frames, the error patterns every core shares and the core's own, and the - core's known error screens.""" + frames, the error patterns every core shares and the core's own, the + core's known error screens, and the log lines by which it says it loaded + the console's own BIOS.""" core = (data.get("cores") or {}).get(library_name or "") or {} references: list[dict] = [] for item in data.get("content") or (): @@ -1185,7 +1186,9 @@ def oracle_for(data: dict, content_sha1: str | None, library_name: str | None) - patterns = [p["pattern"] for p in (data.get("error_patterns") or ())] patterns += [p["pattern"] for p in core.get("error_patterns") or ()] screens = [{"sha1": s["sha1"], "shows": s["shows"]} for s in core.get("error_screens") or ()] - return {"references": references, "error_patterns": patterns, "error_screens": screens} + loaded = [p["pattern"] for p in core.get("bios_patterns") or ()] + return {"references": references, "error_patterns": patterns, "error_screens": screens, + "bios_patterns": loaded} def sampled(rep: dict) -> list[tuple[int, str]]: @@ -1218,6 +1221,11 @@ def judge(rep: dict, oracle: dict) -> tuple[str, dict]: references = {r["sha1"]: r for r in oracle.get("references") or ()} reached = [(frame, digest) for frame, digest in frames if digest in references] stages = list(dict.fromkeys(references[d]["stage"] for _, d in reached)) + # A core that hides the BIOS screens by default still says it loaded one. + if "bios" not in stages and any(re.search(pattern, line["text"]) + for pattern in oracle.get("bios_patterns") or () + for line in rep.get("logs") or ()): + stages.insert(0, "bios") game = [(frame, digest) for frame, digest in reached if references[digest]["stage"] == "game"] if game: frame, digest = game[0] diff --git a/tests/campaign/oracle.yml b/tests/campaign/oracle.yml index fd316c5ea..c027bfc7b 100644 --- a/tests/campaign/oracle.yml +++ b/tests/campaign/oracle.yml @@ -14,6 +14,9 @@ # cores:. A line a core logs and then carries on (a fallback to a # built-in BIOS) is not an error pattern. # error_screens per core, the sha1 of a frame that shows its BIOS error message. +# bios_patterns per core, log lines by which it says it loaded the console's +# own BIOS: they stand for the bios-stage screens of a core +# that hides them by default. # # A run that reaches no reference frame, matches no pattern or screen and # does not end on one colour is `unknown`: frames that differ from another @@ -47,7 +50,11 @@ cores: Without a BIOS, or with one of the wrong size, it logs "Using HLE BIOS" and runs its own (frontend/libretro.c:3893-3899 at c8816799). The BIOS boot logo is off by default (pcsx_rearmed_show_bios_bootlogo), so its - runs reach no bios-stage screen. + runs reach no bios-stage screen: the line it logs once a file passes + its signature check and is kept stands for them. + bios_patterns: + - pattern: '^found (US|EU|JP) BIOS file, crc32 ' + source_ref: frontend/libretro.c:3646-3669 at c8816799 error_patterns: [] error_screens: [] "PCSX1": diff --git a/tests/test_campaign.py b/tests/test_campaign.py index f981cf3ed..ec5753227 100644 --- a/tests/test_campaign.py +++ b/tests/test_campaign.py @@ -155,6 +155,14 @@ class RuleTable(unittest.TestCase): optional = rules.rule_need(NONE_BOOTS, PRESENT, {"name": "a", "required": False}) self.assertEqual([v.field for v in optional], ["hle_fallback"]) + def test_a_file_the_content_never_uses_settles_nothing(self) -> None: + unused = run(rules.BOOTS, "bios.bin", ("game",)) + self.assertFalse(rules.read(NONE_BOOTS, unused, unused, unused)) + self.assertEqual(rules.rule_need(NONE_BOOTS, unused, self.ENTRY, used=False), []) + # A wrong file that hangs where no file boots: the core reads it. + self.assertTrue(rules.read(NONE_BOOTS, unused, run(rules.HANG), unused)) + self.assertTrue(rules.read(NONE_BOOTS, PRESENT, None, None)) + def test_fails_without_and_boots_alone_settles_required(self) -> None: for failure in rules.FAILS: verdicts = rules.rule_need(run(failure, "none"), PRESENT, self.ENTRY) @@ -397,6 +405,19 @@ class StateAndResume(Workspace): self.assertTrue(campaign.should_run({**done, "status": "pending"}, "a", "d")) self.assertFalse(campaign.should_run({**done, "status": "failed"}, "a", "d")) + def test_only_its_own_oracle_entries_make_a_check_run_again(self) -> None: + key = [hashlib.sha1(b"GAME").hexdigest(), "Fake"] + before = self.make().oracle_digest(key) + data = yaml.safe_load(self.oracle.read_text()) + data["content"][0]["references"]["Other"] = {"recorded_from": "2026-10-27-000000000000", + "frames": [{"sha1": "a" * 40, "stage": "game", + "shows": "x"}]} + self.oracle.write_text(yaml.safe_dump(data), encoding="utf-8") + self.assertEqual(self.make().oracle_digest(key), before) + data["cores"] = {"Fake": {"error_patterns": [{"pattern": "BIOS", "reason": "r"}]}} + self.oracle.write_text(yaml.safe_dump(data), encoding="utf-8") + self.assertNotEqual(self.make().oracle_digest(key), before) + def test_evidence_does_not_change_the_digest(self) -> None: profile = yaml.safe_load(PROFILE) before = campaign.profile_digest(profile) diff --git a/tests/test_core_probe.py b/tests/test_core_probe.py index 5fb9c441b..e927cbe2d 100644 --- a/tests/test_core_probe.py +++ b/tests/test_core_probe.py @@ -211,6 +211,13 @@ class OracleTests(unittest.TestCase): self.assertEqual((evidence["rule"], evidence["frame"], evidence["stages"]), ("reference_frame", 20, ["bios", "game"])) + def test_a_bios_load_line_stands_for_hidden_bios_screens(self) -> None: + oracle = {**self.ORACLE, "bios_patterns": [r"^found US BIOS file"]} + verdict, evidence = core_probe.judge( + self.rep(frames((0, self.TITLE)), logs=["found US BIOS file, crc32 1: x"]), oracle) + self.assertEqual((verdict, evidence["stages"]), ("boots", ["bios", "game"])) + self.assertEqual(core_probe.judge(self.rep(frames((0, self.TITLE))), oracle)[1]["stages"], ["game"]) + def test_the_bios_screens_alone_are_not_a_boot(self) -> None: # A console that shows its logo and then refuses the disc never reached the game. verdict, evidence = core_probe.judge( @@ -275,6 +282,7 @@ class OracleTests(unittest.TestCase): picked = core_probe.oracle_for(data, "a" * 40, "Fake") self.assertEqual(picked["references"], [{"sha1": self.TITLE, "stage": "game", "shows": "t"}]) self.assertEqual(picked["error_patterns"], ["shared", "own"]) + self.assertEqual(picked["bios_patterns"], []) self.assertEqual(core_probe.oracle_for(data, "b" * 40, "Fake")["references"], []) self.assertEqual(core_probe.oracle_for(data, "a" * 40, "Nobody")["error_patterns"], ["shared"]) @@ -283,7 +291,7 @@ class OracleTests(unittest.TestCase): data = yaml.safe_load(core_probe.DEFAULT_ORACLE.read_text(encoding="utf-8")) patterns = list(data.get("error_patterns") or ()) for core in (data.get("cores") or {}).values(): - patterns += core.get("error_patterns") or [] + patterns += (core.get("error_patterns") or []) + (core.get("bios_patterns") or []) for screen in core.get("error_screens") or (): self.assertRegex(screen["sha1"], r"^[0-9a-f]{40}$") for pattern in patterns: diff --git a/wiki/tools.md b/wiki/tools.md index 3fe00c65a..622c37414 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -891,7 +891,9 @@ content by the sha1 of the file handed to the core and a core by its library name. A reference frame is recorded once with the full BIOS set and reviewed by looking at its PNG. An error pattern is a log line after which the core stops, read from its source: a line a core logs before it falls back to a -built-in BIOS is not one. Frames that differ from another run never make a +built-in BIOS is not one. A BIOS load pattern is the line a core logs once +it keeps the console's BIOS: it adds the `bios` stage for a core that hides +the BIOS screens by default (PCSX-ReARMed). Frames that differ from another run never make a boot. | Option | Effect | @@ -1000,7 +1002,7 @@ python scripts/campaign.py install-timer # opt-in: writes the autorun units, | Verdicts | Field | Written | |----------|-------|---------| -| `none` boots | `hle_fallback` | `true` | +| `none` boots, and the file changes the run: alone or as a wrong file, another verdict or other stages than with no BIOS | `hle_fallback` | `true`. A file the game never uses settles nothing: a core can open every file of the folder to hash it | | `none` fails (`bios_error`, `crash`, `hang`) and the file alone boots | `required`, `hle_fallback` | `true`, `false` | | Zeros of the file's size refused while the file boots | `validation` | a content check is proven. Agreeing adds evidence, a profile with no md5, sha1, sha256, crc32 or adler32 is an open finding | | Zeros used while the file boots | `validation` | no content check. A profile that declares one is an open finding | @@ -1026,16 +1028,18 @@ prints the record id with the screens that stay on for 3 samples or more in every repetition. A reviewer opens those PNGs and writes the chosen frames into `tests/campaign/oracle.yml` with their stage and what they show. -A run that agrees with the profile adds its record id to `evidence:` and +The rules run on every pass, from the kept records when no check needs to run +again, so a change of the table applies without a run. A run that agrees with +the profile adds its record id to `evidence:` and resolves an open finding of the same field. The profile is edited as text and the edit is refused unless the result parses to the same profile with only those fields changed. State lives in `.cache/campaign/state.json` of the main checkout: each check is `done`, `failed` (with the reason and its log) or `pending` (with the -reason), with the core sha256, the profile digest, the oracle sha256 and the -record version it ran against. A pass skips a check that is done or failed -against the same four, so it +reason), with the core sha256, the profile digest, the oracle entries of its +content and core (by sha1) and the record version it ran against. A pass +skips a check that is done or failed against the same four, so it takes new nightlies, changed profiles, newly fetched content and pending checks. The campaign's own `evidence:` writes do not count as a profile change.