From 218b9249a2858d5bd0c324e1f97a6ec5ade2d56b Mon Sep 17 00:00:00 2001 From: Bart in 't Veld Date: Sat, 1 Aug 2026 09:35:24 +0200 Subject: [PATCH] render: add render.compose seam + second-screen bridge Expose a generic seam so a mod can drive a second screen without the engine owning any dual-screen layout policy: - render.compose hook in Renderer:endFrame hands a mod the finished world + UI canvases, their SGB zones, the frame metrics, Renderer:blitCanvas (lifted from the internal blit closure) and the SecondScreen bridge. Return true to take over the window; no wrap (or calling next) runs the normal single-window composite byte-for-byte. - SecondScreen.lua + the Android Presentation bridge (love_android_ secondary_* in common/android.cpp, GameActivity secondary display) as the optional physical-second-display transport. No battle-render changes: a mod lays out the two screens (including any battle split) itself. Ships with a unit test, no-mod parity via gate_hooks, and docs/modding.md (D14). --- docs/modding.md | 13 ++ .../love/src/jni/love/src/common/android.cpp | 47 +++++ .../java/org/love2d/android/GameActivity.java | 180 ++++++++++++++++++ src/render/Renderer.lua | 99 +++++++--- src/render/SecondScreen.lua | 59 ++++++ tests/engine/render_compose_seam.lua | 62 ++++++ 6 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 src/render/SecondScreen.lua create mode 100644 tests/engine/render_compose_seam.lua diff --git a/docs/modding.md b/docs/modding.md index 5517a3a8..5aa0bec0 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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. 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 7f0cfdca..2facbdc4 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -851,4 +851,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 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 f8374566..b20b2380 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 @@ -296,12 +296,14 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } + teardownSecondaryDisplay(); super.onPause(); } @Override public void onResume() { super.onResume(); + setupSecondaryDisplay(); } /** @@ -921,4 +923,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); + } + } + } } diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 5c4ee919..a6dfb8dc 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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 diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua new file mode 100644 index 00000000..c8a6efc3 --- /dev/null +++ b/src/render/SecondScreen.lua @@ -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 diff --git a/tests/engine/render_compose_seam.lua b/tests/engine/render_compose_seam.lua new file mode 100644 index 00000000..cfb34caf --- /dev/null +++ b/tests/engine/render_compose_seam.lua @@ -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")