mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Merge pull request #1448 from AverageConsumer/codex/android-companion-contract
This commit is contained in:
+9
-3
@@ -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
|
||||
|
||||
@@ -1229,6 +1229,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()
|
||||
{
|
||||
|
||||
@@ -1478,6 +1478,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<String> secondaryTouches =
|
||||
@@ -1496,6 +1504,7 @@ public class GameActivity extends SDLActivity {
|
||||
} else {
|
||||
self.unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1532,24 +1541,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;
|
||||
@@ -1557,6 +1549,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);
|
||||
@@ -1564,6 +1563,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;
|
||||
@@ -1597,9 +1618,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
|
||||
@@ -1607,6 +1633,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;
|
||||
@@ -1680,6 +1756,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 {
|
||||
@@ -1688,7 +1772,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);
|
||||
@@ -1698,7 +1784,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);
|
||||
@@ -1710,6 +1801,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) {
|
||||
@@ -1761,12 +1859,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user