From 530f2bdd1562b5280880694d2d2c4eb680d10d68 Mon Sep 17 00:00:00 2001 From: syybott Date: Sat, 15 Aug 2026 19:39:48 -0500 Subject: [PATCH 01/32] Add optional extended widescreen battle HUD --- src/battle/BattleState.lua | 27 +++++++++ src/battle/WideBattle.lua | 59 +++++++++++++++++--- src/core/Game.lua | 32 ++++++++++- src/core/SaveData.lua | 5 ++ src/import/LauncherSettings.lua | 71 +++++++++++++++++++++--- src/render/Renderer.lua | 98 +++++++++++++++++++++++++++++++-- src/ui/OptionsMenu.lua | 43 ++++++++++++++- 7 files changed, 312 insertions(+), 23 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 38c1bdd7..8bd49062 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -87,6 +87,33 @@ function BattleState:wantsFillScale() return options and options.battleFit == "fill" or false end +-- EXTENDED HUD configurations are admitted one at a time after their own +-- placement and screenshot review. FIXED supports the three authored battle +-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock +-- battles retain the paper field required by Gen 1 back sprites, while arena +-- providers may replace it with their own scene. Only the HUD moves to window +-- space. +function BattleState:extendedHUD() + local options = self.game and self.game.save and self.game.save.options + local bg = options and options.battleBg + return self:wideLayout() + and options and options.battleHud == "extended" + and ((options.battleFit == "fixed" + and (bg == "world" or bg == "white" or bg == "black")) + or (options.battleFit == "fill" and bg == "white")) +end + +function BattleState:extendedWorldHUD() + local options = self.game and self.game.save and self.game.save.options + return self:extendedHUD() and options + and options.battleFit == "fixed" and options.battleBg == "world" +end + +function BattleState:extendedBlackHUD() + local options = self.game and self.game.save and self.game.save.options + return self:extendedHUD() and options and options.battleBg == "black" +end + -- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids -- that grow as the window gets bigger or the view is zoomed out. The battle -- screen itself is untouched: it keeps its white paper field in every mode. diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index abe3ef7a..75df4b6f 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y) end end +local function battleIsTopState(battle) + local stack = battle.game and battle.game.stack + return not (stack and stack.top) or stack:top() == battle +end + +local function anchorHUD(battle, x, y, w, h, anchor) + if not battle:extendedHUD() or not battleIsTopState(battle) then return end + local renderer = battle.game and battle.game.renderer + if not (renderer and renderer.setBattleUIAnchor) then return end + x = x + (battle.extendedHUDOffsetX or 0) + y = y + (battle.extendedHUDOffsetY or 0) + local x2 = math.min(WideBattle.WIDTH, x + w) + local y2 = math.min(WideBattle.HEIGHT, y + h) + x, y = math.max(0, x), math.max(0, y) + w, h = x2 - x, y2 - y + if w > 0 and h > 0 then + renderer:setBattleUIAnchor(x, y, w, h, anchor) + end +end + -- One side's status box: name and level on the first line, a long HP bar -- under it, and the numeric HP on the player's box only (the foe's exact -- HP is never shown, like the original). @@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player) Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), x + tw * 8 - 64, y + 24) end + anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top") end -- the party ball rows DrawAllPokeballs puts up with the intro text, moved @@ -144,7 +165,6 @@ local function drawHUDs(battle, slide) and not battle.showPlayerBack and slide == 0 then drawStatusPanel(battle, battle.player, 184, 56, true) end - drawIntroBalls(battle) end local function drawMessageBox(battle) @@ -268,6 +288,8 @@ local function drawTextArea(battle) else Font.drawBox(0, 13, 38, 5) end + anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM, + WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom") end -- Battle animations are authored in the original 160px coordinate space. @@ -309,17 +331,23 @@ end -- The whole 304x144 composition for one frame. function WideBattle.draw(battle) local g = love.graphics + local renderer = battle.game and battle.game.renderer + local extendedHUD = battle:extendedHUD() and renderer + and renderer.beginBattleHUDPass + and renderer.endBattleHUDPass -- The field is the display mode's paper. Under a forced-mono mode the -- whole surface is remapped downstream (WideBattle.zones), so the field -- goes down as DMG white and comes out of that pass as the mode's paper; -- painting the resolved shade there would run it through the remap twice -- and land a shade off the letterbox the renderer fills around it. - if monoMode() then - g.setColor(1, 1, 1, 1) - else - g.setColor(PaletteFX.paperShade(battle.data)) + if not (extendedHUD and battle:extendedWorldHUD()) then + if monoMode() then + g.setColor(1, 1, 1, 1) + else + g.setColor(PaletteFX.paperShade(battle.data)) + end + g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) end - g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) -- AskName clears the field the same way the classic layout does if battle.blankForAskName then return end @@ -346,6 +374,7 @@ function WideBattle.draw(battle) inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) battle.wideRegion = nil + drawIntroBalls(battle) -- A battle sets rWY to 0 (engine/battle/core.asm), so the window the -- shakes move IS the whole screen: PredefShakeScreenHorizontally, @@ -359,12 +388,26 @@ function WideBattle.draw(battle) if sx == 0 and sy == 0 then return fn() end g.push() g.translate(sx, sy) + battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy fn() + battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil g.pop() end - shaken(function() drawHUDs(battle, slide) end) drawAnimationLayer(battle) - shaken(function() drawTextArea(battle) end) + + if extendedHUD then + local previous = renderer:beginBattleHUDPass() + shaken(function() drawHUDs(battle, slide) end) + shaken(function() drawTextArea(battle) end) + if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then + g.setColor(1, 1, 1, 0.85) + g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) + end + renderer:endBattleHUDPass(previous) + else + shaken(function() drawHUDs(battle, slide) end) + shaken(function() drawTextArea(battle) end) + end if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then g.setColor(1, 1, 1, 0.85) diff --git a/src/core/Game.lua b/src/core/Game.lua index 0f8fb6de..a66912a9 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -305,12 +305,32 @@ function Game.worldBgBattleDim(stack) for i = #(stack and stack.states or {}), 1, -1 do local state = stack.states[i] if state and state.bgMode and state:bgMode() == "world" then + -- The extended fixed HUD intentionally exposes the live world across + -- the whole physical window. Keep this as a world-backed battle (zero + -- is non-nil, so scaling and overlay holds remain active), but do not + -- paint the standard dim veil around the native battle rectangle. + if state.extendedWorldHUD and state:extendedWorldHUD() then + return 0 + end return state.BG_WORLD_DIM or 0.55 end end return nil end +-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer +-- uses this separately from battleDim: the world remains the surround, while +-- the native-width battle field receives a paper backing from top to bottom. +function Game.extendedWorldHUDInStack(stack) + for i = #(stack and stack.states or {}), 1, -1 do + local state = stack.states[i] + if state and state.extendedWorldHUD and state:extendedWorldHUD() then + return true + end + end + return false +end + -- Is a BATTLE BG "world" battle composing itself over the live map right now? -- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the -- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is @@ -432,6 +452,14 @@ function Game.drawBaseInStack(stack, visibleBase) return visibleBase end +-- A classic overlay above the approved world-backed extended HUD paints only +-- its centred area. Keep the wider owner surface transparent so its margins +-- continue to reveal the world instead of becoming an opaque white sheet. +function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle) + return worldBelow or (worldDrawn and wideBattle ~= nil + and wideBattle.extendedHUD and wideBattle:extendedHUD()) +end + -- Shift classic SGB zones to the centred UI. A full-width base zone extends -- into both margins, keeping the canvas' paper color continuous; narrower -- sprite and status zones move with the classic UI content. @@ -485,6 +513,7 @@ function Game:draw() -- the stack for the same reason as uiFill above -- a prompt opened during -- the battle must not drop the dim for a frame. Renderer.battleDim = Game.worldBgBattleDim(self.stack) + Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(self.stack) -- ...and for the same reason the UI's own scale has to know the world is -- still the backdrop while an opaque menu covers it. Renderer:uiScale -- steps the UI down with the survey zoom only while a world is behind it, @@ -502,7 +531,8 @@ function Game:draw() -- menu to its top right, and the whole UI steps down with the zoom. Renderer.uiCentered = not Game.dynamicUI(self.save) Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) - Renderer:beginFrame(worldBelow) + Renderer:beginFrame(Game.uiCanvasTransparent( + worldBelow, worldDrawn, wideBattle)) for i = drawFrom, #self.stack.states do local state = self.stack.states[i] local wideState = state and state.isWideBattleLayout diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 54627da2..6114385f 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -239,6 +239,11 @@ function SaveData.defaultOptions() -- scale the battle surface to the window so it fills vertically. See -- BattleState:wantsFillScale. battleFit = "fixed", + -- BATTLE HUD: STANDARD keeps every wide-battle element inside the native + -- 304x144 surface. EXTENDED is opt-in window-space placement for selected + -- wide layouts; unsupported combinations deliberately fall back to the + -- standard composition. + battleHud = "standard", -- BATTLE BG: what fills the screen behind and around the battle. -- "white" = the display mode's paper shade (the classic look), -- "black" = plain black bars, "world" = the frozen overworld showing diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 0fe7c4de..ab4645c6 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -138,15 +138,72 @@ local function coreRows(opts, hooks) ladder(opts, "battleStyle", { { "shift", "SHIFT" }, { "set", "SET" } }, "shift")) add(Strings("BATTLE LAYOUT"), - ladder(opts, "battleLayout", - { { "og", "OG" }, { "wide", "WIDE" } }, "og")) + function() + return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG") + end, + function() + opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide" + if opts.battleLayout ~= "wide" then + opts.battleHud = "standard" + elseif opts.battleFit == "fill" and opts.battleHud == "extended" then + opts.battleBg = "white" + end + return true + end) add(Strings("BATTLE SIZE"), - ladder(opts, "battleFit", - { { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed")) + function() + return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED") + end, + function() + opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill" + if opts.battleFit == "fill" and opts.battleLayout == "wide" + and opts.battleHud == "extended" then + opts.battleBg = "white" + end + return true + end) + add(Strings("BATTLE HUD"), + function() + return opts.battleLayout == "wide" and opts.battleHud == "extended" + and Strings("EXTENDED") + or Strings("STANDARD") + end, + function() + if opts.battleLayout ~= "wide" then + opts.battleHud = "standard" + return false + end + opts.battleHud = opts.battleHud == "extended" and "standard" or "extended" + if opts.battleHud == "extended" and opts.battleFit == "fill" then + opts.battleBg = "white" + end + return true + end) add(Strings("BATTLE BG"), - ladder(opts, "battleBg", - { { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } }, - "white")) + function() + if opts.battleLayout == "wide" and opts.battleFit == "fill" + and opts.battleHud == "extended" then + opts.battleBg = "white" + return Strings("AUTO") + end + if opts.battleBg == "black" then return Strings("BLACK") end + if opts.battleBg == "world" then return Strings("WORLD") end + return Strings("WHITE") + end, + function(dir) + if opts.battleLayout == "wide" and opts.battleFit == "fill" + and opts.battleHud == "extended" then + opts.battleBg = "white" + return false + end + local order = { "white", "black", "world" } + local cur = 1 + for i, mode in ipairs(order) do + if opts.battleBg == mode then cur = i break end + end + opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1] + return true + end) add(Strings("UI LAYOUT"), ladder(opts, "uiLayout", { { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered")) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 54b5518c..5f3b35ab 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -94,6 +94,7 @@ function Renderer:init() -- reason -- worldViewSize() already works in drawable pixels. self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest") + self.battleHUDCanvas = nil self.worldCanvas = nil self.worldActive = false -- tilt mode only: a transparent overlay canvas the size of the world @@ -202,10 +203,38 @@ function Renderer:setUISize(w, h) w, h = math.floor(w), math.floor(h) if w == self.uiWidth and h == self.uiHeight and self.canvas then return end if self.canvas and self.canvas.release then self.canvas:release() end + if self.battleHUDCanvas and self.battleHUDCanvas.release then + self.battleHUDCanvas:release() + end + self.battleHUDCanvas = nil self.uiWidth, self.uiHeight = w, h self.canvas = PixelCanvas.new(w, h, "nearest") end +-- Transparent native-pixel surface for an extended WIDE battle HUD. The +-- battle scene remains in `canvas`; endFrame places registered HUD regions +-- afterward in physical-window space. +function Renderer:beginBattleHUDPass() + local w, h = self:uiSize() + if not self.battleHUDCanvas + or self.battleHUDCanvas:getWidth() ~= w + or self.battleHUDCanvas:getHeight() ~= h then + if self.battleHUDCanvas and self.battleHUDCanvas.release then + self.battleHUDCanvas:release() + end + self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest") + end + local previous = love.graphics.getCanvas and love.graphics.getCanvas() + or self.canvas + love.graphics.setCanvas(self.battleHUDCanvas) + love.graphics.clear(0, 0, 0, 0) + return previous +end + +function Renderer:endBattleHUDPass(previous) + love.graphics.setCanvas(previous or self.canvas) +end + -- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer -- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel -- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies @@ -671,6 +700,17 @@ end -- centred letterbox. Declared during the element's own draw, in UI-canvas -- pixels, and consumed by endFrame this frame only. -- anchor: "bottom" | "topright" | "topleft" | "bottomright" +local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped, + canvas, extract) + renderer.uiAnchors = renderer.uiAnchors or {} + renderer.uiAnchors[#renderer.uiAnchors + 1] = { + x = x, y = y, w = w, h = h, anchor = anchor, + windowClamped = windowClamped and true or false, + canvas = canvas, + extract = extract ~= false, + } +end + function Renderer:setUIAnchor(x, y, w, h, anchor) -- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from -- save.options.uiLayout): every element stays where it was drawn in the @@ -684,9 +724,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor) -- battle -- keeps every element inside it, so the box blits where it was -- drawn in the canvas instead of being pulled to the window edge. if self.uiAnchorHold then return end - self.uiAnchors = self.uiAnchors or {} - self.uiAnchors[#self.uiAnchors + 1] = - { x = x, y = y, w = w, h = h, anchor = anchor } + addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true) +end + +-- Battle-owned window-space placement. Unlike ordinary UI anchors this is +-- intentionally allowed while BattleState holds general dialogue/menu +-- anchors inside the battle surface. Callers must gate it to an explicit +-- battle HUD mode. +function Renderer:setBattleUIAnchor(x, y, w, h, anchor) + addUIAnchor(self, x, y, w, h, anchor, true, + self.battleHUDCanvas or self.canvas, false) end -- zones: optional list of SGB palette regions (see PaletteFX) in @@ -799,6 +846,8 @@ function Renderer:endFrame(zones, worldZones) -- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in -- a visibly brighter border. local clearR, clearG, clearB = 0, 0, 0 + local extendedBlackBand = false + local bandR, bandG, bandB = 1, 1, 1 if not self.worldActive then local ok, Game = pcall(require, "src.core.Game") local stack = ok and Game and Game.stack @@ -825,7 +874,15 @@ function Renderer:endFrame(zones, worldZones) -- screen stays black (src/core/FaithfulRes.lua); the paper surround -- painted the whole phone white on New Game and in battle (#864), so -- the lock keeps the default black bars. - if state and state.letterboxWhite + if state and state.extendedBlackHUD and state:extendedBlackHUD() + and not FaithfulRes.scaleCap() then + -- Extended/Black keeps the author's black surround, but extends the + -- fixed battle's paper field vertically through the physical window. + -- The band uses the exact centred fixed-width composition bounds, so + -- only vertical black bars remain at the sides. + extendedBlackBand = true + bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data) + elseif state and state.letterboxWhite and not (state.bgMode and state:bgMode() == "black") and not FaithfulRes.scaleCap() then clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) @@ -833,6 +890,10 @@ function Renderer:endFrame(zones, worldZones) end love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.rectangle("fill", 0, 0, ww, wh) + if extendedBlackBand then + love.graphics.setColor(bandR, bandG, bandB, 1) + love.graphics.rectangle("fill", uox, 0, uvpw, wh) + end love.graphics.setColor(1, 1, 1, 1) -- render.letterbox: SGB borders / custom void art in the bars around the -- 160x144 (or world) blit. Drawn after the clear and before the game @@ -975,6 +1036,22 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(1, 1, 1, 1) end + -- Extended/WORLD keeps the frozen world as the physical surround, but stock + -- Gen 1 back sprites rely on the battle's paper shade for visible highlights. + -- Back the exact fixed-width composition from physical top to bottom so only + -- the left and right sides expose the world. The battle canvas and detached + -- HUD remain transparent layers composited afterward. + -- A worldOverride is an arena provider's completed scene (for example, + -- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle + -- field, so never cover it with the native back-sprite fallback. + if self.extendedWorldBand and not self.worldOverride + and not FaithfulRes.scaleCap() then + local ok, Game = pcall(require, "src.core.Game") + love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data)) + love.graphics.rectangle("fill", uox, 0, uvpw, wh) + love.graphics.setColor(1, 1, 1, 1) + end + -- UI: anchored regions against their screen edges, the rest in the classic -- centred letterbox. With nothing anchored this is the single blit it has -- always been. @@ -997,14 +1074,23 @@ function Renderer:endFrame(zones, worldZones) if a.anchor == "bottom" then dx = uox + a.x * Ux -- horizontally it stays with the letterbox dy = wh - gapB - dh + elseif a.anchor == "top" then + dx = uox + a.x * Ux -- horizontally it stays with the letterbox + dy = a.y * Uy elseif a.anchor == "topright" then dx = ww - gapR - dw dy = a.y * Uy else -- unknown anchor: leave it where it is dx, dy = uox + a.x * Ux, uoy + a.y * Uy end + if a.windowClamped then + dx = math.max(0, math.min(math.max(0, ww - dw), dx)) + dy = math.max(0, math.min(math.max(0, wh - dh), dy)) + end placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh } - rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh) + if a.extract then + rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh) + end end for _, r in ipairs(rest) do blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) @@ -1013,7 +1099,7 @@ function Renderer:endFrame(zones, worldZones) -- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy). -- The zone scissors are computed from the same origin, so an SGB -- region travels with the element instead of staying in the letterbox. - blit(self.canvas, Ux, Uy, zones, Ux, Uy, + blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy, p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) end end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index c83c5450..945aaca1 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -180,6 +180,11 @@ local function buildRows(game) step = function(g) local o = g.save.options o.battleLayout = o.battleLayout == "wide" and "og" or "wide" + if o.battleLayout ~= "wide" then + o.battleHud = "standard" + elseif o.battleFit == "fill" and o.battleHud == "extended" then + o.battleBg = "white" + end return true end }, -- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a @@ -195,6 +200,31 @@ local function buildRows(game) step = function(g) local o = g.save.options o.battleFit = o.battleFit == "fill" and "fixed" or "fill" + if o.battleFit == "fill" and o.battleLayout == "wide" + and o.battleHud == "extended" then + o.battleBg = "white" + end + return true + end }, + { id = "battleHud", label = Strings("BATTLE HUD"), + value = function(g) + local o = g.save.options + return o.battleLayout == "wide" and o.battleHud == "extended" + and Strings("EXTENDED") + or Strings("STANDARD") + end, + step = function(g) + local o = g.save.options + -- The extended HUD is a widescreen-only composition. Keep OG locked + -- to the author's standard HUD even if an older save says otherwise. + if o.battleLayout ~= "wide" then + o.battleHud = "standard" + return false + end + o.battleHud = o.battleHud == "extended" and "standard" or "extended" + if o.battleHud == "extended" and o.battleFit == "fill" then + o.battleBg = "white" + end return true end }, -- What sits behind and around the battle. WHITE is the classic paper @@ -203,13 +233,24 @@ local function buildRows(game) -- shows through everywhere the battle does not paint). { id = "battleBg", label = Strings("BATTLE BG"), value = function(g) - local m = g.save.options.battleBg + local o = g.save.options + if o.battleLayout == "wide" and o.battleFit == "fill" + and o.battleHud == "extended" then + o.battleBg = "white" + return Strings("AUTO") + end + local m = o.battleBg if m == "black" then return Strings("BLACK") end if m == "world" then return Strings("WORLD") end return Strings("WHITE") end, step = function(g, dir) local o = g.save.options + if o.battleLayout == "wide" and o.battleFit == "fill" + and o.battleHud == "extended" then + o.battleBg = "white" + return false + end local order = { "white", "black", "world" } local cur = 1 for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end From 6780393f45bb222ffefc59e4a8bd87a15922402e Mon Sep 17 00:00:00 2001 From: syybott Date: Sat, 15 Aug 2026 19:39:48 -0500 Subject: [PATCH 02/32] Add extended battle HUD visual coverage --- tests/drivers/battle_hud_layout_lock_test.lua | 36 ++++++++++ .../drivers/fill_extended_auto_menu_test.lua | 53 +++++++++++++++ .../drivers/fill_extended_white_hud_test.lua | 64 ++++++++++++++++++ .../drivers/fixed_extended_black_hud_test.lua | 64 ++++++++++++++++++ .../drivers/fixed_extended_white_hud_test.lua | 65 +++++++++++++++++++ .../drivers/fixed_extended_world_hud_test.lua | 65 +++++++++++++++++++ 6 files changed, 347 insertions(+) create mode 100644 tests/drivers/battle_hud_layout_lock_test.lua create mode 100644 tests/drivers/fill_extended_auto_menu_test.lua create mode 100644 tests/drivers/fill_extended_white_hud_test.lua create mode 100644 tests/drivers/fixed_extended_black_hud_test.lua create mode 100644 tests/drivers/fixed_extended_white_hud_test.lua create mode 100644 tests/drivers/fixed_extended_world_hud_test.lua diff --git a/tests/drivers/battle_hud_layout_lock_test.lua b/tests/drivers/battle_hud_layout_lock_test.lua new file mode 100644 index 00000000..24b44732 --- /dev/null +++ b/tests/drivers/battle_hud_layout_lock_test.lua @@ -0,0 +1,36 @@ +local U = require("tests.drivers.util") + +local OUT = os.getenv("SHOT_DIR") or "battle-hud-layout-lock" +local BEFORE = OUT .. "/battle_hud_wide_extended.png" +local AFTER = OUT .. "/battle_hud_og_locked_standard.png" + +return function(game) + os.remove(BEFORE) + os.remove(AFTER) + + local options = game.save.options + options.battleLayout = "wide" + options.battleHud = "extended" + + local menu = require("src.ui.Screens").push(game, "OptionsMenu") + local layoutRow, hudRow + for _, row in ipairs(menu.rows) do + if row.id == "battleLayout" then layoutRow = row end + if row.id == "battleHud" then hudRow = row end + end + assert(layoutRow and hudRow, "battle layout/HUD rows are present") + + menu.index = 6 + menu.scroll = 3 + assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED") + assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written") + + layoutRow.step(game, 1) + assert(options.battleLayout == "og", "layout switched to OG") + assert(options.battleHud == "standard", "OG normalized HUD to STANDARD") + assert(hudRow.value(game) == "STANDARD", "OG displays STANDARD") + assert(U.shot(game, AFTER), "OG/STANDARD screenshot was written") + + print("[driver] BATTLE_HUD_LAYOUT_LOCK_PASS") + game.driverDone = true +end diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua new file mode 100644 index 00000000..94311bab --- /dev/null +++ b/tests/drivers/fill_extended_auto_menu_test.lua @@ -0,0 +1,53 @@ +-- Visual and behavioral acceptance for the adaptive BATTLE BG menu rule. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + love.window.setMode(1920, 1080, { resizable = true }) + U.wait(3) + + local options = game.save.options + options.battleLayout = "wide" + options.battleFit = "fixed" + options.battleHud = "extended" + options.battleBg = "black" + + local menu = require("src.ui.Screens").push(game, "OptionsMenu") + local fitRow, bgRow + local bgIndex + for i, row in ipairs(menu.rows) do + if row.id == "battleFit" then fitRow = row end + if row.id == "battleBg" then bgRow, bgIndex = row, i end + end + assert(fitRow and bgRow and bgIndex, "battle size/background rows are present") + + fitRow.step(game, 1) + assert(options.battleFit == "fill", "battle size switched to FILL") + assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE") + assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO") + assert(bgRow.step(game, 1) == false, "AUTO background row is locked") + assert(options.battleBg == "white", "locked AUTO retains the WHITE value") + + menu.index = bgIndex + menu.scroll = math.max(0, bgIndex - 5) + U.wait(2) + local autoPath = DIR .. "/fill_extended_auto_menu.png" + os.remove(autoPath) + local ok = U.shot(game, autoPath) + + fitRow.step(game, -1) + assert(options.battleFit == "fixed", "battle size switched back to FIXED") + assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice") + assert(bgRow.step(game, 1) == true and options.battleBg == "black", + "FIXED can select BLACK") + assert(bgRow.step(game, 1) == true and options.battleBg == "world", + "FIXED can select WORLD") + U.wait(2) + local fixedPath = DIR .. "/fixed_extended_background_choices.png" + os.remove(fixedPath) + ok = U.shot(game, fixedPath) and ok + + U.log(ok and "FILL_EXTENDED_AUTO_MENU_PASS" + or "FILL_EXTENDED_AUTO_MENU_FAIL") + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/fill_extended_white_hud_test.lua b/tests/drivers/fill_extended_white_hud_test.lua new file mode 100644 index 00000000..fc1a3f2a --- /dev/null +++ b/tests/drivers/fill_extended_white_hud_test.lua @@ -0,0 +1,64 @@ +-- Visual acceptance driver for WIDE + FILL + EXTENDED + WHITE. +-- The full physical-window backing remains white while the four battle HUD +-- panels move to their approved window anchors. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(3) + + local options = game.save.options + options.battleLayout = "wide" + options.battleFit = "fill" + options.battleHud = "extended" + options.battleBg = "white" + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) } + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(60) + + local battle = BattleState.newWild(game, "PIDGEY", 3, + { onFinish = function() end }) + game.overworld:pushBattle(battle) + U.wait(360) + + battle.introSlide = 0 + battle.introBalls = nil + battle.showEnemyTrainer = false + battle.showPlayerBack = false + battle.enemySendingOut = false + battle.sendingOut = false + battle.phase = "menu" + battle.menuIndex = 1 + U.wait(2) + + assert(battle:extendedHUD(), "FILL/WHITE activates the approved extended HUD") + assert(not battle:extendedWorldHUD(), "FILL/WHITE does not use FIXED's paper band") + + local path = DIR .. "/fill_extended_white_separate_layer.png" + os.remove(path) + local ok = U.shot(game, path) + + love.window.setMode(960, 540, { resizable = true }) + U.wait(5) + local smallPath = DIR .. "/fill_extended_white_small_16x9.png" + os.remove(smallPath) + ok = U.shot(game, smallPath) and ok + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(5) + local NamingScreen = require("src.ui.NamingScreen") + game.stack:push(NamingScreen.new(game, { + title = "NICKNAME?", maxLen = 10, onDone = function() end, + })) + U.wait(5) + local overlayPath = DIR .. "/fill_extended_white_naming_overlay.png" + os.remove(overlayPath) + ok = U.shot(game, overlayPath) and ok + + U.log(ok and "FILL_EXTENDED_WHITE_PASS" or "FILL_EXTENDED_WHITE_FAIL") + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/fixed_extended_black_hud_test.lua b/tests/drivers/fixed_extended_black_hud_test.lua new file mode 100644 index 00000000..5676c314 --- /dev/null +++ b/tests/drivers/fixed_extended_black_hud_test.lua @@ -0,0 +1,64 @@ +local U = require("tests.drivers.util") + +local OUT = os.getenv("SHOT_DIR") or "fixed-extended-black" +local FULL = OUT .. "/fixed_extended_black_full.png" +local SMALL = OUT .. "/fixed_extended_black_small_16x9.png" +local OVERLAY = OUT .. "/fixed_extended_black_overlay.png" + +return function(game) + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + + os.remove(FULL) + os.remove(SMALL) + os.remove(OVERLAY) + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(3) + + local options = game.save.options + options.battleLayout = "wide" + options.battleFit = "fixed" + options.battleHud = "extended" + options.battleBg = "black" + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) } + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(60) + + local battle = BattleState.newWild(game, "PIDGEY", 3, + { onFinish = function() end }) + game.overworld:pushBattle(battle) + U.wait(360) + battle.introSlide = 0 + battle.introBalls = nil + battle.showEnemyTrainer = false + battle.showPlayerBack = false + battle.enemySendingOut = false + battle.sendingOut = false + battle.phase = "menu" + battle.menuIndex = 1 + U.wait(2) + + assert(battle:extendedHUD(), "BLACK activates the approved extended HUD") + assert(battle:extendedBlackHUD(), "BLACK activates the white vertical battle band") + assert(not battle:extendedWorldHUD(), "BLACK remains separate from WORLD") + assert(U.shot(game, FULL), "full black-background screenshot was written") + + love.window.setMode(960, 540, { resizable = true }) + U.wait(10) + assert(U.shot(game, SMALL), "small black-background screenshot was written") + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(10) + battle.blankForAskName = true + local naming = require("src.ui.NamingScreen").new(game, { + title = "NICKNAME?", maxLen = 10, onDone = function() end, + }) + game.stack:push(naming) + U.wait(10) + assert(U.shot(game, OVERLAY), "black-background overlay screenshot was written") + + print("[driver] FIXED_EXTENDED_BLACK_PASS") + love.event.quit(0) +end diff --git a/tests/drivers/fixed_extended_white_hud_test.lua b/tests/drivers/fixed_extended_white_hud_test.lua new file mode 100644 index 00000000..7f528de5 --- /dev/null +++ b/tests/drivers/fixed_extended_white_hud_test.lua @@ -0,0 +1,65 @@ +local U = require("tests.drivers.util") + +local OUT = os.getenv("SHOT_DIR") or "fixed-extended-white" +local FULL = OUT .. "/fixed_extended_white_full.png" +local SMALL = OUT .. "/fixed_extended_white_small_16x9.png" +local OVERLAY = OUT .. "/fixed_extended_white_overlay.png" + +return function(game) + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + + os.remove(FULL) + os.remove(SMALL) + os.remove(OVERLAY) + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(3) + + local options = game.save.options + options.battleLayout = "wide" + options.battleFit = "fixed" + options.battleHud = "extended" + options.battleBg = "white" + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) } + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(60) + + local battle = BattleState.newWild(game, "PIDGEY", 3, + { onFinish = function() end }) + game.overworld:pushBattle(battle) + U.wait(360) + battle.introSlide = 0 + battle.introBalls = nil + battle.showEnemyTrainer = false + battle.showPlayerBack = false + battle.enemySendingOut = false + battle.sendingOut = false + battle.phase = "menu" + battle.menuIndex = 1 + U.wait(2) + assert(battle:extendedHUD(), "WHITE activates the approved extended HUD") + assert(not battle:extendedWorldHUD(), "WHITE keeps its opaque paper field") + assert(U.shot(game, FULL), "full WHITE screenshot was written") + + love.window.setMode(960, 540, { resizable = true }) + U.wait(10) + assert(U.shot(game, SMALL), "small WHITE screenshot was written") + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(10) + battle.blankForAskName = true + local naming = require("src.ui.NamingScreen").new(game, { + title = "NICKNAME?", + maxLen = 10, + initial = "", + onDone = function() end, + }) + game.stack:push(naming) + U.wait(10) + assert(U.shot(game, OVERLAY), "WHITE overlay screenshot was written") + + print("[driver] FIXED_EXTENDED_WHITE_PASS") + love.event.quit(0) +end diff --git a/tests/drivers/fixed_extended_world_hud_test.lua b/tests/drivers/fixed_extended_world_hud_test.lua new file mode 100644 index 00000000..8ed309a2 --- /dev/null +++ b/tests/drivers/fixed_extended_world_hud_test.lua @@ -0,0 +1,65 @@ +-- Visual acceptance driver for the first EXTENDED HUD configuration only: +-- WIDE + FIXED + EXTENDED + WORLD. +-- POKEPORT_DRIVER=tests/drivers/fixed_extended_world_hud_test.lua \ +-- POKEPORT_IDENTITY=fixed-extended-world POKEPORT_TOUCH=0 \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + + -- Match the 16:9 acceptance screenshot so the fixed 304x144 surface has + -- measurable space above and below it. + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(3) + + local options = game.save.options + options.battleLayout = "wide" + options.battleFit = "fixed" + options.battleHud = "extended" + options.battleBg = "world" + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) } + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(60) + + local battle = BattleState.newWild(game, "PIDGEY", 3, + { onFinish = function() end }) + game.overworld:pushBattle(battle) + U.wait(360) + + battle.introSlide = 0 + battle.introBalls = nil + battle.showEnemyTrainer = false + battle.showPlayerBack = false + battle.enemySendingOut = false + battle.sendingOut = false + battle.phase = "menu" + battle.menuIndex = 1 + U.wait(2) + + local path = DIR .. "/fixed_extended_world_separate_layer.png" + os.remove(path) + local ok = U.shot(game, path) + + love.window.setMode(960, 540, { resizable = true }) + U.wait(5) + local smallPath = DIR .. "/fixed_extended_world_small_16x9.png" + os.remove(smallPath) + ok = U.shot(game, smallPath) and ok + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(5) + local NamingScreen = require("src.ui.NamingScreen") + game.stack:push(NamingScreen.new(game, { + title = "NICKNAME?", maxLen = 10, onDone = function() end, + })) + U.wait(5) + local overlayPath = DIR .. "/fixed_extended_world_naming_overlay.png" + os.remove(overlayPath) + ok = U.shot(game, overlayPath) and ok + + U.log(ok and "FIXED_EXTENDED_WORLD_PASS" or "FIXED_EXTENDED_WORLD_FAIL") + love.event.quit(ok and 0 or 1) +end From 90eb53b00c46b9b08aba4c991188297af67b08cf Mon Sep 17 00:00:00 2001 From: syybott Date: Sat, 15 Aug 2026 23:29:04 -0500 Subject: [PATCH 03/32] Keep WIDE battle visible beneath opaque menus --- src/core/Game.lua | 22 ++++++---- .../fixed_extended_world_bag_overlay_test.lua | 44 +++++++++++++++++++ tests/engine/battle_fixed_menu_scale.lua | 10 ++++- 3 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 tests/drivers/fixed_extended_world_bag_overlay_test.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index a66912a9..86e89a0c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -423,13 +423,13 @@ function Game.uiAnchorsHeldInStack(stack) end -- Where Game:draw starts drawing this frame. Normally the topmost opaque --- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle --- over the LIVE map, and an opaque state pushed on top of it (the party menu, --- the bag) becomes that base, cutting the overworld -- and with it the world --- pass -- out of the frame entirely. The backdrop the battle established --- then collapses to endFrame's flat black clear for as long as the menu is --- up. So a world-bg battle keeps the frame starting from underneath itself --- until it leaves the stack, the same hold uiFill and the dim already use. +-- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE +-- battle must not prevent that battle from drawing. Native WIDE battles own +-- the 304x144 surround around a centred classic menu, while external arena +-- providers establish their window-sized scene from BattleState:draw. If the +-- menu becomes the draw base, neither owner runs and the menu's white field +-- replaces the whole presentation. BATTLE BG "world" additionally needs the +-- overworld below the battle, as before. -- -- Only the START of the draw moves. The clear stays keyed to the real -- visibleBase, so the menu still gets its opaque canvas and draws exactly as @@ -440,9 +440,13 @@ function Game.drawBaseInStack(stack, visibleBase) local states = stack and stack.states or {} for i = visibleBase - 1, 1, -1 do local state = states[i] - if state and state.bgMode and state:bgMode() == "world" then + local worldBattle = state and state.bgMode and state:bgMode() == "world" + local wideBattle = state and state.isWideBattleLayout + and state:isWideBattleLayout() + if worldBattle or wideBattle then -- restart the search from under the battle: the highest opaque state at - -- or below it (the overworld), not the menu sitting over it + -- or below it (the battle itself for white/black WIDE, the overworld for + -- a non-opaque world-backed battle), not the menu sitting over it for j = i, 1, -1 do if states[j].isOpaque then return j end end diff --git a/tests/drivers/fixed_extended_world_bag_overlay_test.lua b/tests/drivers/fixed_extended_world_bag_overlay_test.lua new file mode 100644 index 00000000..95855569 --- /dev/null +++ b/tests/drivers/fixed_extended_world_bag_overlay_test.lua @@ -0,0 +1,44 @@ +-- Visual regression coverage for Professor Oak's scripted Yellow capture Bag +-- over the WIDE + FIXED + EXTENDED + WORLD composition. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + + love.window.setMode(2048, 1152, { resizable = true }) + U.wait(3) + + local options = game.save.options + options.battleLayout = "wide" + options.battleFit = "fixed" + options.battleHud = "extended" + options.battleBg = "world" + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(30) + + local demo = BattleState.newWild(game, "CHARMANDER", 5) + demo:makeOldManDemo("PROF.OAK") + demo.onFinish = function() end + game.overworld:pushBattle(demo) + + for _ = 1, 100 do + if demo.phase == "menu" and (demo.demoTimer or 0) > 5 then break end + U.tap(game, "a") + U.wait(4) + end + for _ = 1, 180 do + if game.stack:top() ~= demo then break end + U.wait(1) + end + U.wait(3) + + local path = DIR .. "/fixed_extended_world_oak_charmander_bag.png" + os.remove(path) + local ok = game.stack:top() ~= demo and U.shot(game, path) + U.log(ok and "FIXED_EXTENDED_WORLD_BAG_PASS" + or "FIXED_EXTENDED_WORLD_BAG_FAIL") + love.event.quit(ok and 0 or 1) +end diff --git a/tests/engine/battle_fixed_menu_scale.lua b/tests/engine/battle_fixed_menu_scale.lua index fd5e27d2..33e79812 100644 --- a/tests/engine/battle_fixed_menu_scale.lua +++ b/tests/engine/battle_fixed_menu_scale.lua @@ -92,6 +92,11 @@ local menu = { isOpaque = true } -- PartyMenu / ListMenu local whiteBattle = setmetatable( { game = { save = { options = { battleBg = "white" } } } }, { __index = BattleState }) +local wideWhiteBattle = setmetatable( + { game = { save = { options = { + battleBg = "white", battleLayout = "wide", + } } } }, + { __index = BattleState }) local function stack(...) return { states = { ... }, visibleBase = function(self) for i = #self.states, 1, -1 do @@ -111,7 +116,10 @@ T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld") T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does") local s3 = stack(overworld, whiteBattle, menu) T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3, - "a white-bg battle has no map to hold, so nothing moves") + "a classic white-bg battle has no presentation to hold, so nothing moves") +local s3wide = stack(overworld, wideWhiteBattle, menu) +T.eq(Game.drawBaseInStack(s3wide, s3wide:visibleBase()), 2, + "an opaque WIDE battle still draws beneath its classic menu") local s4 = stack(overworld, menu) T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2, "and a menu outside a battle is untouched") From b39e11b7cde23b839cd9a65c168c3947981cf24d Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:07:17 +0200 Subject: [PATCH 04/32] feat(mods): add special battle intents --- docs/modding.md | 10 +++++-- src/battle/BattleAPI.lua | 11 ++++++-- src/battle/BattleState.lua | 41 ++++++++++++++++++++++----- tests/mod_battle_snapshot_test.lua | 45 +++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 264ad9d5..abc4a629 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -278,10 +278,16 @@ The shared Red, Blue, Yellow, and Gold intents are: - `{ kind = "move", slot = 1..4 }` - `{ kind = "back" }` while the move menu is active +Red, Blue, and Yellow also expose their generation-specific choices: + +- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the + other accepted actions) +- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index` + Menu choices and moves use the same engine methods as the native controls; `party` and `item` open the native screens rather than exposing or duplicating -their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle -states refuse these core intents. Use `mod.input` for ordinary text advance. +their mutable logic. Tutorial, link, forced, stale, and covered battle states +refuse core intents. Use `mod.input` for ordinary text advance. ## Rendering pipelines diff --git a/src/battle/BattleAPI.lua b/src/battle/BattleAPI.lua index 6d94339f..2ea4d97b 100644 --- a/src/battle/BattleAPI.lua +++ b/src/battle/BattleAPI.lua @@ -218,13 +218,20 @@ function BattleAPI:submit(intent) return nil, "stale battle context" end local kind = battle:battleKind() - if kind == "oldman" or kind == "link" or kind == "safari" then + if kind == "oldman" or kind == "link" then return nil, "battle kind is not controllable" end if top ~= battle then return nil, "battle menu is covered" end local ok, err - if intent.kind == "menu" then + if intent.kind == "safari" then + if kind ~= "safari" then return nil, "safari menu is not active" end + ok, err = battle:chooseSafari(intent.action) + elseif kind == "safari" then + return nil, "battle kind is not controllable" + elseif intent.kind == "mimic" then + ok, err = battle:chooseMimic(intent.index) + elseif intent.kind == "menu" then if battle.phase ~= "menu" then return nil, "battle menu is not active" end if not MENU_CHOICES[intent.choice] then return nil, "unknown battle menu choice" diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8e19da36..18185937 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2018,6 +2018,38 @@ function BattleState:cancelMove() return true end +local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } + +function BattleState:chooseSafari(action) + if self.phase ~= "menu" or not self.safari then + return nil, "safari menu is not active" + end + if self.safari.balls <= 0 then return nil, "no safari balls remain" end + local index = SAFARI_ACTION_INDEX[action] + if not index then return nil, "invalid safari action" end + self.menuIndex = index + self:safariAction(action) + return true +end + +function BattleState:chooseMimic(index) + if self.phase ~= "mimicSelect" then + return nil, "mimic menu is not active" + end + if type(index) ~= "number" or index % 1 ~= 0 then + return nil, "invalid mimic slot" + end + local pick = self.mimicMoves and self.mimicMoves[index] + local ctx = self.mimicCtx + if not pick or not ctx then return nil, "invalid mimic slot" end + self.mimicIndex = index + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + function BattleState:swapMoves(i, j) if i == j then return end local moves = self.player.curMoves @@ -2139,7 +2171,7 @@ function BattleState:update(dt) self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then require("src.core.Sound").play(self.data, "Press_AB") - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -2247,12 +2279,7 @@ function BattleState:update(dt) self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then require("src.core.Sound").play(self.data, "Press_AB") - local pick = moves[self.mimicIndex] - local ctx = self.mimicCtx - self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" - self.nextInsert = 0 -- the copy's anim + text go to the queue head - self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + self:chooseMimic(self.mimicIndex) end return end diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua index 863fb00a..851d3646 100644 --- a/tests/mod_battle_snapshot_test.lua +++ b/tests/mod_battle_snapshot_test.lua @@ -48,7 +48,7 @@ local battle = { enemy = { mon = { species = "TESTMON", level = 4, hp = 12, stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} }, } -function battle:battleKind() return "wild" end +function battle:battleKind() return self.kind or "wild" end function battle:effectRecord() return { accuracyChecked = true } end function battle:visibleText() return { "Wild TESTMON appeared!" } end function battle:menuLockedAction() return nil end @@ -63,6 +63,14 @@ function battle:chooseMove(slot) return true end function battle:cancelMove() self.phase = "menu" return true end +function battle:chooseSafari(action) + self.chosenSafari, self.phase = action, "messages" + return true +end +function battle:chooseMimic(slot) + self.chosenMimic, self.phase = slot, "messages" + return true +end function battle:catchChance(ball) return require("src.battle.Catching").chance(ball, self.enemy.mon, game.data.pokemon[self.enemy.mon.species]) @@ -123,6 +131,18 @@ check(api:submit({ id = 3, revision = back.revision, kind = "back" }), "Gen 1 accepts move-menu back") eq(battle.phase, "menu", "Gen 1 back restores the command menu") +battle.kind, battle.safari = "safari", { balls = 30 } +local safari = api:snapshot() +check(api:submit({ id = 4, revision = safari.revision, + kind = "safari", action = "rock" }), "Gen 1 accepts a Safari action") +eq(battle.chosenSafari, "rock", "Gen 1 uses the semantic Safari path") +battle.kind, battle.safari = "wild", nil +battle.phase, battle.mimicMoves = "mimicSelect", { { slot = 1 } } +local mimic = api:snapshot() +check(api:submit({ id = 5, revision = mimic.revision, + kind = "mimic", index = 1 }), "Gen 1 accepts a Mimic choice") +eq(battle.chosenMimic, 1, "Gen 1 uses the semantic Mimic path") + local player2 = { species = "CHIKORITA", level = 5, hp = 20, maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } } local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12, @@ -228,6 +248,29 @@ do eq(real.phase, "menu", "native Gen 1 move-menu back still works") end +do + local state = setmetatable({ phase = "menu", safari = { balls = 30 }, + menuIndex = 1 }, { __index = Gen1BattleState }) + function state:safariAction(action) self.safariChoice = action end + local ok, err = state:chooseSafari("missing") + check(not ok and err == "invalid safari action", + "native Safari rejects an unknown action") + check(state:chooseSafari("rock"), "native Safari choice is accepted") + eq(state.menuIndex, 3, "native Safari cursor follows the semantic choice") + eq(state.safariChoice, "rock", "native Safari action uses the shared path") + + state.phase = "mimicSelect" + state.mimicMoves = { { slot = 4 } } + state.mimicCtx = { user = {}, target = {}, moveInst = {} } + function state:applyMimic(_, _, _, slot) self.mimicSlot = slot end + ok, err = state:chooseMimic(2) + check(not ok and err == "invalid mimic slot", + "native Mimic rejects an unknown choice") + check(state:chooseMimic(1), "native Mimic choice is accepted") + eq(state.phase, "messages", "native Mimic choice resumes battle messages") + eq(state.mimicSlot, 4, "native Mimic choice copies the selected move slot") +end + local Loader = require("src.mods.Loader") local fs = { read = function() end, getInfo = function() end, getDirectoryItems = function() return {} end } From a3a20a07e14fd29d59470de4c855d3ea2165dc02 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:57:39 +0200 Subject: [PATCH 05/32] feat(mods): expose Fly and Softboiled field actions --- docs/modding.md | 21 ++++-- src/ui/PartyMenu.lua | 18 +---- src/world/OverworldController.lua | 17 +++++ src/world/WorldAPI.lua | 93 +++++++++++++++++++++++- tests/modkit/cases/world_field_items.lua | 41 ++++++++++- 5 files changed, 165 insertions(+), 25 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 264ad9d5..339cf921 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched. start at the player's current position. Both games expose `bicycle`, `fish`, `cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the -contextual `squirtbottle` key item. Fishing rows include the owned rods that -are valid choices. The list is empty while the world is busy, and omits an -action whenever its item, move, badge, terrain, or engine state forbids it. +contextual `squirtbottle` key item. Red additionally exposes `softboiled` with +eligible `sources`; each source contains its eligible `targets`. Fishing rows +include the owned rods that are valid choices. The list is empty while the +world is busy, and omits an action whenever its item, move, badge, terrain, or +engine state forbids it. The optional second return is `"world is busy"` during transient input locks or `"no overworld"` before a playable world exists. Call `mod.world:useFieldAction(id, opts)` to perform a listed action through the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }` -and chooses automatically when only one rod is available. Invalid, stale, and -busy requests return `nil` plus a reason without changing game state. Mods do -not need generation-specific badge, terrain, bike, fishing, or field-move +and chooses automatically when only one rod is available. Red's `softboiled` +accepts one-based `{ sourceSlot, targetSlot }` values copied from its action +record. Invalid, stale, and busy requests return `nil` plus a reason without +changing game state. Mods do not need generation-specific badge, terrain, +bike, fishing, or field-move logic. Action lists are extensible; callers should render the records they understand and ignore unknown ids rather than assuming a fixed list length. +Red exposes FLY separately because it requires a destination picker: +`mod.world:canFly()` reports whether FLY is eligible at the current location, +and `mod.world:flyTo(mapId)` accepts only a visited destination from the native +Fly town list. Gold does not expose these two methods yet. + ## Read-only battle snapshots `mod.battle:snapshot()` returns `nil` outside a battle and a copied battle diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 32db27ae..04c73930 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -628,22 +628,8 @@ function PartyMenu:update(dt) end if self.softboiledFrom then local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end + self.softboiledFrom = nil + self.game.overworld:useSoftboiledFieldMove(user, mon) elseif self.swapFrom then if self.swapFrom ~= self.index then party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6037233a..a2cfda3b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -827,6 +827,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose) return true end +function OverworldState:useSoftboiledFieldMove(user, target) + local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0 + if not user or not user.stats or not target or not target.stats + or target == user or target.hp <= 0 + or target.hp >= target.stats.hp or user.hp <= heal then + Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect."))) + return false + end + user.hp = user.hp - heal + target.hp = math.min(target.stats.hp, target.hp + heal) + require("src.core.Sound").play(Game.data, "Heal_HP") + local def = Game.data.pokemon[target.species] + Game.stack:push(TextBox.new(Game, + Strings("%s's HP\nwas restored!", target.nickname or def.name))) + return true +end + -- The battle transition's dungeon wipe uses the explicit map lists in -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- inclusive map-id ranges -- faithful to the original's omissions diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 286e5dd6..69b9eea2 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -37,6 +37,57 @@ local function validPartySlot(party, slot) and party[slot] ~= nil end +local function outside(game, ow) + return Map.isOutside(ow.map.def, + FieldDefaults.field(game.data, "outsideTilesets")) +end + +local function knows(mon, moveId) + for _, move in ipairs(mon.moves or {}) do + if move.id == moveId then return true end + end + return false +end + +local function monInfo(game, mon, slot) + local def = game.data.pokemon[mon.species] or {} + return { slot = slot, species = mon.species, + name = mon.nickname or def.name or mon.species, level = mon.level, + hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp } +end + +local function softboiledSources(game) + local party, sources = game.save.party or {}, {} + for sourceSlot, source in ipairs(party) do + local heal = source.stats and math.floor(source.stats.hp / 5) or 0 + if knows(source, "SOFTBOILED") and source.hp > heal then + local info = monInfo(game, source, sourceSlot) + info.targets = {} + for targetSlot, target in ipairs(party) do + if target ~= source and target.hp > 0 and target.stats + and target.hp < target.stats.hp then + info.targets[#info.targets + 1] = monInfo(game, target, targetSlot) + end + end + if #info.targets > 0 then sources[#sources + 1] = info end + end + end + return sources +end + +local function flyDestinationAvailable(game, mapId) + local field, save = game.data.field or {}, game.save + for _, id in ipairs(field.flyOrder or {}) do + if id == mapId then + local def = game.data.maps and game.data.maps[id] + return not not (save.visited and save.visited[id] + and field.flyWarps and field.flyWarps[id] + and def and Map.isFlyTown(def)) + end + end + return false +end + function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) end @@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions() and ow:partyKnows("DIG") then out[#out + 1] = { id = "dig", label = "DIG" } end - if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def, - FieldDefaults.field(game.data, "outsideTilesets")) then + if ow:partyKnows("TELEPORT") and outside(game, ow) then out[#out + 1] = { id = "teleport", label = "TELEPORT" } end + local sources = softboiledSources(game) + if #sources > 0 then + out[#out + 1] = { id = "softboiled", label = "SOFTBOILED", + sources = sources } + end return out end @@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts) elseif id == "dig" or id == "teleport" then ow:beginTeleportOut() return true + elseif id == "softboiled" then + local sourceSlot = opts and tonumber(opts.sourceSlot) + local targetSlot = opts and tonumber(opts.targetSlot) + local allowed + for _, source in ipairs(found.sources or {}) do + if source.slot == sourceSlot then + for _, target in ipairs(source.targets or {}) do + if target.slot == targetSlot then allowed = true break end + end + end + end + if not allowed then return nil, "softboiled target unavailable" end + if ow:useSoftboiledFieldMove(game.save.party[sourceSlot], + game.save.party[targetSlot]) then return true end end return nil, "field action unavailable" end +-- FLY needs a destination choice, so it is exposed separately from the +-- immediate actions above. The request is still checked against the same +-- visited-town list as the native Town Map picker before the world may warp. +function WorldAPI:canFly() + local game, ow = self.game, self:overworld() + return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY")) +end + +function WorldAPI:flyTo(mapId) + local game, ow = self.game, self:overworld() + if not ow then return nil, NO_OVERWORLD end + if not self:canFly() then return nil, "fly unavailable" end + if not acceptsMenuInput(game, ow) then return nil, "world is busy" end + if not flyDestinationAvailable(game, mapId) then + return nil, "destination unavailable" + end + ow:flyTo(mapId) + return true +end + -- A compact, read-only view of the active map for minimaps and companion UIs. -- `rows` describes collision terrain; optional `tileRows` reduces each real -- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). diff --git a/tests/modkit/cases/world_field_items.lua b/tests/modkit/cases/world_field_items.lua index 0e90146e..e6e237e7 100644 --- a/tests/modkit/cases/world_field_items.lua +++ b/tests/modkit/cases/world_field_items.lua @@ -24,7 +24,11 @@ local redWorld = { } local redGame = { data = { field = { outsideTilesets = { "OVERWORLD" } }, - items = { OLD_ROD = { name = "OLD ROD" } } }, + items = { OLD_ROD = { name = "OLD ROD" } }, + maps = { PALLET_TOWN = { index = 0, tileset = "OVERWORLD" }, + ROUTE_4 = { index = 11, tileset = "OVERWORLD" } }, + pokemon = { CHANSEY = { name = "CHANSEY" }, + PIKACHU = { name = "PIKACHU" } } }, save = { player = { name = "RED" }, party = {}, inventory = { BICYCLE = 1, OLD_ROD = 1 } }, stack = { states = { redWorld } }, @@ -42,6 +46,7 @@ T.check(type(RedWorld.useBicycle) == "function" and type(RedWorld.useFishingRod) == "function" and type(RedWorld.useFlashFieldMove) == "function" and type(RedWorld.useStrengthFieldMove) == "function" + and type(RedWorld.useSoftboiledFieldMove) == "function" and type(RedWorld.stopSurfing) == "function", "Red keeps field-action execution in its world") local actions = red:availableFieldActions() @@ -93,6 +98,40 @@ T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed and redWorld.flashUsed and redWorld.teleportUsed, "Red delegates every move to its overworld path") +local source = { species = "CHANSEY", level = 30, hp = 80, + stats = { hp = 100 }, moves = { { id = "SOFTBOILED" } } } +local target = { species = "PIKACHU", level = 20, hp = 10, + stats = { hp = 50 }, moves = {} } +redGame.save.party = { source, target } +redWorld.useSoftboiledFieldMove = function(self, user, recipient) + self.softboiled = { user, recipient } + return true +end +byId = {} +for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end +T.check(byId.softboiled and byId.softboiled.sources[1].targets[1].slot == 2, + "Red lists only valid SOFTBOILED targets") +T.check(red:useFieldAction("softboiled", { sourceSlot = 1, targetSlot = 2 }), + "Red accepts a listed SOFTBOILED transfer") +T.check(redWorld.softboiled[1] == source and redWorld.softboiled[2] == target, + "Red delegates SOFTBOILED to its overworld path") +ok, err = red:useFieldAction("softboiled", { sourceSlot = 2, targetSlot = 1 }) +T.check(not ok and err == "softboiled target unavailable", + "Red rejects an invalid SOFTBOILED source") + +redGame.save.inventory.THUNDERBADGE = 1 +redGame.save.visited = { PALLET_TOWN = true, ROUTE_4 = true } +redGame.data.field.flyOrder = { "PALLET_TOWN", "ROUTE_4" } +redGame.data.field.flyWarps = { PALLET_TOWN = true, ROUTE_4 = true } +redMoves.FLY = source +redWorld.flyTo = function(self, mapId) self.flewTo = mapId end +T.check(red:canFly(), "Red exposes FLY only in a valid outdoor context") +T.check(red:flyTo("PALLET_TOWN") and redWorld.flewTo == "PALLET_TOWN", + "Red validates and delegates a visited FLY destination") +ok, err = red:flyTo("ROUTE_4") +T.check(not ok and err == "destination unavailable", + "Red rejects a fly warp that is not a native town destination") + redSurf = "dismount" redWorld.player.surfing = true redWorld.stopSurfing = function(self) self.dismounted = true end From 393a1013e4b3af0a85705f191200552a37377fe7 Mon Sep 17 00:00:00 2001 From: Yunus Emre Umar <77045015+emre155@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:11:32 +0300 Subject: [PATCH 06/32] fix(save-editor): guard cycleMove against undefined moves in catalog Closes #1403 --- tools/save-editor/Ops.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 36d13c64..77ba080a 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -430,7 +430,7 @@ function Ops.setDv(S, mon, key, value) end function Ops.cycleMove(S, mon, slot) - if not mon then return false end + if not mon or not (S.cat and S.cat.moves and #S.cat.moves > 0) then return false end local moves = S.cat.moves local current = mon.moves and mon.moves[slot] and mon.moves[slot].id local idx = 0 @@ -439,9 +439,14 @@ function Ops.cycleMove(S, mon, slot) if id == current then idx = i break end end end - local nextId = moves[(idx % #moves) + 1] - MonOps.setMove(S.data, mon, slot, nextId) - return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId)) + for step = 1, #moves do + local nextId = moves[((idx + step - 1) % #moves) + 1] + if S.data and S.data.moves and S.data.moves[nextId] then + MonOps.setMove(S.data, mon, slot, nextId) + return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId)) + end + end + return false end function Ops.clearMove(S, mon, slot) From 1f3d13adaf048626527fcec90d2c909d5c12c3f1 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:17:35 +0200 Subject: [PATCH 07/32] mods: add OS-independent game viewport composition --- docs/mod-api-gen2-compat.md | 2 +- docs/modding.md | 25 +++- main.lua | 9 +- src/core/Game.lua | 8 +- src/core/Game2.lua | 42 ++++--- src/core/SafeArea.lua | 10 +- src/core/TouchControls.lua | 14 +-- src/render/GameViewport.lua | 185 ++++++++++++++++++++++++++++++ src/render/Renderer.lua | 11 +- src/ui/gen2/BattleTransition.lua | 3 +- src/ui/kit/Layout.lua | 3 +- src/world/OverworldController.lua | 3 +- src/world/gen2/World.lua | 9 +- tests/engine/render_viewport.lua | 101 ++++++++++++++++ 14 files changed, 379 insertions(+), 46 deletions(-) create mode 100644 src/render/GameViewport.lua create mode 100644 tests/engine/render_viewport.lua diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 48429c6f..e412b4d2 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix. passes `game`; positions 2-4 (mon, row, trigger) match. - *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, `render.zones`, `render.compose`, `render.output_enabled`, `render.output`, - `render.letterbox`, `render.hud`. Each sits + `render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it -- the logic tick before the pad is read, a pointer the touch overlay gets first refusal on, the palette zone list handed to the present pass, the diff --git a/docs/modding.md b/docs/modding.md index 264ad9d5..64f9d274 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -654,11 +654,14 @@ the wrapper is visible during that same fixed step. The callback receives `input.pointer` delivers uncaptured gameplay pointer events -- touches and real mouse input alike. The callback receives `(next, game, ev)` where `ev` -is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is +is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure, +button }`: `phase` is `"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"` or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates -are LOVE window units, the same space `render.hud`'s viewport and the touch -overlay lay out in. The on-screen touch controls keep first refusal: a +`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the +active game viewport and `insideGame` says whether the pointer is inside it. +Without a custom viewport both coordinate pairs are identical. The on-screen +touch controls keep first refusal: a pointer that begins on a virtual control belongs to the pad for its whole lifecycle and never reaches the hook, while one that begins outside stays visible even if it later crosses a control. A real mouse reaches the hook @@ -691,6 +694,22 @@ composited and before touch controls draw. The window-space viewport contains and `dpiY`, so a tool can use the letterbox margins without drawing over the playfield or pushing an updating game state. +`render.viewport` lets a layout mod reserve the window-space rectangle in which +the game renders. It receives `(next, ctx)` with the full window's `width`, +`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and +returns `{ x, y, width, height }`. The engine clamps that rectangle to the +window and makes game layout, safe-area calculations, and rendering use it as +their display. Set `capture = true` to request a composition canvas even when +the rectangle fills the window. With no subscriber, no canvas is allocated and +the normal presentation path is unchanged. + +When a viewport is active, `render.window` receives `(next, game, ctx)` after +the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`, +`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and +`generation`. Calling `next(game, ctx)` draws the game at the requested origin; +a wrapper may instead compose that canvas with its own UI. Touch controls remain +full-size OS-window chrome and draw after this hook. + `render.compose` wraps the whole-window composite in `Renderer:endFrame`. It receives `(next, renderer, ctx)`; returning `true` without calling `next` hands the mod full control of the window, while calling `next` runs the engine's diff --git a/main.lua b/main.lua index 23b2385a..c890caf9 100644 --- a/main.lua +++ b/main.lua @@ -15,6 +15,7 @@ local LaunchOptions = require("src.core.LaunchOptions") local NxDisplay = require("src.core.NxDisplay") local PlatformHooks = require("src.core.PlatformHooks") local HostDisplay = require("src.core.HostDisplay") +local GameViewport = require("src.render.GameViewport") -- Lua errors: persist a redacted trace in the save dir and surface a hint. do @@ -503,24 +504,30 @@ end function love.draw() if editorMode then + GameViewport.reset() HostDisplay.beginFrame("editor", EditorApp) local result = EditorApp.draw() HostDisplay.endFrame("editor", EditorApp) return result end if TouchEditor then + GameViewport.reset() HostDisplay.beginFrame("touch_editor", TouchEditor) local result = TouchEditor.draw() HostDisplay.endFrame("touch_editor", TouchEditor) return result end if Importer then + GameViewport.reset() HostDisplay.beginFrame("launcher", Importer) local result = Importer:draw() HostDisplay.endFrame("launcher", Importer) return result end - if not Game then return end + if not Game then + GameViewport.reset() + return + end HostDisplay.beginFrame("game", Game) Game:draw() diff --git a/src/core/Game.lua b/src/core/Game.lua index 0f8fb6de..35989cfd 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep") local Input = require("src.core.Input") local Logger = require("src.core.Logger") local Renderer = require("src.render.Renderer") +local GameViewport = require("src.render.GameViewport") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local TouchControls = require("src.core.TouchControls") @@ -452,6 +453,7 @@ local function centerClassicZones(zones, offset) end function Game:draw() + GameViewport.begin(1) -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic -- white clear @@ -563,7 +565,9 @@ function Game:draw() if ModRuntime.wantsHook("render.hud") then ModRuntime.call("render.hud", function() end, self, viewport) end - -- on-screen mobile controls: pure screen-space, over the finished frame + GameViewport.finish(self) + -- OS-window chrome: keep the pad full-size and above any composed companion + -- view instead of capturing and shrinking it with the game viewport. TouchControls:draw() end @@ -939,8 +943,10 @@ local function pointerUnclaimed() return false end -- coordinates are LOVE window units, the same space render.hud's viewport -- and the touch overlay lay out in function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) + local gameX, gameY, insideGame = GameViewport.toLocal(x, y) return ModRuntime.call("input.pointer", pointerUnclaimed, self, { phase = phase, source = source, id = id, x = x, y = y, + gameX = gameX, gameY = gameY, insideGame = insideGame, dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, }) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 6365cb37..112859eb 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -35,6 +35,7 @@ local World = require("src.world.gen2.World") -- The mod event/hook buses. Gold reaches them through Runtime like every -- other engine file, so a call site here is the same call site Gen 1 has. local ModRuntime = require("src.mods.Runtime") +local GameViewport = require("src.render.GameViewport") -- Only for the mod-supplied save migrations and the mods-changed report, which -- are keyed off save.meta and know nothing about a generation; Gold's own save -- IO is src/core/gen2/Save.lua. @@ -73,8 +74,8 @@ end -- -- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad -- (the FixedStep callback in Game2:load), so none of it goes through --- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight --- hooks below never used to fire here; it is not a reason they should not. A +-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks +-- below never used to fire here; it is not a reason they should not. A -- hook is a contract about a MOMENT in the frame, and Gold has every one of -- these moments -- so each is raised under the Gen 1 NAME with the Gen 1 -- PAYLOAD, at the Gen 1 point in the order: @@ -86,6 +87,8 @@ end -- render.output* the normal composed frame (Renderer.lua:1063) -- render.letterbox the void around the 160x144 blit (Renderer.lua:840) -- render.hud screen-space UI over the frame (src/core/Game.lua:521) +-- render.viewport the game's OS-window rectangle (GameViewport.lua:52) +-- render.window final OS-window composition (GameViewport.lua:145) -- -- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the -- world pass and the UI into ONE canvas, not two -- the call site says so and @@ -1197,9 +1200,7 @@ function Game2:frameFit(w, h) dpi = tonumber(love.window.getDPIScale()) or 1 end local pw, ph = w * dpi, h * dpi - if love.graphics.getPixelDimensions then - pw, ph = love.graphics.getPixelDimensions() - end + pw, ph = GameViewport.pixelDimensions() return scale, ox, oy, dpi, pw, ph end @@ -1217,18 +1218,15 @@ function Game2:viewport(w, h) } end --- The screen-space layer, in the Gen 1 order: render.hud and then the --- on-screen pad (src/core/Game.lua:521 and :524, either side of --- Renderer:endFrame). Both are window-space, both sit over the finished --- frame -- post passes, letterbox and all -- and neither ever enters the game --- canvas. Every exit path of Game2:draw ends here, which is what makes that --- true of the composed frame a mod owns as well as of the plain one. +-- The render.hud layer, in Gen 1's order over the finished game frame. The +-- on-screen pad is drawn separately after GameViewport.finish, because it is +-- OS-window chrome and must not be captured or scaled with this canvas. -- -- render.hud: persistent tool status. The call is fenced with -- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a -- mod render callback: a subscriber that returns cleanly but leaves a shader -- bound, the canvas redirected or the colour changed must not corrupt the next --- frame -- or, now, the pad drawn immediately after it. +-- frame. function Game2:drawHud(w, h) if ModRuntime.wantsHook("render.hud") then local G = love.graphics @@ -1236,10 +1234,6 @@ function Game2:drawHud(w, h) ModRuntime.call("render.hud", noop, self, self:viewport(w, h)) G.pop() end - -- The pad LAST, so a HUD mod cannot draw over the controls the player is - -- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1 - -- forces it, and nothing ever while a controller is in use. - TouchControls:draw() end -- render.letterbox: SGB borders and custom void art in the bars around the @@ -1363,9 +1357,9 @@ end -- is being shown on. Mod post-processes fold in between the two, where -- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid -- is then drawn over, rather than something that smears the grid itself. -function Game2:draw() +function Game2:drawViewportFrame() local G = love.graphics - local w, h = G.getDimensions() + local w, h = GameViewport.dimensions() local GBCFX = require("src.render.GBCFX") local GbcPalette = require("src.render.GbcPalette") local Pipelines = require("src.render.Pipelines") @@ -1477,6 +1471,16 @@ function Game2:draw() self:drawHud(w, h) end +function Game2:draw() + GameViewport.begin(2) + GameViewport.setTarget() + self:drawViewportFrame() + GameViewport.finish(self) + -- OS-window chrome: draw after companion composition so viewport layouts + -- neither shrink nor cover the touch pad. + TouchControls:draw() +end + -- The paper a pushed TextBox has to sit on. A textbox is built entirely from -- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0 -- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm @@ -1803,8 +1807,10 @@ end -- coordinates are LOVE window units, the same space render.hud's viewport is in function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) + local gameX, gameY, insideGame = GameViewport.toLocal(x, y) return ModRuntime.call("input.pointer", pointerUnclaimed, self, { phase = phase, source = source, id = id, x = x, y = y, + gameX = gameX, gameY = gameY, insideGame = insideGame, dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, }) end diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua index 37d600a5..2cd150a9 100644 --- a/src/core/SafeArea.lua +++ b/src/core/SafeArea.lua @@ -7,12 +7,14 @@ -- (touch overlay, launcher) should prefer this over getDimensions; the game -- canvas may still letterbox into the full framebuffer for immersion. +local GameViewport = require("src.render.GameViewport") + local SafeArea = {} -function SafeArea.rect() +function SafeArea.windowRect() local ww, wh = 0, 0 if love and love.graphics and love.graphics.getDimensions then - ww, wh = love.graphics.getDimensions() + ww, wh = GameViewport.fullDimensions() end if ww <= 0 then ww = 1 end if wh <= 0 then wh = 1 end @@ -55,4 +57,8 @@ function SafeArea.rect() return x, y, w, h end +function SafeArea.rect() + return GameViewport.localSafeRect(SafeArea.windowRect()) +end + return SafeArea diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index b3422edb..7d879c41 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -339,7 +339,7 @@ end -- demand. Mirrors it into self.orientation / self.positions / self.scale, -- which layout(), the editor chrome and the tests read. function TouchControls:currentBucket() - local _, _, sw, sh = SafeArea.rect() + local _, _, sw, sh = SafeArea.windowRect() local o = orientationFor(sw, sh) self.layouts = self.layouts or { portrait = {}, landscape = {} } local b = self.layouts[o] @@ -363,7 +363,7 @@ end -- while sizes stay derived from the short edge, times the orientation's -- size setting (#633). function TouchControls:layout() - local ox, oy, sw, sh = SafeArea.rect() + local ox, oy, sw, sh = SafeArea.windowRect() if self.layoutW == sw and self.layoutH == sh and self.layoutOx == ox and self.layoutOy == oy and self.L then return self.L @@ -397,7 +397,7 @@ end -- Move one control to a screen-space point and persist its normalized -- position within the safe rect. Used by the layout editor while dragging. function TouchControls:setControlCenter(name, cx, cy) - local ox, oy, sw, sh = SafeArea.rect() + local ox, oy, sw, sh = SafeArea.windowRect() local L = self:layout() local zone = L[name] if not zone then return end @@ -608,10 +608,10 @@ local function drawIcon(img, zone, pressed, alphaMul) zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end --- Screen-space, called by Game:draw after Renderer:endFrame -- and by --- Game2:drawHud after Gold's own present pass -- so the overlay rides on top --- of everything (world, UI, CRT/GBC FX included). Also used by the launcher --- layout editor under preview mode. +-- OS-window space, called after GameViewport.finish so the overlay rides on +-- top of the game, companion composition and post-processing without being +-- captured or scaled with any game viewport. Also used by the launcher layout +-- editor under preview mode. function TouchControls:draw() if not self:visible() then return end local L = self:layout() diff --git a/src/render/GameViewport.lua b/src/render/GameViewport.lua new file mode 100644 index 00000000..b1ae69a8 --- /dev/null +++ b/src/render/GameViewport.lua @@ -0,0 +1,185 @@ +-- Optional game viewport inside the OS window. A layout mod may reserve any +-- window-space rectangle through render.viewport; the game then renders as if +-- that rectangle were its whole display. With no subscriber this module is a +-- pass-through and allocates no canvas. + +local Runtime = require("src.mods.Runtime") + +local Viewport = { + rect = nil, + full = nil, + canvas = nil, + generation = nil, + frameActive = false, +} + +local function finite(value) + return type(value) == "number" and value == value + and value > -math.huge and value < math.huge +end + +local function realMetrics() + local G = love.graphics + local w, h = G.getDimensions() + local pw, ph = w, h + if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end + local dpiX = w > 0 and pw / w or 1 + local dpiY = h > 0 and ph / h or 1 + if dpiX < 1e-6 then dpiX = 1 end + if dpiY < 1e-6 then dpiY = 1 end + return math.max(1, w), math.max(1, h), + math.max(1, pw), math.max(1, ph), dpiX, dpiY +end + +local function clampRect(value, w, h) + if type(value) ~= "table" then + return { x = 0, y = 0, width = w, height = h } + end + local x = finite(value.x) and math.floor(value.x) or 0 + local y = finite(value.y) and math.floor(value.y) or 0 + local rw = finite(value.width) and math.floor(value.width) or w + local rh = finite(value.height) and math.floor(value.height) or h + x = math.max(0, math.min(x, w - 1)) + y = math.max(0, math.min(y, h - 1)) + rw = math.max(1, math.min(rw, w - x)) + rh = math.max(1, math.min(rh, h - y)) + return { x = x, y = y, width = rw, height = rh } +end + +local function sameSize(canvas, w, h) + return canvas and canvas:getWidth() == w and canvas:getHeight() == h +end + +function Viewport.begin(generation) + local w, h, pw, ph, dpiX, dpiY = realMetrics() + local context = { + width = w, height = h, pixelWidth = pw, pixelHeight = ph, + dpiX = dpiX, dpiY = dpiY, generation = generation, + } + local requested + if Runtime.wantsHook("render.viewport") then + requested = Runtime.call("render.viewport", function(ctx) + return { x = 0, y = 0, width = ctx.width, height = ctx.height } + end, context) + end + local rect = clampRect(requested, w, h) + Viewport.full = context + Viewport.rect = rect + Viewport.generation = generation + local active = type(requested) == "table" and requested.capture == true + or rect.x ~= 0 or rect.y ~= 0 + or rect.width ~= w or rect.height ~= h + Viewport.frameActive = active + if active then + if not sameSize(Viewport.canvas, rect.width, rect.height) then + if Viewport.canvas and Viewport.canvas.release then + Viewport.canvas:release() + end + Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height) + Viewport.canvas:setFilter("nearest", "nearest") + end + else + if Viewport.canvas and Viewport.canvas.release then + Viewport.canvas:release() + end + Viewport.canvas = nil + end + return rect +end + +function Viewport.active() + return Viewport.frameActive == true and Viewport.canvas ~= nil +end + +function Viewport.dimensions() + if Viewport.active() then + return Viewport.rect.width, Viewport.rect.height + end + return love.graphics.getDimensions() +end + +function Viewport.pixelDimensions() + if Viewport.active() then + if Viewport.canvas.getPixelDimensions then + local w, h = Viewport.canvas:getPixelDimensions() + return math.max(1, w), math.max(1, h) + end + return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)), + math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY)) + end + if love.graphics.getPixelDimensions then + return love.graphics.getPixelDimensions() + end + return love.graphics.getDimensions() +end + +function Viewport.fullDimensions() + if Viewport.full then return Viewport.full.width, Viewport.full.height end + return love.graphics.getDimensions() +end + +function Viewport.target() + return Viewport.canvas +end + +function Viewport.setTarget() + love.graphics.setCanvas(Viewport.canvas) +end + +function Viewport.toLocal(x, y) + local rect = Viewport.rect + if not rect then return x, y, true end + local lx, ly = x - rect.x, y - rect.y + return lx, ly, + lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height +end + +function Viewport.localSafeRect(x, y, w, h) + local rect = Viewport.rect + if not Viewport.active() or not rect then return x, y, w, h end + local x1, y1 = math.max(x, rect.x), math.max(y, rect.y) + local x2 = math.min(x + w, rect.x + rect.width) + local y2 = math.min(y + h, rect.y + rect.height) + if x2 <= x1 or y2 <= y1 then + return 0, 0, rect.width, rect.height + end + return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1 +end + +function Viewport.finish(game) + if not Viewport.active() then return end + local G = love.graphics + local rect, full = Viewport.rect, Viewport.full + G.setCanvas() + G.push("all") + G.origin() + G.setScissor() + G.setShader() + G.setBlendMode("alpha") + G.clear(0, 0, 0, 1) + local context = { + canvas = Viewport.canvas, + x = rect.x, y = rect.y, width = rect.width, height = rect.height, + windowWidth = full.width, windowHeight = full.height, + dpiX = full.dpiX, dpiY = full.dpiY, + generation = Viewport.generation, + } + Runtime.call("render.window", function(_, ctx) + G.setColor(1, 1, 1, 1) + G.draw(ctx.canvas, ctx.x, ctx.y) + end, game, context) + G.pop() +end + +function Viewport.reset() + Viewport.frameActive = false + Viewport.rect = nil + Viewport.full = nil + Viewport.generation = nil + if Viewport.canvas and Viewport.canvas.release then + Viewport.canvas:release() + end + Viewport.canvas = nil +end + +return Viewport diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7d5553a1..149e4899 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local GameViewport = require("src.render.GameViewport") -- leaf module (no renderer dependency), so requiring it here cannot cycle local FaithfulRes = require("src.core.FaithfulRes") @@ -69,11 +70,9 @@ Renderer.UPRIGHT_MARGIN = 160 -- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels -- on BOTH axes (square). local function displayMetrics() - local ww, wh = love.graphics.getDimensions() + local ww, wh = GameViewport.dimensions() local pw, ph = ww, wh - if love.graphics.getPixelDimensions then - pw, ph = love.graphics.getPixelDimensions() - end + pw, ph = GameViewport.pixelDimensions() local dpiX, dpiY = 1, 1 if ww > 0 and pw > 0 then dpiX = pw / ww end if wh > 0 and ph > 0 then dpiY = ph / wh end @@ -698,7 +697,7 @@ end -- When GBC FX is active the composite is drawn into presentCanvas and -- presented through the GBC FX shader as a final pass. function Renderer:endFrame(zones, worldZones) - love.graphics.setCanvas() + GameViewport.setTarget() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() -- Sp = integer framebuffer pixels per GB pixel; -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). @@ -1064,7 +1063,7 @@ function Renderer:endFrame(zones, worldZones) end if present then - love.graphics.setCanvas() + GameViewport.setTarget() -- Post-process pipelines run over the finished composite -- world, UI -- and all -- and before GBC FX, so a blur or colour grade is what the -- LCD grid is then drawn over rather than something that smears the diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index a3a22df2..aff3f69d 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -28,6 +28,7 @@ -- covered by tests; the state at the bottom is the only part that draws. local GbcPalette = require("src.render.GbcPalette") +local GameViewport = require("src.render.GameViewport") local Palettes = require("src.world.gen2.Palettes") local Runtime = require("src.mods.Runtime") local SpriteAnims = require("src.ui.gen2.SpriteAnims") @@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row) end function BattleTransition:draw() - local w, h = love.graphics.getDimensions() + local w, h = GameViewport.dimensions() self:drawWidescreen(w, h) end diff --git a/src/ui/kit/Layout.lua b/src/ui/kit/Layout.lua index d5087e5f..9ccc8309 100644 --- a/src/ui/kit/Layout.lua +++ b/src/ui/kit/Layout.lua @@ -17,6 +17,7 @@ local Kit = require("src.ui.kit.Kit") local Theme = require("src.ui.kit.Theme") local SafeArea = require("src.core.SafeArea") +local GameViewport = require("src.render.GameViewport") local Layout = {} @@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax function Layout.metrics(maxAppW) local W, H = 0, 0 if love and love.graphics and love.graphics.getDimensions then - W, H = love.graphics.getDimensions() + W, H = GameViewport.dimensions() end local ox, oy, sw, sh = SafeArea.rect() local s = Kit.layout(sw, sh) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6037233a..7436c392 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -9,6 +9,7 @@ local Collision = require("src.world.Collision") local Encounter = require("src.world.Encounter") local FieldDefaults = require("src.world.FieldDefaults") local GameVersion = require("src.core.GameVersion") +local GameViewport = require("src.render.GameViewport") local Logger = require("src.core.Logger") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") @@ -5131,7 +5132,7 @@ function OverworldState:drawWorld() -- point projects under the pipeline's own camera. That is the direct -- analogue of what :billboard does for tilt, and it keeps exactly one -- copy of every effect: the closures above are the ones that run. - local pw, ph = love.graphics.getDimensions() + local pw, ph = GameViewport.dimensions() local pscale = Zoom.scale(Game.renderer:fitScale()) local ctx = { state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 46aca5d5..3ce90f38 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -34,6 +34,7 @@ local Font = require("src.render.Font") -- a mod has taken a facade (src/mods/Gen2Compat.lua). local Gen1Facade = require("src.mods.Gen2Compat") local GbcPalette = require("src.render.GbcPalette") +local GameViewport = require("src.render.GameViewport") local Gen2Save = require("src.core.gen2.Save") local HallOfFame = require("src.core.gen2.HallOfFame") local HiddenItems = require("src.world.gen2.HiddenItems") @@ -7547,7 +7548,7 @@ function World:interactBody() end function World:fitScale() - local w, h = love.graphics.getDimensions() + local w, h = GameViewport.dimensions() return math.max(1, math.floor(math.min(w / 160, h / 144))) end @@ -8374,7 +8375,7 @@ function World:rebuildNeighbors() self.neighbors = {} if not self.map then return end local s = self:zoomScale() - local ww, wh = love.graphics.getDimensions() + local ww, wh = GameViewport.dimensions() local vw = math.ceil(ww / s) local vh = math.ceil(wh / s) if vw % 2 ~= 0 then vw = vw + 1 end @@ -9741,7 +9742,7 @@ function World:drawGround(s) if canvas then bw, bh = canvas:getDimensions() else - bw, bh = G.getDimensions() + bw, bh = GameViewport.dimensions() end BorderFill.draw(self, self:borderImageFor(self.map.id), cam.x, cam.y, bw, bh, s, self.map.id) @@ -10002,7 +10003,7 @@ end function World:draw() local G = love.graphics - local w, h = G.getDimensions() + local w, h = GameViewport.dimensions() self:refreshColorMode() G.clear(0.07, 0.05, 0.02, 1) diff --git a/tests/engine/render_viewport.lua b/tests/engine/render_viewport.lua new file mode 100644 index 00000000..7c100295 --- /dev/null +++ b/tests/engine/render_viewport.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local savedHooks = Runtime.hooks +local hooks = Hooks.new() +Runtime.hooks = hooks + +local Viewport = require("src.render.GameViewport") +local SafeArea = require("src.core.SafeArea") +local TouchControls = require("src.core.TouchControls") + +Viewport.begin(1) +assert(not Viewport.active(), "vanilla frame must not allocate a viewport") +local w, h = Viewport.dimensions() +assert(w == 640 and h == 576, "vanilla dimensions must stay unchanged") + +hooks:wrap("render.viewport", function(next, ctx) + local full = next(ctx) + assert(full.width == 640 and full.height == 576, + "viewport hook receives OS-independent window geometry") + return { x = 320, y = 12, width = 320, height = 288 } +end, 0, "fixture") + +local presented +hooks:wrap("render.window", function(next, game, ctx) + presented = ctx + return next(game, ctx) +end, 0, "fixture") + +Viewport.begin(2) +assert(Viewport.active(), "a reserved rectangle creates a game target") +w, h = Viewport.dimensions() +assert(w == 320 and h == 288, "game renders against reserved dimensions") +Viewport.target().getPixelDimensions = function() return 737, 664 end +local pw, ph = Viewport.pixelDimensions() +assert(pw == 737 and ph == 664, + "captured rendering uses the target's real high-DPI pixel dimensions") +local x, y, inside = Viewport.toLocal(400, 100) +assert(x == 80 and y == 88 and inside, + "window pointers expose viewport-local coordinates") +local _, _, outside = Viewport.toLocal(20, 20) +assert(not outside, "reserved companion space is outside the game viewport") +local _, _, localW, localH = SafeArea.rect() +local _, _, windowW, windowH = SafeArea.windowRect() +assert(localW == 320 and localH == 288, + "game chrome may still use viewport-local safe geometry") +assert(windowW == 640 and windowH == 576, + "OS chrome can retain the full-window safe geometry") +TouchControls:init() +local controls = TouchControls:layout() +assert(controls.dpad.cx < 160 and controls.a.cx > 480, + "touch controls stay laid out across the full OS window") +local function source(path) + local file = assert(io.open(path, "r")) + local text = file:read("*a") + file:close() + return text +end +for _, path in ipairs({ "src/core/Game.lua", "src/core/Game2.lua" }) do + local text = source(path) + local finish = assert(text:find("GameViewport.finish(self)", 1, true)) + local controlsDraw = assert(text:find("TouchControls:draw()", finish, true)) + assert(controlsDraw > finish, + path .. " draws touch controls after final window composition") +end +Viewport.setTarget() +assert(love.graphics.getCanvas() == Viewport.target(), + "game rendering is redirected into the viewport canvas") +Viewport.finish({}) +assert(presented and presented.x == 320 and presented.y == 12 + and presented.width == 320 and presented.height == 288 + and presented.windowWidth == 640 and presented.windowHeight == 576 + and presented.generation == 2, + "window composition receives game and host geometry") +assert(love.graphics.getCanvas() == nil, + "window composition restores the OS render target") +Viewport.reset() +assert(not Viewport.active(), + "viewport geometry cannot leak into the launcher after presentation") + +hooks.chains["render.viewport"] = nil +hooks:wrap("render.viewport", function(next, ctx) + local full = next(ctx) + full.capture = true + return full +end, 0, "capture-fixture") +Viewport.begin(1) +assert(Viewport.active() and Viewport.dimensions() == 640, + "a full-window capture allocates a final composition target") +presented = nil +Viewport.setTarget() +Viewport.finish({}) +assert(presented and presented.width == 640 and presented.height == 576, + "a full-window capture reaches final window composition") +Viewport.reset() + +Runtime.hooks = savedHooks +print("render viewport: ok") From 524138ff271cff89873ab4dec499f6dea5f15351 Mon Sep 17 00:00:00 2001 From: syybott Date: Sun, 16 Aug 2026 11:52:00 -0500 Subject: [PATCH 08/32] Fix wide battle shake test fixture --- tests/engine/wide_battle_shake_bug562.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/engine/wide_battle_shake_bug562.lua b/tests/engine/wide_battle_shake_bug562.lua index b445748c..ac5c3468 100644 --- a/tests/engine/wide_battle_shake_bug562.lua +++ b/tests/engine/wide_battle_shake_bug562.lua @@ -77,6 +77,7 @@ local function battleWith(fx, sprites) statusHUDVisible = function() return true end, bottomUIVisible = function() return true end, caughtMarkerVisible = function() return false end, + extendedHUD = function() return false end, } end From f3619a00c279585cb2e32b5c7ae4802466f23f08 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:43:38 +0200 Subject: [PATCH 09/32] fix(gen2): stay below LuaJIT local limit --- src/world/gen2/World.lua | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index a8a1f3d9..6bc30ce0 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -34,7 +34,6 @@ local Font = require("src.render.Font") -- a mod has taken a facade (src/mods/Gen2Compat.lua). local Gen1Facade = require("src.mods.Gen2Compat") local GbcPalette = require("src.render.GbcPalette") -local GameViewport = require("src.render.GameViewport") local Gen2Save = require("src.core.gen2.Save") local HallOfFame = require("src.core.gen2.HallOfFame") local HiddenItems = require("src.world.gen2.HiddenItems") @@ -7549,7 +7548,7 @@ function World:interactBody() end function World:fitScale() - local w, h = GameViewport.dimensions() + local w, h = require("src.render.GameViewport").dimensions() return math.max(1, math.floor(math.min(w / 160, h / 144))) end @@ -8376,7 +8375,7 @@ function World:rebuildNeighbors() self.neighbors = {} if not self.map then return end local s = self:zoomScale() - local ww, wh = GameViewport.dimensions() + local ww, wh = require("src.render.GameViewport").dimensions() local vw = math.ceil(ww / s) local vh = math.ceil(wh / s) if vw % 2 ~= 0 then vw = vw + 1 end @@ -9743,7 +9742,7 @@ function World:drawGround(s) if canvas then bw, bh = canvas:getDimensions() else - bw, bh = GameViewport.dimensions() + bw, bh = require("src.render.GameViewport").dimensions() end BorderFill.draw(self, self:borderImageFor(self.map.id), cam.x, cam.y, bw, bh, s, self.map.id) @@ -10003,7 +10002,7 @@ end function World:draw() local G = love.graphics - local w, h = GameViewport.dimensions() + local w, h = require("src.render.GameViewport").dimensions() self:refreshColorMode() G.clear(0.07, 0.05, 0.02, 1) From 99d9908017c98365c07f2d810d1b8a457f8e50ee Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:54:04 +0200 Subject: [PATCH 10/32] render: add cross-platform desktop companion display --- conf.lua | 17 ++- docs/modding.md | 4 + main.lua | 5 + src/core/HostShell.lua | 32 ++++++ src/render/DesktopCompanion.lua | 125 ++++++++++++++++++++++ src/render/DesktopScreen.lua | 137 +++++++++++++++++++++++++ src/render/SecondScreen.lua | 33 ++++-- tests/engine/desktop_companion.lua | 56 ++++++++++ tests/engine/desktop_second_screen.lua | 57 ++++++++++ tests/engine/hostshell_spawn_self.lua | 32 ++++++ 10 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 src/render/DesktopCompanion.lua create mode 100644 src/render/DesktopScreen.lua create mode 100644 tests/engine/desktop_companion.lua create mode 100644 tests/engine/desktop_second_screen.lua create mode 100644 tests/engine/hostshell_spawn_self.lua diff --git a/conf.lua b/conf.lua index 2894ffe5..1fe4d5da 100644 --- a/conf.lua +++ b/conf.lua @@ -6,18 +6,30 @@ function love.conf(t) local editor = os.getenv("POKEPORT_EDITOR") == "1" local developer = os.getenv("POKEPORT_DEV") == "1" + local companion = nil if arg then for _, a in ipairs(arg) do if a == "--editor" then editor = true end if a == "--developer" then developer = true end + local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$") + if port then companion = { port = tonumber(port), token = token } end end end -- main.lua runs in the same Lua state right after conf.lua; stash the -- decision in a global so it doesn't need to reparse `arg`. _G.POKEPORT_EDITOR_MODE = editor _G.POKEPORT_DEV_MODE = developer + _G.POKEPORT_DISPLAY_COMPANION = companion - if editor then + if companion then + t.identity = "pokemon-love2d-companion" + t.window.title = "gen1recomp Secondary Display" + t.window.width = 640 + t.window.height = 576 + t.window.minwidth = 160 + t.window.minheight = 144 + t.window.resizable = true + elseif editor then -- Same identity as the game, deliberately: the editor edits the game's -- saves and reads the game's ROM cache, both of which live under this -- folder. A private editor identity would point love.filesystem at an @@ -51,7 +63,8 @@ function love.conf(t) end t.version = love._os == "iOS" and "12.0" or "11.5" t.window.vsync = 1 - t.modules.joystick = true + t.modules.audio = not companion + t.modules.joystick = not companion t.modules.physics = false -- love.system is not loaded during love.conf; love._os is set by the diff --git a/docs/modding.md b/docs/modding.md index 0180762d..de1726bd 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -738,6 +738,10 @@ palette-correct blit of either canvas into an arbitrary screen rect, and the oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. This is what lets a mod lay the two passes out as two stacked Game Boy screens, or push one onto a second screen, without the engine knowing the layout. +On process-capable Windows, Linux and macOS hosts without a native display +bridge, enabling this facade opens a second resizable app window instead. It +uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled` +contract, so a mod does not need a desktop-specific rendering path. `render.output_enabled` and `render.output` are the later, whole-window seam for mods that need the engine's normal composite rather than its separate diff --git a/main.lua b/main.lua index c890caf9..4fbd44f2 100644 --- a/main.lua +++ b/main.lua @@ -8,6 +8,11 @@ -- opens the editor on that slot's file, and restores the launcher when -- the editor's Close button is pressed (openEditor / closeEditor below) +if POKEPORT_DISPLAY_COMPANION then + return require("src.render.DesktopCompanion").install( + POKEPORT_DISPLAY_COMPANION) +end + local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index fe19021c..9939ef39 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -272,6 +272,38 @@ function HostShell.quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end +-- Launch another instance of this packaged app without waiting for it. The +-- same path works on all process-capable desktop hosts; only the shell's +-- background spelling differs. Source checkouts include their game folder, +-- while fused releases and AppImages already carry it in the executable. +function HostShell.spawnSelfDetached(args) + if not require("src.core.Platform").canSpawnProcess() then return false end + local fs = love and love.filesystem + if not (fs and fs.getExecutablePath) then return false end + local executable = os.getenv("APPIMAGE") or fs.getExecutablePath() + if type(executable) ~= "string" or executable == "" then return false end + + local argv = {} + local fused = fs.isFused and fs.isFused() + if not os.getenv("APPIMAGE") and not fused and fs.getSource then + argv[#argv + 1] = fs.getSource() + end + for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end + + local command = HostShell.quote(executable) + for _, value in ipairs(argv) do + command = command .. " " .. HostShell.quote(value) + end + local osName = love.system and love.system.getOS and love.system.getOS() + if osName == "Windows" then + command = 'start "" /b ' .. command .. " >NUL 2>&1" + else + command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &" + end + local ok, _, code = os.execute(command) + return ok == true or ok == 0 or code == 0 +end + -- MEMOISED per Lua state (so once per thread). This used to spawn a whole -- `curl --version` process on every single fetch -- twice for a GET through -- the Android-bridge fallback -- which doubled the number of spawns the lock diff --git a/src/render/DesktopCompanion.lua b/src/render/DesktopCompanion.lua new file mode 100644 index 00000000..0702040b --- /dev/null +++ b/src/render/DesktopCompanion.lua @@ -0,0 +1,125 @@ +-- Minimal second-window process for src/render/DesktopScreen.lua. + +local DesktopCompanion = {} + +function DesktopCompanion.install(config) + local enet = require("enet") + local host = assert(enet.host_create()) + local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2)) + local image, sourceW, sourceH, preference + local background = { 0, 0, 0, 1 } + local connected, commandedQuit = false, false + local pointerDown = false + local started = love.timer.getTime() + local lastContact = started + + local function send(kind, payload) + if not connected then return end + pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable") + end + + local function receiveFrame(data) + local prefix = "F" .. config.token .. "\n" + if data:sub(1, #prefix) ~= prefix then return end + local split = data:find("\n", #prefix + 1, true) + if not split then return end + local w, h, rgb, mode = data:sub(#prefix + 1, split - 1) + :match("^(%d+),(%d+),(%d+),([%w_:.-]+)$") + w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb) + if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end + local ok, raw = pcall(love.data.decompress, "string", "lz4", + data:sub(split + 1)) + if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end + local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw) + if not made then return end + if not image or sourceW ~= w or sourceH ~= h then + if image and image.release then image:release() end + image = love.graphics.newImage(pixels) + else + image:replacePixels(pixels) + end + sourceW, sourceH, preference = w, h, mode + image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest", + mode:find("cover", 1, true) and "linear" or "nearest") + background = { + math.floor(rgb / 0x10000) % 0x100 / 255, + math.floor(rgb / 0x100) % 0x100 / 255, + rgb % 0x100 / 255, 1, + } + end + + local function service() + while true do + local event = host:service(0) + if not event then break end + if event.type == "connect" then + connected, lastContact = true, love.timer.getTime() + send("H") + elseif event.type == "receive" then + lastContact = love.timer.getTime() + if event.data == "Q" .. config.token then + commandedQuit = true + love.event.quit() + elseif event.data ~= "P" .. config.token then + receiveFrame(event.data) + end + elseif event.type == "disconnect" then + love.event.quit() + end + end + end + + local function placement() + if not image then return 0, 0, 1 end + local ww, wh = love.graphics.getDimensions() + local cover = preference and preference:find("cover", 1, true) + local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH) + return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale + end + + local function input(action, x, y) + if not image then return false end + local dx, dy, scale = placement() + local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale) + if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end + send("I", ("\n%s,%d,%d"):format(action, sx, sy)) + return true + end + + function love.update() + service() + local t = love.timer.getTime() + if (not connected and t - started > 5) or t - lastContact > 5 then + love.event.quit() + end + end + + function love.draw() + love.graphics.clear(background[1], background[2], background[3], background[4]) + if not image then return end + local x, y, scale = placement() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, x, y, 0, scale, scale) + end + + function love.mousepressed(x, y, button) + if button == 1 then pointerDown = input("down", x, y) end + end + function love.mousereleased(x, y, button) + if button == 1 and pointerDown then + if not input("up", x, y) then send("I", "\ncancel,0,0") end + pointerDown = false + end + end + function love.touchpressed(_, x, y) input("down", x, y) end + function love.touchreleased(_, x, y) input("up", x, y) end + function love.keypressed(key) + if key == "escape" then love.event.quit() end + end + function love.quit() + if not commandedQuit then send("C") end + pcall(peer.disconnect_now, peer) + end +end + +return DesktopCompanion diff --git a/src/render/DesktopScreen.lua b/src/render/DesktopScreen.lua new file mode 100644 index 00000000..8026bb80 --- /dev/null +++ b/src/render/DesktopScreen.lua @@ -0,0 +1,137 @@ +-- Cross-platform desktop secondary display. LOVE owns one window, so a +-- second minimal instance of this same app owns the companion window. ENet +-- is bundled with LOVE; binding it to loopback keeps frames and input local. + +local Platform = require("src.core.Platform") +local HostShell = require("src.core.HostShell") +local okEnet, enet = pcall(require, "enet") + +local DesktopScreen = {} +local state = { + enabled = false, blocked = false, host = nil, peer = nil, + token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0, +} + +local function now() + return love and love.timer and love.timer.getTime and love.timer.getTime() + or os.clock() +end + +local function destroy(sendQuit) + if state.peer and sendQuit then + pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable") + end + if state.peer then pcall(state.peer.disconnect_now, state.peer) end + if state.host then pcall(state.host.destroy, state.host) end + state.host, state.peer, state.token, state.port = nil, nil, nil, nil + state.touches = {} +end + +local function token() + local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":") + local digest = love.data.hash("sha256", seed) + return love.data.encode("string", "hex", digest):sub(1, 24) +end + +local function start() + if state.host or state.blocked or now() < state.retryAt then + return state.host ~= nil + end + local base = 49152 + math.floor(now() * 1000) % 12000 + for attempt = 0, 31 do + local port = 49152 + (base - 49152 + attempt * 37) % 12000 + local ok, host = pcall(enet.host_create, + ("127.0.0.1:%d"):format(port), 1, 2) + if ok and host then + state.host, state.port, state.token = host, port, token() + local launched = HostShell.spawnSelfDetached({ + ("--display-companion=%d,%s"):format(port, state.token), + }) + if launched then return true end + destroy(false) + break + end + end + state.retryAt = now() + 1 + return false +end + +local function service() + if not state.enabled or state.blocked then return end + if not state.host and not start() then return end + while state.host do + local ok, event = pcall(state.host.service, state.host, 0) + if not ok then + destroy(false) + state.retryAt = now() + 1 + return + end + if not event then break end + if event.type == "receive" then + local data = event.data or "" + if data == "H" .. state.token then + state.peer = event.peer + elseif event.peer == state.peer + and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then + state.touches[#state.touches + 1] = data:sub(#state.token + 3) + elseif event.peer == state.peer and data == "C" .. state.token then + state.blocked = true + destroy(false) + return + end + elseif event.type == "disconnect" and event.peer == state.peer then + destroy(false) + state.retryAt = now() + 1 + return + end + end + if state.peer and now() >= state.heartbeatAt then + state.heartbeatAt = now() + 1 + pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable") + end +end + +function DesktopScreen.usable() + return okEnet and enet ~= nil and Platform.canSpawnProcess() +end + +function DesktopScreen.available() + return DesktopScreen.detected() +end + +function DesktopScreen.detected() + service() + return state.peer ~= nil +end + +function DesktopScreen.push(imageData, w, h, background, preference) + service() + if not state.peer or not imageData or not imageData.getString then return false end + w, h = tonumber(w), tonumber(h) + if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end + local ok, raw = pcall(imageData.getString, imageData) + if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end + local packed = love.data.compress("string", "lz4", raw, 1) + local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h, + tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", "")) + local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable") + return sent +end + +function DesktopScreen.pollTouch() + service() + return table.remove(state.touches, 1) +end + +function DesktopScreen.setEnabled(on) + on = on == true + if on == state.enabled then + if on then service() end + return + end + state.enabled = on + state.blocked = false + if on then start(); service() else destroy(true) end +end + +return DesktopScreen diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index 2e5fb3b5..d3b03796 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -1,11 +1,11 @@ --- Bridge to native secondary-display output (Android Presentation). The C --- functions live in mobile/android/love/src/jni/love/src/common/android.cpp. --- Everything is guarded: off Android, or if the symbols cannot be resolved, --- this stays inert and the renderer keeps the in-window stacked layout. +-- Shared secondary-display facade. Android uses its native Presentation +-- bridge; process-capable desktop hosts fall back to a companion window. +-- Everything is guarded, so unsupported hosts keep the in-window layout. local SecondScreen = {} local C = nil local ffi = nil +local desktop = nil local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) @@ -37,17 +37,36 @@ do end end +if not C then + local ok, backend = pcall(require, "src.render.DesktopScreen") + if ok and backend and backend.usable and backend.usable() then + desktop = backend + log("desktop companion backend ready") + end +end + function SecondScreen.usable() - return C ~= nil + return C ~= nil or desktop ~= nil end function SecondScreen.available() + if desktop then return desktop.available() end if not C then return false end local ok, r = pcall(C.love_android_secondary_ready) return ok and r ~= 0 end -function SecondScreen.push(imageData, w, h) +-- A connected display is not necessarily the current Presentation yet. This +-- distinction lets a companion retry its first frame after hotplug/re-target. +function SecondScreen.detected() + if desktop then return desktop.detected() end + return SecondScreen.available() +end + +function SecondScreen.push(imageData, w, h, background, preference) + if desktop then + return desktop.push(imageData, w, h, background, preference) + end if not C or not imageData then return false end return pcall(function() C.love_android_push_secondary(imageData:getFFIPointer(), w, h) @@ -57,6 +76,7 @@ end -- Returns the oldest queued secondary-display event as "action,x,y", where -- coordinates are in the submitted frame's pixel space. function SecondScreen.pollTouch() + if desktop then return desktop.pollTouch() end if not C then return nil end local ok, event = pcall(function() return C.love_android_poll_secondary_touch() @@ -66,6 +86,7 @@ function SecondScreen.pollTouch() end function SecondScreen.setEnabled(on) + if desktop then return desktop.setEnabled(on) end if not C then return end pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) end diff --git a/tests/engine/desktop_companion.lua b/tests/engine/desktop_companion.lua new file mode 100644 index 00000000..36d8c5e1 --- /dev/null +++ b/tests/engine/desktop_companion.lua @@ -0,0 +1,56 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local clock, quit = 1, false +local sent, queue, draws = {}, {}, 0 +local peer = { + send = function(_, data) sent[#sent + 1] = data end, + disconnect_now = function() end, +} +local host = { + connect = function() return peer end, + service = function() + if #queue == 0 then return nil end + return table.remove(queue, 1) + end, +} +local rendered = { + setFilter = function() end, replacePixels = function() end, + release = function() end, +} +love = { + timer = { getTime = function() return clock end }, + data = { decompress = function(_, _, value) return value end }, + image = { newImageData = function(w, h, format, raw) + assert(w == 1 and h == 1 and format == "rgba8" and raw == "rgba") + return {} + end }, + graphics = { + newImage = function() return rendered end, + getDimensions = function() return 100, 100 end, + clear = function() end, setColor = function() end, + draw = function() draws = draws + 1 end, + }, + event = { quit = function() quit = true end }, +} +package.preload.enet = function() + return { host_create = function() return host end } +end + +require("src.render.DesktopCompanion").install({ port = 50000, token = "token" }) +queue[#queue + 1] = { type = "connect" } +love.update() +assert(sent[#sent] == "Htoken", "companion authenticates after connecting") +queue[#queue + 1] = { + type = "receive", data = "Ftoken\n1,1,0,auto\nrgba", +} +love.update() +love.draw() +assert(draws == 1, "companion draws a received frame") +love.mousepressed(50, 50, 1) +love.mousereleased(50, 50, 1) +assert(sent[#sent - 1] == "Itoken\ndown,0,0" + and sent[#sent] == "Itoken\nup,0,0", "mouse input maps back to source pixels") +queue[#queue + 1] = { type = "receive", data = "Qtoken" } +love.update() +assert(quit, "parent can close the companion") +print("desktop companion: ok") diff --git a/tests/engine/desktop_second_screen.lua b/tests/engine/desktop_second_screen.lua new file mode 100644 index 00000000..77116a8b --- /dev/null +++ b/tests/engine/desktop_second_screen.lua @@ -0,0 +1,57 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local clock = 1 +love = { + timer = { getTime = function() return clock end }, + data = { + hash = function() return "digest" end, + encode = function() return "0123456789abcdef0123456789abcdef" end, + compress = function(_, _, value) return value end, + }, +} + +local sent, spawned, queue = {}, nil, {} +local peer = { + send = function(_, data, channel, flag) + sent[#sent + 1] = { data = data, channel = channel, flag = flag } + end, + disconnect_now = function() end, +} +local host = { + service = function() + if #queue == 0 then return nil end + return table.remove(queue, 1) + end, + destroy = function() end, +} + +package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end } +package.loaded["src.core.HostShell"] = { + spawnSelfDetached = function(args) spawned = args return true end, +} +package.preload.enet = function() + return { host_create = function() return host end } +end + +local Screen = require("src.render.SecondScreen") +assert(Screen.usable(), "the shared facade selects the desktop backend") +Screen.setEnabled(true) +assert(spawned and spawned[1]:match("^%-%-display%-companion=%d+,[%w]+$"), + "enabling launches one companion of this app") +local token = spawned[1]:match(",([%w]+)$") +queue[#queue + 1] = { type = "receive", peer = peer, data = "H" .. token } +assert(Screen.detected(), "a token-authenticated companion becomes detected") + +local pixels = { getString = function() return "rgba" end } +assert(Screen.push(pixels, 1, 1, 0x102030, "auto"), + "a connected companion accepts a frame") +assert(sent[#sent].data:find("^F" .. token .. "\n1,1,1056816,auto\nrgba"), + "frame metadata and pixels stay in one loopback packet") + +queue[#queue + 1] = { + type = "receive", peer = peer, data = "I" .. token .. "\ndown,3,4", +} +assert(Screen.pollTouch() == "down,3,4", "companion input returns to the mod") +Screen.setEnabled(false) +assert(sent[#sent].data == "Q" .. token, "disabling closes the companion") +print("desktop second screen: ok") diff --git a/tests/engine/hostshell_spawn_self.lua b/tests/engine/hostshell_spawn_self.lua new file mode 100644 index 00000000..e3f2b937 --- /dev/null +++ b/tests/engine/hostshell_spawn_self.lua @@ -0,0 +1,32 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local osName, fused, command = "Windows", true, nil +love = { + system = { getOS = function() return osName end }, + filesystem = { + getExecutablePath = function() return "C:\\Game\\gen1recomp.exe" end, + getSource = function() return "C:\\Source\\gen1recomp" end, + isFused = function() return fused end, + }, +} +package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end } +local execute = os.execute +os.execute = function(value) command = value return 0 end + +local HostShell = require("src.core.HostShell") +assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) +assert(command:find('start "" /b ', 1, true) + and command:find('"C:\\Game\\gen1recomp.exe"', 1, true), + "Windows launches the fused app detached") + +osName, fused = "Linux", false +assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) +assert(command:find("'C:\\Source\\gen1recomp'", 1, true) + and command:sub(-1) == "&", "Linux source runs include the game folder") + +osName, fused = "OS X", true +assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) +assert(not command:find("start", 1, true) and command:sub(-1) == "&", + "macOS uses the same detached POSIX path") +os.execute = execute +print("spawn self detached: ok") From 70f7d5c028e4a848708ca21d58f3f04560aaf92a Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:45:03 +0200 Subject: [PATCH 11/32] Translate the clock-setting screens' day names and time-of-day word DAYS (SUNDAY..SATURDAY), the MORN/DAY/NITE word PrintHour prints, and the "o'clock"/"min." suffixes bypassed src/core/Strings.lua entirely -- they were plain Lua literals with no lookup, so a translation mod's `strings` registry had nothing to catch and Oak's clock screens, the day-of-week wheel, the main menu clock box and the Pokegear's clock card kept printing English regardless of the loaded language (reported from a real Spanish Gold build). Both live in src/core/gen2/Clock.lua, which already owns weekday/hour arithmetic and is already required by InitClock.lua, MainMenu.lua and Pokegear.lua: Clock.DAY_NAMES + Clock.weekdayName(day) is the one place the three screens read a weekday's name from, so a fix to it cannot land on one screen and silently miss the other two. Clock.daytimeLabel(hour) is the translated counterpart to Palettes.clockDaytime, which keeps answering the untranslated MORN/DAY/NITE key every FORCED_DAYTIME lookup in Palettes.lua compares against -- src/world/gen2/Palettes.lua itself is untouched, so that module stays pure table/color math with no Strings coupling. --- src/core/gen2/Clock.lua | 43 ++++++++++++++++++++++++++ src/ui/gen2/InitClock.lua | 26 ++++++++++------ src/ui/gen2/MainMenu.lua | 11 +++---- src/ui/gen2/Pokegear.lua | 10 ++----- tests/gen2_clock_test.lua | 63 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/core/gen2/Clock.lua b/src/core/gen2/Clock.lua index e926cda7..a74d2113 100644 --- a/src/core/gen2/Clock.lua +++ b/src/core/gen2/Clock.lua @@ -16,13 +16,56 @@ -- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek) -- and World only ever reads it. +local Palettes = require("src.world.gen2.Palettes") local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") local Clock = {} Clock.MINUTES_PER_DAY = 24 * 60 Clock.DAYS = 7 +-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0, so +-- index 1 is SUNDAY -- matching both InitClock's `self.day + 1` and +-- `Clock.weekday(save) + 1`. Strings.source, not Strings: built at require +-- time, before Strings.load has a catalog, so Clock.weekdayName looks each +-- name up at display time instead (src/battle/MoveEffects.lua's STAT_LABEL +-- is the same pattern). One shared table and one lookup function, so +-- InitClock's screens, the main menu clock box and the Pokegear clock card +-- cannot drift apart on what a weekday is called. +Clock.DAY_NAMES = { + Strings.source("SUNDAY"), Strings.source("MONDAY"), Strings.source("TUESDAY"), + Strings.source("WEDNESDAY"), Strings.source("THURSDAY"), Strings.source("FRIDAY"), + Strings.source("SATURDAY"), +} + +-- The translated name for a 1-based weekday (SUNDAY = 1), or nil if `day` is +-- out of range. +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end + +-- The three words Palettes.clockDaytime can hand back, translated (never +-- DARK: that one only comes out of Palettes.daytimeFor, for a PALETTE_DARK +-- map, and is never printed as text). Strings.source, not Strings: +-- clockDaytime's return value is also an internal key every +-- FORCED_DAYTIME/palette lookup in Palettes.lua compares against, so THAT +-- stays untranslated -- only this table, and Clock.daytimeLabel below, look +-- a word up, at the UI call sites that actually print it. +local DAYTIME_LABEL = { + MORN = Strings.source("MORN"), DAY = Strings.source("DAY"), + NITE = Strings.source("NITE"), +} + +-- clockDaytime's word, translated -- the one InitClock's clock-setting +-- screen and the Pokegear's clock card print (DisplayHourOClock / +-- Pokegear_UpdateClock). +function Clock.daytimeLabel(hour) + local daytime = Palettes.clockDaytime(hour) + return Strings(DAYTIME_LABEL[daytime] or daytime) +end + -- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute -- buffer left at the zero ByteFill put there. Clock.DEFAULT_HOUR = 10 diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua index d06bd81c..b957c956 100644 --- a/src/ui/gen2/InitClock.lua +++ b/src/ui/gen2/InitClock.lua @@ -60,10 +60,11 @@ InitClock.TEXT = TEXT -- same three and has always had them right. local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18 --- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0. -local DAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} +-- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this +-- table: MainMenu's clock box and the Pokegear's clock card read the same +-- weekday off the same save and must never disagree about what it is +-- called. +local DAYS = Clock.DAY_NAMES InitClock.DAYS = DAYS function InitClock:wantsFillScale() return true end @@ -88,12 +89,15 @@ function InitClock.hourString(hour) local h = math.floor(hour or 0) % 24 local display = h % 12 if display == 0 then display = 12 end - local word = require("src.world.gen2.Palettes").clockDaytime(h) + -- Clock.daytimeLabel, not Palettes.clockDaytime: the printed word, + -- translated -- this string reaches the player as-is, unlike the internal + -- MORN/DAY/NITE key other palette code compares against. + local word = Clock.daytimeLabel(h) return ("%s %d"):format(word, display) end function InitClock.oclockString(hour) - return InitClock.hourString(hour) .. " o'clock" + return Strings("%s o'clock", InitClock.hourString(hour)) end function InitClock.timeString(hour, minute) @@ -187,7 +191,7 @@ function InitClock:question() return Strings(TEXT.whoaMinutes, self.minute) end if self.phase == "confirm-day" then - return Strings(TEXT.confirmDay, DAYS[self.day + 1] or "?") + return Strings(TEXT.confirmDay, Clock.weekdayName(self.day + 1) or "?") end if self.phase == "response" then return Strings(TEXT[InitClock.responseKey(self.hour)], @@ -196,11 +200,15 @@ function InitClock:question() return "" end +-- data/text/common_1.asm's "@MIN." suffix (DisplayMinutesWithMinString), +-- separate from TEXT.whoaMinutes' own "%d min.?" confirmation line above. +local MINUTES = Strings.source("%d min.") + -- The value the picker box shows, or nil while a page is up with no picker. function InitClock:display() if self.phase == "hour" then return InitClock.oclockString(self.hour) end - if self.phase == "minute" then return ("%d min."):format(self.minute) end - if self.phase == "day" then return DAYS[self.day + 1] or "?" end + if self.phase == "minute" then return Strings(MINUTES, self.minute) end + if self.phase == "day" then return Clock.weekdayName(self.day + 1) or "?" end return nil end diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua index ac3ed88e..c8fb68fc 100644 --- a/src/ui/gen2/MainMenu.lua +++ b/src/ui/gen2/MainMenu.lua @@ -29,10 +29,11 @@ local MainMenu = {} MainMenu.__index = MainMenu MainMenu.isOpaque = true --- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. -local DAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} +-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. Clock.DAY_NAMES +-- / Clock.weekdayName is the single translated home for this table (see +-- InitClock.lua's DAYS), so this screen's clock box cannot drift from the +-- Pokegear's own. +local DAYS = Clock.DAY_NAMES -- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet. local MENU_MUSIC = "Music_MainMenu" @@ -164,7 +165,7 @@ function MainMenu:drawClockBox() -- Textbox at (0,12) with 4 interior rows and 13 interior columns. Chrome.textbox(0, 12, 13, 4) local hour, minute, weekday = self:clockParts() - Chrome.print(DAYS[weekday] or "DAY", 1, 14) + Chrome.print(Clock.weekdayName(weekday) or "DAY", 1, 14) -- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded -- minutes; the AM/PM half is drawn by PrintHour itself. local display = hour % 12 diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 0413cdaa..30bbea33 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -56,10 +56,6 @@ local CARDS = { -- card over. One row, so `#self.cards` stays 1 and nothing pages. local FLY_MAP_CARD = { id = "map", label = "FLY" } -local DAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} - -- ---------------------------------------------------------------- the radio -- -- engine/pokegear/radio.asm is not a text table: it is a jumptable of code. @@ -1878,7 +1874,7 @@ function Pokegear:drawClock() -- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and -- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at -- column 12. - self:text(DAYS[weekday] or "", 6, 6) + self:text(Clock.weekdayName(weekday) or "", 6, 6) local display = hour % 12 if display == 0 then display = 12 end self:text(Chrome.number(display, 2), 6, 8) @@ -2203,13 +2199,13 @@ function Pokegear:drawPlain() if id == "clock" then local hour, minute, weekday = self:clockParts() Chrome.box(1, 5, 18, 7) - Chrome.print(DAYS[weekday] or "DAY", 3, 7) + Chrome.print(Clock.weekdayName(weekday) or "DAY", 3, 7) local display = hour % 12 if display == 0 then display = 12 end Chrome.print(("%s:%s %s"):format( Chrome.number(display, 2), Chrome.number(minute, 2, true), hour < 12 and "AM" or "PM"), 5, 9) - Chrome.print(Palettes.clockDaytime(hour), 5, 11) + Chrome.print(Clock.daytimeLabel(hour), 5, 11) elseif id == "radio" then -- Without the gear sheet there is no dial art, so the frequencies go down -- the screen as a list. A frequency whose test failed still gets a row: diff --git a/tests/gen2_clock_test.lua b/tests/gen2_clock_test.lua index 91848816..3d82d007 100644 --- a/tests/gen2_clock_test.lua +++ b/tests/gen2_clock_test.lua @@ -18,6 +18,7 @@ require("src.core.Logger").warn = function() end local Clock = require("src.core.gen2.Clock") local InitClock = require("src.ui.gen2.InitClock") +local Strings = require("src.core.Strings") -- A stub input the screen drives off, the same shape Input:wasPressed has. local function fakeInput() @@ -254,4 +255,66 @@ do end end +-- Clock.DAY_NAMES / Clock.weekdayName / Clock.daytimeLabel: the single home +-- InitClock, MainMenu and the Pokegear clock card all share, so a weekday +-- cannot be named one way on one screen and another way on the next. +do + eq(Clock.weekdayName(1), "SUNDAY", "1-based, SUNDAY first") + eq(Clock.weekdayName(6), "FRIDAY", "and the rest in wCurDay's order") + check(Clock.weekdayName(0) == nil, "day 0 is out of range") + check(Clock.weekdayName(8) == nil, "and so is day 8") + + eq(Clock.daytimeLabel(4), "MORN", "daytimeLabel matches clockDaytime's word") + eq(Clock.daytimeLabel(10), "DAY", "for every hour band") + eq(Clock.daytimeLabel(20), "NITE", "including the wrap back to NITE") + + local MainMenu = require("src.ui.gen2.MainMenu") + check(MainMenu.DAYS == Clock.DAY_NAMES, + "MainMenu reuses the same table InitClock and the Pokegear do") +end + +-- ------------------------------------------------- a translation mod's turn +-- +-- DAYS, the clockDaytime word and the "o'clock"/"min." suffixes used to +-- bypass Strings entirely, so a translation mod's `strings` registry had no +-- seam to catch them: the picker kept printing the English day name and +-- "o'clock" no matter the catalog (reported from a real Gold build). +do + Strings.load({ + strings = { + SUNDAY = "DIMANCHE", + MORN = "MATIN", + ["%s o'clock"] = "%s heures", + ["%d min."] = "%d min", + }, + }) + + local wheel = InitClock.new({ input = fakeInput() }, { mode = "day", save = {} }) + eq(wheel:display(), "DIMANCHE", "a translated catalog reaches the day wheel") + + eq(InitClock.hourString(4), "MATIN 4", + "and the clockDaytime word, through Clock.daytimeLabel") + eq(InitClock.oclockString(4), "MATIN 4 heures", + "and the o'clock suffix, template and all") + + local minutePicker = InitClock.new({ input = fakeInput() }, { save = {} }) + minutePicker.phase = "minute" + minutePicker.minute = 30 + eq(minutePicker:display(), "30 min", "and the minutes picker's own suffix") + + -- Palettes.clockDaytime itself must stay untranslated even with a catalog + -- loaded: FORCED_DAYTIME and the rest of Palettes.lua's own lookups + -- compare against its return value as an internal key, not display text. + local Palettes = require("src.world.gen2.Palettes") + eq(Palettes.clockDaytime(4), "MORN", + "the internal palette key is untouched by the loaded catalog") + + -- Module state is process-global and tests/run_tests.lua runs every suite + -- in one process (see tests/mod_strings_tests.lua's own note): leaving the + -- catalog loaded would translate the day/hour of every suite after this + -- one. + Strings.load({}) + check(not Strings.active(), "the catalog is unloaded for the suites after this one") +end + S.finish() From e9a4a592a492163b6b62bca1be994243fd75dec3 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sun, 16 Aug 2026 15:31:19 -0500 Subject: [PATCH 12/32] feat(update): cache fetched release notes and strict-match patch notes version --- src/update/PatchNotes.lua | 49 +++++++++++++++++++++++---- src/update/check_worker.lua | 25 ++++++++++++++ tests/engine/launcher_patch_notes.lua | 15 ++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/update/PatchNotes.lua b/src/update/PatchNotes.lua index 3288c4de..5fb503b0 100644 --- a/src/update/PatchNotes.lua +++ b/src/update/PatchNotes.lua @@ -16,6 +16,10 @@ PatchNotes.FILES = { "assets/PATCH_NOTES.md", } +PatchNotes.CACHE_FILES = { + "updates/notes_cache.json", +} + PatchNotes.REPO_FILES = { "mobile/ios/app-repo.json", } @@ -48,6 +52,29 @@ local function readPath(path) return nonempty(text) and text or nil end +function PatchNotes.fromCache(engine) + for _, path in ipairs(PatchNotes.CACHE_FILES) do + local text = readPath(path) + if text then + local ok, doc = pcall(Json.decode, text) + if ok and type(doc) == "table" then + if engine and engine ~= "0.0.0-dev" then + if doc[engine] and nonempty(doc[engine]) then + return doc[engine], engine + end + else + for ver, notes in pairs(doc) do + if nonempty(notes) then + return notes, ver + end + end + end + end + end + end + return nil, nil +end + function PatchNotes.fromFile() for _, path in ipairs(PatchNotes.FILES) do local text = readPath(path) @@ -88,6 +115,7 @@ function PatchNotes.fromRepo(engine) return row.notes, row.version end end + return nil, nil end return list[1].notes, list[1].version end @@ -96,17 +124,24 @@ function PatchNotes.fromRepo(engine) end function PatchNotes.body(Check) - local notes, ver = PatchNotes.fromCheck(Check) - if notes then return notes, ver end - notes = PatchNotes.fromFile() - if notes then return notes, ver end local Version = require("src.core.Version") local engine = (Version and Version.engine) or "?" + + local notes, ver = PatchNotes.fromCheck(Check) + if notes and (engine == "0.0.0-dev" or ver == engine or ver == nil) then + return notes, ver or engine + end + + notes, ver = PatchNotes.fromCache(engine) + if notes then return notes, ver end + + notes = PatchNotes.fromFile() + if notes then return notes, engine end + notes, ver = PatchNotes.fromRepo(engine) if notes then return notes, ver end - return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n" - .. "They appear here after the launcher checks GitHub for the latest " - .. "release.", engine + + return "Unable to fetch patch notes.", engine end return PatchNotes diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 7d592ec6..175a5457 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -151,6 +151,28 @@ local function gatePasses(rel) return not (info.minShell and info.minShell > shell) end +local function cacheNotes(ver, notes) + if not (ver and type(notes) == "string" and notes ~= "" and Json) then return end + if not (love and love.filesystem) then return end + pcall(function() + love.filesystem.createDirectory("updates") + local cachePath = "updates/notes_cache.json" + local existing = {} + if love.filesystem.getInfo and love.filesystem.getInfo(cachePath) then + local text = love.filesystem.read(cachePath) + if text then + local ok, doc = pcall(Json.decode, text) + if ok and type(doc) == "table" then existing = doc end + end + end + existing[ver] = notes + local ok, encoded = pcall(Json.encode, existing) + if ok and encoded then + love.filesystem.write(cachePath, encoded) + end + end) +end + -- --------------------------------------------------------------------------- -- check -- --------------------------------------------------------------------------- @@ -177,6 +199,9 @@ local function doCheck() return end pending = rel + if rel.version and type(rel.notes) == "string" and rel.notes ~= "" then + cacheNotes(rel.version, rel.notes) + end -- Unstamped dev build: the working tree always looks "newer", so never -- pester the developer with an update (contract item, Check design). diff --git a/tests/engine/launcher_patch_notes.lua b/tests/engine/launcher_patch_notes.lua index 89fbc9c2..6532006f 100644 --- a/tests/engine/launcher_patch_notes.lua +++ b/tests/engine/launcher_patch_notes.lua @@ -68,6 +68,12 @@ do "stashed notes name a release version") end +do + local notes, ver = PatchNotes.fromRepo("999.999.999") + eq(notes, nil, "fromRepo returns nil when a specific engine version is missing") + eq(ver, nil, "fromRepo version is nil when missing") +end + do local f = assert(io.open("mobile/ios/app-repo.json", "rb")) local list = PatchNotes.parseRepo(f:read("*a")) @@ -78,6 +84,15 @@ do eq(notes, list[2].notes, "fromRepo returns that version's notes") end +do + local oldVersion = package.loaded["src.core.Version"] + package.loaded["src.core.Version"] = { engine = "999.999.999" } + local body, ver = PatchNotes.body(nil) + eq(body, "Unable to fetch patch notes.", "returns Unable to fetch patch notes when version is uncached and unlisted") + eq(ver, "999.999.999", "returns the requested engine version") + package.loaded["src.core.Version"] = oldVersion +end + imp._appPatchNotes = true local modal = drawAndCapture(imp) check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself") From a542ed90bae3f6ab4f7592bb381173b838433c3d Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sun, 16 Aug 2026 15:45:45 -0500 Subject: [PATCH 13/32] refactor: consolidate loose constants into tables in World.lua and add GameViewport module dependency and updated the behavior of the patch notes also fixed manual update checking added test to make sure no prs or build tasks are able to pass if the luajit limits ar exceeded. --- src/import/LauncherView.lua | 2 +- src/update/Check.lua | 7 +- src/world/gen2/World.lua | 482 +++++++++------------ tests/engine/luajit_source_limits_test.lua | 54 +++ 4 files changed, 262 insertions(+), 283 deletions(-) create mode 100644 tests/engine/luajit_source_limits_test.lua diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 5dab216e..fcb6165f 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -979,7 +979,7 @@ function LauncherView._updateControl(imp) end -- idle / uptodate / error: offer a manual check, with no glow. return status, Strings("Check for updates"), - function() pcall(imp.Check.start) end, false + function() pcall(imp.Check.start, true) end, false end -- ------------------------------------------------------------ game panel diff --git a/src/update/Check.lua b/src/update/Check.lua index 8e655733..c087485d 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -155,11 +155,12 @@ local function drain() end -- Begin (or, on a prior error, retry) an async check. Safe to call every frame: --- once a check is in flight or has reached a terminal state it is a no-op. -function Check.start() +-- once a check is in flight or has reached a terminal state it is a no-op unless +-- force=true is passed (e.g. from an explicit button press). +function Check.start(force) drain() if cache.status == "checking" or cache.status == "downloading" then return end - if requested and cache.status ~= "error" and cache.status ~= "idle" then return end + if not force and requested and cache.status ~= "error" and cache.status ~= "idle" then return end if not ensureWorker() then cache = { status = "error", error = "background threads unavailable" } return diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 6bc30ce0..0e0518d5 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -34,6 +34,7 @@ local Font = require("src.render.Font") -- a mod has taken a facade (src/mods/Gen2Compat.lua). local Gen1Facade = require("src.mods.Gen2Compat") local GbcPalette = require("src.render.GbcPalette") +local GameViewport = require("src.render.GameViewport") local Gen2Save = require("src.core.gen2.Save") local HallOfFame = require("src.core.gen2.HallOfFame") local HiddenItems = require("src.world.gen2.HiddenItems") @@ -65,49 +66,25 @@ local Vm = require("src.script.gen2.Vm") local Zoom = require("src.render.Zoom") -- SFX_* indices from audio/sfx_pointers.asm (constants.sfxOrder). -local SFX_ITEM = 1 --- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue: --- it farcalls CheckItemPocket over wCurItem and rings SFX_GET_TM for the TM/HM --- pocket, SFX_ITEM for every other one. That is the sound inside GiveItemScript, --- so it is what every `verbosegiveitem` plays. -local SFX_GET_TM = 0x9b --- SFX_READ_TEXT_2, the blip PlayTalkObject opens every bg event read on --- (engine/overworld/events.asm). -local SFX_READ_TEXT_2 = 8 --- SFX_SECOND_PART_OF_ITEMFINDER, the ding the heal machine rings as each --- ball lands on it (engine/events/heal_machine_anim.asm .party_loop). Gen 2 --- has no SFX_HEAL_MACHINE: the rising chime over the flashing is MUSIC_HEAL, --- a song, not an sfx. -local SFX_SECOND_PART_OF_ITEMFINDER = 0x12 --- .HOF_PlaySFX's pair: the Game Freak chime over the Hall of Fame machine's --- flashing, then SFX_BOOT_PC as it settles. -local SFX_GAME_FREAK_LOGO_GS = 0xaa -local SFX_BOOT_PC = 0x0d --- SFX_SANDSTORM, the rattle ShakeHeadbuttTree plays over a shaking tree --- (engine/events/field_moves.asm, right after its WaitSFX). -local SFX_SANDSTORM = 0x6d --- The field moves' own sounds, by their index in sfxOrder: --- SFX_STRENGTH MovementFunction_Strength, as the boulder goes --- SFX_PLACE_PUZZLE_PIECE_DOWN OWCutAnimation, the snip --- SFX_SURF PlayWhirlpoolSound, which is a bare SFX_SURF --- SFX_BUBBLEBEAM Script_UsedWaterfall's playsound --- SFX_FLASH UseFlashTextScript's text_asm -local SFX_STRENGTH = 27 -local SFX_PLACE_PUZZLE_PIECE_DOWN = 30 -local SFX_BUBBLEBEAM = 81 -local SFX_SURF = 83 -local SFX_FLASH = 169 --- EMOTE_SHOCK, emote 0 in constants/script_constants.asm. Script_FishCastRod --- loads it over EMOTE_ROD, so the bubble that pops on a bite is the shock one. +local SFX = { + ITEM = 1, + GET_TM = 0x9b, + READ_TEXT_2 = 8, + SECOND_PART_OF_ITEMFINDER = 0x12, + GAME_FREAK_LOGO_GS = 0xaa, + BOOT_PC = 0x0d, + SANDSTORM = 0x6d, + STRENGTH = 27, + PLACE_PUZZLE_PIECE_DOWN = 30, + BUBBLEBEAM = 81, + SURF = 83, + FLASH = 169, + ENTER_DOOR = 31, + WARP_TO = 19, + EXIT_BUILDING = 35, + JUMP_OVER_LEDGE = 0x16, +} local EMOTE_SHOCK = 0 --- GetWarpSFX (home/map.asm) picks one of three by the tile the player is --- standing on when the warp is taken; these are their sfxOrder indices in this --- cache (Sfx_EnterDoor, Sfx_WarpTo, Sfx_ExitBuilding), resolved by NAME at the --- call site so a cache with a different table still finds them. -local SFX_ENTER_DOOR = 31 -local SFX_WARP_TO = 19 -local SFX_EXIT_BUILDING = 35 -local SFX_JUMP_OVER_LEDGE = 0x16 local World = {} World.__index = World @@ -116,134 +93,81 @@ World.__index = World local DIR_CONN = { up = "north", down = "south", left = "west", right = "east" } local FACING_ID = { down = 0, up = 1, left = 2, right = 3 } local NEIGHBOR_HOPS = 2 --- constants/script_constants.asm -local VAR_FACING = 0x09 --- VAR_WEEKDAY, whose .DayOfWeek arm is `call GetWeekday` -> wCurDay. 39 of the --- 40 `readvar` sites reachable from a map callback are this one: it is what --- decides which of the seven travelling siblings is standing on their route, --- which haircut brother is in, and which day the Goldenrod underground --- MAPCALLBACK_OBJECTS lets through. -local VAR_WEEKDAY = 0x0b --- VAR_BATTLETYPE, the one VAR_* slot a script writes that anything reads back: --- `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_FORCEITEM` is what makes Lugia, --- Ho-Oh and the Red Gyarados hold their item, FORCESHINY what makes the --- Gyarados red, and CANLOSE what lets the Cherrygrove rival beat you. -local VAR_BATTLETYPE = 0x03 --- constants/battle_constants.asm BATTLETYPE_FORCEITEM: InitEnemyMon's --- `.WildItem` reads wBaseItem1 unconditionally for this type instead of --- rolling the ordinary 25%/8% chance, which is how Ho-Oh's SACRED_ASH (and --- Lugia's, and the Red Gyarados' held item) is guaranteed rather than random. -local BATTLETYPE_FORCEITEM = 10 --- constants/battle_constants.asm BATTLETYPE_FORCESHINY: the Lake of Rage --- Gyarados. InitEnemyMon's `.NotRoaming` arm (engine/battle/core.asm:5876) --- swaps the rolled DVs for ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA, and --- TryToRunAwayFromBattle refuses to run for this type, which the battle --- reads off opts.battleType. -local BATTLETYPE_FORCESHINY = 7 --- constants/battle_constants.asm BATTLETYPE_CANLOSE: the Cherrygrove rival's --- three arms are the only `loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE` in the --- game. LostBattle (engine/battle/core.asm) answers this type by sliding the --- winner's pic in and printing the loss text, then RETURNS -- no grayscale, no --- whiteout -- and the script that armed it follows `startbattle` with a bare --- `reloadmap`, never `reloadmapafterbattle`, so Script_BattleWhiteout is --- unreachable from this battle on either path. -local BATTLETYPE_CANLOSE = 1 -local VAR_PARTYCOUNT = 0x01 -local VAR_BATTLERESULT = 0x02 -local VAR_TIMEOFDAY = 0x04 -local VAR_DEXCAUGHT = 0x05 -local VAR_DEXSEEN = 0x06 -local VAR_BADGES = 0x07 -local VAR_MOVEMENT = 0x08 -local VAR_HOUR = 0x0a -local VAR_MAPGROUP = 0x0c -local VAR_MAPNUMBER = 0x0d -local VAR_UNOWNCOUNT = 0x0e -local VAR_ENVIRONMENT = 0x0f -local VAR_BOXSPACE = 0x10 -local VAR_CONTESTMINUTES = 0x11 -local VAR_XCOORD = 0x12 -local VAR_YCOORD = 0x13 -local VAR_SPECIALPHONECALL = 0x14 --- wPlayerState (constants/ram_constants.asm) as VAR_MOVEMENT reads it raw: --- NORMAL 0, BIKE 1, SKATE 2, SURF 4, SURF_PIKA 8. FieldMoves only models the --- four states the port can actually enter; PLAYER_SKATE is written by nothing --- in Gold. -local PLAYER_STATE_ID = { - [FieldMoves.PLAYER_NORMAL] = 0, - [FieldMoves.PLAYER_BIKE] = 1, - [FieldMoves.PLAYER_SURF] = 4, - [FieldMoves.PLAYER_SURF_PIKA] = 8, +local VAR = { + PARTYCOUNT = 0x01, + BATTLERESULT = 0x02, + BATTLETYPE = 0x03, + TIMEOFDAY = 0x04, + DEXCAUGHT = 0x05, + DEXSEEN = 0x06, + BADGES = 0x07, + MOVEMENT = 0x08, + FACING = 0x09, + HOUR = 0x0a, + WEEKDAY = 0x0b, + MAPGROUP = 0x0c, + MAPNUMBER = 0x0d, + UNOWNCOUNT = 0x0e, + ENVIRONMENT = 0x0f, + BOXSPACE = 0x10, + CONTESTMINUTES = 0x11, + XCOORD = 0x12, + YCOORD = 0x13, + SPECIALPHONECALL = 0x14, } --- The same table backwards, for `loadvar VAR_MOVEMENT, PLAYER_BIKE`: the mount --- and the dismount are a variable write on the cart, so writeVar has to be --- able to turn one back into a state name. -local PLAYER_STATE_BY_ID = {} -for state, id in pairs(PLAYER_STATE_ID) do PLAYER_STATE_BY_ID[id] = state end +local BATTLETYPE = { + CANLOSE = 1, + FORCESHINY = 7, + FORCEITEM = 10, +} -- constants/collision_constants.asm, for GetWarpSFX below. -local COLL_DOOR = 0x71 -local COLL_WARP_PANEL = 0x7c +local COLL = { + DOOR = 0x71, + WARP_PANEL = 0x7c, +} --- constants/sprite_constants.asm: wVariableSprites is indexed from SPRITE_VARS, --- so an object whose `sprite` is one of $f0..$fc names a SLOT rather than a --- sheet and only `variablesprite` can say what stands there. -local SPRITE_VARS = 0xf0 +local SPRITE = { + VARS = 0xf0, + DAY_CARE_MON_1 = 0xe0, + DAY_CARE_MON_2 = 0xe1, +} --- constants/sprite_constants.asm:143-145. Neither byte names a sheet: GetMonSprite --- (engine/overworld/overworld.asm:279-305) tests them BEFORE the SPRITE_VARS --- range and answers with LoadOverworldMonIcon of wBreedMon1Species / --- wBreedMon2Species, i.e. the deposited mon's own party-menu icon. Route 34's --- two yard objects carry them. -local SPRITE_DAY_CARE_MON_1 = 0xe0 -local SPRITE_DAY_CARE_MON_2 = 0xe1 +local ENGINE = { + DAY_CARE_MAN_HAS_EGG = 5, + DAY_CARE_MAN_HAS_MON = 6, + DAY_CARE_LADY_HAS_MON = 7, +} --- constants/engine_flags.asm, const_def, with the five-wide pokegear block --- first. These three are bits of wDayCareMan / wDayCareLady rather than slots --- of their own (data/events/engine_flags.asm:18-20), so World:engineFlag reads --- them straight out of save.dayCare. -local ENGINE_DAY_CARE_MAN_HAS_EGG = 5 -local ENGINE_DAY_CARE_MAN_HAS_MON = 6 -local ENGINE_DAY_CARE_LADY_HAS_MON = 7 +local MAPSETUP = { + WARP = 0xf1, + CONTINUE = 0xf2, + RELOADMAP = 0xf3, + TELEPORT = 0xf4, + DOOR = 0xf5, + FALL = 0xf6, + CONNECTION = 0xf7, + LINKRETURN = 0xf8, + TRAIN = 0xf9, + SUBMENU = 0xfa, + BADWARP = 0xfb, +} --- constants/map_setup_constants.asm (const_def $f1). The byte picks a row of --- MapSetupScripts (data/maps/setup_scripts.asm); the port has one map load, so --- what survives of each script is which fades it is bracketed by. -local MAPSETUP_WARP = 0xf1 -local MAPSETUP_CONTINUE = 0xf2 -local MAPSETUP_RELOADMAP = 0xf3 -local MAPSETUP_TELEPORT = 0xf4 -local MAPSETUP_DOOR = 0xf5 -local MAPSETUP_FALL = 0xf6 -local MAPSETUP_CONNECTION = 0xf7 -local MAPSETUP_LINKRETURN = 0xf8 -local MAPSETUP_TRAIN = 0xf9 -local MAPSETUP_SUBMENU = 0xfa -local MAPSETUP_BADWARP = 0xfb - --- Which of the eleven setup scripts fades, read off data/maps/setup_scripts.asm --- with its FALLTHROUGHS honoured -- MapSetupScript_Fall drops into _Door, which --- drops into _Train, and _Teleport drops into _Warp, so FALL fades out because --- DOOR's FadeOutToWhite is the next command and not because FALL names one. --- --- fade out then in : DOOR, FALL, TELEPORT (a FadeOutToWhite opens the list) --- fade in only : WARP, BADWARP, TRAIN, LINKRETURN, CONTINUE, RELOADMAP --- neither : CONNECTION, SUBMENU (an edge cross must not hitch) local MAPSETUP_FADE_OUT = { - [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true, + [MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true, } local MAPSETUP_FADE_IN = { - [MAPSETUP_DOOR] = true, [MAPSETUP_FALL] = true, [MAPSETUP_TELEPORT] = true, - [MAPSETUP_WARP] = true, [MAPSETUP_BADWARP] = true, [MAPSETUP_TRAIN] = true, - [MAPSETUP_LINKRETURN] = true, [MAPSETUP_CONTINUE] = true, - [MAPSETUP_RELOADMAP] = true, + [MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true, + [MAPSETUP.WARP] = true, [MAPSETUP.BADWARP] = true, [MAPSETUP.TRAIN] = true, + [MAPSETUP.LINKRETURN] = true, [MAPSETUP.CONTINUE] = true, + [MAPSETUP.RELOADMAP] = true, } -- MapSetupScript_Connection and _Submenu are the two with no FadeInFromWhite; -- naming them keeps the table above readable as the whole eleven-row set. local MAPSETUP_NO_FADE = { - [MAPSETUP_CONNECTION] = true, [MAPSETUP_SUBMENU] = true, + [MAPSETUP.CONNECTION] = true, [MAPSETUP.SUBMENU] = true, } -- MapSetupCommands $26 UpdateRoamMons and $27 JumpRoamMons, read off the same @@ -261,13 +185,13 @@ local MAPSETUP_NO_FADE = { -- every beast to a random roam map. Flying across Johto shuffles them; -- walking through a door does not. -- --- A plain MAPSETUP_WARP names neither, which is why warping between two floors +-- A plain MAPSETUP.WARP names neither, which is why warping between two floors -- of a building leaves them where they were. local MAPSETUP_ROAM_UPDATE = { - [MAPSETUP_CONNECTION] = true, [MAPSETUP_DOOR] = true, - [MAPSETUP_FALL] = true, [MAPSETUP_TRAIN] = true, + [MAPSETUP.CONNECTION] = true, [MAPSETUP.DOOR] = true, + [MAPSETUP.FALL] = true, [MAPSETUP.TRAIN] = true, } -local MAPSETUP_ROAM_JUMP = { [MAPSETUP_TELEPORT] = true } +local MAPSETUP_ROAM_JUMP = { [MAPSETUP.TELEPORT] = true } -- FadeOutToWhite / FadeInFromWhite (engine/tilesets/timeofday_pals.asm) are -- `ld b, $4` steps of ConvertTimePalsIncHL / .DecHL, each followed by @@ -435,8 +359,8 @@ local function itemByIndex(items, index) return nil end --- CountSetBits over a { key = true } flag table: VAR_DEXCAUGHT, VAR_DEXSEEN --- and VAR_BADGES are all "how many of these are set" reads off one. +-- CountSetBits over a { key = true } flag table: VAR.DEXCAUGHT, VAR.DEXSEEN +-- and VAR.BADGES are all "how many of these are set" reads off one. local function countFlags(flags) if not flags then return 0 end local n = 0 @@ -594,7 +518,7 @@ function World.new(game) -- GBC color state (engine/gfx/color.asm). `daytime` is the resolved -- MORN/DAY/NITE/DARK the map is currently lit by; clockHour overrides -- World:hour for drivers and tests, so the palette, the hour windows and - -- VAR_HOUR all move together; flashUsed lifts PALETTE_DARK maps. + -- VAR.HOUR all move together; flashUsed lifts PALETTE_DARK maps. palettes = nil, daytime = nil, clockHour = nil, @@ -627,12 +551,12 @@ function World.new(game) -- A field move that is mid-flow (the used-X text, then its effect). fieldMove = nil, -- ---- state the script VM owns ------------------------------------------ - -- wVariableSprites (ram/wram.asm), indexed from SPRITE_VARS: slot -> plain + -- wVariableSprites (ram/wram.asm), indexed from SPRITE.VARS: slot -> plain -- OverworldSprites byte. Cleared on a map load the way the cart's copy is -- not -- it is real WRAM that survives -- so this one survives too, and -- every map that needs a slot filled sets it from its own scene script. variableSprites = {}, - -- The VAR_* slots `writevar` / `loadvar` write. Only VAR_BATTLETYPE is + -- The VAR_* slots `writevar` / `loadvar` write. Only VAR.BATTLETYPE is -- read back today, by the next startbattle. scriptVars = {}, -- WarpCheck's find. A script that ends standing on a warp tile must not @@ -1410,8 +1334,8 @@ function World:load() -- Continue (engine/menus/intro_menu.asm): `ld a, [wSpawnAfterChampion]` is -- read BEFORE the saved position is honoured, and a pending value replaces -- it outright -- .SpawnAfterE4 / SpawnAfterRed write wDefaultSpawnpoint and - -- enter through PostCreditsSpawn's MAPSETUP_WARP instead of - -- MAPSETUP_CONTINUE. So the champion whose induction saved them standing + -- enter through PostCreditsSpawn's MAPSETUP.WARP instead of + -- MAPSETUP.CONTINUE. So the champion whose induction saved them standing -- in the Hall of Fame continues in New Bark Town, not in a room whose only -- exit is sealed. local post = self:consumePostGameSpawn() @@ -1541,7 +1465,7 @@ function World:weekday() return Clock.weekday(self.game and self.game.save) end --- hHours, which VAR_HOUR reads straight off: RTC hour 0..23. `clockHour` +-- hHours, which VAR.HOUR reads straight off: RTC hour 0..23. `clockHour` -- overrides the host clock the same way it does for the daytime palette. function World:hour() if self.clockHour then return math.floor(self.clockHour) % 24 end @@ -1559,73 +1483,73 @@ end -- engine/overworld/variables.asm .VarActionTable, walked in order. readVar -- and writevar/loadvar share the id space (GetVarAction resolves both), but --- only the handful of ADDR_DE rows (VAR_BATTLETYPE, VAR_MOVEMENT) are ever +-- only the handful of ADDR_DE rows (VAR.BATTLETYPE, VAR.MOVEMENT) are ever -- written back through writeVar/self.scriptVars; the rest are RETVAR_EXECUTE -- or RETVAR_STRBUF2 rows that just read state the engine already owns. function World:readVar(varId) - if varId == VAR_FACING and self.player then + if varId == VAR.FACING and self.player then return FACING_ID[self.player.facing] or 0 end - if varId == VAR_WEEKDAY then return self:weekday() end - if varId == VAR_BATTLETYPE then return self.scriptVars[VAR_BATTLETYPE] or 0 end + if varId == VAR.WEEKDAY then return self:weekday() end + if varId == VAR.BATTLETYPE then return self.scriptVars[VAR.BATTLETYPE] or 0 end local save = self.game and self.game.save - if varId == VAR_PARTYCOUNT then + if varId == VAR.PARTYCOUNT then return save and #(save.party or {}) or 0 end - if varId == VAR_BATTLERESULT then + if varId == VAR.BATTLERESULT then -- wBattleResult masked with ~BATTLERESULT_BITMASK (the box-full flag); -- the port never sets that bit, so the stored value already matches. return self.lastBattleResult or 0 end - if varId == VAR_TIMEOFDAY then return self:timeOfDayId() end - if varId == VAR_DEXCAUGHT then + if varId == VAR.TIMEOFDAY then return self:timeOfDayId() end + if varId == VAR.DEXCAUGHT then return countFlags(save and save.pokedex and save.pokedex.caught) end - if varId == VAR_DEXSEEN then + if varId == VAR.DEXSEEN then return countFlags(save and save.pokedex and save.pokedex.seen) end - if varId == VAR_BADGES then + if varId == VAR.BADGES then -- wBadges is TWO bytes (Johto then Kanto); CountSetBits walks both. local player = save and save.player return countFlags(player and player.badges) + countFlags(player and player.kantoBadges) end - if varId == VAR_MOVEMENT then + if varId == VAR.MOVEMENT then return PLAYER_STATE_ID[self.playerState] or 0 end - if varId == VAR_HOUR then return self:hour() end - if varId == VAR_MAPGROUP then + if varId == VAR.HOUR then return self:hour() end + if varId == VAR.MAPGROUP then return (self.map and self.map.def and self.map.def.group) or 0 end - if varId == VAR_MAPNUMBER then + if varId == VAR.MAPNUMBER then return (self.map and self.map.def and self.map.def.map) or 0 end - if varId == VAR_UNOWNCOUNT then + if varId == VAR.UNOWNCOUNT then -- CountUnown walks wUnownDex, a list of the distinct Unown FORMS caught in -- catching order. save.pokedex still only knows the SPECIES; the form list -- is its own record (save.unownDex, src/core/gen2/Unown.lua), written by -- the same two events the cart writes it on. return Unown.count(save) end - if varId == VAR_ENVIRONMENT then + if varId == VAR.ENVIRONMENT then return (self.map and self.map.def and self.map.def.environmentId) or 0 end - if varId == VAR_BOXSPACE then + if varId == VAR.BOXSPACE then if not save then return 0 end return Boxes.MONS_PER_BOX - Boxes.count(save, save.currentBox) end - if varId == VAR_CONTESTMINUTES then + if varId == VAR.CONTESTMINUTES then if not save then return 0 end local minutes = BugContest.timeLeft(save) return minutes end - if varId == VAR_XCOORD then + if varId == VAR.XCOORD then return (self.player and self.player.cellX) or 0 end - if varId == VAR_YCOORD then + if varId == VAR.YCOORD then return (self.player and self.player.cellY) or 0 end - if varId == VAR_SPECIALPHONECALL then + if varId == VAR.SPECIALPHONECALL then return self:specialCall() end return 0 @@ -1669,7 +1593,7 @@ function World:engineFlag(flag) end -- Badges live in save.player.badges, not in the flag table: on the cart the -- ENGINE_*BADGE ids ARE the bits of wJohtoBadges/wKantoBadges, so there is - -- only one store and everything that asks (field moves, VAR_BADGES, the + -- only one store and everything that asks (field moves, VAR.BADGES, the -- trainer card) has to see the same answer. See FieldMoves.BADGE_FLAG. local badge = FieldMoves.BADGE_FLAG[flag] if badge and save then @@ -1686,11 +1610,11 @@ function World:engineFlag(flag) -- two day-care mon objects; a second copy in save.engineFlags is exactly how -- the yard stayed empty forever. if save then - if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then + if flag == ENGINE.DAY_CARE_MAN_HAS_EGG then return Breeding.dayCare(save).hasEgg == true - elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON then + elseif flag == ENGINE.DAY_CARE_MAN_HAS_MON then return (Breeding.side(save, "man") or {}).mon ~= nil - elseif flag == ENGINE_DAY_CARE_LADY_HAS_MON then + elseif flag == ENGINE.DAY_CARE_LADY_HAS_MON then return (Breeding.side(save, "lady") or {}).mon ~= nil end end @@ -1717,18 +1641,18 @@ function World:setEngineFlag(flag, value) return end -- The write half of the day-care aliases. DayCareManScript_Outside's - -- `clearflag ENGINE_DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart + -- `clearflag ENGINE.DAY_CARE_MAN_HAS_EGG` (maps/Route34.asm) is the ONLY cart -- script that writes any of the three, and it is idempotent because -- DayCareManOutside already did `res DAYCAREMAN_HAS_EGG_F, [hl]` -- (engine/events/daycare.asm:393), which is Breeding.collectEgg here. The -- two HAS_MON bits belong to the deposit/withdraw routines, so a script -- write to them would be a second store: swallow it. if save then - if flag == ENGINE_DAY_CARE_MAN_HAS_EGG then + if flag == ENGINE.DAY_CARE_MAN_HAS_EGG then Breeding.dayCare(save).hasEgg = value and true or false return - elseif flag == ENGINE_DAY_CARE_MAN_HAS_MON - or flag == ENGINE_DAY_CARE_LADY_HAS_MON then + elseif flag == ENGINE.DAY_CARE_MAN_HAS_MON + or flag == ENGINE.DAY_CARE_LADY_HAS_MON then return end end @@ -1736,26 +1660,26 @@ function World:setEngineFlag(flag, value) flags[flag] = value and true or nil end --- Script_writevar / Script_loadvar. VAR_BATTLETYPE is the only slot anything +-- Script_writevar / Script_loadvar. VAR.BATTLETYPE is the only slot anything -- reads BACK out of scriptVars today, and startScriptedBattle is where it is -- consumed. -- --- VAR_MOVEMENT is the exception, and it is not a stored value at all: its row --- in .VarActionTable is the ADDRESS of wPlayerState, so `loadvar VAR_MOVEMENT, +-- VAR.MOVEMENT is the exception, and it is not a stored value at all: its row +-- in .VarActionTable is the ADDRESS of wPlayerState, so `loadvar VAR.MOVEMENT, -- PLAYER_BIKE` changes the player's state outright. That is the whole of -- Script_GetOnBike -- the `special UpdatePlayerSprite` after it only reloads -- the sheet applyPlayerState has already picked. function World:writeVar(varId, value) if varId == nil then return end self.scriptVars[varId] = value or 0 - if varId == VAR_MOVEMENT then + if varId == VAR.MOVEMENT then local state = PLAYER_STATE_BY_ID[value or 0] if state then self:applyPlayerState(state) end end end function World:battleType() - return self.scriptVars[VAR_BATTLETYPE] or 0 + return self.scriptVars[VAR.BATTLETYPE] or 0 end -- Script_callasm / Script_memcallasm: a bank:address into raw GB code. The @@ -1850,7 +1774,7 @@ function World:moveObject(objectId, cellX, cellY) end end --- Every POOLED object whose `sprite` is the SPRITE_VARS byte for `slot`, handed +-- Every POOLED object whose `sprite` is the SPRITE.VARS byte for `slot`, handed -- the sheet the slot now names -- `special LoadUsedSpritesGFX`, which is the -- command that sits beside `variablesprite` at every one of its four call sites -- (maps/Route36.asm:71, FuchsiaGym.asm:36 and :66, CopycatsHouse2F.asm:24). @@ -1883,7 +1807,7 @@ end -- off the map until the slot is filled again. function World:repaintVariableSpritePool(slot) if not self.npcPool then return end - local byte = SPRITE_VARS + slot + local byte = SPRITE.VARS + slot for key, npc in pairs(self.npcPool) do if npc.def and npc.def.sprite == byte then local name = self:resolveSprite(byte) @@ -1916,7 +1840,7 @@ function World:setVariableSprite(slot, spriteIndex) self:rebuildPeople({ seamless = true }) end --- The other half of the above: an object whose `sprite` is a SPRITE_VARS byte +-- The other half of the above: an object whose `sprite` is a SPRITE.VARS byte -- resolves through the slot table and constants.spriteOrder (1-based, because -- sprite_constants.asm's block is `const_def 1`). An unfilled slot answers nil -- and the object simply does not spawn, which is the cart's behaviour too. @@ -2000,20 +1924,20 @@ end function World:resolveSprite(sprite) if type(sprite) ~= "number" then return sprite end - -- GetMonSprite tests the two day-care bytes ABOVE the SPRITE_VARS range, so + -- GetMonSprite tests the two day-care bytes ABOVE the SPRITE.VARS range, so -- they must never reach the wVariableSprites arm. .NoBreedmon answers sprite -- 1 for an empty slot; nil is the honest port, because an empty slot leaves -- the object's own event flag (EVENT_DAY_CARE_MON_1/2) set and -- Route34EggCheckCallback only clears it once checkflag says a mon is there. - if sprite == SPRITE_DAY_CARE_MON_1 or sprite == SPRITE_DAY_CARE_MON_2 then + if sprite == SPRITE.DAY_CARE_MON_1 or sprite == SPRITE.DAY_CARE_MON_2 then local save = self.game and self.game.save local slot = save and Breeding.side(save, - sprite == SPRITE_DAY_CARE_MON_1 and "man" or "lady") + sprite == SPRITE.DAY_CARE_MON_1 and "man" or "lady") local mon = slot and slot.mon return mon and self:breedmonSpriteDef(mon.species) or nil end - if sprite < SPRITE_VARS then return nil end - local byte = self.variableSprites[sprite - SPRITE_VARS] + if sprite < SPRITE.VARS then return nil end + local byte = self.variableSprites[sprite - SPRITE.VARS] if not byte or byte == 0 then return nil end local order = self.constants and self.constants.spriteOrder return order and order[byte] or nil @@ -2136,7 +2060,7 @@ end -- group/map pair this cache cannot resolve is a silent no-op rather than a -- crash, the same way Script_warp's own group-0 arm goes nowhere. -- --- Script_warp's own entry method is MAPSETUP_WARP, whose script opens on +-- Script_warp's own entry method is MAPSETUP.WARP, whose script opens on -- DisableLCD rather than on a FadeOutToWhite: the screen goes at once and only -- the way back in is a fade. A `warpfacing` byte is PLAYERSPRITESETUP_CUSTOM_ -- FACING, which SpawnInCustomFacing applies INSTEAD of SpawnInFacingDown, so a @@ -2153,7 +2077,7 @@ end -- run one body and a warp from a mod is indistinguishable from a scripted one. function World:warpToMapId(mapId, cellX, cellY, facing) if not (mapId and cellX and cellY) then return false end - return self:runMapSetup(MAPSETUP_WARP, function() + return self:runMapSetup(MAPSETUP.WARP, function() local ok = self:setMap(mapId, cellX, cellY, facing or (self.player and self.player.facing) or "down") if ok and not facing then self:spawnFacing() end @@ -2163,7 +2087,7 @@ end -- Script_warp's group-0 arm: `warp NONE, 0, 0`. wDefaultSpawnpoint is -- SPAWN_N_A, and EnterMapSpawnPoint leaves the map and the coordinates alone --- when it reads that, so MAPSETUP_BADWARP is a full load of the map the player +-- when it reads that, so MAPSETUP.BADWARP is a full load of the map the player -- is already standing on -- HandleNewMap, LoadBlockData and LoadMapObjects -- included. That is what PlayersHousePCScript's `.Warp` is for: the bedroom's -- decorations only move when the map is loaded again. @@ -2178,7 +2102,7 @@ function World:reloadMapBadWarp(reason) if not (map and p) then return false end local mapId = map.id local cx, cy, facing = p.cellX, p.cellY, p.facing - local ok = self:runMapSetup(MAPSETUP_BADWARP, function() + local ok = self:runMapSetup(MAPSETUP.BADWARP, function() return self:setMap(mapId, cx, cy, facing) end) if ok and reason then @@ -2215,9 +2139,9 @@ end -- destination. Looked up by name so a cache whose sfx table sits at other -- indices still finds them. local WARP_SFX_NAME = { - [SFX_ENTER_DOOR] = "Sfx_EnterDoor", - [SFX_WARP_TO] = "Sfx_WarpTo", - [SFX_EXIT_BUILDING] = "Sfx_ExitBuilding", + [SFX.ENTER_DOOR] = "Sfx_EnterDoor", + [SFX.WARP_TO] = "Sfx_WarpTo", + [SFX.EXIT_BUILDING] = "Sfx_ExitBuilding", } -- Play an sfx by its pokegold LABEL, falling back to the index this cache @@ -2241,20 +2165,20 @@ end -- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue: -- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes --- wCurItem's pocket into wItemAttributeValue, and rings SFX_GET_TM for the --- TM/HM pocket, SFX_ITEM for every other one. It is the sound inside +-- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the +-- TM/HM pocket, SFX.ITEM for every other one. It is the sound inside -- GiveItemScript, so every `verbosegiveitem` runs through it -- Sage Li's -- `verbosegiveitem HM_FLASH` and every gym leader's TM included, all of which -- rang the ordinary item jingle while the item argument was thrown away. An --- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX_ITEM. +-- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX.ITEM. function World:specialSound(itemIndex) local id = itemIndex and self:itemIdByIndex(itemIndex) local items = self.game and self.game.data and self.game.data.items local def = id and items and items[id] if def and def.pocket == "TM_HM" then - self:playSfxNamed("Sfx_GetTm", SFX_GET_TM) + self:playSfxNamed("Sfx_GetTm", SFX.GET_TM) else - self:playSfxNamed("Sfx_Item", SFX_ITEM) + self:playSfxNamed("Sfx_Item", SFX.ITEM) end end @@ -2262,11 +2186,11 @@ function World:warpSound() local p = self.player if not (self.map and p) then return end local coll = self.map:cellCollision(p.cellX, p.cellY) - local id = SFX_EXIT_BUILDING - if coll == COLL_DOOR then - id = SFX_ENTER_DOOR - elseif coll == COLL_WARP_PANEL then - id = SFX_WARP_TO + local id = SFX.EXIT_BUILDING + if coll == COLL.DOOR then + id = SFX.ENTER_DOOR + elseif coll == COLL.WARP_PANEL then + id = SFX.WARP_TO end self:playSfxNamed(WARP_SFX_NAME[id], id) end @@ -2981,7 +2905,7 @@ end -- -- it READS the one the officer's `setval` left there -- so `onDone` takes -- no argument and is only the "the cutscene reached JUMPTABLE_EXIT" signal the -- coroutine in Specials.block is parked on. The `warpcheck` and the --- `newloadmap MAPSETUP_TRAIN` that follow it are the script's, not this. +-- `newloadmap MAPSETUP.TRAIN` that follow it are the script's, not this. function World:magnetTrain(toGoldenrod, onDone) local game = self.game if not (game and game.stack) then @@ -3190,11 +3114,11 @@ function World:credits(onDone) -- SPAWN_RED is the one wSpawnAfterChampion value that does not `jp -- Reset`. SpawnAfterRed writes wDefaultSpawnpoint = SPAWN_MT_SILVER, -- PostCreditsSpawn clears the byte, and the loop re-enters the overworld - -- through MAPSETUP_WARP -- play resumes outside Silver Cave, in session, + -- through MAPSETUP.WARP -- play resumes outside Silver Cave, in session, -- with no trip through the title screen. local spawn = self:consumePostGameSpawn() if spawn then - self:runMapSetup(MAPSETUP_WARP, function() + self:runMapSetup(MAPSETUP.WARP, function() return self:setMap(spawn.map, spawn.x, spawn.y, "down") end) end @@ -3336,7 +3260,7 @@ function World:runMapSetup(method, load) end if MAPSETUP_NO_FADE[method] then return wrapped() end if not MAPSETUP_FADE_OUT[method] then - -- MAPSETUP_WARP and friends open on DisableLCD: the screen simply goes, and + -- MAPSETUP.WARP and friends open on DisableLCD: the screen simply goes, and -- only the way back in is a fade. local ok = wrapped() self.fade, self.fadeLevel = "white", 1 @@ -4224,7 +4148,7 @@ end -- caller) is src/core/gen2/Phone.lua's tryRandomCall, and its carry is -- Script_ReceivePhoneCall, so a landed call answers true the same way the -- contest's over-script does. What belongs here is only what the gate reads --- off the world: CheckStandingOnEntrance (home/map_objects.asm) is COLL_DOOR +-- off the world: CheckStandingOnEntrance (home/map_objects.asm) is COLL.DOOR -- / COLL_DOOR_79 / COLL_STAIRCASE / COLL_CAVE under the player's feet. function World:checkTimeEvents() local save = self.game and self.game.save @@ -4456,16 +4380,16 @@ function World:escapeRopeTarget() return backup.map, destWarp end --- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX_WARP_TO, --- `loadvar VAR_MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP_DOOR` with +-- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX.WARP_TO, +-- `loadvar VAR.MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP.DOOR` with -- the triple already in wNextWarp -- EnterMapWarp and GetWarpDestCoords land -- the player on the destination warp's own tile. The dig-spin sprite work is -- not ported, the same standing decision World:flyTo records for the two fly -- animations. function World:runEscapeWarp(destMapId, destWarp) - self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO) + self:playSfxNamed("Sfx_WarpTo", SFX.WARP_TO) self:applyPlayerState(FieldMoves.PLAYER_NORMAL) - return self:runMapSetup(MAPSETUP_DOOR, function() + return self:runMapSetup(MAPSETUP.DOOR, function() local ok = self:setMap(destMapId, destWarp.x, destWarp.y, "down") if ok then self:spawnFacing() end return ok @@ -4809,7 +4733,7 @@ function World:useSacredAsh() local script = { { op = "special", id = self:specialIdNamed("HealParty") }, { op = "refreshmap" }, - { op = "playsound", id = self:sfxIdNamed("Sfx_WarpTo", SFX_WARP_TO) }, + { op = "playsound", id = self:sfxIdNamed("Sfx_WarpTo", SFX.WARP_TO) }, } for _ = 1, 3 do script[#script + 1] = { op = "special", id = self:specialIdNamed("FadeOutToWhite") } @@ -4971,7 +4895,7 @@ function World:runHeadbutt(cx, cy, mon) -- canvas), so the wobble is the frame's, on the same clock and for the -- same 32 frames as the SFX that goes with it. self:earthquake(0x40, HEADBUTT_SHAKE_FRAMES) - self:playSfx(SFX_SANDSTORM) + self:playSfx(SFX.SANDSTORM) end) end @@ -5378,27 +5302,27 @@ function World:runCut(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:replaceBlock(result.blockIndex, result.replacement) - self:playSfx(SFX_PLACE_PUZZLE_PIECE_DOWN) + self:playSfx(SFX.PLACE_PUZZLE_PIECE_DOWN) end) end -- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and --- PlayWhirlpoolSound (a bare SFX_SURF) in place of the snip. +-- PlayWhirlpoolSound (a bare SFX.SURF) in place of the snip. function World:runWhirlpool(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:replaceBlock(result.blockIndex, result.replacement) - self:playSfx(SFX_SURF) + self:playSfx(SFX.SURF) end) end --- Script_UseFlash: the text plays SFX_FLASH from inside itself +-- Script_UseFlash: the text plays SFX.FLASH from inside itself -- (UseFlashTextScript's text_asm), and BlindingFlash then sets -- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there -- is to it: Palettes.daytimeFor already turns a flashed PALETTE_DARK map into -- a NITE one, which is the cart's own .UsedFlash arm. function World:runFlash(result) - self:playSfx(SFX_FLASH) + self:playSfx(SFX.FLASH) self:showText(Strings(result.text), function() self.flashUsed = true if self:applyPalettes() then self:refreshMapImages() end @@ -5442,7 +5366,7 @@ function World:runStrength(result) end) end --- Script_UsedWaterfall: the line, SFX_BUBBLEBEAM, and then a loop of one +-- Script_UsedWaterfall: the line, SFX.BUBBLEBEAM, and then a loop of one -- turn_waterfall UP step at a time. -- -- .CheckContinueWaterfall writes wScriptVar = 0 while the player is STILL on a @@ -5452,7 +5376,7 @@ end function World:runWaterfall(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() - self:playSfx(SFX_BUBBLEBEAM) + self:playSfx(SFX.BUBBLEBEAM) self.fieldMove = { phase = "waterfall" } self:waterfallStep() end) @@ -5512,7 +5436,7 @@ function World:tryPushBoulder(dir, cx, cy) if e ~= npc and e.cellX == tx and e.cellY == ty then return false end end npc:scriptStep(dir) - self:playSfx(SFX_STRENGTH) + self:playSfx(SFX.STRENGTH) -- Gen 1's four payload keys. Divergence, deliberate: Gen 1 emits from the -- scriptMove completion callback, once the boulder has settled; Gold's -- MovementFunction_Strength has no such callback, so this fires as the push @@ -5576,17 +5500,17 @@ function World:runDigEscape(result) end -- TeleportFunction's .TeleportScript: the return line, then WarpToSpawnPoint --- with `newloadmap MAPSETUP_TELEPORT` -- the same landing a whiteout takes, +-- with `newloadmap MAPSETUP.TELEPORT` -- the same landing a whiteout takes, -- which is exactly what World:warpToSpawn resolves (blackoutmod override -- first, then the SPAWN_* table). PLAYER_NORMAL first, so a teleport off a --- bike arrives on foot the way `loadvar VAR_MOVEMENT, PLAYER_NORMAL` leaves +-- bike arrives on foot the way `loadvar VAR.MOVEMENT, PLAYER_NORMAL` leaves -- it. The teleport spin, like the dig spin, is sprite work and not ported. function World:runTeleport(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() - self:playSfxNamed("Sfx_WarpTo", SFX_WARP_TO) + self:playSfxNamed("Sfx_WarpTo", SFX.WARP_TO) self:applyPlayerState(FieldMoves.PLAYER_NORMAL) - self:runMapSetup(MAPSETUP_TELEPORT, function() + self:runMapSetup(MAPSETUP.TELEPORT, function() self:warpToSpawn() return true end) @@ -5728,7 +5652,7 @@ function World:flyPoints() self.game and self.game.save, self.landmarks, self:region()) end --- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP_TELEPORT` brings the +-- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP.TELEPORT` brings the -- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens -- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the -- same pair of fades a door is, which is where the two fly animations ride: @@ -5740,7 +5664,7 @@ function World:flyTo(spawnId) return false end self:applyPlayerState(FieldMoves.PLAYER_NORMAL) - local ok = self:runMapSetup(MAPSETUP_TELEPORT, function() + local ok = self:runMapSetup(MAPSETUP.TELEPORT, function() return self:setMap(spawn.map, spawn.x, spawn.y, "down") end) -- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off @@ -5936,7 +5860,7 @@ function World:startBattle(opts, onDone) game.stack:pop() -- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2. -- The port never forfeits or draws a battle, so "lose" is the only - -- other outcome startBattle's onDone hands back; VAR_BATTLERESULT + -- other outcome startBattle's onDone hands back; VAR.BATTLERESULT -- reads this back masked with ~BATTLERESULT_BITMASK, same as the cart. self.lastBattleResult = (outcome == "lose") and 1 or 0 -- BattleEnd_HandleRoamMons, which runs on the way out of EVERY wild @@ -5986,7 +5910,7 @@ function World:startBattle(opts, onDone) -- (`checkflag ENGINE_BUG_CONTEST_TIMER / iftrue .bug_contest` skips -- both callasms), so a wipe in the park costs nothing. -- - -- BATTLETYPE_CANLOSE is the other exception, and it is the battle + -- BATTLETYPE.CANLOSE is the other exception, and it is the battle -- engine's own: LostBattle (engine/battle/core.asm) prints the loss -- text for this type and returns with the player exactly where they -- fought, and maps/CherrygroveCity.asm follows the battle with @@ -5995,7 +5919,7 @@ function World:startBattle(opts, onDone) -- .FinishRival is what heals the party, not a whiteout. Warping here -- moved the loser to the spawn point and then ran that walk-off over -- whatever stood there. - if outcome == "lose" and opts.battleType ~= BATTLETYPE_CANLOSE then + if outcome == "lose" and opts.battleType ~= BATTLETYPE.CANLOSE then self:healParty() if not BugContest.isActive(game.save) then CallAsm.run(self, "HalveMoney") @@ -6104,22 +6028,22 @@ function World:startScriptedBattle(record, wild, onDone) } elseif wild and wild.species then local id, def = speciesByIndex(data and data.pokemon, wild.species) - -- InitEnemyMon `.NotRoaming` / BATTLETYPE_FORCESHINY: the DV pair is + -- InitEnemyMon `.NotRoaming` / BATTLETYPE.FORCESHINY: the DV pair is -- forced to ATKDEFDV_SHINY $EA / SPDSPCDV_SHINY $AA (Attack 14, the -- rest 10) before stats are built, which is the whole of what makes the -- Red Gyarados red -- and caught, it keeps the DVs and stays shiny. local monOpts - if self:battleType() == BATTLETYPE_FORCESHINY then + if self:battleType() == BATTLETYPE.FORCESHINY then monOpts = { dvs = { attack = 14, defense = 10, speed = 10, special = 10 } } end opts.wild = id and Mon.new(data, id, wild.level or 5, monOpts) or nil - -- InitEnemyMon's `.WildItem` / BATTLETYPE_FORCEITEM: Item1 is handed over + -- InitEnemyMon's `.WildItem` / BATTLETYPE.FORCEITEM: Item1 is handed over -- unconditionally, no roll, which is the only wild-item path modeled -- -- see Mon.new's own note on why the general 25%/8% roll is not. Read - -- here rather than after startBattle, because scriptVars[VAR_BATTLETYPE] + -- here rather than after startBattle, because scriptVars[VAR.BATTLETYPE] -- is cleared the moment this function hands off to it. - if opts.wild and self:battleType() == BATTLETYPE_FORCEITEM then + if opts.wild and self:battleType() == BATTLETYPE.FORCEITEM then local given = def and def.items and def.items[1] if given then opts.wild.item = given end end @@ -6128,14 +6052,14 @@ function World:startScriptedBattle(record, wild, onDone) if onDone then onDone("win") end return false end - -- wBattleType, which `writevar VAR_BATTLETYPE / loadvar BATTLETYPE_*` armed: + -- wBattleType, which `writevar VAR.BATTLETYPE / loadvar BATTLETYPE_*` armed: -- FORCEITEM 10 (Lugia, Ho-Oh, the Red Gyarados), FORCESHINY 7 (Lake of Rage), -- TRAP 9 (the Rocket base), CANLOSE 1 (the Cherrygrove rival). It is a -- ONE-SHOT on the cart -- BattleStart_TrainerBattle / StartWildBattle reset -- it -- so the value is taken and cleared here and handed to the battle, -- which is the half that still has to act on each case. opts.battleType = self:battleType() - self.scriptVars[VAR_BATTLETYPE] = nil + self.scriptVars[VAR.BATTLETYPE] = nil return self:startBattle(opts, onDone) end @@ -6342,10 +6266,10 @@ function World:startHealMachineAnim(animType, onDone) end -- One frame of the machine, on the cart's own timeline: each party member's --- ball lands with SFX_SECOND_PART_OF_ITEMFINDER then DelayFrames 30, then +-- ball lands with SFX.SECOND_PART_OF_ITEMFINDER then DelayFrames 30, then -- MUSIC_HEAL plays over .FlashPalettes8Times -- eight rotations of the OBJ -- palette ten frames apart. The Hall of Fame arm swaps the jingle for --- SFX_GAME_FREAK_LOGO_GS and rings SFX_BOOT_PC once the flashing stops. +-- SFX.GAME_FREAK_LOGO_GS and rings SFX.BOOT_PC once the flashing stops. -- The special returns after the last flash's delay, which is when the balls -- clear -- the cart leaves its OAM to the overworld redraw the ended script -- allows, and this is that same moment. @@ -6359,11 +6283,11 @@ function World:stepHealAnim() if ha.lit < ha.balls then ha.lit = ha.lit + 1 self:playSfxNamed("Sfx_SecondPartOfItemfinder", - SFX_SECOND_PART_OF_ITEMFINDER) + SFX.SECOND_PART_OF_ITEMFINDER) else ha.phase = "flash" if ha.hof then - self:playSfxNamed("Sfx_GameFreakLogoGs", SFX_GAME_FREAK_LOGO_GS) + self:playSfxNamed("Sfx_GameFreakLogoGs", SFX.GAME_FREAK_LOGO_GS) else -- .PlayHealMusic. playOnce hands the map its theme back when the -- jingle ends; the script's own `pause 30` + RestartMapMusic @@ -6379,7 +6303,7 @@ function World:stepHealAnim() ha.timer = 0 if ha.flashes >= 8 then if ha.hof then - self:playSfxNamed("Sfx_BootPc", SFX_BOOT_PC) + self:playSfxNamed("Sfx_BootPc", SFX.BOOT_PC) end local done = ha.onDone self.healAnim = nil @@ -6863,7 +6787,7 @@ end -- -- `hold` is the cart's `pause` when that pause sits INSIDE the box rather than -- after it: FindItemInBallScript is `writetext .FoundItemText / playsound --- SFX_ITEM / pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17) and +-- SFX.ITEM / pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17) and -- none of those commands takes the box down. It cannot be run as a VM `pause` -- here, because Game2:update stops at the top state -- while ANY box is on the -- stack the overworld and the VM under it do not tick at all -- so the wait has @@ -7494,7 +7418,7 @@ function World:interactBody() if hidden then self.talkNpc = nil -- PlayTalkObject, the SFX every read of a bg event opens on. - self:playSfxNamed("Sfx_ReadText2", SFX_READ_TEXT_2) + self:playSfxNamed("Sfx_ReadText2", SFX.READ_TEXT_2) interacted(self, fx, fy, "hidden", hidden) return self.vm:start(HiddenItems.pickupScript(hidden.item, hidden.event)) end @@ -7548,7 +7472,7 @@ function World:interactBody() end function World:fitScale() - local w, h = require("src.render.GameViewport").dimensions() + local w, h = GameViewport.dimensions() return math.max(1, math.floor(math.min(w / 160, h / 144))) end @@ -8289,7 +8213,7 @@ local function samePalette(name) return name end -- OverworldState:timeOfDay wraps, and the same job: answer what time of day -- the WORLD is in. It carries more here because Gold has a real clock behind -- it (src/core/gen2/Clock.lua), so this is the one write everything downstream --- reads -- World:timeOfDayId's VAR_TIMEOFDAY, the encounter slots, the object +-- reads -- World:timeOfDayId's VAR.TIMEOFDAY, the encounter slots, the object -- hour windows and the palette bake all follow whatever comes back. -- -- Gen 1's ctx keys (map, mapId, x, y, steps) are kept verbatim; `hour` and @@ -8320,7 +8244,7 @@ function World:applyPalettes() local previousTod = self.tod -- GetTimeOfDay reads hHours, the one clock UpdateTime writes (home/time.asm): -- the palette, the object hour windows (World:objectTimeVisible), the - -- day/night encounter slots and VAR_HOUR are all the same read, so this goes + -- day/night encounter slots and VAR.HOUR are all the same read, so this goes -- through World:hour rather than round-tripping the host clock inside -- Palettes.clockDaytime. local hour = self:hour() @@ -8375,7 +8299,7 @@ function World:rebuildNeighbors() self.neighbors = {} if not self.map then return end local s = self:zoomScale() - local ww, wh = require("src.render.GameViewport").dimensions() + local ww, wh = GameViewport.dimensions() local vw = math.ceil(ww / s) local vh = math.ceil(wh / s) if vw % 2 ~= 0 then vw = vw + 1 end @@ -8431,7 +8355,7 @@ function World:setMap(mapId, cx, cy, facing, opts) -- that exemption, saving inside Kurt's house and continuing re-arms the -- latch and he repeats the branch the player already saw. The post-credits -- spawn is not a continue: SpawnAfterE4 / PostCreditsSpawn set - -- MAPSETUP_WARP, so it takes the reset (engine/menus/intro_menu.asm). + -- MAPSETUP.WARP, so it takes the reset (engine/menus/intro_menu.asm). if not opts.continue then self.events:resetMapBuffer() end @@ -8641,12 +8565,12 @@ end -- -- WarpToNewMapScript: -- warpsound --- newloadmap MAPSETUP_DOOR +-- newloadmap MAPSETUP.DOOR -- end -- -- so a warp taken by walking onto the tile is two things, in that order: the -- sound GetWarpSFX picks off the tile the player is STANDING on (which is why --- it has to be read before the load), and the MAPSETUP_DOOR setup script with +-- it has to be read before the load), and the MAPSETUP.DOOR setup script with -- the map load inside it. This used to be five lines that called setMap -- directly, which is why doors were silent and instant. -- home/map.asm GetDestinationWarpNumber: a `warp_event` whose destination warp @@ -8767,7 +8691,7 @@ function World:takeWarp(warpDef) Runtime.emit("player.warped", { fromMap = prevMapId, toMap = destMapId, x = destX, y = destY, warp = warpDef, toWarp = destWarpNumber }) - return self:runMapSetup(MAPSETUP_DOOR, function() + return self:runMapSetup(MAPSETUP.DOOR, function() local ok = self:setMap(destMapId, destX, destY, (self.player and self.player.facing) or "down") if ok then @@ -8783,7 +8707,7 @@ end -- the tile they ARRIVE on, then `call c, SpawnInFacingDown`. A tile that is -- not in that array keeps the facing they walked in with -- so you enter a -- building still facing up (the mat inside is a COLL_WARP_CARPET_*) and step --- out of one facing the street (the doorway outside is COLL_DOOR). +-- out of one facing the street (the doorway outside is COLL.DOOR). -- -- It runs AFTER the load for the same reason the cart's does: the array is -- indexed by wPlayerTileCollision, which is the DESTINATION map's tile. @@ -8900,7 +8824,7 @@ function World:tryLedgeJump(dir) p.progress = 0 -- engine/overworld/map_objects.asm:1163 p.stepFrames = Player.STEP_FRAMES * 2 - self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE) + self:playSfxNamed("Sfx_JumpOverLedge", SFX.JUMP_OVER_LEDGE) return true end @@ -9742,7 +9666,7 @@ function World:drawGround(s) if canvas then bw, bh = canvas:getDimensions() else - bw, bh = require("src.render.GameViewport").dimensions() + bw, bh = GameViewport.dimensions() end BorderFill.draw(self, self:borderImageFor(self.map.id), cam.x, cam.y, bw, bh, s, self.map.id) @@ -10002,7 +9926,7 @@ end function World:draw() local G = love.graphics - local w, h = require("src.render.GameViewport").dimensions() + local w, h = GameViewport.dimensions() self:refreshColorMode() G.clear(0.07, 0.05, 0.02, 1) diff --git a/tests/engine/luajit_source_limits_test.lua b/tests/engine/luajit_source_limits_test.lua new file mode 100644 index 00000000..e002b411 --- /dev/null +++ b/tests/engine/luajit_source_limits_test.lua @@ -0,0 +1,54 @@ +-- Source file LuaJIT limits gate. +-- Verifies that every game engine source file compiles cleanly under +-- LuaJIT without exceeding LuaJIT's strict 200 local variables per-scope limit, +-- 60 upvalue limit, or bytecode compiler limits. +-- luajit tests/engine/luajit_source_limits_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +-- Find all .lua files in a directory recursively. +local function findLuaFiles(dir, out) + out = out or {} + local p = io.popen("find " .. dir .. " -type f -name '*.lua'") + if p then + for line in p:lines() do + out[#out + 1] = line + end + p:close() + end + table.sort(out) + return out +end + +local files = findLuaFiles("src") +findLuaFiles("tools/save-editor", files) +files[#files + 1] = "main.lua" +files[#files + 1] = "conf.lua" + +check(#files > 50, "discovered project source files (found " .. tostring(#files) .. ")") + +for _, path in ipairs(files) do + local f = assert(io.open(path, "rb"), "could not open " .. path) + local source = f:read("*a") + f:close() + + -- Compile through LuaJIT loadstring: detects 'main function has more than 200 local variables' + -- or 'function has more than 200 local variables' across any function scope in the file. + local chunk, err = loadstring(source, "@" .. path) + check(chunk ~= nil, path .. " compiles under LuaJIT: " .. tostring(err)) +end + +-- Meta-test: prove that exceeding 200 locals fails the gate +do + local overflowLocals = {} + for i = 1, 201 do overflowLocals[i] = "v" .. i end + local badCode = "local " .. table.concat(overflowLocals, ", ") + local chunk, err = loadstring(badCode, "@overflow_test.lua") + check(chunk == nil, "LuaJIT strictly rejects chunks exceeding 200 locals") + check(tostring(err):find("200 local variables", 1, true) ~= nil, "error message specifies 200 local variable limit") +end + +T.finish("luajit_source_limits") From 881670db91599296592c11c6c700cdce6a06e2d3 Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:01:49 +0200 Subject: [PATCH 14/32] Clear drainHold once the HP-bar drain actually finishes stepHPDrain counts drainHold down to 0 as the last step of every phase (pixel slide, HP-number step, closing frames) but never let go of the field afterward, so it sat at 0 -- not nil -- for the rest of the battle. BattleSafety.inspect uses drainHold ~= nil as its settled-presentation gate for checkpoint capture, so the very first HP change in a battle permanently refused every checkpoint after it with battle_phase_busy, even once the bar had long since caught up. Only nil the field when the whole drain is actually over (bar pixel, HP number and the closing-frame hold all settled), not on every mid-sequence 0 -- a fresh HP change still needs drainHold to read as busy so BattleSafety keeps refusing captures until that one settles too. --- src/battle/BattleState.lua | 9 +++++++++ tests/engine/battle_checkpoint_boundary.lua | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index dee46a19..36e9bf5e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1103,6 +1103,15 @@ function BattleState:stepHPDrain() if not b.shownPx then b.shownPx = targetPx end if (b.drainHold or 0) > 0 then b.drainHold = b.drainHold - 1 + -- Once the count runs out with nothing left pending (bar and + -- number already on the final total), the drain is over, not just + -- between steps: leave the field at 0 and BattleSafety.inspect + -- reads it as still mid-animation for the rest of the battle, + -- since drainHold ~= nil is its settled-presentation gate. + if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal + and not b.draining then + b.drainHold = nil + end busy = true elseif b.shownPx ~= targetPx then -- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 / diff --git a/tests/engine/battle_checkpoint_boundary.lua b/tests/engine/battle_checkpoint_boundary.lua index c9bfea05..318c460c 100644 --- a/tests/engine/battle_checkpoint_boundary.lua +++ b/tests/engine/battle_checkpoint_boundary.lua @@ -131,4 +131,25 @@ T.same(Checkpoint.inspect(game), { canCapture = true, canRestore = true, kind = "overworld", }, "settled overworld remains supported") +-- drainHold gates capture (see the refused() case above) exactly because it +-- marks an HP bar mid-animation. Once stepHPDrain settles the bar it must +-- let go of that gate too, or the very first drain of a battle leaves the +-- checkpoint contract refused for everything after it. +do + local game3, _, battle3 = makeGame() + battle3.enemy.mon.hp = battle3.enemy.mon.hp - 5 + local frames = 0 + while battle3:stepHPDrain() and frames < 10000 do + frames = frames + 1 + end + T.eq(battle3.enemy.shownHP, battle3.enemy.mon.hp, + "the HP bar settles on the new total") + T.eq(battle3.enemy.drainHold, nil, + "drainHold releases the checkpoint gate once the bar finishes draining") + local capability = Checkpoint.inspect(game3) + T.check(capability.canCapture == true, + "a checkpoint is capturable again after the drain settles: " + .. tostring(capability.reason)) +end + T.finish() From 2b5229e73f5145fd8b1f4de4fe938b61d6d6ad44 Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:03:52 +0200 Subject: [PATCH 15/32] Deny require("jit.util") in the mod sandbox DENIED_PREFIX blocked love.* and ffi.* submodule requires but had no entry for jit, so require("jit.util") walked straight through to the real module. jit.util is LuaJIT's own equivalent of the debug library this file already denies by name: funcbc, funck and the rest read the bytecode and constants of any function a chunk can reach, which is enough to recover upvalues -- the real _G, love, io -- that the sandbox exists to keep out of a mod's hands. Adding "jit" to DENIED_PREFIX blocks jit.* submodule requires the same way love.* and ffi.* already are, while leaving the bare jit global (env.jit, handed over directly for jit.on/off/flush) and a bare require("jit") untouched -- jit.util is not a field of that table without its own require, so neither route was ever a way to reach it. --- src/mods/Sandbox.lua | 10 ++++++++-- tests/modkit/cases/sandbox.lua | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index e41190ba..f52a07ce 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -28,8 +28,14 @@ local DENIED = { } -- Same idea one level up: love.filesystem is reachable by name, and --- love.thread starts a Lua state this sandbox has no say over. -local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true } +-- love.thread starts a Lua state this sandbox has no say over. jit.util +-- is the LuaJIT-specific equal of the debug library above -- funcbc, +-- funck and friends read the bytecode and constants of any function a +-- chunk can reach, which is enough to walk back to upvalues (the real +-- _G, love, io) the rest of this file exists to keep out of reach. The +-- bare `jit` table stays -- env.jit above hands it over directly for +-- jit.on/off/flush -- so only the submodule require is denied. +local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true } -- The wire, which is what the network permission governs. local NETWORK = { socket = true, enet = true, http = true, https = true, diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua index 27133e15..5f9c06c8 100644 --- a/tests/modkit/cases/sandbox.lua +++ b/tests/modkit/cases/sandbox.lua @@ -44,6 +44,11 @@ local PROBE = [[ out.requireDebug = attempt(require, "debug") out.requirePackage = attempt(require, "package") out.requireFfi = attempt(require, "ffi") + -- jit.util exposes bytecode/constant introspection over any function this + -- chunk can reach -- the same class of escape the debug library is denied + -- for -- so it must fail the same way require("debug") does rather than + -- walking straight through under the bare "jit" global's cover. + out.requireJitUtil = attempt(require, "jit.util") out.requireSocket = attempt(require, "socket") out.requireSemver = select(2, pcall(require, "src.mods.Semver")) @@ -180,6 +185,8 @@ T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _ T.eq(out.debug, nil, "the debug library is still absent") T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state") T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C") +T.check(out.requireJitUtil ~= false, + "require(\"jit.util\") is still refused: it is bytecode/constant introspection") T.check(out.requireDebug ~= false, "require(\"debug\") is still refused") T.check(out.requirePackage ~= false, "require(\"package\") is still refused") T.eq(out.popen, nil, "io.popen refuses rather than spawning a process") From 82ae667611c4877e28555e3ccb5ae856bc966fa5 Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:04:23 +0200 Subject: [PATCH 16/32] Update the crossValidate comment for growth_rates / evolution_methods The comment above the gatedFor skip in Schemas.crossValidate still described growth_rates and evolution_methods as unconfirmable Gen 2 namespaces, the way they were before each got a real Gen 2 id space: growth_rates keeps its Gen 1 target and is seeded from the extractor's data.pokemon.growthRates (src/battle/gen2/Mon.lua), and evolution_methods routes to gen2EvolutionMethods, a fixed literal set (src/core/gen2/Evolution.lua) that exists with or without a ROM import. Schemas.GEN2 does not gate either name -- gate_gen2_mod_api.lua pins that directly, including a case that a bad evolution method on a Gold species is still caught -- so the two are validated like any other reference today, not skipped. Nothing here changes that behavior; only the comment, which was describing an earlier state of the code, is corrected. --- src/mods/Schemas.lua | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 23d34b37..9be13727 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -377,11 +377,19 @@ function Schemas.crossValidate(loader, data) and loader.content[ref.registry] -- A registry with no home in this generation has no id space to -- check against: its base view resolves to nothing, so EVERY - -- reference into it would read as dangling. Gold's species carry a - -- growthRate and an evolution method like Red's do; the ids are - -- fine, it is the Gen 1 `growth_rates` / `evolution_methods` - -- namespaces that are not there to confirm them. Skipped for the - -- same reason an undeclared registry is: unknown, not wrong. + -- reference into it would read as dangling. `transitions` is the + -- standing example -- Gold draws its own battle intro and never + -- reads the merged table, so a mod's transition id there is + -- unconfirmable, not wrong. `growth_rates` and `evolution_methods` + -- used to sit in that category too, back when Gold had no id space + -- for either. Both are routed now: `growth_rates` keeps its Gen 1 + -- path and is seeded from data.pokemon.growthRates (the extractor's + -- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods` + -- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's + -- literal EVOLVE_* ids, present with or without a ROM import). A + -- Gold species' growthRate or evolution method is checked against + -- real ids exactly like a Red one's, so a genuine typo is still + -- caught here rather than waved through as "unknown, not wrong." if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then refRegistry = nil end From f62b1268c888bfce5bf591973e6a0c8364e788a6 Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:11:39 +0200 Subject: [PATCH 17/32] Add an item.use hook around BagMenu's item-use dispatch useOn was a plain Lua local: every result ItemEffects.use returned fell through to one unconditional showMessages with no seam a mod could reach, unlike menu.lua/boxmark.lua/formview.lua's screens, which wrap their own default behavior as a table field or a Runtime hook. A mod could not suppress a message, delay it behind a screen of its own, or substitute a different outcome for one item id -- exactly the gap noted against Ultra Burst's item-driven fusion, which had nowhere left to attach a bespoke animation once TextBox.new turned out to be the only other reachable seam. This wraps the whole dispatch in a Runtime.call("item.use", ...) hook, the same mechanism "battle.overlay", "ui.party.submenu" and the rest of src/ui already use, rather than exporting BagMenu.useOn as a table field. A hook is the smaller commitment: it is additive (a fresh Runtime.call site needs no schema or manifest change and costs nothing unsubscribed -- see tests/engine/gate_hooks.lua's null-object case) and a mod can still run the vanilla flow unchanged by calling the handed-in vanilla function, whereas a table field would fix useOn's exact signature as public API the moment it shipped. If the maintainer would rather match the sibling screens' convention directly, exporting BagMenu.useOn is the alternative and does not conflict with this hook existing alongside it. vanillaUseOn keeps the original function body; useOn is now the thin wrapper mods observe through, and every internal caller in this file still goes through useOn so the hook fires on every path into it. --- src/ui/BagMenu.lua | 17 +++++- tests/engine/item_use_hook.lua | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/engine/item_use_hook.lua diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index e8cb6630..5d6807f1 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -4,6 +4,7 @@ local ItemEffects = require("src.inventory.ItemEffects") local ListMenu = require("src.ui.ListMenu") +local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") local BagMenu = {} @@ -46,7 +47,16 @@ end -- the stack, so every exit that prints has to close it afterwards. For -- every other item the picker popped itself first and closePicker's identity -- check makes it a no-op (#252). -local function useOn(game, battle, id, target, list, moveIndex, picker) +-- +-- Every result string used to fall through to this one unconditional +-- function with no seam around it: a mod could not suppress a message, +-- delay it behind a screen of its own, or replace the outcome for one item +-- id. The "item.use" hook wraps the whole dispatch (not a name per +-- result -- a mod deciding what a Poké Doll or a stone does needs the +-- SAME reach a vanilla `if result == ...` branch has, not a narrower one), +-- the way "battle.overlay" and "ui.party.submenu" already wrap a +-- screen's own default behavior elsewhere in src/ui. +local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, battle, moveIndex, game.overworld) local function closePicker() @@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) showMessages(game, payload, closePicker) -- failed end +local function useOn(game, battle, id, target, list, moveIndex, picker) + return Runtime.call("item.use", vanillaUseOn, + game, battle, id, target, list, moveIndex, picker) +end + local function pickTargetAndUse(game, battle, id, list) -- pick a target from the party -- the ETHERs and PP UP open the move menu after picking a mon diff --git a/tests/engine/item_use_hook.lua b/tests/engine/item_use_hook.lua new file mode 100644 index 00000000..9107a32c --- /dev/null +++ b/tests/engine/item_use_hook.lua @@ -0,0 +1,101 @@ +-- Public mod-API coverage for the "item.use" hook (src/ui/BagMenu.lua). +-- +-- Before this hook existed, every result ItemEffects.use returned fell +-- through to one unconditional call with nothing wrapped around it: a mod +-- could not suppress a message, delay it behind a screen of its own, or +-- replace what a specific item id does after the bag decides to use it. +-- This exercises the seam end to end through the public mod API -- a real +-- BagMenu list, a real USE selection -- rather than calling the hook +-- machinery directly. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Bag = require("src.inventory.Bag") + +-- Real TextBoxes want a Font atlas; this only cares that useOn reaches the +-- no-effect fallthrough, so the same stand-in tests/parity_rare_candy_menu.lua +-- uses for a ROM-backed run works here too. +local realTextBox = package.loaded["src.render.TextBox"] +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, +} +package.loaded["src.ui.BagMenu"] = nil +local BagMenu = require("src.ui.BagMenu") + +local FIXTURE = { + ["mods/item_hook_probe/manifest.json"] = [[{ + "id": "item_hook_probe", + "name": "Item Hook Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/item_hook_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("item.use", + function(vanilla, game, battle, id, target, list, moveIndex, picker) + mod.exports.calls = (mod.exports.calls or 0) + 1 + mod.exports.id = id + mod.exports.battle = battle + mod.exports.target = target + return vanilla(game, battle, id, target, list, moveIndex, picker) + end) + ]], +} + +local function newStack() + local stack = { states = {} } + function stack:push(s) self.states[#self.states + 1] = s end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return stack +end + +local run = T.sdk.loadMods({ "mods/item_hook_probe" }, { + fs = T.sdk.memfs(FIXTURE), +}) +T.eq(#run.errors, 0, + "the probe mod loads clean (" .. tostring(run.errors[1]) .. ")") + +local game = { + data = run.data, + stack = newStack(), + save = { + player = { name = "RED" }, inventory = {}, money = 0, + options = { battleStyle = "set", battleAnim = "on" }, + pokedex = { seen = {}, owned = {} }, flags = {}, + }, +} +Bag.add(game.save, "FIX_POTION", 1) + +local list = BagMenu.new(game, {}) +game.stack:push(list) +local row +for i, r in ipairs(list.items) do + if r.value == "FIX_POTION" then row = i end +end +T.check(row ~= nil, "the fixture item is in the bag") +list.index = row +list.onChoose(list.items[row], list) + +-- out of battle the bag offers USE / TOSS first (start_sub_menus.asm) +local sub = game.stack:top() +T.check(sub ~= nil and sub.items and sub.items[1] and sub.items[1].onSelect, + "the USE/TOSS submenu opened") +sub.items[1].onSelect() + +local out = run.loader.exports.item_hook_probe or {} +T.eq(out.calls, 1, "the hook fires exactly once for a bag item use") +T.eq(out.id, "FIX_POTION", "the hook sees the item id") +T.eq(out.battle, nil, "the hook sees the field-use battle argument (nil)") + +local top = game.stack:top() +T.check(type(top) == "table" and top.textBox == true, + "vanilla still ran: the no-effect message box landed on the stack") + +run.release() +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.ui.BagMenu"] = nil + +T.finish() From 2da2168dacca8f6ecc037d86fe0951bb1929f0c8 Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:13:14 +0200 Subject: [PATCH 18/32] Refuse a TM/HM on a species with no tmhm list instead of crashing ItemEffects.use walked speciesDef.tmhm with a bare ipairs() to check whether the species could learn the machine's move. A record with no tmhm field at all -- a mod species that never set one, or any record missing it for whatever reason -- hit ipairs(nil) and took the whole game down on the first TM/HM use, rather than reaching the ordinary "can't learn that move" refusal a species whose list simply omits the move already gets. An absent list now reads the same as an empty one: nothing to learn, same refusal, same sound, same text. --- src/inventory/ItemEffects.lua | 5 ++++- tests/engine/item_tmhm_nil_test.lua | 31 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/engine/item_tmhm_nil_test.lua diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index a900fec9..691b859d 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -515,7 +515,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if not target then return "failed", { noEffect(data) } end local speciesDef = data.pokemon[target.species] local ok = false - for _, m in ipairs(speciesDef.tmhm) do + -- a species record with no tmhm list at all is "teaches nothing", the + -- same as one whose list just does not name this move -- not a reason + -- to crash instead of refusing normally + for _, m in ipairs(speciesDef.tmhm or {}) do if m == itemDef.machine.move then ok = true break end end if not ok then diff --git a/tests/engine/item_tmhm_nil_test.lua b/tests/engine/item_tmhm_nil_test.lua new file mode 100644 index 00000000..217db2ee --- /dev/null +++ b/tests/engine/item_tmhm_nil_test.lua @@ -0,0 +1,31 @@ +-- ItemEffects.use crashed instead of refusing when a species record carried +-- no tmhm list at all (ipairs(nil)), which is a different situation from a +-- species whose list simply does not name the move being taught -- that +-- case already refuses cleanly with MonCannotLearnMachineMoveText. A mod +-- species missing the field entirely took the whole game down on the very +-- first TM/HM use rather than reaching that refusal. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness").suite("item effects tmhm nil") +local Fixtures = require("tests.modkit.fixtures") +local ItemEffects = require("src.inventory.ItemEffects") +local Pokemon = require("src.pokemon.Pokemon") + +local Data = Fixtures.fresh() +Data.pokemon.FIXMON_A.tmhm = nil + +local mon = Pokemon.new(Data, "FIXMON_A", 10) +local save = { player = { name = "RED" } } + +local ok, result, payload = pcall(ItemEffects.use, Data, save, "FIX_TM", mon) +T.check(ok, "using a TM on a species with no tmhm list does not crash: " + .. tostring(result)) +if ok then + T.eq(result, "failed", "the species refuses the move instead of crashing into it") + T.check(type(payload) == "table" and payload[1] ~= nil, + "a refusal message is still returned") +end + +T.finish() From 455ff21aff59ab8c6a634e8bd6663de661141edc Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:19:16 +0200 Subject: [PATCH 19/32] Validate a map object's pokemon field against the pokemon registry R.maps.objects was f.opt(f.list(f.any)): a static wild encounter's species (OverworldController.lua's d.pokemon, handed straight to BattleState.newWild) went completely unchecked at load time, unlike an encounter slot's species. A typo'd or removed id sat in a loaded mod and only surfaced as a crash the moment a player reached that object. Objects share one array across every kind -- NPCs, signs, warps and static encounters all coexist with no field the loader could use to tell them apart ahead of time -- so a strict f.rec covering the whole shape would reject every kind this schema does not enumerate. Added f.partial, an open counterpart to f.rec: it type-checks (and, through collectRefs, cross-reference-checks) only the fields it is given and leaves everything else on the value alone, the same extensibility f.rec already grants at a record's top level but nowhere further in. R.maps.objects now types just `pokemon` through it, so a bad species id is a load-time "unresolved reference" error instead of a runtime crash, while an NPC object's sprite/movement/range/... fields -- never named in this schema -- still pass through untouched. --- src/mods/Schemas.lua | 51 ++++++++++- tests/engine/map_object_pokemon_ref_test.lua | 89 ++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 tests/engine/map_object_pokemon_ref_test.lua diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9be13727..dfa2fb97 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -91,6 +91,28 @@ function f.rec(fields, opts) desc = "{" .. table.concat(parts, ", ") .. "}" } end +-- An open record: the listed fields are typed (including f.id +-- cross-references) and everything else on the value passes through +-- unexamined, unlike f.rec's nested shapes, which reject any key they do +-- not name. Map objects are why this exists -- NPCs, signs, items, warps +-- and static encounters all share one array, and only a full union of +-- every kind's shape could describe it as f.rec; that is a lot of surface +-- to keep in sync with the loader for fields nothing here needs to check. +-- f.partial types just the field that actually names another registry and +-- leaves every kind-specific field around it alone. +function f.partial(fields) + local names = {} + for name in pairs(fields) do names[#names + 1] = name end + table.sort(names) + local parts = {} + for _, name in ipairs(names) do + local ft = fields[name] + parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "") + end + return { kind = "partial", fields = fields, + desc = "{" .. table.concat(parts, ", ") .. ", ...}" } +end + function f.union(alts) local parts = {} for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end @@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top) end return end + if kind == "partial" then + -- the open counterpart of "rec": listed fields are checked exactly + -- like a rec's, and any key not listed is left alone rather than + -- flagged, so a heterogeneous blob (map objects) can have one field + -- typed without every other shape sharing the array being rejected + if type(value) ~= "table" then return fail(errors, path, t.desc, value) end + for key, ft in pairs(t.fields) do + local sub = value[key] + if sub ~= nil then + checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors) + elseif ft.kind ~= "opt" and not patchMode then + errors[#errors + 1] = ("%s.%s: missing required field (%s)") + :format(path, key, ft.desc) + end + end + return + end if kind == "union" then for _, alt in ipairs(t.alts) do local scratch = {} @@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out) for k, v in pairs(value) do collectRefs(t.value, v, path .. "." .. tostring(k), out) end - elseif kind == "rec" and type(value) == "table" then + elseif (kind == "rec" or kind == "partial") and type(value) == "table" then for key, ft in pairs(t.fields) do collectRefs(ft, value[key], path .. "." .. tostring(key), out) end @@ -895,7 +934,15 @@ R.maps = { destMap = f.str, destWarp = f.int(0), destGroup = f.opt(f.int(0)), destMapNum = f.opt(f.int(0)) })), - objects = f.opt(f.list(f.any)), + -- NPCs, signs, items, warps and static wild encounters all share this + -- one array, with no field the loader could use to tell them apart + -- ahead of time -- an f.rec strict enough to describe every kind would + -- reject the others. f.partial types only `pokemon` (the static + -- encounter's species, OverworldController.lua's `d.pokemon` -> + -- BattleState.newWild) so a bad id is a load-time error, the same as + -- an encounter slot's species, instead of the crash newWild has no + -- guard against. Every other object field passes through untouched. + objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })), signs = f.opt(f.list(f.any)), connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)), }, diff --git a/tests/engine/map_object_pokemon_ref_test.lua b/tests/engine/map_object_pokemon_ref_test.lua new file mode 100644 index 00000000..905f400d --- /dev/null +++ b/tests/engine/map_object_pokemon_ref_test.lua @@ -0,0 +1,89 @@ +-- A map object's `pokemon` field (the static wild encounter kind -- +-- OverworldController.lua's `d.pokemon`, handed straight to +-- BattleState.newWild with no existence check of its own) used to go +-- completely unchecked: R.maps.objects was f.opt(f.list(f.any)), so a +-- typo'd species sat in a loaded mod and only surfaced as a crash the +-- moment a player stepped up to that object. Every other kind sharing the +-- objects array (NPCs, signs-as-objects, warps) has fields this schema +-- still does not know about, which is what f.partial is for: it types only +-- `pokemon` and leaves the rest of an object's shape alone. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local function manifest(id) + return ([[{ + "id": "%s", "name": "%s", "version": "1.0.0", + "entry": "main.lua", "api": 2 + }]]):format(id, id) +end + +-- ------- a bad species id is caught as a load error, not left to crash + +local BAD = { + ["mods/bad_static_encounter/manifest.json"] = manifest("bad_static_encounter"), + ["mods/bad_static_encounter/main.lua"] = [[ + local mod = ... + mod.content.maps:patch("FIX_ROUTE", { + objects = { + { pokemon = "NOT_A_SPECIES", level = 30, text = "Gyaoo!" }, + }, + }) + ]], +} + +do + local run = T.sdk.loadMods({ "mods/bad_static_encounter" }, + { fs = T.sdk.memfs(BAD) }) + local dangling = {} + for _, message in ipairs(run.errors) do + if message:match("unresolved reference") then + dangling[#dangling + 1] = message + end + end + T.eq(#dangling, 1, + "a bad static-encounter species is reported once (" + .. table.concat(dangling, "; ") .. ")") + T.check(dangling[1] and dangling[1]:match("maps%.FIX_ROUTE%.objects") + and dangling[1]:match("pokemon"), + "the report names the map, the objects field and the pokemon registry: " + .. tostring(dangling[1])) + run.release() +end + +-- ------- a real species resolves, and an NPC-shaped object beside it (no +-- pokemon field at all, and fields this schema never named -- sprite, +-- movement, range) is untouched + +local GOOD = { + ["mods/good_static_encounter/manifest.json"] = manifest("good_static_encounter"), + ["mods/good_static_encounter/main.lua"] = [[ + local mod = ... + mod.content.maps:patch("FIX_ROUTE", { + objects = { + { index = 1, name = "FIXROUTE_TRAINER", sprite = "SPRITE_FIX_NPC", + movement = "STAY", range = "NONE", text = "TEXT_FIXROUTE_TRAINER", + x = 5, y = 9 }, + { pokemon = "FIXMON_A", level = 30, text = "Gyaoo!" }, + }, + }) + ]], +} + +do + local run = T.sdk.loadMods({ "mods/good_static_encounter" }, + { fs = T.sdk.memfs(GOOD) }) + T.eq(#run.errors, 0, + "a real species and an untyped NPC object both load clean (" + .. tostring(run.errors[1]) .. ")") + local objects = run.data.maps.FIX_ROUTE.objects + T.eq(#objects, 2, "both objects landed on the map") + T.eq(objects[1].sprite, "SPRITE_FIX_NPC", + "the NPC object's untyped fields passed through unexamined") + T.eq(objects[2].pokemon, "FIXMON_A", + "the static encounter's species field passed through too") + run.release() +end + +T.finish() From c23f85cba92b23daff0910e5cf82013787bd13cc Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:21:47 +0200 Subject: [PATCH 20/32] Bind gen2Constants in the save editor's Gold bootstrap bindGoldData points gen2Palettes, gen2Icons, gen2Pokedex, gen2Landmarks, gen2Roofs and gen2Sprites at the extractor's own Gold tables through loadGen, but never gen2Constants -- despite Schemas.GEN2 routing `constants` to that same namespaced-and-differently-shaped category palettes and icons are in. A save editor boot left data.gen2Constants unset, so mod.content.constants:get(...) read an empty table instead of the cart's ordered name lists, misreading the generation and rejecting every record a mod shaped off it. data.gen2Constants now goes through the same loadGen("constants") path the other five already use, falling back the same way they do when no ROM cache is active. --- tests/save_editor_gen2_tests.lua | 17 +++++++++++++++++ tools/save-editor/Gen.lua | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua index 365ec70e..db9474d8 100644 --- a/tests/save_editor_gen2_tests.lua +++ b/tests/save_editor_gen2_tests.lua @@ -324,6 +324,23 @@ do check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets") check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM, "Gen.tilesets prefers gen2Tilesets") + + -- bindGoldData bound gen2Palettes/gen2Icons/gen2Pokedex/gen2Landmarks/ + -- gen2Roofs/gen2Sprites through loadGen but never gen2Constants, so any + -- mod reading mod.content.constants:get(...) under a save-editor Gold + -- bootstrap saw an empty table where it expected the cart's ordered name + -- lists. loadGen falls back to require("data.generated.constants") when + -- the ROM cache has nothing active, which is what a checkout with no + -- ROM imported hits too -- stub that module the same way to prove the + -- wiring without needing a real Gold extraction. + package.loaded["data.generated.constants"] = { badges = { "ZEPHYR" } } + local withConstants = Gen.bindGoldData({}) + package.loaded["data.generated.constants"] = nil + check(withConstants.gen2Constants ~= nil, + "bindGoldData populates gen2Constants") + check(withConstants.gen2Constants and withConstants.gen2Constants.badges + and withConstants.gen2Constants.badges[1] == "ZEPHYR", + "gen2Constants carries the extractor's own name lists") end do diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index 695aa9b8..56258a85 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -95,6 +95,12 @@ function Gen.bindGoldData(data) end data.gen2Palettes = data.gen2Palettes or loadGen("palettes") + -- Namespaced AND differently shaped in Schemas.GEN2 (the cart's ordered + -- name lists, not Gen 1's rule table), same as palettes/icons below -- + -- omitting it left mod.content.constants:get(...) reading an empty table + -- under a Gold save-editor boot, which is what misreads "generation" and + -- rejects every record a mod shapes off it. + data.gen2Constants = data.gen2Constants or loadGen("constants") data.gen2Icons = data.gen2Icons or loadGen("icons") data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex") data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks") From d03d2af5f8e7e61e66bf6776f6ab9625dc5337bc Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:28:38 +0200 Subject: [PATCH 21/32] Pass data through to ItemEffects.partyAction for Gen 2 pack items Both call sites -- Game2:usePartyItem (the field pack) and BattleState:useItem (the battle pack) -- asked ItemEffects.partyAction for an item's family with no `data` argument, even though every other call in the same functions (useOnMon, usePpItem, applyPartyItem) passed it through correctly. partyAction resolves through recordFor, which reads data.gen2ItemEffects when given a dataset and falls back to the module's own built-in RECORDS table when not -- so with no data, a mod's own item_effects record was invisible and every mod-defined Gen 2 field or battle item resolved to a nil action, falling straight through to "isn't going to help here" / "isn't going to help here" without ever opening the party picker. Both now pass the live dataset (self.data on Game2, self.game.data on the battle screen) the same way their sibling calls already did. --- src/core/Game2.lua | 5 +++- src/ui/gen2/BattleState.lua | 6 ++-- tests/gen2_battle_items_test.lua | 48 ++++++++++++++++++++++++++++++++ tests/gen2_field_items_test.lua | 36 ++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 112859eb..7871cbdd 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -683,7 +683,10 @@ end -- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent. function Game2:usePartyItem(itemId) local ItemEffects = require("src.core.gen2.ItemEffects") - local action = ItemEffects.partyAction(itemId) + -- without the merged dataset this can only ever see RECORDS, the + -- module's own built-ins, so a mod's field item resolves to no action + -- at all and never gets past the .Oak refusal below + local action = ItemEffects.partyAction(itemId, self.data) if not action then return end local party = (self.save and self.save.party) or {} if #party == 0 then diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 2d65eeda..bbbc248c 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -3024,8 +3024,10 @@ function BattleState:useItem(itemId) -- Everything else the pack can spend on a party mon runs the same -- item_effects.asm routine the field pack runs: the potion line and the -- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and - -- the ETHER / ELIXER family. - local action = ItemEffects.partyAction(itemId) + -- the ETHER / ELIXER family. Without the merged dataset this can only + -- ever see RECORDS, the module's own built-ins, the same gap + -- Game2:usePartyItem had for the field pack. + local action = ItemEffects.partyAction(itemId, self.game and self.game.data) if action then return self:useOnPartyMon(itemId, action) end diff --git a/tests/gen2_battle_items_test.lua b/tests/gen2_battle_items_test.lua index dbfc8527..186e4596 100644 --- a/tests/gen2_battle_items_test.lua +++ b/tests/gen2_battle_items_test.lua @@ -117,6 +117,27 @@ local DATA = { fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" }, OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD", fieldMenu = "ITEMMENU_CURRENT", battleMenu = "ITEMMENU_NOUSE" }, + -- a mod's own battle-pack item: its action lives only in + -- gen2ItemEffects below, which ItemEffects.RECORDS (the module's + -- built-in table) has never heard of (#8) + MOD_ITEM = item("MOD_ITEM"), + }, + gen2ItemEffects = { + -- a status cure rather than an HP heal: HP is exposed to the wild + -- mon's own reply once the item spends the turn, which would make a + -- direct before/after HP check depend on incidental battle math this + -- fix has nothing to do with. Status is not. + MOD_ITEM = { + action = "status", field = true, needsTarget = true, + use = function(ctx) + local mon = ctx.mon + if mon.status ~= "poison" then + return { used = false, text = "It won't have\nany effect." } + end + mon.status = nil + return { used = true, text = "MOD ITEM used!" } + end, + }, }, } @@ -236,6 +257,33 @@ do eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE untouched") end +-- ---- a mod's own battle-pack item (#8) ------------------------------------- +-- BattleState:useItem asked ItemEffects.partyAction for the item's family +-- with no `data` argument, the same omission Game2:usePartyItem had for the +-- field pack, so a mod item's action -- present only in the merged +-- gen2ItemEffects table -- resolved to nil and the pack fell straight to +-- "That isn't going to help here." instead of opening the party list. +do + local sick = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + sick.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + sick.status = "poison" + local screen, _, _, save, pushed = newScreen({ + player = sick, party = { sick }, inventory = { MOD_ITEM = 1 }, + }) + check(runToMenu(screen), "reached the menu") + + screen:useItem("MOD_ITEM") + eq(screen.phase, "submenu", + "a mod's own gen2ItemEffects record opens UseItem_SelectMon") + local picker = pushed[#pushed] + eq(getmetatable(picker), PartyMenu, "and the pick is the party screen") + if picker and picker.onChoose then + picker.onChoose(1, sick) + eq(sick.status, nil, "the mod item's own use() ran through the real screens") + eq(save.inventory.MOD_ITEM, nil, "and the mod item was spent") + end +end + -- ---- IsItemUsedOnConfusedMon: the battle-only arm -------------------------- do local screen, battle, player, save, pushed = newScreen({ diff --git a/tests/gen2_field_items_test.lua b/tests/gen2_field_items_test.lua index c3f9ab68..63e36568 100644 --- a/tests/gen2_field_items_test.lua +++ b/tests/gen2_field_items_test.lua @@ -102,6 +102,24 @@ local DATA = { TM01 = { id = "TM01", name = "TM01", pocket = "TM_HM", index = 191, fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE", teaches = "SWIFT" }, + -- a mod's own field item, whose action lives only in gen2ItemEffects + -- below -- ItemEffects.RECORDS (the module's built-in table) has never + -- heard of it, so resolving it at all requires the merged dataset (#8) + MOD_ITEM = { id = "MOD_ITEM", name = "MOD ITEM", pocket = "ITEM", + index = 250, fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" }, + }, + gen2ItemEffects = { + MOD_ITEM = { + action = "heal", field = true, needsTarget = true, + use = function(ctx) + local mon = ctx.mon + if mon.hp >= mon.maxHp then + return { used = false, text = "It won't have\nany effect." } + end + mon.hp = math.min(mon.maxHp, mon.hp + 5) + return { used = true, text = "MOD ITEM used!" } + end, + }, }, gen2MenuGfx = {}, gen2Icons = { @@ -461,6 +479,24 @@ do eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent") end +do + -- #8 regression: Game2:usePartyItem asked ItemEffects.partyAction for the + -- item's family with no `data` argument, so it could only ever see + -- RECORDS -- the module's own built-ins. A mod's field item, whose + -- action exists only in the merged gen2ItemEffects table, resolved to a + -- nil action and fell straight through to the "isn't going to help here" + -- refusal instead of opening the party list at all. + local mon = fixtureMon(12, { hp = 10 }) + local host = newHost({ MOD_ITEM = 1 }, { mon }) + host:useFieldItem("MOD_ITEM") + local party = host.stack:top() + check(party ~= nil and party.prompt ~= nil, + "a mod's own gen2ItemEffects record opens the party list") + drive(host, function() return host.stack:top() ~= party end) + eq(mon.hp, 15, "the mod item's own use() ran through the real menu") + eq(host.save.inventory.MOD_ITEM, nil, "and the mod item was spent") +end + do local mon = fixtureMon(12, { statExp = { hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } }) From 70b9def0b0fed04855c50e160d0abca7e95262bb Mon Sep 17 00:00:00 2001 From: sanjinpepic Date: Sun, 16 Aug 2026 20:31:46 +0200 Subject: [PATCH 22/32] Make Mon.syncIdentity's shiny recompute monotonic syncIdentity unconditionally recomputed mon.shiny from the mon's DVs, overwriting whatever was there. It is wired into refreshStats, which SummaryMenu.new calls on every menu open, so a forced shiny -- Mon.new's opts.shiny path, DVs that do not themselves read as shiny -- got un-shinied the moment the summary screen opened, even though opts.shiny already wins over shiny.roll at construction for exactly this case (a scripted shiny is the cart overriding the roll, not a roll to be hooked). mon.shiny now only ever gets PROMOTED by the DV check, never demoted: `mon.shiny or Mon.isShiny(...)`. A naturally shiny mon and a plain one are unaffected -- the DV check still runs and still decides the first time -- and a mon whose DVs are edited to justify shininess later still promotes normally; only an already-true shiny stops being able to flip back to false on a later refresh. --- src/battle/gen2/Mon.lua | 8 +++++++- tests/engine/gen2_new_seams.lua | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua index 3caa03f5..4b79c460 100644 --- a/src/battle/gen2/Mon.lua +++ b/src/battle/gen2/Mon.lua @@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data) if mon.dvs then mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level }) - mon.shiny = Mon.isShiny(mon.dvs, + -- shiny is monotonic once true, the same as opts.shiny winning over + -- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs + -- that do not themselves read as shiny) must not un-shiny the moment + -- this runs again, and it runs on every SummaryMenu open via + -- refreshStats. A mon not already shiny still promotes normally if + -- its DVs justify it, e.g. after an edit. + mon.shiny = mon.shiny or Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level }) if mon.species == Unown.SPECIES then mon.unownLetter = Unown.letterFromDVs(mon.dvs) diff --git a/tests/engine/gen2_new_seams.lua b/tests/engine/gen2_new_seams.lua index f4a54a75..39a3ed56 100644 --- a/tests/engine/gen2_new_seams.lua +++ b/tests/engine/gen2_new_seams.lua @@ -468,6 +468,32 @@ do T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll") end) + -- Mon.syncIdentity (wired into refreshStats, which SummaryMenu.new calls + -- on every menu open) used to recompute mon.shiny from DVs unconditionally, + -- so opening the summary screen on a forced shiny -- one whose DVs do not + -- happen to match the natural pattern -- un-shinied it the moment the menu + -- opened. shiny is monotonic once true: a natural roll or a forced one + -- both stay shiny through any later refresh, the way opts.shiny already + -- wins at construction. + do + local forced = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs, shiny = true }) + T.eq(forced.shiny, true, "still shiny straight out of Mon.new") + Mon.syncIdentity(forced, DATA) + T.eq(forced.shiny, true, "syncIdentity does not clobber a forced shiny") + Mon.refreshStats(forced, DATA) + T.eq(forced.shiny, true, + "refreshStats (SummaryMenu.new's call) does not either") + + -- the natural cases are unaffected: DVs that read shiny stay shiny, + -- DVs that do not stay plain + local natural = Mon.new(DATA, "SEEDMON", 5, { dvs = shinyDvs }) + Mon.syncIdentity(natural, DATA) + T.eq(natural.shiny, true, "a naturally shiny mon still reads shiny") + local plain = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs }) + Mon.syncIdentity(plain, DATA) + T.eq(plain.shiny, false, "a plain mon is not promoted to shiny") + end + local genderCtx withHook("gender.roll", function(nextFn, ctx) genderCtx = ctx From 1fc34da2a9f9924fe9376d24c9f94c9f053cf6d9 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:57:10 +0200 Subject: [PATCH 23/32] android: extend secondary display presentation --- docs/modding.md | 12 +- .../love/src/jni/love/src/common/android.cpp | 48 ++++++ .../java/org/love2d/android/GameActivity.java | 151 +++++++++++++++--- src/render/SecondScreen.lua | 25 +++ tests/engine/android_secondary_present.lua | 36 +++++ tests/engine/second_screen_present.lua | 64 ++++++++ 6 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 tests/engine/android_secondary_present.lua create mode 100644 tests/engine/second_screen_present.lua diff --git a/docs/modding.md b/docs/modding.md index de1726bd..2786a563 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -733,9 +733,15 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a palette-correct blit of either canvas into an arbitrary screen rect, and the -`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` / -`setEnabled`) for driving a second physical display. `pollTouch()` returns the -oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. +`secondScreen` bridge (`available()` / `detected()` / `push(...)` / +`pollTouch()` / `setEnabled`) for driving a second physical display. +`detected()` reports a connected target even while its output is being created; +`available()` means it can accept a frame now. `push(imageData, w, h)` retains +the original contract. Its optional `background` (`0xRRGGBB`) and `preference` +arguments request an extended presentation; a preference ending in `:cover` +fills and crops the target, while other values preserve the whole frame. +`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame +coordinates, or `nil`. This is what lets a mod lay the two passes out as two stacked Game Boy screens, or push one onto a second screen, without the engine knowing the layout. On process-capable Windows, Linux and macOS hosts without a native display diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 429e0353..ad592aa8 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -1180,6 +1180,54 @@ void love_android_secondary_enable(int on) env->DeleteLocalRef(activity); } +extern "C" __attribute__((visibility("default"))) +int love_android_secondary_detected() +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, + "hasSecondaryDisplayCandidate", "()Z"); + jboolean detected = JNI_FALSE; + if (method) + detected = env->CallStaticBooleanMethod(activity, method); + else + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return detected ? 1 : 0; +} + +extern "C" __attribute__((visibility("default"))) +int love_android_present_secondary(const void *rgba, int width, int height, + unsigned int background, int cover) +{ + if (!rgba || width <= 0 || height <= 0) + return 0; + jlong size = (jlong) width * (jlong) height * 4; + if (size <= 0) + return 0; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame", + "(Ljava/nio/ByteBuffer;IIIZ)Z"); + if (!method) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return 0; + } + jobject frame = env->NewDirectByteBuffer((void *) rgba, size); + if (!frame) + { + env->DeleteLocalRef(activity); + return 0; + } + jboolean shown = env->CallStaticBooleanMethod(activity, method, frame, + width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE); + env->DeleteLocalRef(frame); + env->DeleteLocalRef(activity); + return shown ? 1 : 0; +} + extern "C" __attribute__((visibility("default"))) const char *love_android_poll_secondary_touch() { diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index cec24cde..796654dd 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1408,6 +1408,14 @@ public class GameActivity extends SDLActivity { // in src/jni/love/src/common/android.cpp. private static volatile SecondaryPresentation secondaryPresentation; private static volatile boolean secondaryEnabled = false; + private static volatile byte[] secondaryFrame; + private static volatile int secondaryFrameWidth; + private static volatile int secondaryFrameHeight; + private static volatile int secondaryBackground; + private static volatile boolean secondaryFrameCover; + private static final Object secondaryFrameLock = new Object(); + private static volatile long secondaryDetectionAt; + private static volatile boolean secondaryDetected; private SecondaryDisplayMonitor secondaryDisplayMonitor; private static final int MAX_SECONDARY_TOUCHES = 32; private static final java.util.ArrayDeque secondaryTouches = @@ -1426,6 +1434,7 @@ public class GameActivity extends SDLActivity { } else { self.unregisterSecondaryDisplayListener(); teardownSecondaryDisplay(); + synchronized (secondaryFrameLock) { secondaryFrame = null; } } } }); @@ -1462,24 +1471,7 @@ public class GameActivity extends SDLActivity { GameActivity self = (GameActivity) mSingleton; if (self == null || !secondaryEnabled || secondaryPresentation != null) return; try { - android.hardware.display.DisplayManager dm = - (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE); - if (dm == null) return; - Display chosen = null; - for (Display d : dm.getDisplays()) { - android.graphics.Point size = new android.graphics.Point(); - d.getRealSize(size); - Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName() - + " size=" + size.x + "x" + size.y); - if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) { - chosen = d; - } - } - if (chosen == null) { - Display[] pres = - dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); - if (pres != null && pres.length > 0) chosen = pres[0]; - } + Display chosen = findSecondaryDisplay(self, true); if (chosen == null) { Log.d("GameActivity", "no secondary display found"); return; @@ -1487,6 +1479,13 @@ public class GameActivity extends SDLActivity { SecondaryPresentation p = new SecondaryPresentation(self, chosen); p.show(); secondaryPresentation = p; + synchronized (secondaryFrameLock) { + if (secondaryFrame != null) { + p.setBackground(secondaryBackground); + p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame), + secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover); + } + } Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId()); } catch (Throwable t) { Log.d("GameActivity", "secondary display setup failed: " + t); @@ -1494,6 +1493,28 @@ public class GameActivity extends SDLActivity { } } + private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) { + android.hardware.display.DisplayManager dm = + (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE); + if (dm == null) return null; + Display chosen = null; + for (Display d : dm.getDisplays()) { + if (logDisplays) { + android.graphics.Point size = new android.graphics.Point(); + d.getRealSize(size); + Log.d("GameActivity", "display id=" + d.getDisplayId() + + " name=" + d.getName() + " size=" + size.x + "x" + size.y); + } + if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) chosen = d; + } + if (chosen == null) { + Display[] presentations = + dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + if (presentations != null && presentations.length > 0) chosen = presentations[0]; + } + return chosen; + } + private static void teardownSecondaryDisplay() { SecondaryPresentation p = secondaryPresentation; secondaryPresentation = null; @@ -1527,9 +1548,14 @@ public class GameActivity extends SDLActivity { return manager.getDisplay(displayId) != null; } - @Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); } - @Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); } - @Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); } + private void changed() { + secondaryDetectionAt = 0; + refreshSecondaryDisplay(); + } + + @Override public void onDisplayAdded(int displayId) { changed(); } + @Override public void onDisplayRemoved(int displayId) { changed(); } + @Override public void onDisplayChanged(int displayId) { changed(); } } @Keep @@ -1537,6 +1563,56 @@ public class GameActivity extends SDLActivity { return secondaryPresentation != null; } + @Keep + public static boolean hasSecondaryDisplayCandidate() { + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (secondaryPresentation != null) return true; + long now = android.os.SystemClock.uptimeMillis(); + if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) { + return secondaryDetected; + } + secondaryDetected = findSecondaryDisplay(self, false) != null; + secondaryDetectionAt = now; + return secondaryDetected; + } + + @Keep + public static boolean presentSecondaryFrame( + java.nio.ByteBuffer rgba, int width, int height, + int backgroundColor, boolean cover) { + long bytes = (long) width * height * 4; + if (rgba == null || width <= 0 || height <= 0 + || bytes <= 0 || bytes > Integer.MAX_VALUE + || rgba.capacity() < bytes) return false; + synchronized (secondaryFrameLock) { + if (secondaryFrame == null || secondaryFrame.length != (int) bytes) { + secondaryFrame = new byte[(int) bytes]; + } + rgba.rewind(); + rgba.get(secondaryFrame, 0, (int) bytes); + rgba.rewind(); + secondaryFrameWidth = width; + secondaryFrameHeight = height; + secondaryBackground = backgroundColor; + secondaryFrameCover = cover; + SecondaryPresentation p = secondaryPresentation; + if (p == null) return false; + try { + p.setBackground(backgroundColor); + p.updateFrame(rgba, width, height, cover); + return true; + } catch (Throwable t) { + GameActivity self = (GameActivity) mSingleton; + if (self != null) self.runOnUiThread(() -> { + teardownSecondaryDisplay(); + setupSecondaryDisplay(); + }); + return false; + } + } + } + @Keep public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) { SecondaryPresentation p = secondaryPresentation; @@ -1610,6 +1686,14 @@ public class GameActivity extends SDLActivity { void updateFrame(java.nio.ByteBuffer buf, int w, int h) { frameView.updateFrame(buf, w, h); } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) { + frameView.updateFrame(buf, w, h, cover); + } + + void setBackground(int color) { + frameView.setFrameBackground(color); + } } private static class FrameView extends View { @@ -1618,7 +1702,9 @@ public class GameActivity extends SDLActivity { private final android.graphics.Paint paint = new android.graphics.Paint(); private final Object lock = new Object(); private int fw, fh; + private int backgroundColor = 0xFF000000; private int activePointer = -1; + private boolean cover; FrameView(Context context) { super(context); @@ -1628,7 +1714,12 @@ public class GameActivity extends SDLActivity { } void updateFrame(java.nio.ByteBuffer buf, int w, int h) { + updateFrame(buf, w, h, false); + } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) { synchronized (lock) { + this.cover = cover; if (bitmap == null || fw != w || fh != h) { if (bitmap != null) bitmap.recycle(); bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888); @@ -1640,6 +1731,13 @@ public class GameActivity extends SDLActivity { postInvalidate(); } + void setFrameBackground(int color) { + synchronized (lock) { + backgroundColor = 0xFF000000 | (color & 0x00FFFFFF); + } + postInvalidate(); + } + private void enqueueTouch(String event) { synchronized (secondaryTouches) { if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) { @@ -1691,12 +1789,15 @@ public class GameActivity extends SDLActivity { synchronized (lock) { if (bitmap == null || fw == 0 || fh == 0) return; int vw = getWidth(), vh = getHeight(); - int s = Math.min(vw / fw, vh / fh); - if (s < 1) s = 1; - int dw = fw * s, dh = fh * s; + float fit = Math.min((float) vw / fw, (float) vh / fh); + if (fit <= 0) return; + float scale = cover + ? Math.max((float) vw / fw, (float) vh / fh) + : fit >= 2f ? (float) Math.floor(fit) : fit; + int dw = Math.round(fw * scale), dh = Math.round(fh * scale); int dx = (vw - dw) / 2, dy = (vh - dh) / 2; dst.set(dx, dy, dx + dw, dy + dh); - canvas.drawColor(0xFF000000); + canvas.drawColor(backgroundColor); canvas.drawBitmap(bitmap, null, dst, paint); } } diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index d3b03796..3a3a958f 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -6,6 +6,7 @@ local SecondScreen = {} local C = nil local ffi = nil local desktop = nil +local nativePresent = false local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) @@ -21,6 +22,9 @@ do int love_android_secondary_ready(); void love_android_push_secondary(const void *rgba, int w, int h); void love_android_secondary_enable(int on); + int love_android_secondary_detected(); + int love_android_present_secondary(const void *rgba, int w, int h, + unsigned int background, int cover); const char *love_android_poll_secondary_touch(); ]]) local okLib, lib = pcall(ffi.load, "love") @@ -34,6 +38,16 @@ do log(("bridge symbols not found (ffi.load ok=%s); second display disabled") :format(tostring(okLib))) end + if C then + local okDetected, detected = pcall(function() + return C.love_android_secondary_detected + end) + local okPresent, present = pcall(function() + return C.love_android_present_secondary + end) + nativePresent = okDetected and detected ~= nil + and okPresent and present ~= nil + end end end @@ -60,6 +74,10 @@ end -- distinction lets a companion retry its first frame after hotplug/re-target. function SecondScreen.detected() if desktop then return desktop.detected() end + if nativePresent then + local ok, r = pcall(C.love_android_secondary_detected) + return ok and r ~= 0 + end return SecondScreen.available() end @@ -68,6 +86,13 @@ function SecondScreen.push(imageData, w, h, background, preference) return desktop.push(imageData, w, h, background, preference) end if not C or not imageData then return false end + if nativePresent and (background ~= nil or preference ~= nil) then + local cover = type(preference) == "string" + and preference:sub(-6) == ":cover" + local ok, shown = pcall(C.love_android_present_secondary, + imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0) + return ok and shown ~= 0 + end return pcall(function() C.love_android_push_secondary(imageData:getFFIPointer(), w, h) end) diff --git a/tests/engine/android_secondary_present.lua b/tests/engine/android_secondary_present.lua new file mode 100644 index 00000000..ec6ffc5f --- /dev/null +++ b/tests/engine/android_secondary_present.lua @@ -0,0 +1,36 @@ +local function read(path) + local file = assert(io.open(path, "rb")) + local source = file:read("*a") + file:close() + return source +end + +local function check(value, message) + if not value then error(message, 2) end +end + +local java = read( + "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java") +local cpp = read("mobile/android/love/src/jni/love/src/common/android.cpp") + +check(java:find("hasSecondaryDisplayCandidate", 1, true) + and java:find("findSecondaryDisplay(self, false)", 1, true) + and java:find("now %- secondaryDetectionAt < 500"), + "Android exposes cached physical detection before Presentation is ready") +check(java:find("presentSecondaryFrame", 1, true) + and java:find("secondaryFrame = new byte", 1, true) + and java:find("rgba.get(secondaryFrame", 1, true), + "extended presentation reuses a retained frame buffer") +check(java:find("java.nio.ByteBuffer.wrap(secondaryFrame)", 1, true), + "a recreated Presentation receives the retained frame") +check(java:find("0xFF000000 | (color & 0x00FFFFFF)", 1, true), + "RGB companion backgrounds become opaque Android colors") +check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true) + and java:find("Math.floor(fit)", 1, true), + "FrameView supports cover and pixel-friendly contain fits") +check(cpp:find("love_android_secondary_detected", 1, true) + and cpp:find("love_android_present_secondary", 1, true) + and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true), + "JNI exports the optional detected and presentation calls") + +print("android secondary presentation: ok") diff --git a/tests/engine/second_screen_present.lua b/tests/engine/second_screen_present.lua new file mode 100644 index 00000000..7f4643b6 --- /dev/null +++ b/tests/engine/second_screen_present.lua @@ -0,0 +1,64 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local name = "src.render.SecondScreen" +local oldModule = package.loaded[name] +local oldFfi = package.loaded.ffi +local oldPreload = package.preload.ffi +local calls = {} +local null = {} + +local C = { + love_android_secondary_ready = function() return 0 end, + love_android_push_secondary = function(ptr, w, h) + calls.push = { ptr, w, h } + end, + love_android_secondary_enable = function(on) calls.enabled = on end, + love_android_secondary_detected = function() return 1 end, + love_android_present_secondary = function(ptr, w, h, background, cover) + calls.present = { ptr, w, h, background, cover } + return 1 + end, + love_android_poll_secondary_touch = function() return null end, +} +local fakeFfi = { + C = C, + NULL = null, + cdef = function() end, + load = function() return C end, + string = function(value) return value end, +} + +package.loaded[name] = nil +package.loaded.ffi = nil +package.preload.ffi = function() return fakeFfi end + +local SecondScreen = require(name) +local image = { getFFIPointer = function() return "pixels" end } + +T.eq(SecondScreen.available(), false, + "an unbound presentation is not render-ready") +T.eq(SecondScreen.detected(), true, + "physical display detection is independent of presentation readiness") +T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary:cover"), true, + "extended Android presentation accepts frame metadata") +T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 }, + "cover and RGB background reach the native bridge") +T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary"), true, + "contain presentation remains available") +T.same(calls.present, { "pixels", 160, 144, 0x112233, 0 }, + "contain is the default native fit") +T.eq(SecondScreen.push(image, 160, 144, nil, "secondary:cover"), true, + "a fit preference can request extended presentation by itself") +T.same(calls.present, { "pixels", 160, 144, 0, 1 }, + "preference-only presentation defaults to a black background") +T.eq(SecondScreen.push(image, 160, 144), true, + "the original push ABI remains available") +T.same(calls.push, { "pixels", 160, 144 }, + "legacy callers retain the original frame path") + +package.loaded[name] = oldModule +package.loaded.ffi = oldFfi +package.preload.ffi = oldPreload + +T.finish("Android secondary presentation facade") From 24d0c6528da1c22608d69743e3ef8086d27d1adc Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:36:41 +0200 Subject: [PATCH 24/32] Translate the stat name in RBY's X-item and vitamin rose! messages Both the player-side and AI-trainer stat-rise messages (X ATTACK/ DEFENSE/etc. and the vitamins) passed the raised stat's name as a raw uppercase Lua string (stat:upper()), bypassing Strings() entirely, so it always rendered in English regardless of the active language even though the surrounding sentence template was already translated. Wrap the substituted stat name in Strings() at every call site (src/inventory/ItemEffects.lua's two player-side messages and src/battle/TrainerAI.lua's AI-trainer X-item message, found in review), reusing the same "ATTACK"/"DEFENSE"/"SPEED"/"SPECIAL"/"HP" keys SummaryMenu.lua's stat labels already look up the same way. --- src/battle/TrainerAI.lua | 2 +- src/inventory/ItemEffects.lua | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 9654029a..7eb4bb32 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -124,7 +124,7 @@ function TrainerAI.useItem(battle, item) elseif X_STAT[item] then local stat = X_STAT[item] enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) - table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper())) + table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(stat:upper()))) elseif item == "GUARD_SPEC" then enemy.mist = true table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy))) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index a900fec9..678cc517 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -294,7 +294,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) "Nothing happened!") } end b.stages[stat] = cur + 1 - return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) } + return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(stat:upper())) } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- the item, even when it is already active @@ -493,7 +493,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- Spanish ROM puts the stat before the name), so the extracted line -- cannot be filled positionally; the engine wording stands return "consumed", { Strings("%s's %s\nrose!", monName(data, target), - vitaminStat == "hp" and "HP" or vitaminStat:upper()) } + Strings(vitaminStat == "hp" and "HP" or vitaminStat:upper())) } end -- PP UP boosts the move the player picked (ItemUsePPUp's move menu) From c280119d03d1f4bf167cd7efb8bba83ebbf8730d Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:36:46 +0200 Subject: [PATCH 25/32] Translate Gold's Light Screen / Reflect rose! messages Same theme as the RBY fix, found while checking whether Gold had the same gap: EFFECT_LIGHT_SCREEN and EFFECT_REFLECT built their "'s SPCL.DEF/DEFENSE rose!" message by raw string concatenation, bypassing Strings() entirely -- unlike most other messages in this file (e.g. "%s\nused %s!" a few lines up), which already go through it. Wrap the whole message template in Strings(), matching that existing pattern; the substituted name still comes from monName() as before. Gold's gen2/Battle.lua has many more messages built the same unwrapped way (fainted!, learned..., missed!, and so on) -- that is the much larger "Battle messages" gap already tracked separately and deliberately left out of this change. --- src/battle/gen2/Battle.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 05ea1c2e..e2d5a47e 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -2429,7 +2429,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker) if (side.lightScreen or 0) > 0 then return fail(self) end side.lightScreen = Battle.SCREEN_TURNS self:emit({ kind = "message", - text = self:monName(attacker) .. "'s SPCL.DEF rose!" }) + text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) }) end Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker) @@ -2437,7 +2437,7 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker) if (side.reflect or 0) > 0 then return fail(self) end side.reflect = Battle.SCREEN_TURNS self:emit({ kind = "message", - text = self:monName(attacker) .. "'s DEFENSE rose!" }) + text = Strings("%s's DEFENSE rose!", self:monName(attacker)) }) end -- engine/battle/move_effects/safeguard.asm:1 From 9423337bcc4d23f36f78e2b46565cd0fdb4cda46 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:48:28 +0200 Subject: [PATCH 26/32] Make the rose! messages' stat name harvestable by the mod catalog tool Strings(stat:upper()) is a dynamic argument -- tools/modkit.py's STRINGS_CALL harvester only matches a literal string right after Strings(/Strings.source(, so it can't discover "ATTACK"/"DEFENSE"/etc. from these call sites. Translation coverage happened to still work only because the same literals are independently harvested from unrelated call sites (MoveEffects.lua's STAT_LABEL, BattleState.lua's literal Strings("ATTACK") calls) -- real but fragile, found in review. Reuse the codebase's existing pattern for exactly this situation (MoveEffects.lua's STAT_LABEL): a local table built at require time with Strings.source(...), which the harvester can see, resolved to a translated label at use time with Strings(TABLE[key]). Adds one such table to ItemEffects.lua (covering its X-item and vitamin call sites, including "hp") and one to TrainerAI.lua. --- src/battle/TrainerAI.lua | 11 ++++++++++- src/inventory/ItemEffects.lua | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 7eb4bb32..e623b61c 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -33,6 +33,15 @@ end local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 } local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } +-- Strings.source, not Strings: harvested at require time so the catalog +-- generator can see the literal, same pattern as MoveEffects.lua's +-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument +-- the harvester can't discover. +local STAT_LABEL = { + attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"), + speed = Strings.source("SPEED"), +} + -- The trainer's ai_classes record from the merged registry; the direct -- require covers battles built without a loader. A trainer record's -- aiClass field picks a record other than its own id. @@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item) elseif X_STAT[item] then local stat = X_STAT[item] enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) - table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(stat:upper()))) + table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat]))) elseif item == "GUARD_SPEC" then enemy.mist = true table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy))) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 678cc517..86aa814d 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -59,6 +59,16 @@ local STONES = { LEAF_STONE = true, MOON_STONE = true, } +-- Strings.source, not Strings: harvested at require time so the catalog +-- generator can see the literal, same pattern as MoveEffects.lua's +-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument +-- the harvester can't discover. +local STAT_LABEL = { + hp = Strings.source("HP"), attack = Strings.source("ATTACK"), + defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"), + special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"), +} + -- vitamins: stat-exp boosters (ItemUseVitamin) local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense", CARBOS = "speed", CALCIUM = "special" } @@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) "Nothing happened!") } end b.stages[stat] = cur + 1 - return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(stat:upper())) } + return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- the item, even when it is already active @@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- Spanish ROM puts the stat before the name), so the extracted line -- cannot be filled positionally; the engine wording stands return "consumed", { Strings("%s's %s\nrose!", monName(data, target), - Strings(vitaminStat == "hp" and "HP" or vitaminStat:upper())) } + Strings(STAT_LABEL[vitaminStat])) } end -- PP UP boosts the move the player picked (ItemUsePPUp's move menu) From d6ddf23f976de5e37e2d0e3d0b2c2e4831a57eae Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:30:52 +0200 Subject: [PATCH 27/32] Add targeted coverage for the stat-rise message translation fix No existing test could tell a translated stat name apart from a raw stat:upper() that never went through Strings() at all: every rose! message assertion in the suite runs with no catalog loaded, where Strings() is an identity function either way. Loads a real catalog (Strings.load) that translates one stat name at a time and checks it actually reaches the X-item, vitamin, and AI-trainer X-item messages -- ROM-free, over tests/fixture_data. Confirmed this catches the regression: reverting src/inventory/ItemEffects.lua and src/battle/TrainerAI.lua to their pre-fix state fails 5 of 6 checks. --- .../stat_rise_message_translation_test.lua | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/engine/stat_rise_message_translation_test.lua diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua new file mode 100644 index 00000000..5b8ff08c --- /dev/null +++ b/tests/engine/stat_rise_message_translation_test.lua @@ -0,0 +1,73 @@ +-- The stat name substituted into X-item/vitamin "rose!" messages must +-- itself reach a translation catalog, not just the surrounding sentence +-- template (src/inventory/ItemEffects.lua, src/battle/TrainerAI.lua). +-- With no catalog loaded it stays English (the existing baseline); with +-- one loaded that translates e.g. "ATTACK", the substituted word must +-- change too -- that is the actual bug this suite guards against, which +-- passing/failing sentences alone (as other suites already check) +-- cannot tell apart from a raw stat:upper() that was never wrapped in +-- Strings() at all. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local ItemEffects = require("src.inventory.ItemEffects") +local TrainerAI = require("src.battle.TrainerAI") +local Strings = require("src.core.Strings") + +local function withCatalog(catalog, fn) + Strings.load({ strings = catalog }) + local ok, err = pcall(fn) + Strings.load(nil) + if not ok then error(err, 0) end +end + +-- ------------------------------------------------------- player X-item + +local save = SaveData.newGame() +local player = { name = "FIXMON", stages = {} } +local xBattle = { player = player, kind = "wild" } + +local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) +T.check(baseline[1]:find("ATTACK", 1, true) ~= nil, + "X ATTACK's rose! message names the stat in English with no catalog") + +withCatalog({ ATTACK = "ATTAQUE" }, function() + player.stages.attack = nil + local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) + T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil, + "a catalog translating ATTACK reaches the X ATTACK rose! message") + T.check(msgs[1]:find("ATTACK", 1, true) == nil, + "...and the untranslated English stat name is gone") +end) + +-- --------------------------------------------------------- player vitamin + +local target = Pokemon.new(Data, "FIXMON_A", 10) +withCatalog({ DEFENSE = "DEFENSE_FR" }, function() + local _, msgs = ItemEffects.use(Data, save, "IRON", target) + T.check(msgs[1]:find("DEFENSE_FR", 1, true) ~= nil, + "a catalog translating DEFENSE reaches the IRON (vitamin) rose! message") +end) + +local hpTarget = Pokemon.new(Data, "FIXMON_A", 10) +withCatalog({ HP = "PV" }, function() + local _, msgs = ItemEffects.use(Data, save, "HP_UP", hpTarget) + T.check(msgs[1]:find("PV", 1, true) ~= nil, + "a catalog translating HP reaches the HP UP rose! message") +end) + +-- ------------------------------------------------------- AI trainer X-item + +local enemy = { name = "FOE", stages = {} } +local aiBattle = { enemy = enemy, trainer = { name = "TRAINER" }, data = Data } + +withCatalog({ SPEED = "VITESSE" }, function() + local msgs = TrainerAI.useItem(aiBattle, "X_SPEED") + T.check(msgs[2]:find("VITESSE", 1, true) ~= nil, + "a catalog translating SPEED reaches the AI trainer's X SPEED rose! message") +end) + +T.finish("stat rise message translation") From 467566c7996b1d1fee76668765253c851d0cf896 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:53:30 +0200 Subject: [PATCH 28/32] Cover Gold's Light Screen / Reflect fix in the same test The targeted test only covered the RBY side (ItemEffects.lua, TrainerAI.lua); src/battle/gen2/Battle.lua's EFFECT_LIGHT_SCREEN/ EFFECT_REFLECT fix had no test at all, spotted when asked whether the Gold changes were covered. Adds a minimal MACHOP/TACKLE gen2 fixture (same shape as tests/gen2_move_effects_test.lua's), calls both MOVE_EFFECTS directly, and checks a loaded catalog reaches the whole message template (Gold wraps the full sentence, not just the stat name). Confirmed it catches the regression: reverting Battle.lua to its pre-fix state fails 2 of the suite's now 8 checks. --- .../stat_rise_message_translation_test.lua | 86 +++++++++++++++++-- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua index 5b8ff08c..83b4da0c 100644 --- a/tests/engine/stat_rise_message_translation_test.lua +++ b/tests/engine/stat_rise_message_translation_test.lua @@ -1,12 +1,13 @@ --- The stat name substituted into X-item/vitamin "rose!" messages must --- itself reach a translation catalog, not just the surrounding sentence --- template (src/inventory/ItemEffects.lua, src/battle/TrainerAI.lua). --- With no catalog loaded it stays English (the existing baseline); with --- one loaded that translates e.g. "ATTACK", the substituted word must --- change too -- that is the actual bug this suite guards against, which --- passing/failing sentences alone (as other suites already check) --- cannot tell apart from a raw stat:upper() that was never wrapped in --- Strings() at all. +-- The stat name substituted into X-item/vitamin "rose!" messages, and +-- Gold's whole Light Screen / Reflect "rose!" messages, must reach a +-- translation catalog, not just the surrounding sentence template (RBY: +-- src/inventory/ItemEffects.lua, src/battle/TrainerAI.lua; Gold: +-- src/battle/gen2/Battle.lua). With no catalog loaded the message stays +-- English (the existing baseline); with one loaded that translates the +-- relevant word(s), the substitution must change too -- that is the +-- actual bug this suite guards against, which passing/failing sentences +-- alone (as other suites already check) cannot tell apart from text that +-- never reached Strings() at all. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") @@ -70,4 +71,71 @@ withCatalog({ SPEED = "VITESSE" }, function() "a catalog translating SPEED reaches the AI trainer's X SPEED rose! message") end) +-- ------------------------------------------------------- Gold: Light Screen / Reflect + +local Gen2Battle = require("src.battle.gen2.Battle") +local Gen2Mon = require("src.battle.gen2.Mon") + +local GEN2_DATA = { + pokemon = { + MACHOP = { + id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + }, + type_chart = { types = { NORMAL = { id = "NORMAL", index = 0, + category = "physical" } }, matchups = {} }, + items = {}, +} +local perfectDvs = { attack = 15, defense = 15, speed = 15, special = 15 } +perfectDvs.hp = Gen2Mon.hpDV(perfectDvs) + +local function newGen2Battle() + local player = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return Gen2Battle.new({ data = GEN2_DATA, party = { player }, wild = wild }) +end + +withCatalog({ ["%s's SPCL.DEF rose!"] = "%s voit sa DEF.SPÉ augmenter !" }, + function() + local lsBattle = newGen2Battle() + Gen2Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN(lsBattle, lsBattle.player) + local events = lsBattle:takeEvents() + local found = false + for _, event in ipairs(events) do + if event.kind == "message" + and event.text:find("DEF.SPÉ augmenter", 1, true) then + found = true + end + end + T.check(found, + "a catalog translating Light Screen's rose! message reaches it") + end) + +withCatalog({ ["%s's DEFENSE rose!"] = "%s voit sa DEFENSE augmenter !" }, + function() + local refBattle = newGen2Battle() + Gen2Battle.MOVE_EFFECTS.EFFECT_REFLECT(refBattle, refBattle.player) + local events = refBattle:takeEvents() + local found = false + for _, event in ipairs(events) do + if event.kind == "message" + and event.text:find("DEFENSE augmenter", 1, true) then + found = true + end + end + T.check(found, + "a catalog translating Reflect's rose! message reaches it") + end) + T.finish("stat rise message translation") From b20b1370ab327084b6dbdc5048054f5199a5b125 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:38:46 +0200 Subject: [PATCH 29/32] Reset ManagerState's draw color after Font.drawBox Font.drawBox leaves the caller's color at white; every other screen resets to black right after calling it, but ManagerState.lua's draw() and drawOverlay() never did. That's invisible on the vanilla tile font (tile glyphs are black-on-transparent regardless of color) but renders fully invisible white-on-white text once a mod's TTF font is active. --- src/mods/ManagerState.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 721fdb64..3066888e 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -1297,6 +1297,7 @@ function ManagerState:drawOverlay() love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8) love.graphics.setColor(1, 1, 1, 1) Font.drawBox(2, ty, 16, th) + love.graphics.setColor(0, 0, 0, 1) for i, line in ipairs(lines) do drawTruncated(line, 4 * 8, (ty + i) * 8, 14) end @@ -1325,6 +1326,7 @@ function ManagerState:draw() love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) Font.drawBox(0, 0, 20, 18) + love.graphics.setColor(0, 0, 0, 1) Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8) if self.screen == "list" then self:drawList() From 0b00faf38e1b7f14062097b605f265065f6d7e7f Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 16 Aug 2026 20:23:19 +0100 Subject: [PATCH 30/32] feat(android): add httpPost bridge for mod.postLog log sends Android ships no curl and the JNI bridge was GET-only, so mod.postLog failed there with 'no POST transport on this platform' (HostShell.lua). Add the mirror of httpDownload: GameActivity.httpPost (https-only, hand-followed redirects re-POSTing the body, one-way), the JNI bridge with the same old-APK-skew tolerance, the love.system.httpPost binding, and the HostShell arm that rides it when curl is absent. The body crosses the JNI as raw bytes (jbyteArray) so a log ring with arbitrary UTF-8 cannot corrupt through modified-UTF-8 jstring conversion. --- .../love/src/jni/love/src/common/android.cpp | 49 +++++++++++++ .../love/src/jni/love/src/common/android.h | 8 +++ .../jni/love/src/modules/system/System.cpp | 15 ++++ .../src/jni/love/src/modules/system/System.h | 8 +++ .../love/src/modules/system/wrap_System.cpp | 12 ++++ .../java/org/love2d/android/GameActivity.java | 70 +++++++++++++++++++ src/core/HostShell.lua | 18 ++++- 7 files changed, 177 insertions(+), 3 deletions(-) diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 429e0353..1cde858e 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent, return result; } +bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent) +{ + if (url == nullptr || body == nullptr || bodyLen < 0) + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + // Same resolution rule as httpDownload: the activity's own class via + // SDL_AndroidGetActivity, never FindClass -- this bridge is called off + // the main thread (love.thread workers), whose class loader cannot see + // app classes. + jobject activityObj = (jobject) SDL_AndroidGetActivity(); + if (activityObj == nullptr) + return false; + jclass activity = env->GetObjectClass(activityObj); + env->DeleteLocalRef(activityObj); + + // Old APK / new liblove skew: report "no transport" the same way a + // missing curl does, instead of aborting on a missing method (#597). + jmethodID method = env->GetStaticMethodID(activity, "httpPost", + "(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jstring jurl = env->NewStringUTF(url); + // raw bytes across the bridge: a log ring can carry arbitrary UTF-8, + // and a jstring would run it through modified UTF-8 + jbyteArray jbody = env->NewByteArray(bodyLen); + if (jbody != nullptr) + env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body); + jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr; + jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr; + + jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua); + + env->DeleteLocalRef(jurl); + if (jbody != nullptr) + env->DeleteLocalRef(jbody); + if (jct != nullptr) + env->DeleteLocalRef(jct); + if (jua != nullptr) + env->DeleteLocalRef(jua); + env->DeleteLocalRef(activity); + return result; +} + /* * TLS sockets. Same resolution rule as httpDownload above -- the activity's * own class, never FindClass -- and the same tolerance for an old APK: a diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 6e2fa017..a412b25c 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -98,6 +98,14 @@ bool restartApp(); **/ bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept); +/** + * Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The + * mirror of httpDownload for mod.postLog log sends, which need POST and + * have no curl on Android. contentType / userAgent may be null. Returns + * whether the server accepted the send (2xx). + **/ +bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent); + /** * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java). * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index fce90d63..032228cb 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath, #endif } +bool System::httpPost(const char *url, const char *body, int bodyLen, + const char *contentType, const char *userAgent) const +{ +#ifdef LOVE_ANDROID + return love::android::httpPost(url, body, bodyLen, contentType, userAgent); +#else + LOVE_UNUSED(url); + LOVE_UNUSED(body); + LOVE_UNUSED(bodyLen); + LOVE_UNUSED(contentType); + LOVE_UNUSED(userAgent); + return false; +#endif +} + int System::tlsOpen(const char *host, int port) const { #ifdef LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 7ab0484a..bbffd544 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -151,6 +151,14 @@ public: virtual bool httpDownload(const char *url, const char *destPath, const char *userAgent = nullptr, const char *accept = nullptr) const; + /** + * Blocking HTTPS POST of a raw byte body (Android only; false + * elsewhere). The mirror of httpDownload for mod.postLog log sends, + * which need POST and have no curl on Android (#597). + **/ + virtual bool httpPost(const char *url, const char *body, int bodyLen, + const char *contentType = nullptr, const char *userAgent = nullptr) const; + /** * TLS client sockets (Android only; every call fails elsewhere, where * LuaSec or another provider is the answer). Non-blocking by contract: diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 9dfa18ea..48436638 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L) return 1; } +int w_httpPost(lua_State *L) +{ + const char *url = luaL_checkstring(L, 1); + size_t bodyLen = 0; + const char *body = luaL_checklstring(L, 2, &bodyLen); + const char *ct = luaL_optstring(L, 3, nullptr); + const char *ua = luaL_optstring(L, 4, nullptr); + luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua)); + return 1; +} + int w_hasBackgroundMusic(lua_State *L) { lua_pushboolean(L, instance()->hasBackgroundMusic()); @@ -233,6 +244,7 @@ static const luaL_Reg functions[] = { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, + { "httpPost", w_httpPost }, { "tlsOpen", w_tlsOpen }, { "tlsStatus", w_tlsStatus }, { "tlsSend", w_tlsSend }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index cec24cde..85af7d48 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -741,6 +741,76 @@ public class GameActivity extends SDLActivity { } } + /** + * Blocking HTTPS POST, exposed as love.system.httpPost and used by + * src/core/HostShell.lua for mod.postLog. The GET bridge above covers + * downloads; log sends need POST, and Android ships no curl, so this is + * the only POST transport the platform has. Strictly one-way, matching + * the curl branch it mirrors: the response body is drained and + * discarded, and only the 2xx verdict comes back. + * + * Same rules as httpDownload: https only, redirects followed by hand + * (re-POSTing the body on each hop, the way curl -X POST behaves), and + * the call is blocking on the Lua/worker thread -- never the UI thread. + * The body arrives as raw bytes (a jbyteArray across the JNI) because a + * log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8 + * corruption on characters outside the BMP. + */ + @Keep + public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) { + if (url == null || body == null) return false; + HttpURLConnection conn = null; + try { + String current = url; + for (int hop = 0; hop < 5; hop++) { + URL parsed = new URL(current); + if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false; + conn = (HttpURLConnection) parsed.openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("User-Agent", + userAgent == null ? "gen1recomp" : userAgent); + conn.setRequestProperty("Content-Type", + contentType == null ? "text/plain" : contentType); + OutputStream out = new BufferedOutputStream(conn.getOutputStream()); + try { + out.write(body); + } finally { + try { out.close(); } catch (IOException ignored) {} + } + int code = conn.getResponseCode(); + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + String next = conn.getHeaderField("Location"); + conn.disconnect(); + conn = null; + if (next == null) return false; + current = new URL(parsed, next).toString(); + continue; + } + if (code < 200 || code > 299) return false; + // drain and discard, so a slow server cannot wedge the + // worker on a full socket buffer + InputStream in = new BufferedInputStream(conn.getInputStream()); + try { + byte[] buf = new byte[16384]; + while (in.read(buf) > 0) {} + } finally { + try { in.close(); } catch (IOException ignored) {} + } + return true; + } + return false; + } catch (Exception e) { + Log.d("GameActivity", "httpPost failed: " + e.getMessage()); + return false; + } finally { + if (conn != null) conn.disconnect(); + } + } + /** * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export * (pending_export.sav in the app save identity) to Downloads / Drive / diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 9939ef39..b619ebaa 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -452,9 +452,11 @@ end -- POST returning success/failure. Strictly one-way: the response body is -- discarded, only the HTTP status class is surfaced (postLog callers never -- trust the reply). curl --data-binary reads the payload from a pipe, so a --- large body never lands in the command line; the Android bridge has no POST --- transport, and httpPost reports that instead of half-working through --- httpDownload (a GET round-trip to a POST endpoint would be a lie). +-- large body never lands in the command line; where curl is absent (Android +-- and the other bridge-only platforms) the POST rides the JNI bridge -- +-- love.system.httpPost, the dedicated POST arm added beside httpDownload -- +-- instead of half-working through httpDownload (a GET round-trip to a POST +-- endpoint would be a lie). function HostShell.httpPost(url, body, contentType, userAgent, maxTime) if type(url) ~= "string" or url == "" then return nil, "missing url" end if type(body) ~= "string" then return nil, "missing body" end @@ -530,6 +532,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) if not haveBridge() then return nil, "no network transport on this platform" end + -- The GET bridge has no POST; the dedicated love.system.httpPost arm + -- (GameActivity.httpPost) is the transport where curl is missing. A + -- build without it reports the same "no POST transport" a missing curl + -- would -- the old-APK skew path in the JNI bridge returns false. + if love.system and type(love.system.httpPost) == "function" then + local ok, sent = pcall(love.system.httpPost, url, body, contentType, + userAgent) + if ok and sent then return true end + return nil, "log post rejected" + end return nil, "no POST transport on this platform" end From b739fa76c0fde6ab4dc89257b3272414ac3ae838 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 16 Aug 2026 23:45:09 +0100 Subject: [PATCH 31/32] mods: gate love.thread behind a compute permission Threads open a fresh Lua state with a full standard library, so they stayed blocked wholesale. PotatoVoxel's prebuilder wants to run its pure geometry phase on worker threads; the mod declares the new "compute" permission and the sandbox hands out love.thread only then. The worker runs the mod's own source (source-only, like every mod file) and receives data only through channels. --- src/mods/Manifest.lua | 2 +- src/mods/Sandbox.lua | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 6893ff87..92544589 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -12,7 +12,7 @@ local Manifest = {} Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true, steps = true, - background = true } + background = true, compute = true } -- link-relevant registries; a mod that writes into one of these while -- declaring affects_link = false gets an attributed warning from the loader diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index e41190ba..d23b6e7d 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -79,13 +79,25 @@ local BLOCKED_LOVE = { -- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed -- by that mod's own overlay and must not be shared. -local function loveFacade(compat) +local function loveFacade(compat, permissions) if not _G.love then return nil end local overrides = compat and compat.love return setmetatable({}, { __index = function(_, key) local override = overrides and overrides[key] if override ~= nil then return override end + if key == "thread" then + -- Threads open a fresh Lua state with a full standard library, so + -- they stay blocked unless the mod declares the `compute` + -- permission (the mod's own source runs in the worker, and the + -- worker ships source-only like every other mod file). The mod + -- must never receive arbitrary code from elsewhere: channels + -- carry data only. + if not (permissions or {}).compute then + error('love.thread needs the "compute" permission in manifest.json', 2) + end + return _G.love.thread + end local hint = BLOCKED_LOVE[key] if hint then error(("love.%s is not available to mods%s"):format(key, @@ -215,7 +227,7 @@ function Sandbox.envFor(opts) opts = opts or {} local compat = opts.compat local env = baseGlobals() - env.love = loveFacade(compat) + env.love = loveFacade(compat, opts.permissions) env.require = sandboxedRequire(opts.modId, opts.permissions, compat) local loader = sandboxedLoad(env) env.load = loader From 360b69296332be144a5a4529c828570afadad1e6 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:00:05 +0200 Subject: [PATCH 32/32] android: route asymmetric companion displays --- docs/modding.md | 3 + .../android/app/src/main/AndroidManifest.xml | 13 + .../love/src/jni/love/src/common/android.cpp | 14 + .../java/org/love2d/android/GameActivity.java | 348 +++++++++++++++--- src/render/SecondScreen.lua | 15 + tests/engine/android_asymmetric_display.lua | 35 ++ tests/engine/android_secondary_present.lua | 3 +- tests/engine/second_screen_present.lua | 6 + 8 files changed, 384 insertions(+), 53 deletions(-) create mode 100644 tests/engine/android_asymmetric_display.lua diff --git a/docs/modding.md b/docs/modding.md index 2786a563..e7c32c39 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -740,6 +740,9 @@ palette-correct blit of either canvas into an arbitrary screen rect, and the the original contract. Its optional `background` (`0xRRGGBB`) and `preference` arguments request an extended presentation; a preference ending in `:cover` fills and crops the target, while other values preserve the whole frame. +Android also accepts `handheld` or `secondary` (with an optional `:cover` +suffix) as routing hints; unsupported or unavailable targets fall back to the +other connected display. `pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. This is what lets a mod lay the two passes out as two stacked Game Boy screens, diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 5cf7b38a..a2de3161 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,9 @@ android:allowBackup="true" android:icon="@drawable/love" android:label="${NAME}" > + + diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 227e3aa3..3c327be4 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -1229,6 +1229,20 @@ void love_android_secondary_enable(int on) env->DeleteLocalRef(activity); } +extern "C" __attribute__((visibility("default"))) +void love_android_secondary_target(int target) +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, + "setSecondaryDisplayTarget", "(I)V"); + if (method) + env->CallStaticVoidMethod(activity, method, target); + else + env->ExceptionClear(); + env->DeleteLocalRef(activity); +} + extern "C" __attribute__((visibility("default"))) int love_android_secondary_detected() { diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 31910959..aafd4af2 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -60,6 +60,7 @@ import android.os.Environment; import android.os.Handler; import android.os.Looper; import android.os.Vibrator; +import android.provider.Settings; import android.util.Log; import android.util.DisplayMetrics; import android.view.*; @@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity { public void onResume() { super.onResume(); onHostResume(); + refreshDualScreenDisplayMode(); if (secondaryEnabled) registerSecondaryDisplayListener(); setupSecondaryDisplay(); } + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // AYN's panel toggle emits virtual Right Shift, which SDL maps to a + // gameplay button. The setting is absent on other Android devices. + if (secondaryEnabled && dualScreenDisplayMode != -1 + && event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT + && event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) { + return true; + } + return super.dispatchKeyEvent(event); + } + /** * SDL decides the activity's requested orientation at window creation * (SDLActivity.setOrientationBis). With a resizable window and no @@ -1476,8 +1490,21 @@ public class GameActivity extends SDLActivity { // Dual-screen: mirror the engine's bottom-screen canvas onto a secondary // physical display. Driven from the engine through love_android_secondary_* // in src/jni/love/src/common/android.cpp. + private static final int SECONDARY_TARGET_AUTO = 0; + private static final int SECONDARY_TARGET_HANDHELD = 1; + private static final int SECONDARY_TARGET_EXTERNAL = 2; + // AYN keeps disabled panels registered as ON. This optional setting is the + // usable-state signal: 0 = both, 1 = main only, 2 = second only. + private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode"; + private static final String AYN_SECOND_SCREEN = "Screen-2"; private static volatile SecondaryPresentation secondaryPresentation; + private static volatile SecondaryActivity secondaryActivity; + private static volatile boolean secondaryActivityPending; + private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY; + private static volatile long secondaryRetryAfter; private static volatile boolean secondaryEnabled = false; + private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO; + private static volatile int dualScreenDisplayMode = -1; private static volatile byte[] secondaryFrame; private static volatile int secondaryFrameWidth; private static volatile int secondaryFrameHeight; @@ -1487,6 +1514,14 @@ public class GameActivity extends SDLActivity { private static volatile long secondaryDetectionAt; private static volatile boolean secondaryDetected; private SecondaryDisplayMonitor secondaryDisplayMonitor; + private boolean dualScreenModeObserverRegistered; + private final android.database.ContentObserver dualScreenModeObserver = + new android.database.ContentObserver(new Handler(Looper.getMainLooper())) { + @Override public void onChange(boolean selfChange, Uri uri) { + refreshDualScreenDisplayMode(); + rebindSecondaryDisplay(); + } + }; private static final int MAX_SECONDARY_TOUCHES = 32; private static final java.util.ArrayDeque secondaryTouches = new java.util.ArrayDeque<>(); @@ -1499,56 +1534,125 @@ public class GameActivity extends SDLActivity { self.runOnUiThread(new Runnable() { @Override public void run() { if (on) { + self.refreshDualScreenDisplayMode(); self.registerSecondaryDisplayListener(); - setupSecondaryDisplay(); + rebindSecondaryDisplay(); } else { self.unregisterSecondaryDisplayListener(); teardownSecondaryDisplay(); + secondaryRetryAfter = 0; synchronized (secondaryFrameLock) { secondaryFrame = null; } } } }); } + @Keep + public static void setSecondaryDisplayTarget(int target) { + int normalized = target == SECONDARY_TARGET_HANDHELD + || target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO; + if (secondaryTarget == normalized) return; + secondaryTarget = normalized; + secondaryDetectionAt = 0; + rebindSecondaryDisplay(); + } + + private void refreshDualScreenDisplayMode() { + int mode = Settings.System.getInt( + getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1); + if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0; + dualScreenDisplayMode = mode; + } + private void registerSecondaryDisplayListener() { - if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return; - SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this); - if (monitor.register()) secondaryDisplayMonitor = monitor; + if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) { + SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this); + if (monitor.register()) secondaryDisplayMonitor = monitor; + } + if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) { + getContentResolver().registerContentObserver( + Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false, + dualScreenModeObserver); + dualScreenModeObserverRegistered = true; + } } private void unregisterSecondaryDisplayListener() { SecondaryDisplayMonitor monitor = secondaryDisplayMonitor; secondaryDisplayMonitor = null; if (monitor != null) monitor.unregister(); + if (dualScreenModeObserverRegistered) { + getContentResolver().unregisterContentObserver(dualScreenModeObserver); + dualScreenModeObserverRegistered = false; + } } - private static void refreshSecondaryDisplay() { - GameActivity self = (GameActivity) mSingleton; - if (self == null || !secondaryEnabled) return; - SecondaryPresentation current = secondaryPresentation; - Display display = current == null ? null : current.getDisplay(); + private static boolean secondaryOutputIsPreferred(GameActivity self) { + Display preferred = findSecondaryDisplay(self, false); + if (preferred == null) return false; + SecondaryPresentation presentation = secondaryPresentation; + Display display = presentation == null + ? null : presentation.getDisplay(); + if (display == null) { + SecondaryActivity activity = secondaryActivity; + display = activity == null ? null : getActivityDisplay(activity); + } SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor; - if (current == null) { - setupSecondaryDisplay(); - } else if (display == null || monitor == null - || !monitor.hasDisplay(display.getDisplayId())) { + if (display == null || (monitor != null + && !monitor.hasDisplay(display.getDisplayId()))) return false; + return display.getDisplayId() == preferred.getDisplayId(); + } + + private static void rebindSecondaryDisplay() { + GameActivity self = (GameActivity) mSingleton; + if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return; + self.runOnUiThread(() -> { + if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return; teardownSecondaryDisplay(); setupSecondaryDisplay(); - } + }); } private static void setupSecondaryDisplay() { GameActivity self = (GameActivity) mSingleton; - if (self == null || !secondaryEnabled || secondaryPresentation != null) return; + if (self == null || !secondaryEnabled || secondaryPresentation != null + || secondaryActivity != null || secondaryActivityPending + || android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return; try { Display chosen = findSecondaryDisplay(self, true); if (chosen == null) { Log.d("GameActivity", "no secondary display found"); return; } + if (!isPresentationDisplay(chosen)) { + if (android.os.Build.VERSION.SDK_INT < 29) return; + secondaryActivityPending = true; + secondaryActivityTarget = chosen.getDisplayId(); + Intent intent = new Intent(self, SecondaryActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION); + android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic(); + options.setLaunchDisplayId(secondaryActivityTarget); + self.startActivity(intent, options.toBundle()); + final int requestedDisplay = secondaryActivityTarget; + new Handler(Looper.getMainLooper()).postDelayed(() -> { + if (secondaryActivityPending + && secondaryActivityTarget == requestedDisplay) { + secondaryActivityPending = false; + secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000; + } + }, 1000); + return; + } SecondaryPresentation p = new SecondaryPresentation(self, chosen); + p.setOnDismissListener(dialog -> { + if (secondaryPresentation == p) { + secondaryPresentation = null; + rebindSecondaryDisplay(); + } + }); p.show(); secondaryPresentation = p; + secondaryRetryAfter = 0; synchronized (secondaryFrameLock) { if (secondaryFrame != null) { p.setBackground(secondaryBackground); @@ -1559,39 +1663,77 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId()); } catch (Throwable t) { Log.d("GameActivity", "secondary display setup failed: " + t); - secondaryPresentation = null; + secondaryActivityPending = false; + secondaryActivityTarget = Display.INVALID_DISPLAY; + secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000; + teardownSecondaryDisplay(); } } private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) { android.hardware.display.DisplayManager dm = (android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE); - if (dm == null) return null; - Display chosen = null; - for (Display d : dm.getDisplays()) { + if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null; + Display gameDisplay = getActivityDisplay(self); + int gameDisplayId = gameDisplay == null + ? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId(); + Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY); + boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29 + && gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld); + Display external = null; + Display[] presentations = dm.getDisplays( + android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + for (Display d : presentations) { if (logDisplays) { android.graphics.Point size = new android.graphics.Point(); d.getRealSize(size); Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName() + " size=" + size.x + "x" + size.y); } - if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) chosen = d; + if (external == null && d.getDisplayId() != gameDisplayId + && isDisplayUsable(d)) external = d; } - if (chosen == null) { - Display[] presentations = - dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION); - if (presentations != null && presentations.length > 0) chosen = presentations[0]; + if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld; + if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external; + return handheldAvailable ? handheld : external; + } + + private static Display getActivityDisplay(android.app.Activity activity) { + return android.os.Build.VERSION.SDK_INT >= 30 + ? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay(); + } + + private static boolean isPresentationDisplay(Display display) { + if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false; + return android.os.Build.VERSION.SDK_INT < 20 + || (display.getFlags() & Display.FLAG_PRESENTATION) != 0; + } + + private static boolean isDisplayUsable(Display display) { + if (display == null) return false; + if (android.os.Build.VERSION.SDK_INT >= 20 + && display.getState() == Display.STATE_OFF) return false; + if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) { + return false; } - return chosen; + return dualScreenDisplayMode != 2 + || display.getDisplayId() != Display.DEFAULT_DISPLAY; } private static void teardownSecondaryDisplay() { SecondaryPresentation p = secondaryPresentation; secondaryPresentation = null; + SecondaryActivity a = secondaryActivity; + secondaryActivity = null; + secondaryActivityPending = false; + secondaryActivityTarget = Display.INVALID_DISPLAY; synchronized (secondaryTouches) { secondaryTouches.clear(); } if (p != null) { try { p.dismiss(); } catch (Throwable t) {} } + if (a != null) { + try { a.finish(); } catch (Throwable t) {} + } } @android.annotation.TargetApi(17) @@ -1620,7 +1762,7 @@ public class GameActivity extends SDLActivity { private void changed() { secondaryDetectionAt = 0; - refreshSecondaryDisplay(); + rebindSecondaryDisplay(); } @Override public void onDisplayAdded(int displayId) { changed(); } @@ -1630,14 +1772,15 @@ public class GameActivity extends SDLActivity { @Keep public static boolean hasSecondaryDisplay() { - return secondaryPresentation != null; + return secondaryPresentation != null || secondaryActivity != null; } @Keep public static boolean hasSecondaryDisplayCandidate() { GameActivity self = (GameActivity) mSingleton; if (self == null) return false; - if (secondaryPresentation != null) return true; + if (secondaryPresentation != null || secondaryActivity != null) return true; + self.refreshDualScreenDisplayMode(); long now = android.os.SystemClock.uptimeMillis(); if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) { return secondaryDetected; @@ -1667,10 +1810,16 @@ public class GameActivity extends SDLActivity { secondaryBackground = backgroundColor; secondaryFrameCover = cover; SecondaryPresentation p = secondaryPresentation; - if (p == null) return false; + SecondaryActivity a = secondaryActivity; + if (p == null && a == null) return false; try { - p.setBackground(backgroundColor); - p.updateFrame(rgba, width, height, cover); + if (p != null) { + p.setBackground(backgroundColor); + p.updateFrame(rgba, width, height, cover); + } else { + a.setBackground(backgroundColor); + a.updateFrame(rgba, width, height, cover); + } return true; } catch (Throwable t) { GameActivity self = (GameActivity) mSingleton; @@ -1686,8 +1835,10 @@ public class GameActivity extends SDLActivity { @Keep public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) { SecondaryPresentation p = secondaryPresentation; - if (p != null && buf != null && w > 0 && h > 0) { - p.updateFrame(buf, w, h); + SecondaryActivity a = secondaryActivity; + if ((p != null || a != null) && buf != null && w > 0 && h > 0) { + if (p != null) p.updateFrame(buf, w, h); + else a.updateFrame(buf, w, h); } } @@ -1698,6 +1849,102 @@ public class GameActivity extends SDLActivity { } } + private static void applySecondaryImmersive(android.view.Window w) { + if (w == null) return; + if (android.os.Build.VERSION.SDK_INT >= 30) { + w.setDecorFitsSystemWindows(false); + android.view.WindowInsetsController c = w.getInsetsController(); + if (c != null) { + c.hide(android.view.WindowInsets.Type.systemBars()); + c.setSystemBarsBehavior( + android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + } else { + w.getDecorView().setSystemUiVisibility( + android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | android.view.View.SYSTEM_UI_FLAG_FULLSCREEN + | android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + } + } + + public static class SecondaryActivity extends android.app.Activity { + private FrameView frameView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Display display = getActivityDisplay(this); + if (!secondaryEnabled || display == null + || display.getDisplayId() != secondaryActivityTarget) { + secondaryActivityPending = false; + secondaryActivityTarget = Display.INVALID_DISPLAY; + secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000; + finish(); + return; + } + frameView = new FrameView(this); + android.view.Window w = getWindow(); + w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + WindowManager.LayoutParams.FLAG_FULLSCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + setContentView(frameView); + applySecondaryImmersive(w); + secondaryActivity = this; + secondaryActivityPending = false; + secondaryRetryAfter = 0; + synchronized (secondaryFrameLock) { + if (secondaryFrame != null) { + setBackground(secondaryBackground); + updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame), + secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover); + } + } + } + + @Override + protected void onDestroy() { + if (secondaryActivity == this) secondaryActivity = null; + super.onDestroy(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) applySecondaryImmersive(getWindow()); + } + + @Override + public boolean dispatchKeyEvent(android.view.KeyEvent event) { + GameActivity activity = (GameActivity) mSingleton; + return activity != null + ? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) { + GameActivity activity = (GameActivity) mSingleton; + return activity != null + ? activity.dispatchGenericMotionEvent(event) + : super.dispatchGenericMotionEvent(event); + } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h) { + frameView.updateFrame(buf, w, h); + } + + void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) { + frameView.updateFrame(buf, w, h, cover); + } + + void setBackground(int color) { + frameView.setFrameBackground(color); + } + } + private static class SecondaryPresentation extends android.app.Presentation { private final FrameView frameView; @@ -1731,26 +1978,23 @@ public class GameActivity extends SDLActivity { if (hasFocus) applyImmersive(); } + @Override + public boolean dispatchKeyEvent(android.view.KeyEvent event) { + GameActivity activity = (GameActivity) mSingleton; + return activity != null + ? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) { + GameActivity activity = (GameActivity) mSingleton; + return activity != null + ? activity.dispatchGenericMotionEvent(event) + : super.dispatchGenericMotionEvent(event); + } + private void applyImmersive() { - android.view.Window w = getWindow(); - if (w == null) return; - if (android.os.Build.VERSION.SDK_INT >= 30) { - w.setDecorFitsSystemWindows(false); - android.view.WindowInsetsController c = w.getInsetsController(); - if (c != null) { - c.hide(android.view.WindowInsets.Type.systemBars()); - c.setSystemBarsBehavior( - android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } - } else { - w.getDecorView().setSystemUiVisibility( - android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | android.view.View.SYSTEM_UI_FLAG_FULLSCREEN - | android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); - } + applySecondaryImmersive(getWindow()); } void updateFrame(java.nio.ByteBuffer buf, int w, int h) { diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index 3a3a958f..eeda7750 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -7,6 +7,7 @@ local C = nil local ffi = nil local desktop = nil local nativePresent = false +local nativeTarget = false local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) @@ -25,6 +26,7 @@ do int love_android_secondary_detected(); int love_android_present_secondary(const void *rgba, int w, int h, unsigned int background, int cover); + void love_android_secondary_target(int target); const char *love_android_poll_secondary_touch(); ]]) local okLib, lib = pcall(ffi.load, "love") @@ -45,8 +47,12 @@ do local okPresent, present = pcall(function() return C.love_android_present_secondary end) + local okTarget, target = pcall(function() + return C.love_android_secondary_target + end) nativePresent = okDetected and detected ~= nil and okPresent and present ~= nil + nativeTarget = okTarget and target ~= nil end end end @@ -87,6 +93,15 @@ function SecondScreen.push(imageData, w, h, background, preference) end if not C or not imageData then return false end if nativePresent and (background ~= nil or preference ~= nil) then + if nativeTarget then + local target = 0 + if preference == "handheld" or preference == "handheld:cover" then + target = 1 + elseif preference == "secondary" or preference == "secondary:cover" then + target = 2 + end + pcall(C.love_android_secondary_target, target) + end local cover = type(preference) == "string" and preference:sub(-6) == ":cover" local ok, shown = pcall(C.love_android_present_secondary, diff --git a/tests/engine/android_asymmetric_display.lua b/tests/engine/android_asymmetric_display.lua new file mode 100644 index 00000000..36c02368 --- /dev/null +++ b/tests/engine/android_asymmetric_display.lua @@ -0,0 +1,35 @@ +local function read(path) + local file = assert(io.open(path, "rb")) + local source = file:read("*a") + file:close() + return source +end + +local function check(value, message) + if not value then error(message, 2) end +end + +local java = read( + "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java") +local manifest = read("mobile/android/app/src/main/AndroidManifest.xml") + +check(manifest:find("android.allow_multiple_resumed_activities", 1, true) + and manifest:find("GameActivity$SecondaryActivity", 1, true) + and manifest:find('android:exported="false"', 1, true), + "the private companion Activity opts into Android multi-display resume") +check(java:find("android.os.Build.VERSION.SDK_INT < 29", 1, true) + and java:find("options.setLaunchDisplayId", 1, true), + "the primary-display fallback is restricted to Android 10+") +check(java:find("SECONDARY_TARGET_HANDHELD", 1, true) + and java:find("SECONDARY_TARGET_EXTERNAL", 1, true) + and java:find("handheldAvailable ? handheld : external", 1, true), + "routing hints retain a safe available-display fallback") +check(java:find("dualScreenDisplayMode != %-1") + and java:find("AYN_SECOND_SCREEN", 1, true) + and java:find("dualScreenModeObserverRegistered", 1, true), + "the optional AYN state is guarded and lifecycle-bound") +check(java:find("activity.dispatchKeyEvent", 1, true) + and java:find("activity.dispatchGenericMotionEvent", 1, true), + "companion windows forward controller input to the game Activity") + +print("android asymmetric display routing: ok") diff --git a/tests/engine/android_secondary_present.lua b/tests/engine/android_secondary_present.lua index ec6ffc5f..d081410e 100644 --- a/tests/engine/android_secondary_present.lua +++ b/tests/engine/android_secondary_present.lua @@ -30,7 +30,8 @@ check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true) "FrameView supports cover and pixel-friendly contain fits") check(cpp:find("love_android_secondary_detected", 1, true) and cpp:find("love_android_present_secondary", 1, true) + and cpp:find("love_android_secondary_target", 1, true) and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true), - "JNI exports the optional detected and presentation calls") + "JNI exports the optional detected, routing, and presentation calls") print("android secondary presentation: ok") diff --git a/tests/engine/second_screen_present.lua b/tests/engine/second_screen_present.lua index 7f4643b6..dafb7ac8 100644 --- a/tests/engine/second_screen_present.lua +++ b/tests/engine/second_screen_present.lua @@ -14,6 +14,7 @@ local C = { calls.push = { ptr, w, h } end, love_android_secondary_enable = function(on) calls.enabled = on end, + love_android_secondary_target = function(target) calls.target = target end, love_android_secondary_detected = function() return 1 end, love_android_present_secondary = function(ptr, w, h, background, cover) calls.present = { ptr, w, h, background, cover } @@ -44,6 +45,10 @@ T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary:cover"), true, "extended Android presentation accepts frame metadata") T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 }, "cover and RGB background reach the native bridge") +T.eq(calls.target, 2, "secondary routing reaches the optional native bridge") +T.eq(SecondScreen.push(image, 160, 144, 0x112233, "handheld"), true, + "handheld routing remains a contain presentation") +T.eq(calls.target, 1, "handheld routing reaches the optional native bridge") T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary"), true, "contain presentation remains available") T.same(calls.present, { "pixels", 160, 144, 0x112233, 0 }, @@ -52,6 +57,7 @@ T.eq(SecondScreen.push(image, 160, 144, nil, "secondary:cover"), true, "a fit preference can request extended presentation by itself") T.same(calls.present, { "pixels", 160, 144, 0, 1 }, "preference-only presentation defaults to a black background") +T.eq(calls.target, 2, "a suffixed route keeps its target") T.eq(SecondScreen.push(image, 160, 144), true, "the original push ABI remains available") T.same(calls.push, { "pixels", 160, 144 },