From 1a0319954bb74b835343bc6f83c57f1df5f375f5 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:46:12 -0400 Subject: [PATCH 01/37] fix(android): harden dynamic second display host --- mobile/second-display/SecondDisplayHost.java | 287 +++++-------------- 1 file changed, 72 insertions(+), 215 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index e70a7560..cf19f5b0 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,26 @@ 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(); + + // Some dual-screen devices do not mark their built-in lower panel as a + // presentation display. Any non-default Android Display is usable. if (all != null) { for (Display d : all) { if (d.getDisplayId() != Display.DEFAULT_DISPLAY) return d; @@ -186,34 +199,54 @@ private Display secondaryDisplay() { } private void attach(Activity activity) { - if (running) return; + activeActivity = activity; + if (!running) startPump(); + ensurePresentation(); + } + + /** Runs on the UI thread. Safe to call repeatedly while the pump waits for + * Android to expose a late/hot-plugged secondary display. */ + 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; @@ -222,40 +255,34 @@ private void detach() { }); } - /* ------------------------------------------------------------------ * - * The handshake, and the frames. - * ------------------------------------------------------------------ */ - - private void writeHost(int displays, int w, int h) { - byte[] line = (PROTOCOL + " " + displays + " " + w + " " + h + "\n") - .getBytes(Charset.forName("UTF-8")); - for (File dir : roots) { - FileOutputStream os = null; - try { - os = new FileOutputStream(new File(dir, HOST_FILE), false); - os.write(line); - } catch (Throwable ignored) { - } finally { - if (os != null) try { os.close(); } catch (Throwable ignored) { } - } - } - } - - 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; + + // AYN and other dual-screen firmware can publish the lower Display + // after the Activity has already resumed. Keep looking instead of + // permanently settling into the one-display state. + 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,174 +293,4 @@ private void startPump(final PanelPresentation panel) { pump.start(); } - /** 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. */ - public static int[] decodeHeader(byte[] head) { - if (head == null || head.length < HEADER_BYTES) return null; - if (head[0] != 'G' || head[1] != '2' || head[2] != 'S' || head[3] != 'D') { - return null; - } - ByteBuffer hb = ByteBuffer.wrap(head).order(ByteOrder.LITTLE_ENDIAN); - int version = hb.getShort(4) & 0xFFFF; - int w = hb.getShort(6) & 0xFFFF; - int h = hb.getShort(8) & 0xFFFF; - int seq = hb.getShort(10) & 0xFFFF; - if (version != PROTOCOL || w <= 0 || h <= 0) return null; - return new int[] { version, w, h, seq }; - } - - /** A point on the panel, in the bottom screen's own pixels -- or null when - * it landed in the letterbox, which is not a tap on anything. */ - public static int[] mapPoint(float px, float py, int dl, int dt, int dw, int dh, - int sw, int sh) { - if (dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0) return null; - int x = (int) Math.floor((px - dl) / (float) dw * sw); - int y = (int) Math.floor((py - dt) / (float) dh * sh); - if (x < 0 || y < 0 || x >= sw || y >= sh) return null; - return new int[] { x, y }; - } - - private void readFrameOnce(PanelPresentation panel) { - File f = frameFile; - if (f == null || !f.isFile()) return; - long len = f.length(); - if (len < HEADER_BYTES) 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; - 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; - byte[] rgba = new byte[w * h * 4]; - raf.readFully(rgba); - lastSeq = seq; - frameW = w; frameH = h; - panel.post(w, h, rgba); - } catch (Throwable ignored) { - } finally { - if (raf != null) try { raf.close(); } catch (Throwable ignored) { } - } - } - - private void appendTouch(String kind, int id, int x, int y) { - File f = touchFile; - if (f == null) return; - FileOutputStream os = null; - try { - os = new FileOutputStream(f, true); - os.write((kind + " " + id + " " + x + " " + y + "\n") - .getBytes(Charset.forName("UTF-8"))); - } catch (Throwable ignored) { - } finally { - if (os != null) try { os.close(); } catch (Throwable ignored) { } - } - } - - /* ------------------------------------------------------------------ * - * The panel itself. - * ------------------------------------------------------------------ */ - - private final class PanelPresentation extends Presentation { - private PanelView view; - - PanelPresentation(Context outer, Display display) { super(outer, display); } - - @Override protected void onCreate(Bundle state) { - super.onCreate(state); - view = new PanelView(getContext()); - setContentView(view); - } - - int panelWidth() { return view == null ? 0 : Math.max(view.getWidth(), 1); } - int panelHeight() { return view == null ? 0 : Math.max(view.getHeight(), 1); } - - void post(final int w, final int h, final byte[] rgba) { - final PanelView v = view; - if (v == null) return; - ui.post(new Runnable() { - @Override public void run() { v.accept(w, h, rgba); } - }); - } - } - - private final class PanelView extends View { - private Bitmap bitmap; - private final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG); - private final Rect dst = new Rect(); - private int srcW = 0, srcH = 0; - - PanelView(Context c) { - super(c); - setBackgroundColor(Color.BLACK); - } - - 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)); - invalidate(); - } - - @Override protected void onDraw(Canvas canvas) { - canvas.drawColor(Color.BLACK); - Bitmap b = bitmap; - if (b == null || b.isRecycled()) return; - // Letterboxed, integer-agnostic: the panel is rarely 4:3, and stretching - // a 256x192 bottom screen to fill it would skew every Poketch dial. - int vw = getWidth(), vh = getHeight(); - if (vw <= 0 || vh <= 0) return; - float s = Math.min((float) vw / srcW, (float) vh / srcH); - int dw = Math.max(1, Math.round(srcW * s)); - int dh = Math.max(1, Math.round(srcH * s)); - dst.set((vw - dw) / 2, (vh - dh) / 2, (vw - dw) / 2 + dw, (vh - dh) / 2 + dh); - canvas.drawBitmap(b, null, dst, paint); - } - @Override public boolean onTouchEvent(MotionEvent e) { - if (srcW <= 0 || srcH <= 0 || dst.width() <= 0) return false; - int action = e.getActionMasked(); - String kind; - switch (action) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_POINTER_DOWN: kind = "down"; break; - case MotionEvent.ACTION_MOVE: kind = "move"; break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_POINTER_UP: - case MotionEvent.ACTION_CANCEL: kind = "up"; break; - default: return false; - } - if ("move".equals(kind)) { - for (int i = 0; i < e.getPointerCount(); i++) emit(kind, e, i); - } else { - emit(kind, e, e.getActionIndex()); - } - return true; - } - - private void emit(String kind, MotionEvent e, int index) { - // MAPPED INTO THE BOTTOM SCREEN'S OWN COORDINATES, not the panel's. - // Lua rejects anything outside 0..w/0..h, so a tap in the letterbox - // must land outside that range rather than being clamped onto the edge - // -- a clamped tap is a button press the player did not make. - int[] p = mapPoint(e.getX(index), e.getY(index), dst.left, dst.top, - dst.width(), dst.height(), srcW, srcH); - if (p == null) return; - appendTouch(kind, e.getPointerId(index), p[0], p[1]); - } - } -} From 1035cea55050630429e745b9461040314c9bf7a2 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:46:24 -0400 Subject: [PATCH 02/37] fix(gen4): retry failed second-screen frame pushes --- src/ui/SecondScreen.lua | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index 700b6010..bf129e50 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -361,13 +361,18 @@ 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 + local okPush, pushed = pcall(T.push, data, W, H) + 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 From 2fd7e5016d5fe080afd6661934352b5c63837245 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:46:52 -0400 Subject: [PATCH 03/37] fix(android): restore and harden second display host --- mobile/second-display/SecondDisplayHost.java | 218 ++++++++++++++++++- 1 file changed, 207 insertions(+), 11 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index cf19f5b0..c8d24502 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -174,7 +174,6 @@ private Display secondaryDisplay() { Log.w(TAG, "DisplayManager unavailable"); return null; } - Display[] all = dm.getDisplays(); if (all != null) { for (Display d : all) { @@ -184,12 +183,8 @@ private Display secondaryDisplay() { + " state=" + d.getState()); } } - Display[] ds = dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); if (ds != null && ds.length > 0) return ds[0]; - - // Some dual-screen devices do not mark their built-in lower panel as a - // presentation display. Any non-default Android Display is usable. if (all != null) { for (Display d : all) { if (d.getDisplayId() != Display.DEFAULT_DISPLAY) return d; @@ -204,8 +199,6 @@ private void attach(Activity activity) { ensurePresentation(); } - /** Runs on the UI thread. Safe to call repeatedly while the pump waits for - * Android to expose a late/hot-plugged secondary display. */ private void ensurePresentation() { if (Looper.myLooper() != Looper.getMainLooper()) { ui.post(new Runnable() { @@ -227,7 +220,6 @@ private void ensurePresentation() { d.getSize(size); 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 { p.show(); @@ -255,6 +247,25 @@ private void detach() { }); } + /* ------------------------------------------------------------------ * + * The handshake, and the frames. + * ------------------------------------------------------------------ */ + + private void writeHost(int displays, int w, int h) { + byte[] line = (PROTOCOL + " " + displays + " " + w + " " + h + "\n") + .getBytes(Charset.forName("UTF-8")); + for (File dir : roots) { + FileOutputStream os = null; + try { + os = new FileOutputStream(new File(dir, HOST_FILE), false); + os.write(line); + } catch (Throwable ignored) { + } finally { + if (os != null) try { os.close(); } catch (Throwable ignored) { } + } + } + } + private void startPump() { running = true; pump = new Thread(new Runnable() { @@ -265,9 +276,6 @@ private void startPump() { long now = android.os.SystemClock.uptimeMillis(); PanelPresentation panel = presentation; - // AYN and other dual-screen firmware can publish the lower Display - // after the Activity has already resumed. Keep looking instead of - // permanently settling into the one-display state. if (panel == null && now - lastDiscovery >= HOST_INTERVAL_MS) { lastDiscovery = now; ui.post(new Runnable() { @@ -293,4 +301,192 @@ private void startPump() { pump.start(); } + /** 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. */ + public static int[] decodeHeader(byte[] head) { + if (head == null || head.length < HEADER_BYTES) return null; + if (head[0] != 'G' || head[1] != '2' || head[2] != 'S' || head[3] != 'D') { + return null; + } + ByteBuffer hb = ByteBuffer.wrap(head).order(ByteOrder.LITTLE_ENDIAN); + int version = hb.getShort(4) & 0xFFFF; + int w = hb.getShort(6) & 0xFFFF; + int h = hb.getShort(8) & 0xFFFF; + int seq = hb.getShort(10) & 0xFFFF; + if (version != PROTOCOL || w <= 0 || h <= 0) return null; + return new int[] { version, w, h, seq }; + } + + /** A point on the panel, in the bottom screen's own pixels -- or null when + * it landed in the letterbox, which is not a tap on anything. */ + public static int[] mapPoint(float px, float py, int dl, int dt, int dw, int dh, + int sw, int sh) { + if (dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0) return null; + int x = (int) Math.floor((px - dl) / (float) dw * sw); + int y = (int) Math.floor((py - dt) / (float) dh * sh); + if (x < 0 || y < 0 || x >= sw || y >= sh) return null; + 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 void readFrameOnce(PanelPresentation panel) { + File f = findFrameFile(); + if (f == null || !f.isFile()) return; + long len = f.length(); + if (len < HEADER_BYTES) 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; + 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; + byte[] rgba = new byte[w * h * 4]; + raf.readFully(rgba); + lastSeq = seq; + frameW = w; frameH = h; + panel.post(w, h, rgba); + Log.d(TAG, "frame 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) { } + } + } + + private void appendTouch(String kind, int id, int x, int y) { + File f = touchFile; + if (f == null) return; + FileOutputStream os = null; + try { + os = new FileOutputStream(f, true); + os.write((kind + " " + id + " " + x + " " + y + "\n") + .getBytes(Charset.forName("UTF-8"))); + } catch (Throwable ignored) { + } finally { + if (os != null) try { os.close(); } catch (Throwable ignored) { } + } + } + /* ------------------------------------------------------------------ * + * The panel itself. + * ------------------------------------------------------------------ */ + + private final class PanelPresentation extends Presentation { + private PanelView view; + + PanelPresentation(Context outer, Display display) { super(outer, display); } + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + view = new PanelView(getContext()); + setContentView(view); + } + + int panelWidth() { return view == null ? 0 : Math.max(view.getWidth(), 1); } + int panelHeight() { return view == null ? 0 : Math.max(view.getHeight(), 1); } + + void post(final int w, final int h, final byte[] rgba) { + final PanelView v = view; + if (v == null) return; + ui.post(new Runnable() { + @Override public void run() { v.accept(w, h, rgba); } + }); + } + } + + private final class PanelView extends View { + private Bitmap bitmap; + private final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG); + private final Rect dst = new Rect(); + private int srcW = 0, srcH = 0; + + PanelView(Context c) { + super(c); + setBackgroundColor(Color.BLACK); + } + + 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)); + invalidate(); + } + + @Override protected void onDraw(Canvas canvas) { + canvas.drawColor(Color.BLACK); + Bitmap b = bitmap; + if (b == null || b.isRecycled()) return; + // Letterboxed, integer-agnostic: the panel is rarely 4:3, and stretching + // a 256x192 bottom screen to fill it would skew every Poketch dial. + int vw = getWidth(), vh = getHeight(); + if (vw <= 0 || vh <= 0) return; + float s = Math.min((float) vw / srcW, (float) vh / srcH); + int dw = Math.max(1, Math.round(srcW * s)); + int dh = Math.max(1, Math.round(srcH * s)); + dst.set((vw - dw) / 2, (vh - dh) / 2, (vw - dw) / 2 + dw, (vh - dh) / 2 + dh); + canvas.drawBitmap(b, null, dst, paint); + } + + @Override public boolean onTouchEvent(MotionEvent e) { + if (srcW <= 0 || srcH <= 0 || dst.width() <= 0) return false; + int action = e.getActionMasked(); + String kind; + switch (action) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: kind = "down"; break; + case MotionEvent.ACTION_MOVE: kind = "move"; break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_CANCEL: kind = "up"; break; + default: return false; + } + if ("move".equals(kind)) { + for (int i = 0; i < e.getPointerCount(); i++) emit(kind, e, i); + } else { + emit(kind, e, e.getActionIndex()); + } + return true; + } + + private void emit(String kind, MotionEvent e, int index) { + // MAPPED INTO THE BOTTOM SCREEN'S OWN COORDINATES, not the panel's. + // Lua rejects anything outside 0..w/0..h, so a tap in the letterbox + // must land outside that range rather than being clamped onto the edge + // -- a clamped tap is a button press the player did not make. + int[] p = mapPoint(e.getX(index), e.getY(index), dst.left, dst.top, + dst.width(), dst.height(), srcW, srcH); + if (p == null) return; + appendTouch(kind, e.getPointerId(index), p[0], p[1]); + } + } +} From 285a83fe7e69cd97cd00bdf933cbead3d4bc8f0b Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:09:50 -0400 Subject: [PATCH 04/37] fix(android): prefer active Java second-display host over stale FFI bridge --- src/render/SecondScreen.lua | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index 6160f61e..952f10a8 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -221,13 +221,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 +246,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 +271,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) From 25add0d0ebe37d90f4770f3bfaa433323071f70c Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:16:40 -0400 Subject: [PATCH 05/37] fix(android): decode LOVE RGBA frames explicitly --- mobile/second-display/SecondDisplayHost.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index c8d24502..bfce49c6 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -436,9 +436,19 @@ void accept(int w, int h, byte[] rgba) { 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(); } From c5502a309110a8709c7d95b178dfba8acd14ddf6 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:16:49 -0400 Subject: [PATCH 06/37] fix(gen4): track lower-screen ownership per frame --- src/ui/SecondScreen.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index bf129e50..6d97cfed 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -261,6 +261,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 From 2ff2ff043d0b4beb6c8996779f15106c1e6fafcf Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:16:54 -0400 Subject: [PATCH 07/37] fix(gen4): render persistent Poketch on physical lower display --- src/core/Game.lua | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/core/Game.lua b/src/core/Game.lua index 2b5de9df..68049200 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -846,7 +846,31 @@ 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() + + 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. -- From 1b9b9ba44e7b428f0e5d3a00d3a5f62ac4686aa3 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:21:03 -0400 Subject: [PATCH 08/37] fix(second-display): commit frames with sequence trailer --- src/render/SecondScreen.lua | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index 952f10a8..e36ba09c 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -187,12 +187,13 @@ function SecondScreen.filePush(imageData, w, h) 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) + + -- Commit marker goes LAST. Every frame has the same final byte length, so + -- length alone cannot detect an in-place overwrite where Java sees the new + -- header while some rows still belong to the previous frame. The host only + -- accepts a frame when this trailing sequence matches the header sequence. + local trailer = "G2OK" .. u16(fileSeq) + local ok = pcall(f.write, SecondScreen.FRAME, header .. body .. trailer) return ok and true or false end From 5692627c16f69cc674bdc853fd849b08c92f85d1 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:21:07 -0400 Subject: [PATCH 09/37] fix(android): reject torn second-display frames --- mobile/second-display/SecondDisplayHost.java | 27 ++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index bfce49c6..11a8c530 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -359,13 +359,30 @@ private void readFrameOnce(PanelPresentation panel) { if (hdr == null) 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; + final int TRAILER_BYTES = 6; + long want = (long) HEADER_BYTES + (long) w * h * 4L + TRAILER_BYTES; + if (len != want) return; + byte[] rgba = new byte[w * h * 4]; raf.readFully(rgba); + + // The writer commits the frame only after every RGBA byte has been + // written. If we raced an overwrite, this trailer is either absent, + // still carries the previous sequence, or changes under us. + byte[] tail = new byte[TRAILER_BYTES]; + raf.readFully(tail); + if (tail[0] != 'G' || tail[1] != '2' || tail[2] != 'O' || tail[3] != 'K') return; + int tailSeq = (tail[4] & 0xff) | ((tail[5] & 0xff) << 8); + if (tailSeq != seq) return; + + // Re-read the header after the payload too. This closes the opposite + // race: a new write beginning after our first header read. + 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) return; + lastSeq = seq; frameW = w; frameH = h; panel.post(w, h, rgba); From 9c3ac11a3f4292b8d368b6d88bb4f9b58ce5478e Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:26:45 -0400 Subject: [PATCH 10/37] fix(second-display): restore compatible frame payload --- src/render/SecondScreen.lua | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index e36ba09c..bbfc4d0e 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -187,13 +187,7 @@ function SecondScreen.filePush(imageData, w, h) fileSeq = (fileSeq + 1) % 65536 local header = SecondScreen.MAGIC .. u16(SecondScreen.PROTOCOL) .. u16(w) .. u16(h) .. u16(fileSeq) - - -- Commit marker goes LAST. Every frame has the same final byte length, so - -- length alone cannot detect an in-place overwrite where Java sees the new - -- header while some rows still belong to the previous frame. The host only - -- accepts a frame when this trailing sequence matches the header sequence. - local trailer = "G2OK" .. u16(fileSeq) - local ok = pcall(f.write, SecondScreen.FRAME, header .. body .. trailer) + local ok = pcall(f.write, SecondScreen.FRAME, header .. body) return ok and true or false end From 6f1f742ebeb8a28f765186bb1c9f501e83e4962d Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:26:48 -0400 Subject: [PATCH 11/37] fix(android): restore compatible frame reader --- mobile/second-display/SecondDisplayHost.java | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index 11a8c530..ad043c63 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -359,24 +359,15 @@ private void readFrameOnce(PanelPresentation panel) { if (hdr == null) return; int w = hdr[1], h = hdr[2], seq = hdr[3]; if (seq == lastSeq) return; - final int TRAILER_BYTES = 6; - long want = (long) HEADER_BYTES + (long) w * h * 4L + TRAILER_BYTES; + long want = (long) HEADER_BYTES + (long) w * h * 4L; if (len != want) return; byte[] rgba = new byte[w * h * 4]; raf.readFully(rgba); - // The writer commits the frame only after every RGBA byte has been - // written. If we raced an overwrite, this trailer is either absent, - // still carries the previous sequence, or changes under us. - byte[] tail = new byte[TRAILER_BYTES]; - raf.readFully(tail); - if (tail[0] != 'G' || tail[1] != '2' || tail[2] != 'O' || tail[3] != 'K') return; - int tailSeq = (tail[4] & 0xff) | ((tail[5] & 0xff) << 8); - if (tailSeq != seq) return; - - // Re-read the header after the payload too. This closes the opposite - // race: a new write beginning after our first header read. + // Re-read the header after the payload. If Lua started another write + // while this frame was being copied, the sequence/header changes and + // this sample is discarded. raf.seek(0); byte[] verifyHead = new byte[HEADER_BYTES]; raf.readFully(verifyHead); From b81174c962aaca31da96274985283aa3617a769e Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:30:42 -0400 Subject: [PATCH 12/37] debug(android): expose second-display render and transport state --- mobile/second-display/SecondDisplayHost.java | 63 +++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index ad043c63..2b70dc84 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -345,39 +345,62 @@ private File findFrameFile() { return newest; } + private long lastRejectLog = 0; + + private void rejectLog(String reason) { + long now = android.os.SystemClock.uptimeMillis(); + if (now - lastRejectLog >= 1000) { + lastRejectLog = now; + Log.w(TAG, "FRAME REJECT: " + reason); + } + } + private void readFrameOnce(PanelPresentation panel) { File f = findFrameFile(); - if (f == null || !f.isFile()) return; + if (f == null || !f.isFile()) { + rejectLog("no frame.bin found in save roots"); + return; + } long len = f.length(); - if (len < HEADER_BYTES) return; + if (len < HEADER_BYTES) { + rejectLog("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("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; - if (len != want) return; + if (len != want) { + rejectLog("size mismatch seq=" + seq + " got=" + len + " want=" + want + + " dimensions=" + w + "x" + h); + return; + } byte[] rgba = new byte[w * h * 4]; raf.readFully(rgba); - // Re-read the header after the payload. If Lua started another write - // while this frame was being copied, the sequence/header changes and - // this sample is discarded. 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) return; + if (verify == null || verify[3] != seq || verify[1] != w || verify[2] != h) { + rejectLog("header changed during read seq=" + seq); + return; + } lastSeq = seq; frameW = w; frameH = h; panel.post(w, h, rgba); - Log.d(TAG, "frame seq=" + seq + " size=" + w + "x" + h + 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); @@ -413,6 +436,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); } @@ -438,6 +462,27 @@ private final class PanelView extends View { setBackgroundColor(Color.BLACK); } + 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(); From eb7c52432055fcf895b97657a0767b914e1f5f88 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:34:22 -0400 Subject: [PATCH 13/37] debug(android): show frame transport failures on lower display --- mobile/second-display/SecondDisplayHost.java | 59 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index 2b70dc84..599bc915 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -347,23 +347,32 @@ private File findFrameFile() { private long lastRejectLog = 0; - private void rejectLog(String reason) { + private int lastVisibleReject = -1; + + 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 (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 = findFrameFile(); if (f == null || !f.isFile()) { - rejectLog("no frame.bin found in save roots"); + rejectLog(panel, 1, "no frame.bin found in save roots"); return; } long len = f.length(); if (len < HEADER_BYTES) { - rejectLog("short file len=" + len); + rejectLog(panel, 2, "short file len=" + len); return; } RandomAccessFile raf = null; @@ -373,14 +382,14 @@ private void readFrameOnce(PanelPresentation panel) { raf.readFully(head); int[] hdr = decodeHeader(head); if (hdr == null) { - rejectLog("invalid G2SD header len=" + len); + 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; if (len != want) { - rejectLog("size mismatch seq=" + seq + " got=" + len + " want=" + want + rejectLog(panel, 2, "size mismatch seq=" + seq + " got=" + len + " want=" + want + " dimensions=" + w + "x" + h); return; } @@ -393,12 +402,13 @@ private void readFrameOnce(PanelPresentation panel) { raf.readFully(verifyHead); int[] verify = decodeHeader(verifyHead); if (verify == null || verify[3] != seq || verify[1] != w || verify[2] != h) { - rejectLog("header changed during read seq=" + seq); + rejectLog(panel, 4, "header changed during read seq=" + seq); return; } lastSeq = seq; frameW = w; frameH = h; + lastVisibleReject = 5; panel.post(w, h, rgba); Log.i(TAG, "FRAME ACCEPT seq=" + seq + " size=" + w + "x" + h + " bytes=" + len + " path=" + f.getAbsolutePath()); @@ -462,6 +472,43 @@ private final class PanelView extends View { setBackgroundColor(Color.BLACK); } + 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]; From 4f7a1763d97d423b24d09b958d1cf08b5b9980b2 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:38:22 -0400 Subject: [PATCH 14/37] fix(android): accept stale diagnostic frame variant --- mobile/second-display/SecondDisplayHost.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index 599bc915..797da2c8 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -388,7 +388,8 @@ private void readFrameOnce(PanelPresentation panel) { int w = hdr[1], h = hdr[2], seq = hdr[3]; if (seq == lastSeq) return; long want = (long) HEADER_BYTES + (long) w * h * 4L; - if (len != want) { + boolean legacyTrailer = len == want + 6L; + if (len != want && !legacyTrailer) { rejectLog(panel, 2, "size mismatch seq=" + seq + " got=" + len + " want=" + want + " dimensions=" + w + "x" + h); return; @@ -397,6 +398,20 @@ private void readFrameOnce(PanelPresentation panel) { 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); From a38aeac46e0ee4be83c7175e329caa5f8861abfd Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:41:39 -0400 Subject: [PATCH 15/37] fix(second-display): validate RGBA frame payload before write --- src/render/SecondScreen.lua | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index bbfc4d0e..bbbcf73e 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -184,11 +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) - 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 From 1a93d62375eecc1f0ddcd65505ee1a6b9b0409d3 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:44:56 -0400 Subject: [PATCH 16/37] debug(android): render frame size mismatch values on lower display --- mobile/second-display/SecondDisplayHost.java | 44 +++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index 797da2c8..3c21aad9 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -390,8 +390,23 @@ private void readFrameOnce(PanelPresentation panel) { long want = (long) HEADER_BYTES + (long) w * h * 4L; boolean legacyTrailer = len == want + 6L; if (len != want && !legacyTrailer) { - rejectLog(panel, 2, "size mismatch seq=" + seq + " got=" + len + " want=" + want - + " dimensions=" + w + "x" + h); + if (panel != null && panel.view != null) { + final PanelView v = panel.view; + final long actualLen = len, expectedLen = want; + final int fw = w, fh = h, fseq = seq; + ui.post(new Runnable() { + @Override public void run() { + v.showSizeMismatch(actualLen, expectedLen, fw, fh, fseq); + } + }); + } + // Do not replace the detailed screen with the generic two-bar screen. + long now = android.os.SystemClock.uptimeMillis(); + if (now - lastRejectLog >= 1000) { + lastRejectLog = now; + Log.w(TAG, "FRAME REJECT: size mismatch seq=" + seq + " got=" + len + + " want=" + want + " dimensions=" + w + "x" + h); + } return; } @@ -487,6 +502,31 @@ 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, From 5eab3a4e1e5d3e3fcaa74d3979a04702240d0128 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:50:35 -0400 Subject: [PATCH 17/37] fix(second-display): disable DPI scaling on DS framebuffer --- src/ui/SecondScreen.lua | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index 6d97cfed..49e283a1 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -249,7 +249,24 @@ local function surface(game) if game.secondScreenCanvas then return game.secondScreenCanvas end 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, W, H, { + 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, W, H, { format = "rgba8", dpiscale = 1 }) + end + if not (ok and made) then + ok, made = pcall(g.newCanvas, W, H) + end if not (ok and made) then return nil end if made.setFilter then pcall(made.setFilter, made, "nearest", "nearest") end game.secondScreenCanvas = made @@ -362,7 +379,17 @@ 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 okPush, pushed = pcall(T.push, data, W, H) + -- 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 From 74634443d21ca6f67b290c8d2fe796b189627aa8 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:54:30 -0400 Subject: [PATCH 18/37] fix(android): keep last good frame during concurrent writes --- mobile/second-display/SecondDisplayHost.java | 23 ++++++++------------ 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/mobile/second-display/SecondDisplayHost.java b/mobile/second-display/SecondDisplayHost.java index 3c21aad9..ec4666e3 100644 --- a/mobile/second-display/SecondDisplayHost.java +++ b/mobile/second-display/SecondDisplayHost.java @@ -348,6 +348,7 @@ private File findFrameFile() { 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(); @@ -355,7 +356,7 @@ private void rejectLog(final PanelPresentation panel, final int code, String rea lastRejectLog = now; Log.w(TAG, "FRAME REJECT: " + reason); } - if (lastVisibleReject != code && panel != null && panel.view != null) { + if (!haveAcceptedFrame && lastVisibleReject != code && panel != null && panel.view != null) { lastVisibleReject = code; final PanelView v = panel.view; ui.post(new Runnable() { @@ -390,22 +391,15 @@ private void readFrameOnce(PanelPresentation panel) { long want = (long) HEADER_BYTES + (long) w * h * 4L; boolean legacyTrailer = len == want + 6L; if (len != want && !legacyTrailer) { - if (panel != null && panel.view != null) { - final PanelView v = panel.view; - final long actualLen = len, expectedLen = want; - final int fw = w, fh = h, fseq = seq; - ui.post(new Runnable() { - @Override public void run() { - v.showSizeMismatch(actualLen, expectedLen, fw, fh, fseq); - } - }); - } - // Do not replace the detailed screen with the generic two-bar screen. + // 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 REJECT: size mismatch seq=" + seq + " got=" + len - + " want=" + want + " dimensions=" + w + "x" + h); + Log.w(TAG, "FRAME RETRY: transient size mismatch seq=" + seq + + " got=" + len + " want=" + want + " dimensions=" + w + "x" + h); } return; } @@ -439,6 +433,7 @@ private void readFrameOnce(PanelPresentation panel) { lastSeq = seq; frameW = w; frameH = h; lastVisibleReject = 5; + haveAcceptedFrame = true; panel.post(w, h, rgba); Log.i(TAG, "FRAME ACCEPT seq=" + seq + " size=" + w + "x" + h + " bytes=" + len + " path=" + f.getAbsolutePath()); From e30e0d74ec328130d52c4d9300fa560f2a5a7277 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:57:12 -0400 Subject: [PATCH 19/37] feat(second-display): support native-resolution launcher canvas --- src/ui/SecondScreen.lua | 43 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index 49e283a1..fc04b801 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -244,9 +244,34 @@ 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) + 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 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 -- Offscreen DS pixels must be device-independent. On high-DPI Android, @@ -254,7 +279,7 @@ local function surface(game) -- 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, W, H, { + local ok, made = pcall(g.newCanvas, sw, sh, { format = "rgba8", dpiscale = 1, readable = true, @@ -262,14 +287,16 @@ local function surface(game) -- 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, W, H, { format = "rgba8", dpiscale = 1 }) + ok, made = pcall(g.newCanvas, sw, sh, { format = "rgba8", dpiscale = 1 }) end if not (ok and made) then - ok, made = pcall(g.newCanvas, W, H) + 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 @@ -300,6 +327,10 @@ 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) + g.scale(sw / W, sh / H) + end local ok, err = pcall(body) g.setCanvas(previous) g.pop() @@ -449,6 +480,10 @@ 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 game.secondScreenNativePanel and x and y then + local sw, sh = surfaceSize(game) + x, y = x * W / sw, y * H / sh + end if not (x and y) or x < 0 or y < 0 or x >= W or y >= H then return false end local handler = game[method] if type(handler) ~= "function" then return false end From dcebf715c53898803cd3c9a65bfef547823e446f Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:57:17 -0400 Subject: [PATCH 20/37] feat(launcher): render second panel at device native resolution --- src/ui/LauncherSecondScreen.lua | 5 +++++ 1 file changed, 5 insertions(+) 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" } }, } From e67f18740161c4cc2a96350f07e057dbbc8f4486 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:11:34 -0400 Subject: [PATCH 21/37] fix(poketch): composite app LCD beneath extracted device shell --- src/ui/Gen4Poketch.lua | 58 ++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/src/ui/Gen4Poketch.lua b/src/ui/Gen4Poketch.lua index bd0f03d8..71a125b0 100644 --- a/src/ui/Gen4Poketch.lua +++ b/src/ui/Gen4Poketch.lua @@ -328,48 +328,56 @@ 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 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) + -- 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 From 632185a416b3ef2df8eef7b26000dec7db70055e Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:22:22 -0400 Subject: [PATCH 22/37] fix(poketch): clip app tilemaps to LCD viewport --- src/ui/Gen4Poketch.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ui/Gen4Poketch.lua b/src/ui/Gen4Poketch.lua index 71a125b0..1b5ff906 100644 --- a/src/ui/Gen4Poketch.lua +++ b/src/ui/Gen4Poketch.lua @@ -333,6 +333,9 @@ function Gen4Poketch:drawWatch() -- 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 @@ -363,6 +366,8 @@ function Gen4Poketch:drawWatch() end end + 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. From 1dd875c74c15374219a2644de0dbd32799989c2e Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:22:24 -0400 Subject: [PATCH 23/37] feat(second-display): add live Gen1-3 save companion --- src/ui/SaveCompanion.lua | 176 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/ui/SaveCompanion.lua diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua new file mode 100644 index 00000000..a1693a05 --- /dev/null +++ b/src/ui/SaveCompanion.lua @@ -0,0 +1,176 @@ +-- Native lower-screen save companion for single-screen generations. +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 GameVersion = require("src.core.GameVersion") + +local M = {} +local W,H=256,192 +local tabs={"PARTY","BOXES","BAG","PLAYER"} +local state={tab=1,box=1,bagOffset=0,selectedBoxSlot=nil,selectedParty=nil,host=nil} + +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 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 tostring(id) +end +local function rect(x,y,w,h,fill) + local g=love.graphics + if fill then g.setColor(fill[1],fill[2],fill[3],fill[4] or 1); g.rectangle("fill",x,y,w,h) end + g.setColor(1,1,1,.28); g.rectangle("line",x,y,w,h) +end +local function text(s,x,y) + love.graphics.setColor(1,1,1,1); love.graphics.print(tostring(s),x,y) +end +local function tabAt(x,y) + if y>28 then return nil end + local tw=W/#tabs + local i=math.floor(x/tw)+1 + return (i>=1 and i<=#tabs) and i or nil +end + +local function boxes(game) + Boxes.load(game.data) + return Boxes.ensure(game.save) +end + +local function drawParty(game) + local party=game.save.party or {} + text("PARTY "..#party.."/"..Party.MAX,6,34) + for i=1,Party.MAX do + local y=50+(i-1)*22 + local mon=party[i] + local sel=state.selectedParty==i + rect(6,y,244,19,sel and {.20,.42,.32,1} or {.10,.12,.16,1}) + if mon then + text(i..". "..speciesName(game,mon),12,y+4) + text("Lv"..tostring(mon.level or "?"),190,y+4) + else text(i..". --",12,y+4) end + end +end + +local function drawBoxes(game) + local bs=boxes(game); local count=Boxes.count(); local cap=Boxes.capacity() + state.box=math.max(1,math.min(count,state.box or game.save.currentBox or 1)) + local box=bs[state.box] + text("< BOX "..state.box.."/"..count.." >",8,34) + local cols=5; local cellW=47; local cellH=24 + for i=1,math.min(cap,30) do + local c=(i-1)%cols; local r=math.floor((i-1)/cols) + local x=6+c*50; local y=52+r*27 + local mon=box[i]; local sel=state.selectedBoxSlot==i + rect(x,y,47,24,sel and {.25,.34,.48,1} or {.10,.12,.16,1}) + if mon then text(speciesName(game,mon):sub(1,7),x+3,y+3); text("L"..tostring(mon.level or "?"),x+3,y+13) + else text(tostring(i),x+18,y+8) end + end + text("Tap slot: withdraw Party tab: deposit",8,177) +end + +local function bagList(game) + game.save.inventory=game.save.inventory or {} + return Bag.order(game.save) +end +local function drawBag(game) + local ids=bagList(game); local visible=7 + state.bagOffset=math.max(0,math.min(math.max(0,#ids-visible),state.bagOffset or 0)) + text("BAG "..#ids.." slots",8,34) + for row=1,visible do + local idx=state.bagOffset+row; local id=ids[idx] + local y=50+(row-1)*18 + rect(6,y,244,16,{.10,.12,.16,1}) + if id then + text(itemName(game,id):sub(1,24),10,y+2) + text("x"..tostring(game.save.inventory[id] or 0),210,y+2) + end + end + text("^",232,34); text("v",244,34) +end + +local function drawPlayer(game) + local p=game.save.player or {} + local badges=0 + for k,v in pairs(game.save.inventory or {}) do if v and tostring(k):find("BADGE",1,true) then badges=badges+1 end end + text("PLAYER",8,36) + text("Name: "..tostring(p.name or game.save.playerName or "PLAYER"),8,58) + text("Money: $"..tostring(game.save.money or 0),8,78) + text("Badges: "..badges,8,98) + text("Version: "..tostring(GameVersion.get and GameVersion.get() or "?"),8,118) + text("Live companion - changes use current save",8,156) +end + +local function draw(game) + local g=love.graphics + g.setColor(.035,.045,.065,1); g.rectangle("fill",0,0,W,H) + local tw=W/#tabs + for i,label in ipairs(tabs) do + rect((i-1)*tw,0,tw,28,i==state.tab and {.18,.34,.55,1} or {.08,.10,.14,1}) + text(label,(i-1)*tw+5,9) + end + 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(game,x,y) + local t=tabAt(x,y); if t then state.tab=t; return end + if state.tab==1 then + if y>=50 then state.selectedParty=math.max(1,math.min(6,math.floor((y-50)/22)+1)) end + elseif state.tab==2 then + if y>=30 and y<50 then + if x<80 then state.box=math.max(1,state.box-1) elseif x>170 then state.box=math.min(Boxes.count(),state.box+1) end + return + end + if y>=52 and y<174 then + local c=math.floor((x-6)/50); local r=math.floor((y-52)/27) + if c>=0 and c<5 and r>=0 then + local slot=r*5+c+1 + local bs=boxes(game); local box=bs[state.box] + if slot<=Boxes.capacity() then + local mon=box[slot] + if mon and #(game.save.party or {})220 then + if x<242 then state.bagOffset=math.max(0,state.bagOffset-1) + else state.bagOffset=math.min(math.max(0,#ids-visible),state.bagOffset+1) end + end + end +end + +local function hostFor(game) + if not state.host then + local h={secondScreenAlways=true,secondScreenNativePanel=true,data={},save={options={secondScreenMode="display"}}} + function h.touchpressed(_,_,x,y) touch(h.game,x,y) end + function h.touchmoved() end + function h.touchreleased() end + state.host=h + end + state.host.game=game + return state.host +end + +function M.tick(game) + local gen=GameVersion.generation and GameVersion.generation(GameVersion.get()) or 1 + 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 From a0080b2f49cca3820aa9cb0c98ad5f992b9511ea Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:22:27 -0400 Subject: [PATCH 24/37] feat(game): drive Gen1-3 save companion on second display --- src/core/Game.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/Game.lua b/src/core/Game.lua index 68049200..616abb8c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -853,6 +853,13 @@ function Game:draw() 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" From 3d0d5965bc3db25afb793a35df70859956061591 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:36:28 -0400 Subject: [PATCH 25/37] feat(second-display): allow native logical companion resolution --- src/ui/SecondScreen.lua | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index fc04b801..41e863d3 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -260,6 +260,30 @@ local function surfaceSize(game) 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 local sw, sh = surfaceSize(game) @@ -329,7 +353,8 @@ function SecondScreen.draw(game, body) g.clear(0, 0, 0, 1) if game.secondScreenNativePanel then local sw, sh = surfaceSize(game) - g.scale(sw / W, sh / H) + local lw, lh = logicalSize(game) + g.scale(sw / lw, sh / lh) end local ok, err = pcall(body) g.setCanvas(previous) @@ -480,11 +505,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 + local lw, lh = logicalSize(game) if game.secondScreenNativePanel and x and y then local sw, sh = surfaceSize(game) - x, y = x * W / sw, y * H / sh + x, y = x * lw / sw, y * lh / sh end - if not (x and y) or x < 0 or y < 0 or x >= W or y >= H then return false 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 From 43061501ad3f7eec76f4e5f2b02d0018cc319636 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:39:14 -0400 Subject: [PATCH 26/37] feat(save-companion): add full native-resolution live editor --- src/ui/SaveCompanion.lua | 538 ++++++++++++++++++++++++++++++++------- 1 file changed, 442 insertions(+), 96 deletions(-) diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua index a1693a05..5387d8be 100644 --- a/src/ui/SaveCompanion.lua +++ b/src/ui/SaveCompanion.lua @@ -1,37 +1,112 @@ --- Native lower-screen save companion for single-screen generations. +-- 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 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 W,H=256,192 -local tabs={"PARTY","BOXES","BAG","PLAYER"} -local state={tab=1,box=1,bagOffset=0,selectedBoxSlot=nil,selectedParty=nil,host=nil} +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, +} +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 itemName(game,id) - local d=game.data and game.data.items and game.data.items[id] +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 rect(x,y,w,h,fill) - local g=love.graphics - if fill then g.setColor(fill[1],fill[2],fill[3],fill[4] or 1); g.rectangle("fill",x,y,w,h) end - g.setColor(1,1,1,.28); g.rectangle("line",x,y,w,h) +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 text(s,x,y) - love.graphics.setColor(1,1,1,1); love.graphics.print(tostring(s),x,y) +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)28 then return nil end - local tw=W/#tabs - local i=math.floor(x/tw)+1 - return (i>=1 and i<=#tabs) and i or nil +local function ensureCatalog(game) + if state.catalogData == game.data then return end + state.catalogData=game.data + state.moveIds=sortedKeys(game.data and game.data.moves) + state.itemIds=sortedKeys(game.data and game.data.items) end local function boxes(game) @@ -39,133 +114,404 @@ local function boxes(game) return Boxes.ensure(game.save) end +local function recalc(game,mon) + if not mon then return end + local def=game.data and game.data.pokemon and game.data.pokemon[mon.species] + if not def then return end + if type(mon.ivs)=="table" and Stats.calcGen3 then + mon.stats=Stats.calcGen3(def,mon.level or 1,mon.ivs,mon.evs,mon.nature) + else + mon.stats=Stats.calc(def,mon.level or 1,mon.dvs or {},mon.statExp) + end + mon.hp=clamp(tonumber(mon.hp) or (mon.stats.hp or 1),0,mon.stats.hp or 1) +end +local function setLevel(game,mon,delta) + if not mon then return end + local def=game.data.pokemon[mon.species]; if not def then return end + mon.level=clamp(math.floor((mon.level or 1)+delta),1,100) + mon.exp=Growth.expForLevel(def.growthRate,mon.level) + recalc(game,mon) +end +local function setHP(game,mon,delta) + if not mon then return end + recalc(game,mon) + mon.hp=clamp((mon.hp or 0)+delta,0,(mon.stats and mon.stats.hp) or 1) +end +local function syncHpDv(d) + d.hp=(d.attack%2)*8+(d.defense%2)*4+(d.speed%2)*2+(d.special%2) +end +local function adjustIndividual(game,mon,key,delta) + if type(mon.ivs)=="table" then + mon.ivs[key]=clamp((mon.ivs[key] or 0)+delta,0,31) + else + mon.dvs=mon.dvs or {attack=0,defense=0,speed=0,special=0} + mon.dvs[key]=clamp((mon.dvs[key] or 0)+delta,0,15) + syncHpDv(mon.dvs) + end + recalc(game,mon) +end +local function cycleMove(game,mon,slot,delta) + ensureCatalog(game) + local ids=state.moveIds or {}; if #ids==0 then return end + mon.moves=mon.moves or {} + local current=mon.moves[slot] and mon.moves[slot].id + local at=1 + for i,id in ipairs(ids) do if id==current then at=i break end end + at=((at-1+delta)%#ids)+1 + local id=ids[at]; local def=game.data.moves[id] + mon.moves[slot]={id=id,pp=(def and def.pp) or 0} +end + +local function openMon(mon,origin,index,box) + if not mon then return end + state.editor={mon=mon,origin=origin,index=index,box=box} +end + +local function drawHeader() + local hh=H*.13 + roundRect(0,0,W,hh,0,{0.04,0.07,0.12,1}) + local pad=W*.018; local gap=W*.008 + local tw=(W-2*pad-gap*3)/4 + for i,label in ipairs(TABS) do + local x=pad+(i-1)*(tw+gap) + button(label,x,H*.025,tw,H*.078,function() + state.tab=i; state.editor=nil; state.itemPicker=false; state.nameEditor=false + end,{color=i==state.tab and PAL.blue or PAL.panel2, + border=i==state.tab and PAL.blue2 or PAL.line,font="medium"}) + end +end + local function drawParty(game) local party=game.save.party or {} - text("PARTY "..#party.."/"..Party.MAX,6,34) - for i=1,Party.MAX do - local y=50+(i-1)*22 + local top=H*.16; local pad=W*.025; local gap=W*.018 + text("Party",pad,top,"large") + text(#party.."/"..Party.MAX,pad+W*.16,top+H*.018,"medium",PAL.muted) + local y0=top+H*.105 + local cols=2; local rows=3 + local cw=(W-2*pad-gap)/2; local ch=(H-y0-H*.045-gap*2)/3 + for i=1,6 do + local c=(i-1)%cols; local r=math.floor((i-1)/cols) + local x=pad+c*(cw+gap); local y=y0+r*(ch+gap) local mon=party[i] - local sel=state.selectedParty==i - rect(6,y,244,19,sel and {.20,.42,.32,1} or {.10,.12,.16,1}) + roundRect(x,y,cw,ch,12,mon and PAL.panel2 or PAL.panel) + stroke(x,y,cw,ch,12,mon and PAL.line or PAL.faint) if mon then - text(i..". "..speciesName(game,mon),12,y+4) - text("Lv"..tostring(mon.level or "?"),190,y+4) - else text(i..". --",12,y+4) end + text(speciesName(game,mon),x+cw*.05,y+ch*.15,"medium") + text("Lv "..tostring(mon.level or "?"),x+cw*.05,y+ch*.53,"small",PAL.muted) + local hp=(mon.hp or 0); local mx=(mon.stats and mon.stats.hp) or math.max(hp,1) + textRight("HP "..hp.."/"..mx,x,y+ch*.53,cw-cw*.05,"small", + hp<=mx*.2 and PAL.red or PAL.green) + hit(x,y,cw,ch,function() openMon(mon,"party",i) end) + else + textCenter("EMPTY",x,y+ch*.40,cw,"small",PAL.faint) + end end end local function drawBoxes(game) local bs=boxes(game); local count=Boxes.count(); local cap=Boxes.capacity() - state.box=math.max(1,math.min(count,state.box or game.save.currentBox or 1)) + state.box=clamp(state.box or game.save.currentBox or 1,1,count) local box=bs[state.box] - text("< BOX "..state.box.."/"..count.." >",8,34) - local cols=5; local cellW=47; local cellH=24 - for i=1,math.min(cap,30) do + local pad=W*.022; local top=H*.16 + button("<",pad,top,W*.07,H*.09,function() state.box=state.box==1 and count or state.box-1 end) + textCenter("BOX "..state.box.." / "..count,pad+W*.08,top+H*.015,W*.28,"large") + button(">",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*.40,"medium",PAL.muted) + local gridY=top+H*.12; local cols=(cap>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=6+c*50; local y=52+r*27 - local mon=box[i]; local sel=state.selectedBoxSlot==i - rect(x,y,47,24,sel and {.25,.34,.48,1} or {.10,.12,.16,1}) - if mon then text(speciesName(game,mon):sub(1,7),x+3,y+3); text("L"..tostring(mon.level or "?"),x+3,y+13) - else text(tostring(i),x+18,y+8) end + 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) + end end - text("Tap slot: withdraw Party tab: deposit",8,177) end -local function bagList(game) +local function bagIds(game) game.save.inventory=game.save.inventory or {} return Bag.order(game.save) end local function drawBag(game) - local ids=bagList(game); local visible=7 - state.bagOffset=math.max(0,math.min(math.max(0,#ids-visible),state.bagOffset or 0)) - text("BAG "..#ids.." slots",8,34) - for row=1,visible do - local idx=state.bagOffset+row; local id=ids[idx] - local y=50+(row-1)*18 - rect(6,y,244,16,{.10,.12,.16,1}) - if id then - text(itemName(game,id):sub(1,24),10,y+2) - text("x"..tostring(game.save.inventory[id] or 0),210,y+2) + 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 - text("^",232,34); text("v",244,34) + + 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) + local key=Badges.itemFor(entry) + local on=Badges.has(game.save,entry) + local gen=generation() + if gen==2 or gen==3 then + game.save.flags=game.save.flags or {} + game.save.flags[key]=on and nil or true + if game.save.inventory then game.save.inventory[key]=nil end + else + game.save.inventory=game.save.inventory or {} + game.save.inventory[key]=on and nil or 1 + end +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) - local p=game.save.player or {} - local badges=0 - for k,v in pairs(game.save.inventory or {}) do if v and tostring(k):find("BADGE",1,true) then badges=badges+1 end end - text("PLAYER",8,36) - text("Name: "..tostring(p.name or game.save.playerName or "PLAYER"),8,58) - text("Money: $"..tostring(game.save.money or 0),8,78) - text("Badges: "..badges,8,98) - text("Version: "..tostring(GameVersion.get and GameVersion.get() or "?"),8,118) - text("Live companion - changes use current save",8,156) + 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 draw(game) - local g=love.graphics - g.setColor(.035,.045,.065,1); g.rectangle("fill",0,0,W,H) - local tw=W/#tabs - for i,label in ipairs(tabs) do - rect((i-1)*tw,0,tw,28,i==state.tab and {.18,.34,.55,1} or {.08,.10,.14,1}) - text(label,(i-1)*tw+5,9) +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) + + 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=3 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 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.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(game,x,y) - local t=tabAt(x,y); if t then state.tab=t; return end - if state.tab==1 then - if y>=50 then state.selectedParty=math.max(1,math.min(6,math.floor((y-50)/22)+1)) end - elseif state.tab==2 then - if y>=30 and y<50 then - if x<80 then state.box=math.max(1,state.box-1) elseif x>170 then state.box=math.min(Boxes.count(),state.box+1) end - return - end - if y>=52 and y<174 then - local c=math.floor((x-6)/50); local r=math.floor((y-52)/27) - if c>=0 and c<5 and r>=0 then - local slot=r*5+c+1 - local bs=boxes(game); local box=bs[state.box] - if slot<=Boxes.capacity() then - local mon=box[slot] - if mon and #(game.save.party or {})220 then - if x<242 then state.bagOffset=math.max(0,state.bagOffset-1) - else state.bagOffset=math.min(math.max(0,#ids-visible),state.bagOffset+1) 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=4 or not SecondScreen.deviceReady() then return false end local h=hostFor(game) local ok=pcall(SecondScreen.draw,h,function() draw(game) end) From 9c8289210e8bc2ecdc32c1476dd5da9f2c1283c4 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:40:09 -0400 Subject: [PATCH 27/37] fix(save-companion): align Gen2 stats and item catalog --- src/ui/SaveCompanion.lua | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua index 5387d8be..42f1b100 100644 --- a/src/ui/SaveCompanion.lua +++ b/src/ui/SaveCompanion.lua @@ -106,7 +106,12 @@ local function ensureCatalog(game) if state.catalogData == game.data then return end state.catalogData=game.data state.moveIds=sortedKeys(game.data and game.data.moves) - state.itemIds=sortedKeys(game.data and game.data.items) + state.itemIds={} + for _,id in ipairs(sortedKeys(game.data and game.data.items)) do + if not (type(id)=="string" and Bag.isBadge and Bag.isBadge(id)) then + state.itemIds[#state.itemIds+1]=id + end + end end local function boxes(game) @@ -429,8 +434,9 @@ local function drawMonEditor(game) local statX=W*.66; local statW=W*.315 roundRect(statX,top,statW,H*.20,12,PAL.panel2); stroke(statX,top,statW,H*.20,12,PAL.line) local st=mon.stats or {} - local labels=generation()>=3 and {{"ATK","attack"},{"DEF","defense"},{"SPD","speed"},{"SPA","spatk"},{"SDF","spdef"}} - or {{"ATK","attack"},{"DEF","defense"},{"SPD","speed"},{"SPC","special"}} + local gen=generation() + local labels=gen>=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) From 69826f044f53a3e7a94ccb92963cca0b8463a68a Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:43:37 -0400 Subject: [PATCH 28/37] fix(ios): stop double-flipping Gen4 world override --- src/render/Renderer.lua | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 130730bb..38c6c94f 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -1060,12 +1060,11 @@ 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) - local loveMajor = love.getVersion() - if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then - love.graphics.draw(self.worldOverride, 0, wh, 0, osx, -osy) - else - love.graphics.draw(self.worldOverride, 0, 0, 0, osx, osy) - end + -- Canvas orientation is already normalized by current LÖVE on iOS. + -- An older workaround flipped worldOverride vertically on iOS/LÖVE 12+, + -- which now double-flips Gen 4's 3D world while leaving the UI upright. + -- Keep one presentation path on every platform. + love.graphics.draw(self.worldOverride, 0, 0, 0, osx, osy) love.graphics.setScissor() -- the screen-space overlays the flat path draws over its composite local fade = self.worldFadeAlpha From e15c435ee4fcae2a82e663629df6821a7377b957 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:44:43 -0400 Subject: [PATCH 29/37] fix(ios): preserve world flip outside Gen4 --- src/render/Renderer.lua | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 38c6c94f..64922492 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -1060,11 +1060,21 @@ 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) - -- Canvas orientation is already normalized by current LÖVE on iOS. - -- An older workaround flipped worldOverride vertically on iOS/LÖVE 12+, - -- which now double-flips Gen 4's 3D world while leaving the UI upright. - -- Keep one presentation path on every platform. - love.graphics.draw(self.worldOverride, 0, 0, 0, osx, osy) + -- 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() + 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) + end love.graphics.setScissor() -- the screen-space overlays the flat path draws over its composite local fade = self.worldFadeAlpha From b6445243c37d7d12dbc0d99da28bce0095fcaf83 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:52:15 -0400 Subject: [PATCH 30/37] feat(save-companion): add Pokemon creation and stabilize native panel size --- src/ui/SaveCompanion.lua | 167 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 7 deletions(-) diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua index 42f1b100..c619595e 100644 --- a/src/ui/SaveCompanion.lua +++ b/src/ui/SaveCompanion.lua @@ -7,6 +7,7 @@ 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") @@ -17,7 +18,10 @@ 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, + 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" } @@ -106,6 +110,13 @@ local function ensureCatalog(game) if state.catalogData == game.data then return end state.catalogData=game.data state.moveIds=sortedKeys(game.data and game.data.moves) + state.speciesIds={} + for _,id in ipairs(sortedKeys(game.data and game.data.pokemon)) do + local def=game.data.pokemon[id] + if type(def)=="table" and type(def.baseStats)=="table" then + state.speciesIds[#state.speciesIds+1]=id + end + end state.itemIds={} for _,id in ipairs(sortedKeys(game.data and game.data.items)) do if not (type(id)=="string" and Bag.isBadge and Bag.isBadge(id)) then @@ -172,6 +183,55 @@ local function openMon(mon,origin,index,box) state.editor={mon=mon,origin=origin,index=index,box=box} end +local function openSpeciesPicker(mode, origin, index, box, mon) + state.speciesPicker={ + mode=mode, origin=origin, index=index, box=box, mon=mon, + } + state.speciesPage=1 +end + +local function createMon(game,id,level) + local ok, mon=pcall(Pokemon.new,game.data,id,level or 5) + if not (ok and mon) then return nil end + game.save.player=game.save.player or {} + mon.ot=game.save.player.name or game.save.playerName + mon.otId=game.save.player.id + return mon +end + +local function applySpecies(game, picker, id) + if not (picker and id) then return end + if picker.mode=="change" and picker.mon then + local mon=picker.mon + local def=game.data.pokemon[id] + if not def then return end + mon.species=id + mon.exp=Growth.expForLevel(def.growthRate,mon.level or 1) + recalc(game,mon) + state.speciesPicker=nil + return + end + + local mon=createMon(game,id,5) + if not mon then return end + if picker.origin=="party" then + game.save.party=game.save.party or {} + if #game.save.party < Party.MAX then + table.insert(game.save.party,mon) + state.editor={mon=mon,origin="party",index=#game.save.party} + state.speciesPicker=nil + end + elseif picker.origin=="box" then + local bs=boxes(game); local box=bs[picker.box or state.box] + local slot=picker.index or Boxes.firstFree(box) + if slot and slot<=Boxes.capacity() and not box[slot] then + box[slot]=mon + state.editor={mon=mon,origin="box",index=slot,box=picker.box or state.box} + state.speciesPicker=nil + end + end +end + local function drawHeader() local hh=H*.13 roundRect(0,0,W,hh,0,{0.04,0.07,0.12,1}) @@ -191,6 +251,11 @@ local function drawParty(game) local top=H*.16; local pad=W*.025; local gap=W*.018 text("Party",pad,top,"large") text(#party.."/"..Party.MAX,pad+W*.16,top+H*.018,"medium",PAL.muted) + if #party < Party.MAX then + button("+ ADD POKEMON",W*.72,top,W*.255,H*.085,function() + openSpeciesPicker("add","party") + end,{color=PAL.green,font="small"}) + end local y0=top+H*.105 local cols=2; local rows=3 local cw=(W-2*pad-gap)/2; local ch=(H-y0-H*.045-gap*2)/3 @@ -206,9 +271,11 @@ local function drawParty(game) local hp=(mon.hp or 0); local mx=(mon.stats and mon.stats.hp) or math.max(hp,1) textRight("HP "..hp.."/"..mx,x,y+ch*.53,cw-cw*.05,"small", hp<=mx*.2 and PAL.red or PAL.green) + textRight("EDIT",x,y+ch*.12,cw-cw*.05,"tiny",PAL.blue2) hit(x,y,cw,ch,function() openMon(mon,"party",i) end) else - textCenter("EMPTY",x,y+ch*.40,cw,"small",PAL.faint) + textCenter("EMPTY",x,y+ch*.31,cw,"small",PAL.faint) + textCenter("tap ADD above",x,y+ch*.57,cw,"tiny",PAL.faint) end end end @@ -221,7 +288,12 @@ local function drawBoxes(game) button("<",pad,top,W*.07,H*.09,function() state.box=state.box==1 and count or state.box-1 end) textCenter("BOX "..state.box.." / "..count,pad+W*.08,top+H*.015,W*.28,"large") button(">",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*.40,"medium",PAL.muted) + 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 @@ -237,7 +309,8 @@ local function drawBoxes(game) 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) + 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 @@ -392,6 +465,19 @@ local function drawMonEditor(game) 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() @@ -475,11 +561,52 @@ local function drawMonEditor(game) 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) @@ -510,9 +637,35 @@ local function hostFor(game) end state.host.game=game local pw,ph=SecondScreen.panelSize() - state.host.secondScreenLogicalWidth=pw - state.host.secondScreenLogicalHeight=ph - W,H=pw,ph + + -- The Java heartbeat is asynchronous. During reconnect / Presentation + -- discovery it can briefly fall back to the DS protocol size (256x192). + -- Reallocating a native companion canvas on every heartbeat made the lower + -- panel visibly flash between sizes. Require repeated identical dimensions + -- before accepting a change, and ignore the known fallback once a native + -- size has been latched. + local plausible = pw and ph and pw >= 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 + W,H=useW,useH return state.host end From 9c8a5345077afe1cb40eb75d29003b3bcf97730a Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:52:43 -0400 Subject: [PATCH 31/37] fix(second-display): allow stable native framebuffer dimensions --- src/ui/SecondScreen.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/SecondScreen.lua b/src/ui/SecondScreen.lua index 41e863d3..53893448 100644 --- a/src/ui/SecondScreen.lua +++ b/src/ui/SecondScreen.lua @@ -245,6 +245,15 @@ end -- 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 From fa15ca56b689a34519df8d8bbe0025c67063aa1c Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:52:45 -0400 Subject: [PATCH 32/37] fix(save-companion): pin framebuffer to latched panel size --- src/ui/SaveCompanion.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua index c619595e..b57f77b2 100644 --- a/src/ui/SaveCompanion.lua +++ b/src/ui/SaveCompanion.lua @@ -665,6 +665,8 @@ local function hostFor(game) 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 From 8f65070c2a307feef0bed2ffda458dda01f7419d Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:55:46 -0400 Subject: [PATCH 33/37] feat(gen4): keep Platinum compositor for wide battles --- src/battle/BattleState.lua | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e14b25bd..e1f8573a 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 From 9f4b0df2e776de221359575d88d94d68ee30f270 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:56:15 -0400 Subject: [PATCH 34/37] feat(gen4): widen Platinum battle field and HUD composition --- src/battle/Gen4Battle.lua | 86 ++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 14 deletions(-) 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 From 605f7e517ee89a49bc03a17d869725525422bacb Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:56:52 -0400 Subject: [PATCH 35/37] fix(gen4): target wide battler positions in ball animations --- src/battle/BattleState.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e1f8573a..ac025d95 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -6645,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 @@ -9170,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, From 842fb7a30ccd485d129f755bb19d33642eef7a18 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:02:21 -0400 Subject: [PATCH 36/37] fix(badges): clear both backing stores when toggling off --- src/inventory/Badges.lua | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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 From cfdae91bd7f917006209edb49fcdf8bb14348ce0 Mon Sep 17 00:00:00 2001 From: Ceedrack <32251519+UNDERdecoded@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:02:25 -0400 Subject: [PATCH 37/37] fix(save-companion): use canonical badge toggle --- src/ui/SaveCompanion.lua | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/ui/SaveCompanion.lua b/src/ui/SaveCompanion.lua index b57f77b2..9ba0e511 100644 --- a/src/ui/SaveCompanion.lua +++ b/src/ui/SaveCompanion.lua @@ -373,17 +373,7 @@ local function drawBag(game) end local function toggleBadge(game,entry) - local key=Badges.itemFor(entry) - local on=Badges.has(game.save,entry) - local gen=generation() - if gen==2 or gen==3 then - game.save.flags=game.save.flags or {} - game.save.flags[key]=on and nil or true - if game.save.inventory then game.save.inventory[key]=nil end - else - game.save.inventory=game.save.inventory or {} - game.save.inventory[key]=on and nil or 1 - end + Badges.toggle(game.save, entry, GameVersion.get()) end local KEY_ROWS={"ABCDEFG","HIJKLMN","OPQRSTU","VWXYZ"}