From 3fabe4f591c28194d25d928a325b986ff0abe631 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:27:55 +0200 Subject: [PATCH 1/9] fix(ios): use square mobile icon in AltSource --- mobile/ios/app-repo.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index 2557086a..ac4e8828 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -1,13 +1,13 @@ { "name": "gen1recomp App Repo", "identifier": "com.theboisclub.gen1recomp.repo", - "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png", + "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png", "apps": [ { "name": "gen1recomp", "bundleIdentifier": "com.theboisclub.gen1recomp", "developerName": "bryanthaboi", - "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png", + "iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png", "localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke", "tintColor": "3b5ca8", "category": "games", From 02ad846dfa438562e5bf9efaf03e442f56b187ea Mon Sep 17 00:00:00 2001 From: spiritsnails <307422241+spiritsnails@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:44:38 -0600 Subject: [PATCH 2/9] fix: FAITHFUL RATIO works on Android and iOS apply() returned false on its first line for mobile, so the option did nothing there. A phone has no window to resize, so the lock caps the render scale instead: the largest whole multiple of 160x144 the display holds, centred, black around it. Two parts beyond that. The scale is read off the display rather than from the desktop's 1X-4X ladder, which named a different fraction of every device and left the useful levels off the list; mobile shows ON or OFF. And the world pass, which expands to cover the whole display so letterbox becomes more map, is now sized against the locked viewport, so the lock reaches the overworld instead of showing more of it. Pixel perfect throughout, whole multiples only. Desktop and OFF are unchanged. Renames the row to FAITHFUL RATIO on both platforms; the saved key stays faithfulRes so existing settings carry over. --- src/core/FaithfulRes.lua | 103 ++++++++++++++++++-- src/render/Renderer.lua | 28 +++++- src/ui/OptionsMenu.lua | 2 +- tests/engine/faithful_res.lua | 19 +++- tests/engine/faithful_res_mobile.lua | 134 +++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 tests/engine/faithful_res_mobile.lua diff --git a/src/core/FaithfulRes.lua b/src/core/FaithfulRes.lua index c7fc4946..e1535c77 100644 --- a/src/core/FaithfulRes.lua +++ b/src/core/FaithfulRes.lua @@ -8,8 +8,21 @@ -- letterbox entirely, so the surface is the Game Boy screen and nothing else. -- -- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu --- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs --- that lack love.window. +-- and on boot via Game:applyOptions. No-ops in headless stubs that lack +-- love.window. +-- +-- MOBILE takes the other route to the same place. There is no window to +-- resize -- the window IS the screen, and it rotates -- so the lock caps the +-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N +-- physical pixels per GB pixel and centres it, and the rest of the display +-- stays black. Same promise as the desktop lock (a GB pixel is exactly N +-- screen pixels, no more) reached by moving the picture rather than the +-- window. This used to return false on the first line, so the row sat in +-- OPTIONS on Android and iOS doing nothing at all. +-- +-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs +-- every frame off the live drawable size, so portrait and landscape both get +-- the same locked scale with the bars falling wherever the screen is longer. local FaithfulRes = {} @@ -17,6 +30,10 @@ FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144 FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 } FaithfulRes.DEFAULT = 0 +-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF. +-- Renderer:fitScale reads it through FaithfulRes.scaleCap. +FaithfulRes.mobileScale = 0 + -- conf.lua's floor for the resizable desktop window, restored when the lock -- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum -- as well as set the size or LOVE clamps the window back up. @@ -25,21 +42,52 @@ FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360 -- whether this module currently owns the window size FaithfulRes.locked = false +-- The highest level this display can actually show. +-- +-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the +-- feature shipped with. On mobile there is no window to size, so a fixed +-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the +-- levels the panel could really use are not on the list at all. Derive it +-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top +-- of the ladder is the biggest exact-pixel picture it can draw. +-- +-- OFF (0) is untouched by any of this and keeps doing exactly what it always +-- did: the renderer fits and letterboxes as usual. +function FaithfulRes.maxLevel() + -- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea -- + -- there it names a window size you can see. On a phone the same number + -- means a different fraction of every device, and every level below the top + -- is just a smaller picture for no reason. ON means one thing instead: + -- lock the viewport to the Game Boy's 10:9 and size it to this screen. + if FaithfulRes.isMobile() then return 1 end + return 4 +end + +-- the selectable ladder for this display: OFF, then 1X..maxLevel +function FaithfulRes.levels() + local out = { 0 } + for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end + return out +end + function FaithfulRes.normalize(v) v = math.floor(tonumber(v) or FaithfulRes.DEFAULT) if v < 0 then return 0 end - if v > 4 then return 4 end + local max = FaithfulRes.maxLevel() + if v > max then return max end return v end function FaithfulRes.label(v) v = FaithfulRes.normalize(v) if v == 0 then return "OFF" end + -- mobile has one ON: the level is chosen from the display, not the player + if FaithfulRes.isMobile() then return "ON" end return tostring(v) .. "X" end function FaithfulRes.cycle(v, dir) - local levels = FaithfulRes.LEVELS + local levels = FaithfulRes.levels() local cur = 1 for i, level in ipairs(levels) do if level == FaithfulRes.normalize(v) then cur = i break end @@ -48,6 +96,12 @@ function FaithfulRes.cycle(v, dir) end function FaithfulRes.isMobile() + -- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the + -- scale lock can be seen and driven without a device. The window is still + -- resizable, which is the point -- drag it to a phone aspect, rotate it by + -- dragging the other way, and the lock has to hold through both. Only this + -- module reads isMobile, so the override cannot leak into anything else. + if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end if not love or not love.system or not love.system.getOS then return false end local osName = love.system.getOS() return osName == "Android" or osName == "iOS" @@ -86,13 +140,50 @@ end -- Push the lock into the live window. Returns true when the window is -- locked afterwards. +-- The largest WHOLE multiple of the Game Boy screen this display can hold. +-- Integer, never fractional: a GB pixel has to be the same number of screen +-- pixels in both axes or it is not pixel perfect, it is resampled. +-- +-- The leftover is black bars, and on a tall phone there is a lot of it +-- vertically -- that is simply what a 10:9 screen looks like on a 9:20 +-- display, and it is what an emulator shows too. +function FaithfulRes.deviceScale() + local g = love and love.graphics + if not (g and g.getPixelDimensions) then return 1 end + local pw, ph = g.getPixelDimensions() + if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end + return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH, + ph / FaithfulRes.HEIGHT))) +end + +-- The scale the renderer must lock to, or nil for "fit the window as usual". +-- Only ever set on mobile: on desktop the window itself is the lock, so +-- fitScale already lands on N and this would be a second, redundant one. +-- +-- Always the device maximum. Anything less is a smaller picture for no gain, +-- which is how the first cut ended up showing a postage stamp on a 1080p +-- phone. +function FaithfulRes.scaleCap() + if not FaithfulRes.locked then return nil end + if not FaithfulRes.isMobile() then return nil end + return FaithfulRes.deviceScale() +end + function FaithfulRes.apply(v) - if FaithfulRes.isMobile() then return false end + v = FaithfulRes.normalize(v) + -- Mobile: lock the render scale instead of the window. The scale itself + -- comes from the display (deviceScale), not from v -- v only says whether + -- the lock is on. Nothing to restore on release: the renderer simply goes + -- back to filling the display. + if FaithfulRes.isMobile() then + FaithfulRes.locked = v > 0 + FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0 + return FaithfulRes.locked + end if not love or not love.window or not love.window.setMode or not love.window.getMode then return false end - v = FaithfulRes.normalize(v) local curW, curH, flags = love.window.getMode() flags = flags or {} diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 6eb57edf..28618cb2 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +-- leaf module (no renderer dependency), so requiring it here cannot cycle +local FaithfulRes = require("src.core.FaithfulRes") local Renderer = {} @@ -124,7 +126,16 @@ end function Renderer:fitScale() local _, _, pw, ph = displayMetrics() local w, h = self:uiSize() - return math.max(1, math.floor(math.min(pw / w, ph / h))) + local s = math.max(1, math.floor(math.min(pw / w, ph / h))) + -- FAITHFUL RATIO on mobile locks the scale here rather than by resizing the + -- window, which a phone does not have (see src/core/FaithfulRes.lua). The + -- cap is the largest WHOLE multiple the display holds, so the picture is as + -- big as exact pixels allow and the remainder is bars. Computed per frame + -- off the live drawable size, so a rotate re-derives it with nothing to + -- re-apply. + local cap = FaithfulRes.scaleCap() + if cap and cap < s then s = cap end + return s end -- Integer framebuffer pixels per GB pixel for the UI pass. @@ -224,6 +235,21 @@ end -- tilt is inactive). function Renderer:worldViewSize() local _, _, pw, ph = displayMetrics() + -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the + -- WHOLE display, so letterbox voids become more map instead of black bars. + -- That is why the lock appeared to do nothing in the overworld: it shrank + -- the UI blit while the map kept filling the screen -- and showed MORE of + -- the map, because a smaller scale fits more world pixels in. + -- + -- Size the view against the LOCKED VIEWPORT rather than the display. A + -- desktop lock gets this for free by making the window exactly 160N x 144N; + -- this is the same sum with the viewport standing in for the window, so + -- both platforms show the same map area at the same zoom. + local cap = FaithfulRes.scaleCap() + if cap then + local uiw, uih = self:uiSize() + pw, ph = uiw * cap, uih * cap + end local sp = Zoom.scale(self:fitScale()) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp) -- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral), diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 5d066ce6..0cdc7f36 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -354,7 +354,7 @@ local function buildRows(game) -- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE -- because it overrides it: holding an exact size means dropping -- fullscreen. - { id = "faithfulRes", label = Strings("FAITHFUL RES"), + { id = "faithfulRes", label = Strings("FAITHFUL RATIO"), value = function(g) return FaithfulRes.label(g.save.options.faithfulRes) end, diff --git a/tests/engine/faithful_res.lua b/tests/engine/faithful_res.lua index d98b1e26..0f17b044 100644 --- a/tests/engine/faithful_res.lua +++ b/tests/engine/faithful_res.lua @@ -1,4 +1,4 @@ --- FAITHFUL RES: lock the window to an exact 160x144 multiple so the surface +-- FAITHFUL RATIO: lock the window to an exact 160x144 multiple so the surface -- is the Game Boy screen with no letterbox at all. -- -- The interesting parts are the two things a naive setMode gets wrong: the @@ -121,11 +121,22 @@ T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes") T.eq(FaithfulRes.locked, false, "and the module no longer claims the window") --- mobile has no resizable window to lock +-- Mobile has no resizable window to lock, so it locks the RENDER scale +-- instead and never calls setMode. It used to report unlocked and do +-- nothing at all, which is why the OPTIONS row was inert on Android and iOS; +-- tests/engine/faithful_res_mobile.lua covers the scale side. calls = stubWindow(1) love.system = { getOS = function() return "Android" end } -T.eq(FaithfulRes.apply(4), false, "mobile reports unlocked") -T.eq(#calls, 0, "and never touches the window") +T.eq(FaithfulRes.apply(4), true, "mobile locks, by capping the render scale") +-- the level asked for is irrelevant on mobile: ON is ON, and the scale is +-- read off the display so the picture is as big as exact pixels allow +T.eq(FaithfulRes.scaleCap(), FaithfulRes.deviceScale(), + "and the scale comes from the display, not from the level") +T.eq(#calls, 0, "still without ever touching the window") +T.eq(FaithfulRes.apply(0), false, "OFF releases it") +T.eq(FaithfulRes.scaleCap(), nil, "and the cap goes away with it") +T.eq(#calls, 0, "the window is left alone either way") +FaithfulRes.mobileScale = 0 love.window, love.system = savedWindow, savedSystem if love.graphics then diff --git a/tests/engine/faithful_res_mobile.lua b/tests/engine/faithful_res_mobile.lua new file mode 100644 index 00000000..9457e5a5 --- /dev/null +++ b/tests/engine/faithful_res_mobile.lua @@ -0,0 +1,134 @@ +-- FAITHFUL RATIO on Android / iOS. +-- +-- On mobile the setting is ON or OFF, and ON means one thing: lock the +-- viewport to the Game Boy's 10:9 at the largest WHOLE multiple this screen +-- can hold, centred, black around it -- the way an emulator opens a Game Boy +-- game on a phone. +-- +-- Three things had to be true and none of them were: +-- +-- * it had to apply at all. FaithfulRes.apply returned false on its first +-- line for mobile, so the OPTIONS row did nothing on Android and iOS. +-- A phone has no window to resize, so the lock caps the RENDER scale. +-- +-- * it had to be sized for the device. The first cut kept the desktop's +-- absolute 1X-4X ladder, which names a window size you can see on a +-- desktop and means a different fraction of every phone: 4X was a quarter +-- of a 1080p display and 5X/6X were not on the list at all. The scale is +-- read off the display now, not chosen by the player. +-- +-- * it had to work in the OVERWORLD. The world pass deliberately expands +-- to cover the whole display so letterbox voids become more map. So the +-- lock shrank the UI blit while the map kept filling the screen -- and +-- showed MORE map, since a smaller scale fits more world pixels in. +-- +-- Pixel perfect throughout: whole multiples only. The leftover is bars, and +-- on a 9:20 phone there is a lot of it vertically. That is what a 10:9 +-- screen looks like on a tall display; stretching to reach the edges would +-- resample every pixel, which is the one thing this setting exists to refuse. +-- luajit tests/engine/faithful_res_mobile.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local FaithfulRes = require("src.core.FaithfulRes") +local Renderer = require("src.render.Renderer") +local Zoom = require("src.render.Zoom") + +local g = love.graphics +local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions +local realOS = love.system and love.system.getOS +local savedOffset = Zoom.offset +love.system = love.system or {} + +local function pose(w, h, osName) + love.system.getOS = function() return osName or "Android" end + g.getDimensions = function() return w, h end + g.getPixelDimensions = function() return w, h end +end + +Renderer.uiWidth, Renderer.uiHeight = Renderer.WIDTH, Renderer.HEIGHT +Zoom.offset = 0 + +-- ------------------------------------------------------- it applies at all + +pose(1080, 2400) -- a Pixel 7, portrait +T.eq(FaithfulRes.isMobile(), true, "the fixture reads as a phone") +T.eq(FaithfulRes.apply(1), true, "FAITHFUL RATIO applies on mobile at all now") +T.eq(FaithfulRes.locked, true, "and reports itself locked") + +-- ------------------------------------------------- one ON, sized by the device + +T.eq(FaithfulRes.maxLevel(), 1, "mobile offers ON, not a ladder of multiples") +T.eq(#FaithfulRes.levels(), 2, "so the row is exactly OFF and ON") +T.eq(FaithfulRes.label(1), "ON", "and ON is spelled ON, not 1X") +T.eq(FaithfulRes.label(0), "OFF", "with OFF unchanged") + +-- 1080/160 = 6.75 and 2400/144 = 16.6, so the largest WHOLE multiple is 6 +T.eq(FaithfulRes.deviceScale(), 6, "the scale comes off the display: 6x here") +T.eq(FaithfulRes.scaleCap(), 6, "and that is what the renderer is told to lock") +T.eq(Renderer:fitScale(), 6, "so a GB pixel is exactly 6 screen pixels") + +-- the player never picks a smaller one, which is how the first cut managed to +-- draw a postage stamp on a 1080p phone +T.eq(FaithfulRes.normalize(3), 1, "any ON value is just ON") +FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 6, "and always lands on the device maximum") + +-- --------------------------------------------------------- the overworld + +FaithfulRes.apply(0) +local unlockedW = Renderer:worldViewSize() +T.check(unlockedW > 160, "unlocked, the overworld view covers the whole display") + +FaithfulRes.apply(1) +local lockedW, lockedH = Renderer:worldViewSize() +T.eq(lockedW, 160, "locked, the overworld shows exactly a GB screen wide") +T.eq(lockedH, 144, "and exactly a GB screen tall") +T.check(lockedW < unlockedW, + "so the lock SHRINKS the map area instead of growing it") + +-- ------------------------------------------------------ pixel perfect + +-- 6 x 160 = 960 of 1080 wide. Reaching the edges would need 6.75, which +-- resamples every pixel; the bars are the honest answer. +local cap = FaithfulRes.scaleCap() +T.eq(cap, math.floor(cap), "the locked scale is a whole number, never fractional") +T.eq(160 * cap, 960, "which puts the GB screen at 960 of 1080 pixels wide") + +-- ------------------------------------------------------- rotation is free + +-- fitScale reads the live drawable size every frame, so a rotate re-derives +-- the scale with nothing to re-apply +pose(1080, 2400); FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 6, "portrait locks at 6x") +pose(2400, 1080); FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 7, "landscape re-derives to 7x (1080/144), still whole") +T.eq(Renderer:worldViewSize(), 160, "and the overworld stays locked through it") + +-- ---------------------------------------------------------- OFF is OFF + +-- Nothing about OFF changed: it is the behaviour the game already had. +pose(1080, 2400) +FaithfulRes.apply(0) +T.eq(FaithfulRes.locked, false, "OFF releases the lock") +T.eq(FaithfulRes.scaleCap(), nil, "with no cap on the renderer") +T.eq(Renderer:fitScale(), 6, "the UI fits exactly as it did before") +T.check(Renderer:worldViewSize() > 160, "and the overworld fills the screen again") + +-- ------------------------------------------------- desktop is untouched + +pose(1280, 800, "Windows") +T.eq(FaithfulRes.isMobile(), false, "the fixture reads as desktop") +T.eq(FaithfulRes.maxLevel(), 4, "desktop keeps its 1X-4X ladder") +T.eq(FaithfulRes.label(2), "2X", "and its labels") +T.eq(FaithfulRes.scaleCap(), nil, "no cap: the window itself is the lock there") +T.eq(Renderer:fitScale(), 5, "so fitScale is untouched (800/144 = 5)") + +g.getDimensions, g.getPixelDimensions = realDims, realPixelDims +if realOS then love.system.getOS = realOS end +Zoom.offset = savedOffset +FaithfulRes.locked = false +FaithfulRes.mobileScale = 0 + +T.finish("faithful ratio mobile") From 5e41c74682a27dff1c28ee5292a8c36ea47fb8f1 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 2 Aug 2026 21:20:28 +0100 Subject: [PATCH 3/9] Add tile aliases for LOBBY table blocks 45 and 49 The Celadon Diner uses three LOBBY table blocks that share tile 0x37 on their flat surfaces. Only block 29 had the 0x37->0x5a BROWN alias; blocks 45 and 49 showed raw tile 0x37 in ROOF (blue-gray), creating a blue square on the second/third tables with the Advanced Colors preset. Add alias entries for blocks 45 (cells 13/14) and 49 (cells 1/2). Fixes #689 --- src/render/PaletteFX.lua | 24 +++++++++++++++++------- tests/mod_graphics_tests.lua | 13 ++++++++++++- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 735a43aa..064b86fe 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -559,16 +559,26 @@ local TILESET_GROUP_EXCEPTIONS = { } -- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top --- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the --- tabletop and the checkerboard floor -- both raw tile $37 -- can take --- different palettes; the vanilla-derived blockset shares the one tile --- id, so the RED++ atlas path re-creates the duplicate: the alias slot --- is baked as a copy of `tile` in `group`'s colors, and the listed --- 0-based block cells draw the alias instead of the shared tile. --- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84). +-- at a duplicate tile ($5a, BROWN) so the tabletop and the checkerboard +-- floor -- both raw tile $37 -- can take different palettes; the +-- vanilla-derived blockset shares the one tile id, so the RED++ atlas +-- path re-creates the duplicate: the alias slot is baked as a copy of +-- `tile` in `group`'s colors, and the listed 0-based block cells draw +-- the alias instead of the shared tile. +-- +-- Three LOBBY blocks share tile $37 on their flat surfaces: +-- block 29: 2x2 table top at cells 5/6/9/10 +-- block 45: 2-tile strip at cells 13/14 +-- block 49: 2-tile strip at cells 1/2 +-- CELADON_DINER uses all three (#84, #85, #86); CELADON_MART_ROOF +-- uses only block 29 (#52/#53). local LOBBY_TABLE_TOP_ALIAS = { { block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true }, tile = 0x37, alias = 0x5a, group = 5 }, + { block = 45, cells = { [13] = true, [14] = true }, + tile = 0x37, alias = 0x5a, group = 5 }, + { block = 49, cells = { [1] = true, [2] = true }, + tile = 0x37, alias = 0x5a, group = 5 }, } PaletteFX.TILE_ALIASES = { CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS, diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 9e6c3017..8100a907 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -621,7 +621,9 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE, check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON, "RED++ passes zone colors through like GBC") -- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with --- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias +-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias. +-- Issue #689: blocks 45 and 49 also form tables with tile $37 on their +-- flat surfaces; CELADON_DINER uses all three. do local aliases = PaletteFX.TILE_ALIASES local roof = aliases and aliases.CELADON_MART_ROOF @@ -630,11 +632,20 @@ do "CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES") check(diner == roof, "diner reuses the same lobby table-top alias as the mart roof") + check(#diner == 3, "three LOBBY table blocks have the tile alias") local al = diner and diner[1] check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a and al.group == 5 and al.cells[5] and al.cells[6] and al.cells[9] and al.cells[10], "lobby table-top alias remaps block 29 cells 5/6/9/10") + al = diner and diner[2] + check(al and al.block == 45 and al.tile == 0x37 and al.alias == 0x5a + and al.group == 5 and al.cells[13] and al.cells[14], + "lobby table-top alias remaps block 45 cells 13/14") + al = diner and diner[3] + check(al and al.block == 49 and al.tile == 0x37 and al.alias == 0x5a + and al.group == 5 and al.cells[1] and al.cells[2], + "lobby table-top alias remaps block 49 cells 1/2") end -- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1 -- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red From d9d42ec956d44aa5042eb69b8e185b5e595fcf5e Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 2 Aug 2026 21:49:56 +0100 Subject: [PATCH 4/9] Play rival encounter music when rival leaves Oak's Lab after battle The parcel scene in Oak's Lab plays Music_MeetRival on both the rival's arrival and departure (lines 144-146 in oaks_lab.lua), but the post-battle onStep exit sequence only played the fanfare when the rival approached (fixed in #596). It was missing when the rival walks out after the battle. Add stop_music + play_music Music_MeetRival before the rival's exit walk-out in both oaks_lab.lua and oaks_lab_yellow.lua, matching the parcel scene's double-fanfare pattern from the original ROM. Fixes #683 --- data/scripts/oaks_lab.lua | 7 ++++++- data/scripts/oaks_lab_yellow.lua | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index fe2602ff..afc9fc4c 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -325,9 +325,14 @@ return { table.insert(rows, { "jump_if_false", base + 6 }) table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) + -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then + -- walk out past the player. The fanfare was dropped here (#683) -- the + -- parcel scene above already plays Music_MeetRival on both arrival and + -- departure (lines 144-146), and this exit should match (#596). + table.insert(rows, { "stop_music" }) + table.insert(rows, { "play_music", "Music_MeetRival" }) table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) - -- restore the lab theme once he's walked out, same as the Yellow port table.insert(rows, { "play_music", "Music_OaksLab" }) ow.runner:run(rows, { npc = rival }) return true diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index e3a663a1..8f4ac856 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -287,10 +287,12 @@ return { table.insert(rows, { "label", "lost_lab" }) table.insert(rows, { "set_field", "rivalStarter", 3 }) table.insert(rows, { "label", "exit" }) - -- OaksLabRivalStartsExitScript: parting shot, walk out past the - -- player, restore the lab theme + -- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then + -- walk out past the player (#683). table.insert(rows, { "wait", 20 }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) + table.insert(rows, { "stop_music" }) + table.insert(rows, { "play_music", "Music_MeetRival" }) table.insert(rows, { "move_npc_to", RIVAL, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "play_music", "Music_OaksLab" }) From fc2ca6cc260a79869e74264daab6d23f491b1a19 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 2 Aug 2026 22:11:15 +0100 Subject: [PATCH 5/9] Fix PC B-button navigation and hole-fall sound effect Issue #695: Pressing B in PC submenus (BoxMenu, PlayerPC) was exiting the entire PC session instead of returning to the main PC menu. The three main-menu items (Bill's PC, player's PC, Prof. Oak's PC) were missing keepOpen=true, so selecting one popped the main menu off the stack. Added keepOpen to all three, matching the pattern already used by BoxMenu and PlayerPC's own rows. Issue #694: Falling through boulder holes in Seafoam Islands, Victory Road, and Pokemon Mansion played no sound effect. Added Faint_Fall sfx before every hole warp -- the scripted onStep holes in seafoam.lua, story.lua, and story6.lua, plus the warp-tile-based hole detection in OverworldController takeWarp. Faint_Fall is the companion to Faint_Thud (already played when boulders fall into holes). Fixes #695 Fixes #694 --- data/scripts/seafoam.lua | 1 + data/scripts/story.lua | 1 + data/scripts/story6.lua | 1 + src/world/OverworldController.lua | 11 ++++++++++- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua index 788e32e0..4d0206cf 100644 --- a/data/scripts/seafoam.lua +++ b/data/scripts/seafoam.lua @@ -56,6 +56,7 @@ for mapId, holes in pairs(HOLE_FALLS) do M[mapId].onStep = function(game, ow, x, y) for _, h in ipairs(holes) do if x == h[1] and y == h[2] then + require("src.core.Sound").play(game.data, "Faint_Fall") ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) return true end diff --git a/data/scripts/story.lua b/data/scripts/story.lua index c7c58186..53be89c5 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -942,6 +942,7 @@ M.VICTORY_ROAD_3F = { -- fall is onStep, not a collision block. onStep = function(game, ow, x, y) if x == 23 and y == 15 then + require("src.core.Sound").play(game.data, "Faint_Fall") ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing) return true end diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua index d847c09b..e28c1230 100644 --- a/data/scripts/story6.lua +++ b/data/scripts/story6.lua @@ -118,6 +118,7 @@ local MANSION_HOLES = { M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y) for _, h in ipairs(MANSION_HOLES) do if x == h[1] and y == h[2] then + require("src.core.Sound").play(game.data, "Faint_Fall") ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) return true end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e075605a..a6bf7d50 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2580,8 +2580,13 @@ function OverworldState:openPC(onDone) -- (engine/menus/pokemon_pc.asm gates on EVENT_MET_BILL; we reach that -- when Bill hands over the SS Ticket) local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET + -- keepOpen so B in the sub-PC returns here instead of exiting the + -- PC session (#695); the sub-PC screens (BoxMenu, PlayerPC) already + -- use keepOpen for their own rows, matching the original ROM's flow + -- where the main menu stays underneath. table.insert(items, { label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"), + keepOpen = true, onSelect = function() require("src.core.Sound").play(Game.data, "Enter_PC") Screens.push(Game, "BoxMenu") @@ -2592,6 +2597,7 @@ function OverworldState:openPC(onDone) -- the player's item storage is always available table.insert(items, { label = (Game.save.player.name or "RED") .. "'s PC", + keepOpen = true, onSelect = function() Screens.push(Game, "PlayerPC") done() @@ -2602,6 +2608,7 @@ function OverworldState:openPC(onDone) if flags.EVENT_GOT_POKEDEX then table.insert(items, { label = Strings("PROF.OAK's PC"), + keepOpen = true, onSelect = function() self:openOaksPC(done) end, @@ -3774,7 +3781,9 @@ function OverworldState:takeWarp(warpDef) self:startWarpTo(destMap, x, y, facing) return elseif pad == "hole" then - -- falling through a hole: no door SFX, no walk-out step + -- falling through a hole: Faint_Fall plays while the player drops, + -- matching the boulder-hole Faint_Thud at line 3618 (#694) + require("src.core.Sound").play(Game.data, "Faint_Fall") self:startWarpTo(destMap, x, y, facing) return end From 3f88d1c49aaf50886f80e58c07bfcfe081f0c370 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 2 Aug 2026 23:10:06 +0100 Subject: [PATCH 6/9] Add 3X game speed and R2/L2 shoulder button speed hotkeys Add 3X as a speed option between 2X and 4X in GameSpeed.LEVELS (#677). Add controller hotkeys: rightshoulder (R2) cycles speed up through the level list, leftshoulder (L2) cycles speed down. Keyboard equivalent is hotkey 1 (cycles up). All hotkeys are gated during transitions, scripted cutscenes, and link play (same guards as the color hotkey at key 2). The _cycleSpeed helper wraps the save-options update with the same busy/overworld guard used by the existing color-cycle hotkey. Fixes #677 --- src/core/Game.lua | 32 ++++++++++++++++++++++++++++++++ src/core/GameSpeed.lua | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index dcc87fba..d89e5dd4 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -509,6 +509,24 @@ function Game:wheelmoved(_, dy) end end +function Game:_cycleSpeed(dir) + if not (self.save and self.save.options) then return end + local busy + local ow = self.overworld + if ow then + local top = self.stack:top() + busy = ow.transitioning + or (top == ow and ( + (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) + or (ow.scriptMoves and #ow.scriptMoves > 0) + or ow.engaging or ow.emote)) + end + if busy then return end + local GameSpeed = require("src.core.GameSpeed") + self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir) + self:writeOptions() +end + function Game:keypressed(key) if self.stack and self.stack:top() and self.stack:top().onKeyPressed then self.stack:top():onKeyPressed(key) @@ -546,6 +564,11 @@ function Game:keypressed(key) elseif key == "=" then self:zoomStep(1) return + elseif key == "1" then + -- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected); + -- R2/L2 on gamepad do the same (see gamepadpressed) + self:_cycleSpeed(1) + return elseif key == "2" then -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so @@ -626,6 +649,15 @@ function Game:gamepadpressed(joystick, button) -- a controller is being used: the touch overlay steps aside until the -- next screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() + -- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster, + -- L2/leftshoulder = slower; same as keyboard hotkey 1) + if button == "rightshoulder" then + self:_cycleSpeed(1) + return + elseif button == "leftshoulder" then + self:_cycleSpeed(-1) + return + end -- BindingsMenu's pad capture rides the same top-state routing as keys local top = self.stack and self.stack:top() if top and top.onGamepadPressed then diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 85b81b3c..0a5ddde3 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -18,7 +18,7 @@ local GameSpeed = {} -- attempt is long enough that the iteration loop, not the engine, is the -- bottleneck. Vsync caps how much a real frame can do, so past 10X the -- multiplier is increasingly a ceiling rather than a rate. -GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 } +GameSpeed.LEVELS = { 1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200 } GameSpeed.DEFAULT = 1 function GameSpeed.levelLabel(v) From 1f3aa4e11182d2d75e4f22e3f769beede50927f2 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 2 Aug 2026 23:16:10 +0100 Subject: [PATCH 7/9] Fix PP UP max PP not shown on stats/summary screen The SummaryMenu (pause menu stats screen and in-battle stats screen) displayed the base PP from the move definition as the max value, ignoring PP Up bonuses. After using a PP UP, the screen would show e.g. 6/5 instead of 6/6. Fix: calculate maxPP with the PP Up bonus (basePP + ppUps * basePP/5), matching the formula used everywhere else -- battle fight menus, ETHER restore, Pokemon Center heal, link protocol, and save editor. Fixes #641 --- src/ui/SummaryMenu.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index c7e7e361..6ff9f8c8 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -203,7 +203,8 @@ function SummaryMenu:draw() local mdef = data.moves[mv.id] Font.draw(mdef.name, 16, y) Font.draw(Strings("PP"), 88, y + 8) - Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8) + local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5) + Font.draw(("%2d/%2d"):format(mv.pp, maxPP), 112, y + 8) else Font.draw("-", 16, y) Font.draw("--", 112, y + 8) From e5f9d2903d818b884b8ebfe79487cbcd2c0ca0cd Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 2 Aug 2026 23:22:34 +0100 Subject: [PATCH 8/9] Fix revived Pokemon not receiving experience share When a Pokemon faints in battle, onFaint() clears it from the battle participant set. The revive item restored HP but never re-added the mon to self.participants, so at awardExp() time the revived mon passed the HP check but failed the participant gate -- getting no exp. Fix: re-add the revived mon to battle.participants in the revive item effect, so it's counted as a participant and receives its share of experience at battle end. Fixes #648 --- src/inventory/ItemEffects.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index d60e16a3..ad1e2165 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -324,6 +324,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) require("src.core.Sound").play(data, "Heal_HP") -- a revive takes the same .healHP -> .doneHealing route, animating up -- from the fainted mon's 0 HP (#252) + -- re-add to participants so the revived mon gets its share of exp + -- at battle end (onFaint clears the flag; revive must restore it) + if battle and battle.participants then + battle.participants[target] = true + end return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }, { healedFrom = 0 } end From ec22d57cd128f6014137b4846f7fb16f34c5a624 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 3 Aug 2026 00:07:31 +0100 Subject: [PATCH 9/9] Document GAME SPEED hotkeys in README Maintainer review on #699 asked to document the hotkeys. Added key 1 (cycle speed up) and controller R2/L2 to the README Hotkeys table, and GAME SPEED to the Options-menu note. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e008dca1..9f2e1b6f 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ supported out of the box. | Key | What it does | | --------- | ---------------------------------------------------- | | `-` / `=` | Zoom out / in (overworld; also mouse wheel) | +| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) | | `2` | Cycle COLORS | | `3` | Cycle TILT (free-roam overworld) | | `4` | Cycle ZOOM through every level (free-roam overworld) | @@ -121,8 +122,8 @@ supported out of the box. | `F10` | Open / close the mod manager | -COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu -and persist in `options.lua`. +COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the +Options menu and persist in `options.lua`. ### Low-end devices