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")