diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 18996014a7..787714a154 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -250,6 +250,8 @@ Each registry has a companion with the same prefix and the suffix `.needed.json` The report also lists needed libraries that have no file in `--libs` or `--modules`, including dependencies with zero imports. These do not increase the import or reference counts. Windows can skip a missing dependency when no import needs it; Linux still needs the file. The audit also lists imports exported only by a library other than the one they name; those resolve on Linux and can fail on Windows. +The audit also walks the built libraries' own host dependencies: each `.prx` under `--libs` has its PE imports (or ELF `DT_NEEDED` entries) checked for `.prx` names with no file in `--libs` or `--modules`, following present libraries transitively. Gaps fail the audit and are reported as `Importer: Chain: -> ... -> ` lines, since the loader fails on them even when every game import resolves. + | Option | Effect | |--------|--------| | `--libs ` | Built `.prx` directory. Required; repeat for several. | diff --git a/tools/import_audit.py b/tools/import_audit.py index 1659810ae5..549317a6cf 100644 --- a/tools/import_audit.py +++ b/tools/import_audit.py @@ -220,7 +220,7 @@ def elf_imports(path, data): return symbols, needed -def pe_exports(path, data): +def pe_headers(path, data): header = unpack("6} imports {summary['references_by_class'][kind]:>6} references") @@ -587,6 +669,10 @@ def render(summary, records, missing, stops): lines.extend(f" {library}: {count} imports" for library, count in missing.items()) if any(count == 0 for count in missing.values()): lines.append(" Libraries with 0 imports are declared dependencies; Windows can skip them if no import needs them") + if missing_hosts: + lines.append("") + lines.append(f"Host dependencies with no file in --libs or --modules ({len(missing_hosts)})") + lines.extend(f" Importer: {entry['importer']} Chain: {' -> '.join(entry['chain'])}" for entry in missing_hosts) if summary["library_mismatch"]: lines.append("") lines.append(f"{summary['library_mismatch']} imports are exported only by a library other than the one they name: they resolve on Linux and can fail on Windows") @@ -618,11 +704,13 @@ def main(argv=None): imports = [entry for _, entries in registries for entry in entries] needed = sorted({library for path in dependency_registries for library in read_needed_registry(path)}) exports, libraries = built_libraries(args.libs) + host_dependencies = built_host_dependencies(args.libs) module_imports, module_exports = read_modules(args.modules) records, missing = audit(imports, exports, source_stubs(args.source), libraries, set(module_imports), module_imports, module_exports, needed_libraries=needed) + missing_hosts = audit_host_dependencies(host_dependencies, libraries, set(module_imports)) if args.names: attach_names(records, load_names(args.names)) - summary = summarize(records, sum(record["references"] for record in records), missing) + summary = summarize(records, sum(record["references"] for record in records), missing, missing_hosts) stops = [{"registry": str(path), "stop": current_stop(entries, records)} for path, entries in registries] if args.json: commit, dirty = git_stamp(Path(args.source)) @@ -638,8 +726,8 @@ def main(argv=None): except OSError as error: print(f"FAIL: {error.strerror}: {error.filename}" if error.filename else f"FAIL: {error}", file=sys.stderr) return 2 - print(render(summary, records, missing, stops)) - return 1 if summary["unique_by_class"]["absent"] or missing or (args.strict_providers and summary["library_mismatch"]) else 0 + print(render(summary, records, missing, stops, missing_hosts)) + return 1 if summary["unique_by_class"]["absent"] or missing or missing_hosts or (args.strict_providers and summary["library_mismatch"]) else 0 if __name__ == "__main__": diff --git a/tools/tests/test_import_audit.py b/tools/tests/test_import_audit.py index 3441ae3f73..9e6bf68d9b 100644 --- a/tools/tests/test_import_audit.py +++ b/tools/tests/test_import_audit.py @@ -136,6 +136,39 @@ def build_pe(names): return bytes(image) + raw + b"\xc3" * (16 * max(1, len(names))) +def build_pe_with_imports(dlls, names=()): + idata_rva, edata_rva, text_rva, raw_offset = 0x1000, 0x2000, 0x3000, 0x200 + directory, strings = b"", b"" + for dll in dlls: + directory += struct.pack(" libSceIme.prx", out) + self.assertIn("Importer: libSceVideoOut.prx Chain: libSceVideoOut.prx -> libSceKeyboard.prx", out) + self.assertNotIn("KERNEL32", out) + + def test_transitive_host_dependency_reports_chain(self): + work = self.work + (work.libs / "libSceA.prx").write_bytes(build_pe_with_imports(["libSceB.prx"], ["AAAAAAAAAAA"])) + (work.libs / "libSceB.prx").write_bytes(build_pe_with_imports(["libSceMissing.prx"], ["BBBBBBBBBBB"])) + registry = work.root / "game.json" + write_registry(registry, []) + result = work.root / "audit.json" + code, out, err = work.run(registry, "--json", str(result)) + self.assertEqual((code, err), (1, "")) + data = json.loads(result.read_text()) + self.assertEqual(data["missing_host_dependencies"], [ + {"importer": "libSceA.prx", "chain": ["libSceA.prx", "libSceB.prx", "libSceMissing.prx"]}, + {"importer": "libSceB.prx", "chain": ["libSceB.prx", "libSceMissing.prx"]}, + ]) + self.assertIn("Importer: libSceA.prx Chain: libSceA.prx -> libSceB.prx -> libSceMissing.prx", out) + + def test_host_dependency_satisfied_by_module(self): + work = self.work + (work.libs / "libSceA.prx").write_bytes(build_pe_with_imports(["libSceIme.prx"], ["AAAAAAAAAAA"])) + (work.modules / "libSceIme.prx").write_bytes(exporting()) + registry = work.root / "game.json" + write_registry(registry, []) + code, out, err = work.run(registry) + self.assertEqual((code, err), (0, "")) + self.assertNotIn("Host dependencies", out) + + def test_complete_host_dependencies_pass(self): + work = self.work + (work.libs / "libc.prx").write_bytes(build_pe_with_imports([], ["AAAAAAAAAAA"])) + (work.libs / "libSceVideoOut.prx").write_bytes(build_pe_with_imports(["libc.prx"], ["BBBBBBBBBBB"])) + registry = work.root / "game.json" + write_registry(registry, []) + code, out, err = work.run(registry) + self.assertEqual((code, err), (0, "")) + self.assertNotIn("Host dependencies", out) + + def test_elf_host_dependencies_are_walked(self): + work = self.work + (work.libs / "libSceA.prx").write_bytes(build_elf( + [("AAAAAAAAAAA", 1, GLOBAL_FUNCTION)], ["libSceGhost.prx", "KERNEL32.dll"])) + registry = work.root / "game.json" + write_registry(registry, []) + result = work.root / "audit.json" + code, out, err = work.run(registry, "--json", str(result)) + self.assertEqual((code, err), (1, "")) + data = json.loads(result.read_text()) + self.assertEqual(data["missing_host_dependencies"], [ + {"importer": "libSceA.prx", "chain": ["libSceA.prx", "libSceGhost.prx"]}, + ]) + self.assertNotIn("KERNEL32", out) + + def test_conflicting_host_libraries_are_ambiguous(self): + first, second = self.work.libs, self.work.root / "other-libs" + second.mkdir() + (first / "libSceA.prx").write_bytes(build_pe_with_imports(["libSceB.prx"], ["AAAAAAAAAAA"])) + (second / "libSceA.prx").write_bytes(build_pe_with_imports([], ["AAAAAAAAAAA"])) + with self.assertRaisesRegex(import_audit.AuditError, "ambiguous"): + import_audit.built_host_dependencies([first, second]) + def test_elf_unnamed_defined_symbol_is_not_an_export(self): image = bytearray(exporting("alpha"))