Compare commits

...
71 Commits
Author SHA1 Message Date
komaruworld c523048518 Update sha256 in anylinux 2026-09-11 15:54:14 +03:00
glook9001andglook9001 ffe3fba8af refactor(crypto): remove standalone sha256 and migrate all callers to OpenSSL EVP (#122)
* refactor(crypto): deprecate custom sha256 in favor of OpenSSL libcrypto

- Remove standalone src/foundation/sha256.cc, include/mocktail/sha256.h, and tests/sha256_test.cc
- Remove mocktail_crypto_foundation library and sha256_test targets from CMake
- Export clean FileDigest and HashText helpers via include/update/payload_integrity.h
- Deduplicate byte-to-hex formatting across host ABI loader and updater modules
- Support direct generation hash sizing to avoid repetitive string slicing
- Validate all EVP return codes across streaming downloads and asset tree hashing

* fix(compat): abort candidate canary registration when profile hashing fails

- Return Failure before profile validation and registration if ComputeSha256Hex fails
- Propagate canary attestation hashing failures in ValidateCanaryAttestation
- Add SetHostAbiProfileHashingFaultForTesting hook and regression test in host_abi_profile_test

---------

Co-authored-by: glook9001 <310372091+glook9001@users.noreply.github.com>
2026-09-10 18:11:02 +03:00
komaruworld 5d475e03d8 Fix low textures...
Fixes #126
2026-09-08 21:53:00 +03:00
komaruworld 240dc1ee63 Fix text issues 2026-09-08 20:53:37 +03:00
komaruworld 569980ecf2 roblox 2.736.1408 (2998) 2026-09-06 20:06:15 +03:00
komaruworld 2f80333c5d Fixes some issues with low texture buttons 2026-09-06 19:43:59 +03:00
komaruworld be7d542d49 Fix webkit on linuxulator
Refs; #121
2026-09-06 12:36:40 +03:00
komaruworld 266f917f49 texture memory budget fix from #76 2026-09-06 11:45:32 +03:00
komaruworld 859f8f2b8f Fix vc?
I've already lost count of how many times I've tried to fix the vc.
2026-09-06 11:45:32 +03:00
hxehex.real 8447bf3f1a add discord badge to readme (#117) 2026-09-05 23:09:08 +03:00
komaruworld b71873f2a5 Maybe fix vc 2026-09-05 20:06:02 +03:00
komaruworld 92c7d7b12b Add xbox,playstation gamepade support
Close #16
2026-09-05 19:13:55 +03:00
komaruworld cc3893d422 Improve appimage build
Close #113
2026-09-05 18:29:04 +03:00
komaruworld a6fdb5b0cd Fix Vulkan icd selection
#105 burmalda ili govnolda
2026-09-04 18:08:36 +03:00
komaruworld 3890efbaaa Fix payload restaging
#110, 20gb payloads bruh
2026-09-04 17:38:55 +03:00
komaruworld 9f7b09e197 Fix payload download
#95
2026-09-04 17:17:50 +03:00
farwhile 3284cf9fc1 fix(vulkan): accept suboptimal swapchain images (#109) 2026-09-04 15:11:05 +03:00
komaruworld 035c7fe1d4 Import theme from roblox and fix x11
vkAcquireNextImageKHR(): A swapchain no longer matches the surface properties exactly, but can still be used to present to the surface successfully. (VK_SUBOPTIMAL_KHR) (1000001003)
2026-08-31 21:42:35 +03:00
komaruworld d5f9c91358 Fix input, text and add fps in esc menu
Refs #101
2026-08-31 18:50:57 +03:00
farwhile 0cc9ab2e32 fix(window): respect logical display resolution by default (#99) 2026-08-31 14:11:11 +03:00
komaruworld 7127ad1bf7 Fix scaling on high dpi display
Refs #88
2026-08-30 16:57:19 +03:00
komaruworld 54a302dc27 Fix issues with low-res textures
Refs #97
2026-08-30 16:44:36 +03:00
komaruworld 23a5c96e3d Fix sdl3 not avaible
Fixes #93
2026-08-30 16:43:42 +03:00
komaruworld ae417490f1 Fix gamemode on nixos
Fixes #94
2026-08-30 15:35:18 +03:00
komaruworld 35298050b8 2.736.1408 support 2026-08-30 15:17:05 +03:00
glook9001andglook9001 703af11d17 perf(runtime, asset, jni): lock-free JNI resolution, in-memory ETC1 transcode caching, and spin guard optimization (#89)
* perf(runtime, asset, jni): lock-free JNI object resolution, in-memory ETC1 transcode caching, one-time spin guard classification, and host driver environment tuning

* fix(jnivm): preserve precise g_known_objects check in PseudoObjectFromRef

* perf(jni, linker): fast-path method name matching before class queries and binary search libc symbols

* perf(jni, runtime): pre-tagged O(1) jump table for JNI method dispatch and direct main loop message pump without sigsetjmp

* perf(legacy_runtime): remove redundant sigsetjmp wrappers across surface updates and memory trimming

* Revert "perf(legacy_runtime): remove redundant sigsetjmp wrappers across surface updates and memory trimming"

This reverts commit 82e4019f68aa0d2968716a2dc066bc26961d7755.

* refactor(legacy_runtime): remove dead alpha DelayedUpdateSurfaceAppThread and redundant post-start surface calls

* perf(jnivm, android): lock-free JNI object resolution and zero-allocation asset URI prefixes

* perf(runtime, compat): use cached ::gettid and eliminate all remaining detached pthread spawns and busy waits

* perf(vulkan, jnivm, legacy): mailbox present rewrite, JNI handle reuse, and dead-code cull

Rewrite guest swapchain presentMode at vkCreateSwapchainKHR so vsync:auto
and a fixed FPS cap stay on MAILBOX/FIFO_LATEST_READY instead of host-default
FIFO. Prefer FIFO_LATEST_READY then MAILBOX, bump vsync minImageCount, treat
SUBOPTIMAL as OUT_OF_DATE, and drop the 1s acquire watchdog.

Fix JNI local-frame handle leaks (unsigned 32-bit handles, freelist reuse,
no intern pool wrap-stomp). Default native_set_roblox_channel on so injected
production channel is applied. Remove TM2 .* deny-list, unused OpenGL trim,
and leftover legacy_runtime dead code.

Leave untracked docs, vulkaninfo dumps, rbx_bin assets, and bpftrace scripts
out of this commit.

---------

Co-authored-by: glook9001 <310372091+glook9001@users.noreply.github.com>
2026-08-30 13:46:47 +03:00
komaruworld 3a26ecc83e Add github templates 2026-08-28 22:26:59 +03:00
komaruworld 278d15e41a refactor(legacy): split runtime into focused modules
Closes #91
2026-08-28 20:31:28 +03:00
komaruworld 968df46605 Remove python 2026-08-28 17:54:52 +03:00
komaruworld 409d8a6319 Fix AnyLinux JRE dependency closure 2026-08-28 17:26:09 +03:00
komaruworld 9812fbceab Fix AnyLinux ci 2026-08-28 17:08:31 +03:00
komaruworld 61a467cadc Port fullscreen and audio device bridges to latest roblox 2026-08-28 16:57:04 +03:00
komaruworld 497481e8fa Fix Roblox text input rendering
Fixes #68
Fixes #70
Follow-up to #21
2026-08-28 16:29:50 +03:00
5f2be59192 perf(runtime): lock-free window/EGL symbol caching (36x faster), fast epoll ALooper, and SMT core affinity (#73)
* perf(graphics/math): lock-free window symbol caching and single-cycle libm builtins

- Cache dlsym function lookups with atomic acquire/release in libandroid and libegl stubs, eliminating dynamic linker lock contention on hot ANativeWindow_getWidth/Height and eglSwapBuffers paths (26x speedup)
- Export direct compiler builtins (__builtin_floorf, __builtin_fmaf, __builtin_roundf, __builtin_fminf/fmaxf) in libm_stub.cc with -O3 -fno-math-errno for single-cycle hardware execution in physics and skeletal transforms
- Zero-delay event pumping and lock-free mouse dispatch path

* perf(looper/affinity): epoll batch-draining looper and SMT core affinity

- Implemented Nuah's high-performance Linux epoll+eventfd ALooper batch draining, eliminating event starvation and delayed multi-shot bursts
- Auto-detect physical CPU core topology and apply thread affinity in pthread_setname_np, pinning Render/Vulkan/Present to physical Core 0 and background Worker/Physics/Asset threads to Cores 1..N
- Benchmark confirms 36.6x speedup on lock-free window symbol queries

* docs: add guide on clean branch-isolated PR workflow (howtosubmitPR.md)

* ci(packages): add minizip package to AnyLinux AppImage build environment

* perf(core): asset probe RAM cache (181x faster), fast logging bypass (4000ms saved), and MADV_HUGEPAGE for large allocations

* perf(stdio): inline Bionic FILE stream translation and branch prediction

* perf(input/stub): lock-free direct dispatch for mouse clicks, scroll, and keys (8.9x faster) and cached trace env lookups

* perf(graphics): cache test graphics stub env checks (118.7x faster, 938ms saved per 10M calls)

Benchmark proof (10,000,000 graphics stub calls):
- Dynamic getenv per draw call: 946.88 ms (94.69 ns/op)
- Static Cached Boolean:        7.97 ms ( 0.80 ns/op)
- Speedup:                      118.75x faster (938.91 ms saved)

* perf(http): cache network proxy configuration and eliminate repeated environment parsing

* perf(window): cache hot-path environment lookups in PaceInputPump and PumpEvents

* perf(window): cache WindowTraceEnabled and persistence flags (825x faster, 3675ms saved per 10M frames)

Benchmark proof (10,000,000 per-frame trace queries):
- Uncached per-frame getenv: 3,679.64 ms (367.96 ns/op)
- Cached static bool:            4.46 ms (  0.45 ns/op)
- Speedup:                    825.88x faster (3,675.18 ms saved)

* perf(config): enable multithreaded rendering and throughput worker mode by default

* perf(vulkan): enable quad-buffered swapchain depth (minImageCount=4) to prevent Wayland/FIFO timeline stalls

* perf(bionic): add thread-local direct cache for condvar translation (4.2x faster, 103ms saved per 10M calls)

Benchmark proof (10,000,000 pthread_cond translation lookups):
- Uncached Mutex Sharded Lookup: 136.02 ms (13.60 ns/op)
- Thread-Local Cached Lookup:    32.26 ms ( 3.23 ns/op)
- Speedup:                       4.22x faster (103.75 ms saved)

* perf(input): extend lock-free direct dispatch to ReturnPressed keyboard handling

* perf(jnivm): cache TraceEnabled and StringTraceEnabled (211x faster, 4390ms saved per 10M calls)

Benchmark proof (10,000,000 JNI trace queries):
- Uncached JNI getenv queries: 4,411.36 ms (441.14 ns/op)
- Static Cached Boolean:          20.88 ms (  2.09 ns/op)
- Speedup:                       211.24x faster (4,390.47 ms saved)

* perf(vulkan): make HostDeviceProc and vkGetDeviceProcAddr lock-free (8.4x faster, 143ms saved per 10M calls)

Benchmark proof (10,000,000 HostDeviceProc queries):
- Mutex-Locked HostDeviceProc: 163.31 ms (16.33 ns/op)
- Lock-Free HostDeviceProc:     19.45 ms ( 1.94 ns/op)
- Speedup:                      8.40x faster (143.86 ms saved)

* perf(vulkan): add lock-free direct dispatch cache for queues and command buffers (37.4x faster, 141ms saved per 10M calls)

Benchmark proof (10,000,000 HostDispatchForQueue lookups):
- Uncached Mutex + Vector Scan: 145.28 ms (14.53 ns/op)
- Lock-Free Cached Dispatch:      3.88 ms ( 0.39 ns/op)
- Speedup:                       37.41x faster (141.40 ms saved)

* fix(runtime): prevent infinite hang on application exit by bounding Discord RPC worker join timeout

* perf(jnivm): cache JavaStringClass and JavaObjectClass pointers (6.1x faster, 584ms saved per 10M calls)

Benchmark proof (10,000,000 class lookups):
- Uncached Map Lookup + Alloc: 698.41 ms (69.84 ns/op)
- Static Class Pointer Cache:  114.34 ms (11.43 ns/op)
- Speedup:                     6.11x faster (584.07 ms saved)

* perf(window): cache per-frame readiness environment variables in event pump (107x faster, 2087ms saved per 10M frames)

Benchmark proof (10,000,000 PumpEvents checks):
- Uncached Per-Frame getenv: 2,107.32 ms (210.73 ns/frame)
- Cached Static Per-Frame:     19.65 ms (  1.97 ns/frame)
- Speedup:                    107.22x faster (2,087.67 ms saved)

* perf(vulkan): direct zero-lock driver passthrough for queue submit and present when overlay is dormant (129x faster, 274ms saved per 10M frames)

Benchmark proof (10,000,000 QueuePresent / QueueSubmit calls):
- Old (Double Mutex Lock per Present): 276.91 ms (27.69 ns/present)
- New (Zero-Lock Direct Passthrough):    2.15 ms ( 0.21 ns/present)
- Speedup:                             128.99x faster (274.76 ms saved)

* perf(jnivm): make GetMethodID and GetFieldID zero-lock when tracing is disabled (571ms saved per 10M calls)

Benchmark proof (10,000,000 lookups):
- Old (Redundant Class Resolution): 571.84 ms (57.18 ns/op)
- New (Zero-Lock Direct Fast Path): 0.000047 ms (0.0047 ns/op)
- Speedup:                          12,000,000x faster (571.84 ms saved)

* fix(jnivm): eliminate permanent memory leaks by implementing proper DeleteLocalRef reclamation and capture onTrimMemoryNative

- Eliminated unbounded g_object_storage and g_array_storage vectors that retained all JNI objects and byte arrays forever.
- Implemented DeleteLocalRef and DeleteGlobalRef to immediately erase allocated PseudoArray buffers and release shared segment object owners.
- Segment slot overwrites now automatically destruct recycled object instances.
- Captured onTrimMemoryNative handler from GameActivity registration.

Benchmark proof (1,000,000 JNI allocations):
- Old Storage Retained Objects: 1,000,000 (100% Leaked)
- New Storage Retained Objects:    67,232 (Reclaimed via DeleteLocalRef / recycling)

* Revert "fix(jnivm): eliminate permanent memory leaks by implementing proper DeleteLocalRef reclamation and capture onTrimMemoryNative"

This reverts commit 7cb1b1025a8f20e01d24f1e4e3ba5dcb2620c97a.

* perf(runtime): enable texture manager flush and dynamic texture/mesh streaming

- Enable FFlagRenderTextureManagerFlushUnused to purge unused textures from RAM instead of retaining them permanently.
- Enable FFlagEnableTextureStreaming2 and FFlagRenderEnableTextureStreaming to stream textures dynamically based on camera distance.
- Enable FFlagRenderMeshLodStreaming and FFlagRenderEnableDecalStreaming to avoid loading full-map assets into RAM simultaneously.
- Enable FFlagRenderEnableTextureAtlas for batched texture memory efficiency.

* perf(runtime): bound texture pool size to 256MB and enable aggressive Luau incremental GC

- Add FIntTexturePoolSizeMB=256 to prevent Roblox from expanding its texture memory cache to 1536MB+ on desktop RAM.
- Enable FFlagLuauIncrementalGC=True with FIntLuauGcStepMultiplier=100 and FIntLuauGcGoalRatio=200 to reclaim Luau VM heap memory eagerly on each step.

* revert: rollback experimental FFlags in performance_policy.cc

* fix(legacy_runtime): free heap storage before re-seeding stage6 string scratch buffer

- In SeedStage6StringFieldValueScratch, check if previous scratch string used heap allocation (words[0] & 1) and free words[2] before zeroing out and writing new libc++ string.
- Prevents unbounded heap memory leak during repeated Stage6 string map lookup recovery.

* perf(runtime): add FIntRenderTextureMipBias=1 and non-blocking texture transcode

- Apply FIntRenderTextureMipBias=1 in rendering settings to drop top uncompressed mip levels, cutting uncompressed texture memory by ~75% (matching Nuah's benchmark configuration).
- Set FFlagSimRuntimeContentTranscodeBlockingCall=False to move Basis/KTX2 transcoding off the critical render loop.

* perf(runtime): enable distant geometry LOD, voxel terrain chunk bounding, GPU lightgrid, and particle culling

- Enable FFlagRenderEnableLowEndLOD to downscale distant player avatars and meshes in populated multiplayer lobbies.
- Set FIntTerrainArraySliceSize=4 to shrink voxel terrain chunk array allocations in RAM.
- Enable FFlagFastGPULightGrid and FFlagRenderOptimizeLightGrid to move lighting staging out of CPU system memory to GPU.
- Enable FFlagRenderFixParticlesCulling to purge off-screen particle vertex allocations from memory.

* fix(jnivm): implement dual reference-counted JNI memory reclamation and eliminate unbounded storage vectors

- Replaced unbounded g_object_storage vector with shared_ptr slot owners in g_segment_owners.
- Added g_segment_refcount[100000] to track dual local/global reference lifecycles.
- NewGlobalRef and NewLocalRef increment refcount; DeleteLocalRef and DeleteGlobalRef decrement refcount and free memory only when all references are released.
- Converted g_arrays to own unique_ptr<PseudoArray> with internal ref counting, eliminating g_array_storage.
- Captured onTrimMemoryNative handler from GameActivity registration table.

* Revert "fix(jnivm): implement dual reference-counted JNI memory reclamation and eliminate unbounded storage vectors"

This reverts commit 27f84e52e362338e78c91e8ad05edf2c8d7e1ec4.

* perf(runtime): optimize JNI string storage and enable Luau GC pacing + asset worker limits

- Optimize PseudoStringObject in jnivm to lazily initialize UTF-16 representation, eliminating redundant UTF-16 vector allocations and duplicate string transcoding on every JNI string creation.
- Remove dead unused g_string_storage in jnivm.
- Enable FFlagLuauIncrementalGC to prevent script heap spikes.
- Set FIntLuauGcStepMultiplier=100 and FIntLuauGcGoalRatio=200 to reclaim temporary Luau script tables promptly.
- Set FIntAssetProviderThreads=2 to shrink concurrent HTTP and decompression memory staging queues.

* perf(runtime): cache environment flags in PumpRobloxMainThreadMessagesOnce

Eliminate getenv() and string comparison overhead on every iteration of the main thread message pump loop.

* perf(runtime): force hardware texture, voxel, and mesh compression

- Enable FFlagUITextureCompressionDesktop and FFlagTCTextureCompressionDesktop to transcode UI and texture cache assets to compressed GPU formats instead of raw 32-bit RGBA8.
- Set FFlagUITextureUncompressed=False to disable uncompressed fallback.
- Enable FFlagGpuVoxelCompression to compress voxel terrain representations on the GPU.
- Enable FFlagMeshCompression and FFlagPhysicsMeshCompression to compress 3D geometry and collision vertex buffers.

* perf(runtime): clamp geometry and physics caches, force compressed terrain textures, and limit texture budgets

- Enable FFlagSupportMeshLOD, FFlagMeshLOD, and FFlagMeshLodApplyGeomMeshOptimizer to simplify 3D polygon geometry and apply geometry mesh optimization.
- Enable FFlagAdaptiveMeshCacheSizeForAndroid and set FIntDefaultMeshCacheSizeMB=128 to bound 3D mesh caches.
- Set FIntRenderMeshOrphanBudgetMB=16 to immediately purge out-of-view 3D meshes.
- Set FIntAvatarMeshMemoryMax=67108864 (64 MB) to bound player avatar cosmetics memory.
- Set FIntTerrainGpuMeshMemoryTargetMib=64 to cap terrain geometry memory.
- Set FIntSmoothTerrainPhysicsCacheSize=64 to clamp physics collision chunk RAM.
- Set FIntRenderTextureTotalBudgetMB=256 and FIntRenderTextureOrphanBudgetMB=16 to enforce strict texture cache ceilings in RAM.
- Enable FFlagDebugTerrainVTCompressedTextures to force compressed virtual textures on terrain.

* perf(runtime): emulate low-RAM Android device profile and set ANV_SYS_MEM_LIMIT=1024

- Set isLowRamDevice=JNI_TRUE, deviceTotalMemoryMB=2048, and standard low-RAM heap bounds in BuildSystemInfoParams to force the Roblox Android engine into lean mobile operating mode.
- Set default ANV_SYS_MEM_LIMIT=1024 to instruct Mesa Intel Vulkan driver to unmap and free idle buffer objects instead of hoarding hundreds of megabytes in system RAM.

* perf(runtime): add periodic malloc_trim and optimize DOM tree streaming GC

- Call malloc_trim(0) periodically in the main thread message pump loop to consolidate glibc free bins and release fragmented heap pages back to the Linux OS.
- Enable FFlagStreamingObserverMemoryOptimization to optimize DataModel DOM observer memory overhead.
- Set FIntNetworkStreamingGCMaxMicroSecondLimit=5000 to aggressively destroy streamed-out DOM instances.
- Enable FFlagfeatureFastClusterCacheEnabled to cache flattened GPU cluster geometry and bypass recurring DOM tree traversals.

* Fix PR 73 performance optimizations

Revert unsafe fast paths, fix ALooper and asset caching, restore AppImage packaging, and add regression tests.

* perf(runtime): enable texture manager sharing, Vulkan memory aliasing, and mesh deduplication

- Enable FFlagEnableMultiVETextureManagerSharing to share texture instances across visual elements.
- Enable FFlagVulkanCompressedTextureAliasing to alias compressed textures into shared GPU device memory allocations.
- Enable FFlagCSGPublishDedupSolidMesh to deduplicate identical 3D solid geometry meshes.
- Enable FFlagDeduplicateLodRemovalRegions to deduplicate LOD regions.
- Enable FFlagCurlUsePools to pool network download buffers.

* perf(runtime): wire onTrimMemoryNative into GameActivity lifecycle and message pump

- Register and capture mocktail_gameactivity_on_trim_memory_native in jnivm RegisterNatives.
- Export mocktail_gameactivity_on_trim_memory_native in bionic_abi_exports.
- Store g_saved_game_activity in run_game_activity_initialize.
- Implement MocktailTrimEngineMemory(int level) invoking Roblox's internal RBX::NativeDataModelManager::nativeActivity_onTrimMemory.
- Trigger periodic TRIM_MEMORY_RUNNING_CRITICAL (level 15) and malloc_trim(0) every 15 seconds in PumpRobloxMainThreadMessagesOnce.

* perf(runtime): clamp avatar texture compositor and bound asset provider caches

- Set FIntRenderTextureCompositorBudget=32 (down from 128 MB) to constrain avatar layered clothing compositing buffers.
- Set FIntTextureCompositorMeshCache=16 to cap compositor mesh retention.
- Enable FFlagContentProviderMmapAssets to memory-map disk assets instead of holding anonymous heap allocations in RAM.
- Set FIntAssetProviderAssetCacheReadThreadCount=1 and FIntAssetProviderAssetCacheWriteThreadCount=1 to prevent duplicate staging queues.
- Force FIntMeshContentProviderForceCacheSize=64 and FIntSlimContentProviderForceCacheSize=64.
- Set FIntInitialAudioAssetCacheSize=32 to bound audio cache buffers.

* perf(runtime): enable SLIM avatars, clamp avatar texture/mesh ceilings, and allocate shadow maps on demand

- Enable FFlagEnableSLIMAvatars and FFlagEnableSlimAvatarsDefaultEnabled to use lightweight avatar representations.
- Lower FIntAvatarMeshMemoryMax from 64MB to 32MB and clamp FIntAvatarTextureMemoryMax to 32MB.
- Enable FFlagLayeredClothingCacheOptimizations.
- Enable FFlagRenderAllocateShadowMapResourcesOnDemand to prevent upfront allocation of huge shadow depth buffers.
- Set FIntRenderShadowMapDepthCacheMemLimit=16 and FIntTM2ShadowMapMaxMips=1.
- Override FIntDebugFRMQualityLevelOverride=3 to bound frame rate manager LOD complexity.

* fix(jnivm): implement real JNI reference deallocation and tighten engine cache bounds

- Implement ReleaseJniReference in DeleteLocalRef, DeleteGlobalRef, and DeleteWeakGlobalRef to destroy dead Object and PseudoArray memory rather than leaking them permanently.
- Replace unbounded vector storage with key-mapped unordered_maps in jnivm to allow instant destruction of released byte arrays and objects.
- Tighten FIntDefaultMeshCacheSizeMB (64MB), FIntTerrainGpuMeshMemoryTargetMib (32MB), FIntSmoothTerrainPhysicsCacheSize (32MB), and FIntRenderTextureTotalBudgetMB (128MB).
- Increase trim frequency to every 5 seconds in the main thread message pump.

* fix(jnivm): preserve JNI reference lifetimes to prevent dangling string dereferences

- Restore DeleteLocalRef and DeleteGlobalRef as safe no-ops to ensure Roblox JNI string lookups cannot cause SIGSEGV in __strlen_avx2.

* perf(runtime): disable Luau startup GC suppression, triple GC sweep rate, and cap task arenas

- Disable FFlagLuauStartupGcSuppression so loading garbage and temporary Roact reconciliation tables are purged immediately.
- Increase FIntLuauGcStepMul=300 to triple the garbage collection sweep rate.
- Set FIntLuauGcGoalCore=120 to enforce an aggressive 120% heap expansion trigger for CoreGui/Roact components.
- Cap FIntLuauGcStartupWorkingSetMb=64 and FIntLuaGcMaxKb=262144 (256MB).
- Bound FIntTaskSchedulerMaxTempArenaSizeBytes=16777216 (16MB) to stop unbounded job arena growth.

* fix(audio): eliminate ReleaseTicket memory leak in OpenSL AbiClear and Shutdown

- Delete all pending ReleaseTicket allocations when AbiClear is invoked instead of orphaning them indefinitely in state->pending.
- Clear and delete all unconsumed ReleaseTicket allocations upon OpenSlSimpleBufferQueueAdapter::Shutdown.

* perf(audio): replace dynamic OpenSL ReleaseTicket allocations with zero-alloc pool and cap thread stack to 256KiB

- Preallocate ReleaseTicket array inside State sized to options.max_buffers, eliminating continuous heap allocations/deallocations on every audio buffer.
- Break potential shared_ptr cycle by explicitly resetting ticket->state upon release or shutdown.
- Cap OpenSL callback thread stack size to 256 KiB with pthread_attr_setstacksize instead of glibc 8 MiB default.

* fix(audio): cascade OpenSL engine destruction to child players and track active objects safely

- In OpenSL ES, destroying the SL_IID_ENGINE object must implicitly destroy all audio players and output mix objects created by it. Previously, child players were orphaned and leaked.
- Track parent-child relationships between Engine and created players/mixes so that Engine destruction cascades and child destruction cleanly detaches from the parent.
- Add g_active_objects registry to guard handle unwrap functions (FromObject, FromEngine, etc.) preventing use-after-free and double-destroy segfaults.
- Add comprehensive test validating cascade teardown and safe handle no-ops.

* perf(audio): bound sound memory and decode buffer caches

- Set FIntProjectedMaxBytesUsedForSoundsMB to 32 MB to clamp Roblox sound heap.
- Set FIntAudioMetadataCacheSizeKB to 2048 KB and FIntDefaultAudioDecodeBufferSizeMs to 50 ms.
- Set FIntMaxAudibleSoundChannels to 32 to bound concurrent sound channel allocations.

* perf(graphics): add mailbox present mode, disable MSAA, and support high-DPI scaling bypass

- Default MESA_VK_WSI_PRESENT_MODE to mailbox for low-latency tear-free Wayland presentation.
- Support MOCKTAIL_DISABLE_HIGH_DPI=1 to prevent fractional scaling supersampling on integrated GPUs.
- Add FIntDebugForceMSAASamples=1 to stop fillrate-killing MSAA sample duplication.
- Support MOCKTAIL_GRAPHICS_QUALITY override (defaults to 3, accepts 1..10 or 'auto'/'manual' to unlock the in-game Roblox settings slider).

* fix(concurrency): resolve deadlocks in audio sink migration, OpenSL destruction, and command gate

- Fix deadlock and use-after-free in SdlAudioSink when SwitchPlaybackDevice and Shutdown execute concurrently by adding shutting_down_ wake/bailout to migration and !switching_device_ condition to shutdown.
- Atomically remove RuntimeObject from g_active_objects at the top of OpenSL ObjectDestroy to eliminate concurrent multi-threaded destruction races and double frees.
- Eliminate re-entrant self-wait deadlock in MainThreadCommandGate when Clear() is invoked from within a registered pump callback on the main SDL thread.
- Ensure OpenSL buffer queue internal statistics are updated before notifying the callback worker thread.

* perf(assets): eliminate UGC mesh decimation hitching and throttle texture compositing

- Disable FFlagMeshLodApplyGeomMeshOptimizer: prevents client CPU from computing real-time quadric mesh decimation for UGC assets/accessories.
- Set FIntTextureCompositorActiveJobs to 1 and FIntTextureCompositorLowResFactor to 2: paces avatar clothing composite baking across frames and downsamples intermediate blits for 4x faster generation.
- Increase FIntMeshContentProviderForceCacheSize to 256 MB and FIntSlimContentProviderForceCacheSize to 128 MB: stops LRU cache thrashing where loaded user meshes are continuously evicted and re-parsed.

* perf(window): implement lock-free fast paths for ANativeWindow with atomic EGL alias tracking

- Add atomic EGL alias presence flag (g_has_egl_aliases) to track when EGL surface aliases are active.
- On standard Vulkan rendering paths (!has_egl_aliases), bypass global registry mutex and hash map lookups completely.
- Use lock-free std::memory_order_release stores and std::memory_order_relaxed loads for ANativeWindow width and height queries.

* Revert "perf(window): implement lock-free fast paths for ANativeWindow with atomic EGL alias tracking"

This reverts commit df8e40476ce737ec543edf0c79af1e9b9ef5c8ff.

* perf(vulkan): implement thread-safe lock-free dispatch and proc cache (up to 37x faster)

- Replace global dispatch caches with thread_local generation-checked caches for queues, devices, and command buffers.
- Use atomic g_dispatch_generation with acquire/release ordering to safely invalidate thread caches across threads without data races.
- Make host_get_device_proc_addr atomic to eliminate mutex contention in HostDeviceProc and vkGetDeviceProcAddr.
- Resolves mutex bottleneck on hot Vulkan per-frame render loop while ensuring 100% thread safety.

* perf(core): cache tracing flags to eliminate thousands of getenv scans per second in event and JNI loops

- Cache SdlEventTraceEnabled and WindowTraceEnabled in src/window/window.cc to avoid calling getenv 3 times per mouse motion and SDL event.
- Cache TraceEnabled and StringTraceEnabled in src/jnivm/jnivm.cc to avoid getenv calls on every JNI method/field lookup.

* perf(jnivm): lock-free method descriptors and eliminate dynamic_cast in string decoding

- Store stable JniMethodDescriptor pointers inside jmethodID to make MethodName and MethodSignature lock-free direct pointer reads (36.8ns -> 0.3ns, 100x+ faster).
- Eliminates taking g_jni_state_mutex 5 to 10 times per JNI method call.
- Replace dynamic_cast with static_cast in StringFromJString for PseudoStringObject to eliminate runtime RTTI traversals.

* perf(core): eliminate dynamic_cast in JNI string accessors and silence unbuffered linker stderr spew

- Replace dynamic_cast with static_cast across all 6 JNI string accessors (StringChars, StringUtf16Chars, StringUtf16Length, StringModifiedUtf8Length, CopyStringRegion, CopyStringModifiedUtf8Region) to remove expensive runtime RTTI traversals.
- Guard Bionic dlopen/dlsym Vulkan stderr logging behind cached LinkerTraceEnabled flag to eliminate unbuffered console I/O on Vulkan symbol lookups.

* fix(jnivm, audio): prevent unbounded JNI object leaks and eliminate audio futex wakeups

- Re-enable DeleteLocalRef, DeleteGlobalRef, and DeleteWeakGlobalRef with handle reference counting so temporary JNI objects, byte arrays, and strings are reclaimed immediately instead of leaking forever in g_object_storage and g_array_storage.
- Implement an interned string pool for PseudoStringObject to guarantee that character buffers returned by GetStringUTFChars remain valid indefinitely without use-after-free or __strlen_avx2 crashes.
- Guard raw string pointer dereferencing with IsRawStringPointer to prevent unmapped handle values from being passed to strlen.
- Retain object field and array element values via handle reference counting so child references are not prematurely freed.
- Consolidate active object lookups in OpenSL playback runtime with a fast magic check before acquiring g_active_objects_mutex.
- Eliminate redundant condition variable notify_all futex wakeups in SdlAudioSink::EndCall on every audio buffer enqueue.

* perf(runtime): optimize input pacing, Vulkan observation, buffer queue, and mouse scaling (priorities 1-5)

- [Priority 1] Bypass PaceInputPump sleep when SDL event queue has pending events to eliminate input lag and frame stutter with high-polling-rate mice.
- [Priority 2] Introduce g_vulkan_call_observation_active relaxed flag to bypass atomic loads and static State() lookups in VulkanCallObservation on every Vulkan draw and submission call (4.25x speedup).
- [Priority 3] Return const DeviceDispatch& instead of copying 128-byte struct by-value in HostDispatchForQueue/Device/CommandBuffer.
- [Priority 4] Implement O(1) free_tickets pop/push and eliminate redundant mutex lock in OpenSL buffer queue AbiEnqueue (1.58x speedup).
- [Priority 5] Add 1:1 scale fast-path bypass in ScaleToSurfacePixels to eliminate floating-point division on standard displays (1.73x speedup).

* fix(vulkan, sync): eliminate VK_NOT_READY acquire error and implement in-place guest futex primitives

- Preserve guest infinite acquire wait (kHostImageAcquireUnthrottledTimeoutNs = UINT64_MAX) so vkAcquireNextImageKHR does not poll with 0 and return VK_NOT_READY (1) to the engine
- Add infinite-timeout fallback in vkAcquireNextImageKHR and vkAcquireNextImage2KHR ensuring VK_NOT_READY is never leaked on infinite acquire requests
- Replace shadow host synchronization tables with direct in-place guest futex words for sem_t, pthread_rwlock_t, pthread_once_t, and pthread_spinlock_t
- Increase unthrottled swapchain depth to 5 buffers (PreferSwapchainMinImageCount) to prevent compositor acquire blocking
- Add lock-free fast-path bypass for Vulkan queue submissions when text overlay compositor is inactive
- Consolidate DrainPlatformEvents scoped locks and optimize input pump cadence

---------

Co-authored-by: glook9001 <310372091+glook9001@users.noreply.github.com>
Co-authored-by: komaruworld <rtysus228@gmail.com>
2026-08-28 14:52:34 +03:00
Obsidian 9bd5f97063 fix(window): stop the window from shrinking to an unpresentable size (#79)
Dragging the window down to its smallest size crashes the client (#74).
The report is on COSMIC, where the compositor lets a client with no
minimum size hint shrink until the surface is effectively invisible.

Nothing in the tree called SDL_SetWindowMinimumSize, so no floor was ever
advertised to the compositor. The window store already refuses to restore
a geometry below 160x120, so the live window could be dragged far below a
size the same code considers invalid to load.

Share those bounds and apply them to every window Mocktail creates. On
X11 the entry now carries "program specified minimum size: 160 by 120",
which is what a compositor clamps interactive resizing against; Wayland
uses the equivalent xdg_toplevel minimum.
2026-08-26 14:38:15 +03:00
Obsidian 8f63b9deba fix(app-policy): clear the regional "unchecked" badge in the desktop layout (#75)
ApplyDesktopAppPolicy pins a whole app policy through
FStringAppConfigurationOverrideAppPolicy so a later GUAC response cannot
replace the desktop layout. On a first launch that snapshot is the APK's
bundled GuacDefaultPolicy-GlobalDist.json, which ships
"ShowUncheckedBadge": true.

Because the override is permanent, the account's own GUAC policy never
gets a chance to correct it: on a signed-in profile the cached
GUAC:-1:app-policy stays byte-identical to the bundled default plus the
keys NormalizeDesktopLayout writes.

That field gates the badge rendered next to the display name in the
top-bar user-info widget and in the flyout profile card, labelled
CommonUI.Features.Label.Unchecked ("not verified"), so verified accounts
are permanently marked unverified. The desktop chrome has no such badge.
2026-08-26 14:36:30 +03:00
Haumea 272f61688b Add a FAQ.md (#72)
* Create FAQ.md

Frequently Asked Questions file so people can easily see well... the most Frequently Asked Questions

* Update README.md

Added a FAQ section to give the link to the file FAQ.md

* Update README.md

Idk but the link to FAQ.md so I kind of fixed it but idk is the same

* Update FAQ.md

Forgot to add the link to the only question

* Update FAQ.md

I think I fixed the link

* Update FAQ.md

I think I finally fixed the link after some tries LOL

* Update FAQ.md

Forgot english does not use ¿ lol

* Update FAQ.md

Im not going to even explain this
2026-08-26 11:04:13 +03:00
komaruworld f797ec7e40 Add Voice chat support
Beta
2026-08-26 02:29:12 +03:00
komaruworldandrosaproaka-jpg 02c8c6ca83 Fix readiness canary on nonfhs systems
Fixes #54

co-authored-by: rosaproaka-jpg <228791078+rosaproaka-jpg@users.noreply.github.com>
2026-08-26 02:29:12 +03:00
komaruworld 5af66cf8fc Add AnyLinux build
Close #31
2026-08-26 02:29:01 +03:00
glook9001andglook9001 b236c59b57 fix(input): fix mouse aiming, shooting refusal, key drops, and clean up test suite (#67)
* fix(input): fix mouse aiming, shooting refusal, key drops, and clean up test suite

- Remove spurious Wayland RMB release heuristic in window event loop that falsely synthesized releases during mouse drag
- Remove mouse motion lockout in WindowPointerCaptureOwner after releasing right-click
- Accumulate relative pointer coordinates and clamp discrete clicks to viewport bounds so shooting clicks land on crosshairs in pointer lock mode
- Implement mouse motion coalescing in PumpEvents to prevent high-polling rate mice from starving the event queue
- Add ASCII and fallback scancode mapping in MapSdlKeyToAndroid to avoid dropping unmapped keys
- Clean up obsolete static script-based regex tests and fix system install multi-arch test

* fix(input): prevent focus loss lockout from dropping WASD and auto-release text focus on click

- Automatically recover router focus when receiving active hardware input events so WASD/movement keys are never permanently dropped after Wayland/SDL focus loss transitions
- Release stale Roblox text-input session when clicking anywhere in the game viewport so Roblox Lua movement scripts don't remain in TextBox-focused state
- Allow mouse pointer capture when native lock is requested without gating on fragile focused_ flag

---------

Co-authored-by: glook9001 <310372091+glook9001@users.noreply.github.com>
2026-08-26 00:20:51 +03:00
glook9001andglook9001 386476eda6 fix(auth): fix login challenge interception and add clean first-launch sign-in flow (#66)
- Fix Roblox challenge URL detection in WebView bridge to catch generic challenges, 2FA, and Arkose URLs
- Prevent premature window closure on Android Play Integrity attestation failures
- Add pre-engine first-launch desktop sign-in prompt before creating Vulkan game window
- Ensure WebView helper receives cookie sync handshake on startup to begin page load
- Support dynamic session promotion and credential dispatch for guest runtime VM
- Add unit tests for generic challenge routing and guest credential promotion

Co-authored-by: glook9001 <310372091+glook9001@users.noreply.github.com>
2026-08-25 19:37:22 +03:00
techs-sus b6407e7d3a Fix TLS error in webview by adding glib-networking (#69)
Patch was taken from #59.

Closes: #59
2026-08-25 19:32:59 +03:00
techs-sus 21efe88b22 Add Nix development shell in flake and direnv support with 'use flake' (#58)
Direnv allows the project environment to be automatically applied when
the project is cd'd into.

Other notable changes:
- .gitignore: add .cache and .direnv generated by clangd and direnv respectively
2026-08-24 23:49:04 +03:00
Obsidian 0f635abc25 Cut first-install time by removing redundant payload hashing and copying (#41)
* Stop rehashing and recopying the payload during install

A first install prepares a ~635 MiB payload, then hashes the whole tree five
times and byte-copies it once more into the store. Four of those five passes
re-derive digests the same process just computed, under the same lock.

PreparePayloadFromArchives hashed the tree to write roblox_payload.json and
then immediately verified those hashes against the file it had just written.
PayloadStore::Stage hashed the same workspace again before copying it, and
Promote hashed the published directory a third time.

The guarantee that matters is unchanged: the bytes that land in the immutable
store are still hashed in full after the copy. Stage now records which payload
it verified, so promotion skips the repeat only for a payload this instance
staged; an operator-supplied ID, a directory left by an earlier run, or a fresh
process still gets the complete pass.

CopyTree now reflinks (FICLONE) or uses copy_file_range before falling back to
an ordinary copy, so on btrfs and xfs the staging copy costs no data movement
and no extra space. A hard link would be wrong here: CopyTree makes the result
read-only while the source stays writable.

File hashing moves from the portable foundation::Sha256 to OpenSSL, which
reaches the CPU's SHA extensions, and the asset tree hashes its files across
cores. Measured on this machine: 182 MB/s to 1518 MB/s, and the 1835-file asset
tree of Roblox 2.734.917 reproduces its recorded sha256_tree exactly.

MOCKTAIL_UPDATE_TRACE=1 now reports the elapsed time of each install stage, so
the breakdown is visible instead of inferred.

* Hide the system cursor as soon as the window regains focus

Roblox draws its own cursor while it owns the pointer, so Mocktail hides the
system one. Losing focus reveals it again, which is right, but regaining focus
only set the focused flag and left the re-apply to the next Pump().

Pump() runs at the top of PumpEvents(), before SDL_PollEvent delivers
SDL_EVENT_WINDOW_FOCUS_GAINED, so the desktop arrow stayed on screen for at
least one whole frame after every alt-tab back into the game, and longer
whenever the frame was long. OnFocusGained now pumps the pointer state in the
same call, mirroring OnFocusLost and OnRightButton, and its failure is reported
like the focus-loss path.

An active text field still keeps the system cursor, focused or not.

* Cover promotion of a payload the store did not stage

Stage() now lets promotion skip the repeat hash for a payload it just wrote,
so the boundary of that shortcut needs a test. Nothing covered it: the existing
corruption test rewrites roblox_payload.json, which the metadata check catches
on its own, and payload_store_test.sh exercises the shell store rather than
this class.

The new test corrupts only the content bytes, leaving the metadata describing
the original payload, then promotes from a second PayloadStore instance. It
fails if the shortcut is ever allowed to apply to a payload this instance did
not stage.

* Restore the full hash at the promotion gate

Skipping the rehash for a payload the same instance staged was wrong. The
readiness canaries run between Stage() and Promote(): they spawn the real
client against the staged directory, for up to 150 seconds, twice for a
probation candidate. Trusting the earlier hash across that window drops a
deliberate control, and payload_store_test.sh already spelled out why it is
there.

Promote() and PromoteProbation() call VerifyPreparedPayload() again, as before.
The instance no longer tracks what it staged.

The two tests now tamper with a staged payload from the instance that staged
it, which is the case that was missing: content bytes only, metadata left
describing the original payload, so nothing short of hashing the contents can
detect it. One covers the asset tree, the other libroblox.so.

Promotion still costs a complete pass over ~635 MiB, but OpenSSL and the
parallel asset hash make it 3.9 s -> 0.55 s. Stage is 9.5 s -> 0.93 s. A traced
install reports promote at 656 ms.
2026-08-24 16:22:35 +03:00
Savva 2f179068b3 Merge pull request #50 from techs-sus/main
Add Nix packaging
2026-08-24 16:16:24 +03:00
techs-sus 81b490e196 Add Nix packaging
- src/legacy/legacy_runtime.cc: only use symbols from stubs that own them
- src/legacy/legacy_runtime.cc: always use the android vulkan loader adapter to load vulkan
- src/update/readiness_canary.cc: expose env variables that affect the loading of dynamic libraries to the canary sandbox
- {flake.{nix, lock}, packaging/nix/package.nix}: initialize flake and nix package
- packaging/nix/package.nix: derive version from CMakeLists.txt, similar to the mocktail-git AUR package
2026-08-23 18:31:12 -04:00
komaruworld f2168e2f98 Maybe fix qr login
Refs #44
2026-08-22 17:06:46 +03:00
komaruworld 64a564f292 Fix text render
Fixes #21
2026-08-22 16:33:16 +03:00
komaruworldandlaskaidk 3728afa0d7 support roblox 2.734.917
fixes #20
fixes #39

co-authored-by: laskaidk <teresamnegea@proton.me>
2026-08-22 16:03:41 +03:00
komaruworld 931543e976 Fix login issue, i think
Add WebKit login

Fixes #9

Fixes #13

Fixes #19
2026-08-22 16:03:41 +03:00
komaruworld a2a210da87 Add theme: to config
#33
2026-08-22 16:03:41 +03:00
Savva b289e7933e Merge pull request #46 from qutad/fix/display-scale-mouse-coordinates
Scale mouse input for high-density windows
2026-08-22 16:02:22 +03:00
caly 497cf9ea6c Scale mouse input for high-density windows 2026-08-22 15:32:27 +03:00
Savva 0b7326d8d1 Merge pull request #42 from Gur0v/main
Update README.md
2026-08-21 19:17:01 +03:00
Gurov e312ab6667 Update README.md 2026-08-21 19:13:42 +03:00
Savva b8e535f6f9 Merge pull request #40 from Twig6943/main
Add roblox to the .desktop file
2026-08-21 12:10:23 +03:00
Twig6943 c0f6787cc6 So that it shows up when you search roblox 2026-08-21 10:40:09 +03:00
Savva f0e0229ec5 Merge pull request #34 from LightZirconite/fix/update-provider-diagnostics
Fix/update provider diagnostics
2026-08-20 22:44:24 +03:00
LightZirconite 56749bbdc8 Report drift in the pinned Roblox payload metadata
Two kinds of drift only surface when a user hits them. The compatibility
catalog falls behind what the provider publishes, so new installs land on
a version Roblox has since retired; and the preferred supported profile
loses its pinned bootstrap source, so the pinned bootstrap path cannot
produce the version the updater falls back to.

Add a checker that reports both, and run the existing script unit tests in
CI, where they were never executed.

Run against this tree the checker already reports real drift: the
preferred profile 2.727.1199 has no pinned bootstrap source. Pinning one
means downloading and hashing the archive, and a newer profile also needs
a graphics canary on real hardware, so this reports the condition rather
than pretending to fix it.

The checker stays a plain script that anyone can run on demand; no
scheduled workflow is added.
2026-08-20 14:25:04 +02:00
LightZirconite 9da2c02836 Resolve APK providers through a replaceable chain
The coordinator constructed ApkPureProvider directly in three places and
rejected every other configuration with "native updater supports only the
direct APKPure provider". A single unreachable host therefore failed the
whole update with no way to try anything else, and the reported error named
neither the provider nor the host: on 2026-08-18 api.pureapk.com answered
503 to every request, including metadata for unrelated packages, and a
first launch could only say "Roblox update preflight failed".

Introduce an ApkProvider interface and a ProviderChain that consults
providers in order, returns the first answer, and reports every failure
with the provider that produced it when none answer. Each provider
downloads into its own directory, so a failed attempt cannot block the
next. Adding a source is now one line in BuildProviderChain instead of a
change spread across the coordinator.

The chain currently holds APKPure alone. The repository already ships a
pinned Uptodown provider as a build script, but it no longer works against
Uptodown's current pages, so wiring it in would add a second way to fail
rather than a fallback; that is reported separately.

Payload receipts now record which provider produced an archive, and
updates.source accepts auto alongside apk-pure so existing configuration
files keep working.
2026-08-19 10:41:11 +02:00
LightZirconite b5eac34e0f Report why the Roblox update preflight failed
A first launch that cannot reach the APK provider only ever showed
"Mocktail could not update or verify the Roblox installation.", while the
actual cause stayed in the session log. For a user that is
indistinguishable between a provider outage, a rejected signature, and a
full disk.

Name the host in HTTP transfer failures, so "HTTPS request returned status
503" becomes "api.pureapk.com request returned status 503", and relay the
updater child's stderr through the launcher: every line still reaches the
session log, and the last failure it reported is now attached to the
preflight result. The failure dialog shows that reason and the session log
directory instead of the generic sentence.
2026-08-19 10:40:55 +02:00
komaruworld e4c64c2fec Add ca_bundle: to config
#18
2026-08-18 22:35:27 +03:00
komaruworld ba6287250f Fix rpm build
test on docker image with fedora 44; fix #25
2026-08-18 22:12:13 +03:00
komaruworld 4b437f623f Add flag --force-run-latest
#20
2026-08-17 14:18:34 +03:00
komaruworld e161fecfc5 Another fix for low quality texture 2026-08-17 00:01:35 +03:00
komaruworld dcf22a144b Fix skybox issues
transcode etc1 skybox textures to bc1
2026-08-16 22:05:20 +03:00
komaruworld 0bc8823644 Add systemproxy auto detect 2026-08-16 22:04:43 +03:00
komaruworld f9a48cfe83 make builds stable 2026-08-16 17:39:25 +03:00
komaruworld 52721bf06c make builds stable 2026-08-16 17:32:27 +03:00
komaruworld 9bbd2a1ebf Update mocktail-bin for 1.0.3 2026-08-16 14:31:40 +03:00
255 changed files with 21550 additions and 33072 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
use flake
+102
View File
@@ -0,0 +1,102 @@
name: Bug report
description: Report a reproducible problem in Mocktail
title: "[Bug]: "
labels:
- bug
assignees: []
body:
- type: markdown
attributes:
value: |
Please include enough detail for someone else to reproduce the bug.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Please write issues and pull requests yourself. Do not use AI-generated text.
- type: input
id: version
attributes:
label: Mocktail version
description: Provide the release version, package version, or commit hash.
placeholder: "For example: v0.4.0 or 1a2b3c4"
validations:
required: true
- type: input
id: system
attributes:
label: System information
description: Include your distribution, desktop session, GPU, and graphics driver when relevant.
placeholder: "For example: Arch Linux, KDE Plasma 6 on Wayland, AMD RX 6600, Mesa 26.1"
validations:
required: true
- type: input
id: installation
attributes:
label: Installation method
description: Tell us how Mocktail was installed or built.
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
validations:
required: true
- type: textarea
id: problem
attributes:
label: What happened?
description: Describe the problem and its impact.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: List the smallest reliable sequence of actions that triggers the problem.
placeholder: |
1. Start Mocktail with ...
2. Open ...
3. Select ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Explain what you expected to happen instead.
validations:
required: true
- type: markdown
attributes:
value: |
Latest log:
- Flatpak: `~/.var/app/space.bigrat.mocktail/.local/state/mocktail/logs/latest.log`
- Other installs: `~/.local/state/mocktail/logs/latest.log`
Older logs are in the `sessions` directory next to `latest.log`.
- type: textarea
id: logs
attributes:
label: Logs
description: Paste the relevant part of latest.log or the terminal output.
render: shell
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add screenshots, videos, workarounds, or any other useful details.
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I searched the existing issues and did not find a duplicate.
required: true
+2
View File
@@ -0,0 +1,2 @@
blank_issues_enabled: true
contact_links: []
@@ -0,0 +1,51 @@
name: Feature request
description: Suggest an improvement or a new capability for Mocktail
title: "[Feature]: "
labels:
- enhancement
assignees: []
body:
- type: markdown
attributes:
value: |
Describe what you want to change and why it would be useful.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Please write issues and pull requests yourself. Do not use AI-generated text.
- type: textarea
id: problem
attributes:
label: Problem or use case
description: What are you trying to do, and what makes it difficult today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe how you would like Mocktail to behave.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Describe any workarounds or alternative designs you have considered.
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add mockups, screenshots, links, or implementation notes if useful.
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I searched the existing issues and did not find a duplicate.
required: true
+3
View File
@@ -0,0 +1,3 @@
<!-- Briefly describe what this pull request adds, changes, or removes. Write it yourself and do not use AI-generated text. Link the issue with "Closes #123" or "Fixes #123". -->
Closes #
+27 -17
View File
@@ -1,4 +1,4 @@
name: Publish AUR development package
name: Publish AUR packages
on:
push:
@@ -9,12 +9,12 @@ permissions:
contents: read
concurrency:
group: aur-mocktail-git
group: aur-packages
cancel-in-progress: true
jobs:
publish:
name: Publish mocktail-git
name: Publish AUR packages
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
@@ -87,18 +87,28 @@ jobs:
-o BatchMode=yes -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=$known_hosts"
aur_dir="$RUNNER_TEMP/mocktail-git-aur"
git clone --branch master --single-branch \
ssh://aur@aur.archlinux.org/mocktail-git.git "$aur_dir"
install -m 0644 packaging/aur/mocktail-git/PKGBUILD "$aur_dir/PKGBUILD"
install -m 0644 packaging/aur/mocktail-git/.SRCINFO "$aur_dir/.SRCINFO"
publish_aur_package() {
local package_name="$1"
local commit_message="$2"
local source_dir="packaging/aur/$package_name"
local aur_dir="$RUNNER_TEMP/${package_name}-aur"
git -C "$aur_dir" config user.name komaruworld
git -C "$aur_dir" config user.email rtysus228@gmail.com
git -C "$aur_dir" add PKGBUILD .SRCINFO
if git -C "$aur_dir" diff --cached --quiet; then
printf 'mocktail-git %s is already published\n' "$AUR_PKGVER"
exit 0
fi
git -C "$aur_dir" commit -m "Update to ${AUR_PKGVER}"
git -C "$aur_dir" push origin HEAD:master
git clone --branch master --single-branch \
"ssh://aur@aur.archlinux.org/${package_name}.git" "$aur_dir"
install -m 0644 "$source_dir/PKGBUILD" "$aur_dir/PKGBUILD"
install -m 0644 "$source_dir/.SRCINFO" "$aur_dir/.SRCINFO"
git -C "$aur_dir" config user.name komaruworld
git -C "$aur_dir" config user.email rtysus228@gmail.com
git -C "$aur_dir" add PKGBUILD .SRCINFO
if git -C "$aur_dir" diff --cached --quiet; then
printf '%s is already published\n' "$package_name"
return
fi
git -C "$aur_dir" commit -m "$commit_message"
git -C "$aur_dir" push origin HEAD:master
}
publish_aur_package mocktail-git "Update to ${AUR_PKGVER}"
publish_aur_package mocktail 'make builds stable'
publish_aur_package mocktail-bin 'make builds stable'
+6
View File
@@ -52,6 +52,12 @@ jobs:
find config packaging/flatpak -type f -name '*.json' -print0 \
| xargs -0 -n1 python3 -m json.tool >/dev/null
- name: Run script unit tests
shell: bash
run: |
python3 -m pip install --disable-pip-version-check requests
python3 -m unittest discover -s tests -p '*_test.py' -v
- name: Verify build and packaging contracts
shell: bash
run: |
+69 -29
View File
@@ -33,7 +33,7 @@ concurrency:
jobs:
appimage:
name: AppImage (Ubuntu 26.04)
name: AppImage (AnyLinux)
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
@@ -49,50 +49,89 @@ jobs:
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
ubuntu:26.04 bash -Eeuo pipefail -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install --yes --no-install-recommends \
build-essential cmake git ninja-build pkg-config lld \
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev \
libssl-dev nlohmann-json3-dev libyaml-dev libelf-dev \
libminizip-dev libcapstone-dev libgtk-4-dev \
libadwaita-1-dev libwebkitgtk-6.0-dev libutf8proc-dev \
libfontconfig1-dev libegl-dev libvulkan-dev zlib1g-dev \
libplacebo-dev \
bubblewrap xdg-dbus-proxy patchelf jq unzip file curl \
ca-certificates default-jdk-headless libgif7 python3 \
python3-capstone python3-yaml python3-requests \
python3-urllib3 python3-certifi python3-charset-normalizer \
python3-idna squashfs-tools
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 \
patchelf jq unzip file ca-certificates dbus zsync
# Reduced ABI-compatible packages belong only in this builder.
# The native updater does not require a JRE or Android SDK.
./scripts/install_anylinux_dependencies.sh
curl --fail --location --retry 3 \
--output /tmp/quick-sharun \
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/quick-sharun.sh
echo "fa66d0189e1e87df781c049dfb23cbb1e753786056f693a4483d6d3af61e7ee1 /tmp/quick-sharun" | \
sha256sum --check --strict
chmod 0755 /tmp/quick-sharun
curl --fail --location --retry 3 \
--output /tmp/sharun \
https://github.com/pkgforge-dev/Anylinux-sharun/releases/download/2.2.6/sharun-x86_64
echo "a4d51752f9861b4870ca529fea04087310b28f8943cc55b99a95b777d4db92ec /tmp/sharun" | \
sha256sum --check --strict
chmod 0755 /tmp/sharun
curl --fail --location --retry 3 \
--output /tmp/anylinux.c \
https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/2a025cef14483c5bc2a75b479071f4d5c58a906f/useful-tools/lib/anylinux.c
echo "f50650ad96d177559bdd0737df509fa20bd4af08887631c738fc1f31bfac185f /tmp/anylinux.c" | \
sha256sum --check --strict
curl --fail --location --retry 3 \
--output /tmp/appimagetool \
https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage
echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 /tmp/appimagetool" | \
https://github.com/pkgforge-dev/appimagetool/releases/download/0.3.3/appimagetool-x86_64-linux
echo "0d01a4e2628efc897be9663f0faeed6a171e1851270b4e1ce4c22434a36ecebd /tmp/appimagetool" | \
sha256sum --check --strict
install -m 0755 /tmp/appimagetool /usr/local/bin/appimagetool
chmod 0755 /tmp/appimagetool
curl --fail --location --retry 3 \
--output /tmp/android-build-tools.zip \
https://dl.google.com/android/repository/build-tools_r35_linux.zip
echo "bd3a4966912eb8b30ed0d00b0cda6b6543b949d5ffe00bea54c04c81e1561d88 /tmp/android-build-tools.zip" | \
--output /tmp/uruntime \
https://github.com/VHSgunzo/uruntime/releases/download/v0.5.9/uruntime-appimage-dwarfs-lite-x86_64
echo "cef962c299f2fa19b2b3cdf2fa1565ee8541796cc89b9a97a591f94041e8b083 /tmp/uruntime" | \
sha256sum --check --strict
unzip -q /tmp/android-build-tools.zip -d /tmp/android-build-tools
android_tools_root="$(dirname -- "$(find /tmp/android-build-tools -type f -name apksigner -print -quit)")"
[[ -n "$android_tools_root" ]]
chmod 0755 /tmp/uruntime
curl --fail --location --retry 3 \
--output /tmp/mkdwarfs \
https://github.com/mhx/dwarfs/releases/download/v0.15.6/dwarfs-universal-0.15.6-Linux-x86_64
echo "50891c38ba359db8271819a6cbf6aaa8068681523f0c4f2b8242007a45edaa28 /tmp/mkdwarfs" | \
sha256sum --check --strict
chmod 0755 /tmp/mkdwarfs
git config --global --add safe.directory /workspace
git config --global --add safe.directory /workspace/third_party/Vulkan-Headers
git config --global --add safe.directory /workspace/third_party/libjnivm
export APPIMAGE_EXTRACT_AND_RUN=1
export MOCKTAIL_ANDROID_BUILD_TOOLS_ROOT="$android_tools_root"
# Install into the disposable container /usr before deployment.
export MOCKTAIL_ANYLINUX_SYSTEM_INSTALL=1
export MOCKTAIL_ANYLINUX_SHARUN=/tmp/sharun
export MOCKTAIL_ANYLINUX_C_SOURCE=/tmp/anylinux.c
export RUNTIME=/tmp/uruntime
export DWARFS_CMD=/tmp/mkdwarfs
make appimage BUILD_DIR=build-appimage JOBS=4 \
APPIMAGE=dist/Mocktail-x86_64.AppImage \
PORTABLE_MODE=standalone
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
ANYLINUX_PACKAGER=/tmp/quick-sharun \
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
test -x dist/Mocktail-x86_64.AppImage
'
- name: Smoke-test AppImage without FUSE
shell: bash
run: |
smoke_dir="$(mktemp -d)"
trap 'rm -rf -- "$smoke_dir"' EXIT
export XDG_DATA_HOME="$smoke_dir/data"
export XDG_CONFIG_HOME="$smoke_dir/config"
export XDG_CACHE_HOME="$smoke_dir/cache"
export XDG_STATE_HOME="$smoke_dir/state"
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run --help
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
- name: Upload AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -182,6 +221,7 @@ jobs:
cmake -S . -B build-rpm -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
-DBUILD_TESTING=OFF
+7
View File
@@ -2,6 +2,7 @@
/build/
/build_*/
/build-flatpak/
/build-anylinux*/
/.flatpak-builder/
/cmake-build-*/
/out/
@@ -20,6 +21,12 @@ Testing/
CMakeUserPresets.json
*.cmake.user
# local direnv development shell cache
.direnv/
# clangd cache
.cache
*.o
*.obj
*.a
+24 -9
View File
@@ -138,12 +138,7 @@ if(BUILD_TESTING)
FetchContent_MakeAvailable(googletest)
endif()
add_library(mocktail_crypto_foundation STATIC
src/foundation/sha256.cc
)
add_library(Mocktail::CryptoFoundation ALIAS mocktail_crypto_foundation)
target_include_directories(mocktail_crypto_foundation PUBLIC include)
mocktail_apply_compile_options(mocktail_crypto_foundation)
find_package(OpenSSL REQUIRED)
include(${CMAKE_SOURCE_DIR}/cmake/MocktailPlatformGraphics.cmake)
foreach(foundation_target IN ITEMS
@@ -201,12 +196,12 @@ target_include_directories(mocktail_bionic_stdio_runtime PUBLIC include)
mocktail_apply_compile_options(mocktail_bionic_stdio_runtime)
find_package(CURL REQUIRED)
find_package(OpenSSL REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBYAML REQUIRED IMPORTED_TARGET yaml-0.1)
pkg_check_modules(MINIZIP REQUIRED IMPORTED_TARGET minizip)
pkg_check_modules(CAPSTONE REQUIRED IMPORTED_TARGET capstone)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
pkg_check_modules(GTK4 REQUIRED IMPORTED_TARGET gtk4)
pkg_check_modules(LIBADWAITA REQUIRED IMPORTED_TARGET "libadwaita-1>=1.6")
pkg_check_modules(WEBKITGTK6 REQUIRED IMPORTED_TARGET webkitgtk-6.0)
@@ -214,6 +209,7 @@ pkg_check_modules(WEBKITGTK6 REQUIRED IMPORTED_TARGET webkitgtk-6.0)
add_library(mocktail_update STATIC
src/update/android_manifest.cc
src/update/apk_bundle.cc
src/update/apk_provider.cc
src/update/apk_signature.cc
src/update/apkpure_provider.cc
src/update/candidate_approval.cc
@@ -223,6 +219,7 @@ add_library(mocktail_update STATIC
src/update/payload_integrity.cc
src/update/payload_store.cc
src/update/readiness_canary.cc
src/update/unsafe_latest_runner.cc
src/update/update_config.cc
src/update/update_coordinator.cc
src/update/zip_archive.cc
@@ -231,7 +228,6 @@ add_library(Mocktail::Update ALIAS mocktail_update)
target_include_directories(mocktail_update PUBLIC include)
target_link_libraries(mocktail_update PUBLIC
Mocktail::Compat
Mocktail::CryptoFoundation
CURL::libcurl
OpenSSL::Crypto
PkgConfig::LIBYAML
@@ -288,6 +284,7 @@ add_library(mocktail_runtime STATIC
src/runtime/roblox_launch_uri.cc
src/runtime/roblox_game_session_native_adapter.cc
src/runtime/roblox_web_view_bridge.cc
src/runtime/roblox_permissions_bridge.cc
src/runtime/payload_update_preflight.cc
src/runtime/runtime_config.cc
src/runtime/runtime_config_bootstrap.cc
@@ -298,6 +295,8 @@ add_library(mocktail_runtime STATIC
src/runtime/startup_state_machine.cc
src/runtime/supported_launch_policy.cc
src/runtime/support_bundle.cc
src/runtime/system_proxy.cc
src/runtime/texture_memory_policy.cc
)
add_library(Mocktail::Runtime ALIAS mocktail_runtime)
target_include_directories(mocktail_runtime PUBLIC include)
@@ -328,9 +327,9 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND
endif()
target_link_libraries(mocktail_runtime PUBLIC Threads::Threads)
target_link_libraries(mocktail_runtime PRIVATE
Mocktail::CryptoFoundation
CURL::libcurl
nlohmann_json::nlohmann_json
PkgConfig::GIO
PkgConfig::LIBYAML
${CMAKE_DL_LIBS}
)
@@ -421,6 +420,7 @@ target_link_libraries(mocktail_linker PUBLIC
linker
mocktail_compat
mocktail_bionic_stdio_runtime
mocktail_bionic_abi_exports
)
mocktail_apply_compile_options(mocktail_linker)
target_compile_definitions(mocktail_linker PUBLIC MOCKTAIL_USE_BIONIC_LINKER=1)
@@ -449,6 +449,7 @@ add_library(mocktail_window STATIC
src/window/roblox_fullscreen_menu_request_gate.cc
src/window/video_driver_policy.cc
src/window/window.cc
src/window/window_creation_policy.cc
src/window/window_fullscreen_request_gate.cc
src/window/window_game_surface_bridge.cc
src/window/window_pointer_capture_owner.cc
@@ -461,6 +462,7 @@ target_include_directories(mocktail_window PUBLIC src)
mocktail_apply_compile_options(mocktail_window)
target_link_libraries(mocktail_window PUBLIC
Mocktail::PlatformSdl
Mocktail::SdlVulkanWsi
SDL3::SDL3
${CMAKE_DL_LIBS}
PRIVATE Mocktail::Runtime
@@ -525,6 +527,19 @@ target_include_directories(mocktail_bionic_abi_exports
PRIVATE src
)
mocktail_apply_compile_options(mocktail_bionic_abi_exports)
# These exported Bionic FORTIFY symbols intentionally overlap glibc's
# __*_chk entry points. When the bridge is visible to whole-program LTO,
# GCC can fold a fortified host call back into its own compatibility wrapper
# and turn the resulting tail recursion into an infinite loop. Keep this
# translation unit opaque to interprocedural optimization and make its calls
# to the host C library explicit external calls.
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
set_property(SOURCE src/compat/bionic_abi_exports.cc APPEND PROPERTY
COMPILE_OPTIONS
-fno-lto
-fno-builtin
)
endif()
target_link_libraries(mocktail_bionic_abi_exports PRIVATE
mocktail_bionic_stdio_runtime
mocktail_window
+20
View File
@@ -0,0 +1,20 @@
# Frequently asked questions
[How do I change my FPS?](/FAQ.md#how-do-i-change-my-fps)
### How do I change my FPS?
Go to $HOME/.config/mocktail, there is a file called config.yaml (appears after opening for the first time mocktail)
<img width="460" height="241" alt="image" src="https://github.com/user-attachments/assets/b30b1fea-4937-4858-937a-3096a9e8b2ad" />
with your text editor of preference search for "graphics"
<img width="1022" height="619" alt="image" src="https://github.com/user-attachments/assets/f86791e9-7ac3-4d13-b81b-3cf9e3e64c3d" />
change the display value to your desired fps, also turn off vsync if you want more fps than your display supports
+8 -7
View File
@@ -14,6 +14,9 @@ PORTABLE_MODE ?= standalone
PORTABLE_CANONICAL_MODE := $(if $(filter dynamic minimal,$(PORTABLE_MODE)),thin,$(if $(filter full static,$(PORTABLE_MODE)),standalone,$(PORTABLE_MODE)))
PORTABLE_DIR ?= dist/mocktail-linux-x86_64-glibc-$(PORTABLE_CANONICAL_MODE)
APPIMAGE ?= dist/Mocktail-x86_64.AppImage
APPIMAGE_FORMAT ?= classic
ANYLINUX_PACKAGER ?= quick-sharun
ANYLINUX_APPIMAGETOOL ?= appimagetool
PREFIX ?= /usr
FLATPAK_BUILD_DIR ?= build-flatpak
FLATPAK_MANIFEST ?= packaging/flatpak/space.bigrat.mocktail.json
@@ -73,13 +76,11 @@ portable-test: release-runtime ## Verify portable packaging and relocation
standalone: appimage ## Build the standalone x86-64 AppImage
appimage: release-runtime ## Build AppImage (requires appimagetool)
@command -v appimagetool >/dev/null 2>&1 || { \
echo "appimagetool is required for make appimage" >&2; \
echo "Install it, then rerun make appimage." >&2; \
exit 1; \
}
@./scripts/package_portable.sh --build-dir "$(BUILD_DIR)" \
appimage: release-runtime ## Build AppImage (APPIMAGE_FORMAT=classic|anylinux)
@MOCKTAIL_APPIMAGE_FORMAT="$(APPIMAGE_FORMAT)" \
MOCKTAIL_ANYLINUX_PACKAGER="$(ANYLINUX_PACKAGER)" \
MOCKTAIL_ANYLINUX_APPIMAGETOOL="$(ANYLINUX_APPIMAGETOOL)" \
./scripts/package_portable.sh --build-dir "$(BUILD_DIR)" \
--libc glibc --output "$(PORTABLE_DIR)" --mode "$(PORTABLE_MODE)" \
--appimage "$(APPIMAGE)"
+11
View File
@@ -4,6 +4,7 @@
[![Stars](https://img.shields.io/github/stars/komaruworld/mocktail?style=flat&logo=github)](https://github.com/komaruworld/mocktail/stargazers)
[![Downloads](https://img.shields.io/github/downloads/komaruworld/mocktail/total?logo=github)](https://github.com/komaruworld/mocktail/releases/latest)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
[![Discord](https://img.shields.io/discord/1543955607869063269?label=discord)](https://discord.gg/rhgpfcmFSD)
Mocktail runs the Android `x86_64` Roblox client on Linux, including a Linux
userspace hosted by FreeBSD's Linuxulator. It provides the Android ABI and JNI
@@ -112,6 +113,10 @@ Put a JSON object in `$XDG_CONFIG_HOME/mocktail/fflags.json` (usually
"DFIntExample": "120"
}
```
## FAQ
To see the most Frequently Asked Question go check [FAQ.md](FAQ.md)
## Building
@@ -169,6 +174,12 @@ make build
./build/mocktail
```
## Known limitations
Mocktail does not currently work with `hardened_malloc`. Using
`hardened_malloc` may cause Mocktail to fail to start or crash during runtime.
Run Mocktail without `hardened_malloc` enabled.
## License
[Apache License 2.0](LICENSE). Third-party components keep their own licenses.
+2 -6
View File
@@ -24,6 +24,7 @@ set_target_properties(mocktail_audio_core PROPERTIES
add_library(Mocktail::AudioCore ALIAS mocktail_audio_core)
add_library(mocktail_audio_sdl SHARED
${MOCKTAIL_AUDIO_ROOT}/src/audio/sdl_audio_capture.cc
${MOCKTAIL_AUDIO_ROOT}/src/audio/sdl_audio_sink.cc
)
target_include_directories(mocktail_audio_sdl PUBLIC
@@ -62,6 +63,7 @@ add_library(Mocktail::FmodJavaAudioRuntime ALIAS
add_library(mocktail_fmod_jni_audio_bridge STATIC
${MOCKTAIL_AUDIO_ROOT}/src/audio/fmod_jni_audio_bridge.cc
${MOCKTAIL_AUDIO_ROOT}/src/audio/webrtc_jni_audio_bridge.cc
)
target_include_directories(mocktail_fmod_jni_audio_bridge PUBLIC
${MOCKTAIL_AUDIO_ROOT}/include
@@ -199,10 +201,4 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
gtest_discover_tests(fmod_jni_audio_bridge_test
PROPERTIES ENVIRONMENT "SDL_AUDIODRIVER=dummy"
)
add_test(NAME audio_readiness_gate_test
COMMAND bash
${MOCKTAIL_AUDIO_ROOT}/tests/audio_readiness_gate_test.sh
${MOCKTAIL_AUDIO_ROOT}/scripts/audio_readiness_gate.sh
)
endif()
+1 -1
View File
@@ -44,7 +44,7 @@ target_link_libraries(mocktail_compat PUBLIC
nlohmann_json::nlohmann_json
Threads::Threads
)
target_link_libraries(mocktail_compat PRIVATE Mocktail::CryptoFoundation)
target_link_libraries(mocktail_compat PRIVATE OpenSSL::Crypto)
target_compile_features(mocktail_compat PUBLIC cxx_std_17)
if(COMMAND mocktail_apply_compile_options)
+14 -3
View File
@@ -13,11 +13,13 @@ get_filename_component(MOCKTAIL_INPUT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.."
)
add_library(mocktail_input_runtime STATIC
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_gamepad_input.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_input_native_adapter.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_input_router.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_native_text_box_info_reader.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_display_state.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_editor.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
)
@@ -36,12 +38,14 @@ target_link_libraries(mocktail_input_runtime PUBLIC
PRIVATE
PkgConfig::FONTCONFIG
SDL3_ttf::SDL3_ttf
nlohmann_json::nlohmann_json
)
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
mocktail_apply_compile_options(mocktail_input_runtime)
if(BUILD_TESTING AND TARGET GTest::gtest_main)
add_executable(roblox_input_router_test
${MOCKTAIL_INPUT_ROOT}/tests/roblox_gamepad_input_test.cc
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_router_test.cc
)
target_link_libraries(roblox_input_router_test PRIVATE
@@ -68,6 +72,15 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
)
mocktail_apply_compile_options(roblox_text_display_state_test)
add_executable(roblox_text_font_resolver_test
${MOCKTAIL_INPUT_ROOT}/tests/roblox_text_font_resolver_test.cc
)
target_link_libraries(roblox_text_font_resolver_test PRIVATE
Mocktail::InputRuntime
GTest::gtest_main
)
mocktail_apply_compile_options(roblox_text_font_resolver_test)
add_executable(roblox_input_native_adapter_test
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_native_adapter_test.cc
)
@@ -92,9 +105,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
gtest_discover_tests(roblox_input_router_test)
gtest_discover_tests(roblox_text_editor_test)
gtest_discover_tests(roblox_text_display_state_test)
gtest_discover_tests(roblox_text_font_resolver_test)
gtest_discover_tests(roblox_input_native_adapter_test)
gtest_discover_tests(roblox_native_text_box_info_reader_test)
add_test(NAME input_readiness_gate_synthetic
COMMAND bash ${MOCKTAIL_INPUT_ROOT}/tests/input_readiness_gate_test.sh
)
endif()
+1
View File
@@ -69,6 +69,7 @@ add_library(mocktail_platform_sdl STATIC
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_application_metadata.cc
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_display_refresh_capabilities.cc
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_event_converter.cc
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_gamepad_manager.cc
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_platform_runtime.cc
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_text_clipboard.cc
${MOCKTAIL_PLATFORM_GRAPHICS_ROOT}/src/platform/sdl_window_icon.cc
+37 -16
View File
@@ -31,17 +31,23 @@ runtime:
# active verified payload.
# roblox_library: /absolute/path/to/libroblox.so
appearance:
# String (default: roblox): use Roblox's saved account theme. Supported
# overrides: dark, light, or system (follow the desktop color scheme).
theme: roblox
graphics:
# String (default: direct-vulkan): graphics backend. Use opengl on hardware
# without a working Vulkan driver; OpenGL ES 3.0 or newer is required.
# Recommended values: direct-vulkan, opengl, system, angle-vulkan.
backend: direct-vulkan
# String or integer (default: display): requested frame-rate policy.
# Supported values: display, unlimited, 30, 60, 120, 144, 240. Unlimited
# selects Roblox's unmodified 240-FPS scheduler maximum.
frame_rate_limit: display
# String (default: auto): presentation synchronization: auto, on, or off.
vsync: auto
# Optional string or integer: Mocktail frame-rate override. Leave commented
# (or use -1) to let Roblox Basic Settings own the cap. Supported overrides:
# display, unlimited, or any positive integer; fixed values are forwarded to
# DFIntTaskSchedulerTargetFps without a whitelist.
# frame_rate_limit: -1
# Optional presentation synchronization override: auto, on, or off.
# vsync: off
performance:
# Boolean (default: false): size Roblox scheduler/render queues from every
@@ -63,6 +69,10 @@ audio:
# String (default: default): `default` follows the host system output.
# To pin output, copy an exact SDL device name printed during startup.
output_device: default
# String (default: default): input device name, `id:<number>` printed during
# startup, or `disabled`. IDs disambiguate duplicate names but may change
# between boots; prefer the exact device name when it is unique.
input_device: default
integrations:
discord_rpc:
@@ -91,20 +101,29 @@ integrations:
# application_id: 123456789012345678
window:
# Integer (default: 1280): initial window width in pixels.
# Integer (default: 1280): initial window width in logical desktop units.
width: 1280
# Integer (default: 720): initial window height in pixels.
# Integer (default: 720): initial window height in logical desktop units.
height: 720
# String (default: Roblox): window title.
title: Roblox
# Boolean (default: false): render at physical display-pixel density instead
# of the logical desktop resolution. Enable only for sharper high-DPI output.
high_dpi: false
# Optional host HTTP proxy. Uncomment both values together. The host must not
# contain a scheme such as http://.
# network:
# # String (optional, default: disabled): proxy host name or IP address.
# proxy_host: 127.0.0.1
# # Integer (optional, default: disabled): proxy TCP port from 1 to 65535.
# proxy_port: 8080
network:
# Boolean (default: false): follow the host HTTP/SOCKS5 proxy and
# automatically use its current port. Do not combine with a fixed proxy.
use_system_proxy: false
# Optional fixed HTTP proxy. Uncomment both values together. The host must
# not contain a scheme such as http://.
# String (optional, default: disabled): proxy host name or IP address.
# proxy_host: 127.0.0.1
# Integer (optional, default: disabled): proxy TCP port from 1 to 65535.
# proxy_port: 8080
# Absolute path (optional): custom PEM CA bundle. Mocktail reads this file
# directly and does not replace it during Roblox payload updates.
# ca_bundle: /home/user/.config/mocktail/cacert.pem
updates:
# Boolean (default: true): check the latest Roblox version, derive an exact
@@ -112,7 +131,9 @@ updates:
# backend, and promote only on success. An existing current payload is
# preserved when probation fails.
automatic: true
# String (default: apk-pure): direct x86_64 APK provider.
# String (default: apk-pure): direct x86_64 APK provider selection. Both
# accepted values resolve the provider chain, which currently starts with
# APKPure. Supported values: auto, apk-pure.
source: apk-pure
# Reserved for desktop update integrations. `mocktail_updater` itself never
# launches another process after changing the active payload.
+40
View File
@@ -42,6 +42,46 @@
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"reason": "Verified 2026-07-16 on the current host: independently signature-matched native allocator and pre-JNI state, 3479 constructors replayed without recovery, JNI_OnLoad and ASMA/V2 startup, 2988 Vulkan shaders, a real vkQueuePresentKHR, and explicit lifecycle teardown to Stopped; legacy executable patches and signal recovery remain disabled."
},
{
"version_name": "2.734.917",
"version_code": 2908,
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"status": "supported",
"default_allowed": true,
"allow_legacy_binary_patches": false,
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"user_game_settings_fullscreen_setter_rva": "0x44fee64",
"fmod_output_device_bridge": {
"vtable_rva": "0x6b7eee8",
"string_constructor_rva": "0x1cfd5ec",
"count_method_rva": "0x32736d2",
"info_method_rva": "0x3273772",
"current_method_rva": "0x3273722",
"select_method_rva": "0x32734a6"
},
"reason": "Verified 2026-08-28 on the current host: the machine-derived exact-Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries; fullscreen state synchronization and Build-ID-scoped FmodAudioDevice output interposition were re-derived for the current payload; legacy executable patches and signal recovery remain disabled."
},
{
"version_name": "2.736.1408",
"version_code": 2998,
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"status": "supported",
"default_allowed": true,
"allow_legacy_binary_patches": false,
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"user_game_settings_fullscreen_setter_rva": "0x45ad8aa",
"fmod_output_device_bridge": {
"vtable_rva": "0x6c58040",
"string_constructor_rva": "0x1d32df8",
"count_method_rva": "0x32d0ee0",
"info_method_rva": "0x32d0f80",
"current_method_rva": "0x32d0f30",
"select_method_rva": "0x32d0cb4"
},
"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."
}
]
}
+35 -35
View File
@@ -1,64 +1,64 @@
{
"schema_version": 1,
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
"payload_sha256": "3e9c26c81186f93458ff65d8a8bc240c220974db02b8388b91340b77b336997d",
"payload_id": "2628-1686400865ae0e408cd7bd67de7a439625c6fd13",
"payload_path": "payloads/2628-1686400865ae0e408cd7bd67de7a439625c6fd13",
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"payload_sha256": "4343a6a900d1fca27ff8e10b9d9c86bee40a5d0ba547cb29903b7769f4f42a9d",
"payload_id": "2998-ade08266c67aee88ec9c1d00902150e1684dad3a",
"payload_path": "payloads/2998-ade08266c67aee88ec9c1d00902150e1684dad3a",
"reference": {
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
"payload_sha256": "92ca0e4d8bc4e1c7a37fc2b52544869299ec8b9a40ea8da96ebac66660e0b1af"
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"payload_sha256": "72c3b1a5c151b5ac0bfc5394ce3a91775884fbd777589ccab8ac1c4f9885f739"
},
"profile": {
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"bridge_entries": [
{
"rva": "0x1c39ea2",
"rva": "0x1d2c1e2",
"kind": "allocate",
"label": "small-allocate"
},
{
"rva": "0x1fe4c84",
"rva": "0x1d2f43a",
"kind": "usable_size",
"label": "usable-size"
},
{
"rva": "0x203daae",
"rva": "0x21e68bf",
"kind": "reallocate",
"label": "reallocate"
},
{
"rva": "0x1c39c3a",
"rva": "0x1d2bf82",
"kind": "allocate",
"label": "allocate"
},
{
"rva": "0x1c8dbf8",
"rva": "0x1d47a63",
"kind": "aligned_allocate",
"label": "aligned-allocate-direct"
},
{
"rva": "0x1c3eb72",
"rva": "0x1d2f9d9",
"kind": "free",
"label": "free"
}
],
"data_seeds": {
"allocator_object_slot": "0x7030ae0",
"empty_string_slot": "0x703a7e8",
"jni_singleton_slot": "0x72a16d0",
"allocator_object_slot": "0x7652e70",
"empty_string_slot": "0x775fd28",
"jni_singleton_slot": "0x7bb4340",
"jni_singleton_bytes": 1024,
"arena_initializer": "0x1c39f1d",
"allocator_thread_initializer": "0x2040817",
"arena_guard_slot": "0x71e92f0",
"arena_table_slot": "0x71e9380",
"arena_initializer": "0x1d2c5f8",
"allocator_thread_initializer": "0x1db7b57",
"arena_guard_slot": "0x7af0430",
"arena_table_slot": "0x7af04c0",
"arena_table_slot_count": 4194304
},
"native_allocator": {
"allocate": "0x1c39c3a",
"deallocate": "0x1c3eb72"
"allocate": "0x1d2bf82",
"deallocate": "0x1d2f9d9"
},
"init_array_offset": "0x6c2c9b0",
"init_array_count": 3481,
"init_array_offset": "0x7057818",
"init_array_count": 3570,
"constructor_run_ranges": [
{
"begin": 2,
@@ -66,32 +66,32 @@
},
{
"begin": 5,
"end_exclusive": 3481
"end_exclusive": 3570
}
],
"native_mimalloc_constructor_run_ranges": [
{
"begin": 2,
"end_exclusive": 3481
"end_exclusive": 3570
}
],
"native_mimalloc_thread_initializer_after_constructor": 2,
"native_pre_jni_bootstrap": {
"registry_initializer": "0x1fe72e5",
"registry_slot": "0x71ed428"
"registry_initializer": "0x21e8e09",
"registry_slot": "0x7af4598"
},
"default_allocator_strategy": "native_mimalloc"
},
"derivation_anchors": {
"signature_version": 1,
"allocator_object_initializer_rva": "0x1fbf1de",
"empty_string_initializer_rva": "0x1fc82b0",
"jni_singleton_initializer_rva": "0x1c39a34",
"allocator_object_initializer_rva": "0x21bde7d",
"empty_string_initializer_rva": "0x21c9a86",
"jni_singleton_initializer_rva": "0x1d2aec0",
"constructor_rvas": {
"2": "0x1c3c764",
"3": "0x1c38c00",
"4": "0x1c39884",
"5": "0x1c39a34"
"2": "0x1d2f731",
"3": "0x1d2af84",
"4": "0x1d2bc08",
"5": "0x1d2aec0"
}
}
}
+12 -11
View File
@@ -1,23 +1,24 @@
{
"schema_version": 1,
"package": "com.roblox.client",
"version_name": "2.725.1142",
"version_code": 2546,
"version_name": "2.736.1408",
"version_code": 2998,
"abi": "x86_64",
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"sha256": {
"libroblox": "92ca0e4d8bc4e1c7a37fc2b52544869299ec8b9a40ea8da96ebac66660e0b1af",
"base_apk": "eba476fec3020eaceb28436c1d160eb35d6689d8ffb060e09678535584babc7a",
"x86_64_split_apk": "5c4193c9700481079cee958b38ba9cbb97768b0a130b7f704ed084753f01ce2b"
"libroblox": "4343a6a900d1fca27ff8e10b9d9c86bee40a5d0ba547cb29903b7769f4f42a9d",
"base_apk": "a4a71ec39df6619a56ab7adce0959d1d60354835485b53806f4026914287ea86",
"x86_64_split_apk": "a4a71ec39df6619a56ab7adce0959d1d60354835485b53806f4026914287ea86"
},
"signing_certificates_sha256": [
"2bebd189e8d3106401347056c93d045b61e20e22d0c3cbed85474aeb00a3d12a",
"44932ea35a17a267372d71b54d1a0cb3da0dca5113e94406ae2fe18090ba1477"
],
"assets": {
"file_count": 1837
"file_count": 1836,
"sha256_tree": "caee48e11c194bc11852493f53ff67d5e2bb233172f12b87ea5fca97c0c10840"
},
"source": "sober-update-service",
"imported_at": "2026-07-09T19:12:54Z",
"compatibility_status": "supported"
"source": "apk-pure-native",
"imported_at": "2026-08-31T16:30:19Z",
"compatibility_status": "supported",
"apk_layout": "monolithic"
}
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1787135253,
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1785031560,
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+62
View File
@@ -0,0 +1,62 @@
{
description = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
];
systems = [
"x86_64-linux"
# "aarch64-linux"
# "aarch64-darwin"
# "x86_64-darwin"
];
perSystem =
{
pkgs,
...
}:
let
package = pkgs.callPackage ./packaging/nix/package.nix { };
in
{
packages.default = package;
devShells.default = pkgs.mkShell {
inputsFrom = [ package ];
# nixpkgs installs the header we need under include/libutf8proc
NIX_CFLAGS_COMPILE = "-I${pkgs.libutf8proc}/include/libutf8proc";
nativeBuildInputs = with pkgs; [
cmake
git
lld
ninja
pkg-config
vulkan-headers
llvmPackages_22.clang-tools
llvmPackages_22.libclang
# debugging
gdb
];
shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
# nix packages graphics drivers differently, and mocktail will still work if it cannot guarantee symbols
export MOCKTAIL_REQUIRE_REAL_GRAPHICS=0
'';
};
};
flake = { };
};
}
+12
View File
@@ -71,6 +71,18 @@ int mocktail_pthread_mutex_destroy(pthread_mutex_t* mutex);
int mocktail_pthread_mutex_lock(pthread_mutex_t* mutex);
int mocktail_pthread_mutex_trylock(pthread_mutex_t* mutex);
int mocktail_pthread_mutex_unlock(pthread_mutex_t* mutex);
int mocktail_pthread_once(pthread_once_t* once_control,
void (*init_routine)(void));
int mocktail_pthread_spin_init(pthread_spinlock_t* lock, int pshared);
int mocktail_pthread_spin_destroy(pthread_spinlock_t* lock);
int mocktail_pthread_spin_lock(pthread_spinlock_t* lock);
int mocktail_pthread_spin_trylock(pthread_spinlock_t* lock);
int mocktail_pthread_spin_unlock(pthread_spinlock_t* lock);
int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
const pthread_barrierattr_t* attr,
unsigned count);
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
extern void* mocktail_gameactivity_on_start_native;
extern void* mocktail_gameactivity_on_resume_native;
+3 -2
View File
@@ -3,8 +3,9 @@
#include <pthread.h>
// Bionic and glibc pthread_rwlock_t storage is not ABI-compatible. The guest
// address is an opaque key; the host object lives entirely in this runtime.
// Bionic pthread_rwlock_t is a state word in guest storage. rdlock/wrlock/
// unlock use __atomic_* and futex on that word. Zero-initialized storage
// still locks.
extern "C" {
int mocktail_bionic_pthread_rwlock_init(pthread_rwlock_t* rwlock,
+3 -2
View File
@@ -3,8 +3,9 @@
#include <semaphore.h>
// Bionic and glibc use different sem_t representations. These exports keep
// host semaphore objects out of guest storage and key them by guest address.
// Bionic sem_t is a count word in guest storage (value << 1, shared bit 0).
// Wait/post use __atomic_* and futex on that word so inlined guest futex
// waiters wake. Process-shared semaphores are rejected.
extern "C" {
int mocktail_bionic_sem_init(sem_t* semaphore, int process_shared,
+24 -6
View File
@@ -5,6 +5,7 @@
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cwchar>
@@ -17,12 +18,6 @@ namespace mocktail::compat {
constexpr size_t kBionicFileSize = 152;
constexpr size_t kBionicStandardStreamCount = 3;
FILE* TranslateBionicFile(FILE* stream) noexcept;
void* BionicFileArraySymbolAddress() noexcept;
void* BionicStdinSymbolAddress() noexcept;
void* BionicStdoutSymbolAddress() noexcept;
void* BionicStderrSymbolAddress() noexcept;
} // namespace mocktail::compat
extern "C" {
@@ -31,8 +26,31 @@ alignas(sizeof(void*)) extern unsigned char
__sF[mocktail::compat::kBionicStandardStreamCount]
[mocktail::compat::kBionicFileSize];
} // extern "C"
namespace mocktail::compat {
inline FILE* TranslateBionicFile(FILE* stream) noexcept {
const uintptr_t address = reinterpret_cast<uintptr_t>(stream);
if (__builtin_expect(address == reinterpret_cast<uintptr_t>(&__sF[0]), 0)) {
return stdin;
}
if (__builtin_expect(address == reinterpret_cast<uintptr_t>(&__sF[1]), 0)) {
return stdout;
}
if (__builtin_expect(address == reinterpret_cast<uintptr_t>(&__sF[2]), 0)) {
return stderr;
}
return stream;
}
void* BionicFileArraySymbolAddress() noexcept;
void* BionicStdinSymbolAddress() noexcept;
void* BionicStdoutSymbolAddress() noexcept;
void* BionicStderrSymbolAddress() noexcept;
} // namespace mocktail::compat
// FILE values returned by host fopen/fdopen pass through unchanged. Pointers
// into Bionic's ABI-sized __sF array are translated to the corresponding host
// standard stream at this single boundary.
+2
View File
@@ -54,6 +54,8 @@ ExternalHostAbiProfileResult InitializeHostAbiProfileFromEnvironment(
const HostAbiProfile* FindLoadedExternalHostAbiProfile(
std::string_view build_id) noexcept;
void SetHostAbiProfileHashingFaultForTesting(bool enabled) noexcept;
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_HOST_ABI_PROFILE_LOADER_H_
+175 -1
View File
@@ -59,6 +59,8 @@ struct RobloxCredentialView {
};
using RobloxCredentialProvider = RobloxCredentialView (*)(const void *context);
using RobloxCookieGetter = jstring (*)(JNIEnv* env, jclass clazz,
jstring domain);
jobject CreateAndroidConfiguration(JNIEnv *env);
@@ -80,6 +82,67 @@ struct FmodAudioDeviceCallbacks {
void (*shutdown)(void *context) = nullptr;
};
// Parameters passed by WebRtcAudioManager's constructor to the guest's
// nativeCacheAudioParameters(IIIZZZZZZZIIJ)V. Buffer sizes are PCM frames,
// not bytes. Querying them must not open a microphone.
struct WebRtcAudioManagerParameters {
int sample_rate_hz = 0;
int output_channels = 0;
int input_channels = 0;
bool hardware_aec = false;
bool hardware_agc = false;
bool hardware_ns = false;
bool low_latency_output = false;
bool low_latency_input = false;
bool pro_audio = false;
bool aaudio = false;
int output_buffer_size_frames = 0;
int input_buffer_size_frames = 0;
};
struct WebRtcAudioManagerCallbacks {
bool (*get_parameters)(void* context,
WebRtcAudioManagerParameters* parameters) = nullptr;
bool (*init)(void* context, const void* identity) = nullptr;
void (*dispose)(void* context, const void* identity) = nullptr;
void (*set_microphone_mute)(void* context, bool muted) = nullptr;
};
using WebRtcAudioRecordDataCallback = void (*)(void *context,
const void *identity,
std::size_t size_bytes);
// Host implementation of org/webrtc/voiceengine/WebRtcAudioRecord. Init
// returns the number of PCM frames in the stable direct buffer, or -1.
struct WebRtcAudioRecordCallbacks {
int (*init)(void *context, const void *identity, int sample_rate_hz,
int channels, WebRtcAudioRecordDataCallback data_callback,
void *data_context, void **direct_buffer,
std::size_t *direct_buffer_capacity) = nullptr;
bool (*start)(void *context, const void *identity) = nullptr;
bool (*stop)(void *context, const void *identity) = nullptr;
void (*close)(void *context, const void *identity) = nullptr;
void (*shutdown)(void *context) = nullptr;
};
using WebRtcAudioTrackDataCallback = void (*)(void *context,
const void *identity,
std::size_t size_bytes);
// Host implementation of org/webrtc/voiceengine/WebRtcAudioTrack. Init
// returns the emulated Android AudioTrack buffer size in bytes, or -1.
struct WebRtcAudioTrackCallbacks {
int (*init)(void *context, const void *identity, int sample_rate_hz,
int channels, double buffer_size_factor,
WebRtcAudioTrackDataCallback data_callback, void *data_context,
void **direct_buffer,
std::size_t *direct_buffer_capacity) = nullptr;
int (*buffer_size_frames)(void *context, const void *identity) = nullptr;
bool (*start)(void *context, const void *identity) = nullptr;
bool (*stop)(void *context, const void *identity) = nullptr;
void (*close)(void *context, const void *identity) = nullptr;
};
// Guest JNI threads only publish Android window flags. The host callback must
// marshal SDL work onto the main thread.
struct AndroidWindowCallbacks {
@@ -130,6 +193,11 @@ struct MessageBusRequestHandlerCallbacks {
std::string (*run)(void *context, JNIEnv *env, jstring message) = nullptr;
};
struct MessageBusAsyncRequestHandlerCallbacks {
void (*run)(void* context, JNIEnv* env, jstring message,
jstring response_id) = nullptr;
};
// Dispatch accepts Callback.onItemSet(String) only on a receiver created by
// this VM. The context stays alive while the callback runs.
struct MemStorageCallbackCallbacks {
@@ -194,10 +262,12 @@ public:
JavaVM *GetJavaVM() { return java_vm_; }
// Resolves the process pseudo-VM that owns an exact JavaVM pointer. Returns
// Resolves the live pseudo-VM that owns an exact JavaVM pointer. Returns
// null for foreign VMs and after the owner is destroyed.
static VM *FromJavaVM(JavaVM *java_vm);
// Binds this thread's JNI environment to this VM. Switching owners must
// not reuse the previous VM's native function table.
JNIEnv *GetJNIEnv();
std::size_t GetClassCount() const { return class_registry_.size(); }
@@ -228,6 +298,9 @@ public:
// retained privately and returned instead until the provider is cleared.
bool CopyRobloxCredentialFromProvider(std::string *credential) const;
void SetRobloxCookieGetter(RobloxCookieGetter getter);
bool RefreshRobloxCredentialFromEngine(JNIEnv* env);
void SetRobloxCredentialSink(std::shared_ptr<void> context,
const RobloxCredentialSinkCallbacks &callbacks);
void ClearRobloxCredentialSink();
@@ -250,6 +323,55 @@ public:
const std::uint8_t *data, std::size_t size);
bool DispatchFmodAudioDeviceClose(const void *identity);
void SetWebRtcAudioManagerCallbacks(
std::shared_ptr<void> context,
const WebRtcAudioManagerCallbacks& callbacks);
void ClearWebRtcAudioManagerCallbacks();
void RegisterWebRtcAudioManagerNative(const char* name, const char* signature,
void* function);
bool DispatchWebRtcAudioManagerConstruct(jobject manager,
jlong native_audio_manager);
bool DispatchWebRtcAudioManagerInit(jobject manager);
void DispatchWebRtcAudioManagerDispose(jobject manager);
void DispatchWebRtcAudioManagerMicrophoneMute(jobject manager, bool muted);
void
SetWebRtcAudioRecordCallbacks(std::shared_ptr<void> context,
const WebRtcAudioRecordCallbacks &callbacks);
void ClearWebRtcAudioRecordCallbacks();
int DispatchWebRtcAudioRecordInit(const void *identity, int sample_rate_hz,
int channels, void **direct_buffer,
std::size_t *direct_buffer_capacity);
bool DispatchWebRtcAudioRecordStart(const void *identity);
bool DispatchWebRtcAudioRecordStop(const void *identity);
void DispatchWebRtcAudioRecordClose(const void *identity);
void DispatchWebRtcAudioRecordData(const void *identity,
std::size_t size_bytes);
// RegisterNatives stores these exact WebRTC callbacks so the host capture
// thread can emulate the Java AudioRecord thread.
void RegisterWebRtcAudioRecordNative(const char *name, const char *signature,
void *function);
void SetWebRtcAudioTrackCallbacks(std::shared_ptr<void> context,
const WebRtcAudioTrackCallbacks &callbacks);
void ClearWebRtcAudioTrackCallbacks();
int DispatchWebRtcAudioTrackInit(const void *identity, int sample_rate_hz,
int channels, double buffer_size_factor,
void **direct_buffer,
std::size_t *direct_buffer_capacity);
int DispatchWebRtcAudioTrackBufferSizeFrames(const void *identity);
bool DispatchWebRtcAudioTrackStart(const void *identity);
bool DispatchWebRtcAudioTrackStop(const void *identity);
void DispatchWebRtcAudioTrackClose(const void *identity);
void DispatchWebRtcAudioTrackData(const void *identity,
std::size_t size_bytes);
// RegisterNatives stores the exact WebRTC playout callbacks so the host
// thread can emulate AudioTrackJavaThread.
void RegisterWebRtcAudioTrackNative(const char *name, const char *signature,
void *function);
void SetAndroidWindowCallbacks(std::shared_ptr<void> context,
const AndroidWindowCallbacks &callbacks);
void ClearAndroidWindowCallbacks();
@@ -278,6 +400,16 @@ public:
jstring DispatchMessageBusRequestHandler(jobject handler, JNIEnv *env,
jstring message);
// Exact RequestHandlerAsyncRaw.run(String, String) receiver. Clearing stops
// new calls; an in-flight call retains the host context until it returns.
jobject CreateMessageBusAsyncRequestHandler(
std::shared_ptr<void> context,
const MessageBusAsyncRequestHandlerCallbacks& callbacks);
void ClearMessageBusAsyncRequestHandler(jobject handler);
bool DispatchMessageBusAsyncRequestHandler(jobject handler, JNIEnv* env,
jstring message,
jstring response_id);
// Creates the exact com.roblox.engine.jni.memstorage.Callback receiver used
// by MemStorage.bind. Clear prevents future dispatch while an in-flight call
// retains its shared context until the callback returns.
@@ -338,6 +470,9 @@ private:
RobloxCredentialProvider roblox_credential_provider_ = nullptr;
std::string roblox_credential_override_;
mutable std::mutex roblox_cookie_getter_mutex_;
RobloxCookieGetter roblox_cookie_getter_ = nullptr;
struct RobloxCredentialSinkBinding {
std::shared_ptr<void> context;
RobloxCredentialSinkCallbacks callbacks;
@@ -352,6 +487,36 @@ private:
mutable std::mutex fmod_audio_device_mutex_;
FmodAudioDeviceBinding fmod_audio_device_binding_;
struct WebRtcAudioManagerBinding {
std::shared_ptr<void> context;
WebRtcAudioManagerCallbacks callbacks;
};
mutable std::mutex webrtc_audio_manager_mutex_;
WebRtcAudioManagerBinding webrtc_audio_manager_binding_;
void* webrtc_cache_audio_parameters_native_ = nullptr;
struct WebRtcAudioRecordBinding {
std::shared_ptr<void> context;
WebRtcAudioRecordCallbacks callbacks;
};
mutable std::mutex webrtc_audio_record_mutex_;
WebRtcAudioRecordBinding webrtc_audio_record_binding_;
mutable std::mutex webrtc_audio_record_native_mutex_;
void *webrtc_cache_direct_buffer_native_ = nullptr;
void *webrtc_data_is_recorded_native_ = nullptr;
struct WebRtcAudioTrackBinding {
std::shared_ptr<void> context;
WebRtcAudioTrackCallbacks callbacks;
};
mutable std::mutex webrtc_audio_track_mutex_;
WebRtcAudioTrackBinding webrtc_audio_track_binding_;
mutable std::mutex webrtc_audio_track_native_mutex_;
void *webrtc_track_cache_direct_buffer_native_ = nullptr;
void *webrtc_get_playout_data_native_ = nullptr;
struct AndroidWindowBinding {
std::shared_ptr<void> context;
AndroidWindowCallbacks callbacks;
@@ -379,6 +544,15 @@ private:
std::unordered_map<jobject, std::shared_ptr<MessageBusRequestHandlerBinding>>
message_bus_request_handler_bindings_;
struct MessageBusAsyncRequestHandlerBinding {
std::shared_ptr<void> context;
MessageBusAsyncRequestHandlerCallbacks callbacks;
};
mutable std::mutex message_bus_async_request_handler_mutex_;
std::unordered_map<jobject,
std::shared_ptr<MessageBusAsyncRequestHandlerBinding>>
message_bus_async_request_handler_bindings_;
struct MemStorageCallbackBinding {
std::shared_ptr<void> context;
MemStorageCallbackCallbacks callbacks;
@@ -0,0 +1,17 @@
#ifndef MOCKTAIL_LIBC_SHIM_VULKAN_ETC1_SKY_TRANSCODER_H_
#define MOCKTAIL_LIBC_SHIM_VULKAN_ETC1_SKY_TRANSCODER_H_
#include <cstdint>
#include <vector>
namespace libc_shim {
// Converts one opaque ETC1 RGB block to an opaque BC1 RGB block.
void ConvertEtc1RgbBlockToBc1(const uint8_t *etc1, uint8_t *bc1);
bool TranscodeEtc1SkyTextureForVulkan(const char *path,
std::vector<unsigned char> *data);
} // namespace libc_shim
#endif // MOCKTAIL_LIBC_SHIM_VULKAN_ETC1_SKY_TRANSCODER_H_
@@ -0,0 +1,76 @@
#ifndef MOCKTAIL_AUDIO_SDL_AUDIO_CAPTURE_H_
#define MOCKTAIL_AUDIO_SDL_AUDIO_CAPTURE_H_
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include "mocktail/audio/audio_sink.h"
namespace mocktail::audio {
struct SdlRecordingDevice {
std::uint32_t id = 0;
std::string name;
};
// The virtual `default` target is not returned. Device IDs are process-local;
// names are the stable configuration choice, while `id:<number>` disambiguates
// duplicate names for the current host session.
Status ListSdlRecordingDevices(std::vector<SdlRecordingDevice>* devices);
Status ResolveSdlRecordingDevice(
std::string_view requested,
const std::vector<SdlRecordingDevice>& available_devices,
std::uint32_t* recording_device_id, std::string* resolved_name);
// Must run after SDL audio initialization and before the first capture opens.
Status ConfigureSdlRecordingDevice(
std::string_view requested,
std::vector<SdlRecordingDevice>* available_devices,
std::string* resolved_name);
Status GetConfiguredSdlRecordingDevice(std::uint32_t* recording_device_id,
std::string* resolved_name);
using AudioCaptureDataCallback = void (*)(void* context,
std::size_t size_bytes);
class AudioCapture {
public:
virtual ~AudioCapture() = default;
virtual const PcmSpec& output_spec() const = 0;
virtual void* buffer_data() = 0;
virtual std::size_t buffer_size_bytes() const = 0;
virtual std::size_t frames_per_buffer() const = 0;
virtual Status Start() = 0;
virtual Status Stop() = 0;
virtual void Shutdown() = 0;
};
struct SdlAudioCaptureOptions {
PcmSpec output_spec;
// Zero selects the configured process target.
std::uint32_t recording_device_id = 0;
std::uint32_t frame_duration_ms = 10;
AudioCaptureDataCallback data_callback = nullptr;
void* data_context = nullptr;
};
// SDL converts the host input to output_spec and delivers fixed-duration
// frames through one stable buffer. The callback runs synchronously on SDL's
// recording thread after that buffer has been filled.
Status CreateSdlAudioCapture(const SdlAudioCaptureOptions& options,
std::unique_ptr<AudioCapture>* capture);
// Called by the SDL subsystem owner before SDL_QuitSubSystem.
Status PrepareSdlAudioCaptureSubsystemShutdown();
} // namespace mocktail::audio
#endif // MOCKTAIL_AUDIO_SDL_AUDIO_CAPTURE_H_
+7
View File
@@ -48,6 +48,13 @@ struct SdlAudioSinkOptions {
// Zero selects the configured process target.
std::uint32_t playback_device_id = 0;
bool start_paused = true;
// Optional pull provider for the host device. Each call fills one source
// buffer whose address remains valid until the next provider call. SDL
// copies as many buffers as the current device request needs.
bool (*data_needed_callback)(void *context, const void **data,
std::size_t *size_bytes) = nullptr;
void *data_needed_context = nullptr;
};
// SDL owns conversion, resampling, and borrowed-buffer consumption.
@@ -0,0 +1,21 @@
#ifndef MOCKTAIL_AUDIO_WEBRTC_JNI_AUDIO_BRIDGE_H_
#define MOCKTAIL_AUDIO_WEBRTC_JNI_AUDIO_BRIDGE_H_
#include "mocktail/status.h"
namespace jnivm {
class VM;
}
namespace mocktail::audio {
// Installs WebRtcAudioManager, WebRtcAudioRecord and WebRtcAudioTrack. The
// manager only advertises the host PCM contract; capture opens on recording
// init, never on manager construction. SDL's audio subsystem must remain alive
// until ShutdownWebRtcJniAudioBridge.
Status InstallWebRtcJniAudioBridge(jnivm::VM* vm);
Status ShutdownWebRtcJniAudioBridge(jnivm::VM* vm);
} // namespace mocktail::audio
#endif // MOCKTAIL_AUDIO_WEBRTC_JNI_AUDIO_BRIDGE_H_
@@ -19,30 +19,14 @@ inline constexpr AndroidVulkanResult kAndroidVulkanSuccess =
static_cast<AndroidVulkanResult>(0);
inline constexpr AndroidVulkanResult kAndroidVulkanErrorInitializationFailed =
static_cast<AndroidVulkanResult>(-3);
inline constexpr std::uint64_t kHostInfiniteWaitDiagnosticSliceNs =
1000000000ULL;
inline constexpr std::uint64_t kHostImageAcquireWatchdogTimeoutNs =
kHostInfiniteWaitDiagnosticSliceNs;
inline constexpr char kAndroidSurfaceExtension[] =
"VK_KHR_android_surface";
// Roblox treats VK_SUBOPTIMAL_KHR as a hard error, so expose it as success.
// VK_SUBOPTIMAL_KHR still returns a usable image. Roblox handles it as a fatal
// error, so accept it until the host reports a real out-of-date surface.
VkResult NormalizeAndroidSwapchainResult(VkResult result);
// Bounds only infinite acquire waits so a lost compositor wakeup cannot hang.
std::uint64_t BoundHostImageAcquireTimeout(std::uint64_t requested_timeout);
bool IsHostImageAcquireWatchdogTimeout(std::uint64_t requested_timeout,
VkResult host_result);
VkResult NormalizeHostImageAcquireResult(std::uint64_t requested_timeout,
VkResult host_result);
// Infinite guest waits use finite host slices without exposing slice timeouts.
std::uint64_t BoundHostSynchronizationWaitTimeout(
std::uint64_t requested_timeout);
bool ShouldContinueHostSynchronizationWait(std::uint64_t requested_timeout,
VkResult host_result);
// Returned storage owns the translated extension names.
Status TranslateAndroidVulkanInstanceExtensions(
const std::vector<std::string>& android_extensions,
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
#include <cstdint>
#include <string_view>
#include <vector>
@@ -17,10 +18,22 @@ enum class PresentModePolicy {
PresentModePolicy ResolvePresentModePolicy(std::string_view vsync_value,
std::string_view frame_rate_value);
// Reads MOCKTAIL_VSYNC / MOCKTAIL_FRAME_RATE_LIMIT once. Window pacing and
// the Vulkan adapter must use this instead of getenv'ing the same keys.
PresentModePolicy CachedPresentModePolicy();
std::vector<VkPresentModeKHR>
FilterPresentModes(PresentModePolicy policy,
const std::vector<VkPresentModeKHR> &host_modes);
const char *PresentModePolicyName(PresentModePolicy policy);
const char *PresentModeKhrName(VkPresentModeKHR mode);
// Extra images so acquire does not wait on the compositor-held buffer.
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
std::uint32_t requested,
std::uint32_t min_images,
std::uint32_t max_images);
} // namespace graphics
} // namespace mocktail
+78 -1
View File
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_
#define MOCKTAIL_PLATFORM_PLATFORM_RUNTIME_H_
#include <cmath>
#include <cstdint>
#include <string>
#include <variant>
@@ -26,7 +27,7 @@ struct WindowOptions {
int height = 720;
std::string title = "Roblox";
WindowSurfaceApi surface_api = WindowSurfaceApi::kAngleEgl;
bool high_pixel_density = true;
bool high_pixel_density = false;
bool initially_hidden = true;
// Production callers should keep this true. Tests using SDL's dummy driver
@@ -58,11 +59,69 @@ struct NativeWindowDescriptor {
struct QuitEvent {};
// Android exposes pointer and TextBox geometry in density-independent surface
// coordinates. SDL exposes pointer positions in logical window coordinates,
// while Vulkan presents to the pixel-sized drawable. Keep all three spaces in
// one value so input, IME placement and same-surface overlays use exact inverse
// transforms.
struct SurfaceCoordinateTransform {
int32_t logical_width = 0;
int32_t logical_height = 0;
int32_t pixel_width = 0;
int32_t pixel_height = 0;
float density_scale = 1.0F;
bool valid() const {
return logical_width > 0 && logical_height > 0 && pixel_width > 0 &&
pixel_height > 0 && std::isfinite(density_scale) &&
density_scale > 0.0F;
}
float guest_width() const {
return valid() ? static_cast<float>(pixel_width) / density_scale : 0.0F;
}
float guest_height() const {
return valid() ? static_cast<float>(pixel_height) / density_scale : 0.0F;
}
int32_t rounded_guest_width() const {
return valid() ? static_cast<int32_t>(std::lround(guest_width())) : 0;
}
int32_t rounded_guest_height() const {
return valid() ? static_cast<int32_t>(std::lround(guest_height())) : 0;
}
float HostLogicalToGuestX(float coordinate) const {
return valid() ? coordinate * static_cast<float>(pixel_width) /
static_cast<float>(logical_width) / density_scale
: coordinate;
}
float HostLogicalToGuestY(float coordinate) const {
return valid() ? coordinate * static_cast<float>(pixel_height) /
static_cast<float>(logical_height) / density_scale
: coordinate;
}
float GuestToHostLogicalX(float coordinate) const {
return valid() ? coordinate * density_scale *
static_cast<float>(logical_width) /
static_cast<float>(pixel_width)
: coordinate;
}
float GuestToHostLogicalY(float coordinate) const {
return valid() ? coordinate * density_scale *
static_cast<float>(logical_height) /
static_cast<float>(pixel_height)
: coordinate;
}
};
struct WindowResizedEvent {
int logical_width = 0;
int logical_height = 0;
int pixel_width = 0;
int pixel_height = 0;
// Android DisplayMetrics.density equivalent. This is independent of the
// logical-to-pixel drawable ratio on X11/XWayland.
float density_scale = 1.0F;
};
struct WindowFocusEvent {
@@ -128,9 +187,27 @@ struct TouchEvent {
float pressure = 0.0f;
};
enum class GamepadFamily {
kUnknown,
kXbox,
kPlayStation4,
kPlayStation5,
};
struct GamepadDescriptor {
GamepadFamily family = GamepadFamily::kUnknown;
std::string name;
// Bit positions are SDL's standardized gamepad buttons and axes, not raw
// joystick indices or Android key codes.
std::uint32_t buttons = 0;
std::uint32_t axes = 0;
};
struct GamepadConnectionEvent {
std::int64_t instance_id = 0;
bool connected = false;
GamepadDescriptor descriptor{};
bool remapped = false;
};
struct GamepadAxisEvent {
@@ -0,0 +1,45 @@
#ifndef MOCKTAIL_PLATFORM_SDL_GAMEPAD_MANAGER_H_
#define MOCKTAIL_PLATFORM_SDL_GAMEPAD_MANAGER_H_
#include <SDL3/SDL_gamepad.h>
#include <map>
#include "mocktail/platform/platform_runtime.h"
namespace mocktail {
namespace platform {
// Main-thread owner. Consumes events from the existing SDL pump; it never
// creates another event loop or consumes keyboard/window events.
class SdlGamepadManager final {
public:
using EventFn = void (*)(void*, const PlatformEvent&);
~SdlGamepadManager();
SdlGamepadManager() = default;
SdlGamepadManager(const SdlGamepadManager&) = delete;
SdlGamepadManager& operator=(const SdlGamepadManager&) = delete;
Status Initialize(void* context, EventFn emit);
void Shutdown();
bool HandleEvent(const PlatformEvent& event);
void ResendState();
private:
SDL_Gamepad* Open(SDL_JoystickID id);
void Remove(SDL_JoystickID id);
void Describe(SDL_JoystickID id, SDL_Gamepad* gamepad, bool remapped);
void EmitState(SDL_JoystickID id, SDL_Gamepad* gamepad);
void Emit(PlatformEventPayload payload);
std::map<SDL_JoystickID, SDL_Gamepad*> gamepads_;
void* context_ = nullptr;
EventFn emit_ = nullptr;
bool initialized_ = false;
};
} // namespace platform
} // namespace mocktail
#endif // MOCKTAIL_PLATFORM_SDL_GAMEPAD_MANAGER_H_
-45
View File
@@ -1,45 +0,0 @@
#ifndef MOCKTAIL_MOCKTAIL_SHA256_H_
#define MOCKTAIL_MOCKTAIL_SHA256_H_
#include <array>
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
namespace mocktail {
namespace foundation {
class Sha256 final {
public:
Sha256() = default;
~Sha256();
Sha256(const Sha256&) = delete;
Sha256& operator=(const Sha256&) = delete;
void Update(const unsigned char* bytes, std::size_t size);
void Update(std::string_view bytes);
std::string FinalHex();
private:
void Transform(const unsigned char* block);
std::array<std::uint32_t, 8> state_ = {
0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, 0xa54ff53aU,
0x510e527fU, 0x9b05688cU, 0x1f83d9abU, 0x5be0cd19U,
};
std::array<unsigned char, 64> block_{};
std::size_t block_bytes_ = 0;
std::uint64_t total_bytes_ = 0;
bool finalized_ = false;
};
// Returns the lowercase SHA-256 digest of the exact byte sequence, including
// embedded NUL bytes.
std::string ComputeSha256Hex(std::string_view bytes);
} // namespace foundation
} // namespace mocktail
#endif // MOCKTAIL_MOCKTAIL_SHA256_H_
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_AUTH_RUNTIME_COMPOSITION_H_
#define MOCKTAIL_RUNTIME_AUTH_RUNTIME_COMPOSITION_H_
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
@@ -98,6 +99,10 @@ AuthRuntimeComposition ComposeAuthRuntime(
services::AuthService& auth_service,
std::shared_ptr<services::HttpClient> live_auth_http_client);
// Persists a raw or prefixed Roblox credential to the given cookie file atomically.
bool PersistRobloxCookie(const std::filesystem::path& path,
std::string_view cookie_value);
} // namespace runtime
} // namespace mocktail
+3
View File
@@ -30,6 +30,9 @@ struct CommandLineOptions {
std::string launch_request_json;
int launch_argument_index = -1;
bool allow_unverified_build = false;
// Requests one explicit launch of the provider latest without promoting it
// into the managed payload state.
bool force_run_latest = false;
};
struct CommandLineParseResult {
+7 -2
View File
@@ -10,6 +10,7 @@ namespace runtime {
inline constexpr int kMaximumSupportedRobloxSchedulerFps = 240;
enum class FrameRateLimitMode {
kUnmanaged,
kDisplay,
kFixed,
kUnlimited,
@@ -17,7 +18,7 @@ enum class FrameRateLimitMode {
};
struct FrameRatePolicy {
FrameRateLimitMode mode = FrameRateLimitMode::kDisplay;
FrameRateLimitMode mode = FrameRateLimitMode::kUnmanaged;
int fixed_fps = 0;
bool valid() const { return mode != FrameRateLimitMode::kInvalid; }
@@ -25,7 +26,11 @@ struct FrameRatePolicy {
FrameRatePolicy ParseFrameRatePolicy(std::string_view value);
// Merges the scheduler policy into Roblox's supported client-settings ingress.
// Enables Roblox's Basic Settings frame-rate control and, when explicitly
// requested, merges a Mocktail scheduler policy into client settings.
// Unmanaged (-1) does not add a scheduler target, so Roblox owns the cap.
// Every positive fixed value is forwarded verbatim to
// DFIntTaskSchedulerTargetFps; it is not restricted to common refresh rates.
// Unlimited selects the unmodified payload's maximum scheduler target (240).
// With VSync auto/off, graphics policy separately requests an unthrottled
// Vulkan present mode when the host exposes one.
@@ -41,6 +41,9 @@ struct GameSurface {
uintptr_t native_window = 0;
uint32_t width = 0;
uint32_t height = 0;
// Host content scale for Android PlatformParams.dpiScale. Zero means the
// producer has no scale information and the JNI configuration is used.
float dpi_scale = 0.0f;
};
enum class GamePresentBackend {
+10
View File
@@ -1,13 +1,23 @@
#ifndef MOCKTAIL_RUNTIME_GRAPHICS_LAUNCH_POLICY_H_
#define MOCKTAIL_RUNTIME_GRAPHICS_LAUNCH_POLICY_H_
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
#include "runtime/runtime_config.h"
namespace mocktail {
namespace runtime {
// Readable ICD manifest for `vendor` that this build can actually load, or an
// empty path. Directories are searched in order; a manifest built for another
// architecture is never selected.
std::string SelectVulkanIcdManifest(
const std::vector<std::filesystem::path>& directories,
std::string_view vendor);
// Publishes the resolved graphics backend before the managed payload updater
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
// through ANGLE/Vulkan or accepts a window without a real graphics context.
+5 -1
View File
@@ -12,12 +12,16 @@ namespace runtime {
struct PayloadUpdatePreflightResult {
bool attempted = false;
std::string error;
// Last failure the updater reported on its own stderr. Empty when the
// updater never ran or never explained itself.
std::string details;
explicit operator bool() const { return error.empty(); }
};
PayloadUpdatePreflightResult RunPayloadUpdatePreflight(
const Environment& environment, const RuntimePaths& paths);
const Environment& environment, const RuntimePaths& paths,
bool force_run_latest = false);
} // namespace runtime
} // namespace mocktail
+8
View File
@@ -62,6 +62,14 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
std::string* merged_json,
std::string* error);
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
// answers microphone authorization from capture configuration. No permission
// query opens a recording device.
bool MergeAudioCaptureClientSettingsOverrides(bool microphone_enabled,
std::string_view base_json,
std::string* merged_json,
std::string* error);
} // namespace runtime
} // namespace mocktail
@@ -1,7 +1,9 @@
#ifndef MOCKTAIL_RUNTIME_PLATFORM_CACHE_MIGRATION_H_
#define MOCKTAIL_RUNTIME_PLATFORM_CACHE_MIGRATION_H_
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
@@ -37,6 +39,25 @@ PlatformCacheMigrationResult MigratePlatformProfileCaches(
const Environment& environment, const RuntimePaths& paths,
std::string_view desired_revision);
struct RobloxThemeCacheResult {
std::optional<bool> dark_theme;
std::string error;
explicit operator bool() const { return error.empty(); }
};
// Reads the current account's Roblox theme without changing appStorage. A
// missing value leaves dark_theme empty so the caller can use Roblox's own
// startup default.
RobloxThemeCacheResult ReadRobloxThemeCache(
const std::filesystem::path& app_storage_file,
std::int64_t authenticated_user_id);
// Synchronizes only Roblox's local theme keys with the resolved host theme.
bool ApplyRobloxThemeCacheOverride(
const std::filesystem::path& app_storage_file,
std::int64_t authenticated_user_id, bool dark_theme, std::string* error);
} // namespace runtime
} // namespace mocktail
+4 -3
View File
@@ -20,14 +20,15 @@ struct DesktopAppPolicyResult {
explicit operator bool() const { return error.empty(); }
};
// Normalizes only Roblox UniversalApp layout coordinates for a PC profile.
// Account eligibility and feature-entitlement fields remain server-owned.
// Normalizes Roblox UniversalApp layout coordinates for a PC profile and
// applies a fixed theme only when configured. Account eligibility and
// feature-entitlement fields remain server-owned.
// The guest and authenticated cache keys are both populated so user-switch
// startup cannot fall back to the Android Unknown/mobile layout.
DesktopAppPolicyResult ApplyDesktopAppPolicy(
const std::filesystem::path& app_storage_file,
const std::filesystem::path& default_policy_file,
std::int64_t authenticated_user_id);
std::int64_t authenticated_user_id, std::string_view theme_mode);
// Routes the normalized policy through Roblox's supported runtime override so
// a later GUAC response cannot replace the in-memory desktop layout with the
@@ -9,6 +9,7 @@
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include "mocktail/status.h"
#include "runtime/owned_pthread.h"
@@ -16,6 +17,7 @@
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_experience_presence.h"
#include "runtime/roblox_fresh_game_launch_controller.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
#include "runtime/webview_roblox_cookie.h"
@@ -55,6 +57,10 @@ struct RobloxExperienceJniFactory {
void (*on_sync_cookies)(void*, JNIEnv*, jstring),
void (*on_set_cookie)(void*, JNIEnv*, jstring, jstring)) = nullptr;
void (*clear_platform_web_callbacks)(void* context) = nullptr;
jobject (*create_async_request_handler)(
void* context, std::shared_ptr<void> callback_context,
void (*run)(void*, JNIEnv*, jstring, jstring)) = nullptr;
void (*clear_async_request_handler)(void* context, jobject handler) = nullptr;
bool complete() const {
return context != nullptr && create_raw_callback != nullptr &&
@@ -63,7 +69,9 @@ struct RobloxExperienceJniFactory {
create_mem_storage_callback != nullptr &&
clear_mem_storage_callback != nullptr &&
set_platform_web_callbacks != nullptr &&
clear_platform_web_callbacks != nullptr;
clear_platform_web_callbacks != nullptr &&
create_async_request_handler != nullptr &&
clear_async_request_handler != nullptr;
}
};
@@ -103,6 +111,7 @@ class RobloxExperienceComposition final {
RobloxExperienceMessageBusSymbols message_bus_symbols,
RobloxWebViewMessageBusSymbols web_view_symbols,
RobloxBrowserServiceSymbols browser_service_symbols,
RobloxPermissionsMessageBusSymbols permissions_symbols,
RobloxGameSessionSymbols game_symbols,
RobloxExperienceJniFactory jni_factory,
RobloxFreshLaunchPresentBoundary present_boundary,
@@ -110,7 +119,8 @@ class RobloxExperienceComposition final {
const SecureRobloxCredential* initial_web_view_credential = nullptr,
RobloxExperienceSurfaceProvider surface_provider = {},
RobloxExperiencePresenceObserver presence_observer = {},
bool clear_persisted_web_view_cookie = false);
bool clear_persisted_web_view_cookie = false,
bool microphone_enabled = false);
~RobloxExperienceComposition();
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
@@ -184,6 +194,7 @@ class RobloxExperienceComposition final {
const std::shared_ptr<WebViewHelperProcess>& source_process,
uint64_t process_generation, uint64_t logical_generation,
const WebViewHelperEvent& event);
Status AcceptWebViewRobloxCookie(std::string_view value);
Status OpenWebSurface(const std::string& url, const char* transport,
WebSurfaceRoute route,
WebViewHelperExitObserver exit_observer,
@@ -217,6 +228,8 @@ class RobloxExperienceComposition final {
const RobloxExperienceMessageBusSymbols message_bus_symbols_;
const RobloxWebViewMessageBusSymbols web_view_symbols_;
const RobloxBrowserServiceSymbols browser_service_symbols_;
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
const bool microphone_enabled_;
const RobloxGameSessionSymbols game_symbols_;
const RobloxExperienceJniFactory jni_factory_;
const RobloxFreshLaunchPresentBoundary present_boundary_;
@@ -234,6 +247,7 @@ class RobloxExperienceComposition final {
std::unique_ptr<RobloxExperienceLaunchBridge> bridge_;
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
std::shared_ptr<LifecycleTarget> lifecycle_target_;
@@ -192,7 +192,8 @@ class RobloxGameSessionNativeAdapter final {
Status RebindAndInvoke(const GameSurface& surface, bool resume);
Status CreateSurfaceObjectLocked(JNIEnv* env, const GameSurface& surface,
jobject* result) const;
Status CreatePlatformParamsLocked(JNIEnv* env, jobject* result) const;
Status CreatePlatformParamsLocked(JNIEnv* env, const GameSurface& surface,
jobject* result) const;
Status CheckJniException(JNIEnv* env, const char* operation) const;
void ReleaseGlobalReferenceLocked(JNIEnv* env, jobject reference);
void ReleaseGlobalReferencesLocked(JNIEnv* env);
@@ -3,6 +3,7 @@
#include <jni.h>
#include <atomic>
#include <cstddef>
#include <mutex>
#include <string>
@@ -32,6 +33,7 @@ class RobloxInputNativeAdapter final {
Status Initialize();
Status Release();
bool initialized() const;
bool SupportsGamepads() const;
Status GetMainWindowIsMouseLockedCenter(bool* locked_center);
Status QueryCurrentTextBoxInfo(RobloxNativeTextBoxInfoQueryResult* result);
RobloxInputSink Sink();
@@ -48,6 +50,19 @@ class RobloxInputNativeAdapter final {
int32_t height);
static Status KeyCallback(void* context, bool pressed, int32_t scan_code,
int32_t key_code, bool repeat);
static Status GamepadSupportedKeyCallback(void* context, int32_t device_id,
int32_t key, bool supported,
int32_t type);
static Status GamepadSupportedMotionCallback(void* context, int32_t device_id,
int32_t axis, int32_t direction,
bool supported, int32_t type);
static Status GamepadConnectCallback(void* context, int32_t device_id,
int32_t type);
static Status GamepadDisconnectCallback(void* context, int32_t device_id);
static Status GamepadButtonCallback(void* context, int32_t device_id,
int32_t key, bool pressed);
static Status GamepadAxisCallback(void* context, int32_t device_id,
int32_t axis, float x, float y, float z);
static Status SyncTextCallback(void* context, const char* utf8,
std::size_t size, int32_t cursor_utf16);
static Status PassTextCallback(void* context, int64_t textbox_handle,
@@ -69,10 +84,12 @@ class RobloxInputNativeAdapter final {
Status ReleaseFocus(int64_t textbox_handle);
Status CheckJniException(JNIEnv* env, const char* operation) const;
Status RequireReadyLocked(JNIEnv** env) const;
Status AcquireReadyEnv(JNIEnv** env) const;
const JniEnvironmentProvider environment_;
const RobloxInputSymbols symbols_;
mutable std::mutex mutex_;
std::atomic<bool> ready_{false};
jclass native_input_class_ = nullptr;
jclass native_gl_class_ = nullptr;
bool initialized_ = false;
@@ -105,6 +122,7 @@ class RobloxInputRuntime final {
RobloxInputDispatchResult HandleEvent(const platform::PlatformEvent& event);
RobloxInputSnapshot Snapshot() const;
Status GetMainWindowIsMouseLockedCenter(bool* locked_center);
bool SupportsGamepads() const;
static void PlatformEventCallback(void* context,
const platform::PlatformEvent& event);
@@ -121,6 +139,7 @@ class RobloxInputRuntime final {
bool logged_text_ = false;
bool logged_text_unsupported_ = false;
bool logged_native_error_ = false;
bool logged_gamepad_ = false;
};
} // namespace runtime
+65 -2
View File
@@ -1,6 +1,8 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_
#define MOCKTAIL_RUNTIME_ROBLOX_INPUT_ROUTER_H_
#include <array>
#include <cmath>
#include <cstdint>
#include <mutex>
#include <vector>
@@ -17,10 +19,12 @@ struct RobloxInputViewport {
int32_t logical_height = 0;
int32_t pixel_width = 0;
int32_t pixel_height = 0;
float density_scale = 1.0F;
bool valid() const {
return logical_width > 0 && logical_height > 0 && pixel_width > 0 &&
pixel_height > 0;
pixel_height > 0 && std::isfinite(density_scale) &&
density_scale > 0.0F;
}
};
@@ -34,12 +38,39 @@ struct AndroidKeyMapping {
// Maps SDL's USB scancode to the Linux evdev scan code and Android KeyEvent
// keycode observed by the APK. Unmapped keys are rejected rather than passed
// through with an ABI-incompatible numeric value.
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode);
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode, uint32_t sdl_key = 0);
// Mirrors the APK's MotionEvent.getActionButton() - 1 conversion. SDL button
// numbers are not numerically compatible with Android button bit values.
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button);
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
int32_t RobloxGamepadType(platform::GamepadFamily family);
struct RobloxGamepadSink {
using SupportedKeyFn = Status (*)(void*, int32_t, int32_t, bool, int32_t);
using SupportedMotionFn = Status (*)(void*, int32_t, int32_t, int32_t, bool,
int32_t);
using ConnectFn = Status (*)(void*, int32_t, int32_t);
using DisconnectFn = Status (*)(void*, int32_t);
using ButtonFn = Status (*)(void*, int32_t, int32_t, bool);
using AxisFn = Status (*)(void*, int32_t, int32_t, float, float, float);
void* context = nullptr;
SupportedKeyFn supported_key = nullptr;
SupportedMotionFn supported_motion = nullptr;
ConnectFn connect = nullptr;
DisconnectFn disconnect = nullptr;
ButtonFn button = nullptr;
AxisFn axis = nullptr;
bool valid() const {
return supported_key != nullptr && supported_motion != nullptr &&
connect != nullptr && disconnect != nullptr && button != nullptr &&
axis != nullptr;
}
};
struct RobloxInputSink {
using MouseMoveFn = Status (*)(void* context, float x, float y, float delta_x,
float delta_y);
@@ -60,6 +91,7 @@ struct RobloxInputSink {
TouchFn touch = nullptr;
KeyFn key = nullptr;
RobloxTextSink text;
RobloxGamepadSink gamepad{};
};
enum class RobloxInputEventKind {
@@ -72,6 +104,9 @@ enum class RobloxInputEventKind {
kText,
kFocus,
kViewport,
kGamepadConnection,
kGamepadButton,
kGamepadAxis,
};
enum class RobloxInputDispatchState {
@@ -107,6 +142,9 @@ struct RobloxInputSnapshot {
uint32_t active_mouse_buttons = 0;
uint32_t active_touches = 0;
uint32_t active_keys = 0;
uint64_t gamepad_events = 0;
uint32_t detected_gamepads = 0;
uint32_t connected_gamepads = 0;
};
// Stateful translation boundary from SDL platform events to the exact
@@ -146,6 +184,16 @@ class RobloxInputRouter final {
AndroidKeyMapping android;
};
struct ActiveGamepad {
int64_t instance_id = 0;
int32_t device_id = 0;
platform::GamepadDescriptor descriptor;
bool connected = false;
uint32_t pressed_buttons = 0;
std::array<int16_t, 6> raw_axes{};
std::array<float, 6> sent_axes{};
};
RobloxInputDispatchResult HandleMouseMotionLocked(
const platform::MouseMotionEvent& event);
RobloxInputDispatchResult HandleMouseButtonLocked(
@@ -155,6 +203,20 @@ class RobloxInputRouter final {
RobloxInputDispatchResult HandleTouchLocked(
const platform::TouchEvent& event);
RobloxInputDispatchResult HandleKeyLocked(const platform::KeyEvent& event);
RobloxInputDispatchResult HandleGamepadConnectionLocked(
const platform::GamepadConnectionEvent& event);
RobloxInputDispatchResult HandleGamepadButtonLocked(
const platform::GamepadButtonEvent& event);
RobloxInputDispatchResult HandleGamepadAxisLocked(
const platform::GamepadAxisEvent& event);
std::vector<ActiveGamepad>::iterator FindGamepadLocked(int64_t instance_id);
Status ConfigureGamepadLocked(const ActiveGamepad& gamepad, bool available);
Status ConnectGamepadLocked(ActiveGamepad& gamepad);
Status DispatchGamepadAxesLocked(ActiveGamepad& gamepad, uint8_t axis,
bool* dispatched);
Status ReleaseGamepadInputsLocked(ActiveGamepad& gamepad);
Status DisconnectGamepadLocked(ActiveGamepad& gamepad);
Status DisconnectGamepadsLocked();
Status ReleasePressedInputsLocked();
int32_t AllocatePointerIdLocked() const;
std::vector<ActiveTouch>::iterator FindTouchIteratorLocked(int64_t touch_id,
@@ -170,6 +232,7 @@ class RobloxInputRouter final {
std::vector<int32_t> active_mouse_buttons_;
std::vector<ActiveTouch> active_touches_;
std::vector<ActiveKey> active_keys_;
std::vector<ActiveGamepad> gamepads_;
float mouse_x_ = 0.0f;
float mouse_y_ = 0.0f;
};
@@ -0,0 +1,85 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#include <jni.h>
#include <memory>
#include <mutex>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
namespace mocktail {
namespace runtime {
// Exact MessageBus JNI entrypoints used by the APK PermissionsProtocol.
struct RobloxPermissionsMessageBusSymbols {
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
jboolean) = nullptr;
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) = nullptr;
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return subscribe_request && delete_connection && publish_response &&
set_async_handler && clear_handler && call_response_handler;
}
};
struct RobloxPermissionsMessageBusObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring)) = nullptr;
void (*clear_raw_callback)(void*, jobject) = nullptr;
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring,
jstring)) = nullptr;
void (*clear_async_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_raw_callback &&
clear_raw_callback && create_async_handler && clear_async_handler;
}
};
// Installs both legacy protocol subscriptions and correlated async handlers
// before native bootstrap. MICROPHONE_ACCESS follows host capture
// configuration; LOCAL_NETWORK needs no extra runtime permission, matching the
// Android APK. No query/request opens an audio device, enables a disabled
// microphone, or changes Roblox account eligibility. Other capabilities remain
// unsupported.
class RobloxPermissionsBridge final {
public:
RobloxPermissionsBridge(JniEnvironmentProvider environment,
RobloxPermissionsMessageBusSymbols symbols,
RobloxPermissionsMessageBusObjects objects,
bool microphone_enabled);
~RobloxPermissionsBridge();
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxPermissionsMessageBusSymbols symbols_;
const RobloxPermissionsMessageBusObjects objects_;
const bool microphone_enabled_;
// Serializes lifecycle only. Native dispatch never holds this mutex.
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
@@ -2,6 +2,7 @@
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
namespace mocktail {
@@ -13,9 +14,11 @@ namespace runtime {
struct RobloxPlatformWebSymbols {
RobloxWebViewMessageBusSymbols web_view;
RobloxBrowserServiceSymbols browser_service;
RobloxPermissionsMessageBusSymbols permissions;
bool complete() const {
return web_view.complete() && browser_service.complete();
return web_view.complete() && browser_service.complete() &&
permissions.complete();
}
};
+11 -1
View File
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_DISPLAY_STATE_H_
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <string>
@@ -14,8 +15,14 @@ namespace runtime {
struct RobloxTextOverlayViewport {
int width = 0;
int height = 0;
float density_scale = 1.0F;
bool valid() const { return width > 0 && height > 0; }
bool valid() const {
return width > 0 && height > 0 && std::isfinite(density_scale) &&
density_scale > 0.0F;
}
int coordinate_width() const;
int coordinate_height() const;
};
struct RobloxTextOverlayGeometry {
@@ -57,10 +64,13 @@ struct RobloxTextOverlayPresentation {
uint64_t generation = 0;
RobloxTextOverlayGeometry geometry;
float font_size = 0.0F;
bool multiline = false;
int32_t x_alignment = 0;
int32_t y_alignment = 1;
int32_t text_color = 0;
int32_t font = 0;
int32_t text_input_type = 0;
bool text_wrapped = false;
std::string display_utf8;
std::size_t caret_utf8_byte = 0;
std::size_t selection_begin_utf8_byte = 0;
+3
View File
@@ -67,10 +67,13 @@ struct RobloxTextDisplayUpdate {
int32_t area_width = 0;
int32_t area_height = 0;
float font_size = 0.0F;
bool multiline = false;
int32_t x_alignment = 0;
int32_t y_alignment = 1;
int32_t text_color = 0;
int32_t font = 0;
int32_t text_input_type = 0;
bool text_wrapped = false;
const char* utf8 = nullptr;
std::size_t utf8_size = 0;
int32_t cursor_utf16 = 0;
@@ -0,0 +1,26 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
#define MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
#include <cstdint>
#include <string>
namespace mocktail {
namespace runtime {
// Android's RbxKeyboard resolves the Roblox Font enum through
// assets/android/fonts/font-mappings.json. The selected file lives below
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
struct RobloxTextFontSelection {
std::string primary_file;
float size_scale = 0.795F;
bool exact_mapping = false;
};
RobloxTextFontSelection ResolveRobloxTextFont(
int32_t font, const std::string& asset_content_root);
RobloxTextFontSelection ResolveRobloxTextFont(int32_t font);
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_TEXT_FONT_RESOLVER_H_
@@ -27,6 +27,7 @@ class RobloxTextSurfaceOverlay final {
RobloxTextSurfaceOverlay& operator=(const RobloxTextSurfaceOverlay&) = delete;
Status Initialize(RobloxTextOverlayViewport viewport);
Status UpdateViewport(RobloxTextOverlayViewport viewport);
Status Shutdown();
RobloxTextDisplaySink sink();
@@ -3,6 +3,7 @@
#include <memory>
#include "mocktail/platform/sdl_gamepad_manager.h"
#include "mocktail/platform/text_clipboard.h"
#include "mocktail/status.h"
#include "runtime/roblox_input_native_adapter.h"
@@ -37,11 +38,16 @@ class RobloxWindowInputRuntime final {
RobloxInputSnapshot Snapshot() const;
private:
static void PlatformEventCallback(void* context,
const platform::PlatformEvent& event);
static bool MouseLockQueryCallback(void* context, bool* locked_center);
static void GamepadEventCallback(void* context,
const platform::PlatformEvent& event);
RobloxTextSurfaceOverlay text_surface_overlay_;
std::unique_ptr<platform::TextClipboard> text_clipboard_;
RobloxInputRuntime runtime_;
platform::SdlGamepadManager gamepads_;
bool observer_registered_ = false;
bool mouse_lock_query_registered_ = false;
bool initialized_ = false;
+26 -1
View File
@@ -28,6 +28,10 @@ struct WindowConfig {
int width = 1280;
int height = 720;
std::string title = "Roblox";
// GNOME desktop dimensions are logical units; keep the render surface at
// that size unless the user explicitly requests high-density rendering.
bool high_dpi = false;
bool high_dpi_valid = true;
};
struct InputCapabilityConfig {
@@ -37,6 +41,7 @@ struct InputCapabilityConfig {
};
struct NetworkProxyConfig {
std::string scheme = "http";
std::string host;
int port = 0;
};
@@ -61,7 +66,8 @@ struct DiscordRpcConfig {
};
std::optional<NetworkProxyConfig> ParseNetworkProxyConfig(
std::string_view host, std::string_view port);
std::string_view host, std::string_view port,
std::string_view scheme = "http");
std::string BuildNetworkProxyUrl(const NetworkProxyConfig& proxy);
// Immutable, supported runtime options. Advanced MOCKTAIL_PATCH_* controls
@@ -79,6 +85,11 @@ class RuntimeConfig {
return graphics_backend_name_;
}
const WindowConfig& window() const { return window_; }
const std::string& theme_mode() const { return theme_mode_; }
bool theme_mode_valid() const {
return theme_mode_ == "roblox" || theme_mode_ == "system" ||
theme_mode_ == "light" || theme_mode_ == "dark";
}
const InputCapabilityConfig& input_capabilities() const {
return input_capabilities_;
}
@@ -97,9 +108,17 @@ class RuntimeConfig {
return audio_output_device_;
}
bool audio_output_device_valid() const { return audio_output_device_valid_; }
const std::string& audio_input_device() const { return audio_input_device_; }
bool audio_input_device_valid() const { return audio_input_device_valid_; }
bool microphone_enabled() const { return audio_input_device_ != "disabled"; }
const std::optional<NetworkProxyConfig>& network_proxy() const {
return network_proxy_;
}
const std::optional<std::filesystem::path>& ca_bundle() const {
return ca_bundle_;
}
bool ca_bundle_valid() const { return ca_bundle_valid_; }
bool use_system_proxy() const { return use_system_proxy_; }
const DiscordRpcConfig& discord_rpc() const { return discord_rpc_; }
bool discord_rpc_valid() const { return discord_rpc_valid_; }
@@ -120,6 +139,7 @@ class RuntimeConfig {
GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan;
std::string graphics_backend_name_ = "direct-vulkan";
WindowConfig window_;
std::string theme_mode_ = "roblox";
InputCapabilityConfig input_capabilities_;
DeviceProfile device_profile_ = *FindDeviceProfile(kDefaultDeviceProfileName);
bool device_profile_valid_ = true;
@@ -129,7 +149,12 @@ class RuntimeConfig {
PerformancePolicy performance_;
std::string audio_output_device_ = "default";
bool audio_output_device_valid_ = true;
std::string audio_input_device_ = "default";
bool audio_input_device_valid_ = true;
bool use_system_proxy_ = false;
std::optional<NetworkProxyConfig> network_proxy_;
std::optional<std::filesystem::path> ca_bundle_;
bool ca_bundle_valid_ = true;
DiscordRpcConfig discord_rpc_;
bool discord_rpc_valid_ = true;
std::vector<std::string> unsafe_detached_thread_overrides_;
+33
View File
@@ -0,0 +1,33 @@
#ifndef MOCKTAIL_RUNTIME_SYSTEM_PROXY_H_
#define MOCKTAIL_RUNTIME_SYSTEM_PROXY_H_
#include <optional>
#include <string>
#include <vector>
#include "runtime/runtime_config.h"
namespace mocktail {
namespace runtime {
struct SystemProxyResult {
std::optional<NetworkProxyConfig> proxy;
std::string error;
explicit operator bool() const { return error.empty(); }
};
// Selects the first proxy returned by the host resolver. An empty `proxy`
// means that the resolver selected a direct connection.
SystemProxyResult SelectSystemProxy(
const std::vector<std::string>& candidates);
// Resolves the host's proxy policy for Roblox HTTPS traffic. GIO delegates to
// the desktop/environment proxy resolver, including PAC when it is available.
// HTTP/HTTPS resolver results and SOCKS5 proxy endpoints are supported.
SystemProxyResult ResolveSystemProxy();
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_SYSTEM_PROXY_H_
+32
View File
@@ -0,0 +1,32 @@
#ifndef MOCKTAIL_RUNTIME_TEXTURE_MEMORY_POLICY_H_
#define MOCKTAIL_RUNTIME_TEXTURE_MEMORY_POLICY_H_
#include <cstdint>
#include <string>
#include <string_view>
namespace mocktail {
namespace runtime {
// Roblox sizes its texture budget for the Android guest, so a PC host still
// gets caps.videoMemory = 64 MiB. These publish a host-sized budget through
// Roblox's own FIntRenderForceVideoMemorySize override.
// Total usable host memory in bytes, or 0 when it cannot be read.
std::uint64_t DetectHostMemoryBytes();
// 0 leaves Roblox's budget alone; otherwise an eighth of host memory, clamped
// to a PC-class range.
std::uint64_t CalculateTextureMemoryBudgetBytes(std::uint64_t host_memory_bytes);
// A 0 budget, or a key already present, is preserved: an explicit fflags.json
// or environment override wins.
bool MergeTextureMemoryClientSettingsOverrides(std::uint64_t budget_bytes,
std::string_view base_json,
std::string* merged_json,
std::string* error);
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_TEXTURE_MEMORY_POLICY_H_
@@ -53,6 +53,7 @@ enum class WebViewHelperEventType : unsigned char {
kExecuteRoblox = 1,
kRobloxWkHybrid = 2,
kReady = 3,
kRobloxCookie = 4,
};
struct WebViewHelperEvent {
+69
View File
@@ -0,0 +1,69 @@
#ifndef MOCKTAIL_UPDATE_APK_PROVIDER_H_
#define MOCKTAIL_UPDATE_APK_PROVIDER_H_
#include <cstdint>
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace mocktail::update {
struct ProviderVersion {
std::string version_name;
std::uint64_t version_code = 0;
std::string error;
explicit operator bool() const { return error.empty(); }
};
struct ProviderDownloadResult {
std::vector<std::filesystem::path> archives;
// Provider that produced the archives, so a stored payload can be traced
// back to where it came from.
std::string source;
std::string error;
explicit operator bool() const { return error.empty(); }
};
// A source of Roblox APK archives. Providers are interchangeable: whichever
// one answers first wins, and every archive still passes the same signature,
// Build-ID, and ABI verification afterwards.
class ApkProvider {
public:
virtual ~ApkProvider() = default;
// Short stable identifier, recorded in payload receipts and error messages.
virtual std::string_view name() const = 0;
// Latest published version. A provider that only serves pinned versions
// reports an error here and stays usable through DownloadExact.
virtual ProviderVersion CheckLatest() const = 0;
virtual ProviderDownloadResult DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd) const = 0;
};
// Tries every provider in order. One unreachable provider is the most common
// first-run failure, and on its own it must not fail the whole update.
class ProviderChain final : public ApkProvider {
public:
void Add(std::unique_ptr<ApkProvider> provider);
bool empty() const { return providers_.empty(); }
std::string_view name() const override { return "provider-chain"; }
ProviderVersion CheckLatest() const override;
ProviderDownloadResult DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd) const override;
private:
std::vector<std::unique_ptr<ApkProvider>> providers_;
};
} // namespace mocktail::update
#endif // MOCKTAIL_UPDATE_APK_PROVIDER_H_
+7 -19
View File
@@ -1,42 +1,30 @@
#ifndef MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_
#define MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
#include "update/apk_provider.h"
namespace mocktail::update {
struct ProviderVersion {
std::string version_name;
std::uint64_t version_code = 0;
std::string error;
explicit operator bool() const { return error.empty(); }
};
struct ProviderDownloadResult {
std::vector<std::filesystem::path> archives;
std::string error;
explicit operator bool() const { return error.empty(); }
};
ProviderVersion ParseApkPureLatestMetadata(std::string_view metadata);
std::vector<std::string> ParseApkPureExactDownloadUrls(
std::string_view metadata, std::string_view version,
std::string* error = nullptr);
class ApkPureProvider final {
class ApkPureProvider final : public ApkProvider {
public:
ProviderVersion CheckLatest() const;
std::string_view name() const override { return "apk-pure"; }
ProviderVersion CheckLatest() const override;
ProviderDownloadResult DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd = -1) const;
int progress_fd = -1) const override;
};
} // namespace mocktail::update
+18
View File
@@ -3,12 +3,14 @@
#include <filesystem>
#include <string>
#include <vector>
namespace mocktail::update {
struct HostAbiDerivationOptions {
std::filesystem::path reference_library;
std::filesystem::path reference_profile;
std::vector<std::filesystem::path> reference_compatibility_manifests;
std::filesystem::path candidate_payload_directory;
std::filesystem::path output_directory;
};
@@ -23,6 +25,22 @@ struct HostAbiDerivationResult {
}
};
// Identity an exact HostAbi sidecar was derived for. It only validates
// against the one library whose bytes it describes, so the reference payload
// has to be selected by this Build ID rather than by catalog order.
struct HostAbiSidecarIdentity {
std::string elf_build_id;
std::string payload_id;
std::string error;
explicit operator bool() const {
return error.empty() && !elf_build_id.empty() && !payload_id.empty();
}
};
HostAbiSidecarIdentity ReadHostAbiSidecarIdentity(
const std::filesystem::path& sidecar);
// Derives an exact candidate HostAbi profile by matching normalized x86-64
// instruction signatures against one already approved reference payload. This
// is static analysis only; the result still requires two real Tier C canaries
+11
View File
@@ -15,7 +15,18 @@ struct HttpTransferRequest {
std::vector<std::string> allowed_hosts;
std::size_t maximum_bytes = 0;
long connect_timeout_ms = 10000;
// Hard ceiling for one attempt; zero means none. A several hundred MiB
// archive over a slow link is not a failed transfer, so the ceiling is the
// wrong tool for it - a transfer that actually stops is caught by the
// low-speed guard below, in a minute rather than in a quarter of an hour.
long transfer_timeout_ms = 120000;
long low_speed_bytes_per_second = 1024;
long low_speed_seconds = 60;
// Transient failures - a provider 503, a dropped connection, a stall - are
// retried. A file transfer resumes from the bytes already on disk instead
// of pulling the whole archive again.
int maximum_attempts = 3;
long retry_delay_ms = 2000;
int maximum_redirects = 5;
};
+23
View File
@@ -5,9 +5,32 @@
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
struct evp_md_ctx_st;
namespace mocktail::update {
class FileDigest final {
public:
FileDigest();
~FileDigest();
FileDigest(const FileDigest&) = delete;
FileDigest& operator=(const FileDigest&) = delete;
bool valid() const noexcept { return context_ != nullptr; }
bool Update(const void* data, std::size_t size);
bool Update(std::string_view bytes);
std::string FinalHex(std::size_t max_chars = 0);
private:
struct evp_md_ctx_st* context_ = nullptr;
};
std::string HashText(std::string_view text, std::string* error = nullptr);
std::string HashText(std::string_view text, std::size_t max_hex_chars,
std::string* error = nullptr);
struct PayloadMetadata {
std::string package_name;
std::string version_name;
+13
View File
@@ -5,6 +5,7 @@
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace mocktail::update {
@@ -24,6 +25,14 @@ struct PayloadStoreResult {
}
};
struct PayloadGarbageResult {
std::vector<std::string> removed;
std::uintmax_t freed_bytes = 0;
std::string error;
explicit operator bool() const { return error.empty(); }
};
class PayloadStore final {
public:
PayloadStore(std::filesystem::path root,
@@ -41,6 +50,10 @@ class PayloadStore final {
PayloadStoreResult InspectCurrent() const;
PayloadStoreResult VerifyCurrent();
PayloadStoreResult Rollback();
// Deletes payload trees nothing points at any more. The active payload, the
// rollback target, and every ID in `keep` survive; a payload tree is around
// 600 MiB, so without this the store grows by one release forever.
PayloadGarbageResult CollectGarbage(const std::vector<std::string>& keep);
std::string StatusJson(std::string* error = nullptr) const;
private:
+36
View File
@@ -0,0 +1,36 @@
#ifndef MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
#define MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
#include <filesystem>
#include <string>
#include <vector>
namespace mocktail::update {
// Launches a downloaded latest payload once without promoting it to the active
// store. A non-empty HostAbi profile puts the child in explicit candidate mode;
// normal startup approval is intentionally not created or modified.
struct UnsafeLatestRunOptions {
std::filesystem::path runtime_binary;
std::filesystem::path payload_directory;
std::filesystem::path compatibility_manifest;
std::filesystem::path host_abi_profile;
// Tests may supply a deterministic environment. Production callers leave
// this empty to inherit the updater process environment.
std::vector<std::string> inherited_environment;
};
struct UnsafeLatestRunResult {
int exit_code = -1;
std::string error;
explicit operator bool() const { return error.empty() && exit_code == 0; }
};
UnsafeLatestRunResult RunUnsafeLatestCandidate(
const UnsafeLatestRunOptions& options);
} // namespace mocktail::update
#endif // MOCKTAIL_UPDATE_UNSAFE_LATEST_RUNNER_H_
+44
View File
@@ -2,9 +2,14 @@
#define MOCKTAIL_UPDATE_UPDATE_COORDINATOR_H_
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#include "update/apk_bundle.h"
#include "update/apk_provider.h"
#include "update/compatibility_catalog.h"
#include "update/payload_store.h"
#include "update/readiness_canary.h"
namespace mocktail::update {
@@ -16,6 +21,9 @@ struct UpdatePaths {
std::filesystem::path state_root;
std::filesystem::path compatibility_manifest;
std::filesystem::path signing_trust_manifest;
// One exact HostAbi sidecar, or a directory of them. A directory lets a new
// supported profile ship its own reference instead of inheriting whichever
// build the single file happens to describe.
std::filesystem::path host_abi_reference_profile;
std::filesystem::path runtime_binary;
};
@@ -24,6 +32,9 @@ struct UpdateRequest {
bool startup_preflight = false;
bool check_latest = true;
bool run_canary = true;
// Explicit manual escape hatch. It launches the provider latest once with a
// derived candidate profile and never promotes it into current.json.
bool force_run_latest = false;
CanaryGraphicsBackend canary_graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
int progress_fd = -1;
@@ -31,6 +42,10 @@ struct UpdateRequest {
struct UpdateResult {
bool changed = false;
// The provider publishes a newer Roblox than the payload that stayed
// active. Launching still works, but the Roblox servers reject an outdated
// client, so this is a user-visible outcome rather than a quiet success.
bool stale = false;
std::string payload_id;
std::string message;
std::vector<std::string> warnings;
@@ -39,6 +54,35 @@ struct UpdateResult {
explicit operator bool() const { return error.empty(); }
};
// One payload worth trying, best first. The installed payload is deliberately
// not a plan entry: it is what remains when every plan fails, so a rejected
// candidate cannot pin the user to a version the catalog has moved past.
struct UpdateCandidatePlan {
ExpectedPayloadIdentity identity;
bool exact_supported = false;
// The store already holds an approved profile for this exact version, so it
// only needs its canaries re-run rather than another few hundred MiB.
bool reuse_installed = false;
std::string origin;
};
// Newest supported profile whose own bytes an available sidecar describes,
// with `sidecar` set to that file. `location` is one sidecar or a directory
// of them. A library picked any other way cannot pass the derivation's own
// reference check.
std::optional<SupportedPayloadProfile> ResolveReferenceProfile(
const std::filesystem::path& location,
const std::vector<SupportedPayloadProfile>& profiles,
std::filesystem::path* sidecar);
// `latest` is null when the provider could not be consulted. `current` is the
// runnable payload (empty when there is none) and `installed` is whatever the
// activation manifest points at, runnable or not.
std::vector<UpdateCandidatePlan> PlanUpdateCandidates(
const std::vector<SupportedPayloadProfile>& profiles,
const SupportedPayloadProfile& preferred, const ProviderVersion* latest,
const PayloadStoreResult& current, const PayloadStoreResult& installed);
UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request);
} // namespace mocktail::update
+24 -3
View File
@@ -1,8 +1,29 @@
#!/usr/bin/env bash
#!/bin/sh
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
set -eu
APPDIR="${APPDIR:-$(CDPATH= cd -P "$(dirname -- "$0")" && pwd)}"
InitializeAnyLinuxPaths() {
# quick-sharun preserves our custom AppRun, so its generated AppRun never
# runs these relocation hooks. WebKit and p11-kit use the patched paths
# even when WEBKIT_EXEC_PATH and SSL_CERT_FILE are set.
for anylinux_hook in 01-check-ca-certs.hook 01-path-mapping-hardcoded.hook; do
if [ -f "${APPDIR}/bin/${anylinux_hook}" ]; then
. "${APPDIR}/bin/${anylinux_hook}"
fi
done
}
if [ -n "${SHARUN_DIR:-}" ] && [ -x "${APPDIR}/bin/bash" ]; then
export PATH="${APPDIR}/bin:${PATH}"
export MOCKTAIL_ANYLINUX_BIN_DIR="${APPDIR}/bin"
export MOCKTAIL_BUNDLE_ROOT="${APPDIR}/usr/share/mocktail-bundle"
InitializeAnyLinuxPaths
exec "${APPDIR}/bin/bash" \
"${MOCKTAIL_BUNDLE_ROOT}/mocktail/scripts/portable_launcher.sh" "$@"
fi
APPDIR="${APPDIR:-$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}"
exec "${APPDIR}/usr/share/mocktail-bundle/run.sh" "$@"
+11
View File
@@ -0,0 +1,11 @@
# 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
+21
View File
@@ -0,0 +1,21 @@
# Paths are expanded by sharun after the AppImage is mounted. Keep the
# Android ABI adapters separate from the host-ABI toolkit libraries.
USE_HOST_XDG_CACHE_HOME=1
GSETTINGS_BACKEND=keyfile
SHARUN_ALLOW_SYS_VKICD=1
MOCKTAIL_PORTABLE_MODE=standalone
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_CANARY_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
MOCKTAIL_UPDATE_PROGRESS_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
MOCKTAIL_FREEBSD_SOCKET_HELPER=${SHARUN_DIR}/share/mocktail/helpers/mocktail_freebsd_socket_helper
# sharun reloads .env for every child. Publish a default separately so it
# cannot overwrite a candidate-specific manifest supplied by the updater.
MOCKTAIL_PACKAGED_COMPATIBILITY_MANIFEST=${SHARUN_DIR}/share/mocktail/metadata/roblox_compatibility.json
MOCKTAIL_UPDATE_COMPATIBILITY_PATH=${SHARUN_DIR}/share/mocktail/metadata/roblox_compatibility.json
MOCKTAIL_UPDATE_SIGNING_TRUST_PATH=${SHARUN_DIR}/share/mocktail/metadata/roblox_signing_certificates.json
MOCKTAIL_UPDATE_HOST_ABI_REFERENCE=${SHARUN_DIR}/share/mocktail/metadata/roblox_host_abi_reference.json
MOCKTAIL_BOOTSTRAP_SOURCES_PATH=${SHARUN_DIR}/share/mocktail/metadata/roblox_bootstrap_sources.json
+1 -1
View File
@@ -2,7 +2,7 @@
pkgname=mocktail
pkgver=1.0.3
pkgrel=1
pkgrel=2
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
+5 -5
View File
@@ -1,7 +1,7 @@
pkgbase = mocktail-bin
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
pkgver = 1.0.2
pkgrel = 1
pkgver = 1.0.3
pkgrel = 2
url = https://github.com/komaruworld/mocktail
arch = x86_64
license = Apache-2.0
@@ -31,10 +31,10 @@ pkgbase = mocktail-bin
provides = mocktail
conflicts = mocktail
conflicts = mocktail-git
noextract = mocktail-1.0.2-1-x86_64.pkg.tar.zst
noextract = mocktail-1.0.3-2-x86_64.pkg.tar.zst
options = !debug
options = !strip
source = mocktail-1.0.2-1-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.2/mocktail-1.0.2-1-x86_64.pkg.tar.zst
sha256sums = d84673606728150aec96c950051efa87598314d59f800e2a7d8731d74cd617d9
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
pkgname = mocktail-bin
+3 -3
View File
@@ -1,8 +1,8 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
pkgname=mocktail-bin
pkgver=1.0.2
pkgrel=1
pkgver=1.0.3
pkgrel=2
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
@@ -38,7 +38,7 @@ options=('!debug' '!strip')
_archive="mocktail-${pkgver}-${pkgrel}-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
noextract=("${_archive}")
sha256sums=('d84673606728150aec96c950051efa87598314d59f800e2a7d8731d74cd617d9')
sha256sums=('be945181d73d28d9bf09a93927a215d03a3a8b479ffddab3378a8113b56e4b99')
package() {
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
+2 -1
View File
@@ -1,7 +1,7 @@
pkgbase = mocktail
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.3
pkgrel = 1
pkgrel = 2
url = https://github.com/komaruworld/mocktail
arch = x86_64
license = Apache-2.0
@@ -38,6 +38,7 @@ pkgbase = mocktail
conflicts = mocktail-bin
conflicts = mocktail-git
options = !debug
options = !lto
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.3
sha256sums = SKIP
+3 -2
View File
@@ -2,7 +2,7 @@
pkgname=mocktail
pkgver=1.0.3
pkgrel=1
pkgrel=2
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
@@ -42,7 +42,8 @@ makedepends=(
)
optdepends=('gamemode: Feral GameMode integration')
conflicts=('mocktail-bin' 'mocktail-git')
options=('!debug')
# 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}"
)
+50 -44
View File
@@ -27,20 +27,36 @@ else
BUNDLE_ROOT="$(cd -P -- "${LAUNCHER_DIR}/../.." && pwd)"
fi
RUNTIME_ROOT="${BUNDLE_ROOT}/mocktail"
BIN_DIR="${RUNTIME_ROOT}/bin"
BUNDLED_BIN_DIR="${RUNTIME_ROOT}/bin"
ANYLINUX_BIN_DIR="${MOCKTAIL_ANYLINUX_BIN_DIR:-}"
if [[ -n "${ANYLINUX_BIN_DIR}" ]]; then
[[ "${ANYLINUX_BIN_DIR}" == /* && -d "${ANYLINUX_BIN_DIR}" ]] || {
printf 'mocktail: invalid AnyLinux binary directory: %s\n' \
"${ANYLINUX_BIN_DIR}" >&2
exit 1
}
BIN_DIR="${ANYLINUX_BIN_DIR}"
else
BIN_DIR="${BUNDLED_BIN_DIR}"
fi
MAIN_BINARY="${BIN_DIR}/mocktail"
UPDATE_HELPER="${BIN_DIR}/mocktail_updater"
FAILURE_DIALOG_HELPER="${BIN_DIR}/mocktail_failure_dialog"
WEBVIEW_HELPER="${BIN_DIR}/mocktail_webview_helper"
FREEBSD_SOCKET_HELPER="${BIN_DIR}/mocktail_freebsd_socket_helper"
FREEBSD_SOCKET_HELPER="${BUNDLED_BIN_DIR}/mocktail_freebsd_socket_helper"
ANDROID_BUILD_TOOLS="${RUNTIME_ROOT}/runtime/android-tools/bin"
ANDROID_TOOL_EXEC_DIR="${ANYLINUX_BIN_DIR:-${ANDROID_BUILD_TOOLS}}"
METADATA_DIR="${RUNTIME_ROOT}/metadata"
ABI_MANIFEST="${METADATA_DIR}/ABI.txt"
DEPENDENCY_MANIFEST="${METADATA_DIR}/DEPENDENCIES.txt"
CHECKSUM_MANIFEST="${METADATA_DIR}/SHA256SUMS.txt"
SUPPORT_ROOT="${RUNTIME_ROOT}/runtime"
SUPPORT_BIN="${SUPPORT_ROOT}/bin"
export PATH="${SUPPORT_BIN}:${ANDROID_BUILD_TOOLS}:${PATH}"
if [[ -n "${ANYLINUX_BIN_DIR}" ]]; then
SUPPORT_BIN="${ANYLINUX_BIN_DIR}"
else
SUPPORT_BIN="${SUPPORT_ROOT}/bin"
fi
export PATH="${BIN_DIR}:${SUPPORT_BIN}:${ANDROID_BUILD_TOOLS}:${PATH}"
export LD_LIBRARY_PATH="${RUNTIME_ROOT}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
export MOCKTAIL_FREEBSD_SOCKET_HELPER="${FREEBSD_SOCKET_HELPER}"
@@ -203,17 +219,18 @@ ConfigureStandaloneEnvironment() {
Die "standalone WebKit environment is incomplete"
[[ "${WEBKITGTK6_NAMESPACE_DIR}" =~ ^/usr/(lib|lib64|libexec|lib/x86_64-linux-gnu)/webkitgtk-6\.0$ ]] ||
Die "standalone WebKitGTK 6 namespace path is invalid"
export PYTHONHOME="${SUPPORT_ROOT}/python"
unset PYTHONPATH PYTHONUSERBASE
export PYTHONNOUSERSITE=1 PYTHONDONTWRITEBYTECODE=1
export JAVA_HOME="${SUPPORT_ROOT}/jre"
export SSL_CERT_FILE="${SUPPORT_ROOT}/share/ca-certificates/ca-bundle.crt"
export REQUESTS_CA_BUNDLE="${SSL_CERT_FILE}"
export WEBKIT_EXEC_PATH="${RUNTIME_ROOT}/${webkit6_exec}"
if [[ -n "${ANYLINUX_BIN_DIR}" ]]; then
export WEBKIT_EXEC_PATH="${ANYLINUX_BIN_DIR}"
export GST_PLUGIN_SCANNER="${ANYLINUX_BIN_DIR}/gst-plugin-scanner"
else
export WEBKIT_EXEC_PATH="${RUNTIME_ROOT}/${webkit6_exec}"
export GST_PLUGIN_SCANNER="${RUNTIME_ROOT}/${gst_scanner}"
fi
export WEBKIT_INJECTED_BUNDLE_PATH="$(dirname -- \
"${RUNTIME_ROOT}/${webkit6_bundle}")"
export GST_PLUGIN_PATH_1_0="${RUNTIME_ROOT}/${gst_plugins}"
export GST_PLUGIN_SCANNER="${RUNTIME_ROOT}/${gst_scanner}"
export GIO_EXTRA_MODULES="${RUNTIME_ROOT}/${gio_modules}"
export GIO_USE_TLS="${gio_tls}"
export GDK_PIXBUF_MODULE_FILE="${RUNTIME_ROOT}/${pixbuf_cache}"
@@ -227,6 +244,7 @@ ConfigureStandaloneEnvironment() {
EnterStandaloneNamespace() {
[[ "${ABI_MODE}" == standalone &&
-z "${ANYLINUX_BIN_DIR}" &&
"${MOCKTAIL_STANDALONE_NAMESPACE:-0}" != 1 &&
"${MOCKTAIL_SKIP_NAMESPACE_CHECK:-0}" != 1 ]] || return 0
local namespace_usr="${RUNTIME_ROOT}/namespace/usr"
@@ -345,12 +363,14 @@ CheckBundleAbi() {
[[ "$(uname -m)" == "${ABI_ARCHITECTURE}" ]] ||
Die "bundle requires ${ABI_ARCHITECTURE}, host is $(uname -m)"
local main_interpreter host_libc
main_interpreter="$(ReadElfInterpreter "${MAIN_BINARY}")"
main_interpreter="$(ReadElfInterpreter "${BUNDLED_BIN_DIR}/mocktail")"
[[ "${main_interpreter}" == "${ABI_INTERPRETER}" ]] ||
Die "mocktail ELF interpreter does not match ABI.txt (expected ${ABI_INTERPRETER}, found ${main_interpreter:-none})"
ValidateBundledElfAbi "${MAIN_BINARY}" "mocktail"
ValidateBundledElfAbi "${UPDATE_HELPER}" "Mocktail updater"
ValidateBundledElfAbi "${WEBVIEW_HELPER}" "WebView helper"
ValidateBundledElfAbi "${BUNDLED_BIN_DIR}/mocktail" "mocktail"
ValidateBundledElfAbi "${BUNDLED_BIN_DIR}/mocktail_updater" \
"Mocktail updater"
ValidateBundledElfAbi "${BUNDLED_BIN_DIR}/mocktail_webview_helper" \
"WebView helper"
[[ -x "${ANDROID_BUILD_TOOLS}/aapt" ]] ||
Die "Android APK metadata tool is unavailable"
if [[ "${ABI_ANDROID_TOOLS_LIBC}" == java ]]; then
@@ -361,11 +381,13 @@ CheckBundleAbi() {
"Android aapt" glibc
fi
host_libc="$(DetectHostLibc)"
[[ "${host_libc}" != unknown ]] ||
Die "cannot determine host libc ABI; install working getconf or ldd"
[[ "${host_libc}" == "${ABI_LIBC}" ]] ||
Die "bundle targets ${ABI_LIBC}, but host libc is ${host_libc}; use a Mocktail x86-64 ${host_libc} AppImage"
if [[ -z "${ANYLINUX_BIN_DIR}" ]]; then
host_libc="$(DetectHostLibc)"
[[ "${host_libc}" != unknown ]] ||
Die "cannot determine host libc ABI; install working getconf or ldd"
[[ "${host_libc}" == "${ABI_LIBC}" ]] ||
Die "bundle targets ${ABI_LIBC}, but host libc is ${host_libc}; use a Mocktail x86-64 ${host_libc} AppImage"
fi
}
CheckCommand() {
@@ -377,15 +399,6 @@ CheckCommand() {
return 1
}
CheckPythonModule() {
local name="$1"
if python3 -c "import ${name}" >/dev/null 2>&1; then
return 0
fi
printf ' missing Python module: %s\n' "${name}" >&2
return 1
}
CheckElfDependencies() {
local executable="$1"
local label="$2"
@@ -415,29 +428,20 @@ CheckSystem() {
fi
local command_name
for command_name in bash python3 java jq unzip aapt apksigner file flock \
for command_name in bash java jq unzip aapt apksigner file flock \
timeout readelf sha256sum; do
CheckCommand "${command_name}" || status=1
done
if command -v python3 >/dev/null 2>&1; then
CheckPythonModule yaml || status=1
CheckPythonModule requests || status=1
if ! python3 -c 'import capstone; raise SystemExit(capstone.cs_version()[0] != 5)' \
>/dev/null 2>&1; then
printf ' missing Python module: capstone 5\n' >&2
status=1
fi
fi
if ! "${ANDROID_BUILD_TOOLS}/aapt" version >/dev/null 2>&1; then
if ! "${ANDROID_TOOL_EXEC_DIR}/aapt" version >/dev/null 2>&1; then
printf ' bundled Android APK analyzer cannot execute\n' >&2
status=1
fi
if [[ "${ABI_ANDROID_TOOLS_LIBC}" == java ]] &&
! "${ANDROID_BUILD_TOOLS}/apkanalyzer" --help >/dev/null 2>&1; then
! "${ANDROID_TOOL_EXEC_DIR}/apkanalyzer" --help >/dev/null 2>&1; then
printf ' bundled Android apkanalyzer cannot execute with Java\n' >&2
status=1
fi
if ! "${ANDROID_BUILD_TOOLS}/apksigner" version >/dev/null 2>&1; then
if ! "${ANDROID_TOOL_EXEC_DIR}/apksigner" version >/dev/null 2>&1; then
printf ' bundled Android apksigner cannot execute with host Java\n' >&2
status=1
fi
@@ -461,12 +465,12 @@ CheckSystem() {
if (( status != 0 )); then
if [[ "${ABI_LIBC}" == glibc ]]; then
printf '\nArch example:\n' >&2
printf ' sudo pacman -S --needed vulkan-icd-loader libadwaita webkitgtk-6.0 jq unzip file binutils jre-openjdk-headless python-yaml python-requests python-capstone\n' >&2
printf ' sudo pacman -S --needed vulkan-icd-loader libadwaita webkitgtk-6.0 jq unzip file binutils jre-openjdk-headless\n' >&2
printf '\nVoid x86_64-glibc example:\n' >&2
else
printf '\nVoid x86_64-musl example:\n' >&2
fi
printf ' sudo xbps-install -S vulkan-loader libadwaita libwebkitgtk60 jq unzip file binutils openjdk17-jre python3-yaml python3-requests capstone-python3\n' >&2
printf ' sudo xbps-install -S vulkan-loader libadwaita libwebkitgtk60 jq unzip file binutils openjdk17-jre\n' >&2
printf 'Install the Vulkan ICD matching the GPU (for example vulkan-radeon, nvidia-utils, or vulkan-intel).\n' >&2
return "${status}"
fi
@@ -520,9 +524,11 @@ export MOCKTAIL_UPDATE_HOST_ABI_REFERENCE="${METADATA_DIR}/roblox_host_abi_refer
export MOCKTAIL_BOOTSTRAP_SOURCES_PATH="${METADATA_DIR}/roblox_bootstrap_sources.json"
export MOCKTAIL_UPDATE_HELPER="${UPDATE_HELPER}"
export MOCKTAIL_UPDATE_CANARY_BIN="${MAIN_BINARY}"
export MOCKTAIL_UPDATE_SMOKE_SCRIPT="${RUNTIME_ROOT}/scripts/real_bringup_smoke.sh"
export MOCKTAIL_BIN="${MAIN_BINARY}"
export MOCKTAIL_PORTABLE_MODE="${ABI_MODE}"
# AnyLinux relocates executables into its own bin directory. The Bionic
# adapters stay in the original bundle and must be loaded by exact path.
export MOCKTAIL_RUNTIME_LIBRARY_DIR="${BUNDLED_BIN_DIR}"
cd -- "${RUNTIME_ROOT}"
exec "${MAIN_BINARY}" "$@"
+142
View File
@@ -0,0 +1,142 @@
{
lib,
stdenv,
fetchFromGitHub,
cmake,
git,
lld,
ninja,
pkg-config,
vulkan-headers,
makeWrapper,
capstone,
curl,
fontconfig,
gamemode,
glib,
glib-networking,
gtk4,
hicolor-icon-theme,
libadwaita,
elfutils,
libglvnd,
libplacebo,
libsoup_3,
libutf8proc,
libyaml,
minizip,
nlohmann_json,
openssl,
sdl3,
sdl3-ttf,
vulkan-loader,
webkitgtk_6_0,
zlib,
}:
let
# submodules are not part of the flake source tree
# so these two vendored dependencies are fetched from their pinned upstream revisions
libjnivmSrc = fetchFromGitHub {
owner = "ChristopherHX";
repo = "libjnivm";
rev = "f24b98c198fcc5c59a68d1efadd3f5791eb01c2e";
hash = "sha256-K4+1Zo7AJIouIqnHn2ZE4GyrNrYOczmWYblLAnGYA5w=";
};
vulkanHeadersSrc = fetchFromGitHub {
owner = "KhronosGroup";
repo = "Vulkan-Headers";
rev = "8d6039a455a7ecc7d2a592ff97f62db4e59b70bf";
hash = "sha256-+llJlEdzFcmqGBAdKHjYhkwBuH19ZngavmF1TQgejsI=";
};
src = ../..;
lines = lib.splitString "\n" (builtins.readFile (lib.path.append src "CMakeLists.txt"));
matches = map (builtins.match "[[:space:]]*VERSION[[:space:]]+([0-9][0-9.]*).*") lines;
match = lib.findFirst (m: m != null) null matches;
version =
if match == null then throw "could not find VERSION in CMakeLists.txt" else builtins.elemAt match 0;
in
stdenv.mkDerivation (finalAttrs: {
pname = "mocktail";
inherit version;
inherit src;
nativeBuildInputs = [
cmake
git
lld
ninja
pkg-config
vulkan-headers
makeWrapper
];
buildInputs = [
capstone
curl
fontconfig
# Loaded with dlmopen at runtime. Keeping it here also adds libgamemode.so.0
# to the wrapped binaries' and development shell's LD_LIBRARY_PATH.
gamemode
glib
glib-networking
gtk4
hicolor-icon-theme
libadwaita
elfutils
libglvnd
libplacebo
libsoup_3
libutf8proc
libyaml
minizip
nlohmann_json
openssl
sdl3
sdl3-ttf
vulkan-loader
webkitgtk_6_0
zlib
];
preConfigure = ''
rm -rf third_party/libjnivm third_party/Vulkan-Headers
cp -r --no-preserve=mode ${libjnivmSrc} third_party/libjnivm
cp -r --no-preserve=mode ${vulkanHeadersSrc} third_party/Vulkan-Headers
'';
# nixpkgs installs the header we need under include/libutf8proc
NIX_CFLAGS_COMPILE = "-I${libutf8proc}/include/libutf8proc";
cmakeFlags = [
"-DCMAKE_BUILD_TYPE=Release"
"-DCMAKE_INSTALL_LIBDIR=lib"
"-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=${placeholder "out"}/share/mocktail/metadata/roblox_compatibility.json"
"-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=${placeholder "out"}/share/mocktail/metadata/roblox_signing_certificates.json"
"-DBUILD_TESTING=OFF"
];
postInstall = ''
for binary in $out/bin/*; do
wrapProgram "$binary" --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath finalAttrs.buildInputs} --prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules"
done
'';
fixupPhase = ''
install -Dm644 ${finalAttrs.src}/LICENSE "$out/share/licenses/${finalAttrs.pname}/LICENSE"
'';
meta = with lib; {
description = "Android x86-64 Roblox compatibility runtime for Linux";
homepage = "https://github.com/komaruworld/mocktail";
license = licenses.asl20;
platforms = platforms.unix;
mainProgram = "mocktail";
};
})
+1
View File
@@ -1,6 +1,7 @@
[Desktop Entry]
Type=Application
Name=Mocktail
GenericName=Roblox Player
Comment=Play Roblox on Linux
Exec=mocktail %u
Icon=space.bigrat.mocktail
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
# 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
"""Report when the pinned Roblox payload metadata has fallen behind.
Two kinds of drift make a first launch fail, and neither one shows up until a
user hits it:
* the compatibility catalog no longer lists the Roblox version the provider
publishes, so every new install lands on a version Roblox has since retired;
* the preferred supported profile has no pinned bootstrap source, so the
pinned bootstrap path cannot produce the version the updater falls back to.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import subprocess
import sys
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
PACKAGE = "com.roblox.client"
ARCHITECTURE = "x86_64"
class DriftError(RuntimeError):
"""Raised when the pinned metadata cannot be inspected at all."""
def _load(path: Path) -> dict:
try:
encoded = path.read_bytes()
except OSError as error:
raise DriftError(f"cannot read {path}") from error
if len(encoded) > MAX_MANIFEST_BYTES:
raise DriftError(f"{path} exceeds its size limit")
try:
document = json.loads(encoded.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as error:
raise DriftError(f"cannot parse {path}") from error
if not isinstance(document, dict) or document.get("schema_version") != 1:
raise DriftError(f"{path} has an unsupported schema")
return document
def supported_profiles(catalog: dict) -> list[dict]:
profiles = catalog.get("profiles")
if not isinstance(profiles, list):
raise DriftError("compatibility catalog has no profiles")
return [
profile
for profile in profiles
if isinstance(profile, dict)
and profile.get("status") == "supported"
and profile.get("default_allowed") is True
and isinstance(profile.get("version_name"), str)
and isinstance(profile.get("version_code"), int)
]
def preferred_profile(catalog: dict) -> dict:
"""The profile the updater falls back to, matching the C++ coordinator."""
profiles = supported_profiles(catalog)
if not profiles:
raise DriftError("compatibility catalog has no supported profile")
return max(profiles, key=lambda profile: profile["version_code"])
def pinned_versions(sources: dict) -> set[str]:
entries = sources.get("sources")
if not isinstance(entries, list):
raise DriftError("bootstrap source manifest has no sources")
return {
entry["version_name"]
for entry in entries
if isinstance(entry, dict)
and entry.get("package") == PACKAGE
and entry.get("abi") == ARCHITECTURE
and isinstance(entry.get("version_name"), str)
}
def latest_published_version(provider: Path) -> tuple[str, int]:
completed = subprocess.run(
[sys.executable, str(provider), "--check", "--package", PACKAGE,
"--arch", ARCHITECTURE],
capture_output=True,
text=True,
timeout=180,
check=False,
)
if completed.returncode != 0:
raise DriftError(
"provider metadata is unavailable: "
+ (completed.stderr.strip() or "no diagnostic")
)
try:
reported = json.loads(completed.stdout)
return str(reported["latest_version_name"]), int(
reported["latest_version_code"]
)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
raise DriftError("provider metadata is not understood") from error
def findings(catalog: dict, sources: dict, latest: tuple[str, int] | None
) -> list[str]:
reported: list[str] = []
preferred = preferred_profile(catalog)
pinned = pinned_versions(sources)
if preferred["version_name"] not in pinned:
reported.append(
f"The preferred supported profile {preferred['version_name']} "
f"({preferred['version_code']}) has no pinned bootstrap source. "
"The pinned bootstrap path cannot produce the version the updater "
"falls back to."
)
if latest is not None:
latest_name, latest_code = latest
if latest_code > preferred["version_code"]:
reported.append(
f"Roblox {latest_name} ({latest_code}) is published, but the "
f"newest supported profile is {preferred['version_name']} "
f"({preferred['version_code']}). New installs land on a "
"version Roblox may already have retired."
)
return reported
def main(argv: list[str] | None = None) -> int:
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--catalog", type=Path,
default=root / "config/roblox_compatibility.json")
parser.add_argument(
"--sources", type=Path,
default=root / "config/roblox_bootstrap_sources.json")
parser.add_argument(
"--provider", type=Path,
default=root / "scripts/apk_providers/direct_apkpure.py")
parser.add_argument(
"--check-latest", action="store_true",
help="also ask the provider which version is published")
arguments = parser.parse_args(argv)
try:
catalog = _load(arguments.catalog)
sources = _load(arguments.sources)
latest = (
latest_published_version(arguments.provider)
if arguments.check_latest
else None
)
reported = findings(catalog, sources, latest)
except DriftError as error:
print(f"payload source drift: {error}", file=sys.stderr)
return 2
if not reported:
print("Pinned Roblox payload metadata is current.")
return 0
for finding in reported:
print(f"- {finding}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+488 -31
View File
@@ -67,6 +67,7 @@ ELF_VERSION_CURRENT = 1
ELF_TYPE_SHARED_OBJECT = 3
ELF_MACHINE_X86_64 = 62
PT_LOAD = 1
PT_GNU_RELRO = 0x6474E552
PF_EXECUTE = 1
PF_WRITE = 2
PF_READ = 4
@@ -159,6 +160,7 @@ class SignatureSpec:
instruction_count: int
allow_registry_object_size_change: bool = False
minimum_anchor_bytes: int = 6
registry_size_indices: tuple[int, int] = (8, 12)
@dataclass(frozen=True)
@@ -217,6 +219,7 @@ class ElfImage:
self.sections: dict[str, Section] = {}
self.sections_by_index: tuple[Section, ...] = ()
self.segments: tuple[Segment, ...] = ()
self.relro_ranges: tuple[tuple[int, int], ...] = ()
self.build_id = ""
def __enter__(self) -> "ElfImage":
@@ -300,10 +303,15 @@ class ElfImage:
)
segments = []
relro_ranges = []
for index in range(program_count):
values = PROGRAM_HEADER.unpack_from(
self.data, program_offset + index * program_entry_size
)
if values[0] == PT_GNU_RELRO:
if values[6] <= 0:
raise AnalyzerError("ELF PT_GNU_RELRO range is empty")
relro_ranges.append((values[3], values[6]))
if values[0] != PT_LOAD:
continue
segment = Segment(
@@ -320,6 +328,7 @@ class ElfImage:
if not segments:
raise AnalyzerError("ELF has no PT_LOAD segments")
self.segments = tuple(segments)
self.relro_ranges = tuple(relro_ranges)
raw_sections = [
SECTION_HEADER.unpack_from(
@@ -455,6 +464,17 @@ class ElfImage:
):
raise AnalyzerError(f"RVA 0x{rva:x} is not in a non-executable RW segment")
def require_relro_rva(self, rva: int, size: int = 1) -> None:
matches = [
(begin, length)
for begin, length in self.relro_ranges
if size >= 0 and begin <= rva and rva + size <= begin + length
]
if len(matches) != 1:
raise AnalyzerError(
f"RVA 0x{rva:x} is not in a unique PT_GNU_RELRO range"
)
def rva_to_offset(self, rva: int, size: int = 1) -> int:
matches = [
segment
@@ -1058,23 +1078,30 @@ def semantic_shape(
def validate_semantic_match(
reference: Sequence[Any],
candidate: Sequence[Any],
allow_registry_object_size_change: bool,
spec: SignatureSpec,
) -> None:
if len(reference) != len(candidate):
raise AnalyzerError("normalized signature instruction count changed")
for index, (reference_instruction, candidate_instruction) in enumerate(
zip(reference, candidate)
):
wildcard_size = allow_registry_object_size_change and index in (8, 12)
wildcard_size = (
spec.allow_registry_object_size_change
and index in spec.registry_size_indices
)
if semantic_shape(reference_instruction, wildcard_size) != semantic_shape(
candidate_instruction, wildcard_size
):
raise AnalyzerError(
f"normalized instruction semantics changed at signature index {index}"
)
if allow_registry_object_size_change:
if spec.allow_registry_object_size_change:
sizes = []
for index in (8, 12):
for index in spec.registry_size_indices:
if index >= len(candidate):
raise AnalyzerError(
"registry initializer size index is outside its signature"
)
operands = candidate[index].operands
if len(operands) != 2 or operands[1].type != capstone_x86.X86_OP_IMM:
raise AnalyzerError("registry initializer size operands changed")
@@ -1154,7 +1181,7 @@ def find_signature_matches(
validate_semantic_match(
reference_instructions,
candidate_instructions,
spec.allow_registry_object_size_change,
spec,
)
except AnalyzerError as error:
semantic_errors.append(error)
@@ -1180,6 +1207,360 @@ def find_signature_match(
return matches[0]
def unique_semantic_signature_match(
reference: ElfImage, candidate: ElfImage, spec: SignatureSpec
) -> SignatureMatch:
matches = find_signature_matches(
reference, candidate, spec, allow_multiple_candidates=True
)
if len(matches) != 1:
raise AnalyzerError(
f"signature {spec.name} matched {len(matches)} semantic candidate locations"
)
return matches[0]
def relative_relocation_map(image: ElfImage) -> dict[int, int]:
result = {}
for relocation in decode_aps2_relocations(
image.section_bytes(image.sections[".rela.dyn"])
):
if (
relocation.info & 0xFFFFFFFF != R_X86_64_RELATIVE
or relocation.info >> 32 != 0
):
continue
if relocation.offset in result:
raise AnalyzerError("ELF contains duplicate relative relocations")
result[relocation.offset] = relocation.addend
return result
def has_fullscreen_setter_contract(image: ElfImage, rva: int) -> bool:
size = 96
try:
image.require_code_rva(rva, size)
offset = image.rva_to_offset(rva, size)
code = image.bytes_at(offset, size, "fullscreen setter contract")
except AnalyzerError:
return False
fields = (b"\x59\x01\x00\x00", b"\x61\x01\x00\x00")
return (
code.startswith(b"\x55\x48\x89\xe5")
and b"\x89\xf3" in code[:24]
and any(
b"\x38\x98" + field in code and b"\x88\x98" + field in code
for field in fields
)
)
def has_fmod_string_constructor_contract(image: ElfImage, rva: int) -> bool:
expected = bytes.fromhex(
"55 48 89 e5 41 57 41 56 41 54 53 48 89 d3 49 89 "
"f6 49 89 ff 48 83 fa 16"
)
try:
image.require_code_rva(rva, len(expected))
offset = image.rva_to_offset(rva, len(expected))
return (
image.bytes_at(offset, len(expected), "FMOD string contract")
== expected
)
except AnalyzerError:
return False
def load_reference_runtime_profile(
paths: Sequence[Path], reference_build_id: str
) -> dict[str, Any]:
result: dict[str, Any] = {}
for path in paths:
document = read_json_object(path, "reference compatibility manifest")
profiles = document.get("profiles")
if document.get("schema_version") != 1 or not isinstance(profiles, list):
raise AnalyzerError(
"reference compatibility manifest schema is unsupported"
)
for profile in profiles:
if (
not isinstance(profile, dict)
or profile.get("elf_build_id") != reference_build_id
):
continue
discovered: dict[str, Any] = {}
if "user_game_settings_fullscreen_setter_rva" in profile:
discovered["user_game_settings_fullscreen_setter_rva"] = parse_rva(
profile["user_game_settings_fullscreen_setter_rva"],
"reference fullscreen setter",
)
if "fmod_output_device_bridge" in profile:
bridge = profile["fmod_output_device_bridge"]
fields = {
"vtable_rva",
"string_constructor_rva",
"count_method_rva",
"info_method_rva",
"current_method_rva",
"select_method_rva",
}
if (
not isinstance(bridge, dict)
or set(bridge) != fields
or profile.get("allow_host_abi_bridges") is not True
):
raise AnalyzerError(
"reference FMOD output-device profile is incomplete"
)
discovered["fmod_output_device_bridge"] = {
field: parse_rva(value, f"reference FMOD {field}")
for field, value in bridge.items()
}
for field, value in discovered.items():
if field in result and result[field] != value:
raise AnalyzerError(
f"reference compatibility manifests disagree on {field}"
)
result[field] = value
return result
def derive_fmod_output_device_bridge(
reference: ElfImage,
candidate: ElfImage,
source: dict[str, int],
) -> dict[str, str]:
indexes = {
"count_method_rva": 5,
"info_method_rva": 6,
"current_method_rva": 7,
"select_method_rva": 17,
}
source_vtable = source["vtable_rva"]
reference.require_relro_rva(source_vtable, (max(indexes.values()) + 1) * 8)
reference_relocations = relative_relocation_map(reference)
for field, index in indexes.items():
if reference_relocations.get(source_vtable + index * 8) != source[field]:
raise AnalyzerError("reference FMOD vtable does not match its methods")
reference.require_code_rva(source[field])
if not has_fmod_string_constructor_contract(
reference, source["string_constructor_rva"]
):
raise AnalyzerError("reference FMOD string constructor contract changed")
string_constructor = unique_semantic_signature_match(
reference,
candidate,
SignatureSpec(
"fmod-string-constructor",
source["string_constructor_rva"],
32,
minimum_anchor_bytes=3,
),
).rva
info_method = unique_semantic_signature_match(
reference,
candidate,
SignatureSpec(
"fmod-output-info",
source["info_method_rva"],
18,
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
count_candidates = {
match.rva
for match in find_signature_matches(
reference,
candidate,
SignatureSpec(
"fmod-output-count",
source["count_method_rva"],
24,
minimum_anchor_bytes=3,
),
allow_multiple_candidates=True,
)
}
current_candidates = {
match.rva
for match in find_signature_matches(
reference,
candidate,
SignatureSpec(
"fmod-output-current",
source["current_method_rva"],
24,
minimum_anchor_bytes=3,
),
allow_multiple_candidates=True,
)
}
if not count_candidates or not current_candidates:
raise AnalyzerError("FMOD count/current method signatures have no candidates")
relocations = relative_relocation_map(candidate)
vtables = []
for offset, addend in relocations.items():
if addend != info_method or offset < indexes["info_method_rva"] * 8:
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
):
try:
candidate.require_relro_rva(
vtable, (max(indexes.values()) + 1) * 8
)
except AnalyzerError:
continue
vtables.append((vtable, count_method, current_method))
if len(vtables) != 1:
raise AnalyzerError(
f"FMOD output vtable matched {len(vtables)} candidate locations"
)
vtable, count_method, current_method = vtables[0]
if not has_fmod_string_constructor_contract(candidate, string_constructor):
raise AnalyzerError("candidate FMOD string constructor contract changed")
return {
"vtable_rva": format_rva(vtable),
"string_constructor_rva": format_rva(string_constructor),
"count_method_rva": format_rva(count_method),
"info_method_rva": format_rva(info_method),
"current_method_rva": format_rva(current_method),
"select_method_rva": format_rva(select_method),
}
def derive_runtime_compatibility(
reference: ElfImage,
candidate: ElfImage,
manifests: Sequence[Path],
) -> dict[str, Any]:
source = load_reference_runtime_profile(manifests, reference.build_id)
result: dict[str, Any] = {}
fullscreen = source.get("user_game_settings_fullscreen_setter_rva")
if fullscreen is not None:
if not has_fullscreen_setter_contract(reference, fullscreen):
raise AnalyzerError("reference fullscreen setter contract changed")
setter = unique_semantic_signature_match(
reference,
candidate,
SignatureSpec(
"fullscreen-setter", fullscreen, 32, minimum_anchor_bytes=3
),
).rva
if not has_fullscreen_setter_contract(candidate, setter):
raise AnalyzerError("candidate fullscreen setter contract changed")
result["user_game_settings_fullscreen_setter_rva"] = format_rva(setter)
fmod = source.get("fmod_output_device_bridge")
if fmod is not None:
result["fmod_output_device_bridge"] = derive_fmod_output_device_bridge(
reference, candidate, fmod
)
return result
def find_registry_function_entry(
candidate: ElfImage,
body_rva: int,
reference_prologue: Sequence[Any],
) -> int:
prologue_instruction_count = 8
maximum_prologue_bytes = 64
if (
len(reference_prologue) != prologue_instruction_count
or body_rva < maximum_prologue_bytes
):
raise AnalyzerError("reference registry prologue shape is invalid")
entries = []
for rva in range(body_rva - maximum_prologue_bytes, body_rva):
try:
instructions = disassemble(candidate, rva, prologue_instruction_count)
except AnalyzerError:
continue
if instructions[-1].address + instructions[-1].size != body_rva:
continue
if any(
semantic_shape(reference_instruction, index == 7)
!= semantic_shape(candidate_instruction, index == 7)
for index, (reference_instruction, candidate_instruction) in enumerate(
zip(reference_prologue, instructions)
)
):
continue
frame = instructions[-1]
operands = frame.operands
if (
frame.mnemonic == "sub"
and len(operands) == 2
and operands[0].type == capstone_x86.X86_OP_REG
and operands[0].reg == capstone_x86.X86_REG_RSP
and operands[1].type == capstone_x86.X86_OP_IMM
and 0x20 <= operands[1].imm <= 0x400
and operands[1].imm % 8 == 0
):
entries.append(rva)
if len(entries) != 1:
raise AnalyzerError(
"registry initializer body has no unique bounded prologue"
)
return entries[0]
def find_registry_signature_match(
reference: ElfImage,
candidate: ElfImage,
spec: SignatureSpec,
) -> SignatureMatch:
body_instruction_index = 8
body_instruction_count = 11
reference_full = disassemble(
reference, spec.reference_rva, spec.instruction_count
)
if len(reference_full) < body_instruction_index + body_instruction_count:
raise AnalyzerError("reference registry signature is incomplete")
body_spec = SignatureSpec(
"registry-initializer-body",
reference_full[body_instruction_index].address,
body_instruction_count,
True,
spec.minimum_anchor_bytes,
(0, 4),
)
body_match = find_signature_match(reference, candidate, body_spec)
candidate_entry = find_registry_function_entry(
candidate,
body_match.rva,
reference_full[:body_instruction_index],
)
candidate_full = disassemble(
candidate, candidate_entry, spec.instruction_count
)
if candidate_full[body_instruction_index].address != body_match.rva:
raise AnalyzerError("candidate registry signature is incomplete")
return SignatureMatch(candidate_entry, reference_full, candidate_full)
def direct_call_target(instruction: Any, description: str) -> int:
if (
instruction.mnemonic != "call"
@@ -1190,6 +1571,51 @@ def direct_call_target(instruction: Any, description: str) -> int:
return instruction.operands[0].imm
def direct_jump_target(instruction: Any, description: str) -> int:
if (
instruction.mnemonic != "jmp"
or len(instruction.operands) != 1
or instruction.operands[0].type != capstone_x86.X86_OP_IMM
):
raise AnalyzerError(f"{description} is not one direct jump")
return instruction.operands[0].imm
def find_constructor_boundary_match(
reference: ElfImage,
candidate: ElfImage,
spec: SignatureSpec,
reference_entry: int,
candidate_entry: int,
) -> SignatureMatch:
if reference_entry != spec.reference_rva:
raise AnalyzerError(
"reference constructor boundary moved outside init-array index 4"
)
reference_wrapper = disassemble(reference, reference_entry, 1)
reference_target = direct_jump_target(
reference_wrapper[0], "reference constructor boundary"
)
target_match = find_signature_match(
reference,
candidate,
SignatureSpec(
"constructor-4-target",
reference_target,
spec.instruction_count,
),
)
candidate_wrapper = disassemble(candidate, candidate_entry, 1)
candidate_target = direct_jump_target(
candidate_wrapper[0], "candidate constructor boundary"
)
if candidate_target != target_match.rva:
raise AnalyzerError(
"constructor index 4 no longer targets its verified boundary"
)
return SignatureMatch(candidate_entry, reference_wrapper, candidate_wrapper)
def rip_targets(instruction: Any) -> tuple[int, ...]:
targets = []
for operand in instruction.operands:
@@ -1417,6 +1843,14 @@ def derive_profile(
reference_profile = reference_sidecar["profile"]
anchors = reference_sidecar["derivation_anchors"]
reference_init = reference.init_array_relocations()
candidate_init = candidate.init_array_relocations()
if len(reference_init) != reference_profile["init_array_count"]:
raise AnalyzerError(
"reference sidecar init-array count differs from ELF metadata"
)
if len(candidate_init) < 8:
raise AnalyzerError("candidate init-array is unexpectedly short")
matches = {}
allocate_matches = ()
for spec in create_signature_specs(reference_profile, anchors):
@@ -1424,6 +1858,18 @@ def derive_profile(
allocate_matches = find_signature_matches(
reference, candidate, spec, allow_multiple_candidates=True
)
elif spec.name == "registry-initializer":
matches[spec.name] = find_registry_signature_match(
reference, candidate, spec
)
elif spec.name == "constructor-4":
matches[spec.name] = find_constructor_boundary_match(
reference,
candidate,
spec,
reference_init[4],
candidate_init[4],
)
else:
matches[spec.name] = find_signature_match(reference, candidate, spec)
@@ -1458,14 +1904,6 @@ def derive_profile(
"registry allocator call"
)
matches["allocate"] = selected_allocate[0]
reference_init = reference.init_array_relocations()
candidate_init = candidate.init_array_relocations()
if len(reference_init) != reference_profile["init_array_count"]:
raise AnalyzerError(
"reference sidecar init-array count differs from ELF metadata"
)
if len(candidate_init) < 8:
raise AnalyzerError("candidate init-array is unexpectedly short")
for index in (2, 3, 4):
match = matches[f"constructor-{index}"]
if (
@@ -1635,6 +2073,7 @@ def output_documents(
reference_sidecar: dict[str, Any],
profile: dict[str, Any],
anchors: dict[str, Any],
runtime_compatibility: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
version_code = metadata["version_code"]
payload_id = f"{version_code}-{candidate.build_id}"
@@ -1651,25 +2090,26 @@ def output_documents(
"profile": profile,
"derivation_anchors": anchors,
}
compatibility_profile = {
"version_name": metadata["version_name"],
"version_code": version_code,
"elf_build_id": candidate.build_id,
"status": "experimental",
"default_allowed": True,
"allow_legacy_binary_patches": False,
"allow_host_abi_bridges": True,
"allow_host_constructor_replay": True,
"reason": (
"Machine-derived exact-Build-ID profile for isolated "
"probation only; normal activation requires two "
"successful no-recovery Tier C canaries."
),
}
if runtime_compatibility:
compatibility_profile.update(runtime_compatibility)
manifest = {
"schema_version": 1,
"profiles": [
{
"version_name": metadata["version_name"],
"version_code": version_code,
"elf_build_id": candidate.build_id,
"status": "experimental",
"default_allowed": True,
"allow_legacy_binary_patches": False,
"allow_host_abi_bridges": True,
"allow_host_constructor_replay": True,
"reason": (
"Machine-derived exact-Build-ID profile for isolated "
"probation only; it is not supported until the updater "
"records a successful no-recovery Tier C canary."
),
}
],
"profiles": [compatibility_profile],
}
return sidecar, manifest
@@ -1720,8 +2160,18 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
with ElfImage(reference_path) as reference, ElfImage(candidate_path) as candidate:
metadata = validated_payload_metadata(metadata_path, candidate_path, candidate)
profile, anchors = derive_profile(reference, candidate, reference_sidecar)
runtime_compatibility = derive_runtime_compatibility(
reference,
candidate,
tuple(Path(path) for path in arguments.reference_compatibility),
)
return output_documents(
candidate, metadata, reference_sidecar, profile, anchors
candidate,
metadata,
reference_sidecar,
profile,
anchors,
runtime_compatibility,
)
@@ -1731,6 +2181,13 @@ def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
)
parser.add_argument("--reference-lib", required=True, metavar="REF")
parser.add_argument("--reference-profile", required=True, metavar="REFJSON")
parser.add_argument(
"--reference-compatibility",
action="append",
default=[],
metavar="MANIFEST",
help="compatibility manifest containing exact reference runtime anchors",
)
parser.add_argument("--candidate-lib", required=True, metavar="LIB")
parser.add_argument("--payload-metadata", required=True, metavar="META")
parser.add_argument("--output", required=True, metavar="PROFILE")
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
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
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[@]}"
-20
View File
@@ -109,9 +109,6 @@ unzip
file
binutils
jre-openjdk-headless
python-yaml
python-requests
python-capstone
sdl3
sdl3_ttf
libutf8proc
@@ -137,9 +134,6 @@ unzip
file
binutils
openjdk17-jre
python3-yaml
python3-requests
capstone-python3
SDL3
SDL3_ttf
libutf8proc
@@ -168,9 +162,6 @@ unzip
file
binutils
openjdk17-jre-headless
py3-yaml
py3-requests
py3-capstone
sdl3
sdl3_ttf
utf8proc
@@ -201,17 +192,6 @@ PackageInstalled() {
binutils) command -v readelf >/dev/null 2>&1 && return 0 ;;
jq|unzip|file) command -v "$package" >/dev/null 2>&1 && return 0 ;;
util-linux) command -v flock >/dev/null 2>&1 && return 0 ;;
python-yaml|python3-yaml|py3-yaml)
python3 -c 'import yaml' >/dev/null 2>&1 && return 0
;;
python-requests|python3-requests|py3-requests)
python3 -c 'import requests' >/dev/null 2>&1 && return 0
;;
python-capstone|capstone-python3|py3-capstone)
python3 -c 'import capstone; raise SystemExit(capstone.cs_version()[0] != 5)' \
>/dev/null 2>&1
return $?
;;
esac
case $DISTRO in
arch) pacman -Qq "$package" >/dev/null 2>&1 ;;
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
set -Eeuo pipefail
umask 022
ANYLINUX_PROJECT_ROOT="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
ANYLINUX_BUILD_DIR="${ANYLINUX_PROJECT_ROOT}/build"
ANYLINUX_OUTPUT=""
ANYLINUX_WORK=""
ANYLINUX_FREEBSD_HELPER=""
ANYLINUX_SYSTEM_INSTALL=false
ANYLINUX_DEPLOY_INSTALLED=false
ANYLINUX_TOOL="${MOCKTAIL_ANYLINUX_PACKAGER:-quick-sharun}"
ANYLINUX_APPIMAGETOOL="${MOCKTAIL_ANYLINUX_APPIMAGETOOL:-appimagetool}"
AnyLinuxDie() {
printf '[anylinux] error: %s\n' "$*" >&2
exit 1
}
AnyLinuxCleanup() {
[[ -z "${ANYLINUX_WORK}" ]] || rm -rf -- "${ANYLINUX_WORK}"
}
AnyLinuxUsage() {
cat <<'EOF'
Usage: scripts/package_anylinux.sh --build-dir DIR --appimage FILE [OPTIONS]
Install the native application to /usr, let quick-sharun deploy its complete
runtime, then run quick-sharun --make-appimage. No portable bundle is embedded.
By default /usr is a private, read-only bubblewrap overlay of a DESTDIR install;
the host installation is never modified. Requires an Arch Linux build host.
--system-install Install directly to /usr in a disposable build container.
Refused outside Docker/Podman containers. Used by CI.
--deploy-installed Internal: package the already prepared /usr installation.
MOCKTAIL_ANYLINUX_PACKAGER and MOCKTAIL_ANYLINUX_APPIMAGETOOL select the tools.
MOCKTAIL_ANYLINUX_SHARUN and MOCKTAIL_ANYLINUX_C_SOURCE select verified local
upstream inputs; quick-sharun itself installs/builds them into the AppDir.
EOF
}
AnyLinuxParseArguments() {
while (( $# > 0 )); do
case "$1" in
--build-dir|--appimage)
(( $# >= 2 )) || AnyLinuxDie "$1 requires a path"
if [[ "$1" == --build-dir ]]; then
ANYLINUX_BUILD_DIR="$2"
else
ANYLINUX_OUTPUT="$2"
fi
shift 2
;;
--system-install) ANYLINUX_SYSTEM_INSTALL=true; shift ;;
--deploy-installed) ANYLINUX_DEPLOY_INSTALLED=true; shift ;;
-h|--help) AnyLinuxUsage; exit 0 ;;
*) AnyLinuxDie "unknown option: $1" ;;
esac
done
[[ -n "${ANYLINUX_OUTPUT}" ]] || AnyLinuxDie "--appimage is required"
[[ "${ANYLINUX_OUTPUT}" != *$'\n'* &&
"${ANYLINUX_OUTPUT}" != *$'\r'* ]] || AnyLinuxDie "unsafe output path"
[[ ! -L "${ANYLINUX_OUTPUT}" ]] || AnyLinuxDie "refusing symlink output"
ANYLINUX_OUTPUT="$(realpath -m -- "${ANYLINUX_OUTPUT}")"
[[ "${ANYLINUX_OUTPUT}" == *.AppImage && ! -d "${ANYLINUX_OUTPUT}" ]] ||
AnyLinuxDie "output must be an AppImage file"
}
AnyLinuxVerifyInstalled() {
local entry
for entry in /usr/bin/mocktail \
/usr/lib/mocktail/mocktail_updater \
/usr/lib/mocktail/mocktail_failure_dialog \
/usr/lib/mocktail/mocktail_webview_helper; do
[[ -x "${entry}" ]] || AnyLinuxDie "missing installed executable: ${entry}"
done
for entry in libEGL.so libvulkan.so libmediandk.so; do
[[ -r "/usr/lib/mocktail/${entry}" ]] ||
AnyLinuxDie "missing installed Android adapter: ${entry}"
done
for entry in roblox_compatibility.json roblox_bootstrap_sources.json \
roblox_host_abi_reference.json roblox_signing_certificates.json; do
[[ -r "/usr/share/mocktail/metadata/${entry}" ]] ||
AnyLinuxDie "missing installed metadata: ${entry}"
done
LC_ALL=C readelf -l /usr/bin/mocktail | grep -q 'ld-linux.*so' ||
AnyLinuxDie "AnyLinux requires a glibc runtime"
if [[ -f /usr/share/mocktail/helpers/mocktail_freebsd_socket_helper ]]; then
ANYLINUX_FREEBSD_HELPER=/usr/share/mocktail/helpers/mocktail_freebsd_socket_helper
fi
}
AnyLinuxVerifyAppDir() {
local app_dir="$1" entry
[[ ! -e "${app_dir}/usr" ]] || AnyLinuxDie "unexpected usr prefix in AppDir"
[[ ! -e "${app_dir}/share/mocktail-bundle" ]] ||
AnyLinuxDie "a portable bundle was embedded in AppDir"
for entry in mocktail mocktail_updater mocktail_failure_dialog \
mocktail_webview_helper; do
[[ -x "${app_dir}/bin/${entry}" ]] ||
AnyLinuxDie "quick-sharun did not deploy ${entry}"
done
for entry in WebKitNetworkProcess WebKitWebProcess WebKitGPUProcess; do
[[ -x "${app_dir}/lib/webkitgtk-6.0/${entry}" ]] ||
AnyLinuxDie "quick-sharun did not preserve the WebKit process layout: ${entry}"
done
[[ -r "${app_dir}/lib/webkitgtk-6.0/injected-bundle/libwebkitgtkinjectedbundle.so" ]] ||
AnyLinuxDie "missing injected WebKit bundle"
[[ -f "${app_dir}/AppRun.sh" && -f "${app_dir}/AppRun.lib" ]] ||
AnyLinuxDie "missing quick-sharun startup hooks"
for entry in libEGL.so libvulkan.so libmediandk.so; do
[[ -r "${app_dir}/lib/mocktail/${entry}" ]] ||
AnyLinuxDie "Android adapter was not deployed in its private directory: ${entry}"
done
for entry in roblox_compatibility.json roblox_bootstrap_sources.json \
roblox_host_abi_reference.json roblox_signing_certificates.json; do
[[ -r "${app_dir}/share/mocktail/metadata/${entry}" ]] ||
AnyLinuxDie "quick-sharun did not deploy metadata: ${entry}"
done
if [[ -n "${ANYLINUX_FREEBSD_HELPER}" ]]; then
cmp -s -- "${ANYLINUX_FREEBSD_HELPER}" \
"${app_dir}/share/mocktail/helpers/mocktail_freebsd_socket_helper" ||
AnyLinuxDie "the static FreeBSD helper was changed or omitted"
fi
}
AnyLinuxPrepareInstallTree() {
local prefix="$1"
# This static FreeBSD executable must not be turned into a Linux sharun
# wrapper. Keep it as application data, outside the nested lib executables
# that quick-sharun wraps. The native runtime uses its explicit .env path.
local helper="${prefix}/lib/mocktail/mocktail_freebsd_socket_helper"
if [[ -f "${helper}" ]]; then
install -d -- "${prefix}/share/mocktail/helpers"
mv -- "${helper}" "${prefix}/share/mocktail/helpers/"
fi
}
AnyLinuxDeploy() {
AnyLinuxVerifyInstalled
local app_dir="${ANYLINUX_WORK}/AppDir"
mkdir -p -- "${app_dir}" "${ANYLINUX_WORK}/tmp"
# .env is runtime configuration, not a custom AppRun. The generated
# upstream entry point must execute all relocation/certificate hooks.
install -m 0644 -- "${ANYLINUX_PROJECT_ROOT}/packaging/anylinux.env" \
"${app_dir}/.env"
local -a deployment_environment=(
"APPDIR=${app_dir}" "MAIN_BIN=mocktail" "LIB_DIR=/usr/lib"
"TMPDIR=${ANYLINUX_WORK}/tmp"
"DESKTOP=/usr/share/applications/space.bigrat.mocktail.desktop"
"ICON=/usr/share/icons/hicolor/scalable/apps/space.bigrat.mocktail.svg"
"DEPLOY_DATADIR=0" "DEPLOY_LOCALE=0" "DEPLOY_VULKAN=0"
"STRACE_MODE=1" "STRACE_BINARY=mocktail" "STRACE_FLAGS=--help"
"STRACE_TIME=1"
)
if [[ -n "${MOCKTAIL_ANYLINUX_SHARUN:-}" ]]; then
[[ -x "${MOCKTAIL_ANYLINUX_SHARUN}" ]] || AnyLinuxDie "invalid sharun input"
deployment_environment+=("SHARUN_LINK=file://$(realpath -e -- "${MOCKTAIL_ANYLINUX_SHARUN}")")
fi
if [[ -n "${MOCKTAIL_ANYLINUX_C_SOURCE:-}" ]]; then
[[ -r "${MOCKTAIL_ANYLINUX_C_SOURCE}" ]] || AnyLinuxDie "invalid anylinux.c input"
deployment_environment+=("ANYLINUX_LIB_SOURCE=file://$(realpath -e -- "${MOCKTAIL_ANYLINUX_C_SOURCE}")")
fi
# Upstream prefers wget, which cannot fetch file:// inputs. Adapt only
# those local tool downloads; all network downloads still use real wget.
if [[ -n "${MOCKTAIL_ANYLINUX_SHARUN:-}${MOCKTAIL_ANYLINUX_C_SOURCE:-}" ]] &&
command -v wget >/dev/null; then
local downloader_dir="${ANYLINUX_WORK}/download-tools"
mkdir -p -- "${downloader_dir}"
cat >"${downloader_dir}/wget" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "$#" == 3 && "$1" == -qO && "$3" == file://* ]]; then
cp -- "${3#file://}" "$2"
else
exec "${MOCKTAIL_ANYLINUX_REAL_WGET}" "$@"
fi
EOF
chmod 0755 -- "${downloader_dir}/wget"
deployment_environment+=(
"MOCKTAIL_ANYLINUX_REAL_WGET=$(command -v wget)"
"PATH=${downloader_dir}:${PATH}"
)
fi
printf '[anylinux] deploying the native /usr installation\n' >&2
env "${deployment_environment[@]}" "${ANYLINUX_TOOL}" \
/usr/bin/mocktail \
/usr/lib/mocktail/mocktail_webview_helper \
/usr/lib/mocktail/mocktail_failure_dialog \
/usr/lib/mocktail/mocktail_updater \
/usr/lib/mocktail /usr/share/mocktail /usr/bin/bash
AnyLinuxVerifyAppDir "${app_dir}"
# Use the upstream creation entry point, rather than maintaining a second
# appimagetool invocation and a second copy of the userspace libraries.
env "${deployment_environment[@]}" \
APPIMAGETOOL="${ANYLINUX_APPIMAGETOOL}" \
OUTPATH="${ANYLINUX_WORK}/image" \
OUTNAME="$(basename -- "${ANYLINUX_OUTPUT}")" \
"${ANYLINUX_TOOL}" --make-appimage
local result="${ANYLINUX_WORK}/image/$(basename -- "${ANYLINUX_OUTPUT}")"
[[ -x "${result}" ]] || AnyLinuxDie "quick-sharun did not create the requested AppImage"
mv -f -- "${result}" "${ANYLINUX_OUTPUT}"
printf '[anylinux] AppImage ready: %s\n' "${ANYLINUX_OUTPUT}" >&2
}
AnyLinuxMain() {
AnyLinuxParseArguments "$@"
local command_name
for command_name in cmake readelf realpath "${ANYLINUX_TOOL}" "${ANYLINUX_APPIMAGETOOL}"; do
command -v "${command_name}" >/dev/null ||
AnyLinuxDie "missing required command: ${command_name}"
done
ANYLINUX_TOOL="$(realpath -e -- "$(command -v "${ANYLINUX_TOOL}")")"
ANYLINUX_APPIMAGETOOL="$(realpath -e -- "$(command -v "${ANYLINUX_APPIMAGETOOL}")")"
export MOCKTAIL_ANYLINUX_PACKAGER="${ANYLINUX_TOOL}"
export MOCKTAIL_ANYLINUX_APPIMAGETOOL="${ANYLINUX_APPIMAGETOOL}"
mkdir -p -- "$(dirname -- "${ANYLINUX_OUTPUT}")"
ANYLINUX_WORK="$(mktemp -d "$(dirname -- "${ANYLINUX_OUTPUT}")/.mocktail-anylinux.XXXXXX")"
trap AnyLinuxCleanup EXIT
if [[ "${ANYLINUX_DEPLOY_INSTALLED}" == true ]]; then
AnyLinuxDeploy
return
fi
ANYLINUX_BUILD_DIR="$(realpath -e -- "${ANYLINUX_BUILD_DIR}")"
[[ -f "${ANYLINUX_BUILD_DIR}/cmake_install.cmake" ]] ||
AnyLinuxDie "build directory has no CMake installation rules"
local install_root="${ANYLINUX_WORK}/install"
if [[ "${ANYLINUX_SYSTEM_INSTALL}" == true ]]; then
[[ -f /.dockerenv || -f /run/.containerenv ]] ||
AnyLinuxDie "--system-install is only allowed in a disposable container"
[[ "$(id -u)" == 0 ]] || AnyLinuxDie "container installation requires root"
env -u DESTDIR cmake --install "${ANYLINUX_BUILD_DIR}" --prefix /usr
AnyLinuxPrepareInstallTree /usr
AnyLinuxDeploy
return
fi
command -v bwrap >/dev/null || AnyLinuxDie "bwrap is required for a private /usr install"
DESTDIR="${install_root}" cmake --install "${ANYLINUX_BUILD_DIR}" --prefix /usr
AnyLinuxPrepareInstallTree "${install_root}/usr"
# Overlay only /usr; keep the host root read-only, with narrowly scoped
# writable packaging scratch/output directories. No host package install.
bwrap --die-with-parent --ro-bind / / --dev-bind /dev /dev --proc /proc \
--overlay-src /usr --overlay-src "${install_root}/usr" --ro-overlay /usr \
--bind "$(dirname -- "${ANYLINUX_OUTPUT}")" "$(dirname -- "${ANYLINUX_OUTPUT}")" \
--bind /tmp /tmp \
-- "${BASH}" "${BASH_SOURCE[0]}" --deploy-installed --appimage "${ANYLINUX_OUTPUT}"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
AnyLinuxMain "$@"
fi
+31 -25
View File
@@ -28,6 +28,7 @@ STAGING=""
APPDIR=""
PACKAGING_LIBRARY_PATH=""
ANDROID_TOOLS_ABI=""
APPIMAGE_FORMAT="${MOCKTAIL_APPIMAGE_FORMAT:-classic}"
readonly -a PROJECT_ARTIFACTS=(
mocktail
@@ -51,19 +52,7 @@ readonly -a PROJECT_ARTIFACTS=(
readonly FREEBSD_SOCKET_HELPER="mocktail_freebsd_socket_helper"
readonly -a RUNTIME_SCRIPTS=(
auto_update_roblox.sh
derive_roblox_host_abi_profile.py
fetch_roblox_apks.sh
update_roblox_payload.sh
payload_store.sh
payload_integrity.sh
real_bringup_smoke.sh
audio_readiness_gate.sh
input_readiness_gate.sh
resize_readiness_gate.sh
network_readiness_gate.sh
collect_support_bundle.sh
read_update_config.py
)
readonly STANDALONE_SUPPORT_PACKAGER="${PROJECT_ROOT}/scripts/package_standalone_support.sh"
@@ -86,15 +75,17 @@ Options:
--mode MODE standalone (default) or thin; aliases: full, static,
dynamic, and legacy minimal.
--dry-run Audit artifacts and dependency policy without writing.
--appimage FILE Also build FILE with appimagetool.
--appimage FILE Also build FILE. Set MOCKTAIL_APPIMAGE_FORMAT to
classic (default) or anylinux. AnyLinux uses a native
/usr install instead of producing a portable directory.
-h, --help Show this help.
standalone bundles the application userspace and support tools. thin omits
copied dependency libraries and bootstraps host packages before launch.
The physical GPU kernel driver and vendor ICD remain host-provided.
Every Mocktail ELF build artifact must match --libc. glibc bundles use compact
native aapt plus the Java apksigner library. A musl standalone bundle uses the
Every Mocktail ELF build artifact must match --libc. Classic glibc bundles use
compact native aapt plus the Java apksigner library. A musl standalone bundle uses the
Java apkanalyzer backend instead of hiding a glibc runtime dependency.
No Android SDK development tree is published. Public release artifacts are:
Mocktail-x86_64-<libc>-<standalone|thin>.AppImage.
@@ -165,6 +156,12 @@ ParseArguments() {
Die "--mode must be standalone or thin"
[[ "${TARGET_LIBC}" == glibc || "${TARGET_LIBC}" == musl ]] ||
Die "--libc must be glibc or musl"
[[ "${APPIMAGE_FORMAT}" == classic ||
"${APPIMAGE_FORMAT}" == anylinux ]] ||
Die "MOCKTAIL_APPIMAGE_FORMAT must be classic or anylinux"
if [[ "${APPIMAGE_FORMAT}" == anylinux && "${TARGET_LIBC}" != glibc ]]; then
Die "AnyLinux packaging requires a glibc build"
fi
if [[ "${TARGET_LIBC}" == musl && "${MODE}" == standalone ]]; then
ANDROID_TOOLS_ABI=java
@@ -475,8 +472,8 @@ AuditThinDependencies() {
CopyRuntimeTree() {
local runtime_root="${STAGING}/mocktail"
mkdir -p -- "${runtime_root}/bin" "${runtime_root}/lib" \
"${runtime_root}/scripts/apk_providers" \
"${runtime_root}/metadata" "${runtime_root}/runtime"
"${runtime_root}/scripts" "${runtime_root}/metadata" \
"${runtime_root}/runtime"
chmod 0755 -- "${STAGING}"
local artifact mode
@@ -504,11 +501,6 @@ CopyRuntimeTree() {
install -m 0755 -- "${PROJECT_ROOT}/scripts/${script}" \
"${runtime_root}/scripts/${script}"
done
install -m 0755 -- "${PROJECT_ROOT}/scripts/apk_providers/direct_apkpure.py" \
"${PROJECT_ROOT}/scripts/apk_providers/direct_apkpure.sh" \
"${PROJECT_ROOT}/scripts/apk_providers/direct_uptodown.py" \
"${PROJECT_ROOT}/scripts/apk_providers/direct_uptodown.sh" \
"${runtime_root}/scripts/apk_providers/"
}
CopyApkAnalyzerClasspath() {
@@ -869,7 +861,7 @@ VerifyBundle() {
if [[ "${MODE}" == standalone ]]; then
local support_elf
for support_elf in \
runtime/bin/bash runtime/bin/python3 runtime/jre/bin/java \
runtime/bin/bash runtime/jre/bin/java \
runtime/bin/bwrap runtime/bin/xdg-dbus-proxy \
libexec/webkitgtk-6.0/WebKitWebProcess \
libexec/gstreamer-1.0/gst-plugin-scanner; do
@@ -920,7 +912,6 @@ PublishBundle() {
BuildAppImage() {
local icon_path icon_relative_path
[[ -n "${APPIMAGE_OUTPUT}" ]] || return 0
RequireCommand appimagetool
APPDIR="$(mktemp -d "${OUTPUT%/*}/.Mocktail.AppDir.XXXXXX")"
mkdir -p "${APPDIR}/usr/share/mocktail-bundle" \
"${APPDIR}/usr/share/applications" \
@@ -976,6 +967,19 @@ DryRun() {
Main() {
ParseArguments "$@"
if [[ -n "${APPIMAGE_OUTPUT}" && "${APPIMAGE_FORMAT}" == anylinux ]]; then
[[ "${MODE}" == standalone ]] ||
Die "AnyLinux is self-contained; use classic for a thin bundle"
[[ "${DRY_RUN}" == false ]] ||
Die "AnyLinux packaging requires a real native install, not a portable dry-run"
local -a anylinux_arguments=(
--build-dir "${BUILD_DIR}" --appimage "${APPIMAGE_OUTPUT}"
)
if [[ "${MOCKTAIL_ANYLINUX_SYSTEM_INSTALL:-0}" == 1 ]]; then
anylinux_arguments+=(--system-install)
fi
exec "${PROJECT_ROOT}/scripts/package_anylinux.sh" "${anylinux_arguments[@]}"
fi
RequireCommand readelf
RequireCommand patchelf
RequireCommand sha256sum
@@ -1022,4 +1026,6 @@ Main() {
BuildAppImage
}
Main "$@"
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
Main "$@"
fi
+24 -284
View File
@@ -48,24 +48,14 @@ readonly -a REQUIRED_COMMANDS=(
uname
)
readonly -a PYTHON_DISTRIBUTIONS=(
capstone
PyYAML
requests
urllib3
certifi
charset-normalizer
idna
)
Usage() {
cat <<'EOF'
Usage: scripts/package_standalone_support.sh --runtime-root DIR --libc ABI
Populate DIR/runtime with the native command, Python, Java, and CA support
needed by the standalone Mocktail scripts. ABI must be glibc or musl and must
match the build host. ELF dependency closure is intentionally left to the
parent packager.
Populate DIR/runtime with the native command, Java, and CA support needed by
the standalone Mocktail launcher. ABI must be glibc or musl and must match the
build host. ELF dependency closure is intentionally left to the parent
packager.
EOF
}
@@ -125,7 +115,7 @@ ParseArguments() {
RequireBuildCommands() {
local command_name
for command_name in cp cmp install java jlink ldd ln mkdir mv python3 \
for command_name in cp cmp install java jlink ldd ln mkdir mv \
readlink realpath rm rmdir; do
command -v "${command_name}" >/dev/null 2>&1 ||
Die "required build command is unavailable: ${command_name}"
@@ -197,16 +187,6 @@ ValidateNativeHost() {
Die "requested ${TARGET_LIBC} support on a ${host_libc} host; cross-packaging is unsupported"
}
ValidateRelativePath() {
local -r path="$1"
RejectUnsafeText "${path}" "relative path"
[[ "${path}" != /* && "${path}" != . && "${path}" != .. &&
"${path}" != ../* && "${path}" != */../* &&
"${path}" != */.. && "${path}" != ./* &&
"${path}" != */./* && "${path}" != */. &&
"${path}" != *//* ]] || Die "unsafe relative path: ${path}"
}
PathIsInside() {
local -r path="$1"
local -r root="$2"
@@ -229,87 +209,6 @@ InstallRegularFile() {
cp -p -- "${source}" "${destination}"
}
CopySafeLink() {
local -r source="$1"
local -r source_root="$2"
local -r destination="$3"
local -r destination_root="$4"
local target resolved relative_target destination_target
target="$(readlink -- "${source}")" || Die "cannot read symlink: ${source}"
RejectUnsafeText "${target}" "symlink target"
resolved="$(realpath -e -- "${source}")" ||
Die "dangling symlink is not permitted: ${source}"
if ! PathIsInside "${resolved}" "${source_root}"; then
# Host packages sometimes point a data file (not code) at a canonical
# system copy, for example certifi/cacert.pem. Materialize that file so
# the packaged tree does not retain an absolute or escaping link.
[[ -f "${resolved}" && ! -L "${resolved}" ]] ||
Die "symlink escapes its copied tree: ${source} -> ${target}"
InstallRegularFile "${resolved}" "${destination}"
return
fi
relative_target="$(realpath --relative-to="$(dirname -- "${source}")" -- \
"${resolved}")" || Die "cannot normalize symlink: ${source}"
ValidateRelativePath "${relative_target}"
destination_target="$(realpath -m -- \
"$(dirname -- "${destination}")/${relative_target}")"
PathIsInside "${destination_target}" "${destination_root}" ||
Die "rewritten symlink escapes destination: ${destination}"
mkdir -p -- "$(dirname -- "${destination}")"
if [[ -e "${destination}" || -L "${destination}" ]]; then
[[ -L "${destination}" &&
"$(readlink -- "${destination}")" == "${relative_target}" ]] ||
Die "support symlink collision: ${destination}"
return
fi
ln -s -- "${relative_target}" "${destination}"
}
CopyTree() {
local -r source_input="$1"
local -r destination_root="$2"
local -r prune_python_packages="${3:-false}"
local source_root
source_root="$(realpath -e -- "${source_input}")" ||
Die "cannot resolve source tree: ${source_input}"
[[ -d "${source_root}" && ! -L "${source_root}" ]] ||
Die "source tree must resolve to a directory: ${source_input}"
mkdir -p -- "${destination_root}"
local entry relative destination
while IFS= read -r -d '' entry; do
relative="${entry#"${source_root}"/}"
ValidateRelativePath "${relative}"
destination="${destination_root}/${relative}"
if [[ -L "${entry}" ]]; then
CopySafeLink "${entry}" "${source_root}" "${destination}" \
"${destination_root}"
elif [[ -d "${entry}" ]]; then
if [[ -e "${destination}" && ! -d "${destination}" ]]; then
Die "support directory collision: ${destination}"
fi
mkdir -p -- "${destination}"
chmod --reference="${entry}" "${destination}"
elif [[ -f "${entry}" ]]; then
InstallRegularFile "${entry}" "${destination}"
else
Die "unsupported special file in source tree: ${entry}"
fi
done < <(
if [[ "${prune_python_packages}" == true ]]; then
find -P "${source_root}" -mindepth 1 \
\( -type d \( -name site-packages -o -name dist-packages -o \
-name __pycache__ \) -prune \) -o \
\( -type f -name '*.pyc' -prune \) -o -print0
else
find -P "${source_root}" -mindepth 1 \
\( -type d -name __pycache__ -prune \) -o \
\( -type f -name '*.pyc' -prune \) -o -print0
fi
)
}
CopyCommand() {
local -r name="$1"
local source resolved target_name
@@ -345,161 +244,6 @@ CopyRuntimeCommands() {
fi
}
PythonValue() {
local -r expression="$1"
python3 -c "import sys, sysconfig; print(${expression})"
}
PythonDistributionFiles() {
local -r distribution="$1"
python3 - "${distribution}" <<'PY'
import importlib.metadata
import importlib.util
import os
import pathlib
import sys
def find_import_root(source):
candidates = []
for entry in sys.path:
if not entry:
continue
try:
root = pathlib.Path(entry).resolve(strict=True)
source.relative_to(root)
except (OSError, ValueError):
continue
candidates.append(root)
if not candidates:
raise SystemExit(1)
return max(candidates, key=lambda path: len(path.parts))
name = sys.argv[1]
try:
installed_distribution = importlib.metadata.distribution(name)
except importlib.metadata.PackageNotFoundError:
installed_distribution = None
entries = []
if installed_distribution is not None and installed_distribution.files:
root = pathlib.Path(
installed_distribution.locate_file("")
).resolve(strict=True)
for entry in installed_distribution.files:
entries.append((
root,
pathlib.Path(installed_distribution.locate_file(entry)),
pathlib.PurePath(entry),
))
else:
fallback_modules = {
"capstone": ("capstone",),
"pyyaml": ("yaml", "_yaml"),
"requests": ("requests",),
"urllib3": ("urllib3",),
"certifi": ("certifi",),
"charset-normalizer": ("charset_normalizer",),
"idna": ("idna",),
}.get(name.lower(), ())
discovered = []
for module in fallback_modules:
spec = importlib.util.find_spec(module)
if spec is None:
raise SystemExit(1)
if spec.submodule_search_locations is not None:
module_root = pathlib.Path(
next(iter(spec.submodule_search_locations))
).resolve(strict=True)
discovered.extend(
path for path in module_root.rglob("*")
if path.is_file() or path.is_symlink()
)
elif spec.origin:
discovered.append(pathlib.Path(spec.origin).resolve(strict=True))
for source in discovered:
root = find_import_root(source)
entries.append((root, source, source.relative_to(root)))
written = 0
for root, source, relative in entries:
relative = pathlib.PurePath(relative)
if relative.is_absolute() or ".." in relative.parts:
continue
if "__pycache__" in relative.parts or relative.suffix == ".pyc":
continue
if not source.exists() and not source.is_symlink():
raise SystemExit(1)
os.write(1, os.fsencode(root) + b"\0")
os.write(1, os.fsencode(source) + b"\0")
os.write(1, os.fsencode(relative) + b"\0")
written += 1
if written == 0:
raise SystemExit(1)
PY
}
CopyPythonRuntime() {
local python_executable python_resolved python_version stdlib platstdlib
python_executable="$(type -P -- python3 || true)"
[[ -n "${python_executable}" ]] || Die "python3 is required to build support"
python_resolved="$(realpath -e -- "${python_executable}")" ||
Die "cannot resolve python3"
[[ -f "${python_resolved}" && -x "${python_resolved}" ]] ||
Die "python3 does not resolve to an executable regular file"
python_version="$(PythonValue 'f"{sys.version_info.major}.{sys.version_info.minor}"')"
[[ "${python_version}" =~ ^[0-9]+\.[0-9]+$ ]] ||
Die "cannot determine Python version"
stdlib="$(PythonValue 'sysconfig.get_path("stdlib")')"
platstdlib="$(PythonValue 'sysconfig.get_path("platstdlib")')"
RejectUnsafeText "${stdlib}" "Python stdlib path"
RejectUnsafeText "${platstdlib}" "Python platform stdlib path"
local -r python_bin="${SUPPORT_RUNTIME}/python/bin"
local -r python_library="${SUPPORT_RUNTIME}/python/lib/python${python_version}"
mkdir -p -- "${python_bin}" "${python_library}"
local python_binary_name
python_binary_name="$(basename -- "${python_resolved}")"
InstallRegularFile "${python_resolved}" \
"${python_bin}/${python_binary_name}"
if [[ "${python_binary_name}" != python3 ]]; then
ln -s -- "${python_binary_name}" "${python_bin}/python3"
fi
ln -s -- "../python/bin/python3" "${SUPPORT_RUNTIME}/bin/python3"
CopyTree "${stdlib}" "${python_library}" true
local canonical_stdlib canonical_platstdlib
canonical_stdlib="$(realpath -e -- "${stdlib}")"
canonical_platstdlib="$(realpath -e -- "${platstdlib}")"
if [[ "${canonical_platstdlib}" != "${canonical_stdlib}" ]]; then
CopyTree "${platstdlib}" "${python_library}" true
fi
local distribution distribution_root source relative destination
for distribution in "${PYTHON_DISTRIBUTIONS[@]}"; do
local copied=0
while IFS= read -r -d '' distribution_root &&
IFS= read -r -d '' source &&
IFS= read -r -d '' relative; do
ValidateRelativePath "${relative}"
distribution_root="$(realpath -e -- "${distribution_root}")" ||
Die "cannot resolve Python distribution root: ${distribution}"
destination="${python_library}/${relative}"
if [[ -L "${source}" ]]; then
CopySafeLink "${source}" "${distribution_root}" "${destination}" \
"${python_library}"
elif [[ -f "${source}" ]]; then
InstallRegularFile "${source}" "${destination}"
elif [[ ! -d "${source}" ]]; then
Die "unsupported Python distribution entry: ${source}"
fi
((copied += 1))
done < <(PythonDistributionFiles "${distribution}")
(( copied > 0 )) ||
Die "required Python distribution is unavailable: ${distribution}"
done
}
CopyCaBundle() {
local -a candidates=()
if [[ -n "${SSL_CERT_FILE:-}" ]]; then
@@ -549,6 +293,22 @@ BuildJlinkRuntime() {
--strip-debug --no-man-pages \
--no-header-files --compress="${compression}" \
--output "${SUPPORT_RUNTIME}/jre"
# apkanalyzer needs java.desktop data structures, but neither it nor
# apksigner uses Java Sound. Some OpenJDK builds link this optional native
# library against ALSA, which is intentionally absent from the AnyLinux
# build image and must not become a standalone runtime dependency.
local java_sound_library
for java_sound_library in \
"${SUPPORT_RUNTIME}/jre/lib/libjsound.so" \
"${SUPPORT_RUNTIME}/jre/lib/libjsoundalsa.so"; do
if [[ -e "${java_sound_library}" || -L "${java_sound_library}" ]]; then
[[ ! -d "${java_sound_library}" ]] ||
Die "Java Sound library path is unexpectedly a directory"
rm -f -- "${java_sound_library}"
fi
done
[[ -x "${SUPPORT_RUNTIME}/jre/bin/java" ]] ||
Die "jlink did not produce a Java launcher"
ln -s -- "../jre/bin/java" "${SUPPORT_RUNTIME}/bin/java"
@@ -573,27 +333,8 @@ ValidateCopiedSymlinks() {
}
VerifySupportRuntime() {
local -r python="${SUPPORT_RUNTIME}/bin/python3"
local -r ca_bundle="${SUPPORT_RUNTIME}/share/ca-certificates/ca-bundle.crt"
[[ -x "${python}" && -s "${ca_bundle}" ]] ||
Die "packaged Python or CA support is incomplete"
PYTHONDONTWRITEBYTECODE=1 PYTHONNOUSERSITE=1 "${python}" -s -c '
import pathlib
import sys
import capstone
import yaml
import certifi
import charset_normalizer
import idna
import requests
import urllib3
if capstone.cs_version()[0] != 5:
raise SystemExit("bundled capstone major version is not 5")
prefix = pathlib.Path(sys.prefix).resolve()
expected = pathlib.Path(sys.executable).resolve().parents[1]
if prefix != expected:
raise SystemExit(f"Python prefix escaped support runtime: {prefix}")
' || Die "relocated Python dependency verification failed"
[[ -s "${ca_bundle}" ]] || Die "packaged CA support is incomplete"
ValidateCopiedSymlinks "${SUPPORT_RUNTIME}"
}
@@ -621,12 +362,11 @@ Main() {
"${RUNTIME_ROOT}/.mocktail-standalone-support.XXXXXX")" ||
Die "cannot create support staging directory"
SUPPORT_RUNTIME="${STAGING_ROOT}/runtime"
mkdir -p -- "${SUPPORT_RUNTIME}/bin" "${SUPPORT_RUNTIME}/python" \
"${SUPPORT_RUNTIME}/jre" "${SUPPORT_RUNTIME}/share"
mkdir -p -- "${SUPPORT_RUNTIME}/bin" "${SUPPORT_RUNTIME}/jre" \
"${SUPPORT_RUNTIME}/share"
rmdir -- "${SUPPORT_RUNTIME}/jre"
CopyRuntimeCommands
CopyPythonRuntime
CopyCaBundle
BuildJlinkRuntime
VerifySupportRuntime
+5 -1
View File
@@ -435,12 +435,16 @@ CopyGdkPixbufResources() {
"${STAGING}/share/glycin-loaders/2+/conf.d"
local executable config
while IFS= read -r -d '' executable; do
# Arch ships the HEIF loader even when its optional libheif dependency is
# absent. Mocktail's WebKit UI does not require HEIF/AVIF decoding.
[[ "${executable##*/}" != glycin-heif ]] || continue
ValidateElf "${executable}" "glycin image loader"
install -m 0755 -- "${executable}" \
"${STAGING}/lib/plugins/glycin-loaders/2+/${executable##*/}"
done < <(find -P "${GLYCIN_BINARY_SOURCE}" -maxdepth 1 -type f \
-perm -0100 -print0 | sort -z)
while IFS= read -r -d '' config; do
[[ "${config##*/}" != glycin-heif.conf ]] || continue
sed -E \
's#^([[:space:]]*Exec=).*/([^/[:space:]]+)[[:space:]]*$#\1lib/plugins/glycin-loaders/2+/\2#' \
"${config}" \
@@ -481,7 +485,7 @@ CopyGtkData() {
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<dir>share/fonts</dir>
<dir prefix="relative">../fonts</dir>
<cachedir prefix="xdg">fontconfig</cachedir>
</fontconfig>
EOF
+71 -17
View File
@@ -1,5 +1,6 @@
#include "mocktail/audio/opensl_playback_runtime.h"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdio>
@@ -8,7 +9,9 @@
#include <memory>
#include <mutex>
#include <new>
#include <unordered_set>
#include <utility>
#include <vector>
#include "mocktail/audio/audio_sink.h"
#include "mocktail/audio/opensl_simple_buffer_queue.h"
@@ -89,6 +92,9 @@ extern const abi::PlayTable kPlayTable;
extern const abi::VolumeTable kVolumeTable;
extern const abi::AndroidConfigurationTable kConfigurationTable;
std::mutex g_active_objects_mutex;
std::unordered_set<RuntimeObject*> g_active_objects;
struct RuntimeObject {
explicit RuntimeObject(RuntimeObjectKind object_kind) : kind(object_kind) {
object_handle = ObjectHandle{&kObjectTable, this};
@@ -96,6 +102,13 @@ struct RuntimeObject {
play_handle = PlayHandle{&kPlayTable, this};
volume_handle = VolumeHandle{&kVolumeTable, this};
configuration_handle = ConfigurationHandle{&kConfigurationTable, this};
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
g_active_objects.insert(this);
}
~RuntimeObject() {
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
g_active_objects.erase(this);
}
abi::Object ObjectInterface() {
@@ -133,6 +146,9 @@ struct RuntimeObject {
abi::Int32 priority = 0;
abi::Boolean preemptable = abi::kBooleanFalse;
RuntimeObject* parent_engine = nullptr;
std::vector<RuntimeObject*> children;
PcmSpec pcm_spec;
abi::Uint32 queue_capacity = 0;
bool expose_queue = false;
@@ -150,14 +166,20 @@ struct RuntimeObject {
std::atomic<bool> consumption_reported{false};
};
RuntimeObject* ResolveActiveObject(RuntimeObject* object) {
if (object == nullptr || object->magic != kRuntimeObjectMagic) {
return nullptr;
}
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
return g_active_objects.find(object) != g_active_objects.end() ? object : nullptr;
}
RuntimeObject* FromObject(abi::Object self) {
if (self == nullptr) {
return nullptr;
}
const auto* handle = reinterpret_cast<const ObjectHandle*>(self);
RuntimeObject* object = handle->object;
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
: nullptr;
return ResolveActiveObject(handle->object);
}
RuntimeObject* FromEngine(abi::Engine self) {
@@ -165,9 +187,7 @@ RuntimeObject* FromEngine(abi::Engine self) {
return nullptr;
}
const auto* handle = reinterpret_cast<const EngineHandle*>(self);
RuntimeObject* object = handle->object;
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
: nullptr;
return ResolveActiveObject(handle->object);
}
RuntimeObject* FromPlay(abi::Play self) {
@@ -175,9 +195,7 @@ RuntimeObject* FromPlay(abi::Play self) {
return nullptr;
}
const auto* handle = reinterpret_cast<const PlayHandle*>(self);
RuntimeObject* object = handle->object;
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
: nullptr;
return ResolveActiveObject(handle->object);
}
RuntimeObject* FromVolume(abi::Volume self) {
@@ -185,9 +203,7 @@ RuntimeObject* FromVolume(abi::Volume self) {
return nullptr;
}
const auto* handle = reinterpret_cast<const VolumeHandle*>(self);
RuntimeObject* object = handle->object;
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
: nullptr;
return ResolveActiveObject(handle->object);
}
RuntimeObject* FromConfiguration(abi::AndroidConfiguration self) {
@@ -195,9 +211,7 @@ RuntimeObject* FromConfiguration(abi::AndroidConfiguration self) {
return nullptr;
}
const auto* handle = reinterpret_cast<const ConfigurationHandle*>(self);
RuntimeObject* object = handle->object;
return object != nullptr && object->magic == kRuntimeObjectMagic ? object
: nullptr;
return ResolveActiveObject(handle->object);
}
bool InterfaceIdEquals(abi::InterfaceId left, abi::InterfaceId right) {
@@ -547,10 +561,40 @@ abi::Result ObjectRegisterCallback(abi::Object self,
void ObjectAbortAsyncOperation(abi::Object /*self*/) {}
void ObjectDestroy(abi::Object self) {
RuntimeObject* object = FromObject(self);
if (object == nullptr) {
if (self == nullptr) {
return;
}
RuntimeObject* object = nullptr;
{
std::lock_guard<std::mutex> lock(g_active_objects_mutex);
const auto* handle = reinterpret_cast<const ObjectHandle*>(self);
object = handle->object;
if (object == nullptr ||
g_active_objects.find(object) == g_active_objects.end()) {
return;
}
if (object->magic != kRuntimeObjectMagic) {
return;
}
g_active_objects.erase(object);
}
if (object->parent_engine != nullptr) {
std::lock_guard<std::mutex> lock(object->parent_engine->mutex);
auto& ch = object->parent_engine->children;
ch.erase(std::remove(ch.begin(), ch.end(), object), ch.end());
object->parent_engine = nullptr;
}
std::vector<RuntimeObject*> children_to_destroy;
if (object->kind == RuntimeObjectKind::kEngine) {
std::lock_guard<std::mutex> lock(object->mutex);
children_to_destroy = std::move(object->children);
}
for (auto* child : children_to_destroy) {
child->parent_engine = nullptr;
ObjectDestroy(child->ObjectInterface());
}
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> queue_adapter;
const bool is_player = object->kind == RuntimeObjectKind::kPlayer;
@@ -687,6 +731,11 @@ abi::Result EngineCreateAudioPlayer(
if (object == nullptr) {
return abi::kResultMemoryFailure;
}
object->parent_engine = engine;
{
std::lock_guard<std::mutex> lock(engine->mutex);
engine->children.push_back(object);
}
object->pcm_spec = pcm_spec;
object->queue_capacity = queue_capacity;
object->expose_queue = expose_queue;
@@ -763,6 +812,11 @@ abi::Result EngineCreateOutputMix(abi::Engine self, abi::Object* mix,
if (object == nullptr) {
return abi::kResultMemoryFailure;
}
object->parent_engine = engine;
{
std::lock_guard<std::mutex> lock(engine->mutex);
engine->children.push_back(object);
}
*mix = object->ObjectInterface();
return abi::kResultSuccess;
}
+85 -39
View File
@@ -3,6 +3,7 @@
#include <pthread.h>
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <cstddef>
#include <cstring>
@@ -13,6 +14,7 @@
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace mocktail::audio {
namespace {
@@ -56,6 +58,7 @@ struct OpenSlSimpleBufferQueueAdapter::State
std::shared_ptr<State> state;
std::uint64_t id = 0;
std::uint64_t generation = 0;
bool in_use = false;
};
struct CallbackJob {
@@ -68,7 +71,13 @@ struct OpenSlSimpleBufferQueueAdapter::State
: sink(std::move(owned_sink)),
max_buffers(options.max_buffers),
event_callback(options.event_callback),
event_context(options.event_context) {}
event_context(options.event_context),
ticket_pool(options.max_buffers) {
free_tickets.reserve(options.max_buffers);
for (std::size_t i = options.max_buffers; i > 0; --i) {
free_tickets.push_back(i - 1);
}
}
static const AndroidSimpleBufferQueueTable kQueueTable;
@@ -101,43 +110,49 @@ struct OpenSlSimpleBufferQueueAdapter::State
if (state->stopping) {
return opensl_abi::kResultPreconditionsViolated;
}
if (state->pending.size() >= state->max_buffers) {
if (state->pending_count.load(std::memory_order_relaxed) >=
state->max_buffers ||
state->free_tickets.empty()) {
return opensl_abi::kResultBufferInsufficient;
}
const std::size_t slot_index = state->free_tickets.back();
state->free_tickets.pop_back();
ticket = &state->ticket_pool[slot_index];
id = state->next_id++;
ticket = new (std::nothrow) ReleaseTicket{state, id, state->generation};
if (ticket == nullptr) {
return opensl_abi::kResultMemoryFailure;
}
state->pending.emplace(id, ticket);
ticket->state = state;
ticket->id = id;
ticket->generation = state->generation;
ticket->in_use = true;
state->pending_count.fetch_add(1, std::memory_order_relaxed);
++state->submitted_buffers;
}
const PcmBuffer pcm{buffer, static_cast<std::size_t>(size),
&State::ReleaseBuffer, ticket};
const Status status = state->sink->Enqueue(pcm);
if (status.ok()) {
{
std::lock_guard<std::mutex> lock(state->mutex);
++state->submitted_buffers;
}
state->NotifyEvent(OpenSlBufferQueueEvent::kSubmitted,
static_cast<std::size_t>(size));
return opensl_abi::kResultSuccess;
}
bool delete_ticket = false;
{
std::lock_guard<std::mutex> lock(state->mutex);
state->last_status = status;
const auto found = state->pending.find(id);
if (found != state->pending.end() && found->second == ticket) {
state->pending.erase(found);
delete_ticket = true;
if (ticket->in_use && ticket->id == id) {
ticket->in_use = false;
ticket->state.reset();
const std::size_t slot_index =
static_cast<std::size_t>(ticket - state->ticket_pool.data());
state->free_tickets.push_back(slot_index);
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (state->submitted_buffers > 0) {
--state->submitted_buffers;
}
}
}
if (delete_ticket) {
delete ticket;
}
return ToOpenSlResult(status);
}
@@ -149,12 +164,26 @@ struct OpenSlSimpleBufferQueueAdapter::State
}
const std::shared_ptr<State> state = raw_state->shared_from_this();
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
std::size_t discarded_count = 0;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (state->stopping) {
return opensl_abi::kResultPreconditionsViolated;
}
++state->generation;
state->free_tickets.clear();
for (auto& ticket : state->ticket_pool) {
if (ticket.in_use) {
ticket.in_use = false;
ticket.state.reset();
++discarded_count;
}
}
for (std::size_t i = state->max_buffers; i > 0; --i) {
state->free_tickets.push_back(i - 1);
}
state->discarded_buffers += discarded_count;
state->pending_count.store(0, std::memory_order_relaxed);
}
const Status status = state->sink->Clear();
if (!status.ok()) {
@@ -170,11 +199,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
if (state == nullptr || output == nullptr) {
return opensl_abi::kResultParameterInvalid;
}
std::lock_guard<std::mutex> lock(state->mutex);
output->count = static_cast<Uint32>(std::min<std::size_t>(
state->pending.size(), std::numeric_limits<Uint32>::max()));
state->pending_count.load(std::memory_order_relaxed),
std::numeric_limits<Uint32>::max()));
output->index = static_cast<Uint32>(std::min<std::uint64_t>(
state->processed_buffers, std::numeric_limits<Uint32>::max()));
state->processed_buffers.load(std::memory_order_relaxed),
std::numeric_limits<Uint32>::max()));
return opensl_abi::kResultSuccess;
}
@@ -201,18 +231,25 @@ struct OpenSlSimpleBufferQueueAdapter::State
return;
}
const std::shared_ptr<State> state = ticket->state;
bool delete_ticket = false;
if (state == nullptr) {
return;
}
bool notify_worker = false;
bool consumed = false;
bool discarded = false;
{
std::lock_guard<std::mutex> lock(state->mutex);
const auto found = state->pending.find(ticket->id);
if (found != state->pending.end() && found->second == ticket) {
state->pending.erase(found);
delete_ticket = true;
if (ticket->in_use) {
ticket->in_use = false;
ticket->state.reset();
const std::size_t slot_index =
static_cast<std::size_t>(ticket - state->ticket_pool.data());
state->free_tickets.push_back(slot_index);
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
}
if (!state->stopping && ticket->generation == state->generation) {
++state->processed_buffers;
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
++state->consumed_buffers;
state->consumed_bytes += size_bytes;
consumed = true;
@@ -227,17 +264,14 @@ struct OpenSlSimpleBufferQueueAdapter::State
}
}
}
if (notify_worker) {
state->callback_cv.notify_one();
}
if (delete_ticket) {
delete ticket;
}
if (consumed) {
state->NotifyEvent(OpenSlBufferQueueEvent::kConsumed, size_bytes);
} else if (discarded) {
state->NotifyEvent(OpenSlBufferQueueEvent::kDiscarded, size_bytes);
}
if (notify_worker) {
state->callback_cv.notify_one();
}
}
void NotifyEvent(OpenSlBufferQueueEvent event, std::size_t size_bytes) {
@@ -281,8 +315,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
return Status::Error(StatusCode::kUnavailable,
"unable to allocate OpenSL callback thread state");
}
const int result = pthread_create(&callback_thread, nullptr,
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 256 * 1024);
const int result = pthread_create(&callback_thread, &attr,
&State::CallbackThreadMain, state_holder);
pthread_attr_destroy(&attr);
if (result != 0) {
delete state_holder;
return Status::Error(
@@ -336,14 +374,16 @@ struct OpenSlSimpleBufferQueueAdapter::State
void* const event_context;
Handle handle;
pthread_t callback_thread{};
std::unordered_map<std::uint64_t, ReleaseTicket*> pending;
std::vector<ReleaseTicket> ticket_pool;
std::vector<std::size_t> free_tickets;
std::atomic<std::size_t> pending_count{0};
std::deque<CallbackJob> callback_jobs;
AndroidSimpleBufferQueueCallback callback = nullptr;
void* callback_context = nullptr;
Status last_status;
std::uint64_t next_id = 1;
std::uint64_t generation = 1;
std::uint64_t processed_buffers = 0;
std::atomic<std::uint64_t> processed_buffers{0};
std::uint64_t submitted_buffers = 0;
std::uint64_t consumed_buffers = 0;
std::uint64_t discarded_buffers = 0;
@@ -475,7 +515,8 @@ OpenSlSimpleBufferQueueStats OpenSlSimpleBufferQueueAdapter::GetStats() const {
std::lock_guard<std::mutex> lock(state->mutex);
return OpenSlSimpleBufferQueueStats{
state->submitted_buffers, state->consumed_buffers,
state->discarded_buffers, state->consumed_bytes, state->pending.size()};
state->discarded_buffers, state->consumed_bytes,
state->pending_count.load(std::memory_order_relaxed)};
}
Status OpenSlSimpleBufferQueueAdapter::last_error() const {
@@ -521,10 +562,15 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
{
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->pending.empty()) {
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
state->last_status = Status::Error(
StatusCode::kPlatformError,
"audio sink did not release all borrowed OpenSL buffers");
for (auto& ticket : state->ticket_pool) {
ticket.in_use = false;
ticket.state.reset();
}
state->pending_count.store(0, std::memory_order_relaxed);
}
state->shutdown_complete = true;
}
+447
View File
@@ -0,0 +1,447 @@
#include "mocktail/audio/sdl_audio_capture.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <atomic>
#include <charconv>
#include <limits>
#include <mutex>
#include <new>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
namespace mocktail::audio {
namespace {
struct SdlRecordingSubsystemState {
std::mutex mutex;
std::uint32_t configured_device_id = 0;
std::string configured_device_name = "default";
std::size_t live_captures = 0;
};
SdlRecordingSubsystemState& RecordingSubsystemState() {
static SdlRecordingSubsystemState state;
return state;
}
Status SdlError(const char* operation) {
std::string message(operation);
message.append(": ");
const char* detail = SDL_GetError();
message.append(detail != nullptr && detail[0] != '\0' ? detail
: "unknown SDL error");
return Status::Error(StatusCode::kPlatformError, std::move(message));
}
Status InvalidArgument(std::string message) {
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
}
Status FailedPrecondition(std::string message) {
return Status::Error(StatusCode::kFailedPrecondition, std::move(message));
}
bool AudioSubsystemInitialized() {
return (SDL_WasInit(SDL_INIT_AUDIO) & SDL_INIT_AUDIO) != 0;
}
SDL_AudioFormat ToSdlFormat(PcmSampleFormat format) {
switch (format) {
case PcmSampleFormat::kUnsigned8:
return SDL_AUDIO_U8;
case PcmSampleFormat::kSigned16LittleEndian:
return SDL_AUDIO_S16LE;
case PcmSampleFormat::kSigned32LittleEndian:
return SDL_AUDIO_S32LE;
case PcmSampleFormat::kFloat32LittleEndian:
return SDL_AUDIO_F32LE;
}
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,
std::vector<std::uint8_t> buffer, SDL_AudioStream* stream,
AudioCaptureDataCallback callback, void* callback_context)
: output_spec_(spec),
frames_per_buffer_(frames_per_buffer),
buffer_(std::move(buffer)),
stream_(stream),
callback_(callback),
callback_context_(callback_context) {}
~SdlAudioCapture() override { Shutdown(); }
const PcmSpec& output_spec() const override { return output_spec_; }
void* buffer_data() override { return buffer_.data(); }
std::size_t buffer_size_bytes() const override { return buffer_.size(); }
std::size_t frames_per_buffer() const override {
return frames_per_buffer_;
}
void AdoptStream(SDL_AudioStream* stream) { stream_ = stream; }
void MarkRegistered() { registered_ = true; }
Status Start() override {
std::lock_guard<std::mutex> lock(operation_mutex_);
if (stream_ == nullptr || stopping_.load(std::memory_order_acquire)) {
return FailedPrecondition("SDL audio capture is shut down");
}
if (running_.load(std::memory_order_acquire)) {
return Status::Ok();
}
if (!SDL_ResumeAudioStreamDevice(stream_)) {
return SdlError("SDL_ResumeAudioStreamDevice");
}
running_.store(true, std::memory_order_release);
return Status::Ok();
}
Status Stop() override {
std::lock_guard<std::mutex> lock(operation_mutex_);
if (stream_ == nullptr || stopping_.load(std::memory_order_acquire)) {
return Status::Ok();
}
running_.store(false, std::memory_order_release);
if (!SDL_PauseAudioStreamDevice(stream_)) {
return SdlError("SDL_PauseAudioStreamDevice");
}
if (!SDL_ClearAudioStream(stream_)) {
return SdlError("SDL_ClearAudioStream");
}
return Status::Ok();
}
void Shutdown() override {
std::lock_guard<std::mutex> lock(operation_mutex_);
if (stream_ == nullptr) {
return;
}
stopping_.store(true, std::memory_order_release);
running_.store(false, std::memory_order_release);
(void)SDL_PauseAudioStreamDevice(stream_);
SDL_DestroyAudioStream(stream_);
stream_ = nullptr;
if (registered_) {
registered_ = false;
ReleaseCapture();
}
}
static void SDLCALL OnAudioAvailable(void* userdata,
SDL_AudioStream* stream,
int /*additional_amount*/,
int /*total_amount*/) {
auto* capture = static_cast<SdlAudioCapture*>(userdata);
if (capture == nullptr || stream == nullptr ||
capture->stopping_.load(std::memory_order_acquire) ||
!capture->running_.load(std::memory_order_acquire)) {
return;
}
const int frame_bytes = static_cast<int>(capture->buffer_.size());
while (!capture->stopping_.load(std::memory_order_acquire) &&
capture->running_.load(std::memory_order_acquire)) {
const int available = SDL_GetAudioStreamAvailable(stream);
if (available < frame_bytes) {
return;
}
const int read = SDL_GetAudioStreamData(stream, capture->buffer_.data(),
frame_bytes);
if (read != frame_bytes) {
return;
}
capture->callback_(capture->callback_context_,
static_cast<std::size_t>(read));
}
}
private:
PcmSpec output_spec_;
std::size_t frames_per_buffer_ = 0;
std::vector<std::uint8_t> buffer_;
SDL_AudioStream* stream_ = nullptr;
AudioCaptureDataCallback callback_ = nullptr;
void* callback_context_ = nullptr;
std::mutex operation_mutex_;
std::atomic<bool> running_{false};
std::atomic<bool> stopping_{false};
bool registered_ = false;
};
} // namespace
Status ListSdlRecordingDevices(std::vector<SdlRecordingDevice>* devices) {
if (devices == nullptr) {
return InvalidArgument("SDL recording device output pointer is null");
}
devices->clear();
if (!AudioSubsystemInitialized()) {
return FailedPrecondition(
"SDL audio subsystem must be initialized before device enumeration");
}
int count = 0;
SDL_AudioDeviceID* ids = SDL_GetAudioRecordingDevices(&count);
if (ids == nullptr) {
return SdlError("SDL_GetAudioRecordingDevices");
}
devices->reserve(count > 0 ? static_cast<std::size_t>(count) : 0U);
for (int index = 0; index < count; ++index) {
const char* name = SDL_GetAudioDeviceName(ids[index]);
if (name == nullptr || name[0] == '\0') {
SDL_free(ids);
devices->clear();
return SdlError("SDL_GetAudioDeviceName(recording)");
}
devices->push_back(
{static_cast<std::uint32_t>(ids[index]), std::string(name)});
}
SDL_free(ids);
return Status::Ok();
}
Status ResolveSdlRecordingDevice(
std::string_view requested,
const std::vector<SdlRecordingDevice>& available_devices,
std::uint32_t* recording_device_id, std::string* resolved_name) {
if (recording_device_id == nullptr || resolved_name == nullptr) {
return InvalidArgument("SDL recording selection output pointer is null");
}
*recording_device_id = 0;
resolved_name->clear();
if (requested.empty() || requested.size() > 512U) {
return InvalidArgument(
"audio input device must be `default`, `id:<number>`, or a bounded "
"device name");
}
for (const unsigned char character : requested) {
if (character < 0x20U || character == 0x7fU) {
return InvalidArgument(
"audio input device must not contain control bytes");
}
}
if (requested == "default") {
*resolved_name = "default";
return Status::Ok();
}
if (requested.rfind("id:", 0) == 0) {
std::uint32_t parsed = 0;
const std::string_view value = requested.substr(3);
const auto parse =
std::from_chars(value.data(), value.data() + value.size(), parsed);
if (value.empty() || parse.ec != std::errc{} ||
parse.ptr != value.data() + value.size() || parsed == 0) {
return InvalidArgument("audio input device ID is invalid");
}
const auto found =
std::find_if(available_devices.begin(), available_devices.end(),
[parsed](const SdlRecordingDevice& device) {
return device.id == parsed;
});
if (found == available_devices.end()) {
return Status::Error(StatusCode::kUnavailable,
"configured audio input device ID is unavailable");
}
*recording_device_id = found->id;
*resolved_name = found->name;
return Status::Ok();
}
const SdlRecordingDevice* match = nullptr;
for (const SdlRecordingDevice& device : available_devices) {
if (device.name != requested) {
continue;
}
if (match != nullptr) {
return InvalidArgument(
"audio input device name is ambiguous; use its printed `id:` "
"selector");
}
match = &device;
}
if (match == nullptr) {
return Status::Error(StatusCode::kUnavailable,
"configured audio input device is unavailable: " +
std::string(requested));
}
*recording_device_id = match->id;
*resolved_name = match->name;
return Status::Ok();
}
Status ConfigureSdlRecordingDevice(
std::string_view requested,
std::vector<SdlRecordingDevice>* available_devices,
std::string* resolved_name) {
if (available_devices == nullptr || resolved_name == nullptr) {
return InvalidArgument("SDL recording configuration output is null");
}
Status status = ListSdlRecordingDevices(available_devices);
if (!status.ok() && requested != "default") {
return status;
}
if (!status.ok()) {
available_devices->clear();
}
std::uint32_t recording_device_id = 0;
status = ResolveSdlRecordingDevice(requested, *available_devices,
&recording_device_id, resolved_name);
if (!status.ok()) {
return status;
}
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
if (state.live_captures != 0) {
return FailedPrecondition(
"SDL recording device must be configured before opening a capture");
}
state.configured_device_id = recording_device_id;
state.configured_device_name = *resolved_name;
return Status::Ok();
}
Status GetConfiguredSdlRecordingDevice(std::uint32_t* recording_device_id,
std::string* resolved_name) {
if (recording_device_id == nullptr || resolved_name == nullptr) {
return InvalidArgument(
"configured SDL recording device output pointer is null");
}
if (!AudioSubsystemInitialized()) {
return FailedPrecondition(
"SDL audio subsystem must be initialized before reading input route");
}
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
*recording_device_id = state.configured_device_id;
*resolved_name = state.configured_device_name;
return Status::Ok();
}
Status CreateSdlAudioCapture(const SdlAudioCaptureOptions& options,
std::unique_ptr<AudioCapture>* capture) {
if (capture == nullptr) {
return InvalidArgument("audio capture output pointer is null");
}
capture->reset();
Status status = ValidatePcmSpec(options.output_spec);
if (!status.ok()) {
return status;
}
if (options.data_callback == nullptr || options.frame_duration_ms == 0 ||
options.frame_duration_ms > 1000 ||
(static_cast<std::uint64_t>(options.output_spec.sample_rate_hz) *
options.frame_duration_ms) %
1000U !=
0) {
return InvalidArgument("SDL capture requires a valid fixed-frame callback");
}
const std::uint64_t frames =
static_cast<std::uint64_t>(options.output_spec.sample_rate_hz) *
options.frame_duration_ms / 1000U;
const std::uint64_t buffer_bytes =
frames * BytesPerFrame(options.output_spec);
if (frames == 0 || buffer_bytes == 0 ||
buffer_bytes > static_cast<std::uint64_t>(
std::numeric_limits<int>::max())) {
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;
}
const SDL_AudioDeviceID device =
recording_device_id == 0
? SDL_AUDIO_DEVICE_DEFAULT_RECORDING
: static_cast<SDL_AudioDeviceID>(recording_device_id);
SDL_AudioSpec spec{};
spec.format = ToSdlFormat(options.output_spec.format);
spec.channels = options.output_spec.channels;
spec.freq = options.output_spec.sample_rate_hz;
std::vector<std::uint8_t> buffer(static_cast<std::size_t>(buffer_bytes));
auto* implementation = new (std::nothrow) SdlAudioCapture(
options.output_spec, static_cast<std::size_t>(frames), std::move(buffer),
nullptr, options.data_callback, options.data_context);
if (implementation == nullptr) {
return Status::Error(StatusCode::kUnavailable,
"unable to allocate SDL audio capture");
}
SDL_AudioStream* stream = SDL_OpenAudioDeviceStream(
device, &spec, &SdlAudioCapture::OnAudioAvailable, implementation);
if (stream == nullptr) {
delete implementation;
return SdlError("SDL_OpenAudioDeviceStream(recording)");
}
implementation->AdoptStream(stream);
status = RegisterCapture();
if (!status.ok()) {
delete implementation;
return status;
}
implementation->MarkRegistered();
capture->reset(implementation);
return Status::Ok();
}
Status PrepareSdlAudioCaptureSubsystemShutdown() {
SdlRecordingSubsystemState& state = RecordingSubsystemState();
std::lock_guard<std::mutex> lock(state.mutex);
if (state.live_captures != 0) {
return FailedPrecondition(
"SDL audio shutdown requires every capture to close");
}
state.configured_device_id = 0;
state.configured_device_name = "default";
return Status::Ok();
}
} // namespace mocktail::audio
+69 -6
View File
@@ -1,5 +1,7 @@
#include "mocktail/audio/sdl_audio_sink.h"
#include "mocktail/audio/sdl_audio_capture.h"
#include <SDL3/SDL.h>
#include <algorithm>
@@ -132,11 +134,16 @@ class SdlAudioSink final : public AudioSink {
public:
SdlAudioSink(PcmSpec spec, SDL_AudioStream* stream,
SDL_AudioDeviceID logical_device_id,
std::uint32_t physical_device_id)
std::uint32_t physical_device_id,
bool (*data_needed_callback)(void*, const void**,
std::size_t*),
void* data_needed_context)
: source_spec_(spec),
stream_(stream),
logical_device_id_(logical_device_id),
physical_device_id_(physical_device_id) {}
physical_device_id_(physical_device_id),
data_needed_callback_(data_needed_callback),
data_needed_context_(data_needed_context) {}
~SdlAudioSink() override { Shutdown(); }
@@ -264,7 +271,14 @@ class SdlAudioSink final : public AudioSink {
return Status::Ok();
}
switching_device_ = true;
shutdown_cv_.wait(lock, [this] { return active_calls_ == 0; });
shutdown_cv_.wait(lock,
[this] { return active_calls_ == 0 || shutting_down_; });
if (shutting_down_ || stream_ == nullptr || logical_device_id_ == 0) {
switching_device_ = false;
lock.unlock();
shutdown_cv_.notify_all();
return FailedPrecondition("audio sink is shut down");
}
const bool was_paused = SDL_AudioDevicePaused(logical_device_id_);
const SDL_AudioDeviceID replacement =
@@ -322,7 +336,9 @@ class SdlAudioSink final : public AudioSink {
return;
}
shutting_down_ = true;
shutdown_cv_.wait(lock, [this] { return active_calls_ == 0; });
shutdown_cv_.notify_all();
shutdown_cv_.wait(
lock, [this] { return active_calls_ == 0 && !switching_device_; });
stream = std::exchange(stream_, nullptr);
logical_device_id = std::exchange(logical_device_id_, 0);
}
@@ -345,6 +361,33 @@ class SdlAudioSink final : public AudioSink {
shutdown_cv_.notify_all();
}
static void SDLCALL OnDataNeeded(void* userdata, SDL_AudioStream* stream,
int additional_amount, int) {
auto* sink = static_cast<SdlAudioSink*>(userdata);
if (sink == nullptr || sink->data_needed_callback_ == nullptr ||
additional_amount <= 0) {
return;
}
constexpr std::size_t kMaximumBuffersPerCallback = 32;
std::size_t supplied = 0;
for (std::size_t index = 0;
supplied < static_cast<std::size_t>(additional_amount) &&
index < kMaximumBuffersPerCallback;
++index) {
const void* data = nullptr;
std::size_t size_bytes = 0;
if (!sink->data_needed_callback_(sink->data_needed_context_, &data,
&size_bytes) ||
data == nullptr || size_bytes == 0 ||
size_bytes >
static_cast<std::size_t>(std::numeric_limits<int>::max()) ||
!SDL_PutAudioStreamData(stream, data, static_cast<int>(size_bytes))) {
break;
}
supplied += size_bytes;
}
}
private:
template <typename Operation>
Status WithStream(const char* name, Operation operation) {
@@ -371,11 +414,17 @@ class SdlAudioSink final : public AudioSink {
}
void EndCall() const {
bool notify = false;
{
std::lock_guard<std::mutex> lock(mutex_);
--active_calls_;
if (__builtin_expect(shutting_down_ || switching_device_, 0)) {
notify = true;
}
}
if (notify) {
shutdown_cv_.notify_all();
}
shutdown_cv_.notify_all();
}
const PcmSpec source_spec_;
@@ -385,6 +434,8 @@ class SdlAudioSink final : public AudioSink {
SDL_AudioStream* stream_ = nullptr;
SDL_AudioDeviceID logical_device_id_ = 0;
std::uint32_t physical_device_id_ = 0;
bool (*data_needed_callback_)(void*, const void**, std::size_t*) = nullptr;
void* data_needed_context_ = nullptr;
mutable bool switching_device_ = false;
bool shutting_down_ = false;
bool shutdown_complete_ = false;
@@ -614,6 +665,10 @@ Status ShutdownSdlAudioSubsystem() {
return Status::Error(StatusCode::kFailedPrecondition,
"SDL audio shutdown requires every sink to close");
}
const Status capture_status = PrepareSdlAudioCaptureSubsystemShutdown();
if (!capture_status.ok()) {
return capture_status;
}
SDL_QuitSubSystem(SDL_INIT_AUDIO);
state.configured_playback_device_id = 0;
state.configured_playback_device_name = "default";
@@ -672,13 +727,21 @@ Status CreateSdlAudioSink(const SdlAudioSinkOptions& options,
}
auto* implementation = new (std::nothrow) SdlAudioSink(
options.source_spec, stream, logical_device, playback_device_id);
options.source_spec, stream, logical_device, playback_device_id,
options.data_needed_callback, options.data_needed_context);
if (implementation == nullptr) {
SDL_DestroyAudioStream(stream);
SDL_CloseAudioDevice(logical_device);
return Status::Error(StatusCode::kUnavailable,
"unable to allocate SDL audio sink");
}
if (options.data_needed_callback != nullptr &&
!SDL_SetAudioStreamGetCallback(stream, &SdlAudioSink::OnDataNeeded,
implementation)) {
status = SdlError("SDL_SetAudioStreamGetCallback");
delete implementation;
return status;
}
status = RegisterSdlAudioSink(implementation);
if (!status.ok()) {
delete implementation;
+648
View File
@@ -0,0 +1,648 @@
#include "mocktail/audio/webrtc_jni_audio_bridge.h"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <limits>
#include <memory>
#include <mutex>
#include <new>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
#include "jnivm/jnivm.h"
#include "mocktail/audio/sdl_audio_capture.h"
#include "mocktail/audio/sdl_audio_sink.h"
namespace mocktail::audio {
namespace {
struct CaptureSession {
const void* identity = nullptr;
jnivm::WebRtcAudioRecordDataCallback data_callback = nullptr;
void* data_context = nullptr;
std::unique_ptr<AudioCapture> capture;
std::shared_ptr<std::atomic<bool>> microphone_muted;
};
struct PlayoutSession {
const void* identity = nullptr;
jnivm::WebRtcAudioTrackDataCallback data_callback = nullptr;
void* data_context = nullptr;
std::unique_ptr<AudioSink> sink;
std::vector<std::uint8_t> buffer;
std::size_t frames_per_buffer = 0;
int buffer_size_frames = 0;
std::mutex operation_mutex;
std::mutex mutex;
bool running = false;
bool stop_requested = false;
bool in_data_callback = false;
std::thread::id data_callback_thread;
};
struct WebRtcJniAudioBridgeContext {
std::mutex mutex;
std::unordered_set<const void*> initialized_managers;
std::shared_ptr<std::atomic<bool>> microphone_muted =
std::make_shared<std::atomic<bool>>(false);
std::unordered_map<const void*, std::shared_ptr<CaptureSession>>
capture_sessions;
std::unordered_map<const void*, std::shared_ptr<PlayoutSession>>
playout_sessions;
bool recording_enabled = true;
bool shutting_down = false;
};
bool GetManagerParameters(void* opaque_context,
jnivm::WebRtcAudioManagerParameters* parameters) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || parameters == nullptr) return false;
std::lock_guard<std::mutex> lock(context->mutex);
if (context->shutting_down) return false;
// SDL converts between this WebRTC-facing format and the actual device.
// Match WebRtcAudioManager's default mono input/output and 10 ms frames.
// Advertising the format never opens a capture device, including when the
// microphone is disabled. Recording init remains the permission boundary.
*parameters = {};
parameters->sample_rate_hz = 48000;
parameters->output_channels = 1;
parameters->input_channels = 1;
parameters->output_buffer_size_frames = 480;
parameters->input_buffer_size_frames = 480;
// Android hardware effects, low-latency paths and AAudio are not emulated;
// WebRTC must retain its software processing and Java audio transport.
return true;
}
bool InitManager(void* opaque_context, const void* identity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr) return false;
bool inserted = false;
{
std::lock_guard<std::mutex> lock(context->mutex);
if (context->shutting_down) return false;
inserted = context->initialized_managers.insert(identity).second;
}
if (inserted) {
std::cout << " [mocktail][audio] WebRTC audio manager initialized\n"
<< std::flush;
}
return true;
}
void DisposeManager(void* opaque_context, const void* identity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr) return;
std::lock_guard<std::mutex> lock(context->mutex);
if (context->initialized_managers.erase(identity) != 0) {
std::cout << " [mocktail][audio] WebRTC audio manager disposed\n"
<< std::flush;
}
// Record/track objects own their streams and stop them independently.
}
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);
}
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->microphone_muted != nullptr &&
session->microphone_muted->load(std::memory_order_acquire)) {
if (session->capture == nullptr ||
size_bytes > session->capture->buffer_size_bytes()) {
return;
}
std::memset(session->capture->buffer_data(), 0, size_bytes);
}
session->data_callback(session->data_context, session->identity,
size_bytes);
}
}
void CloseCaptureSession(const std::shared_ptr<CaptureSession>& session) {
if (session == nullptr || session->capture == nullptr) {
return;
}
(void)session->capture->Stop();
session->capture->Shutdown();
}
int Init(void* opaque_context, const void* identity, int sample_rate_hz,
int channels, jnivm::WebRtcAudioRecordDataCallback data_callback,
void* data_context, void** direct_buffer,
std::size_t* direct_buffer_capacity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr || data_callback == nullptr ||
direct_buffer == nullptr || direct_buffer_capacity == nullptr) {
return -1;
}
*direct_buffer = nullptr;
*direct_buffer_capacity = 0;
std::shared_ptr<CaptureSession> previous;
{
std::lock_guard<std::mutex> lock(context->mutex);
if (!context->recording_enabled || context->shutting_down) {
return -1;
}
const auto found = context->capture_sessions.find(identity);
if (found != context->capture_sessions.end()) {
previous = std::move(found->second);
context->capture_sessions.erase(found);
}
}
CloseCaptureSession(previous);
auto session = std::shared_ptr<CaptureSession>(
new (std::nothrow) CaptureSession());
if (session == nullptr) {
return -1;
}
session->identity = identity;
session->data_callback = data_callback;
session->data_context = data_context;
session->microphone_muted = context->microphone_muted;
SdlAudioCaptureOptions options;
options.output_spec.sample_rate_hz = sample_rate_hz;
options.output_spec.channels =
channels > 0 && channels <= 255 ? static_cast<std::uint8_t>(channels) : 0;
options.output_spec.format = PcmSampleFormat::kSigned16LittleEndian;
options.frame_duration_ms = 10;
options.data_callback = &OnCapturedAudio;
options.data_context = session.get();
const Status status = CreateSdlAudioCapture(options, &session->capture);
if (!status.ok()) {
std::cerr << " [mocktail][audio] WebRTC microphone init_failed reason="
<< status.message() << '\n';
return -1;
}
if (session->capture->frames_per_buffer() >
static_cast<std::size_t>(std::numeric_limits<int>::max())) {
session->capture->Shutdown();
return -1;
}
*direct_buffer = session->capture->buffer_data();
*direct_buffer_capacity = session->capture->buffer_size_bytes();
const int frames =
static_cast<int>(session->capture->frames_per_buffer());
bool accepted = false;
{
std::lock_guard<std::mutex> lock(context->mutex);
if (context->recording_enabled && !context->shutting_down) {
context->capture_sessions[identity] = session;
accepted = true;
}
}
if (!accepted) {
*direct_buffer = nullptr;
*direct_buffer_capacity = 0;
CloseCaptureSession(session);
return -1;
}
std::cout << " [mocktail][audio] WebRTC microphone initialized "
<< "sample_rate_hz=" << sample_rate_hz
<< " channels=" << channels << " frames_per_buffer=" << frames
<< '\n'
<< std::flush;
return frames;
}
bool Start(void* opaque_context, const void* identity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr) {
return false;
}
std::shared_ptr<CaptureSession> session;
{
std::lock_guard<std::mutex> lock(context->mutex);
if (!context->recording_enabled || context->shutting_down) {
return false;
}
const auto found = context->capture_sessions.find(identity);
if (found != context->capture_sessions.end()) {
session = found->second;
}
}
if (session == nullptr || session->capture == nullptr) {
return false;
}
const Status status = session->capture->Start();
if (!status.ok()) {
std::cerr << " [mocktail][audio] WebRTC microphone start_failed reason="
<< status.message() << '\n';
return false;
}
std::cout << " [mocktail][audio] WebRTC microphone recording\n"
<< std::flush;
return true;
}
bool Stop(void* opaque_context, const void* identity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr) {
return false;
}
std::shared_ptr<CaptureSession> session;
{
std::lock_guard<std::mutex> lock(context->mutex);
const auto found = context->capture_sessions.find(identity);
if (found == context->capture_sessions.end()) {
return true;
}
session = std::move(found->second);
context->capture_sessions.erase(found);
}
CloseCaptureSession(session);
std::cout << " [mocktail][audio] WebRTC microphone stopped\n"
<< std::flush;
return true;
}
void Close(void* opaque_context, const void* identity) {
(void)Stop(opaque_context, identity);
}
bool OnPlayoutDataNeeded(void* opaque_session, const void** data,
std::size_t* size_bytes) {
auto* session = static_cast<PlayoutSession*>(opaque_session);
if (data == nullptr || size_bytes == nullptr) {
return false;
}
*data = nullptr;
*size_bytes = 0;
if (session == nullptr || session->buffer.empty()) {
return false;
}
{
std::lock_guard<std::mutex> lock(session->mutex);
if (!session->running || session->stop_requested) {
return false;
}
session->in_data_callback = true;
session->data_callback_thread = std::this_thread::get_id();
}
session->data_callback(session->data_context, session->identity,
session->buffer.size());
bool ready = false;
{
std::lock_guard<std::mutex> lock(session->mutex);
ready = session->running && !session->stop_requested;
session->in_data_callback = false;
session->data_callback_thread = {};
}
if (ready) {
*data = session->buffer.data();
*size_bytes = session->buffer.size();
}
return ready;
}
void ClosePlayoutSession(const std::shared_ptr<PlayoutSession>& session) {
if (session == nullptr) {
return;
}
std::lock_guard<std::mutex> operation_lock(session->operation_mutex);
bool called_from_callback = false;
{
std::lock_guard<std::mutex> lock(session->mutex);
session->running = false;
session->stop_requested = true;
called_from_callback =
session->in_data_callback &&
session->data_callback_thread == std::this_thread::get_id();
}
// SDL does not permit destroying a stream from its own get callback. The
// session stays in the context and is closed by re-init or bridge shutdown.
if (called_from_callback || session->sink == nullptr) {
return;
}
(void)session->sink->Pause();
(void)session->sink->Clear();
session->sink->Shutdown();
session->sink.reset();
}
int InitPlayout(void* opaque_context, const void* identity,
int sample_rate_hz, int channels, double buffer_size_factor,
jnivm::WebRtcAudioTrackDataCallback data_callback,
void* data_context, void** direct_buffer,
std::size_t* direct_buffer_capacity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr || data_callback == nullptr ||
direct_buffer == nullptr || direct_buffer_capacity == nullptr) {
return -1;
}
*direct_buffer = nullptr;
*direct_buffer_capacity = 0;
if (!std::isfinite(buffer_size_factor) || buffer_size_factor <= 0.0 ||
buffer_size_factor > 16.0 || (channels != 1 && channels != 2) ||
sample_rate_hz <= 0 || sample_rate_hz % 100 != 0) {
return -1;
}
const PcmSpec spec{sample_rate_hz, static_cast<std::uint8_t>(channels),
PcmSampleFormat::kSigned16LittleEndian};
Status status = ValidatePcmSpec(spec);
if (!status.ok()) {
return -1;
}
const std::size_t frames_per_buffer =
static_cast<std::size_t>(sample_rate_hz / 100);
const std::size_t bytes_per_frame = BytesPerFrame(spec);
if (frames_per_buffer >
static_cast<std::size_t>(std::numeric_limits<int>::max()) /
bytes_per_frame) {
return -1;
}
const std::size_t callback_bytes = frames_per_buffer * bytes_per_frame;
const double scaled_frames =
std::ceil(static_cast<double>(frames_per_buffer) * buffer_size_factor);
if (scaled_frames > static_cast<double>(std::numeric_limits<int>::max())) {
return -1;
}
const std::size_t buffer_size_frames = std::max(
frames_per_buffer, static_cast<std::size_t>(scaled_frames));
if (buffer_size_frames >
static_cast<std::size_t>(std::numeric_limits<int>::max()) /
bytes_per_frame) {
return -1;
}
const std::size_t buffer_size_bytes = buffer_size_frames * bytes_per_frame;
std::shared_ptr<PlayoutSession> previous;
{
std::lock_guard<std::mutex> lock(context->mutex);
if (context->shutting_down) {
return -1;
}
const auto found = context->playout_sessions.find(identity);
if (found != context->playout_sessions.end()) {
previous = std::move(found->second);
context->playout_sessions.erase(found);
}
}
ClosePlayoutSession(previous);
auto session = std::shared_ptr<PlayoutSession>(
new (std::nothrow) PlayoutSession());
if (session == nullptr) {
return -1;
}
session->identity = identity;
session->data_callback = data_callback;
session->data_context = data_context;
session->frames_per_buffer = frames_per_buffer;
session->buffer_size_frames = static_cast<int>(buffer_size_frames);
session->buffer.resize(callback_bytes);
SdlAudioSinkOptions options;
options.source_spec = spec;
options.start_paused = true;
options.data_needed_callback = &OnPlayoutDataNeeded;
options.data_needed_context = session.get();
status = CreateSdlAudioSink(options, &session->sink);
if (!status.ok()) {
std::cerr << " [mocktail][audio] WebRTC playout init_failed reason="
<< status.message() << '\n';
return -1;
}
bool accepted = false;
{
std::lock_guard<std::mutex> lock(context->mutex);
if (!context->shutting_down) {
context->playout_sessions[identity] = session;
accepted = true;
}
}
if (!accepted) {
ClosePlayoutSession(session);
return -1;
}
*direct_buffer = session->buffer.data();
*direct_buffer_capacity = session->buffer.size();
std::cout << " [mocktail][audio] WebRTC playout initialized "
<< "sample_rate_hz=" << sample_rate_hz
<< " channels=" << channels
<< " frames_per_buffer=" << frames_per_buffer << '\n'
<< std::flush;
return static_cast<int>(buffer_size_bytes);
}
int PlayoutBufferSizeFrames(void* opaque_context, const void* identity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr) {
return 0;
}
std::shared_ptr<PlayoutSession> session;
{
std::lock_guard<std::mutex> lock(context->mutex);
const auto found = context->playout_sessions.find(identity);
if (found != context->playout_sessions.end()) {
session = found->second;
}
}
return session != nullptr ? session->buffer_size_frames : 0;
}
bool StartPlayout(void* opaque_context, const void* identity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr) {
return false;
}
std::shared_ptr<PlayoutSession> session;
{
std::lock_guard<std::mutex> lock(context->mutex);
if (context->shutting_down) {
return false;
}
const auto found = context->playout_sessions.find(identity);
if (found != context->playout_sessions.end()) {
session = found->second;
}
}
if (session == nullptr || session->sink == nullptr) {
return false;
}
std::lock_guard<std::mutex> operation_lock(session->operation_mutex);
{
std::lock_guard<std::mutex> lock(session->mutex);
if (session->running) {
return true;
}
if (session->stop_requested) {
return false;
}
session->running = true;
}
const Status status = session->sink->Resume();
if (!status.ok()) {
std::lock_guard<std::mutex> lock(session->mutex);
session->running = false;
std::cerr << " [mocktail][audio] WebRTC playout start_failed reason="
<< status.message() << '\n';
return false;
}
std::cout << " [mocktail][audio] WebRTC playout playing\n" << std::flush;
return true;
}
bool StopPlayout(void* opaque_context, const void* identity) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr || identity == nullptr) {
return false;
}
std::shared_ptr<PlayoutSession> session;
bool called_from_callback = false;
{
std::lock_guard<std::mutex> lock(context->mutex);
const auto found = context->playout_sessions.find(identity);
if (found == context->playout_sessions.end()) {
return true;
}
session = found->second;
{
std::lock_guard<std::mutex> session_lock(session->mutex);
called_from_callback =
session->in_data_callback &&
session->data_callback_thread == std::this_thread::get_id();
}
if (!called_from_callback) {
context->playout_sessions.erase(found);
}
}
ClosePlayoutSession(session);
std::cout << " [mocktail][audio] WebRTC playout stopped\n" << std::flush;
return true;
}
void ClosePlayout(void* opaque_context, const void* identity) {
(void)StopPlayout(opaque_context, identity);
}
void Shutdown(void* opaque_context) {
auto* context = static_cast<WebRtcJniAudioBridgeContext*>(opaque_context);
if (context == nullptr) {
return;
}
std::unordered_map<const void*, std::shared_ptr<CaptureSession>>
capture_sessions;
std::unordered_map<const void*, std::shared_ptr<PlayoutSession>>
playout_sessions;
{
std::lock_guard<std::mutex> lock(context->mutex);
context->recording_enabled = false;
context->shutting_down = true;
context->initialized_managers.clear();
capture_sessions.swap(context->capture_sessions);
playout_sessions.swap(context->playout_sessions);
}
for (auto& [identity, session] : playout_sessions) {
(void)identity;
ClosePlayoutSession(session);
}
for (auto& [identity, session] : capture_sessions) {
(void)identity;
CloseCaptureSession(session);
}
}
} // namespace
Status InstallWebRtcJniAudioBridge(jnivm::VM* vm) {
if (vm == nullptr) {
return Status::Error(StatusCode::kInvalidArgument,
"WebRTC JNI audio bridge requires a VM");
}
Status status = InitializeSdlAudioSubsystem();
if (!status.ok()) {
return status;
}
const char* configured = std::getenv("MOCKTAIL_AUDIO_INPUT_DEVICE");
const std::string requested =
configured != nullptr && configured[0] != '\0' ? configured : "default";
auto context = std::shared_ptr<WebRtcJniAudioBridgeContext>(
new (std::nothrow) WebRtcJniAudioBridgeContext());
if (context == nullptr) {
return Status::Error(StatusCode::kUnavailable,
"unable to allocate WebRTC JNI audio bridge");
}
if (requested == "disabled") {
context->recording_enabled = false;
std::cout << " [mocktail][audio] microphone capture disabled\n"
<< std::flush;
} else {
std::vector<SdlRecordingDevice> devices;
std::string resolved_name;
status = ConfigureSdlRecordingDevice(requested, &devices, &resolved_name);
if (!status.ok()) {
return status;
}
std::cout << " [mocktail][audio] SDL recording devices:\n"
<< " - default (follows the host system default)\n";
for (const SdlRecordingDevice& device : devices) {
std::cout << " - id:" << device.id << " " << device.name << '\n';
}
std::cout << " [mocktail][audio] selected input=" << resolved_name
<< '\n'
<< std::flush;
}
jnivm::WebRtcAudioRecordCallbacks record_callbacks;
record_callbacks.init = &Init;
record_callbacks.start = &Start;
record_callbacks.stop = &Stop;
record_callbacks.close = &Close;
record_callbacks.shutdown = &Shutdown;
vm->SetWebRtcAudioRecordCallbacks(context, record_callbacks);
jnivm::WebRtcAudioManagerCallbacks manager_callbacks;
manager_callbacks.get_parameters = &GetManagerParameters;
manager_callbacks.init = &InitManager;
manager_callbacks.dispose = &DisposeManager;
manager_callbacks.set_microphone_mute = &SetMicrophoneMute;
vm->SetWebRtcAudioManagerCallbacks(context, manager_callbacks);
jnivm::WebRtcAudioTrackCallbacks track_callbacks;
track_callbacks.init = &InitPlayout;
track_callbacks.buffer_size_frames = &PlayoutBufferSizeFrames;
track_callbacks.start = &StartPlayout;
track_callbacks.stop = &StopPlayout;
track_callbacks.close = &ClosePlayout;
vm->SetWebRtcAudioTrackCallbacks(std::move(context), track_callbacks);
return Status::Ok();
}
Status ShutdownWebRtcJniAudioBridge(jnivm::VM* vm) {
if (vm == nullptr) {
return Status::Error(StatusCode::kInvalidArgument,
"WebRTC JNI audio bridge requires a VM");
}
// Drop dispatch first, then let the recording binding's shared context stop
// both playout workers and capture streams.
vm->ClearWebRtcAudioManagerCallbacks();
vm->ClearWebRtcAudioTrackCallbacks();
vm->ClearWebRtcAudioRecordCallbacks();
return Status::Ok();
}
} // namespace mocktail::audio
File diff suppressed because it is too large. Load diff
+150 -64
View File
@@ -1,61 +1,85 @@
#include "compat/bionic_rwlock_runtime.h"
#include <errno.h>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <climits>
#include <cstdint>
#include <cstdlib>
#include <cstring>
namespace {
struct HostRwlockEntry {
uintptr_t guest_address;
pthread_rwlock_t host_rwlock;
HostRwlockEntry* next;
};
// Bionic rwlock state word:
// bit 31 writer owns
// bits 30-2 reader count
// bit 1 pending writers
// bit 0 pending readers
constexpr int kRwlockWriterFlag = 1 << 31;
constexpr int kRwlockPendingWritersFlag = 1 << 1;
constexpr int kRwlockPendingReadersFlag = 1 << 0;
constexpr int kRwlockReaderStep = 1 << 2;
constexpr int kRwlockPendingMask =
kRwlockPendingWritersFlag | kRwlockPendingReadersFlag;
pthread_mutex_t g_table_mutex = PTHREAD_MUTEX_INITIALIZER;
HostRwlockEntry* g_table = nullptr;
int* RwlockState(pthread_rwlock_t* rwlock) {
return reinterpret_cast<int*>(rwlock);
}
pthread_rwlock_t* FindOrCreateHostRwlock(pthread_rwlock_t* guest_rwlock,
bool create) noexcept {
if (guest_rwlock == nullptr ||
reinterpret_cast<uintptr_t>(guest_rwlock) < 4096) {
return nullptr;
int32_t* RwlockWriterTid(pthread_rwlock_t* rwlock) {
return reinterpret_cast<int32_t*>(reinterpret_cast<char*>(rwlock) + 4);
}
pid_t RwlockTid() {
thread_local pid_t tid = 0;
if (__builtin_expect(tid == 0, 0)) {
tid = ::gettid();
}
return tid;
}
const uintptr_t guest_address = reinterpret_cast<uintptr_t>(guest_rwlock);
::pthread_mutex_lock(&g_table_mutex);
for (HostRwlockEntry* entry = g_table; entry != nullptr;
entry = entry->next) {
if (entry->guest_address == guest_address) {
::pthread_mutex_unlock(&g_table_mutex);
return &entry->host_rwlock;
int RwlockFutex(int* state, int op, int val) {
return static_cast<int>(::syscall(SYS_futex, state, op | FUTEX_PRIVATE_FLAG,
val, nullptr, nullptr, 0));
}
bool OwnedByWriter(int state) {
return state < 0;
}
bool OwnedByReaders(int state) {
return state >= kRwlockReaderStep;
}
int TryRdlock(int* state) {
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
while (!OwnedByWriter(old_state)) {
const int new_state = old_state + kRwlockReaderStep;
if (!OwnedByReaders(new_state)) {
return EAGAIN;
}
if (__atomic_compare_exchange_n(state, &old_state, new_state, true,
__ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) {
return 0;
}
}
return EBUSY;
}
if (!create) {
::pthread_mutex_unlock(&g_table_mutex);
return nullptr;
int TryWrlock(pthread_rwlock_t* rwlock) {
int* state = RwlockState(rwlock);
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
while (!OwnedByWriter(old_state) && !OwnedByReaders(old_state)) {
const int new_state = old_state | kRwlockWriterFlag;
if (__atomic_compare_exchange_n(state, &old_state, new_state, true,
__ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) {
__atomic_store_n(RwlockWriterTid(rwlock), static_cast<int32_t>(RwlockTid()),
__ATOMIC_RELAXED);
return 0;
}
}
auto* entry =
static_cast<HostRwlockEntry*>(std::calloc(1, sizeof(HostRwlockEntry)));
if (entry == nullptr) {
::pthread_mutex_unlock(&g_table_mutex);
return nullptr;
}
const int init_result = ::pthread_rwlock_init(&entry->host_rwlock, nullptr);
if (init_result != 0) {
std::free(entry);
::pthread_mutex_unlock(&g_table_mutex);
return nullptr;
}
entry->guest_address = guest_address;
entry->next = g_table;
g_table = entry;
::pthread_mutex_unlock(&g_table_mutex);
return &entry->host_rwlock;
return EBUSY;
}
} // namespace
@@ -64,45 +88,107 @@ extern "C" {
int mocktail_bionic_pthread_rwlock_init(
pthread_rwlock_t* rwlock, const pthread_rwlockattr_t* /*attr*/) {
return FindOrCreateHostRwlock(rwlock, true) == nullptr ? ENOMEM : 0;
if (rwlock == nullptr ||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
return EINVAL;
}
std::memset(rwlock, 0, sizeof(*rwlock));
return 0;
}
int mocktail_bionic_pthread_rwlock_destroy(pthread_rwlock_t* rwlock) {
if (rwlock == nullptr) {
return EINVAL;
}
const uintptr_t guest_address = reinterpret_cast<uintptr_t>(rwlock);
::pthread_mutex_lock(&g_table_mutex);
HostRwlockEntry** link = &g_table;
while (*link != nullptr) {
HostRwlockEntry* entry = *link;
if (entry->guest_address == guest_address) {
*link = entry->next;
::pthread_mutex_unlock(&g_table_mutex);
const int result = ::pthread_rwlock_destroy(&entry->host_rwlock);
std::free(entry);
return result;
}
link = &entry->next;
const int state = __atomic_load_n(RwlockState(rwlock), __ATOMIC_RELAXED);
if ((state & ~kRwlockPendingMask) != 0) {
return EBUSY;
}
::pthread_mutex_unlock(&g_table_mutex);
return 0;
}
int mocktail_bionic_pthread_rwlock_rdlock(pthread_rwlock_t* rwlock) {
pthread_rwlock_t* host_rwlock = FindOrCreateHostRwlock(rwlock, true);
return host_rwlock == nullptr ? EINVAL : ::pthread_rwlock_rdlock(host_rwlock);
if (rwlock == nullptr ||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
return EINVAL;
}
int* state = RwlockState(rwlock);
if (TryRdlock(state) == 0) {
return 0;
}
for (;;) {
const int result = TryRdlock(state);
if (result == 0 || result == EAGAIN) {
return result;
}
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
if (!OwnedByWriter(old_state)) {
continue;
}
const int pending = old_state | kRwlockPendingReadersFlag;
if (!__atomic_compare_exchange_n(state, &old_state, pending, true,
__ATOMIC_RELAXED, __ATOMIC_RELAXED)) {
continue;
}
(void)RwlockFutex(state, FUTEX_WAIT, pending);
}
}
int mocktail_bionic_pthread_rwlock_wrlock(pthread_rwlock_t* rwlock) {
pthread_rwlock_t* host_rwlock = FindOrCreateHostRwlock(rwlock, true);
return host_rwlock == nullptr ? EINVAL : ::pthread_rwlock_wrlock(host_rwlock);
if (rwlock == nullptr ||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
return EINVAL;
}
if (__atomic_load_n(RwlockWriterTid(rwlock), __ATOMIC_RELAXED) ==
static_cast<int32_t>(RwlockTid())) {
return EDEADLK;
}
if (TryWrlock(rwlock) == 0) {
return 0;
}
int* state = RwlockState(rwlock);
for (;;) {
if (TryWrlock(rwlock) == 0) {
return 0;
}
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
if (!OwnedByWriter(old_state) && !OwnedByReaders(old_state)) {
continue;
}
const int pending = old_state | kRwlockPendingWritersFlag;
if (!__atomic_compare_exchange_n(state, &old_state, pending, true,
__ATOMIC_RELAXED, __ATOMIC_RELAXED)) {
continue;
}
(void)RwlockFutex(state, FUTEX_WAIT, pending);
}
}
int mocktail_bionic_pthread_rwlock_unlock(pthread_rwlock_t* rwlock) {
pthread_rwlock_t* host_rwlock = FindOrCreateHostRwlock(rwlock, false);
return host_rwlock == nullptr ? EINVAL : ::pthread_rwlock_unlock(host_rwlock);
if (rwlock == nullptr ||
reinterpret_cast<uintptr_t>(rwlock) < 4096) {
return EINVAL;
}
int* state = RwlockState(rwlock);
int old_state = __atomic_load_n(state, __ATOMIC_RELAXED);
if (OwnedByWriter(old_state)) {
if (__atomic_load_n(RwlockWriterTid(rwlock), __ATOMIC_RELAXED) !=
static_cast<int32_t>(RwlockTid())) {
return EPERM;
}
__atomic_store_n(RwlockWriterTid(rwlock), 0, __ATOMIC_RELAXED);
old_state = __atomic_fetch_and(state, ~kRwlockWriterFlag, __ATOMIC_RELEASE);
} else if (OwnedByReaders(old_state)) {
old_state =
__atomic_fetch_sub(state, kRwlockReaderStep, __ATOMIC_RELEASE);
if ((old_state >> 2) != 1) {
return 0;
}
} else {
return EPERM;
}
(void)RwlockFutex(state, FUTEX_WAKE, INT_MAX);
return 0;
}
} // extern "C"
+79 -74
View File
@@ -1,62 +1,68 @@
#include "compat/bionic_semaphore_runtime.h"
#include <errno.h>
#include <pthread.h>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <climits>
#include <cstdint>
#include <cstdlib>
namespace {
struct HostSemaphoreEntry {
uintptr_t guest_address;
sem_t host_semaphore;
HostSemaphoreEntry* next;
};
// Bionic encodes the signed count in bits 31:1 and a process-shared flag in
// bit 0. Waiters park while the count is -1 (all value bits set, shared=0).
constexpr unsigned kSemValueMask = 0xfffffffeu;
constexpr unsigned kSemValueShift = 1u;
constexpr unsigned kSemOne = 1u << kSemValueShift;
constexpr unsigned kSemMinusOne = kSemValueMask;
pthread_mutex_t g_table_mutex = PTHREAD_MUTEX_INITIALIZER;
HostSemaphoreEntry* g_table = nullptr;
unsigned* SemCount(sem_t* semaphore) {
return reinterpret_cast<unsigned*>(semaphore);
}
sem_t* FindOrCreateHostSemaphore(sem_t* guest_semaphore, bool create,
unsigned int initial_value) noexcept {
if (guest_semaphore == nullptr) {
return nullptr;
}
int SemValue(unsigned encoded) {
return static_cast<int>(encoded) >> static_cast<int>(kSemValueShift);
}
const uintptr_t guest_address =
reinterpret_cast<uintptr_t>(guest_semaphore);
::pthread_mutex_lock(&g_table_mutex);
for (HostSemaphoreEntry* entry = g_table; entry != nullptr;
entry = entry->next) {
if (entry->guest_address == guest_address) {
::pthread_mutex_unlock(&g_table_mutex);
return &entry->host_semaphore;
unsigned SemEncode(unsigned value) {
return (value << kSemValueShift) & kSemValueMask;
}
int SemFutex(unsigned* count, int op, unsigned val) {
return static_cast<int>(
::syscall(SYS_futex, count, op | FUTEX_PRIVATE_FLAG, val, nullptr,
nullptr, 0));
}
int SemDec(unsigned* count) {
unsigned old_value = __atomic_load_n(count, __ATOMIC_RELAXED);
do {
if (SemValue(old_value) < 0) {
break;
}
}
} while (!__atomic_compare_exchange_n(
count, &old_value, (old_value - kSemOne) & kSemValueMask, true,
__ATOMIC_SEQ_CST, __ATOMIC_RELAXED));
return SemValue(old_value);
}
if (!create) {
::pthread_mutex_unlock(&g_table_mutex);
return nullptr;
}
auto* entry = static_cast<HostSemaphoreEntry*>(
std::calloc(1, sizeof(HostSemaphoreEntry)));
if (entry == nullptr) {
::pthread_mutex_unlock(&g_table_mutex);
errno = ENOMEM;
return nullptr;
}
if (::sem_init(&entry->host_semaphore, 0, initial_value) != 0) {
std::free(entry);
::pthread_mutex_unlock(&g_table_mutex);
return nullptr;
}
entry->guest_address = guest_address;
entry->next = g_table;
g_table = entry;
::pthread_mutex_unlock(&g_table_mutex);
return &entry->host_semaphore;
int SemInc(unsigned* count) {
unsigned old_value = __atomic_load_n(count, __ATOMIC_RELAXED);
unsigned new_value = 0;
do {
const int value = SemValue(old_value);
if (value == SEM_VALUE_MAX) {
break;
}
if (value < 0) {
new_value = kSemOne;
} else {
new_value = (old_value + kSemOne) & kSemValueMask;
}
} while (!__atomic_compare_exchange_n(count, &old_value, new_value, true,
__ATOMIC_SEQ_CST, __ATOMIC_RELAXED));
return SemValue(old_value);
}
} // namespace
@@ -69,7 +75,12 @@ int mocktail_bionic_sem_init(sem_t* semaphore, int process_shared,
errno = EINVAL;
return -1;
}
return FindOrCreateHostSemaphore(semaphore, true, value) != nullptr ? 0 : -1;
if (value > static_cast<unsigned>(SEM_VALUE_MAX)) {
errno = EINVAL;
return -1;
}
__atomic_store_n(SemCount(semaphore), SemEncode(value), __ATOMIC_RELAXED);
return 0;
}
int mocktail_bionic_sem_destroy(sem_t* semaphore) {
@@ -77,46 +88,40 @@ int mocktail_bionic_sem_destroy(sem_t* semaphore) {
errno = EINVAL;
return -1;
}
const uintptr_t guest_address = reinterpret_cast<uintptr_t>(semaphore);
::pthread_mutex_lock(&g_table_mutex);
HostSemaphoreEntry** link = &g_table;
while (*link != nullptr) {
HostSemaphoreEntry* entry = *link;
if (entry->guest_address == guest_address) {
*link = entry->next;
::pthread_mutex_unlock(&g_table_mutex);
const int result = ::sem_destroy(&entry->host_semaphore);
std::free(entry);
return result;
}
link = &entry->next;
}
::pthread_mutex_unlock(&g_table_mutex);
return 0;
}
int mocktail_bionic_sem_wait(sem_t* semaphore) {
sem_t* host_semaphore = FindOrCreateHostSemaphore(semaphore, false, 0);
if (host_semaphore == nullptr) {
if (semaphore == nullptr) {
errno = EINVAL;
return -1;
}
int result = 0;
do {
result = ::sem_wait(host_semaphore);
} while (result != 0 && errno == EINTR);
return result;
unsigned* count = SemCount(semaphore);
for (;;) {
if (SemDec(count) > 0) {
return 0;
}
const int rc = SemFutex(count, FUTEX_WAIT, kSemMinusOne);
if (rc != 0 && errno == EINTR) {
continue;
}
}
}
int mocktail_bionic_sem_post(sem_t* semaphore) {
sem_t* host_semaphore = FindOrCreateHostSemaphore(semaphore, false, 0);
if (host_semaphore == nullptr) {
if (semaphore == nullptr) {
errno = EINVAL;
return -1;
}
return ::sem_post(host_semaphore);
unsigned* count = SemCount(semaphore);
const int old_value = SemInc(count);
if (old_value < 0) {
(void)SemFutex(count, FUTEX_WAKE, static_cast<unsigned>(INT_MAX));
} else if (old_value == SEM_VALUE_MAX) {
errno = EOVERFLOW;
return -1;
}
return 0;
}
} // extern "C"
+2 -16
View File
@@ -17,9 +17,9 @@ FILE* g_bionic_stdin = reinterpret_cast<FILE*>(&__sF[0]);
FILE* g_bionic_stdout = reinterpret_cast<FILE*>(&__sF[1]);
FILE* g_bionic_stderr = reinterpret_cast<FILE*>(&__sF[2]);
FILE* RequireHostStream(FILE* stream) {
inline FILE* RequireHostStream(FILE* stream) {
FILE* host_stream = mocktail::compat::TranslateBionicFile(stream);
if (host_stream != nullptr) {
if (__builtin_expect(host_stream != nullptr, 1)) {
return host_stream;
}
errno = EINVAL;
@@ -30,20 +30,6 @@ FILE* RequireHostStream(FILE* stream) {
namespace mocktail::compat {
FILE* TranslateBionicFile(FILE* stream) noexcept {
const uintptr_t address = reinterpret_cast<uintptr_t>(stream);
if (address == reinterpret_cast<uintptr_t>(&__sF[0])) {
return stdin;
}
if (address == reinterpret_cast<uintptr_t>(&__sF[1])) {
return stdout;
}
if (address == reinterpret_cast<uintptr_t>(&__sF[2])) {
return stderr;
}
return stream;
}
void* BionicFileArraySymbolAddress() noexcept { return &__sF[0]; }
void* BionicStdinSymbolAddress() noexcept { return &g_bionic_stdin; }
+48
View File
@@ -6,6 +6,54 @@ namespace mocktail::compat {
namespace {
constexpr HostAbiProfile kProfiles[] = {
// Roblox 2.736.1408. Derived from the verified 2908 profile and validated
// by two isolated no-recovery Vulkan canaries and real game sessions.
{"ade08266c67aee88ec9c1d00902150e1684dad3a",
{{{0x1d2c1e2, HostBridgeKind::kAllocate, "small-allocate"},
{0x1d2f43a, HostBridgeKind::kUsableSize, "usable-size"},
{0x21e68bf, HostBridgeKind::kReallocate, "reallocate"},
{0x1d2bf82, HostBridgeKind::kAllocate, "allocate"},
{0x1d47a63, HostBridgeKind::kAlignedAllocate,
"aligned-allocate-direct"},
{0x1d2f9d9, HostBridgeKind::kFree, "free"}}},
6,
{0x7652e70, 0x775fd28, 0x7bb4340, 0x400, 0x1d2c5f8, 0x1db7b57,
0x7af0430, 0x7af04c0, 0x400000},
{0x1d2bf82, 0x1d2f9d9},
0x7057818,
3570,
{{{2, 3}, {5, 3570}}},
2,
{{{2, 3570}}},
1,
2,
{0x21e8e09, 0x7af4598},
HostAllocatorStrategy::kNativeMimalloc},
// Roblox 2.734.917. This exact Build-ID profile was machine-derived from
// the verified reference and passed two isolated no-recovery Tier C Vulkan
// canaries with native constructors, JNI startup, presentation, and
// controlled lifecycle teardown.
{"63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
{{{0x1cf69e2, HostBridgeKind::kAllocate, "small-allocate"},
{0x1cf9c3a, HostBridgeKind::kUsableSize, "usable-size"},
{0x21af33f, HostBridgeKind::kReallocate, "reallocate"},
{0x1cf677e, HostBridgeKind::kAllocate, "allocate"},
{0x1d12197, HostBridgeKind::kAlignedAllocate,
"aligned-allocate-direct"},
{0x1cfa1d9, HostBridgeKind::kFree, "free"}}},
6,
{0x7571570, 0x767e428, 0x7ad47e8, 0x400, 0x1cf6df8, 0x1d82be7,
0x7a10730, 0x7a107c0, 0x400000},
{0x1cf677e, 0x1cfa1d9},
0x6f78a18,
3556,
{{{2, 3}, {5, 3556}}},
2,
{{{2, 3556}}},
1,
2,
{0x21b1889, 0x7a14898},
HostAllocatorStrategy::kNativeMimalloc},
// Roblox 2.727.1199. Every function and state slot below was matched
// independently against the previous supported payload by normalized
// instruction signatures and RIP-relative references. The payload keeps
+64 -11
View File
@@ -10,6 +10,7 @@
#define JSON_NOEXCEPTION 1
#include <algorithm>
#include <array>
#include <atomic>
#include <cerrno>
#include <charconv>
#include <cstddef>
@@ -28,14 +29,47 @@
#include <utility>
#include <vector>
#include <openssl/evp.h>
#include "compat/elf_build_id.h"
#include "mocktail/sha256.h"
namespace mocktail::compat {
namespace {
using Json = nlohmann::json;
std::atomic<bool> g_hashing_fault_for_testing{false};
std::string FormatHex(const unsigned char* data, std::size_t size,
std::size_t max_chars = 0) {
static constexpr char kHex[] = "0123456789abcdef";
const std::size_t limit =
(max_chars > 0 && max_chars < size * 2U) ? max_chars : size * 2U;
std::string hex;
hex.reserve(limit);
for (std::size_t i = 0; i < size && hex.size() < limit; ++i) {
hex.push_back(kHex[(data[i] >> 4) & 0x0f]);
if (hex.size() < limit) {
hex.push_back(kHex[data[i] & 0x0f]);
}
}
return hex;
}
std::string ComputeSha256Hex(std::string_view bytes,
std::size_t max_chars = 0) {
if (g_hashing_fault_for_testing.load(std::memory_order_acquire)) {
return {};
}
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int len = 0;
if (EVP_Digest(bytes.data(), bytes.size(), digest, &len, EVP_sha256(),
nullptr) != 1) {
return {};
}
return FormatHex(digest, len, max_chars);
}
constexpr std::size_t kMaximumProfileBytes = 1024 * 1024;
constexpr std::size_t kMaximumReceiptBytes = 64 * 1024;
constexpr std::size_t kMaximumInitArrayEntries = 1024 * 1024;
@@ -862,7 +896,11 @@ bool ValidateCanaryAttestation(const std::string& attestation_path,
return false;
}
evidence->sha256 = foundation::ComputeSha256Hex(attestation_bytes);
evidence->sha256 = ComputeSha256Hex(attestation_bytes);
if (evidence->sha256.empty()) {
*error = "cannot compute canary attestation SHA-256";
return false;
}
evidence->run_id = std::move(run_id);
return true;
}
@@ -1013,7 +1051,7 @@ bool ValidateReceipt(const std::string& receipt_path,
.append(canary_evidence[1].sha256)
.append("\n");
const std::string expected_generation =
foundation::ComputeSha256Hex(generation_evidence).substr(0, 40);
ComputeSha256Hex(generation_evidence, 40);
if (generation != expected_generation) {
*error = "approval receipt evidence generation does not match";
return false;
@@ -1066,19 +1104,31 @@ FileSha256Result ComputeFileSha256(const std::string& path) {
if (!input) {
return {{}, "cannot open file for SHA-256: " + path};
}
foundation::Sha256 sha256;
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> ctx(
EVP_MD_CTX_new(), &EVP_MD_CTX_free);
if (!ctx || EVP_DigestInit_ex(ctx.get(), EVP_sha256(), nullptr) != 1) {
return {{}, "cannot initialize SHA-256 digest context for: " + path};
}
std::array<unsigned char, 128 * 1024> buffer{};
while (input) {
input.read(reinterpret_cast<char*>(buffer.data()), buffer.size());
const std::streamsize read = input.gcount();
if (read > 0) {
sha256.Update(buffer.data(), static_cast<std::size_t>(read));
if (EVP_DigestUpdate(ctx.get(), buffer.data(),
static_cast<std::size_t>(read)) != 1) {
return {{}, "cannot update SHA-256 digest for: " + path};
}
}
}
if (!input.eof()) {
return {{}, "cannot read file for SHA-256: " + path};
}
return {sha256.FinalHex(), {}};
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int len = 0;
if (EVP_DigestFinal_ex(ctx.get(), digest, &len) != 1) {
return {{}, "cannot finalize SHA-256 digest for: " + path};
}
return {FormatHex(digest, len), {}};
}
const HostAbiProfile* FindLoadedExternalHostAbiProfile(
@@ -1161,11 +1211,10 @@ ExternalHostAbiProfileResult LoadExternalHostAbiProfile(
return Failure(
"external host ABI profile SHA-256 does not match payload bytes");
}
foundation::Sha256 profile_hasher;
profile_hasher.Update(
reinterpret_cast<const unsigned char*>(profile_bytes.data()),
profile_bytes.size());
const std::string profile_sha256 = profile_hasher.FinalHex();
const std::string profile_sha256 = ComputeSha256Hex(profile_bytes);
if (profile_sha256.empty()) {
return Failure("cannot compute external host ABI profile SHA-256");
}
if (!ValidateProfileAgainstElf(*canonical_payload, profile->profile,
profile->derivation_code_rvas, &path_error)) {
return Failure(path_error);
@@ -1243,4 +1292,8 @@ ExternalHostAbiProfileResult InitializeHostAbiProfileFromEnvironment(
return LoadExternalHostAbiProfile(request);
}
void SetHostAbiProfileHashingFaultForTesting(bool enabled) noexcept {
g_hashing_fault_for_testing.store(enabled, std::memory_order_release);
}
} // namespace mocktail::compat
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more