Compare commits

...
Author SHA1 Message Date
vii e5716ab09f fix(input): advance the TextBox caret past trailing whitespace
The final cluster rect SDL_ttf reports stops at the last inked glyph, so a
caret at the end of a single-line field did not move when a trailing space
was typed. Use the laid-out width there; wrapped layouts keep the clamp
because their width is the widest line rather than the last one.
2026-09-18 21:13:53 -05:00
komaruworld 854969457f Fix textures and device info from Nightcap
Ported from CoderDayton/nightcap
2026-09-18 16:55:29 +03:00
komaruworld b54c896498 ci hate me? 2026-09-15 19:52:24 +03:00
komaruworld 1cd7b7db8f Fuck ci 2026-09-15 19:42:56 +03:00
komaruworld cdbf9ac1eb revert commit fdce534 2026-09-15 18:26:13 +03:00
komaruworld f383fbbff4 update faq.md 2026-09-15 12:08:18 +03:00
komaruworld d76d75b7f8 rollback to native login
not fully test
2026-09-14 19:03:36 +03:00
komaruworld 537828acc2 Fix vc
Fixes #128 Fixes #132
2026-09-14 16:36:35 +03:00
komaruworld 5be2f68a55 Support input devices
Fix ci, some shit with webkit on wayland
2026-09-14 15:24:04 +03:00
komaruworld f7e519bae1 I forgot to add changelog 2026-09-14 14:50:12 +03:00
komaruworld ad8fbfcf65 Add 1.0.4 release to flathub metadata 2026-09-14 14:36:49 +03:00
komaruworld 96e1966d16 sigxcpu crashes fix
Refs #84
2026-09-14 13:15:54 +03:00
komaruworld becaee71c0 Add fleasion support
Close #18
2026-09-14 13:05:24 +03:00
komaruworld ab4944a907 Fix shit with sigxcpu
Refs #84
2026-09-14 12:34:09 +03:00
komaruworld 017f79398d Fix crash in resize windows 2026-09-13 14:32:41 +03:00
komaruworld fc120e8285 support rbx 2.738 with fix charts 2026-09-13 13:02:25 +03:00
komaruworld 117b9b2016 Update mocktail package in aur
😢
2026-09-12 23:21:27 +03:00
komaruworld 1376ab2474 Ci fixes sha256
i skoro sha snesy naher
2026-09-12 23:13:55 +03:00
komaruworld fdce53421f Fix low textures
THX HIPPO! <3
2026-09-12 20:52:25 +03:00
komaruworld 770ed086d5 Fix aur bin
Close #132

:(
2026-09-12 19:27:53 +03:00
komaruworld c48db09f13 Fix chat text render on opengl 2026-09-12 15:59:03 +03:00
komaruworld 0548ebd253 Fix chat text render and chat shiftlock issues
Fixes 106, Fixes #127, Refs #116
2026-09-12 14:45:29 +03:00
116 changed files with 7777 additions and 533 deletions

No files matched your search

+1
View File
@@ -64,3 +64,4 @@ jobs:
bash tests/managed_payload_build_contract_test.sh .
bash tests/flatpak_packaging_test.sh .
bash tests/native_packaging_test.sh .
bash tests/anylinux_dependencies_test.sh .
+7 -4
View File
@@ -45,16 +45,19 @@ jobs:
- name: Build AppImage
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env GH_TOKEN \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
pacman -Syu --noconfirm --needed \
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
nlohmann-json libyaml libelf minizip minizip-ng capstone gtk4 \
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
vulkan-headers zlib libplacebo bubblewrap xdg-dbus-proxy \
vulkan-headers zlib libplacebo libpng bubblewrap xdg-dbus-proxy \
patchelf jq unzip file ca-certificates dbus zsync
# Reduced ABI-compatible packages belong only in this builder.
@@ -169,7 +172,7 @@ jobs:
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 zlib1g-dev
libvulkan-dev libplacebo-dev libpng-dev zlib1g-dev
cmake -S . -B build-deb -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
@@ -217,7 +220,7 @@ jobs:
minizip-ng-compat-devel capstone-devel gtk4-devel libadwaita-devel \
webkitgtk6.0-devel utf8proc-devel fontconfig-devel \
libglvnd-devel vulkan-headers vulkan-loader-devel \
libplacebo-devel zlib-ng-compat-devel
libplacebo-devel libpng-devel zlib-ng-compat-devel
cmake -S . -B build-rpm -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
@@ -261,7 +264,7 @@ jobs:
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
nlohmann-json libyaml libelf minizip capstone gtk4 \
libadwaita webkitgtk-6.0 libutf8proc fontconfig \
libplacebo vulkan-headers vulkan-icd-loader libglvnd zlib
libplacebo libpng vulkan-headers vulkan-icd-loader libglvnd zlib
useradd --create-home builder
chown -R builder:builder /workspace
runuser -u builder -- env MOCKTAIL_SOURCE_DIR=/workspace \
+1 -13
View File
@@ -57,19 +57,7 @@ Makefile.cmake
/cmake-build-release/
# Roblox payload
/rbx_bin/libroblox.so
/rbx_bin/libroblox.so.*
/rbx_bin/client_settings_android.json
/rbx_bin/assets/
/rbx_bin/android/
/rbx_bin/sober_apk/
/rbx_bin/versions/
/rbx_bin/.payload-update.*/
/rbx_bin/*.apk
/rbx_bin/*.so
/rbx_bin/*.so.*
!/rbx_bin/.gitkeep
/rbx_bin/
# Runtime state
/runtime/
+7 -2
View File
@@ -8,7 +8,7 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 1.0.3
VERSION 1.0.4
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
LANGUAGES CXX
)
@@ -264,10 +264,12 @@ add_library(mocktail_runtime STATIC
src/runtime/command_line.cc
src/runtime/crash_report_policy.cc
src/runtime/discord_rpc.cc
src/runtime/device_memory_profile.cc
src/runtime/device_profile.cc
src/runtime/environment.cc
src/runtime/external_launch_broker.cc
src/runtime/frame_rate_policy.cc
src/runtime/fleasion.cc
src/runtime/game_mode.cc
src/runtime/game_session_coordinator.cc
src/runtime/graphics_launch_policy.cc
@@ -286,6 +288,8 @@ add_library(mocktail_runtime STATIC
src/runtime/roblox_web_view_bridge.cc
src/runtime/roblox_permissions_bridge.cc
src/runtime/payload_update_preflight.cc
src/runtime/process_launch_policy.cc
src/runtime/process_diagnostics.cc
src/runtime/runtime_config.cc
src/runtime/runtime_config_bootstrap.cc
src/runtime/runtime_config_file.cc
@@ -327,6 +331,7 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND
endif()
target_link_libraries(mocktail_runtime PUBLIC Threads::Threads)
target_link_libraries(mocktail_runtime PRIVATE
OpenSSL::Crypto
CURL::libcurl
nlohmann_json::nlohmann_json
PkgConfig::GIO
@@ -465,7 +470,7 @@ target_link_libraries(mocktail_window PUBLIC
Mocktail::SdlVulkanWsi
SDL3::SDL3
${CMAKE_DL_LIBS}
PRIVATE Mocktail::Runtime
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay
)
add_library(mocktail_roblox_output_device_runtime STATIC
+40 -8
View File
@@ -1,20 +1,52 @@
# Frequently asked questions
# FAQ
[How do I change my FPS?](/FAQ.md#how-do-i-change-my-fps)
## How do I enable Discord RPC?
### How do I change my FPS?
RPC is disabled by default. Launch Mocktail once to create the config, then
close it. Open `config.yaml`:
- Native / AppImage: `~/.config/mocktail/config.yaml`
- Flatpak: `~/.var/app/space.bigrat.mocktail/config/mocktail/config.yaml`
Go to $HOME/.config/mocktail, there is a file called config.yaml (appears after opening for the first time mocktail)
For native installs, a custom `$XDG_CONFIG_HOME` replaces `~/.config`.
Set `enabled` to `true` under `integrations.discord_rpc`. If the block is
missing, add it under the existing `integrations` section:
<img width="460" height="241" alt="image" src="https://github.com/user-attachments/assets/b30b1fea-4937-4858-937a-3096a9e8b2ad" />
```yaml
integrations:
discord_rpc:
enabled: true
```
Save the file, open Discord Desktop, and restart Mocktail. Set `enabled`
back to `false` to disable RPC.
with your text editor of preference search for "graphics"
## Where can I find logs for a bug report?
Mocktail saves logs automatically. Reproduce the issue, close Mocktail, and
attach `latest.log`:
<img width="1022" height="619" alt="image" src="https://github.com/user-attachments/assets/f86791e9-7ac3-4d13-b81b-3cf9e3e64c3d" />
- Native / AppImage: `~/.local/state/mocktail/logs/latest.log`
- Flatpak: `~/.var/app/space.bigrat.mocktail/.local/state/mocktail/logs/latest.log`
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
change the display value to your desired fps, also turn off vsync if you want more fps than your display supports
## Can I play in VR?
VR is experimental. Install the [build dependencies](README.md#building),
then switch to the `vr` branch and build:
```bash
git fetch origin
git switch vr
git submodule update --init --recursive
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON
cmake --build build -j4
```
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
```bash
./build/mocktail -vr
```
+4
View File
@@ -107,6 +107,10 @@ Direct AppImage, DEB, and RPM downloads are available from the
Put a JSON object in `$XDG_CONFIG_HOME/mocktail/fflags.json` (usually
`~/.config/mocktail/fflags.json`). Mocktail applies it on the next launch.
PC profiles use the legacy Charts page by default to avoid the blank screen
caused by `Color3` errors in the newer SDUI page. An explicit
`FFlagLuaAppChartsAppPage` override takes precedence over this default.
```json
{
"FFlagExample": "True",
+24
View File
@@ -9,6 +9,7 @@ get_filename_component(MOCKTAIL_PLATFORM_GRAPHICS_ROOT
find_package(SDL3 3.4 REQUIRED CONFIG)
find_path(MOCKTAIL_EGL_INCLUDE_DIR EGL/egl.h REQUIRED)
find_path(MOCKTAIL_GLES3_INCLUDE_DIR GLES3/gl3.h REQUIRED)
set(MOCKTAIL_WINDOW_ICON_PNG
"${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/packaging/icons/hicolor/48x48/apps/space.bigrat.mocktail.png"
@@ -103,6 +104,18 @@ target_link_libraries(mocktail_graphics_foundation PRIVATE
target_compile_features(mocktail_graphics_foundation PUBLIC cxx_std_17)
add_library(Mocktail::GraphicsFoundation ALIAS mocktail_graphics_foundation)
add_library(mocktail_gles_text_overlay STATIC
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/graphics/gles_text_overlay_compositor.cc
)
target_include_directories(mocktail_gles_text_overlay
PUBLIC ${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/include
PRIVATE ${MOCKTAIL_GLES3_INCLUDE_DIR}
)
target_link_libraries(mocktail_gles_text_overlay PUBLIC SDL3::SDL3)
target_compile_features(mocktail_gles_text_overlay PUBLIC cxx_std_17)
mocktail_apply_compile_options(mocktail_gles_text_overlay)
add_library(Mocktail::GlesTextOverlay ALIAS mocktail_gles_text_overlay)
add_library(mocktail_sdl_vulkan_wsi STATIC
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/graphics/sdl_vulkan_wsi.cc
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/graphics/android_vulkan_wsi_adapter.cc
@@ -120,6 +133,16 @@ target_compile_features(mocktail_sdl_vulkan_wsi PUBLIC cxx_std_17)
add_library(Mocktail::SdlVulkanWsi ALIAS mocktail_sdl_vulkan_wsi)
if(BUILD_TESTING AND TARGET GTest::gtest_main)
add_executable(gles_text_overlay_compositor_test
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/tests/gles_text_overlay_compositor_test.cc
)
target_include_directories(gles_text_overlay_compositor_test PRIVATE
${MOCKTAIL_GLES3_INCLUDE_DIR}
)
target_link_libraries(gles_text_overlay_compositor_test PRIVATE
Mocktail::GlesTextOverlay
GTest::gtest_main
)
add_executable(bionic_egl_bridge_test
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/tests/bionic_egl_bridge_test.cc
)
@@ -153,6 +176,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
GTest::gtest_main
)
include(GoogleTest)
gtest_discover_tests(gles_text_overlay_compositor_test)
gtest_discover_tests(bionic_egl_bridge_test)
gtest_discover_tests(platform_graphics_foundation_test)
gtest_discover_tests(display_refresh_capabilities_test)
+10
View File
@@ -75,6 +75,16 @@ audio:
input_device: default
integrations:
fleasion:
# Boolean (default: false): trust Fleasion's CA without editing Roblox files.
enabled: false
# String: match Fleasion's routing mode: env or hosts.
proxy_mode: env
# Integer: Fleasion's local HTTP proxy port in env mode.
proxy_port: 58443
# Optional absolute public CA path. Default: $XDG_CONFIG_HOME/Fleasion/proxy_ca/ca.crt
# or ~/.config/Fleasion/proxy_ca/ca.crt. Never select ca.key.
# ca_certificate: /home/user/.config/Fleasion/proxy_ca/ca.crt
discord_rpc:
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
# This never signs in to Discord and never reads an account token.
+5 -1
View File
@@ -79,7 +79,11 @@
"count_method_rva": "0x32d0ee0",
"info_method_rva": "0x32d0f80",
"current_method_rva": "0x32d0f30",
"select_method_rva": "0x32d0cb4"
"select_method_rva": "0x32d0cb4",
"input_count_method_rva": "0x32d12dc",
"input_info_method_rva": "0x32d12ea",
"input_current_method_rva": "0x32d13a6",
"input_select_method_rva": "0x32d0a56"
},
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled."
}
@@ -2,6 +2,8 @@
#define MOCKTAIL_COMPAT_BIONIC_PTHREAD_CREATE_RUNTIME_H_
#include <pthread.h>
#include <sched.h>
#include <sys/types.h>
#include <cstddef>
@@ -29,4 +31,10 @@ extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
void* (*start_routine)(void*),
void* argument);
// Guest real-time scheduling is denied to avoid the host RLIMIT_RTTIME watchdog.
extern "C" int mocktail_bionic_pthread_setschedparam(
pthread_t thread, int policy, const struct sched_param* parameters);
extern "C" int mocktail_bionic_sched_setscheduler(
pid_t tid, int policy, const struct sched_param* parameters);
#endif // MOCKTAIL_COMPAT_BIONIC_PTHREAD_CREATE_RUNTIME_H_
+24
View File
@@ -1,6 +1,9 @@
#ifndef MOCKTAIL_COMPAT_BUILD_PROFILE_H_
#define MOCKTAIL_COMPAT_BUILD_PROFILE_H_
#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
@@ -22,6 +25,27 @@ struct FmodOutputDeviceBridgeProfile {
std::uintptr_t info_method_rva = 0;
std::uintptr_t current_method_rva = 0;
std::uintptr_t select_method_rva = 0;
// Roblox 2.738 inserted device-list methods before the current/select slots.
int vtable_layout_version = 1;
// Optional exact-build input count/info/current/select methods.
std::array<std::uintptr_t, 4> input_method_rvas{};
bool has_input_devices() const { return input_method_rvas[0] != 0; }
std::array<std::size_t, 4> input_vtable_indexes() const {
return vtable_layout_version == 2
? std::array<std::size_t, 4>{9, 10, 12, 18}
: std::array<std::size_t, 4>{8, 9, 10, 16};
}
bool valid_vtable_layout() const {
return vtable_layout_version == 1 || vtable_layout_version == 2;
}
std::size_t current_vtable_index() const {
return vtable_layout_version == 2 ? 8 : 7;
}
std::size_t select_vtable_index() const {
return vtable_layout_version == 2 ? 19 : 17;
}
};
struct BuildProfile {
@@ -0,0 +1,42 @@
#ifndef MOCKTAIL_COMPAT_FMOD_OUTPUT_DEVICE_CONTRACT_H_
#define MOCKTAIL_COMPAT_FMOD_OUTPUT_DEVICE_CONTRACT_H_
#include <cstddef>
#include <initializer_list>
#include <string_view>
namespace mocktail::compat {
// The 2.738 integer-index selector adds a feature-flag dispatch before the
// count/clamp/setDriver path. Keep the calling convention, count slot (5),
// object offset (0x28), and branch into that path exact. Only relocated
// addresses and non-local branch/call targets vary between builds.
inline constexpr char kFmodDeviceListsSelectContract[] =
"\x55\x48\x89\xe5\x41\x57\x41\x56\x41\x54\x53\x48\x83\xec\x50\x41"
"\x89\xf7\x49\x89\xfe\x4c\x8b\x25\x00\x00\x00\x00\x49\x8b\x04\x24"
"\x48\x89\x45\xd8\x80\x3d\x00\x00\x00\x00\x00\x74\x25\x49\x8b\x04"
"\x24\x48\x3b\x45\xd8\x0f\x85\x00\x00\x00\x00\x4c\x89\xf7\x44\x89"
"\xfe\x48\x83\xc4\x50\x5b\x41\x5c\x41\x5e\x41\x5f\x5d\xe9\x00\x00"
"\x00\x00\x49\x8b\x06\x4c\x89\xf7\xff\x50\x28\x44\x39\xf8\x0f\x8e"
"\x00\x00\x00\x00\x31\xdb\x45\x85\xff\x41\x0f\x4f\xdf\x49\x8b\x7e"
"\x28\x89\xde\xe8\x00\x00\x00\x00";
inline constexpr std::size_t kFmodDeviceListsSelectContractSize =
sizeof(kFmodDeviceListsSelectContract) - 1;
inline bool HasFmodDeviceListsSelectContract(std::string_view code) {
if (code.size() < kFmodDeviceListsSelectContractSize) return false;
for (std::size_t index = 0; index < kFmodDeviceListsSelectContractSize;
++index) {
bool relocated = false;
for (const std::size_t start : {24, 38, 55, 78, 96, 116}) {
if (index >= start && index < start + 4) relocated = true;
}
if (!relocated && code[index] != kFmodDeviceListsSelectContract[index])
return false;
}
return true;
}
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_FMOD_OUTPUT_DEVICE_CONTRACT_H_
@@ -0,0 +1,64 @@
#ifndef MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_
#define MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_
#include <string_view>
#if defined(__x86_64__)
#include <xmmintrin.h>
#endif
namespace mocktail::audio {
// FMOD's Android threads identify themselves through JavaVMAttachArgs before
// entering their mix loop. Voice DSP filters can accumulate subnormal values
// and spend an entire x86 core on floating-point assists (issue #128).
// Limit FTZ/DAZ to these audio threads; retain the caller's other FP controls.
class FmodThreadFloatingPointMode final {
public:
FmodThreadFloatingPointMode() = default;
~FmodThreadFloatingPointMode() { Restore(); }
FmodThreadFloatingPointMode(const FmodThreadFloatingPointMode&) = delete;
FmodThreadFloatingPointMode& operator=(const FmodThreadFloatingPointMode&) =
delete;
bool Enable(const char* thread_name) noexcept {
#if defined(__x86_64__)
if (active_ || thread_name == nullptr) return false;
const std::string_view name(thread_name);
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
return false;
}
const unsigned int control = _mm_getcsr();
original_mode_ = control & kModeMask;
_mm_setcsr(control | kModeMask);
active_ = true;
return true;
#else
(void)thread_name;
return false;
#endif
}
void Restore() noexcept {
#if defined(__x86_64__)
if (active_) {
// Restore only our two bits, preserving rounding, exception masks and
// status flags set by the guest while the thread was attached.
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
active_ = false;
}
#endif
}
private:
#if defined(__x86_64__)
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
unsigned int original_mode_ = 0;
bool active_ = false;
#endif
};
} // namespace mocktail::audio
#endif // MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_
@@ -4,6 +4,7 @@
#include <array>
#include <cstdint>
#include <mutex>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
@@ -13,13 +14,15 @@
namespace mocktail::audio {
// Replaces only the output-device query/select slots of the exact
class NativeInputCapture;
// Replaces the profiled input/output-device query/select slots of the exact
// Build-ID-scoped FmodAudioDevice vtable. Roblox keeps owning its FMOD engine,
// while the existing settings UI sees and selects the SDL host routes that
// consume Android AudioTrack PCM.
class RobloxOutputDeviceBridge final {
public:
RobloxOutputDeviceBridge() = default;
RobloxOutputDeviceBridge();
~RobloxOutputDeviceBridge();
RobloxOutputDeviceBridge(const RobloxOutputDeviceBridge&) = delete;
@@ -48,16 +51,25 @@ class RobloxOutputDeviceBridge final {
Status Activate(std::uintptr_t image_base);
Status PatchVtableLocked();
bool RestoreVtableLocked();
int DeviceCount();
void* DeviceInfo(void* result, int index);
int CurrentDevice();
void SelectDevice(int index);
static int GetInputDeviceCount(void *self);
static void *GetInputDeviceInfo(void *result, void *self, int index);
static int GetCurrentInputDevice(void *self);
static void SetCurrentInputDevice(void *self, int index);
int DeviceCount(bool input = false);
void *DeviceInfo(void *result, int index, bool input = false);
int CurrentDevice(bool input = false);
void SelectDevice(int index, bool input = false);
void ConstructGuestString(void* destination, std::string_view value) const;
std::unique_ptr<NativeInputCapture> capture_;
bool capture_profile_supported_ = false;
std::array<std::uintptr_t, 6> original_capture_methods_{};
mutable std::mutex mutex_;
compat::FmodOutputDeviceBridgeProfile profile_;
std::vector<MenuDevice> devices_;
std::array<std::uintptr_t, 4> original_methods_{};
std::vector<MenuDevice> input_devices_;
int selected_input_index_ = 0;
std::array<std::uintptr_t, 8> original_methods_{};
std::uintptr_t library_base_ = 0;
std::uintptr_t* vtable_ = nullptr;
void* string_constructor_ = nullptr;
@@ -36,6 +36,10 @@ Status ConfigureSdlRecordingDevice(
Status GetConfiguredSdlRecordingDevice(std::uint32_t* recording_device_id,
std::string* resolved_name);
// Migrates live captures without changing their format or callback buffers.
Status SwitchSdlRecordingDevice(std::uint32_t recording_device_id,
std::string *resolved_name);
using AudioCaptureDataCallback = void (*)(void* context,
std::size_t size_bytes);
+53
View File
@@ -0,0 +1,53 @@
#ifndef MOCKTAIL_GRAPHICS_ETC2_DECODER_H_
#define MOCKTAIL_GRAPHICS_ETC2_DECODER_H_
#include <cstddef>
#include <cstdint>
namespace mocktail::graphics {
enum class EtcFormat {
kEtc2Rgb8,
kEtc2Rgb8A1,
kEtc2Rgba8,
kEacR11,
kEacR11Signed,
kEacRg11,
kEacRg11Signed,
};
std::size_t EtcBlockBytes(EtcFormat format);
std::size_t EtcDecodedTexelBytes(EtcFormat format);
bool DecodeEtcImage(EtcFormat format, const std::uint8_t* source,
std::size_t source_bytes, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination,
std::size_t destination_bytes);
bool DecodeEtcImageBlockRows(EtcFormat format, const std::uint8_t* source,
std::size_t source_bytes, std::uint32_t width,
std::uint32_t height,
std::uint32_t first_block_row,
std::uint32_t block_row_count,
std::uint8_t* destination,
std::size_t destination_bytes);
struct EtcDecodeJob {
EtcFormat format = EtcFormat::kEtc2Rgb8;
const std::uint8_t* source = nullptr;
std::size_t source_bytes = 0;
std::uint32_t width = 0;
std::uint32_t height = 0;
std::uint8_t* destination = nullptr;
std::size_t destination_bytes = 0;
bool ok = false;
};
// Synchronous; job destination buffers must not overlap.
void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
unsigned worker_count);
} // namespace mocktail::graphics
#endif // MOCKTAIL_GRAPHICS_ETC2_DECODER_H_
@@ -0,0 +1,36 @@
#ifndef MOCKTAIL_GRAPHICS_GLES_TEXT_OVERLAY_COMPOSITOR_H_
#define MOCKTAIL_GRAPHICS_GLES_TEXT_OVERLAY_COMPOSITOR_H_
#include <memory>
#include "mocktail/graphics/text_overlay_frame.h"
struct SDL_Window;
namespace mocktail::graphics {
struct GlesTextOverlaySource {
bool (*may_present)() = nullptr;
bool (*query)(MocktailTextOverlayFrameInfo *) = nullptr;
bool (*copy)(std::uint64_t, void *, std::size_t) = nullptr;
};
// Composites the host text editor before SDL swaps the game's GLES backbuffer.
// Draw and destruction use the owning GL context when it is current. If that
// context is no longer current at destruction, its destruction frees the GL
// resources. Create a new compositor when creating a new context.
class GlesTextOverlayCompositor {
public:
explicit GlesTextOverlayCompositor(GlesTextOverlaySource source);
~GlesTextOverlayCompositor();
bool Draw(SDL_Window *window);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace mocktail::graphics
#endif // MOCKTAIL_GRAPHICS_GLES_TEXT_OVERLAY_COMPOSITOR_H_
@@ -4,7 +4,7 @@
#include <cstddef>
#include <cstdint>
// Process-local ABI with libvulkan.so. Pixels are RGBA8; consumers must copy a
// Process-local ABI for host compositors. Pixels are RGBA8; consumers must copy a
// matching revision before the producer clears its storage.
struct MocktailTextOverlayFrameInfo {
static constexpr std::uint32_t kAbiVersion = 2;
@@ -0,0 +1,55 @@
#ifndef MOCKTAIL_GRAPHICS_TEXTURE_OVERRIDE_H_
#define MOCKTAIL_GRAPHICS_TEXTURE_OVERRIDE_H_
#include <cstddef>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace mocktail::graphics {
struct RgbaImage {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::vector<std::uint8_t> pixels;
};
std::uint64_t HashBytes(const std::uint8_t* data, std::size_t size);
std::string HashName(std::uint64_t hash);
bool ReadPngRgba(const std::string& path, RgbaImage* image);
bool WritePngRgba(const std::string& path, const RgbaImage& image);
void ResampleRgba(const RgbaImage& source, 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::uint32_t width,
std::uint32_t height, std::uint8_t* destination);
class TextureOverrides {
public:
TextureOverrides(std::string override_dir, std::string dump_dir);
static TextureOverrides FromEnvironment();
bool enabled() const { return !override_dir_.empty() || !dump_dir_.empty(); }
bool dumping() const { return !dump_dir_.empty(); }
std::shared_ptr<const RgbaImage> Lookup(std::uint64_t hash);
void Dump(std::uint64_t hash, std::uint32_t width, std::uint32_t height,
const std::uint8_t* rgba);
private:
std::string override_dir_;
std::string dump_dir_;
std::mutex mutex_;
std::unordered_map<std::uint64_t, std::shared_ptr<const RgbaImage>> cache_;
std::unordered_set<std::uint64_t> dumped_;
};
} // namespace mocktail::graphics
#endif // MOCKTAIL_GRAPHICS_TEXTURE_OVERRIDE_H_
@@ -0,0 +1,102 @@
#ifndef MOCKTAIL_GRAPHICS_VULKAN_ETC2_EMULATION_H_
#define MOCKTAIL_GRAPHICS_VULKAN_ETC2_EMULATION_H_
#include <vulkan/vulkan.h>
#include <cstdint>
#include "mocktail/graphics/etc2_decoder.h"
namespace mocktail::graphics {
struct Etc2EmulatedFormat {
VkFormat host_format = VK_FORMAT_UNDEFINED;
EtcFormat etc_format = EtcFormat::kEtc2Rgb8;
};
bool LookupEmulatedEtc2Format(VkFormat format, Etc2EmulatedFormat* out);
VkFormatFeatureFlags EmulatedEtc2FormatFeatures(
VkFormatFeatureFlags host_features);
bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent,
std::uint32_t layer_count, VkDeviceSize* compressed,
VkDeviceSize* decoded);
bool Etc2SupportAdvertised(const char* value);
bool Etc2SupportAdvertised();
inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
std::uint32_t SmallTextureUpscale(const char* value);
class VulkanEtc2Emulation final {
public:
VulkanEtc2Emulation();
~VulkanEtc2Emulation();
VulkanEtc2Emulation(const VulkanEtc2Emulation&) = delete;
VulkanEtc2Emulation& operator=(const VulkanEtc2Emulation&) = delete;
bool PhysicalDeviceNeedsEmulation(VkPhysicalDevice physical_device,
PFN_vkGetPhysicalDeviceFeatures host);
void RegisterDevice(VkDevice device, VkPhysicalDevice physical_device,
bool emulated,
const VkPhysicalDeviceMemoryProperties& memory,
PFN_vkGetDeviceProcAddr get_device_proc_addr);
void DestroyDevice(VkDevice device);
VkResult CreateImage(VkDevice device, const VkImageCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImage* image);
void DestroyImage(VkDevice device, VkImage image,
const VkAllocationCallbacks* allocator);
VkResult CreateImageView(VkDevice device,
const VkImageViewCreateInfo* create_info,
const VkAllocationCallbacks* allocator,
VkImageView* view);
VkResult BindBufferMemory(VkDevice device, VkBuffer buffer,
VkDeviceMemory memory, VkDeviceSize offset);
VkResult BindBufferMemory2(VkDevice device, std::uint32_t count,
const VkBindBufferMemoryInfo* infos);
VkResult MapMemory(VkDevice device, VkDeviceMemory memory,
VkDeviceSize offset, VkDeviceSize size,
VkMemoryMapFlags flags, void** data);
VkResult MapMemory2(VkDevice device, const VkMemoryMapInfo* info,
void** data);
void UnmapMemory(VkDevice device, VkDeviceMemory memory);
VkResult UnmapMemory2(VkDevice device, const VkMemoryUnmapInfo* info);
void FreeMemory(VkDevice device, VkDeviceMemory memory,
const VkAllocationCallbacks* allocator);
void DestroyBuffer(VkDevice device, VkBuffer buffer,
const VkAllocationCallbacks* allocator);
void CmdCopyBufferToImage(VkDevice device, VkCommandBuffer command_buffer,
VkBuffer source, VkImage destination,
VkImageLayout layout, std::uint32_t region_count,
const VkBufferImageCopy* regions);
void CmdCopyBufferToImage2(VkDevice device, VkCommandBuffer command_buffer,
const VkCopyBufferToImageInfo2* info);
void CmdCopyImage(VkDevice device, VkCommandBuffer command_buffer,
VkImage source, VkImageLayout source_layout,
VkImage destination, VkImageLayout destination_layout,
std::uint32_t region_count, const VkImageCopy* regions);
void CmdCopyImage2(VkDevice device, VkCommandBuffer command_buffer,
const VkCopyImageInfo2* info);
void CmdExecuteCommands(VkDevice device, VkCommandBuffer command_buffer,
std::uint32_t count,
const VkCommandBuffer* secondaries);
// Resubmission must reread the source bytes.
void PrepareSubmit(const VkCommandBuffer* command_buffers,
std::uint32_t count);
// Call only after the command buffer is no longer pending.
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
struct State;
private:
State* state_;
};
} // namespace mocktail::graphics
#endif // MOCKTAIL_GRAPHICS_VULKAN_ETC2_EMULATION_H_
+23
View File
@@ -0,0 +1,23 @@
#ifndef MOCKTAIL_RUNTIME_DEVICE_MEMORY_PROFILE_H_
#define MOCKTAIL_RUNTIME_DEVICE_MEMORY_PROFILE_H_
#include <cstdint>
namespace mocktail {
namespace runtime {
struct DeviceMemoryProfile {
std::int32_t total_memory_mb = 0;
std::int32_t memory_class_mb = 0;
std::int32_t large_memory_class_mb = 0;
std::int64_t low_memory_killer_background_threshold = 0;
std::int64_t low_memory_killer_foreground_threshold = 0;
bool low_ram_device = false;
};
DeviceMemoryProfile BuildDeviceMemoryProfile(std::uint64_t host_memory_bytes);
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_DEVICE_MEMORY_PROFILE_H_
+66
View File
@@ -0,0 +1,66 @@
#ifndef MOCKTAIL_RUNTIME_DISPLAY_SIZE_H_
#define MOCKTAIL_RUNTIME_DISPLAY_SIZE_H_
#include <cstdint>
#include <limits>
namespace mocktail {
namespace runtime {
struct DisplaySize {
std::int32_t width = 1920;
std::int32_t height = 1080;
};
// Physical pixels; window size is captured at startup.
inline constexpr const char* kDisplaySizeEnvironment =
"MOCKTAIL_DISPLAY_SIZE_INTERNAL";
inline constexpr const char* kWindowSizeEnvironment =
"MOCKTAIL_WINDOW_SIZE_INTERNAL";
inline DisplaySize ParseDisplaySize(const char* value) {
const DisplaySize fallback;
if (value == nullptr) {
return fallback;
}
std::int64_t parts[2] = {0, 0};
const char* cursor = value;
for (int index = 0; index < 2; ++index) {
if (*cursor < '0' || *cursor > '9') {
return fallback;
}
std::int64_t parsed = 0;
while (*cursor >= '0' && *cursor <= '9') {
parsed = parsed * 10 + (*cursor - '0');
if (parsed > std::numeric_limits<std::int32_t>::max()) {
return fallback;
}
++cursor;
}
if (parsed == 0) {
return fallback;
}
parts[index] = parsed;
if (index == 0) {
if (*cursor != 'x') {
return fallback;
}
++cursor;
}
}
if (*cursor != '\0') {
return fallback;
}
return {static_cast<std::int32_t>(parts[0]),
static_cast<std::int32_t>(parts[1])};
}
inline std::int32_t PixelsToMillimetersAt160Dpi(std::int32_t pixels) {
return static_cast<std::int32_t>(static_cast<std::int64_t>(pixels) * 254 /
1600);
}
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_DISPLAY_SIZE_H_
+28
View File
@@ -0,0 +1,28 @@
#ifndef MOCKTAIL_RUNTIME_FLEASION_H_
#define MOCKTAIL_RUNTIME_FLEASION_H_
#include <filesystem>
#include <string>
#include "runtime/runtime_config.h"
#include "runtime/runtime_paths.h"
namespace mocktail::runtime {
struct FleasionPreparation {
std::filesystem::path certificate;
std::filesystem::path base_bundle;
std::filesystem::path bundle;
std::string error;
explicit operator bool() const { return error.empty(); }
};
// Generates a private, replaceable trust bundle outside the managed payload.
// Reads certificates only; never reads Fleasion's private key or changes it.
FleasionPreparation PrepareFleasion(const RuntimeConfig& config,
const Environment& environment,
const RuntimePaths& paths);
} // namespace mocktail::runtime
#endif
+4
View File
@@ -65,6 +65,10 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
// answers microphone authorization from capture configuration. No permission
// query opens a recording device.
bool MergeAudioDeviceMenuClientSettingsOverrides(std::string_view base_json,
std::string *merged_json,
std::string *error);
bool MergeAudioCaptureClientSettingsOverrides(bool microphone_enabled,
std::string_view base_json,
std::string* merged_json,
+15
View File
@@ -0,0 +1,15 @@
#ifndef MOCKTAIL_RUNTIME_PROCESS_DIAGNOSTICS_H_
#define MOCKTAIL_RUNTIME_PROCESS_DIAGNOSTICS_H_
namespace mocktail::runtime {
enum class ProcessDiagnosticStage { kStartup, kNativeRuntime, kShutdown };
// Installs a diagnostic handler only if SIGXCPU still has its default action.
// The handler logs to stderr and re-delivers SIGXCPU with the default action.
void InstallCpuLimitDiagnostics();
void LogProcessDiagnostics(ProcessDiagnosticStage stage);
} // namespace mocktail::runtime
#endif // MOCKTAIL_RUNTIME_PROCESS_DIAGNOSTICS_H_
+16
View File
@@ -0,0 +1,16 @@
#ifndef MOCKTAIL_RUNTIME_PROCESS_LAUNCH_POLICY_H_
#define MOCKTAIL_RUNTIME_PROCESS_LAUNCH_POLICY_H_
#include <string>
namespace mocktail::runtime {
// Call only for interactive launches, before creating any threads. Removes an
// inherited soft CPU limit when the hard limit is unlimited and demotes the
// calling thread from FIFO/RR scheduling. Returns diagnostics for the session
// log, including failures; never changes hard limits or signal dispositions.
std::string ApplyInteractiveProcessLaunchPolicy();
} // namespace mocktail::runtime
#endif // MOCKTAIL_RUNTIME_PROCESS_LAUNCH_POLICY_H_
+2
View File
@@ -138,6 +138,8 @@ struct RobloxInputSnapshot {
uint64_t keyboard_events = 0;
uint64_t ignored_text_events = 0;
uint64_t text_events = 0;
// Zero after the host editor finishes, even if Roblox sends no hide callback.
uint64_t text_focus_generation = 0;
uint64_t native_errors = 0;
uint32_t active_mouse_buttons = 0;
uint32_t active_touches = 0;
+12
View File
@@ -119,6 +119,13 @@ class RuntimeConfig {
}
bool ca_bundle_valid() const { return ca_bundle_valid_; }
bool use_system_proxy() const { return use_system_proxy_; }
bool fleasion_enabled() const { return fleasion_enabled_; }
bool fleasion_valid() const { return fleasion_valid_; }
const std::string& fleasion_proxy_mode() const { return fleasion_proxy_mode_; }
int fleasion_proxy_port() const { return fleasion_proxy_port_; }
const std::optional<std::filesystem::path>& fleasion_ca_certificate() const {
return fleasion_ca_certificate_;
}
const DiscordRpcConfig& discord_rpc() const { return discord_rpc_; }
bool discord_rpc_valid() const { return discord_rpc_valid_; }
@@ -152,6 +159,11 @@ class RuntimeConfig {
std::string audio_input_device_ = "default";
bool audio_input_device_valid_ = true;
bool use_system_proxy_ = false;
bool fleasion_enabled_ = false;
bool fleasion_valid_ = true;
std::string fleasion_proxy_mode_ = "env";
int fleasion_proxy_port_ = 58443;
std::optional<std::filesystem::path> fleasion_ca_certificate_;
std::optional<NetworkProxyConfig> network_proxy_;
std::optional<std::filesystem::path> ca_bundle_;
bool ca_bundle_valid_ = true;
-11
View File
@@ -1,11 +0,0 @@
# pkgforge-dev/archlinux-pkgs-debloated, continuous release, 2026-09-11.
# These assets are rolling. Refresh names/hashes together; do not bypass a
# checksum mismatch. Only the disposable AppImage build container installs them.
19e57cf78e309052b1ce55293374599f19fbc56cd2ac572191f475e718b42503 ffmpeg-mini-x86_64.pkg.tar.zst
5a796f6846a898d5f706d92ae34a70d6f6cf28595369ccafbfda21ad6ccc6b7f gdk-pixbuf2-mini-x86_64.pkg.tar.zst
9a42b41a03fc493a9bc4d00c9882e34ee28b5239a79175cc47e1dfe869f5d4a6 gtk4-mini-x86_64.pkg.tar.zst
8bec7844a49eb7e3efc020ba0858e69cfb4fed64b83c116df04b4179cc2fa1a6 icu-mini-x86_64.pkg.tar.zst
91fd7550208a306ff4e43fe10f405db01f2bc1ce8bdad5469229c05185201983 librsvg-mini-x86_64.pkg.tar.zst
d5220136f8d9b4eaaf173a44d3f0092e97b61cfe9f5d627c570c6e81add9e789 libxml2-mini-x86_64.pkg.tar.zst
90ec938ec71e62478745185f76d64253cc0a7ac2e0a18aaff0fb59cd70eb1f92 mesa-mini-x86_64.pkg.tar.zst
857f7d895a471786b7920b430fbf0b91078bfb8efc6bf332c27928f6104929ae opus-mini-x86_64.pkg.tar.zst
+12
View File
@@ -0,0 +1,12 @@
# Allowed reduced packages from pkgforge-dev/archlinux-pkgs-debloated.
# continuous is rolling: the installer snapshots GitHub asset IDs and SHA256
# digests together at build time, and verifies every archive before installation.
# Only the disposable AppImage build container installs these packages.
ffmpeg-mini-x86_64.pkg.tar.zst
gdk-pixbuf2-mini-x86_64.pkg.tar.zst
gtk4-mini-x86_64.pkg.tar.zst
icu-mini-x86_64.pkg.tar.zst
librsvg-mini-x86_64.pkg.tar.zst
libxml2-mini-x86_64.pkg.tar.zst
mesa-mini-x86_64.pkg.tar.zst
opus-mini-x86_64.pkg.tar.zst
+1
View File
@@ -16,6 +16,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libutf8proc'
'libyaml'
'minizip'
+6 -5
View File
@@ -1,7 +1,7 @@
pkgbase = mocktail-bin
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
pkgver = 1.0.3
pkgrel = 2
pkgver = 1.0.4
pkgrel = 1
url = https://github.com/komaruworld/mocktail
arch = x86_64
license = Apache-2.0
@@ -17,6 +17,7 @@ pkgbase = mocktail-bin
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -31,10 +32,10 @@ pkgbase = mocktail-bin
provides = mocktail
conflicts = mocktail
conflicts = mocktail-git
noextract = mocktail-1.0.3-2-x86_64.pkg.tar.zst
noextract = mocktail-1.0.4-x86_64.pkg.tar.zst
options = !debug
options = !strip
source = mocktail-1.0.3-2-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.3/mocktail-1.0.3-2-x86_64.pkg.tar.zst
sha256sums = be945181d73d28d9bf09a93927a215d03a3a8b479ffddab3378a8113b56e4b99
source = mocktail-1.0.4-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.4/mocktail-1.0.4-x86_64.pkg.tar.zst
sha256sums = d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7
pkgname = mocktail-bin
+6 -4
View File
@@ -1,8 +1,8 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
pkgname=mocktail-bin
pkgver=1.0.3
pkgrel=2
pkgver=1.0.4
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -35,10 +36,11 @@ optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
conflicts=('mocktail' 'mocktail-git')
options=('!debug' '!strip')
_archive="mocktail-${pkgver}-${pkgrel}-${CARCH}.pkg.tar.zst"
# Release asset names do not include the AUR package release.
_archive="mocktail-${pkgver}-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
noextract=("${_archive}")
sha256sums=('be945181d73d28d9bf09a93927a215d03a3a8b479ffddab3378a8113b56e4b99')
sha256sums=('d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7')
package() {
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
+1
View File
@@ -24,6 +24,7 @@ pkgbase = mocktail-git
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
+1
View File
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
+4 -3
View File
@@ -1,7 +1,7 @@
pkgbase = mocktail
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.3
pkgrel = 2
pkgver = 1.0.4
pkgrel = 1
url = https://github.com/komaruworld/mocktail
arch = x86_64
license = Apache-2.0
@@ -24,6 +24,7 @@ pkgbase = mocktail
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -39,7 +40,7 @@ pkgbase = mocktail
conflicts = mocktail-git
options = !debug
options = !lto
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.3
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.4
sha256sums = SKIP
pkgname = mocktail
+3 -3
View File
@@ -1,8 +1,8 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
pkgname=mocktail
pkgver=1.0.3
pkgrel=2
pkgver=1.0.4
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -42,7 +43,6 @@ makedepends=(
)
optdepends=('gamemode: Feral GameMode integration')
conflicts=('mocktail-bin' 'mocktail-git')
# The 1.0.3 tag predates the source-level FORTIFY/LTO isolation fix.
options=('!debug' '!lto')
source=(
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
+2
View File
@@ -23,6 +23,7 @@
elfutils,
libglvnd,
libplacebo,
libpng,
libsoup_3,
libutf8proc,
libyaml,
@@ -92,6 +93,7 @@ stdenv.mkDerivation (finalAttrs: {
elfutils
libglvnd
libplacebo
libpng
libsoup_3
libutf8proc
libyaml
@@ -55,6 +55,19 @@
<binary>mocktail</binary>
</provides>
<releases>
<release version="1.0.4" date="2026-09-11">
<description>
<ul>
<li>Fixed login issues</li>
<li>Fixed low-resolution textures, skybox rendering and HiDPI scaling.</li>
<li>Added vc support.</li>
<li>Added Xbox and PlayStation gamepad support.</li>
<li>Added Nix support</li>
<li>Improved text input, fullscreen mode, window sizing, and mouse controls.</li>
<li>Updated support for Roblox 2.736.1408.</li>
</ul>
</description>
</release>
<release version="1.0.3" date="2026-08-16">
<description>
<ul>
View File
Whitespace-only changes.
+121 -35
View File
@@ -1271,6 +1271,33 @@ def has_fmod_string_constructor_contract(image: ElfImage, rva: int) -> bool:
return False
def has_fmod_device_lists_select_contract(image: ElfImage, rva: int) -> bool:
# Keep in sync with compat/fmod_output_device_contract.h. The fixed bytes
# bind the int-index ABI, count slot, clamp, and FMOD system-object offset.
expected = bytes.fromhex(
"55 48 89 e5 41 57 41 56 41 54 53 48 83 ec 50 41 "
"89 f7 49 89 fe 4c 8b 25 00 00 00 00 49 8b 04 24 "
"48 89 45 d8 80 3d 00 00 00 00 00 74 25 49 8b 04 "
"24 48 3b 45 d8 0f 85 00 00 00 00 4c 89 f7 44 89 "
"fe 48 83 c4 50 5b 41 5c 41 5e 41 5f 5d e9 00 00 "
"00 00 49 8b 06 4c 89 f7 ff 50 28 44 39 f8 0f 8e "
"00 00 00 00 31 db 45 85 ff 41 0f 4f df 49 8b 7e "
"28 89 de e8 00 00 00 00"
)
try:
image.require_code_rva(rva, len(expected))
offset = image.rva_to_offset(rva, len(expected))
code = image.bytes_at(offset, len(expected), "FMOD device-list selector")
except AnalyzerError:
return False
relocated = {index for start in (24, 38, 55, 78, 96, 116)
for index in range(start, start + 4)}
return len(code) == len(expected) and all(
index in relocated or actual == wanted
for index, (actual, wanted) in enumerate(zip(code, expected))
)
def load_reference_runtime_profile(
paths: Sequence[Path], reference_build_id: str
) -> dict[str, Any]:
@@ -1306,7 +1333,11 @@ def load_reference_runtime_profile(
}
if (
not isinstance(bridge, dict)
or set(bridge) != fields
or not fields <= set(bridge)
or set(bridge) - fields - {"vtable_layout_version"} - {
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva",
}
or profile.get("allow_host_abi_bridges") is not True
):
raise AnalyzerError(
@@ -1315,12 +1346,32 @@ def load_reference_runtime_profile(
discovered["fmod_output_device_bridge"] = {
field: parse_rva(value, f"reference FMOD {field}")
for field, value in bridge.items()
if field != "vtable_layout_version"
}
layout = bridge.get("vtable_layout_version", 1)
if type(layout) is not int or layout not in (1, 2):
raise AnalyzerError("reference FMOD vtable layout is unsupported")
if layout != 1:
discovered["fmod_output_device_bridge"]["vtable_layout_version"] = layout
for field, value in discovered.items():
if field in result and result[field] != value:
raise AnalyzerError(
f"reference compatibility manifests disagree on {field}"
)
if field in result:
previous = result[field]
if field == "fmod_output_device_bridge":
input_fields = {
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva",
}
compatible = ((set(previous) ^ set(value)) <= input_fields and
all(previous[key] == value[key]
for key in previous.keys() & value.keys()))
if compatible:
value = {**previous, **value}
else:
compatible = previous == value
if not compatible:
raise AnalyzerError(
f"reference compatibility manifests disagree on {field}"
)
result[field] = value
return result
@@ -1329,12 +1380,13 @@ def derive_fmod_output_device_bridge(
reference: ElfImage,
candidate: ElfImage,
source: dict[str, int],
) -> dict[str, str]:
) -> dict[str, Any]:
source_layout = source.get("vtable_layout_version", 1)
indexes = {
"count_method_rva": 5,
"info_method_rva": 6,
"current_method_rva": 7,
"select_method_rva": 17,
"current_method_rva": 8 if source_layout == 2 else 7,
"select_method_rva": 19 if source_layout == 2 else 17,
}
source_vtable = source["vtable_rva"]
reference.require_relro_rva(source_vtable, (max(indexes.values()) + 1) * 8)
@@ -1368,16 +1420,20 @@ def derive_fmod_output_device_bridge(
minimum_anchor_bytes=3,
),
).rva
select_method = unique_semantic_signature_match(
reference,
candidate,
SignatureSpec(
"fmod-output-select",
source["select_method_rva"],
18,
minimum_anchor_bytes=3,
),
).rva
try:
select_method = unique_semantic_signature_match(
reference,
candidate,
SignatureSpec(
"fmod-output-select",
source["select_method_rva"],
18,
minimum_anchor_bytes=3,
),
).rva
except AnalyzerError:
# The device-list layout has a separately checked selector contract.
select_method = None
count_candidates = {
match.rva
for match in find_signature_matches(
@@ -1416,32 +1472,35 @@ def derive_fmod_output_device_bridge(
continue
vtable = offset - indexes["info_method_rva"] * 8
count_method = relocations.get(vtable + indexes["count_method_rva"] * 8)
current_method = relocations.get(
vtable + indexes["current_method_rva"] * 8
)
if (
vtable % 8 == 0
and count_method in count_candidates
and current_method in current_candidates
and count_method != current_method
and relocations.get(vtable + indexes["select_method_rva"] * 8)
== select_method
):
for layout, current_index, select_index in ((1, 7, 17), (2, 8, 19)):
current_method = relocations.get(vtable + current_index * 8)
selected = relocations.get(vtable + select_index * 8)
if (
vtable % 8 != 0
or count_method not in count_candidates
or current_method not in current_candidates
or count_method == current_method
or selected is None
):
continue
try:
candidate.require_relro_rva(
vtable, (max(indexes.values()) + 1) * 8
)
candidate.require_relro_rva(vtable, (select_index + 1) * 8)
except AnalyzerError:
continue
vtables.append((vtable, count_method, current_method))
if layout == 1:
if source_layout != 1 or select_method is None or selected != select_method:
continue
elif not has_fmod_device_lists_select_contract(candidate, selected):
continue
vtables.append((vtable, count_method, current_method, selected, layout))
if len(vtables) != 1:
raise AnalyzerError(
f"FMOD output vtable matched {len(vtables)} candidate locations"
)
vtable, count_method, current_method = vtables[0]
vtable, count_method, current_method, select_method, layout = vtables[0]
if not has_fmod_string_constructor_contract(candidate, string_constructor):
raise AnalyzerError("candidate FMOD string constructor contract changed")
return {
result = {
"vtable_rva": format_rva(vtable),
"string_constructor_rva": format_rva(string_constructor),
"count_method_rva": format_rva(count_method),
@@ -1449,6 +1508,33 @@ def derive_fmod_output_device_bridge(
"current_method_rva": format_rva(current_method),
"select_method_rva": format_rva(select_method),
}
if layout != 1:
result["vtable_layout_version"] = layout
input_fields = (
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva",
)
if any(field in source for field in input_fields):
if not all(field in source for field in input_fields):
raise AnalyzerError("reference FMOD input profile is incomplete")
source_slots = (9, 10, 12, 18) if source_layout == 2 else (8, 9, 10, 16)
target_slots = (9, 10, 12, 18) if layout == 2 else (8, 9, 10, 16)
for field, old_slot, new_slot, length in zip(
input_fields, source_slots, target_slots, (4, 16, 12, 16)
):
if reference_relocations.get(source_vtable + old_slot * 8) != source[field]:
raise AnalyzerError("reference FMOD input slot changed")
target = relocations.get(vtable + new_slot * 8)
matches = find_signature_matches(
reference, candidate,
SignatureSpec(field, source[field], length, minimum_anchor_bytes=3),
allow_multiple_candidates=True,
)
if target is None or target not in {match.rva for match in matches}:
raise AnalyzerError(f"candidate FMOD input contract changed: {field}")
candidate.require_code_rva(target)
result[field] = format_rva(target)
return result
def derive_runtime_compatibility(
+116 -30
View File
@@ -4,36 +4,122 @@
set -Eeuo pipefail
# The reduced packages are intentionally limited to the disposable builder.
# Never replace a developer's desktop / graphics packages on the host.
[[ -f /.dockerenv || -f /run/.containerenv ]] || {
printf 'AnyLinux dependency installation requires a disposable container\n' >&2
exit 1
}
[[ "$(id -u)" == 0 ]] || {
printf 'AnyLinux dependency installation requires container root\n' >&2
AnyLinuxDependencyDie() {
printf '[anylinux-dependencies] error: %s\n' "$*" >&2
exit 1
}
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.sha256"
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
readonly BASE_URL=https://github.com/pkgforge-dev/archlinux-pkgs-debloated/releases/download/continuous
declare -a packages=()
while read -r digest filename remainder; do
[[ -n "${digest}" && "${digest}" != \#* ]] || continue
[[ "${digest}" =~ ^[0-9a-f]{64}$ &&
"${filename}" =~ ^[a-z0-9-]+-x86_64\.pkg\.tar\.zst$ &&
-z "${remainder}" ]] || {
printf 'Invalid AnyLinux dependency manifest entry\n' >&2
exit 1
}
curl --fail --location --retry 3 --output "${DOWNLOADS}/${filename}" \
"${BASE_URL}/${filename}"
printf '%s %s\n' "${digest}" "${DOWNLOADS}/${filename}" |
sha256sum --check --strict
packages+=("${DOWNLOADS}/${filename}")
done <"${MANIFEST}"
(( ${#packages[@]} > 0 ))
pacman -U --noconfirm --needed "${packages[@]}"
AnyLinuxDependencyRequireContainer() {
# Never replace a developer's desktop / graphics packages on the host.
[[ -f /.dockerenv || -f /run/.containerenv ]] ||
AnyLinuxDependencyDie 'installation requires a disposable container'
[[ "$(id -u)" == 0 ]] ||
AnyLinuxDependencyDie 'installation requires container root'
}
AnyLinuxDependencySnapshot() {
local manifest="$1" release="$2" filename remainder names_json
local -a names=()
local -A seen=()
while read -r filename remainder || [[ -n "${filename}" ]]; do
[[ -n "${filename}" && "${filename}" != \#* ]] || continue
[[ "${filename}" =~ ^[a-z0-9-]+-mini-x86_64\.pkg\.tar\.zst$ &&
-z "${remainder}" && -z "${seen[${filename}]:-}" ]] ||
AnyLinuxDependencyDie 'invalid or duplicate dependency manifest entry'
seen["${filename}"]=1
names+=("${filename}")
done <"${manifest}"
(( ${#names[@]} > 0 )) || AnyLinuxDependencyDie 'empty dependency manifest'
names_json="$(printf '%s\n' "${names[@]}" | jq --raw-input --slurp \
'split("\n")[:-1]')"
# continuous assets are replaced on upstream rebuilds. Resolve all required
# names and GitHub-computed digests in one response, then download by asset ID
# rather than by rolling filename. These are per-build integrity checks, not
# immutable version pins. Never accept absent digests or compute our own trust.
jq --exit-status --raw-output --argjson names "${names_json}" \
--arg base 'https://api.github.com/repos/pkgforge-dev/archlinux-pkgs-debloated/releases/assets/' '
(if .tag_name != "continuous" or (.assets | type) != "array" then
error("invalid continuous release metadata")
else .assets end) as $assets |
$names[] as $name |
[$assets[] | select(.name == $name)] as $matches |
if ($matches | length) != 1 then
error("missing or duplicate dependency asset: " + $name)
else $matches[0] end |
if .state != "uploaded" or (.id | type) != "number" or
.id <= 0 or .id != (.id | floor) or
(.digest | type) != "string" then
error("invalid dependency asset metadata: " + $name)
elif (.digest | test("^sha256:[0-9a-f]{64}$") | not) or
.url != ($base + (.id | tostring)) then
error("invalid dependency digest or URL: " + $name)
else
[(.digest | ltrimstr("sha256:")), $name, .url] | @tsv
end
' "${release}" || AnyLinuxDependencyDie 'cannot resolve verified dependency assets'
}
AnyLinuxDownloadDependencies() {
local manifest="$1" downloads="$2" attempt digest filename url failed
local -a curl_options=(
--fail --silent --show-error --location --retry 3
--proto '=https' --proto-redir '=https'
--connect-timeout 15 --max-time 180
--header 'X-GitHub-Api-Version: 2022-11-28'
)
if [[ -n "${GH_TOKEN:-}" ]]; then
curl_options+=(--header "Authorization: Bearer ${GH_TOKEN}")
fi
for attempt in 1 2 3; do
curl "${curl_options[@]}" --header 'Accept: application/vnd.github+json' \
--output "${downloads}/release.json" \
https://api.github.com/repos/pkgforge-dev/archlinux-pkgs-debloated/releases/tags/continuous ||
AnyLinuxDependencyDie 'cannot fetch upstream release metadata'
AnyLinuxDependencySnapshot "${manifest}" "${downloads}/release.json" \
>"${downloads}/dependencies.tsv"
: >"${downloads}/SHA256SUMS"
failed=false
while IFS=$'\t' read -r digest filename url; do
printf '[anylinux-dependencies] downloading %s (SHA256 %s)\n' \
"${filename}" "${digest}" >&2
if ! curl "${curl_options[@]}" --header 'Accept: application/octet-stream' \
--output "${downloads}/${filename}" "${url}"; then
failed=true
break
fi
printf '%s %s\n' "${digest}" "${filename}" >>"${downloads}/SHA256SUMS"
done <"${downloads}/dependencies.tsv"
if [[ "${failed}" == true ]]; then
# An upstream upload can delete an ID between resolution and download.
# Retry a complete snapshot, never mix old checksums with new filenames.
printf '[anylinux-dependencies] download failed; refreshing snapshot (%s/3)\n' \
"${attempt}" >&2
continue
fi
(cd -- "${downloads}" && sha256sum --check --strict SHA256SUMS) ||
AnyLinuxDependencyDie 'dependency checksum verification failed'
return 0
done
AnyLinuxDependencyDie 'dependency downloads failed after 3 snapshots'
}
AnyLinuxDependenciesMain() (
AnyLinuxDependencyRequireContainer
(( $# == 0 )) || AnyLinuxDependencyDie 'no arguments are supported'
readonly ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
readonly MANIFEST="${ROOT}/packaging/anylinux-dependencies.txt"
readonly DOWNLOADS="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-debloated.XXXXXX")"
trap 'rm -rf -- "${DOWNLOADS}"' EXIT
AnyLinuxDownloadDependencies "${MANIFEST}" "${DOWNLOADS}"
local digest filename url
local -a packages=()
while IFS=$'\t' read -r digest filename url; do
packages+=("${DOWNLOADS}/${filename}")
done <"${DOWNLOADS}/dependencies.tsv"
pacman -U --noconfirm --needed "${packages[@]}"
)
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
AnyLinuxDependenciesMain "$@"
fi
+3
View File
@@ -119,6 +119,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
util-linux'
INSTALL_ARGS='-S --needed --noconfirm'
;;
@@ -144,6 +145,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
util-linux'
if HostUsesMusl; then
PACKAGES="$PACKAGES gcompat"
@@ -172,6 +174,7 @@ libyaml
fontconfig
minizip
libplacebo
libpng
coreutils
findutils
util-linux'
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env bash
# Copyright 2026 Mocktail Project Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BUILD_DIR="${MOCKTAIL_BUILD_DIR:-${PROJECT_ROOT}/build}"
BINARY="${BUILD_DIR}/mocktail"
LIBROBLOX_PATH="${ROBLOX_LIB_PATH:-${PROJECT_ROOT}/rbx_bin/libroblox.so}"
if [[ "${LIBROBLOX_PATH}" != /* ]]; then
LIBROBLOX_PATH="${PROJECT_ROOT}/${LIBROBLOX_PATH}"
fi
target_user_home() {
local user="$1"
local entry
entry="$(getent passwd "${user}" 2>/dev/null || true)"
if [[ -z "${entry}" ]]; then
return 1
fi
local name passwd uid gid gecos home shell
IFS=: read -r name passwd uid gid gecos home shell <<< "${entry}"
printf '%s\n' "${home}"
}
configure_wayland_env() {
local uid="$1"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/${uid}}"
export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}"
export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=${XDG_RUNTIME_DIR}/bus}"
export MOCKTAIL_PREFER_WAYLAND="${MOCKTAIL_PREFER_WAYLAND:-1}"
# Leave SDL_VIDEODRIVER unset so the runtime policy can avoid native
# Wayland Vulkan WSI on NVIDIA. Explicit caller overrides remain untouched.
}
if [[ ! -f "${LIBROBLOX_PATH}" ]]; then
echo "[error] libroblox.so not found: ${LIBROBLOX_PATH}" >&2
echo " Put an x86_64 Roblox lib at rbx_bin/libroblox.so or set ROBLOX_LIB_PATH." >&2
exit 1
fi
if [[ ! -x "${BINARY}" ]]; then
cmake -S "${PROJECT_ROOT}" -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Debug
cmake --build "${BUILD_DIR}" -j"$(nproc)"
fi
cd "${PROJECT_ROOT}"
export ROBLOX_LIB_PATH="${LIBROBLOX_PATH}"
export MOCKTAIL_SOBER_MODE="${MOCKTAIL_SOBER_MODE:-1}"
export MOCKTAIL_ASSET_ROOT="${MOCKTAIL_ASSET_ROOT:-${PROJECT_ROOT}/rbx_bin/assets}"
export MOCKTAIL_ASSET_PATH="${MOCKTAIL_ASSET_PATH:-rbx_bin/assets/content}"
export MOCKTAIL_SOBER_BOOTSTRAP_ONLY="${MOCKTAIL_SOBER_BOOTSTRAP_ONLY:-0}"
export MOCKTAIL_GRAPHICS_BACKEND="${MOCKTAIL_GRAPHICS_BACKEND:-system}"
RUN_AS_USER="${MOCKTAIL_RUN_AS_USER:-}"
if [[ -n "${RUN_AS_USER}" && "${MOCKTAIL_RUNNER_REEXECED:-0}" == "0" &&
"$(id -un)" != "${RUN_AS_USER}" ]]; then
if [[ "$(id -u)" != "0" ]]; then
echo "[warn] not running as root; cannot switch GUI process to ${RUN_AS_USER}" >&2
elif ! id "${RUN_AS_USER}" >/dev/null 2>&1; then
echo "[warn] user not found: ${RUN_AS_USER}; running as $(id -un)" >&2
else
RUN_AS_UID="$(id -u "${RUN_AS_USER}")"
RUN_AS_HOME="$(target_user_home "${RUN_AS_USER}")"
configure_wayland_env "${RUN_AS_UID}"
echo "[info] switching GUI process to ${RUN_AS_USER} on ${WAYLAND_DISPLAY}"
exec runuser -u "${RUN_AS_USER}" -- env \
HOME="${RUN_AS_HOME}" \
USER="${RUN_AS_USER}" \
LOGNAME="${RUN_AS_USER}" \
XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR}" \
WAYLAND_DISPLAY="${WAYLAND_DISPLAY}" \
DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS}" \
SDL_VIDEODRIVER="${SDL_VIDEODRIVER:-}" \
MOCKTAIL_RUNNER_REEXECED=1 \
ROBLOX_LIB_PATH="${ROBLOX_LIB_PATH}" \
MOCKTAIL_BUILD_DIR="${BUILD_DIR}" \
MOCKTAIL_SOBER_MODE="${MOCKTAIL_SOBER_MODE}" \
MOCKTAIL_ASSET_ROOT="${MOCKTAIL_ASSET_ROOT}" \
MOCKTAIL_ASSET_PATH="${MOCKTAIL_ASSET_PATH}" \
MOCKTAIL_SOBER_BOOTSTRAP_ONLY="${MOCKTAIL_SOBER_BOOTSTRAP_ONLY}" \
MOCKTAIL_GRAPHICS_BACKEND="${MOCKTAIL_GRAPHICS_BACKEND}" \
MOCKTAIL_PREFER_WAYLAND="${MOCKTAIL_PREFER_WAYLAND}" \
MOCKTAIL_EGL_LIBRARY="${MOCKTAIL_EGL_LIBRARY:-}" \
MOCKTAIL_GLES_LIBRARY="${MOCKTAIL_GLES_LIBRARY:-}" \
MOCKTAIL_FORCE_X11="${MOCKTAIL_FORCE_X11:-0}" \
MOCKTAIL_ANGLE_FORCE_X11="${MOCKTAIL_ANGLE_FORCE_X11:-0}" \
"${BASH}" "${BASH_SOURCE[0]}" "$@"
fi
fi
if [[ -n "${RUN_AS_USER}" && "$(id -un)" == "${RUN_AS_USER}" ]]; then
configure_wayland_env "$(id -u)"
fi
if [[ "${MOCKTAIL_SOBER_BOOTSTRAP_ONLY}" != "0" ]]; then
export MOCKTAIL_STEP_APP_BRIDGE_APP_START="${MOCKTAIL_STEP_APP_BRIDGE_APP_START:-0}"
export MOCKTAIL_STEP_START_LUA_APP_DM="${MOCKTAIL_STEP_START_LUA_APP_DM:-0}"
fi
echo "[info] starting Mocktail"
echo "[info] SDL video driver: ${SDL_VIDEODRIVER:-auto}"
echo "[info] graphics backend: ${MOCKTAIL_GRAPHICS_BACKEND}"
echo "[info] display: DISPLAY=${DISPLAY:-unset} WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-unset}"
echo "[info] bootstrap-only: ${MOCKTAIL_SOBER_BOOTSTRAP_ONLY}"
echo "[info] press Ctrl+C to stop"
if [[ -n "${MOCKTAIL_LOG_PATH:-}" ]]; then
set +e
"${BINARY}" "$@" 2>&1 | tee "${MOCKTAIL_LOG_PATH}"
binary_status="${PIPESTATUS[0]}"
set -e
exit "${binary_status}"
fi
exec "${BINARY}" "$@"
+225
View File
@@ -0,0 +1,225 @@
#ifndef MOCKTAIL_AUDIO_NATIVE_INPUT_CAPTURE_H_
#define MOCKTAIL_AUDIO_NATIVE_INPUT_CAPTURE_H_
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <map>
#include <memory>
#include <mutex>
#include <vector>
#include "mocktail/audio/sdl_audio_capture.h"
namespace mocktail::audio {
class NativeInputCapture;
struct NativePcmFormat {
std::uint32_t rate = 48000;
std::uint8_t channels = 1;
std::uint8_t bytes_per_sample = 2;
std::uint8_t flags = 0;
std::uint8_t padding = 0;
};
struct NativeOptionalFormat {
NativePcmFormat format{};
std::uint64_t present = 0;
};
struct NativeSharedSink { void* object; void* owner; };
static_assert(sizeof(NativeOptionalFormat) == 16);
static_assert(sizeof(NativePcmFormat) == 8);
inline std::atomic<NativeInputCapture*> g_native_capture{nullptr};
class NativeInputCapture {
public:
using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t,
std::uint64_t, const NativePcmFormat*);
using Destroy = void (*)(NativeSharedSink*);
using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*);
struct SinkAbi {
std::uintptr_t vtable;
DeliverPcm deliver;
Destroy release;
OriginalLatency latency;
};
NativeInputCapture(SinkAbi abi, bool enabled)
: abi_(abi), enabled_(enabled) {}
~NativeInputCapture() { Shutdown(); }
static NativeOptionalFormat Format(void*) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
return {{}, capture && capture->enabled_ ? 1ULL : 0ULL};
}
static void Latency(void* self, std::uint32_t* playback,
std::uint32_t* recording) {
// Preserve the native playback latency and report the host capture queue.
auto* capture = g_native_capture.load(std::memory_order_acquire);
if (!capture) return;
capture->abi_.latency(self, playback, recording);
if (recording) {
std::lock_guard lock(capture->mutex_);
*recording = 10;
auto it = capture->sessions_.find(self);
if (it != capture->sessions_.end()) {
std::lock_guard queue_lock(it->second->queue_mutex);
*recording += static_cast<std::uint32_t>(it->second->count * 10);
}
}
}
static bool Start(void* self, NativeSharedSink* sink) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
return capture && capture->StartSink(self, sink);
}
static void Stop(void* self, NativeSharedSink* sink) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
if (capture) capture->StopSink(self, sink);
}
static bool Recording(void* self) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
if (!capture) return false;
std::lock_guard lock(capture->mutex_);
return capture->sessions_.find(self) != capture->sessions_.end();
}
static void Poll(void* self) {
auto* capture = g_native_capture.load(std::memory_order_acquire);
if (capture) capture->Deliver(self);
}
void Shutdown() {
std::lock_guard lock(mutex_);
for (auto& [self, session] : sessions_) Close(*session);
sessions_.clear();
}
private:
struct Session {
std::unique_ptr<AudioCapture> input;
std::vector<NativeSharedSink> sinks;
std::mutex queue_mutex;
// At most 200 ms; a stalled consumer must not accumulate stale speech.
std::array<std::array<std::int16_t, 480>, 20> frames{};
std::size_t head = 0, count = 0;
std::uint64_t delivered = 0, dropped = 0;
bool audible = false;
};
static void OnData(void* context, std::size_t bytes) {
auto& session = *static_cast<Session*>(context);
if (bytes != sizeof(session.frames[0])) return;
std::lock_guard lock(session.queue_mutex);
if (session.count == session.frames.size()) {
session.head = (session.head + 1) % session.frames.size();
--session.count;
++session.dropped;
}
std::memcpy(session.frames[(session.head + session.count) %
session.frames.size()].data(),
session.input->buffer_data(), bytes);
++session.count;
}
bool StartSink(void* self, NativeSharedSink* sink) {
std::lock_guard lock(mutex_);
if (!enabled_ || !self || !sink || !sink->object || !sink->owner)
return false;
auto* table = *reinterpret_cast<std::uintptr_t**>(sink->object);
if (reinterpret_cast<std::uintptr_t>(table) != abi_.vtable ||
table[2] != reinterpret_cast<std::uintptr_t>(abi_.deliver)) {
std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n");
return false;
}
auto it = sessions_.find(self);
if (it != sessions_.end()) {
for (const auto& existing : it->second->sinks)
if (existing.object == sink->object) return true;
} else {
auto session = std::make_unique<Session>();
SdlAudioCaptureOptions options;
options.output_spec.channels = 1;
options.data_callback = &OnData;
options.data_context = session.get();
auto status = CreateSdlAudioCapture(options, &session->input);
if (status.ok()) status = session->input->Start();
if (!status.ok()) {
std::fprintf(stderr, " [audio-input] capture start failed: %s\n",
status.message().c_str());
return false;
}
it = sessions_.emplace(self, std::move(session)).first;
std::fprintf(stderr,
" [audio-input] native capture started: 48000 Hz mono signed16\n");
}
// The native parameter is a by-value shared_ptr. Retain its existing
// ownership by moving it, leaving the caller's temporary empty.
it->second->sinks.push_back(*sink);
*sink = {};
return true;
}
void Release(NativeSharedSink& sink) {
abi_.release(&sink);
sink = {};
}
void Close(Session& session) {
session.input->Shutdown();
for (auto& sink : session.sinks) Release(sink);
session.sinks.clear();
std::fprintf(stderr,
" [audio-input] native capture stopped: delivered=%llu dropped=%llu\n",
static_cast<unsigned long long>(session.delivered),
static_cast<unsigned long long>(session.dropped));
}
void StopSink(void* self, NativeSharedSink* sink) {
std::lock_guard lock(mutex_);
auto it = sessions_.find(self);
if (it == sessions_.end() || !sink) return;
auto& session = *it->second;
auto existing = std::find_if(session.sinks.begin(), session.sinks.end(),
[&](const auto& entry) { return entry.object == sink->object; });
if (existing == session.sinks.end()) return;
Release(*existing);
session.sinks.erase(existing);
if (session.sinks.empty()) {
Close(session);
sessions_.erase(it);
}
}
void Deliver(void* self) {
// Invoked by Roblox's recording thread. Guest code and allocator TLS must
// never run on SDL's capture callback thread.
std::lock_guard lock(mutex_);
auto it = sessions_.find(self);
if (it == sessions_.end()) return;
auto& session = *it->second;
for (std::size_t n = 0; n < session.frames.size(); ++n) {
std::array<std::int16_t, 480> pcm;
std::uint64_t delay;
{
std::lock_guard queue_lock(session.queue_mutex);
if (!session.count) break;
pcm = session.frames[session.head];
delay = session.count * 10000000ULL;
session.head = (session.head + 1) % session.frames.size();
--session.count;
}
const NativePcmFormat format;
for (const auto& sink : session.sinks)
abi_.deliver(
sink.object, pcm.data(), pcm.size(), delay, &format);
if (++session.delivered == 1)
std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n");
if (!session.audible && std::any_of(pcm.begin(), pcm.end(),
[](auto value) { return value != 0; })) {
session.audible = true;
std::fprintf(stderr, " [audio-input] nonzero microphone PCM delivered\n");
}
}
}
SinkAbi abi_;
bool enabled_;
std::mutex mutex_;
std::map<void*, std::unique_ptr<Session>> sessions_;
};
} // namespace mocktail::audio
#endif
+227 -31
View File
@@ -12,6 +12,7 @@
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <string>
@@ -20,7 +21,9 @@
#include <vector>
#include "audio/roblox_output_device_bridge_internal.h"
#include "audio/native_input_capture.h"
#include "linker/linker.h"
#include "mocktail/audio/sdl_audio_capture.h"
#include "mocktail/audio/sdl_audio_sink.h"
namespace mocktail::audio {
@@ -28,8 +31,6 @@ namespace {
constexpr std::size_t kOutputCountVtableIndex = 5;
constexpr std::size_t kOutputInfoVtableIndex = 6;
constexpr std::size_t kCurrentOutputVtableIndex = 7;
constexpr std::size_t kSelectOutputVtableIndex = 17;
constexpr std::size_t kGuestStringSize = 24;
constexpr std::size_t kGuestDeviceInfoSize = kGuestStringSize * 2 + 1;
constexpr std::size_t kStringConstructorContractSize = 24;
@@ -94,7 +95,8 @@ bool IsRelroImageRange(std::uintptr_t image_base, std::uintptr_t rva,
return IsProgramHeaderRange(image_base, rva, size, PT_GNU_RELRO, 0);
}
bool SetVtableWritable(std::uintptr_t* vtable, bool writable) {
bool SetVtableWritable(std::uintptr_t* vtable, std::size_t select_index,
bool writable) {
if (vtable == nullptr) {
return false;
}
@@ -104,9 +106,9 @@ bool SetVtableWritable(std::uintptr_t* vtable, bool writable) {
}
const std::uintptr_t page_size = static_cast<std::uintptr_t>(page_size_value);
const std::uintptr_t first =
reinterpret_cast<std::uintptr_t>(vtable + kOutputCountVtableIndex);
reinterpret_cast<std::uintptr_t>(vtable + 4);
const std::uintptr_t last =
reinterpret_cast<std::uintptr_t>(vtable + kSelectOutputVtableIndex + 1);
reinterpret_cast<std::uintptr_t>(vtable + select_index + 1);
if (last <= first) {
return false;
}
@@ -128,6 +130,16 @@ std::uintptr_t FunctionAddress(Function function) {
} // namespace
// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu
// profile: relocating query methods alone does not establish a recording ABI.
namespace {
constexpr std::array<std::size_t, 6> kCaptureSlots{4, 13, 14, 15, 16, 17};
constexpr std::array<std::uintptr_t, 6> kCaptureRvas{
0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a};
} // namespace
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
namespace internal {
bool HasExpectedFmodStringConstructorContract(const std::uint8_t* code,
@@ -145,8 +157,19 @@ bool HasExpectedFmodStringConstructorContract(const std::uint8_t* code,
bool HasExpectedFmodOutputDeviceVtable(
const std::uintptr_t* vtable, std::uintptr_t image_base,
const compat::FmodOutputDeviceBridgeProfile& profile) {
return vtable != nullptr && image_base != 0 &&
vtable[kOutputCountVtableIndex] ==
const std::size_t kCurrentOutputVtableIndex = profile.current_vtable_index();
const std::size_t kSelectOutputVtableIndex = profile.select_vtable_index();
if (vtable == nullptr || image_base == 0 || !profile.valid_vtable_layout())
return false;
if (profile.has_input_devices()) {
const auto indexes = profile.input_vtable_indexes();
for (std::size_t i = 0; i < indexes.size(); ++i) {
if (profile.input_method_rvas[i] == 0 ||
vtable[indexes[i]] != image_base + profile.input_method_rvas[i])
return false;
}
}
return vtable[kOutputCountVtableIndex] ==
image_base + profile.count_method_rva &&
vtable[kOutputInfoVtableIndex] ==
image_base + profile.info_method_rva &&
@@ -191,6 +214,9 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) {
return FailedPrecondition(
"FMOD output-device profile requires host ABI bridges");
}
if (!profile.fmod_output_device_bridge->valid_vtable_layout()) {
return FailedPrecondition("unsupported FMOD output-device vtable layout");
}
RobloxOutputDeviceBridge* expected = nullptr;
if (!g_active_bridge.compare_exchange_strong(expected, this,
@@ -200,6 +226,10 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) {
"another Roblox output-device bridge owns the process");
}
profile_ = *profile.fmod_output_device_bridge;
capture_profile_supported_ =
profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" &&
profile_.vtable_rva == 0x6cd3ce0 &&
profile_.vtable_layout_version == 2 && profile_.has_input_devices();
if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) {
g_active_bridge.store(nullptr, std::memory_order_release);
profile_ = {};
@@ -225,8 +255,12 @@ void RobloxOutputDeviceBridge::Shutdown() {
" [audio-menu] failed to restore FmodAudioDevice "
"vtable protection\n");
}
g_native_capture.store(nullptr, std::memory_order_release);
capture_.reset();
active_ = false;
devices_.clear();
input_devices_.clear();
selected_input_index_ = 0;
string_constructor_ = nullptr;
library_base_ = 0;
vtable_ = nullptr;
@@ -318,6 +352,37 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) {
return FailedPrecondition("configured SDL audio output disappeared");
}
input_devices_.clear();
selected_input_index_ = 0;
const char *input_target = std::getenv("MOCKTAIL_AUDIO_INPUT_DEVICE");
if (profile_.has_input_devices() &&
(input_target == nullptr ||
std::string_view(input_target) != "disabled")) {
std::vector<SdlRecordingDevice> recording_devices;
status = ListSdlRecordingDevices(&recording_devices);
if (!status.ok())
return status;
if (recording_devices.size() + 1 > kMaximumMenuDevices)
return InvalidArgument("host exposes too many audio input devices");
std::uint32_t selected = 0;
std::string selected_name;
status = GetConfiguredSdlRecordingDevice(&selected, &selected_name);
if (!status.ok())
return status;
input_devices_.push_back({0, "System Default", "mocktail:input:default"});
for (const auto &device : recording_devices) {
if (device.name.empty() || device.name.size() > kMaximumDeviceNameBytes)
return InvalidArgument("SDL audio input name is empty or too long");
input_devices_.push_back(
{device.id, device.name,
"input:" + internal::MakeOutputDeviceGuid(device.id, device.name)});
if (device.id == selected)
selected_input_index_ = static_cast<int>(input_devices_.size() - 1);
}
if (selected != 0 && selected_input_index_ == 0)
return FailedPrecondition("configured SDL audio input disappeared");
}
library_base_ = image_base;
vtable_ = reinterpret_cast<std::uintptr_t*>(image_base + profile_.vtable_rva);
string_constructor_ =
@@ -325,6 +390,8 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) {
status = PatchVtableLocked();
if (!status.ok()) {
devices_.clear();
input_devices_.clear();
selected_input_index_ = 0;
string_constructor_ = nullptr;
library_base_ = 0;
vtable_ = nullptr;
@@ -338,19 +405,29 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) {
for (const MenuDevice& device : devices_) {
std::fprintf(stderr, " [audio-menu] %s\n", device.name.c_str());
}
if (profile_.has_input_devices()) {
std::fprintf(stderr,
" [audio-menu] exposed %zu host input routes; selected=%s\n",
input_devices_.size(),
input_devices_.empty()
? "disabled"
: input_devices_[selected_input_index_].name.c_str());
}
return Status::Ok();
}
Status RobloxOutputDeviceBridge::PatchVtableLocked() {
const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index();
const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index();
if (profile_.vtable_rva >
std::numeric_limits<std::uintptr_t>::max() -
(kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) {
return FailedPrecondition("FMOD output-device vtable range overflows");
}
const std::uintptr_t first_slot_rva =
profile_.vtable_rva + kOutputCountVtableIndex * sizeof(std::uintptr_t);
profile_.vtable_rva + 4 * sizeof(std::uintptr_t);
const std::size_t slot_range_size =
(kSelectOutputVtableIndex - kOutputCountVtableIndex + 1) *
(kSelectOutputVtableIndex - 4 + 1) *
sizeof(std::uintptr_t);
if (!IsRelroImageRange(library_base_, first_slot_rva, slot_range_size) ||
!IsExecutableImageRange(library_base_, profile_.string_constructor_rva,
@@ -362,6 +439,15 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
return FailedPrecondition(
"FMOD output-device profile points outside verified image ranges");
}
if (profile_.has_input_devices()) {
for (const auto rva : profile_.input_method_rvas) {
if (rva == 0 ||
rva > std::numeric_limits<std::uintptr_t>::max() - library_base_ ||
!IsExecutableImageRange(library_base_, rva, 1))
return FailedPrecondition(
"FMOD input profile points outside executable image");
}
}
if (!internal::HasExpectedFmodOutputDeviceVtable(vtable_, library_base_,
profile_)) {
return FailedPrecondition(
@@ -375,13 +461,41 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
"FmodAudioDevice string ABI contract validation failed");
}
if (capture_profile_supported_) {
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) {
if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) ||
vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i])
return FailedPrecondition("native input capture vtable ABI mismatch");
original_capture_methods_[i] = vtable_[kCaptureSlots[i]];
}
if (!IsExecutableImageRange(library_base_, 0x3213154, 1) ||
!IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) ||
!IsRelroImageRange(library_base_, 0x6cd4550, 24))
return FailedPrecondition("native input capture sink ABI outside image");
capture_ = std::make_unique<NativeInputCapture>(
NativeInputCapture::SinkAbi{
library_base_ + 0x6cd4550,
reinterpret_cast<NativeInputCapture::DeliverPcm>(
library_base_ + 0x3213154),
reinterpret_cast<NativeInputCapture::Destroy>(
library_base_ + 0x1dc4cd0),
reinterpret_cast<NativeInputCapture::OriginalLatency>(
library_base_ + kCaptureRvas[1])},
!input_devices_.empty());
}
original_methods_ = {
vtable_[kOutputCountVtableIndex],
vtable_[kOutputInfoVtableIndex],
vtable_[kCurrentOutputVtableIndex],
vtable_[kSelectOutputVtableIndex],
};
if (!SetVtableWritable(vtable_, true)) {
if (profile_.has_input_devices()) {
const auto indexes = profile_.input_vtable_indexes();
for (std::size_t i = 0; i < indexes.size(); ++i)
original_methods_[4 + i] = vtable_[indexes[i]];
}
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, true)) {
return Status::Error(StatusCode::kPlatformError,
"cannot make FmodAudioDevice vtable writable");
}
@@ -394,7 +508,29 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
FunctionAddress(&GetCurrentOutputDevice), __ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kSelectOutputVtableIndex],
FunctionAddress(&SetCurrentOutputDevice), __ATOMIC_RELEASE);
if (!SetVtableWritable(vtable_, false)) {
if (profile_.has_input_devices()) {
const auto indexes = profile_.input_vtable_indexes();
const std::array<std::uintptr_t, 4> methods = {
FunctionAddress(&GetInputDeviceCount),
FunctionAddress(&GetInputDeviceInfo),
FunctionAddress(&GetCurrentInputDevice),
FunctionAddress(&SetCurrentInputDevice)};
for (std::size_t i = 0; i < indexes.size(); ++i)
__atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE);
}
if (capture_) {
g_native_capture.store(capture_.get(), std::memory_order_release);
const std::array<std::uintptr_t, 6> methods{
FunctionAddress(&NativeInputCapture::Format),
FunctionAddress(&NativeInputCapture::Latency),
FunctionAddress(&NativeInputCapture::Start),
FunctionAddress(&NativeInputCapture::Stop),
FunctionAddress(&NativeInputCapture::Poll),
FunctionAddress(&NativeInputCapture::Recording)};
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
__atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE);
}
if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) {
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kOutputInfoVtableIndex], original_methods_[1],
@@ -403,7 +539,20 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kSelectOutputVtableIndex], original_methods_[3],
__ATOMIC_RELEASE);
(void)SetVtableWritable(vtable_, false);
if (profile_.has_input_devices()) {
const auto indexes = profile_.input_vtable_indexes();
for (std::size_t i = 0; i < indexes.size(); ++i)
__atomic_store_n(&vtable_[indexes[i]], original_methods_[4 + i],
__ATOMIC_RELEASE);
}
if (capture_) {
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
__ATOMIC_RELEASE);
g_native_capture.store(nullptr, std::memory_order_release);
capture_.reset();
}
(void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
return Status::Error(StatusCode::kPlatformError,
"cannot restore FmodAudioDevice RELRO protection");
}
@@ -411,7 +560,10 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() {
}
bool RobloxOutputDeviceBridge::RestoreVtableLocked() {
if (vtable_ == nullptr || !SetVtableWritable(vtable_, true)) {
const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index();
const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index();
if (vtable_ == nullptr ||
!SetVtableWritable(vtable_, kSelectOutputVtableIndex, true)) {
return false;
}
__atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0],
@@ -422,7 +574,18 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() {
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kSelectOutputVtableIndex], original_methods_[3],
__ATOMIC_RELEASE);
return SetVtableWritable(vtable_, false);
if (profile_.has_input_devices()) {
const auto indexes = profile_.input_vtable_indexes();
for (std::size_t i = 0; i < indexes.size(); ++i)
__atomic_store_n(&vtable_[indexes[i]], original_methods_[4 + i],
__ATOMIC_RELEASE);
}
if (capture_) {
for (std::size_t i = 0; i < kCaptureSlots.size(); ++i)
__atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i],
__ATOMIC_RELEASE);
}
return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false);
}
int RobloxOutputDeviceBridge::GetOutputDeviceCount(void* self) {
@@ -462,22 +625,51 @@ void RobloxOutputDeviceBridge::SetCurrentOutputDevice(void* self, int index) {
}
}
int RobloxOutputDeviceBridge::DeviceCount() {
std::lock_guard<std::mutex> lock(mutex_);
return active_ ? static_cast<int>(devices_.size()) : 0;
int RobloxOutputDeviceBridge::GetInputDeviceCount(void *) {
auto *bridge = g_active_bridge.load(std::memory_order_acquire);
return bridge ? bridge->DeviceCount(true) : 0;
}
void* RobloxOutputDeviceBridge::DeviceInfo(void* result, int index) {
void *RobloxOutputDeviceBridge::GetInputDeviceInfo(void *result, void *,
int index) {
auto *bridge = g_active_bridge.load(std::memory_order_acquire);
if (bridge)
return bridge->DeviceInfo(result, index, true);
if (result)
std::memset(result, 0, kGuestDeviceInfoSize);
return result;
}
int RobloxOutputDeviceBridge::GetCurrentInputDevice(void *) {
auto *bridge = g_active_bridge.load(std::memory_order_acquire);
return bridge ? bridge->CurrentDevice(true) : 0;
}
void RobloxOutputDeviceBridge::SetCurrentInputDevice(void *, int index) {
auto *bridge = g_active_bridge.load(std::memory_order_acquire);
if (bridge)
bridge->SelectDevice(index, true);
}
int RobloxOutputDeviceBridge::DeviceCount(bool input) {
const auto &devices = input ? input_devices_ : devices_;
std::lock_guard<std::mutex> lock(mutex_);
return active_ ? static_cast<int>(devices.size()) : 0;
}
void *RobloxOutputDeviceBridge::DeviceInfo(void *result, int index,
bool input) {
const auto &devices = input ? input_devices_ : devices_;
if (result == nullptr) {
return nullptr;
}
std::memset(result, 0, kGuestDeviceInfoSize);
std::lock_guard<std::mutex> lock(mutex_);
if (!active_ || index < 0 ||
static_cast<std::size_t>(index) >= devices_.size()) {
static_cast<std::size_t>(index) >= devices.size()) {
return result;
}
const MenuDevice& device = devices_[static_cast<std::size_t>(index)];
const MenuDevice &device = devices[static_cast<std::size_t>(index)];
ConstructGuestString(result, device.name);
ConstructGuestString(static_cast<std::uint8_t*>(result) + kGuestStringSize,
device.guid);
@@ -485,29 +677,33 @@ void* RobloxOutputDeviceBridge::DeviceInfo(void* result, int index) {
return result;
}
int RobloxOutputDeviceBridge::CurrentDevice() {
int RobloxOutputDeviceBridge::CurrentDevice(bool input) {
std::lock_guard<std::mutex> lock(mutex_);
return active_ ? selected_index_ : 0;
return active_ ? (input ? selected_input_index_ : selected_index_) : 0;
}
void RobloxOutputDeviceBridge::SelectDevice(int index) {
void RobloxOutputDeviceBridge::SelectDevice(int index, bool input) {
const auto &devices = input ? input_devices_ : devices_;
std::lock_guard<std::mutex> lock(mutex_);
if (!active_ || index < 0 ||
static_cast<std::size_t>(index) >= devices_.size()) {
static_cast<std::size_t>(index) >= devices.size()) {
return;
}
const MenuDevice& device = devices_[static_cast<std::size_t>(index)];
const MenuDevice &device = devices[static_cast<std::size_t>(index)];
std::string resolved_name;
const Status status =
SwitchSdlPlaybackDevice(device.playback_device_id, &resolved_name);
input
? SwitchSdlRecordingDevice(device.playback_device_id, &resolved_name)
: SwitchSdlPlaybackDevice(device.playback_device_id, &resolved_name);
if (!status.ok()) {
std::fprintf(stderr, " [audio-menu] cannot select host output '%s': %s\n",
device.name.c_str(), status.message().c_str());
std::fprintf(stderr, " [audio-menu] cannot select host %s '%s': %s\n",
input ? "input" : "output", device.name.c_str(),
status.message().c_str());
return;
}
selected_index_ = index;
std::fprintf(stderr, " [audio-menu] selected host output=%s\n",
resolved_name.c_str());
(input ? selected_input_index_ : selected_index_) = index;
std::fprintf(stderr, " [audio-menu] selected host %s=%s\n",
input ? "input" : "output", resolved_name.c_str());
}
void RobloxOutputDeviceBridge::ConstructGuestString(
+137 -51
View File
@@ -16,11 +16,13 @@
namespace mocktail::audio {
namespace {
class SdlAudioCapture;
struct SdlRecordingSubsystemState {
std::mutex mutex;
std::uint32_t configured_device_id = 0;
std::string configured_device_name = "default";
std::size_t live_captures = 0;
std::vector<SdlAudioCapture *> live_captures;
};
SdlRecordingSubsystemState& RecordingSubsystemState() {
@@ -63,42 +65,6 @@ SDL_AudioFormat ToSdlFormat(PcmSampleFormat format) {
return SDL_AUDIO_UNKNOWN;
}
Status ResolveConfiguredDevice(std::uint32_t requested_device_id,
std::uint32_t* resolved_device_id) {
if (resolved_device_id == nullptr) {
return InvalidArgument("resolved SDL recording device pointer is null");
}
if (!AudioSubsystemInitialized()) {
return FailedPrecondition(
"SDL audio subsystem must be initialized before recording");
}
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
*resolved_device_id = requested_device_id == 0
? state.configured_device_id
: requested_device_id;
return Status::Ok();
}
Status RegisterCapture() {
if (!AudioSubsystemInitialized()) {
return FailedPrecondition(
"SDL audio subsystem stopped while opening a capture");
}
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
++state.live_captures;
return Status::Ok();
}
void ReleaseCapture() {
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
if (state.live_captures != 0) {
--state.live_captures;
}
}
class SdlAudioCapture final : public AudioCapture {
public:
SdlAudioCapture(PcmSpec spec, std::size_t frames_per_buffer,
@@ -155,6 +121,8 @@ class SdlAudioCapture final : public AudioCapture {
}
void Shutdown() override {
auto &state = RecordingSubsystemState();
std::lock_guard<std::mutex> registry_lock(state.mutex);
std::lock_guard<std::mutex> lock(operation_mutex_);
if (stream_ == nullptr) {
return;
@@ -166,10 +134,63 @@ class SdlAudioCapture final : public AudioCapture {
stream_ = nullptr;
if (registered_) {
registered_ = false;
ReleaseCapture();
auto &captures = state.live_captures;
captures.erase(std::remove(captures.begin(), captures.end(), this),
captures.end());
}
}
std::mutex &operation_mutex() { return operation_mutex_; }
// The registry and operation mutexes are held throughout a migration.
Status PrepareSwitch(std::uint32_t id) {
SDL_AudioSpec spec{};
spec.format = ToSdlFormat(output_spec_.format);
spec.channels = output_spec_.channels;
spec.freq = output_spec_.sample_rate_hz;
replacement_ = SDL_OpenAudioDeviceStream(
id == 0 ? SDL_AUDIO_DEVICE_DEFAULT_RECORDING : id, &spec,
&OnAudioAvailable, this);
return replacement_ ? Status::Ok()
: SdlError("open replacement microphone");
}
Status PauseForSwitch() {
if (!SDL_PauseAudioStreamDevice(stream_))
return SdlError("pause old microphone");
// Wait for an in-flight callback before the replacement can use buffer_.
if (!SDL_LockAudioStream(stream_))
return SdlError("lock old microphone");
SDL_UnlockAudioStream(stream_);
return Status::Ok();
}
Status StartReplacement() {
if (running_.load(std::memory_order_acquire) &&
!SDL_ResumeAudioStreamDevice(replacement_))
return SdlError("resume replacement microphone");
return Status::Ok();
}
void CancelReplacement() {
if (replacement_) {
SDL_DestroyAudioStream(replacement_);
replacement_ = nullptr;
}
}
Status RestoreAfterSwitch() {
if (running_.load(std::memory_order_acquire) &&
!SDL_ResumeAudioStreamDevice(stream_))
return SdlError("restore original microphone");
return Status::Ok();
}
void CommitSwitch() {
SDL_DestroyAudioStream(stream_);
stream_ = std::exchange(replacement_, nullptr);
}
static void SDLCALL OnAudioAvailable(void* userdata,
SDL_AudioStream* stream,
int /*additional_amount*/,
@@ -202,6 +223,7 @@ class SdlAudioCapture final : public AudioCapture {
std::size_t frames_per_buffer_ = 0;
std::vector<std::uint8_t> buffer_;
SDL_AudioStream* stream_ = nullptr;
SDL_AudioStream *replacement_ = nullptr;
AudioCaptureDataCallback callback_ = nullptr;
void* callback_context_ = nullptr;
std::mutex operation_mutex_;
@@ -335,7 +357,7 @@ Status ConfigureSdlRecordingDevice(
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
if (state.live_captures != 0) {
if (!state.live_captures.empty()) {
return FailedPrecondition(
"SDL recording device must be configured before opening a capture");
}
@@ -391,12 +413,14 @@ Status CreateSdlAudioCapture(const SdlAudioCaptureOptions& options,
return InvalidArgument("SDL capture frame size is invalid");
}
std::uint32_t recording_device_id = 0;
status = ResolveConfiguredDevice(options.recording_device_id,
&recording_device_id);
if (!status.ok()) {
return status;
}
auto &state = RecordingSubsystemState();
std::unique_lock<std::mutex> registry_lock(state.mutex);
if (!AudioSubsystemInitialized())
return FailedPrecondition(
"SDL audio subsystem stopped while opening capture");
const std::uint32_t recording_device_id = options.recording_device_id == 0
? state.configured_device_id
: options.recording_device_id;
const SDL_AudioDeviceID device =
recording_device_id == 0
? SDL_AUDIO_DEVICE_DEFAULT_RECORDING
@@ -417,25 +441,87 @@ Status CreateSdlAudioCapture(const SdlAudioCaptureOptions& options,
SDL_AudioStream* stream = SDL_OpenAudioDeviceStream(
device, &spec, &SdlAudioCapture::OnAudioAvailable, implementation);
if (stream == nullptr) {
registry_lock.unlock();
delete implementation;
return SdlError("SDL_OpenAudioDeviceStream(recording)");
}
implementation->AdoptStream(stream);
status = RegisterCapture();
if (!status.ok()) {
delete implementation;
return status;
}
state.live_captures.push_back(implementation);
implementation->MarkRegistered();
capture->reset(implementation);
return Status::Ok();
}
Status SwitchSdlRecordingDevice(std::uint32_t recording_device_id,
std::string *resolved_name) {
if (resolved_name == nullptr)
return InvalidArgument("microphone switch output is null");
resolved_name->clear();
std::vector<SdlRecordingDevice> devices;
Status status = ListSdlRecordingDevices(&devices);
if (!status.ok())
return status;
std::uint32_t resolved_id = 0;
std::string name;
status = ResolveSdlRecordingDevice(
recording_device_id == 0 ? "default"
: "id:" + std::to_string(recording_device_id),
devices, &resolved_id, &name);
if (!status.ok())
return status;
auto &state = RecordingSubsystemState();
std::lock_guard<std::mutex> registry_lock(state.mutex);
if (!AudioSubsystemInitialized())
return FailedPrecondition(
"SDL audio subsystem stopped during microphone switch");
std::vector<std::unique_lock<std::mutex>> locks;
locks.reserve(state.live_captures.size());
for (auto *capture : state.live_captures)
locks.emplace_back(capture->operation_mutex());
// Keep the old streams alive until every replacement is ready and running.
for (auto *capture : state.live_captures) {
status = capture->PrepareSwitch(resolved_id);
if (!status.ok())
break;
}
if (status.ok()) {
for (auto *capture : state.live_captures) {
status = capture->PauseForSwitch();
if (!status.ok())
break;
}
}
if (status.ok()) {
for (auto *capture : state.live_captures) {
status = capture->StartReplacement();
if (!status.ok())
break;
}
}
if (!status.ok()) {
for (auto *capture : state.live_captures)
capture->CancelReplacement();
for (auto *capture : state.live_captures) {
const Status restored = capture->RestoreAfterSwitch();
if (!restored.ok())
return restored;
}
return status;
}
for (auto *capture : state.live_captures)
capture->CommitSwitch();
state.configured_device_id = resolved_id;
state.configured_device_name = name;
*resolved_name = name;
return Status::Ok();
}
Status PrepareSdlAudioCaptureSubsystemShutdown() {
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
if (state.live_captures != 0) {
if (!state.live_captures.empty()) {
return FailedPrecondition(
"SDL audio shutdown requires every capture to close");
}
+30 -5
View File
@@ -30,6 +30,9 @@ struct CaptureSession {
void* data_context = nullptr;
std::unique_ptr<AudioCapture> capture;
std::shared_ptr<std::atomic<bool>> microphone_muted;
std::atomic<std::uint64_t> delivered_buffers{0};
std::atomic<std::uint64_t> audible_buffers{0};
std::atomic<std::uint64_t> muted_buffers{0};
};
struct PlayoutSession {
@@ -112,22 +115,40 @@ void DisposeManager(void* opaque_context, const void* identity) {
void SetMicrophoneMute(void* opaque_context, bool muted) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr) return;
context->microphone_muted->store(muted, std::memory_order_release);
if (context->microphone_muted->exchange(muted, std::memory_order_acq_rel) != muted) {
std::cerr << " [mocktail][audio] WebRTC microphone muted=" << muted << '\n';
}
}
void OnCapturedAudio(void* opaque_session, std::size_t size_bytes) {
auto* session = static_cast<CaptureSession*>(opaque_session);
if (session != nullptr && session->data_callback != nullptr) {
if (session->capture == nullptr ||
size_bytes > session->capture->buffer_size_bytes()) {
return;
}
const auto* samples = static_cast<const unsigned char*>(
session->capture->buffer_data());
int peak = 0;
for (std::size_t i = 0; i + 1 < size_bytes; i += 2) {
const int word = samples[i] | (static_cast<int>(samples[i + 1]) << 8);
peak = std::max(peak, std::abs(word >= 32768 ? word - 65536 : word));
}
if (peak != 0 && session->audible_buffers.fetch_add(1) == 0) {
std::cerr << " [mocktail][audio] WebRTC microphone first nonzero PCM"
<< " peak=" << peak << "/32768\n";
}
if (session->microphone_muted != nullptr &&
session->microphone_muted->load(std::memory_order_acquire)) {
if (session->capture == nullptr ||
size_bytes > session->capture->buffer_size_bytes()) {
return;
}
session->muted_buffers.fetch_add(1, std::memory_order_relaxed);
std::memset(session->capture->buffer_data(), 0, size_bytes);
}
session->data_callback(session->data_context, session->identity,
size_bytes);
if (session->delivered_buffers.fetch_add(1) == 0) {
std::cerr << " [mocktail][audio] WebRTC microphone first JNI delivery"
<< " bytes=" << size_bytes << '\n';
}
}
}
@@ -137,6 +158,10 @@ void CloseCaptureSession(const std::shared_ptr<CaptureSession>& session) {
}
(void)session->capture->Stop();
session->capture->Shutdown();
std::cerr << " [mocktail][audio] WebRTC microphone capture summary"
<< " buffers=" << session->delivered_buffers.load()
<< " nonzero=" << session->audible_buffers.load()
<< " muted=" << session->muted_buffers.load() << '\n';
}
int Init(void* opaque_context, const void* identity, int sample_rate_hz,
@@ -1,6 +1,7 @@
#include "compat/bionic_pthread_create_runtime.h"
#include <errno.h>
#include <stdio.h>
#include <algorithm>
#include <atomic>
@@ -22,6 +23,24 @@ struct ThreadStartContext {
std::atomic<NativeThreadInitializer> g_thread_initializer{nullptr};
// Roblox requests maximum-priority FIFO for its Main thread. On hosts with
// RLIMIT_RTPRIO=99 this succeeds and later trips the finite RLIMIT_RTTIME.
bool RejectRealtimeScheduling(int policy) noexcept {
const int base_policy = policy & ~SCHED_RESET_ON_FORK;
if (base_policy != SCHED_FIFO && base_policy != SCHED_RR) {
return false;
}
static std::atomic_flag logged = ATOMIC_FLAG_INIT;
if (!logged.test_and_set(std::memory_order_relaxed)) {
const int saved_errno = errno;
fprintf(stderr, " [scheduler] denied guest real-time scheduling (policy=%d): "
"keeping host scheduling to prevent RLIMIT_RTTIME SIGXCPU\n",
policy);
errno = saved_errno;
}
return true;
}
size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept {
#if defined(__GLIBC__)
using GetStaticTlsInfo = void (*)(size_t*, size_t*);
@@ -99,6 +118,9 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source,
if (result == 0) {
result = pthread_attr_getschedparam(&source, &scheduler_parameters);
}
if (result == 0 && RejectRealtimeScheduling(scheduler_policy)) {
return EPERM;
}
if (result == 0) {
result = pthread_attr_setschedpolicy(destination, scheduler_policy);
}
@@ -274,3 +296,20 @@ extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
return mocktail::compat::CreateBionicPthread(thread, attr, start_routine,
argument);
}
extern "C" int mocktail_bionic_pthread_setschedparam(
pthread_t thread, int policy, const struct sched_param* parameters) {
if (mocktail::compat::RejectRealtimeScheduling(policy)) {
return EPERM;
}
return pthread_setschedparam(thread, policy, parameters);
}
extern "C" int mocktail_bionic_sched_setscheduler(
pid_t tid, int policy, const struct sched_param* parameters) {
if (mocktail::compat::RejectRealtimeScheduling(policy)) {
errno = EPERM;
return -1;
}
return sched_setscheduler(tid, policy, parameters);
}
+40 -2
View File
@@ -119,11 +119,33 @@ bool ParseOptionalFmodOutputDeviceBridge(
return false;
}
int layout = 1;
const auto layout_field = field->find("vtable_layout_version");
if (layout_field != field->end()) {
if (!layout_field->is_number_integer() ||
(*layout_field != 1 && *layout_field != 2)) {
return false;
}
layout = layout_field->get<int>();
}
*bridge_profile = FmodOutputDeviceBridgeProfile{
*vtable_rva, *string_constructor_rva, *count_method_rva,
*info_method_rva, *current_method_rva, *select_method_rva,
*info_method_rva, *current_method_rva, *select_method_rva, layout,
};
return true;
constexpr const char *input_fields[] = {
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva"};
std::size_t input_fields_present = 0;
for (std::size_t i = 0; i < 4; ++i) {
std::optional<std::uintptr_t> rva;
if (!ParseOptionalRva(*field, input_fields[i], &rva))
return false;
if (rva) {
++input_fields_present;
(*bridge_profile)->input_method_rvas[i] = *rva;
}
}
return input_fields_present == 0 || input_fields_present == 4;
}
} // namespace
@@ -229,6 +251,22 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path,
normalized_requested);
}
// Upgrade previously cached manifests for these exact, validated images.
// This adds input routes without changing the payload's approval status.
if (fmod_output_device_bridge &&
!fmod_output_device_bridge->has_input_devices()) {
auto &bridge = *fmod_output_device_bridge;
if (normalized_profile == "ade08266c67aee88ec9c1d00902150e1684dad3a" &&
bridge.vtable_rva == 0x6c58040 && bridge.vtable_layout_version == 1) {
bridge.input_method_rvas = {0x32d12dc, 0x32d12ea, 0x32d13a6, 0x32d0a56};
} else if (normalized_profile ==
"5f0704edd9064f566ee3d6df2bd2fabbcc709f03" &&
bridge.vtable_rva == 0x6cd3ce0 &&
bridge.vtable_layout_version == 2) {
bridge.input_method_rvas = {0x320ccf6, 0x320cd04, 0x320d152, 0x320bd18};
}
}
matched_profile = BuildProfile{
version_name->get_ref<const std::string&>(),
version_code->get<int>(),
+456 -5
View File
@@ -1,3 +1,4 @@
// Includes changes by vii from CoderDayton/nightcap.
// Android Vulkan loader ABI -> host Vulkan loader + SDL3 WSI.
#include <dlfcn.h>
@@ -17,6 +18,7 @@
#include "mocktail/graphics/android_vulkan_wsi_adapter.h"
#include "mocktail/graphics/present_mode_policy.h"
#include "mocktail/graphics/vulkan_etc2_emulation.h"
#include "mocktail/graphics/vulkan_text_overlay_compositor.h"
#include "mocktail/platform/platform_runtime.h"
@@ -188,6 +190,7 @@ struct AdapterState {
VkInstance latest_instance = VK_NULL_HANDLE;
size_t active_instance_count = 0;
mocktail::graphics::VulkanTextOverlayCompositor text_overlay;
mocktail::graphics::VulkanEtc2Emulation etc2;
std::atomic<NotePresentFn> note_present{nullptr};
std::atomic<NoteHostPresentBeginFn> note_host_present_begin{nullptr};
std::atomic<NoteHostPresentEndFn> note_host_present_end{nullptr};
@@ -727,12 +730,90 @@ PFN_vkVoidFunction AdapterProc(const char* name) {
MOCKTAIL_VK_PROC(vkQueueWaitIdle)
MOCKTAIL_VK_PROC(vkDeviceWaitIdle)
MOCKTAIL_VK_PROC(vkQueuePresentKHR)
MOCKTAIL_VK_PROC(vkGetPhysicalDeviceFeatures)
MOCKTAIL_VK_PROC(vkGetPhysicalDeviceFeatures2)
MOCKTAIL_VK_PROC(vkGetPhysicalDeviceFormatProperties)
MOCKTAIL_VK_PROC(vkGetPhysicalDeviceFormatProperties2)
MOCKTAIL_VK_PROC(vkGetPhysicalDeviceImageFormatProperties)
MOCKTAIL_VK_PROC(vkGetPhysicalDeviceImageFormatProperties2)
MOCKTAIL_VK_PROC(vkCreateImage)
MOCKTAIL_VK_PROC(vkDestroyImage)
MOCKTAIL_VK_PROC(vkCreateImageView)
MOCKTAIL_VK_PROC(vkBindBufferMemory)
MOCKTAIL_VK_PROC(vkBindBufferMemory2)
MOCKTAIL_VK_PROC(vkMapMemory)
MOCKTAIL_VK_PROC(vkMapMemory2)
MOCKTAIL_VK_PROC(vkUnmapMemory)
MOCKTAIL_VK_PROC(vkUnmapMemory2)
MOCKTAIL_VK_PROC(vkFreeMemory)
MOCKTAIL_VK_PROC(vkDestroyBuffer)
MOCKTAIL_VK_PROC(vkCmdCopyBufferToImage)
MOCKTAIL_VK_PROC(vkCmdCopyImage)
MOCKTAIL_VK_PROC(vkCmdCopyImage2)
MOCKTAIL_VK_PROC(vkCmdCopyBufferToImage2)
MOCKTAIL_VK_PROC(vkCmdExecuteCommands)
#undef MOCKTAIL_VK_PROC
#define MOCKTAIL_VK_ALIAS(alias, function) \
if (std::strcmp(name, #alias) == 0) { \
return reinterpret_cast<PFN_vkVoidFunction>(function); \
}
MOCKTAIL_VK_ALIAS(vkGetPhysicalDeviceFeatures2KHR,
vkGetPhysicalDeviceFeatures2)
MOCKTAIL_VK_ALIAS(vkGetPhysicalDeviceFormatProperties2KHR,
vkGetPhysicalDeviceFormatProperties2)
MOCKTAIL_VK_ALIAS(vkGetPhysicalDeviceImageFormatProperties2KHR,
vkGetPhysicalDeviceImageFormatProperties2)
MOCKTAIL_VK_ALIAS(vkBindBufferMemory2KHR, vkBindBufferMemory2)
MOCKTAIL_VK_ALIAS(vkCmdCopyImage2KHR, vkCmdCopyImage2)
MOCKTAIL_VK_ALIAS(vkMapMemory2KHR, vkMapMemory2)
MOCKTAIL_VK_ALIAS(vkUnmapMemory2KHR, vkUnmapMemory2)
MOCKTAIL_VK_ALIAS(vkCmdCopyBufferToImage2KHR, vkCmdCopyBufferToImage2)
#undef MOCKTAIL_VK_ALIAS
return nullptr;
}
bool NameInList(const char* name, const char* const* names,
std::size_t count) {
return name != nullptr &&
std::any_of(names, names + count, [name](const char* candidate) {
return std::strcmp(name, candidate) == 0;
});
}
bool IsEtc2DeviceProc(const char* name) {
static constexpr const char* kNames[] = {
"vkCreateImage", "vkDestroyImage",
"vkCreateImageView", "vkBindBufferMemory",
"vkBindBufferMemory2", "vkBindBufferMemory2KHR",
"vkMapMemory", "vkMapMemory2",
"vkMapMemory2KHR", "vkUnmapMemory",
"vkUnmapMemory2", "vkUnmapMemory2KHR",
"vkFreeMemory", "vkDestroyBuffer",
"vkCmdCopyBufferToImage", "vkCmdCopyBufferToImage2",
"vkCmdCopyImage", "vkCmdCopyImage2",
"vkCmdCopyImage2KHR",
"vkCmdCopyBufferToImage2KHR", "vkCmdExecuteCommands",
};
return NameInList(name, kNames, sizeof(kNames) / sizeof(kNames[0]));
}
bool IsEtc2PhysicalDeviceProc(const char* name) {
static constexpr const char* kNames[] = {
"vkGetPhysicalDeviceFeatures",
"vkGetPhysicalDeviceFeatures2",
"vkGetPhysicalDeviceFeatures2KHR",
"vkGetPhysicalDeviceFormatProperties",
"vkGetPhysicalDeviceFormatProperties2",
"vkGetPhysicalDeviceFormatProperties2KHR",
"vkGetPhysicalDeviceImageFormatProperties",
"vkGetPhysicalDeviceImageFormatProperties2",
"vkGetPhysicalDeviceImageFormatProperties2KHR",
};
return NameInList(name, kNames, sizeof(kNames) / sizeof(kNames[0]));
}
bool IsDeviceAdapterProc(const char* name) {
return name != nullptr && (std::strcmp(name, "vkDestroyDevice") == 0 ||
return name != nullptr && (IsEtc2DeviceProc(name) || std::strcmp(name, "vkDestroyDevice") == 0 ||
std::strcmp(name, "vkGetDeviceQueue") == 0 ||
std::strcmp(name, "vkGetDeviceQueue2") == 0 ||
std::strcmp(name, "vkCreateSwapchainKHR") == 0 ||
@@ -959,11 +1040,23 @@ struct FpsWaitTrace {
std::atomic<std::uint64_t> total_ns{0};
std::atomic<std::uint64_t> max_ns{0};
std::atomic<std::uint64_t> window_start_ns{0};
std::atomic<std::uint64_t> max_gap_ns{0};
std::atomic<std::uint64_t> last_start_ns{0};
void Record(std::uint64_t start_ns) {
if (name == nullptr || start_ns == 0) {
return;
}
const std::uint64_t previous_start =
last_start_ns.exchange(start_ns, std::memory_order_relaxed);
if (previous_start != 0 && start_ns > previous_start) {
const std::uint64_t gap_ns = start_ns - previous_start;
std::uint64_t max_gap = max_gap_ns.load(std::memory_order_relaxed);
while (gap_ns > max_gap &&
!max_gap_ns.compare_exchange_weak(max_gap, gap_ns,
std::memory_order_relaxed)) {
}
}
const std::uint64_t wait_ns = MonotonicNanos() - start_ns;
total_ns.fetch_add(wait_ns, std::memory_order_relaxed);
std::uint64_t max_wait = max_ns.load(std::memory_order_relaxed);
@@ -984,14 +1077,18 @@ struct FpsWaitTrace {
const std::uint64_t total = total_ns.exchange(0, std::memory_order_relaxed);
const std::uint64_t peak = max_ns.exchange(0, std::memory_order_relaxed);
const std::uint64_t count = samples.exchange(0, std::memory_order_relaxed);
const std::uint64_t gap_peak =
max_gap_ns.exchange(0, std::memory_order_relaxed);
window_start_ns.store(start_ns, std::memory_order_relaxed);
if (count == 0) {
return;
}
std::fprintf(stderr, " [fps] %s n=%llu avg=%llu us max=%llu us\n", name,
static_cast<unsigned long long>(count),
std::fprintf(stderr,
" [fps] %s n=%llu avg=%llu us max=%llu us gap_max=%llu us\n",
name, static_cast<unsigned long long>(count),
static_cast<unsigned long long>(total / count / 1000ULL),
static_cast<unsigned long long>(peak / 1000ULL));
static_cast<unsigned long long>(peak / 1000ULL),
static_cast<unsigned long long>(gap_peak / 1000ULL));
}
};
@@ -1042,6 +1139,58 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) {
return result;
}
template <typename Function>
Function HostPhysicalDeviceProc(const char* name, const char* alias) {
VkInstance instance = VK_NULL_HANDLE;
{
AdapterState& state = State();
std::lock_guard<std::mutex> lock(state.mutex);
instance = state.latest_instance;
}
PFN_vkVoidFunction proc = HostInstanceProc(instance, name);
if (proc == nullptr && alias != nullptr) {
proc = HostInstanceProc(instance, alias);
}
return reinterpret_cast<Function>(proc);
}
bool Etc2EmulatedPhysicalDevice(VkPhysicalDevice physical_device) {
return State().etc2.PhysicalDeviceNeedsEmulation(
physical_device, HostPhysicalDeviceProc<PFN_vkGetPhysicalDeviceFeatures>(
"vkGetPhysicalDeviceFeatures", nullptr));
}
void ReleaseEtc2PoolCommandBuffers(VkDevice device,
VkCommandPool command_pool) {
std::vector<VkCommandBuffer> command_buffers;
{
AdapterState& state = State();
std::lock_guard<std::mutex> lock(state.mutex);
for (const auto& binding : state.command_buffer_bindings) {
if (binding.device == device && binding.command_pool == command_pool) {
command_buffers.push_back(binding.command_buffer);
}
}
}
for (VkCommandBuffer command_buffer : command_buffers) {
State().etc2.ReleaseCommandBuffer(command_buffer);
}
}
void PrepareEtc2Submit2(std::uint32_t submit_count,
const VkSubmitInfo2* submits) {
if (submits == nullptr) {
return;
}
for (std::uint32_t index = 0; index < submit_count; ++index) {
for (std::uint32_t info = 0; info < submits[index].commandBufferInfoCount;
++info) {
State().etc2.PrepareSubmit(
&submits[index].pCommandBufferInfos[info].commandBuffer, 1);
}
}
}
} // namespace
extern "C" {
@@ -1247,7 +1396,7 @@ vkGetInstanceProcAddr(VkInstance instance, const char* name) {
}
if (const PFN_vkVoidFunction adapter = AdapterProc(name);
adapter != nullptr) {
if (IsDeviceAdapterProc(name) &&
if ((IsDeviceAdapterProc(name) || IsEtc2PhysicalDeviceProc(name)) &&
HostInstanceProc(instance, name) == nullptr) {
return nullptr;
}
@@ -1306,6 +1455,30 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice(
VkDeviceCreateInfo host_info = *create_info;
EnabledDeviceFeatures enabled = InspectEnabledFeatures(*create_info);
const bool etc2_emulated = State().etc2.PhysicalDeviceNeedsEmulation(
physical_device, reinterpret_cast<PFN_vkGetPhysicalDeviceFeatures>(
HostInstanceProc(instance,
"vkGetPhysicalDeviceFeatures")));
VkPhysicalDeviceFeatures host_enabled_features{};
VkPhysicalDeviceFeatures2* requested_features2 = nullptr;
VkBool32 requested_features2_etc2 = VK_FALSE;
if (etc2_emulated) {
enabled.root.features.textureCompressionETC2 = VK_FALSE;
if (create_info->pEnabledFeatures != nullptr) {
host_enabled_features = *create_info->pEnabledFeatures;
host_enabled_features.textureCompressionETC2 = VK_FALSE;
host_info.pEnabledFeatures = &host_enabled_features;
}
// Restore the caller-owned feature bit after the host call.
requested_features2 = const_cast<VkPhysicalDeviceFeatures2*>(
reinterpret_cast<const VkPhysicalDeviceFeatures2*>(FindFeature(
create_info->pNext, VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2)));
if (requested_features2 != nullptr) {
requested_features2_etc2 =
requested_features2->features.textureCompressionETC2;
requested_features2->features.textureCompressionETC2 = VK_FALSE;
}
}
enabled.root.pNext = &enabled.vulkan11;
enabled.vulkan11.pNext = &enabled.vulkan12;
enabled.vulkan12.pNext = nullptr;
@@ -1341,11 +1514,25 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice(
const VkResult result =
host_create(physical_device, &host_info, allocator, device);
if (requested_features2 != nullptr) {
requested_features2->features.textureCompressionETC2 =
requested_features2_etc2;
}
if (result != VK_SUCCESS) {
return result;
}
RegisterHostDeviceDispatch(*device, physical_device,
host_get_device_proc_addr);
VkPhysicalDeviceMemoryProperties memory_properties{};
if (const auto host_memory_properties =
reinterpret_cast<PFN_vkGetPhysicalDeviceMemoryProperties>(
HostInstanceProc(instance,
"vkGetPhysicalDeviceMemoryProperties"));
host_memory_properties != nullptr) {
host_memory_properties(physical_device, &memory_properties);
}
State().etc2.RegisterDevice(*device, physical_device, etc2_emulated,
memory_properties, host_get_device_proc_addr);
std::uint32_t queue_family_count = 0;
const auto host_get_queue_families =
@@ -1403,6 +1590,7 @@ vkDestroyDevice(VkDevice device, const VkAllocationCallbacks* allocator) {
const auto host_destroy = reinterpret_cast<PFN_vkDestroyDevice>(
HostDeviceProc(device, "vkDestroyDevice"));
State().text_overlay.DestroyDevice(device);
State().etc2.DestroyDevice(device);
if (host_destroy != nullptr) {
host_destroy(device, allocator);
}
@@ -1650,6 +1838,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkResetCommandPool(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return VK_ERROR_INITIALIZATION_FAILED;
}
ReleaseEtc2PoolCommandBuffers(device, command_pool);
const VkResult result = host_reset(device, command_pool, flags);
observation.SetResult(result);
return result;
@@ -1706,6 +1895,11 @@ VKAPI_ATTR void VKAPI_CALL vkFreeCommandBuffers(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return;
}
if (command_buffers != nullptr) {
for (std::uint32_t index = 0; index < command_buffer_count; ++index) {
State().etc2.ReleaseCommandBuffer(command_buffers[index]);
}
}
host_free(device, command_pool, command_buffer_count, command_buffers);
RemoveHostCommandBuffers(device, command_pool, command_buffer_count,
command_buffers);
@@ -1722,6 +1916,7 @@ VKAPI_ATTR void VKAPI_CALL vkDestroyCommandPool(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return;
}
ReleaseEtc2PoolCommandBuffers(device, command_pool);
host_destroy(device, command_pool, allocator);
RemoveHostCommandPoolBindings(device, command_pool);
observation.SetResult(VK_SUCCESS);
@@ -1737,6 +1932,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkBeginCommandBuffer(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return VK_ERROR_INITIALIZATION_FAILED;
}
State().etc2.ReleaseCommandBuffer(command_buffer);
const VkResult result = host_begin(command_buffer, begin_info);
observation.SetResult(result);
return result;
@@ -1765,6 +1961,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkResetCommandBuffer(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return VK_ERROR_INITIALIZATION_FAILED;
}
State().etc2.ReleaseCommandBuffer(command_buffer);
const VkResult result = host_reset(command_buffer, flags);
observation.SetResult(result);
return result;
@@ -1801,6 +1998,12 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return VK_ERROR_INITIALIZATION_FAILED;
}
if (submits != nullptr) {
for (std::uint32_t index = 0; index < submit_count; ++index) {
State().etc2.PrepareSubmit(submits[index].pCommandBuffers,
submits[index].commandBufferCount);
}
}
const VkResult result = State().text_overlay.QueueSubmit(
queue, submit_count, submits, fence, host_submit);
observation.SetResult(result);
@@ -1817,6 +2020,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return VK_ERROR_INITIALIZATION_FAILED;
}
PrepareEtc2Submit2(submit_count, submits);
const VkResult result = State().text_overlay.QueueSubmit2(
queue, submit_count, submits, fence, host_submit);
observation.SetResult(result);
@@ -1833,6 +2037,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2KHR(
observation.SetResult(VK_ERROR_INITIALIZATION_FAILED);
return VK_ERROR_INITIALIZATION_FAILED;
}
PrepareEtc2Submit2(submit_count, submits);
const VkResult result = State().text_overlay.QueueSubmit2(
queue, submit_count, submits, fence,
reinterpret_cast<PFN_vkQueueSubmit2>(host_submit));
@@ -2025,4 +2230,250 @@ vkQueuePresentKHR(VkQueue queue, const VkPresentInfoKHR* present_info) {
return normalized_result;
}
VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceFeatures(
VkPhysicalDevice physical_device, VkPhysicalDeviceFeatures* features) {
const auto host = HostPhysicalDeviceProc<PFN_vkGetPhysicalDeviceFeatures>(
"vkGetPhysicalDeviceFeatures", nullptr);
if (host == nullptr || features == nullptr) {
return;
}
host(physical_device, features);
if (Etc2EmulatedPhysicalDevice(physical_device) &&
mocktail::graphics::Etc2SupportAdvertised()) {
features->textureCompressionETC2 = VK_TRUE;
}
}
VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceFeatures2(
VkPhysicalDevice physical_device, VkPhysicalDeviceFeatures2* features) {
const auto host = HostPhysicalDeviceProc<PFN_vkGetPhysicalDeviceFeatures2>(
"vkGetPhysicalDeviceFeatures2", "vkGetPhysicalDeviceFeatures2KHR");
if (host == nullptr || features == nullptr) {
return;
}
host(physical_device, features);
if (Etc2EmulatedPhysicalDevice(physical_device) &&
mocktail::graphics::Etc2SupportAdvertised()) {
features->features.textureCompressionETC2 = VK_TRUE;
}
}
VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceFormatProperties(
VkPhysicalDevice physical_device, VkFormat format,
VkFormatProperties* properties) {
const auto host =
HostPhysicalDeviceProc<PFN_vkGetPhysicalDeviceFormatProperties>(
"vkGetPhysicalDeviceFormatProperties", nullptr);
if (host == nullptr || properties == nullptr) {
return;
}
mocktail::graphics::Etc2EmulatedFormat mapped;
if (!mocktail::graphics::LookupEmulatedEtc2Format(format, &mapped) ||
!Etc2EmulatedPhysicalDevice(physical_device)) {
host(physical_device, format, properties);
return;
}
host(physical_device, mapped.host_format, properties);
properties->linearTilingFeatures = 0;
properties->optimalTilingFeatures =
mocktail::graphics::EmulatedEtc2FormatFeatures(
properties->optimalTilingFeatures);
properties->bufferFeatures = 0;
}
VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceFormatProperties2(
VkPhysicalDevice physical_device, VkFormat format,
VkFormatProperties2* properties) {
const auto host =
HostPhysicalDeviceProc<PFN_vkGetPhysicalDeviceFormatProperties2>(
"vkGetPhysicalDeviceFormatProperties2",
"vkGetPhysicalDeviceFormatProperties2KHR");
if (host == nullptr || properties == nullptr) {
return;
}
mocktail::graphics::Etc2EmulatedFormat mapped;
if (!mocktail::graphics::LookupEmulatedEtc2Format(format, &mapped) ||
!Etc2EmulatedPhysicalDevice(physical_device)) {
host(physical_device, format, properties);
return;
}
host(physical_device, mapped.host_format, properties);
VkFormatProperties& base = properties->formatProperties;
base.linearTilingFeatures = 0;
base.optimalTilingFeatures =
mocktail::graphics::EmulatedEtc2FormatFeatures(base.optimalTilingFeatures);
base.bufferFeatures = 0;
if (auto* extended = const_cast<VkFormatProperties3*>(
reinterpret_cast<const VkFormatProperties3*>(FindFeature(
properties->pNext, VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_3)));
extended != nullptr) {
extended->linearTilingFeatures = 0;
extended->optimalTilingFeatures =
mocktail::graphics::EmulatedEtc2FormatFeatures(
static_cast<VkFormatFeatureFlags>(
extended->optimalTilingFeatures));
extended->bufferFeatures = 0;
}
}
VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceImageFormatProperties(
VkPhysicalDevice physical_device, VkFormat format, VkImageType type,
VkImageTiling tiling, VkImageUsageFlags usage, VkImageCreateFlags flags,
VkImageFormatProperties* properties) {
const auto host =
HostPhysicalDeviceProc<PFN_vkGetPhysicalDeviceImageFormatProperties>(
"vkGetPhysicalDeviceImageFormatProperties", nullptr);
if (host == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
mocktail::graphics::Etc2EmulatedFormat mapped;
if (!mocktail::graphics::LookupEmulatedEtc2Format(format, &mapped) ||
!Etc2EmulatedPhysicalDevice(physical_device)) {
return host(physical_device, format, type, tiling, usage, flags,
properties);
}
if (tiling != VK_IMAGE_TILING_OPTIMAL || type != VK_IMAGE_TYPE_2D) {
return VK_ERROR_FORMAT_NOT_SUPPORTED;
}
return host(physical_device, mapped.host_format, type, tiling, usage,
flags & ~static_cast<VkImageCreateFlags>(
VK_IMAGE_CREATE_BLOCK_TEXEL_VIEW_COMPATIBLE_BIT),
properties);
}
VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceImageFormatProperties2(
VkPhysicalDevice physical_device,
const VkPhysicalDeviceImageFormatInfo2* info,
VkImageFormatProperties2* properties) {
const auto host =
HostPhysicalDeviceProc<PFN_vkGetPhysicalDeviceImageFormatProperties2>(
"vkGetPhysicalDeviceImageFormatProperties2",
"vkGetPhysicalDeviceImageFormatProperties2KHR");
if (host == nullptr || info == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
mocktail::graphics::Etc2EmulatedFormat mapped;
if (!mocktail::graphics::LookupEmulatedEtc2Format(info->format, &mapped) ||
!Etc2EmulatedPhysicalDevice(physical_device)) {
return host(physical_device, info, properties);
}
if (info->tiling != VK_IMAGE_TILING_OPTIMAL ||
info->type != VK_IMAGE_TYPE_2D) {
return VK_ERROR_FORMAT_NOT_SUPPORTED;
}
VkPhysicalDeviceImageFormatInfo2 host_info = *info;
host_info.format = mapped.host_format;
host_info.flags &= ~static_cast<VkImageCreateFlags>(
VK_IMAGE_CREATE_BLOCK_TEXEL_VIEW_COMPATIBLE_BIT);
return host(physical_device, &host_info, properties);
}
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage(
VkDevice device, const VkImageCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImage* image) {
return State().etc2.CreateImage(device, create_info, allocator, image);
}
VKAPI_ATTR void VKAPI_CALL vkDestroyImage(
VkDevice device, VkImage image, const VkAllocationCallbacks* allocator) {
State().etc2.DestroyImage(device, image, allocator);
}
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImageView(
VkDevice device, const VkImageViewCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImageView* view) {
return State().etc2.CreateImageView(device, create_info, allocator, view);
}
VKAPI_ATTR VkResult VKAPI_CALL vkBindBufferMemory(VkDevice device,
VkBuffer buffer,
VkDeviceMemory memory,
VkDeviceSize offset) {
return State().etc2.BindBufferMemory(device, buffer, memory, offset);
}
VKAPI_ATTR VkResult VKAPI_CALL vkBindBufferMemory2(
VkDevice device, std::uint32_t count,
const VkBindBufferMemoryInfo* infos) {
return State().etc2.BindBufferMemory2(device, count, infos);
}
VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory(VkDevice device,
VkDeviceMemory memory,
VkDeviceSize offset,
VkDeviceSize size,
VkMemoryMapFlags flags,
void** data) {
return State().etc2.MapMemory(device, memory, offset, size, flags, data);
}
VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory2(VkDevice device,
const VkMemoryMapInfo* info,
void** data) {
return State().etc2.MapMemory2(device, info, data);
}
VKAPI_ATTR void VKAPI_CALL vkUnmapMemory(VkDevice device,
VkDeviceMemory memory) {
State().etc2.UnmapMemory(device, memory);
}
VKAPI_ATTR VkResult VKAPI_CALL vkUnmapMemory2(VkDevice device,
const VkMemoryUnmapInfo* info) {
return State().etc2.UnmapMemory2(device, info);
}
VKAPI_ATTR void VKAPI_CALL vkFreeMemory(
VkDevice device, VkDeviceMemory memory,
const VkAllocationCallbacks* allocator) {
State().etc2.FreeMemory(device, memory, allocator);
}
VKAPI_ATTR void VKAPI_CALL vkDestroyBuffer(
VkDevice device, VkBuffer buffer, const VkAllocationCallbacks* allocator) {
State().etc2.DestroyBuffer(device, buffer, allocator);
}
VKAPI_ATTR void VKAPI_CALL vkCmdCopyBufferToImage(
VkCommandBuffer command_buffer, VkBuffer source, VkImage destination,
VkImageLayout layout, std::uint32_t region_count,
const VkBufferImageCopy* regions) {
State().etc2.CmdCopyBufferToImage(
HostDispatchForCommandBuffer(command_buffer).device, command_buffer,
source, destination, layout, region_count, regions);
}
VKAPI_ATTR void VKAPI_CALL vkCmdCopyBufferToImage2(
VkCommandBuffer command_buffer, const VkCopyBufferToImageInfo2* info) {
State().etc2.CmdCopyBufferToImage2(
HostDispatchForCommandBuffer(command_buffer).device, command_buffer,
info);
}
VKAPI_ATTR void VKAPI_CALL vkCmdCopyImage(
VkCommandBuffer command_buffer, VkImage source,
VkImageLayout source_layout, VkImage destination,
VkImageLayout destination_layout, std::uint32_t region_count,
const VkImageCopy* regions) {
State().etc2.CmdCopyImage(
HostDispatchForCommandBuffer(command_buffer).device, command_buffer,
source, source_layout, destination, destination_layout, region_count,
regions);
}
VKAPI_ATTR void VKAPI_CALL vkCmdCopyImage2(VkCommandBuffer command_buffer,
const VkCopyImageInfo2* info) {
State().etc2.CmdCopyImage2(
HostDispatchForCommandBuffer(command_buffer).device, command_buffer,
info);
}
VKAPI_ATTR void VKAPI_CALL vkCmdExecuteCommands(
VkCommandBuffer command_buffer, std::uint32_t count,
const VkCommandBuffer* secondaries) {
State().etc2.CmdExecuteCommands(
HostDispatchForCommandBuffer(command_buffer).device, command_buffer,
count, secondaries);
}
} // extern "C"
+398
View File
@@ -0,0 +1,398 @@
#include "mocktail/graphics/etc2_decoder.h"
#include <algorithm>
#include <array>
#include <pthread.h>
#include <atomic>
#include <limits>
#include <vector>
namespace mocktail::graphics {
namespace {
constexpr std::array<int, 8> kDistance = {3, 6, 11, 16, 23, 32, 41, 64};
// Indexed by (msb << 1) | lsb.
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},
};
std::uint8_t Clamp255(int value) {
return static_cast<std::uint8_t>(std::clamp(value, 0, 255));
}
int Expand4(int value) { return (value << 4) | value; }
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); }
// Texels use column-major indexing: x * 4 + y.
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 = [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];
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);
}
}
}
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);
}
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);
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));
}
} // namespace
std::size_t EtcBlockBytes(EtcFormat format) {
switch (format) {
case EtcFormat::kEtc2Rgba8:
case EtcFormat::kEacRg11:
case EtcFormat::kEacRg11Signed:
return 16;
default:
return 8;
}
}
std::size_t EtcDecodedTexelBytes(EtcFormat format) {
switch (format) {
case EtcFormat::kEacR11:
case EtcFormat::kEacR11Signed:
return 2;
default:
return 4;
}
}
namespace {
constexpr std::uint64_t kBlocksPerBand = 16384;
constexpr std::uint64_t kParallelMinimumBlocks = 65536;
bool ImageFits(EtcFormat format, const std::uint8_t* source,
std::size_t source_bytes, std::uint32_t width,
std::uint32_t height, const std::uint8_t* destination,
std::size_t destination_bytes) {
if (source == nullptr || destination == nullptr || width == 0 ||
height == 0) {
return false;
}
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;
return blocks_wide * blocks_high * EtcBlockBytes(format) <= source_bytes &&
static_cast<std::uint64_t>(width) * height *
EtcDecodedTexelBytes(format) <=
destination_bytes;
}
} // namespace
bool DecodeEtcImage(EtcFormat format, const std::uint8_t* source,
std::size_t source_bytes, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination,
std::size_t destination_bytes) {
return DecodeEtcImageBlockRows(format, source, source_bytes, width, height, 0,
(height + 3) / 4, destination,
destination_bytes);
}
namespace {
struct DecodeBand {
EtcDecodeJob* job;
std::uint32_t first_block_row;
std::uint32_t block_row_count;
};
struct DecodeBandQueue {
std::vector<DecodeBand> bands;
std::atomic<std::size_t> next{0};
void Run() {
for (;;) {
const std::size_t index = next.fetch_add(1, std::memory_order_relaxed);
if (index >= bands.size()) {
return;
}
const DecodeBand& band = bands[index];
const EtcDecodeJob& job = *band.job;
DecodeEtcImageBlockRows(job.format, job.source, job.source_bytes,
job.width, job.height, band.first_block_row,
band.block_row_count, job.destination,
job.destination_bytes);
}
}
};
void* RunDecodeBandQueue(void* queue) {
static_cast<DecodeBandQueue*>(queue)->Run();
return nullptr;
}
} // namespace
void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
unsigned worker_count) {
if (jobs == nullptr) {
return;
}
DecodeBandQueue queue;
std::vector<DecodeBand>& bands = queue.bands;
std::uint64_t total_blocks = 0;
for (std::size_t index = 0; index < count; ++index) {
EtcDecodeJob& job = jobs[index];
job.ok = ImageFits(job.format, job.source, job.source_bytes, job.width,
job.height, job.destination, job.destination_bytes);
if (!job.ok) {
continue;
}
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));
for (std::uint32_t row = 0; row < blocks_high; row += rows_per_band) {
bands.push_back(
{&job, row, std::min(rows_per_band, blocks_high - row)});
}
total_blocks += static_cast<std::uint64_t>(blocks_wide) * blocks_high;
}
const std::size_t threads =
total_blocks < kParallelMinimumBlocks
? 1
: std::min<std::size_t>(std::max(worker_count, 1U), bands.size());
std::vector<pthread_t> workers;
workers.reserve(threads > 0 ? threads - 1 : 0);
for (std::size_t index = 1; index < threads; ++index) {
pthread_t worker;
if (pthread_create(&worker, nullptr, RunDecodeBandQueue, &queue) != 0) {
break;
}
workers.push_back(worker);
}
queue.Run();
for (pthread_t worker : workers) {
pthread_join(worker, nullptr);
}
}
bool DecodeEtcImageBlockRows(EtcFormat format, const std::uint8_t* source,
std::size_t source_bytes, std::uint32_t width,
std::uint32_t height,
std::uint32_t first_block_row,
std::uint32_t block_row_count,
std::uint8_t* destination,
std::size_t destination_bytes) {
if (!ImageFits(format, source, source_bytes, width, height, destination,
destination_bytes)) {
return false;
}
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;
if (static_cast<std::uint64_t>(first_block_row) + block_row_count >
blocks_high) {
return false;
}
const std::size_t texel_bytes = EtcDecodedTexelBytes(format);
const std::size_t block_bytes = EtcBlockBytes(format);
std::array<std::uint8_t, 64> rgba{};
const std::uint64_t end_block_row =
static_cast<std::uint64_t>(first_block_row) + block_row_count;
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;
}
}
}
}
}
}
return true;
}
} // namespace mocktail::graphics
@@ -0,0 +1,375 @@
#include "mocktail/graphics/gles_text_overlay_compositor.h"
#include <GLES3/gl3.h>
#include <SDL3/SDL.h>
#include <array>
#include <cstdio>
#include <vector>
namespace mocktail::graphics {
namespace {
constexpr std::uint64_t kMaximumOverlayBytes = 64ULL * 1024ULL * 1024ULL;
#define OVERLAY_GL_FUNCTIONS(F) \
F(PFNGLACTIVETEXTUREPROC, ActiveTexture) \
F(PFNGLATTACHSHADERPROC, AttachShader) \
F(PFNGLBINDBUFFERPROC, BindBuffer) \
F(PFNGLBINDFRAMEBUFFERPROC, BindFramebuffer) \
F(PFNGLBINDSAMPLERPROC, BindSampler) \
F(PFNGLBINDTEXTUREPROC, BindTexture) \
F(PFNGLBINDVERTEXARRAYPROC, BindVertexArray) \
F(PFNGLBLENDEQUATIONSEPARATEPROC, BlendEquationSeparate) \
F(PFNGLBLENDFUNCSEPARATEPROC, BlendFuncSeparate) \
F(PFNGLCOLORMASKPROC, ColorMask) \
F(PFNGLCOMPILESHADERPROC, CompileShader) \
F(PFNGLCREATEPROGRAMPROC, CreateProgram) \
F(PFNGLCREATESHADERPROC, CreateShader) \
F(PFNGLDELETEPROGRAMPROC, DeleteProgram) \
F(PFNGLDELETESHADERPROC, DeleteShader) \
F(PFNGLDELETETEXTURESPROC, DeleteTextures) \
F(PFNGLDELETEVERTEXARRAYSPROC, DeleteVertexArrays) \
F(PFNGLDISABLEPROC, Disable) \
F(PFNGLDRAWARRAYSPROC, DrawArrays) \
F(PFNGLDRAWBUFFERSPROC, DrawBuffers) \
F(PFNGLENABLEPROC, Enable) \
F(PFNGLGENTEXTURESPROC, GenTextures) \
F(PFNGLGENVERTEXARRAYSPROC, GenVertexArrays) \
F(PFNGLGETBOOLEANVPROC, GetBooleanv) \
F(PFNGLGETINTEGERVPROC, GetIntegerv) \
F(PFNGLGETPROGRAMINFOLOGPROC, GetProgramInfoLog) \
F(PFNGLGETPROGRAMIVPROC, GetProgramiv) \
F(PFNGLGETSHADERINFOLOGPROC, GetShaderInfoLog) \
F(PFNGLGETSHADERIVPROC, GetShaderiv) \
F(PFNGLGETUNIFORMLOCATIONPROC, GetUniformLocation) \
F(PFNGLISENABLEDPROC, IsEnabled) \
F(PFNGLLINKPROGRAMPROC, LinkProgram) \
F(PFNGLPIXELSTOREIPROC, PixelStorei) \
F(PFNGLSHADERSOURCEPROC, ShaderSource) \
F(PFNGLTEXIMAGE2DPROC, TexImage2D) \
F(PFNGLTEXPARAMETERIPROC, TexParameteri) \
F(PFNGLUNIFORM1IPROC, Uniform1i) \
F(PFNGLUNIFORM4FPROC, Uniform4f) \
F(PFNGLUSEPROGRAMPROC, UseProgram) \
F(PFNGLVIEWPORTPROC, Viewport)
struct Gl {
#define DECLARE_GL(type, name) type name = nullptr;
OVERLAY_GL_FUNCTIONS(DECLARE_GL)
#undef DECLARE_GL
bool Load() {
#define LOAD_GL(type, name) \
name = reinterpret_cast<type>(SDL_GL_GetProcAddress("gl" #name)); \
if (name == nullptr) \
return false;
OVERLAY_GL_FUNCTIONS(LOAD_GL)
#undef LOAD_GL
return true;
}
};
#undef OVERLAY_GL_FUNCTIONS
constexpr GLenum kCapabilities[] = {GL_BLEND,
GL_CULL_FACE,
GL_DEPTH_TEST,
GL_STENCIL_TEST,
GL_SCISSOR_TEST,
GL_RASTERIZER_DISCARD,
GL_SAMPLE_ALPHA_TO_COVERAGE,
GL_SAMPLE_COVERAGE};
constexpr GLenum kUnpackParameters[] = {
GL_UNPACK_ALIGNMENT, GL_UNPACK_ROW_LENGTH, GL_UNPACK_SKIP_ROWS,
GL_UNPACK_SKIP_PIXELS};
// Roblox caches GL state. Every binding and state changed here must be restored
// even if a new overlay revision disappears while it is being copied.
struct SavedGlState {
explicit SavedGlState(Gl &api) : gl(api) {
gl.GetIntegerv(GL_CURRENT_PROGRAM, &program);
gl.GetIntegerv(GL_VERTEX_ARRAY_BINDING, &vertex_array);
gl.GetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &framebuffer);
gl.GetIntegerv(GL_VIEWPORT, viewport.data());
gl.GetIntegerv(GL_ACTIVE_TEXTURE, &active_texture);
gl.ActiveTexture(GL_TEXTURE0);
gl.GetIntegerv(GL_TEXTURE_BINDING_2D, &texture);
gl.GetIntegerv(GL_SAMPLER_BINDING, &sampler);
gl.GetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &unpack_buffer);
for (std::size_t i = 0; i < unpack.size(); ++i) {
gl.GetIntegerv(kUnpackParameters[i], &unpack[i]);
}
gl.GetIntegerv(GL_BLEND_SRC_RGB, &blend_src_rgb);
gl.GetIntegerv(GL_BLEND_DST_RGB, &blend_dst_rgb);
gl.GetIntegerv(GL_BLEND_SRC_ALPHA, &blend_src_alpha);
gl.GetIntegerv(GL_BLEND_DST_ALPHA, &blend_dst_alpha);
gl.GetIntegerv(GL_BLEND_EQUATION_RGB, &blend_equation_rgb);
gl.GetIntegerv(GL_BLEND_EQUATION_ALPHA, &blend_equation_alpha);
gl.GetBooleanv(GL_COLOR_WRITEMASK, color_mask.data());
for (std::size_t i = 0; i < enabled.size(); ++i) {
enabled[i] = gl.IsEnabled(kCapabilities[i]);
}
gl.BindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
gl.GetIntegerv(GL_DRAW_BUFFER0, &backbuffer_draw_buffer);
}
~SavedGlState() {
const GLenum draw_buffer = static_cast<GLenum>(backbuffer_draw_buffer);
gl.DrawBuffers(1, &draw_buffer);
gl.BindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer);
gl.Viewport(viewport[0], viewport[1], viewport[2], viewport[3]);
gl.UseProgram(program);
gl.BindVertexArray(vertex_array);
gl.BindTexture(GL_TEXTURE_2D, texture);
gl.BindSampler(0, sampler);
gl.ActiveTexture(active_texture);
gl.BindBuffer(GL_PIXEL_UNPACK_BUFFER, unpack_buffer);
for (std::size_t i = 0; i < unpack.size(); ++i) {
gl.PixelStorei(kUnpackParameters[i], unpack[i]);
}
gl.BlendFuncSeparate(blend_src_rgb, blend_dst_rgb, blend_src_alpha,
blend_dst_alpha);
gl.BlendEquationSeparate(blend_equation_rgb, blend_equation_alpha);
gl.ColorMask(color_mask[0], color_mask[1], color_mask[2], color_mask[3]);
for (std::size_t i = 0; i < enabled.size(); ++i) {
(enabled[i] ? gl.Enable : gl.Disable)(kCapabilities[i]);
}
}
Gl &gl;
GLint program = 0, vertex_array = 0, framebuffer = 0;
GLint active_texture = GL_TEXTURE0, texture = 0, sampler = 0;
GLint unpack_buffer = 0, backbuffer_draw_buffer = GL_BACK;
GLint blend_src_rgb = 0, blend_dst_rgb = 0;
GLint blend_src_alpha = 0, blend_dst_alpha = 0;
GLint blend_equation_rgb = 0, blend_equation_alpha = 0;
std::array<GLint, 4> viewport{}, unpack{};
std::array<GLboolean, 4> color_mask{};
std::array<GLboolean, sizeof(kCapabilities) / sizeof(kCapabilities[0])>
enabled{};
};
struct PixelCopy {
explicit PixelCopy(std::size_t bytes) : pixels(bytes) {}
~PixelCopy() {
volatile std::uint8_t *bytes = pixels.data();
for (std::size_t i = 0; i < pixels.size(); ++i)
bytes[i] = 0;
}
std::vector<std::uint8_t> pixels;
};
bool ValidFrame(const MocktailTextOverlayFrameInfo &frame) {
const std::uint64_t row_bytes = std::uint64_t{frame.width} * 4;
const std::uint64_t bytes = row_bytes * frame.height;
return frame.abi_version == MocktailTextOverlayFrameInfo::kAbiVersion &&
frame.coordinate_width != 0 && frame.coordinate_height != 0 &&
frame.width != 0 && frame.height != 0 &&
row_bytes <= kMaximumOverlayBytes && bytes <= kMaximumOverlayBytes &&
frame.row_bytes == row_bytes && frame.rgba_bytes == bytes;
}
} // namespace
struct GlesTextOverlayCompositor::Impl {
explicit Impl(GlesTextOverlaySource input) : source(input) {}
~Impl() {
if (context != nullptr && SDL_GL_GetCurrentContext() == context) {
if (texture != 0)
gl.DeleteTextures(1, &texture);
if (vertex_array != 0)
gl.DeleteVertexArrays(1, &vertex_array);
if (program != 0)
gl.DeleteProgram(program);
}
}
bool Fail(const char *message) {
if (!failure_logged) {
std::fprintf(stderr, " [gles-overlay] %s\n", message);
failure_logged = true;
}
return false;
}
GLuint Compile(GLenum type, const char *code) {
const GLuint shader = gl.CreateShader(type);
if (shader == 0)
return 0;
gl.ShaderSource(shader, 1, &code, nullptr);
gl.CompileShader(shader);
GLint compiled = GL_FALSE;
gl.GetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_TRUE)
return shader;
char log[1024]{};
gl.GetShaderInfoLog(shader, sizeof(log), nullptr, log);
Fail(log);
gl.DeleteShader(shader);
return 0;
}
bool CreateResources() {
constexpr const char *vertex_source = R"(#version 300 es
precision highp float;
uniform vec4 bounds;
out vec2 uv;
void main() {
vec2 corners[4] = vec2[4](vec2(0, 0), vec2(1, 0),
vec2(0, 1), vec2(1, 1));
uv = corners[gl_VertexID];
gl_Position = vec4(mix(bounds.xy, bounds.zw, uv), 0, 1);
}
)";
constexpr const char *fragment_source = R"(#version 300 es
precision mediump float;
uniform sampler2D image;
in vec2 uv;
out vec4 color;
void main() { color = texture(image, uv); }
)";
const GLuint vertex = Compile(GL_VERTEX_SHADER, vertex_source);
const GLuint fragment = Compile(GL_FRAGMENT_SHADER, fragment_source);
if (vertex == 0 || fragment == 0) {
if (vertex != 0)
gl.DeleteShader(vertex);
if (fragment != 0)
gl.DeleteShader(fragment);
return false;
}
program = gl.CreateProgram();
if (program != 0) {
gl.AttachShader(program, vertex);
gl.AttachShader(program, fragment);
gl.LinkProgram(program);
}
gl.DeleteShader(vertex);
gl.DeleteShader(fragment);
GLint linked = GL_FALSE;
if (program != 0)
gl.GetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked != GL_TRUE) {
char log[1024] = "unable to create the GLES text program";
if (program != 0)
gl.GetProgramInfoLog(program, sizeof(log), nullptr, log);
return Fail(log);
}
bounds_location = gl.GetUniformLocation(program, "bounds");
image_location = gl.GetUniformLocation(program, "image");
gl.GenVertexArrays(1, &vertex_array);
gl.GenTextures(1, &texture);
gl.GetIntegerv(GL_MAX_TEXTURE_SIZE, &max_texture_size);
return vertex_array != 0 && texture != 0 && bounds_location >= 0 &&
image_location >= 0;
}
GlesTextOverlaySource source;
Gl gl;
SDL_GLContext context = nullptr;
GLuint program = 0, vertex_array = 0, texture = 0;
GLint bounds_location = -1, image_location = -1, max_texture_size = 0;
bool initialized = false, failed = false, failure_logged = false;
bool uploaded = false, ready_logged = false;
std::uint64_t revision = 0;
std::uint32_t width = 0, height = 0;
};
GlesTextOverlayCompositor::GlesTextOverlayCompositor(
GlesTextOverlaySource source)
: impl_(std::make_unique<Impl>(source)) {}
GlesTextOverlayCompositor::~GlesTextOverlayCompositor() = default;
bool GlesTextOverlayCompositor::Draw(SDL_Window *window) {
Impl &state = *impl_;
const GlesTextOverlaySource &source = state.source;
if (source.may_present != nullptr && !source.may_present())
return true;
if (source.query == nullptr || source.copy == nullptr)
return true;
MocktailTextOverlayFrameInfo frame{};
if (!source.query(&frame) || frame.visible == 0)
return true;
if (!ValidFrame(frame))
return state.Fail("invalid text overlay frame");
int pixel_width = 0, pixel_height = 0;
if (window == nullptr ||
!SDL_GetWindowSizeInPixels(window, &pixel_width, &pixel_height) ||
pixel_width <= 0 || pixel_height <= 0)
return false;
if (state.failed)
return false;
if (!state.initialized) {
state.context = SDL_GL_GetCurrentContext();
if (state.context == nullptr || !state.gl.Load()) {
state.failed = true;
return state.Fail("OpenGL ES 3.0 functions are unavailable");
}
} else if (SDL_GL_GetCurrentContext() != state.context) {
return state.Fail("text overlay called with a different GL context");
}
Gl &gl = state.gl;
SavedGlState saved(gl);
if (!state.initialized) {
if (!state.CreateResources()) {
state.failed = true;
return state.Fail("unable to initialize the GLES text overlay");
}
state.initialized = true;
}
if (frame.width > static_cast<std::uint32_t>(state.max_texture_size) ||
frame.height > static_cast<std::uint32_t>(state.max_texture_size)) {
return state.Fail("text overlay exceeds GL_MAX_TEXTURE_SIZE");
}
gl.BindTexture(GL_TEXTURE_2D, state.texture);
gl.BindSampler(0, 0);
if (!state.uploaded || state.revision != frame.revision ||
state.width != frame.width || state.height != frame.height) {
PixelCopy copy(static_cast<std::size_t>(frame.rgba_bytes));
if (!source.copy(frame.revision, copy.pixels.data(), copy.pixels.size())) {
return false;
}
gl.BindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
gl.PixelStorei(GL_UNPACK_ALIGNMENT, 1);
gl.PixelStorei(GL_UNPACK_ROW_LENGTH, 0);
gl.PixelStorei(GL_UNPACK_SKIP_ROWS, 0);
gl.PixelStorei(GL_UNPACK_SKIP_PIXELS, 0);
gl.TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, frame.width, frame.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, copy.pixels.data());
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
gl.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
state.revision = frame.revision;
state.width = frame.width;
state.height = frame.height;
state.uploaded = true;
}
for (GLenum capability : kCapabilities)
gl.Disable(capability);
gl.Enable(GL_BLEND);
gl.BlendEquationSeparate(GL_FUNC_ADD, GL_FUNC_ADD);
gl.BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE,
GL_ONE_MINUS_SRC_ALPHA);
gl.ColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
const GLenum back = GL_BACK;
gl.DrawBuffers(1, &back);
gl.Viewport(0, 0, pixel_width, pixel_height);
gl.UseProgram(state.program);
gl.BindVertexArray(state.vertex_array);
gl.Uniform1i(state.image_location, 0);
const double x_scale = 2.0 / frame.coordinate_width;
const double y_scale = 2.0 / frame.coordinate_height;
gl.Uniform4f(state.bounds_location, frame.x * x_scale - 1.0,
1.0 - frame.y * y_scale,
(static_cast<double>(frame.x) + frame.width) * x_scale - 1.0,
1.0 - (static_cast<double>(frame.y) + frame.height) * y_scale);
gl.DrawArrays(GL_TRIANGLE_STRIP, 0, 4);
if (!state.ready_logged) {
std::fprintf(stderr,
" [gles-overlay] same-surface TextBox compositor ready\n");
state.ready_logged = true;
}
return true;
}
} // namespace mocktail::graphics
+182
View File
@@ -0,0 +1,182 @@
#include "mocktail/graphics/texture_override.h"
#include <png.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
namespace mocktail::graphics {
std::uint64_t HashBytes(const std::uint8_t* data, std::size_t size) {
std::uint64_t hash = 0xcbf29ce484222325ULL;
for (std::size_t index = 0; index < size; ++index) {
hash ^= data[index];
hash *= 0x100000001b3ULL;
}
return hash;
}
std::string HashName(std::uint64_t hash) {
char text[17];
std::snprintf(text, sizeof(text), "%016llx",
static_cast<unsigned long long>(hash));
return text;
}
bool ReadPngRgba(const std::string& path, RgbaImage* image) {
if (image == nullptr) {
return false;
}
png_image png{};
png.version = PNG_IMAGE_VERSION;
if (png_image_begin_read_from_file(&png, path.c_str()) == 0) {
return false;
}
png.format = PNG_FORMAT_RGBA;
RgbaImage result;
result.width = png.width;
result.height = png.height;
result.pixels.resize(PNG_IMAGE_SIZE(png));
if (png_image_finish_read(&png, nullptr, result.pixels.data(), 0,
nullptr) == 0) {
png_image_free(&png);
return false;
}
*image = std::move(result);
return true;
}
bool WritePngRgba(const std::string& path, const RgbaImage& image) {
if (image.width == 0 || image.height == 0 ||
image.pixels.size() !=
static_cast<std::size_t>(image.width) * image.height * 4) {
return false;
}
png_image png{};
png.version = PNG_IMAGE_VERSION;
png.width = image.width;
png.height = image.height;
png.format = PNG_FORMAT_RGBA;
const int written = png_image_write_to_file(&png, path.c_str(), 0,
image.pixels.data(), 0, nullptr);
png_image_free(&png);
return written != 0;
}
void ResampleRgba(const RgbaImage& source, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination) {
if (source.pixels.size() <
static_cast<std::size_t>(source.width) * source.height * 4) {
return;
}
ResampleRgba(source.pixels.data(), source.width, source.height, 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) {
if (source == nullptr || destination == nullptr || width == 0 ||
height == 0 || source_width == 0 || source_height == 0) {
return;
}
for (std::uint32_t y = 0; y < height; ++y) {
const std::uint64_t y0 = static_cast<std::uint64_t>(y) * source_height / height;
std::uint64_t y1 =
(static_cast<std::uint64_t>(y) + 1) * source_height / height;
if (y1 <= y0) {
y1 = y0 + 1;
}
for (std::uint32_t x = 0; x < width; ++x) {
const std::uint64_t x0 = static_cast<std::uint64_t>(x) * source_width / width;
std::uint64_t x1 =
(static_cast<std::uint64_t>(x) + 1) * source_width / width;
if (x1 <= x0) {
x1 = x0 + 1;
}
std::uint64_t sum[4] = {0, 0, 0, 0};
for (std::uint64_t sy = y0; sy < y1; ++sy) {
for (std::uint64_t sx = x0; sx < x1; ++sx) {
const std::uint8_t* pixel = source + (sy * source_width + sx) * 4;
for (int channel = 0; channel < 4; ++channel) {
sum[channel] += pixel[channel];
}
}
}
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);
}
}
}
}
namespace {
std::string EnvironmentString(const char* name) {
const char* value = std::getenv(name);
return value != nullptr ? value : "";
}
} // namespace
TextureOverrides::TextureOverrides(std::string override_dir,
std::string dump_dir)
: override_dir_(std::move(override_dir)), dump_dir_(std::move(dump_dir)) {}
TextureOverrides TextureOverrides::FromEnvironment() {
return TextureOverrides(EnvironmentString("MOCKTAIL_TEXTURE_OVERRIDE_DIR"),
EnvironmentString("MOCKTAIL_TEXTURE_DUMP_DIR"));
}
std::shared_ptr<const RgbaImage> TextureOverrides::Lookup(std::uint64_t hash) {
if (override_dir_.empty()) {
return nullptr;
}
std::lock_guard<std::mutex> lock(mutex_);
const auto cached = cache_.find(hash);
if (cached != cache_.end()) {
return cached->second;
}
std::shared_ptr<const RgbaImage> loaded;
RgbaImage image;
const std::string path = override_dir_ + "/" + HashName(hash) + ".png";
if (ReadPngRgba(path, &image) && image.width > 0 && image.height > 0) {
loaded = std::make_shared<const RgbaImage>(std::move(image));
std::fprintf(stderr, " [vulkan] texture override loaded: %s (%ux%u)\n",
path.c_str(), loaded->width, loaded->height);
}
cache_[hash] = loaded;
return loaded;
}
void TextureOverrides::Dump(std::uint64_t hash, std::uint32_t width,
std::uint32_t height, const std::uint8_t* rgba) {
if (dump_dir_.empty() || rgba == nullptr || width == 0 || height == 0) {
return;
}
{
std::lock_guard<std::mutex> lock(mutex_);
if (!dumped_.insert(hash).second) {
return;
}
}
std::error_code error;
std::filesystem::create_directories(dump_dir_, error);
RgbaImage image;
image.width = width;
image.height = height;
image.pixels.assign(rgba, rgba + static_cast<std::size_t>(width) * height * 4);
const std::string path = dump_dir_ + "/" + HashName(hash) + "_" +
std::to_string(width) + "x" +
std::to_string(height) + ".png";
if (!WritePngRgba(path, image)) {
std::fprintf(stderr, " [vulkan] texture dump failed: %s\n", path.c_str());
}
}
} // namespace mocktail::graphics
File diff suppressed because it is too large. Load diff
+64 -23
View File
@@ -1,5 +1,7 @@
#include "jnivm/jnivm.h"
#include "mocktail/audio/fmod_thread_floating_point.h"
#include <algorithm>
#include <atomic>
#include <cctype>
@@ -21,6 +23,8 @@
#include <unordered_set>
#include <vector>
#include "runtime/display_size.h"
namespace jnivm {
void Class::RegisterMethod(const std::string& method_name,
@@ -56,6 +60,7 @@ namespace {
thread_local JNIEnv* g_thread_local_env = nullptr;
thread_local JNIEnv g_thread_env_storage = {};
thread_local VM* g_thread_vm_instance = nullptr;
thread_local mocktail::audio::FmodThreadFloatingPointMode g_thread_audio_fp_mode;
thread_local std::vector<std::vector<jobject>> g_local_frames;
std::recursive_mutex g_jni_state_mutex;
@@ -559,13 +564,25 @@ bool ResolvedDarkTheme() {
}
jint IntResultForName(const char* name) {
if (std::strcmp(name, "getScreenWidth") == 0 ||
std::strcmp(name, "getWidth") == 0) {
return 1280;
if (std::strcmp(name, "getScreenWidth") == 0) {
return mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kDisplaySizeEnvironment))
.width;
}
if (std::strcmp(name, "getScreenHeight") == 0 ||
std::strcmp(name, "getHeight") == 0) {
return 720;
if (std::strcmp(name, "getScreenHeight") == 0) {
return mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kDisplaySizeEnvironment))
.height;
}
if (std::strcmp(name, "getWidth") == 0) {
return mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kWindowSizeEnvironment))
.width;
}
if (std::strcmp(name, "getHeight") == 0) {
return mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kWindowSizeEnvironment))
.height;
}
if (std::strcmp(name, "getDensityDpi") == 0) {
return 160;
@@ -2272,8 +2289,11 @@ jobject MakeDeviceStaticParamsObject() {
pseudo_object->object_fields["soc_model"] =
MakeString(identity.soc_model.c_str());
pseudo_object->boolean_fields["cpu64Bit"] = JNI_TRUE;
pseudo_object->int_fields["screenWidth"] = 1280;
pseudo_object->int_fields["screenHeight"] = 720;
const mocktail::runtime::DisplaySize host_display =
mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kDisplaySizeEnvironment));
pseudo_object->int_fields["screenWidth"] = host_display.width;
pseudo_object->int_fields["screenHeight"] = host_display.height;
pseudo_object->int_fields["screenDensityDpi"] = 160;
pseudo_object->int_fields["apiVersion"] = 33;
pseudo_object->int_fields["sdkVersion"] = 33;
@@ -2443,14 +2463,17 @@ void EnsureAndroidObjectGraph() {
SetObjectFieldRaw(decor_view, "holder", surface_holder);
SetObjectFieldRaw(decor_view, "surface", surface);
SetObjectFieldRaw(decor_view, "display", display);
SetIntFieldRaw(decor_view, "width", 1280);
SetIntFieldRaw(decor_view, "height", 720);
const mocktail::runtime::DisplaySize host_window =
mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kWindowSizeEnvironment));
SetIntFieldRaw(decor_view, "width", host_window.width);
SetIntFieldRaw(decor_view, "height", host_window.height);
SetObjectFieldRaw(surface_view, "holder", surface_holder);
SetObjectFieldRaw(surface_view, "surface", surface);
SetObjectFieldRaw(surface_view, "rootView", root_view);
SetIntFieldRaw(surface_view, "width", 1280);
SetIntFieldRaw(surface_view, "height", 720);
SetIntFieldRaw(surface_view, "width", host_window.width);
SetIntFieldRaw(surface_view, "height", host_window.height);
SetObjectFieldRaw(surface_holder, "surface", surface);
SetObjectFieldRaw(surface_holder, "surfaceFrame",
@@ -2458,11 +2481,14 @@ void EnsureAndroidObjectGraph() {
SetBooleanFieldRaw(surface, "valid", JNI_TRUE);
SetBooleanFieldRaw(surface, "isValid", JNI_TRUE);
SetIntFieldRaw(display, "width", 1280);
SetIntFieldRaw(display, "height", 720);
const mocktail::runtime::DisplaySize host_display =
mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kDisplaySizeEnvironment));
SetIntFieldRaw(display, "width", host_display.width);
SetIntFieldRaw(display, "height", host_display.height);
SetIntFieldRaw(display, "rotation", 0);
SetIntFieldRaw(display_metrics, "widthPixels", 1280);
SetIntFieldRaw(display_metrics, "heightPixels", 720);
SetIntFieldRaw(display_metrics, "widthPixels", host_display.width);
SetIntFieldRaw(display_metrics, "heightPixels", host_display.height);
SetIntFieldRaw(display_metrics, "densityDpi", 160);
SetFloatFieldRaw(display_metrics, "density", 1.0f);
SetFloatFieldRaw(display_metrics, "scaledDensity", 1.0f);
@@ -2473,9 +2499,10 @@ void EnsureAndroidObjectGraph() {
: PlatformIdentity{};
SetIntFieldRaw(configuration, "orientation", 2);
SetIntFieldRaw(configuration, "densityDpi", 160);
SetIntFieldRaw(configuration, "screenWidthDp", 1280);
SetIntFieldRaw(configuration, "screenHeightDp", 720);
SetIntFieldRaw(configuration, "smallestScreenWidthDp", 720);
SetIntFieldRaw(configuration, "screenWidthDp", host_window.width);
SetIntFieldRaw(configuration, "screenHeightDp", host_window.height);
SetIntFieldRaw(configuration, "smallestScreenWidthDp",
std::min(host_window.width, host_window.height));
SetIntFieldRaw(configuration, "touchscreen", identity.touch_enabled ? 3 : 1);
SetIntFieldRaw(configuration, "keyboard", identity.keyboard_enabled ? 2 : 1);
SetIntFieldRaw(configuration, "keyboardHidden",
@@ -4949,10 +4976,14 @@ jobject CreateAndroidConfiguration(JNIEnv* env) {
SetIntFieldRaw(configuration, "navigation", 1);
SetIntFieldRaw(configuration, "navigationHidden", 1);
SetIntFieldRaw(configuration, "orientation", 2);
SetIntFieldRaw(configuration, "screenHeightDp", 720);
const mocktail::runtime::DisplaySize config_window =
mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kWindowSizeEnvironment));
SetIntFieldRaw(configuration, "screenHeightDp", config_window.height);
SetIntFieldRaw(configuration, "screenLayout", 0);
SetIntFieldRaw(configuration, "screenWidthDp", 1280);
SetIntFieldRaw(configuration, "smallestScreenWidthDp", 720);
SetIntFieldRaw(configuration, "screenWidthDp", config_window.width);
SetIntFieldRaw(configuration, "smallestScreenWidthDp",
std::min(config_window.width, config_window.height));
SetIntFieldRaw(configuration, "touchscreen", identity.touch_enabled ? 3 : 1);
SetIntFieldRaw(configuration, "uiMode", 0);
return configuration;
@@ -4970,6 +5001,7 @@ VM::~VM() {
g_live_vms.erase(std::remove(g_live_vms.begin(), g_live_vms.end(), this),
g_live_vms.end());
if (g_thread_vm_instance == this) {
g_thread_audio_fp_mode.Restore();
g_thread_vm_instance = nullptr;
g_thread_local_env = nullptr;
g_thread_env_storage.functions = nullptr;
@@ -6323,6 +6355,7 @@ JNIEnv* VM::GetJNIEnv() {
}
return g_thread_local_env;
}
g_thread_audio_fp_mode.Restore();
g_thread_vm_instance = this;
g_thread_env_storage.functions = &native_interface_;
g_thread_local_env = &g_thread_env_storage;
@@ -6372,7 +6405,7 @@ std::shared_ptr<Class> VM::FindClass(const std::string& class_name) const {
void VM::InitJNIFunctionTables() {
invoke_interface_.AttachCurrentThread =
[](JavaVM* vm, void** env, void* /*args*/) -> jint {
[](JavaVM* vm, void** env, void* args) -> jint {
if (JniVmTraceEnabled()) {
std::cout << " [JNI] AttachCurrentThread enter vm=" << vm
<< " env_out=" << env << '\n';
@@ -6389,6 +6422,7 @@ void VM::InitJNIFunctionTables() {
return JNI_EINVAL;
}
if (g_thread_vm_instance != owner || !IsThreadLocalEnvValid()) {
g_thread_audio_fp_mode.Restore();
g_thread_vm_instance = owner;
if (!owner->jni_env_) {
owner->jni_env_ = &owner->jni_env_storage_;
@@ -6398,6 +6432,12 @@ void VM::InitJNIFunctionTables() {
owner->jni_env_->functions ? owner->jni_env_->functions
: &owner->native_interface_;
g_thread_local_env = &g_thread_env_storage;
const auto* attach_args = static_cast<const JavaVMAttachArgs*>(args);
if (attach_args != nullptr &&
g_thread_audio_fp_mode.Enable(attach_args->name)) {
std::fprintf(stderr, " [mocktail][audio] %s: enabled FTZ/DAZ\n",
attach_args->name);
}
}
// Reattaching to the same VM must retain any guest JNI table wrapper.
*env = g_thread_local_env;
@@ -6421,6 +6461,7 @@ void VM::InitJNIFunctionTables() {
if (g_thread_vm_instance != owner || !IsThreadLocalEnvValid()) {
return JNI_EDETACHED;
}
g_thread_audio_fp_mode.Restore();
g_thread_local_env = nullptr;
g_thread_vm_instance = nullptr;
g_thread_env_storage.functions = nullptr;
+110 -28
View File
@@ -3,6 +3,7 @@
#include <cctype>
#include <cerrno>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
@@ -56,6 +57,8 @@
#include "libc_shim/libc_shim.h"
#include "linker/linker.h"
#include "mocktail/graphics/bionic_egl_bridge.h"
#include "runtime/device_memory_profile.h"
#include "runtime/display_size.h"
#include "runtime/environment.h"
#include "runtime/discord_rpc.h"
#include "runtime/jnivm_platform_web_callbacks.h"
@@ -70,6 +73,7 @@
#include "runtime/roblox_text_input_jni_bridge.h"
#include "runtime/runtime_config.h"
#include "runtime/runtime_paths.h"
#include "runtime/texture_memory_policy.h"
#include "services/client_settings_service.h"
#include "services/http_client.h"
#include "window/window.h"
@@ -2393,6 +2397,21 @@ void InstallNativeGlJavaImplementation(JNIEnv* env, jclass native_gl_java_class)
jobject BuildPlatformParams(JNIEnv* env, jobject surface, bool is_headless);
mocktail::runtime::DisplaySize HostDisplaySize() {
return mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kDisplaySizeEnvironment));
}
mocktail::runtime::DisplaySize HostWindowPixelSize() {
const mocktail::window::WindowViewportSnapshot viewport =
mocktail::window::GetWindowViewportSnapshot();
if (viewport.valid()) {
return {viewport.pixel_width, viewport.pixel_height};
}
return mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kWindowSizeEnvironment));
}
jobject BuildDeviceParams(JNIEnv* env) {
jobject params = NewObject(env, "com/roblox/engine/jni/model/DeviceParams");
if (!params) {
@@ -2414,27 +2433,40 @@ jobject BuildDeviceParams(JNIEnv* env) {
SetStringField(env, params, "appVersion", app_version.c_str());
SetStringField(env, params, "country", "US");
SetStringField(env, params, "manufacturer", manufacturer.c_str());
SetStringField(env, params, "displayResolution", "1280x720");
const mocktail::runtime::DisplaySize display_size =
mocktail::runtime::ParseDisplaySize(
std::getenv(mocktail::runtime::kDisplaySizeEnvironment));
const std::string display_resolution = std::to_string(display_size.width) +
"x" +
std::to_string(display_size.height);
SetStringField(env, params, "displayResolution", display_resolution.c_str());
SetStringField(env, params, "networkType", "wifi");
SetStringField(env, params, "deviceSku", device_sku.c_str());
SetStringField(env, params, "socModel", soc_model.c_str());
SetStringField(env, params, "appBuildVariant", "googleProdRelease");
SetStringField(env, params, "testDeviceName", device_name.c_str());
const bool is_low_ram = !IsEnabled("MOCKTAIL_DISABLE_LOW_RAM_DEVICE");
SetIntField(env, params, "deviceTotalMemoryMB", is_low_ram ? 2048 : 4096);
SetIntField(env, params, "displayPhysicalWidthPixels", 1280);
SetIntField(env, params, "displayPhysicalHeightPixels", 720);
SetIntField(env, params, "memoryClass", is_low_ram ? 256 : 512);
SetIntField(env, params, "largeMemoryClass", is_low_ram ? 512 : 1024);
std::uint64_t host_memory_bytes = mocktail::runtime::DetectHostMemoryBytes();
if (IsEnabled("MOCKTAIL_DISABLE_LOW_RAM_DEVICE")) {
host_memory_bytes =
std::max<std::uint64_t>(host_memory_bytes, 4096ULL * 1024ULL * 1024ULL);
}
const mocktail::runtime::DeviceMemoryProfile memory =
mocktail::runtime::BuildDeviceMemoryProfile(host_memory_bytes);
SetIntField(env, params, "deviceTotalMemoryMB", memory.total_memory_mb);
SetIntField(env, params, "displayPhysicalWidthPixels", display_size.width);
SetIntField(env, params, "displayPhysicalHeightPixels",
display_size.height);
SetIntField(env, params, "memoryClass", memory.memory_class_mb);
SetIntField(env, params, "largeMemoryClass", memory.large_memory_class_mb);
SetLongField(env, params, "lowMemoryKillerBackgroundAppThreshold",
is_low_ram ? 256 : 0);
memory.low_memory_killer_background_threshold);
SetLongField(env, params, "lowMemoryKillerForegroundAppThreshold",
is_low_ram ? 512 : 0);
memory.low_memory_killer_foreground_threshold);
SetBooleanField(env, params, "cpu64Bit", JNI_TRUE);
SetBooleanField(env, params, "isChrome", JNI_FALSE);
SetBooleanField(env, params, "isLowRamDevice",
is_low_ram ? JNI_TRUE : JNI_FALSE);
memory.low_ram_device ? JNI_TRUE : JNI_FALSE);
return params;
}
@@ -2534,8 +2566,9 @@ jobject BuildAppBridgeInitParams(JNIEnv* env, jstring client_settings,
SetStringField(env, params, "locale", "en_us");
SetRobloxServiceUrlFields(env, params);
SetIntField(env, params, "screenWidth", 1280);
SetIntField(env, params, "screenHeight", 720);
const mocktail::runtime::DisplaySize init_display = HostDisplaySize();
SetIntField(env, params, "screenWidth", init_display.width);
SetIntField(env, params, "screenHeight", init_display.height);
SetIntField(env, params, "densityDpi", 160);
SetIntField(env, params, "sdkVersion", 33);
jboolean headless = is_headless ? JNI_TRUE : JNI_FALSE;
@@ -2569,13 +2602,19 @@ jobject BuildPlatformParams(JNIEnv* env, jobject surface, bool is_headless) {
SetStringField(env, params, "deviceName", platform_device_name.c_str());
SetStringField(env, params, "locale", "en_us");
SetStringField(env, params, "assetFolderPath", DefaultAssetPath().c_str());
SetIntField(env, params, "width", 1280);
SetIntField(env, params, "height", 720);
SetIntField(env, params, "screenWidth", 1280);
SetIntField(env, params, "screenHeight", 720);
const mocktail::runtime::DisplaySize platform_window = HostWindowPixelSize();
const mocktail::runtime::DisplaySize platform_display = HostDisplaySize();
SetIntField(env, params, "width", platform_window.width);
SetIntField(env, params, "height", platform_window.height);
SetIntField(env, params, "screenWidth", platform_display.width);
SetIntField(env, params, "screenHeight", platform_display.height);
SetIntField(env, params, "densityDpi", 160);
SetIntField(env, params, "viewportWidthMm", 203);
SetIntField(env, params, "viewportHeightMm", 114);
SetIntField(env, params, "viewportWidthMm",
mocktail::runtime::PixelsToMillimetersAt160Dpi(
platform_window.width));
SetIntField(env, params, "viewportHeightMm",
mocktail::runtime::PixelsToMillimetersAt160Dpi(
platform_window.height));
const mocktail::window::WindowViewportSnapshot viewport =
mocktail::window::GetWindowViewportSnapshot();
SetFloatField(env, params, "dpiScale", viewport.dpi_scale);
@@ -2684,10 +2723,12 @@ jobject BuildStartAppParams(JNIEnv* env, jstring app_params,
SetStringField(env, params, "deviceName", start_device_name.c_str());
SetStringField(env, params, "locale", "en_us");
SetRobloxServiceUrlFields(env, params);
SetIntField(env, params, "width", 1280);
SetIntField(env, params, "height", 720);
SetIntField(env, params, "screenWidth", 1280);
SetIntField(env, params, "screenHeight", 720);
const mocktail::runtime::DisplaySize start_window = HostWindowPixelSize();
const mocktail::runtime::DisplaySize start_display = HostDisplaySize();
SetIntField(env, params, "width", start_window.width);
SetIntField(env, params, "height", start_window.height);
SetIntField(env, params, "screenWidth", start_display.width);
SetIntField(env, params, "screenHeight", start_display.height);
SetIntField(env, params, "densityDpi", 160);
SetIntField(env, params, "sdkVersion", 33);
SetIntField(env, params, "placeId", 0);
@@ -2815,8 +2856,9 @@ jobject BuildMockSurface(JNIEnv* env) {
if (!surface) {
return nullptr;
}
SetIntField(env, surface, "width", 1280);
SetIntField(env, surface, "height", 720);
const mocktail::runtime::DisplaySize surface_size = HostWindowPixelSize();
SetIntField(env, surface, "width", surface_size.width);
SetIntField(env, surface, "height", surface_size.height);
SetBooleanField(env, surface, "valid", JNI_TRUE);
SetBooleanField(env, surface, "isValid", JNI_TRUE);
return surface;
@@ -3913,7 +3955,10 @@ std::cerr << " [engine] nativeAppBridgeV2InitWithParams recovered\n"
on_surface_created(env, game_activity, handle, surface);
}
if (run_lifecycle_callbacks && on_surface_changed) {
on_surface_changed(env, game_activity, handle, surface, 4, 1280, 720);
const mocktail::runtime::DisplaySize changed_size =
HostWindowPixelSize();
on_surface_changed(env, game_activity, handle, surface, 4,
changed_size.width, changed_size.height);
}
if (run_lifecycle_callbacks && on_surface_redraw_needed) {
on_surface_redraw_needed(env, game_activity, handle, surface);
@@ -4084,8 +4129,9 @@ std::cerr << " [engine] nativeAppBridgeV2InitWithParams recovered\n"
ShouldRunStartupStep("MOCKTAIL_UPDATE_APP_UI_SIZES", false)) {
env = ensure_env();
std::cout << " [engine] updateAppUISizes\n" << std::flush;
context->native_update_app_ui_sizes(env, native_gl_class, 1280, 720, 0, 0,
0);
const mocktail::runtime::DisplaySize ui_size = HostWindowPixelSize();
context->native_update_app_ui_sizes(env, native_gl_class, ui_size.width,
ui_size.height, 0, 0, 0);
std::cout << " [engine] updateAppUISizes returned\n" << std::flush;
}
@@ -4726,6 +4772,37 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
<< std::flush;
}
}
if (window_initialised) {
auto* sdl_window =
static_cast<SDL_Window*>(mocktail::window::GetBackendWindow());
const SDL_DisplayID display =
sdl_window != nullptr ? SDL_GetDisplayForWindow(sdl_window) : 0;
const SDL_DisplayMode* mode =
display != 0 ? SDL_GetCurrentDisplayMode(display) : nullptr;
if (mode != nullptr && mode->w > 0 && mode->h > 0) {
// SDL mode dimensions need pixel_density to become physical pixels.
const float density = mode->pixel_density > 0.0f ? mode->pixel_density
: 1.0f;
const std::string display_size =
std::to_string(std::lround(mode->w * density)) + "x" +
std::to_string(std::lround(mode->h * density));
setenv(mocktail::runtime::kDisplaySizeEnvironment, display_size.c_str(),
1);
std::cout << " [window] host display " << display_size << " pixels\n"
<< std::flush;
}
}
if (window_initialised) {
const mocktail::window::WindowViewportSnapshot viewport =
mocktail::window::GetWindowViewportSnapshot();
if (viewport.valid()) {
const std::string window_size = std::to_string(viewport.pixel_width) +
"x" +
std::to_string(viewport.pixel_height);
setenv(mocktail::runtime::kWindowSizeEnvironment, window_size.c_str(),
1);
}
}
const SDL_SystemTheme system_theme =
window_initialised ? SDL_GetSystemTheme() : SDL_SYSTEM_THEME_UNKNOWN;
const bool system_dark_theme = system_theme == SDL_SYSTEM_THEME_DARK;
@@ -5161,7 +5238,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
"pthread_kill",
"pthread_exit",
"pthread_getschedparam",
"pthread_setschedparam",
"pthread_key_create",
"pthread_key_delete",
"pthread_getspecific",
@@ -5479,6 +5555,12 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
linker::RegisterSymbol(
"pthread_create",
reinterpret_cast<void*>(mocktail_bionic_pthread_create));
linker::RegisterSymbol(
"pthread_setschedparam",
reinterpret_cast<void*>(mocktail_bionic_pthread_setschedparam));
linker::RegisterSymbol(
"sched_setscheduler",
reinterpret_cast<void*>(mocktail_bionic_sched_setscheduler));
linker::RegisterSymbol("abort", reinterpret_cast<void*>(mocktail_abort));
linker::RegisterSymbol("__stack_chk_fail", reinterpret_cast<void*>(mocktail_recover_stack_chk_fail));
(void)registered;
+21 -6
View File
@@ -8,16 +8,19 @@
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <filesystem>
#include <iostream>
#include <limits.h>
#include <unordered_map>
#include <unistd.h>
#include <vector>
namespace libc_shim {
namespace {
std::unordered_map<std::string, std::string> g_path_mappings;
std::vector<std::string> g_ca_bundle_aliases;
bool g_installed = false;
std::atomic<GuestAllocator> g_guest_allocator{nullptr};
@@ -27,9 +30,15 @@ constexpr std::array<const char*, 2> kDefaultCaBundles = {
"/etc/ssl/cert.pem",
"/etc/ssl/certs/ca-certificates.crt",
};
constexpr std::array<const char*, 2> kAndroidCaBundlePaths = {
constexpr std::array<const char*, 8> kAndroidCaBundlePaths = {
"/data/user/0/com.roblox.client/files/exe/cacert.pem",
"/data/data/com.roblox.client/files/exe/cacert.pem",
"/data/user/0/com.roblox.client/files/exe/ssl/cacert.pem",
"/data/data/com.roblox.client/files/exe/ssl/cacert.pem",
"ssl/cacert.pem",
"content/ssl/cacert.pem",
"rbx_bin/assets/ssl/cacert.pem",
"rbx_bin/assets/content/ssl/cacert.pem",
};
bool IsEnabled(const char* name) {
@@ -192,17 +201,22 @@ HostCaBundleResolution ResolveHostCaBundle() {
}
HostCaBundleResolution ConfigureHostCaBundlePathMappings() {
for (const char* android_path : kAndroidCaBundlePaths) {
g_path_mappings.erase(android_path);
for (const auto& alias : g_ca_bundle_aliases) g_path_mappings.erase(alias);
g_ca_bundle_aliases.assign(kAndroidCaBundlePaths.begin(), kAndroidCaBundlePaths.end());
if (const char* content = GetEnvNonEmpty("MOCKTAIL_ASSET_PATH")) {
auto root = std::filesystem::path(content).lexically_normal();
if (root.filename().empty()) root = root.parent_path();
g_ca_bundle_aliases.push_back((root / "ssl/cacert.pem").string());
if (root.filename() == "content")
g_ca_bundle_aliases.push_back((root.parent_path() / "ssl/cacert.pem").string());
}
HostCaBundleResolution resolution = ResolveHostCaBundle();
if (!resolution.ok()) {
return resolution;
}
for (const char* android_path : kAndroidCaBundlePaths) {
RegisterPathMapping(android_path, resolution.host_path);
}
for (const auto& alias : g_ca_bundle_aliases)
RegisterPathMapping(alias, resolution.host_path);
return resolution;
}
@@ -308,6 +322,7 @@ void RegisterPathMapping(const std::string& android_prefix,
void ClearPathMappings() {
g_path_mappings.clear();
g_ca_bundle_aliases.clear();
}
} // namespace libc_shim
+74
View File
@@ -29,12 +29,15 @@
#include "runtime/environment.h"
#include "runtime/external_launch_broker.h"
#include "runtime/failure_dialog.h"
#include "runtime/fleasion.h"
#include "runtime/game_mode.h"
#include "runtime/graphics_launch_policy.h"
#include "runtime/memory_limit.h"
#include "runtime/payload_update_preflight.h"
#include "runtime/performance_policy.h"
#include "runtime/platform_cache_migration.h"
#include "runtime/process_diagnostics.h"
#include "runtime/process_launch_policy.h"
#include "runtime/roblox_desktop_app_policy.h"
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_fullscreen_runtime_bridge.h"
@@ -123,6 +126,10 @@ void PromptFirstLaunchSignIn(
environment.Get("MOCKTAIL_SKIP_FIRST_LAUNCH_LOGIN") == "1") {
return;
}
if (environment.Get("MOCKTAIL_NATIVE_LOGIN") != "0") {
std::cout << " [auth] native sign-in selected; opening Roblox welcome screen\n";
return;
}
std::filesystem::path helper;
const char* helper_override = std::getenv("MOCKTAIL_WEBVIEW_HELPER");
@@ -251,6 +258,12 @@ int main(int argc, char* argv[]) {
command_line.options.program_name);
return EXIT_SUCCESS;
}
// Normalize inherited process state before bootstrap or helpers can create
// threads. Keep research/canary resource limits under their caller's control.
const std::string process_launch_diagnostics =
command_line.options.mode == mocktail::runtime::CommandMode::kRun
? mocktail::runtime::ApplyInteractiveProcessLaunchPolicy()
: std::string{};
std::string command_line_error;
if (!mocktail::runtime::ApplySupportedLaunchPolicy(
command_line.options.mode == mocktail::runtime::CommandMode::kRun,
@@ -456,6 +469,42 @@ int main(int argc, char* argv[]) {
std::cerr << " [session] automatic logging unavailable: "
<< session_log.error() << '\n';
}
std::cout << process_launch_diagnostics << std::flush;
mocktail::runtime::InstallCpuLimitDiagnostics();
mocktail::runtime::LogProcessDiagnostics(
mocktail::runtime::ProcessDiagnosticStage::kStartup);
}
if (runtime_config.config.fleasion_enabled()) {
const auto fleasion = mocktail::runtime::PrepareFleasion(
runtime_config.config, environment, paths);
if (!fleasion ||
setenv("MOCKTAIL_CA_BUNDLE", fleasion.bundle.c_str(), 1) != 0 ||
setenv("MOCKTAIL_FLEASION_BASE_CA_BUNDLE", fleasion.base_bundle.c_str(), 1) != 0 ||
setenv("MOCKTAIL_FLEASION_GENERATED_BUNDLE", fleasion.bundle.c_str(), 1) != 0 ||
setenv("MOCKTAIL_FLEASION_CA_CERTIFICATE", fleasion.certificate.c_str(), 1) != 0) {
std::cerr << "[FATAL] Cannot prepare Fleasion: "
<< (!fleasion ? fleasion.error : "cannot export certificate paths") << '\n';
return EXIT_FAILURE;
}
if (const auto& proxy = runtime_config.config.network_proxy(); proxy &&
(setenv("MOCKTAIL_HTTP_PROXY_HOST", proxy->host.c_str(), 1) != 0 ||
setenv("MOCKTAIL_HTTP_PROXY_PORT", std::to_string(proxy->port).c_str(), 1) != 0 ||
setenv("MOCKTAIL_HTTP_PROXY_SCHEME", proxy->scheme.c_str(), 1) != 0)) {
std::cerr << "[FATAL] Cannot export Fleasion proxy\n";
return EXIT_FAILURE;
}
runtime_config = mocktail::runtime::LoadRuntimeConfig(environment, paths.config_file());
if (!runtime_config) {
std::cerr << "[FATAL] Cannot apply Fleasion: " << runtime_config.error << '\n';
return EXIT_FAILURE;
}
std::cout << " [fleasion] mode=" << runtime_config.config.fleasion_proxy_mode()
<< " certificate=" << fleasion.certificate
<< " trust_bundle=" << fleasion.bundle << '\n';
if (runtime_config.config.network_proxy()) {
std::cout << " [fleasion] proxy=" << mocktail::runtime::BuildNetworkProxyUrl(
*runtime_config.config.network_proxy()) << "; start Fleasion before Roblox\n";
}
}
if (config_bootstrap.created()) {
std::cout << " [runtime] created first-run configuration: "
@@ -840,6 +889,21 @@ int main(int argc, char* argv[]) {
<< fullscreen_status.message() << '\n';
return EXIT_FAILURE;
}
if (compatibility.profile.fmod_output_device_bridge &&
compatibility.profile.fmod_output_device_bridge->has_input_devices()) {
std::string menu_settings;
const char *settings =
std::getenv("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON");
if (!mocktail::runtime::MergeAudioDeviceMenuClientSettingsOverrides(
settings ? settings : "{}", &menu_settings,
&command_line_error) ||
setenv("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON",
menu_settings.c_str(), 1) != 0) {
std::cerr << "[FATAL] Cannot configure host audio device menu: "
<< command_line_error << '\n';
return EXIT_FAILURE;
}
}
const mocktail::Status output_device_status =
output_device_bridge.Install(compatibility.profile);
if (!output_device_status.ok()) {
@@ -1024,8 +1088,18 @@ int main(int argc, char* argv[]) {
}
failure_dialog.SetMessage(
"Roblox closed unexpectedly because of an internal error.");
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
mocktail::runtime::LogProcessDiagnostics(
mocktail::runtime::ProcessDiagnosticStage::kNativeRuntime);
}
int runtime_status =
mocktail::legacy::Run(command_line.options, std::move(dependencies));
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
mocktail::runtime::LogProcessDiagnostics(
mocktail::runtime::ProcessDiagnosticStage::kShutdown);
std::cerr << "[diagnostic] native runtime exit_status=" << runtime_status
<< '\n';
}
const mocktail::Status launch_broker_shutdown_status =
external_launch_broker.Shutdown();
if (!launch_broker_shutdown_status.ok()) {
+7 -4
View File
@@ -258,10 +258,13 @@ std::string CommandLineUsage(const std::string& program_name) {
"website link\n"
<< " --help, -h Show this help\n\n"
<< "Auth:\n"
<< " When no saved Roblox cookie is found, Roblox starts in guest mode "
"and\n"
<< " uses its native sign-in flow. New credentials are stored "
"privately.\n\n"
<< " When no saved Roblox session is found, Roblox's welcome screen "
"opens with the native sign-in flow.\n"
<< " Set MOCKTAIL_NATIVE_LOGIN=0 to use the optional WebView sign-in "
"window.\n"
<< " Roblox may still open a WebView for required verification "
"challenges.\n"
<< " New credentials are stored privately.\n\n"
<< "Additional runtime options are available as MOCKTAIL_* environment "
"variables.\n";
return usage.str();
+35
View File
@@ -0,0 +1,35 @@
#include "runtime/device_memory_profile.h"
#include <algorithm>
#include <limits>
namespace mocktail {
namespace runtime {
namespace {
constexpr std::uint64_t kMebibyte = 1024U * 1024U;
constexpr std::uint64_t kMinimumNormalRamBytes = 4096U * kMebibyte;
} // namespace
DeviceMemoryProfile BuildDeviceMemoryProfile(std::uint64_t host_memory_bytes) {
DeviceMemoryProfile profile;
if (host_memory_bytes < kMinimumNormalRamBytes) {
profile.total_memory_mb = 2048;
profile.memory_class_mb = 256;
profile.large_memory_class_mb = 512;
profile.low_memory_killer_background_threshold = 256;
profile.low_memory_killer_foreground_threshold = 512;
profile.low_ram_device = true;
return profile;
}
profile.total_memory_mb = static_cast<std::int32_t>(std::min<std::uint64_t>(
host_memory_bytes / kMebibyte,
static_cast<std::uint64_t>(std::numeric_limits<std::int32_t>::max())));
profile.memory_class_mb = 512;
profile.large_memory_class_mb = 1024;
return profile;
}
} // namespace runtime
} // namespace mocktail
+4
View File
@@ -422,8 +422,12 @@ std::optional<Json> GetRobloxJson(std::string url) {
if (proxy.has_value()) {
proxy_url = BuildNetworkProxyUrl(*proxy);
curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url.c_str());
curl_easy_setopt(curl, CURLOPT_NOPROXY, "");
}
}
if (const char* ca_bundle = std::getenv("MOCKTAIL_CA_BUNDLE")) {
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
}
const CURLcode status = curl_easy_perform(curl);
long http_status = 0;
if (status == CURLE_OK) {
+151
View File
@@ -0,0 +1,151 @@
#include "runtime/fleasion.h"
#include <openssl/err.h>
#include <openssl/pem.h>
#include <openssl/x509v3.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cerrno>
#include <fstream>
#include <memory>
namespace mocktail::runtime {
namespace {
using Bio = std::unique_ptr<BIO, decltype(&BIO_free)>;
using Certificate = std::unique_ptr<X509, decltype(&X509_free)>;
bool ReadFile(const std::filesystem::path& path, std::string* contents) {
std::error_code error;
if (!path.is_absolute() || !std::filesystem::is_regular_file(path, error))
return false;
const auto size = std::filesystem::file_size(path, error);
if (error || size == 0 || size > 4 * 1024 * 1024) return false;
std::ifstream input(path, std::ios::binary);
contents->resize(static_cast<std::size_t>(size));
return static_cast<bool>(input.read(contents->data(), contents->size()));
}
std::filesystem::path DefaultCertificate(const Environment& environment,
const RuntimePaths& paths) {
std::filesystem::path root = environment.GetOr("XDG_CONFIG_HOME", "");
if (!root.is_absolute()) root = paths.home() / ".config";
return root / "Fleasion/proxy_ca/ca.crt";
}
bool WriteBundle(const std::filesystem::path& destination,
const char* contents, std::size_t size) {
std::error_code error;
std::filesystem::create_directories(destination.parent_path(), error);
if (error || std::filesystem::is_symlink(destination.parent_path(), error))
return false;
std::string temporary = destination.string() + ".XXXXXX";
const int descriptor = mkstemp(temporary.data());
if (descriptor < 0) return false;
std::size_t offset = 0;
while (offset < size) {
const ssize_t count = write(descriptor, contents + offset, size - offset);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) break;
offset += static_cast<std::size_t>(count);
}
bool ok = offset == size && fsync(descriptor) == 0;
if (close(descriptor) != 0) ok = false;
if (ok) ok = rename(temporary.c_str(), destination.c_str()) == 0;
if (!ok) unlink(temporary.c_str());
return ok;
}
} // namespace
FleasionPreparation PrepareFleasion(const RuntimeConfig& config,
const Environment& environment,
const RuntimePaths& paths) {
FleasionPreparation result;
if (!config.fleasion_enabled()) return result;
if (!config.fleasion_valid()) {
result.error = "invalid Fleasion configuration";
return result;
}
result.certificate = config.fleasion_ca_certificate().value_or(
DefaultCertificate(environment, paths));
result.bundle = paths.cache_root() / "fleasion/cacert.pem";
result.base_bundle = config.ca_bundle().value_or(std::filesystem::path{});
// Re-exec and updater canaries inherit the generated bundle. Rebuild from
// the original roots so a rotated Fleasion CA does not accumulate forever.
if (result.base_bundle == result.bundle ||
result.base_bundle == environment.GetOr("MOCKTAIL_FLEASION_GENERATED_BUNDLE", "")) {
result.base_bundle = environment.GetOr("MOCKTAIL_FLEASION_BASE_CA_BUNDLE", "");
}
if (result.base_bundle.empty()) {
for (const char* candidate : {"/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt"}) {
std::error_code error;
if (std::filesystem::is_regular_file(candidate, error)) {
result.base_bundle = candidate;
break;
}
}
}
std::error_code equivalent_error;
const bool certificate_is_output = std::filesystem::equivalent(
result.certificate, result.bundle, equivalent_error);
equivalent_error.clear();
const bool base_is_output = std::filesystem::equivalent(
result.base_bundle, result.bundle, equivalent_error);
if (result.certificate == result.bundle || result.base_bundle == result.bundle ||
certificate_is_output || base_is_output) {
result.error = "Fleasion source certificates must be outside its generated bundle";
return result;
}
std::string ca_pem;
if (!ReadFile(result.certificate, &ca_pem)) {
result.error = "cannot read Fleasion CA certificate at " + result.certificate.string() +
"; start Fleasion first or set integrations.fleasion.ca_certificate";
return result;
}
Bio ca_input(BIO_new_mem_buf(ca_pem.data(), static_cast<int>(ca_pem.size())), BIO_free);
Certificate ca(ca_input ? PEM_read_bio_X509(ca_input.get(), nullptr, nullptr, nullptr)
: nullptr, X509_free);
if (!ca || X509_check_ca(ca.get()) <= 0 ||
X509_cmp_current_time(X509_get0_notBefore(ca.get())) >= 0 ||
X509_cmp_current_time(X509_get0_notAfter(ca.get())) <= 0) {
ERR_clear_error();
result.error = "Fleasion CA must be a currently valid PEM CA certificate";
return result;
}
std::string roots;
if (!ReadFile(result.base_bundle, &roots)) {
result.error = "cannot read base CA bundle for Fleasion: " + result.base_bundle.string();
return result;
}
Bio input(BIO_new_mem_buf(roots.data(), static_cast<int>(roots.size())), BIO_free);
Bio output(BIO_new(BIO_s_mem()), BIO_free);
if (!input || !output) {
result.error = "cannot allocate Fleasion certificate bundle";
return result;
}
int count = 0;
for (;;) {
Certificate root(PEM_read_bio_X509(input.get(), nullptr, nullptr, nullptr), X509_free);
if (!root) break;
++count;
if (X509_cmp(root.get(), ca.get()) != 0 &&
PEM_write_bio_X509(output.get(), root.get()) != 1) {
result.error = "cannot encode base CA certificate";
return result;
}
}
ERR_clear_error();
if (count == 0 || PEM_write_bio_X509(output.get(), ca.get()) != 1) {
result.error = "base bundle must contain PEM certificates";
return result;
}
char* bytes = nullptr;
const long size = BIO_get_mem_data(output.get(), &bytes);
if (size <= 0 || !WriteBundle(result.bundle, bytes, static_cast<std::size_t>(size)))
result.error = "cannot write generated Fleasion CA bundle: " + result.bundle.string();
return result;
}
} // namespace mocktail::runtime
+1 -4
View File
@@ -18,11 +18,8 @@ namespace mocktail {
namespace runtime {
namespace {
// TextureManager2 selects its mobile MipPackStream path on Android Vulkan and
// can keep world textures at low-resolution mips. TM1 preserves full texture
// fidelity until that path is compatible with the host Vulkan adapter.
constexpr char kVulkanClientSettingsOverrides[] =
R"({"FStringGraphicsTextureManager2DenyPattern2":".*","FStringGraphicsVulkanShaderMTDenyPattern":"4318:.*"})";
R"({"FStringGraphicsVulkanShaderMTDenyPattern":"4318:.*"})";
constexpr const char* kIcdDirectories[] = {
"/usr/share/vulkan/icd.d",
+20
View File
@@ -367,6 +367,26 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
merged_json, error);
}
bool MergeAudioDeviceMenuClientSettingsOverrides(std::string_view base_json,
std::string *merged_json,
std::string *error) {
if (merged_json == nullptr)
return false;
auto overrides = nlohmann::json::parse(base_json.empty() ? "{}" : base_json,
nullptr, false, true);
if (overrides.is_discarded() || !overrides.is_object()) {
if (error)
*error = "audio device menu overrides must be a JSON object";
return false;
}
// Use the profiled FMOD count/info/select interface for device enumeration.
// RemoteAudioDeviceSync instead uses the unbridged device-list virtuals.
overrides["FFlagDebugUseWebRtcAudioDevices"] = "False";
overrides["FFlagRemoteAudioDeviceSync"] = "False";
*merged_json = overrides.dump();
return true;
}
bool MergeAudioCaptureClientSettingsOverrides(bool microphone_enabled,
std::string_view base_json,
std::string* merged_json,
+181
View File
@@ -0,0 +1,181 @@
#include "runtime/process_diagnostics.h"
#include <fcntl.h>
#include <sched.h>
#include <signal.h>
#include <sys/syscall.h>
#include <time.h>
#include <ucontext.h>
#include <unistd.h>
#include <cerrno>
#include <cstddef>
#include <cstdint>
namespace mocktail::runtime {
namespace {
volatile sig_atomic_t current_stage = 0;
const char* StageName() {
switch (current_stage) {
case 1: return "native-runtime";
case 2: return "shutdown";
default: return "startup";
}
}
// All helpers used by the signal handler use fixed storage and system calls;
// no iostreams, allocation, unwinding, or locks in the crashing thread.
void WriteBytes(const char* bytes, std::size_t size) {
while (size > 0) {
const ssize_t count = write(STDERR_FILENO, bytes, size);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) return;
bytes += count;
size -= static_cast<std::size_t>(count);
}
}
void Text(const char* text) {
std::size_t length = 0;
while (text[length] != '\0') ++length;
WriteBytes(text, length);
}
void Number(std::uint64_t value, unsigned base = 10) {
char buffer[32];
std::size_t start = sizeof(buffer);
do {
buffer[--start] = "0123456789abcdef"[value % base];
value /= base;
} while (value != 0);
WriteBytes(buffer + start, sizeof(buffer) - start);
}
void SignedNumber(long value) {
if (value < 0) {
Text("-");
Number(static_cast<std::uint64_t>(-(value + 1)) + 1);
} else {
Number(static_cast<std::uint64_t>(value));
}
}
void CpuTime(const char* label, clockid_t clock) {
Text(label);
timespec time{};
if (clock_gettime(clock, &time) == 0) {
Number(static_cast<std::uint64_t>(time.tv_sec) * 1000000000ULL +
static_cast<std::uint64_t>(time.tv_nsec));
} else {
Text("unavailable");
}
}
void DumpFile(const char* path, std::size_t maximum_bytes) {
Text("[diagnostic] ");
Text(path);
Text("\n");
const int descriptor = open(path, O_RDONLY | O_CLOEXEC);
if (descriptor < 0) {
Text("unavailable errno=");
SignedNumber(errno);
Text("\n");
return;
}
char buffer[2048];
while (maximum_bytes > 0) {
const std::size_t capacity = maximum_bytes < sizeof(buffer)
? maximum_bytes : sizeof(buffer);
const ssize_t count = read(descriptor, buffer, capacity);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) break;
WriteBytes(buffer, static_cast<std::size_t>(count));
maximum_bytes -= static_cast<std::size_t>(count);
}
if (maximum_bytes == 0) Text("\n[diagnostic] file output capped\n");
close(descriptor);
}
void Snapshot() {
Text("[diagnostic] stage=");
Text(StageName());
Text(" pid=");
Number(getpid());
Text(" ppid=");
Number(getppid());
Text(" tid=");
SignedNumber(syscall(SYS_gettid));
Text(" scheduler=");
SignedNumber(syscall(SYS_sched_getscheduler, 0));
Text(" (OTHER=0 FIFO=1 RR=2 BATCH=3 IDLE=5; RESET_ON_FORK=1073741824)");
CpuTime(" process_cpu_ns=", CLOCK_PROCESS_CPUTIME_ID);
CpuTime(" thread_cpu_ns=", CLOCK_THREAD_CPUTIME_ID);
CpuTime(" monotonic_ns=", CLOCK_MONOTONIC);
Text("\n");
DumpFile("/proc/self/limits", 8192);
DumpFile("/proc/self/cgroup", 4096);
}
void CpuLimitSignal(int signal_number, siginfo_t* info, void* context) {
Text("\n[crash] SIGXCPU signal=");
SignedNumber(signal_number);
Text(" si_code=");
SignedNumber(info != nullptr ? info->si_code : 0);
Text(" (SI_KERNEL=128 SI_USER=0 SI_TKILL=-6) ip=0x");
std::uintptr_t instruction = 0;
if (context != nullptr) {
#if defined(__x86_64__)
instruction = static_cast<ucontext_t*>(context)->uc_mcontext.gregs[REG_RIP];
#elif defined(__aarch64__)
instruction = static_cast<ucontext_t*>(context)->uc_mcontext.pc;
#endif
}
Number(instruction, 16);
Text("\n");
Snapshot();
DumpFile("/proc/thread-self/comm", 256);
DumpFile("/proc/thread-self/sched", 8192);
DumpFile("/proc/self/maps", 65536);
Text("[crash] SIGXCPU diagnostics complete; preserving fatal signal. "
"For SI_KERNEL, compare CPU time with Max cpu time and the failing "
"thread's policy with Max realtime timeout. Collect coredumpctl info "
"for this PID.\n");
// SA_RESETHAND already restored SIG_DFL. The signal is blocked during this
// handler and is delivered to this same thread when the handler returns.
if (syscall(SYS_tgkill, getpid(), syscall(SYS_gettid), signal_number) < 0) {
_exit(128 + signal_number);
}
}
} // namespace
void InstallCpuLimitDiagnostics() {
struct sigaction previous{};
if (sigaction(SIGXCPU, nullptr, &previous) != 0) {
Text("[diagnostic] cannot inspect SIGXCPU handler\n");
return;
}
if (previous.sa_handler != SIG_DFL) {
Text("[diagnostic] existing SIGXCPU handler retained\n");
return;
}
struct sigaction action{};
action.sa_sigaction = CpuLimitSignal;
action.sa_flags = SA_SIGINFO | SA_RESETHAND;
sigemptyset(&action.sa_mask);
if (sigaction(SIGXCPU, &action, nullptr) != 0) {
Text("[diagnostic] cannot install SIGXCPU diagnostics\n");
return;
}
Text("[diagnostic] SIGXCPU diagnostics enabled; output goes to console and "
"the active session log\n");
}
void LogProcessDiagnostics(ProcessDiagnosticStage stage) {
current_stage = static_cast<sig_atomic_t>(stage);
Snapshot();
}
} // namespace mocktail::runtime
+82
View File
@@ -0,0 +1,82 @@
#include "runtime/process_launch_policy.h"
#include <sched.h>
#include <sys/resource.h>
#include <cerrno>
#include <cstring>
#include <sstream>
namespace mocktail::runtime {
namespace {
std::string LimitValue(rlim_t value) {
return value == RLIM_INFINITY ? "unlimited" : std::to_string(value);
}
void LogLimit(std::ostringstream& log, const char* name, const rlimit& limit,
const char* units) {
log << " [process] " << name << " soft=" << LimitValue(limit.rlim_cur)
<< " hard=" << LimitValue(limit.rlim_max) << " units=" << units << '\n';
}
} // namespace
std::string ApplyInteractiveProcessLaunchPolicy() {
std::ostringstream log;
// Browser protocol handlers inherit the browser's scheduling policy. A
// compute-heavy engine thread running FIFO/RR can exhaust RLIMIT_RTTIME and
// receive SIGXCPU even when the ordinary CPU-time limit is unlimited.
const int policy = sched_getscheduler(0);
if (policy < 0) {
log << " [process] cannot read scheduling policy: " << std::strerror(errno)
<< '\n';
} else {
log << " [process] inherited scheduler=" << policy << '\n';
const int base_policy = policy & ~SCHED_RESET_ON_FORK;
if (base_policy == SCHED_FIFO || base_policy == SCHED_RR) {
const sched_param parameters{};
// Clearing RESET_ON_FORK requires CAP_SYS_NICE, even when demoting.
const int target_policy = SCHED_OTHER | (policy & SCHED_RESET_ON_FORK);
if (sched_setscheduler(0, target_policy, &parameters) == 0) {
log << " [process] reset inherited real-time scheduler to SCHED_OTHER\n";
} else {
log << " [process] cannot reset real-time scheduler: "
<< std::strerror(errno) << "; RLIMIT_RTTIME may terminate startup\n";
}
}
}
rlimit cpu{};
if (getrlimit(RLIMIT_CPU, &cpu) == 0) {
LogLimit(log, "RLIMIT_CPU", cpu, "seconds");
if (cpu.rlim_cur != RLIM_INFINITY && cpu.rlim_max == RLIM_INFINITY) {
cpu.rlim_cur = RLIM_INFINITY;
if (setrlimit(RLIMIT_CPU, &cpu) == 0) {
log << " [process] cleared inherited soft CPU-time limit\n";
} else {
log << " [process] cannot clear soft CPU-time limit: "
<< std::strerror(errno) << '\n';
}
} else if (cpu.rlim_max != RLIM_INFINITY) {
log << " [process] finite CPU-time limit retained; if startup ends with "
"SIGXCPU, adjust the launching service's LimitCPU or launch from "
"a terminal with an unlimited CPU-time limit\n";
}
} else {
log << " [process] cannot read RLIMIT_CPU: " << std::strerror(errno) << '\n';
}
// Keep the real-time watchdog intact for any threads that deliberately opt
// into real-time scheduling later (for example, an audio backend).
rlimit realtime{};
if (getrlimit(RLIMIT_RTTIME, &realtime) == 0) {
LogLimit(log, "RLIMIT_RTTIME", realtime, "microseconds");
} else {
log << " [process] cannot read RLIMIT_RTTIME: " << std::strerror(errno)
<< '\n';
}
return log.str();
}
} // namespace mocktail::runtime
+4
View File
@@ -357,6 +357,10 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
return false;
}
overrides[std::string(kDesktopAppPolicyOverride)] = policy_json;
// The SDUI Charts page in 2.736/2.738 can pass a table to TextColor3 and
// leave the whole LuaApp blank. Default to the working legacy page while
// preserving explicit user overrides for testing newer UI patches.
overrides.emplace("FFlagLuaAppChartsAppPage", "False");
*merged_json = overrides.dump();
return true;
}
+9 -8
View File
@@ -623,21 +623,22 @@ Status RobloxExperienceComposition::CloseWebSurface() {
if (web_surface_route_ == WebSurfaceRoute::kNone) {
return Status::Ok();
}
process = web_surface_process_;
process = std::move(web_surface_process_);
web_surface_process_generation_ = 0;
exit_observer = std::move(web_surface_logical_exit_observer_);
web_surface_route_ = WebSurfaceRoute::kNone;
web_surface_logical_generation_ = 0;
}
const bool hidden =
process == nullptr || !process->running() || process->SetVisible(false);
// A closed challenge must stop executing. Reusing its hidden document lets
// late callbacks from the old attempt reach the next login attempt. The APK
// removes its WebView fragment here and creates a new one on the next open.
const bool closed =
process == nullptr || !process->running() || process->RequestClose();
if (exit_observer.valid()) {
exit_observer.on_exit(exit_observer.context.get());
}
if (!hidden) {
if (process != nullptr) {
(void)process->RequestClose();
}
return Unavailable("could not hide reusable Roblox web surface");
if (!closed) {
return Unavailable("could not close Roblox web surface");
}
return Status::Ok();
}
+4 -1
View File
@@ -526,7 +526,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleEvent(
RobloxInputSnapshot RobloxInputRouter::Snapshot() const {
std::lock_guard<std::mutex> lock(mutex_);
return snapshot_;
RobloxInputSnapshot snapshot = snapshot_;
const RobloxTextEditorSnapshot text = text_editor_.Snapshot();
snapshot.text_focus_generation = text.focused ? text.generation : 0;
return snapshot;
}
RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
+2 -4
View File
@@ -14,8 +14,6 @@ namespace mocktail {
namespace runtime {
namespace {
constexpr int kMinimumNativeWidth = 32;
constexpr int kMinimumNativeHeight = 18;
constexpr char kPasswordBullet[] = "\xE2\x80\xA2";
void SecureClear(std::string* value) {
@@ -83,8 +81,8 @@ bool ValidateAndCountUtf8(const char* text, std::size_t size,
bool NativeGeometryIntersectsViewport(
const RobloxTextDisplayUpdate& update,
const RobloxTextOverlayViewport& viewport) {
if (!viewport.valid() || update.area_width < kMinimumNativeWidth ||
update.area_height < kMinimumNativeHeight) {
if (!viewport.valid() || update.area_width <= 0 ||
update.area_height <= 0) {
return false;
}
const int64_t right = static_cast<int64_t>(update.area_x) +
@@ -142,6 +142,9 @@ class ProductionTextInputBackend final
return runtime_->EndTextFocusSession(textbox_handle, generation,
notify_native);
}
bool IsTextFocusSessionActive(uint64_t generation) override {
return runtime_->Snapshot().text_focus_generation == generation;
}
Status ReplaceFocusedTextFromEngine(uint64_t generation,
std::string authoritative_utf8) override {
return runtime_->ReplaceFocusedTextFromEngine(
@@ -559,6 +562,44 @@ struct RobloxTextInputJniBridge::State {
return CompleteTerminalOnMainThread("queue");
}
// RbxKeyboard hides its own editor after Enter/Escape. The native side
// need not echo hideKeyboard, so reconcile the host editor before polling
// properties or replaying delayed text updates from that focus session.
if (applied_active &&
!backend->IsTextFocusSessionActive(applied_generation)) {
const uint64_t ended_generation = applied_generation;
if (!backend->RequestHideTextInput(ended_generation)) {
for (Command& command : pending) command.ClearSensitiveData();
{
std::lock_guard<std::mutex> lock(mutex);
EnterTerminalLocked("SDL text-input completion hide was rejected");
}
return CompleteTerminalOnMainThread("host-focus-end");
}
{
std::lock_guard<std::mutex> lock(mutex);
if (active_generation == ended_generation) {
desired_active = false;
active_handle = 0;
active_info = {};
active_geometry = {};
has_active_geometry = false;
geometry_refresh_pumps = 0;
}
}
for (auto it = pending.begin(); it != pending.end();) {
if (it->generation == ended_generation) {
it->ClearSensitiveData();
it = pending.erase(it);
} else {
++it;
}
}
applied_active = false;
applied_generation = 0;
applied_handle = 0;
}
AppendGeometryRefresh(&pending);
bool success = true;
@@ -27,6 +27,7 @@ class RobloxTextInputJniBridgeBackend {
virtual Status EndTextFocusSession(int64_t textbox_handle,
uint64_t generation,
bool notify_native) = 0;
virtual bool IsTextFocusSessionActive(uint64_t generation) = 0;
virtual Status ReplaceFocusedTextFromEngine(
uint64_t generation, std::string authoritative_utf8) = 0;
virtual Status QueryCurrentTextBoxInfo(
+15 -11
View File
@@ -244,7 +244,7 @@ struct TextBytePosition {
};
TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
int text_width) {
int text_width, bool single_line) {
if (layout == nullptr) {
return {};
}
@@ -259,7 +259,11 @@ TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset,
position.y = substring.rect.y;
position.height = substring.rect.h;
if ((substring.flags & TTF_SUBSTRING_TEXT_END) != 0) {
position.x = std::min(text_width, substring.rect.x + substring.rect.w);
// Trailing whitespace has no ink, so the final cluster rect stops short of
// the laid-out width that a caret at the end of a single line sits behind.
position.x = single_line ? text_width
: std::min(text_width,
substring.rect.x + substring.rect.w);
}
return position;
}
@@ -431,13 +435,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
static_cast<std::size_t>(width * height * 4), 0);
const int frame_width = static_cast<int>(width);
const int frame_height = static_cast<int>(height);
// Use Roblox's horizontal text bounds directly. Inventing padding from
// the field's width or position shifts text away from its native origin
// and makes long lines scroll before they reach the field's actual edge.
// Use Roblox's text bounds directly. Extra padding shifts the native text
// origin, scrolls long lines too early and clips compact chat fields.
const int clip_left = 0;
const int clip_right = frame_width;
const int clip_top = 3;
const int clip_bottom = std::max(clip_top, frame_height - 3);
const int clip_top = 0;
const int clip_bottom = frame_height;
const int available_width = std::max(1, clip_right - clip_left);
const bool wrapped_layout =
presentation.multiline || presentation.text_wrapped;
@@ -502,11 +505,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
TextBytePosition selection_end_position;
if (layout != nullptr) {
(void)TTF_GetTextSize(layout, &text_width, &text_height);
caret_position = TextPosition(layout, caret_byte, text_width);
selection_begin_position =
TextPosition(layout, selection_begin_byte, text_width);
caret_position =
TextPosition(layout, caret_byte, text_width, !wrapped_layout);
selection_begin_position = TextPosition(layout, selection_begin_byte,
text_width, !wrapped_layout);
selection_end_position =
TextPosition(layout, selection_end_byte, text_width);
TextPosition(layout, selection_end_byte, text_width, !wrapped_layout);
}
if (!text.value.empty()) {
// SDL_ttf implementations have historically differed on whether fg.a is
+19 -1
View File
@@ -8,6 +8,8 @@
#include <string>
#include <utility>
#include "runtime/environment.h"
namespace mocktail {
namespace runtime {
namespace {
@@ -1207,7 +1209,23 @@ Status RobloxWebViewBridge::DispatchOpenRequest(
if (!status.ok()) {
return status;
}
const bool browser_login_fallback = IsLoginChallengeUrl(request.url);
// Native sign-in must keep the original challenge and its return token so
// Roblox can finish the in-app login instead of starting a browser login.
const bool login_challenge = IsLoginChallengeUrl(request.url);
const bool browser_login_fallback =
ProcessEnvironment{}.Get("MOCKTAIL_NATIVE_LOGIN") == "0" &&
login_challenge;
if (login_challenge) {
// Roblox can preload challenges with isVisible=false and ask to reveal
// them only after the page signals readiness. A failed resource load then
// leaves the native login waiting behind an inaccessible host window.
// Present verification immediately so it can always be seen and closed.
request.is_visible = true;
if (request.title.empty()) {
request.title = "Roblox verification";
}
std::fprintf(stderr, " [webview] presenting login verification window\n");
}
if (browser_login_fallback) {
request.url = kBrowserLoginUrl;
request.title = "Roblox sign in";
+29
View File
@@ -287,6 +287,35 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
config.use_system_proxy_ =
LegacyEnabled(environment, "MOCKTAIL_USE_SYSTEM_PROXY");
config.network_proxy_ = ReadNetworkProxy(environment);
config.fleasion_enabled_ = ReadBoolean(
environment, "MOCKTAIL_FLEASION_ENABLED", false, &config.fleasion_valid_);
config.fleasion_proxy_mode_ = environment.GetOr("MOCKTAIL_FLEASION_PROXY_MODE", "env");
const auto fleasion_proxy = ParseNetworkProxyConfig(
"127.0.0.1", environment.GetOr("MOCKTAIL_FLEASION_PROXY_PORT", "58443"));
if (!fleasion_proxy || (config.fleasion_proxy_mode_ != "env" &&
config.fleasion_proxy_mode_ != "hosts")) {
config.fleasion_valid_ = false;
} else {
config.fleasion_proxy_port_ = fleasion_proxy->port;
}
if (const auto certificate = environment.Get("MOCKTAIL_FLEASION_CA_CERTIFICATE")) {
config.fleasion_ca_certificate_ = *certificate;
if (certificate->empty() || !config.fleasion_ca_certificate_->is_absolute())
config.fleasion_valid_ = false;
}
if (config.fleasion_enabled_) {
if (config.use_system_proxy_) config.fleasion_valid_ = false;
if (config.fleasion_proxy_mode_ == "env" && fleasion_proxy) {
if (config.network_proxy_ &&
(config.network_proxy_->host != fleasion_proxy->host ||
config.network_proxy_->port != fleasion_proxy->port ||
config.network_proxy_->scheme != "http"))
config.fleasion_valid_ = false;
config.network_proxy_ = fleasion_proxy;
} else if (config.network_proxy_) {
config.fleasion_valid_ = false;
}
}
if (const std::optional<std::string> ca_bundle =
environment.Get("MOCKTAIL_CA_BUNDLE");
ca_bundle.has_value()) {
+10
View File
@@ -93,6 +93,16 @@ audio:
input_device: default
integrations:
fleasion:
# Boolean (default: false): trust Fleasion's CA without editing Roblox files.
enabled: false
# String: match Fleasion's routing mode: env or hosts.
proxy_mode: env
# Integer: Fleasion's local HTTP proxy port in env mode.
proxy_port: 58443
# Optional absolute public CA path. Default: $XDG_CONFIG_HOME/Fleasion/proxy_ca/ca.crt
# or ~/.config/Fleasion/proxy_ca/ca.crt. Never select ca.key.
# ca_certificate: /home/user/.config/Fleasion/proxy_ca/ca.crt
discord_rpc:
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
# This never signs in to Discord and never reads an account token.
+36
View File
@@ -188,6 +188,10 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
"network.proxy_host",
"network.proxy_port",
"network.ca_bundle",
"integrations.fleasion.enabled",
"integrations.fleasion.proxy_mode",
"integrations.fleasion.proxy_port",
"integrations.fleasion.ca_certificate",
"integrations.discord_rpc.enabled",
"integrations.discord_rpc.show_place_name",
"integrations.discord_rpc.show_elapsed_time",
@@ -483,6 +487,7 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
std::pair<std::string_view, std::string_view>(
"integrations.discord_rpc.enabled",
"MOCKTAIL_DISCORD_RPC_ENABLED"),
{"integrations.fleasion.enabled", "MOCKTAIL_FLEASION_ENABLED"},
{"integrations.discord_rpc.show_place_name",
"MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME"},
{"integrations.discord_rpc.show_elapsed_time",
@@ -503,6 +508,15 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
}
(*environment)[std::string(variable)] = parsed ? "1" : "0";
}
for (const auto& [key, variable] : {
std::pair<std::string_view, std::string_view>(
"integrations.fleasion.proxy_mode", "MOCKTAIL_FLEASION_PROXY_MODE"),
{"integrations.fleasion.proxy_port", "MOCKTAIL_FLEASION_PROXY_PORT"},
{"integrations.fleasion.ca_certificate", "MOCKTAIL_FLEASION_CA_CERTIFICATE"},
}) {
if (const auto configured = value(key))
(*environment)[std::string(variable)] = *configured;
}
if (const auto application_id =
value("integrations.discord_rpc.application_id");
application_id.has_value()) {
@@ -751,6 +765,10 @@ RuntimeConfigLoadResult LoadRuntimeConfig(
result.error = "GameMode policy is invalid";
} else if (!result.config.performance().physics_worker_mode_valid) {
result.error = "physics worker policy is invalid";
} else if (!result.config.fleasion_valid()) {
result.error = "Fleasion configuration is invalid: use proxy_mode env or hosts, "
"a port from 1 to 65535, an absolute CA certificate path, and "
"no conflicting system/fixed proxy";
} else if (!result.config.ca_bundle_valid()) {
result.error = "CA bundle path is invalid";
} else if (!result.config.discord_rpc_valid()) {
@@ -761,6 +779,10 @@ RuntimeConfigLoadResult LoadRuntimeConfig(
bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
std::string* error) {
if (!config.fleasion_valid()) {
if (error != nullptr) *error = "cannot export invalid Fleasion configuration";
return false;
}
if (!config.device_profile_valid()) {
if (error != nullptr) {
*error = "cannot export an invalid device profile";
@@ -910,6 +932,20 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
if (!base_exported) {
return false;
}
if (!SetEnvironmentValue("MOCKTAIL_FLEASION_ENABLED",
config.fleasion_enabled() ? "1" : "0", error) ||
!SetEnvironmentValue("MOCKTAIL_FLEASION_PROXY_MODE",
config.fleasion_proxy_mode(), error) ||
!SetEnvironmentValue("MOCKTAIL_FLEASION_PROXY_PORT",
std::to_string(config.fleasion_proxy_port()), error))
return false;
if (config.fleasion_ca_certificate()) {
if (!SetEnvironmentValue("MOCKTAIL_FLEASION_CA_CERTIFICATE",
config.fleasion_ca_certificate()->string(), error))
return false;
} else if (!UnsetEnvironmentValue("MOCKTAIL_FLEASION_CA_CERTIFICATE", error)) {
return false;
}
if (config.ca_bundle().has_value()) {
if (!SetEnvironmentValue("MOCKTAIL_CA_BUNDLE",
config.ca_bundle()->string(), error)) {
+4 -1
View File
@@ -1,3 +1,4 @@
// Includes changes by vii from CoderDayton/nightcap.
#include "runtime/runtime_paths.h"
#define JSON_NOEXCEPTION 1
@@ -603,7 +604,9 @@ bool ExportRuntimePathEnvironment(const RuntimePaths& paths,
SetEnvironmentDefault("MOCKTAIL_ANDROID_CACHE_HOST_ROOT",
paths.android_cache_root(), error) &&
SetEnvironmentDefault("MOCKTAIL_VULKAN_SHADER_CACHE_HOST_PATH",
paths.vulkan_shader_cache_file(), error);
paths.vulkan_shader_cache_file(), error) &&
SetEnvironmentDefault("MOCKTAIL_TEXTURE_OVERRIDE_DIR",
paths.config_root() / "textures", error);
}
bool PrepareManagedPayloadWorkingDirectory(const RuntimePaths& paths,
+6
View File
@@ -118,6 +118,9 @@ HttpResponse PerformRequest(const HttpRequest& request,
curl_easy_setopt(handle, CURLOPT_WRITEDATA, &writer);
curl_easy_setopt(handle, CURLOPT_HEADERFUNCTION, WriteHeader);
curl_easy_setopt(handle, CURLOPT_HEADERDATA, &response.headers);
if (const char* ca_bundle = std::getenv("MOCKTAIL_CA_BUNDLE")) {
curl_easy_setopt(handle, CURLOPT_CAINFO, ca_bundle);
}
std::string proxy_url;
const char* proxy_host = std::getenv("MOCKTAIL_HTTP_PROXY_HOST");
const char* proxy_port = std::getenv("MOCKTAIL_HTTP_PROXY_PORT");
@@ -130,6 +133,9 @@ HttpResponse PerformRequest(const HttpRequest& request,
if (proxy.has_value()) {
proxy_url = runtime::BuildNetworkProxyUrl(*proxy);
curl_easy_setopt(handle, CURLOPT_PROXY, proxy_url.c_str());
// A configured application proxy must not be bypassed by an inherited
// NO_PROXY entry, including '*' from a desktop launcher.
curl_easy_setopt(handle, CURLOPT_NOPROXY, "");
}
}
if (headers != nullptr) {
+136 -28
View File
@@ -27,6 +27,7 @@
#include <vector>
#include "compat/elf_build_id.h"
#include "compat/fmod_output_device_contract.h"
#include "update/payload_integrity.h"
namespace mocktail::update {
@@ -1124,6 +1125,15 @@ struct FmodRuntimeAnchors {
std::uint64_t info_method = 0;
std::uint64_t current_method = 0;
std::uint64_t select_method = 0;
int layout_version = 1;
std::array<std::uint64_t, 4> input_methods{};
std::array<std::size_t, 4> input_indexes() const {
return layout_version == 2 ? std::array<std::size_t, 4>{9, 10, 12, 18}
: std::array<std::size_t, 4>{8, 9, 10, 16};
}
std::size_t current_index() const { return layout_version == 2 ? 8 : 7; }
std::size_t select_index() const { return layout_version == 2 ? 19 : 17; }
bool operator==(const FmodRuntimeAnchors& other) const {
return vtable == other.vtable &&
@@ -1131,7 +1141,9 @@ struct FmodRuntimeAnchors {
count_method == other.count_method &&
info_method == other.info_method &&
current_method == other.current_method &&
select_method == other.select_method;
select_method == other.select_method &&
layout_version == other.layout_version &&
input_methods == other.input_methods;
}
};
@@ -1177,7 +1189,14 @@ std::optional<RuntimeCompatibilityAnchors> LoadRuntimeCompatibilityAnchors(
"vtable_rva", "string_constructor_rva", "count_method_rva",
"info_method_rva", "current_method_rva", "select_method_rva",
};
if (!bridge.is_object() || bridge.size() != kFields.size() ||
constexpr std::array<std::string_view, 4> input_fields = {
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva"};
const bool has_input = bridge.contains(input_fields[0]);
const bool has_layout = bridge.contains("vtable_layout_version");
if (!bridge.is_object() ||
bridge.size() !=
kFields.size() + (has_layout ? 1U : 0U) + (has_input ? 4U : 0U) ||
!profile.value("allow_host_abi_bridges", false)) {
*error = "reference FMOD output-device profile is incomplete";
return std::nullopt;
@@ -1193,9 +1212,39 @@ std::optional<RuntimeCompatibilityAnchors> LoadRuntimeCompatibilityAnchors(
if (!value.has_value()) return std::nullopt;
values[index] = *value;
}
const FmodRuntimeAnchors discovered = {
values[0], values[1], values[2], values[3], values[4], values[5]};
if (result.fmod.has_value() && !(*result.fmod == discovered)) {
int layout = 1;
if (has_layout) {
const Json& value = bridge["vtable_layout_version"];
if (!value.is_number_integer() || (value != 1 && value != 2)) {
*error = "reference FMOD vtable layout is unsupported";
return std::nullopt;
}
layout = value.get<int>();
}
FmodRuntimeAnchors discovered = {values[0], values[1], values[2],
values[3], values[4], values[5],
layout};
if (has_input) {
for (std::size_t i = 0; i < input_fields.size(); ++i) {
if (!bridge.contains(input_fields[i])) {
*error = "reference FMOD input profile is incomplete";
return std::nullopt;
}
auto value =
ParseRva(bridge[input_fields[i]], input_fields[i], error);
if (!value)
return std::nullopt;
discovered.input_methods[i] = *value;
}
}
auto previous = result.fmod;
if (previous) {
if (previous->input_methods[0] == 0)
previous->input_methods = discovered.input_methods;
if (discovered.input_methods[0] == 0)
discovered.input_methods = previous->input_methods;
}
if (previous.has_value() && !(*previous == discovered)) {
*error =
"reference compatibility manifests disagree on FMOD anchors";
return std::nullopt;
@@ -1247,6 +1296,18 @@ bool HasFmodStringConstructorContract(const ElfImage& image,
local_error.empty();
}
bool HasFmodDeviceListsSelectContract(const ElfImage& image,
std::uint64_t rva) {
constexpr std::size_t kSize = compat::kFmodDeviceListsSelectContractSize;
std::string local_error;
if (!image.RequireCode(rva, kSize, &local_error)) return false;
const auto offset = image.OffsetForRva(rva, kSize, &local_error);
return offset.has_value() &&
compat::HasFmodDeviceListsSelectContract(
image.Bytes(*offset, kSize, &local_error)) &&
local_error.empty();
}
std::optional<Json> DeriveRuntimeCompatibility(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler,
@@ -1273,11 +1334,8 @@ std::optional<Json> DeriveRuntimeCompatibility(
constexpr std::size_t kCountIndex = 5;
constexpr std::size_t kInfoIndex = 6;
constexpr std::size_t kCurrentIndex = 7;
constexpr std::size_t kSelectIndex = 17;
constexpr std::size_t kVtableBytes = (kSelectIndex + 1) * 8;
const FmodRuntimeAnchors& old = *source.fmod;
if (!reference.RequireRelro(old.vtable, kVtableBytes, error)) {
if (!reference.RequireRelro(old.vtable, (old.select_index() + 1) * 8, error)) {
return std::nullopt;
}
const auto reference_relocations = reference.RelativeRelocations(error);
@@ -1285,8 +1343,8 @@ std::optional<Json> DeriveRuntimeCompatibility(
for (const auto [index, method] :
{std::pair{kCountIndex, old.count_method},
std::pair{kInfoIndex, old.info_method},
std::pair{kCurrentIndex, old.current_method},
std::pair{kSelectIndex, old.select_method}}) {
std::pair{old.current_index(), old.current_method},
std::pair{old.select_index(), old.select_method}}) {
const auto found = reference_relocations->find(old.vtable + index * 8);
if (found == reference_relocations->end() || found->second != method ||
!reference.RequireCode(method, 1, error)) {
@@ -1307,11 +1365,13 @@ std::optional<Json> DeriveRuntimeCompatibility(
const auto info_method = FindUniqueSemanticSignatureMatch(
reference, candidate, disassembler,
{"fmod-output-info", old.info_method, 18, false, 3}, error);
// A missing legacy selector is expected at the device-list ABI transition.
// The new layout is accepted only with its own complete selector contract.
std::string select_error;
const auto select_method = FindUniqueSemanticSignatureMatch(
reference, candidate, disassembler,
{"fmod-output-select", old.select_method, 18, false, 3}, error);
if (!string_constructor.has_value() || !info_method.has_value() ||
!select_method.has_value()) {
{"fmod-output-select", old.select_method, 18, false, 3}, &select_error);
if (!string_constructor.has_value() || !info_method.has_value()) {
return std::nullopt;
}
const auto count_matches = FindSignatureMatches(
@@ -1341,21 +1401,31 @@ std::optional<Json> DeriveRuntimeCompatibility(
if (addend != info_method->rva || offset < kInfoIndex * 8) continue;
const std::uint64_t vtable = offset - kInfoIndex * 8;
const auto count = relocations->find(vtable + kCountIndex * 8);
const auto current = relocations->find(vtable + kCurrentIndex * 8);
const auto select = relocations->find(vtable + kSelectIndex * 8);
std::string relro_error;
if (vtable % 8 != 0 || count == relocations->end() ||
current == relocations->end() || select == relocations->end() ||
count_candidates.find(count->second) == count_candidates.end() ||
current_candidates.find(current->second) == current_candidates.end() ||
count->second == current->second ||
select->second != select_method->rva ||
!candidate.RequireRelro(vtable, kVtableBytes, &relro_error)) {
continue;
for (const int layout : {1, 2}) {
FmodRuntimeAnchors derived;
derived.layout_version = layout;
const auto current = relocations->find(vtable + derived.current_index() * 8);
const auto select = relocations->find(vtable + derived.select_index() * 8);
std::string relro_error;
if (vtable % 8 != 0 || count == relocations->end() ||
current == relocations->end() || select == relocations->end() ||
count_candidates.find(count->second) == count_candidates.end() ||
current_candidates.find(current->second) == current_candidates.end() ||
count->second == current->second ||
!candidate.RequireRelro(vtable, (derived.select_index() + 1) * 8,
&relro_error)) {
continue;
}
if (layout == 1) {
if (old.layout_version != 1 || !select_method.has_value() ||
select->second != select_method->rva) continue;
} else if (!HasFmodDeviceListsSelectContract(candidate, select->second)) {
continue;
}
candidates.push_back({vtable, string_constructor->rva, count->second,
info_method->rva, current->second, select->second,
layout});
}
candidates.push_back({vtable, string_constructor->rva, count->second,
info_method->rva, current->second,
select_method->rva});
}
if (candidates.size() != 1U) {
*error = "FMOD output vtable matched " +
@@ -1376,6 +1446,44 @@ std::optional<Json> DeriveRuntimeCompatibility(
{"current_method_rva", FormatRva(derived.current_method)},
{"select_method_rva", FormatRva(derived.select_method)},
};
if (old.input_methods[0] != 0) {
constexpr std::array<std::string_view, 4> fields = {
"input_count_method_rva", "input_info_method_rva",
"input_current_method_rva", "input_select_method_rva"};
constexpr std::array<std::size_t, 4> lengths = {4, 16, 12, 16};
const auto old_slots = old.input_indexes();
const auto new_slots = derived.input_indexes();
for (std::size_t i = 0; i < fields.size(); ++i) {
const auto old_method =
reference_relocations->find(old.vtable + old_slots[i] * 8);
const auto new_method =
relocations->find(derived.vtable + new_slots[i] * 8);
if (old_method == reference_relocations->end() ||
old_method->second != old.input_methods[i] ||
new_method == relocations->end()) {
*error = "FMOD input vtable does not match its methods";
return std::nullopt;
}
const auto matches = FindSignatureMatches(
reference, candidate, disassembler,
{std::string(fields[i]), old.input_methods[i], lengths[i], false, 3},
true, error);
if (!matches || !std::any_of(matches->begin(), matches->end(),
[&](const auto &match) {
return match.rva == new_method->second;
})) {
if (error->empty())
*error = "candidate FMOD input contract changed";
return std::nullopt;
}
result["fmod_output_device_bridge"][fields[i]] =
FormatRva(new_method->second);
}
}
if (derived.layout_version != 1) {
result["fmod_output_device_bridge"]["vtable_layout_version"] =
derived.layout_version;
}
return result;
}
+220 -23
View File
@@ -8,6 +8,10 @@
#include <unistd.h>
#include <webkit/webkit.h>
#ifdef GDK_WINDOWING_WAYLAND
#include <gdk/wayland/gdkwayland.h>
#endif
#include <algorithm>
#include <array>
#include <cerrno>
@@ -40,6 +44,8 @@ using mocktail::webview::CaptchaEventType;
using mocktail::webview::EvaluateNavigationUri;
using mocktail::webview::ExtractExecuteRobloxCommand;
using mocktail::webview::ExtractRobloxWkHybridCommand;
using mocktail::webview::IsBrowserLoginUrl;
using mocktail::webview::IsEssentialWebResource;
using mocktail::webview::ParseCaptchaEvent;
using mocktail::webview::UriPolicyResult;
@@ -75,6 +81,7 @@ struct AppState {
bool cookie_install_in_flight = false;
bool browser_login_mode = false;
bool initial_load_started = false;
bool disable_hardware_acceleration = false;
};
struct CookieInstallContext {
@@ -89,11 +96,16 @@ struct SurfaceState {
g_weak_ref_init(&window, G_OBJECT(surface_window));
}
~SurfaceState() { g_weak_ref_clear(&window); }
~SurfaceState() {
if (retry_source != 0) g_source_remove(retry_source);
g_weak_ref_clear(&window);
}
AppState* app = nullptr;
GWeakRef window;
bool primary = false;
guint retry_source = 0;
unsigned int resource_retries = 0;
};
struct CallbackConfirmation {
@@ -342,26 +354,6 @@ bool IsRobloxCookieDomain(const char* domain) {
value == "www.roblox.com";
}
bool IsBrowserLoginUrl(std::string_view url) {
std::string_view normalized = url;
if (normalized.compare(0, 23, "https://www.roblox.com/") == 0) {
normalized.remove_prefix(23);
} else if (normalized.compare(0, 19, "https://roblox.com/") == 0) {
normalized.remove_prefix(19);
} else if (normalized.compare(0, 4, "www:") == 0) {
normalized.remove_prefix(4);
} else if (!normalized.empty() && normalized.front() == '/') {
normalized.remove_prefix(1);
}
constexpr std::string_view kLogin = "login";
return (normalized.compare(0, kLogin.size(), kLogin) == 0 ||
normalized.compare(0, kLogin.size(), "Login") == 0) &&
(normalized.size() == kLogin.size() ||
normalized[kLogin.size()] == '/' ||
normalized[kLogin.size()] == '?' ||
normalized[kLogin.size()] == '#');
}
void FinishRobloxCookieQuery(GObject* source, GAsyncResult* result,
gpointer user_data) {
auto* state = static_cast<AppState*>(user_data);
@@ -584,7 +576,7 @@ void BeginTermination(AppState* state, const char* reason) {
g_object_ref(item->data);
}
for (GList* item = windows; item != nullptr; item = item->next) {
gtk_window_close(GTK_WINDOW(item->data));
gtk_window_destroy(GTK_WINDOW(item->data));
}
g_list_free_full(windows, g_object_unref);
g_application_quit(G_APPLICATION(state->application));
@@ -831,12 +823,113 @@ void UpdateDomainTitle(WebKitWebView* web_view, SurfaceState* surface) {
}
}
void SetLoadErrorVisible(WebKitWebView* web_view, bool visible) {
auto* banner = GTK_REVEALER(
g_object_get_data(G_OBJECT(web_view), "mocktail-load-error"));
if (banner != nullptr) {
if (visible && !gtk_revealer_get_reveal_child(banner)) {
std::cerr << "[webview] showing page load error with retry action\n";
}
gtk_revealer_set_reveal_child(banner, visible);
}
}
bool ScheduleResourceRetry(WebKitWebView* web_view, const char* uri,
const GError* error) {
// Retry an interrupted verification resource load once, before Roblox's
// challenge timeout. Certificate failures and verification responses are
// left to the page and the user; every retry still validates TLS normally.
if (uri == nullptr ||
!(g_error_matches(error, G_TLS_ERROR, G_TLS_ERROR_NOT_TLS) ||
g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CONNECTION_CLOSED) ||
g_error_matches(error, G_IO_ERROR, G_IO_ERROR_TIMED_OUT))) {
return false;
}
const std::string_view url(uri);
const std::string_view path = url.substr(0, url.find_first_of("?#"));
constexpr std::string_view kMetadataPath = "/captcha/v1/metadata";
const bool captcha_metadata =
path.size() >= kMetadataPath.size() &&
path.substr(path.size() - kMetadataPath.size()) == kMetadataPath;
if (!(path.size() >= 3 && path.substr(path.size() - 3) == ".js") &&
!(path.size() >= 4 && path.substr(path.size() - 4) == ".css") &&
!captcha_metadata) {
return false;
}
auto* surface = static_cast<SurfaceState*>(
g_object_get_data(G_OBJECT(web_view), "mocktail-surface-state"));
if (surface == nullptr || surface->app->terminating) return false;
if (surface->retry_source != 0) return true;
if (surface->resource_retries != 0) return false;
++surface->resource_retries;
std::cerr << "[webview] interrupted page resource; retrying page once\n";
surface->retry_source = g_timeout_add(
500,
[](gpointer data) -> gboolean {
auto* view = WEBKIT_WEB_VIEW(data);
auto* surface = static_cast<SurfaceState*>(
g_object_get_data(G_OBJECT(view), "mocktail-surface-state"));
surface->retry_source = 0;
if (!surface->app->terminating) {
webkit_web_view_reload_bypass_cache(view);
}
return G_SOURCE_REMOVE;
},
web_view);
return surface->retry_source != 0;
}
void OnResourceFailed(WebKitWebResource* resource, GError* error,
gpointer user_data) {
const auto policy = EvaluateNavigationUri(webkit_web_resource_get_uri(resource));
std::cerr << "[webview] resource failed host=" << policy.host
<< " domain=" << BoundedLogToken(
error ? g_quark_to_string(error->domain) : nullptr, "unknown")
<< " code=" << (error ? error->code : 0) << '\n';
if (!g_error_matches(error, WEBKIT_NETWORK_ERROR,
WEBKIT_NETWORK_ERROR_CANCELLED) &&
IsEssentialWebResource(webkit_web_resource_get_uri(resource))) {
auto* web_view = WEBKIT_WEB_VIEW(user_data);
if (!ScheduleResourceRetry(web_view, webkit_web_resource_get_uri(resource),
error)) {
SetLoadErrorVisible(web_view, true);
}
}
}
void OnResourceFinished(WebKitWebResource* resource, gpointer user_data) {
auto* response = webkit_web_resource_get_response(resource);
if (!response || webkit_uri_response_get_status_code(response) < 400) return;
const auto policy = EvaluateNavigationUri(webkit_web_resource_get_uri(resource));
std::cerr << "[webview] resource HTTP error host=" << policy.host
<< " status=" << webkit_uri_response_get_status_code(response) << '\n';
if (IsEssentialWebResource(webkit_web_resource_get_uri(resource))) {
SetLoadErrorVisible(WEBKIT_WEB_VIEW(user_data), true);
}
}
void OnResourceStarted(WebKitWebView* web_view, WebKitWebResource* resource,
WebKitURIRequest*, gpointer) {
g_signal_connect_object(resource, "failed", G_CALLBACK(OnResourceFailed),
G_OBJECT(web_view), G_CONNECT_DEFAULT);
g_signal_connect_object(resource, "finished", G_CALLBACK(OnResourceFinished),
G_OBJECT(web_view), G_CONNECT_DEFAULT);
}
void OnLoadChanged(WebKitWebView* web_view, WebKitLoadEvent event,
gpointer user_data) {
const UriPolicyResult policy =
EvaluateNavigationUri(webkit_web_view_get_uri(web_view));
std::cerr << "[webview] load stage=" << LoadEventName(event)
<< " scheme=" << policy.scheme << " host=" << policy.host << '\n';
if (event == WEBKIT_LOAD_STARTED) {
auto* surface = static_cast<SurfaceState*>(user_data);
if (surface->retry_source != 0) {
g_source_remove(surface->retry_source);
surface->retry_source = 0;
}
SetLoadErrorVisible(web_view, false);
}
if (event == WEBKIT_LOAD_COMMITTED || event == WEBKIT_LOAD_FINISHED) {
UpdateDomainTitle(web_view, static_cast<SurfaceState*>(user_data));
auto* surface = static_cast<SurfaceState*>(user_data);
@@ -858,6 +951,10 @@ gboolean OnLoadFailed(WebKitWebView* web_view, WebKitLoadEvent event,
: g_quark_to_string(error->domain),
"unknown")
<< " code=" << (error == nullptr ? 0 : error->code) << '\n';
if (!g_error_matches(error, WEBKIT_NETWORK_ERROR,
WEBKIT_NETWORK_ERROR_CANCELLED)) {
SetLoadErrorVisible(web_view, true);
}
return FALSE;
}
@@ -906,6 +1003,10 @@ gboolean OnDecidePolicy(WebKitWebView* web_view, WebKitPolicyDecision* decision,
void ConfigureWebView(WebKitWebView* web_view, const AppState* app) {
WebKitSettings* settings = webkit_web_view_get_settings(web_view);
if (app->disable_hardware_acceleration) {
webkit_settings_set_hardware_acceleration_policy(
settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER);
}
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_enable_page_cache(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
@@ -1130,6 +1231,14 @@ void OnReadyToShow(WebKitWebView* web_view, gpointer user_data) {
}
}
void OnWindowMapped(GtkWidget*, gpointer) {
std::cerr << "[webview] window mapped\n";
}
void OnWindowUnmapped(GtkWidget*, gpointer) {
std::cerr << "[webview] window unmapped\n";
}
void OnWebViewClose(WebKitWebView* web_view, gpointer user_data) {
auto* surface = static_cast<SurfaceState*>(user_data);
if (surface->primary) {
@@ -1151,6 +1260,37 @@ gboolean OnWindowCloseRequest(GtkWindow* window, gpointer user_data) {
return FALSE;
}
void ReloadWebPage(WebKitWebView* web_view) {
if (webkit_web_view_get_uri(web_view) != nullptr) {
auto* surface = static_cast<SurfaceState*>(
g_object_get_data(G_OBJECT(web_view), "mocktail-surface-state"));
if (surface != nullptr) {
if (surface->retry_source != 0) {
g_source_remove(surface->retry_source);
surface->retry_source = 0;
}
surface->resource_retries = 0;
}
std::cerr << "[webview] page reload requested\n";
webkit_web_view_reload_bypass_cache(web_view);
}
}
void OnReloadClicked(GtkButton*, gpointer user_data) {
ReloadWebPage(WEBKIT_WEB_VIEW(user_data));
}
gboolean OnReloadKeyPressed(GtkEventControllerKey*, guint keyval, guint,
GdkModifierType state, gpointer user_data) {
if (keyval == GDK_KEY_F5 ||
((state & GDK_CONTROL_MASK) != 0 &&
(keyval == GDK_KEY_r || keyval == GDK_KEY_R))) {
ReloadWebPage(WEBKIT_WEB_VIEW(user_data));
return TRUE;
}
return FALSE;
}
WebKitWebView* CreateSurface(AppState* app, WebKitWebView* related_view) {
WebKitUserContentManager* content_manager = webkit_user_content_manager_new();
WebKitWebView* web_view = nullptr;
@@ -1175,6 +1315,21 @@ WebKitWebView* CreateSurface(AppState* app, WebKitWebView* related_view) {
GtkWidget* window = gtk_application_window_new(app->application);
gtk_window_set_title(GTK_WINDOW(window), "Roblox");
GtkWidget* header = gtk_header_bar_new();
GtkWidget* reload = gtk_button_new_from_icon_name("view-refresh-symbolic");
gtk_widget_set_tooltip_text(reload, "Reload page (Ctrl+R or F5)");
gtk_accessible_update_property(GTK_ACCESSIBLE(reload),
GTK_ACCESSIBLE_PROPERTY_LABEL,
"Reload page", -1);
gtk_header_bar_pack_start(GTK_HEADER_BAR(header), reload);
gtk_window_set_titlebar(GTK_WINDOW(window), header);
g_signal_connect_object(reload, "clicked", G_CALLBACK(OnReloadClicked),
G_OBJECT(web_view), G_CONNECT_DEFAULT);
GtkEventController* keys = gtk_event_controller_key_new();
gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_CAPTURE);
g_signal_connect_object(keys, "key-pressed", G_CALLBACK(OnReloadKeyPressed),
G_OBJECT(web_view), G_CONNECT_DEFAULT);
gtk_widget_add_controller(window, keys);
gtk_window_set_default_size(GTK_WINDOW(window),
related_view == nullptr ? 1100 : 900,
related_view == nullptr ? 760 : 720);
@@ -1191,7 +1346,30 @@ WebKitWebView* CreateSurface(AppState* app, WebKitWebView* related_view) {
}
}
}
gtk_window_set_child(GTK_WINDOW(window), GTK_WIDGET(web_view));
GtkWidget* content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget* error_banner = gtk_revealer_new();
GtkWidget* error_content = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
gtk_widget_set_margin_start(error_content, 12);
gtk_widget_set_margin_end(error_content, 12);
gtk_widget_set_margin_top(error_content, 8);
gtk_widget_set_margin_bottom(error_content, 8);
GtkWidget* error_message = gtk_label_new(
"Couldn't load the page. Check your connection and try again.");
gtk_label_set_wrap(GTK_LABEL(error_message), TRUE);
gtk_label_set_xalign(GTK_LABEL(error_message), 0);
gtk_widget_set_hexpand(error_message, TRUE);
GtkWidget* retry = gtk_button_new_with_label("Retry");
g_signal_connect_object(retry, "clicked", G_CALLBACK(OnReloadClicked),
G_OBJECT(web_view), G_CONNECT_DEFAULT);
gtk_box_append(GTK_BOX(error_content), error_message);
gtk_box_append(GTK_BOX(error_content), retry);
gtk_revealer_set_child(GTK_REVEALER(error_banner), error_content);
gtk_box_append(GTK_BOX(content), error_banner);
gtk_widget_set_vexpand(GTK_WIDGET(web_view), TRUE);
gtk_box_append(GTK_BOX(content), GTK_WIDGET(web_view));
gtk_window_set_child(GTK_WINDOW(window), content);
g_object_set_data_full(G_OBJECT(web_view), "mocktail-load-error",
g_object_ref(error_banner), g_object_unref);
auto* surface =
new SurfaceState(app, GTK_WINDOW(window), related_view == nullptr);
@@ -1227,6 +1405,8 @@ WebKitWebView* CreateSurface(AppState* app, WebKitWebView* related_view) {
webkit_user_script_unref(bridge_script);
ConfigureWebView(web_view, app);
g_signal_connect(web_view, "resource-load-started",
G_CALLBACK(OnResourceStarted), nullptr);
g_signal_connect(web_view, "load-changed", G_CALLBACK(OnLoadChanged),
surface);
g_signal_connect(web_view, "load-failed", G_CALLBACK(OnLoadFailed), surface);
@@ -1242,12 +1422,29 @@ WebKitWebView* CreateSurface(AppState* app, WebKitWebView* related_view) {
g_signal_connect(web_view, "close", G_CALLBACK(OnWebViewClose), surface);
g_signal_connect(window, "close-request", G_CALLBACK(OnWindowCloseRequest),
surface);
g_signal_connect(window, "map", G_CALLBACK(OnWindowMapped), nullptr);
g_signal_connect(window, "unmap", G_CALLBACK(OnWindowUnmapped), nullptr);
return web_view;
}
void Activate(GtkApplication* application, gpointer user_data) {
auto* state = static_cast<AppState*>(user_data);
state->application = application;
bool wayland_display = false;
#ifdef GDK_WINDOWING_WAYLAND
wayland_display = GDK_IS_WAYLAND_DISPLAY(gdk_display_get_default());
#endif
// Auth/challenge pages can finish loading while accelerated WebKit surfaces
// remain blank on wlroots/Mesa. Use software compositing for these small
// helper windows, including popups, without changing the game's renderer.
// Inspect GTK's actual display: an XWayland helper may inherit WAYLAND_DISPLAY.
state->disable_hardware_acceleration =
mocktail::webview::ShouldDisableWebViewHardwareAcceleration(
wayland_display, std::getenv("WEBKIT_DISABLE_COMPOSITING_MODE"));
std::cerr << "[webview] display=" << (wayland_display ? "wayland" : "other")
<< " compositing="
<< (state->disable_hardware_acceleration ? "software" : "default")
<< '\n';
if (!InitializeNetworkSession(state)) {
state->startup_failed = true;
g_application_quit(G_APPLICATION(application));
+45 -1
View File
@@ -123,6 +123,14 @@ bool ShouldDisableWebKitSandbox(std::string_view kernel_version,
kernel_version.find("freebsd.org") != std::string_view::npos);
}
bool ShouldDisableWebViewHardwareAcceleration(
bool wayland_display, const char* compositing_override) {
if (compositing_override != nullptr) {
return std::string_view(compositing_override) != "0";
}
return wayland_display;
}
const char* AndroidBridgeSource() { return kBridgeSource; }
std::string BuildRobloxAndroidUserAgent() {
@@ -134,7 +142,43 @@ std::string BuildRobloxAndroidUserAgent() {
return "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) ROBLOX "
"Android App " +
std::string(version) + " Tablet Hybrid() GooglePlayStore RobloxApp/" +
std::string(version) + "(GlobalDist; GooglePlayStore)";
std::string(version) + " (GlobalDist; GooglePlayStore)";
}
bool IsBrowserLoginUrl(std::string_view url) {
GUri* parsed = g_uri_parse(std::string(url).c_str(), G_URI_FLAGS_NONE, nullptr);
if (parsed == nullptr) return false;
const char* host = g_uri_get_host(parsed);
const char* path = g_uri_get_path(parsed);
const bool browser_login =
EvaluateNavigationUri(std::string(url).c_str()).allowed && host != nullptr &&
(g_ascii_strcasecmp(host, "www.roblox.com") == 0 ||
g_ascii_strcasecmp(host, "roblox.com") == 0) && path != nullptr &&
(g_ascii_strcasecmp(path, "/login") == 0 ||
g_ascii_strcasecmp(path, "/login/") == 0);
g_uri_unref(parsed);
return browser_login;
}
bool IsEssentialWebResource(const char* uri) {
if (uri == nullptr) return false;
const UriPolicyResult policy = EvaluateNavigationUri(uri);
if (!policy.allowed) return false;
if (policy.host == "css.rbxcdn.com" || policy.host == "js.rbxcdn.com") {
return true;
}
if (policy.host != "arkoselabs.roblox.com" &&
policy.host != "apis.rbxcdn.com") return false;
GUri* parsed = g_uri_parse(uri, G_URI_FLAGS_NONE, nullptr);
if (parsed == nullptr) return false;
const char* path = g_uri_get_path(parsed);
const bool essential =
path != nullptr &&
(policy.host == "apis.rbxcdn.com"
? std::string_view(path) == "/captcha/v1/metadata"
: g_str_has_prefix(path, "/v2/") || g_str_has_prefix(path, "/fc/"));
g_uri_unref(parsed);
return essential;
}
std::string BoundedLogToken(const char* value, std::string_view fallback) {
+5
View File
@@ -35,8 +35,13 @@ struct UriPolicyResult {
// An explicit WebKit sandbox setting takes precedence over host defaults.
bool ShouldDisableWebKitSandbox(std::string_view kernel_version,
const char* sandbox_override);
// Match WebKit's WEBKIT_DISABLE_COMPOSITING_MODE override semantics.
bool ShouldDisableWebViewHardwareAcceleration(
bool wayland_display, const char* compositing_override);
const char* AndroidBridgeSource();
std::string BuildRobloxAndroidUserAgent();
bool IsBrowserLoginUrl(std::string_view url);
bool IsEssentialWebResource(const char* uri);
std::string BoundedLogToken(const char* value, std::string_view fallback);
UriPolicyResult EvaluateNavigationUri(const char* uri);
const char* CaptchaEventName(CaptchaEventType type);
+32 -5
View File
@@ -18,6 +18,7 @@
#include <utility>
#include <vector>
#include "mocktail/graphics/gles_text_overlay_compositor.h"
#include "mocktail/graphics/present_mode_policy.h"
#include "mocktail/platform/display_refresh_capabilities.h"
#include "mocktail/platform/sdl_application_metadata.h"
@@ -151,6 +152,7 @@ static std::unique_ptr<SdlTextInputBackend> g_text_input_backend;
static std::unique_ptr<WindowTextInputOwner> g_text_input_owner;
static std::unique_ptr<SdlPointerCaptureBackend> g_pointer_capture_backend;
static std::unique_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;
@@ -1542,9 +1544,15 @@ extern "C" bool mocktail_window_has_presented_frame() {
return HasPresentedFrame();
}
extern "C" int mocktail_window_width() { return GetWidth(); }
extern "C" int mocktail_window_width() {
const auto surface = g_window_surface_lifecycle.Snapshot();
return surface.generation != 0 ? static_cast<int>(surface.width) : GetWidth();
}
extern "C" int mocktail_window_height() { return GetHeight(); }
extern "C" int mocktail_window_height() {
const auto surface = g_window_surface_lifecycle.Snapshot();
return surface.generation != 0 ? static_cast<int>(surface.height) : GetHeight();
}
extern "C" void* mocktail_gl_proc_address(const char* name) {
return GetGLProcAddress(name);
@@ -1573,6 +1581,19 @@ bool SwapBuffers() {
return false;
}
if (g_gles_text_overlay == nullptr) {
graphics::GlesTextOverlaySource source;
source.may_present = reinterpret_cast<decltype(source.may_present)>(
dlsym(RTLD_DEFAULT, "mocktail_text_overlay_may_present"));
source.query = reinterpret_cast<decltype(source.query)>(
dlsym(RTLD_DEFAULT, "mocktail_text_overlay_query"));
source.copy = reinterpret_cast<decltype(source.copy)>(
dlsym(RTLD_DEFAULT, "mocktail_text_overlay_copy"));
g_gles_text_overlay =
std::make_unique<graphics::GlesTextOverlayCompositor>(source);
}
(void)g_gles_text_overlay->Draw(g_state.sdl_window);
if (!SDL_GL_SwapWindow(g_state.sdl_window)) {
fprintf(stderr, " [window] SDL_GL_SwapWindow failed: %s\n",
SDL_GetError());
@@ -2189,9 +2210,11 @@ bool PumpEvents() {
}
}
g_state.native_window = observed_native_window;
if (pixel_width > 0 && pixel_height > 0) {
g_state.width = pixel_width;
g_state.height = pixel_height;
const WindowSurfaceSnapshot surface =
g_window_surface_lifecycle.Snapshot();
if (surface.available) {
g_state.width = static_cast<int>(surface.width);
g_state.height = static_cast<int>(surface.height);
}
if ((event.type == SDL_EVENT_WINDOW_RESIZED ||
event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED ||
@@ -2336,6 +2359,10 @@ void Shutdown() {
g_pointer_capture_backend.reset();
g_text_input_owner.reset();
g_text_input_backend.reset();
// PresentLifecycleGate has drained every compositor call. If the render
// context is on another thread, destroying that context releases its GL
// resources below.
g_gles_text_overlay.reset();
if (!g_state.direct_vulkan && g_state.egl_context != nullptr) {
SDL_GL_MakeCurrent(g_state.sdl_window, nullptr);
}
+8 -2
View File
@@ -52,6 +52,9 @@ void WindowPointerCaptureOwner::ClearQuery() {
}
bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
if (!focused_) {
return Apply(false, true);
}
MouseLockQueryCallback callback = nullptr;
void* context = nullptr;
{
@@ -93,9 +96,12 @@ bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
const bool client_active = query_ok;
const bool native_capture_active = client_active && native_lock_active;
const bool right_drag_active = right_button_held_;
// Roblox can keep Shift Lock active while chat has text focus. Honor that
// native lock so camera motion stays relative and cannot leave the window.
// Text editing only suppresses the host's fallback RMB camera capture.
const bool capture_requested =
(right_drag_active || native_capture_active) && !text_input_active;
// Text input releases capture, but Roblox still draws its own cursor.
native_capture_active || (right_drag_active && !text_input_active);
// When capture is released, Roblox still draws its own cursor.
// Show the system pointer only when the native client cannot provide one.
const bool cursor_visible = !client_active && !capture_requested;
return Apply(capture_requested, cursor_visible);
+9 -5
View File
@@ -3,6 +3,8 @@
#include <limits>
#include <utility>
#include "window/window_state_store.h"
namespace mocktail {
namespace window {
namespace {
@@ -17,7 +19,8 @@ Status FailedPrecondition(std::string message) {
bool ValidAvailableSurface(uintptr_t native_window, uint32_t width,
uint32_t height, float dpi_scale) {
return native_window != 0 && width != 0 && height != 0 && dpi_scale > 0.0f;
return native_window != 0 && width >= kMinimumWindowWidth &&
height >= kMinimumWindowHeight && dpi_scale > 0.0f;
}
} // namespace
@@ -68,10 +71,11 @@ Status WindowSurfaceLifecycle::Observe(uintptr_t native_window, uint32_t width,
return InvalidArgument("observed window surface requires a DPI scale");
}
// A mapped handle with a temporarily zero pixel extent is not fabricated as
// Android surface destruction. Minimized Wayland/X11 windows can report this
// while their native surface lifetime remains intact.
if (width == 0 || height == 0) {
// Compositors can report extents below SDL's minimum size during resize
// and minimization. Retain the last usable extent: Roblox derives smaller
// render targets from it, and a one-pixel axis produces a zero-sized texture.
// The native surface lifetime is still tracked independently of its size.
if (width < kMinimumWindowWidth || height < kMinimumWindowHeight) {
if (current_.available && native_window != current_.native_window) {
WindowSurfaceSnapshot destroyed = current_;
destroyed.available = false;
+4 -1
View File
@@ -120,7 +120,10 @@ bool WindowTextInputOwner::Pump() {
}
if (command.type == CommandType::kHide) {
if (!active_ || active_generation_ != command.generation) {
// A hide can replace both a pending show and the hide of the previous
// editor. It must also stop that older editor if the new show was never
// pumped (Roblox briefly focuses an empty TextBox when closing chat).
if (!active_ || active_generation_ > command.generation) {
return true;
}
return StopAndClear();
+7
View File
@@ -1,3 +1,4 @@
# Includes changes by vii from CoderDayton/nightcap.
# Copyright 2026 Mocktail Project Authors
# Licensed under the Apache License, Version 2.0.
@@ -106,13 +107,19 @@ target_link_libraries(stub_glesv2 PRIVATE dl)
add_library(stub_vulkan SHARED
${CMAKE_SOURCE_DIR}/src/graphics/bionic_vulkan_loader_adapter.cc
${CMAKE_SOURCE_DIR}/src/graphics/vulkan_text_overlay_compositor.cc
${CMAKE_SOURCE_DIR}/src/graphics/vulkan_etc2_emulation.cc
${CMAKE_SOURCE_DIR}/src/graphics/etc2_decoder.cc
${CMAKE_SOURCE_DIR}/src/graphics/texture_override.cc
)
configure_stub(stub_vulkan "libvulkan.so")
target_include_directories(stub_vulkan PRIVATE ${CMAKE_SOURCE_DIR}/include)
pkg_check_modules(LIBPLACEBO REQUIRED IMPORTED_TARGET libplacebo)
pkg_check_modules(LIBPNG REQUIRED IMPORTED_TARGET libpng)
target_link_libraries(stub_vulkan PRIVATE
Mocktail::SdlVulkanWsi
PkgConfig::LIBPLACEBO
PkgConfig::LIBPNG
Threads::Threads
dl
)
+28
View File
@@ -16,6 +16,18 @@ target_link_libraries(runtime_config_test PRIVATE
GTest::gtest_main
)
add_executable(fleasion_probe fleasion_probe.cc)
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 AND MOCKTAIL_TEST_OPENSSL)
add_test(NAME FleasionIntegrationTest.ProxyAndTrust
COMMAND ${MOCKTAIL_TEST_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/fleasion_integration_test.py
$<TARGET_FILE:fleasion_probe> ${MOCKTAIL_TEST_OPENSSL})
set_tests_properties(FleasionIntegrationTest.ProxyAndTrust PROPERTIES TIMEOUT 45)
endif()
add_executable(runtime_config_file_test runtime_config_file_test.cc)
target_link_libraries(runtime_config_file_test PRIVATE
Mocktail::Runtime
@@ -189,6 +201,13 @@ target_link_libraries(supported_launch_policy_test PRIVATE
GTest::gtest_main
)
add_executable(process_launch_policy_test process_launch_policy_test.cc)
target_link_libraries(process_launch_policy_test PRIVATE
Mocktail::Runtime
GTest::gtest_main
)
mocktail_apply_compile_options(process_launch_policy_test)
add_executable(platform_cache_migration_test
platform_cache_migration_test.cc
)
@@ -723,6 +742,8 @@ target_link_libraries(bionic_pthread_create_runtime_test PRIVATE
)
target_link_options(bionic_pthread_create_runtime_test PRIVATE
"-Wl,--wrap=pthread_create"
"-Wl,--wrap=pthread_setschedparam"
"-Wl,--wrap=sched_setscheduler"
)
add_executable(bionic_sysconf_test bionic_sysconf_test.cc)
@@ -885,6 +906,7 @@ gtest_discover_tests(memory_limit_test)
gtest_discover_tests(runtime_paths_test)
gtest_discover_tests(single_instance_lock_test)
gtest_discover_tests(supported_launch_policy_test)
gtest_discover_tests(process_launch_policy_test)
gtest_discover_tests(platform_cache_migration_test)
gtest_discover_tests(roblox_desktop_app_policy_test)
gtest_discover_tests(browser_tracker_service_test)
@@ -974,6 +996,12 @@ add_test(
${CMAKE_SOURCE_DIR}/packaging/AppRun
)
add_test(
NAME AnyLinuxDependenciesTest.RollingAssetsAreVerified
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/anylinux_dependencies_test.sh
${CMAKE_SOURCE_DIR}
)
add_test(
NAME AnyLinuxPackagingTest.RelocatedRuntimePaths
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/anylinux_packaging_test.sh
+208
View File
@@ -0,0 +1,208 @@
#!/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 TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mocktail-dependency-test.XXXXXX")"
trap 'rm -rf -- "${TEMP_DIR}"' EXIT
source "${ROOT}/scripts/install_anylinux_dependencies.sh"
Fail() {
printf 'AnyLinux dependency test failed: %s\n' "$*" >&2
exit 1
}
readonly API=https://api.github.com/repos/pkgforge-dev/archlinux-pkgs-debloated/releases
readonly FFMPEG=ffmpeg-mini-x86_64.pkg.tar.zst
readonly OPUS=opus-mini-x86_64.pkg.tar.zst
printf 'ffmpeg fixture\n' >"${TEMP_DIR}/ffmpeg"
printf 'updated ffmpeg fixture\n' >"${TEMP_DIR}/updated-ffmpeg"
printf 'opus fixture\n' >"${TEMP_DIR}/opus"
readonly FFMPEG_HASH="$(sha256sum "${TEMP_DIR}/ffmpeg" | cut -d ' ' -f 1)"
readonly UPDATED_FFMPEG_HASH="$(sha256sum "${TEMP_DIR}/updated-ffmpeg" | cut -d ' ' -f 1)"
readonly OPUS_HASH="$(sha256sum "${TEMP_DIR}/opus" | cut -d ' ' -f 1)"
printf '# Required fixture packages\n\n%s\n%s' "${FFMPEG}" "${OPUS}" \
>"${TEMP_DIR}/manifest.txt"
jq --null-input --arg api "${API}" --arg ffmpeg "${FFMPEG}" --arg opus "${OPUS}" \
--arg ffmpeg_hash "${FFMPEG_HASH}" --arg opus_hash "${OPUS_HASH}" '
{tag_name: "continuous", assets: [
{id: 10, name: $ffmpeg, state: "uploaded", digest: ("sha256:" + $ffmpeg_hash),
url: ($api + "/assets/10")},
{id: 20, name: $opus, state: "uploaded", digest: ("sha256:" + $opus_hash),
url: ($api + "/assets/20")},
{id: 30, name: "unrequested-mini-x86_64.pkg.tar.zst", state: "uploaded",
digest: null, url: ($api + "/assets/30")}
]}
' >"${TEMP_DIR}/release.json"
jq --arg hash "${UPDATED_FFMPEG_HASH}" '
.assets[0].id = 11 | .assets[0].url |= sub("/10$"; "/11") |
.assets[0].digest = ("sha256:" + $hash) |
.assets[1].id = 21 | .assets[1].url |= sub("/20$"; "/21")
' \
"${TEMP_DIR}/release.json" >"${TEMP_DIR}/refreshed-release.json"
mkdir -p "${TEMP_DIR}/project/scripts" "${TEMP_DIR}/project/packaging"
cp "${ROOT}/scripts/install_anylinux_dependencies.sh" "${TEMP_DIR}/project/scripts/"
cp "${TEMP_DIR}/manifest.txt" "${TEMP_DIR}/project/packaging/anylinux-dependencies.txt"
# Record requests without any network access or changes to host packages.
curl() {
local output="" accept="" authorization="" url=""
while (( $# > 0 )); do
case "$1" in
--output) output="$2"; shift 2 ;;
--header)
case "$2" in
Accept:*) accept="$2" ;;
Authorization:*) authorization="$2" ;;
esac
shift 2 ;;
--retry|--proto|--proto-redir|--connect-timeout|--max-time) shift 2 ;;
--fail|--silent|--show-error|--location) shift ;;
https://*) url="$1"; shift ;;
*) Fail "unexpected curl option: $1" ;;
esac
done
if [[ -n "${GH_TOKEN:-}" ]]; then
[[ "${authorization}" == "Authorization: Bearer ${GH_TOKEN}" ]] ||
Fail 'missing authenticated GitHub request'
fi
printf '%s\n' "${url}" >>"${TEMP_DIR}/calls"
case "${url}" in
"${API}/tags/continuous")
[[ "${accept}" == 'Accept: application/vnd.github+json' ]]
if [[ "${SCENARIO}" == metadata-failure ]]; then return 22; fi
if [[ "${SCENARIO}" == replaced && -f "${TEMP_DIR}/asset-replaced" ]]; then
cp "${TEMP_DIR}/refreshed-release.json" "${output}"
else
cp "${TEST_RELEASE}" "${output}"
fi ;;
"${API}/assets/10"|"${API}/assets/11"|"${API}/assets/20"|"${API}/assets/21")
[[ "${accept}" == 'Accept: application/octet-stream' ]]
if [[ "${SCENARIO}" == download-failure ]]; then return 22; fi
if [[ "${SCENARIO}" == replaced && "${url}" == "${API}/assets/20" ]]; then
touch "${TEMP_DIR}/asset-replaced"
return 22
fi
if [[ "${url}" == "${API}/assets/20" || "${url}" == "${API}/assets/21" ]]; then
cp "${TEMP_DIR}/opus" "${output}"
elif [[ "${url}" == "${API}/assets/11" ]]; then
cp "${TEMP_DIR}/updated-ffmpeg" "${output}"
else
cp "${TEMP_DIR}/ffmpeg" "${output}"
fi
if [[ "${SCENARIO}" == corrupted && "${url}" == "${API}/assets/20" ]]; then
printf 'corrupted\n' >>"${output}"
fi ;;
*) Fail "unrequested or rolling asset URL: ${url}" ;;
esac
}
pacman() {
[[ "$#" == 5 && "$1" == -U && "$2" == --noconfirm && "$3" == --needed ]]
if [[ "${SCENARIO}" == replaced ]]; then
cmp "${TEMP_DIR}/updated-ffmpeg" "$4"
else
cmp "${TEMP_DIR}/ffmpeg" "$4"
fi
cmp "${TEMP_DIR}/opus" "$5"
local downloads="${4%/*}"
(cd "${downloads}" && sha256sum --check --strict SHA256SUMS)
cp "$4" "$5" "${downloads}/SHA256SUMS" "${downloads}/dependencies.tsv" \
"${TEMP_DIR}/${SCENARIO}/"
touch "${TEMP_DIR}/installed"
}
export -f curl pacman Fail
export TEMP_DIR API FFMPEG OPUS SCENARIO TEST_RELEASE
ExpectRejectedSnapshot() {
local description="$1" filter="$2"
jq "${filter}" "${TEMP_DIR}/release.json" >"${TEMP_DIR}/invalid.json"
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
"${TEMP_DIR}/invalid.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
Fail "accepted ${description}"
fi
}
(AnyLinuxDependencySnapshot "${TEMP_DIR}/manifest.txt" \
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/snapshot.tsv" ||
Fail 'rejected valid dependency snapshot'
printf '%s\t%s\t%s\n' \
"${FFMPEG_HASH}" "${FFMPEG}" "${API}/assets/10" \
"${OPUS_HASH}" "${OPUS}" "${API}/assets/20" \
>"${TEMP_DIR}/expected-snapshot.tsv"
cmp "${TEMP_DIR}/expected-snapshot.tsv" "${TEMP_DIR}/snapshot.tsv" ||
Fail 'incorrect dependency snapshot'
ExpectRejectedSnapshot 'missing asset' '.assets |= .[1:]'
ExpectRejectedSnapshot 'duplicate asset' '.assets += [.assets[0]]'
ExpectRejectedSnapshot 'absent SHA256' '.assets[0].digest = null'
ExpectRejectedSnapshot 'malformed SHA256' '.assets[0].digest = "sha256:bad"'
ExpectRejectedSnapshot 'foreign asset URL' '.assets[0].url = "https://example.org/archive"'
ExpectRejectedSnapshot 'fractional asset ID' '.assets[0].id = 10.5'
ExpectRejectedSnapshot 'unuploaded asset' '.assets[0].state = "starter"'
ExpectRejectedSnapshot 'different release' '.tag_name = "other"'
for invalid_manifest in '../ffmpeg-mini-x86_64.pkg.tar.zst' \
"${FFMPEG} extra" "${FFMPEG}"$'\n'"${FFMPEG}" ''; do
printf '%s\n' "${invalid_manifest}" >"${TEMP_DIR}/invalid-manifest.txt"
if (AnyLinuxDependencySnapshot "${TEMP_DIR}/invalid-manifest.txt" \
"${TEMP_DIR}/release.json") >"${TEMP_DIR}/invalid.log" 2>&1; then
Fail 'accepted unsafe, duplicate, or empty package allowlist'
fi
done
for SCENARIO in success replaced corrupted download-failure metadata-failure invalid-metadata; do
TEST_RELEASE="${TEMP_DIR}/release.json"
if [[ "${SCENARIO}" == invalid-metadata ]]; then
# A valid first row must not allow downloads when a later row is invalid.
jq '.assets[1].digest = null' "${TEMP_DIR}/release.json" >"${TEMP_DIR}/invalid.json"
TEST_RELEASE="${TEMP_DIR}/invalid.json"
fi
mkdir "${TEMP_DIR}/${SCENARIO}"
: >"${TEMP_DIR}/calls"
if GH_TOKEN=fixture-token bash -Eeuo pipefail -c '
source "$1/project/scripts/install_anylinux_dependencies.sh"
AnyLinuxDependencyRequireContainer() { :; }
AnyLinuxDependenciesMain
' _ "${TEMP_DIR}" \
>"${TEMP_DIR}/${SCENARIO}.log" 2>&1; then
[[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]] ||
Fail "installed packages after ${SCENARIO}"
[[ -f "${TEMP_DIR}/installed" ]]
(cd "${TEMP_DIR}/${SCENARIO}" && sha256sum --check --strict SHA256SUMS)
rm -- "${TEMP_DIR}/installed"
else
if [[ "${SCENARIO}" == success || "${SCENARIO}" == replaced ]]; then
cat -- "${TEMP_DIR}/${SCENARIO}.log" >&2
Fail "valid download failed: ${SCENARIO}"
fi
[[ ! -e "${TEMP_DIR}/installed" ]] || Fail 'installed unverified archives'
fi
case "${SCENARIO}" in
success)
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 3 ]]
grep -Fq "${API}/assets/10" "${TEMP_DIR}/calls" ;;
replaced)
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 6 ]]
grep -Fq "${API}/assets/11" "${TEMP_DIR}/calls"
grep -Fq "${API}/assets/11" "${TEMP_DIR}/replaced/dependencies.tsv"
grep -Fq "${API}/assets/21" "${TEMP_DIR}/replaced/dependencies.tsv" ;;
corrupted)
grep -Fq 'dependency checksum verification failed' "${TEMP_DIR}/${SCENARIO}.log" ;;
download-failure)
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 6 ]]
grep -Fq 'failed after 3 snapshots' "${TEMP_DIR}/${SCENARIO}.log" ;;
metadata-failure|invalid-metadata)
[[ "$(wc -l <"${TEMP_DIR}/calls")" == 1 ]] ;;
esac
done
if (id() { printf '1000\n'; }; AnyLinuxDependencyRequireContainer) \
>"${TEMP_DIR}/guard.log" 2>&1; then
Fail 'container/root installation guard was bypassed'
fi
printf 'AnyLinux dependency tests passed\n'
+61 -23
View File
@@ -10,6 +10,7 @@
namespace {
std::atomic<int> g_host_scheduler_calls{0};
std::atomic<bool> g_intercept_pthread_create{false};
std::atomic<int> g_intercepted_create_calls{0};
std::atomic<int> g_forced_create_failures{0};
@@ -55,6 +56,22 @@ extern "C" int __wrap_pthread_create(pthread_t* thread,
return __real_pthread_create(thread, attributes, start_routine, argument);
}
// These host substitutes would accept RT calls; no privileges are required.
extern "C" int __wrap_pthread_setschedparam(
pthread_t, int policy, const sched_param*) {
++g_host_scheduler_calls;
return policy == SCHED_OTHER ? EAGAIN : 0;
}
extern "C" int __wrap_sched_setscheduler(pid_t, int policy, const sched_param*) {
++g_host_scheduler_calls;
if (policy == SCHED_OTHER) {
errno = ESRCH;
return -1;
}
return 0;
}
namespace mocktail::compat {
namespace {
@@ -221,31 +238,52 @@ TEST(BionicPthreadCreateRuntimeTest, PreservesRequestedStackSize) {
kRequestedStackSize);
}
TEST(BionicPthreadCreateRuntimeTest, RetryPreservesRequestedStackSize) {
constexpr size_t kRequestedStackSize = 3 * 1024 * 1024;
g_observed_stack_size.store(0, std::memory_order_release);
TEST(BionicPthreadCreateRuntimeTest, RejectsExplicitRealtimeBeforeHostCreate) {
for (const int policy : {SCHED_FIFO, SCHED_RR}) {
pthread_attr_t attributes;
ASSERT_EQ(pthread_attr_init(&attributes), 0);
ASSERT_EQ(pthread_attr_setinheritsched(&attributes, PTHREAD_EXPLICIT_SCHED), 0);
ASSERT_EQ(pthread_attr_setschedpolicy(&attributes, policy), 0);
sched_param parameters{};
parameters.sched_priority = 99;
ASSERT_EQ(pthread_attr_setschedparam(&attributes, &parameters), 0);
g_intercepted_create_calls.store(0);
g_intercept_pthread_create.store(true);
pthread_t thread{};
const int result = mocktail_bionic_pthread_create(
&thread, &attributes, RecordThreadStackSize, nullptr);
g_intercept_pthread_create.store(false);
EXPECT_EQ(result, EPERM);
EXPECT_EQ(g_intercepted_create_calls.load(), 0);
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
}
}
pthread_attr_t attributes;
ASSERT_EQ(pthread_attr_init(&attributes), 0);
ASSERT_EQ(pthread_attr_setstacksize(&attributes, kRequestedStackSize), 0);
ASSERT_EQ(pthread_attr_setinheritsched(&attributes, PTHREAD_EXPLICIT_SCHED),
0);
ASSERT_EQ(pthread_attr_setschedpolicy(&attributes, SCHED_FIFO), 0);
// A zero FIFO priority makes the fully copied host attr invalid. The
// compatibility retry drops advisory scheduling while retaining the stack.
sched_param invalid_parameters{};
ASSERT_EQ(pthread_attr_getschedparam(&attributes, &invalid_parameters), 0);
ASSERT_EQ(invalid_parameters.sched_priority, 0);
TEST(BionicPthreadCreateRuntimeTest, DeniesRealtimePromotionWithoutCallingHost) {
const int before = g_host_scheduler_calls.load();
sched_param parameters{};
parameters.sched_priority = 99;
for (const int policy : {SCHED_FIFO, SCHED_RR,
SCHED_FIFO | SCHED_RESET_ON_FORK,
SCHED_RR | SCHED_RESET_ON_FORK}) {
errno = EDOM;
EXPECT_EQ(mocktail_bionic_pthread_setschedparam(
pthread_self(), policy, &parameters), EPERM);
EXPECT_EQ(errno, EDOM);
EXPECT_EQ(mocktail_bionic_sched_setscheduler(0, policy, &parameters), -1);
EXPECT_EQ(errno, EPERM);
}
EXPECT_EQ(g_host_scheduler_calls.load(), before);
}
pthread_t thread{};
ASSERT_EQ(mocktail_bionic_pthread_create(&thread, &attributes,
RecordThreadStackSize, nullptr),
0);
EXPECT_EQ(pthread_attr_destroy(&attributes), 0);
ASSERT_EQ(pthread_join(thread, nullptr), 0);
ExpectRequestedStackSize(
g_observed_stack_size.load(std::memory_order_acquire),
kRequestedStackSize);
TEST(BionicPthreadCreateRuntimeTest, ForwardsNormalSchedulingAndHostErrors) {
sched_param parameters{};
const int before = g_host_scheduler_calls.load();
EXPECT_EQ(mocktail_bionic_pthread_setschedparam(
pthread_self(), SCHED_OTHER, &parameters), EAGAIN);
EXPECT_EQ(mocktail_bionic_sched_setscheduler(0, SCHED_OTHER, &parameters), -1);
EXPECT_EQ(errno, ESRCH);
EXPECT_EQ(g_host_scheduler_calls.load(), before + 2);
}
} // namespace
+81
View File
@@ -1,7 +1,12 @@
#include "compat/build_profile.h"
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include <unistd.h>
#include <fstream>
#include <iterator>
#include <string>
#ifndef MOCKTAIL_TEST_SOURCE_DIR
@@ -149,5 +154,81 @@ TEST(BuildProfileTest, RejectsInvalidManifest) {
EXPECT_FALSE(result.error.empty());
}
TEST(BuildProfileTest, RejectsPartialInputProfilesAndUpgradesCachedLayouts) {
nlohmann::json document;
{
std::ifstream input(kManifestPath);
input >> document;
}
auto &entry = document["profiles"].back();
auto &bridge = entry["fmod_output_device_bridge"];
char path[] = "/tmp/mocktail-input-profile-XXXXXX";
int fd = mkstemp(path);
ASSERT_GE(fd, 0);
close(fd);
const auto read = [&] {
{
std::ofstream output(path);
output << document;
}
return FindBuildProfile(path, entry["elf_build_id"].get<std::string>());
};
bridge.erase("input_count_method_rva");
EXPECT_FALSE(read());
for (const auto *name : {"input_info_method_rva", "input_current_method_rva",
"input_select_method_rva"})
bridge.erase(name);
auto result = read();
ASSERT_TRUE(result && result.profile);
EXPECT_TRUE(result.profile->fmod_output_device_bridge->has_input_devices());
EXPECT_EQ(result.profile->fmod_output_device_bridge->input_method_rvas[0],
0x32d12dcU);
entry["elf_build_id"] = "5f0704edd9064f566ee3d6df2bd2fabbcc709f03";
bridge["vtable_rva"] = "0x6cd3ce0";
bridge["vtable_layout_version"] = 2;
result = read();
ASSERT_TRUE(result && result.profile);
EXPECT_EQ(result.profile->fmod_output_device_bridge->input_method_rvas[3],
0x320bd18U);
bridge["vtable_rva"] = "0x1000";
result = read();
ASSERT_TRUE(result && result.profile);
EXPECT_FALSE(result.profile->fmod_output_device_bridge->has_input_devices());
unlink(path);
}
TEST(BuildProfileTest, ParsesOnlyKnownFmodVtableLayouts) {
std::ifstream input(kManifestPath);
const std::string manifest{std::istreambuf_iterator<char>(input), {}};
const std::string marker = "\"fmod_output_device_bridge\": {";
const auto position = manifest.find(marker);
ASSERT_NE(position, std::string::npos);
char path[] = "/tmp/mocktail-fmod-profile-XXXXXX";
const int descriptor = mkstemp(path);
ASSERT_GE(descriptor, 0);
close(descriptor);
for (const std::string layout : {"1", "2", "0", "3", "true", "\"2\"", "null"}) {
auto document = manifest;
document.insert(position + marker.size(),
"\"vtable_layout_version\":" + layout + ",");
{ std::ofstream output(path); output << document; }
const auto result = FindBuildProfile(
path, "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21");
if (layout == "1" || layout == "2") {
EXPECT_TRUE(result) << result.error;
if (result.profile && result.profile->fmod_output_device_bridge) {
const auto& bridge = *result.profile->fmod_output_device_bridge;
EXPECT_EQ(bridge.current_vtable_index(), layout == "2" ? 8U : 7U);
EXPECT_EQ(bridge.select_vtable_index(), layout == "2" ? 19U : 17U);
} else {
ADD_FAILURE() << "parsed layout lost its FMOD profile";
}
} else {
EXPECT_FALSE(result) << layout;
}
}
unlink(path);
}
} // namespace
} // namespace mocktail::compat
@@ -27,6 +27,7 @@ import sys
import tempfile
import types
import unittest
from unittest import mock
PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -47,6 +48,8 @@ LATEST_CANDIDATE_METADATA = (
PAYLOAD_ROOT / LATEST_CANDIDATE_PAYLOAD_ID / "roblox_payload.json"
)
LATEST_REFERENCE_PROFILE = REFERENCE_PROFILE_PATH
DEVICE_LISTS_PAYLOAD_ID = "3092-5f0704edd9064f566ee3d6df2bd2fabbcc709f03"
DEVICE_LISTS_LIBRARY = PAYLOAD_ROOT / DEVICE_LISTS_PAYLOAD_ID / "libroblox.so"
def load_analyzer():
@@ -537,5 +540,99 @@ class RealRuntimeCompatibilityAcceptanceTest(unittest.TestCase):
)
@unittest.skipUnless(
ANALYZER.capstone is not None
and CANDIDATE_LIBRARY.is_file()
and DEVICE_LISTS_LIBRARY.is_file(),
"local exact 2998 and 3092 payloads are unavailable",
)
class DeviceListsRuntimeCompatibilityAcceptanceTest(unittest.TestCase):
EXPECTED_BRIDGE = {
"vtable_rva": "0x6cd3ce0",
"string_constructor_rva": "0x1d7a038",
"count_method_rva": "0x320c676",
"info_method_rva": "0x320c716",
"current_method_rva": "0x320c6c6",
"select_method_rva": "0x320bf78",
"vtable_layout_version": 2,
"input_count_method_rva": "0x320ccf6",
"input_info_method_rva": "0x320cd04",
"input_current_method_rva": "0x320d152",
"input_select_method_rva": "0x320bd18",
}
def derive(self, reference, candidate):
return ANALYZER.derive_runtime_compatibility(
reference, candidate,
(PROJECT_ROOT / "config" / "roblox_compatibility.json",),
)
def test_2998_to_3092_keeps_fullscreen_and_shifted_audio_slots(self):
with ANALYZER.ElfImage(CANDIDATE_LIBRARY) as reference, ANALYZER.ElfImage(
DEVICE_LISTS_LIBRARY
) as candidate:
derived = self.derive(reference, candidate)
self.assertEqual(derived, {
"user_game_settings_fullscreen_setter_rva": "0x45c5f42",
"fmod_output_device_bridge": self.EXPECTED_BRIDGE,
})
def test_ambiguous_device_list_vtables_are_rejected(self):
original = ANALYZER.relative_relocation_map
with ANALYZER.ElfImage(CANDIDATE_LIBRARY) as reference, ANALYZER.ElfImage(
DEVICE_LISTS_LIBRARY
) as candidate:
def duplicated(image):
relocations = original(image)
if image is candidate:
vtable = int(self.EXPECTED_BRIDGE["vtable_rva"], 16)
duplicate = vtable + 0x400
candidate.require_relro_rva(duplicate, 20 * 8)
for slot in (5, 6, 8, 19):
relocations[duplicate + slot * 8] = relocations[vtable + slot * 8]
return relocations
with mock.patch.object(ANALYZER, "relative_relocation_map", duplicated):
with self.assertRaisesRegex(ANALYZER.AnalyzerError, "matched 2 candidate"):
self.derive(reference, candidate)
def test_cached_reference_can_omit_new_input_anchors(self):
with tempfile.TemporaryDirectory() as temporary:
paths = [Path(temporary) / name for name in ("old.json", "new.json")]
build_id = DEVICE_LISTS_PAYLOAD_ID.split("-", 1)[1]
for index, path in enumerate(paths):
bridge = dict(self.EXPECTED_BRIDGE)
if index == 0:
bridge = {k: v for k, v in bridge.items() if not k.startswith("input_")}
path.write_text(json.dumps({"schema_version": 1, "profiles": [{
"elf_build_id": build_id,
"allow_host_abi_bridges": True,
"fmod_output_device_bridge": bridge,
}]}))
for ordered in (paths, paths[::-1]):
loaded = ANALYZER.load_reference_runtime_profile(ordered, build_id)
self.assertEqual(loaded["fmod_output_device_bridge"]["input_select_method_rva"],
0x320bd18)
changed = json.loads(paths[0].read_text())
changed["profiles"][0]["fmod_output_device_bridge"]["vtable_layout_version"] = 1
paths[0].write_text(json.dumps(changed))
with self.assertRaisesRegex(ANALYZER.AnalyzerError, "disagree"):
ANALYZER.load_reference_runtime_profile(paths, build_id)
def test_device_list_profile_can_be_used_as_a_reference(self):
with tempfile.TemporaryDirectory() as temporary:
manifest = Path(temporary) / "compatibility.json"
manifest.write_text(json.dumps({"schema_version": 1, "profiles": [{
"elf_build_id": DEVICE_LISTS_PAYLOAD_ID.split("-", 1)[1],
"allow_host_abi_bridges": True,
"fmod_output_device_bridge": self.EXPECTED_BRIDGE,
}]}))
with ANALYZER.ElfImage(DEVICE_LISTS_LIBRARY) as reference, ANALYZER.ElfImage(
DEVICE_LISTS_LIBRARY
) as candidate:
derived = ANALYZER.derive_runtime_compatibility(
reference, candidate, (manifest,))
self.assertEqual(derived["fmod_output_device_bridge"], self.EXPECTED_BRIDGE)
if __name__ == "__main__":
unittest.main()
+145
View File
@@ -0,0 +1,145 @@
"""Exercise real CA composition, native path mapping, and HTTPS CONNECT routing."""
import os
from pathlib import Path
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
def command(*args):
subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
def main():
probe, openssl = sys.argv[1:]
with tempfile.TemporaryDirectory(prefix="mocktail-fleasion-test-") as directory:
root = Path(directory)
for name in ("public", "fleasion", "rotated"):
command(openssl, "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2",
"-subj", f"/CN={name}", "-addext", "basicConstraints=critical,CA:TRUE",
"-keyout", str(root / f"{name}.key"), "-out", str(root / f"{name}.crt"))
command(openssl, "req", "-newkey", "rsa:2048", "-nodes", "-subj",
"/CN=assetdelivery.roblox.com", "-keyout", str(root / "leaf.key"),
"-out", str(root / "leaf.csr"))
(root / "leaf.ext").write_text("subjectAltName=DNS:assetdelivery.roblox.com\nbasicConstraints=CA:FALSE\n")
command(openssl, "x509", "-req", "-in", str(root / "leaf.csr"), "-CA",
str(root / "fleasion.crt"), "-CAkey", str(root / "fleasion.key"),
"-CAcreateserial", "-days", "1", "-extfile", str(root / "leaf.ext"),
"-out", str(root / "leaf.crt"))
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(root / "leaf.crt", root / "leaf.key")
intercepted = []
failures = []
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen()
listener.settimeout(5)
port = listener.getsockname()[1]
def serve():
try:
# Correct CA succeeds; the unrelated CA must fail TLS verification.
for attempt in range(2):
connection, _ = listener.accept()
with connection:
connection.settimeout(5)
connect = b""
while not connect.endswith(b"\r\n\r\n"):
data = connection.recv(4096)
if not data:
raise AssertionError("proxy connection closed before CONNECT")
connect += data
assert connect.startswith(b"CONNECT assetdelivery.roblox.com:443 "), connect
connection.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
try:
with context.wrap_socket(connection, server_side=True) as tls:
request = tls.recv(8192)
assert request.startswith(b"GET /replacement "), request
intercepted.append(request)
body = b"fleasion-replaced-asset"
tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: " +
str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
except (ssl.SSLError, ConnectionError):
if attempt == 0:
raise
# The wrong CA may close with either a TLS alert or
# a TCP reset before the server finishes its handshake.
except BaseException as error:
failures.append(error)
thread = threading.Thread(target=serve, daemon=True)
thread.start()
env = {key: value for key, value in os.environ.items()
if not key.startswith(("MOCKTAIL_", "ROBLOX_"))}
for key in ("http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
env.pop(key, None)
env.update(HOME=str(root), XDG_CONFIG_HOME=str(root / "config"),
MOCKTAIL_CACHE_ROOT=str(root / "cache"), NO_PROXY="*", no_proxy="*")
certificate = root / "config/Fleasion/proxy_ca/ca.crt"
certificate.parent.mkdir(parents=True)
original = (root / "fleasion.crt").read_bytes()
certificate.write_bytes(original)
config = root / "config.yaml"
def configure(extra="", enabled="true", mode="env"):
config.write_text(f"version: 1\nnetwork:\n ca_bundle: {root / 'public.crt'}\n"
f"integrations:\n fleasion:\n enabled: {enabled}\n"
f" proxy_mode: {mode}\n proxy_port: {port}\n{extra}")
def run(url="prepare", success=True, environment=None):
result = subprocess.run([probe, str(config), url], env=environment or env,
capture_output=True, text=True, timeout=10)
assert (result.returncode == 0) == success, result.stdout + result.stderr
return result
configure()
response = run("https://assetdelivery.roblox.com/replacement")
assert "fleasion-replaced-asset" in response.stdout
assert certificate.read_bytes() == original
generated = root / "cache/fleasion/cacert.pem"
assert generated.stat().st_mode & 0o777 == 0o600
command(openssl, "verify", "-CAfile", str(generated), str(root / "public.crt"))
first_bundle = generated.read_bytes()
run() # Repeated startup must not duplicate trust roots.
assert generated.read_bytes() == first_bundle
certificate.write_bytes((root / "rotated.crt").read_bytes())
response = run("https://assetdelivery.roblox.com/replacement", success=False)
assert response.returncode == 10, response.stderr
thread.join(timeout=7)
listener.close()
assert not thread.is_alive() and not failures, failures
assert len(intercepted) == 1
assert original not in generated.read_bytes()
command(openssl, "verify", "-CAfile", str(generated), str(root / "rotated.crt"))
# Re-exec must compose from the original public roots, not old proxy CAs.
inherited = dict(env, MOCKTAIL_CA_BUNDLE=str(generated),
MOCKTAIL_FLEASION_GENERATED_BUNDLE=str(generated),
MOCKTAIL_FLEASION_BASE_CA_BUNDLE=str(root / "public.crt"))
certificate.write_bytes(original)
run(environment=inherited)
assert generated.read_bytes() == first_bundle
for bad in (b"not a certificate", (root / "leaf.crt").read_bytes()):
certificate.write_bytes(bad)
run(success=False)
assert generated.read_bytes() == first_bundle
certificate.unlink()
run(success=False)
configure(enabled="false")
run() # Disabled integration does not require Fleasion.
certificate.write_bytes(original)
configure(mode="hosts")
run()
configure(mode="invalid")
run(success=False)
configure(extra=" ca_certificate: relative.crt\n")
run(success=False)
print("Fleasion integration: proxy replacement, TLS rejection, CA rotation, "
"native aliases, and configuration checks passed")
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
#include "runtime/fleasion.h"
#include "runtime/runtime_config_file.h"
#include "services/http_client.h"
#include "libc_shim/libc_shim.h"
#include <cstdlib>
#include <iostream>
int main(int argc, char** argv) {
if (argc != 3) return 2;
const mocktail::runtime::ProcessEnvironment environment;
const auto paths = mocktail::runtime::RuntimePaths::FromEnvironment(environment);
const auto loaded = mocktail::runtime::LoadRuntimeConfig(environment, argv[1]);
if (!loaded) {
std::cerr << loaded.error << '\n';
return 3;
}
const auto prepared = mocktail::runtime::PrepareFleasion(loaded.config, environment, paths);
if (!prepared) {
std::cerr << prepared.error << '\n';
return 4;
}
std::string error;
if (!mocktail::runtime::ExportRuntimeConfigEnvironment(loaded.config, &error)) return 5;
if (!prepared.bundle.empty()) {
if (setenv("MOCKTAIL_CA_BUNDLE", prepared.bundle.c_str(), 1) != 0) return 6;
const auto mapped = libc_shim::ConfigureHostCaBundlePathMappings();
if (!mapped.ok()) return 7;
FILE* certificate = mocktail_fopen("ssl/cacert.pem", "r");
if (certificate == nullptr) return 8;
const int first = fgetc(certificate);
fclose(certificate);
if (first != '-') return 9;
}
if (std::string(argv[2]) == "prepare") {
std::cout << prepared.bundle.string() << '\n';
return 0;
}
mocktail::services::CurlHttpClient client;
mocktail::services::HttpRequest request;
request.url = argv[2];
request.timeout_ms = 3000;
const auto response = client.Get(request);
if (!response.transport_ok) {
std::cerr << response.error << '\n';
return 10;
}
std::cout << response.status_code << '\n' << response.body;
return response.status_code == 200 ? 0 : 11;
}
+75
View File
@@ -293,6 +293,36 @@ TEST(WebRtcJniAudioBridgeTest, CapturesTenMillisecondFramesThroughExactJni) {
[&probe] { return probe.callbacks > 0; }));
EXPECT_EQ(probe.last_size, probe.capacity);
}
// Exercise migration while the native side holds the original direct buffer.
void *original_buffer = nullptr;
{
std::lock_guard<std::mutex> lock(probe.mutex);
original_buffer = probe.buffer;
}
std::vector<SdlRecordingDevice> recording_devices;
ASSERT_TRUE(ListSdlRecordingDevices(&recording_devices).ok());
ASSERT_FALSE(recording_devices.empty());
std::string selected_name;
ASSERT_TRUE(
SwitchSdlRecordingDevice(recording_devices.front().id, &selected_name)
.ok());
EXPECT_EQ(selected_name, recording_devices.front().name);
ASSERT_TRUE(SwitchSdlRecordingDevice(0, &selected_name).ok());
EXPECT_EQ(selected_name, "default");
EXPECT_FALSE(SwitchSdlRecordingDevice(0xffffffffU, &selected_name).ok());
std::uint32_t selected_id = 999;
ASSERT_TRUE(
GetConfiguredSdlRecordingDevice(&selected_id, &selected_name).ok());
EXPECT_EQ(selected_id, 0U);
{
std::unique_lock<std::mutex> lock(probe.mutex);
const int before = probe.callbacks;
ASSERT_TRUE(probe.cv.wait_for(lock, std::chrono::seconds(2), [&] {
return probe.callbacks >= before + 3;
}));
EXPECT_EQ(probe.buffer, original_buffer);
EXPECT_EQ(probe.last_size, probe.capacity);
}
EXPECT_EQ(env->CallBooleanMethod(recorder, stop), JNI_TRUE);
EXPECT_TRUE(ShutdownWebRtcJniAudioBridge(&vm).ok());
@@ -604,6 +634,51 @@ TEST(WebRtcJniAudioBridgeTest,
env->GetMethodID(manager_class, "dispose", "()V"));
}
TEST(SdlAudioCaptureTest, MigrationPreservesPausedCapturesAndBuffers) {
ScopedSdlAudioShutdown shutdown;
ASSERT_TRUE(InitializeSdlAudioSubsystem().ok());
WebRtcCaptureProbe probe;
SdlAudioCaptureOptions options;
options.output_spec.channels = 1;
options.data_context = &probe;
options.data_callback = [](void *opaque, std::size_t) {
auto *p = static_cast<WebRtcCaptureProbe *>(opaque);
std::lock_guard<std::mutex> lock(p->mutex);
++p->callbacks;
p->cv.notify_all();
};
std::unique_ptr<AudioCapture> first, second;
ASSERT_TRUE(CreateSdlAudioCapture(options, &first).ok());
ASSERT_TRUE(CreateSdlAudioCapture(options, &second).ok());
void *first_buffer = first->buffer_data();
void *second_buffer = second->buffer_data();
std::string name;
ASSERT_TRUE(SwitchSdlRecordingDevice(0, &name).ok());
{
std::unique_lock<std::mutex> lock(probe.mutex);
EXPECT_FALSE(probe.cv.wait_for(lock, std::chrono::milliseconds(50),
[&] { return probe.callbacks != 0; }));
}
ASSERT_TRUE(first->Start().ok());
ASSERT_TRUE(second->Start().ok());
{
std::unique_lock<std::mutex> lock(probe.mutex);
ASSERT_TRUE(probe.cv.wait_for(lock, std::chrono::seconds(2),
[&] { return probe.callbacks >= 4; }));
}
ASSERT_TRUE(first->Stop().ok());
ASSERT_TRUE(second->Stop().ok());
ASSERT_TRUE(SwitchSdlRecordingDevice(0, &name).ok());
EXPECT_EQ(first->buffer_data(), first_buffer);
EXPECT_EQ(second->buffer_data(), second_buffer);
{
std::unique_lock<std::mutex> lock(probe.mutex);
const int count = probe.callbacks;
EXPECT_FALSE(probe.cv.wait_for(lock, std::chrono::milliseconds(50),
[&] { return probe.callbacks != count; }));
}
}
TEST(SdlAudioCaptureTest, ResolvesDefaultIdAndUnambiguousDeviceNames) {
const std::vector<SdlRecordingDevice> devices = {
{11, "USB Microphone"},
+296
View File
@@ -0,0 +1,296 @@
#include "mocktail/graphics/gles_text_overlay_compositor.h"
#include <GLES3/gl3.h>
#include <SDL3/SDL.h>
#include <gtest/gtest.h>
#include <array>
#include <cstring>
#include <memory>
#include <vector>
namespace mocktail::graphics {
namespace {
#define GL(name) \
reinterpret_cast<decltype(&gl##name)>(SDL_GL_GetProcAddress("gl" #name))
MocktailTextOverlayFrameInfo g_frame;
std::array<std::uint8_t, 16> g_pixels;
bool g_active = false, g_copy_succeeds = true;
int g_queries = 0, g_copies = 0;
bool MayPresent() { return g_active; }
bool Query(MocktailTextOverlayFrameInfo *frame) {
++g_queries;
*frame = g_frame;
return true;
}
bool Copy(std::uint64_t revision, void *data, std::size_t size) {
++g_copies;
if (!g_copy_succeeds || revision != g_frame.revision ||
size != g_pixels.size())
return false;
std::memcpy(data, g_pixels.data(), g_pixels.size());
return true;
}
TEST(GlesTextOverlayCompositorTest, InactiveNeedsNeitherWindowNorGlContext) {
g_active = false;
g_queries = 0;
GlesTextOverlayCompositor compositor({MayPresent, Query, Copy});
EXPECT_TRUE(compositor.Draw(nullptr));
EXPECT_EQ(g_queries, 0);
}
class GlesTextOverlayRenderingTest : public ::testing::Test {
protected:
void SetUp() override {
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
if (!SDL_Init(SDL_INIT_VIDEO))
GTEST_SKIP() << SDL_GetError();
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);
window = SDL_CreateWindow("GLES overlay test", 256, 192,
SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN);
if (window == nullptr)
GTEST_SKIP() << SDL_GetError();
context = SDL_GL_CreateContext(window);
if (context == nullptr)
GTEST_SKIP() << SDL_GetError();
ASSERT_TRUE(SDL_GL_MakeCurrent(window, context));
ASSERT_TRUE(SDL_GetWindowSizeInPixels(window, &pixel_width, &pixel_height));
ASSERT_EQ(pixel_width, 256);
ASSERT_EQ(pixel_height, 192);
g_frame = {};
g_frame.visible = 1;
g_frame.revision = 1;
g_frame.coordinate_width = 128;
g_frame.coordinate_height = 96;
g_frame.x = 16;
g_frame.y = 8;
g_frame.width = 2;
g_frame.height = 2;
g_frame.row_bytes = 8;
g_frame.rgba_bytes = 16;
g_pixels = {255, 0, 0, 128, 0, 255, 0, 255,
255, 255, 255, 0, 255, 255, 0, 255};
g_active = true;
g_copy_succeeds = true;
g_queries = 0;
g_copies = 0;
compositor = std::make_unique<GlesTextOverlayCompositor>(
GlesTextOverlaySource{MayPresent, Query, Copy});
Clear();
}
void TearDown() override {
compositor.reset();
if (context != nullptr)
SDL_GL_DestroyContext(context);
if (window != nullptr)
SDL_DestroyWindow(window);
SDL_Quit();
}
void Clear() {
GL(ClearColor)(0, 0, 1, 1);
GL(Clear)(GL_COLOR_BUFFER_BIT);
}
std::array<std::uint8_t, 4> Pixel(int x, int y_from_top) {
GLint previous = 0;
GL(GetIntegerv)(GL_READ_FRAMEBUFFER_BINDING, &previous);
GL(BindFramebuffer)(GL_READ_FRAMEBUFFER, 0);
std::array<std::uint8_t, 4> pixel{};
GL(ReadPixels)(x, pixel_height - 1 - y_from_top, 1, 1, GL_RGBA,
GL_UNSIGNED_BYTE, pixel.data());
GL(BindFramebuffer)(GL_READ_FRAMEBUFFER, previous);
return pixel;
}
std::vector<GLint> Snapshot() {
const GLenum parameters[] = {GL_CURRENT_PROGRAM,
GL_VERTEX_ARRAY_BINDING,
GL_DRAW_FRAMEBUFFER_BINDING,
GL_READ_FRAMEBUFFER_BINDING,
GL_DRAW_BUFFER0,
GL_ACTIVE_TEXTURE,
GL_TEXTURE_BINDING_2D,
GL_SAMPLER_BINDING,
GL_PIXEL_UNPACK_BUFFER_BINDING,
GL_UNPACK_ALIGNMENT,
GL_UNPACK_ROW_LENGTH,
GL_UNPACK_SKIP_ROWS,
GL_UNPACK_SKIP_PIXELS,
GL_BLEND_SRC_RGB,
GL_BLEND_DST_RGB,
GL_BLEND_SRC_ALPHA,
GL_BLEND_DST_ALPHA,
GL_BLEND_EQUATION_RGB,
GL_BLEND_EQUATION_ALPHA};
std::vector<GLint> values;
for (GLenum parameter : parameters) {
GLint value = 0;
GL(GetIntegerv)(parameter, &value);
values.push_back(value);
}
GLint active = 0;
GL(GetIntegerv)(GL_ACTIVE_TEXTURE, &active);
GL(ActiveTexture)(GL_TEXTURE0);
GLint texture = 0, sampler = 0;
GL(GetIntegerv)(GL_TEXTURE_BINDING_2D, &texture);
GL(GetIntegerv)(GL_SAMPLER_BINDING, &sampler);
values.push_back(texture);
values.push_back(sampler);
GL(ActiveTexture)(active);
GLint viewport[4]{};
GL(GetIntegerv)(GL_VIEWPORT, viewport);
values.insert(values.end(), viewport, viewport + 4);
GLboolean mask[4]{};
GL(GetBooleanv)(GL_COLOR_WRITEMASK, mask);
values.insert(values.end(), mask, mask + 4);
for (GLenum capability :
{GL_BLEND, GL_CULL_FACE, GL_DEPTH_TEST, GL_STENCIL_TEST,
GL_SCISSOR_TEST, GL_RASTERIZER_DISCARD, GL_SAMPLE_ALPHA_TO_COVERAGE,
GL_SAMPLE_COVERAGE}) {
values.push_back(GL(IsEnabled)(capability));
}
return values;
}
GLuint GameProgram() {
const char *vertex = "#version 300 es\nvoid main(){gl_Position=vec4(0);}";
const char *fragment = "#version 300 es\nprecision mediump float;"
"out vec4 c;void main(){c=vec4(1);}";
GLuint shaders[2] = {GL(CreateShader)(GL_VERTEX_SHADER),
GL(CreateShader)(GL_FRAGMENT_SHADER)};
GL(ShaderSource)(shaders[0], 1, &vertex, nullptr);
GL(ShaderSource)(shaders[1], 1, &fragment, nullptr);
GLuint program = GL(CreateProgram)();
for (GLuint shader : shaders) {
GL(CompileShader)(shader);
GL(AttachShader)(program, shader);
}
GL(LinkProgram)(program);
GLint linked = GL_FALSE;
GL(GetProgramiv)(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE);
for (GLuint shader : shaders)
GL(DeleteShader)(shader);
return program;
}
SDL_Window *window = nullptr;
SDL_GLContext context = nullptr;
int pixel_width = 0, pixel_height = 0;
std::unique_ptr<GlesTextOverlayCompositor> compositor;
};
TEST_F(GlesTextOverlayRenderingTest,
DrawsAlphaAtLogicalCoordinatesRestoringState) {
GLuint textures[2]{}, sampler = 0, vao = 0, buffer = 0, framebuffers[2]{};
GL(GenTextures)(2, textures);
GL(ActiveTexture)(GL_TEXTURE0);
GL(BindTexture)(GL_TEXTURE_2D, textures[0]);
GL(GenSamplers)(1, &sampler);
GL(BindSampler)(0, sampler);
GL(ActiveTexture)(GL_TEXTURE3);
GL(BindTexture)(GL_TEXTURE_2D, textures[1]);
GL(GenVertexArrays)(1, &vao);
GL(BindVertexArray)(vao);
GL(GenBuffers)(1, &buffer);
GL(BindBuffer)(GL_PIXEL_UNPACK_BUFFER, buffer);
GL(BufferData)(GL_PIXEL_UNPACK_BUFFER, 128, nullptr, GL_STATIC_DRAW);
GL(PixelStorei)(GL_UNPACK_ALIGNMENT, 8);
GL(PixelStorei)(GL_UNPACK_ROW_LENGTH, 31);
GL(PixelStorei)(GL_UNPACK_SKIP_ROWS, 3);
GL(PixelStorei)(GL_UNPACK_SKIP_PIXELS, 4);
const GLenum no_draw = GL_NONE;
GL(DrawBuffers)(1, &no_draw);
GL(GenFramebuffers)(2, framebuffers);
GL(BindFramebuffer)(GL_DRAW_FRAMEBUFFER, framebuffers[0]);
GL(BindFramebuffer)(GL_READ_FRAMEBUFFER, framebuffers[1]);
GL(Viewport)(7, 9, 33, 55);
GL(ColorMask)(GL_FALSE, GL_TRUE, GL_FALSE, GL_FALSE);
GL(BlendFuncSeparate)(GL_ONE, GL_ZERO, GL_DST_ALPHA, GL_SRC_ALPHA);
GL(BlendEquationSeparate)(GL_FUNC_SUBTRACT, GL_FUNC_REVERSE_SUBTRACT);
GL(Disable)(GL_BLEND);
for (GLenum capability : {GL_CULL_FACE, GL_DEPTH_TEST, GL_STENCIL_TEST,
GL_SCISSOR_TEST, GL_RASTERIZER_DISCARD,
GL_SAMPLE_ALPHA_TO_COVERAGE, GL_SAMPLE_COVERAGE}) {
GL(Enable)(capability);
}
GL(Scissor)(0, 0, 0, 0);
GL(UseProgram)(GameProgram());
ASSERT_EQ(GL(GetError)(), GLenum{GL_NO_ERROR});
const auto before = Snapshot();
ASSERT_TRUE(compositor->Draw(window));
EXPECT_EQ(Snapshot(), before);
EXPECT_EQ(GL(GetError)(), GLenum{GL_NO_ERROR});
const auto red = Pixel(32, 16);
EXPECT_NEAR(red[0], 128, 1);
EXPECT_EQ(red[1], 0);
EXPECT_NEAR(red[2], 127, 1);
EXPECT_EQ(red[3], 255);
EXPECT_EQ(Pixel(35, 16), (std::array<std::uint8_t, 4>{0, 255, 0, 255}));
EXPECT_EQ(Pixel(32, 19), (std::array<std::uint8_t, 4>{0, 0, 255, 255}));
EXPECT_EQ(Pixel(35, 19), (std::array<std::uint8_t, 4>{255, 255, 0, 255}));
EXPECT_EQ(Pixel(5, 5), (std::array<std::uint8_t, 4>{0, 0, 255, 255}));
EXPECT_EQ(GL(GetError)(), GLenum{GL_NO_ERROR});
}
TEST_F(GlesTextOverlayRenderingTest,
CachesRevisionsAndDoesNotDrawStaleOrHiddenText) {
ASSERT_TRUE(compositor->Draw(window));
ASSERT_EQ(g_copies, 1);
ASSERT_TRUE(compositor->Draw(window));
EXPECT_EQ(g_copies, 1);
Clear();
++g_frame.revision;
g_copy_succeeds = false;
const auto before = Snapshot();
EXPECT_FALSE(compositor->Draw(window));
EXPECT_EQ(Snapshot(), before);
EXPECT_EQ(Pixel(35, 16), (std::array<std::uint8_t, 4>{0, 0, 255, 255}));
g_copy_succeeds = true;
g_pixels[4] = 255;
g_pixels[5] = 0;
ASSERT_TRUE(compositor->Draw(window));
EXPECT_EQ(Pixel(35, 16), (std::array<std::uint8_t, 4>{255, 0, 0, 255}));
Clear();
g_frame.visible = 0;
EXPECT_TRUE(compositor->Draw(window));
EXPECT_EQ(Pixel(35, 16), (std::array<std::uint8_t, 4>{0, 0, 255, 255}));
EXPECT_EQ(GL(GetError)(), GLenum{GL_NO_ERROR});
}
TEST_F(GlesTextOverlayRenderingTest,
RejectsMalformedFramesBeforeCopyingPixels) {
const auto before = Snapshot();
g_frame.row_bytes = 7;
EXPECT_FALSE(compositor->Draw(window));
g_frame.row_bytes = 8;
g_frame.rgba_bytes = 15;
EXPECT_FALSE(compositor->Draw(window));
g_frame.rgba_bytes = 16;
g_frame.coordinate_width = 0;
EXPECT_FALSE(compositor->Draw(window));
g_frame.coordinate_width = 128;
g_frame.width = UINT32_MAX;
g_frame.height = UINT32_MAX;
EXPECT_FALSE(compositor->Draw(window));
EXPECT_EQ(g_copies, 0);
EXPECT_EQ(Snapshot(), before);
EXPECT_EQ(GL(GetError)(), GLenum{GL_NO_ERROR});
}
#undef GL
} // namespace
} // namespace mocktail::graphics
+140
View File
@@ -13,6 +13,10 @@
#include <thread>
#include <vector>
#if defined(__x86_64__)
#include <xmmintrin.h>
#endif
namespace jnivm {
namespace {
@@ -376,6 +380,142 @@ TEST_F(JniVmTest, JniEnvPointerIsNotNull) {
EXPECT_NE(vm_->GetJNIEnv(), nullptr);
}
#if defined(__x86_64__)
constexpr unsigned int kAudioFpMode = (1U << 15) | (1U << 6);
class ScopedMxcsr final {
public:
ScopedMxcsr() : saved_(_mm_getcsr()) {}
~ScopedMxcsr() { _mm_setcsr(saved_); }
private:
unsigned int saved_;
};
// Volatile input ensures the multiply executes in SSE with the current MXCSR,
// rather than being constant-folded using the compiler's default FP model.
float MultiplyAudioSample(volatile float* sample, float gain) {
return _mm_cvtss_f32(_mm_mul_ss(_mm_set_ss(*sample), _mm_set_ss(gain)));
}
TEST_F(JniVmTest, FmodMixerFlushesSubnormalAudioWithoutChangingParentThread) {
ScopedMxcsr restore;
_mm_setcsr(0x1f80); // IEEE defaults; FTZ/DAZ disabled.
JavaVM* java_vm = vm_->GetJavaVM();
std::thread mixer([java_vm]() {
ScopedMxcsr restore_worker;
volatile float tail = 0x1p-140f;
volatile float quiet_normal = 0x1p-126f;
volatile float audible = 0.25f;
EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f);
EXPECT_GT(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f);
const unsigned int before = _mm_getcsr();
char name[] = "FMOD mixer thread";
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
void* env = nullptr;
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode);
EXPECT_EQ(MultiplyAudioSample(&tail, 1.0f), 0.0f);
EXPECT_EQ(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f);
EXPECT_EQ(MultiplyAudioSample(&audible, 0.5f), 0.125f);
// Reattaching with no name must not lose the mixer's saved FP state.
ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK);
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode);
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, before & kAudioFpMode);
EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f);
});
mixer.join();
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
}
TEST_F(JniVmTest, FmodDaemonThreadsRestoreExistingModesAndOtherControls) {
ScopedMxcsr restore;
JavaVM* java_vm = vm_->GetJavaVM();
for (const char* name : {"FMOD mixer thread", "FMOD feeder thread",
"FMOD Convolution thread", "FMOD Worker Thread"}) {
for (const unsigned int mode : {0U, 1U << 6, 1U << 15, kAudioFpMode}) {
_mm_setcsr(0x1f80 | mode | _MM_ROUND_DOWN);
const unsigned int before = _mm_getcsr();
JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast<char*>(name), nullptr};
void* env = nullptr;
ASSERT_EQ(java_vm->AttachCurrentThreadAsDaemon(&env, &args), JNI_OK);
EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode);
// Guest changes to other controls/status must survive detach.
_mm_setcsr((_mm_getcsr() & ~_MM_ROUND_MASK) | _MM_ROUND_UP | 0x20U);
const unsigned int after_guest = _mm_getcsr();
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
EXPECT_EQ(_mm_getcsr(), (after_guest & ~kAudioFpMode) | mode);
}
}
}
TEST_F(JniVmTest, OrdinaryJniThreadsKeepIeeeFloatingPointBehavior) {
ScopedMxcsr restore;
_mm_setcsr(0x1f80);
JavaVM* java_vm = vm_->GetJavaVM();
for (const char* name : {"Main", "RBX Worker", "FMOD mixer thread extra",
"FMOD file thread", "", static_cast<const char*>(nullptr)}) {
JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast<char*>(name), nullptr};
void* env = nullptr;
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
// JNI ignores new names when a thread is already attached.
args.name = const_cast<char*>("FMOD mixer thread");
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
}
void* env = nullptr;
ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK);
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
EXPECT_EQ(java_vm->DetachCurrentThread(), JNI_OK);
}
TEST_F(JniVmTest, RejectedFmodAttachDoesNotChangeFloatingPointMode) {
ScopedMxcsr restore;
_mm_setcsr(0x1f80);
JavaVM* java_vm = vm_->GetJavaVM();
char name[] = "FMOD mixer thread";
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
EXPECT_EQ(java_vm->AttachCurrentThread(nullptr, &args), JNI_EINVAL);
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
JavaVM foreign = {java_vm->functions};
void* env = nullptr;
EXPECT_EQ(foreign.AttachCurrentThread(&env, &args), JNI_EINVAL);
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
}
TEST_F(JniVmTest, ChangingVmRestoresFmodFloatingPointMode) {
ScopedMxcsr restore;
_mm_setcsr(0x1f80);
JavaVM* java_vm = vm_->GetJavaVM();
char name[] = "FMOD mixer thread";
JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr};
void* env = nullptr;
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
{
VM temporary;
// Merely constructing/destroying another VM must not change this thread.
}
EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode);
{
VM other;
ASSERT_EQ(other.GetJavaVM()->AttachCurrentThread(&env, nullptr), JNI_OK);
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
}
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
{
VM other;
ASSERT_NE(other.GetJNIEnv(), nullptr);
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
}
ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK);
vm_.reset();
EXPECT_EQ(_mm_getcsr(), 0x1f80U);
}
#endif
TEST_F(JniVmTest, DiscardedTemporaryVmKeepsOriginalUsable) {
JavaVM* java_vm = vm_->GetJavaVM();
JNIEnv* original_env = vm_->GetJNIEnv();
+9
View File
@@ -141,6 +141,7 @@ TEST_F(LibcShimTest, ValidCaBundleOverrideMapsAndroidAliases) {
static_cast<ssize_t>(sizeof(kTestCertificate) - 1));
ASSERT_EQ(close(fd), 0);
ASSERT_EQ(setenv("MOCKTAIL_CA_BUNDLE", temp_template, 1), 0);
ASSERT_EQ(setenv("MOCKTAIL_ASSET_PATH", "/payload/assets/content/", 1), 0);
RegisterPathMapping("/data/user/0/com.roblox.client", "/host/sober");
const HostCaBundleResolution resolution =
@@ -157,6 +158,14 @@ TEST_F(LibcShimTest, ValidCaBundleOverrideMapsAndroidAliases) {
temp_template);
EXPECT_EQ(TranslatePath("/data/user/0/com.roblox.client/files/other"),
"/host/sober/files/other");
for (const char* alias : {
"ssl/cacert.pem", "content/ssl/cacert.pem",
"rbx_bin/assets/ssl/cacert.pem", "rbx_bin/assets/content/ssl/cacert.pem",
"/payload/assets/ssl/cacert.pem", "/payload/assets/content/ssl/cacert.pem",
"/data/user/0/com.roblox.client/files/exe/ssl/cacert.pem",
"/data/data/com.roblox.client/files/exe/ssl/cacert.pem"}) {
EXPECT_EQ(TranslatePath(alias), temp_template);
}
FILE* mapped_file = mocktail_fopen(
"/data/user/0/com.roblox.client/files/exe/cacert.pem", "r");
ASSERT_NE(mapped_file, nullptr);
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more