Compare commits

...
35 Commits
Author SHA1 Message Date
vii 169e65bf60 docs(changelog): release 0.3.3 2026-10-04 20:44:33 -05:00
vii 69476f0f47 docs(changelog): add theme crash and Flatpak NVIDIA fixes 2026-10-04 20:14:25 -05:00
vii 79d0cdfb40 chore(merge): merge upstream komaruworld/mocktail main
Keep our captured-pointer handling in place of upstream's relative_mode
version. Keep upstream's motion coalescing guard on our relative flag.
2026-10-04 20:14:13 -05:00
komaruworld 273209b9ea Fix angle platform selection 2026-10-03 21:19:26 +03:00
komaruworld bbfd603e17 Fix captured mouse input
Refs; #135
2026-10-03 14:28:12 +03:00
komaruworld 1ba2941e7f Fix theme crash
Fixes #144
2026-10-03 14:03:25 +03:00
komaruworld 68c90ce428 flatpak nvidia xwayland fallback
#186
2026-10-03 13:48:39 +03:00
vii 068f07e125 chore(merge): merge upstream komaruworld/mocktail main 2026-10-02 20:02:32 -05:00
vii a469689bbb fix(webview): use GTK's software renderer when the helper composites in software
GTK's GL renderer aborted in libepoxy while snapshotting a page switch, which closed the web view. GSK_RENDERER set by the user still wins.
2026-10-02 20:02:32 -05:00
vii 51836a980d docs(changelog): add follow-user, Nix desktop entry, text focus and sky fixes 2026-10-02 20:02:32 -05:00
vii ad546a7781 Merge pull request #16 from kimlherme/docs/recovery-marker-cleanup
docs: document leftover recovery marker cleanup
2026-10-02 19:37:49 -05:00
kimlherme 3c45450dfc docs: document recovery marker cleanup 2026-10-02 20:21:34 -03:00
vii 185c11b57f test(update): expect a missing abi to count only on x86_64 hosts 2026-10-02 12:52:16 -05:00
vii 2c3d7b485e test(update): declare the guest ABI in test catalogs so aarch64 matches
Profiles without abi default to x86_64, so every payload store test skipped its catalog on arm64.
2026-10-02 12:44:47 -05:00
komaruworld 6f8344a28b Sky fixes 2026-10-02 16:52:27 +03:00
vii b60c43e8f0 chore(merge): merge upstream komaruworld/mocktail main
Keep both the CallProtocol and AccountProtocol bridges. Keep our pointer
hold and catalog validation. Re-apply our JNI local-frame counting,
singleton handles, class handle cache, handle-table report and thread
detach release on upstream's split jnivm files.
2026-10-01 21:41:43 -05:00
vii b5e4273277 docs(changelog): add updater, audio and platform fixes 2026-10-01 19:26:21 -05:00
vii d2858fedc8 Merge pull request #13 from kimlherme/fix/payload-update-correctness
fix(update): preserve rollback state and validate payload activation
2026-10-01 19:25:58 -05:00
vii 326a0659ea Merge pull request #15 from kimlherme/fix/platform-lifecycle-compatibility
fix(platform): correct Vulkan teardown, pointer capture and DNS flags
2026-10-01 19:25:51 -05:00
vii dd45467056 Merge pull request #14 from kimlherme/fix/audio-jni-ownership
fix(runtime): preserve OpenSL tickets and JNI array references
2026-10-01 19:25:40 -05:00
vii 94b1036296 docs(changelog): add ETC2 decode speedup 2026-10-01 19:14:37 -05:00
vii 4c61daad0c Merge pull request #10 from glook9001/simd-etc2-decoder
perf(graphics): SIMD PSHUFB ETC2/EAC decode with bit-exact scalar verification
2026-10-01 19:14:33 -05:00
kimlherme f5f224875a fix(platform): correct Vulkan teardown, pointer capture and DNS flags 2026-10-01 21:05:31 -03:00
kimlherme 90d4891a4e fix(update): preserve rollback state and validate payload activation 2026-10-01 21:05:30 -03:00
kimlherme 46dd99266c fix(runtime): preserve OpenSL tickets and JNI array references 2026-10-01 21:05:30 -03:00
77fce18a95 po co chodzic, mozna latac (#183)
* po co chodzic, mozna latac

* Fix jni ownership, split helpers

---------

Co-authored-by: Mia <uwie@thinkwise.ro>
Co-authored-by: komaruworld <rtysus228@gmail.com>
2026-10-01 20:31:52 +03:00
komaruworld d6f0f2c755 Fix some vc
Fokora
2026-10-01 18:03:02 +03:00
glook9001 c6afc5af24 perf(graphics): eliminate crop page faults with strided resampling and streaming WC loads 2026-10-01 12:08:54 +03:00
glook9001 74e7455ee1 perf(graphics): SIMD PSHUFB ETC2/EAC decode with bit-exact scalar verification 2026-10-01 12:08:35 +03:00
vii 000d021452 docs(changelog): add payload import fix 2026-09-30 21:34:06 -05:00
vii 2eef2c3821 Merge pull request #11 from kimlherme/fix/payload-import-rollback
fix(payload): preserve the previous payload on failed imports
2026-09-30 21:33:43 -05:00
Guilhermepycharm 4928443f90 fix(payload): preserve previous files across failed imports
Enable rollback before the first backup move, preserve originals during incomplete backups, and retain recovery files if restoration fails. Add offline full-importer regression coverage and register it with CTest.
2026-09-30 22:54:33 -03:00
native b37497db7b fix text overlay again (#179) 2026-09-30 17:55:30 +03:00
Lupey 6bbdc39d7e feat(nix): add desktop application (#177) 2026-09-30 17:48:05 +03:00
komaruworld 1c11336ba1 Fix text issues
bla bla bla
2026-09-30 16:56:48 +03:00
84 changed files with 7554 additions and 1965 deletions

No files matched your search

+13 -2
View File
@@ -104,14 +104,25 @@ jobs:
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
libplacebo-dev libpng-dev zlib1g-dev
libplacebo-dev libpng-dev zlib1g-dev \
xvfb xauth libgl1-mesa-dri jq patchelf
- name: Build and check native executables
shell: bash
run: |
test "$(uname -m)" = "${{ matrix.arch }}"
cmake -S . -B build-ci -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
cmake --build build-ci --parallel 4
./build-ci/mocktail --help
./build-ci/mocktail_updater status
- name: Run C++ and integration tests
shell: bash
env:
SDL_AUDIODRIVER: dummy
SDL_VIDEODRIVER: x11
LIBGL_ALWAYS_SOFTWARE: '1'
run: |
xvfb-run -a ctest --test-dir build-ci --output-on-failure \
--parallel 4 --timeout 120
+44 -1
View File
@@ -6,6 +6,48 @@ GitHub are taken from the matching section here.
## [Unreleased]
## [0.3.3] - 2026-10-04
### Added
- Joining a friend from a Roblox "follow user" link now works. Nightcap looks
up which game they are in and joins it.
- Nix users get a desktop entry for the app.
### Changed
- Roblox textures now decode about 3 times faster, which shortens the stalls
when a place loads and when new parts of the map stream in. Thanks to
@glook9001.
### Fixed
- `make update-roblox` no longer deletes your previous Roblox install when an
import fails partway through. If a step fails, the old files are put back,
or kept in a folder whose path is printed. Thanks to @kimlherme.
- A failed Roblox update now restores your previous version's records instead
of leaving them half switched. A damaged version file is rejected instead of
being half read, and a temporary failure to start the startup check no longer
marks a working Roblox build as broken for good. Thanks to @kimlherme.
- Fixed a possible crash when Roblox clears its audio queue while sound is
still playing, and a memory leak in lists of objects passed to Roblox's Java
code. Thanks to @kimlherme.
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox
releases the mouse lock while another release is already in progress, and
network lookups that misread Roblox's options. Thanks to @kimlherme.
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan
renderer.
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox
decides when a click ends typing.
- Going to another page in the web view, such as Friends from a profile, no
longer closes the web view. It crashed on every page change on Wayland
systems whose GL setup GTK could not use.
- Fixed a crash when Roblox's saved dark or light theme setting was missing or
damaged.
- The Flatpak build now detects an NVIDIA driver from inside its sandbox, so it
uses X11 instead of native Wayland where both are available. This avoids
Wayland hangs and display disconnects.
## [0.3.2] - 2026-09-29
### Added
@@ -238,7 +280,8 @@ First Nightcap release, forked from komaruworld/mocktail.
- The AppImage build no longer fails on stale checksums for the slimmed
Arch packages.
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.2...HEAD
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.3...HEAD
[0.3.3]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.3
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
+7 -1
View File
@@ -249,6 +249,9 @@ mocktail_apply_compile_options(mocktail_updater)
add_library(mocktail_legacy_jnivm STATIC
src/jnivm/jnivm.cc
src/jnivm/jni_references.cc
src/jnivm/jni_fields.cc
src/jnivm/jni_strings.cc
)
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
@@ -285,10 +288,13 @@ add_library(mocktail_runtime STATIC
src/runtime/roblox_capability_resolver.cc
src/runtime/roblox_experience_launch_bridge.cc
src/runtime/roblox_launch_uri.cc
src/runtime/roblox_presence_resolver.cc
src/runtime/roblox_game_session_native_adapter.cc
src/runtime/roblox_web_view_bridge.cc
src/runtime/roblox_permissions_bridge.cc
src/runtime/roblox_account_bridge.cc
src/runtime/roblox_message_bus_request_bridge.cc
src/runtime/roblox_call_protocol_bridge.cc
src/runtime/payload_update_preflight.cc
src/runtime/process_launch_policy.cc
src/runtime/process_diagnostics.cc
@@ -474,7 +480,7 @@ target_link_libraries(mocktail_window PUBLIC
Mocktail::SdlVulkanWsi
SDL3::SDL3
${CMAKE_DL_LIBS}
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay Mocktail::GraphicsFoundation
)
add_library(mocktail_roblox_output_device_runtime STATIC
+6
View File
@@ -95,6 +95,12 @@ The APK is checked before any native code is loaded. It is downloaded on first
launch and is not bundled with Nightcap. The last working copy is kept in case
an update fails.
A leftover `.previous_good-recovery.json` in the payload store marks pending
recovery after a publication or restoration failure and blocks further payload
publication and garbage collection. Check `current.json` and `previous_good.json`
against the saved recovery state and fix the underlying problem before manually
removing the marker.
<details>
<summary>Screenshots</summary>
+11
View File
@@ -49,6 +49,17 @@
gdb
];
desktopItems = [
(pkgs.makeDesktopItem {
name = "Mocktail";
exec = "mocktail";
icon = "mocktail";
desktopName = "Mocktail";
comment = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
categories = [ "Game" ];
})
];
shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
+3
View File
@@ -16,6 +16,9 @@ struct AngleProbeOptions {
bool allow_software_device = false;
};
// Checks ANGLE entry points and client extensions without initializing a display.
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options);
// Loads a pinned ANGLE pair, verifies the required EGL/GLES symbols, and
// initializes an EGL_PLATFORM_ANGLE Vulkan display. No context is fabricated.
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options);
@@ -35,6 +35,10 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination);
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::size_t source_stride_bytes,
std::uint32_t width, std::uint32_t height,
std::uint8_t* destination);
// Replacement textures keyed by the hash of a texture's level-0 compressed
// bytes. An override lives at <override_dir>/<hash>.png. With a dump
+2 -2
View File
@@ -47,8 +47,8 @@ struct RobloxThemeCacheResult {
};
// Reads the current account's Roblox theme without changing appStorage. A
// missing value leaves dark_theme empty so the caller can use Roblox's own
// startup default.
// missing or unrecognized value falls back to AuthenticatedTheme, then the
// caller's startup default.
RobloxThemeCacheResult ReadRobloxThemeCache(
const std::filesystem::path& app_storage_file,
std::int64_t authenticated_user_id);
@@ -0,0 +1,26 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
#include "runtime/roblox_message_bus_request_bridge.h"
namespace mocktail {
namespace runtime {
// In-experience CoreScripts query the app's cross-experience call state before
// initializing voice. Mocktail does not start or accept cross-experience calls,
// so that state is Idle. This query neither joins voice nor opens a microphone.
class RobloxCallProtocolBridge final {
public:
RobloxCallProtocolBridge(JniEnvironmentProvider environment,
RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects);
Status Initialize();
Status Shutdown();
private:
RobloxMessageBusRequestBridge bridge_;
};
} // namespace runtime
} // namespace mocktail
#endif
@@ -15,6 +15,7 @@
#include "mocktail/status.h"
#include "runtime/owned_pthread.h"
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_call_protocol_bridge.h"
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_experience_presence.h"
#include "runtime/roblox_fresh_game_launch_controller.h"
@@ -97,7 +98,9 @@ struct RobloxExperienceSurfaceProvider {
void* context = nullptr;
GameSurface (*snapshot)(void* context) = nullptr;
bool valid() const { return snapshot != nullptr; }
bool valid() const {
return snapshot != nullptr;
}
};
// Production composition for the APK platform protocols and ExperienceProtocol
@@ -256,6 +259,7 @@ class RobloxExperienceComposition final {
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
// Optional: a payload without the AccountProtocol entrypoints still starts.
std::unique_ptr<RobloxAccountBridge> account_bridge_;
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
@@ -0,0 +1,87 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
#include <jni.h>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
namespace mocktail {
namespace runtime {
struct RobloxMessageBusSymbols {
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
jboolean) = nullptr;
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) = nullptr;
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return subscribe_request && delete_connection && publish_response &&
set_async_handler && clear_handler && call_response_handler;
}
};
struct RobloxMessageBusObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring)) = nullptr;
void (*clear_raw_callback)(void*, jobject) = nullptr;
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring,
jstring)) = nullptr;
void (*clear_async_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_raw_callback &&
clear_raw_callback && create_async_handler && clear_async_handler;
}
};
struct RobloxMessageBusReply {
std::string json;
jint code = 0;
const char* outcome = "ok";
};
// Owns both legacy subscriptions and correlated async request handlers.
// A policy receives only a request's method index and JNI-local payload.
class RobloxMessageBusRequestBridge final {
public:
using Handler =
std::function<RobloxMessageBusReply(JNIEnv*, jstring, std::size_t)>;
RobloxMessageBusRequestBridge(JniEnvironmentProvider environment,
RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects,
std::string protocol,
std::vector<std::string> methods,
Handler handler);
~RobloxMessageBusRequestBridge();
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxMessageBusSymbols symbols_;
const RobloxMessageBusObjects objects_;
const std::string protocol_name_;
const std::vector<std::string> methods_;
const Handler handler_;
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
};
} // namespace runtime
} // namespace mocktail
#endif
+5 -65
View File
@@ -1,58 +1,12 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#include <jni.h>
#include <memory>
#include <mutex>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
#include "runtime/roblox_message_bus_request_bridge.h"
namespace mocktail {
namespace runtime {
using RobloxPermissionsMessageBusSymbols = RobloxMessageBusSymbols;
using RobloxPermissionsMessageBusObjects = RobloxMessageBusObjects;
// Exact MessageBus JNI entrypoints used by the APK PermissionsProtocol.
struct RobloxPermissionsMessageBusSymbols {
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
jboolean) = nullptr;
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) = nullptr;
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return subscribe_request && delete_connection && publish_response &&
set_async_handler && clear_handler && call_response_handler;
}
};
struct RobloxPermissionsMessageBusObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring)) = nullptr;
void (*clear_raw_callback)(void*, jobject) = nullptr;
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring,
jstring)) = nullptr;
void (*clear_async_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_raw_callback &&
clear_raw_callback && create_async_handler && clear_async_handler;
}
};
// Installs both legacy protocol subscriptions and correlated async handlers
// before native bootstrap. MICROPHONE_ACCESS follows host capture
// configuration; LOCAL_NETWORK needs no extra runtime permission, matching the
// Android APK. No query/request opens an audio device, enables a disabled
// microphone, or changes Roblox account eligibility. Other capabilities remain
// unsupported.
class RobloxPermissionsBridge final {
public:
RobloxPermissionsBridge(JniEnvironmentProvider environment,
@@ -60,26 +14,12 @@ class RobloxPermissionsBridge final {
RobloxPermissionsMessageBusObjects objects,
bool microphone_enabled);
~RobloxPermissionsBridge();
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxPermissionsMessageBusSymbols symbols_;
const RobloxPermissionsMessageBusObjects objects_;
const bool microphone_enabled_;
// Serializes lifecycle only. Native dispatch never holds this mutex.
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#endif
@@ -0,0 +1,35 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
#include <cstdint>
#include <string>
#include "mocktail/status.h"
namespace mocktail {
namespace runtime {
struct ResolvedFollowUserPlace {
int64_t place_id = 0;
std::string game_instance_id; // Roblox's "gameId" for the running session.
};
// Reads the session cookie mocktail already persists at
// ~/.local/share/mocktail/auth/roblox.cookie (or $XDG_DATA_HOME
// equivalent). Returns an empty string if no session is stored.
std::string ReadStoredRoblosecurityCookie();
// Calls Roblox's presence API to find what place/instance a user is
// currently in. `auth_cookie` is the runtime's already-authenticated
// .ROBLOSECURITY (or equivalent bearer token) — this function performs no
// authentication of its own and requires the runtime's validated session.
//
// Returns an error if the user is offline, has presence hidden, or is not
// in a joinable experience (e.g. in Studio, or privacy settings block it).
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
ResolvedFollowUserPlace* out);
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
+10 -1
View File
@@ -4,6 +4,7 @@
#include <array>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <string>
#include <vector>
@@ -33,11 +34,18 @@ struct PayloadGarbageResult {
explicit operator bool() const { return error.empty(); }
};
// Optional per-store fault hook for atomic manifest writes. Returning true
// makes the selected operation fail through its normal cleanup path.
enum class ManifestWritePhase { kCreate, kWrite, kFileFsync, kRename };
using ManifestWriteFault =
std::function<bool(const std::filesystem::path&, ManifestWritePhase)>;
class PayloadStore final {
public:
PayloadStore(std::filesystem::path root,
std::filesystem::path compatibility_manifest,
std::filesystem::path runtime_binary = {});
std::filesystem::path runtime_binary = {},
ManifestWriteFault manifest_write_fault = {});
PayloadStoreResult Stage(const std::filesystem::path& prepared_payload);
PayloadStoreResult Promote(std::string_view payload_id);
@@ -60,6 +68,7 @@ class PayloadStore final {
std::filesystem::path root_;
std::filesystem::path compatibility_manifest_;
std::filesystem::path runtime_binary_;
ManifestWriteFault manifest_write_fault_;
};
} // namespace mocktail::update
+13 -2
View File
@@ -1,7 +1,10 @@
#ifndef MOCKTAIL_UPDATE_READINESS_CANARY_H_
#define MOCKTAIL_UPDATE_READINESS_CANARY_H_
#include <spawn.h>
#include <filesystem>
#include <functional>
#include <string>
#include <string_view>
@@ -22,6 +25,12 @@ std::string_view CanaryGraphicsBackendName(CanaryGraphicsBackend backend);
bool ParseCanaryGraphicsBackend(std::string_view name,
CanaryGraphicsBackend* backend);
// Optional per-request replacement for the POSIX spawn operation.
// It follows posix_spawn return/ownership semantics; empty uses posix_spawn.
using CanarySpawn =
std::function<int(pid_t*, const char*, const posix_spawn_file_actions_t*,
const posix_spawnattr_t*, char* const[], char* const[])>;
struct CanaryOptions {
std::filesystem::path runtime_binary;
std::filesystem::path payload_directory;
@@ -29,9 +38,9 @@ struct CanaryOptions {
std::filesystem::path host_abi_profile;
std::filesystem::path cache_root;
std::filesystem::path state_root;
CanaryGraphicsBackend graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
CanaryGraphicsBackend graphics_backend = CanaryGraphicsBackend::kDirectVulkan;
int timeout_seconds = 150;
CanarySpawn spawn;
bool probation_candidate() const { return !host_abi_profile.empty(); }
};
@@ -39,6 +48,8 @@ struct CanaryOptions {
struct CanaryResult {
std::filesystem::path log_path;
int exit_code = -1;
// Direct posix_spawn return code; zero when spawn was not attempted.
int spawn_error = 0;
std::string error;
explicit operator bool() const {
+3
View File
@@ -26,6 +26,8 @@ struct UpdatePaths {
// build the single file happens to describe.
std::filesystem::path host_abi_reference_profile;
std::filesystem::path runtime_binary;
// Optional packaging wrapper; runtime_binary identifies the actual ELF.
std::filesystem::path runtime_launcher;
};
struct UpdateRequest {
@@ -38,6 +40,7 @@ struct UpdateRequest {
CanaryGraphicsBackend canary_graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
int progress_fd = -1;
CanarySpawn canary_spawn;
};
struct UpdateResult {
+1
View File
@@ -7,6 +7,7 @@ MOCKTAIL_PORTABLE_MODE=standalone
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_CANARY_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_RUNTIME_BINARY=${SHARUN_DIR}/shared/bin/mocktail
MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
@@ -9,7 +9,7 @@
"--share=ipc",
"--share=network",
"--socket=wayland",
"--socket=fallback-x11",
"--socket=x11",
"--socket=pulseaudio",
"--device=dri",
"--talk-name=com.feralinteractive.GameMode",
@@ -0,0 +1,602 @@
// cc -O2 -o mocktail_webview_host mocktail_webview_host.c $(pkg-config --cflags --libs gtk4 webkitgtk-6.0)
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <glib-unix.h>
#include <gtk/gtk.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <webkit/webkit.h>
#define PORT 2137
#define MAX_PACKET (64u * 1024u + 12u)
#define VERSION "2.725.1142"
enum {
OP_LOAD_URL = 1, OP_SET_TITLE = 2, OP_SET_VISIBLE = 3, OP_EVAL_JS = 4,
OP_CLOSE = 5, OP_SET_BACK_DISABLED = 6, OP_SHOW_DOMAIN_TITLE = 7,
OP_SET_COOKIE = 8, OP_CLEAR_COOKIE = 9, OP_RETAIN_COOKIE = 10
};
enum { EV_EXECUTE_ROBLOX = 1, EV_ROBLOX_WK_HYBRID = 2, EV_READY = 3, EV_COOKIE = 4 };
static const char kBridge[] =
"(() => { 'use strict';"
" const h = window.webkit && window.webkit.messageHandlers;"
" const handler = h && h.executeRoblox; if (!handler) return;"
" const bridge = {};"
" Object.defineProperty(bridge, 'executeRoblox', {"
" value: (q) => handler.postMessage(JSON.parse(q)),"
" enumerable: true, writable: false, configurable: false });"
" try { Object.defineProperty(window, '__globalRobloxAndroidBridge__', {"
" value: bridge, enumerable: true, writable: false, configurable: false });"
" } catch (_) {} })();";
static GtkApplication *app;
static GtkWindow *window;
static WebKitWebView *view;
static WebKitNetworkSession *session;
static int listener = -1;
static int client = -1;
static guint client_read_source;
static guint client_write_source;
static GByteArray *rx;
static GQueue *tx;
static size_t tx_offset;
static gboolean domain_title;
static gboolean login_page;
static gboolean back_disabled;
static gboolean shutting_down;
static char *last_cookie;
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data);
static gboolean on_client(gint fd, GIOCondition cond, gpointer data);
static void apply(uint8_t op, const char *payload, size_t size);
static void create_surface(void);
static void destroy_surface(void);
static void drop_client(const char *why) {
if (client < 0) return;
fprintf(stderr, "client dropped: %s\n", why);
if (client_read_source) {
g_source_remove(client_read_source);
client_read_source = 0;
}
if (client_write_source) {
g_source_remove(client_write_source);
client_write_source = 0;
}
close(client);
client = -1;
g_byte_array_set_size(rx, 0);
while (!g_queue_is_empty(tx)) g_bytes_unref(g_queue_pop_head(tx));
tx_offset = 0;
g_free(last_cookie);
last_cookie = NULL;
destroy_surface();
}
static void quit_app(void) {
if (shutting_down) return;
shutting_down = TRUE;
drop_client("shutting down");
if (app) g_application_quit(G_APPLICATION(app));
}
static void send_event(uint8_t type, const char *payload, size_t size) {
if (client < 0) return;
size_t body = 12 + size;
uint8_t *buffer = g_malloc0(4 + body);
buffer[0] = body >> 24; buffer[1] = body >> 16; buffer[2] = body >> 8; buffer[3] = body;
memcpy(buffer + 4, "MWVE", 4);
buffer[8] = 1;
buffer[9] = type;
buffer[12] = size >> 24; buffer[13] = size >> 16; buffer[14] = size >> 8; buffer[15] = size;
if (size) memcpy(buffer + 16, payload, size);
g_queue_push_tail(tx, g_bytes_new_take(buffer, 4 + body));
if (!client_write_source)
client_write_source = g_unix_fd_add(client, G_IO_OUT | G_IO_HUP | G_IO_ERR, flush_tx, NULL);
}
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data) {
(void)data;
if (cond & (G_IO_HUP | G_IO_ERR)) {
client_write_source = 0;
drop_client("write side closed");
return G_SOURCE_REMOVE;
}
while (!g_queue_is_empty(tx)) {
GBytes *head = g_queue_peek_head(tx);
gsize length;
const uint8_t *bytes = g_bytes_get_data(head, &length);
ssize_t count = send(fd, bytes + tx_offset, length - tx_offset, MSG_DONTWAIT | MSG_NOSIGNAL);
if (count < 0 && errno == EINTR) continue;
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return G_SOURCE_CONTINUE;
if (count <= 0) {
client_write_source = 0;
drop_client("write failed");
return G_SOURCE_REMOVE;
}
tx_offset += (size_t)count;
if (tx_offset == length) {
tx_offset = 0;
g_bytes_unref(g_queue_pop_head(tx));
}
}
client_write_source = 0;
return G_SOURCE_REMOVE;
}
static char *user_agent(void) {
const char *v = getenv("MOCKTAIL_ROBLOX_VERSION");
if (!v || !*v) v = VERSION;
return g_strdup_printf(
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) ROBLOX Android App "
"%s Tablet Hybrid() GooglePlayStore RobloxApp/%s (GlobalDist; GooglePlayStore)", v, v);
}
static void on_cookies(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
GList *cookies = webkit_cookie_manager_get_cookies_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
for (GList *i = cookies; i; i = i->next) {
SoupCookie *c = i->data;
const char *name = soup_cookie_get_name(c);
const char *value = soup_cookie_get_value(c);
if (name && value && strcmp(name, ".ROBLOSECURITY") == 0 && *value &&
(!last_cookie || strcmp(last_cookie, value) != 0)) {
g_free(last_cookie);
last_cookie = g_strdup(value);
send_event(EV_COOKIE, value, strlen(value));
break;
}
}
g_list_free_full(cookies, (GDestroyNotify)soup_cookie_free);
g_clear_error(&error);
}
static void request_cookie(void) {
if (!login_page || !session || client < 0) return;
webkit_cookie_manager_get_cookies(webkit_network_session_get_cookie_manager(session),
"https://www.roblox.com/", NULL, on_cookies, NULL);
}
static void on_cookie_changed(WebKitCookieManager *manager, gpointer data) {
(void)manager; (void)data;
request_cookie();
}
static void configure_agent(const char *url) {
WebKitSettings *settings = webkit_web_view_get_settings(view);
GUri *uri = g_uri_parse(url, G_URI_FLAGS_NONE, NULL);
const char *path = uri ? g_uri_get_path(uri) : NULL;
login_page = path && (strcmp(path, "/login") == 0 || strcmp(path, "/login/") == 0);
if (uri) g_uri_unref(uri);
if (login_page) {
webkit_settings_set_user_agent(settings, NULL);
request_cookie();
} else {
char *agent = user_agent();
webkit_settings_set_user_agent(settings, agent);
g_free(agent);
}
}
static void update_title(void) {
if (!domain_title || !window || !view) return;
const char *uri = webkit_web_view_get_uri(view);
if (!uri) return;
GUri *parsed = g_uri_parse(uri, G_URI_FLAGS_NONE, NULL);
if (!parsed) return;
const char *host = g_uri_get_host(parsed);
if (host) gtk_window_set_title(window, host);
g_uri_unref(parsed);
}
static void on_load_changed(WebKitWebView *v, WebKitLoadEvent event, gpointer data) {
(void)v; (void)data;
if (event == WEBKIT_LOAD_COMMITTED || event == WEBKIT_LOAD_FINISHED) {
update_title();
request_cookie();
}
}
static GtkWidget *on_create(WebKitWebView *v, WebKitNavigationAction *action, gpointer data) {
(void)v; (void)data;
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = request ? webkit_uri_request_get_uri(request) : NULL;
if (uri && view) webkit_web_view_load_uri(view, uri);
return NULL;
}
static char *json_string_after(const char *json, const char *key) {
char *needle = g_strdup_printf("\"%s\"", key);
const char *at = strstr(json, needle);
g_free(needle);
if (!at) return NULL;
at = strchr(at + strlen(key) + 2, ':');
if (!at) return NULL;
at++;
while (*at == ' ') at++;
if (*at != '"') return NULL;
at++;
GString *out = g_string_new(NULL);
while (*at && *at != '"') {
if (*at == '\\' && at[1]) at++;
g_string_append_c(out, *at);
at++;
}
return g_string_free(out, FALSE);
}
static void on_callback_done(GObject *source, GAsyncResult *result, gpointer data) {
gboolean close_after = GPOINTER_TO_INT(data);
GError *error = NULL;
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
gboolean confirmed = !error && value && jsc_value_is_boolean(value) && jsc_value_to_boolean(value);
if (value) g_object_unref(value);
g_clear_error(&error);
if (confirmed && close_after) destroy_surface();
}
static void acknowledge_page_command(const char *json) {
if (!view || !json) return;
char *feature = json_string_after(json, "feature");
char *callback = json_string_after(json, "callbackID");
if (feature && callback && *callback) {
gboolean success = strcmp(feature, "CaptchaSuccess") == 0;
if (success || strcmp(feature, "CaptchaShown") == 0) {
GString *quoted = g_string_new("\"");
for (const char *p = callback; *p; p++) {
if (*p == '"' || *p == '\\') g_string_append_c(quoted, '\\');
g_string_append_c(quoted, *p);
}
g_string_append_c(quoted, '"');
char *script = g_strdup_printf(
"(() => { try { if (window.Roblox && window.Roblox.Hybrid && "
"window.Roblox.Hybrid.Bridge && typeof window.Roblox.Hybrid.Bridge.nativeCallback === 'function') { "
"window.Roblox.Hybrid.Bridge.nativeCallback(%s, true, {}); return true; } } catch (_) {} return false; })();",
quoted->str);
webkit_web_view_evaluate_javascript(view, script, -1, NULL, NULL, NULL,
on_callback_done, GINT_TO_POINTER(success));
g_free(script);
g_string_free(quoted, TRUE);
}
}
g_free(feature);
g_free(callback);
}
static void forward(uint8_t type, JSCValue *value) {
char *text = NULL;
if (jsc_value_is_string(value)) text = jsc_value_to_string(value);
else if (jsc_value_is_object(value)) text = jsc_value_to_json(value, 0);
if (!text) return;
size_t size = strlen(text);
if (size && size <= 64u * 1024u) {
send_event(type, text, size);
acknowledge_page_command(text);
}
g_free(text);
}
static void on_execute_roblox(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
(void)m; (void)data;
forward(EV_EXECUTE_ROBLOX, value);
}
static void on_hybrid(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
(void)m; (void)data;
if (!jsc_value_is_object(value)) return;
JSCValue *command = jsc_value_object_get_property(value, "command");
if (command) {
forward(EV_ROBLOX_WK_HYBRID, command);
g_object_unref(command);
}
}
static void on_view_close(WebKitWebView *v, gpointer data) {
(void)v; (void)data;
destroy_surface();
}
static gboolean on_close_request(GtkWindow *w, gpointer data) {
(void)w; (void)data;
window = NULL;
view = NULL;
return FALSE;
}
static void on_reload(GtkButton *button, gpointer data) {
(void)button; (void)data;
if (view) webkit_web_view_reload_bypass_cache(view);
}
static void on_eval(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
if (value) g_object_unref(value);
g_clear_error(&error);
}
static void on_cookie_done(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
webkit_cookie_manager_add_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
g_clear_error(&error);
}
static void on_cookie_deleted(GObject *source, GAsyncResult *result, gpointer data) {
(void)data;
GError *error = NULL;
webkit_cookie_manager_delete_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
g_clear_error(&error);
}
static void ensure_session(void) {
if (session) return;
gchar *data_dir = g_build_filename(g_get_user_data_dir(), "mocktail", "webview", NULL);
gchar *cache_dir = g_build_filename(g_get_user_cache_dir(), "mocktail", "webview", NULL);
g_mkdir_with_parents(data_dir, 0700);
g_mkdir_with_parents(cache_dir, 0700);
gchar *cookie_db = g_build_filename(data_dir, "cookies.sqlite", NULL);
session = webkit_network_session_new(data_dir, cache_dir);
g_free(data_dir);
g_free(cache_dir);
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
webkit_cookie_manager_set_persistent_storage(cookies, cookie_db, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
webkit_cookie_manager_set_accept_policy(cookies, WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS);
g_signal_connect(cookies, "changed", G_CALLBACK(on_cookie_changed), NULL);
g_free(cookie_db);
webkit_network_session_set_itp_enabled(session, FALSE);
webkit_network_session_set_persistent_credential_storage_enabled(session, TRUE);
}
static void create_surface(void) {
if (window || view) return;
ensure_session();
WebKitUserContentManager *manager = webkit_user_content_manager_new();
WebKitWebView *created = WEBKIT_WEB_VIEW(g_object_new(WEBKIT_TYPE_WEB_VIEW, "network-session", session,
"user-content-manager", manager, NULL));
g_object_unref(manager);
manager = webkit_web_view_get_user_content_manager(created);
g_signal_connect(manager, "script-message-received::executeRoblox", G_CALLBACK(on_execute_roblox), NULL);
g_signal_connect(manager, "script-message-received::RobloxWKHybrid", G_CALLBACK(on_hybrid), NULL);
g_signal_connect(manager, "script-message-received::mocktailRobloxBridge", G_CALLBACK(on_execute_roblox), NULL);
webkit_user_content_manager_register_script_message_handler(manager, "executeRoblox", NULL);
webkit_user_content_manager_register_script_message_handler(manager, "RobloxWKHybrid", NULL);
webkit_user_content_manager_register_script_message_handler(manager, "mocktailRobloxBridge", NULL);
WebKitUserScript *script = webkit_user_script_new(kBridge, WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, NULL, NULL);
webkit_user_content_manager_add_script(manager, script);
webkit_user_script_unref(script);
WebKitSettings *settings = webkit_web_view_get_settings(created);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
webkit_settings_set_enable_back_forward_navigation_gestures(settings, !back_disabled);
char *agent = user_agent();
webkit_settings_set_user_agent(settings, agent);
g_free(agent);
g_signal_connect(created, "load-changed", G_CALLBACK(on_load_changed), NULL);
g_signal_connect(created, "create", G_CALLBACK(on_create), NULL);
g_signal_connect(created, "close", G_CALLBACK(on_view_close), NULL);
GtkWindow *created_window = GTK_WINDOW(gtk_application_window_new(app));
gtk_window_set_title(created_window, "Roblox");
gtk_window_set_default_size(created_window, 1100, 760);
GtkWidget *header = gtk_header_bar_new();
GtkWidget *reload = gtk_button_new_from_icon_name("view-refresh-symbolic");
g_signal_connect(reload, "clicked", G_CALLBACK(on_reload), NULL);
gtk_header_bar_pack_start(GTK_HEADER_BAR(header), reload);
gtk_window_set_titlebar(created_window, header);
gtk_widget_set_vexpand(GTK_WIDGET(created), TRUE);
gtk_window_set_child(created_window, GTK_WIDGET(created));
g_signal_connect(created_window, "close-request", G_CALLBACK(on_close_request), NULL);
view = created;
window = created_window;
}
static void destroy_surface(void) {
GtkWindow *w = window;
window = NULL;
view = NULL;
login_page = FALSE;
domain_title = FALSE;
if (w) gtk_window_destroy(w);
}
static void apply(uint8_t op, const char *payload, size_t size) {
if (op == OP_CLOSE) {
destroy_surface();
return;
}
if (op == OP_SET_BACK_DISABLED) {
back_disabled = size == 1 && payload[0];
if (view) webkit_settings_set_enable_back_forward_navigation_gestures(
webkit_web_view_get_settings(view), !back_disabled);
return;
}
if (op == OP_SHOW_DOMAIN_TITLE) {
domain_title = size == 1 && payload[0];
update_title();
return;
}
if (op == OP_SET_COOKIE || op == OP_CLEAR_COOKIE) {
ensure_session();
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
if (op == OP_SET_COOKIE) {
char *value = g_strndup(payload, size);
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", value, ".roblox.com", "/", -1);
g_free(value);
if (cookie) {
soup_cookie_set_secure(cookie, TRUE);
soup_cookie_set_http_only(cookie, TRUE);
soup_cookie_set_same_site_policy(cookie, SOUP_SAME_SITE_POLICY_LAX);
webkit_cookie_manager_add_cookie(cookies, cookie, NULL, on_cookie_done, NULL);
soup_cookie_free(cookie);
}
} else {
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", "", ".roblox.com", "/", -1);
if (cookie) {
webkit_cookie_manager_delete_cookie(cookies, cookie, NULL, on_cookie_deleted, NULL);
soup_cookie_free(cookie);
}
}
return;
}
if (op == OP_RETAIN_COOKIE) return;
if (op == OP_LOAD_URL) create_surface();
if (!window || !view) return;
switch (op) {
case OP_LOAD_URL: {
char *url = g_strndup(payload, size);
configure_agent(url);
webkit_web_view_load_uri(view, url);
gtk_window_present(window);
g_free(url);
break;
}
case OP_SET_TITLE: {
char *title = g_strndup(payload, size);
gtk_window_set_title(window, title);
g_free(title);
break;
}
case OP_SET_VISIBLE:
if (size == 1 && payload[0]) gtk_window_present(window);
else gtk_widget_set_visible(GTK_WIDGET(window), FALSE);
break;
case OP_EVAL_JS:
webkit_web_view_evaluate_javascript(view, payload, (gssize)size, NULL, NULL, NULL, on_eval, NULL);
break;
default:
break;
}
}
static gboolean process_frames(void) {
while (rx->len >= 4) {
uint32_t length = ((uint32_t)rx->data[0] << 24) | ((uint32_t)rx->data[1] << 16) |
((uint32_t)rx->data[2] << 8) | rx->data[3];
if (length > MAX_PACKET) return FALSE;
if (rx->len < 4u + length) break;
uint8_t *copy = g_memdup2(rx->data + 4, length);
g_byte_array_remove_range(rx, 0, 4 + length);
if (length >= 12 && memcmp(copy, "MWVC", 4) == 0) {
size_t size = ((size_t)copy[8] << 24) | ((size_t)copy[9] << 16) |
((size_t)copy[10] << 8) | copy[11];
if (size == length - 12) apply(copy[5], (const char *)copy + 12, size);
}
g_free(copy);
if (client < 0) return TRUE;
}
return TRUE;
}
static gboolean on_client(gint fd, GIOCondition cond, gpointer data) {
(void)data;
uint8_t buffer[8192];
while (TRUE) {
ssize_t count = read(fd, buffer, sizeof buffer);
if (count < 0 && errno == EINTR) continue;
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
if (count <= 0) {
client_read_source = 0;
drop_client("client closed");
return G_SOURCE_REMOVE;
}
g_byte_array_append(rx, buffer, (guint)count);
if (!process_frames()) {
client_read_source = 0;
drop_client("bad frame");
return G_SOURCE_REMOVE;
}
if (client < 0) return G_SOURCE_REMOVE;
}
if (cond & (G_IO_HUP | G_IO_ERR)) {
client_read_source = 0;
drop_client("client hung up");
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
static gboolean on_listener(gint fd, GIOCondition cond, gpointer data) {
(void)cond; (void)data;
int accepted = accept(fd, NULL, NULL);
if (accepted < 0) return G_SOURCE_CONTINUE;
if (client >= 0) drop_client("replaced by a new connection");
fcntl(accepted, F_SETFD, FD_CLOEXEC);
fcntl(accepted, F_SETFL, fcntl(accepted, F_GETFL) | O_NONBLOCK);
int one = 1;
setsockopt(accepted, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
client = accepted;
fprintf(stderr, "client connected\n");
client_read_source = g_unix_fd_add(client, G_IO_IN | G_IO_HUP | G_IO_ERR, on_client, NULL);
send_event(EV_READY, "", 0);
return G_SOURCE_CONTINUE;
}
static int listen_socket(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
fcntl(fd, F_SETFD, FD_CLOEXEC);
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
struct sockaddr_in address;
memset(&address, 0, sizeof address);
address.sin_family = AF_INET;
address.sin_port = htons(PORT);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(fd, (struct sockaddr *)&address, sizeof address) != 0 || listen(fd, 4) != 0) {
close(fd);
return -1;
}
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
return fd;
}
static void activate(GtkApplication *application, gpointer data) {
(void)application; (void)data;
}
static gboolean on_sigterm(gpointer data) {
(void)data;
quit_app();
return G_SOURCE_REMOVE;
}
int main(int argc, char **argv) {
(void)argc;
setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 0);
setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 0);
rx = g_byte_array_new();
tx = g_queue_new();
listener = listen_socket();
if (listener < 0) {
fprintf(stderr, "cannot listen on 127.0.0.1:%d: %s\n", PORT, strerror(errno));
return 1;
}
g_unix_fd_add(listener, G_IO_IN, on_listener, NULL);
g_unix_signal_add(SIGTERM, on_sigterm, NULL);
g_unix_signal_add(SIGINT, on_sigterm, NULL);
app = gtk_application_new("org.mocktail.WebViewHost", G_APPLICATION_NON_UNIQUE);
g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
g_application_hold(G_APPLICATION(app));
int status = g_application_run(G_APPLICATION(app), 1, argv);
g_object_unref(app);
return status;
}
@@ -57,6 +57,20 @@
<binary>mocktail</binary>
</provides>
<releases>
<release version="0.3.3" date="2026-10-04">
<description>
<ul>
<li>Joining a friend from a follow user link now works.</li>
<li>Roblox textures decode about 3 times faster, so loading stalls are shorter.</li>
<li>A failed Roblox update now restores your previous install instead of leaving it half changed.</li>
<li>Fixed crashes and freezes with audio, the text overlay, the mouse lock and saved themes.</li>
<li>Going to another page in the web view no longer closes it.</li>
<li>Clicking while typing no longer closes the text box.</li>
<li>Fixed the sky showing the wrong textures.</li>
<li>The Flatpak build on NVIDIA now uses X11 to avoid Wayland hangs.</li>
</ul>
</description>
</release>
<release version="0.3.2" date="2026-09-29">
<description>
<ul>
+7 -3
View File
@@ -409,9 +409,11 @@ WriteClassicPixbufCache() {
Die "classic GdkPixbuf loader cache is unavailable"
sed -E \
-e 's@^# LoaderDir = .*$@# LoaderDir = lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders@' \
-e 's#^"[^"]*/(libpixbufloader[-_][^/"]+\.so)"$#"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/\1"#' \
-e 's#^"[^"]*/([^/"]+\.so)"$#"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/\1"#' \
"${source_cache}" >"${destination_cache}"
if grep -Eq '^"/(usr|lib|lib64|opt|home|hdd|tmp)/' \
# Module records contain only a quoted path. Image signatures such as XPM
# can also start with '/' but have additional fields after the string.
if grep -Eq '^"/[^"]*"$' \
"${destination_cache}"; then
Die "GdkPixbuf cache still contains an absolute loader path"
fi
@@ -663,4 +665,6 @@ Main() {
PublishResources
}
Main "$@"
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
Main "$@"
fi
+42 -20
View File
@@ -28,6 +28,7 @@ STORE_ROOT=""
TEMP_DIR=""
ROLLBACK_DIR=""
ROLLBACK_NEEDED=false
BACKUP_COMPLETE=false
usage() {
cat <<'EOF'
@@ -146,34 +147,54 @@ certificate_digests() {
}
restore_payload() {
[[ "${ROLLBACK_NEEDED}" == true && -d "${ROLLBACK_DIR}" ]] || return 0
[[ "${ROLLBACK_NEEDED}" == true ]] || return 0
[[ -d "${ROLLBACK_DIR}" ]] || return 1
log WARN "restoring the previous payload"
mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")"
mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")" || return 1
local path
for path in libroblox.so sober_apk assets; do
if [[ -e "${RBX_BIN_DIR}/${path}" ]]; then
mv -- "${RBX_BIN_DIR}/${path}" "${TEMP_DIR}/failed-${path}"
local path active_path failed_path restore_status=0
for path in libroblox.so sober_apk assets roblox_payload.json; do
active_path="${RBX_BIN_DIR}/${path}"
failed_path="${TEMP_DIR}/failed-${path}"
if [[ "${path}" == roblox_payload.json ]]; then
active_path="${METADATA_PATH}"
failed_path="${TEMP_DIR}/failed-metadata.json"
fi
if [[ -e "${ROLLBACK_DIR}/${path}" ]]; then
mv -- "${ROLLBACK_DIR}/${path}" "${RBX_BIN_DIR}/${path}"
# Before activation, anything still active belongs to the old payload.
# Only components removed by the backup need restoring in this phase.
if [[ "${BACKUP_COMPLETE}" == false ]]; then
if [[ -e "${active_path}" || -L "${active_path}" ||
! -e "${ROLLBACK_DIR}/${path}" && ! -L "${ROLLBACK_DIR}/${path}" ]]; then
continue
fi
fi
if [[ -e "${active_path}" || -L "${active_path}" ]]; then
# Do not restore onto an occupied destination if removing the new
# component fails: mv could overwrite a file or nest a directory.
if ! mv -- "${active_path}" "${failed_path}"; then
restore_status=1
continue
fi
fi
if [[ -e "${ROLLBACK_DIR}/${path}" || -L "${ROLLBACK_DIR}/${path}" ]]; then
mv -- "${ROLLBACK_DIR}/${path}" "${active_path}" || restore_status=1
fi
done
if [[ -e "${METADATA_PATH}" ]]; then
mv -- "${METADATA_PATH}" "${TEMP_DIR}/failed-metadata.json"
fi
if [[ -e "${ROLLBACK_DIR}/roblox_payload.json" ]]; then
mv -- "${ROLLBACK_DIR}/roblox_payload.json" "${METADATA_PATH}"
fi
return "${restore_status}"
}
cleanup() {
local -r status=$?
local status=$?
trap - EXIT
set +e
restore_payload
if ! restore_payload; then
log ERROR "payload restoration failed; recovery files retained at ${TEMP_DIR}"
(( status != 0 )) || status=1
exit "${status}"
fi
if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then
rm -rf -- "${TEMP_DIR}"
fi
@@ -401,17 +422,18 @@ main() {
mkdir -p "${VERSIONS_DIR}" "$(dirname -- "${METADATA_PATH}")"
ROLLBACK_DIR="${TEMP_DIR}/rollback"
mkdir -p "${ROLLBACK_DIR}"
ROLLBACK_NEEDED=true
local active_path
for active_path in libroblox.so sober_apk assets; do
if [[ -e "${RBX_BIN_DIR}/${active_path}" ]]; then
if [[ -e "${RBX_BIN_DIR}/${active_path}" || -L "${RBX_BIN_DIR}/${active_path}" ]]; then
mv -- "${RBX_BIN_DIR}/${active_path}" "${ROLLBACK_DIR}/${active_path}"
fi
done
if [[ -e "${METADATA_PATH}" ]]; then
if [[ -e "${METADATA_PATH}" || -L "${METADATA_PATH}" ]]; then
mv -- "${METADATA_PATH}" "${ROLLBACK_DIR}/roblox_payload.json"
fi
ROLLBACK_NEEDED=true
BACKUP_COMPLETE=true
mv -- "${stage_dir}/libroblox.so" "${RBX_BIN_DIR}/libroblox.so"
mv -- "${stage_dir}/sober_apk" "${RBX_BIN_DIR}/sober_apk"
+40 -81
View File
@@ -12,9 +12,7 @@
#include <mutex>
#include <new>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace mocktail::audio {
namespace {
@@ -55,10 +53,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
};
struct ReleaseTicket {
std::shared_ptr<State> state;
std::uint64_t id = 0;
std::uint64_t generation = 0;
bool in_use = false;
const std::shared_ptr<State> state;
const std::uint64_t generation;
};
struct CallbackJob {
@@ -71,13 +67,7 @@ struct OpenSlSimpleBufferQueueAdapter::State
: sink(std::move(owned_sink)),
max_buffers(options.max_buffers),
event_callback(options.event_callback),
event_context(options.event_context),
ticket_pool(options.max_buffers) {
free_tickets.reserve(options.max_buffers);
for (std::size_t i = options.max_buffers; i > 0; --i) {
free_tickets.push_back(i - 1);
}
}
event_context(options.event_context) {}
static const AndroidSimpleBufferQueueTable kQueueTable;
@@ -103,7 +93,6 @@ struct OpenSlSimpleBufferQueueAdapter::State
const std::shared_ptr<State> state = raw_state->shared_from_this();
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
std::uint64_t id = 0;
ReleaseTicket* ticket = nullptr;
{
std::lock_guard<std::mutex> lock(state->mutex);
@@ -111,24 +100,24 @@ struct OpenSlSimpleBufferQueueAdapter::State
return opensl_abi::kResultPreconditionsViolated;
}
if (state->pending_count.load(std::memory_order_relaxed) >=
state->max_buffers ||
state->free_tickets.empty()) {
state->max_buffers) {
return opensl_abi::kResultBufferInsufficient;
}
const std::size_t slot_index = state->free_tickets.back();
state->free_tickets.pop_back();
ticket = &state->ticket_pool[slot_index];
id = state->next_id++;
ticket->state = state;
ticket->id = id;
ticket->generation = state->generation;
ticket->in_use = true;
ticket = new (std::nothrow)
ReleaseTicket{state, state->generation};
if (ticket == nullptr) {
state->last_status = Status::Error(
StatusCode::kUnavailable, "unable to allocate OpenSL release ticket");
return ToOpenSlResult(state->last_status);
}
state->pending_count.fetch_add(1, std::memory_order_relaxed);
++state->submitted_buffers;
}
const PcmBuffer pcm{buffer, static_cast<std::size_t>(size),
&State::ReleaseBuffer, ticket};
// A successful enqueue may release synchronously. Only a failed enqueue
// leaves ownership here; its contract forbids a release callback.
const Status status = state->sink->Enqueue(pcm);
if (status.ok()) {
state->NotifyEvent(OpenSlBufferQueueEvent::kSubmitted,
@@ -136,22 +125,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
return opensl_abi::kResultSuccess;
}
const std::unique_ptr<ReleaseTicket> failed_ticket(ticket);
{
std::lock_guard<std::mutex> lock(state->mutex);
state->last_status = status;
if (ticket->in_use && ticket->id == id) {
ticket->in_use = false;
ticket->state.reset();
const std::size_t slot_index =
static_cast<std::size_t>(ticket - state->ticket_pool.data());
state->free_tickets.push_back(slot_index);
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (state->submitted_buffers > 0) {
--state->submitted_buffers;
}
}
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
--state->submitted_buffers;
}
return ToOpenSlResult(status);
}
@@ -164,25 +143,14 @@ struct OpenSlSimpleBufferQueueAdapter::State
}
const std::shared_ptr<State> state = raw_state->shared_from_this();
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
std::size_t discarded_count = 0;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (state->stopping) {
return opensl_abi::kResultPreconditionsViolated;
}
++state->generation;
state->free_tickets.clear();
for (auto& ticket : state->ticket_pool) {
if (ticket.in_use) {
ticket.in_use = false;
ticket.state.reset();
++discarded_count;
}
}
for (std::size_t i = state->max_buffers; i > 0; --i) {
state->free_tickets.push_back(i - 1);
}
state->discarded_buffers += discarded_count;
state->discarded_buffers +=
state->pending_count.load(std::memory_order_relaxed);
state->pending_count.store(0, std::memory_order_relaxed);
}
const Status status = state->sink->Clear();
@@ -226,7 +194,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
static void ReleaseBuffer(void* context, const void* /*data*/,
std::size_t size_bytes) {
auto* ticket = static_cast<ReleaseTicket*>(context);
const std::unique_ptr<ReleaseTicket> ticket(
static_cast<ReleaseTicket*>(context));
if (ticket == nullptr) {
return;
}
@@ -239,29 +208,27 @@ struct OpenSlSimpleBufferQueueAdapter::State
bool discarded = false;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (ticket->in_use) {
ticket->in_use = false;
ticket->state.reset();
const std::size_t slot_index =
static_cast<std::size_t>(ticket - state->ticket_pool.data());
state->free_tickets.push_back(slot_index);
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (!state->stopping && ticket->generation == state->generation) {
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
++state->consumed_buffers;
state->consumed_bytes += size_bytes;
consumed = true;
if (state->callback != nullptr) {
state->callback_jobs.push_back(
CallbackJob{state->callback, state->callback_context});
notify_worker = true;
}
} else {
++state->discarded_buffers;
discarded = true;
// Clear already counted older generations as discarded. Their tickets
// remain alive until release without affecting newly enqueued buffers.
if (ticket->generation != state->generation) {
return;
}
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (!state->stopping) {
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
++state->consumed_buffers;
state->consumed_bytes += size_bytes;
consumed = true;
if (state->callback != nullptr) {
state->callback_jobs.push_back(
CallbackJob{state->callback, state->callback_context});
notify_worker = true;
}
} else {
++state->discarded_buffers;
discarded = true;
}
}
if (consumed) {
@@ -374,14 +341,11 @@ struct OpenSlSimpleBufferQueueAdapter::State
void* const event_context;
Handle handle;
pthread_t callback_thread{};
std::vector<ReleaseTicket> ticket_pool;
std::vector<std::size_t> free_tickets;
std::atomic<std::size_t> pending_count{0};
std::deque<CallbackJob> callback_jobs;
AndroidSimpleBufferQueueCallback callback = nullptr;
void* callback_context = nullptr;
Status last_status;
std::uint64_t next_id = 1;
std::uint64_t generation = 1;
std::atomic<std::uint64_t> processed_buffers{0};
std::uint64_t submitted_buffers = 0;
@@ -547,7 +511,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
}
state->shutdown_started = true;
state->stopping = true;
++state->generation;
state->callback = nullptr;
state->callback_context = nullptr;
state->callback_jobs.clear();
@@ -566,10 +529,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
state->last_status = Status::Error(
StatusCode::kPlatformError,
"audio sink did not release all borrowed OpenSL buffers");
for (auto& ticket : state->ticket_pool) {
ticket.in_use = false;
ticket.state.reset();
}
state->pending_count.store(0, std::memory_order_relaxed);
}
state->shutdown_complete = true;
+50 -3
View File
@@ -15,6 +15,49 @@ namespace {
constexpr int kBionicAddressInfoFailure = 4;
constexpr int kBionicAddressInfoMemory = 6;
constexpr int kBionicAddressInfoBadFlags = 3;
struct AddressInfoFlag {
int bionic;
int host;
};
// Bundled Android netdb.h AI_MASK includes only these five flags.
constexpr std::array<AddressInfoFlag, 5> kAddressInfoFlags = {{
{0x00000001, AI_PASSIVE},
{0x00000002, AI_CANONNAME},
{0x00000004, AI_NUMERICHOST},
{0x00000008, AI_NUMERICSERV},
{0x00000400, AI_ADDRCONFIG},
}};
constexpr int SupportedBionicAddressInfoFlags() noexcept {
int flags = 0;
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
flags |= entry.bionic;
}
return flags;
}
int HostAddressInfoFlags(int bionic_flags) noexcept {
int flags = 0;
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
if ((bionic_flags & entry.bionic) != 0) {
flags |= entry.host;
}
}
return flags;
}
int BionicAddressInfoFlags(int host_flags) noexcept {
int flags = 0;
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
if ((host_flags & entry.host) != 0) {
flags |= entry.bionic;
}
}
return flags;
}
constexpr std::array<std::string_view, 3> kBlockedUploadSuffixes = {
"crashes.rbxinfra.com",
@@ -63,7 +106,7 @@ int BionicAddressInfoError(int host_error) noexcept {
return 2;
}
if (host_error == EAI_BADFLAGS) {
return 3;
return kBionicAddressInfoBadFlags;
}
if (host_error == EAI_FAIL) {
return kBionicAddressInfoFailure;
@@ -102,7 +145,7 @@ int BionicAddressInfoError(int host_error) noexcept {
addrinfo HostHints(const BionicAddressInfo* hints) noexcept {
addrinfo host_hints{};
if (hints != nullptr) {
host_hints.ai_flags = hints->ai_flags;
host_hints.ai_flags = HostAddressInfoFlags(hints->ai_flags);
host_hints.ai_family = hints->ai_family;
host_hints.ai_socktype = hints->ai_socktype;
host_hints.ai_protocol = hints->ai_protocol;
@@ -122,7 +165,7 @@ bool CopyHostAddressInfo(const addrinfo* host,
BionicFreeAddressInfo(head);
return false;
}
node->ai_flags = current->ai_flags;
node->ai_flags = BionicAddressInfoFlags(current->ai_flags);
node->ai_family = current->ai_family;
node->ai_socktype = current->ai_socktype;
node->ai_protocol = current->ai_protocol;
@@ -187,6 +230,10 @@ int BionicGetAddressInfo(const char* node, const char* service,
if (node != nullptr && IsBlockedCrashReportUploadHost(node)) {
return kBionicAddressInfoNameNotFound;
}
if (hints != nullptr &&
(hints->ai_flags & ~SupportedBionicAddressInfoFlags()) != 0) {
return kBionicAddressInfoBadFlags;
}
const addrinfo host_hints = HostHints(hints);
addrinfo* host_result = nullptr;
+26 -6
View File
@@ -67,9 +67,8 @@ bool HasSymbols(const SharedLibrary& library,
return true;
}
} // namespace
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
BackendCapability CheckAngleLibraries(const AngleProbeOptions& options,
bool initialize_display) {
if (options.egl_library_path.empty() || options.gles_library_path.empty()) {
return Unavailable(
"pinned ANGLE EGL and GLES library paths are both required");
@@ -104,6 +103,20 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
missing);
}
auto query_string =
reinterpret_cast<PFNEGLQUERYSTRINGPROC>(egl.Find("eglQueryString"));
const char* extensions = query_string(EGL_NO_DISPLAY, EGL_EXTENSIONS);
const std::string extension_list =
std::string(" ") + (extensions != nullptr ? extensions : "") + " ";
if (extension_list.find(" EGL_ANGLE_platform_angle ") == std::string::npos) {
return Unavailable("EGL library does not support EGL_ANGLE_platform_angle");
}
if (!initialize_display) {
return {GraphicsBackendKind::kAngleVulkan, CapabilityState::kLoadable,
HardwareAcceleration::kUnknown,
"ANGLE EGL/GLES entry points available"};
}
auto get_proc_address =
reinterpret_cast<PFNEGLGETPROCADDRESSPROC>(egl.Find("eglGetProcAddress"));
auto get_platform_display = reinterpret_cast<PFNEGLGETPLATFORMDISPLAYEXTPROC>(
@@ -121,9 +134,6 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
reinterpret_cast<PFNEGLINITIALIZEPROC>(egl.Find("eglInitialize"));
auto terminate =
reinterpret_cast<PFNEGLTERMINATEPROC>(egl.Find("eglTerminate"));
auto query_string =
reinterpret_cast<PFNEGLQUERYSTRINGPROC>(egl.Find("eglQueryString"));
const EGLint device_type =
options.allow_software_device
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE
@@ -173,5 +183,15 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
std::move(detail)};
}
} // namespace
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options) {
return CheckAngleLibraries(options, false);
}
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
return CheckAngleLibraries(options, true);
}
} // namespace graphics
} // namespace mocktail
+266 -86
View File
@@ -2,7 +2,11 @@
#include <algorithm>
#include <array>
#include <cstring>
#include <pthread.h>
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
#include <immintrin.h>
#endif
#include <atomic>
#include <condition_variable>
@@ -42,9 +46,46 @@ int Expand5(int value) { return (value << 3) | (value >> 2); }
int Expand6(int value) { return (value << 2) | (value >> 4); }
int Expand7(int value) { return (value << 1) | (value >> 6); }
inline std::uint32_t MakeRgba32(int r, int g, int bl, int a) {
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
__m128i v = _mm_set_epi32(a, bl, g, r);
__m128i p16 = _mm_packs_epi32(v, v);
__m128i p8 = _mm_packus_epi16(p16, p16);
return static_cast<std::uint32_t>(_mm_cvtsi128_si32(p8));
#else
return static_cast<std::uint32_t>(Clamp255(r)) |
(static_cast<std::uint32_t>(Clamp255(g)) << 8) |
(static_cast<std::uint32_t>(Clamp255(bl)) << 16) |
(static_cast<std::uint32_t>(static_cast<std::uint8_t>(a)) << 24);
#endif
}
#if defined(__SSSE3__) || defined(__x86_64__) || defined(_M_X64)
struct ColumnShuffleTable {
alignas(16) std::uint8_t masks[256][16];
constexpr ColumnShuffleTable() : masks{} {
for (int m = 0; m < 16; ++m) {
for (int l = 0; l < 16; ++l) {
const int idx = (m << 4) | l;
for (int p = 0; p < 4; ++p) {
const int color_idx = (((m >> p) & 1) << 1) | ((l >> p) & 1);
for (int b = 0; b < 4; ++b) {
masks[idx][p * 4 + b] = static_cast<std::uint8_t>(color_idx * 4 + b);
}
}
}
}
}
};
inline constexpr ColumnShuffleTable kColShuffleTable{};
#endif
// Decodes one ETC2 RGB block into 16 column-major RGBA texels (x * 4 + y).
#if (defined(__GNUC__) || defined(__clang__)) && (defined(__x86_64__) || defined(_M_X64))
__attribute__((target("ssse3")))
#endif
void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
std::array<std::uint8_t, 64>* out) {
std::uint32_t* out32) {
static constexpr int kDelta[8] = {0, 1, 2, 3, -4, -3, -2, -1};
const bool diff_or_opaque = (b[3] & 2) != 0;
const bool opaque = !punchthrough || diff_or_opaque;
@@ -53,13 +94,6 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
auto index_of = [msb, lsb](int texel) {
return (((msb >> texel) & 1) << 1) | ((lsb >> texel) & 1);
};
auto write = [out](int texel, int r, int g, int bl, int a) {
const std::size_t o = static_cast<std::size_t>(texel) * 4;
(*out)[o] = Clamp255(r);
(*out)[o + 1] = Clamp255(g);
(*out)[o + 2] = Clamp255(bl);
(*out)[o + 3] = static_cast<std::uint8_t>(a);
};
const int r_sum = (b[0] >> 3) + kDelta[b[0] & 7];
const int g_sum = (b[1] >> 3) + kDelta[b[1] & 7];
@@ -100,13 +134,13 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
paint[3][c] = c2[c] - d;
}
}
std::uint32_t paint32[4];
for (int i = 0; i < 4; ++i) {
paint32[i] = MakeRgba32(paint[i][0], paint[i][1], paint[i][2], 255);
}
for (int texel = 0; texel < 16; ++texel) {
const int index = index_of(texel);
if (!opaque && index == 2) {
write(texel, 0, 0, 0, 0);
} else {
write(texel, paint[index][0], paint[index][1], paint[index][2], 255);
}
out32[texel] = (!opaque && index == 2) ? 0 : paint32[index];
}
return;
}
@@ -124,9 +158,10 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
const int bv = Expand6(b[7] & 63);
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
write(x * 4 + y, (x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
out32[x * 4 + y] = MakeRgba32(
(x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
}
}
return;
@@ -144,39 +179,108 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
}
const int tables[2] = {b[3] >> 5, (b[3] >> 2) & 7};
const bool flipped = (b[3] & 1) != 0;
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
const int texel = x * 4 + y;
const int index = index_of(texel);
if (!opaque && index == 2) {
write(texel, 0, 0, 0, 0);
continue;
}
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
// Basis Universal palette precalculation: 8 colors per block instead of 48 additions/clamps.
std::uint32_t palette[2][4];
for (int sub = 0; sub < 2; ++sub) {
for (int idx = 0; idx < 4; ++idx) {
const int modifier =
(!opaque && index == 0) ? 0 : kEtc1Modifiers[tables[sub]][index];
write(texel, base[sub][0] + modifier, base[sub][1] + modifier,
base[sub][2] + modifier, 255);
(!opaque && idx == 0) ? 0 : kEtc1Modifiers[tables[sub]][idx];
palette[sub][idx] = MakeRgba32(base[sub][0] + modifier,
base[sub][1] + modifier,
base[sub][2] + modifier, 255);
}
}
if (opaque) {
#if defined(__SSSE3__) || defined(__x86_64__) || defined(_M_X64)
const __m128i pal0 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(palette[0]));
const __m128i pal1 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(palette[1]));
if (!flipped) {
for (int x = 0; x < 2; ++x) {
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
const __m128i mask = _mm_load_si128(
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4),
_mm_shuffle_epi8(pal0, mask));
}
for (int x = 2; x < 4; ++x) {
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
const __m128i mask = _mm_load_si128(
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4),
_mm_shuffle_epi8(pal1, mask));
}
} else {
for (int x = 0; x < 4; ++x) {
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
const __m128i mask = _mm_load_si128(
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
const __m128i col0 = _mm_shuffle_epi8(pal0, mask);
const __m128i col1 = _mm_shuffle_epi8(pal1, mask);
const __m128i blended =
_mm_unpacklo_epi64(col0, _mm_srli_si128(col1, 8));
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4), blended);
}
}
#else
if (!flipped) {
for (int x = 0; x < 2; ++x) {
const std::uint32_t* pal = palette[0];
out32[x * 4 + 0] = pal[index_of(x * 4 + 0)];
out32[x * 4 + 1] = pal[index_of(x * 4 + 1)];
out32[x * 4 + 2] = pal[index_of(x * 4 + 2)];
out32[x * 4 + 3] = pal[index_of(x * 4 + 3)];
}
for (int x = 2; x < 4; ++x) {
const std::uint32_t* pal = palette[1];
out32[x * 4 + 0] = pal[index_of(x * 4 + 0)];
out32[x * 4 + 1] = pal[index_of(x * 4 + 1)];
out32[x * 4 + 2] = pal[index_of(x * 4 + 2)];
out32[x * 4 + 3] = pal[index_of(x * 4 + 3)];
}
} else {
for (int x = 0; x < 4; ++x) {
out32[x * 4 + 0] = palette[0][index_of(x * 4 + 0)];
out32[x * 4 + 1] = palette[0][index_of(x * 4 + 1)];
out32[x * 4 + 2] = palette[1][index_of(x * 4 + 2)];
out32[x * 4 + 3] = palette[1][index_of(x * 4 + 3)];
}
}
#endif
} else {
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
const int texel = x * 4 + y;
const int index = index_of(texel);
if (index == 2) {
out32[texel] = 0;
} else {
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
out32[texel] = palette[sub][index];
}
}
}
}
}
// EAC 3-bit index for column-major texel (x * 4 + y).
int EacIndex(const std::uint8_t* b, int texel) {
inline std::uint64_t ReadEacBits(const std::uint8_t* b) {
std::uint64_t bits = 0;
for (int i = 2; i < 8; ++i) {
bits = (bits << 8) | b[i];
}
return bits;
}
inline int EacIndexFromBits(std::uint64_t bits, int texel) {
return static_cast<int>((bits >> (45 - texel * 3)) & 7);
}
int EacModifier(const std::uint8_t* b, int texel) {
return kEacModifiers[b[1] & 15][EacIndex(b, texel)];
}
std::uint16_t DecodeR11(const std::uint8_t* b, int texel, bool is_signed) {
const int multiplier = b[1] >> 4;
const int modifier = EacModifier(b, texel);
inline std::uint16_t DecodeR11Modifier(const std::uint8_t* b, int multiplier,
const int* modifier_table,
int modifier_idx, bool is_signed) {
const int modifier = modifier_table[modifier_idx];
const int scaled = multiplier != 0 ? modifier * multiplier * 8 : modifier;
if (!is_signed) {
const int value = std::clamp(b[0] * 8 + 4 + scaled, 0, 2047);
@@ -308,12 +412,18 @@ class DecodePool {
run_ = run;
context_ = context;
unclaimed_ = helpers;
running_ = helpers;
running_.store(helpers, std::memory_order_relaxed);
}
for (std::size_t i = 0; i < helpers; ++i) {
ready_.notify_one();
}
ready_.notify_all();
run(context);
std::unique_lock<std::mutex> lock(mutex_);
done_.wait(lock, [this] { return running_ == 0; });
if (running_.load(std::memory_order_acquire) > 0) {
std::unique_lock<std::mutex> lock(done_mutex_);
done_.wait(lock, [this] {
return running_.load(std::memory_order_acquire) == 0;
});
}
run_ = nullptr;
context_ = nullptr;
return true;
@@ -343,29 +453,33 @@ class DecodePool {
}
void WorkerLoop() {
std::unique_lock<std::mutex> lock(mutex_);
for (;;) {
ready_.wait(lock, [this] { return unclaimed_ > 0; });
--unclaimed_;
void (*run)(void*) = run_;
void* context = context_;
lock.unlock();
void (*run)(void*) = nullptr;
void* context = nullptr;
{
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [this] { return unclaimed_ > 0; });
--unclaimed_;
run = run_;
context = context_;
}
run(context);
lock.lock();
if (--running_ == 0) {
done_.notify_all();
if (running_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
std::lock_guard<std::mutex> lock(done_mutex_);
done_.notify_one();
}
}
}
std::mutex batch_mutex_;
std::mutex mutex_;
std::mutex done_mutex_;
std::condition_variable ready_;
std::condition_variable done_;
void (*run_)(void*) = nullptr;
void* context_ = nullptr;
std::size_t unclaimed_ = 0;
std::size_t running_ = 0;
std::atomic<std::size_t> running_{0};
std::size_t threads_ = 0;
};
@@ -378,6 +492,16 @@ void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
}
DecodeBandQueue queue;
std::vector<DecodeBand>& bands = queue.bands;
std::size_t estimated_bands = 0;
for (std::size_t index = 0; index < count; ++index) {
const EtcDecodeJob& job = jobs[index];
const std::uint32_t blocks_wide = (job.width + 3) / 4;
const std::uint32_t blocks_high = (job.height + 3) / 4;
const std::uint32_t rows_per_band = static_cast<std::uint32_t>(
std::max<std::uint64_t>(1, kBlocksPerBand / (blocks_wide ? blocks_wide : 1)));
estimated_bands += (blocks_high + rows_per_band - 1) / (rows_per_band ? rows_per_band : 1);
}
bands.reserve(estimated_bands);
std::uint64_t total_blocks = 0;
for (std::size_t index = 0; index < count; ++index) {
EtcDecodeJob& job = jobs[index];
@@ -439,48 +563,104 @@ bool DecodeEtcImageBlockRows(EtcFormat format, const std::uint8_t* source,
const std::size_t texel_bytes = EtcDecodedTexelBytes(format);
const std::size_t block_bytes = EtcBlockBytes(format);
std::array<std::uint8_t, 64> rgba{};
alignas(16) std::uint32_t cols[16];
const std::uint64_t end_block_row =
static_cast<std::uint64_t>(first_block_row) + block_row_count;
const bool is_rgba_family = (format == EtcFormat::kEtc2Rgb8 ||
format == EtcFormat::kEtc2Rgb8A1 ||
format == EtcFormat::kEtc2Rgba8);
for (std::uint64_t by = first_block_row; by < end_block_row; ++by) {
for (std::uint64_t bx = 0; bx < blocks_wide; ++bx) {
const std::uint8_t* block =
source + (by * blocks_wide + bx) * block_bytes;
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, &rgba);
} else if (format == EtcFormat::kEtc2Rgba8) {
DecodeRgbBlock(block + 8, false, &rgba);
}
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
const int texel = static_cast<int>(x * 4 + y);
std::uint8_t* out =
destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
switch (format) {
case EtcFormat::kEtc2Rgb8:
case EtcFormat::kEtc2Rgb8A1:
case EtcFormat::kEtc2Rgba8:
std::copy_n(rgba.data() + texel * 4, 4, out);
if (format == EtcFormat::kEtc2Rgba8) {
const int alpha =
block[0] + EacModifier(block, texel) * (block[1] >> 4);
out[3] = Clamp255(alpha);
}
break;
case EtcFormat::kEacR11:
case EtcFormat::kEacR11Signed:
case EtcFormat::kEacRg11:
case EtcFormat::kEacRg11Signed: {
const bool is_signed = format == EtcFormat::kEacR11Signed ||
format == EtcFormat::kEacRg11Signed;
const int channels = texel_bytes / 2;
for (int channel = 0; channel < channels; ++channel) {
const std::uint16_t value =
DecodeR11(block + channel * 8, texel, is_signed);
out[channel * 2] = static_cast<std::uint8_t>(value);
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
}
break;
const bool interior = (by * 4 + 4 <= height) && (bx * 4 + 4 <= width);
if (is_rgba_family) {
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, cols);
} else {
DecodeRgbBlock(block + 8, false, cols);
const std::uint64_t alpha_bits = ReadEacBits(block);
const int alpha_base = block[0];
const int alpha_multiplier = block[1] >> 4;
const auto& alpha_table = kEacModifiers[block[1] & 15];
std::uint8_t alpha_palette[8];
for (int i = 0; i < 8; ++i) {
alpha_palette[i] =
Clamp255(alpha_base + alpha_table[i] * alpha_multiplier);
}
for (int texel = 0; texel < 16; ++texel) {
cols[texel] =
(cols[texel] & 0x00FFFFFF) |
(static_cast<std::uint32_t>(
alpha_palette[EacIndexFromBits(alpha_bits, texel)])
<< 24);
}
}
if (interior) {
std::uint32_t* dst_row = reinterpret_cast<std::uint32_t*>(
destination + (by * 4 * width + bx * 4) * 4);
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
__m128 r0 = _mm_load_ps(reinterpret_cast<const float*>(cols));
__m128 r1 = _mm_load_ps(reinterpret_cast<const float*>(cols + 4));
__m128 r2 = _mm_load_ps(reinterpret_cast<const float*>(cols + 8));
__m128 r3 = _mm_load_ps(reinterpret_cast<const float*>(cols + 12));
_MM_TRANSPOSE4_PS(r0, r1, r2, r3);
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r0);
dst_row += width;
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r1);
dst_row += width;
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r2);
dst_row += width;
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r3);
#else
for (int y = 0; y < 4; ++y) {
dst_row[0] = cols[y];
dst_row[1] = cols[4 + y];
dst_row[2] = cols[8 + y];
dst_row[3] = cols[12 + y];
dst_row += width;
}
#endif
} else {
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
const int texel = static_cast<int>(x * 4 + y);
std::uint32_t* out = reinterpret_cast<std::uint32_t*>(
destination + ((by * 4 + y) * width + bx * 4 + x) * 4);
*out = cols[texel];
}
}
}
} else {
const bool is_signed = format == EtcFormat::kEacR11Signed ||
format == EtcFormat::kEacRg11Signed;
const int channels = static_cast<int>(texel_bytes / 2);
std::uint64_t channel_bits[2] = {0, 0};
std::uint16_t channel_palette[2][8];
for (int c = 0; c < channels; ++c) {
const std::uint8_t* cb = block + c * 8;
channel_bits[c] = ReadEacBits(cb);
const int mult = cb[1] >> 4;
const int* table = kEacModifiers[cb[1] & 15];
for (int i = 0; i < 8; ++i) {
channel_palette[c][i] =
DecodeR11Modifier(cb, mult, table, i, is_signed);
}
}
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
const int texel = static_cast<int>(x * 4 + y);
std::uint8_t* out =
destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
for (int channel = 0; channel < channels; ++channel) {
const std::uint16_t value =
channel_palette[channel][EacIndexFromBits(channel_bits[channel],
texel)];
out[channel * 2] = static_cast<std::uint8_t>(value);
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
}
}
}
+33 -12
View File
@@ -72,20 +72,33 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
static_cast<std::size_t>(source.width) * source.height * 4) {
return;
}
ResampleRgba(source.pixels.data(), source.width, source.height, width,
height, destination);
ResampleRgba(source.pixels.data(), source.width, source.height,
static_cast<std::size_t>(source.width) * 4, width, height,
destination);
}
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination) {
ResampleRgba(source, source_width, source_height,
static_cast<std::size_t>(source_width) * 4, width, height,
destination);
}
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::size_t source_stride_bytes,
std::uint32_t width, std::uint32_t height,
std::uint8_t* destination) {
if (source == nullptr || destination == nullptr || width == 0 ||
height == 0 || source_width == 0 || source_height == 0) {
height == 0 || source_width == 0 || source_height == 0 ||
source_stride_bytes < static_cast<std::size_t>(source_width) * 4) {
return;
}
// Source box of destination column x is [columns[2x], columns[2x + 1]).
thread_local std::vector<std::uint32_t> columns;
columns.resize(static_cast<std::size_t>(width) * 2);
if (columns.size() < static_cast<std::size_t>(width) * 2) {
columns.resize(static_cast<std::size_t>(width) * 2);
}
bool one_texel_columns = true;
for (std::uint32_t x = 0; x < width; ++x) {
const std::uint32_t x0 = static_cast<std::uint32_t>(
@@ -115,11 +128,11 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
previous_y0 = y0;
previous_y1 = y1;
if (one_texel_columns && y1 == y0 + 1) {
const std::uint8_t* source_row = source + y0 * source_width * 4;
const std::uint8_t* source_row = source + y0 * source_stride_bytes;
for (std::uint32_t x = 0; x < width; ++x) {
std::memcpy(out_row + static_cast<std::size_t>(x) * 4,
source_row + columns[2 * static_cast<std::size_t>(x)] * 4,
4);
std::uint32_t pixel;
std::memcpy(&pixel, source_row + columns[2 * static_cast<std::size_t>(x)] * 4, 4);
std::memcpy(out_row + static_cast<std::size_t>(x) * 4, &pixel, 4);
}
continue;
}
@@ -128,8 +141,9 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
const std::uint64_t x1 = columns[2 * static_cast<std::size_t>(x) + 1];
std::uint64_t sum[4] = {0, 0, 0, 0};
for (std::uint64_t sy = y0; sy < y1; ++sy) {
const std::uint8_t* row_ptr = source + sy * source_stride_bytes;
for (std::uint64_t sx = x0; sx < x1; ++sx) {
const std::uint8_t* pixel = source + (sy * source_width + sx) * 4;
const std::uint8_t* pixel = row_ptr + sx * 4;
for (int channel = 0; channel < 4; ++channel) {
sum[channel] += pixel[channel];
}
@@ -137,9 +151,16 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
}
const std::uint64_t count = (y1 - y0) * (x1 - x0);
std::uint8_t* out = destination + (static_cast<std::size_t>(y) * width + x) * 4;
for (int channel = 0; channel < 4; ++channel) {
out[channel] =
static_cast<std::uint8_t>((sum[channel] + count / 2) / count);
if (count == 1) {
for (int channel = 0; channel < 4; ++channel) {
out[channel] = static_cast<std::uint8_t>(sum[channel]);
}
} else {
const std::uint64_t half = count / 2;
for (int channel = 0; channel < 4; ++channel) {
out[channel] =
static_cast<std::uint8_t>((sum[channel] + half) / count);
}
}
}
}
+55 -23
View File
@@ -18,6 +18,10 @@
#include <utility>
#include <vector>
#if defined(__x86_64__)
#include <immintrin.h>
#endif
namespace mocktail::graphics {
bool LookupEmulatedEtc2Format(VkFormat format, Etc2EmulatedFormat* out) {
@@ -398,6 +402,45 @@ bool CreateStagingBlock(const HostDevice& dev, VkDeviceSize size,
return true;
}
#if defined(__x86_64__)
__attribute__((target("sse4.1")))
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
std::size_t bytes) {
if ((reinterpret_cast<std::uintptr_t>(src) & 15) != 0 || bytes < 16) {
std::memcpy(dst, src, bytes);
return;
}
std::size_t i = 0;
for (; i + 64 <= bytes; i += 64) {
__m128i c0 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
__m128i c1 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 16)));
__m128i c2 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 32)));
__m128i c3 = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 48)));
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c0);
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 16), c1);
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 32), c2);
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 48), c3);
}
for (; i + 16 <= bytes; i += 16) {
__m128i c = _mm_stream_load_si128(
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c);
}
if (i < bytes) {
std::memcpy(dst + i, src + i, bytes - i);
}
}
#else
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
std::size_t bytes) {
std::memcpy(dst, src, bytes);
}
#endif
// Packs each upload's compressed bytes into `destination`, dropping the row
// and layer padding the application may have asked for. Sources are parallel
// to `uploads`.
@@ -411,17 +454,18 @@ void GatherCompressed(const std::vector<PendingUpload>& uploads,
std::uint8_t* target = destination + offset;
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
if (upload.source_span == upload.compressed) {
std::memcpy(target, source, upload.compressed);
CopyHostVisibleMemory(target, source, upload.compressed);
} else {
const VkDeviceSize block_rows =
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
const VkDeviceSize packed_row = compressed_layer / block_rows;
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
for (VkDeviceSize row = 0; row < block_rows; ++row) {
std::memcpy(target + layer * compressed_layer + row * packed_row,
source + layer * upload.source_layer_stride +
row * upload.source_row_stride,
packed_row);
CopyHostVisibleMemory(
target + layer * compressed_layer + row * packed_row,
source + layer * upload.source_layer_stride +
row * upload.source_row_stride,
packed_row);
}
}
}
@@ -710,21 +754,11 @@ struct VulkanEtc2Emulation::State {
upload.target);
return;
}
const std::uint8_t* source = decoded;
std::vector<std::uint8_t> cropped;
if (upload.source_width != upload.width ||
upload.source_height != upload.height) {
const std::size_t row = static_cast<std::size_t>(upload.source_width) * 4;
cropped.resize(row * upload.source_height);
for (std::uint32_t y = 0; y < upload.source_height; ++y) {
std::memcpy(cropped.data() + y * row,
decoded + static_cast<std::size_t>(y) * upload.width * 4,
row);
}
source = cropped.data();
}
ResampleRgba(source, upload.source_width, upload.source_height,
upload.target_width, upload.target_height, upload.target);
const std::size_t source_stride =
static_cast<std::size_t>(upload.width) * 4;
ResampleRgba(decoded, upload.source_width, upload.source_height,
source_stride, upload.target_width, upload.target_height,
upload.target);
} else {
std::memcpy(upload.target, decoded, upload.decoded);
}
@@ -1700,9 +1734,7 @@ Etc2SubmitWait VulkanEtc2Emulation::PrepareSubmit(
collect(command_buffers[index]);
const auto record = state_->commands.find(command_buffers[index]);
if (record != state_->commands.end()) {
const std::vector<VkCommandBuffer> secondaries =
record->second.secondaries;
for (VkCommandBuffer secondary : secondaries) {
for (VkCommandBuffer secondary : record->second.secondaries) {
collect(secondary);
}
}
@@ -749,8 +749,10 @@ void VulkanTextOverlayCompositor::DestroyDevice(VkDevice device) {
if (iterator == impl_->devices.end()) {
return;
}
std::lock_guard<std::mutex> overlay_lock((*iterator)->overlay_mutex);
impl_->DestroyDeviceResources(iterator->get());
{
std::lock_guard<std::mutex> overlay_lock((*iterator)->overlay_mutex);
impl_->DestroyDeviceResources(iterator->get());
}
impl_->devices.erase(iterator);
}
+137
View File
@@ -0,0 +1,137 @@
#include "jni_fields.h"
#include "jni_references.h"
namespace jnivm::internal {
namespace {
std::unordered_set<std::string> g_field_names;
std::unordered_map<std::string, jobject> g_static_object_fields;
} // namespace
jfieldID StoreFieldId(const char* name) {
if (name == nullptr) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
const auto stored = g_field_names.emplace(name).first;
// unordered_set preserves references to its elements across rehashes.
return reinterpret_cast<jfieldID>(const_cast<char*>(stored->c_str()));
}
void SetStaticObjectFieldRaw(const char* name, jobject value) {
if (name == nullptr) return;
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
RetainJniReference(value);
auto& field = g_static_object_fields[name];
const jobject previous = field;
field = value;
ReleaseJniReference(previous);
}
jobject StaticObjectFieldValue(const char* name) {
if (name == nullptr) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
const auto found = g_static_object_fields.find(name);
return found == g_static_object_fields.end()
? nullptr
: NewLocalJniReference(found->second);
}
void SetObjectFieldRaw(jobject obj, const char* field_name, jobject value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
RetainJniReference(value);
auto it = pseudo_object->object_fields.find(field_name);
jobject prev =
(it != pseudo_object->object_fields.end()) ? it->second : nullptr;
pseudo_object->object_fields[field_name] = value;
if (prev != nullptr) {
ReleaseJniReference(prev);
}
}
}
void SetIntFieldRaw(jobject obj, const char* field_name, jint value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->int_fields[field_name] = value;
}
}
void SetLongFieldRaw(jobject obj, const char* field_name, jlong value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->long_fields[field_name] = value;
}
}
void SetFloatFieldRaw(jobject obj, const char* field_name, jfloat value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->float_fields[field_name] = value;
}
}
void SetBooleanFieldRaw(jobject obj, const char* field_name, jboolean value) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (pseudo_object && field_name) {
pseudo_object->boolean_fields[field_name] = value;
}
}
jobject ObjectFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return nullptr;
}
auto it = pseudo_object->object_fields.find(field_name);
return it == pseudo_object->object_fields.end()
? nullptr
: NewLocalJniReference(it->second);
}
jint IntFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return 0;
}
auto it = pseudo_object->int_fields.find(field_name);
return it == pseudo_object->int_fields.end() ? 0 : it->second;
}
jboolean BooleanFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return JNI_FALSE;
}
auto it = pseudo_object->boolean_fields.find(field_name);
return it == pseudo_object->boolean_fields.end() ? JNI_FALSE : it->second;
}
jlong LongFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return 0;
}
auto it = pseudo_object->long_fields.find(field_name);
return it == pseudo_object->long_fields.end() ? 0 : it->second;
}
jfloat FloatFieldValue(jobject obj, const char* field_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !field_name) {
return 0.0f;
}
auto it = pseudo_object->float_fields.find(field_name);
return it == pseudo_object->float_fields.end() ? 0.0f : it->second;
}
} // namespace jnivm::internal
+25
View File
@@ -0,0 +1,25 @@
#ifndef MOCKTAIL_JNIVM_JNI_FIELDS_H_
#define MOCKTAIL_JNIVM_JNI_FIELDS_H_
#include <jni.h>
namespace jnivm::internal {
// Field names are interned for the lifetime of the process. Object getters
// return independently owned local references; setters retain their values.
jfieldID StoreFieldId(const char* name);
void SetStaticObjectFieldRaw(const char* name, jobject value);
jobject StaticObjectFieldValue(const char* name);
void SetObjectFieldRaw(jobject obj, const char* name, jobject value);
void SetIntFieldRaw(jobject obj, const char* name, jint value);
void SetLongFieldRaw(jobject obj, const char* name, jlong value);
void SetFloatFieldRaw(jobject obj, const char* name, jfloat value);
void SetBooleanFieldRaw(jobject obj, const char* name, jboolean value);
jobject ObjectFieldValue(jobject obj, const char* name);
jint IntFieldValue(jobject obj, const char* name);
jlong LongFieldValue(jobject obj, const char* name);
jfloat FloatFieldValue(jobject obj, const char* name);
jboolean BooleanFieldValue(jobject obj, const char* name);
} // namespace jnivm::internal
#endif
+448
View File
@@ -0,0 +1,448 @@
#include "jni_references.h"
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdlib>
namespace jnivm {
// Encoded handles are also read by the guest ABI boundary.
void* my_segment[100000] = {};
int g_jni_ref_index = 1;
} // namespace jnivm
namespace jnivm::internal {
enum class SegmentType : uint8_t {
kEmpty = 0,
kObject = 1,
kClass = 2,
};
// Class handles are interned by binary name. Real JNI resolves the same class
// to the same runtime class object, and class handles are never released, so
// every FindClass/GetObjectClass used to consume a fresh permanent segment
// slot and exhaust the table during long sessions.
// Each frame counts how many local references it holds per handle, so
// DeleteLocalRef costs one hash lookup per open frame.
using LocalFrame = std::unordered_map<jobject, uint32_t>;
thread_local std::vector<LocalFrame> g_local_frames;
std::recursive_mutex g_jni_state_mutex;
std::unordered_map<jobject, std::unique_ptr<Object>> g_object_storage;
std::unordered_map<jobject, uint32_t> g_jni_ref_counts;
std::shared_ptr<void> g_segment_owners[kJniSegmentCapacity];
std::unordered_set<jclass> g_known_classes;
// One handle per Class. Class slots are never freed, so the table holds at
// most one slot per distinct class.
std::unordered_map<const Class*, jclass> g_class_handles;
std::unordered_map<std::string, jobject> g_singleton_objects;
std::unordered_set<jobject> g_singleton_handles;
std::unordered_map<std::string, std::shared_ptr<Class>> g_fallback_classes;
std::unordered_set<jstring> g_known_strings;
std::unordered_map<jarray, std::unique_ptr<PseudoArray>> g_array_storage;
static std::atomic<uint8_t> g_segment_types[kJniSegmentCapacity] = {};
std::vector<int> g_free_slots;
static bool g_shutting_down_jnivm = false;
struct JniVmShutdownHook {
JniVmShutdownHook() {
std::atexit([]() { g_shutting_down_jnivm = true; });
}
};
static JniVmShutdownHook g_shutdown_hook;
bool TraceEnabled() {
static const bool enabled = std::getenv("MOCKTAIL_JNI_TRACE") != nullptr;
return enabled;
}
jobject JniHandleFromIndex(uint32_t index) {
return reinterpret_cast<jobject>(static_cast<uintptr_t>(index)
<< kJniHandleShift);
}
uint32_t JniIndexFromHandle(jobject obj) {
return static_cast<uint32_t>(reinterpret_cast<uintptr_t>(obj) >>
kJniHandleShift);
}
void EnsureLocalFrame() {
if (g_local_frames.empty()) {
g_local_frames.emplace_back();
}
}
void RegisterLocalRef(jobject obj) {
if (obj == nullptr) {
return;
}
EnsureLocalFrame();
++g_local_frames.back()[obj];
}
void UnregisterLocalRef(jobject obj) {
if (obj == nullptr) {
return;
}
for (auto frame = g_local_frames.rbegin(); frame != g_local_frames.rend();
++frame) {
auto it = frame->find(obj);
if (it != frame->end()) {
if (--it->second == 0) {
frame->erase(it);
}
return;
}
}
}
// A detached thread can no longer use its local references, so they go the
// way ART drops them on detach.
void ReleaseThreadLocalRefs() {
std::vector<LocalFrame> frames = std::move(g_local_frames);
g_local_frames.clear();
for (const LocalFrame& frame : frames) {
for (const auto& [obj, count] : frame) {
for (uint32_t i = 0; i < count; ++i) {
ReleaseJniReference(obj);
}
}
}
}
void RetainJniReference(jobject obj) {
if (obj == nullptr) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_jni_ref_counts.find(obj);
if (it != g_jni_ref_counts.end()) {
++it->second;
} else {
g_jni_ref_counts[obj] = 1;
}
}
void ReportHandleTableFull();
int AllocateSegmentSlot(void* value, std::shared_ptr<void> owner = nullptr,
SegmentType type = SegmentType::kObject) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
int index = 0;
if (!g_free_slots.empty()) {
index = g_free_slots.back();
g_free_slots.pop_back();
} else if (g_jni_ref_index > 0 &&
static_cast<uint32_t>(g_jni_ref_index) < kJniSegmentCapacity) {
index = g_jni_ref_index++;
} else {
ReportHandleTableFull();
return 0;
}
my_segment[index] = value;
g_segment_owners[index] = std::move(owner);
g_segment_types[index].store(static_cast<uint8_t>(type),
std::memory_order_release);
return index;
}
std::shared_ptr<Class> FallbackClassForName(const std::string& class_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_fallback_classes.find(class_name);
if (it != g_fallback_classes.end()) {
return it->second;
}
auto cls = std::make_shared<Class>(class_name);
g_fallback_classes[class_name] = cls;
return cls;
}
std::shared_ptr<Class> ClassFromJClass(jclass clazz) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!clazz) {
return nullptr;
}
const uint32_t index = JniIndexFromHandle(reinterpret_cast<jobject>(clazz));
Class* cls = nullptr;
if (index > 0 && index < kJniSegmentCapacity) {
cls = reinterpret_cast<Class*>(my_segment[index]);
if (g_segment_owners[index]) {
return std::static_pointer_cast<Class>(g_segment_owners[index]);
}
} else {
cls = reinterpret_cast<Class*>(clazz);
}
if (!cls) {
return nullptr;
}
return FallbackClassForName(cls->GetName());
}
jclass StoreClass(std::shared_ptr<Class> cls) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!cls) {
return nullptr;
}
Class* raw_ptr = cls.get();
if (const auto cached = g_class_handles.find(raw_ptr);
cached != g_class_handles.end()) {
return cached->second;
}
int index = AllocateSegmentSlot(raw_ptr, cls, SegmentType::kClass);
if (index <= 0) {
return nullptr;
}
jclass handle = reinterpret_cast<jclass>(
JniHandleFromIndex(static_cast<uint32_t>(index)));
g_known_classes.insert(handle);
g_class_handles.emplace(raw_ptr, handle);
return handle;
}
jobject StoreObject(std::unique_ptr<Object> object) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
Object* raw_ptr = object.get();
int index = AllocateSegmentSlot(raw_ptr, nullptr, SegmentType::kObject);
if (index <= 0) {
return nullptr;
}
jobject handle = JniHandleFromIndex(static_cast<uint32_t>(index));
g_object_storage[handle] = std::move(object);
g_jni_ref_counts[handle] = 1;
RegisterLocalRef(handle);
return handle;
}
void ReleaseJniReference(jobject obj) {
if (obj == nullptr || g_shutting_down_jnivm) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (g_shutting_down_jnivm) {
return;
}
auto ref_it = g_jni_ref_counts.find(obj);
if (ref_it != g_jni_ref_counts.end()) {
if (ref_it->second > 1) {
--ref_it->second;
return;
}
g_jni_ref_counts.erase(ref_it);
}
auto arr_it = g_array_storage.find(reinterpret_cast<jarray>(obj));
if (arr_it != g_array_storage.end()) {
std::unique_ptr<PseudoArray> dying_array = std::move(arr_it->second);
std::vector<jobject> elements = std::move(dying_array->objects);
g_array_storage.erase(arr_it);
// Release after detaching the array: elements may recursively erase other
// arrays. Keep dying_array alive until those releases have completed.
for (jobject element : elements) {
ReleaseJniReference(element);
}
return;
}
if (g_known_classes.find(reinterpret_cast<jclass>(obj)) !=
g_known_classes.end()) {
return;
}
if (g_singleton_handles.count(obj) != 0) {
return;
}
const uint32_t index = JniIndexFromHandle(obj);
if (index > 0 && index < kJniSegmentCapacity) {
g_segment_types[index].store(static_cast<uint8_t>(SegmentType::kEmpty),
std::memory_order_release);
my_segment[index] = nullptr;
g_segment_owners[index].reset();
g_free_slots.push_back(static_cast<int>(index));
}
g_known_strings.erase(reinterpret_cast<jstring>(obj));
std::unique_ptr<Object> dying_object;
auto obj_it = g_object_storage.find(obj);
if (obj_it != g_object_storage.end()) {
dying_object = std::move(obj_it->second);
g_object_storage.erase(obj_it);
}
}
PseudoJavaObject* PseudoObjectFromRef(jobject obj) {
if (__builtin_expect(obj == nullptr, 0)) {
return nullptr;
}
const uint32_t index = JniIndexFromHandle(obj);
if (__builtin_expect(index > 0 && index < kJniSegmentCapacity, 1)) {
const uint8_t type = g_segment_types[index].load(std::memory_order_acquire);
if (__builtin_expect(type == static_cast<uint8_t>(SegmentType::kObject),
1)) {
void* raw_ptr = my_segment[index];
if (__builtin_expect(raw_ptr != nullptr, 1)) {
return static_cast<PseudoJavaObject*>(
reinterpret_cast<Object*>(raw_ptr));
}
}
}
return nullptr;
}
jobject MakeObjectForClass(const std::string& class_name) {
auto cls = FallbackClassForName(class_name);
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
}
jobject MakeObject(jclass clazz) {
auto cls = ClassFromJClass(clazz);
if (!cls) {
cls = FallbackClassForName("java/lang/Object");
}
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
}
jobject SingletonObject(const std::string& class_name) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_singleton_objects.find(class_name);
if (it != g_singleton_objects.end()) {
return it->second;
}
jobject object = MakeObjectForClass(class_name);
g_singleton_objects[class_name] = object;
g_singleton_handles.insert(object);
return object;
}
std::string_view ObjectClassName(jobject obj) {
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
if (!pseudo_object || !pseudo_object->GetClass()) {
return {};
}
return pseudo_object->GetClass()->GetName();
}
void MarkSingletonHandle(jobject obj) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
g_singleton_handles.insert(obj);
}
// Logs once per process which classes hold the live handles, so the leak
// behind a later null FindClass or NewStringUTF can be named.
void ReportHandleTableFull() {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
static bool reported = false;
if (reported) {
return;
}
reported = true;
std::unordered_map<std::string_view, std::size_t> live_by_class;
for (const auto& entry : g_object_storage) {
std::string_view name = ObjectClassName(entry.first);
++live_by_class[name.empty() ? std::string_view("<unknown>") : name];
}
std::vector<std::pair<std::string_view, std::size_t>> ranked(
live_by_class.begin(), live_by_class.end());
const std::size_t shown = std::min<std::size_t>(ranked.size(), 10);
std::partial_sort(ranked.begin(), ranked.begin() + shown, ranked.end(),
[](const auto& a, const auto& b) {
return a.second > b.second;
});
fprintf(stderr,
"[JNI] handle table full (%u slots): %zu objects, %zu classes, "
"%zu arrays live; new objects return null\n",
kJniSegmentCapacity, g_object_storage.size(),
g_class_handles.size(), g_array_storage.size());
for (std::size_t i = 0; i < shown; ++i) {
fprintf(stderr, "[JNI] %zu x %.*s\n", ranked[i].second,
static_cast<int>(ranked[i].first.size()), ranked[i].first.data());
}
}
PseudoArray* ArrayFromRef(jarray array) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto it = g_array_storage.find(array);
return it == g_array_storage.end() ? nullptr : it->second.get();
}
jbyteArray MakeByteArray(jsize len) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto array = std::make_unique<PseudoArray>();
if (len > 0) {
array->bytes.resize(static_cast<std::size_t>(len));
}
jbyteArray ref = reinterpret_cast<jbyteArray>(array.get());
g_array_storage[ref] = std::move(array);
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
RegisterLocalRef(reinterpret_cast<jobject>(ref));
return ref;
}
jfloatArray MakeFloatArray(jsize len) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto array = std::make_unique<PseudoArray>();
if (len > 0) {
array->floats.resize(static_cast<std::size_t>(len));
}
jfloatArray ref = reinterpret_cast<jfloatArray>(array.get());
g_array_storage[ref] = std::move(array);
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
RegisterLocalRef(reinterpret_cast<jobject>(ref));
return ref;
}
jobjectArray MakeObjectArray(jsize len, jobject init) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto array = std::make_unique<PseudoArray>();
if (len > 0) {
array->objects.resize(static_cast<std::size_t>(len), init);
if (init != nullptr) {
for (jsize index = 0; index < len; ++index) {
RetainJniReference(init);
}
}
}
jobjectArray ref = reinterpret_cast<jobjectArray>(array.get());
g_array_storage[ref] = std::move(array);
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
RegisterLocalRef(reinterpret_cast<jobject>(ref));
return ref;
}
PseudoJavaObject::~PseudoJavaObject() {
for (const auto& field : object_fields) ReleaseJniReference(field.second);
}
jobject NewLocalJniReference(jobject obj) {
if (obj != nullptr) {
RetainJniReference(obj);
RegisterLocalRef(obj);
}
return obj;
}
void DeleteLocalJniReference(jobject obj) {
UnregisterLocalRef(obj);
ReleaseJniReference(obj);
}
void PushLocalJniFrame() {
EnsureLocalFrame();
g_local_frames.emplace_back();
}
jobject PopLocalJniFrame(jobject result) {
EnsureLocalFrame();
RetainJniReference(result);
LocalFrame frame = std::move(g_local_frames.back());
if (g_local_frames.size() > 1)
g_local_frames.pop_back();
else
g_local_frames.back().clear();
for (const auto& [obj, count] : frame) {
for (uint32_t i = 0; i < count; ++i) {
ReleaseJniReference(obj);
}
}
RegisterLocalRef(result);
return result;
}
} // namespace jnivm::internal
+65
View File
@@ -0,0 +1,65 @@
#ifndef MOCKTAIL_JNIVM_JNI_REFERENCES_H_
#define MOCKTAIL_JNIVM_JNI_REFERENCES_H_
#include <string_view>
#include <unordered_set>
#include "jnivm/jnivm.h"
namespace jnivm::internal {
// Shared by reference ownership and object/field access. Callers retaining a
// raw PseudoJavaObject pointer must keep its JNI reference alive.
extern std::recursive_mutex g_jni_state_mutex;
extern std::unordered_set<jstring> g_known_strings;
inline constexpr uint32_t kJniSegmentCapacity = 100000;
inline constexpr uint32_t kJniHandleShift = 16;
struct PseudoArray {
std::vector<jbyte> bytes;
std::vector<jfloat> floats;
std::vector<jobject> objects;
};
struct PseudoJavaObject : Object {
explicit PseudoJavaObject(std::shared_ptr<Class> cls)
: Object(std::move(cls)) {}
~PseudoJavaObject() override;
std::unordered_map<std::string, jobject> object_fields;
std::unordered_map<std::string, jint> int_fields;
std::unordered_map<std::string, jlong> long_fields;
std::unordered_map<std::string, jfloat> float_fields;
std::unordered_map<std::string, jboolean> boolean_fields;
};
bool TraceEnabled();
void RegisterLocalRef(jobject obj);
void UnregisterLocalRef(jobject obj);
void ReleaseThreadLocalRefs();
void RetainJniReference(jobject obj);
void ReleaseJniReference(jobject obj);
jobject NewLocalJniReference(jobject obj);
void DeleteLocalJniReference(jobject obj);
void PushLocalJniFrame();
jobject PopLocalJniFrame(jobject result);
std::shared_ptr<Class> FallbackClassForName(const std::string& name);
std::shared_ptr<Class> ClassFromJClass(jclass clazz);
jclass StoreClass(std::shared_ptr<Class> cls);
jobject StoreObject(std::unique_ptr<Object> object);
PseudoJavaObject* PseudoObjectFromRef(jobject obj);
jobject MakeObjectForClass(const std::string& name);
jobject MakeObject(jclass clazz);
jobject SingletonObject(const std::string& name);
// Marks a VM-owned handle so detaching its creating thread does not free it.
void MarkSingletonHandle(jobject obj);
std::string_view ObjectClassName(jobject obj);
PseudoArray* ArrayFromRef(jarray array);
jbyteArray MakeByteArray(jsize len);
jfloatArray MakeFloatArray(jsize len);
jobjectArray MakeObjectArray(jsize len, jobject init);
} // namespace jnivm::internal
#endif
+381
View File
@@ -0,0 +1,381 @@
#include "jni_strings.h"
#include <algorithm>
#include <cstring>
#include <iostream>
#include <limits>
#include "jni_references.h"
namespace jnivm::internal {
namespace {
constexpr jchar kEmptyUtf16[] = {0};
void AppendUtf8CodePoint(std::uint32_t code_point, std::string* output) {
if (code_point <= 0x7f) {
output->push_back(static_cast<char>(code_point));
} else if (code_point <= 0x7ff) {
output->push_back(static_cast<char>(0xc0 | (code_point >> 6)));
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
} else if (code_point <= 0xffff) {
output->push_back(static_cast<char>(0xe0 | (code_point >> 12)));
output->push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
} else {
output->push_back(static_cast<char>(0xf0 | (code_point >> 18)));
output->push_back(static_cast<char>(0x80 | ((code_point >> 12) & 0x3f)));
output->push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
}
}
std::string Utf16ToUtf8(const std::vector<jchar>& utf16) {
std::string output;
output.reserve(utf16.size());
for (std::size_t index = 0; index < utf16.size(); ++index) {
std::uint32_t code_point = utf16[index];
if (code_point >= 0xd800 && code_point <= 0xdbff &&
index + 1 < utf16.size() && utf16[index + 1] >= 0xdc00 &&
utf16[index + 1] <= 0xdfff) {
code_point =
0x10000 + ((code_point - 0xd800) << 10) + (utf16[++index] - 0xdc00);
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
code_point = 0xfffd;
}
AppendUtf8CodePoint(code_point, &output);
}
return output;
}
std::string Utf16ToModifiedUtf8(const std::vector<jchar>& utf16,
std::size_t begin = 0,
std::size_t count = std::string::npos) {
std::string output;
if (begin >= utf16.size()) {
return output;
}
const std::size_t end =
std::min(utf16.size(), begin + std::min(count, utf16.size() - begin));
output.reserve((end - begin) * 3);
for (std::size_t index = begin; index < end; ++index) {
const std::uint32_t code_unit = utf16[index];
if (code_unit == 0) {
output.push_back(static_cast<char>(0xc0));
output.push_back(static_cast<char>(0x80));
} else if (code_unit <= 0x7f) {
output.push_back(static_cast<char>(code_unit));
} else if (code_unit <= 0x7ff) {
output.push_back(static_cast<char>(0xc0 | (code_unit >> 6)));
output.push_back(static_cast<char>(0x80 | (code_unit & 0x3f)));
} else {
output.push_back(static_cast<char>(0xe0 | (code_unit >> 12)));
output.push_back(static_cast<char>(0x80 | ((code_unit >> 6) & 0x3f)));
output.push_back(static_cast<char>(0x80 | (code_unit & 0x3f)));
}
}
return output;
}
std::vector<jchar> ModifiedUtf8ToUtf16(const char* input) {
std::vector<jchar> output;
if (input == nullptr) {
return output;
}
const auto* bytes = reinterpret_cast<const std::uint8_t*>(input);
const std::size_t size = std::strlen(input);
for (std::size_t index = 0; index < size;) {
const std::uint8_t first = bytes[index];
std::uint32_t code_point = 0xfffd;
std::size_t consumed = 1;
if (first != 0 && first <= 0x7f) {
code_point = first;
} else if ((first & 0xe0) == 0xc0 && index + 1 < size &&
(bytes[index + 1] & 0xc0) == 0x80) {
code_point = ((first & 0x1f) << 6) | (bytes[index + 1] & 0x3f);
if (code_point == 0 || code_point >= 0x80) {
consumed = 2;
} else {
code_point = 0xfffd;
}
} else if ((first & 0xf0) == 0xe0 && index + 2 < size &&
(bytes[index + 1] & 0xc0) == 0x80 &&
(bytes[index + 2] & 0xc0) == 0x80) {
code_point = ((first & 0x0f) << 12) | ((bytes[index + 1] & 0x3f) << 6) |
(bytes[index + 2] & 0x3f);
if (code_point >= 0x800) {
consumed = 3;
} else {
code_point = 0xfffd;
}
} else if ((first & 0xf8) == 0xf0 && index + 3 < size &&
(bytes[index + 1] & 0xc0) == 0x80 &&
(bytes[index + 2] & 0xc0) == 0x80 &&
(bytes[index + 3] & 0xc0) == 0x80) {
code_point = ((first & 0x07) << 18) | ((bytes[index + 1] & 0x3f) << 12) |
((bytes[index + 2] & 0x3f) << 6) | (bytes[index + 3] & 0x3f);
if (code_point >= 0x10000 && code_point <= 0x10ffff) {
consumed = 4;
} else {
code_point = 0xfffd;
}
}
if (code_point <= 0xffff) {
output.push_back(static_cast<jchar>(code_point));
} else {
code_point -= 0x10000;
output.push_back(static_cast<jchar>(0xd800 + (code_point >> 10)));
output.push_back(static_cast<jchar>(0xdc00 + (code_point & 0x3ff)));
}
index += consumed;
}
return output;
}
static std::vector<jchar> MakeUtf16Vector(const jchar* utf16, jsize length) {
if (utf16 != nullptr && length > 0) {
return std::vector<jchar>(utf16, utf16 + length);
}
return {};
}
struct PseudoStringObject : PseudoJavaObject {
PseudoStringObject(std::shared_ptr<Class> cls, const char* utf)
: PseudoJavaObject(std::move(cls)),
chars(ModifiedUtf8ToUtf16(utf)),
value(Utf16ToUtf8(chars)),
modified_utf8(Utf16ToModifiedUtf8(chars)) {}
PseudoStringObject(std::shared_ptr<Class> cls, const jchar* utf16,
jsize length)
: PseudoJavaObject(std::move(cls)),
chars(MakeUtf16Vector(utf16, length)),
value(Utf16ToUtf8(chars)),
modified_utf8(Utf16ToModifiedUtf8(chars)) {}
std::vector<jchar> chars;
std::string value;
std::string modified_utf8;
};
} // namespace
jstring MakeString(const char* utf) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto cls = FallbackClassForName("java/lang/String");
auto object = std::make_unique<PseudoStringObject>(std::move(cls), utf);
jobject handle = StoreObject(std::move(object));
jstring str = reinterpret_cast<jstring>(handle);
g_known_strings.insert(str);
return str;
}
jstring MakeUtf16String(const jchar* utf16, jsize length) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto cls = FallbackClassForName("java/lang/String");
auto object =
std::make_unique<PseudoStringObject>(std::move(cls), utf16, length);
jobject handle = StoreObject(std::move(object));
jstring str = reinterpret_cast<jstring>(handle);
g_known_strings.insert(str);
return str;
}
std::string_view StringViewFromJString(jstring str) {
if (__builtin_expect(!str, 0)) {
return {};
}
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
if (string_object) {
return string_object->value;
}
const char* utf = reinterpret_cast<const char*>(str);
return utf ? std::string_view(utf) : std::string_view();
}
std::string StringFromJString(jstring str) {
const std::string_view sv = StringViewFromJString(str);
return std::string(sv);
}
bool IsUtf8CharsetName(jstring charset_name) {
std::string normalized = StringFromJString(charset_name);
for (char& character : normalized) {
if (character >= 'A' && character <= 'Z') {
character = static_cast<char>(character - 'A' + 'a');
}
}
return normalized == "utf-8" || normalized == "utf8" ||
normalized == "unicode-1-1-utf-8";
}
std::string JavaStringUtf8Bytes(const std::vector<jchar>& utf16) {
std::string output;
output.reserve(utf16.size());
for (std::size_t index = 0; index < utf16.size(); ++index) {
std::uint32_t code_point = utf16[index];
if (code_point >= 0xd800 && code_point <= 0xdbff &&
index + 1 < utf16.size() && utf16[index + 1] >= 0xdc00 &&
utf16[index + 1] <= 0xdfff) {
code_point =
0x10000 + ((code_point - 0xd800) << 10) + (utf16[++index] - 0xdc00);
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
// CharsetEncoder replaces each malformed UTF-16 unit with '?'.
output.push_back('?');
continue;
}
AppendUtf8CodePoint(code_point, &output);
}
return output;
}
jbyteArray JavaStringGetUtf8Bytes(jobject obj, jstring charset_name) {
if (!IsUtf8CharsetName(charset_name)) {
if (TraceEnabled()) {
std::cerr << " [JNI] java/lang/String.getBytes rejected unsupported "
"charset\n";
}
return nullptr;
}
std::string bytes;
{
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* string_object =
dynamic_cast<PseudoStringObject*>(PseudoObjectFromRef(obj));
if (string_object == nullptr) {
return nullptr;
}
bytes = JavaStringUtf8Bytes(string_object->chars);
}
if (bytes.size() >
static_cast<std::size_t>(std::numeric_limits<jsize>::max())) {
return nullptr;
}
jbyteArray result = MakeByteArray(static_cast<jsize>(bytes.size()));
PseudoArray* array = ArrayFromRef(result);
if (array == nullptr || array->bytes.size() != bytes.size()) {
return nullptr;
}
if (!bytes.empty()) {
std::memcpy(array->bytes.data(), bytes.data(), bytes.size());
}
return result;
}
static inline bool IsRawStringPointer(jstring str) {
uintptr_t val = reinterpret_cast<uintptr_t>(str);
if (val < 0x10000) {
return false;
}
if ((val & 0xffff) == 0 && (val >> kJniHandleShift) < kJniSegmentCapacity) {
return false;
}
return true;
}
const char* StringChars(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!str) {
return nullptr;
}
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object != nullptr ? string_object->modified_utf8.c_str() : "";
}
if (!IsRawStringPointer(str)) {
return "";
}
return reinterpret_cast<const char*>(str);
}
const jchar* StringUtf16Chars(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (!str) {
return nullptr;
}
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object && !string_object->chars.empty()
? string_object->chars.data()
: kEmptyUtf16;
}
if (!IsRawStringPointer(str)) {
return kEmptyUtf16;
}
return reinterpret_cast<const jchar*>(str);
}
jsize StringUtf16Length(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object != nullptr
? static_cast<jsize>(string_object->chars.size())
: 0;
}
if (!IsRawStringPointer(str)) {
return 0;
}
const char* bytes = reinterpret_cast<const char*>(str);
return bytes != nullptr ? static_cast<jsize>(std::strlen(bytes)) : 0;
}
jsize StringModifiedUtf8Length(jstring str) {
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
if (g_known_strings.find(str) != g_known_strings.end()) {
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
return string_object != nullptr
? static_cast<jsize>(string_object->modified_utf8.size())
: 0;
}
if (!IsRawStringPointer(str)) {
return 0;
}
const char* bytes = reinterpret_cast<const char*>(str);
return bytes != nullptr ? static_cast<jsize>(std::strlen(bytes)) : 0;
}
void CopyStringRegion(jstring str, jsize start, jsize length, jchar* output) {
if (str == nullptr || output == nullptr || start < 0 || length <= 0) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
if (string_object == nullptr ||
static_cast<std::size_t>(start) >= string_object->chars.size()) {
return;
}
const std::size_t count =
std::min(static_cast<std::size_t>(length),
string_object->chars.size() - static_cast<std::size_t>(start));
std::copy_n(string_object->chars.data() + start, count, output);
}
void CopyStringModifiedUtf8Region(jstring str, jsize start, jsize length,
char* output) {
if (str == nullptr || output == nullptr || start < 0 || length <= 0) {
return;
}
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
auto* string_object = static_cast<PseudoStringObject*>(
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
if (string_object == nullptr ||
static_cast<std::size_t>(start) >= string_object->chars.size()) {
return;
}
const std::size_t count =
std::min(static_cast<std::size_t>(length),
string_object->chars.size() - static_cast<std::size_t>(start));
const std::string encoded = Utf16ToModifiedUtf8(
string_object->chars, static_cast<std::size_t>(start), count);
std::memcpy(output, encoded.data(), encoded.size());
}
} // namespace jnivm::internal
+25
View File
@@ -0,0 +1,25 @@
#ifndef MOCKTAIL_JNIVM_JNI_STRINGS_H_
#define MOCKTAIL_JNIVM_JNI_STRINGS_H_
#include <jni.h>
#include <string>
#include <string_view>
namespace jnivm::internal {
jstring MakeString(const char* utf);
jstring MakeUtf16String(const jchar* utf16, jsize length);
std::string_view StringViewFromJString(jstring str);
std::string StringFromJString(jstring str);
jbyteArray JavaStringGetUtf8Bytes(jobject obj, jstring charset_name);
const char* StringChars(jstring str);
const jchar* StringUtf16Chars(jstring str);
jsize StringUtf16Length(jstring str);
jsize StringModifiedUtf8Length(jstring str);
void CopyStringRegion(jstring str, jsize start, jsize length, jchar* output);
void CopyStringModifiedUtf8Region(jstring str, jsize start, jsize length,
char* output);
} // namespace jnivm::internal
#endif
+62 -1041
View File
File diff suppressed because it is too large. Load diff
+85 -6
View File
@@ -4951,9 +4951,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
mocktail::runtime::ReadRobloxThemeCache(
app_storage_file, dependencies.account_identity().user_id);
if (!saved_theme) {
std::cerr << "[FATAL] Cannot read Roblox theme cache: "
std::cerr << " [theme] Cannot read Roblox theme cache; using default: "
<< saved_theme.error << '\n';
return EXIT_FAILURE;
}
if (saved_theme.dark_theme.has_value()) {
dark_theme = *saved_theme.dark_theme;
@@ -4964,7 +4963,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (runtime_config.theme_mode() == "roblox") {
std::cout << " [theme] "
<< (roblox_theme_found ? "Roblox saved theme="
: "Roblox theme missing; default=")
: "Roblox theme unavailable; default=")
<< (dark_theme ? "dark" : "light") << '\n'
<< std::flush;
} else if (app_storage_file != nullptr) {
@@ -4972,9 +4971,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
if (!mocktail::runtime::ApplyRobloxThemeCacheOverride(
app_storage_file, dependencies.account_identity().user_id,
dark_theme, &theme_error)) {
std::cerr << "[FATAL] Roblox theme cache override failed: " << theme_error
<< '\n';
return EXIT_FAILURE;
std::cerr << " [theme] Cannot persist Roblox theme override: "
<< theme_error << '\n';
}
std::cout << " [theme] Roblox local theme="
<< (dark_theme ? "dark" : "light") << '\n'
@@ -6006,6 +6004,10 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
experience_game_symbols;
std::shared_ptr<mocktail::runtime::RobloxExperienceComposition>
experience_composition;
std::unique_ptr<mocktail::runtime::RobloxPermissionsBridge>
eager_permissions_bridge;
std::unique_ptr<mocktail::runtime::RobloxCallProtocolBridge>
eager_call_protocol_bridge;
std::shared_ptr<ExperienceLifecycleTarget> experience_lifecycle_target;
std::shared_ptr<mocktail::runtime::RobloxWindowInputRuntime>
window_input_runtime;
@@ -6665,6 +6667,75 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
<< " run_start_app_with_params="
<< (run_start_app_with_params ? 1 : 0) << '\n' << std::flush;
// The direct GAME path still runs CoreScripts that query app protocols.
// Install these independently of ExperienceProtocol's dynamic launch path.
if (game_session_runtime != nullptr) {
mocktail::runtime::JniEnvironmentProvider environment{
raw_vm, jni_vm.get(), &RestoreGameSessionJniEnvironment};
JNIEnv* env = nullptr;
auto status = environment.Acquire(&env);
if (!status.ok()) {
std::cerr << "[FATAL] GAME protocol JNI environment unavailable\n";
return EXIT_FAILURE;
}
const auto symbols = mocktail::runtime::ResolveRobloxPlatformWebSymbols(
roblox_handle,
reinterpret_cast<mocktail::runtime::SubscribeExperienceLaunchRawFn>(
linker::ResolveSymbol(roblox_handle,
"Java_com_roblox_universalapp_messagebus_"
"MessageBus_doSubscribeRaw")),
reinterpret_cast<mocktail::runtime::DeleteMessageBusConnectionFn>(
linker::ResolveSymbol(roblox_handle,
"Java_com_roblox_universalapp_messagebus_"
"Connection_deleteSharedPtr")));
jclass bus_class =
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
jmethodID singleton =
bus_class ? env->GetStaticMethodID(
bus_class, "f",
"()Lcom/roblox/universalapp/messagebus/MessageBus;")
: nullptr;
jobject bus = singleton
? env->CallStaticObjectMethod(bus_class, singleton)
: nullptr;
if (bus_class)
env->DeleteLocalRef(bus_class);
if (!bus || env->ExceptionCheck()) {
if (env->ExceptionCheck())
env->ExceptionClear();
if (bus)
env->DeleteLocalRef(bus);
std::cerr << "[FATAL] GAME platform MessageBus is unavailable\n";
return EXIT_FAILURE;
}
const mocktail::runtime::RobloxMessageBusObjects objects{
bus,
jni_vm.get(),
&CreateExperienceRawCallback,
&ClearExperienceRawCallback,
&CreateAsyncMessageBusRequestHandler,
&ClearAsyncMessageBusRequestHandler};
eager_permissions_bridge =
std::make_unique<mocktail::runtime::RobloxPermissionsBridge>(
environment, symbols.permissions, objects,
runtime_config.microphone_enabled());
eager_call_protocol_bridge =
std::make_unique<mocktail::runtime::RobloxCallProtocolBridge>(
environment, symbols.permissions, objects);
status = eager_permissions_bridge->Initialize();
if (status.ok())
status = eager_call_protocol_bridge->Initialize();
env->DeleteLocalRef(bus);
if (!status.ok()) {
std::cerr << "[FATAL] GAME platform protocols did not initialize: "
<< status.message() << '\n';
return EXIT_FAILURE;
}
std::cout << " [platform] direct GAME PermissionsProtocol and "
"CallProtocol ready\n"
<< std::flush;
}
if (experience_game_symbols != nullptr) {
mocktail::runtime::RobloxExperienceMessageBusSymbols message_bus_symbols;
message_bus_symbols.get_launch_id =
@@ -7156,6 +7227,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
input_shutdown_completed = window_input_runtime->Shutdown().ok() &&
input_shutdown_completed;
}
if (eager_call_protocol_bridge != nullptr) {
(void)eager_call_protocol_bridge->Shutdown();
eager_call_protocol_bridge.reset();
}
if (eager_permissions_bridge != nullptr) {
(void)eager_permissions_bridge->Shutdown();
eager_permissions_bridge.reset();
}
bool experience_destroyed_app = false;
if (experience_composition != nullptr) {
jni_vm->ClearRobloxExperienceLifecycleCallbacks();
+20 -23
View File
@@ -359,46 +359,43 @@ RobloxThemeCacheResult ReadRobloxThemeCache(
return result;
}
const auto read_theme = [&result](const nlohmann::json& value,
std::string_view name) {
const auto read_theme = [](const nlohmann::json& value) -> std::optional<bool> {
if (!value.is_string()) {
result.error = std::string(name) + " is not a string";
return;
return std::nullopt;
}
const std::string& theme = value.get_ref<const std::string&>();
if (theme != "dark" && theme != "light") {
result.error = std::string(name) + " must be dark or light";
return;
if (theme == "dark") {
return true;
}
result.dark_theme = theme == "dark";
if (theme == "light") {
return false;
}
return std::nullopt;
};
if (authenticated_user_id >= 0) {
const auto encoded_device_themes = storage.value.find("DeviceLevelTheme");
if (encoded_device_themes != storage.value.end()) {
if (!encoded_device_themes->is_string()) {
result.error = "Roblox DeviceLevelTheme is not an encoded object";
return result;
}
if (encoded_device_themes != storage.value.end() &&
encoded_device_themes->is_string()) {
const nlohmann::json device_themes = nlohmann::json::parse(
encoded_device_themes->get_ref<const std::string&>(), nullptr, false,
true);
if (device_themes.is_discarded() || !device_themes.is_object()) {
result.error = "Roblox DeviceLevelTheme encoding is invalid";
return result;
}
const auto account_theme =
device_themes.find(std::to_string(authenticated_user_id));
if (account_theme != device_themes.end()) {
read_theme(*account_theme, "Roblox account theme");
return result;
if (device_themes.is_object()) {
const auto account_theme =
device_themes.find(std::to_string(authenticated_user_id));
if (account_theme != device_themes.end()) {
result.dark_theme = read_theme(*account_theme);
if (result.dark_theme.has_value()) {
return result;
}
}
}
}
}
const auto authenticated_theme = storage.value.find("AuthenticatedTheme");
if (authenticated_theme != storage.value.end()) {
read_theme(*authenticated_theme, "Roblox authenticated theme");
result.dark_theme = read_theme(*authenticated_theme);
}
return result;
}
@@ -0,0 +1,33 @@
#include "runtime/roblox_call_protocol_bridge.h"
namespace mocktail {
namespace runtime {
RobloxCallProtocolBridge::RobloxCallProtocolBridge(
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects)
: bridge_(environment, symbols, objects, "Call", {"getCallState"},
[](JNIEnv* env, jstring request, std::size_t) {
// CallProtocolCore registers the wire protocol "Call" (not the
// Lua module name "CallProtocol"). Its handler ignores the
// payload and returns CallState::serializeCallStateJson. Match
// the no-call state; preserve the caller's default-muted
// initialization path.
if (request && env->GetStringUTFLength(request) > 65536) {
return RobloxMessageBusReply{"{}", 13, "oversized_request"};
}
return RobloxMessageBusReply{
R"({"status":"Idle","muted":true,"camEnabled":false})", 0,
"idle"};
}) {}
Status RobloxCallProtocolBridge::Initialize() {
return bridge_.Initialize();
}
Status RobloxCallProtocolBridge::Shutdown() {
return bridge_.Shutdown();
}
} // namespace runtime
} // namespace mocktail
+150 -70
View File
@@ -138,11 +138,16 @@ Status PublishSystemTheme(JNIEnv* env,
status = CheckJni(env, "publish SystemTheme.systemThemeUpdated");
}
if (message_id != nullptr) env->DeleteLocalRef(message_id);
if (payload != nullptr) env->DeleteLocalRef(payload);
if (message != nullptr) env->DeleteLocalRef(message);
if (protocol != nullptr) env->DeleteLocalRef(protocol);
if (message_bus_class != nullptr) env->DeleteLocalRef(message_bus_class);
if (message_id != nullptr)
env->DeleteLocalRef(message_id);
if (payload != nullptr)
env->DeleteLocalRef(payload);
if (message != nullptr)
env->DeleteLocalRef(message);
if (protocol != nullptr)
env->DeleteLocalRef(protocol);
if (message_bus_class != nullptr)
env->DeleteLocalRef(message_bus_class);
return status;
}
@@ -203,7 +208,8 @@ template <>
bool SetField<jint>(JNIEnv* env, jobject object, jclass clazz, const char* name,
const char* signature, jint value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetIntField(object, field, value);
return true;
}
@@ -212,7 +218,8 @@ template <>
bool SetField<jlong>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature, jlong value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetLongField(object, field, value);
return true;
}
@@ -222,7 +229,8 @@ bool SetField<jboolean>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature,
jboolean value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetBooleanField(object, field, value);
return true;
}
@@ -231,7 +239,8 @@ template <>
bool SetField<jfloat>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature, jfloat value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetFloatField(object, field, value);
return true;
}
@@ -240,7 +249,8 @@ template <>
bool SetField<jobject>(JNIEnv* env, jobject object, jclass clazz,
const char* name, const char* signature, jobject value) {
jfieldID field = env->GetFieldID(clazz, name, signature);
if (field == nullptr) return false;
if (field == nullptr)
return false;
env->SetObjectField(object, field, value);
return true;
}
@@ -352,7 +362,9 @@ RobloxExperienceComposition::RobloxExperienceComposition(
}
}
RobloxExperienceComposition::~RobloxExperienceComposition() { Shutdown(); }
RobloxExperienceComposition::~RobloxExperienceComposition() {
Shutdown();
}
Status RobloxExperienceComposition::InitializePlatformProtocols() {
if (!web_view_cookie_initialization_error_.empty()) {
@@ -366,7 +378,8 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (platform_protocols_initialized_ || objects_ != nullptr ||
web_view_bridge_ != nullptr || permissions_bridge_ != nullptr) {
web_view_bridge_ != nullptr || permissions_bridge_ != nullptr ||
call_protocol_bridge_ != nullptr) {
return FailedPrecondition("platform protocols are already initialized");
}
}
@@ -443,6 +456,16 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
(void)ReleaseGlobalObjects();
return status;
}
auto call_protocol_bridge = std::make_unique<RobloxCallProtocolBridge>(
environment_, permissions_symbols_, permissions_objects);
status = call_protocol_bridge->Initialize();
if (!status.ok()) {
(void)permissions_bridge->Shutdown();
(void)browser_service_bridge->Shutdown();
(void)web_view_bridge->Shutdown();
(void)ReleaseGlobalObjects();
return status;
}
// AccountProtocol only shortens a device-integrity login challenge that
// cannot succeed here, so its absence or failure never blocks startup.
std::unique_ptr<RobloxAccountBridge> account_bridge;
@@ -469,12 +492,13 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
web_view_bridge_ = std::move(web_view_bridge);
browser_service_bridge_ = std::move(browser_service_bridge);
permissions_bridge_ = std::move(permissions_bridge);
call_protocol_bridge_ = std::move(call_protocol_bridge);
account_bridge_ = std::move(account_bridge);
platform_protocols_initialized_ = true;
}
std::fprintf(stderr,
" [platform] Android WebViewProtocol, BrowserService and "
"PermissionsProtocol "
"PermissionsProtocol and CallProtocol "
"bridges ready\n");
return Status::Ok();
}
@@ -494,7 +518,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
std::lock_guard<std::mutex> lock(mutex_);
if (!platform_protocols_initialized_ || objects_ == nullptr ||
objects_->message_bus == nullptr || web_view_bridge_ == nullptr ||
browser_service_bridge_ == nullptr || permissions_bridge_ == nullptr) {
browser_service_bridge_ == nullptr || permissions_bridge_ == nullptr ||
call_protocol_bridge_ == nullptr) {
return FailedPrecondition(
"platform protocols must be initialized before LuaApp readiness");
}
@@ -549,7 +574,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
controller_.reset();
}
}
if (!status.ok()) (void)ReleaseLuaAppGlobalObjects();
if (!status.ok())
(void)ReleaseLuaAppGlobalObjects();
if (status.ok()) {
std::fprintf(stderr,
" [theme] published SystemTheme.systemThemeUpdated (%s)\n",
@@ -622,8 +648,7 @@ Status RobloxExperienceComposition::OpenWebSurface(
!current->SetBackNavigationDisabled(
presentation.back_navigation_disabled) ||
!current->SetShowDomainAsTitle(presentation.show_domain_as_title) ||
!cookie_synchronized ||
(!spawn && !current->LoadUrl(url))) {
!cookie_synchronized || (!spawn && !current->LoadUrl(url))) {
(void)current->RequestClose();
return Unavailable("could not configure reusable Roblox web surface");
}
@@ -1211,10 +1236,10 @@ Status RobloxExperienceComposition::RefreshLateSurface() {
SurfaceUpdateStatus(SurfaceCreated(event.surface.generation));
break;
case window::WindowSurfaceEventType::kChanged:
status = SurfaceUpdateStatus(SurfaceChanged(
{event.surface.generation, event.surface.native_window,
event.surface.width, event.surface.height,
event.surface.dpi_scale}));
status = SurfaceUpdateStatus(
SurfaceChanged({event.surface.generation,
event.surface.native_window, event.surface.width,
event.surface.height, event.surface.dpi_scale}));
break;
case window::WindowSurfaceEventType::kDestroyed:
status =
@@ -1338,14 +1363,16 @@ Status RobloxExperienceComposition::DrainExternalLaunchRequests() {
void RobloxExperienceComposition::NotifyLuaAppDidReturn() {
std::lock_guard<std::mutex> lock(mutex_);
if (subscribed_) lua_app_return_pending_ = true;
if (subscribed_)
lua_app_return_pending_ = true;
}
Status RobloxExperienceComposition::DrainLaunchRequests() {
Status completed_result = Status::Ok();
if (launch_worker_.joinable()) {
const OwnedPthreadWaitResult wait = launch_worker_.WaitFor(0, 1);
if (wait.status == OwnedPthreadWaitStatus::kTimedOut) return Status::Ok();
if (wait.status == OwnedPthreadWaitStatus::kTimedOut)
return Status::Ok();
if (!wait.joined()) {
return Status::Error(StatusCode::kPlatformError,
"could not reap experience launch worker: " +
@@ -1405,7 +1432,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
}
Status external_launch_status = DrainExternalLaunchRequests();
if (!external_launch_status.ok()) return external_launch_status;
if (!external_launch_status.ok())
return external_launch_status;
bool switch_active_game = false;
std::optional<RobloxExperienceLaunchRequest> joining_request;
@@ -1414,7 +1442,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
return FailedPrecondition("experience composition is not subscribed");
}
if (controlled_switch_waiting_for_return_) return Status::Ok();
if (controlled_switch_waiting_for_return_)
return Status::Ok();
if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ &&
!pending_launch_requests_.empty()) {
// Start the queued launch only after consuming this leave callback, so
@@ -1439,9 +1468,12 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
return FailedPrecondition("experience composition is not subscribed");
}
if (launch_in_progress_ || active_launch_ != nullptr) return Status::Ok();
if (game_active_) return Status::Ok();
if (pending_launch_requests_.empty()) return Status::Ok();
if (launch_in_progress_ || active_launch_ != nullptr)
return Status::Ok();
if (game_active_)
return Status::Ok();
if (pending_launch_requests_.empty())
return Status::Ok();
if (next_request_id_ == 0 ||
next_request_id_ == std::numeric_limits<uint64_t>::max()) {
pending_launch_requests_.clear();
@@ -1477,7 +1509,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
const int start_error =
launch_worker_.Start(&RobloxExperienceComposition::RunLaunchWorker, this,
kLaunchWorkerStackSize);
if (start_error == 0) return Status::Ok();
if (start_error == 0)
return Status::Ok();
{
std::lock_guard<std::mutex> lock(mutex_);
launch_in_progress_ = false;
@@ -1534,7 +1567,8 @@ void RobloxExperienceComposition::RunActiveLaunch() {
std::lock_guard<std::mutex> lock(mutex_);
task = active_launch_.get();
}
if (task == nullptr) return;
if (task == nullptr)
return;
Status status =
BuildLaunchObjects(task->context.game_surface, &task->context.surface,
&task->context.platform_params);
@@ -1658,6 +1692,7 @@ Status RobloxExperienceComposition::Shutdown() {
std::unique_ptr<RobloxWebViewBridge> web_view_bridge;
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge;
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge;
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge;
std::unique_ptr<RobloxAccountBridge> account_bridge;
std::shared_ptr<WebViewHelperProcess> web_surface_process;
jnivm::VM* late_lifecycle_vm = nullptr;
@@ -1683,6 +1718,7 @@ Status RobloxExperienceComposition::Shutdown() {
web_view_bridge = std::move(web_view_bridge_);
browser_service_bridge = std::move(browser_service_bridge_);
permissions_bridge = std::move(permissions_bridge_);
call_protocol_bridge = std::move(call_protocol_bridge_);
account_bridge = std::move(account_bridge_);
web_surface_process = std::move(web_surface_process_);
web_surface_logical_exit_observer_ = {};
@@ -1700,16 +1736,23 @@ Status RobloxExperienceComposition::Shutdown() {
if (web_surface_process != nullptr) {
(void)web_surface_process->RequestClose();
}
Status status =
Status status = call_protocol_bridge != nullptr
? call_protocol_bridge->Shutdown()
: Status::Ok();
const Status account_status =
account_bridge != nullptr ? account_bridge->Shutdown() : Status::Ok();
if (status.ok())
status = account_status;
const Status permissions_status = permissions_bridge != nullptr
? permissions_bridge->Shutdown()
: Status::Ok();
if (status.ok()) status = permissions_status;
if (status.ok())
status = permissions_status;
const Status browser_status = browser_service_bridge != nullptr
? browser_service_bridge->Shutdown()
: Status::Ok();
if (status.ok()) status = browser_status;
if (status.ok())
status = browser_status;
const Status web_view_status =
web_view_bridge != nullptr ? web_view_bridge->Shutdown() : Status::Ok();
if (status.ok()) {
@@ -1721,6 +1764,7 @@ Status RobloxExperienceComposition::Shutdown() {
status = bridge_status;
}
browser_service_bridge.reset();
call_protocol_bridge.reset();
account_bridge.reset();
permissions_bridge.reset();
web_view_bridge.reset();
@@ -1739,14 +1783,16 @@ Status RobloxExperienceComposition::Shutdown() {
launch_in_progress_ = false;
lua_app_return_pending_ = false;
if (active_launch_ != nullptr) {
if (status.ok()) status = active_launch_->result;
if (status.ok())
status = active_launch_->result;
active_launch_.reset();
}
controller = std::move(controller_);
}
if (controller != nullptr) {
const Status controller_status = controller->Shutdown();
if (status.ok()) status = controller_status;
if (status.ok())
status = controller_status;
}
const Status release = ReleaseGlobalObjects();
return status.ok() ? release : status;
@@ -1777,7 +1823,8 @@ void RobloxExperienceComposition::NotifyPresence(
Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jobject bus_local = nullptr;
jclass bus_class =
@@ -1791,7 +1838,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
bus_local = singleton != nullptr
? env->CallStaticObjectMethod(bus_class, singleton)
: nullptr;
if (bus_class != nullptr) env->DeleteLocalRef(bus_class);
if (bus_class != nullptr)
env->DeleteLocalRef(bus_class);
status = bus_local != nullptr
? CheckJni(env, "obtain exact MessageBus.f singleton")
: Unavailable("MessageBus.f singleton is unavailable");
@@ -1804,7 +1852,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
}
}
if (bus_local != nullptr) env->DeleteLocalRef(bus_local);
if (bus_local != nullptr)
env->DeleteLocalRef(bus_local);
if (!status.ok()) {
if (global->message_bus != nullptr)
env->DeleteGlobalRef(global->message_bus);
@@ -1822,7 +1871,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jclass native_gl_local =
env->FindClass("com/roblox/engine/jni/NativeGLInterface");
@@ -1846,12 +1896,17 @@ Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
}
}
if (activity_local != nullptr) env->DeleteLocalRef(activity_local);
if (activity_class != nullptr) env->DeleteLocalRef(activity_class);
if (native_gl_local != nullptr) env->DeleteLocalRef(native_gl_local);
if (activity_local != nullptr)
env->DeleteLocalRef(activity_local);
if (activity_class != nullptr)
env->DeleteLocalRef(activity_class);
if (native_gl_local != nullptr)
env->DeleteLocalRef(native_gl_local);
if (!status.ok()) {
if (activity_global != nullptr) env->DeleteGlobalRef(activity_global);
if (native_gl_global != nullptr) env->DeleteGlobalRef(native_gl_global);
if (activity_global != nullptr)
env->DeleteGlobalRef(activity_global);
if (native_gl_global != nullptr)
env->DeleteGlobalRef(native_gl_global);
return status;
}
@@ -1878,7 +1933,8 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
*platform_params = nullptr;
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jclass surface_class = nullptr;
jobject surface_local = nullptr;
@@ -1912,8 +1968,9 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
SetField<jobject>(env, params_local, params_class, "assetFolderPath",
"Ljava/lang/String;", asset_path) &&
SetField<jfloat>(env, params_local, params_class, "dpiScale", "F",
surface.dpi_scale > 0.0f ? surface.dpi_scale
: surface_config_.dpi_scale) &&
surface.dpi_scale > 0.0f
? surface.dpi_scale
: surface_config_.dpi_scale) &&
SetField<jint>(env, params_local, params_class, "viewportWidthMm", "I",
surface_config_.viewport_width_mm) &&
SetField<jint>(env, params_local, params_class, "viewportHeightMm", "I",
@@ -1932,12 +1989,17 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
? CheckJni(env, "populate fresh launch PlatformParams")
: Unavailable("required PlatformParams fields are unavailable");
}
if (asset_path != nullptr) env->DeleteLocalRef(asset_path);
if (surface_class != nullptr) env->DeleteLocalRef(surface_class);
if (params_class != nullptr) env->DeleteLocalRef(params_class);
if (asset_path != nullptr)
env->DeleteLocalRef(asset_path);
if (surface_class != nullptr)
env->DeleteLocalRef(surface_class);
if (params_class != nullptr)
env->DeleteLocalRef(params_class);
if (!status.ok()) {
if (params_local != nullptr) env->DeleteLocalRef(params_local);
if (surface_local != nullptr) env->DeleteLocalRef(surface_local);
if (params_local != nullptr)
env->DeleteLocalRef(params_local);
if (surface_local != nullptr)
env->DeleteLocalRef(surface_local);
return status;
}
*surface_object = surface_local;
@@ -2008,13 +2070,19 @@ Status RobloxExperienceComposition::BuildLuaAppStartParams(
"required LuaApp StartAppParams fields are unavailable");
}
if (selected_theme != nullptr) env->DeleteLocalRef(selected_theme);
if (username != nullptr) env->DeleteLocalRef(username);
if (app_starter_script != nullptr) env->DeleteLocalRef(app_starter_script);
if (app_starter_place != nullptr) env->DeleteLocalRef(app_starter_place);
if (params_class != nullptr) env->DeleteLocalRef(params_class);
if (selected_theme != nullptr)
env->DeleteLocalRef(selected_theme);
if (username != nullptr)
env->DeleteLocalRef(username);
if (app_starter_script != nullptr)
env->DeleteLocalRef(app_starter_script);
if (app_starter_place != nullptr)
env->DeleteLocalRef(app_starter_place);
if (params_class != nullptr)
env->DeleteLocalRef(params_class);
if (!status.ok()) {
if (params_local != nullptr) env->DeleteLocalRef(params_local);
if (params_local != nullptr)
env->DeleteLocalRef(params_local);
return status;
}
*start_app_params = params_local;
@@ -2046,7 +2114,8 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jobject surface_object = nullptr;
jobject platform_params = nullptr;
@@ -2086,11 +2155,16 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
status = CheckJni(env, "restart LuaApp with recreated surface");
}
if (start_reason != nullptr) env->DeleteLocalRef(start_reason);
if (stop_reason != nullptr) env->DeleteLocalRef(stop_reason);
if (start_app_params != nullptr) env->DeleteLocalRef(start_app_params);
if (platform_params != nullptr) env->DeleteLocalRef(platform_params);
if (surface_object != nullptr) env->DeleteLocalRef(surface_object);
if (start_reason != nullptr)
env->DeleteLocalRef(start_reason);
if (stop_reason != nullptr)
env->DeleteLocalRef(stop_reason);
if (start_app_params != nullptr)
env->DeleteLocalRef(start_app_params);
if (platform_params != nullptr)
env->DeleteLocalRef(platform_params);
if (surface_object != nullptr)
env->DeleteLocalRef(surface_object);
if (status.ok()) {
std::fprintf(
stderr,
@@ -2150,7 +2224,8 @@ Status RobloxExperienceComposition::RebindLuaAppSurface(
Status RobloxExperienceComposition::ReleaseGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
std::unique_ptr<GlobalObjects> objects;
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -2165,10 +2240,12 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
pending_launch_requests_.clear();
next_request_id_ = 1;
}
if (objects == nullptr) return Status::Ok();
if (objects == nullptr)
return Status::Ok();
if (objects->message_bus != nullptr)
env->DeleteGlobalRef(objects->message_bus);
if (objects->activity != nullptr) env->DeleteGlobalRef(objects->activity);
if (objects->activity != nullptr)
env->DeleteGlobalRef(objects->activity);
if (objects->native_gl_class != nullptr)
env->DeleteGlobalRef(objects->native_gl_class);
return CheckJni(env, "release experience composition JNI roots");
@@ -2177,7 +2254,8 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
if (!status.ok())
return status;
jclass native_gl_class = nullptr;
jobject activity = nullptr;
@@ -2197,8 +2275,10 @@ Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
pending_launch_requests_.clear();
next_request_id_ = 1;
}
if (activity != nullptr) env->DeleteGlobalRef(activity);
if (native_gl_class != nullptr) env->DeleteGlobalRef(native_gl_class);
if (activity != nullptr)
env->DeleteGlobalRef(activity);
if (native_gl_class != nullptr)
env->DeleteGlobalRef(native_gl_class);
return CheckJni(env, "release LuaApp experience JNI roots");
}
+1 -7
View File
@@ -609,13 +609,7 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
}
const float clamped_x = mouse_x_;
const float clamped_y = mouse_y_;
if (event.pressed) {
const RobloxTextEditorSnapshot text = text_editor_.Snapshot();
if (text.focused) {
(void)text_editor_.EndFocusSession(text.textbox_handle, text.generation,
true);
}
}
// Let Roblox decide whether a click releases TextBox focus.
Status status = sink_.mouse_button(sink_.context, clamped_x, clamped_y,
event.pressed, roblox_button);
if (status.ok()) {
+33 -2
View File
@@ -2,6 +2,9 @@
#include <curl/curl.h>
// [patch.py] RequestFollowUser support
#include "runtime/roblox_presence_resolver.h"
#include <algorithm>
#include <array>
#include <cctype>
@@ -541,14 +544,42 @@ Status ValidateSelector(const LaunchFields& fields) {
? Status::Ok()
: Invalid("RequestPlayTogetherGame requires a conversation");
}
// [patch.py] RequestFollowUser support
if (request == "requestfollowuser") {
// By the time ValidateSelector runs, MakeRequest has already
// resolved user_id -> place_id and cleared user_id, so check the
// resolved place_id here rather than the now-zeroed user_id.
return fields.place_id > 0
? Status::Ok()
: Invalid("RequestFollowUser could not resolve a place");
}
return Invalid("Roblox PlaceLauncher request type is unsupported");
}
Status MakeRequest(const LaunchFields& fields,
// [patch.py] RequestFollowUser support
Status MakeRequest(const LaunchFields& fields_in,
RobloxExperienceLaunchRequest* request) {
if (fields.place_id <= 0) {
LaunchFields fields = fields_in;
const bool is_follow_user =
AsciiLower(fields.request_type) == "requestfollowuser";
if (fields.place_id <= 0 && !is_follow_user) {
return Invalid("Roblox launch URI requires a positive placeId");
}
if (fields.place_id <= 0 && is_follow_user) {
ResolvedFollowUserPlace resolved;
const Status resolve_status = ResolveFollowUserPlace(
fields.user_id, ReadStoredRoblosecurityCookie(), &resolved);
if (!resolve_status.ok()) {
return resolve_status;
}
fields.place_id = resolved.place_id;
if (!resolved.game_instance_id.empty()) {
fields.game_instance_id = resolved.game_instance_id;
}
// user_id was only needed to resolve which place to join; clear
// it so ValidateSelector does not see two conflicting selectors.
fields.user_id = 0;
}
Status status = ValidateSelector(fields);
if (!status.ok()) {
return status;
@@ -0,0 +1,295 @@
#include "runtime/roblox_message_bus_request_bridge.h"
#include <condition_variable>
#include <cstdio>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
Status Unavailable(const char* message) {
return Status::Error(StatusCode::kUnavailable, message);
}
Status CheckJni(JNIEnv* env) {
if (!env->ExceptionCheck())
return Status::Ok();
env->ExceptionClear();
return Unavailable("MessageBus JNI operation failed");
}
// JNI local references are never retained across callback invocations.
struct LocalString {
JNIEnv* env;
jstring value;
LocalString(JNIEnv* source, const char* text)
: env(source), value(source->NewStringUTF(text)) {}
~LocalString() {
if (value)
env->DeleteLocalRef(value);
}
};
} // namespace
struct RobloxMessageBusRequestBridge::State {
struct Endpoint {
std::shared_ptr<State> state;
std::size_t method;
};
struct Registration {
jobject callback = nullptr;
jobject handler = nullptr;
jobject connection = nullptr;
jlong connection_handle = 0;
jstring method = nullptr;
bool handler_registered = false;
};
RobloxMessageBusSymbols symbols;
jobject bus = nullptr;
jstring protocol = nullptr;
std::string protocol_name;
std::vector<std::string> methods;
Handler handler;
std::vector<Registration> registrations;
std::mutex mutex;
std::condition_variable drained;
bool accepting = true;
std::size_t in_flight = 0;
static void Legacy(void* context, JNIEnv* env, jstring message) {
Dispatch(context, env, message, nullptr);
}
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
Dispatch(context, env, message, id);
}
static void Dispatch(void* context, JNIEnv* env, jstring message,
jstring id) {
const auto* endpoint = static_cast<Endpoint*>(context);
const auto state = endpoint->state;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->accepting)
return;
++state->in_flight;
}
struct Finish {
std::shared_ptr<State> state;
~Finish() {
std::lock_guard<std::mutex> lock(state->mutex);
--state->in_flight;
state->drained.notify_all();
}
} finish{state};
const auto response = state->handler(env, message, endpoint->method);
// Request bodies and correlation IDs must never be logged.
std::fprintf(stderr, " [messagebus] %s method=%s result=%s transport=%s\n",
state->protocol_name.c_str(),
state->methods[endpoint->method].c_str(), response.outcome,
id ? "async" : "legacy");
LocalString body(env, response.json.c_str());
LocalString telemetry(env, "{}");
LocalString method(env, state->methods[endpoint->method].c_str());
const Status allocated = CheckJni(env);
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
std::fprintf(stderr, " [messagebus] response allocation failed\n");
return;
}
// The APK publishes the legacy response even for the newer async API,
// then resolves the individual request by its opaque response ID.
state->symbols.publish_response(env, state->bus, state->protocol,
method.value, body.value, response.code,
telemetry.value);
const Status published = CheckJni(env);
if (id) {
state->symbols.call_response_handler(env, state->bus, id, body.value);
}
if (!CheckJni(env).ok() || !published.ok()) {
std::fprintf(stderr, " [messagebus] response delivery failed\n");
}
}
};
RobloxMessageBusRequestBridge::RobloxMessageBusRequestBridge(
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects, std::string protocol,
std::vector<std::string> methods, Handler handler)
: environment_(environment),
symbols_(symbols),
objects_(objects),
protocol_name_(std::move(protocol)),
methods_(std::move(methods)),
handler_(std::move(handler)) {}
RobloxMessageBusRequestBridge::~RobloxMessageBusRequestBridge() {
(void)Shutdown();
}
Status RobloxMessageBusRequestBridge::Initialize() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
if (state_) {
return Status::Error(StatusCode::kFailedPrecondition,
"MessageBus request bridge is already initialized");
}
if (!environment_.valid() || !symbols_.complete() || !objects_.complete() ||
protocol_name_.empty() || methods_.empty() || methods_.size() > 64 ||
!handler_) {
return Unavailable(
"MessageBus request bridge prerequisites are incomplete");
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok())
return status;
state_ = std::make_shared<State>();
state_->symbols = symbols_;
state_->protocol_name = protocol_name_;
state_->methods = methods_;
state_->handler = handler_;
state_->registrations.resize(methods_.size());
state_->bus = env->NewGlobalRef(objects_.message_bus);
LocalString protocol(env, protocol_name_.c_str());
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
auto fail = [this](Status failure) {
(void)ShutdownLocked();
return failure;
};
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
return fail(
Unavailable("could not retain MessageBus request bridge roots"));
}
for (std::size_t i = 0; i < methods_.size(); ++i) {
auto& entry = state_->registrations[i];
auto endpoint =
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
LocalString method(env, methods_[i].c_str());
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
&State::Legacy);
if (callback) {
entry.callback = env->NewGlobalRef(callback);
if (!entry.callback)
objects_.clear_raw_callback(objects_.context, callback);
env->DeleteLocalRef(callback);
}
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
&State::Async);
if (handler) {
entry.handler = env->NewGlobalRef(handler);
if (!entry.handler)
objects_.clear_async_handler(objects_.context, handler);
env->DeleteLocalRef(handler);
}
if (!entry.method || !entry.callback || !entry.handler ||
!CheckJni(env).ok()) {
return fail(
Unavailable("could not create MessageBus request bridge callbacks"));
}
jobject connection =
symbols_.subscribe_request(env, state_->bus, state_->protocol,
entry.method, entry.callback, JNI_FALSE);
if (connection) {
jclass cls = env->GetObjectClass(connection);
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
if (pointer)
entry.connection_handle = env->GetLongField(connection, pointer);
if (cls)
env->DeleteLocalRef(cls);
entry.connection = env->NewGlobalRef(connection);
if (!entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, connection, entry.connection_handle);
entry.connection_handle = 0;
}
env->DeleteLocalRef(connection);
}
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
return fail(
Unavailable("could not subscribe MessageBus request bridge request"));
}
entry.handler_registered = true;
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
entry.handler);
status = CheckJni(env);
if (!status.ok())
return fail(status);
}
std::fprintf(stderr, " [messagebus] %s ready legacy=%zu async=%zu\n",
protocol_name_.c_str(), methods_.size(), methods_.size());
return Status::Ok();
}
Status RobloxMessageBusRequestBridge::Shutdown() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
return ShutdownLocked();
}
Status RobloxMessageBusRequestBridge::ShutdownLocked() {
if (!state_)
return Status::Ok();
{
std::unique_lock<std::mutex> lock(state_->mutex);
state_->accepting = false;
state_->drained.wait(lock, [this] {
return state_->in_flight == 0;
});
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok())
return status; // Keep roots for a later shutdown retry.
// Clear any exception from partially failed initialization before cleanup.
const Status pending = CheckJni(env);
if (!pending.ok())
status = pending;
for (auto& entry : state_->registrations) {
if (entry.handler_registered) {
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
const Status cleared = CheckJni(env);
if (!cleared.ok())
status = cleared;
entry.handler_registered = false;
}
if (entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, entry.connection,
entry.connection_handle);
const Status disconnected = CheckJni(env);
if (!disconnected.ok())
status = disconnected;
entry.connection_handle = 0;
}
if (entry.callback) {
objects_.clear_raw_callback(objects_.context, entry.callback);
env->DeleteGlobalRef(entry.callback);
}
if (entry.handler) {
objects_.clear_async_handler(objects_.context, entry.handler);
env->DeleteGlobalRef(entry.handler);
}
if (entry.connection)
env->DeleteGlobalRef(entry.connection);
if (entry.method)
env->DeleteGlobalRef(entry.method);
entry = {};
const Status cleared = CheckJni(env);
if (!cleared.ok())
status = cleared;
}
if (state_->protocol)
env->DeleteGlobalRef(state_->protocol);
if (state_->bus)
env->DeleteGlobalRef(state_->bus);
state_.reset();
return status;
}
} // namespace runtime
} // namespace mocktail
+29 -257
View File
@@ -1,46 +1,14 @@
#include "runtime/roblox_permissions_bridge.h"
#include <array>
#include <condition_variable>
#include <cstdio>
#include <nlohmann/json.hpp>
#include <string>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
constexpr char kProtocol[] = "PermissionsProtocol";
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
constexpr const char* kMethods[] = {
"SupportsPermissions", "HasPermissions", "PermissionsRequest",
"ShouldShowPermissionUpsell", "ShouldShowRequestPermissionRationale"};
constexpr std::size_t kMethodCount = std::size(kMethods);
constexpr std::size_t kMaximumJsonBytes = 64 * 1024;
constexpr std::size_t kMaximumFeatures = 64;
Status Unavailable(const char* message) {
return Status::Error(StatusCode::kUnavailable, message);
}
Status CheckJni(JNIEnv* env) {
if (!env->ExceptionCheck()) return Status::Ok();
env->ExceptionClear();
return Unavailable("PermissionsProtocol JNI operation failed");
}
// JNI local references are never retained across callback invocations.
struct LocalString {
JNIEnv* env;
jstring value;
LocalString(JNIEnv* source, const char* text)
: env(source), value(source->NewStringUTF(text)) {}
~LocalString() {
if (value) env->DeleteLocalRef(value);
}
};
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
struct Response {
nlohmann::json body;
@@ -63,7 +31,8 @@ bool HasBoundedDepth(const std::string& json) {
} else if (ch == '"') {
quoted = true;
} else if (ch == '{' || ch == '[') {
if (++depth > 8) return false;
if (++depth > 8)
return false;
} else if (ch == '}' || ch == ']') {
--depth;
}
@@ -82,18 +51,22 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
: nlohmann::json{{"status", "DENIED"},
{"missingPermissions", {kMicrophone}}},
13, "invalid_request"};
if (!message) return rejected;
if (!message)
return rejected;
const jsize size = env->GetStringUTFLength(message);
if (size <= 0 || static_cast<std::size_t>(size) > kMaximumJsonBytes) {
return rejected;
}
const char* bytes = env->GetStringUTFChars(message, nullptr);
if (!bytes) return rejected;
if (!bytes)
return rejected;
const std::string owned(bytes, static_cast<std::size_t>(size));
env->ReleaseStringUTFChars(message, bytes);
if (!HasBoundedDepth(owned)) return rejected;
if (!HasBoundedDepth(owned))
return rejected;
const auto request = nlohmann::json::parse(owned, nullptr, false);
if (!request.is_object()) return rejected;
if (!request.is_object())
return rejected;
if (support) {
return {{{"permissions", {kMicrophone, kLocalNetwork}}}, 0, "supported"};
}
@@ -104,7 +77,8 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
}
auto missing = nlohmann::json::array();
for (const auto& feature : *features) {
if (!feature.is_string()) return rejected;
if (!feature.is_string())
return rejected;
const auto& name = feature.get_ref<const std::string&>();
if (name.empty() || name.size() > 128 ||
name.find_first_of("\r\n\t") != std::string::npos ||
@@ -132,233 +106,31 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
} // namespace
struct RobloxPermissionsBridge::State {
struct Endpoint {
std::shared_ptr<State> state;
std::size_t method;
};
struct Registration {
jobject callback = nullptr;
jobject handler = nullptr;
jobject connection = nullptr;
jlong connection_handle = 0;
jstring method = nullptr;
bool handler_registered = false;
};
RobloxPermissionsMessageBusSymbols symbols;
jobject bus = nullptr;
jstring protocol = nullptr;
bool microphone_enabled = false;
std::array<Registration, kMethodCount> registrations;
std::mutex mutex;
std::condition_variable drained;
bool accepting = true;
std::size_t in_flight = 0;
static void Legacy(void* context, JNIEnv* env, jstring message) {
Dispatch(context, env, message, nullptr);
}
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
Dispatch(context, env, message, id);
}
static void Dispatch(void* context, JNIEnv* env, jstring message,
jstring id) {
const auto* endpoint = static_cast<Endpoint*>(context);
const auto state = endpoint->state;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->accepting) return;
++state->in_flight;
}
struct Finish {
std::shared_ptr<State> state;
~Finish() {
std::lock_guard<std::mutex> lock(state->mutex);
--state->in_flight;
state->drained.notify_all();
}
} finish{state};
Response response =
Answer(env, message, endpoint->method, state->microphone_enabled);
// No payload, feature strings, or correlation IDs in logs.
std::fprintf(stderr,
" [permissions] %s method=%s result=%s microphone=%s "
"transport=%s\n",
kProtocol, kMethods[endpoint->method], response.outcome,
state->microphone_enabled ? "enabled" : "disabled",
id ? "async" : "legacy");
LocalString body(env, response.body.dump().c_str());
LocalString telemetry(env, "{}");
LocalString method(env, kMethods[endpoint->method]);
const Status allocated = CheckJni(env);
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
std::fprintf(stderr, " [permissions] response allocation failed\n");
return;
}
// The APK publishes the legacy response even for the newer async API,
// then resolves the individual request by its opaque response ID.
state->symbols.publish_response(env, state->bus, state->protocol,
method.value, body.value, response.code,
telemetry.value);
const Status published = CheckJni(env);
if (id) {
state->symbols.call_response_handler(env, state->bus, id, body.value);
}
if (!CheckJni(env).ok() || !published.ok()) {
std::fprintf(stderr, " [permissions] response delivery failed\n");
}
}
};
RobloxPermissionsBridge::RobloxPermissionsBridge(
JniEnvironmentProvider environment,
RobloxPermissionsMessageBusSymbols symbols,
RobloxPermissionsMessageBusObjects objects, bool microphone_enabled)
: environment_(environment),
symbols_(symbols),
objects_(objects),
microphone_enabled_(microphone_enabled) {}
: bridge_(std::make_unique<RobloxMessageBusRequestBridge>(
environment, symbols, objects, "PermissionsProtocol",
std::vector<std::string>{"SupportsPermissions", "HasPermissions",
"PermissionsRequest",
"ShouldShowPermissionUpsell",
"ShouldShowRequestPermissionRationale"},
[microphone_enabled](JNIEnv* env, jstring message,
std::size_t method) {
auto response = Answer(env, message, method, microphone_enabled);
return RobloxMessageBusReply{response.body.dump(), response.code,
response.outcome};
})) {}
RobloxPermissionsBridge::~RobloxPermissionsBridge() { (void)Shutdown(); }
RobloxPermissionsBridge::~RobloxPermissionsBridge() = default;
Status RobloxPermissionsBridge::Initialize() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
if (state_) {
return Status::Error(StatusCode::kFailedPrecondition,
"PermissionsProtocol is already initialized");
}
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
return Unavailable("PermissionsProtocol prerequisites are incomplete");
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status;
state_ = std::make_shared<State>();
state_->symbols = symbols_;
state_->microphone_enabled = microphone_enabled_;
state_->bus = env->NewGlobalRef(objects_.message_bus);
LocalString protocol(env, kProtocol);
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
auto fail = [this](Status failure) {
(void)ShutdownLocked();
return failure;
};
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
return fail(Unavailable("could not retain PermissionsProtocol roots"));
}
for (std::size_t i = 0; i < kMethodCount; ++i) {
auto& entry = state_->registrations[i];
auto endpoint =
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
LocalString method(env, kMethods[i]);
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
&State::Legacy);
if (callback) {
entry.callback = env->NewGlobalRef(callback);
if (!entry.callback)
objects_.clear_raw_callback(objects_.context, callback);
env->DeleteLocalRef(callback);
}
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
&State::Async);
if (handler) {
entry.handler = env->NewGlobalRef(handler);
if (!entry.handler)
objects_.clear_async_handler(objects_.context, handler);
env->DeleteLocalRef(handler);
}
if (!entry.method || !entry.callback || !entry.handler ||
!CheckJni(env).ok()) {
return fail(
Unavailable("could not create PermissionsProtocol callbacks"));
}
jobject connection =
symbols_.subscribe_request(env, state_->bus, state_->protocol,
entry.method, entry.callback, JNI_FALSE);
if (connection) {
jclass cls = env->GetObjectClass(connection);
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
if (pointer)
entry.connection_handle = env->GetLongField(connection, pointer);
if (cls) env->DeleteLocalRef(cls);
entry.connection = env->NewGlobalRef(connection);
if (!entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, connection, entry.connection_handle);
entry.connection_handle = 0;
}
env->DeleteLocalRef(connection);
}
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
return fail(
Unavailable("could not subscribe PermissionsProtocol request"));
}
entry.handler_registered = true;
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
entry.handler);
status = CheckJni(env);
if (!status.ok()) return fail(status);
}
std::fprintf(stderr,
" [permissions] PermissionsProtocol ready microphone=%s "
"legacy=5 async=5\n",
microphone_enabled_ ? "enabled" : "disabled");
return Status::Ok();
return bridge_->Initialize();
}
Status RobloxPermissionsBridge::Shutdown() {
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
return ShutdownLocked();
return bridge_->Shutdown();
}
Status RobloxPermissionsBridge::ShutdownLocked() {
if (!state_) return Status::Ok();
{
std::unique_lock<std::mutex> lock(state_->mutex);
state_->accepting = false;
state_->drained.wait(lock, [this] { return state_->in_flight == 0; });
}
JNIEnv* env = nullptr;
Status status = environment_.Acquire(&env);
if (!status.ok()) return status; // Keep roots for a later shutdown retry.
// Clear any exception from partially failed initialization before cleanup.
const Status pending = CheckJni(env);
if (!pending.ok()) status = pending;
for (auto& entry : state_->registrations) {
if (entry.handler_registered) {
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
const Status cleared = CheckJni(env);
if (!cleared.ok()) status = cleared;
entry.handler_registered = false;
}
if (entry.connection && entry.connection_handle) {
symbols_.delete_connection(env, entry.connection,
entry.connection_handle);
const Status disconnected = CheckJni(env);
if (!disconnected.ok()) status = disconnected;
entry.connection_handle = 0;
}
if (entry.callback) {
objects_.clear_raw_callback(objects_.context, entry.callback);
env->DeleteGlobalRef(entry.callback);
}
if (entry.handler) {
objects_.clear_async_handler(objects_.context, entry.handler);
env->DeleteGlobalRef(entry.handler);
}
if (entry.connection) env->DeleteGlobalRef(entry.connection);
if (entry.method) env->DeleteGlobalRef(entry.method);
entry = {};
const Status cleared = CheckJni(env);
if (!cleared.ok()) status = cleared;
}
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
if (state_->bus) env->DeleteGlobalRef(state_->bus);
state_.reset();
return status;
}
} // namespace runtime
} // namespace mocktail
+141
View File
@@ -0,0 +1,141 @@
#include "runtime/roblox_presence_resolver.h"
#include <curl/curl.h>
#include <cstdlib>
#include <fstream>
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
namespace mocktail {
namespace runtime {
namespace {
Status Invalid(std::string message) {
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
}
size_t WriteToString(char* data, size_t size, size_t nmemb, void* userdata) {
static_cast<std::string*>(userdata)->append(data, size * nmemb);
return size * nmemb;
}
} // namespace
// Reads mocktail's own persisted session cookie from
// $XDG_DATA_HOME/mocktail/auth/roblox.cookie (default
// ~/.local/share/mocktail/auth/roblox.cookie), matching the format
// mocktail's own auth code already writes there: a single line of the
// form ".ROBLOSECURITY=<value>". Strips the prefix so callers get just
// the bare value. Returns an empty string if no session is stored yet.
std::string ReadStoredRoblosecurityCookie() {
const char* home = std::getenv("HOME");
if (home == nullptr || home[0] == '\0') {
return std::string();
}
const char* xdg_data_home = std::getenv("XDG_DATA_HOME");
const std::string base =
(xdg_data_home != nullptr && xdg_data_home[0] != '\0')
? std::string(xdg_data_home)
: std::string(home) + "/.local/share";
const std::string path = base + "/mocktail/auth/roblox.cookie";
std::ifstream file(path);
if (!file.is_open()) {
return std::string();
}
std::string line;
std::getline(file, line);
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
line.pop_back();
}
constexpr std::string_view kPrefix = ".ROBLOSECURITY=";
if (line.size() > kPrefix.size() &&
line.compare(0, kPrefix.size(), kPrefix) == 0) {
return line.substr(kPrefix.size());
}
return line;
}
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
ResolvedFollowUserPlace* out) {
if (out == nullptr || user_id <= 0) {
return Invalid("Roblox presence lookup requires a positive userId");
}
if (auth_cookie.empty()) {
return Invalid("Roblox presence lookup requires an authenticated session");
}
CURL* curl = curl_easy_init();
if (curl == nullptr) {
return Invalid("Roblox presence lookup could not initialize a request");
}
const nlohmann::json body = {{"userIds", {user_id}}};
const std::string payload = body.dump();
std::string response;
struct curl_slist* headers = nullptr;
headers = curl_slist_append(headers, "Content-Type: application/json");
const std::string cookie_header = "Cookie: .ROBLOSECURITY=" + auth_cookie;
headers = curl_slist_append(headers, cookie_header.c_str());
curl_easy_setopt(curl, CURLOPT_URL,
"https://presence.roblox.com/v1/presence/users");
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteToString);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
const CURLcode result = curl_easy_perform(curl);
long http_status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (result != CURLE_OK) {
return Invalid("Roblox presence lookup failed to connect");
}
if (http_status != 200) {
return Invalid("Roblox presence lookup was rejected (status " +
std::to_string(http_status) + ")");
}
const nlohmann::json parsed = nlohmann::json::parse(
response, /*cb=*/nullptr, /*allow_exceptions=*/false);
if (parsed.is_discarded()) {
return Invalid("Roblox presence lookup returned malformed JSON");
}
if (!parsed.contains("userPresences") ||
!parsed["userPresences"].is_array() ||
parsed["userPresences"].empty()) {
return Invalid("Roblox presence lookup returned no data for user");
}
const auto& presence = parsed["userPresences"][0];
// userPresenceType: 0 offline, 1 online, 2 in-game, 3 in-studio.
const int presence_type = presence.value("userPresenceType", 0);
if (presence_type != 2) {
return Invalid("User is not currently in a joinable Roblox experience");
}
if (!presence.contains("placeId") || presence["placeId"].is_null()) {
return Invalid("Roblox presence lookup did not include a placeId");
}
out->place_id = presence.value("placeId", int64_t{0});
if (presence.contains("gameId") && !presence["gameId"].is_null()) {
out->game_instance_id = presence.value("gameId", std::string());
}
if (out->place_id <= 0) {
return Invalid("Roblox presence lookup returned an invalid placeId");
}
return Status::Ok();
}
} // namespace runtime
} // namespace mocktail
+232 -39
View File
@@ -1,6 +1,9 @@
#include "runtime/webview_helper_launcher.h"
#include <arpa/inet.h>
#include <curl/curl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <fcntl.h>
#include <poll.h>
#include <pthread.h>
@@ -21,6 +24,7 @@
#include <cstring>
#include <deque>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <new>
@@ -38,6 +42,8 @@ struct WebViewHelperProcess::State {
std::mutex control_mutex;
int control_descriptor = -1;
std::deque<WebViewHelperEvent> pending_events;
std::string receive_buffer;
bool tcp = false;
bool ready = false;
~State() {
@@ -67,7 +73,36 @@ class SensitiveStringGuard final {
WebViewHelperProcess::WebViewHelperProcess(std::shared_ptr<State> state)
: state_(std::move(state)) {}
bool WebViewHelperProcess::running() const { return process_id() > 0; }
bool WebViewHelperProcess::running() const {
if (state_ == nullptr) {
return false;
}
if (!state_->tcp) {
return process_id() > 0;
}
std::lock_guard<std::mutex> lock(state_->control_mutex);
if (state_->control_descriptor < 0 ||
state_->child.load(std::memory_order_acquire) <= 0) {
return false;
}
pollfd probe = {state_->control_descriptor, POLLIN, 0};
if (poll(&probe, 1, 0) > 0) {
if ((probe.revents & (POLLHUP | POLLERR | POLLNVAL)) != 0) {
state_->child.store(-1, std::memory_order_release);
return false;
}
if ((probe.revents & POLLIN) != 0) {
char peek;
const ssize_t count = recv(state_->control_descriptor, &peek, 1,
MSG_PEEK | MSG_DONTWAIT);
if (count == 0) {
state_->child.store(-1, std::memory_order_release);
return false;
}
}
}
return true;
}
pid_t WebViewHelperProcess::process_id() const {
return state_ != nullptr ? state_->child.load(std::memory_order_acquire) : -1;
@@ -135,6 +170,114 @@ private:
char *value_ = nullptr;
};
bool HostIsFreeBsd() {
char buffer[512] = {};
std::ifstream file("/proc/version", std::ios::binary);
file.read(buffer, sizeof(buffer) - 1);
const std::string_view text(buffer, static_cast<std::size_t>(file.gcount()));
return text.find("FreeBSD") != std::string_view::npos ||
text.find("freebsd.org") != std::string_view::npos;
}
std::string FramePacket(std::string_view packet) {
std::string out(4, '\0');
const std::size_t size = packet.size();
out[0] = static_cast<char>((size >> 24) & 0xff);
out[1] = static_cast<char>((size >> 16) & 0xff);
out[2] = static_cast<char>((size >> 8) & 0xff);
out[3] = static_cast<char>(size & 0xff);
out.append(packet);
return out;
}
bool ExtractFrame(std::string *buffer, std::string *frame) {
if (buffer->size() < 4) {
return false;
}
const std::size_t size =
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[0])) << 24) |
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[1])) << 16) |
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[2])) << 8) |
static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[3]));
if (size > kMaximumWebViewEventPacketBytes) {
buffer->clear();
return false;
}
if (buffer->size() < 4 + size) {
return false;
}
frame->assign(*buffer, 4, size);
buffer->erase(0, 4 + size);
return true;
}
int ConnectToFreeBsdHost() {
const int descriptor = socket(AF_INET, SOCK_STREAM, 0);
if (descriptor < 0) {
return -1;
}
fcntl(descriptor, F_SETFD, FD_CLOEXEC);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(2137);
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int enabled = 1;
setsockopt(descriptor, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof(enabled));
if (connect(descriptor, reinterpret_cast<sockaddr *>(&address),
sizeof(address)) != 0) {
close(descriptor);
return -1;
}
const int flags = fcntl(descriptor, F_GETFL);
if (flags < 0 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) != 0) {
close(descriptor);
return -1;
}
return descriptor;
}
ssize_t ReceivePacket(WebViewHelperProcess::State *state,
std::string *packet) {
if (!state->tcp) {
packet->assign(kMaximumWebViewEventPacketBytes, '\0');
const ssize_t count = recv(state->control_descriptor, packet->data(),
packet->size(), MSG_DONTWAIT | MSG_TRUNC);
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
return -1;
}
if (count < 0) {
return -2;
}
if (count > 0 && static_cast<std::size_t>(count) <= packet->size()) {
packet->resize(static_cast<std::size_t>(count));
}
return count;
}
while (true) {
if (ExtractFrame(&state->receive_buffer, packet)) {
return static_cast<ssize_t>(packet->size());
}
char chunk[4096];
const ssize_t count = read(state->control_descriptor, chunk, sizeof(chunk));
if (count < 0 && errno == EINTR) {
continue;
}
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
return -1;
}
if (count < 0) {
return -2;
}
if (count == 0) {
return 0;
}
state->receive_buffer.append(chunk, static_cast<std::size_t>(count));
if (state->receive_buffer.size() > kMaximumWebViewEventPacketBytes * 2) {
return -2;
}
}
}
bool IsRobloxHost(std::string_view host) {
constexpr std::string_view kRoot = "roblox.com";
std::string normalized(host);
@@ -258,13 +401,39 @@ bool SendControlCommand(
std::lock_guard<std::mutex> lock(state->control_mutex);
if (state->child.load(std::memory_order_acquire) > 0 &&
state->control_descriptor >= 0) {
ssize_t count = -1;
do {
// FreeBSD requires an explicit record boundary for SOCK_SEQPACKET.
count = send(state->control_descriptor, packet.data(), packet.size(),
MSG_DONTWAIT | MSG_NOSIGNAL | MSG_EOR);
} while (count < 0 && errno == EINTR);
sent = count == static_cast<ssize_t>(packet.size());
if (state->tcp) {
const std::string framed = FramePacket(packet);
std::size_t offset = 0;
sent = true;
while (offset < framed.size()) {
const ssize_t count =
send(state->control_descriptor, framed.data() + offset,
framed.size() - offset, MSG_NOSIGNAL);
if (count < 0 && errno == EINTR) {
continue;
}
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
pollfd writable = {state->control_descriptor, POLLOUT, 0};
if (poll(&writable, 1, 1000) <= 0) {
sent = false;
break;
}
continue;
}
if (count <= 0) {
sent = false;
break;
}
offset += static_cast<std::size_t>(count);
}
} else {
ssize_t count = -1;
do {
count = send(state->control_descriptor, packet.data(), packet.size(),
MSG_DONTWAIT | MSG_NOSIGNAL | MSG_EOR);
} while (count < 0 && errno == EINTR);
sent = count == static_cast<ssize_t>(packet.size());
}
}
}
std::fill(packet.begin(), packet.end(), '\0');
@@ -740,31 +909,41 @@ bool WebViewHelperProcess::WaitUntilReady(
if (remaining.count() <= 0) {
break;
}
std::string packet;
bool closed = false;
while (!state_->ready) {
const ssize_t count = ReceivePacket(state_.get(), &packet);
if (count == 0 || count == -2) {
closed = true;
break;
}
if (count < 0) {
break;
}
WebViewHelperEvent event;
if (!DecodeWebViewHelperEventPacket(packet, &event)) {
continue;
}
if (event.type == WebViewHelperEventType::kReady) {
state_->ready = true;
} else {
state_->pending_events.push_back(std::move(event));
}
}
if (closed || state_->ready) {
break;
}
pollfd descriptor = {state_->control_descriptor, POLLIN, 0};
int poll_status = -1;
do {
poll_status = poll(&descriptor, 1, static_cast<int>(remaining.count()));
} while (poll_status < 0 && errno == EINTR);
if (poll_status <= 0 || (descriptor.revents & POLLIN) == 0) {
if (poll_status < 0) {
break;
}
std::string packet(kMaximumWebViewEventPacketBytes, '\0');
const ssize_t count = recv(state_->control_descriptor, packet.data(),
packet.size(), MSG_DONTWAIT | MSG_TRUNC);
if (count <= 0 || static_cast<std::size_t>(count) > packet.size()) {
if (poll_status > 0 && (descriptor.revents & (POLLERR | POLLNVAL)) != 0) {
break;
}
WebViewHelperEvent event;
if (!DecodeWebViewHelperEventPacket(
std::string_view(packet.data(), static_cast<std::size_t>(count)),
&event)) {
continue;
}
if (event.type == WebViewHelperEventType::kReady) {
state_->ready = true;
} else {
state_->pending_events.push_back(std::move(event));
}
}
return state_->ready;
}
@@ -782,26 +961,17 @@ bool WebViewHelperProcess::DrainEvents(
events->push_back(std::move(state_->pending_events.front()));
state_->pending_events.pop_front();
}
std::string packet(kMaximumWebViewEventPacketBytes, '\0');
std::string packet;
while (true) {
ssize_t count = recv(state_->control_descriptor, packet.data(),
packet.size(), MSG_DONTWAIT | MSG_TRUNC);
if (count < 0 && errno == EINTR) {
continue;
}
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
const ssize_t count = ReceivePacket(state_.get(), &packet);
if (count == -1 || count == 0) {
return true;
}
if (count <= 0) {
return count == 0;
}
if (static_cast<std::size_t>(count) > packet.size()) {
continue;
if (count < 0) {
return false;
}
WebViewHelperEvent event;
if (DecodeWebViewHelperEventPacket(
std::string_view(packet.data(), static_cast<std::size_t>(count)),
&event)) {
if (DecodeWebViewHelperEventPacket(packet, &event)) {
if (event.type == WebViewHelperEventType::kReady) {
state_->ready = true;
} else {
@@ -819,6 +989,9 @@ bool WebViewHelperProcess::RequestClose() const {
if (SendControlCommand(state_, WebViewHelperControlOperation::kClose, {})) {
return true;
}
if (state_->tcp) {
return false;
}
const pid_t current_child = process_id();
if (current_child <= 0) {
return true;
@@ -835,6 +1008,26 @@ WebViewHelperLaunchResult LaunchWebViewHelper(
if (!NormalizeWebViewUrl(url, &normalized_url, &result.error)) {
return result;
}
if (HostIsFreeBsd()) {
const int descriptor = ConnectToFreeBsdHost();
if (descriptor < 0) {
result.error = "cannot connect to the FreeBSD webview host";
return result;
}
auto tcp_state = std::make_shared<WebViewHelperProcess::State>();
tcp_state->tcp = true;
tcp_state->control_descriptor = descriptor;
tcp_state->child.store(1, std::memory_order_release);
result.process = std::shared_ptr<WebViewHelperProcess>(
new WebViewHelperProcess(std::move(tcp_state)));
result.spawned = true;
result.process_id = 1;
if (!result.process->LoadUrl(normalized_url)) {
result.spawned = false;
result.error = "cannot send the initial URL to the FreeBSD webview host";
}
return result;
}
std::error_code filesystem_error;
const std::filesystem::file_status helper_status =
std::filesystem::symlink_status(helper, filesystem_error);
+14 -3
View File
@@ -73,13 +73,24 @@ CompatibilityCatalogResult LoadCompatibilityCatalog(
const nlohmann::json document =
nlohmann::json::parse(contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object() ||
document.value("schema_version", 0) != 1 ||
!document.contains("profiles") || !document["profiles"].is_array()) {
!document.contains("schema_version") ||
!document["schema_version"].is_number_integer() ||
document["schema_version"] != 1 || !document.contains("profiles") ||
!document["profiles"].is_array()) {
result.error = "compatibility manifest has an unsupported schema";
return result;
}
for (const nlohmann::json& profile : document["profiles"]) {
if (!profile.is_object() || profile.value("status", "") != "supported" ||
if (!profile.is_object()) continue;
if ((profile.contains("status") && !profile["status"].is_string()) ||
(profile.contains("default_allowed") &&
!profile["default_allowed"].is_boolean()) ||
(profile.contains("allow_legacy_binary_patches") &&
!profile["allow_legacy_binary_patches"].is_boolean())) {
result.error = "compatibility profile flags have invalid types";
return result;
}
if (profile.value("status", "") != "supported" ||
!profile.value("default_allowed", false) ||
profile.value("allow_legacy_binary_patches", true)) {
continue;
+3 -1
View File
@@ -145,8 +145,10 @@ mocktail::update::UpdatePaths ResolvePaths() {
runtime = public_runtime;
}
}
paths.runtime_binary =
paths.runtime_launcher =
Environment("MOCKTAIL_UPDATE_CANARY_BIN").value_or(runtime.string());
paths.runtime_binary = Environment("MOCKTAIL_UPDATE_RUNTIME_BINARY")
.value_or(paths.runtime_launcher.string());
return paths;
}
+27 -3
View File
@@ -12,6 +12,7 @@
#include <cctype>
#include <cerrno>
#include <filesystem>
#include <limits>
#include <mutex>
#include <nlohmann/json.hpp>
#include <string>
@@ -322,14 +323,35 @@ PayloadIntegrityResult InspectPreparedPayload(
const nlohmann::json document =
nlohmann::json::parse(contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object() ||
document.value("schema_version", 0) != 1 ||
!document.contains("version_code") ||
!document.contains("schema_version") ||
!document["schema_version"].is_number_integer() ||
document["schema_version"] != 1 || !document.contains("version_code") ||
!document["version_code"].is_number_unsigned() ||
!document.contains("sha256") || !document["sha256"].is_object() ||
!document.contains("assets") || !document["assets"].is_object()) {
result.error = "payload metadata schema is invalid";
return result;
}
for (const char* field : {"package", "version_name", "elf_build_id"}) {
if (!document.contains(field) || !document[field].is_string()) {
result.error = "payload metadata identity or hashes are invalid";
return result;
}
}
for (const std::string& field :
{std::string("libroblox"), std::string("base_apk"),
std::string(compat::kGuestSplitApkHashKey)}) {
if (!document["sha256"].contains(field) ||
!document["sha256"][field].is_string()) {
result.error = "payload metadata identity or hashes are invalid";
return result;
}
}
if (!document["assets"].contains("sha256_tree") ||
!document["assets"]["sha256_tree"].is_string()) {
result.error = "payload metadata identity or hashes are invalid";
return result;
}
result.metadata.package_name = document.value("package", "");
result.metadata.version_name = document.value("version_name", "");
result.metadata.version_code = document["version_code"].get<std::uint64_t>();
@@ -339,7 +361,9 @@ PayloadIntegrityResult InspectPreparedPayload(
result.metadata.split_apk_sha256 =
document["sha256"].value(std::string(compat::kGuestSplitApkHashKey), "");
if (!document["assets"].contains("file_count") ||
!document["assets"]["file_count"].is_number_unsigned()) {
!document["assets"]["file_count"].is_number_unsigned() ||
document["assets"]["file_count"] >
std::numeric_limits<std::size_t>::max()) {
result.error = "payload metadata asset count is invalid";
return result;
}
+157 -36
View File
@@ -111,22 +111,32 @@ std::string ReadRegular(const std::filesystem::path& path, std::size_t maximum,
bool WriteAtomic(const std::filesystem::path& root,
const std::filesystem::path& destination,
std::string_view contents, std::string* error) {
std::string_view contents, std::string* error,
const ManifestWriteFault& fault = {}) {
static std::uint64_t generation = 0;
const std::filesystem::path temporary =
root / (".manifest-" + std::to_string(getpid()) + "-" +
std::to_string(++generation));
const auto fail = [&](ManifestWritePhase phase) {
if (!fault || !fault(destination, phase)) return false;
errno = EIO;
return true;
};
const int descriptor =
open(temporary.c_str(),
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644);
fail(ManifestWritePhase::kCreate)
? -1
: open(temporary.c_str(),
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644);
if (descriptor < 0) {
*error = "cannot create atomic payload manifest";
return false;
}
std::size_t offset = 0;
while (offset < contents.size()) {
const ssize_t bytes =
write(descriptor, contents.data() + offset, contents.size() - offset);
const ssize_t bytes = fail(ManifestWritePhase::kWrite)
? -1
: write(descriptor, contents.data() + offset,
contents.size() - offset);
if (bytes < 0) {
if (errno == EINTR) continue;
close(descriptor);
@@ -137,7 +147,8 @@ bool WriteAtomic(const std::filesystem::path& root,
}
offset += static_cast<std::size_t>(bytes);
}
const bool synced = fsync(descriptor) == 0;
const bool synced =
!fail(ManifestWritePhase::kFileFsync) && fsync(descriptor) == 0;
close(descriptor);
if (!synced) {
std::error_code ignored;
@@ -146,7 +157,11 @@ bool WriteAtomic(const std::filesystem::path& root,
return false;
}
std::error_code filesystem_error;
std::filesystem::rename(temporary, destination, filesystem_error);
if (fail(ManifestWritePhase::kRename)) {
filesystem_error = std::make_error_code(std::errc::io_error);
} else {
std::filesystem::rename(temporary, destination, filesystem_error);
}
if (filesystem_error) {
std::filesystem::remove(temporary, filesystem_error);
*error = "cannot publish atomic payload manifest";
@@ -453,14 +468,19 @@ ManifestIdentity ReadManifest(const std::filesystem::path& path,
const nlohmann::json document =
nlohmann::json::parse(result.contents, nullptr, false, true);
if (document.is_discarded() || !document.is_object() ||
document.value("schema_version", 0) != 1 ||
!document.contains("schema_version") ||
!document["schema_version"].is_number_integer() ||
document["schema_version"] != 1 ||
!document.contains("payload_id") || !document["payload_id"].is_string()) {
result.error = "active payload manifest is invalid";
return result;
}
result.payload_id = document["payload_id"].get<std::string>();
if (!ValidPayloadId(result.payload_id) ||
document.value("payload_path", "") != "payloads/" + result.payload_id) {
!document.contains("payload_path") ||
!document["payload_path"].is_string() ||
document["payload_path"].get_ref<const std::string&>() !=
"payloads/" + result.payload_id) {
result.error = "active payload manifest identity is invalid";
}
return result;
@@ -535,7 +555,14 @@ bool PopulateInspectedResult(
if (verified_payload != nullptr) *verified_payload = payload;
const nlohmann::json document =
nlohmann::json::parse(manifest.contents, nullptr, false, true);
if (document.is_discarded() ||
if (document.is_discarded() || !document.is_object() ||
!document.contains("version_name") ||
!document["version_name"].is_string() ||
!document.contains("elf_build_id") ||
!document["elf_build_id"].is_string() ||
!document.contains("version_code") ||
!document["version_code"].is_number_unsigned() ||
document["version_code"] == 0 ||
document.value("version_name", "") != payload.metadata.version_name ||
document.value("version_code", 0ULL) != payload.metadata.version_code ||
document.value("elf_build_id", "") != payload.metadata.build_id) {
@@ -550,6 +577,13 @@ bool PopulateInspectedResult(
result->error = "active payload manifest hash does not match payload";
return false;
}
for (const char* field : {"host_abi_profile_path",
"compatibility_manifest_path", "approval_path"}) {
if (document.contains(field) && !document[field].is_string()) {
result->error = "active payload approval reference set is invalid";
return false;
}
}
const std::string profile = document.value("host_abi_profile_path", "");
const std::string compatibility =
document.value("compatibility_manifest_path", "");
@@ -591,14 +625,120 @@ bool PersistPayloadSha256(const std::filesystem::path& root,
error);
}
constexpr const char* kPreviousRecovery = ".previous_good-recovery.json";
constexpr const char* kAbsentRecovery = ".previous_good-recovery.absent";
// Inspect the directory entry itself: a dangling symlink is also a pending
// recovery artifact, and an inspection failure must never authorize deletion.
bool NoPendingRecovery(const std::filesystem::path& root, std::string* error) {
for (const char* name : {kPreviousRecovery, kAbsentRecovery}) {
std::error_code filesystem_error;
const auto status =
std::filesystem::symlink_status(root / name, filesystem_error);
if (filesystem_error &&
filesystem_error != std::errc::no_such_file_or_directory) {
*error = "cannot inspect previous_good recovery: " +
filesystem_error.message();
return false;
}
if (status.type() != std::filesystem::file_type::not_found) {
*error = "previous_good recovery is pending: " + (root / name).string();
return false;
}
}
return true;
}
bool RemoveRecovery(const std::filesystem::path& recovery, std::string* error,
bool current_published = false) {
std::error_code filesystem_error;
std::filesystem::remove(recovery, filesystem_error);
if (!filesystem_error) return true;
if (!error->empty()) *error += "; ";
if (current_published) *error += "current manifest published; ";
*error +=
"cannot remove previous_good recovery: " + filesystem_error.message() +
"; recovery retained at " + recovery.string();
return false;
}
// Called only while StoreLock is held. This recovers a failed publication;
// it does not make two manifest renames atomic against a process crash.
bool PublishActivation(const std::filesystem::path& root,
std::string_view payload_id, std::string_view contents,
const ManifestWriteFault& fault, std::string* error) {
if (!NoPendingRecovery(root, error)) return false;
const auto current = ReadManifest(root / "current.json", true);
if (!current) {
*error = current.error;
return false;
}
if (current.contents.empty() || current.payload_id == payload_id) {
return WriteAtomic(root, root / "current.json", contents, error, fault);
}
std::error_code filesystem_error;
const auto previous_status = std::filesystem::symlink_status(
root / "previous_good.json", filesystem_error);
if (filesystem_error &&
filesystem_error != std::errc::no_such_file_or_directory) {
*error =
"cannot inspect previous_good manifest: " + filesystem_error.message();
return false;
}
const bool previous_present =
previous_status.type() != std::filesystem::file_type::not_found;
std::string previous;
if (previous_present) {
// Keep the original bytes, including an explicitly present empty file.
previous = ReadRegular(root / "previous_good.json", 1024U * 1024U, error);
if (!error->empty()) return false;
}
const auto recovery =
root / (previous_present ? kPreviousRecovery : kAbsentRecovery);
if (!WriteAtomic(root, recovery, previous_present ? previous : "absent\n",
error, fault)) {
return false;
}
if (!WriteAtomic(root, root / "previous_good.json", current.contents, error,
fault)) {
RemoveRecovery(recovery, error);
return false;
}
if (!WriteAtomic(root, root / "current.json", contents, error, fault)) {
std::string restore_error;
if (previous_present) {
WriteAtomic(root, root / "previous_good.json", previous, &restore_error,
fault);
} else {
filesystem_error.clear();
std::filesystem::remove(root / "previous_good.json", filesystem_error);
if (filesystem_error) {
restore_error = "cannot restore absence of previous_good: " +
filesystem_error.message();
}
}
if (!restore_error.empty()) {
*error += "; previous_good restoration failed: " + restore_error +
"; recovery retained at " + recovery.string();
} else {
RemoveRecovery(recovery, error);
}
return false;
}
return RemoveRecovery(recovery, error, true);
}
} // namespace
PayloadStore::PayloadStore(std::filesystem::path root,
std::filesystem::path compatibility_manifest,
std::filesystem::path runtime_binary)
std::filesystem::path runtime_binary,
ManifestWriteFault manifest_write_fault)
: root_(std::move(root)),
compatibility_manifest_(std::move(compatibility_manifest)),
runtime_binary_(std::move(runtime_binary)) {}
runtime_binary_(std::move(runtime_binary)),
manifest_write_fault_(std::move(manifest_write_fault)) {}
PayloadStoreResult PayloadStore::Stage(
const std::filesystem::path& prepared_payload) {
@@ -679,20 +819,9 @@ PayloadStoreResult PayloadStore::Promote(std::string_view payload_id) {
result.version_name = payload.metadata.version_name;
result.version_code = payload.metadata.version_code;
result.build_id = payload.metadata.build_id;
const ManifestIdentity current = ReadManifest(root_ / "current.json", true);
if (!current && !current.error.empty()) {
result.error = current.error;
return result;
}
if (!current.contents.empty() && current.payload_id != result.payload_id &&
!WriteAtomic(root_, root_ / "previous_good.json", current.contents,
&result.error)) {
return result;
}
const std::string contents = ActivationManifest(payload).dump(2) + "\n";
if (!WriteAtomic(root_, root_ / "current.json", contents, &result.error)) {
return result;
}
PublishActivation(root_, result.payload_id, contents, manifest_write_fault_,
&result.error);
return result;
}
@@ -742,17 +871,8 @@ PayloadStoreResult PayloadStore::PromoteProbation(
&result.error)) {
return result;
}
const ManifestIdentity current = ReadManifest(root_ / "current.json", true);
if (!current && !current.error.empty()) {
result.error = current.error;
return result;
}
if (!current.contents.empty() && current.payload_id != result.payload_id &&
!WriteAtomic(root_, root_ / "previous_good.json", current.contents,
&result.error)) {
return result;
}
if (!WriteAtomic(root_, root_ / "current.json", contents, &result.error)) {
if (!PublishActivation(root_, result.payload_id, contents,
manifest_write_fault_, &result.error)) {
return result;
}
result.version_name = payload.metadata.version_name;
@@ -875,6 +995,7 @@ PayloadGarbageResult PayloadStore::CollectGarbage(
PayloadGarbageResult result;
StoreLock lock(root_, &result.error);
if (!lock) return result;
if (!NoPendingRecovery(root_, &result.error)) return result;
std::set<std::string> retained(keep.begin(), keep.end());
for (const char* manifest : {"current.json", "previous_good.json"}) {
const ManifestIdentity identity = ReadManifest(root_ / manifest, true);
+14 -2
View File
@@ -302,6 +302,13 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
"DISPLAY",
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_SESSION_TYPE",
"SDL_VIDEO_DRIVER",
"SDL_VIDEODRIVER",
"MOCKTAIL_FORCE_WAYLAND",
"MOCKTAIL_FORCE_X11",
"MOCKTAIL_ANGLE_FORCE_X11",
"MOCKTAIL_PREFER_WAYLAND",
"DBUS_SESSION_BUS_ADDRESS",
"PULSE_SERVER",
"PIPEWIRE_REMOTE",
@@ -419,8 +426,13 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
}
pid_t child = -1;
if (spawn_status == 0) {
spawn_status = posix_spawn(&child, options.runtime_binary.c_str(), &actions,
nullptr, arguments, environment_pointers.data());
result.spawn_error =
options.spawn
? options.spawn(&child, options.runtime_binary.c_str(), &actions,
nullptr, arguments, environment_pointers.data())
: posix_spawn(&child, options.runtime_binary.c_str(), &actions,
nullptr, arguments, environment_pointers.data());
spawn_status = result.spawn_error;
}
posix_spawn_file_actions_destroy(&actions);
close(log_descriptor);
+22 -13
View File
@@ -495,7 +495,9 @@ UpdateResult RunUnsafeLatest(
// Staging preserves the immutable APK for inspection, but this path must
// never promote it or alter the normal current payload.
UnsafeLatestRunOptions launch;
launch.runtime_binary = paths.runtime_binary;
launch.runtime_binary = paths.runtime_launcher.empty()
? paths.runtime_binary
: paths.runtime_launcher;
launch.payload_directory = candidate.staged.payload_directory;
launch.compatibility_manifest = candidate.exact_supported
? paths.compatibility_manifest
@@ -519,9 +521,10 @@ UpdateResult RunUnsafeLatest(
bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
bool run_canary,
CanaryGraphicsBackend graphics_backend,
int progress_fd,
int progress_fd, const CanarySpawn& spawn,
std::array<std::filesystem::path, 2>* logs,
std::string* error) {
std::string* error, bool* retryable) {
*retryable = false;
if (!run_canary) {
if (!candidate.exact_supported) {
*error = "latest candidate cannot be activated without Tier C probation";
@@ -536,7 +539,9 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
: "Testing latest Roblox (" +
std::to_string(index + 1) + "/2)...");
CanaryOptions canary;
canary.runtime_binary = paths.runtime_binary;
canary.runtime_binary = paths.runtime_launcher.empty()
? paths.runtime_binary
: paths.runtime_launcher;
canary.payload_directory = candidate.staged.payload_directory;
canary.compatibility_manifest = candidate.exact_supported
? paths.compatibility_manifest
@@ -545,12 +550,14 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
canary.cache_root = paths.cache_root;
canary.state_root = paths.state_root;
canary.graphics_backend = graphics_backend;
canary.spawn = spawn;
const CanaryResult canary_result = [&] {
StageTimer timer("canary[" + std::to_string(index + 1) + "/" +
std::to_string(runs) + "]");
return RunReadinessCanary(canary);
}();
if (!canary_result) {
*retryable = canary_result.spawn_error == EAGAIN;
*error = canary_result.error;
if (!canary_result.log_path.empty()) {
*error += "; log: " + canary_result.log_path.string();
@@ -565,11 +572,13 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
PayloadStoreResult PromoteCandidate(const UpdatePaths& paths,
const UpdateRequest& request,
const Candidate& candidate,
PayloadStore* store, std::string* error) {
PayloadStore* store, std::string* error,
bool* retryable) {
*retryable = false;
std::array<std::filesystem::path, 2> logs;
if (!RunCandidateCanaries(paths, candidate, request.run_canary,
request.canary_graphics_backend,
request.progress_fd, &logs, error)) {
if (!RunCandidateCanaries(
paths, candidate, request.run_canary, request.canary_graphics_backend,
request.progress_fd, request.canary_spawn, &logs, error, retryable)) {
return {};
}
Progress(request.progress_fd, "Installing Roblox...");
@@ -824,14 +833,14 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
}
examined.push_back(candidate.staged.payload_id);
if (request.startup_preflight && current &&
RejectedForRuntime(paths, candidate,
request.canary_graphics_backend)) {
RejectedForRuntime(paths, candidate, request.canary_graphics_backend)) {
blocked = description + " already failed probation with this runtime";
continue;
}
std::string candidate_error;
bool candidate_rejected = false;
bool retryable = false;
if (!candidate.exact_supported && candidate.profile.empty()) {
const ReferenceProfile reference =
ResolveReference(paths, installed, catalog.profiles, &provider,
@@ -865,9 +874,9 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
}
PayloadStoreResult promoted;
if (candidate_error.empty()) {
promoted =
PromoteCandidate(paths, request, candidate, &store, &candidate_error);
candidate_rejected = !candidate_error.empty();
promoted = PromoteCandidate(paths, request, candidate, &store,
&candidate_error, &retryable);
candidate_rejected = !candidate_error.empty() && !retryable;
}
if (candidate_error.empty() && promoted) {
result.changed = !current || current.payload_id != promoted.payload_id;
+5
View File
@@ -1424,6 +1424,11 @@ void Activate(GtkApplication* application, gpointer user_data) {
state->disable_hardware_acceleration =
mocktail::webview::ShouldDisableWebViewHardwareAcceleration(
wayland_display, std::getenv("WEBKIT_DISABLE_COMPOSITING_MODE"));
// GTK picks its renderer when a window is realized, which is after this.
if (mocktail::webview::ShouldForceSoftwareGtkRenderer(
state->disable_hardware_acceleration, std::getenv("GSK_RENDERER"))) {
setenv("GSK_RENDERER", "cairo", 0);
}
std::cerr << "[webview] display=" << (wayland_display ? "wayland" : "other")
<< " compositing="
<< (state->disable_hardware_acceleration ? "software" : "default")
+5
View File
@@ -133,6 +133,11 @@ bool ShouldDisableWebViewHardwareAcceleration(
return wayland_display;
}
bool ShouldForceSoftwareGtkRenderer(bool software_compositing,
const char* renderer_override) {
return software_compositing && renderer_override == nullptr;
}
const char* AndroidBridgeSource() { return kBridgeSource; }
std::string BuildRobloxAndroidUserAgent() {
+7
View File
@@ -38,6 +38,13 @@ bool ShouldDisableWebKitSandbox(std::string_view kernel_version,
// Match WebKit's WEBKIT_DISABLE_COMPOSITING_MODE override semantics.
bool ShouldDisableWebViewHardwareAcceleration(
bool wayland_display, const char* compositing_override);
// GTK's GL renderer aborts in libepoxy when it snapshots a page switch on
// hosts where its GL context cannot become current. With software compositing
// the helper does not need GL for its UI, so use the Cairo renderer unless the
// user chose one through GSK_RENDERER.
bool ShouldForceSoftwareGtkRenderer(bool software_compositing,
const char* renderer_override);
const char* AndroidBridgeSource();
std::string BuildRobloxAndroidUserAgent();
bool IsBrowserLoginUrl(std::string_view url);
+12
View File
@@ -1,6 +1,8 @@
#include "window/video_driver_policy.h"
#include <algorithm>
#include <system_error>
#include <unistd.h>
namespace mocktail {
namespace window {
@@ -39,6 +41,16 @@ const char* VideoDriverChoiceName(VideoDriverChoice choice) {
return nullptr;
}
bool HasNvidiaKernelDriver(const std::filesystem::path& proc_version,
const std::filesystem::path& pci_driver) {
if (access(proc_version.c_str(), R_OK) == 0) {
return true;
}
std::error_code error;
return std::filesystem::is_directory(pci_driver, error);
}
bool HasAvailableVideoDriverCandidate(
std::string_view requested,
const std::vector<std::string_view>& available_drivers) {
+8 -2
View File
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_WINDOW_VIDEO_DRIVER_POLICY_H_
#define MOCKTAIL_WINDOW_VIDEO_DRIVER_POLICY_H_
#include <filesystem>
#include <string_view>
#include <vector>
@@ -27,12 +28,17 @@ struct VideoDriverPolicyInput {
// Resolves the SDL video backend before SDL_Init. An explicit SDL driver is
// always authoritative. NVIDIA's direct Vulkan WSI uses X11/XWayland by
// default when both display transports are available because a blocked native
// Wayland present cannot be cancelled without violating VkQueue ownership.
// default when both display transports are available to avoid native Wayland
// WSI hangs and explicit-sync protocol errors that disconnect the display.
VideoDriverChoice ResolveVideoDriverChoice(const VideoDriverPolicyInput& input);
const char* VideoDriverChoiceName(VideoDriverChoice choice);
// Also detects sandboxed drivers through sysfs when procfs is unavailable.
bool HasNvidiaKernelDriver(
const std::filesystem::path& proc_version = "/proc/driver/nvidia/version",
const std::filesystem::path& pci_driver = "/sys/bus/pci/drivers/nvidia");
// SDL accepts a comma-separated priority list. An inherited override is only
// useful when at least one requested driver exists in the linked SDL build.
bool HasAvailableVideoDriverCandidate(
+188 -103
View File
@@ -13,11 +13,11 @@
#include <cstring>
#include <filesystem>
#include <memory>
#include <mutex>
#include <unordered_map>
#include <string>
#include <utility>
#include <vector>
#include "mocktail/graphics/angle_probe.h"
#include "mocktail/graphics/gles_text_overlay_compositor.h"
#include "mocktail/graphics/present_mode_policy.h"
#include "mocktail/platform/display_refresh_capabilities.h"
@@ -66,6 +66,11 @@ static constexpr EGLint EGL_ALPHA_SIZE_VAL = 0x3021;
static constexpr EGLint EGL_DEPTH_SIZE_VAL = 0x3025;
static constexpr EGLint EGL_STENCIL_SIZE_VAL = 0x3026;
static constexpr EGLint EGL_CONTEXT_CLIENT_VERSION_VAL = 0x3098;
static constexpr EGLint EGL_EXTENSIONS_VAL = 0x3055;
static constexpr EGLint EGL_DEBUG_MSG_CRITICAL_KHR_VAL = 0x33B9;
static constexpr EGLint EGL_DEBUG_MSG_ERROR_KHR_VAL = 0x33BA;
static constexpr EGLint EGL_DEBUG_MSG_WARN_KHR_VAL = 0x33BB;
static constexpr EGLint EGL_DEBUG_MSG_INFO_KHR_VAL = 0x33BC;
static constexpr EGLint EGL_PLATFORM_ANGLE_ANGLE_VAL = 0x3202;
static constexpr EGLint EGL_PLATFORM_ANGLE_TYPE_ANGLE_VAL = 0x3203;
static constexpr EGLint EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE_VAL = 0x3209;
@@ -76,6 +81,10 @@ static constexpr EGLint EGL_PLATFORM_ANGLE_DEBUG_LAYERS_ENABLED_ANGLE_VAL =
static constexpr EGLint EGL_PLATFORM_ANGLE_TYPE_VULKAN_ANGLE_VAL = 0x3450;
static constexpr EGLint EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE_VAL =
0x3487;
static constexpr EGLint EGL_PLATFORM_ANGLE_NATIVE_PLATFORM_TYPE_ANGLE_VAL =
0x348F;
static constexpr EGLint EGL_PLATFORM_X11_EXT_VAL = 0x31D5;
static constexpr EGLint EGL_PLATFORM_WAYLAND_EXT_VAL = 0x31D8;
static constexpr EGLint EGL_TRUE_VAL = 1;
static constexpr EGLint EGL_FALSE_VAL = 0;
static constexpr EGLBoolean EGL_TRUE_B = 1;
@@ -157,8 +166,6 @@ static std::unique_ptr<SdlPointerCaptureBackend> g_pointer_capture_backend;
static std::mutex g_pointer_capture_owner_mutex;
static std::shared_ptr<WindowPointerCaptureOwner> g_pointer_capture_owner;
static std::unique_ptr<graphics::GlesTextOverlayCompositor> g_gles_text_overlay;
static char g_preferred_egl_library[4096];
static char g_preferred_gles_library[4096];
static bool g_auto_angle_retry_attempted = false;
static std::filesystem::path g_window_state_path;
@@ -239,17 +246,8 @@ const char* GetEnvNonEmpty(const char* name) {
if (name == nullptr) {
return nullptr;
}
static std::mutex mutex;
static std::unordered_map<const char*, const char*> cache;
std::lock_guard<std::mutex> lock(mutex);
const auto existing = cache.find(name);
if (existing != cache.end()) {
return existing->second;
}
const char* raw = std::getenv(name);
const char* value = (raw != nullptr && raw[0] != '\0') ? raw : nullptr;
cache.emplace(name, value);
return value;
return raw != nullptr && raw[0] != '\0' ? raw : nullptr;
}
bool IsEnabledEnv(const char* name) {
@@ -435,19 +433,6 @@ bool FileExists(const char* path) {
return path != nullptr && path[0] != '\0' && access(path, R_OK) == 0;
}
bool JoinExistingLibraryPath(const char* dir, const char* soname, char* out,
size_t out_size) {
if (dir == nullptr || dir[0] == '\0' || soname == nullptr ||
soname[0] == '\0' || out == nullptr || out_size == 0) {
return false;
}
int written = std::snprintf(out, out_size, "%s/%s", dir, soname);
if (written <= 0 || static_cast<size_t>(written) >= out_size) {
return false;
}
return FileExists(out);
}
bool ShouldUseAngleBackend() {
const char* backend = GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND");
return StartsWith(backend, "angle") ||
@@ -491,8 +476,7 @@ VideoDriverChoice ResolveConfiguredVideoDriverChoice() {
input.has_wayland_session = HasWaylandSession();
input.has_x11_display = GetEnvNonEmpty("DISPLAY") != nullptr;
input.uses_direct_vulkan = ShouldUseNativeVulkanBackend();
input.has_nvidia_kernel_driver =
access("/proc/driver/nvidia/version", R_OK) == 0;
input.has_nvidia_kernel_driver = HasNvidiaKernelDriver();
return ResolveVideoDriverChoice(input);
}
@@ -553,25 +537,26 @@ bool ShouldShowWindowImmediately() {
return IsEnabledEnv("MOCKTAIL_DEBUG_SHOW_WINDOW_BEFORE_FRAME");
}
const char* FindAngleLibrary(const char* explicit_env, const char* soname,
char* out, size_t out_size) {
const char* explicit_path = GetEnvNonEmpty(explicit_env);
if (explicit_path != nullptr) {
return explicit_path;
}
struct GraphicsLibraries {
std::string egl_path;
std::string gles_path;
};
const char* angle_dir = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
if (JoinExistingLibraryPath(angle_dir, soname, out, out_size)) {
return out;
}
if (!ShouldUseAngleBackend()) {
return nullptr;
}
static const char* kAngleDirs[] = {
GraphicsLibraries FindInstalledAngleLibraries() {
const char* runtime_directory =
GetEnvNonEmpty("MOCKTAIL_RUNTIME_LIBRARY_DIR");
const std::string bundled_directory =
runtime_directory != nullptr
? (std::filesystem::path(runtime_directory) / "angle").string()
: std::string();
const char* kAngleDirs[] = {
bundled_directory.empty() ? nullptr : bundled_directory.c_str(),
"/usr/lib64/chromium",
"/usr/lib64/chromium-browser",
"/usr/lib/chromium",
"/usr/lib/chromium-browser",
"/usr/lib64/electron43",
"/usr/lib/electron43",
"/usr/lib/electron42",
"/usr/lib/electron41",
"/usr/lib/electron40",
@@ -582,47 +567,59 @@ const char* FindAngleLibrary(const char* explicit_env, const char* soname,
"/opt/google/chrome-unstable",
};
for (const char* dir : kAngleDirs) {
if (JoinExistingLibraryPath(dir, soname, out, out_size)) {
return out;
if (dir == nullptr) {
continue;
}
const std::filesystem::path directory(dir);
GraphicsLibraries libraries{(directory / "libEGL.so").string(),
(directory / "libGLESv2.so").string()};
if (FileExists(libraries.egl_path.c_str()) &&
FileExists(libraries.gles_path.c_str()) &&
graphics::InspectAngleLibraries(
{libraries.egl_path, libraries.gles_path})
.state == graphics::CapabilityState::kLoadable) {
return libraries;
}
}
return nullptr;
return {};
}
const char* FindInstalledAngleLibrary(const char* soname, char* out,
size_t out_size) {
static const char* kAngleDirs[] = {
"/usr/lib/chromium",
"/usr/lib/chromium-browser",
"/usr/lib/electron42",
"/usr/lib/electron41",
"/usr/lib/electron40",
"/usr/lib/electron39",
"/usr/lib/cef",
"/opt/google/chrome",
"/opt/google/chrome-beta",
"/opt/google/chrome-unstable",
};
for (const char* dir : kAngleDirs) {
if (JoinExistingLibraryPath(dir, soname, out, out_size)) {
return out;
GraphicsLibraries ResolveGraphicsLibraries() {
const char* egl_path = GetEnvNonEmpty("MOCKTAIL_EGL_LIBRARY");
const char* gles_path = GetEnvNonEmpty("MOCKTAIL_GLES_LIBRARY");
GraphicsLibraries libraries{egl_path != nullptr ? egl_path : "",
gles_path != nullptr ? gles_path : ""};
std::filesystem::path directory;
const char* angle_dir = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
if (angle_dir != nullptr) {
directory = angle_dir;
} else if (ShouldUseAngleBackend()) {
if (libraries.egl_path.empty() && libraries.gles_path.empty()) {
return FindInstalledAngleLibraries();
}
directory =
std::filesystem::path(libraries.egl_path.empty() ? libraries.gles_path
: libraries.egl_path)
.parent_path();
} else {
return libraries;
}
return nullptr;
if (libraries.egl_path.empty()) {
libraries.egl_path = (directory / "libEGL.so").string();
}
if (libraries.gles_path.empty()) {
libraries.gles_path = (directory / "libGLESv2.so").string();
}
return libraries;
}
bool HasInstalledAnglePair() {
char egl_path[4096];
char gles_path[4096];
return FindInstalledAngleLibrary("libEGL.so", egl_path, sizeof(egl_path)) !=
nullptr &&
FindInstalledAngleLibrary("libGLESv2.so", gles_path,
sizeof(gles_path)) != nullptr;
return !FindInstalledAngleLibraries().egl_path.empty();
}
SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
SDL_EGLAttrib* attrs =
static_cast<SDL_EGLAttrib*>(SDL_malloc(sizeof(SDL_EGLAttrib) * 7));
static_cast<SDL_EGLAttrib*>(SDL_malloc(sizeof(SDL_EGLAttrib) * 9));
if (attrs == nullptr) {
return nullptr;
}
@@ -633,6 +630,13 @@ SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
attrs[i++] = ShouldUseAngleSwiftShader()
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE_VAL
: EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE_VAL;
const char* video_driver = SDL_GetCurrentVideoDriver();
if (StringEquals(video_driver, "x11") || StringEquals(video_driver, "wayland")) {
attrs[i++] = EGL_PLATFORM_ANGLE_NATIVE_PLATFORM_TYPE_ANGLE_VAL;
attrs[i++] = StringEquals(video_driver, "x11")
? EGL_PLATFORM_X11_EXT_VAL
: EGL_PLATFORM_WAYLAND_EXT_VAL;
}
if (IsEnabledEnv("MOCKTAIL_ANGLE_DEBUG_LAYERS")) {
attrs[i++] = EGL_PLATFORM_ANGLE_DEBUG_LAYERS_ENABLED_ANGLE_VAL;
attrs[i++] = EGL_TRUE_VAL;
@@ -641,10 +645,7 @@ SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
return attrs;
}
void ConfigureGraphicsBackendBeforeSDL() {
g_preferred_egl_library[0] = '\0';
g_preferred_gles_library[0] = '\0';
bool ConfigureGraphicsBackendBeforeSDL() {
SanitizeSdlVideoDriverOverrides();
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
@@ -670,21 +671,47 @@ void ConfigureGraphicsBackendBeforeSDL() {
"X11/XWayland WSI; set SDL_VIDEODRIVER=wayland to override\n");
}
const char* egl_library = FindAngleLibrary(
"MOCKTAIL_EGL_LIBRARY", "libEGL.so", g_preferred_egl_library,
sizeof(g_preferred_egl_library));
const char* gles_library = FindAngleLibrary(
"MOCKTAIL_GLES_LIBRARY", "libGLESv2.so", g_preferred_gles_library,
sizeof(g_preferred_gles_library));
const GraphicsLibraries libraries = ResolveGraphicsLibraries();
const char* egl_library =
libraries.egl_path.empty() ? nullptr : libraries.egl_path.c_str();
const char* gles_library =
libraries.gles_path.empty() ? nullptr : libraries.gles_path.c_str();
if (ShouldUseAngleBackend() && !ShouldUseNativeVulkanBackend() &&
(!FileExists(egl_library) || !FileExists(gles_library))) {
const char* directory = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
SDL_SetError(
"ANGLE requires compatible libEGL.so and libGLESv2.so; "
"directory=%s egl=%s gles=%s",
directory != nullptr ? directory : "(automatic)",
egl_library != nullptr ? egl_library : "(missing)",
gles_library != nullptr ? gles_library : "(missing)");
fprintf(stderr, " [window] %s\n", SDL_GetError());
return false;
}
if (ShouldUseAngleVulkanBackend()) {
const auto capability =
graphics::InspectAngleLibraries({egl_library, gles_library});
if (capability.state != graphics::CapabilityState::kLoadable) {
SDL_SetError("ANGLE unavailable: %s", capability.detail.c_str());
fprintf(stderr, " [window] %s\n", SDL_GetError());
return false;
}
}
if (egl_library != nullptr) {
setenv("MOCKTAIL_EGL_LIBRARY", egl_library, 0);
SDL_SetHint(SDL_HINT_EGL_LIBRARY, egl_library);
SDL_SetHintWithPriority(SDL_HINT_EGL_LIBRARY, egl_library,
ShouldUseAngleBackend() ? SDL_HINT_OVERRIDE
: SDL_HINT_NORMAL);
}
if (gles_library != nullptr) {
setenv("MOCKTAIL_GLES_LIBRARY", gles_library, 0);
SDL_SetHint(SDL_HINT_OPENGL_LIBRARY, gles_library);
SDL_SetHint(SDL_HINT_OPENGL_ES_DRIVER, "1");
const auto priority = ShouldUseAngleBackend() ? SDL_HINT_OVERRIDE
: SDL_HINT_NORMAL;
SDL_SetHintWithPriority(SDL_HINT_OPENGL_LIBRARY, gles_library, priority);
SDL_SetHintWithPriority(SDL_HINT_OPENGL_ES_DRIVER, "1", priority);
}
if (ShouldUseAngleVulkanBackend()) {
@@ -693,37 +720,88 @@ void ConfigureGraphicsBackendBeforeSDL() {
if (ShouldUseAngleBackend() || egl_library != nullptr ||
gles_library != nullptr || WindowTraceEnabled()) {
const char* requested_video_driver = GetEnvNonEmpty("SDL_VIDEO_DRIVER");
if (requested_video_driver == nullptr) {
requested_video_driver = GetEnvNonEmpty("SDL_VIDEODRIVER");
}
fprintf(stderr, " [window] graphics backend=%s video=%s egl=%s gles=%s\n",
GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND")
? GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND")
: "system",
GetEnvNonEmpty("SDL_VIDEODRIVER")
? GetEnvNonEmpty("SDL_VIDEODRIVER")
: "(SDL default)",
requested_video_driver ? requested_video_driver : "(SDL default)",
egl_library ? egl_library : "(system)",
gles_library ? gles_library : "(system)");
}
return true;
}
void ConfigureGraphicsBackendAfterSDLInit() {
void LogEglError(EGLenum error, const char* command, EGLint /*message_type*/,
void* /*thread_label*/, void* /*object_label*/,
const char* message) {
fprintf(stderr, " [window][EGL] %s: error=0x%04x %s\n",
command != nullptr ? command : "(unknown call)", error,
message != nullptr ? message : "(no details)");
}
void EnableEglErrorLogging() {
using QueryString = const char* (*)(EGLDisplay, EGLint);
using DebugCallback = void (*)(EGLenum, const char*, EGLint, void*, void*,
const char*);
using DebugMessageControl = EGLint (*)(DebugCallback, const SDL_EGLAttrib*);
const auto query_string = reinterpret_cast<QueryString>(
SDL_EGL_GetProcAddress("eglQueryString"));
if (query_string == nullptr) {
return;
}
const char* extensions = query_string(EGL_NO_DISPLAY, EGL_EXTENSIONS_VAL);
const std::string extension_list =
std::string(" ") + (extensions != nullptr ? extensions : "") + " ";
if (extension_list.find(" EGL_KHR_debug ") == std::string::npos) {
return;
}
const auto debug_message_control = reinterpret_cast<DebugMessageControl>(
SDL_EGL_GetProcAddress("eglDebugMessageControlKHR"));
if (debug_message_control != nullptr) {
const SDL_EGLAttrib attributes[] = {
EGL_DEBUG_MSG_CRITICAL_KHR_VAL, EGL_TRUE_VAL,
EGL_DEBUG_MSG_ERROR_KHR_VAL, EGL_TRUE_VAL,
EGL_DEBUG_MSG_WARN_KHR_VAL, EGL_FALSE_VAL,
EGL_DEBUG_MSG_INFO_KHR_VAL, EGL_FALSE_VAL, EGL_NONE_VAL};
debug_message_control(LogEglError, attributes);
}
}
bool ConfigureGraphicsBackendAfterSDLInit() {
if (ShouldUseAngleVulkanBackend()) {
if (!SDL_GL_SetAttribute(SDL_GL_EGL_PLATFORM, EGL_PLATFORM_ANGLE_ANGLE_VAL)) {
fprintf(stderr, " [window] cannot select ANGLE EGL platform: %s\n",
SDL_GetError());
return false;
}
SDL_EGL_SetAttributeCallbacks(AnglePlatformAttributes, nullptr, nullptr,
nullptr);
fprintf(stderr,
" [window] ANGLE Vulkan EGL platform attributes enabled "
"(video=%s)\n", SDL_GetCurrentVideoDriver());
}
const char* gles_library = GetEnvNonEmpty("MOCKTAIL_GLES_LIBRARY");
if (gles_library != nullptr) {
if (!SDL_GL_LoadLibrary(gles_library)) {
fprintf(stderr, " [window] SDL_GL_LoadLibrary(%s) failed: %s\n",
gles_library, SDL_GetError());
} else if (WindowTraceEnabled() || ShouldUseAngleBackend()) {
return false;
}
if (WindowTraceEnabled() || ShouldUseAngleBackend()) {
fprintf(stderr, " [window] SDL_GL_LoadLibrary(%s) succeeded\n",
gles_library);
}
if (WindowTraceEnabled() || TraceFlagEnabled("MOCKTAIL_EGL_TRACE")) {
EnableEglErrorLogging();
}
}
if (ShouldUseAngleVulkanBackend()) {
SDL_GL_SetAttribute(SDL_GL_EGL_PLATFORM, EGL_PLATFORM_ANGLE_ANGLE_VAL);
SDL_EGL_SetAttributeCallbacks(AnglePlatformAttributes, nullptr, nullptr,
nullptr);
fprintf(stderr,
" [window] ANGLE Vulkan EGL platform attributes enabled\n");
}
return true;
}
void ShowWindowAccordingToStartupMode() {
@@ -1038,7 +1116,9 @@ bool Init(int width, int height, const char* title) {
g_fullscreen_menu_request_gate.Reset();
g_real_swap_count.store(0, std::memory_order_relaxed);
ConfigureGraphicsBackendBeforeSDL();
if (!ConfigureGraphicsBackendBeforeSDL()) {
return false;
}
if (StringEquals(GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND"),
"direct-vulkan")) {
@@ -1178,11 +1258,10 @@ bool Init(int width, int height, const char* title) {
return false;
}
if (WindowTraceEnabled()) {
fprintf(stderr, " [window] SDL_Init(SDL_INIT_VIDEO) succeeded\n");
fprintf(stderr, " [window] SDL_Init(SDL_INIT_VIDEO) succeeded (video=%s)\n",
SDL_GetCurrentVideoDriver());
}
ConfigureGraphicsBackendAfterSDLInit();
// SDL may recheck this hint during context creation.
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
@@ -1197,6 +1276,11 @@ bool Init(int width, int height, const char* title) {
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
if (!ConfigureGraphicsBackendAfterSDLInit()) {
SDL_Quit();
return false;
}
const WindowStartupPresentationPlan presentation =
RestoredWindowPresentationPlan();
SDL_WindowFlags window_flags = ApplyHighPixelDensityWindowFlag(
@@ -2273,7 +2357,8 @@ bool PumpEvents() {
std::get_if<platform::MouseMotionEvent>(&pending_motion.payload);
const auto* nxt =
std::get_if<platform::MouseMotionEvent>(&platform_event.payload);
if (cur != nullptr && nxt != nullptr) {
if (cur != nullptr && nxt != nullptr &&
cur->relative == nxt->relative) {
cur->delta_x += nxt->delta_x;
cur->delta_y += nxt->delta_y;
cur->x = nxt->x;
@@ -39,10 +39,14 @@ bool WindowPointerCaptureOwner::RegisterQuery(MouseLockQueryCallback callback,
void WindowPointerCaptureOwner::ClearQuery() {
std::unique_lock<std::mutex> lock(mutex_);
if (clearing_ && t_pointer_query_owner == this) {
return;
}
condition_.wait(lock, [this] { return !clearing_; });
clearing_ = true;
callback_ = nullptr;
context_ = nullptr;
condition_.notify_all();
condition_.wait(lock, [this] {
return in_flight_ == 0 || t_pointer_query_owner == this;
});
@@ -56,6 +56,8 @@ class WindowPointerCaptureOwner final {
bool cursor_visible() const { return cursor_visible_; }
private:
friend class WindowPointerCaptureOwnerTestPeer;
bool Apply(bool capture, bool cursor_visible);
PointerCaptureBackend* backend_ = nullptr;
+7 -1
View File
@@ -42,6 +42,7 @@ struct AAsset {
std::string path;
std::string apk_entry;
std::vector<unsigned char> data;
bool transcoded = false;
size_t offset = 0;
};
struct AAssetDir {};
@@ -341,7 +342,8 @@ AAsset* AAssetManager_open(AAssetManager* mgr, const char* filename,
return nullptr;
}
if (MocktailUsesDirectVulkan()) {
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
asset->transcoded =
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
}
if (AssetTraceEnabled()) {
std::fprintf(stderr, "[asset] open %s -> %s (%zu bytes)\n", filename,
@@ -455,6 +457,10 @@ int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart,
if (asset == nullptr || asset->path.empty()) {
return -1;
}
// Force buffered reads: files still contain the original ETC1 bytes.
if (asset->transcoded) {
return -1;
}
int fd = -1;
off_t start = 0;
off_t descriptor_length = static_cast<off_t>(asset->data.size());
+30
View File
@@ -11,6 +11,13 @@ target_link_libraries(jnivm_test PRIVATE
GTest::gtest_main
)
add_executable(jnivm_fields_test jnivm_fields_test.cc)
target_link_libraries(jnivm_fields_test PRIVATE
Mocktail::LegacyJni
GTest::gtest_main
Threads::Threads
)
add_executable(runtime_config_test runtime_config_test.cc)
target_link_libraries(runtime_config_test PRIVATE
Mocktail::Runtime
@@ -22,6 +29,13 @@ target_link_libraries(fleasion_probe PRIVATE Mocktail::Services libc_shim)
mocktail_apply_compile_options(fleasion_probe)
find_program(MOCKTAIL_TEST_PYTHON python3)
find_program(MOCKTAIL_TEST_OPENSSL openssl)
if(MOCKTAIL_TEST_PYTHON)
add_test(NAME PayloadImportRollbackTest.PreservesPreviousInstallation
COMMAND ${MOCKTAIL_TEST_PYTHON}
${CMAKE_CURRENT_SOURCE_DIR}/payload_import_rollback_test.py)
set_tests_properties(PayloadImportRollbackTest.PreservesPreviousInstallation
PROPERTIES TIMEOUT 180)
endif()
if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL)
add_test(NAME FleasionIntegrationTest.ProxyAndTrust
COMMAND ${MOCKTAIL_TEST_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/fleasion_integration_test.py
@@ -72,6 +86,12 @@ target_compile_definitions(native_update_test PRIVATE
MOCKTAIL_TEST_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
)
add_executable(compatibility_catalog_test compatibility_catalog_test.cc)
target_link_libraries(compatibility_catalog_test PRIVATE
Mocktail::Update
GTest::gtest_main
)
add_executable(apk_provider_test apk_provider_test.cc)
target_link_libraries(apk_provider_test PRIVATE
Mocktail::Update
@@ -978,11 +998,13 @@ target_link_libraries(headless_graphics_test PRIVATE
include(GoogleTest)
gtest_discover_tests(jnivm_test)
gtest_discover_tests(jnivm_fields_test)
gtest_discover_tests(runtime_config_test)
gtest_discover_tests(runtime_config_file_test)
gtest_discover_tests(payload_update_preflight_test)
gtest_discover_tests(apk_provider_test)
gtest_discover_tests(native_update_test)
gtest_discover_tests(compatibility_catalog_test)
gtest_discover_tests(support_bundle_runtime_test)
gtest_discover_tests(vulkan_icd_selection_test)
gtest_discover_tests(session_log_test)
@@ -1135,6 +1157,11 @@ add_test(
)
find_program(MOCKTAIL_PATCHELF_EXECUTABLE patchelf)
add_test(
NAME StandaloneWebkitCacheTest.RelocatesAllLoaderNames
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/standalone_webkit_cache_test.sh
${CMAKE_SOURCE_DIR}
)
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER AND MOCKTAIL_PATCHELF_EXECUTABLE)
add_test(
NAME PortablePackagingTest.RelocatableReadOnlyBundle
@@ -1142,6 +1169,9 @@ if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER AND MOCKTAIL_PATCHELF_EXECUTABLE)
MOCKTAIL_PORTABLE_TEST_BUILD_DIR=${CMAKE_BINARY_DIR}
bash ${CMAKE_CURRENT_SOURCE_DIR}/portable_packaging_test.sh
)
# Copies and verifies a full JVM/WebKit bundle, unlike the unit tests.
set_tests_properties(PortablePackagingTest.RelocatableReadOnlyBundle
PROPERTIES TIMEOUT 600)
endif()
add_test(
+99
View File
@@ -5,6 +5,7 @@
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <sys/stat.h>
#include <unistd.h>
@@ -26,20 +27,47 @@ off_t AAsset_getRemainingLength(AAsset* asset);
off_t AAsset_seek(AAsset* asset, off_t offset, int whence);
int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart,
off_t* outLength);
int AAsset_openFileDescriptor64(AAsset* asset, off_t* outStart,
off_t* outLength);
}
namespace {
std::vector<unsigned char> MakeEtc1SkyTexture() {
std::vector<unsigned char> data = {
0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
};
// KTX1: one 4x4 ETC1 mip and its byte count.
for (uint32_t value : {0x04030201U, 0U, 1U, 0U, 0x8d64U, 0x1907U,
4U, 4U, 0U, 0U, 1U, 1U, 0U, 8U}) {
for (unsigned shift = 0; shift < 32; shift += 8) {
data.push_back(static_cast<unsigned char>(value >> shift));
}
}
const std::array<unsigned char, 8> block = {0x55, 0x77, 0x99, 0, 0, 0, 0, 0};
data.insert(data.end(), block.begin(), block.end());
return data;
}
class AssetManagerTest : public ::testing::Test {
protected:
void SetUp() override {
root_ = MakeTempRoot();
ASSERT_FALSE(root_.empty());
setenv("MOCKTAIL_ASSET_ROOT", root_.c_str(), 1);
const char* backend = std::getenv("MOCKTAIL_GRAPHICS_BACKEND");
had_backend_ = backend != nullptr;
previous_backend_ = backend != nullptr ? backend : "";
unsetenv("MOCKTAIL_GRAPHICS_BACKEND");
}
void TearDown() override {
unsetenv("MOCKTAIL_ASSET_ROOT");
if (had_backend_) {
setenv("MOCKTAIL_GRAPHICS_BACKEND", previous_backend_.c_str(), 1);
} else {
unsetenv("MOCKTAIL_GRAPHICS_BACKEND");
}
for (const std::string& root : roots_to_remove_) {
std::filesystem::remove_all(root);
}
@@ -71,8 +99,20 @@ class AssetManagerTest : public ::testing::Test {
ASSERT_EQ(std::fclose(file), 0);
}
void WriteBinaryAsset(const std::string& name,
const std::vector<unsigned char>& data) {
const std::filesystem::path path = std::filesystem::path(root_) / name;
std::filesystem::create_directories(path.parent_path());
std::ofstream file(path, std::ios::binary);
file.write(reinterpret_cast<const char*>(data.data()), data.size());
file.close();
ASSERT_TRUE(file);
}
std::string root_;
std::vector<std::string> roots_to_remove_;
bool had_backend_ = false;
std::string previous_backend_;
};
TEST_F(AssetManagerTest, FromJavaReturnsSingletonManager) {
@@ -155,6 +195,65 @@ TEST_F(AssetManagerTest, OpenFileDescriptorReturnsReadableFd) {
AAsset_close(asset);
}
TEST_F(AssetManagerTest, TranscodedSkyCannotBypassConversionThroughDescriptor) {
const auto original = MakeEtc1SkyTexture();
const std::string name = "assets/android/textures/sky/test.tex";
WriteBinaryAsset(name, original);
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "direct-vulkan", 1), 0);
AAsset* asset = AAssetManager_open(
AAssetManager_fromJava(nullptr, nullptr), name.c_str(), 0);
ASSERT_NE(asset, nullptr);
ASSERT_EQ(AAsset_getLength(asset), static_cast<off_t>(original.size()));
const auto* buffer = static_cast<const unsigned char*>(AAsset_getBuffer(asset));
ASSERT_NE(buffer, nullptr);
// BC1 format in the KTX header.
EXPECT_EQ(buffer[28], 0xf0);
EXPECT_EQ(buffer[29], 0x83);
for (auto open_descriptor : {AAsset_openFileDescriptor,
AAsset_openFileDescriptor64}) {
off_t start = -1;
off_t length = -1;
const int fd = open_descriptor(asset, &start, &length);
EXPECT_LT(fd, 0);
if (fd >= 0) close(fd);
}
std::vector<unsigned char> readback(original.size());
EXPECT_EQ(AAsset_read(asset, readback.data(), readback.size()),
static_cast<int>(readback.size()));
EXPECT_EQ(readback, std::vector<unsigned char>(buffer, buffer + readback.size()));
AAsset_close(asset);
std::vector<unsigned char> on_disk(original.size());
std::ifstream file(root_ + "/" + name, std::ios::binary);
file.read(reinterpret_cast<char*>(on_disk.data()), on_disk.size());
EXPECT_EQ(on_disk, original);
}
TEST_F(AssetManagerTest, OpenGlSkyKeepsOriginalFileDescriptor) {
const auto original = MakeEtc1SkyTexture();
const std::string name = "assets/android/textures/sky/test.tex";
WriteBinaryAsset(name, original);
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "opengl", 1), 0);
AAsset* asset = AAssetManager_open(
AAssetManager_fromJava(nullptr, nullptr), name.c_str(), 0);
ASSERT_NE(asset, nullptr);
off_t start = -1;
off_t length = -1;
const int fd = AAsset_openFileDescriptor64(asset, &start, &length);
ASSERT_GE(fd, 0);
EXPECT_EQ(length, static_cast<off_t>(original.size()));
std::vector<unsigned char> readback(original.size());
EXPECT_EQ(pread(fd, readback.data(), readback.size(), start),
static_cast<ssize_t>(readback.size()));
EXPECT_EQ(readback, original);
close(fd);
AAsset_close(asset);
}
TEST_F(AssetManagerTest, MissingOrUnsafeAssetsReturnNull) {
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
EXPECT_EQ(AAssetManager_open(manager, "missing.bin", 0), nullptr);
+293 -7
View File
@@ -30,19 +30,60 @@ namespace {
using namespace std::chrono_literals;
class ReleaseGate {
public:
void WaitBeforeRelease() {
std::unique_lock<std::mutex> lock(mutex_);
waiting_ = true;
cv_.notify_all();
cv_.wait(lock, [this] { return open_; });
}
bool WaitUntilBlocked() {
std::unique_lock<std::mutex> lock(mutex_);
return cv_.wait_for(lock, 2s, [this] { return waiting_; });
}
void Open() {
{
std::lock_guard<std::mutex> lock(mutex_);
open_ = true;
}
cv_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable cv_;
bool waiting_ = false;
bool open_ = false;
};
class ControlledAudioSink final : public AudioSink {
public:
explicit ControlledAudioSink(bool release_on_enqueue = false)
: release_on_enqueue_(release_on_enqueue) {}
const PcmSpec& source_spec() const override { return spec_; }
Status Enqueue(const PcmBuffer& buffer) override {
if (buffer.data == nullptr || buffer.size_bytes == 0) {
return Status::Error(StatusCode::kInvalidArgument, "empty buffer");
}
std::lock_guard<std::mutex> lock(mutex_);
if (stopped_) {
return Status::Error(StatusCode::kFailedPrecondition, "stopped");
{
std::lock_guard<std::mutex> lock(mutex_);
if (stopped_) {
return Status::Error(StatusCode::kFailedPrecondition, "stopped");
}
if (reject_next_enqueue_) {
reject_next_enqueue_ = false;
return Status::Error(StatusCode::kUnavailable, "enqueue rejected");
}
buffers_.push_back(buffer);
}
if (release_on_enqueue_) {
ReleaseOne();
}
buffers_.push_back(buffer);
return Status::Ok();
}
@@ -93,10 +134,13 @@ class ControlledAudioSink final : public AudioSink {
std::lock_guard<std::mutex> lock(mutex_);
stopped_ = true;
}
release_cv_.notify_all();
ReleaseAll();
std::unique_lock<std::mutex> lock(mutex_);
release_cv_.wait(lock, [this] { return active_releases_ == 0; });
}
void ReleaseOne() {
void ReleaseOne(ReleaseGate* gate = nullptr) {
PcmBuffer buffer;
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -105,11 +149,30 @@ class ControlledAudioSink final : public AudioSink {
}
buffer = buffers_.front();
buffers_.erase(buffers_.begin());
++active_releases_;
}
if (gate != nullptr) {
gate->WaitBeforeRelease();
}
if (buffer.release_callback != nullptr) {
buffer.release_callback(buffer.release_context, buffer.data,
buffer.size_bytes);
}
{
std::lock_guard<std::mutex> lock(mutex_);
--active_releases_;
}
release_cv_.notify_all();
}
bool WaitUntilShutdownStarts() {
std::unique_lock<std::mutex> lock(mutex_);
return release_cv_.wait_for(lock, 2s, [this] { return stopped_; });
}
void RejectNextEnqueue() {
std::lock_guard<std::mutex> lock(mutex_);
reject_next_enqueue_ = true;
}
std::size_t buffer_count() const {
@@ -141,11 +204,36 @@ class ControlledAudioSink final : public AudioSink {
}
const PcmSpec spec_;
const bool release_on_enqueue_;
mutable std::mutex mutex_;
std::condition_variable release_cv_;
std::vector<PcmBuffer> buffers_;
std::size_t active_releases_ = 0;
bool playing_ = false;
float gain_ = 1.0F;
bool stopped_ = false;
bool reject_next_enqueue_ = false;
};
class DelayedBufferRelease {
public:
explicit DelayedBufferRelease(ControlledAudioSink* sink)
: thread_([this, sink] { sink->ReleaseOne(&gate_); }) {}
~DelayedBufferRelease() {
if (thread_.joinable()) {
gate_.Open();
thread_.join();
}
}
bool WaitUntilBlocked() { return gate_.WaitUntilBlocked(); }
void AllowRelease() { gate_.Open(); }
void Join() { thread_.join(); }
private:
ReleaseGate gate_;
std::thread thread_;
};
struct CallbackProbe {
@@ -389,6 +477,7 @@ TEST(SdlAudioSinkTest, ReleasesBorrowedBufferWhenStreamIsCleared) {
TEST(OpenSlQueueTest, DeliversPcmToSinkAndDispatchesCompletion) {
auto controlled = std::make_unique<ControlledAudioSink>();
ControlledAudioSink* controlled_view = controlled.get();
CallbackProbe probe;
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
Status status = OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
{}, &adapter);
@@ -396,7 +485,6 @@ TEST(OpenSlQueueTest, DeliversPcmToSinkAndDispatchesCompletion) {
auto queue = adapter->interface();
ASSERT_NE(queue, nullptr);
CallbackProbe probe;
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
opensl_abi::kResultSuccess);
@@ -520,6 +608,204 @@ TEST(OpenSlQueueTest, ClearSuppressesCallbacksForDiscardedBuffers) {
EXPECT_EQ(probe.calls, 0);
}
TEST(OpenSlQueueTest, DelayedReleaseAfterClearPreservesReenqueuedBuffer) {
auto controlled = std::make_unique<ControlledAudioSink>();
ControlledAudioSink* controlled_view = controlled.get();
OpenSlSimpleBufferQueueOptions options;
options.max_buffers = 1;
CallbackProbe probe;
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
options, &adapter)
.ok());
auto queue = adapter->interface();
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
opensl_abi::kResultSuccess);
const std::vector<std::int16_t> samples(32 * 2, 789);
const auto bytes =
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultSuccess);
DelayedBufferRelease release(controlled_view);
ASSERT_TRUE(release.WaitUntilBlocked());
ASSERT_EQ(controlled_view->buffer_count(), 0u);
ASSERT_EQ((*queue)->Clear(queue), opensl_abi::kResultSuccess);
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultSuccess);
release.AllowRelease();
release.Join();
opensl_abi::AndroidSimpleBufferQueueState queue_state{};
ASSERT_EQ((*queue)->GetState(queue, &queue_state),
opensl_abi::kResultSuccess);
EXPECT_EQ(queue_state.count, 1u);
EXPECT_EQ(queue_state.index, 0u);
const auto before_release = adapter->GetStats();
EXPECT_EQ(before_release.submitted_buffers, 2u);
EXPECT_EQ(before_release.pending_buffers, 1u);
EXPECT_EQ(before_release.consumed_buffers, 0u);
EXPECT_EQ(before_release.discarded_buffers, 1u);
EXPECT_EQ(before_release.consumed_bytes, 0u);
controlled_view->ReleaseOne();
{
std::unique_lock<std::mutex> lock(probe.mutex);
EXPECT_TRUE(probe.cv.wait_for(lock, 2s, [&probe] { return probe.calls >= 1; }));
}
adapter->Shutdown();
{
std::lock_guard<std::mutex> lock(probe.mutex);
EXPECT_EQ(probe.calls, 1);
}
ASSERT_EQ((*queue)->GetState(queue, &queue_state),
opensl_abi::kResultSuccess);
EXPECT_EQ(queue_state.count, 0u);
EXPECT_EQ(queue_state.index, 1u);
const auto after_release = adapter->GetStats();
EXPECT_EQ(after_release.pending_buffers, 0u);
EXPECT_EQ(after_release.consumed_buffers, 1u);
EXPECT_EQ(after_release.discarded_buffers, 1u);
EXPECT_EQ(after_release.consumed_bytes, bytes);
EXPECT_TRUE(adapter->last_error().ok());
}
TEST(OpenSlQueueTest, ClearCanRaceBorrowedBufferRelease) {
auto controlled = std::make_unique<ControlledAudioSink>();
ControlledAudioSink* controlled_view = controlled.get();
OpenSlSimpleBufferQueueOptions options;
options.max_buffers = 1;
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
options, &adapter)
.ok());
auto queue = adapter->interface();
const std::vector<std::int16_t> samples(32 * 2, 789);
const auto bytes =
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
constexpr std::uint64_t kIterations = 64;
for (std::uint64_t iteration = 0; iteration < kIterations; ++iteration) {
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultSuccess);
DelayedBufferRelease release(controlled_view);
ASSERT_TRUE(release.WaitUntilBlocked());
// Clear and ReleaseBuffer both run after this gate. Joining only after
// Clear avoids ordering the ticket reset before the callback's first read.
release.AllowRelease();
ASSERT_EQ((*queue)->Clear(queue), opensl_abi::kResultSuccess);
release.Join();
EXPECT_EQ(adapter->GetStats().pending_buffers, 0u);
}
adapter->Shutdown();
const auto stats = adapter->GetStats();
EXPECT_EQ(stats.submitted_buffers, kIterations);
EXPECT_EQ(stats.consumed_buffers + stats.discarded_buffers, kIterations);
EXPECT_EQ(stats.pending_buffers, 0u);
EXPECT_TRUE(adapter->last_error().ok());
}
TEST(OpenSlQueueTest, ShutdownWaitsForDelayedReleaseAfterClear) {
auto controlled = std::make_unique<ControlledAudioSink>();
ControlledAudioSink* controlled_view = controlled.get();
OpenSlSimpleBufferQueueOptions options;
options.max_buffers = 1;
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
options, &adapter)
.ok());
auto queue = adapter->interface();
const std::vector<std::int16_t> samples(32 * 2, 789);
const auto bytes =
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultSuccess);
DelayedBufferRelease release(controlled_view);
ASSERT_TRUE(release.WaitUntilBlocked());
ASSERT_EQ((*queue)->Clear(queue), opensl_abi::kResultSuccess);
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultSuccess);
std::atomic<bool> shutdown_done{false};
std::thread shutdown_thread([&] {
adapter->Shutdown();
shutdown_done.store(true);
});
EXPECT_TRUE(controlled_view->WaitUntilShutdownStarts());
EXPECT_FALSE(shutdown_done.load());
release.AllowRelease();
release.Join();
shutdown_thread.join();
EXPECT_TRUE(shutdown_done.load());
EXPECT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultPreconditionsViolated);
const auto stats = adapter->GetStats();
EXPECT_EQ(stats.pending_buffers, 0u);
EXPECT_EQ(stats.consumed_buffers, 0u);
EXPECT_EQ(stats.discarded_buffers, 2u);
EXPECT_TRUE(adapter->last_error().ok());
}
TEST(OpenSlQueueTest, FailedEnqueuePreservesCapacityAndCounters) {
auto controlled = std::make_unique<ControlledAudioSink>();
ControlledAudioSink* controlled_view = controlled.get();
OpenSlSimpleBufferQueueOptions options;
options.max_buffers = 1;
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
options, &adapter)
.ok());
auto queue = adapter->interface();
const std::vector<std::int16_t> samples(32 * 2, 789);
const auto bytes =
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
controlled_view->RejectNextEnqueue();
EXPECT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultResourceError);
EXPECT_EQ(adapter->GetStats().submitted_buffers, 0u);
EXPECT_EQ(adapter->GetStats().pending_buffers, 0u);
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultSuccess);
controlled_view->ReleaseOne();
const auto stats = adapter->GetStats();
EXPECT_EQ(stats.submitted_buffers, 1u);
EXPECT_EQ(stats.consumed_buffers, 1u);
EXPECT_EQ(stats.discarded_buffers, 0u);
EXPECT_EQ(stats.pending_buffers, 0u);
}
TEST(OpenSlQueueTest, EnqueueCanReleaseBorrowedBufferSynchronously) {
auto controlled = std::make_unique<ControlledAudioSink>(true);
OpenSlSimpleBufferQueueOptions options;
options.max_buffers = 1;
CallbackProbe probe;
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
options, &adapter)
.ok());
auto queue = adapter->interface();
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
opensl_abi::kResultSuccess);
const std::vector<std::int16_t> samples(32 * 2, 789);
const auto bytes =
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
for (int enqueue = 0; enqueue < 2; ++enqueue) {
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
opensl_abi::kResultSuccess);
EXPECT_EQ(adapter->GetStats().pending_buffers, 0u);
}
{
std::unique_lock<std::mutex> lock(probe.mutex);
EXPECT_TRUE(probe.cv.wait_for(lock, 2s, [&probe] { return probe.calls == 2; }));
}
adapter->Shutdown();
const auto stats = adapter->GetStats();
EXPECT_EQ(stats.submitted_buffers, 2u);
EXPECT_EQ(stats.consumed_buffers, 2u);
EXPECT_EQ(stats.discarded_buffers, 0u);
EXPECT_EQ(stats.consumed_bytes, 2u * bytes);
EXPECT_TRUE(adapter->last_error().ok());
}
TEST(OpenSlQueueTest, RejectsOverflowAndShutsDownFailClosed) {
auto controlled = std::make_unique<ControlledAudioSink>();
OpenSlSimpleBufferQueueOptions options;
@@ -590,12 +876,12 @@ TEST(OpenSlQueueTest, ShutdownWaitsForInFlightCallback) {
TEST(OpenSlQueueTest, GetStatePollsWhilePlaybackReleasesBuffers) {
auto controlled = std::make_unique<ControlledAudioSink>();
ControlledAudioSink* controlled_view = controlled.get();
CallbackProbe probe;
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled), {},
&adapter)
.ok());
auto queue = adapter->interface();
CallbackProbe probe;
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
opensl_abi::kResultSuccess);
+120
View File
@@ -2,12 +2,18 @@
#include <gtest/gtest.h>
#include <netdb.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <memory>
namespace mocktail {
namespace compat {
namespace {
using AddressInfoOwner =
std::unique_ptr<BionicAddressInfo, decltype(&BionicFreeAddressInfo)>;
TEST(BionicDnsRuntimeTest, BlocksOnlyCrashUploadInfrastructure) {
EXPECT_TRUE(IsBlockedCrashReportUploadHost("upload.crashes.rbxinfra.com"));
EXPECT_TRUE(IsBlockedCrashReportUploadHost("UPLOAD.CRASHES.RBXINFRA.COM."));
@@ -58,6 +64,120 @@ TEST(BionicDnsRuntimeTest, RejectsMissingResultStorage) {
BionicFreeAddressInfo(nullptr);
}
TEST(BionicDnsRuntimeTest, GuestNumericServiceRejectsNamesAndAcceptsNumbers) {
BionicAddressInfo hints;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = 0x00000008 | 0x00000004;
BionicAddressInfo* result = nullptr;
EXPECT_EQ(BionicGetAddressInfo("127.0.0.1", "http", &hints, &result),
kBionicAddressInfoNameNotFound);
EXPECT_EQ(result, nullptr);
BionicFreeAddressInfo(result);
result = nullptr;
const int status = BionicGetAddressInfo("127.0.0.1", "80", &hints, &result);
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
ASSERT_EQ(status, 0);
ASSERT_NE(result, nullptr);
EXPECT_EQ(result->ai_flags, 0x0000000c);
ASSERT_NE(result->ai_addr, nullptr);
ASSERT_EQ(result->ai_family, AF_INET);
EXPECT_EQ(
ntohs(reinterpret_cast<const sockaddr_in*>(result->ai_addr)->sin_port),
80);
}
TEST(BionicDnsRuntimeTest, GuestAddressConfigurationAllowsNamedService) {
addrinfo host_hints{};
host_hints.ai_family = AF_INET;
host_hints.ai_socktype = SOCK_STREAM;
host_hints.ai_flags = AI_ADDRCONFIG | AI_NUMERICHOST;
addrinfo* host_result = nullptr;
const int host_status =
::getaddrinfo("127.0.0.1", "http", &host_hints, &host_result);
ASSERT_EQ(host_status, 0)
<< "Host baseline requires a configured IPv4 interface and http service: "
<< ::gai_strerror(host_status);
ASSERT_NE(host_result, nullptr);
::freeaddrinfo(host_result);
BionicAddressInfo hints;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = 0x00000400 | 0x00000004;
BionicAddressInfo* result = nullptr;
const int status = BionicGetAddressInfo("127.0.0.1", "http", &hints, &result);
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
ASSERT_EQ(status, 0);
ASSERT_NE(result, nullptr);
for (const BionicAddressInfo* current = result; current != nullptr;
current = current->ai_next) {
EXPECT_EQ(current->ai_flags, 0x00000404);
EXPECT_EQ(current->ai_flags & 0x00000020, 0);
ASSERT_NE(current->ai_addr, nullptr);
ASSERT_EQ(current->ai_family, AF_INET);
EXPECT_EQ(
ntohs(reinterpret_cast<const sockaddr_in*>(current->ai_addr)->sin_port),
80);
}
}
TEST(BionicDnsRuntimeTest, SupportedGuestFlagsRoundTrip) {
// Android values are intentional; host AI_* constants are not guest hints.
for (const int flags : {0x00000001, 0x00000002, 0x00000004, 0x00000008,
0x00000400, 0x0000000f, 0x00000407, 0x0000040f}) {
SCOPED_TRACE(flags);
BionicAddressInfo hints;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = flags;
BionicAddressInfo* result = nullptr;
const int status = BionicGetAddressInfo("127.0.0.1", "80", &hints, &result);
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
ASSERT_EQ(status, 0);
ASSERT_NE(result, nullptr);
for (const BionicAddressInfo* current = result; current != nullptr;
current = current->ai_next) {
EXPECT_EQ(current->ai_flags, flags);
}
if ((flags & 0x00000002) != 0) {
ASSERT_NE(result->ai_canonname, nullptr);
EXPECT_STREQ(result->ai_canonname, "127.0.0.1");
}
}
}
TEST(BionicDnsRuntimeTest, RejectsUnsupportedGuestFlagsBeforeHostResolution) {
for (const int flags : {0x00000010, 0x00000020, 0x00000040, 0x00000080,
0x00000100, 0x00000200, 0x00000800, 0x00001000,
0x00000424, -1}) {
SCOPED_TRACE(flags);
BionicAddressInfo hints;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = flags;
BionicAddressInfo* result =
reinterpret_cast<BionicAddressInfo*>(uintptr_t{1});
const int status = BionicGetAddressInfo("127.0.0.1", "80", &hints, &result);
EXPECT_EQ(status, 3);
EXPECT_EQ(result, nullptr);
BionicFreeAddressInfo(result);
}
}
TEST(BionicDnsRuntimeTest, NullHintsPreserveLocalResolutionAndGuestFlags) {
BionicAddressInfo* result = nullptr;
const int status = BionicGetAddressInfo("127.0.0.1", "80", nullptr, &result);
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
ASSERT_EQ(status, 0);
ASSERT_NE(result, nullptr);
for (const BionicAddressInfo* current = result; current != nullptr;
current = current->ai_next) {
EXPECT_EQ(current->ai_flags & ~0x0000040f, 0);
}
}
} // namespace
} // namespace compat
} // namespace mocktail
+114
View File
@@ -0,0 +1,114 @@
#include "update/compatibility_catalog.h"
#include <gtest/gtest.h>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include "compat/guest_abi.h"
namespace mocktail::update {
namespace {
class CompatibilityCatalogTest : public testing::Test {
protected:
void SetUp() override {
char pattern[] = "/tmp/mocktail_catalog_XXXXXX";
const char* created = mkdtemp(pattern);
ASSERT_NE(created, nullptr);
directory = created;
}
void TearDown() override {
std::error_code ignored;
if (!directory.empty()) std::filesystem::remove_all(directory, ignored);
}
nlohmann::json ValidProfile() {
return {{"status", "supported"},
{"default_allowed", true},
{"allow_legacy_binary_patches", false},
{"abi", compat::kGuestAbi},
{"version_name", "1.2.3"},
{"version_code", 123},
{"elf_build_id", std::string(40, 'a')}};
}
CompatibilityCatalogResult Load(const nlohmann::json& document) {
const auto path = directory / "catalog.json";
std::ofstream(path) << document.dump();
return LoadCompatibilityCatalog(path);
}
std::filesystem::path directory;
};
TEST_F(CompatibilityCatalogTest, LoadsValidSupportedProfile) {
const auto catalog =
Load({{"schema_version", 1}, {"profiles", {ValidProfile()}}});
ASSERT_TRUE(catalog) << catalog.error;
ASSERT_EQ(catalog.profiles.size(), 1u);
EXPECT_EQ(catalog.profiles.front().version_code, 123u);
}
TEST_F(CompatibilityCatalogTest, RejectsInvalidSchemaTypesWithoutTerminating) {
for (const auto& version :
nlohmann::json::array({nullptr, true, "1", 1.0, 4294967297ULL,
nlohmann::json::object()})) {
SCOPED_TRACE(version.dump());
const auto catalog =
Load({{"schema_version", version}, {"profiles", {ValidProfile()}}});
EXPECT_FALSE(catalog);
EXPECT_FALSE(catalog.error.empty());
}
}
TEST_F(CompatibilityCatalogTest, RejectsInvalidPolicyTypesWithoutTerminating) {
const std::pair<const char*, nlohmann::json> invalid[] = {
{"status", true},
{"status", 1},
{"status", nullptr},
{"default_allowed", "true"},
{"default_allowed", 1},
{"default_allowed", nullptr},
{"allow_legacy_binary_patches", "false"},
{"allow_legacy_binary_patches", 0},
{"allow_legacy_binary_patches", nullptr}};
for (const auto& [name, value] : invalid) {
SCOPED_TRACE(name);
SCOPED_TRACE(value.dump());
auto profile = ValidProfile();
profile[name] = value;
const auto catalog = Load({{"schema_version", 1}, {"profiles", {profile}}});
EXPECT_FALSE(catalog);
EXPECT_FALSE(catalog.error.empty());
}
}
TEST_F(CompatibilityCatalogTest, MissingPolicyFieldsCannotAuthorizeAPayload) {
for (const char* name :
{"status", "default_allowed", "allow_legacy_binary_patches"}) {
SCOPED_TRACE(name);
auto profile = ValidProfile();
profile.erase(name);
const auto catalog = Load({{"schema_version", 1}, {"profiles", {profile}}});
EXPECT_FALSE(catalog);
EXPECT_TRUE(catalog.profiles.empty());
}
}
TEST_F(CompatibilityCatalogTest, FiltersOtherArchitectures) {
auto other = ValidProfile();
other["abi"] = compat::kGuestAbi == "x86_64" ? "arm64-v8a" : "x86_64";
other["version_code"] = 456;
const auto catalog =
Load({{"schema_version", 1}, {"profiles", {other, ValidProfile()}}});
ASSERT_TRUE(catalog) << catalog.error;
ASSERT_EQ(catalog.profiles.size(), 1u);
EXPECT_EQ(catalog.profiles.front().version_code, 123u);
}
} // namespace
} // namespace mocktail::update
+269
View File
@@ -2,6 +2,7 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdint>
@@ -360,5 +361,273 @@ TEST(Etc2DecoderTest, RejectsShortBuffers) {
output.data(), output.size()));
}
namespace reference {
constexpr std::array<int, 8> kDistance = {3, 6, 11, 16, 23, 32, 41, 64};
constexpr int kEtc1Modifiers[8][4] = {
{2, 8, -2, -8}, {5, 17, -5, -17}, {9, 29, -9, -29},
{13, 42, -13, -42}, {18, 60, -18, -60}, {24, 80, -24, -80},
{33, 106, -33, -106}, {47, 183, -47, -183},
};
constexpr int kEacModifiers[16][8] = {
{-3, -6, -9, -15, 2, 5, 8, 14}, {-3, -7, -10, -13, 2, 6, 9, 12},
{-2, -5, -8, -13, 1, 4, 7, 12}, {-2, -4, -6, -13, 1, 3, 5, 12},
{-3, -6, -8, -12, 2, 5, 7, 11}, {-3, -7, -9, -11, 2, 6, 8, 10},
{-4, -7, -8, -11, 3, 6, 7, 10}, {-3, -5, -8, -11, 2, 4, 7, 10},
{-2, -6, -8, -10, 1, 5, 7, 9}, {-2, -5, -8, -10, 1, 4, 7, 9},
{-2, -4, -8, -10, 1, 3, 7, 9}, {-2, -5, -7, -10, 1, 4, 6, 9},
{-3, -4, -7, -10, 2, 3, 6, 9}, {-1, -2, -3, -10, 0, 1, 2, 9},
{-4, -6, -8, -9, 3, 5, 7, 8}, {-3, -5, -7, -9, 2, 4, 6, 8},
};
inline std::uint8_t Clamp255(int value) {
return static_cast<std::uint8_t>(std::clamp(value, 0, 255));
}
inline int Expand4(int value) { return (value << 4) | value; }
inline int Expand5(int value) { return (value << 3) | (value >> 2); }
inline int Expand6(int value) { return (value << 2) | (value >> 4); }
inline int Expand7(int value) { return (value << 1) | (value >> 6); }
inline void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
std::array<std::uint8_t, 64>* out) {
static constexpr int kDelta[8] = {0, 1, 2, 3, -4, -3, -2, -1};
const bool diff_or_opaque = (b[3] & 2) != 0;
const bool opaque = !punchthrough || diff_or_opaque;
const int msb = (b[4] << 8) | b[5];
const int lsb = (b[6] << 8) | b[7];
auto index_of = [msb, lsb](int texel) {
return (((msb >> texel) & 1) << 1) | ((lsb >> texel) & 1);
};
auto write = [&](int texel, int r, int g, int bl, int a) {
(*out)[texel * 4] = Clamp255(r);
(*out)[texel * 4 + 1] = Clamp255(g);
(*out)[texel * 4 + 2] = Clamp255(bl);
(*out)[texel * 4 + 3] = static_cast<std::uint8_t>(a);
};
const int r_sum = (b[0] >> 3) + kDelta[b[0] & 7];
const int g_sum = (b[1] >> 3) + kDelta[b[1] & 7];
const int b_sum = (b[2] >> 3) + kDelta[b[2] & 7];
const bool individual = !punchthrough && !diff_or_opaque;
if (!individual && (r_sum < 0 || r_sum > 31 || g_sum < 0 || g_sum > 31)) {
std::array<std::array<int, 3>, 4> paint{};
if (r_sum < 0 || r_sum > 31) {
const std::array<int, 3> c1 = {
Expand4((((b[0] >> 3) & 3) << 2) | (b[0] & 3)),
Expand4(b[1] >> 4), Expand4(b[1] & 15)};
const std::array<int, 3> c2 = {Expand4(b[2] >> 4), Expand4(b[2] & 15),
Expand4(b[3] >> 4)};
const int d = kDistance[(((b[3] >> 2) & 3) << 1) | (b[3] & 1)];
for (int c = 0; c < 3; ++c) {
paint[0][c] = c1[c];
paint[1][c] = c2[c] + d;
paint[2][c] = c2[c];
paint[3][c] = c2[c] - d;
}
} else {
const std::array<int, 3> c1 = {
Expand4((b[0] >> 3) & 15),
Expand4(((b[0] & 7) << 1) | ((b[1] >> 4) & 1)),
Expand4((b[1] & 8) | ((b[1] & 3) << 1) | (b[2] >> 7))};
const std::array<int, 3> c2 = {
Expand4((b[2] >> 3) & 15), Expand4(((b[2] & 7) << 1) | (b[3] >> 7)),
Expand4((b[3] >> 3) & 15)};
const int v1 = (c1[0] << 16) | (c1[1] << 8) | c1[2];
const int v2 = (c2[0] << 16) | (c2[1] << 8) | c2[2];
const int d =
kDistance[(b[3] & 4) | ((b[3] & 1) << 1) | (v1 >= v2 ? 1 : 0)];
for (int c = 0; c < 3; ++c) {
paint[0][c] = c1[c] + d;
paint[1][c] = c1[c] - d;
paint[2][c] = c2[c] + d;
paint[3][c] = c2[c] - d;
}
}
for (int texel = 0; texel < 16; ++texel) {
const int index = index_of(texel);
if (!opaque && index == 2) {
write(texel, 0, 0, 0, 0);
} else {
write(texel, paint[index][0], paint[index][1], paint[index][2], 255);
}
}
return;
}
if (!individual && (b_sum < 0 || b_sum > 31)) {
const int ro = Expand6((b[0] >> 1) & 63);
const int go = Expand7(((b[0] & 1) << 6) | ((b[1] >> 1) & 63));
const int bo = Expand6(((b[1] & 1) << 5) | (b[2] & 0x18) |
((b[2] & 3) << 1) | (b[3] >> 7));
const int rh = Expand6(((b[3] & 0x7c) >> 1) | (b[3] & 1));
const int gh = Expand7(b[4] >> 1);
const int bh = Expand6(((b[4] & 1) << 5) | (b[5] >> 3));
const int rv = Expand6(((b[5] & 7) << 3) | (b[6] >> 5));
const int gv = Expand7(((b[6] & 31) << 2) | (b[7] >> 6));
const int bv = Expand6(b[7] & 63);
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
write(x * 4 + y, (x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
}
}
return;
}
std::array<std::array<int, 3>, 2> base{};
for (int c = 0; c < 3; ++c) {
if (individual) {
base[0][c] = Expand4(b[c] >> 4);
base[1][c] = Expand4(b[c] & 15);
} else {
base[0][c] = Expand5(b[c] >> 3);
base[1][c] = Expand5((b[c] >> 3) + kDelta[b[c] & 7]);
}
}
const int tables[2] = {b[3] >> 5, (b[3] >> 2) & 7};
const bool flipped = (b[3] & 1) != 0;
for (int x = 0; x < 4; ++x) {
for (int y = 0; y < 4; ++y) {
const int texel = x * 4 + y;
const int index = index_of(texel);
if (!opaque && index == 2) {
write(texel, 0, 0, 0, 0);
continue;
}
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
const int modifier =
(!opaque && index == 0) ? 0 : kEtc1Modifiers[tables[sub]][index];
write(texel, base[sub][0] + modifier, base[sub][1] + modifier,
base[sub][2] + modifier, 255);
}
}
}
inline int EacIndex(const std::uint8_t* b, int texel) {
std::uint64_t bits = 0;
for (int i = 2; i < 8; ++i) {
bits = (bits << 8) | b[i];
}
return static_cast<int>((bits >> (45 - texel * 3)) & 7);
}
inline int EacModifier(const std::uint8_t* b, int texel) {
return kEacModifiers[b[1] & 15][EacIndex(b, texel)];
}
inline std::uint16_t DecodeR11(const std::uint8_t* b, int texel, bool is_signed) {
const int multiplier = b[1] >> 4;
const int modifier = EacModifier(b, texel);
const int scaled = multiplier != 0 ? modifier * multiplier * 8 : modifier;
if (!is_signed) {
const int value = std::clamp(b[0] * 8 + 4 + scaled, 0, 2047);
return static_cast<std::uint16_t>((value << 5) | (value >> 6));
}
const int base = std::max(-127, static_cast<int>(static_cast<std::int8_t>(b[0])));
const int value = std::clamp(base * 8 + scaled, -1023, 1023);
const int magnitude = value < 0 ? -value : value;
const int expanded = (magnitude << 5) | (magnitude >> 5);
return static_cast<std::uint16_t>(
static_cast<std::int16_t>(value < 0 ? -expanded : expanded));
}
inline void DecodeReference(EtcFormat format, const std::uint8_t* source,
std::uint32_t width, std::uint32_t height,
std::uint8_t* destination) {
const std::size_t texel_bytes = EtcDecodedTexelBytes(format);
const std::size_t block_bytes = EtcBlockBytes(format);
const std::uint64_t blocks_wide = (static_cast<std::uint64_t>(width) + 3) / 4;
const std::uint64_t blocks_high = (static_cast<std::uint64_t>(height) + 3) / 4;
std::array<std::uint8_t, 64> rgba{};
for (std::uint64_t by = 0; by < blocks_high; ++by) {
for (std::uint64_t bx = 0; bx < blocks_wide; ++bx) {
const std::uint8_t* block = source + (by * blocks_wide + bx) * block_bytes;
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, &rgba);
} else if (format == EtcFormat::kEtc2Rgba8) {
DecodeRgbBlock(block + 8, false, &rgba);
}
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
const int texel = static_cast<int>(x * 4 + y);
std::uint8_t* out = destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
switch (format) {
case EtcFormat::kEtc2Rgb8:
case EtcFormat::kEtc2Rgb8A1:
case EtcFormat::kEtc2Rgba8:
std::copy_n(rgba.data() + texel * 4, 4, out);
if (format == EtcFormat::kEtc2Rgba8) {
const int alpha = block[0] + EacModifier(block, texel) * (block[1] >> 4);
out[3] = Clamp255(alpha);
}
break;
case EtcFormat::kEacR11:
case EtcFormat::kEacR11Signed:
case EtcFormat::kEacRg11:
case EtcFormat::kEacRg11Signed: {
const bool is_signed = format == EtcFormat::kEacR11Signed || format == EtcFormat::kEacRg11Signed;
const int channels = static_cast<int>(texel_bytes / 2);
for (int channel = 0; channel < channels; ++channel) {
const std::uint16_t value = DecodeR11(block + channel * 8, texel, is_signed);
out[channel * 2] = static_cast<std::uint8_t>(value);
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
}
break;
}
}
}
}
}
}
}
} // namespace reference
TEST(Etc2DecoderTest, ComparesBitExactAgainstScalarReferenceAcrossAllFormats) {
const EtcFormat formats[] = {
EtcFormat::kEtc2Rgb8,
EtcFormat::kEtc2Rgb8A1,
EtcFormat::kEtc2Rgba8,
EtcFormat::kEacR11,
EtcFormat::kEacR11Signed,
EtcFormat::kEacRg11,
EtcFormat::kEacRg11Signed,
};
const struct {
std::uint32_t width;
std::uint32_t height;
} sizes[] = {
{1, 1},
{4, 4},
{7, 5},
{16, 16},
{67, 59},
{128, 128},
};
for (std::size_t f = 0; f < std::size(formats); ++f) {
const EtcFormat format = formats[f];
for (std::size_t s = 0; s < std::size(sizes); ++s) {
const std::uint32_t width = sizes[s].width;
const std::uint32_t height = sizes[s].height;
const auto source = RandomBlocks(
format, width, height,
static_cast<std::uint32_t>(0xABCD1234 + f * 100 + s));
const auto actual = Decode(format, source, width, height);
std::vector<std::uint8_t> expected(actual.size(), 0);
reference::DecodeReference(format, source.data(), width, height,
expected.data());
EXPECT_EQ(actual, expected)
<< "Mismatch in format=" << static_cast<int>(format)
<< " width=" << width << " height=" << height;
}
}
}
} // namespace
} // namespace mocktail::graphics
+3 -1
View File
@@ -37,12 +37,14 @@ finish_args = set(manifest["finish-args"])
required_permissions = {
"--share=network",
"--socket=wayland",
"--socket=fallback-x11",
"--socket=x11",
"--socket=pulseaudio",
"--device=dri",
"--filesystem=xdg-run/discord-ipc-0:rw",
}
assert required_permissions <= finish_args
# Keep NVIDIA's XWayland fallback available even when Wayland is present.
assert "--socket=fallback-x11" not in finish_args
assert not any(argument.startswith("--filesystem=host") for argument in finish_args)
assert not any(argument == "--filesystem=home" for argument in finish_args)
+215
View File
@@ -0,0 +1,215 @@
#include <gtest/gtest.h>
#include <array>
#include <atomic>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
#include "jnivm/jnivm.h"
namespace jnivm {
namespace {
class JniFieldsTest : public testing::Test {
protected:
void SetUp() override {
env = vm.GetJNIEnv();
clazz = env->FindClass("test/Fields");
}
std::string ReadString(jobject value) {
const auto string = static_cast<jstring>(value);
const char* bytes = env->GetStringUTFChars(string, nullptr);
const std::string result = bytes ? bytes : "";
if (bytes) env->ReleaseStringUTFChars(string, bytes);
return result;
}
VM vm;
JNIEnv* env = nullptr;
jclass clazz = nullptr;
};
TEST_F(JniFieldsTest, StaticFieldOwnsItsValueAfterLocalReferenceIsDeleted) {
const auto field =
env->GetStaticFieldID(clazz, "saved", "Ljava/lang/String;");
const auto value = env->NewStringUTF("retained");
env->SetStaticObjectField(clazz, field, value);
env->DeleteLocalRef(value);
const auto other = env->NewStringUTF("replacement");
const auto saved = env->GetStaticObjectField(clazz, field);
EXPECT_EQ(ReadString(saved), "retained");
env->DeleteLocalRef(saved);
env->DeleteLocalRef(other);
env->SetStaticObjectField(clazz, field, nullptr);
}
TEST_F(JniFieldsTest, ReadingStaticFieldReturnsAnIndependentLocalReference) {
const auto field = env->GetStaticFieldID(clazz, "read", "Ljava/lang/String;");
const auto value = env->NewStringUTF("retained");
env->SetStaticObjectField(clazz, field, value);
const auto first = env->GetStaticObjectField(clazz, field);
env->DeleteLocalRef(first);
const auto second = env->GetStaticObjectField(clazz, field);
EXPECT_EQ(ReadString(second), "retained");
env->SetStaticObjectField(clazz, field, nullptr);
EXPECT_EQ(ReadString(second), "retained");
env->DeleteLocalRef(second);
env->DeleteLocalRef(value);
}
TEST_F(JniFieldsTest, FieldIdsOwnTemporaryNames) {
auto object = env->AllocObject(clazz);
char name[] = "original";
const auto field = env->GetFieldID(clazz, name, "I");
const auto static_field =
env->GetStaticFieldID(clazz, name, "Ljava/lang/String;");
std::memcpy(name, "modified", sizeof(name));
env->SetIntField(object, field, 42);
EXPECT_EQ(env->GetIntField(object, env->GetFieldID(clazz, "original", "I")),
42);
const auto value = env->NewStringUTF("stable");
env->SetStaticObjectField(clazz, static_field, value);
const auto saved = env->GetStaticObjectField(
clazz, env->GetStaticFieldID(clazz, "original", "Ljava/lang/String;"));
EXPECT_EQ(ReadString(saved), "stable");
env->DeleteLocalRef(saved);
env->DeleteLocalRef(value);
env->SetStaticObjectField(clazz, static_field, nullptr);
env->DeleteLocalRef(object);
}
TEST_F(JniFieldsTest, ReclaimsObjectFieldReferencesAcrossManyAllocations) {
const auto field = env->GetFieldID(clazz, "child", "Ljava/lang/String;");
for (int iteration = 0; iteration < 110000; ++iteration) {
const auto parent = env->AllocObject(clazz);
const auto child = env->NewStringUTF("child");
ASSERT_NE(parent, nullptr) << iteration;
ASSERT_NE(child, nullptr) << iteration;
env->SetObjectField(parent, field, child);
env->DeleteLocalRef(child);
env->DeleteLocalRef(parent);
}
}
TEST_F(JniFieldsTest, ReleasingParentPreservesAnExplicitGlobalChildReference) {
const auto field = env->GetFieldID(clazz, "child", "Ljava/lang/String;");
const auto parent = env->AllocObject(clazz);
const auto child = env->NewStringUTF("survives");
const auto global = env->NewGlobalRef(child);
env->SetObjectField(parent, field, child);
env->DeleteLocalRef(child);
env->DeleteLocalRef(parent);
EXPECT_EQ(ReadString(global), "survives");
env->DeleteGlobalRef(global);
}
TEST_F(JniFieldsTest, InstanceGetterReturnsAnIndependentLocalReference) {
const auto field = env->GetFieldID(clazz, "child", "Ljava/lang/String;");
const auto parent = env->AllocObject(clazz);
const auto child = env->NewStringUTF("survives");
env->SetObjectField(parent, field, child);
env->DeleteLocalRef(child);
const auto result = env->CallObjectMethod(
parent, env->GetMethodID(clazz, "getChild", "()Ljava/lang/String;"));
env->DeleteLocalRef(parent);
EXPECT_EQ(ReadString(result), "survives");
env->DeleteLocalRef(result);
}
TEST_F(JniFieldsTest, StaticCallbackSetterOwnsItsImplementation) {
const auto interface =
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
const auto callback_class =
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
const auto callback = env->AllocObject(callback_class);
const auto marker = env->GetFieldID(callback_class, "marker", "I");
env->SetIntField(callback, marker, 73);
const auto setter =
env->GetStaticMethodID(interface, "setImplementation",
"(Lcom/roblox/engine/jni/EngineJavaCallback2;)V");
env->CallStaticVoidMethod(interface, setter, callback);
env->DeleteLocalRef(callback);
const auto other = env->AllocObject(callback_class);
env->SetIntField(other, marker, 99);
const auto getter =
env->GetStaticMethodID(interface, "getImplementation",
"()Lcom/roblox/engine/jni/EngineJavaCallback2;");
const auto saved = env->CallStaticObjectMethod(interface, getter);
EXPECT_EQ(env->GetIntField(saved, marker), 73);
env->DeleteLocalRef(saved);
env->DeleteLocalRef(other);
env->CallStaticVoidMethod(interface, setter, static_cast<jobject>(nullptr));
}
TEST_F(JniFieldsTest, ReclaimsObjectArrayReferencesAcrossManyAllocations) {
for (int iteration = 0; iteration < 110000; ++iteration) {
const auto child = env->NewStringUTF("child");
ASSERT_NE(child, nullptr) << iteration;
const auto array = env->NewObjectArray(2, clazz, child);
ASSERT_NE(array, nullptr);
env->DeleteLocalRef(child);
const auto element = env->GetObjectArrayElement(array, 1);
EXPECT_EQ(ReadString(element), "child");
env->DeleteLocalRef(element);
env->DeleteLocalRef(array);
}
}
TEST_F(JniFieldsTest, PopLocalFramePromotesOnlyOneResultReference) {
for (int iteration = 0; iteration < 110000; ++iteration) {
ASSERT_EQ(env->PushLocalFrame(3), JNI_OK);
const auto value = env->NewStringUTF("result");
ASSERT_NE(value, nullptr) << iteration;
env->NewLocalRef(value);
env->NewLocalRef(value);
const auto result = env->PopLocalFrame(value);
ASSERT_EQ(ReadString(result), "result");
env->DeleteLocalRef(result);
}
}
TEST_F(JniFieldsTest, ConcurrentPrimitiveFieldWritesPreserveAllValues) {
constexpr int kThreads = 4;
constexpr int kFields = 1000;
std::vector<std::array<jfieldID, 3>> ids;
for (int i = 0; i < kThreads * kFields; ++i) {
const std::string name = std::to_string(i);
ids.push_back({env->GetFieldID(clazz, (name + "i").c_str(), "I"),
env->GetFieldID(clazz, (name + "b").c_str(), "Z"),
env->GetFieldID(clazz, (name + "f").c_str(), "F")});
}
const auto local = env->AllocObject(clazz);
const auto object = env->NewGlobalRef(local);
env->DeleteLocalRef(local);
std::atomic<int> ready{0};
std::array<std::thread, kThreads> workers;
for (int thread = 0; thread < kThreads; ++thread) {
workers[thread] = std::thread([&, thread] {
JNIEnv* worker = nullptr;
const jint attached = vm.GetJavaVM()->AttachCurrentThread(
reinterpret_cast<void**>(&worker), nullptr);
ready.fetch_add(1);
while (ready.load() != kThreads) std::this_thread::yield();
ASSERT_EQ(attached, JNI_OK);
for (int i = thread * kFields; i < (thread + 1) * kFields; ++i) {
worker->SetIntField(object, ids[i][0], i);
worker->SetBooleanField(object, ids[i][1], JNI_TRUE);
worker->SetFloatField(object, ids[i][2], static_cast<float>(i));
}
EXPECT_EQ(vm.GetJavaVM()->DetachCurrentThread(), JNI_OK);
});
}
for (auto& worker : workers) worker.join();
for (int i = 0; i < kThreads * kFields; ++i) {
EXPECT_EQ(env->GetIntField(object, ids[i][0]), i);
EXPECT_EQ(env->GetBooleanField(object, ids[i][1]), JNI_TRUE);
EXPECT_EQ(env->GetFloatField(object, ids[i][2]), static_cast<float>(i));
}
env->DeleteGlobalRef(object);
}
} // namespace
} // namespace jnivm
+297 -3
View File
@@ -725,6 +725,27 @@ TEST_F(JniVmTest, FindClassReturnsRegisteredInstance) {
EXPECT_EQ(registered.get(), found.get());
}
TEST_F(JniVmTest, RepeatedClassLookupsReuseTheSameHandle) {
JNIEnv *env = vm_->GetJNIEnv();
jclass first = env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
ASSERT_NE(first, nullptr);
jclass string_class = env->FindClass("java/lang/String");
ASSERT_NE(string_class, nullptr);
// Exceeds the JNI segment capacity (100000); without interned class handles
// every lookup consumes a permanent slot and the table runs out here.
constexpr int kIterations = 120000;
for (int i = 0; i < kIterations; ++i) {
ASSERT_EQ(env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface"),
first);
jstring value = env->NewStringUTF("probe");
ASSERT_NE(value, nullptr);
jclass value_class = env->GetObjectClass(value);
ASSERT_EQ(value_class, string_class);
ASSERT_EQ(env->GetObjectClass(value), value_class);
env->DeleteLocalRef(value);
}
}
TEST_F(JniVmTest, RegisteredMethodIsFound) {
auto cls = vm_->RegisterClass("rbx/JNIRobloxSettings");
cls->RegisterMethod("nativeInitClientSettings", "()V",
@@ -1088,6 +1109,279 @@ TEST_F(JniVmTest, FloatArrayElementsAreMutable) {
env->ReleaseFloatArrayElements(array, elements, 0);
}
TEST_F(JniVmTest, ObjectArrayInitializerSurvivesLocalDeletionAndSlotReuse) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
jstring initial = env->NewStringUTF("original initializer");
ASSERT_NE(initial, nullptr);
jobjectArray array = env->NewObjectArray(1, string_class, initial);
ASSERT_NE(array, nullptr);
env->DeleteLocalRef(initial);
jstring replacement = env->NewStringUTF("new allocation");
ASSERT_NE(replacement, nullptr);
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
ASSERT_NE(element, nullptr);
EXPECT_NE(element, replacement);
EXPECT_EQ(ReadJavaString(env, element), "original initializer");
env->DeleteLocalRef(element);
env->DeleteLocalRef(array);
env->DeleteLocalRef(replacement);
env->DeleteLocalRef(string_class);
}
TEST_F(JniVmTest, ObjectArrayDeletionReleasesSetElementSlot) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
jobjectArray array = env->NewObjectArray(1, string_class, nullptr);
ASSERT_NE(array, nullptr);
jstring element = env->NewStringUTF("held by array");
ASSERT_NE(element, nullptr);
env->SetObjectArrayElement(array, 0, element);
env->DeleteLocalRef(element);
env->DeleteLocalRef(array);
// Only compare the saved handle value; never call JNI with a deleted ref.
jstring replacement = env->NewStringUTF("reuses released slot");
ASSERT_NE(replacement, nullptr);
EXPECT_EQ(replacement, element);
EXPECT_EQ(ReadJavaString(env, replacement), "reuses released slot");
env->DeleteLocalRef(replacement);
env->DeleteLocalRef(string_class);
}
TEST_F(JniVmTest, ObjectArrayInitializerSurvivesLocalFramePop) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
ASSERT_EQ(env->PushLocalFrame(4), JNI_OK);
jstring initial = env->NewStringUTF("frame initializer");
ASSERT_NE(initial, nullptr);
jobjectArray array = env->NewObjectArray(1, string_class, initial);
ASSERT_NE(array, nullptr);
array = static_cast<jobjectArray>(env->PopLocalFrame(array));
ASSERT_NE(array, nullptr);
jstring replacement = env->NewStringUTF("after frame pop");
ASSERT_NE(replacement, nullptr);
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
ASSERT_NE(element, nullptr);
EXPECT_NE(element, replacement);
EXPECT_EQ(ReadJavaString(env, element), "frame initializer");
env->DeleteLocalRef(element);
env->DeleteLocalRef(array);
jstring recycled = env->NewStringUTF("released frame initializer");
ASSERT_NE(recycled, nullptr);
EXPECT_EQ(recycled, initial);
env->DeleteLocalRef(recycled);
env->DeleteLocalRef(replacement);
env->DeleteLocalRef(string_class);
}
TEST_F(JniVmTest, ObjectArrayRepeatedInitializerOwnsEveryPosition) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
jstring initial = env->NewStringUTF("three retained positions");
ASSERT_NE(initial, nullptr);
jobjectArray array = env->NewObjectArray(3, string_class, initial);
ASSERT_NE(array, nullptr);
env->DeleteLocalRef(initial);
env->SetObjectArrayElement(array, 0, nullptr);
env->SetObjectArrayElement(array, 1, nullptr);
jstring replacement = env->NewStringUTF("last position still owns original");
ASSERT_NE(replacement, nullptr);
auto remaining = static_cast<jstring>(env->GetObjectArrayElement(array, 2));
ASSERT_NE(remaining, nullptr);
EXPECT_NE(remaining, replacement);
EXPECT_EQ(ReadJavaString(env, remaining), "three retained positions");
env->DeleteLocalRef(remaining);
env->SetObjectArrayElement(array, 2, nullptr);
jstring recycled = env->NewStringUTF("last position released");
ASSERT_NE(recycled, nullptr);
EXPECT_EQ(recycled, initial);
env->DeleteLocalRef(array);
EXPECT_EQ(ReadJavaString(env, recycled), "last position released");
env->DeleteLocalRef(recycled);
env->DeleteLocalRef(replacement);
env->DeleteLocalRef(string_class);
}
TEST_F(JniVmTest, ObjectArrayReplacementReleasesSameDifferentAndNullElements) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
jobjectArray array = env->NewObjectArray(1, string_class, nullptr);
ASSERT_NE(array, nullptr);
jstring first = env->NewStringUTF("first");
jstring second = env->NewStringUTF("second");
ASSERT_NE(first, nullptr);
ASSERT_NE(second, nullptr);
env->SetObjectArrayElement(array, 0, first);
env->SetObjectArrayElement(array, 0, first);
env->DeleteLocalRef(first);
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
ASSERT_NE(element, nullptr);
EXPECT_EQ(ReadJavaString(env, element), "first");
env->DeleteLocalRef(element);
env->SetObjectArrayElement(array, 0, second);
jstring first_reuse = env->NewStringUTF("first slot reused");
ASSERT_NE(first_reuse, nullptr);
EXPECT_EQ(first_reuse, first);
env->DeleteLocalRef(second);
element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
ASSERT_NE(element, nullptr);
EXPECT_EQ(ReadJavaString(env, element), "second");
env->DeleteLocalRef(element);
env->SetObjectArrayElement(array, 0, nullptr);
EXPECT_EQ(env->GetObjectArrayElement(array, 0), nullptr);
jstring second_reuse = env->NewStringUTF("second slot reused");
ASSERT_NE(second_reuse, nullptr);
EXPECT_EQ(second_reuse, second);
env->DeleteLocalRef(array);
env->DeleteLocalRef(first_reuse);
env->DeleteLocalRef(second_reuse);
env->DeleteLocalRef(string_class);
}
TEST_F(JniVmTest, ObjectArrayDeletionRecursivelyReleasesNestedElements) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
jclass object_class = env->FindClass("java/lang/Object");
jstring initial = env->NewStringUTF("nested string");
ASSERT_NE(initial, nullptr);
jobjectArray inner = env->NewObjectArray(1, string_class, initial);
ASSERT_NE(inner, nullptr);
jobjectArray outer = env->NewObjectArray(1, object_class, inner);
ASSERT_NE(outer, nullptr);
env->DeleteLocalRef(initial);
env->DeleteLocalRef(inner);
auto retained_inner =
static_cast<jobjectArray>(env->GetObjectArrayElement(outer, 0));
ASSERT_NE(retained_inner, nullptr);
auto element =
static_cast<jstring>(env->GetObjectArrayElement(retained_inner, 0));
ASSERT_NE(element, nullptr);
EXPECT_EQ(ReadJavaString(env, element), "nested string");
env->DeleteLocalRef(element);
env->DeleteLocalRef(retained_inner);
env->DeleteLocalRef(outer);
jstring recycled = env->NewStringUTF("recursive release completed");
ASSERT_NE(recycled, nullptr);
EXPECT_EQ(recycled, initial);
env->DeleteLocalRef(recycled);
env->DeleteLocalRef(string_class);
env->DeleteLocalRef(object_class);
}
TEST_F(JniVmTest, ObjectArrayNullAndEmptyArraysDoNotRetainInitializer) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
jstring initial = env->NewStringUTF("not stored in an empty array");
ASSERT_NE(initial, nullptr);
jobjectArray empty = env->NewObjectArray(0, string_class, initial);
jobjectArray nulls = env->NewObjectArray(3, string_class, nullptr);
ASSERT_NE(empty, nullptr);
ASSERT_NE(nulls, nullptr);
EXPECT_EQ(env->GetArrayLength(empty), 0);
EXPECT_EQ(env->GetArrayLength(nulls), 3);
for (jsize index = 0; index < 3; ++index) {
EXPECT_EQ(env->GetObjectArrayElement(nulls, index), nullptr);
env->SetObjectArrayElement(nulls, index, nullptr);
}
env->DeleteLocalRef(initial);
jstring recycled = env->NewStringUTF("empty initializer was released");
ASSERT_NE(recycled, nullptr);
EXPECT_EQ(recycled, initial);
env->DeleteLocalRef(empty);
env->DeleteLocalRef(nulls);
EXPECT_EQ(ReadJavaString(env, recycled), "empty initializer was released");
env->DeleteLocalRef(recycled);
env->DeleteLocalRef(string_class);
}
TEST_F(JniVmTest, ObjectArraySelfReferenceCanBeBrokenWithValidExternalReference) {
JNIEnv *env = vm_->GetJNIEnv();
jclass object_class = env->FindClass("java/lang/Object");
jobjectArray array = env->NewObjectArray(2, object_class, nullptr);
ASSERT_NE(array, nullptr);
jstring initial = env->NewStringUTF("owned beside self reference");
ASSERT_NE(initial, nullptr);
env->SetObjectArrayElement(array, 0, array);
env->SetObjectArrayElement(array, 1, initial);
auto external = static_cast<jobjectArray>(env->NewGlobalRef(array));
ASSERT_NE(external, nullptr);
env->DeleteLocalRef(array);
env->DeleteLocalRef(initial);
jobject self = env->GetObjectArrayElement(external, 0);
ASSERT_NE(self, nullptr);
EXPECT_EQ(env->IsSameObject(self, external), JNI_TRUE);
env->DeleteLocalRef(self);
env->SetObjectArrayElement(external, 0, nullptr);
auto element = static_cast<jstring>(env->GetObjectArrayElement(external, 1));
ASSERT_NE(element, nullptr);
EXPECT_EQ(ReadJavaString(env, element), "owned beside self reference");
env->DeleteLocalRef(element);
env->DeleteGlobalRef(external);
jstring recycled = env->NewStringUTF("broken cycle released its element");
ASSERT_NE(recycled, nullptr);
EXPECT_EQ(recycled, initial);
env->DeleteLocalRef(recycled);
env->DeleteLocalRef(object_class);
}
TEST_F(JniVmTest, ObjectArraySurvivesTemporaryVmTeardownAndEnvironmentReattachment) {
JNIEnv *env = vm_->GetJNIEnv();
jclass string_class = env->FindClass("java/lang/String");
jstring initial = env->NewStringUTF("surviving VM array");
ASSERT_NE(initial, nullptr);
jobjectArray array = env->NewObjectArray(1, string_class, initial);
ASSERT_NE(array, nullptr);
env->DeleteLocalRef(initial);
{
VM temporary;
ASSERT_NE(temporary.GetJNIEnv(), nullptr);
}
// Temporary VM teardown invalidates the thread's environment; reacquire it
// before invoking JNI on references owned by the surviving VM.
env = vm_->GetJNIEnv();
ASSERT_NE(env, nullptr);
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
ASSERT_NE(element, nullptr);
EXPECT_EQ(ReadJavaString(env, element), "surviving VM array");
env->DeleteLocalRef(element);
env->DeleteLocalRef(array);
jstring recycled = env->NewStringUTF("surviving array released");
ASSERT_NE(recycled, nullptr);
EXPECT_EQ(recycled, initial);
env->DeleteLocalRef(recycled);
env->DeleteLocalRef(string_class);
}
TEST_F(JniVmTest, ObjectArrayLiveAtProcessExitDoesNotReenterDestroyedTables) {
ASSERT_EXIT(
{
VM temporary;
JNIEnv *env = temporary.GetJNIEnv();
if (env == nullptr) std::_Exit(10);
jclass string_class = env->FindClass("java/lang/String");
jclass object_class = env->FindClass("java/lang/Object");
jstring initial = env->NewStringUTF("live at shutdown");
if (initial == nullptr) std::_Exit(11);
jobjectArray inner = env->NewObjectArray(3, string_class, initial);
if (inner == nullptr) std::_Exit(12);
jobjectArray outer = env->NewObjectArray(1, object_class, inner);
if (outer == nullptr) std::_Exit(13);
env->DeleteLocalRef(initial);
env->DeleteLocalRef(inner);
// Run global destructors with outer/inner/string still alive. Release
// of elements belongs to explicit reference release, not ~PseudoArray.
std::exit(0);
},
::testing::ExitedWithCode(0), "");
}
constexpr int kMoreThanHandleCapacity = 150000;
TEST_F(JniVmTest, RepeatedFindClassDoesNotExhaustHandles) {
@@ -2225,7 +2519,7 @@ TEST_F(JniVmTest, RobloxTextInputCapturesExactStaticDirectVAndACalls) {
CallStaticVoidMethodVForTest(env, cls, show, static_cast<jlong>(42), JNI_TRUE,
text, info);
EXPECT_EQ(probe->show_calls, 1);
EXPECT_EQ(probe->show_calls, 2);
jvalue show_args[4] = {};
show_args[0].j = 43;
@@ -2233,7 +2527,7 @@ TEST_F(JniVmTest, RobloxTextInputCapturesExactStaticDirectVAndACalls) {
show_args[2].l = text;
show_args[3].l = info;
env->CallStaticVoidMethodA(cls, show, show_args);
EXPECT_EQ(probe->show_calls, 2);
EXPECT_EQ(probe->show_calls, 3);
EXPECT_EQ(probe->request.text_box, 43);
jmethodID wrong_signature =
@@ -2243,7 +2537,7 @@ TEST_F(JniVmTest, RobloxTextInputCapturesExactStaticDirectVAndACalls) {
jclass wrong_cls = env->FindClass("example/NativeGLJavaInterface");
env->CallStaticVoidMethod(wrong_cls, show, static_cast<jlong>(44), JNI_FALSE,
text, info);
EXPECT_EQ(probe->show_calls, 2);
EXPECT_EQ(probe->show_calls, 3);
jstring replacement = env->NewStringUTF("engine replacement");
env->CallStaticVoidMethod(cls, replace, replacement);
File diff suppressed because it is too large. Load diff
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
# Copyright 2026 Mocktail Project Authors
# Licensed under the Apache License, Version 2.0.
"""Exercise the full legacy importer with synthetic APKs and failing renames.
APK/ELF inspection tools are fixtures; hashing, extraction, locking, metadata,
and filesystem operations use the real tools. No installed payload is touched.
"""
import json
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import zipfile
ROOT = Path(__file__).resolve().parents[1]
COMPONENTS = ('libroblox.so', 'sober_apk', 'assets', 'roblox_payload.json')
OLD_FILES = {
'libroblox.so': b'OLD-LIBRARY',
'sober_apk/base.apk': b'OLD-APK',
'sober_apk/split_config.x86_64.apk': b'OLD-SPLIT',
'assets/content/data.bin': b'OLD-ASSET',
'roblox_payload.json': b'{"old_metadata":true}\n',
}
TOOL = r'''
import json
import os
from pathlib import Path
import subprocess
import sys
tool = Path(sys.argv[0]).name
args = sys.argv[1:]
if tool == 'aapt':
old = Path(args[-1]).read_bytes().startswith(b'OLD-')
code, name = ('100', '1.0') if old else ('200', '2.0')
split = " split='config.x86_64'" if 'split_' in args[-1] else ''
print(f"package: name='com.roblox.client' versionCode='{code}' versionName='{name}'{split}")
elif tool == 'apksigner':
if '--print-certs' in args:
print('Signer #1 certificate SHA-256 digest: ' + 'a' * 64)
elif tool == 'file':
print(args[-1] + ': ELF 64-bit for Android')
elif tool == 'readelf':
if '-h' in args:
print(' Machine: Advanced Micro Devices X86-64')
elif '-n' in args:
old = Path(args[-1]).read_bytes().startswith(b'OLD-')
print('Build ID: ' + ('1' if old else '2') * 40)
else:
print('JNI_OnLoad')
for name in ('nativeGameGlobalInit', 'nativeUpdateAdapterInit',
'nativeAppBridgeV2InitWithParams', 'nativeAppBridgeStartLuaAppDM',
'nativeAppBridgeV2StartAppWithParams',
'nativeAppBridgeV2UpdateSurfaceAppWithPlatformParams'):
print('Java_com_roblox_engine_jni_NativeGLInterface_' + name)
elif tool == 'mv':
source, destination = map(Path, args[-2:])
phase = 'other'
component = source.name
if destination.parent.name == 'rollback':
phase = 'backup'
elif source.parent.name == 'new':
phase = 'activate'
elif destination.name.startswith('failed-'):
phase = 'discard'
component = 'roblox_payload.json' if destination.name == 'failed-metadata.json' else component
elif source.parent.name == 'rollback':
phase = 'restore'
elif source.name == 'rollback' and destination.parent.name == 'versions':
phase = 'archive'
with open(os.environ['NIGHTCAP_TEST_MOVES'], 'a') as log:
log.write(json.dumps([phase, component]) + '\n')
failures = json.loads(os.environ['NIGHTCAP_TEST_FAILURES'])
if phase == 'backup' and ['partial-backup', component] in failures:
if source.is_dir():
destination.mkdir()
(destination / 'partial').write_bytes(b'INCOMPLETE-COPY')
else:
destination.write_bytes(b'INCOMPLETE-COPY')
print(f'injected failure: partial-backup/{component}', file=sys.stderr)
sys.exit(74)
if [phase, component] in failures:
print(f'injected failure: {phase}/{component}', file=sys.stderr)
sys.exit(74)
sys.exit(subprocess.call([os.environ['NIGHTCAP_TEST_REAL_MV'], *args]))
else:
raise AssertionError(tool)
'''
class PayloadImportRollbackTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='nightcap-import-test-')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.project = self.root / 'project with spaces'
self.bin = self.root / 'bin'
self.moves = self.root / 'moves.jsonl'
for tool in ('bash', 'jq', 'unzip', 'flock', 'mv'):
self.assertIsNotNone(shutil.which(tool), f'required test tool missing: {tool}')
self.real_mv = shutil.which('mv')
(self.project / 'scripts').mkdir(parents=True)
(self.project / 'config').mkdir()
self.bin.mkdir()
for name in ('update_roblox_payload.sh', 'payload_integrity.sh'):
shutil.copy2(ROOT / 'scripts' / name, self.project / 'scripts' / name)
for name in ('aapt', 'apksigner', 'readelf', 'file', 'mv'):
path = self.bin / name
path.write_text(f'#!{sys.executable}\n' + TOOL)
path.chmod(0o755)
(self.project / 'config/roblox_signing_certificates.json').write_text(
json.dumps({'schema_version': 1, 'package': 'com.roblox.client',
'trusted_sha256': ['a' * 64]})
)
self.base = self.root / 'base.apk'
self.split = self.root / 'split_config.x86_64.apk'
with zipfile.ZipFile(self.base, 'w') as archive:
archive.writestr('assets/content/data.bin', b'NEW-ASSET')
with zipfile.ZipFile(self.split, 'w') as archive:
archive.writestr('lib/x86_64/libroblox.so', b'NEW-LIBRARY')
def active_path(self, component):
directory = 'config' if component == 'roblox_payload.json' else 'rbx_bin'
return self.project / directory / component
def create_previous(self, components=COMPONENTS):
shutil.rmtree(self.project / 'rbx_bin', ignore_errors=True)
self.active_path('roblox_payload.json').unlink(missing_ok=True)
for relative, content in OLD_FILES.items():
if relative.split('/')[0] not in components:
continue
path = self.active_path(relative.split('/')[0])
if '/' in relative:
path = path / relative.split('/', 1)[1]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
def contents(self, path):
if path.is_symlink():
return ('symlink', os.readlink(path))
if not path.exists():
return None
if path.is_file():
return path.read_bytes()
return {str(p.relative_to(path)): p.read_bytes()
for p in path.rglob('*') if p.is_file()}
def snapshot(self):
return {name: self.contents(self.active_path(name)) for name in COMPONENTS}
def run_import(self, failures=()):
self.moves.unlink(missing_ok=True)
env = {key: value for key, value in os.environ.items()
if not key.startswith(('MOCKTAIL_', 'NIGHTCAP_TEST_'))}
env.update(PATH=str(self.bin) + os.pathsep + env['PATH'],
NIGHTCAP_TEST_REAL_MV=self.real_mv,
NIGHTCAP_TEST_FAILURES=json.dumps(failures),
NIGHTCAP_TEST_MOVES=str(self.moves))
return subprocess.run(
['bash', str(self.project / 'scripts/update_roblox_payload.sh'),
'--base', str(self.base), '--x86-64', str(self.split)],
env=env, capture_output=True, text=True, timeout=20,
)
def backups(self):
return list((self.project / 'rbx_bin').glob('.payload-update.*'))
def assert_failed_and_restored(self, result, before, phase, component):
self.assertEqual(result.returncode, 74, result.stderr)
self.assertIn(f'injected failure: {phase}/{component}', result.stderr)
self.assertEqual(self.snapshot(), before)
self.assertEqual(self.backups(), [])
def test_backup_failures_preserve_every_previous_component(self):
for component in COMPONENTS:
with self.subTest(component=component):
self.create_previous()
before = self.snapshot()
result = self.run_import([['backup', component]])
self.assert_failed_and_restored(result, before, 'backup', component)
def test_activation_failures_restore_previous_components(self):
for component in COMPONENTS:
with self.subTest(component=component):
self.create_previous()
before = self.snapshot()
result = self.run_import([['activate', component]])
self.assert_failed_and_restored(result, before, 'activate', component)
def test_failed_first_install_removes_activated_components(self):
for component in COMPONENTS:
with self.subTest(component=component):
self.create_previous(())
before = self.snapshot()
result = self.run_import([['activate', component]])
self.assert_failed_and_restored(result, before, 'activate', component)
def test_sparse_previous_install_is_restored(self):
self.create_previous(('assets', 'roblox_payload.json'))
before = self.snapshot()
result = self.run_import([['activate', 'roblox_payload.json']])
self.assert_failed_and_restored(result, before, 'activate', 'roblox_payload.json')
def test_partial_backup_copy_does_not_replace_untouched_original(self):
self.create_previous()
before = self.snapshot()
result = self.run_import([['partial-backup', 'sober_apk']])
self.assert_failed_and_restored(result, before, 'partial-backup', 'sober_apk')
def test_restore_failure_retains_backup_and_restores_other_components(self):
for component in COMPONENTS:
with self.subTest(component=component):
self.create_previous()
before = self.snapshot()
result = self.run_import([['archive', 'rollback'], ['restore', component]])
self.assertEqual(result.returncode, 74, result.stderr)
self.assertIn(f'injected failure: restore/{component}', result.stderr)
workspaces = self.backups()
self.assertEqual(len(workspaces), 1)
workspace = workspaces[0]
self.assertIn(str(workspace), result.stderr)
self.assertEqual(self.contents(workspace / 'rollback' / component), before[component])
for other in COMPONENTS:
if other != component:
self.assertEqual(self.contents(self.active_path(other)), before[other])
# A failed recovery is intentionally retained by the importer.
shutil.rmtree(workspace)
def test_discard_failure_does_not_move_backup_into_active_directory(self):
for component in COMPONENTS:
with self.subTest(component=component):
self.create_previous()
before = self.snapshot()
result = self.run_import([['archive', 'rollback'], ['discard', component]])
self.assertEqual(result.returncode, 74, result.stderr)
self.assertIn(f'injected failure: discard/{component}', result.stderr)
workspaces = self.backups()
self.assertEqual(len(workspaces), 1)
workspace = workspaces[0]
self.assertIn(str(workspace), result.stderr)
self.assertEqual(self.contents(workspace / 'rollback' / component), before[component])
moves = [json.loads(line) for line in self.moves.read_text().splitlines()]
self.assertNotIn(['restore', component], moves)
for other in COMPONENTS:
if other != component:
self.assertEqual(self.contents(self.active_path(other)), before[other])
shutil.rmtree(workspace)
def test_successful_import_keeps_previous_archive_and_consistent_metadata(self):
self.create_previous()
before = self.snapshot()
result = self.run_import()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.active_path('libroblox.so').read_bytes(), b'NEW-LIBRARY')
self.assertEqual((self.active_path('assets') / 'content/data.bin').read_bytes(), b'NEW-ASSET')
self.assertEqual((self.active_path('sober_apk') / 'base.apk').read_bytes(), self.base.read_bytes())
metadata = json.loads(self.active_path('roblox_payload.json').read_text())
self.assertEqual(metadata['version_code'], 200)
self.assertEqual(metadata['elf_build_id'], '2' * 40)
self.assertEqual(metadata['compatibility_status'], 'unverified')
self.assertEqual(metadata['sha256']['libroblox'], hashlib.sha256(b'NEW-LIBRARY').hexdigest())
self.assertEqual(metadata['sha256']['base_apk'], hashlib.sha256(self.base.read_bytes()).hexdigest())
self.assertEqual(metadata['sha256']['x86_64_split_apk'], hashlib.sha256(self.split.read_bytes()).hexdigest())
archive = self.project / 'rbx_bin/versions' / ('1.0-' + '1' * 40)
for component in COMPONENTS:
self.assertEqual(self.contents(archive / component), before[component])
self.assertEqual(self.backups(), [])
def test_successful_first_install_has_no_previous_archive(self):
result = self.run_import()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(all(self.active_path(name).exists() for name in COMPONENTS))
self.assertEqual(list((self.project / 'rbx_bin/versions').iterdir()), [])
self.assertEqual(self.backups(), [])
def test_successful_sparse_install_archives_unversioned_components(self):
self.create_previous(('assets', 'roblox_payload.json'))
before = self.snapshot()
result = self.run_import()
self.assertEqual(result.returncode, 0, result.stderr)
archives = list((self.project / 'rbx_bin/versions').glob('unknown-*'))
self.assertEqual(len(archives), 1)
for component in COMPONENTS:
self.assertEqual(self.contents(archives[0] / component), before[component])
self.assertTrue(all(self.active_path(name).exists() for name in COMPONENTS))
self.assertEqual(self.backups(), [])
def test_successful_import_archives_dangling_asset_symlink(self):
self.create_previous()
shutil.rmtree(self.active_path('assets'))
self.active_path('assets').symlink_to('missing-assets')
result = self.run_import()
self.assertEqual(result.returncode, 0, result.stderr)
archive = self.project / 'rbx_bin/versions' / ('1.0-' + '1' * 40)
self.assertEqual(self.contents(archive / 'assets'), ('symlink', 'missing-assets'))
self.assertEqual((self.active_path('assets') / 'content/data.bin').read_bytes(), b'NEW-ASSET')
self.assertEqual(self.backups(), [])
if __name__ == '__main__':
unittest.main()
+60
View File
@@ -340,6 +340,66 @@ TEST(PlatformCacheMigrationTest, MissingRobloxThemeUsesCallerDefault) {
EXPECT_FALSE(std::filesystem::exists(fixture.app_storage));
}
TEST(PlatformCacheMigrationTest, UnknownAccountThemeUsesAuthenticatedTheme) {
TemporaryDirectory temporary;
FixturePaths fixture = PathsFor(temporary);
const nlohmann::json invalid_themes[] = {
"system", "", nullptr, false, 42, nlohmann::json::object()};
for (const auto& theme : invalid_themes) {
SCOPED_TRACE(theme.dump());
const nlohmann::json device_themes = {{"42", theme}, {"99", "dark"}};
ASSERT_TRUE(WriteJson(fixture.app_storage,
{{"DeviceLevelTheme", device_themes.dump()},
{"AuthenticatedTheme", "light"},
{"UnrelatedPreference", "keep"}}));
const std::string original = ReadFile(fixture.app_storage);
const auto result = ReadRobloxThemeCache(fixture.app_storage, 42);
ASSERT_TRUE(result) << result.error;
EXPECT_EQ(result.dark_theme, std::optional<bool>(false));
EXPECT_EQ(ReadFile(fixture.app_storage), original);
}
}
TEST(PlatformCacheMigrationTest, MalformedDeviceThemesUseAuthenticatedTheme) {
TemporaryDirectory temporary;
FixturePaths fixture = PathsFor(temporary);
const nlohmann::json invalid_caches[] = {
"{broken", "[]", "null", 42, nullptr, nlohmann::json::object()};
for (const auto& cache : invalid_caches) {
SCOPED_TRACE(cache.dump());
ASSERT_TRUE(WriteJson(fixture.app_storage,
{{"DeviceLevelTheme", cache},
{"AuthenticatedTheme", "dark"}}));
const std::string original = ReadFile(fixture.app_storage);
const auto result = ReadRobloxThemeCache(fixture.app_storage, 42);
ASSERT_TRUE(result) << result.error;
EXPECT_EQ(result.dark_theme, std::optional<bool>(true));
EXPECT_EQ(ReadFile(fixture.app_storage), original);
}
}
TEST(PlatformCacheMigrationTest, UnknownThemesUseCallerDefault) {
TemporaryDirectory temporary;
FixturePaths fixture = PathsFor(temporary);
const nlohmann::json device_themes = {{"42", "system"}};
const nlohmann::json invalid_themes[] = {
"future-theme", "", nullptr, true, 42, nlohmann::json::array()};
for (const auto& theme : invalid_themes) {
SCOPED_TRACE(theme.dump());
ASSERT_TRUE(WriteJson(fixture.app_storage,
{{"DeviceLevelTheme", device_themes.dump()},
{"AuthenticatedTheme", theme}}));
const std::string original = ReadFile(fixture.app_storage);
const auto result = ReadRobloxThemeCache(fixture.app_storage, 42);
ASSERT_TRUE(result) << result.error;
EXPECT_FALSE(result.dark_theme.has_value());
EXPECT_EQ(ReadFile(fixture.app_storage), original);
}
}
} // namespace
} // namespace runtime
} // namespace mocktail
@@ -76,6 +76,8 @@ TEST(AngleProbeTest, RequiresExplicitPinnedLibraryPaths) {
auto capability = graphics::ProbeAngleVulkan({});
EXPECT_EQ(capability.state, CapabilityState::kUnavailable);
EXPECT_NE(capability.detail.find("required"), std::string::npos);
EXPECT_EQ(graphics::InspectAngleLibraries({}).state,
CapabilityState::kUnavailable);
}
TEST(AngleProbeTest, ValidatesConfiguredAngleVulkanDistribution) {
@@ -88,6 +90,8 @@ TEST(AngleProbeTest, ValidatesConfiguredAngleVulkanDistribution) {
graphics::AngleProbeOptions options;
options.egl_library_path = egl;
options.gles_library_path = gles;
const auto inspected = graphics::InspectAngleLibraries(options);
ASSERT_EQ(inspected.state, CapabilityState::kLoadable) << inspected.detail;
auto capability = graphics::ProbeAngleVulkan(options);
EXPECT_EQ(capability.state, CapabilityState::kReady) << capability.detail;
EXPECT_EQ(capability.acceleration, HardwareAcceleration::kHardware);
+39 -14
View File
@@ -308,7 +308,9 @@ RobloxAccountProtocolSymbols AccountSymbols() {
SetAccountHandler, ClearAccountHandler};
}
void ClearNativeRequestHandler(JNIEnv*, jobject, jstring, jstring) {}
void PublishRaw(JNIEnv*, jobject, jstring, jstring) {}
void BroadcastDataModelFocus(JNIEnv*, jclass, jstring, jstring, jstring) {}
jstring GetLaunchId(JNIEnv* env, jclass) {
@@ -399,14 +401,17 @@ jobject Subscribe(JNIEnv* env, jobject, jstring id, jobject callback,
}
void Disconnect(JNIEnv*, jobject, jlong handle) {
if (handle == 91) ++g_probe->disconnects;
if (handle == 91)
++g_probe->disconnects;
}
void Foreground(JNIEnv* env, jclass, jboolean enabled, jstring reason) {
if (reason == nullptr) return;
if (reason == nullptr)
return;
const char* chars = env->GetStringUTFChars(reason, nullptr);
const std::string value = chars != nullptr ? chars : "";
if (chars != nullptr) env->ReleaseStringUTFChars(reason, chars);
if (chars != nullptr)
env->ReleaseStringUTFChars(reason, chars);
if (value == "ASMA.stop" || value == "ASMA.start") {
g_probe->app_surface_lifecycle.push_back(
std::string(enabled == JNI_TRUE ? "background:" : "foreground:") +
@@ -437,7 +442,10 @@ jint Start(JNIEnv* env, jclass, jobject params) {
return 1;
}
void Update(JNIEnv*, jclass, jobject, jobject, jobject) { ++g_probe->updates; }
void Update(JNIEnv*, jclass, jobject, jobject, jobject) {
++g_probe->updates;
}
void UpdateApp(JNIEnv* env, jclass, jobject surface, jobject platform_params) {
++g_probe->app_updates;
jclass surface_class = env->GetObjectClass(surface);
@@ -449,15 +457,25 @@ void UpdateApp(JNIEnv* env, jclass, jobject surface, jobject platform_params) {
env->DeleteLocalRef(surface_class);
env->DeleteLocalRef(params_class);
}
void PauseGame(JNIEnv*, jclass) {}
void CallMessagesFromMainThread(JNIEnv*, jclass) {}
void ResumeGame(JNIEnv*, jclass, jobject, jobject, jobject) {}
void Leave(JNIEnv*, jclass) { ++g_probe->leaves; }
void Leave(JNIEnv*, jclass) {
++g_probe->leaves;
}
void PauseApp(JNIEnv*, jclass) {
++g_probe->app_pauses;
g_probe->app_surface_lifecycle.emplace_back("pause-app");
}
void DestroyApp(JNIEnv*, jclass) { ++g_probe->destroys; }
void DestroyApp(JNIEnv*, jclass) {
++g_probe->destroys;
}
void StartApp(JNIEnv* env, jclass, jobject params) {
++g_probe->app_starts;
@@ -936,8 +954,9 @@ TEST(RobloxExperienceCompositionTest,
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
EXPECT_FALSE(composition.subscribed());
EXPECT_EQ(probe.permission_subscriptions, 5);
EXPECT_EQ(probe.permission_handlers.size(), 5u);
EXPECT_EQ(probe.permission_subscriptions, 6);
EXPECT_EQ(probe.permission_handlers.size(), 6u);
EXPECT_EQ(probe.permission_handlers.count("getCallState"), 1u);
EXPECT_EQ(QueryMicrophone(&probe).at("status"), "DENIED");
EXPECT_EQ(probe.web_view_initializations, 1);
EXPECT_EQ(probe.browser_bindings, 4);
@@ -946,8 +965,8 @@ TEST(RobloxExperienceCompositionTest,
EXPECT_EQ(probe.browser_releases, 0);
EXPECT_EQ(probe.browser_callbacks_cleared, 0);
EXPECT_TRUE(composition.Shutdown().ok());
EXPECT_EQ(probe.permission_disconnects, 5);
EXPECT_EQ(probe.permission_handlers_cleared, 5);
EXPECT_EQ(probe.permission_disconnects, 6);
EXPECT_EQ(probe.permission_handlers_cleared, 6);
EXPECT_EQ(probe.disconnects, 3);
EXPECT_EQ(probe.browser_disconnects, 4);
EXPECT_EQ(probe.browser_releases, 4);
@@ -1110,7 +1129,8 @@ class RobloxExperienceCompositionWebSurfaceTest : public ::testing::Test {
explicit Helper(const char* before_ready = "") {
char pattern[] = "/tmp/mocktail_surface_close_XXXXXX";
const char* created = mkdtemp(pattern);
if (created == nullptr) return;
if (created == nullptr)
return;
directory = created;
path = directory / "helper";
std::ofstream output(path);
@@ -1127,13 +1147,18 @@ while True:
break
)PY";
output.close();
if (!output.good() || chmod(path.c_str(), 0700) != 0) path.clear();
if (!output.good() || chmod(path.c_str(), 0700) != 0)
path.clear();
}
~Helper() {
if (process != nullptr) (void)process->RequestClose();
if (process != nullptr)
(void)process->RequestClose();
std::error_code error;
if (!directory.empty()) std::filesystem::remove_all(directory, error);
if (!directory.empty())
std::filesystem::remove_all(directory, error);
}
std::filesystem::path directory;
std::filesystem::path path;
std::shared_ptr<WebViewHelperProcess> process;
+41
View File
@@ -698,6 +698,47 @@ TEST_F(RobloxInputRouterTest, ReportsHostTextFocusCompletion) {
EXPECT_EQ(router_.Snapshot().text_focus_generation, 4U);
}
TEST_F(RobloxInputRouterTest, MouseClicksKeepTextFocusUntilEngineReleasesIt) {
RobloxTextFocusSession session{42, 1, "draft", false, false};
session.area_x = 40;
session.area_y = 300;
session.area_width = 400;
session.area_height = 30;
ASSERT_TRUE(router_.BeginTextFocusSession(session).ok());
for (const auto button : {SDL_BUTTON_LEFT, SDL_BUTTON_MIDDLE,
SDL_BUTTON_RIGHT}) {
SCOPED_TRACE(button);
for (const bool pressed : {true, false}) {
ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{
pressed, static_cast<uint8_t>(button), 1, 100.0F, 310.0F}))
.dispatched());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 1U);
}
}
EXPECT_TRUE(probe_.text_operations.empty());
EXPECT_EQ(probe_.mouse_buttons.size(), 6U);
EXPECT_TRUE(router_.HandleEvent(Event(platform::TextInputEvent{" next"}))
.dispatched());
EXPECT_EQ(probe_.text_operations,
(std::vector<std::string>{"sync", "pass"}));
ASSERT_TRUE(router_.EndTextFocusSession(42, 1, false).ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 0U);
EXPECT_FALSE(router_.HandleEvent(Event(platform::TextInputEvent{"late"}))
.dispatched());
}
TEST_F(RobloxInputRouterTest, MouseClickRespectsManualTextFocusRelease) {
ASSERT_TRUE(router_.BeginTextFocusSession({42, 1, "draft", true, false}).ok());
ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_LEFT, 1, 900.0F, 600.0F})).dispatched());
EXPECT_TRUE(probe_.text_operations.empty());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 1U);
EXPECT_TRUE(router_.HandleEvent(Event(platform::TextInputEvent{" next"}))
.dispatched());
}
TEST(RobloxInputRouterClipboardTest,
CtrlVPastesWithoutForwardingTheLetterKeyToRoblox) {
Probe probe;
+75 -15
View File
@@ -14,6 +14,7 @@
#include <vector>
#include "jnivm/jnivm.h"
#include "runtime/roblox_call_protocol_bridge.h"
namespace mocktail {
namespace runtime {
@@ -23,15 +24,18 @@ using Json = nlohmann::json;
constexpr char kRequest[] = R"({"permissions":["MICROPHONE_ACCESS"]})";
std::string Copy(JNIEnv* env, jstring value) {
if (!value) return {};
if (!value)
return {};
const char* chars = env->GetStringUTFChars(value, nullptr);
const std::string copy = chars ? chars : "";
if (chars) env->ReleaseStringUTFChars(value, chars);
if (chars)
env->ReleaseStringUTFChars(value, chars);
return copy;
}
struct Probe {
jnivm::VM* vm = nullptr;
std::string expected_protocol = "PermissionsProtocol";
std::map<std::string, jobject> legacy;
std::map<std::string, jobject> handlers;
int callbacks_created = 0;
@@ -50,11 +54,13 @@ struct Probe {
bool block_publish = false;
bool publish_entered = false;
bool release_publish = false;
struct Published {
std::string method;
Json body;
jint code;
};
std::vector<Published> published;
std::vector<std::pair<std::string, Json>> resolved;
};
@@ -68,7 +74,8 @@ void Prepare(void* context) {
jobject CreateRaw(void* context, std::shared_ptr<void> target,
void (*run)(void*, JNIEnv*, jstring)) {
auto* probe = static_cast<Probe*>(context);
if (++probe->callbacks_created == probe->fail_callback_at) return nullptr;
if (++probe->callbacks_created == probe->fail_callback_at)
return nullptr;
return probe->vm->CreateMessageBusRawCallback(
std::move(target), jnivm::MessageBusRawCallbacks{run});
}
@@ -113,19 +120,22 @@ void InvokeAsync(JNIEnv* env, jobject handler, const char* payload,
args[0].l = message;
args[1].l = id;
env->CallVoidMethodA(handler, run, args);
if (message) env->DeleteLocalRef(message);
if (message)
env->DeleteLocalRef(message);
env->DeleteLocalRef(id);
env->DeleteLocalRef(cls);
}
jobject Subscribe(JNIEnv* env, jobject, jstring protocol, jstring method,
jobject callback, jboolean once) {
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
EXPECT_EQ(once, JNI_FALSE);
const int handle = ++g_probe->subscriptions;
if (handle == g_probe->fail_subscription_at) return nullptr;
if (handle == g_probe->fail_subscription_at)
return nullptr;
g_probe->legacy[Copy(env, method)] = callback;
if (g_probe->call_during_registration) InvokeLegacy(env, callback, kRequest);
if (g_probe->call_during_registration)
InvokeLegacy(env, callback, kRequest);
jclass cls = env->FindClass("com/roblox/universalapp/messagebus/Connection");
jmethodID ctor = env->GetMethodID(cls, "<init>", "(J)V");
jobject connection = env->NewObject(cls, ctor, static_cast<jlong>(handle));
@@ -140,18 +150,18 @@ void Disconnect(JNIEnv*, jobject, jlong handle) {
void SetHandler(JNIEnv* env, jobject, jstring protocol, jstring method,
jobject handler) {
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
g_probe->handlers[Copy(env, method)] = handler;
}
void ClearHandler(JNIEnv* env, jobject, jstring protocol, jstring) {
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
++g_probe->native_cleared;
}
void Publish(JNIEnv* env, jobject, jstring protocol, jstring method,
jstring response, jint code, jstring telemetry) {
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
EXPECT_EQ(Copy(env, telemetry), "{}");
{
std::unique_lock<std::mutex> lock(g_probe->mutex);
@@ -160,7 +170,9 @@ void Publish(JNIEnv* env, jobject, jstring protocol, jstring method,
if (g_probe->block_publish) {
g_probe->publish_entered = true;
g_probe->changed.notify_all();
g_probe->changed.wait(lock, [] { return g_probe->release_publish; });
g_probe->changed.wait(lock, [] {
return g_probe->release_publish;
});
}
}
if (g_probe->reenter) {
@@ -185,11 +197,14 @@ class RobloxPermissionsBridgeTest : public testing::Test {
bus = env->AllocObject(cls);
env->DeleteLocalRef(cls);
}
void TearDown() override {
call_bridge.reset();
bridge.reset();
env->DeleteLocalRef(bus);
g_probe = nullptr;
}
Status Initialize(bool enabled = true) {
bridge = std::make_unique<RobloxPermissionsBridge>(
JniEnvironmentProvider{vm.GetJavaVM(), &vm, Prepare},
@@ -200,10 +215,23 @@ class RobloxPermissionsBridgeTest : public testing::Test {
enabled);
return bridge->Initialize();
}
Status InitializeCall() {
probe.expected_protocol = "Call";
call_bridge = std::make_unique<RobloxCallProtocolBridge>(
JniEnvironmentProvider{vm.GetJavaVM(), &vm, Prepare},
RobloxMessageBusSymbols{Subscribe, Disconnect, Publish, SetHandler,
ClearHandler, Resolve},
RobloxMessageBusObjects{bus, &probe, CreateRaw, ClearRaw, CreateAsync,
ClearAsync});
return call_bridge->Initialize();
}
Probe::Published Legacy(const char* method, const char* payload = kRequest) {
InvokeLegacy(env, probe.legacy.at(method), payload);
return probe.published.back();
}
Json Async(const char* method, const char* payload = kRequest,
const char* id = "request-1") {
InvokeAsync(env, probe.handlers.at(method), payload, id);
@@ -215,8 +243,38 @@ class RobloxPermissionsBridgeTest : public testing::Test {
JNIEnv* env = nullptr;
jobject bus = nullptr;
std::unique_ptr<RobloxPermissionsBridge> bridge;
std::unique_ptr<RobloxCallProtocolBridge> call_bridge;
};
TEST_F(RobloxPermissionsBridgeTest,
CallStateResolvesBothTransportsWithoutStartingCall) {
ASSERT_TRUE(InitializeCall().ok());
ASSERT_EQ(probe.legacy.size(), 1u);
ASSERT_EQ(probe.handlers.size(), 1u);
const auto legacy = Legacy("getCallState", "{}");
EXPECT_EQ(legacy.code, 0);
EXPECT_EQ(legacy.body["status"], "Idle");
EXPECT_EQ(legacy.body["muted"], true);
EXPECT_EQ(legacy.body["camEnabled"], false);
const auto asynchronous = Async("getCallState", "{}", "voice-init-1");
EXPECT_EQ(asynchronous, legacy.body);
ASSERT_EQ(probe.resolved.size(), 1u);
EXPECT_EQ(probe.resolved[0].first, "voice-init-1");
}
TEST_F(RobloxPermissionsBridgeTest, CallStateShutdownDisablesStaleCallbacks) {
ASSERT_TRUE(InitializeCall().ok());
jobject handler = env->NewGlobalRef(probe.handlers.at("getCallState"));
ASSERT_TRUE(call_bridge->Shutdown().ok());
InvokeAsync(env, handler, "{}", "stale");
EXPECT_TRUE(probe.published.empty());
EXPECT_TRUE(probe.resolved.empty());
EXPECT_EQ(probe.disconnected, 1);
EXPECT_EQ(probe.native_cleared, 1);
EXPECT_EQ(probe.handlers_cleared, 1);
env->DeleteGlobalRef(handler);
}
TEST_F(RobloxPermissionsBridgeTest, RegistersBothTransportsAndReinitializes) {
ASSERT_TRUE(Initialize().ok());
EXPECT_EQ(probe.legacy.size(), 5u);
@@ -395,12 +453,14 @@ TEST_F(RobloxPermissionsBridgeTest,
bool entered;
{
std::unique_lock<std::mutex> lock(probe.mutex);
entered = probe.changed.wait_for(lock, std::chrono::seconds(2),
[&] { return probe.publish_entered; });
entered = probe.changed.wait_for(lock, std::chrono::seconds(2), [&] {
return probe.publish_entered;
});
}
EXPECT_TRUE(entered);
auto shutdown =
std::async(std::launch::async, [&] { return bridge->Shutdown(); });
auto shutdown = std::async(std::launch::async, [&] {
return bridge->Shutdown();
});
EXPECT_EQ(shutdown.wait_for(std::chrono::milliseconds(30)),
std::future_status::timeout);
{
@@ -281,6 +281,51 @@ TEST(RobloxTextInputJniBridgeTest,
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
IdenticalPendingShowsOpenOnlyOneFocusSession) {
jnivm::VM vm;
auto backend = std::make_shared<FakeBackend>();
std::unique_ptr<RobloxTextInputJniBridge> bridge;
ASSERT_TRUE(
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
const auto request = ShowRequest(42, "draft");
for (int i = 0; i < 80; ++i) {
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
}
ASSERT_TRUE(backend->Pump());
EXPECT_EQ(backend->calls, (std::vector<std::string>{"begin:1", "show:1"}));
EXPECT_TRUE(backend->active);
EXPECT_EQ(backend->last_initial_text, "draft");
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
SameTextBoxCanReopenAfterHostCompletionWithoutNativeHide) {
for (const bool pump_completion_first : {false, true}) {
SCOPED_TRACE(pump_completion_first);
jnivm::VM vm;
auto backend = std::make_shared<FakeBackend>();
std::unique_ptr<RobloxTextInputJniBridge> bridge;
ASSERT_TRUE(
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
const auto request = ShowRequest(42, "draft");
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
ASSERT_TRUE(backend->Pump());
backend->active = false;
if (pump_completion_first) {
ASSERT_TRUE(backend->Pump());
}
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
ASSERT_TRUE(backend->Pump());
EXPECT_TRUE(backend->active);
EXPECT_EQ(backend->active_handle, 42);
EXPECT_EQ(backend->active_generation, 2U);
EXPECT_TRUE(bridge->Shutdown().ok());
}
}
TEST(RobloxTextInputJniBridgeTest,
PendingReplaceCallbacksCollapseToLatestTextInCurrentGeneration) {
jnivm::VM vm;
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
set -Eeuo pipefail
readonly ROOT="${1:?source root is required}"
readonly TEST_ROOT="$(mktemp -d)"
trap 'rm -rf -- "${TEST_ROOT}"' EXIT
source "${ROOT}/scripts/package_standalone_webkit.sh"
GDK_PIXBUF_SOURCE="${TEST_ROOT}/source"
STAGING="${TEST_ROOT}/staging"
readonly CACHE_REL=lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders.cache
mkdir -p -- "${GDK_PIXBUF_SOURCE}" "${STAGING}/${CACHE_REL%/*}"
cat >"${GDK_PIXBUF_SOURCE}/loaders.cache" <<'EOF'
# LoaderDir = /usr/lib/gdk-pixbuf-2.0/2.10.0/loaders
"/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so"
"svg" 6 "gdk-pixbuf" "Scalable Vector Graphics" "LGPL"
"/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/io-wmf.so"
"wmf" 0 "gdk-pixbuf" "Windows Metafile" "LGPL"
"/custom/loaders/extra-loader.so"
"xpm" 0 "gdk-pixbuf" "XPM" "LGPL"
"/* XPM */" "" 100
EOF
cat >"${TEST_ROOT}/expected.cache" <<'EOF'
# LoaderDir = lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders
"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so"
"svg" 6 "gdk-pixbuf" "Scalable Vector Graphics" "LGPL"
"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/io-wmf.so"
"wmf" 0 "gdk-pixbuf" "Windows Metafile" "LGPL"
"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/extra-loader.so"
"xpm" 0 "gdk-pixbuf" "XPM" "LGPL"
"/* XPM */" "" 100
EOF
WriteClassicPixbufCache
cmp -- "${TEST_ROOT}/expected.cache" "${STAGING}/${CACHE_REL}"
# Unknown absolute entries must also be rejected outside the usual /usr tree.
printf '"/custom/unrecognized-loader"\n' >"${GDK_PIXBUF_SOURCE}/loaders.cache"
if bash -c 'source "$1"; GDK_PIXBUF_SOURCE="$2"; STAGING="$3"; WriteClassicPixbufCache' \
bash "${ROOT}/scripts/package_standalone_webkit.sh" \
"${GDK_PIXBUF_SOURCE}" "${STAGING}" >"${TEST_ROOT}/rejected.log" 2>&1; then
printf 'absolute GdkPixbuf loader path was accepted\n' >&2
exit 1
fi
grep -Fq 'absolute loader path' "${TEST_ROOT}/rejected.log"
+15
View File
@@ -96,6 +96,21 @@ TEST(TextureOverrideTest, ResampleAveragesSourceBoxes) {
EXPECT_EQ(grown[pixel * 4 + 0], 9);
EXPECT_EQ(grown[pixel * 4 + 3], 6);
}
// 4x4 image with row stride = 16 bytes, but we only resample the top-left 2x2 region
std::vector<std::uint8_t> padded(4 * 16, 0);
for (std::uint32_t y = 0; y < 2; ++y) {
for (std::uint32_t x = 0; x < 2; ++x) {
padded[y * 16 + x * 4 + 0] = source.pixels[(y * 2 + x) * 4 + 0];
padded[y * 16 + x * 4 + 1] = source.pixels[(y * 2 + x) * 4 + 1];
padded[y * 16 + x * 4 + 2] = source.pixels[(y * 2 + x) * 4 + 2];
padded[y * 16 + x * 4 + 3] = source.pixels[(y * 2 + x) * 4 + 3];
}
}
std::uint8_t strided_four[16] = {};
ResampleRgba(padded.data(), 2, 2, 16, 2, 2, strided_four);
EXPECT_EQ(std::vector<std::uint8_t>(strided_four, strided_four + 16),
source.pixels);
}
// Rounded mean of the source box [x*sw/w, (x+1)*sw/w) per axis, at least one
+190
View File
@@ -5,7 +5,14 @@
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <thread>
#include <utility>
#include <vector>
#include "window/window.h"
@@ -105,6 +112,68 @@ TEST(VideoDriverPolicyTest, RejectsUnavailableOrEmptyDriverList) {
EXPECT_FALSE(HasAvailableVideoDriverCandidate("", available));
}
class NvidiaDriverDetectionTest : public ::testing::Test {
protected:
void SetUp() override {
char pattern[] = "/tmp/mocktail_video_driver_XXXXXX";
const char* created = mkdtemp(pattern);
ASSERT_NE(created, nullptr);
root_ = created;
proc_version_ = root_ / "proc/driver/nvidia/version";
pci_drivers_ = root_ / "sys/bus/pci/drivers";
}
void TearDown() override {
std::error_code error;
std::filesystem::remove_all(root_, error);
}
bool Detect() const {
return HasNvidiaKernelDriver(proc_version_, pci_drivers_ / "nvidia");
}
std::filesystem::path root_;
std::filesystem::path proc_version_;
std::filesystem::path pci_drivers_;
};
TEST_F(NvidiaDriverDetectionTest, DetectsProcDriverWithoutSysfs) {
std::filesystem::create_directories(proc_version_.parent_path());
std::ofstream(proc_version_) << "NVRM version: NVIDIA UNIX Kernel Module\n";
EXPECT_TRUE(Detect());
}
TEST_F(NvidiaDriverDetectionTest, HybridGpuUsesXwaylandWhenProcIsHidden) {
std::filesystem::create_directories(pci_drivers_ / "i915");
std::filesystem::create_directories(pci_drivers_ / "nvidia");
VideoDriverPolicyInput input = NvidiaWaylandDirectVulkan();
input.has_nvidia_kernel_driver = Detect();
EXPECT_TRUE(input.has_nvidia_kernel_driver);
EXPECT_EQ(ResolveVideoDriverChoice(input),
VideoDriverChoice::kNvidiaDirectVulkanX11);
input.force_wayland = true;
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kWayland);
input.has_explicit_sdl_driver = true;
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kSdlDefault);
}
TEST_F(NvidiaDriverDetectionTest, DoesNotTreatNouveauAsTheNvidiaDriver) {
std::filesystem::create_directories(pci_drivers_ / "nouveau");
std::filesystem::create_directories(pci_drivers_ / "i915");
std::filesystem::create_directories(pci_drivers_ / "amdgpu");
EXPECT_FALSE(Detect());
}
TEST_F(NvidiaDriverDetectionTest, IgnoresMissingAndUnrelatedDeviceInformation) {
EXPECT_FALSE(Detect());
std::filesystem::create_directories(pci_drivers_ / "nvidia_drm");
std::ofstream(pci_drivers_ / "nvidia") << "not a registered driver\n";
EXPECT_FALSE(Detect());
}
TEST(VideoDriverPolicyIntegrationTest,
DropsUnavailableLegacyOverrideBeforeSdlInit) {
bool has_dummy_driver = false;
@@ -129,6 +198,127 @@ TEST(VideoDriverPolicyIntegrationTest,
Shutdown();
}
class AngleWindowInitializationTest : public ::testing::Test {
protected:
void SetUp() override {
char pattern[] = "/tmp/mocktail_angle_window_XXXXXX";
const char* created = mkdtemp(pattern);
ASSERT_NE(created, nullptr);
directory_ = created;
for (const char* name : {"MOCKTAIL_GRAPHICS_BACKEND", "MOCKTAIL_ANGLE_LIB_DIR",
"MOCKTAIL_EGL_LIBRARY", "MOCKTAIL_GLES_LIBRARY",
"MOCKTAIL_DISABLE_AUTO_ANGLE_FALLBACK",
"MOCKTAIL_ENABLE_TEST_GRAPHICS_STUBS",
"MOCKTAIL_SOFTWARE_WINDOW_FALLBACK",
"ANGLE_DEFAULT_PLATFORM", "NODEVICE_SELECT",
"DISABLE_LAYER_MESA_ANTI_LAG",
"SDL_VIDEO_DRIVER", "SDL_VIDEODRIVER",
"SDL_EGL_LIBRARY", "SDL_OPENGL_LIBRARY"}) {
const char* value = std::getenv(name);
environment_.emplace_back(name, value != nullptr
? std::optional<std::string>(value)
: std::nullopt);
ASSERT_EQ(unsetenv(name), 0);
}
ASSERT_EQ(setenv("SDL_VIDEO_DRIVER", "dummy", 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "angle-vulkan", 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_ANGLE_LIB_DIR", directory_.c_str(), 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_DISABLE_AUTO_ANGLE_FALLBACK", "1", 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_ENABLE_TEST_GRAPHICS_STUBS", "1", 1), 0);
}
void TearDown() override {
Shutdown();
SDL_Quit();
SDL_ResetHint(SDL_HINT_EGL_LIBRARY);
SDL_ResetHint(SDL_HINT_OPENGL_LIBRARY);
SDL_ResetHint(SDL_HINT_OPENGL_ES_DRIVER);
SDL_ResetHint(SDL_HINT_VIDEO_DRIVER);
for (const auto& [name, value] : environment_) {
if (value) {
setenv(name.c_str(), value->c_str(), 1);
} else {
unsetenv(name.c_str());
}
}
std::error_code error;
std::filesystem::remove_all(directory_, error);
}
std::filesystem::path directory_;
std::vector<std::pair<std::string, std::optional<std::string>>> environment_;
};
TEST_F(AngleWindowInitializationTest, MissingAngleNeverFallsBackToSystemEgl) {
EXPECT_FALSE(Init(320, 180, "missing ANGLE"));
EXPECT_FALSE(IsInitialised());
EXPECT_EQ(SDL_WasInit(SDL_INIT_VIDEO), 0U);
}
TEST_F(AngleWindowInitializationTest, PartialAngleNeverMixesWithInstalledBrowsers) {
std::ofstream(directory_ / "libEGL.so") << "fixture";
EXPECT_FALSE(Init(320, 180, "partial ANGLE"));
EXPECT_FALSE(IsInitialised());
EXPECT_EQ(SDL_WasInit(SDL_INIT_VIDEO), 0U);
}
TEST_F(AngleWindowInitializationTest, UnloadableAngleNeverCreatesWaitingWindow) {
std::ofstream(directory_ / "libEGL.so") << "invalid EGL library";
std::ofstream(directory_ / "libGLESv2.so") << "invalid GLES library";
EXPECT_FALSE(Init(320, 180, "unloadable ANGLE"));
EXPECT_FALSE(IsInitialised());
}
TEST_F(AngleWindowInitializationTest, ReinitializationUsesCurrentBackend) {
ASSERT_EQ(unsetenv("MOCKTAIL_ANGLE_LIB_DIR"), 0);
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "system", 1), 0);
ASSERT_TRUE(Init(320, 180, "initial backend"));
Shutdown();
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "direct-vulkan", 1), 0);
EXPECT_FALSE(Init(320, 180, "new backend"));
EXPECT_FALSE(IsInitialised());
}
TEST_F(AngleWindowInitializationTest, RealAngleContextBindsOnRenderThread) {
const char* directory = std::getenv("MOCKTAIL_TEST_ANGLE_LIB_DIR");
if (directory == nullptr) {
GTEST_SKIP() << "real ANGLE libraries were not explicitly provided";
}
ASSERT_EQ(setenv("MOCKTAIL_ANGLE_LIB_DIR", directory, 1), 0);
ASSERT_EQ(setenv("SDL_VIDEO_DRIVER", "x11", 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_ENABLE_TEST_GRAPHICS_STUBS", "0", 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_SOFTWARE_WINDOW_FALLBACK", "0", 1), 0);
ASSERT_EQ(setenv("SDL_EGL_LIBRARY", "/missing/generic/libEGL.so", 1), 0);
ASSERT_EQ(setenv("SDL_OPENGL_LIBRARY", "/missing/generic/libGLESv2.so", 1), 0);
ASSERT_EQ(setenv("ANGLE_DEFAULT_PLATFORM", "opengl", 1), 0);
ASSERT_TRUE(Init(320, 180, "ANGLE context handoff")) << SDL_GetError();
ASSERT_EQ(SDL_GL_GetCurrentContext(), nullptr);
bool bound = false;
bool released = false;
std::string renderer;
std::string error;
std::thread render([&] {
bound = MakeCurrentOnThread();
if (bound) {
using GetString = const unsigned char* (*)(unsigned int);
const auto get_string =
reinterpret_cast<GetString>(GetGLProcAddress("glGetString"));
const unsigned char* value = get_string != nullptr ? get_string(0x1f01)
: nullptr;
if (value != nullptr) renderer = reinterpret_cast<const char*>(value);
released = ReleaseCurrentOnThread();
}
error = SDL_GetError();
});
render.join();
EXPECT_TRUE(bound) << error;
EXPECT_TRUE(released) << error;
EXPECT_NE(renderer.find("ANGLE"), std::string::npos) << renderer;
EXPECT_NE(renderer.find("Vulkan"), std::string::npos) << renderer;
}
} // namespace
} // namespace window
} // namespace mocktail
@@ -1,6 +1,7 @@
#include "mocktail/graphics/vulkan_text_overlay_compositor.h"
#include <gtest/gtest.h>
#include <libplacebo/vulkan.h>
#include <atomic>
#include <cstdint>
@@ -57,6 +58,76 @@ class VulkanTextOverlayCompositorTest : public ::testing::Test {
}
};
class VulkanTextOverlayCompositorDeviceTest
: public VulkanTextOverlayCompositorTest {
protected:
void SetUp() override {
VulkanTextOverlayCompositorTest::SetUp();
pl_vulkan_params params = pl_vulkan_default_params;
params.allow_software = true;
params.async_compute = false;
params.async_transfer = false;
host_ = pl_vulkan_create(nullptr, &params);
if (host_ == nullptr) {
GTEST_SKIP() << "A compatible Vulkan device is unavailable";
}
get_device_proc_addr_ = reinterpret_cast<PFN_vkGetDeviceProcAddr>(
host_->get_proc_addr(host_->instance, "vkGetDeviceProcAddr"));
ASSERT_NE(get_device_proc_addr_, nullptr);
}
void TearDown() override { pl_vulkan_destroy(&host_); }
bool RegisterHostDevice(VulkanTextOverlayCompositor* compositor) {
return compositor->RegisterDevice(
host_->instance, host_->phys_device, host_->device, host_->api_version,
host_->extensions, host_->num_extensions, host_->queue_graphics.index,
host_->queue_graphics.count, host_->features, host_->get_proc_addr,
get_device_proc_addr_);
}
pl_vulkan host_ = nullptr;
PFN_vkGetDeviceProcAddr get_device_proc_addr_ = nullptr;
};
TEST_F(VulkanTextOverlayCompositorDeviceTest,
RegisteredDeviceTeardownRemovesStateAndPreservesHost) {
VulkanTextOverlayCompositor compositor;
ASSERT_TRUE(RegisterHostDevice(&compositor));
const auto get_device_queue = reinterpret_cast<PFN_vkGetDeviceQueue>(
get_device_proc_addr_(host_->device, "vkGetDeviceQueue"));
ASSERT_NE(get_device_queue, nullptr);
VkQueue queue = VK_NULL_HANDLE;
get_device_queue(host_->device, host_->queue_graphics.index, 0, &queue);
ASSERT_NE(queue, VK_NULL_HANDLE);
ASSERT_TRUE(compositor.RegisterQueue(host_->device, queue,
host_->queue_graphics.index, 0));
compositor.DestroyDevice(VK_NULL_HANDLE);
compositor.DestroyDevice(
reinterpret_cast<VkDevice>(static_cast<uintptr_t>(0x51d)));
ASSERT_TRUE(compositor.RegisterQueue(host_->device, queue,
host_->queue_graphics.index, 0));
// Run with ThreadSanitizer to detect an unlock after DeviceState is freed.
compositor.DestroyDevice(host_->device);
EXPECT_FALSE(compositor.RegisterQueue(host_->device, queue,
host_->queue_graphics.index, 0));
compositor.DestroyDevice(host_->device);
EXPECT_EQ(compositor.QueueSubmit(queue, 0, nullptr, VK_NULL_HANDLE,
RecordingQueueSubmit),
VK_SUCCESS);
EXPECT_EQ(g_submit_calls.load(std::memory_order_relaxed), 1);
EXPECT_EQ(g_last_queue.load(std::memory_order_relaxed), queue);
const auto device_wait_idle = reinterpret_cast<PFN_vkDeviceWaitIdle>(
get_device_proc_addr_(host_->device, "vkDeviceWaitIdle"));
ASSERT_NE(device_wait_idle, nullptr);
EXPECT_EQ(device_wait_idle(host_->device), VK_SUCCESS);
ASSERT_TRUE(RegisterHostDevice(&compositor));
compositor.DestroyDevice(host_->device);
}
TEST_F(VulkanTextOverlayCompositorTest,
InactiveOverlayQueueSubmitInvokesFallback) {
VulkanTextOverlayCompositor compositor;
+13
View File
@@ -134,6 +134,19 @@ TEST(WebViewHelperPolicyTest, HonorsExplicitCompositingEnvironment) {
}
}
TEST(WebViewHelperPolicyTest, ForcesCairoRendererWithSoftwareCompositing) {
EXPECT_TRUE(ShouldForceSoftwareGtkRenderer(true, nullptr));
EXPECT_FALSE(ShouldForceSoftwareGtkRenderer(false, nullptr));
}
TEST(WebViewHelperPolicyTest, HonorsExplicitGtkRendererEnvironment) {
for (bool software_compositing : {false, true}) {
for (const char* value : {"gl", "vulkan", "cairo", ""}) {
EXPECT_FALSE(ShouldForceSoftwareGtkRenderer(software_compositing, value));
}
}
}
TEST(WebViewHelperPolicyTest, AllowsOnlyTlsTopLevelNavigation) {
const UriPolicyResult roblox =
EvaluateNavigationUri("https://www.roblox.com/login");
+174
View File
@@ -2,11 +2,39 @@
#include <gtest/gtest.h>
#include <unistd.h>
#include <condition_variable>
#include <cstddef>
#include <cstdlib>
#include <mutex>
#include <thread>
#include <vector>
namespace mocktail {
namespace window {
class WindowPointerCaptureOwnerTestPeer {
public:
static void WaitForClearingQuery(WindowPointerCaptureOwner* owner) {
std::unique_lock<std::mutex> lock(owner->mutex_);
owner->condition_.wait(lock, [owner] {
return owner->clearing_ && owner->in_flight_ == 1 &&
owner->callback_ == nullptr && owner->context_ == nullptr;
});
}
static bool ClearingQueryIsInFlight(WindowPointerCaptureOwner* owner) {
std::lock_guard<std::mutex> lock(owner->mutex_);
return owner->clearing_ && owner->in_flight_ == 1;
}
static bool HasInFlightQuery(WindowPointerCaptureOwner* owner) {
std::lock_guard<std::mutex> lock(owner->mutex_);
return owner->in_flight_ != 0;
}
};
namespace {
class FakeBackend final : public PointerCaptureBackend {
@@ -52,6 +80,126 @@ bool ClearQueryReentrantly(void* context, bool* locked_center) {
return true;
}
struct GatedQueryState {
WindowPointerCaptureOwner* owner = nullptr;
std::mutex mutex;
std::condition_variable condition;
bool reentrant = false;
bool entered = false;
bool allow_inner_clear = false;
bool inner_clear_returned = false;
bool allow_return = false;
bool external_clear_returned = false;
bool second_clear_started = false;
bool second_clear_returned = false;
};
bool GatedQuery(void* context, bool* locked_center) {
auto* state = static_cast<GatedQueryState*>(context);
std::unique_lock<std::mutex> lock(state->mutex);
state->entered = true;
state->condition.notify_all();
if (state->reentrant) {
state->condition.wait(lock, [state] { return state->allow_inner_clear; });
lock.unlock();
state->owner->ClearQuery();
lock.lock();
state->inner_clear_returned = true;
state->condition.notify_all();
}
state->condition.wait(lock, [state] { return state->allow_return; });
*locked_center = false;
return true;
}
void RunOverlappingClearScenario(bool reentrant) {
// Every failure exits the child directly. A deadlock cannot strand a join or
// a test destructor in the parent; the alarm bounds all waits in this child.
alarm(8);
FakeBackend backend;
WindowPointerCaptureOwner owner(&backend);
GatedQueryState state;
state.owner = &owner;
state.reentrant = reentrant;
if (!owner.RegisterQuery(GatedQuery, &state)) {
std::_Exit(1);
}
std::thread pump([&] {
if (!owner.Pump(false)) {
std::_Exit(2);
}
});
{
std::unique_lock<std::mutex> lock(state.mutex);
state.condition.wait(lock, [&state] { return state.entered; });
}
std::thread external_clear([&] {
owner.ClearQuery();
if (WindowPointerCaptureOwnerTestPeer::HasInFlightQuery(&owner)) {
std::_Exit(7);
}
std::lock_guard<std::mutex> lock(state.mutex);
state.external_clear_returned = true;
state.condition.notify_all();
});
WindowPointerCaptureOwnerTestPeer::WaitForClearingQuery(&owner);
QueryState replacement;
if (owner.RegisterQuery(Query, &replacement)) {
std::_Exit(3);
}
std::thread second_clear;
if (reentrant) {
std::unique_lock<std::mutex> lock(state.mutex);
state.allow_inner_clear = true;
state.condition.notify_all();
state.condition.wait(lock,
[&state] { return state.inner_clear_returned; });
} else {
second_clear = std::thread([&] {
{
std::lock_guard<std::mutex> lock(state.mutex);
state.second_clear_started = true;
state.condition.notify_all();
}
owner.ClearQuery();
if (WindowPointerCaptureOwnerTestPeer::HasInFlightQuery(&owner)) {
std::_Exit(7);
}
std::lock_guard<std::mutex> lock(state.mutex);
state.second_clear_returned = true;
state.condition.notify_all();
});
std::unique_lock<std::mutex> lock(state.mutex);
state.condition.wait(lock, [&state] { return state.second_clear_started; });
}
if (!WindowPointerCaptureOwnerTestPeer::ClearingQueryIsInFlight(&owner) ||
owner.RegisterQuery(Query, &replacement)) {
std::_Exit(4);
}
{
std::lock_guard<std::mutex> lock(state.mutex);
if (state.external_clear_returned || state.second_clear_returned) {
std::_Exit(5);
}
state.allow_return = true;
state.condition.notify_all();
}
pump.join();
external_clear.join();
if (second_clear.joinable()) {
second_clear.join();
}
if (!state.external_clear_returned ||
(!reentrant && !state.second_clear_returned) ||
!owner.RegisterQuery(Query, &replacement)) {
std::_Exit(6);
}
owner.ClearQuery();
std::_Exit(0);
}
TEST(WindowPointerCaptureOwnerTest, FollowsNativeMouseLockState) {
FakeBackend backend;
QueryState query;
@@ -135,6 +283,32 @@ TEST(WindowPointerCaptureOwnerTest, QueryCanClearItselfWithoutDeadlock) {
EXPECT_TRUE(owner.RegisterQuery(Query, &replacement));
}
TEST(WindowPointerCaptureOwnerTest, ClearRemovesQueryAndAllowsReplacement) {
FakeBackend backend;
QueryState query;
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
ASSERT_FALSE(owner.cursor_visible());
owner.ClearQuery();
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.RegisterQuery(Query, &query));
}
TEST(WindowPointerCaptureOwnerTest,
QueryCanClearItselfWhileExternalClearWaitsForItsReturn) {
ASSERT_EXIT(RunOverlappingClearScenario(true), ::testing::ExitedWithCode(0),
"");
}
TEST(WindowPointerCaptureOwnerTest,
ConcurrentExternalClearsWaitForQueryReturn) {
ASSERT_EXIT(RunOverlappingClearScenario(false), ::testing::ExitedWithCode(0),
"");
}
TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotCaptureOutsideAnExperience) {
// The app surface has no camera. Capturing there would freeze the cursor
// over Roblox's own menu UI.