Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1a03199
fix(android): harden dynamic second display host
UNDERdecoded Sep 30, 2026
1035cea
fix(gen4): retry failed second-screen frame pushes
UNDERdecoded Sep 30, 2026
2fd7e50
fix(android): restore and harden second display host
UNDERdecoded Sep 30, 2026
285a83f
fix(android): prefer active Java second-display host over stale FFI b…
UNDERdecoded Sep 30, 2026
25add0d
fix(android): decode LOVE RGBA frames explicitly
UNDERdecoded Sep 30, 2026
c5502a3
fix(gen4): track lower-screen ownership per frame
UNDERdecoded Sep 30, 2026
2ff2ff0
fix(gen4): render persistent Poketch on physical lower display
UNDERdecoded Sep 30, 2026
1b9b9ba
fix(second-display): commit frames with sequence trailer
UNDERdecoded Sep 30, 2026
5692627
fix(android): reject torn second-display frames
UNDERdecoded Sep 30, 2026
9c3ac11
fix(second-display): restore compatible frame payload
UNDERdecoded Sep 30, 2026
6f1f742
fix(android): restore compatible frame reader
UNDERdecoded Sep 30, 2026
b81174c
debug(android): expose second-display render and transport state
UNDERdecoded Sep 30, 2026
eb7c524
debug(android): show frame transport failures on lower display
UNDERdecoded Sep 30, 2026
4f7a176
fix(android): accept stale diagnostic frame variant
UNDERdecoded Sep 30, 2026
a38aeac
fix(second-display): validate RGBA frame payload before write
UNDERdecoded Sep 30, 2026
1a93d62
debug(android): render frame size mismatch values on lower display
UNDERdecoded Sep 30, 2026
5eab3a4
fix(second-display): disable DPI scaling on DS framebuffer
UNDERdecoded Sep 30, 2026
7463444
fix(android): keep last good frame during concurrent writes
UNDERdecoded Sep 30, 2026
e30e0d7
feat(second-display): support native-resolution launcher canvas
UNDERdecoded Sep 30, 2026
dcebf71
feat(launcher): render second panel at device native resolution
UNDERdecoded Sep 30, 2026
e67f187
fix(poketch): composite app LCD beneath extracted device shell
UNDERdecoded Sep 30, 2026
632185a
fix(poketch): clip app tilemaps to LCD viewport
UNDERdecoded Sep 30, 2026
1dd875c
feat(second-display): add live Gen1-3 save companion
UNDERdecoded Sep 30, 2026
a0080b2
feat(game): drive Gen1-3 save companion on second display
UNDERdecoded Sep 30, 2026
3d0d596
feat(second-display): allow native logical companion resolution
UNDERdecoded Sep 30, 2026
4306150
feat(save-companion): add full native-resolution live editor
UNDERdecoded Sep 30, 2026
9c82892
fix(save-companion): align Gen2 stats and item catalog
UNDERdecoded Sep 30, 2026
69826f0
fix(ios): stop double-flipping Gen4 world override
UNDERdecoded Sep 30, 2026
e15c435
fix(ios): preserve world flip outside Gen4
UNDERdecoded Sep 30, 2026
b644524
feat(save-companion): add Pokemon creation and stabilize native panel…
UNDERdecoded Sep 30, 2026
9c8a534
fix(second-display): allow stable native framebuffer dimensions
UNDERdecoded Sep 30, 2026
fa15ca5
fix(save-companion): pin framebuffer to latched panel size
UNDERdecoded Sep 30, 2026
8f65070
feat(gen4): keep Platinum compositor for wide battles
UNDERdecoded Sep 30, 2026
9f4b0df
feat(gen4): widen Platinum battle field and HUD composition
UNDERdecoded Sep 30, 2026
605f7e5
fix(gen4): target wide battler positions in ball animations
UNDERdecoded Sep 30, 2026
842fb7a
fix(badges): clear both backing stores when toggling off
UNDERdecoded Sep 30, 2026
cfdae91
fix(save-companion): use canonical badge toggle
UNDERdecoded Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 252 additions & 39 deletions mobile/second-display/SecondDisplayHost.java

Large diffs are not rendered by default.

43 changes: 38 additions & 5 deletions src/battle/BattleState.lua
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ function BattleState:isWideBattleLayout()
-- Emerald's own healthboxes on the surface's own edges and Emerald's own
-- bottom strip spanning it. This function is the Game Boy layout's
-- question and keeps the Game Boy layout's answer.
if GameVersion.isGen3() then return false end
if GameVersion.isGen3() or GameVersion.isGen4() then return false end
local options = self.game and self.game.save and self.game.save.options
return options and options.battleLayout == "wide" or false
end
Expand Down Expand Up @@ -242,7 +242,35 @@ function BattleState:gen3SurfaceWidth()
return self._gen3SurfaceW
end

-- A WIDENED EMERALD BATTLE OWNS THE SURFACE UNTIL IT LEAVES THE STACK.
-- BATTLE LAYOUT = WIDE, IN SINNOH'S OWN TERMS. Platinum keeps its DS battle
-- compositor; only its surface grows horizontally.
function BattleState:gen4WideLayout()
if not self:gen4Layout() then return false end
local options = self.game and self.game.save and self.game.save.options
return options and options.battleLayout == "wide" or false
end

function BattleState:gen4SurfaceWidth()
if not self:gen4WideLayout() then return Gen4Battle.WIDTH end
local Renderer = require("src.render.Renderer")
local pw, ph = 0, 0
if Renderer.pixelSize then
local ok, a, b = pcall(Renderer.pixelSize, Renderer)
if ok and type(a) == "number" and type(b) == "number" then pw, ph = a, b end
end
local fill = self:wantsFillScale()
local key = pw .. "x" .. ph .. (fill and "/fill" or "/fixed")
if self._gen4SurfaceKey ~= key then
self._gen4SurfaceKey = key
self._gen4SurfaceW = Gen4Battle.surfaceWidth(pw, ph, fill,
math.min(Renderer.MAX_UI_WIDTH or 512, 512))
end
return self._gen4SurfaceW
end

-- A WIDENED EMERALD OR SINNOH BATTLE OWNS THE SURFACE UNTIL IT LEAVES
-- the stack, so menus pushed over it cannot collapse the framebuffer.

--
-- Same rule, and the same reason, as the Game Boy wide layout's
-- (Game.wideBattleInStack): the party menu, the bag and the dialogue boxes a
Expand All @@ -253,6 +281,9 @@ end
-- redrawing its wider composition into a surface 240 wide, clipped at the
-- right edge, and snapping back out again when the menu closed.
function BattleState:holdsUISurface()
if self:gen4Layout() then
return self:gen4SurfaceWidth() > Gen4Battle.WIDTH
end
return self:gen3SurfaceWidth() > Gen3Battle.WIDTH
end

Expand Down Expand Up @@ -471,7 +502,7 @@ function BattleState:uiSize()
-- 512x256 with the visible half 256x192, and the platform and battler
-- positions are absolute pixels on that.
if self:gen4Layout() then
return Gen4Battle.WIDTH, Gen4Battle.HEIGHT
return self:gen4SurfaceWidth(), Gen4Battle.HEIGHT
end
return 160, 144
end
Expand Down Expand Up @@ -6614,7 +6645,8 @@ function BattleState:gen4SendOut(battler)
-- Already throwing something: a second ball in the air is worse than none.
if self.gen4Ball then return false end

local pos = Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[0]
local pos = Gen4Battle.battlerPos and Gen4Battle.battlerPos(self, 0)
or (Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[0])
if not pos then return false end

-- WHICH BALL THIS POKEMON LIVES IN. `MON_DATA_POKEBALL` is an item id and
Expand Down Expand Up @@ -9139,7 +9171,8 @@ function BattleState:gen4BallChain(caught, shakes, ball)

-- WHERE IT IS THROWN TO: the foe's own slot, from the cartridge's position
-- table, rather than a number typed here.
local pos = Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[1]
local pos = Gen4Battle.battlerPos and Gen4Battle.battlerPos(self, 1)
or (Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[1])
local anim = Gen4BallAnim.new({
ball = name,
to = pos and { x = pos.x, y = pos.y } or nil,
Expand Down
86 changes: 72 additions & 14 deletions src/battle/Gen4Battle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,56 @@ local Gen4Battle = {}
-- Platinum fights on the DS's own screen, not the Game Boy's 160x144.
Gen4Battle.WIDTH, Gen4Battle.HEIGHT = 256, 192

-- Widescreen keeps Platinum's 192-pixel-tall composition and only grows the
-- horizontal surface. The extractor's battle backdrop is already 512 pixels
-- wide, so this reveals real cartridge art instead of stretching 256 pixels.
function Gen4Battle.surfaceWidth(pw, ph, fill, maxWidth)
local least = Gen4Battle.WIDTH
local most = math.floor(tonumber(maxWidth) or 512)
if most < least then most = least end
pw, ph = tonumber(pw), tonumber(ph)
if not (pw and ph and pw > 0 and ph > 0) then return least end
local want
if fill then
want = math.floor(Gen4Battle.HEIGHT * pw / ph / 8 + 0.5) * 8
else
local s = math.max(1, math.floor(ph / Gen4Battle.HEIGHT))
want = math.floor(pw / s / 8) * 8
end
return math.max(least, math.min(most, want))
end

function Gen4Battle.width(battle)
local fn = battle and battle.gen4SurfaceWidth
if type(fn) ~= "function" then return Gen4Battle.WIDTH end
local ok, w = pcall(fn, battle)
if not (ok and type(w) == "number" and w >= Gen4Battle.WIDTH) then
return Gen4Battle.WIDTH
end
return math.floor(w / 8) * 8
end

function Gen4Battle.extra(battle)
return Gen4Battle.width(battle) - Gen4Battle.WIDTH
end

-- Player-side field objects stay attached to the left half; enemy-side ones
-- stay attached to the right. At 256px this returns the cartridge coordinates
-- byte-for-byte.
function Gen4Battle.battlerPos(battle, slot)
local p = Gen4Battle.BATTLER_POS[slot]
if not p then return nil end
local shift = (slot == 1 or slot == 3 or slot == 5) and Gen4Battle.extra(battle) or 0
return { x = p.x + shift, y = p.y }
end

function Gen4Battle.healthboxPos(battle, slot)
local p = Gen4Battle.HEALTHBOX_POS[slot]
if not p then return nil end
local shift = (slot == 0 or slot == 2 or slot == 4) and Gen4Battle.extra(battle) or 0
return { x = p.x + shift, y = p.y }
end

-- ---------------------------------------------------------------------------
-- The two enumerations, 0-based, from generated/battle_backgrounds.txt and
-- generated/battle_terrains.txt. They are NOT the same list and do not line up
Expand Down Expand Up @@ -531,7 +581,7 @@ function Gen4Battle.drawField(battle)
local ox, oy = -(sx % gw), -(sy % gh)
for _, dx in ipairs({ 0, gw }) do
for _, dy in ipairs({ 0, gh }) do
if ox + dx < Gen4Battle.WIDTH and oy + dy < Gen4Battle.HEIGHT then
if ox + dx < Gen4Battle.width(battle) and oy + dy < Gen4Battle.HEIGHT then
g.draw(ground, ox + dx, oy + dy)
end
end
Expand All @@ -542,7 +592,7 @@ function Gen4Battle.drawField(battle)
-- because every piece of Gen 4 art on top of it is drawn for a dark field
-- and white would show as a bright border round each platform.
g.setColor(0, 0, 0, 1)
g.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT)
g.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT)
g.setColor(1, 1, 1, 1)
end

Expand All @@ -555,6 +605,7 @@ function Gen4Battle.drawField(battle)
if enemy then
local ex, ey = Gen4Battle.platformAt(
"enemy", Gen4Battle.platformSlideElapsed(battle, "enemy"))
ex = ex + Gen4Battle.extra(battle)
g.draw(enemy, corner(ex, ey, enemy))
end
if player then
Expand Down Expand Up @@ -672,7 +723,7 @@ function Gen4Battle.drawTrainerBack(battle)
local img = battle.picImage and battle:picImage(battle.playerBackPic)
or battle.playerBackPic
if not (img and img.getWidth) then return false end
local pos = Gen4Battle.BATTLER_POS and Gen4Battle.BATTLER_POS[0]
local pos = Gen4Battle.battlerPos(battle, 0)
if not pos then return false end
local g = love.graphics
local x, y = corner(pos.x, pos.y, img)
Expand All @@ -687,8 +738,8 @@ function Gen4Battle.drawBattlers(battle)
-- The foe first, so where the two overlap the player's Pokemon is in front --
-- which is what standing nearer the camera means.
local pairs_ = {
{ battler = battle.enemy, pos = Gen4Battle.BATTLER_POS[1] },
{ battler = battle.player, pos = Gen4Battle.BATTLER_POS[0] },
{ battler = battle.enemy, pos = Gen4Battle.battlerPos(battle, 1) },
{ battler = battle.player, pos = Gen4Battle.battlerPos(battle, 0) },
}
for _, row in ipairs(pairs_) do
local battler = row.battler
Expand All @@ -706,7 +757,7 @@ function Gen4Battle.drawBattlers(battle)
-- for its own reasons must not have it cleared out from under it by a
-- battler draw, and this runs inside whatever the caller had set.
local sx, sy, sw, sh = g.getScissor()
g.setScissor(0, 0, Gen4Battle.WIDTH, math.max(0, math.floor(clip)))
g.setScissor(0, 0, Gen4Battle.width(battle), math.max(0, math.floor(clip)))
local drew, err = pcall(battle.drawBattlerPic, battle,
battler, x, y, 1)
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
Expand Down Expand Up @@ -764,7 +815,7 @@ function Gen4Battle.drawBackgroundFade(battle)
if not (got and a and a > 0) then return false end
local gfx = love.graphics
gfx.setColor(r or 0, g or 0, b or 0, a)
gfx.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT)
gfx.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT)
gfx.setColor(1, 1, 1, 1)
return true
end
Expand Down Expand Up @@ -825,7 +876,7 @@ function Gen4Battle.drawEffectBackground(battle)
-- The cross-fade's half: the field dimmed to its coefficient.
if st.blend and (st.base or 1) < 1 then
g.setColor(0, 0, 0, 1 - st.base)
g.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT)
g.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT)
g.setColor(1, 1, 1, 1)
drew = true
end
Expand Down Expand Up @@ -857,7 +908,7 @@ function Gen4Battle.drawEffectBackground(battle)
-- the 81 is 256 or 512 wide by 256 tall.
for _, dx in ipairs({ 0, w }) do
for _, dy in ipairs({ 0, h }) do
if ox + dx < Gen4Battle.WIDTH and oy + dy < Gen4Battle.HEIGHT then
if ox + dx < Gen4Battle.width(battle) and oy + dy < Gen4Battle.HEIGHT then
g.draw(art, ox + dx, oy + dy)
end
end
Expand All @@ -870,7 +921,7 @@ function Gen4Battle.drawEffectBackground(battle)
local tint = st.tint
if tint and tint[4] and tint[4] > 0 and row and row.opaque then
g.setColor(tint[1] or 0, tint[2] or 0, tint[3] or 0, tint[4])
g.rectangle("fill", 0, 0, Gen4Battle.WIDTH, Gen4Battle.HEIGHT)
g.rectangle("fill", 0, 0, Gen4Battle.width(battle), Gen4Battle.HEIGHT)
g.setColor(1, 1, 1, 1)
end
return true
Expand Down Expand Up @@ -949,8 +1000,8 @@ end
-- resource's own stored position, which is expressed relative to the middle of
-- the field. Those two agreeing is a coincidence of this layout, not one fact.
function Gen4Battle.particleOrigin(battle, name, attackerIsPlayer)
local me = Gen4Battle.BATTLER_POS[0]
local foe = Gen4Battle.BATTLER_POS[1]
local me = Gen4Battle.battlerPos(battle, 0)
local foe = Gen4Battle.battlerPos(battle, 1)
if name == "player" then return me.x, me.y end
if name == "enemy" then return foe.x, foe.y end
local attacker = attackerIsPlayer and me or foe
Expand Down Expand Up @@ -1423,7 +1474,7 @@ function Gen4Battle.drawHealthboxes(battle)
for _, row in ipairs(sides) do
local art = Gen4Battle.HEALTHBOX_ART[row.side]
local img = art and healthboxImage(battle, art.key)
local centre = Gen4Battle.HEALTHBOX_POS[row.slot]
local centre = Gen4Battle.healthboxPos(battle, row.slot)
if img and centre and row.battler and row.battler.mon then
local x, y = centre.x + art.ox, centre.y + art.oy
g.setColor(1, 1, 1, 1)
Expand Down Expand Up @@ -1777,6 +1828,13 @@ function Gen4Battle.drawTextArea(battle)
local T, ROWS = Gen4Battle.MESSAGE_TEXT, Gen4Battle.MESSAGE_ROWS
local LINE_H, GAP = Gen4Battle.MESSAGE_LINE_H, Gen4Battle.MENU_GAP
local B = Gen4Battle.MESSAGE_BOX
if Gen4Battle.extra(battle) > 0 then
local copy = {}
for k,v in pairs(B) do copy[k] = v end
copy.tw = (copy.tw or 0) + Gen4Battle.extra(battle) / 8
copy.w = (copy.w or 0) + Gen4Battle.extra(battle)
B = copy
end
local phase = battle.phase

-- WHICH PRESENTATION, DECIDED BEFORE ANYTHING IS DRAWN -- because the answer
Expand Down Expand Up @@ -1845,7 +1903,7 @@ function Gen4Battle.drawTextArea(battle)
-- which is where every other version in the launcher puts it.
if (battle.msgWaiting or battle.msgPrompt)
and (battle.frame or 0) % 60 < 30 then
Font.drawCode(Theme.moreArrow, T.x + T.w - 10, ROWS[2] + 4)
Font.drawCode(Theme.moreArrow, T.x + T.w + Gen4Battle.extra(battle) - 10, ROWS[2] + 4)
end
end
return
Expand Down
31 changes: 31 additions & 0 deletions src/core/Game.lua
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,38 @@ Game.centerClassicZones = centerClassicZones

function Game:draw()
local closeDraw = FrameProfile.section("draw: whole frame")

-- A specialized lower-screen state (battle menu, mining, Poketch screen)
-- claims the panel by calling SecondScreen.draw during _draw(). If nothing
-- claims it, a Gen 4 overworld still needs Platinum's always-present Poketch.
self.secondScreenDrawnThisFrame = false
self:_draw()

-- Single-screen generations use the physical Android lower panel as a live
-- save companion without changing the game's own screen mode.
do
local okC, Companion = pcall(require, "src.ui.SaveCompanion")
if okC and Companion and Companion.tick then pcall(Companion.tick, self) end
end

do
local okSS, SS = pcall(require, "src.ui.SecondScreen")
if okSS and SS.mode and SS.mode(self) == "display"
and not self.secondScreenDrawnThisFrame then
local okP, Poketch = pcall(require, "src.ui.Gen4Poketch")
if okP and Poketch and Poketch.new then
if not self.secondScreenOverworldPoketch then
local okNew, p = pcall(Poketch.new, self, {})
if okNew then self.secondScreenOverworldPoketch = p end
end
local p = self.secondScreenOverworldPoketch
if p and p.drawWatch then
pcall(SS.draw, self, function() p:drawWatch() end)
end
end
end
end

closeDraw()
-- THE SECOND PANEL'S FRAME LEAVES LAST.
--
Expand Down
36 changes: 36 additions & 0 deletions src/inventory/Badges.lua
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,40 @@ function Badges.count(data, save, version)
return n
end

-- Canonical badge mutation. Reads accept either historical store, but writes
-- must not leave a truthy duplicate behind in the other one. Turning OFF
-- therefore clears both stores; turning ON writes only where this generation
-- actually stores badges.
function Badges.set(save, entry, on, version)
if not (save and entry) then return false end
local key = Badges.itemFor(entry)
if not key then return false end

save.inventory = save.inventory or {}
save.flags = save.flags or {}

if not on then
save.inventory[key] = nil
save.flags[key] = nil
return true
end

local GameVersion = require("src.core.GameVersion")
local flagBadge = GameVersion.isGen2(version) or GameVersion.isGen3(version)
if flagBadge then
save.flags[key] = true
save.inventory[key] = nil
else
save.inventory[key] = 1
save.flags[key] = nil
end
return true
end

function Badges.toggle(save, entry, version)
local on = Badges.has(save, entry)
Badges.set(save, entry, not on, version)
return not on
end

return Badges
11 changes: 10 additions & 1 deletion src/render/Renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1060,8 +1060,17 @@ function Renderer:endFrame(zones, worldZones)
local ow, oh = self.worldOverride:getDimensions()
local osx = (ow and ow > 0) and (ww / ow) or (1 / dpiX)
local osy = (oh and oh > 0) and (wh / oh) or (1 / dpiY)
-- iOS still needs the historical presentation flip for the older
-- generations. Gen 4 is the exception: its 3D worldOverride already
-- reaches this point in the correct orientation, so applying the same
-- iOS flip a second time turns Platinum upside down while its UI remains
-- upright.
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
local gen = require("src.core.GameVersion").generation()
if love.system and love.system.getOS
and love.system.getOS() == "iOS"
and loveMajor >= 12
and gen ~= 4 then
love.graphics.draw(self.worldOverride, 0, wh, 0, osx, -osy)
else
love.graphics.draw(self.worldOverride, 0, 0, 0, osx, osy)
Expand Down
Loading
Loading