improved Discord rpc, autoupdate

fixes #4
This commit is contained in:
komaruworld committed 2026-08-14 00:34:46 +03:00
1 parent 6f5de3a31d
commit 23fe2eeca5
20 files changed
+378 -392

No files matched your search

+1 -1
View File
@@ -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.
+1 -4
View File
@@ -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
+5
View File
@@ -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
-15
View File
@@ -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
-12
View File
@@ -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
+2 -4
View File
@@ -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<std::string>* reexec_arguments = nullptr);
+1
View File
@@ -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";
};
+33 -1
View File
@@ -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" \
+110 -18
View File
@@ -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"]:
-66
View File
@@ -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
+2 -1
View File
@@ -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:
+44 -6
View File
@@ -18,8 +18,13 @@
#include <algorithm>
#include <atomic>
#include <limits>
#include <new>
#if defined(__GLIBC__)
#include <dlfcn.h>
#endif
namespace mocktail::compat {
namespace {
@@ -31,6 +36,34 @@ struct ThreadStartContext {
std::atomic<NativeThreadInitializer> 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<GetStaticTlsInfo>(
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<size_t>::max() - (tls_alignment - 1)) {
const size_t reserve =
(tls_size + tls_alignment - 1) & ~(tls_alignment - 1);
if (guest_stack_size <= std::numeric_limits<size_t>::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);
}
+2 -5
View File
@@ -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;
+19 -8
View File
@@ -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;
+4 -162
View File
@@ -15,7 +15,6 @@
#include "runtime/memory_limit.h"
#include <fcntl.h>
#include <limits.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
@@ -27,9 +26,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
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<std::size_t>(total)));
}
bool ReadTextFile(const std::string& path, std::string* value) {
std::ifstream input(path);
if (!input) {
return false;
}
return static_cast<bool>(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<char, PATH_MAX + 1> executable{};
const ssize_t length =
readlink("/proc/self/exe", executable.data(), PATH_MAX);
if (length > 0) {
executable[static_cast<std::size_t>(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<std::string>* 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<std::string> arguments;
arguments.reserve((reexec_arguments != nullptr
? reexec_arguments->size()
: static_cast<std::size_t>(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<char*> 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;
}
+5
View File
@@ -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
+109 -13
View File
@@ -18,6 +18,7 @@
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <iterator>
#include <map>
#include <nlohmann/json.hpp>
#include <optional>
@@ -906,10 +907,10 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
return true;
}
std::optional<SignatureMatch> FindSignatureMatch(
std::optional<std::vector<SignatureMatch>> 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<SignatureMatch> 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<SignatureMatch> 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<SignatureMatch> 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<std::uint64_t> 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<std::uint64_t>(instruction.operands[0].value);
}
std::vector<std::uint64_t> RipTargets(const Instruction& instruction) {
@@ -1182,7 +1230,9 @@ std::optional<std::vector<SignatureSpec>> SignatureSpecs(const Json& sidecar,
for (const auto& [label, count] :
std::array<std::pair<std::string_view, std::size_t>, 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<std::vector<SignatureSpec>> 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<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
const auto specs = SignatureSpecs(sidecar, error);
if (!specs.has_value()) return std::nullopt;
std::map<std::string, SignatureMatch> matches;
std::vector<SignatureMatch> 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()) {
+5
View File
@@ -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";
+35 -8
View File
@@ -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<ProviderVersion> 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;
-68
View File
@@ -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"