mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
Merge pull request #543 from BartInTheField/render-second-screen-seam
This commit is contained in:
@@ -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
|
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||||
playfield or pushing an updating game state.
|
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
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|||||||
@@ -863,4 +863,51 @@ const char *getArg0()
|
|||||||
} // android
|
} // android
|
||||||
} // love
|
} // 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
|
#endif // LOVE_ANDROID
|
||||||
|
|||||||
@@ -331,12 +331,14 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
|
teardownSecondaryDisplay();
|
||||||
super.onPause();
|
super.onPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
setupSecondaryDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1128,4 +1130,182 @@ public class GameActivity extends SDLActivity {
|
|||||||
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
|
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
@@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass)
|
|||||||
return merged
|
return merged
|
||||||
end
|
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
|
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||||
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
||||||
-- regions in world-canvas pixels (overworld survey zoom colors each
|
-- regions in world-canvas pixels (overworld survey zoom colors each
|
||||||
@@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
zones = withTrueColor(zones, "ui")
|
zones = withTrueColor(zones, "ui")
|
||||||
worldZones = withTrueColor(worldZones, "world")
|
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
|
-- 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
|
-- 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
|
-- present path; with neither, the frame draws straight to the screen
|
||||||
@@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
|
||||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
-- call sites below stay unchanged.
|
||||||
-- convert zone coords (canvas-space) into screen units.
|
|
||||||
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||||
bx, by, boxX, boxY, boxW, boxH)
|
bx, by, boxX, boxY, boxW, boxH)
|
||||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||||
if not shader then
|
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
||||||
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
|
end
|
||||||
|
|
||||||
if self.worldOverride then
|
if self.worldOverride then
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user