Merge pull request #1448 from AverageConsumer/codex/android-companion-contract

This commit is contained in:
bryanthaboi
2026-08-16 20:33:22 -04:00
committed by GitHub
6 changed files with 308 additions and 28 deletions
+9 -3
View File
@@ -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);
}
}
+25
View File
@@ -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")
+64
View File
@@ -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")