diff --git a/include/compat/build_profile.h b/include/compat/build_profile.h index d424e6d..7a63a76 100644 --- a/include/compat/build_profile.h +++ b/include/compat/build_profile.h @@ -36,6 +36,11 @@ struct FmodOutputDeviceBridgeProfile { ? std::array{9, 10, 12, 18} : std::array{8, 9, 10, 16}; } + std::array capture_vtable_indexes() const { + return vtable_layout_version == 2 + ? std::array{4, 13, 14, 15, 16, 17} + : std::array{4, 11, 12, 13, 14, 15}; + } bool valid_vtable_layout() const { return vtable_layout_version == 1 || vtable_layout_version == 2; diff --git a/include/mocktail/audio/roblox_output_device_bridge.h b/include/mocktail/audio/roblox_output_device_bridge.h index bd1d462..f9bf61c 100644 --- a/include/mocktail/audio/roblox_output_device_bridge.h +++ b/include/mocktail/audio/roblox_output_device_bridge.h @@ -61,8 +61,7 @@ class RobloxOutputDeviceBridge final { void SelectDevice(int index, bool input = false); void ConstructGuestString(void* destination, std::string_view value) const; - std::unique_ptr capture_; - bool capture_profile_supported_ = false; + std::shared_ptr capture_; std::array original_capture_methods_{}; mutable std::mutex mutex_; compat::FmodOutputDeviceBridgeProfile profile_; diff --git a/src/audio/native_input_capture.h b/src/audio/native_input_capture.h index 0bf2f0e..9794419 100644 --- a/src/audio/native_input_capture.h +++ b/src/audio/native_input_capture.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -31,18 +32,18 @@ struct NativeOptionalFormat { struct NativeSharedSink { void* object; void* owner; }; static_assert(sizeof(NativeOptionalFormat) == 16); static_assert(sizeof(NativePcmFormat) == 8); -inline std::atomic g_native_capture{nullptr}; +inline std::shared_ptr g_native_capture; class NativeInputCapture { public: using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t, std::uint64_t, const NativePcmFormat*); - using Destroy = void (*)(NativeSharedSink*); using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*); + using ImageRange = bool (*)(std::uintptr_t, std::uintptr_t, std::size_t); struct SinkAbi { - std::uintptr_t vtable; - DeliverPcm deliver; - Destroy release; + std::uintptr_t image_base; + ImageRange is_image_relro; + ImageRange is_image_code; OriginalLatency latency; }; NativeInputCapture(SinkAbi abi, bool enabled) @@ -50,17 +51,22 @@ class NativeInputCapture { ~NativeInputCapture() { Shutdown(); } static NativeOptionalFormat Format(void*) { - auto* capture = g_native_capture.load(std::memory_order_acquire); - return {{}, capture && capture->enabled_ ? 1ULL : 0ULL}; + auto capture = + std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire); + if (!capture) return {}; + std::lock_guard lock(capture->mutex_); + return {{}, capture->enabled_ && !capture->closed_ ? 1ULL : 0ULL}; } static void Latency(void* self, std::uint32_t* playback, std::uint32_t* recording) { // Preserve the native playback latency and report the host capture queue. - auto* capture = g_native_capture.load(std::memory_order_acquire); + auto capture = + std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire); if (!capture) return; + std::lock_guard lock(capture->mutex_); + if (capture->closed_) return; capture->abi_.latency(self, playback, recording); if (recording) { - std::lock_guard lock(capture->mutex_); *recording = 10; auto it = capture->sessions_.find(self); if (it != capture->sessions_.end()) { @@ -70,33 +76,48 @@ class NativeInputCapture { } } static bool Start(void* self, NativeSharedSink* sink) { - auto* capture = g_native_capture.load(std::memory_order_acquire); + auto capture = + std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire); return capture && capture->StartSink(self, sink); } static void Stop(void* self, NativeSharedSink* sink) { - auto* capture = g_native_capture.load(std::memory_order_acquire); + auto capture = + std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire); if (capture) capture->StopSink(self, sink); } static bool Recording(void* self) { - auto* capture = g_native_capture.load(std::memory_order_acquire); + auto capture = + std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire); if (!capture) return false; std::lock_guard lock(capture->mutex_); return capture->sessions_.find(self) != capture->sessions_.end(); } static void Poll(void* self) { - auto* capture = g_native_capture.load(std::memory_order_acquire); + auto capture = + std::atomic_load_explicit(&g_native_capture, std::memory_order_acquire); if (capture) capture->Deliver(self); } void Shutdown() { std::lock_guard lock(mutex_); + closed_ = true; for (auto& [self, session] : sessions_) Close(*session); sessions_.clear(); } private: + struct Sink { + NativeSharedSink shared; + DeliverPcm deliver; + }; + struct SharedCount { + const std::uintptr_t* vtable; + long shared_owners; + long weak_owners; + }; + using ControlBlockHook = void (*)(void*); struct Session { std::unique_ptr input; - std::vector sinks; + std::vector sinks; std::mutex queue_mutex; // At most 200 ms; a stalled consumer must not accumulate stale speech. std::array, 20> frames{}; @@ -118,20 +139,33 @@ class NativeInputCapture { session.input->buffer_data(), bytes); ++session.count; } + bool IsGuestVtable(const void* object, std::size_t size, + std::initializer_list methods) const { + const auto base = abi_.image_base; + const auto* table = *static_cast(object); + const auto address = reinterpret_cast(table); + if (address < base || + !abi_.is_image_relro(base, address - base, size * sizeof(*table))) + return false; + for (const auto slot : methods) + if (table[slot] < base || + !abi_.is_image_code(base, table[slot] - base, 1)) + return false; + return true; + } bool StartSink(void* self, NativeSharedSink* sink) { std::lock_guard lock(mutex_); - if (!enabled_ || !self || !sink || !sink->object || !sink->owner) + if (closed_ || !enabled_ || !self || !sink || !sink->object || !sink->owner) return false; - auto* table = *reinterpret_cast(sink->object); - if (reinterpret_cast(table) != abi_.vtable || - table[2] != reinterpret_cast(abi_.deliver)) { + if (!IsGuestVtable(sink->object, 3, {2}) || + !IsGuestVtable(sink->owner, 5, {2, 4})) { std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n"); return false; } auto it = sessions_.find(self); if (it != sessions_.end()) { for (const auto& existing : it->second->sinks) - if (existing.object == sink->object) return true; + if (existing.shared.object == sink->object) return true; } else { auto session = std::make_unique(); SdlAudioCaptureOptions options; @@ -151,13 +185,20 @@ class NativeInputCapture { } // The native parameter is a by-value shared_ptr. Retain its existing // ownership by moving it, leaving the caller's temporary empty. - it->second->sinks.push_back(*sink); + const auto* table = *static_cast(sink->object); + it->second->sinks.push_back({*sink, reinterpret_cast(table[2])}); *sink = {}; return true; } - void Release(NativeSharedSink& sink) { - abi_.release(&sink); + static void Release(Sink& sink) { + auto* count = static_cast(sink.shared.owner); sink = {}; + if (__atomic_fetch_sub(&count->shared_owners, 1, __ATOMIC_ACQ_REL) != 0) + return; + reinterpret_cast(count->vtable[2])(count); + if (__atomic_load_n(&count->weak_owners, __ATOMIC_ACQUIRE) == 0 || + __atomic_fetch_sub(&count->weak_owners, 1, __ATOMIC_ACQ_REL) == 0) + reinterpret_cast(count->vtable[4])(count); } void Close(Session& session) { session.input->Shutdown(); @@ -174,7 +215,7 @@ class NativeInputCapture { if (it == sessions_.end() || !sink) return; auto& session = *it->second; auto existing = std::find_if(session.sinks.begin(), session.sinks.end(), - [&](const auto& entry) { return entry.object == sink->object; }); + [&](const auto& entry) { return entry.shared.object == sink->object; }); if (existing == session.sinks.end()) return; Release(*existing); session.sinks.erase(existing); @@ -203,8 +244,7 @@ class NativeInputCapture { } const NativePcmFormat format; for (const auto& sink : session.sinks) - abi_.deliver( - sink.object, pcm.data(), pcm.size(), delay, &format); + sink.deliver(sink.shared.object, pcm.data(), pcm.size(), delay, &format); if (++session.delivered == 1) std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n"); if (!session.audible && std::any_of(pcm.begin(), pcm.end(), @@ -216,6 +256,7 @@ class NativeInputCapture { } SinkAbi abi_; bool enabled_; + bool closed_ = false; std::mutex mutex_; std::map> sessions_; }; diff --git a/src/audio/roblox_output_device_bridge.cc b/src/audio/roblox_output_device_bridge.cc index e4fda6c..0134c21 100644 --- a/src/audio/roblox_output_device_bridge.cc +++ b/src/audio/roblox_output_device_bridge.cc @@ -130,14 +130,6 @@ std::uintptr_t FunctionAddress(Function function) { } // namespace -// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu -// profile: relocating query methods alone does not establish a recording ABI. -namespace { -constexpr std::array kCaptureSlots{4, 13, 14, 15, 16, 17}; -constexpr std::array kCaptureRvas{ - 0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a}; -} // namespace - RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default; namespace internal { @@ -226,10 +218,6 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) { "another Roblox output-device bridge owns the process"); } profile_ = *profile.fmod_output_device_bridge; - capture_profile_supported_ = - profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" && - profile_.vtable_rva == 0x6cd3ce0 && - profile_.vtable_layout_version == 2 && profile_.has_input_devices(); if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) { g_active_bridge.store(nullptr, std::memory_order_release); profile_ = {}; @@ -255,7 +243,10 @@ void RobloxOutputDeviceBridge::Shutdown() { " [audio-menu] failed to restore FmodAudioDevice " "vtable protection\n"); } - g_native_capture.store(nullptr, std::memory_order_release); + std::atomic_store_explicit(&g_native_capture, + std::shared_ptr{}, + std::memory_order_release); + if (capture_) capture_->Shutdown(); capture_.reset(); active_ = false; devices_.clear(); @@ -419,6 +410,7 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) { Status RobloxOutputDeviceBridge::PatchVtableLocked() { const std::size_t kCurrentOutputVtableIndex = profile_.current_vtable_index(); const std::size_t kSelectOutputVtableIndex = profile_.select_vtable_index(); + const auto capture_indexes = profile_.capture_vtable_indexes(); if (profile_.vtable_rva > std::numeric_limits::max() - (kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) { @@ -461,26 +453,25 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { "FmodAudioDevice string ABI contract validation failed"); } - if (capture_profile_supported_) { - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) { - if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) || - vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i]) + if (profile_.has_input_devices()) { + for (std::size_t i = 0; i < capture_indexes.size(); ++i) { + const auto method = vtable_[capture_indexes[i]]; + if (method < library_base_ || + !IsExecutableImageRange(library_base_, method - library_base_, 1) || + std::find(profile_.input_method_rvas.begin(), + profile_.input_method_rvas.end(), method - library_base_) != + profile_.input_method_rvas.end() || + std::find(original_capture_methods_.begin(), + original_capture_methods_.begin() + i, method) != + original_capture_methods_.begin() + i) return FailedPrecondition("native input capture vtable ABI mismatch"); - original_capture_methods_[i] = vtable_[kCaptureSlots[i]]; + original_capture_methods_[i] = method; } - if (!IsExecutableImageRange(library_base_, 0x3213154, 1) || - !IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) || - !IsRelroImageRange(library_base_, 0x6cd4550, 24)) - return FailedPrecondition("native input capture sink ABI outside image"); - capture_ = std::make_unique( + capture_ = std::make_shared( NativeInputCapture::SinkAbi{ - library_base_ + 0x6cd4550, - reinterpret_cast( - library_base_ + 0x3213154), - reinterpret_cast( - library_base_ + 0x1dc4cd0), + library_base_, &IsRelroImageRange, &IsExecutableImageRange, reinterpret_cast( - library_base_ + kCaptureRvas[1])}, + original_capture_methods_[1])}, !input_devices_.empty()); } @@ -519,7 +510,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { __atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE); } if (capture_) { - g_native_capture.store(capture_.get(), std::memory_order_release); + std::atomic_store_explicit(&g_native_capture, capture_, + std::memory_order_release); const std::array methods{ FunctionAddress(&NativeInputCapture::Format), FunctionAddress(&NativeInputCapture::Latency), @@ -527,8 +519,8 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { FunctionAddress(&NativeInputCapture::Stop), FunctionAddress(&NativeInputCapture::Poll), FunctionAddress(&NativeInputCapture::Recording)}; - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) - __atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE); + for (std::size_t i = 0; i < capture_indexes.size(); ++i) + __atomic_store_n(&vtable_[capture_indexes[i]], methods[i], __ATOMIC_RELEASE); } if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) { __atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0], @@ -546,10 +538,13 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { __ATOMIC_RELEASE); } if (capture_) { - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) - __atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i], + for (std::size_t i = 0; i < capture_indexes.size(); ++i) + __atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i], __ATOMIC_RELEASE); - g_native_capture.store(nullptr, std::memory_order_release); + std::atomic_store_explicit(&g_native_capture, + std::shared_ptr{}, + std::memory_order_release); + capture_->Shutdown(); capture_.reset(); } (void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false); @@ -581,8 +576,9 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() { __ATOMIC_RELEASE); } if (capture_) { - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) - __atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i], + const auto capture_indexes = profile_.capture_vtable_indexes(); + for (std::size_t i = 0; i < capture_indexes.size(); ++i) + __atomic_store_n(&vtable_[capture_indexes[i]], original_capture_methods_[i], __ATOMIC_RELEASE); } return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false); diff --git a/src/runtime/performance_policy.cc b/src/runtime/performance_policy.cc index 7b9f9ea..ca73b3b 100644 --- a/src/runtime/performance_policy.cc +++ b/src/runtime/performance_policy.cc @@ -243,7 +243,9 @@ bool MergePerformanceClientSettingsOverrides( // ForceCacheSize settings are byte counts. Let Roblox size its mesh and // SLIM content caches; values like 256/128 would cap them to a few bytes. - const std::array rendering_settings = {{ + // Leave MSAA selection to Roblox or explicit client-settings overrides. + // Forcing one sample disables anti-aliasing even at maximum quality. + const std::array rendering_settings = {{ {"FIntSmoothClusterTaskQueueMaxParallelTasks", workers}, {"FIntOcclusionWorkerThreadCount", occlusion_workers}, {"FFlagMovePrerenderV2", "True"}, @@ -300,7 +302,6 @@ bool MergePerformanceClientSettingsOverrides( {"FFlagRenderAllocateShadowMapResourcesOnDemand", "True"}, {"FIntRenderShadowMapDepthCacheMemLimit", "16"}, {"FIntTM2ShadowMapMaxMips", "1"}, - {"FIntDebugForceMSAASamples", "1"}, {"FFlagLuauStartupGcSuppression", "False"}, {"FIntLuauGcStepMul", "300"}, {"FIntLuauGcGoalCore", "120"}, diff --git a/src/update/payload_store.cc b/src/update/payload_store.cc index 03deba4..21f0850 100644 --- a/src/update/payload_store.cc +++ b/src/update/payload_store.cc @@ -312,7 +312,10 @@ bool CloneOrCopyFile(const std::filesystem::path& source, } bool copied = false; #if defined(__linux__) && defined(FICLONE) - copied = ioctl(output, FICLONE, input) == 0; + struct stat cloned_metadata = {}; + copied = ioctl(output, FICLONE, input) == 0 && + fstat(output, &cloned_metadata) == 0 && + cloned_metadata.st_size == metadata.st_size; #endif #if defined(__linux__) if (!copied) { diff --git a/tests/performance_policy_test.cc b/tests/performance_policy_test.cc index 03a7b41..29af3c3 100644 --- a/tests/performance_policy_test.cc +++ b/tests/performance_policy_test.cc @@ -155,6 +155,34 @@ TEST(PerformancePolicyTest, WorkerModesRetainRobloxManagedAssetCacheSizes) { } } +TEST(PerformancePolicyTest, WorkerModesPreserveRobloxAndExplicitMsaaSelection) { + for (const bool throughput : {false, true}) { + PerformancePolicy policy; + policy.multithreaded_rendering = !throughput; + policy.physical_core_count = 14; + policy.physics_worker_mode = throughput ? PhysicsWorkerMode::kThroughput + : PhysicsWorkerMode::kAuto; + for (const std::string samples : {"", "1", "4", "8"}) { + nlohmann::json requested = nlohmann::json::object(); + if (!samples.empty()) { + requested["FIntDebugForceMSAASamples"] = samples; + } + std::string merged; + std::string error; + ASSERT_TRUE(MergeRuntimeClientSettingsOverrides( + ParseFrameRatePolicy("144"), policy, requested.dump(), &merged, + &error)) << error; + const auto parsed = nlohmann::json::parse(merged); + EXPECT_FALSE(parsed.contains("")); + if (samples.empty()) { + EXPECT_FALSE(parsed.contains("FIntDebugForceMSAASamples")); + } else { + EXPECT_EQ(parsed.at("FIntDebugForceMSAASamples"), samples); + } + } + } +} + TEST(PerformancePolicyTest, PreservesExplicitAssetCacheByteBudgets) { const nlohmann::json requested = { {"FIntMeshContentProviderForceCacheSize", "268435456"}, @@ -245,9 +273,10 @@ TEST(PerformancePolicyTest, KeepsUserOverridesThatConflictWithPreset) { EXPECT_EQ(parsed.at("FFlagMovePrerenderV2"), true); EXPECT_EQ(parsed.at("FIntDebugForceMSAASamples"), 4); EXPECT_EQ(parsed.at("FFlagFastGPULightGrid"), "True"); + // The preset does not set MSAA, so that value passes through without + // counting as a kept conflict. EXPECT_EQ(kept, (std::vector{"FIntOcclusionWorkerThreadCount", - "FFlagMovePrerenderV2", - "FIntDebugForceMSAASamples"})); + "FFlagMovePrerenderV2"})); PerformancePolicy latency = ParsePerformancePolicy("true", "0", "auto", "latency"); diff --git a/tests/roblox_output_device_bridge_test.cc b/tests/roblox_output_device_bridge_test.cc index 373364a..a17b7ba 100644 --- a/tests/roblox_output_device_bridge_test.cc +++ b/tests/roblox_output_device_bridge_test.cc @@ -1,13 +1,22 @@ #include "mocktail/audio/roblox_output_device_bridge.h" #include +#include #include +#include +#include #include +#include +#include #include +#include +#include +#include "audio/native_input_capture.h" #include "audio/roblox_output_device_bridge_internal.h" #include "compat/fmod_output_device_contract.h" +#include "mocktail/audio/sdl_audio_sink.h" namespace mocktail::audio { namespace { @@ -143,5 +152,317 @@ TEST(RobloxOutputDeviceBridgeTest, EnforcesSingleProcessOwner) { second.Shutdown(); } +class NativeInputCaptureTest : public testing::Test { + protected: + struct PcmSink { + const std::uintptr_t* vtable = sink_table.data(); + std::size_t frames = 0; + int peak = 0; + }; + struct Owner { + const std::uintptr_t* vtable = owner_table.data(); + long shared = 0; + long weak = 0; + int released = 0; + int freed = 0; + int* freed_counter = nullptr; + }; + static std::size_t Deliver(void* object, const void* data, std::size_t frames, + std::uint64_t delay, const NativePcmFormat* format) { + EXPECT_EQ(frames, 480U); + EXPECT_EQ(format->rate, 48000U); + EXPECT_EQ(format->channels, 1); + EXPECT_EQ(format->bytes_per_sample, 2); + EXPECT_GE(delay, 10000000U); + EXPECT_LE(delay, 200000000U); + auto& sink = *static_cast(object); + sink.frames += frames; + const auto* pcm = static_cast(data); + for (std::size_t i = 0; i < frames; ++i) + sink.peak = std::max(sink.peak, std::abs(static_cast(pcm[i]))); + return frames; + } + static void Release(void* owner) { ++static_cast(owner)->released; } + static void Free(void* opaque) { + auto* owner = static_cast(opaque); + ++owner->freed; + if (owner->freed_counter) { + ++*owner->freed_counter; + delete owner; + } + } + static void Latency(void*, std::uint32_t* playback, std::uint32_t* recording) { + if (playback) *playback = 25; + if (recording) *recording = 0; + } + bool PollUntil(PcmSink& sink, std::size_t frames) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (sink.frames < frames && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + NativeInputCapture::Poll(this); + } + return sink.frames >= frames; + } + static bool Relro(std::uintptr_t base, std::uintptr_t rva, std::size_t size) { + return (base + rva == reinterpret_cast(sink_table.data()) && + size <= sizeof(sink_table)) || + (base + rva == reinterpret_cast(owner_table.data()) && + size <= sizeof(owner_table)); + } + static bool Code(std::uintptr_t base, std::uintptr_t rva, std::size_t) { + return base + rva == reinterpret_cast(&Deliver) || + base + rva == reinterpret_cast(&Release) || + base + rva == reinterpret_cast(&Free); + } + void SetUp() override { + ASSERT_TRUE(SDL_SetHintWithPriority(SDL_HINT_AUDIO_DRIVER, "dummy", + SDL_HINT_OVERRIDE)); + ASSERT_TRUE(InitializeSdlAudioSubsystem().ok()); + capture = std::make_shared( + NativeInputCapture::SinkAbi{1, &Relro, &Code, &Latency}, true); + std::atomic_store(&g_native_capture, capture); + } + void TearDown() override { + std::atomic_store(&g_native_capture, std::shared_ptr{}); + capture.reset(); + EXPECT_TRUE(ShutdownSdlAudioSubsystem().ok()); + SDL_ResetHint(SDL_HINT_AUDIO_DRIVER); + SDL_ResetHint(SDL_HINT_AUDIO_DISK_INPUT_FILE); + SDL_ResetHint(SDL_HINT_AUDIO_DISK_OUTPUT_FILE); + if (!pcm_path.empty()) std::remove(pcm_path.c_str()); + } + inline static const std::array sink_table{ + 0, 0, reinterpret_cast(&Deliver)}; + inline static const std::array owner_table{ + 0, 0, reinterpret_cast(&Release), 0, + reinterpret_cast(&Free)}; + PcmSink first, second; + Owner first_owner, second_owner; + std::shared_ptr capture; + std::string pcm_path; +}; + +TEST_F(NativeInputCaptureTest, DeliversPcmAndReleasesEachGuestSink) { + NativeSharedSink a{&first, &first_owner}, b{&second, &second_owner}; + ASSERT_TRUE(NativeInputCapture::Start(this, &a)); + ASSERT_TRUE(NativeInputCapture::Start(this, &b)); + EXPECT_EQ(a.object, nullptr); + EXPECT_EQ(a.owner, nullptr); + EXPECT_EQ(b.object, nullptr); + EXPECT_EQ(b.owner, nullptr); + EXPECT_TRUE(PollUntil(first, 480)); + EXPECT_EQ(first.frames, second.frames); + a = {&first, &first_owner}; + NativeInputCapture::Stop(this, &a); + EXPECT_TRUE(NativeInputCapture::Recording(this)); + EXPECT_EQ(first_owner.released, 1); + EXPECT_EQ(first_owner.freed, 1); + b = {&second, &second_owner}; + NativeInputCapture::Stop(this, &b); + EXPECT_FALSE(NativeInputCapture::Recording(this)); + EXPECT_EQ(second_owner.released, 1); + EXPECT_EQ(second_owner.freed, 1); +} + +TEST_F(NativeInputCaptureTest, PreservesOtherSharedAndWeakOwners) { + first_owner.shared = 1; + second_owner.weak = 1; + NativeSharedSink a{&first, &first_owner}, b{&second, &second_owner}; + ASSERT_TRUE(NativeInputCapture::Start(this, &a)); + ASSERT_TRUE(NativeInputCapture::Start(this, &b)); + capture->Shutdown(); + EXPECT_EQ(first_owner.shared, 0); + EXPECT_EQ(first_owner.released, 0); + EXPECT_EQ(first_owner.freed, 0); + EXPECT_EQ(second_owner.weak, 0); + EXPECT_EQ(second_owner.released, 1); + EXPECT_EQ(second_owner.freed, 0); +} + +TEST_F(NativeInputCaptureTest, RejectsUnverifiedSinkAndOwnerTables) { + auto unverified = sink_table; + first.vtable = unverified.data(); + NativeSharedSink sink{&first, &first_owner}; + EXPECT_FALSE(NativeInputCapture::Start(this, &sink)); + first.vtable = sink_table.data(); + first_owner.vtable = sink_table.data(); + EXPECT_FALSE(NativeInputCapture::Start(this, &sink)); + EXPECT_FALSE(NativeInputCapture::Recording(this)); + EXPECT_EQ(sink.object, &first); + EXPECT_EQ(first_owner.released, 0); +} + +TEST_F(NativeInputCaptureTest, DisabledCaptureDoesNotTakeOwnership) { + capture = std::make_shared( + NativeInputCapture::SinkAbi{1, &Relro, &Code, nullptr}, false); + std::atomic_store(&g_native_capture, capture); + NativeSharedSink sink{&first, &first_owner}; + EXPECT_EQ(NativeInputCapture::Format(this).present, 0U); + EXPECT_FALSE(NativeInputCapture::Start(this, &sink)); + EXPECT_EQ(sink.object, &first); + EXPECT_EQ(first_owner.released, 0); +} + +TEST_F(NativeInputCaptureTest, ShutdownRejectsLateRecordingRequests) { + capture->Shutdown(); + NativeSharedSink sink{&first, &first_owner}; + EXPECT_EQ(NativeInputCapture::Format(this).present, 0U); + EXPECT_FALSE(NativeInputCapture::Start(this, &sink)); + EXPECT_FALSE(NativeInputCapture::Recording(this)); + EXPECT_EQ(sink.object, &first); + EXPECT_EQ(sink.owner, &first_owner); +} + +TEST_F(NativeInputCaptureTest, DeliversNonzeroPcmFromSyntheticRecording) { + bool have_disk = false; + for (int i = 0; i < SDL_GetNumAudioDrivers(); ++i) + have_disk |= std::strcmp(SDL_GetAudioDriver(i), "disk") == 0; + if (!have_disk) GTEST_SKIP() << "SDL disk audio driver is unavailable"; + ASSERT_TRUE(ShutdownSdlAudioSubsystem().ok()); + char path[] = "/tmp/mocktail-native-pcm-XXXXXX"; + const int fd = mkstemp(path); + ASSERT_GE(fd, 0); + pcm_path = path; + FILE* file = fdopen(fd, "wb"); + if (!file) close(fd); + ASSERT_NE(file, nullptr); + const std::vector pcm(1024 * 1024, 0x3f); + const auto written = std::fwrite(pcm.data(), 1, pcm.size(), file); + const int closed = std::fclose(file); + ASSERT_EQ(written, pcm.size()); + ASSERT_EQ(closed, 0); + ASSERT_TRUE(SDL_SetHintWithPriority(SDL_HINT_AUDIO_DRIVER, "disk", + SDL_HINT_OVERRIDE)); + ASSERT_TRUE(SDL_SetHint(SDL_HINT_AUDIO_DISK_INPUT_FILE, path)); + ASSERT_TRUE(SDL_SetHint(SDL_HINT_AUDIO_DISK_OUTPUT_FILE, "/dev/null")); + ASSERT_TRUE(InitializeSdlAudioSubsystem().ok()); + NativeSharedSink sink{&first, &first_owner}; + ASSERT_TRUE(NativeInputCapture::Start(this, &sink)); + ASSERT_TRUE(PollUntil(first, 480 * 3)); + EXPECT_GT(first.peak, 0); +} + +TEST_F(NativeInputCaptureTest, CanStopRestartAndMigrateAnActiveRecording) { + for (int run = 0; run < 3; ++run) { + first_owner = {}; + NativeSharedSink sink{&first, &first_owner}; + ASSERT_TRUE(NativeInputCapture::Start(this, &sink)); + ASSERT_TRUE(PollUntil(first, first.frames + 480)); + std::string name; + ASSERT_TRUE(SwitchSdlRecordingDevice(0, &name).ok()); + ASSERT_TRUE(PollUntil(first, first.frames + 480)); + EXPECT_FALSE(SwitchSdlRecordingDevice(0xffffffffU, &name).ok()); + ASSERT_TRUE(PollUntil(first, first.frames + 480)); + sink = {&first, &first_owner}; + NativeInputCapture::Stop(this, &sink); + const auto frames = first.frames; + NativeInputCapture::Poll(this); + NativeInputCapture::Stop(this, &sink); + EXPECT_EQ(first.frames, frames); + EXPECT_EQ(first_owner.released, 1); + EXPECT_EQ(first_owner.freed, 1); + } +} + +TEST_F(NativeInputCaptureTest, BoundsQueuedAudioWhenTheConsumerStalls) { + NativeSharedSink sink{&first, &first_owner}; + ASSERT_TRUE(NativeInputCapture::Start(this, &sink)); + std::uint32_t playback = 0, recording = 0; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (recording < 210 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + NativeInputCapture::Latency(this, &playback, &recording); + } + EXPECT_EQ(playback, 25U); + ASSERT_EQ(recording, 210U); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + NativeInputCapture::Latency(this, nullptr, &recording); + EXPECT_EQ(recording, 210U); + NativeInputCapture::Poll(this); + EXPECT_GT(first.frames, 0U); + EXPECT_LE(first.frames, 20U * 480); +} + +TEST_F(NativeInputCaptureTest, FreesAHeapControlBlockExactlyOnce) { + int freed = 0; + auto* owner = new Owner; + owner->freed_counter = &freed; + NativeSharedSink sink{&first, owner}; + ASSERT_TRUE(NativeInputCapture::Start(this, &sink)); + sink = {&first, owner}; + NativeInputCapture::Stop(this, &sink); + NativeInputCapture::Stop(this, &sink); + capture->Shutdown(); + EXPECT_EQ(freed, 1); +} + +TEST_F(NativeInputCaptureTest, ConcurrentPollingStopsBeforeShutdownReturns) { + NativeSharedSink sink{&first, &first_owner}; + ASSERT_TRUE(NativeInputCapture::Start(this, &sink)); + std::atomic done{false}; + std::promise started; + std::thread poller([&] { + NativeInputCapture::Poll(this); + started.set_value(); + while (!done.load()) { + NativeInputCapture::Poll(this); + NativeInputCapture::Format(this); + NativeInputCapture::Recording(this); + std::this_thread::yield(); + } + }); + started.get_future().wait(); + capture->Shutdown(); + const auto frames = first.frames; + std::atomic_store(&g_native_capture, std::shared_ptr{}); + capture.reset(); + done.store(true); + poller.join(); + EXPECT_EQ(first.frames, frames); + EXPECT_EQ(first_owner.released, 1); + EXPECT_EQ(first_owner.freed, 1); +} + +TEST_F(NativeInputCaptureTest, ShutdownWaitsForAnInFlightNativeCall) { + struct Gate { + std::mutex mutex; + std::condition_variable cv; + bool entered = false; + bool released = false; + } gate; + auto latency = +[](void* opaque, std::uint32_t*, std::uint32_t*) { + auto& g = *static_cast(opaque); + std::unique_lock lock(g.mutex); + g.entered = true; + g.cv.notify_all(); + g.cv.wait(lock, [&] { return g.released; }); + }; + capture = std::make_shared( + NativeInputCapture::SinkAbi{1, &Relro, &Code, latency}, true); + std::weak_ptr lifetime = capture; + std::atomic_store(&g_native_capture, capture); + std::thread caller([&] { NativeInputCapture::Latency(&gate, nullptr, nullptr); }); + { + std::unique_lock lock(gate.mutex); + gate.cv.wait(lock, [&] { return gate.entered; }); + } + std::atomic_store(&g_native_capture, std::shared_ptr{}); + auto shutdown = std::async(std::launch::async, [owned = std::move(capture)]() mutable { + owned->Shutdown(); + owned.reset(); + }); + EXPECT_EQ(shutdown.wait_for(std::chrono::milliseconds(30)), + std::future_status::timeout); + EXPECT_FALSE(lifetime.expired()); + { + std::lock_guard lock(gate.mutex); + gate.released = true; + } + gate.cv.notify_all(); + caller.join(); + shutdown.get(); + EXPECT_TRUE(lifetime.expired()); +} + } // namespace } // namespace mocktail::audio diff --git a/tests/webview_helper_launcher_test.cc b/tests/webview_helper_launcher_test.cc index 3b69b08..5c814da 100644 --- a/tests/webview_helper_launcher_test.cc +++ b/tests/webview_helper_launcher_test.cc @@ -312,10 +312,13 @@ TEST(WebViewHelperLauncherTest, SendsNormalizedInternalRouteToHelper) { ASSERT_TRUE( WriteExecutable(helper, "#!/bin/sh\ncat > '" + output.string() + "'\n")); + auto exit_probe = std::make_shared(); const WebViewHelperLaunchResult result = - LaunchWebViewHelper(helper, "www:games/servers-section/987654321"); + LaunchWebViewHelper(helper, "www:games/servers-section/987654321", + WebViewHelperExitObserver{exit_probe, &RecordExit}); ASSERT_TRUE(result) << result.error; - ASSERT_TRUE(WaitForFile(output, std::chrono::seconds(2))); + // Redirection creates the file before cat writes it; wait for completion. + ASSERT_TRUE(WaitForExit(exit_probe, std::chrono::seconds(2))); std::ifstream input(output); const std::string request((std::istreambuf_iterator(input)),