diff --git a/LICENSE b/LICENSE index d645695..d5cf3b8 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 komaruworld Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index f86814e..e70f861 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := all -.PHONY: all build release debug test apk install register-url-handler release-runtime portable portable-test standalone appimage flatpak run-flatpak run run-smoke run-unlimited run-game run-input run-resize run-network auto-run run-gles run-angle update-roblox update-auto update-auto-launch install-updater payload-status payload-rollback support-bundle clean submodules help +.PHONY: all build release debug test apk install register-url-handler release-runtime portable portable-test standalone appimage flatpak run-flatpak run run-smoke run-unlimited run-game run-input run-resize run-network auto-run run-gles run-angle update-roblox update-auto update-auto-launch payload-status payload-rollback support-bundle clean submodules help BUILD_DIR := build BINARY := $(BUILD_DIR)/mocktail @@ -141,9 +141,6 @@ update-auto-launch: build ## Update Roblox and launch the verified current paylo @./$(BUILD_DIR)/mocktail_updater update @./$(BINARY) -install-updater: ## Install and enable the six-hour systemd user update timer - @./scripts/install_user_updater.sh - payload-status: build ## Show current and previous-good immutable payloads @./$(BUILD_DIR)/mocktail_updater status diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index 416fd33..0c54038 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -112,6 +112,11 @@ updates: # backend, and promote only on success. An existing current payload is # preserved when probation fails. automatic: true + # Boolean (default: true): testing track. Always try provider latest, even + # when it has no verified payload profile, and do not download or activate + # an older supported payload in its place. Exact HostAbi derivation and two + # canaries are still required before latest can become current. + testing_latest_only: true # String (default: apk-pure): direct x86_64 APK provider. source: apk-pure # Reserved for desktop update integrations. `mocktail_updater` itself never diff --git a/config/systemd/mocktail-update.service b/config/systemd/mocktail-update.service deleted file mode 100644 index 585d45e..0000000 --- a/config/systemd/mocktail-update.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Check, canary, and install the latest working Roblox for Mocktail -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -EnvironmentFile=%h/.config/mocktail/updater.env -ExecStart=/usr/bin/env ${MOCKTAIL_UPDATE_HELPER} update -Nice=10 -IOSchedulingClass=best-effort -IOSchedulingPriority=7 - -[Install] -WantedBy=default.target diff --git a/config/systemd/mocktail-update.timer b/config/systemd/mocktail-update.timer deleted file mode 100644 index 90e6d05..0000000 --- a/config/systemd/mocktail-update.timer +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Periodically check Roblox updates for Mocktail - -[Timer] -OnBootSec=5m -OnUnitActiveSec=6h -RandomizedDelaySec=15m -Persistent=true -Unit=mocktail-update.service - -[Install] -WantedBy=timers.target diff --git a/include/runtime/memory_limit.h b/include/runtime/memory_limit.h index 5440933..25bbc76 100644 --- a/include/runtime/memory_limit.h +++ b/include/runtime/memory_limit.h @@ -53,10 +53,8 @@ struct CgroupMemoryLimitResult { bool active() const { return status == CgroupMemoryLimitStatus::kActive; } }; -// Replaces the current process with the same command inside a transient -// systemd user scope when cgroup v2 delegation is available. A successful -// re-exec does not return. Systems without systemd continue with the watchdog -// fallback and receive kUnavailable. +// Compatibility hook for the former cgroup re-exec path. Automatic cgroup +// scope creation is disabled, so callers continue with the watchdog fallback. CgroupMemoryLimitResult MaybeReexecWithCgroupMemoryLimit( int argc, char* const argv[], std::uint64_t limit_bytes, const std::vector* reexec_arguments = nullptr); diff --git a/include/update/update_config.h b/include/update/update_config.h index a9bcc68..f336067 100644 --- a/include/update/update_config.h +++ b/include/update/update_config.h @@ -22,6 +22,7 @@ namespace mocktail::update { struct UpdateConfig { bool automatic = true; + bool testing_latest_only = true; bool launch_after_update = false; std::string source = "apk-pure"; }; diff --git a/scripts/auto_update_roblox.sh b/scripts/auto_update_roblox.sh index 869b85d..9e2773d 100755 --- a/scripts/auto_update_roblox.sh +++ b/scripts/auto_update_roblox.sh @@ -86,6 +86,7 @@ LAUNCH="${MOCKTAIL_UPDATE_LAUNCH:-false}" SKIP_BUILD=false SCHEDULED=false STARTUP_PREFLIGHT=false +TESTING_LATEST_ONLY=true UPDATE_CONFIG_JSON="{}" STAGED_PAYLOAD_ID="" DOWNLOAD_STAGING_ROOT="${CACHE_ROOT}/downloads/apk-staging" @@ -113,7 +114,8 @@ Options: --skip-build Reuse the existing Release binary for the canary. --scheduled Run only when updates.automatic is true in YAML. --startup-preflight Validate a runnable local payload without checking for - remote updates; provision one when none is available. + remote updates, unless updates.testing_latest_only is + enabled; provision one when none is available. -h, --help Show this help. EOF } @@ -188,6 +190,8 @@ ParseArguments() { LoadYamlDefaults() { UPDATE_CONFIG_JSON="$(python3 "${SCRIPT_DIR}/read_update_config.py" "${CONFIG_FILE}")" [[ -n "${SOURCE}" ]] || SOURCE="$(jq -r '.source // "apk-pure"' <<<"${UPDATE_CONFIG_JSON}")" + TESTING_LATEST_ONLY="$(jq -r '.testing_latest_only // true' \ + <<<"${UPDATE_CONFIG_JSON}")" if [[ "${LAUNCH}" != true && "$(jq -r '.launch_after_update // false' <<<"${UPDATE_CONFIG_JSON}")" == true ]]; then LAUNCH=true @@ -562,6 +566,9 @@ SkipDownloadWhenCurrent() { Log "latest-version API check failed; preserving the runnable local payload" return 0 fi + if [[ "${TESTING_LATEST_ONLY}" == true ]]; then + Die "updates.testing_latest_only requires available provider latest metadata" + fi Log "latest-version API check failed and no runnable local payload exists; using the compatibility bootstrap profile" SelectSupportedBootstrapFallback return 1 @@ -572,6 +579,9 @@ SkipDownloadWhenCurrent() { Log "latest-version API returned invalid metadata; preserving the runnable local payload" return 0 fi + if [[ "${TESTING_LATEST_ONLY}" == true ]]; then + Die "updates.testing_latest_only requires valid provider latest metadata" + fi Log "provider returned invalid version metadata and no runnable local payload exists; using the compatibility bootstrap profile" SelectSupportedBootstrapFallback return 1 @@ -605,6 +615,11 @@ SkipRemoteUpdateForStartup() { Die "current payload is not runnable by this Mocktail runtime and rollback failed" fi + if [[ "${TESTING_LATEST_ONLY}" == true ]]; then + Log "testing latest-only track enabled; checking provider latest during startup" + return 1 + fi + local current_payload_id current_payload_id="$(VerifiedCurrentPayloadId 2>/dev/null || true)" if [[ "${current_payload_id}" =~ ^[0-9]+-[0-9a-f]{40}$ ]]; then @@ -1551,6 +1566,9 @@ Main() { LoadYamlDefaults ParseArguments "$@" [[ "${SOURCE}" == apk-pure ]] || Die "update source must be apk-pure" + if [[ "${TESTING_LATEST_ONLY}" == true && -n "${VERSION}" ]]; then + Die "--version cannot be combined with updates.testing_latest_only" + fi if [[ "${SCHEDULED}" == true && "$(jq -r 'if has("automatic") then .automatic else true end' \ <<<"${UPDATE_CONFIG_JSON}")" != true ]]; then @@ -1585,6 +1603,10 @@ Main() { PreserveCurrentAfterUpdateFailure "latest payload acquisition failed" \ "${pre_stage_payload_id}" && return 0 + if [[ "${TESTING_LATEST_ONLY}" == true ]]; then + Log "latest payload acquisition failed; testing latest-only track forbids a supported fallback" + return "${stage_status}" + fi local bootstrap_ceiling="" if [[ "${BOOTSTRAP_SELECTED_VERSION_CODE}" =~ ^[0-9]+$ ]]; then (( 10#${BOOTSTRAP_SELECTED_VERSION_CODE} > 1 )) || @@ -1618,6 +1640,16 @@ Main() { fi return 0 fi + if [[ "${TESTING_LATEST_ONLY}" == true ]]; then + if [[ "${had_current}" == true ]]; then + PreserveCurrentAfterUpdateFailure \ + "latest candidate ${latest_payload_id} failed probation" \ + "${LAST_ATTEMPT_BASELINE_PAYLOAD_ID}" + return 0 + fi + Log "latest candidate ${latest_payload_id} failed probation; testing latest-only track forbids a supported fallback" + return "${attempt_status}" + fi if [[ "${latest_needs_reference}" != true && "${had_current}" == true ]]; then PreserveCurrentAfterUpdateFailure \ "latest candidate ${latest_payload_id} failed probation" \ diff --git a/scripts/derive_roblox_host_abi_profile.py b/scripts/derive_roblox_host_abi_profile.py index 07e0db2..262ac3f 100755 --- a/scripts/derive_roblox_host_abi_profile.py +++ b/scripts/derive_roblox_host_abi_profile.py @@ -1088,9 +1088,13 @@ def validate_semantic_match( raise AnalyzerError("registry initializer object size is inconsistent") -def find_signature_match( - reference: ElfImage, candidate: ElfImage, spec: SignatureSpec -) -> SignatureMatch: +def find_signature_matches( + reference: ElfImage, + candidate: ElfImage, + spec: SignatureSpec, + *, + allow_multiple_candidates: bool = False, +) -> tuple[SignatureMatch, ...]: reference_instructions = disassemble( reference, spec.reference_rva, spec.instruction_count ) @@ -1131,18 +1135,59 @@ def find_signature_match( ): matches.append(signature_offset) search_offset = anchor_position + 1 + if not matches: + raise AnalyzerError( + f"signature {spec.name} matched 0 candidate locations" + ) + if not allow_multiple_candidates and len(matches) != 1: + raise AnalyzerError( + f"signature {spec.name} matched {len(matches)} candidate locations" + ) + semantic_matches = [] + semantic_errors = [] + for match in matches: + match_rva = text.address + match - text.offset + candidate_instructions = disassemble( + candidate, match_rva, spec.instruction_count + ) + try: + validate_semantic_match( + reference_instructions, + candidate_instructions, + spec.allow_registry_object_size_change, + ) + except AnalyzerError as error: + semantic_errors.append(error) + continue + semantic_matches.append( + SignatureMatch( + match_rva, reference_instructions, candidate_instructions + ) + ) + if not semantic_matches and len(matches) == 1 and semantic_errors: + raise semantic_errors[0] + return tuple(semantic_matches) + + +def find_signature_match( + reference: ElfImage, candidate: ElfImage, spec: SignatureSpec +) -> SignatureMatch: + matches = find_signature_matches(reference, candidate, spec) if len(matches) != 1: raise AnalyzerError( f"signature {spec.name} matched {len(matches)} candidate locations" ) - match_rva = text.address + matches[0] - text.offset - candidate_instructions = disassemble(candidate, match_rva, spec.instruction_count) - validate_semantic_match( - reference_instructions, - candidate_instructions, - spec.allow_registry_object_size_change, - ) - return SignatureMatch(match_rva, reference_instructions, candidate_instructions) + return matches[0] + + +def direct_call_target(instruction: Any, description: str) -> int: + if ( + instruction.mnemonic != "call" + or len(instruction.operands) != 1 + or instruction.operands[0].type != capstone_x86.X86_OP_IMM + ): + raise AnalyzerError(f"{description} is not one direct call") + return instruction.operands[0].imm def rip_targets(instruction: Any) -> tuple[int, ...]: @@ -1256,7 +1301,13 @@ def create_signature_specs( 24, ), SignatureSpec( - "usable-size", parse_rva(bridges["usable-size"]["rva"], "usable size"), 24 + # This bridge is an 18-instruction leaf. Keeping the signature + # bounded to the leaf avoids coupling it to padding or the next + # function, which changed from executable code to INT3 padding in + # Roblox versionCode 2904. + "usable-size", + parse_rva(bridges["usable-size"]["rva"], "usable size"), + 18, ), SignatureSpec( "reallocate", parse_rva(bridges["reallocate"]["rva"], "reallocate"), 24 @@ -1277,8 +1328,12 @@ def create_signature_specs( ), SignatureSpec( "allocator-thread-initializer", - parse_rva(data_seeds["allocator_thread_initializer"], "thread initializer"), - 24, + parse_rva( + data_seeds["allocator_thread_initializer"], "thread initializer" + ), + # The wrapper ends at instruction 19. Later instructions belong + # to an unrelated adjacent initializer. + 19, ), SignatureSpec( "registry-initializer", @@ -1362,10 +1417,47 @@ def derive_profile( reference_profile = reference_sidecar["profile"] anchors = reference_sidecar["derivation_anchors"] - matches = { - spec.name: find_signature_match(reference, candidate, spec) - for spec in create_signature_specs(reference_profile, anchors) - } + matches = {} + allocate_matches = () + for spec in create_signature_specs(reference_profile, anchors): + if spec.name == "allocate": + allocate_matches = find_signature_matches( + reference, candidate, spec, allow_multiple_candidates=True + ) + else: + matches[spec.name] = find_signature_match(reference, candidate, spec) + + # The allocate signature alone is intentionally ambiguous in 2904. Resolve + # it through an independent invariant: the registry initializer directly + # calls the allocator wrapper that Mocktail must publish in the profile. + registry_match = matches["registry-initializer"] + reference_allocate = parse_rva( + bridge_map(reference_profile)["allocate"]["rva"], + "reference allocate", + ) + if ( + direct_call_target( + registry_match.reference_instructions[9], + "reference registry allocator call", + ) + != reference_allocate + ): + raise AnalyzerError( + "reference registry initializer does not call its allocator bridge" + ) + candidate_allocate = direct_call_target( + registry_match.candidate_instructions[9], + "candidate registry allocator call", + ) + selected_allocate = tuple( + match for match in allocate_matches if match.rva == candidate_allocate + ) + if len(selected_allocate) != 1: + raise AnalyzerError( + "signature allocate candidates do not identify exactly one " + "registry allocator call" + ) + matches["allocate"] = selected_allocate[0] reference_init = reference.init_array_relocations() candidate_init = candidate.init_array_relocations() if len(reference_init) != reference_profile["init_array_count"]: diff --git a/scripts/install_user_updater.sh b/scripts/install_user_updater.sh deleted file mode 100755 index cd4fe7c..0000000 --- a/scripts/install_user_updater.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 Mocktail Project Authors -# Licensed under the Apache License, Version 2.0. - -set -Eeuo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" -CONFIG_ROOT="${XDG_CONFIG_HOME:-${HOME}/.config}/mocktail" -UNIT_ROOT="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user" -CANARY_BINARY="${MOCKTAIL_UPDATE_CANARY_BIN:-}" -UPDATE_HELPER="${MOCKTAIL_UPDATE_HELPER:-}" - -if [[ -z "${CANARY_BINARY}" ]]; then - for candidate in "${PROJECT_ROOT}/build/mocktail" \ - /usr/lib/mocktail/mocktail; do - if [[ -f "${candidate}" && ! -L "${candidate}" && -x "${candidate}" ]]; then - CANARY_BINARY="${candidate}" - break - fi - done -fi -if [[ -z "${UPDATE_HELPER}" ]]; then - for candidate in "${PROJECT_ROOT}/build/mocktail_updater" \ - /usr/lib/mocktail/mocktail_updater; do - if [[ -f "${candidate}" && ! -L "${candidate}" && -x "${candidate}" ]]; then - UPDATE_HELPER="${candidate}" - break - fi - done -fi -[[ "${CANARY_BINARY}" == /* && -f "${CANARY_BINARY}" && - ! -L "${CANARY_BINARY}" && -x "${CANARY_BINARY}" ]] || { - echo "build or install Mocktail before enabling its update timer" >&2 - exit 1 -} -[[ "${UPDATE_HELPER}" == /* && -f "${UPDATE_HELPER}" && - ! -L "${UPDATE_HELPER}" && -x "${UPDATE_HELPER}" ]] || { - echo "build or install the native Mocktail updater before enabling its timer" >&2 - exit 1 -} - -[[ "${PROJECT_ROOT}" != *$'\n'* && "${PROJECT_ROOT}" != *'%'* ]] || { - echo "unsupported project path for systemd environment file" >&2 - exit 1 -} -mkdir -p "${CONFIG_ROOT}" "${UNIT_ROOT}" -install -m 0644 "${PROJECT_ROOT}/config/systemd/mocktail-update.service" \ - "${UNIT_ROOT}/mocktail-update.service" -install -m 0644 "${PROJECT_ROOT}/config/systemd/mocktail-update.timer" \ - "${UNIT_ROOT}/mocktail-update.timer" -if [[ ! -e "${CONFIG_ROOT}/config.yaml" ]]; then - install -m 0600 "${PROJECT_ROOT}/config/mocktail.example.yaml" \ - "${CONFIG_ROOT}/config.yaml" -fi -temporary="$(mktemp "${CONFIG_ROOT}/.updater.env.XXXXXX")" -trap 'rm -f -- "${temporary}"' EXIT -printf 'MOCKTAIL_UPDATE_HELPER=%q\n' "${UPDATE_HELPER}" > "${temporary}" -printf 'MOCKTAIL_UPDATE_CANARY_BIN=%q\n' "${CANARY_BINARY}" >> "${temporary}" -chmod 0600 "${temporary}" -mv -f -- "${temporary}" "${CONFIG_ROOT}/updater.env" -trap - EXIT - -systemctl --user daemon-reload -systemctl --user enable --now mocktail-update.timer -systemctl --user list-timers mocktail-update.timer --no-pager diff --git a/scripts/read_update_config.py b/scripts/read_update_config.py index 391fce2..2212c6f 100755 --- a/scripts/read_update_config.py +++ b/scripts/read_update_config.py @@ -105,13 +105,14 @@ def main() -> None: fail("updates must be a mapping") allowed = { "automatic", + "testing_latest_only", "source", "launch_after_update", } unknown = set(updates) - allowed if unknown: fail(f"unknown updates key: {sorted(unknown)[0]}") - for key in ("automatic", "launch_after_update"): + for key in ("automatic", "testing_latest_only", "launch_after_update"): if key in updates and not isinstance(updates[key], bool): fail(f"updates.{key} must be true or false") if "source" in updates and updates["source"] not in SUPPORTED_SOURCES: diff --git a/src/compat/bionic_pthread_create_runtime.cc b/src/compat/bionic_pthread_create_runtime.cc index f6192ed..21d3e93 100644 --- a/src/compat/bionic_pthread_create_runtime.cc +++ b/src/compat/bionic_pthread_create_runtime.cc @@ -18,8 +18,13 @@ #include #include +#include #include +#if defined(__GLIBC__) +#include +#endif + namespace mocktail::compat { namespace { @@ -31,6 +36,34 @@ struct ThreadStartContext { std::atomic g_thread_initializer{nullptr}; +size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept { +#if defined(__GLIBC__) + using GetStaticTlsInfo = void (*)(size_t*, size_t*); + static const auto get_static_tls_info = + reinterpret_cast( + dlsym(RTLD_DEFAULT, "_dl_get_tls_static_info")); + size_t tls_size = 0; + size_t tls_alignment = 0; + if (get_static_tls_info != nullptr) { + get_static_tls_info(&tls_size, &tls_alignment); + } + + // glibc places static TLS inside the requested mapping. Compensate only + // unusually large blocks; Mocktail's compatibility TLS is several MiB. + constexpr size_t kLargeStaticTlsThreshold = 1U * 1024U * 1024U; + if (tls_size > kLargeStaticTlsThreshold && tls_alignment != 0 && + (tls_alignment & (tls_alignment - 1)) == 0 && + tls_size <= std::numeric_limits::max() - (tls_alignment - 1)) { + const size_t reserve = + (tls_size + tls_alignment - 1) & ~(tls_alignment - 1); + if (guest_stack_size <= std::numeric_limits::max() - reserve) { + return guest_stack_size + reserve; + } + } +#endif + return guest_stack_size; +} + int CopySupportedThreadAttributes(const pthread_attr_t& source, pthread_attr_t* destination) noexcept { int detach_state = PTHREAD_CREATE_JOINABLE; @@ -62,7 +95,8 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source, if (result != 0) { return result; } - result = pthread_attr_setstacksize(destination, stack_size); + result = pthread_attr_setstacksize(destination, + HostStackSizeForGuest(stack_size)); if (result != 0) { return result; } @@ -112,7 +146,8 @@ int CopyRequiredThreadAttributes(const pthread_attr_t& source, if (result != 0) { return result; } - return pthread_attr_setstacksize(destination, stack_size); + return pthread_attr_setstacksize(destination, + HostStackSizeForGuest(stack_size)); } int ConfigureHostSafeStackFallback(const pthread_attr_t* source, @@ -139,9 +174,11 @@ int ConfigureHostSafeStackFallback(const pthread_attr_t* source, if (result != 0) { return result; } + const size_t guest_floor = + std::max(guest_stack_size, kBionicLp64FallbackThreadStackSize); return pthread_attr_setstacksize( - destination, std::max({guest_stack_size, host_default_stack_size, - kBionicLp64FallbackThreadStackSize})); + destination, + std::max(host_default_stack_size, HostStackSizeForGuest(guest_floor))); } void* RunGuestThread(void* raw_context) noexcept { @@ -183,8 +220,9 @@ int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr, if (result == 0) { normalized_attr_initialized = true; if (attr == nullptr) { - result = pthread_attr_setstacksize(&normalized_attr, - kBionicLp64DefaultThreadStackSize); + result = pthread_attr_setstacksize( + &normalized_attr, + HostStackSizeForGuest(kBionicLp64DefaultThreadStackSize)); } else { result = CopySupportedThreadAttributes(*attr, &normalized_attr); } diff --git a/src/main.cc b/src/main.cc index 4b75933..f320736 100644 --- a/src/main.cc +++ b/src/main.cc @@ -293,9 +293,6 @@ int main(int argc, char* argv[]) { std::cout << " [memory] hard process-tree limit active: " << runtime_config.config.performance().memory_limit_mb << " MiB RAM, swap disabled\n"; - } else { - std::cerr << " [memory] cgroup hard limit unavailable: " - << cgroup_limit.detail << "; using the RSS+swap watchdog\n"; } } if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) { @@ -308,8 +305,8 @@ int main(int argc, char* argv[]) { << '\n'; } - // Register immediately after any cgroup re-exec and before starting helper - // processes or loading the Android payload. libgamemode caches its shared + // Register before starting helper processes or loading the Android payload. + // libgamemode caches its shared // D-Bus connection, so the first use must happen in the final game process. const mocktail::runtime::GameModePolicy game_mode_policy = runtime_config.config.performance().game_mode; diff --git a/src/runtime/discord_rpc.cc b/src/runtime/discord_rpc.cc index fef8e4d..bb39c3d 100644 --- a/src/runtime/discord_rpc.cc +++ b/src/runtime/discord_rpc.cc @@ -552,10 +552,12 @@ DiscordRpcActivity BuildDiscordRpcActivity( activity.details = TruncateUtf8(config.text.browsing, 128); break; case RobloxExperiencePresencePhase::kJoining: - activity.details = TruncateUtf8(config.text.joining, 128); - break; case RobloxExperiencePresencePhase::kPlaying: { if (place_name.empty()) { + if (phase == RobloxExperiencePresencePhase::kJoining) { + activity.details = TruncateUtf8(config.text.joining, 128); + break; + } place_name = config.text.unknown_place; } if (config.show_place_name) { @@ -564,14 +566,16 @@ DiscordRpcActivity BuildDiscordRpcActivity( activity.details = activity.state; activity.state.clear(); } - if (config.show_elapsed_time && session_started_at > 0) { + if (phase == RobloxExperiencePresencePhase::kPlaying && + config.show_elapsed_time && session_started_at > 0) { activity.start_timestamp = session_started_at; } if (IsSafeExternalImageUrl(place_icon_url)) { activity.large_image = std::move(place_icon_url); activity.large_text = TruncateUtf8(place_name, 128); } - if (config.join_enabled && request != nullptr && + if (phase == RobloxExperiencePresencePhase::kPlaying && + config.join_enabled && request != nullptr && (!config.public_servers_only || IsPublicDiscordJoin(*request))) { activity.button_url = BuildDiscordJoinUrl(*request); if (!activity.button_url.empty()) { @@ -733,7 +737,8 @@ class DiscordRpcSession::Impl final { std::string place_name; std::string place_icon_url; bool resolve_metadata_after_publish = false; - if (desired.phase == RobloxExperiencePresencePhase::kPlaying && + if ((desired.phase == RobloxExperiencePresencePhase::kJoining || + desired.phase == RobloxExperiencePresencePhase::kPlaying) && desired.request.place_id > 0) { CachedPlaceMetadata& cached = place_metadata_[desired.request.place_id]; @@ -741,7 +746,6 @@ class DiscordRpcSession::Impl final { place_icon_url = cached.icon_url; if ((cached.name.empty() || cached.icon_url.empty()) && std::chrono::steady_clock::now() >= cached.next_attempt) { - // Publish fallback activity while metadata is in flight. resolve_metadata_after_publish = true; } } @@ -761,7 +765,12 @@ class DiscordRpcSession::Impl final { std::fprintf(stderr, " [discord-rpc] connected to Discord Desktop\n"); published_revision = 0; } - if (published_revision != desired.revision) { + const bool wait_for_place_metadata = + (desired.phase == RobloxExperiencePresencePhase::kJoining || + desired.phase == RobloxExperiencePresencePhase::kPlaying) && + desired.request.place_id > 0 && place_name.empty(); + if (published_revision != desired.revision && + !wait_for_place_metadata) { const auto now = std::chrono::steady_clock::now(); while (!activity_updates.empty() && now - activity_updates.front() >= kDiscordActivityRateWindow) { @@ -824,7 +833,9 @@ class DiscordRpcSession::Impl final { if (Snapshot().revision != desired.revision) { continue; } - if (changed) { + if (changed && + (desired.phase == RobloxExperiencePresencePhase::kJoining || + desired.phase == RobloxExperiencePresencePhase::kPlaying)) { // Publish cached metadata through the normal rate limiter. published_revision = 0; continue; diff --git a/src/runtime/memory_limit.cc b/src/runtime/memory_limit.cc index 1ef819f..35646d0 100644 --- a/src/runtime/memory_limit.cc +++ b/src/runtime/memory_limit.cc @@ -15,7 +15,6 @@ #include "runtime/memory_limit.h" #include -#include #include #include #include @@ -27,9 +26,7 @@ #include #include #include -#include #include -#include namespace mocktail { namespace runtime { @@ -38,7 +35,6 @@ namespace { constexpr std::uint64_t kBytesPerKibibyte = 1024U; constexpr std::uint64_t kBytesPerMebibyte = 1024U * 1024U; constexpr int kWatchdogPollMilliseconds = 100; -constexpr char kCgroupMarker[] = "MOCKTAIL_MEMORY_CGROUP_LIMIT_BYTES"; void WriteAllBestEffort(int descriptor, std::string_view bytes) { std::size_t offset = 0; @@ -115,90 +111,6 @@ ProcessMemoryUsage ReadCurrentProcessMemory() { std::string_view(buffer.data(), static_cast(total))); } -bool ReadTextFile(const std::string& path, std::string* value) { - std::ifstream input(path); - if (!input) { - return false; - } - return static_cast(input >> *value); -} - -bool ReadUnifiedCgroupPath(std::string* path) { - std::ifstream input("/proc/self/cgroup"); - std::string line; - while (std::getline(input, line)) { - if (line.rfind("0::", 0) == 0) { - *path = line.substr(3); - return !path->empty() && path->front() == '/'; - } - } - return false; -} - -bool ActiveCgroupMatches(std::uint64_t expected_bytes, - std::string* detail) { - std::string cgroup_path; - if (!ReadUnifiedCgroupPath(&cgroup_path)) { - *detail = "cannot resolve the current unified cgroup"; - return false; - } - const std::string root = "/sys/fs/cgroup" + cgroup_path; - std::string memory_max; - std::string swap_max; - if (!ReadTextFile(root + "/memory.max", &memory_max) || - !ReadTextFile(root + "/memory.swap.max", &swap_max)) { - *detail = "cannot read current cgroup memory limits"; - return false; - } - std::uint64_t configured_bytes = 0; - const std::from_chars_result parsed = std::from_chars( - memory_max.data(), memory_max.data() + memory_max.size(), - configured_bytes); - if (parsed.ec != std::errc() || - parsed.ptr != memory_max.data() + memory_max.size() || - configured_bytes > expected_bytes) { - *detail = "current cgroup memory.max does not match the requested limit"; - return false; - } - if (swap_max != "0") { - *detail = "current cgroup still permits swap"; - return false; - } - return true; -} - -std::string CurrentExecutable(char* const argv[]) { - std::array executable{}; - const ssize_t length = - readlink("/proc/self/exe", executable.data(), PATH_MAX); - if (length > 0) { - executable[static_cast(length)] = '\0'; - return executable.data(); - } - return argv != nullptr && argv[0] != nullptr ? argv[0] : std::string(); -} - -bool SystemdUserManagerAvailable(std::string* detail) { - if (access("/sys/fs/cgroup/cgroup.controllers", R_OK) != 0) { - *detail = "cgroup v2 is not mounted"; - return false; - } - if (access("/usr/bin/systemd-run", X_OK) != 0) { - *detail = "systemd-run is not installed"; - return false; - } - const char* runtime_directory = getenv("XDG_RUNTIME_DIR"); - const std::string user_runtime = - runtime_directory != nullptr && runtime_directory[0] != '\0' - ? runtime_directory - : "/run/user/" + std::to_string(geteuid()); - if (access((user_runtime + "/systemd/private").c_str(), F_OK) != 0) { - *detail = "systemd user manager is not running"; - return false; - } - return true; -} - } // namespace ProcessMemoryUsage ParseProcessStatusMemory(std::string_view status) { @@ -221,84 +133,14 @@ CgroupMemoryLimitResult MaybeReexecWithCgroupMemoryLimit( int argc, char* const argv[], std::uint64_t limit_bytes, const std::vector* reexec_arguments) { CgroupMemoryLimitResult result; + (void)argc; + (void)argv; + (void)reexec_arguments; if (limit_bytes == 0) { result.detail = "memory limit is disabled"; return result; } - - const char* marker = getenv(kCgroupMarker); - if (marker != nullptr) { - std::uint64_t marked_bytes = 0; - const std::string_view marker_value(marker); - const std::from_chars_result parsed = std::from_chars( - marker_value.data(), marker_value.data() + marker_value.size(), - marked_bytes); - if (parsed.ec == std::errc() && - parsed.ptr == marker_value.data() + marker_value.size() && - marked_bytes == limit_bytes && - ActiveCgroupMatches(limit_bytes, &result.detail)) { - result.status = CgroupMemoryLimitStatus::kActive; - result.detail = "cgroup v2 MemoryMax is active and swap is disabled"; - } else if (result.detail.empty()) { - result.detail = "cgroup activation marker is invalid"; - } - return result; - } - - if (!SystemdUserManagerAvailable(&result.detail)) { - return result; - } - const std::string executable = CurrentExecutable(argv); - if (executable.empty()) { - result.status = CgroupMemoryLimitStatus::kExecFailed; - result.detail = "cannot resolve the Mocktail executable"; - return result; - } - - const std::string limit = std::to_string(limit_bytes); - std::vector arguments; - arguments.reserve((reexec_arguments != nullptr - ? reexec_arguments->size() - : static_cast(argc)) + - 10U); - arguments.emplace_back("/usr/bin/systemd-run"); - arguments.emplace_back("--user"); - arguments.emplace_back("--scope"); - arguments.emplace_back("--quiet"); - arguments.emplace_back("--same-dir"); - arguments.emplace_back("--expand-environment=no"); - arguments.emplace_back("--property=MemoryMax=" + limit); - arguments.emplace_back("--property=MemorySwapMax=0"); - arguments.emplace_back("--"); - arguments.push_back(executable); - if (reexec_arguments != nullptr) { - arguments.insert(arguments.end(), reexec_arguments->begin(), - reexec_arguments->end()); - } else { - for (int index = 1; index < argc; ++index) { - arguments.emplace_back(argv[index]); - } - } - std::vector exec_arguments; - exec_arguments.reserve(arguments.size() + 1U); - for (std::string& argument : arguments) { - exec_arguments.push_back(argument.data()); - } - exec_arguments.push_back(nullptr); - - if (setenv(kCgroupMarker, limit.c_str(), 1) != 0) { - result.status = CgroupMemoryLimitStatus::kExecFailed; - result.detail = std::string("cannot mark cgroup re-exec: ") + - std::strerror(errno); - return result; - } - (void)fflush(nullptr); - execv(arguments.front().c_str(), exec_arguments.data()); - const int exec_error = errno; - (void)unsetenv(kCgroupMarker); - result.status = CgroupMemoryLimitStatus::kExecFailed; - result.detail = std::string("cannot execute systemd-run: ") + - std::strerror(exec_error); + result.detail = "automatic cgroup scopes are disabled"; return result; } diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index 7a8f282..e0a7268 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -144,6 +144,11 @@ updates: # backend, and promote only on success. An existing current payload is # preserved when probation fails. automatic: true + # Boolean (default: true): testing track. Always try provider latest, even + # when it has no verified payload profile, and do not download or activate + # an older supported payload in its place. Exact HostAbi derivation and two + # canaries are still required before latest can become current. + testing_latest_only: true # String (default: apk-pure): direct x86_64 APK provider. source: apk-pure # Reserved for desktop update integrations. `mocktail_updater` itself never diff --git a/src/update/host_abi_deriver.cc b/src/update/host_abi_deriver.cc index d1a060b..b0311a6 100644 --- a/src/update/host_abi_deriver.cc +++ b/src/update/host_abi_deriver.cc @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -906,10 +907,10 @@ bool ValidateSemantics(const std::vector& reference, return true; } -std::optional FindSignatureMatch( +std::optional> FindSignatureMatches( const ElfImage& reference, const ElfImage& candidate, const Disassembler& disassembler, const SignatureSpec& spec, - std::string* error) { + bool allow_multiple_candidates, std::string* error) { const auto reference_instructions = disassembler.Decode( reference, spec.reference_rva, spec.instruction_count, 4096, error); if (!reference_instructions.has_value()) return std::nullopt; @@ -995,21 +996,68 @@ std::optional FindSignatureMatch( } search = found + 1; } - if (matches.size() != 1) { + if (matches.empty()) { + *error = "signature " + spec.name + " matched " + + "0 candidate locations"; + return std::nullopt; + } + if (!allow_multiple_candidates && matches.size() != 1U) { *error = "signature " + spec.name + " matched " + std::to_string(matches.size()) + " candidate locations"; return std::nullopt; } - const std::uint64_t match_rva = text->address + matches[0]; - const auto candidate_instructions = disassembler.Decode( - candidate, match_rva, spec.instruction_count, 4096, error); - if (!candidate_instructions.has_value() || - !ValidateSemantics(*reference_instructions, *candidate_instructions, - spec.registry_size_may_change, error)) { + std::vector semantic_matches; + std::string first_semantic_error; + for (const std::size_t match : matches) { + const std::uint64_t match_rva = text->address + match; + std::string semantic_error; + const auto candidate_instructions = disassembler.Decode( + candidate, match_rva, spec.instruction_count, 4096, &semantic_error); + if (!candidate_instructions.has_value() || + !ValidateSemantics(*reference_instructions, *candidate_instructions, + spec.registry_size_may_change, &semantic_error)) { + if (first_semantic_error.empty()) { + first_semantic_error = std::move(semantic_error); + } + continue; + } + semantic_matches.push_back(SignatureMatch{ + match_rva, *reference_instructions, *candidate_instructions}); + } + if (semantic_matches.empty() && matches.size() == 1U && + !first_semantic_error.empty()) { + *error = std::move(first_semantic_error); return std::nullopt; } - return SignatureMatch{match_rva, *reference_instructions, - *candidate_instructions}; + return semantic_matches; +} + +std::optional FindSignatureMatch( + const ElfImage& reference, const ElfImage& candidate, + const Disassembler& disassembler, const SignatureSpec& spec, + std::string* error) { + auto matches = + FindSignatureMatches(reference, candidate, disassembler, spec, false, + error); + if (!matches.has_value()) return std::nullopt; + if (matches->size() != 1U) { + *error = "signature " + spec.name + " matched " + + std::to_string(matches->size()) + " candidate locations"; + return std::nullopt; + } + return std::move(matches->front()); +} + +std::optional DirectCallTarget( + const Instruction& instruction, std::string_view description, + std::string* error) { + if (instruction.mnemonic != "call" || instruction.operands.size() != 1U || + instruction.operands[0].type != Operand::Type::kImmediate || + instruction.operands[0].value < 0) { + *error = std::string(description) + " is not one direct call"; + return std::nullopt; + } + return static_cast(instruction.operands[0].value); } std::vector RipTargets(const Instruction& instruction) { @@ -1182,7 +1230,9 @@ std::optional> SignatureSpecs(const Json& sidecar, for (const auto& [label, count] : std::array, 6>{ {{"small-allocate", 24}, - {"usable-size", 24}, + // usable-size is an 18-instruction leaf. Do not include bytes from + // the adjacent function or alignment padding in its signature. + {"usable-size", 18}, {"reallocate", 24}, {"allocate", 24}, {"aligned-allocate-direct", 24}, @@ -1193,7 +1243,7 @@ std::optional> SignatureSpecs(const Json& sidecar, } if (!add("arena-initializer", seeds["arena_initializer"], 24) || !add("allocator-thread-initializer", - seeds["allocator_thread_initializer"], 24) || + seeds["allocator_thread_initializer"], 19) || !add("registry-initializer", bootstrap["registry_initializer"], 20, true) || !add("allocator-object-initializer", @@ -1290,12 +1340,58 @@ std::optional> DeriveDocuments(const ElfImage& reference, const auto specs = SignatureSpecs(sidecar, error); if (!specs.has_value()) return std::nullopt; std::map matches; + std::vector allocate_matches; for (const SignatureSpec& spec : *specs) { + if (spec.name == "allocate") { + auto candidates = FindSignatureMatches(reference, candidate, + disassembler, spec, true, error); + if (!candidates.has_value()) return std::nullopt; + allocate_matches = std::move(*candidates); + continue; + } auto match = FindSignatureMatch(reference, candidate, disassembler, spec, error); if (!match.has_value()) return std::nullopt; matches.emplace(spec.name, std::move(*match)); } + // 2904 contains two semantically equivalent allocate wrappers. Select the + // one independently referenced by the registry initializer instead of + // weakening signature uniqueness globally. + const SignatureMatch& registry_match = matches.at("registry-initializer"); + if (allocate_matches.empty() || registry_match.reference.size() <= 9U || + registry_match.candidate.size() <= 9U) { + *error = "allocator signatures are incomplete"; + return std::nullopt; + } + const auto reference_allocate = DirectCallTarget( + registry_match.reference[9], "reference registry allocator call", error); + if (!reference_allocate.has_value() || + *reference_allocate != + allocate_matches.front().reference.front().address) { + if (error->empty()) { + *error = + "reference registry initializer does not call its allocator bridge"; + } + return std::nullopt; + } + const auto candidate_allocate = DirectCallTarget( + registry_match.candidate[9], "candidate registry allocator call", error); + if (!candidate_allocate.has_value()) return std::nullopt; + auto selected_allocate = std::find_if( + allocate_matches.begin(), allocate_matches.end(), + [&](const SignatureMatch& match) { + return match.rva == *candidate_allocate; + }); + if (selected_allocate == allocate_matches.end() || + std::find_if(std::next(selected_allocate), allocate_matches.end(), + [&](const SignatureMatch& match) { + return match.rva == *candidate_allocate; + }) != allocate_matches.end()) { + *error = "signature allocate candidates do not identify exactly one " + "registry allocator call"; + return std::nullopt; + } + matches.emplace("allocate", std::move(*selected_allocate)); const auto reference_init = reference.InitArray(error); const auto candidate_init = candidate.InitArray(error); if (!reference_init.has_value() || !candidate_init.has_value()) { diff --git a/src/update/update_config.cc b/src/update/update_config.cc index 3cbda7a..333fd17 100644 --- a/src/update/update_config.cc +++ b/src/update/update_config.cc @@ -155,6 +155,11 @@ UpdateConfigResult LoadUpdateConfig(const std::filesystem::path& path) { result.error = "updates.automatic must be true or false"; break; } + } else if (key == "testing_latest_only") { + if (!ParseBoolean(value, &result.config.testing_latest_only)) { + result.error = "updates.testing_latest_only must be true or false"; + break; + } } else if (key == "launch_after_update") { if (!ParseBoolean(value, &result.config.launch_after_update)) { result.error = "updates.launch_after_update must be true or false"; diff --git a/src/update/update_coordinator.cc b/src/update/update_coordinator.cc index 2428e89..ee239e7 100644 --- a/src/update/update_coordinator.cc +++ b/src/update/update_coordinator.cc @@ -235,7 +235,8 @@ ReferenceProfile ResolveReference(const UpdatePaths& paths, ApkPureProvider* provider, PayloadStore* store, const std::filesystem::path& workspace, - int progress_fd) { + int progress_fd, + bool allow_reference_download) { ReferenceProfile result; if (installed && !installed.host_abi_profile.empty()) { std::error_code filesystem_error; @@ -262,6 +263,12 @@ ReferenceProfile ResolveReference(const UpdatePaths& paths, result.profile = paths.host_abi_reference_profile; return result; } + if (!allow_reference_download) { + result.error = + "updates.testing_latest_only requires an installed HostAbi reference; " + "downloading an older reference payload is disabled"; + return result; + } Candidate downloaded = DownloadCandidate(provider, store, ExactPayloadIdentity(preferred), true, paths, workspace / "reference", progress_fd); @@ -397,7 +404,9 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { } ApkPureProvider provider; std::optional latest_version; - if (request.check_latest) { + const bool check_latest = + request.check_latest || configured.config.testing_latest_only; + if (check_latest) { Progress(request.progress_fd, "Checking Roblox..."); const ProviderVersion latest = provider.CheckLatest(); if (latest) { @@ -414,11 +423,20 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { result.message = "installed Roblox is newer than provider metadata"; return result; } - } else if (current) { - result.payload_id = current.payload_id; - result.message = - "update metadata is temporarily unavailable: " + latest.error; - return result; + } else { + if (current) { + result.payload_id = current.payload_id; + result.message = + "update metadata is temporarily unavailable: " + latest.error; + return result; + } + if (configured.config.testing_latest_only) { + result.error = + "updates.testing_latest_only requires available provider latest " + "metadata: " + + latest.error; + return result; + } } } const std::filesystem::path workspace = UniqueDirectory( @@ -475,7 +493,8 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { if (candidate && !candidate.exact_supported && candidate.profile.empty()) { const ReferenceProfile reference = ResolveReference(paths, installed, *preferred, &provider, &store, - workspace, request.progress_fd); + workspace, request.progress_fd, + !configured.config.testing_latest_only); if (!reference) { candidate_error = reference.error; } else { @@ -530,6 +549,14 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { return result; } + if (configured.config.testing_latest_only) { + result.error = "latest Roblox failed probation (" + candidate_error + + "); updates.testing_latest_only forbids activating an " + "older supported fallback"; + cleanup(); + return result; + } + Candidate fallback; const std::string preferred_id = std::to_string(preferred->version_code) + "-" + preferred->elf_build_id; diff --git a/tests/install_user_updater_test.sh b/tests/install_user_updater_test.sh deleted file mode 100755 index 475b7ed..0000000 --- a/tests/install_user_updater_test.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 Mocktail Project Authors -# SPDX-License-Identifier: Apache-2.0 - -set -Eeuo pipefail - -readonly installer="${1:?user updater installer is required}" -readonly project_root="${2:?project root is required}" -test_root="$(mktemp -d)" -trap 'rm -rf -- "${test_root}"' EXIT - -mkdir -p "${test_root}/bin" "${test_root}/home" -canary="${test_root}/mocktail" -printf '#!/usr/bin/env bash\nexit 0\n' >"${canary}" -chmod 0755 "${canary}" -cat >"${test_root}/bin/systemctl" <<'EOF' -#!/usr/bin/env bash -printf '%s\n' "$*" >>"${SYSTEMCTL_CALLS:?}" -EOF -chmod 0755 "${test_root}/bin/systemctl" - -export HOME="${test_root}/home" -export XDG_CONFIG_HOME="${test_root}/config" -export MOCKTAIL_UPDATE_CANARY_BIN="${canary}" -export SYSTEMCTL_CALLS="${test_root}/systemctl-calls" -export PATH="${test_root}/bin:${PATH}" -"${installer}" >/dev/null - -readonly config_root="${XDG_CONFIG_HOME}/mocktail" -readonly unit_root="${XDG_CONFIG_HOME}/systemd/user" -[[ -f "${config_root}/config.yaml" ]] -[[ -f "${unit_root}/mocktail-update.service" ]] -[[ -f "${unit_root}/mocktail-update.timer" ]] -grep -Fxq "MOCKTAIL_PROJECT_ROOT=${project_root}" \ - "${config_root}/updater.env" -grep -Fxq "MOCKTAIL_UPDATE_CANARY_BIN=${canary}" \ - "${config_root}/updater.env" -grep -Fq \ - 'auto_update_roblox.sh --skip-build --no-launch --scheduled' \ - "${unit_root}/mocktail-update.service" -grep -Fxq -- '--user daemon-reload' "${SYSTEMCTL_CALLS}" -grep -Fxq -- '--user enable --now mocktail-update.timer' "${SYSTEMCTL_CALLS}" -grep -Fxq -- '--user list-timers mocktail-update.timer --no-pager' \ - "${SYSTEMCTL_CALLS}" - -# A source-tree timer must follow the runtime built from that same tree. An -# older system installation may coexist, but it cannot safely approve or -# reject payloads on behalf of the development binary. -fixture_project="${test_root}/fixture-project" -mkdir -p "${fixture_project}/scripts" "${fixture_project}/build" \ - "${fixture_project}/config/systemd" -cp -- "${installer}" "${fixture_project}/scripts/install_user_updater.sh" -cp -- "${project_root}/config/systemd/mocktail-update.service" \ - "${fixture_project}/config/systemd/mocktail-update.service" -cp -- "${project_root}/config/systemd/mocktail-update.timer" \ - "${fixture_project}/config/systemd/mocktail-update.timer" -cp -- "${project_root}/config/mocktail.example.yaml" \ - "${fixture_project}/config/mocktail.example.yaml" -printf '#!/usr/bin/env bash\nexit 0\n' > \ - "${fixture_project}/build/mocktail" -chmod 0755 "${fixture_project}/build/mocktail" -unset MOCKTAIL_UPDATE_CANARY_BIN -"${fixture_project}/scripts/install_user_updater.sh" >/dev/null -grep -Fxq \ - "MOCKTAIL_UPDATE_CANARY_BIN=${fixture_project}/build/mocktail" \ - "${config_root}/updater.env" - -echo "user updater installation checks passed"