diff --git a/include/mocktail/audio/fmod_thread_floating_point.h b/include/mocktail/audio/fmod_thread_floating_point.h new file mode 100644 index 0000000..d42d0a1 --- /dev/null +++ b/include/mocktail/audio/fmod_thread_floating_point.h @@ -0,0 +1,64 @@ +#ifndef MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_ +#define MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_ + +#include + +#if defined(__x86_64__) +#include +#endif + +namespace mocktail::audio { + +// FMOD's Android threads identify themselves through JavaVMAttachArgs before +// entering their mix loop. Voice DSP filters can accumulate subnormal values +// and spend an entire x86 core on floating-point assists (issue #128). +// Limit FTZ/DAZ to these audio threads; retain the caller's other FP controls. +class FmodThreadFloatingPointMode final { + public: + FmodThreadFloatingPointMode() = default; + ~FmodThreadFloatingPointMode() { Restore(); } + FmodThreadFloatingPointMode(const FmodThreadFloatingPointMode&) = delete; + FmodThreadFloatingPointMode& operator=(const FmodThreadFloatingPointMode&) = + delete; + + bool Enable(const char* thread_name) noexcept { +#if defined(__x86_64__) + if (active_ || thread_name == nullptr) return false; + const std::string_view name(thread_name); + if (name != "FMOD mixer thread" && name != "FMOD feeder thread" && + name != "FMOD Convolution thread" && name != "FMOD Worker Thread") { + return false; + } + const unsigned int control = _mm_getcsr(); + original_mode_ = control & kModeMask; + _mm_setcsr(control | kModeMask); + active_ = true; + return true; +#else + (void)thread_name; + return false; +#endif + } + + void Restore() noexcept { +#if defined(__x86_64__) + if (active_) { + // Restore only our two bits, preserving rounding, exception masks and + // status flags set by the guest while the thread was attached. + _mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_); + active_ = false; + } +#endif + } + + private: +#if defined(__x86_64__) + static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6); + unsigned int original_mode_ = 0; + bool active_ = false; +#endif +}; + +} // namespace mocktail::audio + +#endif // MOCKTAIL_AUDIO_FMOD_THREAD_FLOATING_POINT_H_ diff --git a/include/mocktail/audio/roblox_output_device_bridge.h b/include/mocktail/audio/roblox_output_device_bridge.h index 495e4cf..bd1d462 100644 --- a/include/mocktail/audio/roblox_output_device_bridge.h +++ b/include/mocktail/audio/roblox_output_device_bridge.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -13,13 +14,15 @@ namespace mocktail::audio { +class NativeInputCapture; + // Replaces the profiled input/output-device query/select slots of the exact // Build-ID-scoped FmodAudioDevice vtable. Roblox keeps owning its FMOD engine, // while the existing settings UI sees and selects the SDL host routes that // consume Android AudioTrack PCM. class RobloxOutputDeviceBridge final { public: - RobloxOutputDeviceBridge() = default; + RobloxOutputDeviceBridge(); ~RobloxOutputDeviceBridge(); RobloxOutputDeviceBridge(const RobloxOutputDeviceBridge&) = delete; @@ -58,6 +61,9 @@ 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::array original_capture_methods_{}; mutable std::mutex mutex_; compat::FmodOutputDeviceBridgeProfile profile_; std::vector devices_; diff --git a/src/audio/native_input_capture.h b/src/audio/native_input_capture.h new file mode 100644 index 0000000..0bf2f0e --- /dev/null +++ b/src/audio/native_input_capture.h @@ -0,0 +1,225 @@ +#ifndef MOCKTAIL_AUDIO_NATIVE_INPUT_CAPTURE_H_ +#define MOCKTAIL_AUDIO_NATIVE_INPUT_CAPTURE_H_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mocktail/audio/sdl_audio_capture.h" + +namespace mocktail::audio { +class NativeInputCapture; + +struct NativePcmFormat { + std::uint32_t rate = 48000; + std::uint8_t channels = 1; + std::uint8_t bytes_per_sample = 2; + std::uint8_t flags = 0; + std::uint8_t padding = 0; +}; +struct NativeOptionalFormat { + NativePcmFormat format{}; + std::uint64_t present = 0; +}; +struct NativeSharedSink { void* object; void* owner; }; +static_assert(sizeof(NativeOptionalFormat) == 16); +static_assert(sizeof(NativePcmFormat) == 8); +inline std::atomic g_native_capture{nullptr}; + +class NativeInputCapture { + public: + using DeliverPcm = std::size_t (*)(void*, const void*, std::size_t, + std::uint64_t, const NativePcmFormat*); + using Destroy = void (*)(NativeSharedSink*); + using OriginalLatency = void (*)(void*, std::uint32_t*, std::uint32_t*); + struct SinkAbi { + std::uintptr_t vtable; + DeliverPcm deliver; + Destroy release; + OriginalLatency latency; + }; + NativeInputCapture(SinkAbi abi, bool enabled) + : abi_(abi), enabled_(enabled) {} + ~NativeInputCapture() { Shutdown(); } + + static NativeOptionalFormat Format(void*) { + auto* capture = g_native_capture.load(std::memory_order_acquire); + return {{}, capture && capture->enabled_ ? 1ULL : 0ULL}; + } + static void Latency(void* self, std::uint32_t* playback, + std::uint32_t* recording) { + // Preserve the native playback latency and report the host capture queue. + auto* capture = g_native_capture.load(std::memory_order_acquire); + if (!capture) return; + capture->abi_.latency(self, playback, recording); + if (recording) { + std::lock_guard lock(capture->mutex_); + *recording = 10; + auto it = capture->sessions_.find(self); + if (it != capture->sessions_.end()) { + std::lock_guard queue_lock(it->second->queue_mutex); + *recording += static_cast(it->second->count * 10); + } + } + } + static bool Start(void* self, NativeSharedSink* sink) { + auto* capture = g_native_capture.load(std::memory_order_acquire); + return capture && capture->StartSink(self, sink); + } + static void Stop(void* self, NativeSharedSink* sink) { + auto* capture = g_native_capture.load(std::memory_order_acquire); + if (capture) capture->StopSink(self, sink); + } + static bool Recording(void* self) { + auto* capture = g_native_capture.load(std::memory_order_acquire); + if (!capture) return false; + std::lock_guard lock(capture->mutex_); + return capture->sessions_.find(self) != capture->sessions_.end(); + } + static void Poll(void* self) { + auto* capture = g_native_capture.load(std::memory_order_acquire); + if (capture) capture->Deliver(self); + } + void Shutdown() { + std::lock_guard lock(mutex_); + for (auto& [self, session] : sessions_) Close(*session); + sessions_.clear(); + } + + private: + struct Session { + std::unique_ptr input; + std::vector sinks; + std::mutex queue_mutex; + // At most 200 ms; a stalled consumer must not accumulate stale speech. + std::array, 20> frames{}; + std::size_t head = 0, count = 0; + std::uint64_t delivered = 0, dropped = 0; + bool audible = false; + }; + static void OnData(void* context, std::size_t bytes) { + auto& session = *static_cast(context); + if (bytes != sizeof(session.frames[0])) return; + std::lock_guard lock(session.queue_mutex); + if (session.count == session.frames.size()) { + session.head = (session.head + 1) % session.frames.size(); + --session.count; + ++session.dropped; + } + std::memcpy(session.frames[(session.head + session.count) % + session.frames.size()].data(), + session.input->buffer_data(), bytes); + ++session.count; + } + bool StartSink(void* self, NativeSharedSink* sink) { + std::lock_guard lock(mutex_); + if (!enabled_ || !self || !sink || !sink->object || !sink->owner) + return false; + auto* table = *reinterpret_cast(sink->object); + if (reinterpret_cast(table) != abi_.vtable || + table[2] != reinterpret_cast(abi_.deliver)) { + std::fprintf(stderr, " [audio-input] unsupported native PCM sink ABI\n"); + return false; + } + auto it = sessions_.find(self); + if (it != sessions_.end()) { + for (const auto& existing : it->second->sinks) + if (existing.object == sink->object) return true; + } else { + auto session = std::make_unique(); + SdlAudioCaptureOptions options; + options.output_spec.channels = 1; + options.data_callback = &OnData; + options.data_context = session.get(); + auto status = CreateSdlAudioCapture(options, &session->input); + if (status.ok()) status = session->input->Start(); + if (!status.ok()) { + std::fprintf(stderr, " [audio-input] capture start failed: %s\n", + status.message().c_str()); + return false; + } + it = sessions_.emplace(self, std::move(session)).first; + std::fprintf(stderr, + " [audio-input] native capture started: 48000 Hz mono signed16\n"); + } + // The native parameter is a by-value shared_ptr. Retain its existing + // ownership by moving it, leaving the caller's temporary empty. + it->second->sinks.push_back(*sink); + *sink = {}; + return true; + } + void Release(NativeSharedSink& sink) { + abi_.release(&sink); + sink = {}; + } + void Close(Session& session) { + session.input->Shutdown(); + for (auto& sink : session.sinks) Release(sink); + session.sinks.clear(); + std::fprintf(stderr, + " [audio-input] native capture stopped: delivered=%llu dropped=%llu\n", + static_cast(session.delivered), + static_cast(session.dropped)); + } + void StopSink(void* self, NativeSharedSink* sink) { + std::lock_guard lock(mutex_); + auto it = sessions_.find(self); + if (it == sessions_.end() || !sink) return; + auto& session = *it->second; + auto existing = std::find_if(session.sinks.begin(), session.sinks.end(), + [&](const auto& entry) { return entry.object == sink->object; }); + if (existing == session.sinks.end()) return; + Release(*existing); + session.sinks.erase(existing); + if (session.sinks.empty()) { + Close(session); + sessions_.erase(it); + } + } + void Deliver(void* self) { + // Invoked by Roblox's recording thread. Guest code and allocator TLS must + // never run on SDL's capture callback thread. + std::lock_guard lock(mutex_); + auto it = sessions_.find(self); + if (it == sessions_.end()) return; + auto& session = *it->second; + for (std::size_t n = 0; n < session.frames.size(); ++n) { + std::array pcm; + std::uint64_t delay; + { + std::lock_guard queue_lock(session.queue_mutex); + if (!session.count) break; + pcm = session.frames[session.head]; + delay = session.count * 10000000ULL; + session.head = (session.head + 1) % session.frames.size(); + --session.count; + } + const NativePcmFormat format; + for (const auto& sink : session.sinks) + abi_.deliver( + sink.object, pcm.data(), pcm.size(), delay, &format); + if (++session.delivered == 1) + std::fprintf(stderr, " [audio-input] PCM delivered to native voice sink\n"); + if (!session.audible && std::any_of(pcm.begin(), pcm.end(), + [](auto value) { return value != 0; })) { + session.audible = true; + std::fprintf(stderr, " [audio-input] nonzero microphone PCM delivered\n"); + } + } + } + SinkAbi abi_; + bool enabled_; + std::mutex mutex_; + std::map> sessions_; +}; + + +} // namespace mocktail::audio +#endif diff --git a/src/audio/roblox_output_device_bridge.cc b/src/audio/roblox_output_device_bridge.cc index 5b6ed75..e4fda6c 100644 --- a/src/audio/roblox_output_device_bridge.cc +++ b/src/audio/roblox_output_device_bridge.cc @@ -21,6 +21,7 @@ #include #include "audio/roblox_output_device_bridge_internal.h" +#include "audio/native_input_capture.h" #include "linker/linker.h" #include "mocktail/audio/sdl_audio_capture.h" #include "mocktail/audio/sdl_audio_sink.h" @@ -105,7 +106,7 @@ bool SetVtableWritable(std::uintptr_t* vtable, std::size_t select_index, } const std::uintptr_t page_size = static_cast(page_size_value); const std::uintptr_t first = - reinterpret_cast(vtable + kOutputCountVtableIndex); + reinterpret_cast(vtable + 4); const std::uintptr_t last = reinterpret_cast(vtable + select_index + 1); if (last <= first) { @@ -129,6 +130,16 @@ std::uintptr_t FunctionAddress(Function function) { } // namespace +// ABI verified for Roblox 2.738.1397. Keep capture separate from the menu +// profile: relocating query methods alone does not establish a recording ABI. +namespace { +constexpr std::array kCaptureSlots{4, 13, 14, 15, 16, 17}; +constexpr std::array kCaptureRvas{ + 0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a}; +} // namespace + +RobloxOutputDeviceBridge::RobloxOutputDeviceBridge() = default; + namespace internal { bool HasExpectedFmodStringConstructorContract(const std::uint8_t* code, @@ -215,6 +226,10 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) { "another Roblox output-device bridge owns the process"); } profile_ = *profile.fmod_output_device_bridge; + capture_profile_supported_ = + profile.elf_build_id == "5f0704edd9064f566ee3d6df2bd2fabbcc709f03" && + profile_.vtable_rva == 0x6cd3ce0 && + profile_.vtable_layout_version == 2 && profile_.has_input_devices(); if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) { g_active_bridge.store(nullptr, std::memory_order_release); profile_ = {}; @@ -240,6 +255,8 @@ void RobloxOutputDeviceBridge::Shutdown() { " [audio-menu] failed to restore FmodAudioDevice " "vtable protection\n"); } + g_native_capture.store(nullptr, std::memory_order_release); + capture_.reset(); active_ = false; devices_.clear(); input_devices_.clear(); @@ -408,9 +425,9 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { return FailedPrecondition("FMOD output-device vtable range overflows"); } const std::uintptr_t first_slot_rva = - profile_.vtable_rva + kOutputCountVtableIndex * sizeof(std::uintptr_t); + profile_.vtable_rva + 4 * sizeof(std::uintptr_t); const std::size_t slot_range_size = - (kSelectOutputVtableIndex - kOutputCountVtableIndex + 1) * + (kSelectOutputVtableIndex - 4 + 1) * sizeof(std::uintptr_t); if (!IsRelroImageRange(library_base_, first_slot_rva, slot_range_size) || !IsExecutableImageRange(library_base_, profile_.string_constructor_rva, @@ -444,6 +461,29 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { "FmodAudioDevice string ABI contract validation failed"); } + if (capture_profile_supported_) { + for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) { + if (!IsExecutableImageRange(library_base_, kCaptureRvas[i], 1) || + vtable_[kCaptureSlots[i]] != library_base_ + kCaptureRvas[i]) + return FailedPrecondition("native input capture vtable ABI mismatch"); + original_capture_methods_[i] = vtable_[kCaptureSlots[i]]; + } + if (!IsExecutableImageRange(library_base_, 0x3213154, 1) || + !IsExecutableImageRange(library_base_, 0x1dc4cd0, 1) || + !IsRelroImageRange(library_base_, 0x6cd4550, 24)) + return FailedPrecondition("native input capture sink ABI outside image"); + capture_ = std::make_unique( + NativeInputCapture::SinkAbi{ + library_base_ + 0x6cd4550, + reinterpret_cast( + library_base_ + 0x3213154), + reinterpret_cast( + library_base_ + 0x1dc4cd0), + reinterpret_cast( + library_base_ + kCaptureRvas[1])}, + !input_devices_.empty()); + } + original_methods_ = { vtable_[kOutputCountVtableIndex], vtable_[kOutputInfoVtableIndex], @@ -478,6 +518,18 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { for (std::size_t i = 0; i < indexes.size(); ++i) __atomic_store_n(&vtable_[indexes[i]], methods[i], __ATOMIC_RELEASE); } + if (capture_) { + g_native_capture.store(capture_.get(), std::memory_order_release); + const std::array methods{ + FunctionAddress(&NativeInputCapture::Format), + FunctionAddress(&NativeInputCapture::Latency), + FunctionAddress(&NativeInputCapture::Start), + FunctionAddress(&NativeInputCapture::Stop), + FunctionAddress(&NativeInputCapture::Poll), + FunctionAddress(&NativeInputCapture::Recording)}; + for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) + __atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE); + } if (!SetVtableWritable(vtable_, kSelectOutputVtableIndex, false)) { __atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0], __ATOMIC_RELEASE); @@ -493,6 +545,13 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { __atomic_store_n(&vtable_[indexes[i]], original_methods_[4 + i], __ATOMIC_RELEASE); } + if (capture_) { + for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) + __atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i], + __ATOMIC_RELEASE); + g_native_capture.store(nullptr, std::memory_order_release); + capture_.reset(); + } (void)SetVtableWritable(vtable_, kSelectOutputVtableIndex, false); return Status::Error(StatusCode::kPlatformError, "cannot restore FmodAudioDevice RELRO protection"); @@ -521,6 +580,11 @@ bool RobloxOutputDeviceBridge::RestoreVtableLocked() { __atomic_store_n(&vtable_[indexes[i]], original_methods_[4 + i], __ATOMIC_RELEASE); } + if (capture_) { + for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) + __atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i], + __ATOMIC_RELEASE); + } return SetVtableWritable(vtable_, kSelectOutputVtableIndex, false); } diff --git a/src/audio/webrtc_jni_audio_bridge.cc b/src/audio/webrtc_jni_audio_bridge.cc index f2081e0..43e5d57 100644 --- a/src/audio/webrtc_jni_audio_bridge.cc +++ b/src/audio/webrtc_jni_audio_bridge.cc @@ -30,6 +30,9 @@ struct CaptureSession { void* data_context = nullptr; std::unique_ptr capture; std::shared_ptr> microphone_muted; + std::atomic delivered_buffers{0}; + std::atomic audible_buffers{0}; + std::atomic muted_buffers{0}; }; struct PlayoutSession { @@ -112,22 +115,40 @@ void DisposeManager(void* opaque_context, const void* identity) { void SetMicrophoneMute(void* opaque_context, bool muted) { auto* context = static_cast(opaque_context); if (context == nullptr) return; - context->microphone_muted->store(muted, std::memory_order_release); + if (context->microphone_muted->exchange(muted, std::memory_order_acq_rel) != muted) { + std::cerr << " [mocktail][audio] WebRTC microphone muted=" << muted << '\n'; + } } void OnCapturedAudio(void* opaque_session, std::size_t size_bytes) { auto* session = static_cast(opaque_session); if (session != nullptr && session->data_callback != nullptr) { + if (session->capture == nullptr || + size_bytes > session->capture->buffer_size_bytes()) { + return; + } + const auto* samples = static_cast( + session->capture->buffer_data()); + int peak = 0; + for (std::size_t i = 0; i + 1 < size_bytes; i += 2) { + const int word = samples[i] | (static_cast(samples[i + 1]) << 8); + peak = std::max(peak, std::abs(word >= 32768 ? word - 65536 : word)); + } + if (peak != 0 && session->audible_buffers.fetch_add(1) == 0) { + std::cerr << " [mocktail][audio] WebRTC microphone first nonzero PCM" + << " peak=" << peak << "/32768\n"; + } if (session->microphone_muted != nullptr && session->microphone_muted->load(std::memory_order_acquire)) { - if (session->capture == nullptr || - size_bytes > session->capture->buffer_size_bytes()) { - return; - } + session->muted_buffers.fetch_add(1, std::memory_order_relaxed); std::memset(session->capture->buffer_data(), 0, size_bytes); } session->data_callback(session->data_context, session->identity, size_bytes); + if (session->delivered_buffers.fetch_add(1) == 0) { + std::cerr << " [mocktail][audio] WebRTC microphone first JNI delivery" + << " bytes=" << size_bytes << '\n'; + } } } @@ -137,6 +158,10 @@ void CloseCaptureSession(const std::shared_ptr& session) { } (void)session->capture->Stop(); session->capture->Shutdown(); + std::cerr << " [mocktail][audio] WebRTC microphone capture summary" + << " buffers=" << session->delivered_buffers.load() + << " nonzero=" << session->audible_buffers.load() + << " muted=" << session->muted_buffers.load() << '\n'; } int Init(void* opaque_context, const void* identity, int sample_rate_hz, diff --git a/src/jnivm/jnivm.cc b/src/jnivm/jnivm.cc index 10951aa..6f9df95 100644 --- a/src/jnivm/jnivm.cc +++ b/src/jnivm/jnivm.cc @@ -1,5 +1,7 @@ #include "jnivm/jnivm.h" +#include "mocktail/audio/fmod_thread_floating_point.h" + #include #include #include @@ -56,6 +58,7 @@ namespace { thread_local JNIEnv* g_thread_local_env = nullptr; thread_local JNIEnv g_thread_env_storage = {}; thread_local VM* g_thread_vm_instance = nullptr; +thread_local mocktail::audio::FmodThreadFloatingPointMode g_thread_audio_fp_mode; thread_local std::vector> g_local_frames; std::recursive_mutex g_jni_state_mutex; @@ -4970,6 +4973,7 @@ VM::~VM() { g_live_vms.erase(std::remove(g_live_vms.begin(), g_live_vms.end(), this), g_live_vms.end()); if (g_thread_vm_instance == this) { + g_thread_audio_fp_mode.Restore(); g_thread_vm_instance = nullptr; g_thread_local_env = nullptr; g_thread_env_storage.functions = nullptr; @@ -6323,6 +6327,7 @@ JNIEnv* VM::GetJNIEnv() { } return g_thread_local_env; } + g_thread_audio_fp_mode.Restore(); g_thread_vm_instance = this; g_thread_env_storage.functions = &native_interface_; g_thread_local_env = &g_thread_env_storage; @@ -6372,7 +6377,7 @@ std::shared_ptr VM::FindClass(const std::string& class_name) const { void VM::InitJNIFunctionTables() { invoke_interface_.AttachCurrentThread = - [](JavaVM* vm, void** env, void* /*args*/) -> jint { + [](JavaVM* vm, void** env, void* args) -> jint { if (JniVmTraceEnabled()) { std::cout << " [JNI] AttachCurrentThread enter vm=" << vm << " env_out=" << env << '\n'; @@ -6389,6 +6394,7 @@ void VM::InitJNIFunctionTables() { return JNI_EINVAL; } if (g_thread_vm_instance != owner || !IsThreadLocalEnvValid()) { + g_thread_audio_fp_mode.Restore(); g_thread_vm_instance = owner; if (!owner->jni_env_) { owner->jni_env_ = &owner->jni_env_storage_; @@ -6398,6 +6404,12 @@ void VM::InitJNIFunctionTables() { owner->jni_env_->functions ? owner->jni_env_->functions : &owner->native_interface_; g_thread_local_env = &g_thread_env_storage; + const auto* attach_args = static_cast(args); + if (attach_args != nullptr && + g_thread_audio_fp_mode.Enable(attach_args->name)) { + std::fprintf(stderr, " [mocktail][audio] %s: enabled FTZ/DAZ\n", + attach_args->name); + } } // Reattaching to the same VM must retain any guest JNI table wrapper. *env = g_thread_local_env; @@ -6421,6 +6433,7 @@ void VM::InitJNIFunctionTables() { if (g_thread_vm_instance != owner || !IsThreadLocalEnvValid()) { return JNI_EDETACHED; } + g_thread_audio_fp_mode.Restore(); g_thread_local_env = nullptr; g_thread_vm_instance = nullptr; g_thread_env_storage.functions = nullptr; diff --git a/tests/jnivm_test.cc b/tests/jnivm_test.cc index 8aa05c0..d953fb9 100644 --- a/tests/jnivm_test.cc +++ b/tests/jnivm_test.cc @@ -13,6 +13,10 @@ #include #include +#if defined(__x86_64__) +#include +#endif + namespace jnivm { namespace { @@ -376,6 +380,142 @@ TEST_F(JniVmTest, JniEnvPointerIsNotNull) { EXPECT_NE(vm_->GetJNIEnv(), nullptr); } +#if defined(__x86_64__) +constexpr unsigned int kAudioFpMode = (1U << 15) | (1U << 6); + +class ScopedMxcsr final { + public: + ScopedMxcsr() : saved_(_mm_getcsr()) {} + ~ScopedMxcsr() { _mm_setcsr(saved_); } + + private: + unsigned int saved_; +}; + +// Volatile input ensures the multiply executes in SSE with the current MXCSR, +// rather than being constant-folded using the compiler's default FP model. +float MultiplyAudioSample(volatile float* sample, float gain) { + return _mm_cvtss_f32(_mm_mul_ss(_mm_set_ss(*sample), _mm_set_ss(gain))); +} + +TEST_F(JniVmTest, FmodMixerFlushesSubnormalAudioWithoutChangingParentThread) { + ScopedMxcsr restore; + _mm_setcsr(0x1f80); // IEEE defaults; FTZ/DAZ disabled. + JavaVM* java_vm = vm_->GetJavaVM(); + std::thread mixer([java_vm]() { + ScopedMxcsr restore_worker; + volatile float tail = 0x1p-140f; + volatile float quiet_normal = 0x1p-126f; + volatile float audible = 0.25f; + EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f); + EXPECT_GT(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f); + const unsigned int before = _mm_getcsr(); + char name[] = "FMOD mixer thread"; + JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr}; + void* env = nullptr; + ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK); + EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode); + EXPECT_EQ(MultiplyAudioSample(&tail, 1.0f), 0.0f); + EXPECT_EQ(MultiplyAudioSample(&quiet_normal, 0.5f), 0.0f); + EXPECT_EQ(MultiplyAudioSample(&audible, 0.5f), 0.125f); + // Reattaching with no name must not lose the mixer's saved FP state. + ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK); + EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode); + ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK); + EXPECT_EQ(_mm_getcsr() & kAudioFpMode, before & kAudioFpMode); + EXPECT_GT(MultiplyAudioSample(&tail, 1.0f), 0.0f); + }); + mixer.join(); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); +} + +TEST_F(JniVmTest, FmodDaemonThreadsRestoreExistingModesAndOtherControls) { + ScopedMxcsr restore; + JavaVM* java_vm = vm_->GetJavaVM(); + for (const char* name : {"FMOD mixer thread", "FMOD feeder thread", + "FMOD Convolution thread", "FMOD Worker Thread"}) { + for (const unsigned int mode : {0U, 1U << 6, 1U << 15, kAudioFpMode}) { + _mm_setcsr(0x1f80 | mode | _MM_ROUND_DOWN); + const unsigned int before = _mm_getcsr(); + JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast(name), nullptr}; + void* env = nullptr; + ASSERT_EQ(java_vm->AttachCurrentThreadAsDaemon(&env, &args), JNI_OK); + EXPECT_EQ(_mm_getcsr(), before | kAudioFpMode); + // Guest changes to other controls/status must survive detach. + _mm_setcsr((_mm_getcsr() & ~_MM_ROUND_MASK) | _MM_ROUND_UP | 0x20U); + const unsigned int after_guest = _mm_getcsr(); + ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK); + EXPECT_EQ(_mm_getcsr(), (after_guest & ~kAudioFpMode) | mode); + } + } +} + +TEST_F(JniVmTest, OrdinaryJniThreadsKeepIeeeFloatingPointBehavior) { + ScopedMxcsr restore; + _mm_setcsr(0x1f80); + JavaVM* java_vm = vm_->GetJavaVM(); + for (const char* name : {"Main", "RBX Worker", "FMOD mixer thread extra", + "FMOD file thread", "", static_cast(nullptr)}) { + JavaVMAttachArgs args{JNI_VERSION_1_6, const_cast(name), nullptr}; + void* env = nullptr; + ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); + // JNI ignores new names when a thread is already attached. + args.name = const_cast("FMOD mixer thread"); + ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); + ASSERT_EQ(java_vm->DetachCurrentThread(), JNI_OK); + } + void* env = nullptr; + ASSERT_EQ(java_vm->AttachCurrentThread(&env, nullptr), JNI_OK); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); + EXPECT_EQ(java_vm->DetachCurrentThread(), JNI_OK); +} + +TEST_F(JniVmTest, RejectedFmodAttachDoesNotChangeFloatingPointMode) { + ScopedMxcsr restore; + _mm_setcsr(0x1f80); + JavaVM* java_vm = vm_->GetJavaVM(); + char name[] = "FMOD mixer thread"; + JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr}; + EXPECT_EQ(java_vm->AttachCurrentThread(nullptr, &args), JNI_EINVAL); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); + JavaVM foreign = {java_vm->functions}; + void* env = nullptr; + EXPECT_EQ(foreign.AttachCurrentThread(&env, &args), JNI_EINVAL); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); +} + +TEST_F(JniVmTest, ChangingVmRestoresFmodFloatingPointMode) { + ScopedMxcsr restore; + _mm_setcsr(0x1f80); + JavaVM* java_vm = vm_->GetJavaVM(); + char name[] = "FMOD mixer thread"; + JavaVMAttachArgs args{JNI_VERSION_1_6, name, nullptr}; + void* env = nullptr; + ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK); + { + VM temporary; + // Merely constructing/destroying another VM must not change this thread. + } + EXPECT_EQ(_mm_getcsr() & kAudioFpMode, kAudioFpMode); + { + VM other; + ASSERT_EQ(other.GetJavaVM()->AttachCurrentThread(&env, nullptr), JNI_OK); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); + } + ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK); + { + VM other; + ASSERT_NE(other.GetJNIEnv(), nullptr); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); + } + ASSERT_EQ(java_vm->AttachCurrentThread(&env, &args), JNI_OK); + vm_.reset(); + EXPECT_EQ(_mm_getcsr(), 0x1f80U); +} +#endif + TEST_F(JniVmTest, DiscardedTemporaryVmKeepsOriginalUsable) { JavaVM* java_vm = vm_->GetJavaVM(); JNIEnv* original_env = vm_->GetJNIEnv();