diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index e70a7560..ec4666e3 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -94,9 +94,10 @@ public class SecondDisplayHost extends ContentProvider { private final Handler ui = new Handler(Looper.getMainLooper()); private volatile boolean running = false; private Thread pump; - private PanelPresentation presentation; + private volatile Activity activeActivity; + private volatile PanelPresentation presentation; private File[] roots = new File[0]; - private File frameFile, touchFile; + private volatile File frameFile, touchFile; private volatile int lastSeq = -1; private volatile int frameW = 0, frameH = 0; @@ -115,10 +116,8 @@ public boolean onCreate() { Log.w(TAG, "no save directory found; second display inactive"); return true; } - // The frame and the taps live beside each other in whichever root Lua - // actually chose; that is the one where frame.bin turns up. - frameFile = new File(roots[0], FRAME_FILE); - touchFile = new File(roots[0], TOUCH_FILE); + Log.i(TAG, "SecondDisplayHost created"); + for (File root : roots) Log.i(TAG, "save root: " + root.getAbsolutePath()); if (appContext instanceof Application) { ((Application) appContext).registerActivityLifecycleCallbacks( @@ -171,12 +170,21 @@ private final class Lifecycle implements Application.ActivityLifecycleCallbacks private Display secondaryDisplay() { DisplayManager dm = (DisplayManager) appContext.getSystemService(Context.DISPLAY_SERVICE); - if (dm == null) return null; + if (dm == null) { + Log.w(TAG, "DisplayManager unavailable"); + return null; + } + Display[] all = dm.getDisplays(); + if (all != null) { + for (Display d : all) { + Log.d(TAG, "display id=" + d.getDisplayId() + + " name=" + d.getName() + + " flags=" + d.getFlags() + + " state=" + d.getState()); + } + } Display[] ds = dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); if (ds != null && ds.length > 0) return ds[0]; - // Some devices do not tag their built-in second panel as a presentation - // display. Anything that is not the default one still counts. - Display[] all = dm.getDisplays(); if (all != null) { for (Display d : all) { if (d.getDisplayId() != Display.DEFAULT_DISPLAY) return d; @@ -186,34 +194,51 @@ private Display secondaryDisplay() { } private void attach(Activity activity) { - if (running) return; + activeActivity = activity; + if (!running) startPump(); + ensurePresentation(); + } + + private void ensurePresentation() { + if (Looper.myLooper() != Looper.getMainLooper()) { + ui.post(new Runnable() { + @Override public void run() { ensurePresentation(); } + }); + return; + } + if (!running || presentation != null) return; + Activity activity = activeActivity; + if (activity == null || activity.isFinishing()) return; + Display d = secondaryDisplay(); if (d == null) { - // ONE display: say so, every second, so Lua keeps the option hidden - // rather than offering a mode that would blank the bottom of the phone. writeHost(1, 0, 0); - startPump(null); return; } + android.graphics.Point size = new android.graphics.Point(); d.getSize(size); - presentation = new PanelPresentation(activity, d); + Log.i(TAG, "using secondary display id=" + d.getDisplayId() + + " name=" + d.getName() + " size=" + size.x + "x" + size.y); + PanelPresentation p = new PanelPresentation(activity, d); try { - presentation.show(); + p.show(); + presentation = p; + lastSeq = -1; + writeHost(2, size.x, size.y); + Log.i(TAG, "Presentation.show succeeded"); + Log.i(TAG, "second display attached: " + size.x + "x" + size.y); } catch (Throwable t) { - Log.w(TAG, "presentation refused: " + t); + Log.e(TAG, "presentation refused", t); + try { p.dismiss(); } catch (Throwable ignored) { } presentation = null; writeHost(1, 0, 0); - startPump(null); - return; } - writeHost(2, size.x, size.y); - startPump(presentation); - Log.i(TAG, "second display attached: " + size.x + "x" + size.y); } private void detach() { running = false; + activeActivity = null; if (pump != null) { pump.interrupt(); pump = null; } final PanelPresentation p = presentation; presentation = null; @@ -241,21 +266,31 @@ private void writeHost(int displays, int w, int h) { } } - private void startPump(final PanelPresentation panel) { + private void startPump() { running = true; - final int displays = panel == null ? 1 : 2; pump = new Thread(new Runnable() { @Override public void run() { long lastHost = 0; + long lastDiscovery = 0; while (running) { long now = android.os.SystemClock.uptimeMillis(); + PanelPresentation panel = presentation; + + if (panel == null && now - lastDiscovery >= HOST_INTERVAL_MS) { + lastDiscovery = now; + ui.post(new Runnable() { + @Override public void run() { ensurePresentation(); } + }); + } + if (now - lastHost >= HOST_INTERVAL_MS) { lastHost = now; - // Re-stated every second: its being FRESH is the heartbeat, and a - // Lua that keeps reading a stale file must be able to notice. + panel = presentation; if (panel == null) writeHost(1, 0, 0); else writeHost(2, panel.panelWidth(), panel.panelHeight()); } + + panel = presentation; if (panel != null) readFrameOnce(panel); try { Thread.sleep(FRAME_POLL_MS); } catch (InterruptedException e) { return; } @@ -266,7 +301,7 @@ private void startPump(final PanelPresentation panel) { pump.start(); } - /** The twelve bytes, or null if they are not a frame this end can draw. + /** The twelve bytes /** The twelve bytes, or null if they are not a frame this end can draw. * Extracted so tools/gen4_second_display_protocol_check.lua can feed it a * header the LUA end actually wrote, rather than one a test made up to * match. The two ends never meet anywhere else. */ @@ -295,31 +330,115 @@ public static int[] mapPoint(float px, float py, int dl, int dt, int dw, int dh, return new int[] { x, y }; } + private File findFrameFile() { + File newest = null; + for (File root : roots) { + File f = new File(root, FRAME_FILE); + if (!f.isFile()) continue; + if (newest == null || f.lastModified() > newest.lastModified()) newest = f; + } + if (newest != null && (frameFile == null || !newest.equals(frameFile))) { + frameFile = newest; + touchFile = new File(newest.getParentFile(), TOUCH_FILE); + Log.i(TAG, "active frame root: " + newest.getParentFile().getAbsolutePath()); + } + return newest; + } + + private long lastRejectLog = 0; + + private int lastVisibleReject = -1; + private boolean haveAcceptedFrame = false; + + private void rejectLog(final PanelPresentation panel, final int code, String reason) { + long now = android.os.SystemClock.uptimeMillis(); + if (now - lastRejectLog >= 1000) { + lastRejectLog = now; + Log.w(TAG, "FRAME REJECT: " + reason); + } + if (!haveAcceptedFrame && lastVisibleReject != code && panel != null && panel.view != null) { + lastVisibleReject = code; + final PanelView v = panel.view; + ui.post(new Runnable() { + @Override public void run() { v.showTransportStatus(code); } + }); + } + } + private void readFrameOnce(PanelPresentation panel) { - File f = frameFile; - if (f == null || !f.isFile()) return; + File f = findFrameFile(); + if (f == null || !f.isFile()) { + rejectLog(panel, 1, "no frame.bin found in save roots"); + return; + } long len = f.length(); - if (len < HEADER_BYTES) return; + if (len < HEADER_BYTES) { + rejectLog(panel, 2, "short file len=" + len); + return; + } RandomAccessFile raf = null; try { raf = new RandomAccessFile(f, "r"); byte[] head = new byte[HEADER_BYTES]; raf.readFully(head); int[] hdr = decodeHeader(head); - if (hdr == null) return; + if (hdr == null) { + rejectLog(panel, 3, "invalid G2SD header len=" + len); + return; + } int w = hdr[1], h = hdr[2], seq = hdr[3]; if (seq == lastSeq) return; long want = (long) HEADER_BYTES + (long) w * h * 4L; - // A TORN FRAME IS SKIPPED, NOT DRAWN. There is no rename on LOVE's - // filesystem, so a read can land mid-write; a short file is exactly - // what that looks like, and the next sequence will be whole. - if (len < want) return; + boolean legacyTrailer = len == want + 6L; + if (len != want && !legacyTrailer) { + // love.filesystem.write truncates/replaces frame.bin while this pump is + // polling it. A temporary length mismatch is therefore expected and + // must never replace the last good frame with an error screen. Leave + // lastSeq untouched and retry on the next pump. + long now = android.os.SystemClock.uptimeMillis(); + if (now - lastRejectLog >= 1000) { + lastRejectLog = now; + Log.w(TAG, "FRAME RETRY: transient size mismatch seq=" + seq + + " got=" + len + " want=" + want + " dimensions=" + w + "x" + h); + } + return; + } + byte[] rgba = new byte[w * h * 4]; raf.readFully(rgba); + + // One diagnostic build appended "G2OK" + sequence. Accept those files + // too so an old frame left in the save directory cannot permanently + // block a newer APK from reaching the renderer. + if (legacyTrailer) { + byte[] tail = new byte[6]; + raf.readFully(tail); + int tailSeq = (tail[4] & 0xff) | ((tail[5] & 0xff) << 8); + if (tail[0] != 'G' || tail[1] != '2' || tail[2] != 'O' || tail[3] != 'K' + || tailSeq != seq) { + rejectLog(panel, 2, "invalid legacy trailer seq=" + seq); + return; + } + } + + raf.seek(0); + byte[] verifyHead = new byte[HEADER_BYTES]; + raf.readFully(verifyHead); + int[] verify = decodeHeader(verifyHead); + if (verify == null || verify[3] != seq || verify[1] != w || verify[2] != h) { + rejectLog(panel, 4, "header changed during read seq=" + seq); + return; + } + lastSeq = seq; frameW = w; frameH = h; + lastVisibleReject = 5; + haveAcceptedFrame = true; panel.post(w, h, rgba); - } catch (Throwable ignored) { + Log.i(TAG, "FRAME ACCEPT seq=" + seq + " size=" + w + "x" + h + + " bytes=" + len + " path=" + f.getAbsolutePath()); + } catch (Throwable t) { + Log.e(TAG, "readFrameOnce failed", t); } finally { if (raf != null) try { raf.close(); } catch (Throwable ignored) { } } @@ -352,6 +471,7 @@ private final class PanelPresentation extends Presentation { super.onCreate(state); view = new PanelView(getContext()); setContentView(view); + view.showDiagnosticPattern(); } int panelWidth() { return view == null ? 0 : Math.max(view.getWidth(), 1); } @@ -377,15 +497,108 @@ private final class PanelView extends View { setBackgroundColor(Color.BLACK); } + void showSizeMismatch(final long actual, final long expected, + final int frameWidth, final int frameHeight, final int seq) { + final int w = 512, h = 384; + Bitmap status = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + Canvas cc = new Canvas(status); + cc.drawColor(0xffffff00); + Paint tp = new Paint(Paint.ANTI_ALIAS_FLAG); + tp.setColor(0xff000000); + tp.setTextSize(38f); + tp.setTypeface(android.graphics.Typeface.MONOSPACE); + cc.drawText("FRAME SIZE MISMATCH", 22, 55, tp); + tp.setTextSize(32f); + cc.drawText("FILE = " + actual, 22, 115, tp); + cc.drawText("EXPECTED = " + expected, 22, 160, tp); + cc.drawText("DIFF = " + (actual - expected), 22, 205, tp); + cc.drawText("W x H = " + frameWidth + " x " + frameHeight, 22, 250, tp); + cc.drawText("SEQ = " + seq, 22, 295, tp); + tp.setTextSize(22f); + cc.drawText("Send a photo of these numbers", 22, 350, tp); + if (bitmap != null) bitmap.recycle(); + bitmap = status; + srcW = w; srcH = h; + invalidate(); + } + + void showTransportStatus(final int code) { + // Visible debugger for devices where logcat is unavailable: + // 1=red no frame, 2=yellow short/size, 3=magenta bad header, + // 4=cyan frame changed during read, 5=green valid frame accepted. + final int w = 256, h = 192; + int color; + switch (code) { + case 1: color = 0xffff0000; break; + case 2: color = 0xffffff00; break; + case 3: color = 0xffff00ff; break; + case 4: color = 0xff00ffff; break; + case 5: color = 0xff00ff00; break; + default: color = 0xff202020; break; + } + int[] pixels = new int[w * h]; + java.util.Arrays.fill(pixels, color); + // Black border plus code bars: count the vertical white bars if color + // reproduction itself is questionable. + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (x < 4 || x >= w - 4 || y < 4 || y >= h - 4) + pixels[y * w + x] = 0xff000000; + } + } + for (int n = 0; n < code; n++) { + int x0 = 18 + n * 28; + for (int y = 70; y < 122; y++) + for (int x = x0; x < x0 + 12; x++) + pixels[y * w + x] = 0xffffffff; + } + if (bitmap != null) bitmap.recycle(); + bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, w, 0, 0, w, h); + srcW = w; srcH = h; + invalidate(); + } + + void showDiagnosticPattern() { + final int w = 256, h = 192; + int[] pixels = new int[w * h]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int color; + if (y < h / 2) color = x < w / 2 ? 0xffff0000 : 0xff00ff00; + else color = x < w / 2 ? 0xff0000ff : 0xffffffff; + if (x == 0 || x == w - 1 || y == 0 || y == h - 1 + || x == w / 2 || y == h / 2) color = 0xff000000; + pixels[y * w + x] = color; + } + } + if (bitmap != null) bitmap.recycle(); + bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, w, 0, 0, w, h); + srcW = w; srcH = h; + invalidate(); + Log.i(TAG, "DIAG: Java color-quadrant pattern posted"); + } + void accept(int w, int h, byte[] rgba) { if (bitmap == null || srcW != w || srcH != h) { if (bitmap != null) bitmap.recycle(); bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); srcW = w; srcH = h; } - // ARGB_8888 is RGBA in memory order, which is what LOVE's ImageData - // hands over, so the bytes go straight in with no per-pixel work. - bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(rgba)); + + // LOVE's ImageData string is packed RGBA. Convert explicitly instead of + // relying on Bitmap's native raw-buffer byte order. + int count = w * h; + int[] pixels = new int[count]; + for (int p = 0, off = 0; p < count; p++, off += 4) { + int r = rgba[off] & 0xff; + int g = rgba[off + 1] & 0xff; + int bl = rgba[off + 2] & 0xff; + int alpha = rgba[off + 3] & 0xff; + pixels[p] = (alpha << 24) | (r << 16) | (g << 8) | bl; + } + bitmap.setPixels(pixels, 0, w, 0, 0, w, h); invalidate(); } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e14b25bd..ac025d95 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -165,7 +165,7 @@ function BattleState:isWideBattleLayout() -- Emerald's own healthboxes on the surface's own edges and Emerald's own -- bottom strip spanning it. This function is the Game Boy layout's -- question and keeps the Game Boy layout's answer. - if GameVersion.isGen3() then return false end + if GameVersion.isGen3() or GameVersion.isGen4() then return false end local options = self.game and self.game.save and self.game.save.options return options and options.battleLayout == "wide" or false end @@ -242,7 +242,35 @@ function BattleState:gen3SurfaceWidth() return self._gen3SurfaceW end --- A WIDENED EMERALD BATTLE OWNS THE SURFACE UNTIL IT LEAVES THE STACK. +-- BATTLE LAYOUT = WIDE, IN SINNOH'S OWN TERMS. Platinum keeps its DS battle +-- compositor; only its surface grows horizontally. +function BattleState:gen4WideLayout() + if not self:gen4Layout() then return false end + local options = self.game and self.game.save and self.game.save.options + return options and options.battleLayout == "wide" or false +end + +function BattleState:gen4SurfaceWidth() + if not self:gen4WideLayout() then return Gen4Battle.WIDTH end + local Renderer = require("src.render.Renderer") + local pw, ph = 0, 0 + if Renderer.pixelSize then + local ok, a, b = pcall(Renderer.pixelSize, Renderer) + if ok and type(a) == "number" and type(b) == "number" then pw, ph = a, b end + end + local fill = self:wantsFillScale() + local key = pw .. "x" .. ph .. (fill and "/fill" or "/fixed") + if self._gen4SurfaceKey ~= key then + self._gen4SurfaceKey = key + self._gen4SurfaceW = Gen4Battle.surfaceWidth(pw, ph, fill, + math.min(Renderer.MAX_UI_WIDTH or 512, 512)) + end + return self._gen4SurfaceW +end + +-- A WIDENED EMERALD OR SINNOH BATTLE OWNS THE SURFACE UNTIL IT LEAVES +-- the stack, so menus pushed over it cannot collapse the framebuffer. + -- -- Same rule, and the same reason, as the Game Boy wide layout's -- (Game.wideBattleInStack): the party menu, the bag and the dialogue boxes a @@ -253,6 +281,9 @@ end -- redrawing its wider composition into a surface 240 wide, clipped at the -- right edge, and snapping back out again when the menu closed. function BattleState:holdsUISurface() + if self:gen4Layout() then + return self:gen4SurfaceWidth() > Gen4Battle.WIDTH + end return self:gen3SurfaceWidth() > Gen3Battle.WIDTH end @@ -471,7 +502,7 @@ function BattleState:uiSize() -- 512x256 with the visible half 256x192, and the platform and battler -- positions are absolute pixels on that. if self:gen4Layout() then - return Gen4Battle.WIDTH, Gen4Battle.HEIGHT + return self:gen4SurfaceWidth(), Gen4Battle.HEIGHT end return 160, 144 end @@ -6614,7 +6645,8 @@ function BattleState:gen4SendOut(battler) -- Already throwing something: a second ball in the air is worse than none. if self.gen4Ball then return false end - local pos = Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[0] + local pos = Gen4Battle.battlerPos and Gen4Battle.battlerPos(self, 0) + or (Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[0]) if not pos then return false end -- WHICH BALL THIS POKEMON LIVES IN. `MON_DATA_POKEBALL` is an item id and @@ -9139,7 +9171,8 @@ function BattleState:gen4BallChain(caught, shakes, ball) -- WHERE IT IS THROWN TO: the foe's own slot, from the cartridge's position -- table, rather than a number typed here. - local pos = Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[1] + local pos = Gen4Battle.battlerPos and Gen4Battle.battlerPos(self, 1) + or (Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[1]) local anim = Gen4BallAnim.new({ ball = name, to = pos and { x = pos.x, y = pos.y } or nil, diff --git a/src/battle/Gen4Battle.lua b/src/battle/Gen4Battle.lua index 6dbaf25a..ff9efd21 100644 --- a/src/battle/Gen4Battle.lua +++ b/src/battle/Gen4Battle.lua @@ -47,6 +47,56 @@ local Gen4Battle = {} -- Platinum fights on the DS's own screen, not the Game Boy's 160x144. Gen4Battle.WIDTH, Gen4Battle.HEIGHT = 256, 192 +-- Widescreen keeps Platinum's 192-pixel-tall composition and only grows the +-- horizontal surface. The extractor's battle backdrop is already 512 pixels +-- wide, so this reveals real cartridge art instead of stretching 256 pixels. +function Gen4Battle.surfaceWidth(pw, ph, fill, maxWidth) + local least = Gen4Battle.WIDTH + local most = math.floor(tonumber(maxWidth) or 512) + if most < least then most = least end + pw, ph = tonumber(pw), tonumber(ph) + if not (pw and ph and pw > 0 and ph > 0) then return least end + local want + if fill then + want = math.floor(Gen4Battle.HEIGHT * pw / ph / 8 + 0.5) * 8 + else + local s = math.max(1, math.floor(ph / Gen4Battle.HEIGHT)) + want = math.floor(pw / s / 8) * 8 + end + return math.max(least, math.min(most, want)) +end + +function Gen4Battle.width(battle) + local fn = battle and battle.gen4SurfaceWidth + if type(fn) ~= "function" then return Gen4Battle.WIDTH end + local ok, w = pcall(fn, battle) + if not (ok and type(w) == "number" and w >= Gen4Battle.WIDTH) then + return Gen4Battle.WIDTH + end + return math.floor(w / 8) * 8 +end + +function Gen4Battle.extra(battle) + return Gen4Battle.width(battle) - Gen4Battle.WIDTH +end + +-- Player-side field objects stay attached to the left half; enemy-side ones +-- stay attached to the right. At 256px this returns the cartridge coordinates +-- byte-for-byte. +function Gen4Battle.battlerPos(battle, slot) + local p = Gen4Battle.BATTLER_POS[slot] + if not p then return nil end + local shift = (slot == 1 or slot == 3 or slot == 5) and Gen4Battle.extra(battle) or 0 + return { x = p.x + shift, y = p.y } +end + +function Gen4Battle.healthboxPos(battle, slot) + local p = Gen4Battle.HEALTHBOX_POS[slot] + if not p then return nil end + local shift = (slot == 0 or slot == 2 or slot == 4) and Gen4Battle.extra(battle) or 0 + return { x = p.x + shift, y = p.y } +end + -- --------------------------------------------------------------------------- -- The two enumerations, 0-based, from generated/battle_backgrounds.txt and -- generated/battle_terrains.txt. They are NOT the same list and do not line up @@ -531,7 +581,7 @@ function Gen4Battle.drawField(battle) local ox, oy = -(sx % gw), -(sy % gh) for _, dx in ipairs({ 0, gw }) do for _, dy in ipairs({ 0, gh }) do - if ox + dx < Gen4Battle.WIDTH and oy + dy < Gen4Battle.HEIGHT then + if ox + dx < Gen4Battle.width(battle) and oy + dy < Gen4Battle.HEIGHT then g.draw(ground, ox + dx, oy + dy) end end @@ -542,7 +592,7 @@ function Gen4Battle.drawField(battle) -- because every piece of Gen 4 art on top of it is drawn for a dark field -- and white would show as a bright border round each platform. g.setColor(0, 0, 0, 1) - g.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT) + g.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT) g.setColor(1, 1, 1, 1) end @@ -555,6 +605,7 @@ function Gen4Battle.drawField(battle) if enemy then local ex, ey = Gen4Battle.platformAt( "enemy", Gen4Battle.platformSlideElapsed(battle, "enemy")) + ex = ex + Gen4Battle.extra(battle) g.draw(enemy, corner(ex, ey, enemy)) end if player then @@ -672,7 +723,7 @@ function Gen4Battle.drawTrainerBack(battle) local img = battle.picImage and battle:picImage(battle.playerBackPic) or battle.playerBackPic if not (img and img.getWidth) then return false end - local pos = Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[0] + local pos = Gen4Battle.battlerPos(battle, 0) if not pos then return false end local g = love.graphics local x, y = corner(pos.x, pos.y, img) @@ -687,8 +738,8 @@ function Gen4Battle.drawBattlers(battle) -- The foe first, so where the two overlap the player's Pokemon is in front -- -- which is what standing nearer the camera means. local pairs_ = { - { battler = battle.enemy, pos = Gen4Battle.BATTLER_POS[1] }, - { battler = battle.player, pos = Gen4Battle.BATTLER_POS[0] }, + { battler = battle.enemy, pos = Gen4Battle.battlerPos(battle, 1) }, + { battler = battle.player, pos = Gen4Battle.battlerPos(battle, 0) }, } for _, row in ipairs(pairs_) do local battler = row.battler @@ -706,7 +757,7 @@ function Gen4Battle.drawBattlers(battle) -- for its own reasons must not have it cleared out from under it by a -- battler draw, and this runs inside whatever the caller had set. local sx, sy, sw, sh = g.getScissor() - g.setScissor(0, 0, Gen4Battle.WIDTH, math.max(0, math.floor(clip))) + g.setScissor(0, 0, Gen4Battle.width(battle), math.max(0, math.floor(clip))) local drew, err = pcall(battle.drawBattlerPic, battle, battler, x, y, 1) if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end @@ -764,7 +815,7 @@ function Gen4Battle.drawBackgroundFade(battle) if not (got and a and a > 0) then return false end local gfx = love.graphics gfx.setColor(r or 0, g or 0, b or 0, a) - gfx.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT) + gfx.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT) gfx.setColor(1, 1, 1, 1) return true end @@ -825,7 +876,7 @@ function Gen4Battle.drawEffectBackground(battle) -- The cross-fade's half: the field dimmed to its coefficient. if st.blend and (st.base or 1) < 1 then g.setColor(0, 0, 0, 1 - st.base) - g.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT) + g.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT) g.setColor(1, 1, 1, 1) drew = true end @@ -857,7 +908,7 @@ function Gen4Battle.drawEffectBackground(battle) -- the 81 is 256 or 512 wide by 256 tall. for _, dx in ipairs({ 0, w }) do for _, dy in ipairs({ 0, h }) do - if ox + dx < Gen4Battle.WIDTH and oy + dy < Gen4Battle.HEIGHT then + if ox + dx < Gen4Battle.width(battle) and oy + dy < Gen4Battle.HEIGHT then g.draw(art, ox + dx, oy + dy) end end @@ -870,7 +921,7 @@ function Gen4Battle.drawEffectBackground(battle) local tint = st.tint if tint and tint[4] and tint[4] > 0 and row and row.opaque then g.setColor(tint[1] or 0, tint[2] or 0, tint[3] or 0, tint[4]) - g.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT) + g.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT) g.setColor(1, 1, 1, 1) end return true @@ -949,8 +1000,8 @@ end -- resource's own stored position, which is expressed relative to the middle of -- the field. Those two agreeing is a coincidence of this layout, not one fact. function Gen4Battle.particleOrigin(battle, name, attackerIsPlayer) - local me = Gen4Battle.BATTLER_POS[0] - local foe = Gen4Battle.BATTLER_POS[1] + local me = Gen4Battle.battlerPos(battle, 0) + local foe = Gen4Battle.battlerPos(battle, 1) if name == "player" then return me.x, me.y end if name == "enemy" then return foe.x, foe.y end local attacker = attackerIsPlayer and me or foe @@ -1423,7 +1474,7 @@ function Gen4Battle.drawHealthboxes(battle) for _, row in ipairs(sides) do local art = Gen4Battle.HEALTHBOX_ART[row.side] local img = art and healthboxImage(battle, art.key) - local centre = Gen4Battle.HEALTHBOX_POS[row.slot] + local centre = Gen4Battle.healthboxPos(battle, row.slot) if img and centre and row.battler and row.battler.mon then local x, y = centre.x + art.ox, centre.y + art.oy g.setColor(1, 1, 1, 1) @@ -1777,6 +1828,13 @@ function Gen4Battle.drawTextArea(battle) local T, ROWS = Gen4Battle.MESSAGE_TEXT, Gen4Battle.MESSAGE_ROWS local LINE_H, GAP = Gen4Battle.MESSAGE_LINE_H, Gen4Battle.MENU_GAP local B = Gen4Battle.MESSAGE_BOX + if Gen4Battle.extra(battle) > 0 then + local copy = {} + for k,v in pairs(B) do copy[k] = v end + copy.tw = (copy.tw or 0) + Gen4Battle.extra(battle) / 8 + copy.w = (copy.w or 0) + Gen4Battle.extra(battle) + B = copy + end local phase = battle.phase -- WHICH PRESENTATION, DECIDED BEFORE ANYTHING IS DRAWN -- because the answer @@ -1845,7 +1903,7 @@ function Gen4Battle.drawTextArea(battle) -- which is where every other version in the launcher puts it. if (battle.msgWaiting or battle.msgPrompt) and (battle.frame or 0) % 60 < 30 then - Font.drawCode(Theme.moreArrow, T.x + T.w - 10, ROWS[2] + 4) + Font.drawCode(Theme.moreArrow, T.x + T.w + Gen4Battle.extra(battle) - 10, ROWS[2] + 4) end end return diff --git a/src/core/Game.lua b/src/core/Game.lua index 2b5de9df..616abb8c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -846,7 +846,38 @@ Game.centerClassicZones = centerClassicZones function Game:draw() local closeDraw = FrameProfile.section("draw: whole frame") + + -- A specialized lower-screen state (battle menu, mining, Poketch screen) + -- claims the panel by calling SecondScreen.draw during _draw(). If nothing + -- claims it, a Gen 4 overworld still needs Platinum's always-present Poketch. + self.secondScreenDrawnThisFrame = false self:_draw() + + -- Single-screen generations use the physical Android lower panel as a live + -- save companion without changing the game's own screen mode. + do + local okC, Companion = pcall(require, "src.ui.SaveCompanion") + if okC and Companion and Companion.tick then pcall(Companion.tick, self) end + end + + do + local okSS, SS = pcall(require, "src.ui.SecondScreen") + if okSS and SS.mode and SS.mode(self) == "display" + and not self.secondScreenDrawnThisFrame then + local okP, Poketch = pcall(require, "src.ui.Gen4Poketch") + if okP and Poketch and Poketch.new then + if not self.secondScreenOverworldPoketch then + local okNew, p = pcall(Poketch.new, self, {}) + if okNew then self.secondScreenOverworldPoketch = p end + end + local p = self.secondScreenOverworldPoketch + if p and p.drawWatch then + pcall(SS.draw, self, function() p:drawWatch() end) + end + end + end + end + closeDraw() -- THE SECOND PANEL'S FRAME LEAVES LAST. -- diff --git a/src/inventory/Badges.lua b/src/inventory/Badges.lua index 55151ab5..320767cd 100644 --- a/src/inventory/Badges.lua +++ b/src/inventory/Badges.lua @@ -57,4 +57,40 @@ function Badges.count(data, save, version) return n end +-- Canonical badge mutation. Reads accept either historical store, but writes +-- must not leave a truthy duplicate behind in the other one. Turning OFF +-- therefore clears both stores; turning ON writes only where this generation +-- actually stores badges. +function Badges.set(save, entry, on, version) + if not (save and entry) then return false end + local key = Badges.itemFor(entry) + if not key then return false end + + save.inventory = save.inventory or {} + save.flags = save.flags or {} + + if not on then + save.inventory[key] = nil + save.flags[key] = nil + return true + end + + local GameVersion = require("src.core.GameVersion") + local flagBadge = GameVersion.isGen2(version) or GameVersion.isGen3(version) + if flagBadge then + save.flags[key] = true + save.inventory[key] = nil + else + save.inventory[key] = 1 + save.flags[key] = nil + end + return true +end + +function Badges.toggle(save, entry, version) + local on = Badges.has(save, entry) + Badges.set(save, entry, not on, version) + return not on +end + return Badges diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 130730bb..64922492 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -1060,8 +1060,17 @@ function Renderer:endFrame(zones, worldZones) local ow, oh = self.worldOverride:getDimensions() local osx = (ow and ow > 0) and (ww / ow) or (1 / dpiX) local osy = (oh and oh > 0) and (wh / oh) or (1 / dpiY) + -- iOS still needs the historical presentation flip for the older + -- generations. Gen 4 is the exception: its 3D worldOverride already + -- reaches this point in the correct orientation, so applying the same + -- iOS flip a second time turns Platinum upside down while its UI remains + -- upright. local loveMajor = love.getVersion() - if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then + local gen = require("src.core.GameVersion").generation() + if love.system and love.system.getOS + and love.system.getOS() == "iOS" + and loveMajor >= 12 + and gen ~= 4 then love.graphics.draw(self.worldOverride, 0, wh, 0, osx, -osy) else love.graphics.draw(self.worldOverride, 0, 0, 0, osx, osy) diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index 6160f61e..bbbcf73e 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -184,16 +184,25 @@ function SecondScreen.filePush(imageData, w, h) end local okStr, body = pcall(imageData.getString, imageData) if not (okStr and type(body) == "string") then return false end + + -- The file protocol is RGBA8 by definition. Never write a header claiming + -- 256x192x4 while attaching a differently-sized ImageData payload; that + -- leaves the Java side with a valid header and an unusable file forever. + local expected = (tonumber(w) or 0) * (tonumber(h) or 0) * 4 + if #body ~= expected then + log(("refusing frame: %dx%d expects %d RGBA8 bytes, ImageData returned %d") + :format(tonumber(w) or 0, tonumber(h) or 0, expected, #body)) + return false + end + fileSeq = (fileSeq + 1) % 65536 local header = SecondScreen.MAGIC .. u16(SecondScreen.PROTOCOL) .. u16(w) .. u16(h) .. u16(fileSeq) - -- WRITTEN WHOLE, then renamed, is what a reader would want -- and LOVE's - -- filesystem has no rename. The sequence number in the header is the - -- answer instead: a host that reads a torn frame sees a sequence it has - -- already drawn, or a length that does not match, and waits for the next - -- one rather than drawing half a picture. - local ok = pcall(f.write, SecondScreen.FRAME, header .. body) - return ok and true or false + local packet = header .. body + local ok, wrote = pcall(f.write, SecondScreen.FRAME, packet) + -- LOVE filesystem.write normally returns true. Treat an explicit false as + -- failure so SecondScreen.flush keeps the frame dirty and retries. + return ok and wrote ~= false end -- Everything the host has recorded since the last call, in order, and the file @@ -221,13 +230,17 @@ function SecondScreen.pollTouch() end function SecondScreen.usable() - return C ~= nil or SecondScreen.fileAvailable() + return SecondScreen.fileAvailable() or C ~= nil end --- Which transport answered, for the log and for a check to assert on. +-- The Java ContentProvider host owns the Android Presentation in packaged +-- builds, so prefer its file protocol whenever its heartbeat is visible. +-- The older FFI bridge owns a different GameActivity Presentation; choosing it +-- merely because its symbols are linked can report "not ready" while the Java +-- host is already displaying the physical lower panel. function SecondScreen.backend() - if C ~= nil then return "ffi" end if SecondScreen.fileAvailable() then return "file" end + if C ~= nil then return "ffi" end return nil end @@ -242,9 +255,11 @@ SecondScreen.PROBE_INTERVAL = 0.5 local probedAt, probed = nil, false function SecondScreen.available() - -- The native bridge first -- it hands the host a pointer and costs nothing - -- per frame -- then the file protocol, which needs no native code at all. - if not C then return SecondScreen.fileAvailable() end + -- Prefer the Java host. It is the component that actually owns the Android + -- Presentation in current APK builds. The FFI bridge remains a fallback for + -- builds which do not install SecondDisplayHost. + if SecondScreen.fileAvailable() then return true end + if not C then return false end local now = clock() if probedAt and now and (now - probedAt) < SecondScreen.PROBE_INTERVAL then return probed @@ -265,7 +280,10 @@ end function SecondScreen.push(imageData, w, h) if not imageData then return false end - if not C then return SecondScreen.filePush(imageData, w, h) end + if SecondScreen.fileAvailable() then + return SecondScreen.filePush(imageData, w, h) + end + if not C then return false end return pcall(function() C.love_android_push_secondary(imageData:getFFIPointer(), w, h) end) diff --git a/src/ui/Gen4Poketch.lua b/src/ui/Gen4Poketch.lua index bd0f03d8..1b5ff906 100644 --- a/src/ui/Gen4Poketch.lua +++ b/src/ui/Gen4Poketch.lua @@ -328,48 +328,61 @@ function Gen4Poketch:drawWatch() local g = love.graphics local app = self:app() - local border = self:img(self.border) - if border then - g.setColor(1, 1, 1, 1) - g.draw(border, 0, 0) - else - g.setColor(0.10, 0.13, 0.18, 1) - g.rectangle("fill", 0, 0, W, H) - g.setColor(0.55, 0.72, 0.42, 1) - g.rectangle("fill", FACE.x, FACE.y, FACE.w, FACE.h) - g.setColor(1, 1, 1, 1) - end - - -- The app's own face, under whatever it draws on top of it. + -- APP LCD FIRST. The extracted Poketch app tilemaps are full 256x192 + -- compositions and may contain opaque pixels outside the LCD. Drawing one + -- after the shell used to cover the cartridge's bezel and buttons entirely. local face = app and app.art and self:img(app.art) if face then + -- App tilemaps are composed as full 256x192 screens. Only the LCD hole + -- belongs to the app; pixels outside it must never cover the device shell. + g.setScissor(FACE.x, FACE.y, FACE.w, FACE.h) g.setColor(1, 1, 1, 1) g.draw(face, 0, 0) + else + -- Only apps which genuinely have no composed face use the cartridge's + -- unavailable screen. Do not put this over an existing app merely because + -- its interactive behaviour has not been implemented yet. + local blank = self:img(self.unavailable) + if blank then + g.setColor(1, 1, 1, 1) + g.draw(blank, 0, 0) + else + g.setColor(0.55, 0.72, 0.42, 1) + g.rectangle("fill", FACE.x, FACE.y, FACE.w, FACE.h) + end end + -- Live overlays belong on the LCD, above the app's cartridge background. if app then local body = app.name and DRAW[app.name] if body then body(self) - else - -- AN APP WITH NO BEHAVIOUR SAYS SO, on the cartridge's own screen for - -- exactly this. A blank face would read as a bug in the app. - local blank = self:img(self.unavailable) - if blank then - g.setColor(1, 1, 1, 0.85) - g.draw(blank, 0, 0) - g.setColor(1, 1, 1, 1) - end + elseif not face then + -- Only a truly missing face gets a textual fallback. A valid extracted + -- app remains visually faithful even before its behaviour is emulated. local label = app.name or Strings("APP") Font.draw(label, FACE.x + 8, FACE.y + 8) Font.draw(Strings("NOT BUILT YET"), FACE.x + 8, FACE.y + 24) end end - -- Which app, and that there are others. - if #self.apps > 0 then - Font.draw(("%d/%d"):format(self.index, #self.apps), 8, H - 16) + g.setScissor() + + -- SHELL LAST. Platinum composes the Poketch device around the LCD; this + -- extracted full-screen border contains the bezel and physical app-change + -- buttons and must mask the edges of every app tilemap. + local border = self:img(self.border) + if border then + g.setColor(1, 1, 1, 1) + g.draw(border, 0, 0) + else + -- Keep a visible frame if an old cache predates border extraction. + g.setColor(0.10, 0.13, 0.18, 1) + g.setLineWidth(2) + g.rectangle("line", FACE.x - 2, FACE.y - 2, FACE.w + 4, FACE.h + 4) + g.setLineWidth(1) end + g.setColor(1, 1, 1, 1) end diff --git a/src/ui/LauncherSecondScreen.lua b/src/ui/LauncherSecondScreen.lua index a3513753..af80ac57 100644 --- a/src/ui/LauncherSecondScreen.lua +++ b/src/ui/LauncherSecondScreen.lua @@ -199,6 +199,11 @@ local function hostFor(importer) if not host then host = { secondScreenAlways = true, + -- The launcher is not a Nintendo DS framebuffer. Use the Android host's + -- actual lower-panel resolution; SecondScreen scales this module's + -- 256x192 logical layout to it and maps touches back to logical space. + -- A running Gen 4 Game does not set this flag and remains true 256x192. + secondScreenNativePanel = true, data = {}, save = { options = { secondScreenMode = "display" } }, } diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua new file mode 100644 index 00000000..9ba0e511 --- /dev/null +++ b/src/ui/SaveCompanion.lua @@ -0,0 +1,673 @@ +-- Native-resolution lower-screen save companion for Gen 1-3. +-- This is intentionally separate from the desktop save editor: it mutates the +-- running game's live save through the same engine modules, but lays controls +-- out for a touch screen which is present throughout play. + +local SecondScreen = require("src.ui.SecondScreen") +local Boxes = require("src.pokemon.Boxes") +local Bag = require("src.inventory.Bag") +local Party = require("src.pokemon.Party") +local Pokemon = require("src.pokemon.Pokemon") +local Stats = require("src.pokemon.Stats") +local Growth = require("src.pokemon.Growth") +local Badges = require("src.inventory.Badges") +local GameVersion = require("src.core.GameVersion") + +local M = {} +local state = { + tab = 1, box = 1, bagPage = 1, itemPage = 1, itemQty = 1, + host = nil, hits = {}, editor = nil, itemPicker = false, + nameEditor = false, nameDraft = "", fonts = {}, fontKey = nil, + catalogData = nil, moveIds = nil, itemIds = nil, speciesIds = nil, + speciesPicker = nil, speciesPage = 1, + panelW = nil, panelH = nil, panelCandidateW = nil, panelCandidateH = nil, + panelCandidateCount = 0, +} +local TABS = { "PARTY", "BOXES", "BAG", "PLAYER" } + +local W, H = 640, 360 +local PAL = { + bg={0.035,0.055,0.09,1}, panel={0.07,0.10,0.15,1}, + panel2={0.10,0.14,0.21,1}, line={0.20,0.32,0.46,1}, + blue={0.10,0.38,0.72,1}, blue2={0.16,0.52,0.88,1}, + green={0.13,0.55,0.36,1}, red={0.72,0.18,0.22,1}, + gold={0.88,0.64,0.16,1}, text={0.95,0.97,1,1}, + muted={0.62,0.70,0.80,1}, faint={0.38,0.48,0.60,1}, +} + +local function clamp(v,a,b) return math.max(a, math.min(b,v)) end +local function setColor(c) love.graphics.setColor(c[1],c[2],c[3],c[4] or 1) end +local function roundRect(x,y,w,h,r,c) + setColor(c); love.graphics.rectangle("fill",x,y,w,h,r,r) +end +local function stroke(x,y,w,h,r,c) + setColor(c); love.graphics.rectangle("line",x,y,w,h,r,r) +end + +local function ensureFonts() + local key = tostring(W).."x"..tostring(H) + if state.fontKey == key then return end + state.fontKey = key + local base = clamp(math.floor(H/30), 13, 24) + state.fonts = { + tiny = love.graphics.newFont(math.max(11, base-2)), + small = love.graphics.newFont(base), + medium = love.graphics.newFont(base+3), + large = love.graphics.newFont(base+8), + } +end +local function text(s,x,y,font,c) + ensureFonts(); love.graphics.setFont(state.fonts[font or "small"]) + setColor(c or PAL.text); love.graphics.print(tostring(s),x,y) +end +local function textRight(s,x,y,w,font,c) + ensureFonts(); love.graphics.setFont(state.fonts[font or "small"]) + local sw=love.graphics.getFont():getWidth(tostring(s)) + text(s,x+w-sw,y,font,c) +end +local function textCenter(s,x,y,w,font,c) + ensureFonts(); love.graphics.setFont(state.fonts[font or "small"]) + local sw=love.graphics.getFont():getWidth(tostring(s)) + text(s,x+(w-sw)/2,y,font,c) +end + +local function hit(x,y,w,h,fn) + state.hits[#state.hits+1]={x=x,y=y,w=w,h=h,fn=fn} +end +local function button(label,x,y,w,h,fn,opt) + opt=opt or {} + roundRect(x,y,w,h,opt.radius or math.max(6,H*.018),opt.color or PAL.panel2) + stroke(x,y,w,h,opt.radius or math.max(6,H*.018),opt.border or PAL.line) + textCenter(label,x,y+(h-(state.fonts.small and state.fonts.small:getHeight() or 14))/2,w, + opt.font or "small",opt.text or PAL.text) + if fn then hit(x,y,w,h,fn) end +end + +local function generation() + return GameVersion.generation and GameVersion.generation(GameVersion.get()) or 1 +end +local function speciesName(game,mon) + if not mon then return "-" end + local d=game.data and game.data.pokemon and game.data.pokemon[mon.species] + return (d and (d.name or d.displayName)) or tostring(mon.species or "?") +end +local function moveName(game,id) + if not id then return "---" end + local d=game.data and game.data.moves and game.data.moves[id] + return (d and (d.name or d.displayName)) or tostring(id) +end +local function itemName(game,id) + local d=game.data and game.data.items and game.data.items[id] + return (d and (d.name or d.displayName or d.key)) or tostring(id) +end +local function sortedKeys(t) + local out={} + for k in pairs(t or {}) do out[#out+1]=k end + table.sort(out,function(a,b) return tostring(a)",pad+W*.37,top,W*.07,H*.09,function() state.box=state.box==count and 1 or state.box+1 end) + textRight(Boxes.used(box).."/"..cap,W*.55,top+H*.025,W*.18,"medium",PAL.muted) + if Boxes.used(box)20) and 6 or 5 + local rows=math.ceil(cap/cols); local gap=W*.008 + local cw=(W-2*pad-gap*(cols-1))/cols + local ch=(H-gridY-H*.035-gap*(rows-1))/rows + for i=1,cap do + local c=(i-1)%cols; local r=math.floor((i-1)/cols) + local x=pad+c*(cw+gap); local y=gridY+r*(ch+gap) + local mon=box[i] + roundRect(x,y,cw,ch,8,mon and PAL.panel2 or PAL.panel) + stroke(x,y,cw,ch,8,mon and PAL.line or PAL.faint) + if mon then + textCenter(speciesName(game,mon):sub(1,10),x,y+ch*.20,cw,"tiny") + textCenter("Lv"..tostring(mon.level or "?"),x,y+ch*.55,cw,"tiny",PAL.muted) + hit(x,y,cw,ch,function() openMon(mon,"box",i,state.box) end) + else + textCenter("+ "..tostring(i),x,y+ch*.38,cw,"tiny",PAL.faint) + hit(x,y,cw,ch,function() openSpeciesPicker("add","box",i,state.box) end) + end + end +end + +local function bagIds(game) + game.save.inventory=game.save.inventory or {} + return Bag.order(game.save) +end +local function drawBag(game) + ensureCatalog(game) + if state.itemPicker then + local ids=state.itemIds or {}; local pad=W*.025 + text("Add item",pad,H*.17,"large") + button("BACK",W*.83,H*.155,W*.14,H*.085,function() state.itemPicker=false end) + local pageSize=8; local pages=math.max(1,math.ceil(#ids/pageSize)) + state.itemPage=clamp(state.itemPage,1,pages) + local y0=H*.27; local rowH=H*.072; local gap=H*.012 + for row=1,pageSize do + local idx=(state.itemPage-1)*pageSize+row; local id=ids[idx] + if not id then break end + local y=y0+(row-1)*(rowH+gap) + roundRect(pad,y,W*.70,rowH,8,PAL.panel2); stroke(pad,y,W*.70,rowH,8,PAL.line) + text(itemName(game,id),pad+W*.018,y+rowH*.22,"small") + button("ADD x"..state.itemQty,W*.76,y,W*.20,rowH,function() + Bag.add(game.save,id,state.itemQty,game.data) + end,{color=PAL.green}) + end + button("-",pad,H*.90,W*.07,H*.07,function() state.itemQty=clamp(state.itemQty-1,1,99) end) + text("Qty "..state.itemQty,pad+W*.09,H*.91,"medium") + button("+",pad+W*.21,H*.90,W*.07,H*.07,function() state.itemQty=clamp(state.itemQty+1,1,99) end) + button("< PAGE",W*.50,H*.90,W*.14,H*.07,function() state.itemPage=math.max(1,state.itemPage-1) end) + textCenter(state.itemPage.."/"..pages,W*.65,H*.91,W*.10,"small",PAL.muted) + button("PAGE >",W*.76,H*.90,W*.19,H*.07,function() state.itemPage=math.min(pages,state.itemPage+1) end) + return + end + + local ids=bagIds(game); local pad=W*.025 + text("Bag",pad,H*.17,"large") + text(#ids.." item types",pad+W*.12,H*.19,"small",PAL.muted) + button("+ ADD ITEM",W*.78,H*.155,W*.19,H*.085,function() state.itemPicker=true end,{color=PAL.blue}) + local pageSize=7; local pages=math.max(1,math.ceil(#ids/pageSize)) + state.bagPage=clamp(state.bagPage,1,pages) + local y0=H*.29; local rowH=H*.075; local gap=H*.012 + for row=1,pageSize do + local idx=(state.bagPage-1)*pageSize+row; local id=ids[idx] + if not id then break end + local y=y0+(row-1)*(rowH+gap) + roundRect(pad,y,W*.94,rowH,8,PAL.panel2); stroke(pad,y,W*.94,rowH,8,PAL.line) + text(itemName(game,id),pad+W*.018,y+rowH*.22,"small") + textRight("x"..tostring(game.save.inventory[id] or 0),W*.50,y+rowH*.22,W*.16,"small",PAL.gold) + button("-",W*.70,y+H*.006,W*.07,rowH-H*.012,function() Bag.remove(game.save,id,1) end) + button("+",W*.79,y+H*.006,W*.07,rowH-H*.012,function() Bag.add(game.save,id,1,game.data) end,{color=PAL.green}) + button("DROP",W*.88,y+H*.006,W*.085,rowH-H*.012,function() + local qty=game.save.inventory[id] or 0; if qty>0 then Bag.remove(game.save,id,qty) end + end,{color=PAL.red,font="tiny"}) + end + button("<",pad,H*.91,W*.08,H*.065,function() state.bagPage=math.max(1,state.bagPage-1) end) + textCenter(state.bagPage.."/"..pages,pad+W*.10,H*.92,W*.12,"small",PAL.muted) + button(">",pad+W*.23,H*.91,W*.08,H*.065,function() state.bagPage=math.min(pages,state.bagPage+1) end) +end + +local function toggleBadge(game,entry) + Badges.toggle(game.save, entry, GameVersion.get()) +end + +local KEY_ROWS={"ABCDEFG","HIJKLMN","OPQRSTU","VWXYZ"} +local function drawNameEditor(game) + local pad=W*.04 + text("Edit trainer name",pad,H*.16,"large") + roundRect(pad,H*.25,W*.92,H*.12,10,PAL.panel2); stroke(pad,H*.25,W*.92,H*.12,10,PAL.blue2) + textCenter(state.nameDraft,pad,H*.275,W*.92,"large") + local y=H*.41 + for _,row in ipairs(KEY_ROWS) do + local n=#row; local gap=W*.01; local bw=(W-2*pad-gap*(n-1))/n + for i=1,n do + local ch=row:sub(i,i); local x=pad+(i-1)*(bw+gap) + button(ch,x,y,bw,H*.095,function() + if #state.nameDraft<7 then state.nameDraft=state.nameDraft..ch end + end,{font="medium"}) + end + y=y+H*.115 + end + button("BACKSPACE",pad,H*.88,W*.24,H*.075,function() + state.nameDraft=state.nameDraft:sub(1,math.max(0,#state.nameDraft-1)) + end) + button("CLEAR",pad+W*.27,H*.88,W*.18,H*.075,function() state.nameDraft="" end,{color=PAL.red}) + button("CANCEL",W*.57,H*.88,W*.17,H*.075,function() state.nameEditor=false end) + button("SAVE",W*.77,H*.88,W*.19,H*.075,function() + game.save.player=game.save.player or {} + local name=state.nameDraft~="" and state.nameDraft or "PLAYER" + game.save.player.name=name; game.save.playerName=name + state.nameEditor=false + end,{color=PAL.green}) +end + +local function drawPlayer(game) + if state.nameEditor then return drawNameEditor(game) end + game.save.player=game.save.player or {} + local p=game.save.player; local pad=W*.025 + text("Player",pad,H*.17,"large") + roundRect(pad,H*.27,W*.45,H*.18,12,PAL.panel2); stroke(pad,H*.27,W*.45,H*.18,12,PAL.line) + text("TRAINER",pad+W*.025,H*.295,"tiny",PAL.muted) + text(p.name or game.save.playerName or "PLAYER",pad+W*.025,H*.34,"large") + button("EDIT NAME",pad+W*.28,H*.335,W*.14,H*.075,function() + state.nameDraft=tostring(p.name or game.save.playerName or "PLAYER"):sub(1,7) + state.nameEditor=true + end,{color=PAL.blue}) + + roundRect(W*.51,H*.27,W*.465,H*.18,12,PAL.panel2); stroke(W*.51,H*.27,W*.465,H*.18,12,PAL.line) + text("MONEY",W*.535,H*.295,"tiny",PAL.muted) + text("$"..tostring(game.save.money or 0),W*.535,H*.34,"large",PAL.gold) + local by=H*.47; local bw=W*.105; local gap=W*.012 + local moneyButtons={{"-1000",-1000},{"-100",-100},{"+100",100},{"+1000",1000}} + for i,b in ipairs(moneyButtons) do + button(b[1],pad+(i-1)*(bw+gap),by,bw,H*.075,function() + game.save.money=clamp((tonumber(game.save.money) or 0)+b[2],0,999999) + end,{color=b[2]<0 and PAL.panel2 or PAL.green}) + end + button("MAX",pad+4*(bw+gap),by,bw,H*.075,function() game.save.money=999999 end,{color=PAL.gold}) + + local badges=Badges.list(game.data,GameVersion.get()) + text("Badges "..Badges.count(game.data,game.save,GameVersion.get()).."/"..#badges, + pad,H*.59,"medium") + local cols=math.min(8,math.max(4,#badges)); local gapB=W*.008 + local cb=(W-2*pad-gapB*(cols-1))/cols; local ch=H*.105 + for i,entry in ipairs(badges) do + local c=(i-1)%cols; local r=math.floor((i-1)/cols) + local x=pad+c*(cb+gapB); local y=H*.66+r*(ch+H*.015) + local on=Badges.has(game.save,entry) + button((entry.name or entry.id or tostring(i)):sub(1,10),x,y,cb,ch, + function() toggleBadge(game,entry) end, + {color=on and PAL.green or PAL.panel2,border=on and PAL.green or PAL.line,font="tiny"}) + end + text("Version: "..tostring(GameVersion.get()),pad,H*.93,"small",PAL.faint) +end + +local function drawMonEditor(game) + local e=state.editor; local mon=e and e.mon + if not mon then state.editor=nil return end + ensureCatalog(game) + local pad=W*.025 + button("< BACK",pad,H*.025,W*.13,H*.075,function() state.editor=nil end) + text(speciesName(game,mon),pad+W*.16,H*.03,"large") + text("Lv "..tostring(mon.level or 1),pad+W*.16,H*.09,"medium",PAL.gold) + button("CHANGE SPECIES",W*.43,H*.025,W*.20,H*.075,function() + openSpeciesPicker("change",e.origin,e.index,e.box,mon) + end,{color=PAL.blue,font="tiny"}) + button("DELETE",W*.64,H*.025,W*.085,H*.075,function() + if e.origin=="party" then + local party=game.save.party or {} + if party[e.index]==mon then table.remove(party,e.index) end + else + local bs=boxes(game); local box=bs[e.box] + if box and box[e.index]==mon then box[e.index]=nil end + end + state.editor=nil + end,{color=PAL.red,font="tiny"}) + + local actionLabel=e.origin=="party" and "DEPOSIT TO BOX" or "WITHDRAW TO PARTY" + button(actionLabel,W*.73,H*.025,W*.245,H*.075,function() + if e.origin=="party" then + local party=game.save.party or {}; local live=party[e.index] + if live==mon then + game.save.currentBox=state.box + local used=Boxes.deposit(game.save,mon) + if used then table.remove(party,e.index); state.editor=nil end + end + else + game.save.party=game.save.party or {} + if #game.save.party=2 and {{"ATK","attack"},{"DEF","defense"},{"SPD","speed"},{"SPA","spatk"},{"SDF","spdef"}} + or {{"ATK","attack"},{"DEF","defense"},{"SPD","speed"},{"SPC","special"}} + for i,row in ipairs(labels) do + local yy=top+H*.018+(i-1)*H*.034 + text(row[1],statX+W*.015,yy,"tiny",PAL.muted) + textRight(tostring(st[row[2]] or 0),statX+W*.10,yy,statW-W*.12,"tiny",PAL.text) + end + + -- IV/DV editor. + local indY=H*.42; text(type(mon.ivs)=="table" and "INDIVIDUAL VALUES" or "DETERMINANT VALUES", + pad,indY,"medium") + local keys=type(mon.ivs)=="table" + and {{"HP","hp"},{"ATK","attack"},{"DEF","defense"},{"SPD","speed"},{"SPA","spatk"},{"SDF","spdef"}} + or {{"ATK","attack"},{"DEF","defense"},{"SPD","speed"},{"SPC","special"}} + local iw=(W*.60-W*.02)/2; local ih=H*.075 + for i,row in ipairs(keys) do + local col=(i-1)%2; local rr=math.floor((i-1)/2) + local x=pad+col*(iw+W*.02); local y=indY+H*.065+rr*(ih+H*.012) + local source=type(mon.ivs)=="table" and mon.ivs or mon.dvs + roundRect(x,y,iw,ih,8,PAL.panel2); stroke(x,y,iw,ih,8,PAL.line) + text(row[1],x+W*.012,y+ih*.25,"small",PAL.muted) + text(tostring((source and source[row[2]]) or 0),x+W*.09,y+ih*.25,"medium") + button("-",x+iw-W*.12,y+H*.008,W*.05,ih-H*.016,function() adjustIndividual(game,mon,row[2],-1) end,{font="small"}) + button("+",x+iw-W*.06,y+H*.008,W*.05,ih-H*.016,function() adjustIndividual(game,mon,row[2],1) end,{font="small",color=PAL.green}) + end + + -- Move editor on the right. + local mx=W*.66; local my=H*.42; local mw=W*.315 + text("MOVES",mx,my,"medium") + for slot=1,4 do + local y=my+H*.065+(slot-1)*H*.105 + local m=mon.moves and mon.moves[slot] + roundRect(mx,y,mw,H*.09,8,PAL.panel2); stroke(mx,y,mw,H*.09,8,PAL.line) + button("<",mx+W*.008,y+H*.008,W*.045,H*.074,function() cycleMove(game,mon,slot,-1) end,{font="small"}) + textCenter(moveName(game,m and m.id),mx+W*.06,y+H*.018,mw-W*.12,"small") + if m then textCenter("PP "..tostring(m.pp or 0),mx+W*.06,y+H*.052,mw-W*.12,"tiny",PAL.muted) end + button(">",mx+mw-W*.053,y+H*.008,W*.045,H*.074,function() cycleMove(game,mon,slot,1) end,{font="small",color=PAL.blue}) + end +end + +local function drawSpeciesPicker(game) + ensureCatalog(game) + local p=state.speciesPicker + if not p then return end + local ids=state.speciesIds or {} + local pad=W*.025 + text(p.mode=="change" and "Change Pokemon species" or "Add Pokemon",pad,H*.055,"large") + button("CANCEL",W*.82,H*.045,W*.155,H*.08,function() state.speciesPicker=nil end) + + local pageSize=12 + local pages=math.max(1,math.ceil(#ids/pageSize)) + state.speciesPage=clamp(state.speciesPage,1,pages) + local cols=3 + local gap=W*.012 + local cw=(W-2*pad-gap*(cols-1))/cols + local y0=H*.17 + local ch=H*.155 + for n=1,pageSize do + local idx=(state.speciesPage-1)*pageSize+n + local id=ids[idx] + if not id then break end + local col=(n-1)%cols + local row=math.floor((n-1)/cols) + local x=pad+col*(cw+gap) + local y=y0+row*(ch+H*.018) + local def=game.data.pokemon[id] + local label=(def and (def.name or def.displayName)) or tostring(id) + button(label,x,y,cw,ch,function() applySpecies(game,p,id) end, + {color=PAL.panel2,border=PAL.line,font="small"}) + end + + button("< PAGE",pad,H*.89,W*.16,H*.075,function() + state.speciesPage=math.max(1,state.speciesPage-1) + end) + textCenter(state.speciesPage.." / "..pages,W*.40,H*.905,W*.20,"medium",PAL.muted) + button("PAGE >",W*.79,H*.89,W*.185,H*.075,function() + state.speciesPage=math.min(pages,state.speciesPage+1) + end) +end + +local function draw(game) + ensureFonts(); state.hits={} + setColor(PAL.bg); love.graphics.rectangle("fill",0,0,W,H) + -- subtle launcher-like panel wash + setColor({0.04,0.12,0.22,0.28}); love.graphics.rectangle("fill",0,0,W,H*.42) + if state.speciesPicker then return drawSpeciesPicker(game) end + if state.editor then return drawMonEditor(game) end + drawHeader() + if state.tab==1 then drawParty(game) + elseif state.tab==2 then drawBoxes(game) + elseif state.tab==3 then drawBag(game) + else drawPlayer(game) end +end + +local function touch(_,x,y) + for i=#state.hits,1,-1 do + local h=state.hits[i] + if x>=h.x and y>=h.y and x= 256 and ph >= 192 + local fallback = pw == 256 and ph == 192 + if plausible and not (state.panelW and fallback) then + if pw == state.panelCandidateW and ph == state.panelCandidateH then + state.panelCandidateCount = state.panelCandidateCount + 1 + else + state.panelCandidateW, state.panelCandidateH = pw, ph + state.panelCandidateCount = 1 + end + local need = state.panelW and 20 or 3 + if state.panelCandidateCount >= need + and (pw ~= state.panelW or ph ~= state.panelH) then + state.panelW, state.panelH = pw, ph + state.fontKey = nil + end + end + + local useW = state.panelW or W + local useH = state.panelH or H + state.host.secondScreenLogicalWidth=useW + state.host.secondScreenLogicalHeight=useH + state.host.secondScreenSurfaceWidth=useW + state.host.secondScreenSurfaceHeight=useH + W,H=useW,useH + return state.host +end + +function M.tick(game) + local gen=generation() + if gen>=4 or not SecondScreen.deviceReady() then return false end + local h=hostFor(game) + local ok=pcall(SecondScreen.draw,h,function() draw(game) end) + pcall(SecondScreen.flush,h) + return ok +end + +return M diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index 700b6010..53893448 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -244,15 +244,92 @@ end -- it dies with the session rather than outliving it in a module local, at -- exactly the DS's own 256x192: the panel it goes to is some other size and -- scaling to it is the host's job, not a decision baked into the pixels. +local function surfaceSize(game) + -- A native companion may latch a stable physical-panel size. The Android + -- host heartbeat is asynchronous and can briefly report protocol fallback + -- dimensions while Presentation reconnects; using those transient values + -- here reallocates the framebuffer and visibly flashes the lower display. + local pinnedW = game and tonumber(game.secondScreenSurfaceWidth) + local pinnedH = game and tonumber(game.secondScreenSurfaceHeight) + if pinnedW and pinnedH and pinnedW > 0 and pinnedH > 0 then + return math.floor(pinnedW), math.floor(pinnedH) + end + if game and game.secondScreenNativePanel then + local T = transport() + if T and T.readHost then + local ok, host = pcall(T.readHost) + if ok and host then + local w, h = tonumber(host.width), tonumber(host.height) + if w and h and w > 0 and h > 0 then + return math.floor(w), math.floor(h) + end + end + end + end + return W, H +end + +local function logicalSize(game) + local w = game and tonumber(game.secondScreenLogicalWidth) + local h = game and tonumber(game.secondScreenLogicalHeight) + if w and h and w > 0 and h > 0 then + return math.floor(w), math.floor(h) + end + return W, H +end + +function SecondScreen.panelSize() + local T = transport() + if T and T.readHost then + local ok, host = pcall(T.readHost) + if ok and host then + local w, h = tonumber(host.width), tonumber(host.height) + if w and h and w > 0 and h > 0 then + return math.floor(w), math.floor(h) + end + end + end + return W, H +end + + local function surface(game) if not game then return nil end - if game.secondScreenCanvas then return game.secondScreenCanvas end + local sw, sh = surfaceSize(game) + if game.secondScreenCanvas + and game.secondScreenCanvasWidth == sw + and game.secondScreenCanvasHeight == sh then + return game.secondScreenCanvas + end + if game.secondScreenCanvas and game.secondScreenCanvas.release then + pcall(game.secondScreenCanvas.release, game.secondScreenCanvas) + end + game.secondScreenCanvas = nil local g = love.graphics if not (g and g.newCanvas) then return nil end - local ok, made = pcall(g.newCanvas, W, H) + -- Offscreen DS pixels must be device-independent. On high-DPI Android, + -- newCanvas(W,H) inherits the window DPI scale: on the AYN Thor a logical + -- 256x192 canvas became a 591x443 backing texture. Canvas:newImageData() + -- then returns those physical pixels, while the transport header still said + -- 256x192, producing scrambled rows / rejected frame sizes. + local ok, made = pcall(g.newCanvas, sw, sh, { + format = "rgba8", + dpiscale = 1, + readable = true, + }) + -- Older LOVE builds may not know readable/dpiscale settings. Preserve + -- compatibility, though flush() below will use the actual readback size. + if not (ok and made) then + ok, made = pcall(g.newCanvas, sw, sh, { format = "rgba8", dpiscale = 1 }) + end + if not (ok and made) then + ok, made = pcall(g.newCanvas, sw, sh) + end if not (ok and made) then return nil end if made.setFilter then pcall(made.setFilter, made, "nearest", "nearest") end game.secondScreenCanvas = made + game.secondScreenCanvasWidth = sw + game.secondScreenCanvasHeight = sh return made end @@ -261,6 +338,7 @@ function SecondScreen.canvas(game) end function SecondScreen.draw(game, body) + if game then game.secondScreenDrawnThisFrame = true end local g = love.graphics -- `display` is the one mode where the bottom screen is not in the window, -- so it cannot be a translate: the body is rendered to our own canvas and @@ -282,6 +360,11 @@ function SecondScreen.draw(game, body) g.setScissor() g.setColor(1, 1, 1, 1) g.clear(0, 0, 0, 1) + if game.secondScreenNativePanel then + local sw, sh = surfaceSize(game) + local lw, lh = logicalSize(game) + g.scale(sw / lw, sh / lh) + end local ok, err = pcall(body) g.setCanvas(previous) g.pop() @@ -361,13 +444,28 @@ function SecondScreen.flush(game) if last and now - last < SecondScreen.PUSH_INTERVAL then return false end local okData, data = pcall(canvas.newImageData, canvas) if not (okData and data) then return false end - local sent = T.push(data, W, H) and true or false + -- newImageData reports physical pixel dimensions. Normally dpiscale=1 + -- above makes these exactly 256x192; using the actual dimensions here also + -- keeps the wire header truthful on a backend which ignores that setting. + local pushW, pushH = W, H + if data.getDimensions then + local okDims, dw, dh = pcall(data.getDimensions, data) + if okDims and tonumber(dw) and tonumber(dh) and dw > 0 and dh > 0 then + pushW, pushH = dw, dh + end + end + local okPush, pushed = pcall(T.push, data, pushW, pushH) + local sent = okPush and pushed and true or false if data.release then pcall(data.release, data) end - -- Cleared whether or not the push landed. A frame the host refused is - -- stale by the next one anyway, and keeping the flag set would retry the - -- readback every frame for as long as the panel stayed unhappy. - game.secondScreenDirty = false - game.secondScreenPushedAt = now + + -- Do not throw away the pending frame when Android has detected the second + -- display but its Presentation/file bridge is not ready yet. This is common + -- during startup on dual-screen Android hardware: keeping the dirty flag set + -- lets the same bottom-screen state retry on the next eligible frame. + if sent then + game.secondScreenDirty = false + game.secondScreenPushedAt = now + end return sent end @@ -416,7 +514,12 @@ end -- asks and none of them learns a second coordinate space. function SecondScreen.injectTouch(game, method, id, x, y) if not game or SecondScreen.mode(game) ~= "display" then return false end - if not (x and y) or x < 0 or y < 0 or x >= W or y >= H then return false end + local lw, lh = logicalSize(game) + if game.secondScreenNativePanel and x and y then + local sw, sh = surfaceSize(game) + x, y = x * lw / sw, y * lh / sh + end + if not (x and y) or x < 0 or y < 0 or x >= lw or y >= lh then return false end local handler = game[method] if type(handler) ~= "function" then return false end local was = game.secondScreenInjecting