chore(merge): merge upstream komaruworld/mocktail main

This commit is contained in:
vii committed 2026-10-10 00:09:42 -05:00
commit 0ddb546ba8
9 files changed
+469 -71

No files matched your search

+5
View File
@@ -36,6 +36,11 @@ struct FmodOutputDeviceBridgeProfile {
? std::array<std::size_t, 4>{9, 10, 12, 18}
: std::array<std::size_t, 4>{8, 9, 10, 16};
}
std::array<std::size_t, 6> capture_vtable_indexes() const {
return vtable_layout_version == 2
? std::array<std::size_t, 6>{4, 13, 14, 15, 16, 17}
: std::array<std::size_t, 6>{4, 11, 12, 13, 14, 15};
}
bool valid_vtable_layout() const {
return vtable_layout_version == 1 || vtable_layout_version == 2;
@@ -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<NativeInputCapture> capture_;
bool capture_profile_supported_ = false;
std::shared_ptr<NativeInputCapture> capture_;
std::array<std::uintptr_t, 6> original_capture_methods_{};
mutable std::mutex mutex_;
compat::FmodOutputDeviceBridgeProfile profile_;
+66 -25
View File
@@ -7,6 +7,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <map>
#include <memory>
#include <mutex>
@@ -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<NativeInputCapture*> g_native_capture{nullptr};
inline std::shared_ptr<NativeInputCapture> 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<AudioCapture> input;
std::vector<NativeSharedSink> sinks;
std::vector<Sink> sinks;
std::mutex queue_mutex;
// At most 200 ms; a stalled consumer must not accumulate stale speech.
std::array<std::array<std::int16_t, 480>, 20> frames{};
@@ -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<std::size_t> methods) const {
const auto base = abi_.image_base;
const auto* table = *static_cast<const std::uintptr_t* const*>(object);
const auto address = reinterpret_cast<std::uintptr_t>(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<std::uintptr_t**>(sink->object);
if (reinterpret_cast<std::uintptr_t>(table) != abi_.vtable ||
table[2] != reinterpret_cast<std::uintptr_t>(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<Session>();
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<const std::uintptr_t* const*>(sink->object);
it->second->sinks.push_back({*sink, reinterpret_cast<DeliverPcm>(table[2])});
*sink = {};
return true;
}
void Release(NativeSharedSink& sink) {
abi_.release(&sink);
static void Release(Sink& sink) {
auto* count = static_cast<SharedCount*>(sink.shared.owner);
sink = {};
if (__atomic_fetch_sub(&count->shared_owners, 1, __ATOMIC_ACQ_REL) != 0)
return;
reinterpret_cast<ControlBlockHook>(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<ControlBlockHook>(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<void*, std::unique_ptr<Session>> sessions_;
};
+33 -37
View File
@@ -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<std::size_t, 6> kCaptureSlots{4, 13, 14, 15, 16, 17};
constexpr std::array<std::uintptr_t, 6> kCaptureRvas{
0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a};
} // namespace
RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default;
namespace internal {
@@ -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<NativeInputCapture>{},
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<std::uintptr_t>::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<NativeInputCapture>(
capture_ = std::make_shared<NativeInputCapture>(
NativeInputCapture::SinkAbi{
library_base_ + 0x6cd4550,
reinterpret_cast<NativeInputCapture::DeliverPcm>(
library_base_ + 0x3213154),
reinterpret_cast<NativeInputCapture::Destroy>(
library_base_ + 0x1dc4cd0),
library_base_, &IsRelroImageRange, &IsExecutableImageRange,
reinterpret_cast<NativeInputCapture::OriginalLatency>(
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<std::uintptr_t, 6> 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<NativeInputCapture>{},
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);
+3 -2
View File
@@ -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<ClientSetting, 68> 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<ClientSetting, 67> 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"},
+4 -1
View File
@@ -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) {
+31 -2
View File
@@ -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<std::string>{"FIntOcclusionWorkerThreadCount",
"FFlagMovePrerenderV2",
"FIntDebugForceMSAASamples"}));
"FFlagMovePrerenderV2"}));
PerformancePolicy latency =
ParsePerformancePolicy("true", "0", "auto", "latency");
+321
View File
@@ -1,13 +1,22 @@
#include "mocktail/audio/roblox_output_device_bridge.h"
#include <gtest/gtest.h>
#include <SDL3/SDL.h>
#include <array>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <future>
#include <mutex>
#include <string>
#include <thread>
#include <unistd.h>
#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<PcmSink*>(object);
sink.frames += frames;
const auto* pcm = static_cast<const std::int16_t*>(data);
for (std::size_t i = 0; i < frames; ++i)
sink.peak = std::max(sink.peak, std::abs(static_cast<int>(pcm[i])));
return frames;
}
static void Release(void* owner) { ++static_cast<Owner*>(owner)->released; }
static void Free(void* opaque) {
auto* owner = static_cast<Owner*>(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<std::uintptr_t>(sink_table.data()) &&
size <= sizeof(sink_table)) ||
(base + rva == reinterpret_cast<std::uintptr_t>(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<std::uintptr_t>(&Deliver) ||
base + rva == reinterpret_cast<std::uintptr_t>(&Release) ||
base + rva == reinterpret_cast<std::uintptr_t>(&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>(
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<NativeInputCapture>{});
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<std::uintptr_t, 3> sink_table{
0, 0, reinterpret_cast<std::uintptr_t>(&Deliver)};
inline static const std::array<std::uintptr_t, 5> owner_table{
0, 0, reinterpret_cast<std::uintptr_t>(&Release), 0,
reinterpret_cast<std::uintptr_t>(&Free)};
PcmSink first, second;
Owner first_owner, second_owner;
std::shared_ptr<NativeInputCapture> 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>(
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<unsigned char> 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<bool> done{false};
std::promise<void> 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<NativeInputCapture>{});
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<Gate*>(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>(
NativeInputCapture::SinkAbi{1, &Relro, &Code, latency}, true);
std::weak_ptr<NativeInputCapture> 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<NativeInputCapture>{});
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
+5 -2
View File
@@ -312,10 +312,13 @@ TEST(WebViewHelperLauncherTest, SendsNormalizedInternalRouteToHelper) {
ASSERT_TRUE(
WriteExecutable(helper, "#!/bin/sh\ncat > '" + output.string() + "'\n"));
auto exit_probe = std::make_shared<ExitProbe>();
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<char>(input)),