Merge pull request #543 from BartInTheField/render-second-screen-seam

This commit is contained in:
bryanthaboi
2026-08-01 06:56:38 -04:00
committed by GitHub
6 changed files with 430 additions and 30 deletions
+13
View File
@@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's
normal single-window composite so the mod can decorate around it. `ctx` carries
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)` / `setEnabled`)
for driving a second physical display. 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.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
@@ -863,4 +863,51 @@ const char *getArg0()
} // android
} // love
extern "C" __attribute__((visibility("default")))
int love_android_secondary_ready()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
jboolean ready = JNI_FALSE;
if (m)
ready = env->CallStaticBooleanMethod(activity, m);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return ready ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
void love_android_push_secondary(const void *rgba, int w, int h)
{
if (!rgba || w <= 0 || h <= 0)
return;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
if (m)
{
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
env->CallStaticVoidMethod(activity, m, buf, w, h);
env->DeleteLocalRef(buf);
}
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
void love_android_secondary_enable(int on)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
if (m)
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
#endif // LOVE_ANDROID
@@ -331,12 +331,14 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
teardownSecondaryDisplay();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
setupSecondaryDisplay();
}
/**
@@ -1128,4 +1130,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
@Keep
public static void setSecondaryEnabled(final boolean on) {
secondaryEnabled = on;
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
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];
}
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.show();
secondaryPresentation = p;
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
}
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
}
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) {
p.updateFrame(buf, w, h);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
SecondaryPresentation(Context context, Display display) {
super(context, display);
frameView = new FrameView(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.view.Window w = getWindow();
if (w != null) {
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
}
setContentView(frameView);
applyImmersive();
frameView.post(new Runnable() {
@Override public void run() { applyImmersive(); }
});
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applyImmersive();
}
private void applyImmersive() {
android.view.Window w = getWindow();
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
}
private static class FrameView extends View {
private android.graphics.Bitmap bitmap;
private final android.graphics.Rect dst = new android.graphics.Rect();
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
FrameView(Context context) {
super(context);
paint.setFilterBitmap(false);
paint.setAntiAlias(false);
setBackgroundColor(0xFF000000);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
synchronized (lock) {
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);
fw = w; fh = h;
}
buf.rewind();
bitmap.copyPixelsFromBuffer(buf);
}
postInvalidate();
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
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;
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
}
}
+69 -30
View File
@@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass)
return merged
end
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
-- render.compose mod can composite the world/UI canvases into its own layout.
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
end
-- zones: optional list of SGB palette regions (see PaletteFX) in
-- 160x144 UI space, applied to the UI pass. worldZones: optional
-- regions in world-canvas pixels (overworld survey zoom colors each
@@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
-- render.compose: hand a mod the finished world + UI canvases (and their
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
-- stacked Game Boy screens, or driving one onto a second physical display.
-- The mod returns true to take over the whole window; anything else (or no
-- mod wrapping the hook) falls through to the normal composite below.
if Runtime.wantsHook("render.compose") then
local ctx = {
renderer = self,
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
worldOverride = self.worldOverride,
worldActive = self.worldActive and true or false,
zones = zones, worldZones = worldZones,
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
secondScreen = require("src.render.SecondScreen"),
}
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh, gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
}
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
@@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
})
end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
-- call sites below stay unchanged.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end
if self.worldOverride then
+59
View File
@@ -0,0 +1,59 @@
-- Bridge to native secondary-display output (Android Presentation). The C
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
-- this stays inert and the renderer keeps the in-window stacked layout.
local SecondScreen = {}
local C = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end
do
local ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled")
else
pcall(ffi.cdef, [[
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
]])
local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
C = lib
log("bridge linked via ffi.load('love')")
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
C = ffi.C
log("bridge linked via default namespace")
else
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
end
end
function SecondScreen.usable()
return C ~= nil
end
function SecondScreen.available()
if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0
end
function SecondScreen.push(imageData, w, h)
if not C or not imageData then return false end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
end
function SecondScreen.setEnabled(on)
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
return SecondScreen
+62
View File
@@ -0,0 +1,62 @@
-- Unit coverage for the render.compose seam (D14: a public-API test names
-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents
-- it). render.compose lets a mod take over window composition: a wrap that
-- returns true without calling next owns the whole window; a wrap that calls
-- next lets the engine's normal single-window composite run and decorates it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local bus = Hooks.new()
Runtime.hooks = bus
-- the shape Renderer:endFrame hands the hook
local function fakeCtx()
return {
renderer = {}, worldCanvas = {}, uiCanvas = {},
worldActive = true, zones = {}, worldZones = nil,
ww = 480, wh = 432, ox = 0, oy = 12, scale = 3,
dpiX = 1, dpiY = 1, secondScreen = {},
}
end
-- takeover: a mod drawing its own layout returns true and never calls next,
-- so the engine's vanilla composite is skipped entirely
do
local vanillaRan, gotCtx = false, nil
bus:wrap("render.compose", function(next, renderer, ctx)
gotCtx = ctx
return true
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(handled, true, "a mod returning true signals full window takeover")
T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)")
T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil,
"the hook receives the frame ctx (metrics, canvases, secondScreen)")
bus.chains["render.compose"] = nil
end
-- decorate: a mod calling next lets the engine composite run, and the
-- engine's not-handled return (false) flows back through the chain
do
local vanillaRan = false
bus:wrap("render.compose", function(next, renderer, ctx)
return next()
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(vanillaRan, true, "calling next runs the engine composite")
T.eq(handled, false, "the engine's not-handled return flows back through next")
bus.chains["render.compose"] = nil
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
T.finish("render_compose_seam")