diff --git a/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/EmulationSurface.kt b/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/EmulationSurface.kt index 4c1fb0a2f..dbd54ab8b 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/EmulationSurface.kt +++ b/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/EmulationSurface.kt @@ -57,6 +57,41 @@ class EmulationSurface(context: Context) : super.onAttachedToWindow() hostWindow()?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) displayManager.registerDisplayListener(this, null) + redeliverSurface() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + if (visibility == VISIBLE) redeliverSurface() + } + + /** + * Hand the current Surface to native again, if we already have a usable one. + * + * surfaceChanged is a ONE-SHOT: Android delivers it when the surface is created or resized and + * never repeats it. The native renderer blocks in getNativeWindow() until that single delivery + * arrives — a 100 ms sleep loop with no timeout — so if it is ever missed, the RSX thread parks + * forever and the game area stays black at 0% CPU with the boot log stopping dead just after + * Vulkan device creation. That is the "launch a game the instant the app opens and get a black + * screen" report: the SurfaceView and its compositor layer exist, the touch controls draw over + * it, and nothing is wrong except that native was never told. Rotating the device "fixed" it + * only because a configuration change forces a fresh surfaceChanged. + * + * Re-delivering is safe and idempotent: the native side compares the incoming ANativeWindow + * against the one it holds and treats an identical pointer as a no-op, so calling this on every + * attach and every window-visibility change costs nothing when the surface already arrived. + */ + fun redeliverSurface() { + // post() rather than inline: onAttachedToWindow runs before layout, so width/height are + // still 0 here, and a 0x0 report is exactly what the native side is told to ignore. + post { + val current = holder.surface + + if (current != null && current.isValid && width > 0 && height > 0) { + pushDisplayCutoutInset(width, height) + NativeApp.onNativeSurfaceChanged(current, width, height) + } + } } override fun onDetachedFromWindow() { diff --git a/android/src/rpcsx-android.cpp b/android/src/rpcsx-android.cpp index 5c637d311..922d69e17 100644 --- a/android/src/rpcsx-android.cpp +++ b/android/src/rpcsx-android.cpp @@ -2,6 +2,7 @@ #include "Crypto/unpkg.h" #include "Crypto/unself.h" #include "Emu/Audio/Cubeb/CubebBackend.h" +#include "Emu/RSX/VK/VKFrameGen.h" #include "Emu/Audio/Oboe/OboeBackend.h" #include "Emu/Audio/Null/NullAudioBackend.h" #include "Emu/Cell/PPUAnalyser.h" @@ -113,6 +114,12 @@ static std::atomic g_native_window_size; // Set when losing the surface is what paused the emulator, so getting it back resumes only // the pause we caused. static std::atomic g_paused_by_surface_loss; + +// Bumped every time g_native_window starts pointing somewhere else. See +// GSFrameBase::display_epoch: a new Surface with the same dimensions as the old one is otherwise +// undetectable by the renderer, and boot-then-immediately-open-a-game is exactly when the +// emulation SurfaceView is replaced under a swapchain that has already been built. +static std::atomic g_native_window_epoch; extern std::string g_android_executable_dir; extern std::string g_android_config_dir; extern std::string g_android_cache_dir; @@ -387,12 +394,26 @@ struct GraphicsFrame : GSFrameBase { ANativeWindow *getNativeWindow() const { ANativeWindow *result; + + // Waiting here is normal for a moment at boot -- the RSX thread usually starts before the + // SurfaceView has been laid out. Waiting here FOREVER is not, and used to be completely + // silent: surfaceChanged is a one-shot, so a single missed delivery parked this loop for the + // rest of the session. The symptom was a black game area at 0% CPU with the log stopping dead + // just after Vulkan device creation and nothing at all to say why. Say so instead. + u32 waited_ms = 0; + while ((result = g_native_window.load()) == nullptr) [[unlikely]] { if (Emu.IsStopped()) { return activeNativeWindow; } std::this_thread::sleep_for(std::chrono::milliseconds(100)); + waited_ms += 100; + + if (waited_ms % 3000 == 0) { + rpcsx_android.error("Still waiting for a Surface after %u ms -- the renderer cannot start " + "until surfaceChanged reaches native.", waited_ms); + } } if (result != activeNativeWindow) [[unlikely]] { @@ -529,6 +550,8 @@ struct GraphicsFrame : GSFrameBase { display_handle_t handle() const override { return getNativeWindow(); } + u64 display_epoch() const override { return g_native_window_epoch.load(); } + bool can_consume_frame() const override { return false; } // Upstream takes the buffer by rvalue ref (it moves it); RPCSX's lvalue-ref @@ -3631,6 +3654,7 @@ extern "C" bool _rpcsx_surfaceEvent(JNIEnv *env, jobject surface, jint event) { auto prevWindow = g_native_window.exchange(nullptr); if (prevWindow != nullptr) { ANativeWindow_release(prevWindow); + g_native_window_epoch.fetch_add(1); } // get_pad_thread() defaults to relaxed=false, which is @@ -3691,6 +3715,15 @@ extern "C" bool _rpcsx_surfaceEvent(JNIEnv *env, jobject surface, jint event) { ANativeWindow_release(prevWindow); } + // Only when it really is a different window. Rotation keeps the same one (the activity + // handles orientation itself), and bumping on every surfaceChanged would rebuild the + // swapchain for nothing on every rotation. + if (prevWindow != newWindow) { + g_native_window_epoch.fetch_add(1); + rpcsx_android.notice("native window replaced (%p -> %p), swapchain will be rebuilt", + prevWindow, newWindow); + } + if (event == 0 && g_paused_by_surface_loss && Emu.IsPaused()) { g_paused_by_surface_loss = false; Emu.Resume(); @@ -4667,6 +4700,26 @@ extern "C" void _rpcsx_settingsEndBatch() { } } +// Import the Lossless Scaling shaders from a file the user chose. +// +// The path is a real filesystem path, not a content:// URI -- the Kotlin side copies the picked +// file into app storage first, because the extraction walks the PE with ordinary file IO. +// +// Returns the number of shaders extracted, negative on failure. The message is left where +// frameGenShaderError can retrieve it, so the settings screen can say what actually went wrong +// rather than "import failed". +extern "C" int _rpcsx_frameGenImportShaders(std::string_view path) { + return vk::frame_gen::import_shaders(std::string(path)); +} + +extern "C" int _rpcsx_frameGenShaderCount() { + return vk::frame_gen::shader_count(); +} + +extern "C" const char *_rpcsx_frameGenShaderError() { + return vk::frame_gen::last_error(); +} + extern "C" bool _rpcsx_settingsSet(std::string_view path, std::string_view valueString) { auto root = find_cfg_node(&g_cfg, path); diff --git a/rpcs3/Emu/RSX/GSFrameBase.h b/rpcs3/Emu/RSX/GSFrameBase.h index b5edbc19a..36b2ae31d 100644 --- a/rpcs3/Emu/RSX/GSFrameBase.h +++ b/rpcs3/Emu/RSX/GSFrameBase.h @@ -30,6 +30,15 @@ public: virtual display_handle_t handle() const = 0; + // Bumped whenever handle() starts referring to a DIFFERENT native window. + // + // The swapchain is rebuilt when client_width()/client_height() stop matching it, and that is + // the only trigger there is. A replacement window with the same dimensions is therefore + // invisible: the swapchain stays bound to a window that is no longer on screen and the picture + // goes black until something happens to change the size -- which is why rotating the device + // "fixed" it. Platforms whose window cannot be swapped under a live swapchain keep the default. + virtual u64 display_epoch() const { return 0; } + virtual bool can_consume_frame() const = 0; virtual void present_frame(std::vector&& data, u32 pitch, u32 width, u32 height, bool is_bgra) const = 0; virtual void take_screenshot(std::vector&& sshot_data, u32 sshot_width, u32 sshot_height, bool is_bgra) = 0;