From 1fc34da2a9f9924fe9376d24c9f94c9f053cf6d9 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:57:10 +0200 Subject: [PATCH] android: extend secondary display presentation --- docs/modding.md | 12 +- .../love/src/jni/love/src/common/android.cpp | 48 ++++++ .../java/org/love2d/android/GameActivity.java | 151 +++++++++++++++--- src/render/SecondScreen.lua | 25 +++ tests/engine/android_secondary_present.lua | 36 +++++ tests/engine/second_screen_present.lua | 64 ++++++++ 6 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 tests/engine/android_secondary_present.lua create mode 100644 tests/engine/second_screen_present.lua diff --git a/docs/modding.md b/docs/modding.md index de1726bd..2786a563 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -733,9 +733,15 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a palette-correct blit of either canvas into an arbitrary screen rect, and the -`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` / -`setEnabled`) for driving a second physical display. `pollTouch()` returns the -oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. +`secondScreen` bridge (`available()` / `detected()` / `push(...)` / +`pollTouch()` / `setEnabled`) for driving a second physical display. +`detected()` reports a connected target even while its output is being created; +`available()` means it can accept a frame now. `push(imageData, w, h)` retains +the original contract. Its optional `background` (`0xRRGGBB`) and `preference` +arguments request an extended presentation; a preference ending in `:cover` +fills and crops the target, while other values preserve the whole frame. +`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame +coordinates, or `nil`. This is what lets a mod lay the two passes out as two stacked Game Boy screens, or push one onto a second screen, without the engine knowing the layout. On process-capable Windows, Linux and macOS hosts without a native display diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 429e0353..ad592aa8 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -1180,6 +1180,54 @@ void love_android_secondary_enable(int on) env->DeleteLocalRef(activity); } +extern "C" __attribute__((visibility("default"))) +int love_android_secondary_detected() +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, + "hasSecondaryDisplayCandidate", "()Z"); + jboolean detected = JNI_FALSE; + if (method) + detected = env->CallStaticBooleanMethod(activity, method); + else + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return detected ? 1 : 0; +} + +extern "C" __attribute__((visibility("default"))) +int love_android_present_secondary(const void *rgba, int width, int height, + unsigned int background, int cover) +{ + if (!rgba || width <= 0 || height <= 0) + return 0; + jlong size = (jlong) width * (jlong) height * 4; + if (size <= 0) + return 0; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame", + "(Ljava/nio/ByteBuffer;IIIZ)Z"); + if (!method) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return 0; + } + jobject frame = env->NewDirectByteBuffer((void *) rgba, size); + if (!frame) + { + env->DeleteLocalRef(activity); + return 0; + } + jboolean shown = env->CallStaticBooleanMethod(activity, method, frame, + width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE); + env->DeleteLocalRef(frame); + env->DeleteLocalRef(activity); + return shown ? 1 : 0; +} + extern "C" __attribute__((visibility("default"))) const char *love_android_poll_secondary_touch() { diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index cec24cde..796654dd 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1408,6 +1408,14 @@ public class GameActivity extends SDLActivity { // in src/jni/love/src/common/android.cpp. private static volatile SecondaryPresentation secondaryPresentation; private static volatile boolean secondaryEnabled = false; + private static volatile byte[] secondaryFrame; + private static volatile int secondaryFrameWidth; + private static volatile int secondaryFrameHeight; + private static volatile int secondaryBackground; + private static volatile boolean secondaryFrameCover; + private static final Object secondaryFrameLock = new Object(); + private static volatile long secondaryDetectionAt; + private static volatile boolean secondaryDetected; private SecondaryDisplayMonitor secondaryDisplayMonitor; private static final int MAX_SECONDARY_TOUCHES = 32; private static final java.util.ArrayDeque secondaryTouches = @@ -1426,6 +1434,7 @@ public class GameActivity extends SDLActivity { } else { self.unregisterSecondaryDisplayListener(); teardownSecondaryDisplay(); + synchronized (secondaryFrameLock) { secondaryFrame = null; } } } }); @@ -1462,24 +1471,7 @@ public class GameActivity extends SDLActivity { GameActivity self = (GameActivity) mSingleton; if (self == null || !secondaryEnabled || secondaryPresentation != null) return; try { - android.hardware.display.DisplayManager dm = - (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE); - if (dm == null) return; - Display chosen = null; - for (Display d : dm.getDisplays()) { - android.graphics.Point size = new android.graphics.Point(); - d.getRealSize(size); - Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName() - + " size=" + size.x + "x" + size.y); - if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) { - chosen = d; - } - } - if (chosen == null) { - Display[] pres = - dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); - if (pres != null && pres.length > 0) chosen = pres[0]; - } + Display chosen = findSecondaryDisplay(self, true); if (chosen == null) { Log.d("GameActivity", "no secondary display found"); return; @@ -1487,6 +1479,13 @@ public class GameActivity extends SDLActivity { SecondaryPresentation p = new SecondaryPresentation(self, chosen); p.show(); secondaryPresentation = p; + synchronized (secondaryFrameLock) { + if (secondaryFrame != null) { + p.setBackground(secondaryBackground); + p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame), + secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover); + } + } Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId()); } catch (Throwable t) { Log.d("GameActivity", "secondary display setup failed: " + t); @@ -1494,6 +1493,28 @@ public class GameActivity extends SDLActivity { } } + private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) { + android.hardware.display.DisplayManager dm = + (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE); + if (dm == null) return null; + Display chosen = null; + for (Display d : dm.getDisplays()) { + if (logDisplays) { + android.graphics.Point size = new android.graphics.Point(); + d.getRealSize(size); + Log.d("GameActivity", "display id=" + d.getDisplayId() + + " name=" + d.getName() + " size=" + size.x + "x" + size.y); + } + if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) chosen = d; + } + if (chosen == null) { + Display[] presentations = + dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + if (presentations != null && presentations.length > 0) chosen = presentations[0]; + } + return chosen; + } + private static void teardownSecondaryDisplay() { SecondaryPresentation p = secondaryPresentation; secondaryPresentation = null; @@ -1527,9 +1548,14 @@ public class GameActivity extends SDLActivity { return manager.getDisplay(displayId) != null; } - @Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); } - @Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); } - @Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); } + private void changed() { + secondaryDetectionAt = 0; + refreshSecondaryDisplay(); + } + + @Override public void onDisplayAdded(int displayId) { changed(); } + @Override public void onDisplayRemoved(int displayId) { changed(); } + @Override public void onDisplayChanged(int displayId) { changed(); } } @Keep @@ -1537,6 +1563,56 @@ public class GameActivity extends SDLActivity { return secondaryPresentation != null; } + @Keep + public static boolean hasSecondaryDisplayCandidate() { + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (secondaryPresentation != null) return true; + long now = android.os.SystemClock.uptimeMillis(); + if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) { + return secondaryDetected; + } + secondaryDetected = findSecondaryDisplay(self, false) != null; + secondaryDetectionAt = now; + return secondaryDetected; + } + + @Keep + public static boolean presentSecondaryFrame( + java.nio.ByteBuffer rgba, int width, int height, + int backgroundColor, boolean cover) { + long bytes = (long) width * height * 4; + if (rgba == null || width <= 0 || height <= 0 + || bytes <= 0 || bytes > Integer.MAX_VALUE + || rgba.capacity() < bytes) return false; + synchronized (secondaryFrameLock) { + if (secondaryFrame == null || secondaryFrame.length != (int) bytes) { + secondaryFrame = new byte[(int) bytes]; + } + rgba.rewind(); + rgba.get(secondaryFrame, 0, (int) bytes); + rgba.rewind(); + secondaryFrameWidth = width; + secondaryFrameHeight = height; + secondaryBackground = backgroundColor; + secondaryFrameCover = cover; + SecondaryPresentation p = secondaryPresentation; + if (p == null) return false; + try { + p.setBackground(backgroundColor); + p.updateFrame(rgba, width, height, cover); + return true; + } catch (Throwable t) { + GameActivity self = (GameActivity) mSingleton; + if (self != null) self.runOnUiThread(() -> { + teardownSecondaryDisplay(); + setupSecondaryDisplay(); + }); + return false; + } + } + } + @Keep public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) { SecondaryPresentation p = secondaryPresentation; @@ -1610,6 +1686,14 @@ public class GameActivity extends SDLActivity { void updateFrame(java.nio.ByteBuffer buf, int w, int h) { frameView.updateFrame(buf, w, h); } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) { + frameView.updateFrame(buf, w, h, cover); + } + + void setBackground(int color) { + frameView.setFrameBackground(color); + } } private static class FrameView extends View { @@ -1618,7 +1702,9 @@ public class GameActivity extends SDLActivity { private final android.graphics.Paint paint = new android.graphics.Paint(); private final Object lock = new Object(); private int fw, fh; + private int backgroundColor = 0xFF000000; private int activePointer = -1; + private boolean cover; FrameView(Context context) { super(context); @@ -1628,7 +1714,12 @@ public class GameActivity extends SDLActivity { } void updateFrame(java.nio.ByteBuffer buf, int w, int h) { + updateFrame(buf, w, h, false); + } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) { synchronized (lock) { + this.cover = cover; if (bitmap == null || fw != w || fh != h) { if (bitmap != null) bitmap.recycle(); bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888); @@ -1640,6 +1731,13 @@ public class GameActivity extends SDLActivity { postInvalidate(); } + void setFrameBackground(int color) { + synchronized (lock) { + backgroundColor = 0xFF000000 | (color & 0x00FFFFFF); + } + postInvalidate(); + } + private void enqueueTouch(String event) { synchronized (secondaryTouches) { if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) { @@ -1691,12 +1789,15 @@ public class GameActivity extends SDLActivity { synchronized (lock) { if (bitmap == null || fw == 0 || fh == 0) return; int vw = getWidth(), vh = getHeight(); - int s = Math.min(vw / fw, vh / fh); - if (s < 1) s = 1; - int dw = fw * s, dh = fh * s; + float fit = Math.min((float) vw / fw, (float) vh / fh); + if (fit <= 0) return; + float scale = cover + ? Math.max((float) vw / fw, (float) vh / fh) + : fit >= 2f ? (float) Math.floor(fit) : fit; + int dw = Math.round(fw * scale), dh = Math.round(fh * scale); int dx = (vw - dw) / 2, dy = (vh - dh) / 2; dst.set(dx, dy, dx + dw, dy + dh); - canvas.drawColor(0xFF000000); + canvas.drawColor(backgroundColor); canvas.drawBitmap(bitmap, null, dst, paint); } } diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index d3b03796..3a3a958f 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -6,6 +6,7 @@ local SecondScreen = {} local C = nil local ffi = nil local desktop = nil +local nativePresent = false local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) @@ -21,6 +22,9 @@ do int love_android_secondary_ready(); void love_android_push_secondary(const void *rgba, int w, int h); void love_android_secondary_enable(int on); + int love_android_secondary_detected(); + int love_android_present_secondary(const void *rgba, int w, int h, + unsigned int background, int cover); const char *love_android_poll_secondary_touch(); ]]) local okLib, lib = pcall(ffi.load, "love") @@ -34,6 +38,16 @@ do log(("bridge symbols not found (ffi.load ok=%s); second display disabled") :format(tostring(okLib))) end + if C then + local okDetected, detected = pcall(function() + return C.love_android_secondary_detected + end) + local okPresent, present = pcall(function() + return C.love_android_present_secondary + end) + nativePresent = okDetected and detected ~= nil + and okPresent and present ~= nil + end end end @@ -60,6 +74,10 @@ end -- distinction lets a companion retry its first frame after hotplug/re-target. function SecondScreen.detected() if desktop then return desktop.detected() end + if nativePresent then + local ok, r = pcall(C.love_android_secondary_detected) + return ok and r ~= 0 + end return SecondScreen.available() end @@ -68,6 +86,13 @@ function SecondScreen.push(imageData, w, h, background, preference) return desktop.push(imageData, w, h, background, preference) end if not C or not imageData then return false end + if nativePresent and (background ~= nil or preference ~= nil) then + local cover = type(preference) == "string" + and preference:sub(-6) == ":cover" + local ok, shown = pcall(C.love_android_present_secondary, + imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0) + return ok and shown ~= 0 + end return pcall(function() C.love_android_push_secondary(imageData:getFFIPointer(), w, h) end) diff --git a/tests/engine/android_secondary_present.lua b/tests/engine/android_secondary_present.lua new file mode 100644 index 00000000..ec6ffc5f --- /dev/null +++ b/tests/engine/android_secondary_present.lua @@ -0,0 +1,36 @@ +local function read(path) + local file = assert(io.open(path, "rb")) + local source = file:read("*a") + file:close() + return source +end + +local function check(value, message) + if not value then error(message, 2) end +end + +local java = read( + "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java") +local cpp = read("mobile/android/love/src/jni/love/src/common/android.cpp") + +check(java:find("hasSecondaryDisplayCandidate", 1, true) + and java:find("findSecondaryDisplay(self, false)", 1, true) + and java:find("now %- secondaryDetectionAt < 500"), + "Android exposes cached physical detection before Presentation is ready") +check(java:find("presentSecondaryFrame", 1, true) + and java:find("secondaryFrame = new byte", 1, true) + and java:find("rgba.get(secondaryFrame", 1, true), + "extended presentation reuses a retained frame buffer") +check(java:find("java.nio.ByteBuffer.wrap(secondaryFrame)", 1, true), + "a recreated Presentation receives the retained frame") +check(java:find("0xFF000000 | (color & 0x00FFFFFF)", 1, true), + "RGB companion backgrounds become opaque Android colors") +check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true) + and java:find("Math.floor(fit)", 1, true), + "FrameView supports cover and pixel-friendly contain fits") +check(cpp:find("love_android_secondary_detected", 1, true) + and cpp:find("love_android_present_secondary", 1, true) + and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true), + "JNI exports the optional detected and presentation calls") + +print("android secondary presentation: ok") diff --git a/tests/engine/second_screen_present.lua b/tests/engine/second_screen_present.lua new file mode 100644 index 00000000..7f4643b6 --- /dev/null +++ b/tests/engine/second_screen_present.lua @@ -0,0 +1,64 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local name = "src.render.SecondScreen" +local oldModule = package.loaded[name] +local oldFfi = package.loaded.ffi +local oldPreload = package.preload.ffi +local calls = {} +local null = {} + +local C = { + love_android_secondary_ready = function() return 0 end, + love_android_push_secondary = function(ptr, w, h) + calls.push = { ptr, w, h } + end, + love_android_secondary_enable = function(on) calls.enabled = on end, + love_android_secondary_detected = function() return 1 end, + love_android_present_secondary = function(ptr, w, h, background, cover) + calls.present = { ptr, w, h, background, cover } + return 1 + end, + love_android_poll_secondary_touch = function() return null end, +} +local fakeFfi = { + C = C, + NULL = null, + cdef = function() end, + load = function() return C end, + string = function(value) return value end, +} + +package.loaded[name] = nil +package.loaded.ffi = nil +package.preload.ffi = function() return fakeFfi end + +local SecondScreen = require(name) +local image = { getFFIPointer = function() return "pixels" end } + +T.eq(SecondScreen.available(), false, + "an unbound presentation is not render-ready") +T.eq(SecondScreen.detected(), true, + "physical display detection is independent of presentation readiness") +T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary:cover"), true, + "extended Android presentation accepts frame metadata") +T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 }, + "cover and RGB background reach the native bridge") +T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary"), true, + "contain presentation remains available") +T.same(calls.present, { "pixels", 160, 144, 0x112233, 0 }, + "contain is the default native fit") +T.eq(SecondScreen.push(image, 160, 144, nil, "secondary:cover"), true, + "a fit preference can request extended presentation by itself") +T.same(calls.present, { "pixels", 160, 144, 0, 1 }, + "preference-only presentation defaults to a black background") +T.eq(SecondScreen.push(image, 160, 144), true, + "the original push ABI remains available") +T.same(calls.push, { "pixels", 160, 144 }, + "legacy callers retain the original frame path") + +package.loaded[name] = oldModule +package.loaded.ffi = oldFfi +package.preload.ffi = oldPreload + +T.finish("Android secondary presentation facade")