mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 23:00:46 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5716ab09f | ||
|
|
854969457f | ||
|
|
b54c896498 | ||
|
|
1cd7b7db8f | ||
|
|
cdbf9ac1eb | ||
|
|
f383fbbff4 | ||
|
|
d76d75b7f8 | ||
|
|
537828acc2 | ||
|
|
5be2f68a55 | ||
|
|
f7e519bae1 | ||
|
|
ad8fbfcf65 | ||
|
|
96e1966d16 | ||
|
|
becaee71c0 | ||
|
|
ab4944a907 | ||
|
|
017f79398d | ||
|
|
fc120e8285 | ||
|
|
117b9b2016 | ||
|
|
1376ab2474 | ||
|
|
fdce53421f | ||
|
|
770ed086d5 | ||
|
|
c48db09f13 | ||
|
|
0548ebd253 |
No files matched your search
@@ -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 .
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
'minizip'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ pkgbase = mocktail-git
|
||||
depends = libelf
|
||||
depends = libglvnd
|
||||
depends = libplacebo
|
||||
depends = libpng
|
||||
depends = libsoup3
|
||||
depends = libutf8proc
|
||||
depends = libyaml
|
||||
|
||||
@@ -20,6 +20,7 @@ depends=(
|
||||
'libelf'
|
||||
'libglvnd'
|
||||
'libplacebo'
|
||||
'libpng'
|
||||
'libsoup3'
|
||||
'libutf8proc'
|
||||
'libyaml'
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Whitespace-only changes.
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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}" "$@"
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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);
|
||||
}
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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"
|
||||
@@ -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 ≷
|
||||
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
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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, ¶meters) == 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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, ¶meters), 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, ¶meters), EPERM);
|
||||
EXPECT_EQ(errno, EDOM);
|
||||
EXPECT_EQ(mocktail_bionic_sched_setscheduler(0, policy, ¶meters), -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, ¶meters), EAGAIN);
|
||||
EXPECT_EQ(mocktail_bionic_sched_setscheduler(0, SCHED_OTHER, ¶meters), -1);
|
||||
EXPECT_EQ(errno, ESRCH);
|
||||
EXPECT_EQ(g_host_scheduler_calls.load(), before + 2);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in new issue
Block a user