diff --git a/include/update/host_abi_deriver.h b/include/update/host_abi_deriver.h index 324fb71..ef6597c 100644 --- a/include/update/host_abi_deriver.h +++ b/include/update/host_abi_deriver.h @@ -11,6 +11,10 @@ struct HostAbiDerivationOptions { std::filesystem::path reference_library; std::filesystem::path reference_profile; std::vector reference_compatibility_manifests; + // Optional libroblox.so of another build that the compatibility manifests + // describe. It supplies the FMOD output-device anchors when the manifests + // have none for the reference build. + std::filesystem::path runtime_fallback_library; std::filesystem::path candidate_payload_directory; std::filesystem::path output_directory; }; diff --git a/scripts/derive_roblox_host_abi_profile.py b/scripts/derive_roblox_host_abi_profile.py index d1a8389..d228a00 100755 --- a/scripts/derive_roblox_host_abi_profile.py +++ b/scripts/derive_roblox_host_abi_profile.py @@ -23,6 +23,7 @@ supported. from __future__ import annotations import argparse +import contextlib from dataclasses import dataclass import hashlib import json @@ -1598,6 +1599,7 @@ def derive_runtime_compatibility( reference: ElfImage, candidate: ElfImage, manifests: Sequence[Path], + fallback: ElfImage | None = None, ) -> dict[str, Any]: source = load_reference_runtime_profile(manifests, reference.build_id) result: dict[str, Any] = {} @@ -1620,9 +1622,38 @@ def derive_runtime_compatibility( result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge( reference, candidate, fmod ) + elif fallback is not None: + bridge = derive_fallback_fmod_output_device_bridge( + fallback, candidate, manifests + ) + if bridge is not None: + result["fmod_output_device_bridge"] = bridge return result +def derive_fallback_fmod_output_device_bridge( + fallback: ElfImage, + candidate: ElfImage, + manifests: Sequence[Path], +) -> dict[str, Any] | None: + """Derive the optional FMOD bridge from another build in the manifests. + + Any failure leaves the bridge out. + """ + if fallback.build_id == candidate.build_id: + return None + try: + fallback.validate_toolchain() + fmod = load_reference_runtime_profile(manifests, fallback.build_id).get( + "fmod_output_device_bridge" + ) + if fmod is None: + return None + return derive_fmod_output_device_bridge(fallback, candidate, fmod) + except AnalyzerError: + return None + + def find_registry_function_entry( candidate: ElfImage, body_rva: int, @@ -2549,13 +2580,22 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An metadata_path = Path(arguments.payload_metadata) sidecar_path = Path(arguments.reference_profile) reference_sidecar = validated_sidecar(sidecar_path) - with ElfImage(reference_path) as reference, ElfImage(candidate_path) as candidate: + with contextlib.ExitStack() as stack: + reference = stack.enter_context(ElfImage(reference_path)) + candidate = stack.enter_context(ElfImage(candidate_path)) + fallback = None + if arguments.runtime_fallback_lib is not None: + with contextlib.suppress(AnalyzerError): + fallback = stack.enter_context( + ElfImage(Path(arguments.runtime_fallback_lib)) + ) metadata = validated_payload_metadata(metadata_path, candidate_path, candidate) profile, anchors = derive_profile(reference, candidate, reference_sidecar) runtime_compatibility = derive_runtime_compatibility( reference, candidate, tuple(Path(path) for path in arguments.reference_compatibility), + fallback, ) return output_documents( candidate, @@ -2580,6 +2620,12 @@ def parse_arguments(argv: Sequence[str]) -> argparse.Namespace: metavar="MANIFEST", help="compatibility manifest containing exact reference runtime anchors", ) + parser.add_argument( + "--runtime-fallback-lib", + metavar="LIB", + help="libroblox.so of another build in the compatibility manifests; " + "supplies the FMOD output-device anchors when the reference has none", + ) parser.add_argument("--candidate-lib", required=True, metavar="LIB") parser.add_argument("--payload-metadata", required=True, metavar="META") parser.add_argument("--output", required=True, metavar="PROFILE") diff --git a/src/update/host_abi_deriver.cc b/src/update/host_abi_deriver.cc index b421685..397c954 100644 --- a/src/update/host_abi_deriver.cc +++ b/src/update/host_abi_deriver.cc @@ -2563,6 +2563,35 @@ std::optional> DeriveDocuments(const ElfImage& reference, return std::pair{std::move(derived), std::move(compatibility)}; } +// Derives the optional FMOD output-device bridge from `library`, another build +// the manifests describe. Any failure leaves the manifest without the bridge. +void AddFallbackFmodBridge(const std::filesystem::path& library, + const std::vector& manifests, + const ElfImage& candidate, Json* compatibility) { + std::string error; + ElfImage fallback; + if (!fallback.Open(library, &error) || + fallback.build_id() == candidate.build_id() || + !fallback.ValidateToolchain(&error)) { + return; + } + const auto anchors = + LoadRuntimeCompatibilityAnchors(manifests, fallback.build_id(), &error); + if (!anchors.has_value() || !anchors->fmod.has_value()) return; + Disassembler disassembler; + if (!disassembler.Open(&error)) return; + RuntimeCompatibilityAnchors source; + source.fmod = anchors->fmod; + const auto derived = DeriveRuntimeCompatibility(fallback, candidate, + disassembler, source, &error); + if (!derived.has_value() || + !derived->contains("fmod_output_device_bridge")) { + return; + } + (*compatibility)["profiles"][0]["fmod_output_device_bridge"] = + (*derived)["fmod_output_device_bridge"]; +} + } // namespace HostAbiSidecarIdentity ReadHostAbiSidecarIdentity( @@ -2620,10 +2649,15 @@ HostAbiDerivationResult DeriveHostAbiProfile( options.reference_compatibility_manifests, reference.build_id(), &result.error); if (!runtime_source.has_value()) return result; - const auto documents = DeriveDocuments(reference, candidate, *sidecar, - *metadata, *runtime_source, - &result.error); + auto documents = DeriveDocuments(reference, candidate, *sidecar, *metadata, + *runtime_source, &result.error); if (!documents.has_value()) return result; + if (!runtime_source->fmod.has_value() && + !options.runtime_fallback_library.empty()) { + AddFallbackFmodBridge(options.runtime_fallback_library, + options.reference_compatibility_manifests, candidate, + &documents->second); + } result.profile = options.output_directory / "host_abi_profile.json"; result.compatibility_manifest = options.output_directory / "compatibility.json"; diff --git a/src/update/update_coordinator.cc b/src/update/update_coordinator.cc index e9fd6f1..8c3cbb2 100644 --- a/src/update/update_coordinator.cc +++ b/src/update/update_coordinator.cc @@ -301,6 +301,9 @@ struct ReferenceProfile { std::filesystem::path library; std::filesystem::path profile; std::vector compatibility_manifests; + // libroblox.so of the shipped reference payload when the reference is a + // different, machine-derived payload. Empty when it is not staged. + std::filesystem::path runtime_fallback_library; std::string error; explicit operator bool() const { @@ -367,6 +370,23 @@ ReferenceProfile ResolveReference( paths.compatibility_manifest)) { result.compatibility_manifests.push_back(paths.compatibility_manifest); } + // A machine-derived profile can lack the optional FMOD output-device + // anchors. The shipped reference payload stays staged and has them. + const std::optional shipped = + ResolveReferenceProfile(paths.host_abi_reference_profile, profiles, + nullptr); + if (shipped.has_value()) { + const std::string shipped_id = std::to_string(shipped->version_code) + + "-" + shipped->elf_build_id; + const std::filesystem::path shipped_directory = + paths.data_root / "payloads" / shipped_id; + const PayloadIntegrityResult shipped_payload = + InspectPreparedPayload(shipped_directory); + if (shipped_id != installed.payload_id && shipped_payload && + shipped_payload.payload_id == shipped_id) { + result.runtime_fallback_library = shipped_directory / "libroblox.so"; + } + } return result; } } @@ -475,6 +495,7 @@ UpdateResult RunUnsafeLatest( derivation.reference_profile = reference.profile; derivation.reference_compatibility_manifests = reference.compatibility_manifests; + derivation.runtime_fallback_library = reference.runtime_fallback_library; derivation.candidate_payload_directory = candidate.staged.payload_directory; derivation.output_directory = workspace / "derived"; Progress(request.progress_fd, "Deriving latest Roblox compatibility..."); @@ -853,6 +874,8 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { derivation.reference_profile = reference.profile; derivation.reference_compatibility_manifests = reference.compatibility_manifests; + derivation.runtime_fallback_library = + reference.runtime_fallback_library; derivation.candidate_payload_directory = candidate.staged.payload_directory; derivation.output_directory = diff --git a/tests/derive_roblox_host_abi_profile_test.py b/tests/derive_roblox_host_abi_profile_test.py index 7765b9d..56de842 100644 --- a/tests/derive_roblox_host_abi_profile_test.py +++ b/tests/derive_roblox_host_abi_profile_test.py @@ -50,6 +50,10 @@ LATEST_CANDIDATE_METADATA = ( LATEST_REFERENCE_PROFILE = REFERENCE_PROFILE_PATH DEVICE_LISTS_PAYLOAD_ID = "3092-5f0704edd9064f566ee3d6df2bd2fabbcc709f03" DEVICE_LISTS_LIBRARY = PAYLOAD_ROOT / DEVICE_LISTS_PAYLOAD_ID / "libroblox.so" +FALLBACK_CANDIDATE_PAYLOAD_ID = "3222-a67a91ef96c700c0730acd36a250c2f93cfafbc1" +FALLBACK_CANDIDATE_LIBRARY = ( + PAYLOAD_ROOT / FALLBACK_CANDIDATE_PAYLOAD_ID / "libroblox.so" +) def load_analyzer(): @@ -799,5 +803,38 @@ class DeviceListsRuntimeCompatibilityAcceptanceTest(unittest.TestCase): self.assertEqual(derived["fmod_output_device_bridge"], self.EXPECTED_BRIDGE) +@unittest.skipUnless( + ANALYZER.capstone is not None + and CANDIDATE_LIBRARY.is_file() + and DEVICE_LISTS_LIBRARY.is_file() + and FALLBACK_CANDIDATE_LIBRARY.is_file(), + "local exact 2998, 3092 and 3222 payloads are unavailable", +) +class FallbackRuntimeCompatibilityAcceptanceTest(unittest.TestCase): + MANIFESTS = (PROJECT_ROOT / "config" / "roblox_compatibility.json",) + + def test_fallback_library_supplies_missing_fmod_bridge(self): + # The shipped manifest describes 2998 but not 3092. + with ANALYZER.ElfImage(DEVICE_LISTS_LIBRARY) as reference, ANALYZER.ElfImage( + FALLBACK_CANDIDATE_LIBRARY + ) as candidate, ANALYZER.ElfImage(CANDIDATE_LIBRARY) as fallback: + without = ANALYZER.derive_runtime_compatibility( + reference, candidate, self.MANIFESTS) + derived = ANALYZER.derive_runtime_compatibility( + reference, candidate, self.MANIFESTS, fallback) + self.assertNotIn("fmod_output_device_bridge", without) + bridge = derived["fmod_output_device_bridge"] + self.assertEqual(bridge["vtable_layout_version"], 2) + self.assertIn("input_count_method_rva", bridge) + + def test_fallback_without_anchors_leaves_bridge_out(self): + with ANALYZER.ElfImage(DEVICE_LISTS_LIBRARY) as reference, ANALYZER.ElfImage( + FALLBACK_CANDIDATE_LIBRARY + ) as candidate: + derived = ANALYZER.derive_runtime_compatibility( + reference, candidate, self.MANIFESTS, reference) + self.assertNotIn("fmod_output_device_bridge", derived) + + if __name__ == "__main__": unittest.main() diff --git a/tests/native_update_test.cc b/tests/native_update_test.cc index a145c14..5f2f89c 100644 --- a/tests/native_update_test.cc +++ b/tests/native_update_test.cc @@ -2112,5 +2112,71 @@ TEST(PayloadStoreTest, RequiresTwoRuntimeBoundCanariesForLatestCandidate) { metadata["sha256"]["libroblox"]); } +// Needs real Roblox payloads. MOCKTAIL_TEST_UPDATE_DATA_ROOT is an updater data +// root whose derived 3092 profile has no FMOD output-device bridge. +TEST(HostAbiDeriverRealPayloadTest, FallbackLibrarySuppliesMissingFmodBridge) { + const char* data_root = std::getenv("MOCKTAIL_TEST_UPDATE_DATA_ROOT"); + if (data_root == nullptr) { + GTEST_SKIP() << "real Roblox payloads were not explicitly provided"; + } + const std::filesystem::path root = data_root; + const std::string reference_id = + "3092-5f0704edd9064f566ee3d6df2bd2fabbcc709f03"; + // Derived profiles are named -.json. + std::string installed_profile; + std::error_code scan_error; + for (const auto& entry : std::filesystem::directory_iterator( + root / "host_abi_profiles", scan_error)) { + const std::string name = entry.path().filename().string(); + if (name.rfind(reference_id + "-", 0) == 0 && + std::filesystem::exists(root / "compatibility_profiles" / name)) { + installed_profile = name; + break; + } + } + ASSERT_FALSE(installed_profile.empty()); + char pattern[] = "/tmp/mocktail_fmod_fallback_XXXXXX"; + const char* created = mkdtemp(pattern); + ASSERT_NE(created, nullptr); + const std::filesystem::path output = created; + + const auto derive = [&](const std::filesystem::path& directory, + const std::filesystem::path& fallback) { + HostAbiDerivationOptions options; + options.reference_library = + root / "payloads" / reference_id / "libroblox.so"; + options.reference_profile = root / "host_abi_profiles" / installed_profile; + options.reference_compatibility_manifests = { + root / "compatibility_profiles" / installed_profile, + std::filesystem::path(MOCKTAIL_TEST_SOURCE_DIR) / + "config/roblox_compatibility.json"}; + options.runtime_fallback_library = fallback; + options.candidate_payload_directory = + root / "payloads/3222-a67a91ef96c700c0730acd36a250c2f93cfafbc1"; + options.output_directory = directory; + const HostAbiDerivationResult derived = DeriveHostAbiProfile(options); + EXPECT_TRUE(derived) << derived.error; + std::ifstream input(derived.compatibility_manifest); + const nlohmann::json manifest = + nlohmann::json::parse(input, nullptr, false); + return manifest.is_object() ? manifest["profiles"][0] : nlohmann::json(); + }; + + const nlohmann::json without = derive(output / "without", {}); + EXPECT_FALSE(without.contains("fmod_output_device_bridge")); + + const nlohmann::json with = derive( + output / "with", + root / "payloads/2998-ade08266c67aee88ec9c1d00902150e1684dad3a/" + "libroblox.so"); + ASSERT_TRUE(with.contains("fmod_output_device_bridge")); + EXPECT_TRUE( + with["fmod_output_device_bridge"].contains("input_count_method_rva")); + EXPECT_EQ(with["fmod_output_device_bridge"]["vtable_layout_version"], 2); + + std::error_code filesystem_error; + std::filesystem::remove_all(output, filesystem_error); +} + } // namespace } // namespace mocktail::update