mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 17:00:55 +02:00
chore(merge): merge upstream komaruworld/mocktail main
This commit is contained in:
commit
0ddb546ba8
9 files changed
+469
-71
No files matched your search
@@ -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_;
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user