diff --git a/README.md b/README.md index e7858c57..c9c12d1c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,16 @@ A native LÖVE2D recreation of Poke Red and Blue. The engine and map behavior are hand-written Lua; game data and graphics are decoded from a ROM supplied by the player. -SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu) +

+ +**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu) + +

As seen on Polygon

+ +### Watch the latest update video + +[![Watch the latest update video](https://img.youtube.com/vi/TbHdJIrKJJU/maxresdefault.jpg)](https://www.youtube.com/watch?v=TbHdJIrKJJU) + This project does not include a ROM, emulate the Game Boy, transpile assembly, or download a disassembly. A canonical US Poke Red or Blue ROM is the only @@ -185,4 +194,6 @@ request with real detail is one that can actually get built. This project would not be possible without [pret](https://github.com/pret) > the pret band of decompiling maniacs > and their -[pokered](https://github.com/pret/pokered) disassembly. \ No newline at end of file +[pokered](https://github.com/pret/pokered) disassembly. + +

diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index d636618c..131ee8cf 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,15 +38,16 @@ BattleState.letterboxWhite = true -- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on -- a 304x144 surface (src/battle/WideBattle.lua). Only the composition --- differs; every battler, queue and animation below is shared. The wide --- layout is live only while this battle is the state being drawn on top -- --- a party menu or bag pushed over it is a 160x144 screen, so the surface --- goes back with it and the battle underneath is not drawn at all. -function BattleState:wideLayout() +-- differs; every battler, queue and animation below is shared. Menus and +-- prompts pushed during a wide battle keep its wide canvas, while drawing +-- their classic 160px UI centred within it (Game:draw). +function BattleState:isWideBattleLayout() local options = self.game and self.game.save and self.game.save.options - if not options or options.battleLayout ~= "wide" then return false end - local stack = self.game.stack - return (stack and stack.top and stack:top()) == self + return options and options.battleLayout == "wide" or false +end + +function BattleState:wideLayout() + return self:isWideBattleLayout() end -- Renderer:setUISize asks the top state for its surface before anything draws diff --git a/src/core/Game.lua b/src/core/Game.lua index 22e72772..05c2427e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -244,35 +244,93 @@ end -- exactly as the owning state computed it local function sameZones(_, zones) return zones end +-- A wide battle owns the surface until it leaves the stack. The party, +-- bag, choice and text states it opens still draw their original 160px UI, +-- but the canvas must not snap to 160px between those states. +function Game.wideBattleInStack(stack) + for i = #(stack and stack.states or {}), 1, -1 do + local state = stack.states[i] + if state and state.isWideBattleLayout and state:isWideBattleLayout() then + return state + end + end + return nil +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. +local function centerClassicZones(zones, offset) + if not zones or offset == 0 then return zones end + local shifted = {} + for i, zone in ipairs(zones) do + local copy = {} + for key, value in pairs(zone) do copy[key] = value end + if copy.x == 0 and copy.w == Renderer.WIDTH then + copy.w = copy.w + offset * 2 + else + copy.x = (copy.x or 0) + offset + end + shifted[i] = copy + end + return shifted +end + function Game:draw() -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic -- white clear local base = self.stack:visibleBase() local worldBelow = self.stack.states[base] == self.overworld - -- The UI surface is resolved once, before any state draws: the top state - -- may want more than the Game Boy's 160x144 (the widescreen battle layout - -- asks for 304x144). Anything else keeps the classic surface, so a menu - -- pushed over a wide battle brings the screen straight back to 160x144. + -- A wide battle holds its 304px surface through every menu or prompt it + -- opens. States that do not draw the wide battle composition are centred + -- in that surface below, so their classic coordinates and hit testing stay + -- unchanged. Outside a battle, including the title screen, the option is + -- intentionally inactive because it is a battle-layout setting. local top = self.stack:top() - if top and top.uiSize then + local wideBattle = Game.wideBattleInStack(self.stack) + local classicOffset = 0 + if wideBattle and wideBattle.uiSize then + Renderer:setUISize(wideBattle:uiSize()) + classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2) + elseif top and top.uiSize then Renderer:setUISize(top:uiSize()) else Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT) end Renderer:beginFrame(worldBelow) - self.stack:draw() + for i = self.stack:visibleBase(), #self.stack.states do + local state = self.stack.states[i] + local wideState = state and state.isWideBattleLayout + and state:isWideBattleLayout() + if state and state.draw then + if classicOffset ~= 0 and not wideState then + love.graphics.push() + love.graphics.translate(classicOffset, 0) + state:draw() + love.graphics.pop() + else + state:draw() + end + end + end -- SGB colorization: the topmost state that knows its palette owns the -- screen (overlays like text boxes inherit from what's beneath them); -- the overworld's world pass colors each visible map area separately - local zones, worldZones + local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] if s.sgbPalettes then zones = s:sgbPalettes(self) + zoneOwner = s break end end + if classicOffset ~= 0 and zoneOwner + and not (zoneOwner.isWideBattleLayout + and zoneOwner:isWideBattleLayout()) then + zones = centerClassicZones(zones, classicOffset) + end -- 14's render.zones: weather/lighting overlays and custom colorization -- recolor or add zones before the blit if ModRuntime.wantsHook("render.zones") then diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index e908abea..b9b050df 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -116,13 +116,12 @@ local function defaultsSave() end -- Merge a GenSave.decode() result over the new-game defaults, exactly the --- way convert.lua did, then stamp the requested version. The 32768-byte --- import template GenSave stashes as `rawImport` and the decode `warnings` --- are dropped here: neither belongs in a serialized slot file (a fresh --- export always starts zero-filled -- see GenSave.lua's header). +-- way convert.lua did, then stamp the requested version. Keep the imported +-- SRAM image with the slot: Pokémon Red restores its saved current-map cache +-- before Continue, and an export needs that unmodeled data to remain bootable. +-- Decode warnings are only import diagnostics and do not belong in the slot. local function mergeDefaults(decoded, version) decoded.warnings = nil - decoded.rawImport = nil local save = defaultsSave() for k, v in pairs(decoded) do save[k] = v end save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y } diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua index bf6b0e2a..6645c7c9 100644 --- a/tests/engine/save_file_io_tests.lua +++ b/tests/engine/save_file_io_tests.lua @@ -96,7 +96,14 @@ local function syntheticSave(name) moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45, } } - return GenSave.encode(seed, data, nil) + -- The current-map view pointer is part of wMainData's map cache, not a + -- modeled save field. Pokémon Red restores that cache before Continue, + -- so it is a useful canary for the import -> slot -> export path. + local raw = GenSave.encode(seed, data, nil) + local cacheOff = OFF.mainData + 104 + local cacheTemplate = raw:sub(1, cacheOff) .. string.char(0xA5) + .. raw:sub(cacheOff + 2) + return GenSave.encode(seed, data, cacheTemplate) end -- ---------------------------------------------- importToSlot -> listSlots @@ -149,6 +156,8 @@ do eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes") check(outBytes and mainChecksumValid(outBytes), "the export carries a valid main-data checksum") + eq(outBytes and outBytes:byte(OFF.mainData + 105), 0xA5, + "the export keeps the saved current-map cache") -- the export re-imports to an equivalent save local re = SaveConvert.importSav(outBytes, "red") diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 9a349e77..5571af39 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -6,12 +6,20 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local WideBattle = require("src.battle.WideBattle") local Renderer = require("src.render.Renderer") +local Game = require("src.core.Game") T.eq(WideBattle.WIDTH, 304, "the wide layout runs on a 304px native surface") T.eq(WideBattle.HEIGHT, 144, "the wide surface keeps the native height") T.eq(WideBattle.FIELD_BOTTOM, 104, "the lower 40 rows are the message / command windows") +local wide = { isWideBattleLayout = function() return true end } +local normal = { isWideBattleLayout = function() return false end } +T.eq(Game.wideBattleInStack({ states = { normal, wide, normal } }), wide, + "a wide battle remains the surface owner under a classic overlay") +T.eq(Game.wideBattleInStack({ states = { normal } }), nil, + "a classic stack keeps the normal surface") + -- move grid: slots are laid out 1 2 / 3 4 T.eq(WideBattle.moveGridIndex(1, 4, "right"), 2, "RIGHT crosses the row") T.eq(WideBattle.moveGridIndex(2, 4, "left"), 1, "LEFT crosses the row") diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index 29c77e18..a775f0c4 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -297,9 +297,10 @@ check(scSave and scSave.lastHeal and scSave.lastHeal.map == scSave.player.map, "SaveConvert.importSav: lastHeal derives from the decoded position") check(scSave and scSave.lastOutdoor and scSave.lastOutdoor.id ~= nil, "SaveConvert.importSav: lastOutdoor is set") --- the import template + decode warnings never leak into the slot table -check(scSave and scSave.rawImport == nil and scSave.warnings == nil, - "SaveConvert.importSav: rawImport/warnings stripped from the returned table") +-- The original SRAM image carries the current-map cache that Red restores on +-- Continue, while decode warnings are only import diagnostics. +check(scSave and type(scSave.rawImport) == "string" and scSave.warnings == nil, + "SaveConvert.importSav: keeps the SRAM template but drops warnings") -- size / type validation local badSize, badSizeErr = SaveConvert.importSav("too short", 2)