From 20f1039d8964ffaaf93406bafd17a9471418a987 Mon Sep 17 00:00:00 2001 From: WhiteAssassins <28965087+WhiteAssassins@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:52:36 -0400 Subject: [PATCH 1/4] feat(tools): list verified patch catalog entries --- .../relinker/tests/test_patch_advisor.py | 23 +++++-- docs/user/USAGE.md | 4 +- tools/suggest_patches.py | 42 ++++++++---- tools/tests/test_suggest_patches.py | 64 +++++++++++++++++++ 4 files changed, 113 insertions(+), 20 deletions(-) diff --git a/core/relinker/relinker/tests/test_patch_advisor.py b/core/relinker/relinker/tests/test_patch_advisor.py index bed41997bd..ae25bd0e96 100644 --- a/core/relinker/relinker/tests/test_patch_advisor.py +++ b/core/relinker/relinker/tests/test_patch_advisor.py @@ -25,27 +25,38 @@ def main(): metadata = root / "param.json" metadata.write_text(json.dumps({"titleId": "TEST00001", "contentVersion": "01.000.000"})) - def advise(digest, patches=("patch.toml",)): + def advise(digest, patches=("patch.toml",), *options): (catalog / "index.json").write_text(json.dumps({"format": 1, "entries": [{ "titleId": "TEST00001", "contentVersion": "01.000.000", "patch": name, "targets": {"eboot.bin": digest} } for name in patches]})) return subprocess.run([sys.executable, str(advisor), "--catalog", str(catalog), "--param-json", str(metadata), "--input", str(source), - "--relinker", f"./{relinker.name}", "--skip-sce-module"], + "--relinker", f"./{relinker.name}", "--skip-sce-module", *options], cwd=relinker.parent, capture_output=True, text=True, timeout=30) - result = advise(hashlib.sha256(image).hexdigest()) + digest = hashlib.sha256(image).hexdigest() + result = advise(digest) assert result.returncode == 0 and f"Recommended: {patch}" in result.stdout, (result.stdout, result.stderr) + second = catalog / "second patch.toml" + second.write_text(patch_text(address, expected, "flip_bit")) + result = advise(digest, ("patch.toml", second.name)) + assert result.returncode == 1 and "multiple verified catalog entries" in result.stdout, (result.stdout, result.stderr) + result = advise(digest, ("patch.toml", second.name), "--list-verified") + assert result.returncode == 0 and "Verified catalog entries: 2" in result.stdout, (result.stdout, result.stderr) + assert f"Catalog entry 1: {patch}" in result.stdout and f"Catalog entry 2: {second}" in result.stdout, (result.stdout, result.stderr) + assert "Recommended:" not in result.stdout, (result.stdout, result.stderr) broken = catalog / "broken.toml" broken.write_text(patch_text(address, expected, "broken").replace('description = "flip one bit"', r'description = "flip \"one\" bit"')) - result = advise(hashlib.sha256(image).hexdigest(), ("broken.toml", "patch.toml")) + result = advise(digest, ("broken.toml", "patch.toml")) assert result.returncode == 0 and "relinker error (exit 2)" in result.stdout and f"Recommended: {patch}" in result.stdout, (result.stdout, result.stderr) - result = advise(hashlib.sha256(image).hexdigest(), ("broken.toml",)) + result = advise(digest, ("broken.toml", "patch.toml"), "--list-verified") + assert result.returncode == 0 and "relinker error (exit 2)" in result.stdout and "Verified catalog entries: 1" in result.stdout, (result.stdout, result.stderr) + result = advise(digest, ("broken.toml",), "--list-verified") assert result.returncode == 2 and "relinker error (exit 2)" in result.stdout, (result.stdout, result.stderr) patch.write_text(patch_text(address, expected ^ 1, "wrong_byte")) - result = advise(hashlib.sha256(image).hexdigest()) + result = advise(digest) assert result.returncode == 1 and "rejected by relinker" in result.stdout, (result.stdout, result.stderr) patch.write_text(patch_text(address, expected, "flip_bit")) result = advise(hashlib.sha256(b"other build").hexdigest()) diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 3a4dc187ee..4e31b9602e 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -103,7 +103,7 @@ A patch file changes bytes of the input executable or of a bundled module before The advisor requires Python 3.11 or newer. To check a local patch catalog, run: ```sh -python3 tools/suggest_patches.py --catalog --param-json --input --relinker [--sce-module-path ] [--module-dir ]... [--executable ]... [--skip-sce-module] [--timeout-seconds ] +python3 tools/suggest_patches.py --catalog --param-json --input --relinker [--sce-module-path ] [--module-dir ]... [--executable ]... [--skip-sce-module] [--timeout-seconds ] [--list-verified] ``` The advisor reads `titleId` and `contentVersion` from the specified `param.json`, checks the SHA-256 of every file targeted by a candidate patch, then calls the relinker specified by `--relinker` to run `--verify-patches`. A relative path such as `--relinker ./relinker` resolves to that local file. The explicit metadata path also works when `param.json` and the unwrapped ELF files are stored separately. The catalog directory must contain an `index.json` with `format: 1` and an `entries` array. Each entry has `titleId`, `contentVersion`, a relative `patch` path, and `targets` mapping every patch target to its SHA-256. Hexadecimal digests are case-insensitive. For example: @@ -122,6 +122,8 @@ The advisor reads `titleId` and `contentVersion` from the specified `param.json` The advisor validates patch files only for entries matching the requested title and version. It reports and skips a malformed matching entry or a relinker verification error, so another valid entry can still be recommended. Verification times out after 120 seconds per entry by default; use `--timeout-seconds ` to change the limit. For synthetic inputs without system modules, use `--skip-sce-module` if conversion also uses it; this option conflicts with `--sce-module-path`. No matching entry or no verified patch exits `1`; invalid input or no verified patch after a relinker error exits `2`. If multiple entries verify, the advisor recommends none because it cannot choose or combine them. The tool does not fetch or apply patches. A recommendation prints `--patches` and the module and executable options to use when converting. Keep catalog patch files outside the input executable's directory to avoid automatic `relinker-patches.toml` discovery. +With `--list-verified`, the advisor prints every entry that passes metadata, hash and relinker checks, in catalog order. Each result includes its catalog number, patch file, target hashes and conversion options. It exits `0` when at least one entry verifies, even if another entry was rejected; with no verified entries, the existing `1` or `2` exit status still applies. Without this option, a single verified entry is recommended and multiple verified entries remain ambiguous. Listing entries does not combine patch files or apply them during conversion; choose a patch explicitly after reviewing it. + The target hashes identify the exact unwrapped files changed by a patch, which might not include the main executable. Different dump tools could produce different bytes for the same title version; the advisor then rejects a patch even if it would work. It has not been confirmed that all dump tools produce identical files. The hashes do not authenticate a catalog or establish that a patch is safe or that the title is fully playable. Use catalogs from sources you trust and review the patch before applying it. If the patch targets another title executable, pass `--executable ` to the advisor for each additional executable, using paths relative to the module parent directory, such as `picker/picker.bin`. The advisor hashes those target files and forwards the same options to `relinker --verify-patches`. Use the same `--executable` options when converting with `--patches`. diff --git a/tools/suggest_patches.py b/tools/suggest_patches.py index 51d91d85a1..46995b9379 100644 --- a/tools/suggest_patches.py +++ b/tools/suggest_patches.py @@ -38,6 +38,19 @@ def command_line(arguments): return subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments) +def conversion_options(args, patch): + options = ["--patches", str(patch)] + if args.skip_sce_module: + options.append("--skip-sce-module") + if args.sce_module_path: + options.extend(("--sce-module-path", str(args.sce_module_path))) + for module_dir in args.module_dir: + options.extend(("--module-dir", module_dir)) + for executable in args.executable: + options.extend(("--executable", executable)) + return command_line(options) + + def load_catalog(directory, title, version): data = json.loads(confined(directory, "index.json").read_text(encoding="utf-8")) if not isinstance(data, dict) or set(data) != {"format", "entries"} or type(data["format"]) is not int or data["format"] != 1 or not isinstance(data["entries"], list): @@ -79,6 +92,7 @@ def main(argv=None): parser.add_argument("--executable", action="append", default=[], help="additional title executable relative to the module parent; repeatable") parser.add_argument("--skip-sce-module", action="store_true", help="skip bundled modules during verification") parser.add_argument("--timeout-seconds", type=int, default=120, help="maximum seconds per relinker verification (default: 120)") + parser.add_argument("--list-verified", action="store_true", help="list every verified catalog entry without choosing one") args = parser.parse_args(argv) try: metadata = json.loads(args.param_json.read_text(encoding="utf-8")) @@ -102,7 +116,8 @@ def main(argv=None): matches = [] entries = load_catalog(args.catalog.resolve(), title, version) if not entries: - print(f"No recommendation: no catalog entry for {title} {version}.") + prefix = "No verified catalog entries" if args.list_verified else "No recommendation" + print(f"{prefix}: no catalog entry for {title} {version}.") return 1 relinker_errors = False for number, entry in entries: @@ -157,29 +172,30 @@ def main(argv=None): print(f"{label}: rejected: relinker did not confirm every patch") continue matches.append((number, entry, patch, targets)) + if args.list_verified and matches: + print(f"Verified catalog entries: {len(matches)}") + for number, entry, patch, targets in matches: + print(f"Catalog entry {number}: {patch}") + for name, digest in targets.items(): + print(f" {name} SHA-256 {digest}") + print(f"Use these options when converting:\n {conversion_options(args, patch)}") + return 0 if len(matches) == 1: number, entry, patch, targets = matches[0] print(f"Recommended: {patch} (catalog entry {number})") for name, digest in targets.items(): print(f" {name} SHA-256 {digest}") - conversion = ["--patches", str(patch)] - if args.skip_sce_module: - conversion.append("--skip-sce-module") - if args.sce_module_path: - conversion.extend(("--sce-module-path", str(args.sce_module_path))) - for module_dir in args.module_dir: - conversion.extend(("--module-dir", module_dir)) - for executable in args.executable: - conversion.extend(("--executable", executable)) - print(f"Use these options when converting:\n {command_line(conversion)}") + print(f"Use these options when converting:\n {conversion_options(args, patch)}") return 0 if len(matches) > 1: print("No recommendation: multiple verified catalog entries match.") return 1 if relinker_errors: - print("No recommendation: relinker errors occurred; see catalog entries above.", file=sys.stderr) + prefix = "No verified catalog entries" if args.list_verified else "No recommendation" + print(f"{prefix}: relinker errors occurred; see catalog entries above.", file=sys.stderr) return 2 - print("No recommendation: no catalog entry passed all checks.") + prefix = "No verified catalog entries" if args.list_verified else "No recommendation" + print(f"{prefix}: no catalog entry passed all checks.") return 1 except (OSError, ValueError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: print(f"Patch advisor: {error}", file=sys.stderr) diff --git a/tools/tests/test_suggest_patches.py b/tools/tests/test_suggest_patches.py index 0da25c6f5d..5adf80b6e4 100644 --- a/tools/tests/test_suggest_patches.py +++ b/tools/tests/test_suggest_patches.py @@ -192,6 +192,70 @@ class PatchAdvisorTests(unittest.TestCase): self.assertIn("multiple verified catalog entries", output.getvalue()) self.assertEqual(run.call_count, 2) + @patch.object(suggest_patches.subprocess, "run") + def test_list_verified_shows_each_match_and_quoted_patch_path(self, run): + second = self.catalog / "second patch.toml" + second.write_bytes((self.catalog / "patch.toml").read_bytes()) + self.write_index([self.entry, dict(self.entry, patch=second.name)]) + run.return_value.returncode = 0 + run.return_value.stdout = VERIFIED + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(self.advise("--list-verified"), 0) + self.assertIn("Verified catalog entries: 2", output.getvalue()) + self.assertIn(f"Catalog entry 1: {self.catalog / 'patch.toml'}", output.getvalue()) + self.assertIn(f"Catalog entry 2: {second}", output.getvalue()) + self.assertIn(suggest_patches.command_line(["--patches", str(second), "--module-dir", "Media/Modules"]), output.getvalue()) + self.assertEqual(output.getvalue().count("SHA-256"), 2) + self.assertNotIn("Recommended:", output.getvalue()) + self.assertEqual(run.call_count, 2) + + @patch.object(suggest_patches.subprocess, "run") + def test_list_verified_single_match_preserves_conversion_options(self, run): + self.additional_executable() + run.return_value.returncode = 0 + run.return_value.stdout = VERIFIED + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(self.advise("--list-verified", "--executable", "picker/picker.bin", + "--sce-module-path", str(self.root)), 0) + expected = suggest_patches.command_line(["--patches", str(self.catalog / "patch.toml"), + "--sce-module-path", str(self.root), "--module-dir", + "Media/Modules", "--executable", "picker/picker.bin"]) + self.assertIn(expected, output.getvalue()) + self.assertIn("Verified catalog entries: 1", output.getvalue()) + self.assertNotIn("Recommended:", output.getvalue()) + + @patch.object(suggest_patches.subprocess, "run") + def test_list_verified_skips_individual_errors(self, run): + bad = dict(self.entry, patch="missing.toml") + self.write_index([bad, self.entry, self.entry]) + run.side_effect = [subprocess.CompletedProcess([], 2, "", "FAIL: invalid patch"), + subprocess.CompletedProcess([], 0, VERIFIED, "")] + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(self.advise("--list-verified"), 0) + self.assertIn("Catalog entry 1 ('missing.toml'): rejected", output.getvalue()) + self.assertIn("Catalog entry 2 ('patch.toml'): relinker error (exit 2)", output.getvalue()) + self.assertIn("Verified catalog entries: 1", output.getvalue()) + self.assertIn(f"Catalog entry 3: {self.catalog / 'patch.toml'}", output.getvalue()) + self.assertEqual(run.call_count, 2) + + @patch.object(suggest_patches.subprocess, "run") + def test_list_verified_no_match_keeps_exit_statuses(self, run): + self.write_index([]) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(self.advise("--list-verified"), 1) + self.assertIn("No verified catalog entries: no catalog entry", output.getvalue()) + run.assert_not_called() + self.write_index() + run.return_value = subprocess.CompletedProcess([], 2, "", "FAIL: invalid patch") + errors = io.StringIO() + with contextlib.redirect_stderr(errors): + self.assertEqual(self.advise("--list-verified"), 2) + self.assertIn("No verified catalog entries: relinker errors occurred", errors.getvalue()) + @patch.object(suggest_patches.subprocess, "run") def test_not_checked_patch_is_not_recommended(self, run): run.return_value.returncode = 0 From 618d6d3df6f5028d3dfce9619db2105f67effae6 Mon Sep 17 00:00:00 2001 From: WhiteAssassins <28965087+WhiteAssassins@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:55:25 -0400 Subject: [PATCH 2/4] test(tools): exclude incompatible patches from listing --- core/relinker/relinker/tests/test_patch_advisor.py | 3 +++ tools/tests/test_suggest_patches.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/core/relinker/relinker/tests/test_patch_advisor.py b/core/relinker/relinker/tests/test_patch_advisor.py index ae25bd0e96..f18d446b2e 100644 --- a/core/relinker/relinker/tests/test_patch_advisor.py +++ b/core/relinker/relinker/tests/test_patch_advisor.py @@ -46,6 +46,7 @@ def main(): assert result.returncode == 0 and "Verified catalog entries: 2" in result.stdout, (result.stdout, result.stderr) assert f"Catalog entry 1: {patch}" in result.stdout and f"Catalog entry 2: {second}" in result.stdout, (result.stdout, result.stderr) assert "Recommended:" not in result.stdout, (result.stdout, result.stderr) + assert source.read_bytes() == image broken = catalog / "broken.toml" broken.write_text(patch_text(address, expected, "broken").replace('description = "flip one bit"', r'description = "flip \"one\" bit"')) @@ -58,6 +59,8 @@ def main(): patch.write_text(patch_text(address, expected ^ 1, "wrong_byte")) result = advise(digest) assert result.returncode == 1 and "rejected by relinker" in result.stdout, (result.stdout, result.stderr) + result = advise(digest, ("patch.toml",), "--list-verified") + assert result.returncode == 1 and "rejected by relinker" in result.stdout and "Catalog entry 1:" not in result.stdout, (result.stdout, result.stderr) patch.write_text(patch_text(address, expected, "flip_bit")) result = advise(hashlib.sha256(b"other build").hexdigest()) assert result.returncode == 1 and "SHA-256 mismatch" in result.stdout, (result.stdout, result.stderr) diff --git a/tools/tests/test_suggest_patches.py b/tools/tests/test_suggest_patches.py index 5adf80b6e4..52c25bd82a 100644 --- a/tools/tests/test_suggest_patches.py +++ b/tools/tests/test_suggest_patches.py @@ -229,17 +229,21 @@ class PatchAdvisorTests(unittest.TestCase): @patch.object(suggest_patches.subprocess, "run") def test_list_verified_skips_individual_errors(self, run): bad = dict(self.entry, patch="missing.toml") - self.write_index([bad, self.entry, self.entry]) + wrong_hash = dict(self.entry, targets={"Media/Modules/module.prx": "0" * 64}) + self.write_index([bad, wrong_hash, self.entry, self.entry, self.entry]) run.side_effect = [subprocess.CompletedProcess([], 2, "", "FAIL: invalid patch"), + subprocess.CompletedProcess([], 1, "Verified 1 patches: 0 ok, 0 not-checked, 1 mismatch\n", ""), subprocess.CompletedProcess([], 0, VERIFIED, "")] output = io.StringIO() with contextlib.redirect_stdout(output): self.assertEqual(self.advise("--list-verified"), 0) self.assertIn("Catalog entry 1 ('missing.toml'): rejected", output.getvalue()) - self.assertIn("Catalog entry 2 ('patch.toml'): relinker error (exit 2)", output.getvalue()) + self.assertIn("Catalog entry 2 ('patch.toml'): rejected: Media/Modules/module.prx: SHA-256 mismatch", output.getvalue()) + self.assertIn("Catalog entry 3 ('patch.toml'): relinker error (exit 2)", output.getvalue()) + self.assertIn("Catalog entry 4 ('patch.toml'): rejected by relinker", output.getvalue()) self.assertIn("Verified catalog entries: 1", output.getvalue()) - self.assertIn(f"Catalog entry 3: {self.catalog / 'patch.toml'}", output.getvalue()) - self.assertEqual(run.call_count, 2) + self.assertIn(f"Catalog entry 5: {self.catalog / 'patch.toml'}", output.getvalue()) + self.assertEqual(run.call_count, 3) @patch.object(suggest_patches.subprocess, "run") def test_list_verified_no_match_keeps_exit_statuses(self, run): From decf5429c56b4a8f46cb7a011c86c6f4d60d755b Mon Sep 17 00:00:00 2001 From: WhiteAssassins <28965087+WhiteAssassins@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:12:00 -0400 Subject: [PATCH 3/4] test(tools): assert quoted listed patch path --- tools/tests/test_suggest_patches.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/tests/test_suggest_patches.py b/tools/tests/test_suggest_patches.py index 52c25bd82a..afb4834377 100644 --- a/tools/tests/test_suggest_patches.py +++ b/tools/tests/test_suggest_patches.py @@ -2,6 +2,7 @@ import contextlib import hashlib import io import json +import os import subprocess import sys import tempfile @@ -205,7 +206,8 @@ class PatchAdvisorTests(unittest.TestCase): self.assertIn("Verified catalog entries: 2", output.getvalue()) self.assertIn(f"Catalog entry 1: {self.catalog / 'patch.toml'}", output.getvalue()) self.assertIn(f"Catalog entry 2: {second}", output.getvalue()) - self.assertIn(suggest_patches.command_line(["--patches", str(second), "--module-dir", "Media/Modules"]), output.getvalue()) + quoted = f'"{second}"' if os.name == "nt" else f"'{second}'" + self.assertIn(f"--patches {quoted} --module-dir Media/Modules", output.getvalue()) self.assertEqual(output.getvalue().count("SHA-256"), 2) self.assertNotIn("Recommended:", output.getvalue()) self.assertEqual(run.call_count, 2) From dbe74bf422e953f24c4fe9af824e5e7b7b49adfc Mon Sep 17 00:00:00 2001 From: WhiteAssassins <28965087+WhiteAssassins@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:24:38 -0400 Subject: [PATCH 4/4] fix(tools): guide users through verified patch choices --- core/relinker/relinker/tests/test_patch_advisor.py | 3 ++- docs/user/USAGE.md | 4 ++-- tools/suggest_patches.py | 7 +++---- tools/tests/test_suggest_patches.py | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/core/relinker/relinker/tests/test_patch_advisor.py b/core/relinker/relinker/tests/test_patch_advisor.py index f18d446b2e..f31bbd882c 100644 --- a/core/relinker/relinker/tests/test_patch_advisor.py +++ b/core/relinker/relinker/tests/test_patch_advisor.py @@ -41,9 +41,10 @@ def main(): second = catalog / "second patch.toml" second.write_text(patch_text(address, expected, "flip_bit")) result = advise(digest, ("patch.toml", second.name)) - assert result.returncode == 1 and "multiple verified catalog entries" in result.stdout, (result.stdout, result.stderr) + assert result.returncode == 1 and "multiple verified catalog entries match; use --list-verified to see them" in result.stdout, (result.stdout, result.stderr) result = advise(digest, ("patch.toml", second.name), "--list-verified") assert result.returncode == 0 and "Verified catalog entries: 2" in result.stdout, (result.stdout, result.stderr) + assert "Choose one patch file; listed entries are not combined." in result.stdout, (result.stdout, result.stderr) assert f"Catalog entry 1: {patch}" in result.stdout and f"Catalog entry 2: {second}" in result.stdout, (result.stdout, result.stderr) assert "Recommended:" not in result.stdout, (result.stdout, result.stderr) assert source.read_bytes() == image diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 4e31b9602e..006353b583 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -120,9 +120,9 @@ The advisor reads `titleId` and `contentVersion` from the specified `param.json` } ``` -The advisor validates patch files only for entries matching the requested title and version. It reports and skips a malformed matching entry or a relinker verification error, so another valid entry can still be recommended. Verification times out after 120 seconds per entry by default; use `--timeout-seconds ` to change the limit. For synthetic inputs without system modules, use `--skip-sce-module` if conversion also uses it; this option conflicts with `--sce-module-path`. No matching entry or no verified patch exits `1`; invalid input or no verified patch after a relinker error exits `2`. If multiple entries verify, the advisor recommends none because it cannot choose or combine them. The tool does not fetch or apply patches. A recommendation prints `--patches` and the module and executable options to use when converting. Keep catalog patch files outside the input executable's directory to avoid automatic `relinker-patches.toml` discovery. +The advisor validates patch files only for entries matching the requested title and version. It reports and skips a malformed matching entry or a relinker verification error, so another valid entry can still be recommended. Verification times out after 120 seconds per entry by default; use `--timeout-seconds ` to change the limit. For synthetic inputs without system modules, use `--skip-sce-module` if conversion also uses it; this option conflicts with `--sce-module-path`. No matching entry or no verified patch exits `1`; invalid input or no verified patch after a relinker error exits `2`. If multiple entries verify, the advisor recommends none because it cannot choose or combine them, and points to `--list-verified` to inspect the matches. The tool does not fetch or apply patches. A recommendation prints `--patches` and the module and executable options to use when converting. Keep catalog patch files outside the input executable's directory to avoid automatic `relinker-patches.toml` discovery. -With `--list-verified`, the advisor prints every entry that passes metadata, hash and relinker checks, in catalog order. Each result includes its catalog number, patch file, target hashes and conversion options. It exits `0` when at least one entry verifies, even if another entry was rejected; with no verified entries, the existing `1` or `2` exit status still applies. Without this option, a single verified entry is recommended and multiple verified entries remain ambiguous. Listing entries does not combine patch files or apply them during conversion; choose a patch explicitly after reviewing it. +With `--list-verified`, the advisor prints every entry that passes metadata, hash and relinker checks, in catalog order. Each result includes its catalog number, patch file, target hashes and conversion options. It exits `0` when at least one entry verifies, even if another entry was rejected; with no verified entries, the existing `1` or `2` exit status still applies. Without this option, a single verified entry is recommended and multiple verified entries remain ambiguous. The listing tells users to choose one patch file. It does not combine patch files or apply them during conversion; review the chosen patch before applying it. The target hashes identify the exact unwrapped files changed by a patch, which might not include the main executable. Different dump tools could produce different bytes for the same title version; the advisor then rejects a patch even if it would work. It has not been confirmed that all dump tools produce identical files. The hashes do not authenticate a catalog or establish that a patch is safe or that the title is fully playable. Use catalogs from sources you trust and review the patch before applying it. diff --git a/tools/suggest_patches.py b/tools/suggest_patches.py index 46995b9379..9e59781af8 100644 --- a/tools/suggest_patches.py +++ b/tools/suggest_patches.py @@ -94,6 +94,7 @@ def main(argv=None): parser.add_argument("--timeout-seconds", type=int, default=120, help="maximum seconds per relinker verification (default: 120)") parser.add_argument("--list-verified", action="store_true", help="list every verified catalog entry without choosing one") args = parser.parse_args(argv) + prefix = "No verified catalog entries" if args.list_verified else "No recommendation" try: metadata = json.loads(args.param_json.read_text(encoding="utf-8")) if not isinstance(metadata, dict): @@ -116,7 +117,6 @@ def main(argv=None): matches = [] entries = load_catalog(args.catalog.resolve(), title, version) if not entries: - prefix = "No verified catalog entries" if args.list_verified else "No recommendation" print(f"{prefix}: no catalog entry for {title} {version}.") return 1 relinker_errors = False @@ -174,6 +174,7 @@ def main(argv=None): matches.append((number, entry, patch, targets)) if args.list_verified and matches: print(f"Verified catalog entries: {len(matches)}") + print("Choose one patch file; listed entries are not combined.") for number, entry, patch, targets in matches: print(f"Catalog entry {number}: {patch}") for name, digest in targets.items(): @@ -188,13 +189,11 @@ def main(argv=None): print(f"Use these options when converting:\n {conversion_options(args, patch)}") return 0 if len(matches) > 1: - print("No recommendation: multiple verified catalog entries match.") + print("No recommendation: multiple verified catalog entries match; use --list-verified to see them.") return 1 if relinker_errors: - prefix = "No verified catalog entries" if args.list_verified else "No recommendation" print(f"{prefix}: relinker errors occurred; see catalog entries above.", file=sys.stderr) return 2 - prefix = "No verified catalog entries" if args.list_verified else "No recommendation" print(f"{prefix}: no catalog entry passed all checks.") return 1 except (OSError, ValueError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error: diff --git a/tools/tests/test_suggest_patches.py b/tools/tests/test_suggest_patches.py index afb4834377..cc6709280d 100644 --- a/tools/tests/test_suggest_patches.py +++ b/tools/tests/test_suggest_patches.py @@ -190,7 +190,7 @@ class PatchAdvisorTests(unittest.TestCase): output = io.StringIO() with contextlib.redirect_stdout(output): self.assertEqual(self.advise(), 1) - self.assertIn("multiple verified catalog entries", output.getvalue()) + self.assertIn("multiple verified catalog entries match; use --list-verified to see them.", output.getvalue()) self.assertEqual(run.call_count, 2) @patch.object(suggest_patches.subprocess, "run") @@ -204,6 +204,7 @@ class PatchAdvisorTests(unittest.TestCase): with contextlib.redirect_stdout(output): self.assertEqual(self.advise("--list-verified"), 0) self.assertIn("Verified catalog entries: 2", output.getvalue()) + self.assertIn("Choose one patch file; listed entries are not combined.", output.getvalue()) self.assertIn(f"Catalog entry 1: {self.catalog / 'patch.toml'}", output.getvalue()) self.assertIn(f"Catalog entry 2: {second}", output.getvalue()) quoted = f'"{second}"' if os.name == "nt" else f"'{second}'" @@ -226,6 +227,7 @@ class PatchAdvisorTests(unittest.TestCase): "Media/Modules", "--executable", "picker/picker.bin"]) self.assertIn(expected, output.getvalue()) self.assertIn("Verified catalog entries: 1", output.getvalue()) + self.assertIn("Choose one patch file; listed entries are not combined.", output.getvalue()) self.assertNotIn("Recommended:", output.getvalue()) @patch.object(suggest_patches.subprocess, "run")