From f0e00baf68e1ae1f8236540aa9fa93985a36a76c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:12:44 +0200 Subject: [PATCH 01/68] fix: adapt the public Gen2 naming screen contract for mods --- docs/mod-naming-compat.md | 23 +++++ src/ui/Gen2NamingScreen.lua | 68 +++++++++++++ tests/engine/gen2_mod_naming.lua | 160 +++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 docs/mod-naming-compat.md create mode 100644 src/ui/Gen2NamingScreen.lua create mode 100644 tests/engine/gen2_mod_naming.lua diff --git a/docs/mod-naming-compat.md b/docs/mod-naming-compat.md new file mode 100644 index 000000000..27ec99978 --- /dev/null +++ b/docs/mod-naming-compat.md @@ -0,0 +1,23 @@ +# Gen1Recomp Gen-2 naming screen compatibility + +API-2 mods may open `mod.ui.push(game, "Gen2NamingScreen", opts)`, the public +screen ID used by Anytime Rename 1.2.1. The adapter uses Gen2Recomped's native +keyboard and supports `type` (player, rival, mom, box, nickname), `prompt`, +`maxLength`, `initial`, `mon`, `monName` and `onDone(name)`. + +The completion callback owns closing this screen. It runs while the naming +state is still on the stack; a caller may pop it and return to its existing +parent menu. The ordinary `NamingScreen` API remains different: it pops itself +before calling `onDone`. Mapping the ID directly to that class would pop two +states when an existing Gen1Recomp mod closes its own screen. + +`text` reflects the editable name and may also be assigned. Initial values and +assignments are limited by glyph count, preserving UTF-8 and charmap tokens. +The adapter keeps the native keyboard's input, drawing and `ui.naming.grid` +hook; it does not reproduce Gen1Recomp's separate Gen-2 keyboard layout or +change native game save fields. Mods should use the native naming screen for +their target host when they do not need this compatibility contract. + +Regression coverage: `tests/engine/gen2_mod_naming.lua`. External verification +uses the unchanged Anytime Rename 1.2.1 entry point and its 55-check suite; +its native-menu flow suite additionally runs six games in two languages. diff --git a/src/ui/Gen2NamingScreen.lua b/src/ui/Gen2NamingScreen.lua new file mode 100644 index 000000000..3f003a05c --- /dev/null +++ b/src/ui/Gen2NamingScreen.lua @@ -0,0 +1,68 @@ +-- Public Gen1Recomp screen ID used by API-2 mods on a Gen-2 save. +-- Reuse this engine's native keyboard while preserving the caller-owned +-- completion contract: unlike NamingScreen, this screen never pops itself. +local NamingScreen = require("src.ui.NamingScreen") +local Font = require("src.render.Font") + +local Compat = {} +local TYPES = { + player = { prompt = "YOUR NAME?", length = 7, kind = "player" }, + rival = { prompt = "RIVAL'S NAME?", length = 7, kind = "rival" }, + mom = { prompt = "MOTHER'S NAME?", length = 7 }, + box = { prompt = "BOX NAME?", length = 8 }, + nickname = { prompt = "NICKNAME?", length = 10, kind = "mon" }, +} +local function glyphs(text, limit) + text = type(text) == "string" and text or "" + local out = {} + for _, span in ipairs(Font.split(text)) do + if #out >= limit then + break + end + out[#out + 1] = text:sub(span.from, span.to) + end + return out +end +local function accept(self) + if self.onDone then + self.onDone(table.concat(self.glyphs)) + end +end +local meta = { + __index = function(self, key) + if key == "text" then + return table.concat(self.glyphs) + end + if key == "accept" or key == "confirm" then + return accept + end + return NamingScreen[key] + end, + __newindex = function(self, key, value) + if key == "text" then + self.glyphs = glyphs(value, self.maxLen) + else + rawset(self, key, value) + end + end, +} + +function Compat.new(game, opts) + opts = opts or {} + local kind = TYPES[opts.type or "player"] or TYPES.player + local screen = NamingScreen.new(game, { + title = opts.prompt or require("src.core.Strings")(kind.prompt), + maxLen = opts.maxLength or kind.length, + kind = kind.kind, + mon = opts.mon, + onDone = opts.onDone, + }) + screen.maxLength = screen.maxLen + screen.prompt = screen.title + screen.monName = opts.monName + screen.onCancel = opts.onCancel + screen.glyphs = glyphs(opts.initial, screen.maxLen) + return setmetatable(screen, meta) +end + +return Compat diff --git a/tests/engine/gen2_mod_naming.lua b/tests/engine/gen2_mod_naming.lua new file mode 100644 index 000000000..377e0e3db --- /dev/null +++ b/tests/engine/gen2_mod_naming.lua @@ -0,0 +1,160 @@ +-- The public Gen1Recomp ID and caller-owned completion used by Anytime Rename. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Screens = require("src.ui.Screens") +local Font = require("src.render.Font") +local V = require("src.core.GameVersion") +local original = V.get() +V.set("gold") +local data = T.fixtures.fresh() +data.font.charmap[#data.font.charmap + 1] = { seq = "", code = 0xe1 } +Font.load(data) +local run = T.sdk.loadMods( + { "mods/rename_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/rename_probe/manifest.json"] = [[{ + "id":"rename_probe", "name":"Rename probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/rename_probe/main.lua"] = [[ + local mod = ... + mod.exports.open = function(game, opts) + return mod.ui.push(game, "Gen2NamingScreen", opts) + end + mod.hooks:wrap("ui.naming.grid", function(nextFn, grid, ctx) + local out = nextFn(grid, ctx) + if ctx.title == "LOCALIZED?" then + local copy = {} + for i, row in ipairs(out) do + copy[i] = {} + for j, cell in ipairs(row) do copy[i][j] = cell end + end + copy[1][1] = "Ä" + return copy + end + return out + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the API-2 screen probe loads") +local game = { data = data, save = { player = {} }, stack = { states = {} } } +function game.stack:push(s) + self.states[#self.states + 1] = s + if s.enter then + s:enter() + end +end +function game.stack:pop() + return table.remove(self.states) +end +function game.stack:top() + return self.states[#self.states] +end +local function press(screen, key) + game.input = { + wasPressed = function(_, k) + return k == key + end, + } + screen:update(1 / 60) +end +local open = run.loader.exports.rename_probe.open +for _, spec in ipairs({ + { "player", 7 }, + { "rival", 7 }, + { "mom", 7 }, + { "box", 8 }, + { "nickname", 10 }, +}) do + Screens.invalidate() + local parent = { name = "parent" } + game.stack.states = { parent } + local calls, result = 0, nil + local screen + screen = open(game, { + type = spec[1], + initial = "AB", + onDone = function(name) + calls, result = calls + 1, name + T.eq(game.stack:top(), screen, "the caller still owns the naming screen at completion") + game.stack:pop() -- exactly what the existing Gen1Recomp mod does + end, + }) + T.eq(screen.screenId, "Gen2NamingScreen", "the public screen ID resolves") + T.eq(screen.maxLength, spec[2], spec[1] .. " keeps its name limit") + T.eq(screen.text, "AB", "the initial name is editable") + press(screen, "b") + T.eq(screen.text, "A", "B deletes one initial glyph") + screen.row, screen.col = 1, 2 + press(screen, "a") + T.eq(screen.text, "AB", "native keyboard input updates the public text") + -- Native START confirms; Gen2Recomped may reuse its own keyboard layout. + press(screen, "start") + T.eq(calls, 1, "completion calls the caller once") + T.eq(result, "AB", "completion returns the edited name") + T.eq(game.stack:top(), parent, "completion preserves the parent state") +end + +Screens.invalidate() +local parent = {} +game.stack.states = { parent } +local result +local screen = open(game, { + type = "nickname", + prompt = "LOCALIZED?", + initial = "", + maxLength = 2, + monName = "FIXMON", + onDone = function(name) + result = name + end, +}) +T.eq(screen.prompt, "LOCALIZED?", "explicit localized prompt survives") +T.eq(screen.monName, "FIXMON", "the nickname header is retained") +press(screen, "a") +T.eq(screen.text, "Ä", "the public grid hook supplies a Unicode glyph") +screen.row, screen.col = 1, 2 +press(screen, "a") +T.eq(screen.text, "ÄB", "the name limit counts glyphs rather than bytes") +press(screen, "start") +T.eq(result, "ÄB", "Unicode completion keeps the full glyph") +T.eq(game.stack:top(), screen, "an observing callback does not implicitly close the screen") +press(screen, "b") +T.eq(screen.text, "Ä", "deletion keeps a multi-byte glyph whole") +screen.text = "ÄZ" +T.eq(screen.text, "Ä", "direct text assignment respects charmap glyphs and the limit") +T.eq(#screen.glyphs, 2, "the native keyboard receives two actual glyphs") + +-- The original, unqualified screen keeps its existing pop-before-callback API. +Screens.invalidate() +game.stack.states = { parent } +local ordinary = Screens.push(game, "NamingScreen", { + default = "RED", + onDone = function(name) + T.eq(game.stack:top(), parent, "ordinary NamingScreen still pops before callback") + T.eq(name, "RED", "ordinary NamingScreen retains its default-name behavior") + end, +}) +press(ordinary, "start") +T.eq(game.stack:top(), parent, "ordinary screen closes exactly once") + +-- A registry override remains authoritative for the compatibility ID. +Screens.invalidate() +local custom = { custom = true } +data.screens = data.screens or {} +data.screens.Gen2NamingScreen = { + new = function(_, opts) + T.eq(opts.initial, "OVERRIDE", "screen overrides receive original Gen2 options") + return custom + end, +} +T.eq(open(game, { initial = "OVERRIDE" }), custom, "registered screen overrides win") +T.eq(#run.errors, 0, "the native flows produce no mod errors") +run.release() +Screens.invalidate() +V.set(original) +T.finish("public Gen2 mod naming compatibility") From fb22e4a1ad1e7278559fb42ae4d290e226a78a32 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 00:54:27 +0200 Subject: [PATCH 02/68] Publish Gen2 palette row metadata for native atlas renderers --- src/render/PaletteFX.lua | 4 ++++ tests/engine/gen2_palette_queries.lua | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/engine/gen2_palette_queries.lua diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..b07099033 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -467,6 +467,10 @@ local gen2ClockTod = "DAY" -- what the clock alone says local gen2MapPalette = 0 -- PALETTE_* from the current map's header -- OverworldState:timeOfDay answers "MORNING"/"NITE"; a mod's world.tod hook may -- answer "NIGHT". Normalise to the four row names the importer writes. +-- Public ROM row order, shared with native Gen2 atlas consumers. +PaletteFX.DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } +PaletteFX.DAYTIME_ID = { MORN = 1, DAY = 2, NITE = 3, DARK = 4 } + local GEN2_TOD_ALIAS = { MORN = "MORN", MORNING = "MORN", DAY = "DAY", DAYTIME = "DAY", NITE = "NITE", NIGHT = "NITE", EVE = "NITE", EVENING = "NITE", diff --git a/tests/engine/gen2_palette_queries.lua b/tests/engine/gen2_palette_queries.lua new file mode 100644 index 000000000..3bc458519 --- /dev/null +++ b/tests/engine/gen2_palette_queries.lua @@ -0,0 +1,27 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.render.PaletteFX') +local Data=require('src.core.Data') +local original={tilesets=Data.tilesets,palettes=Data.gen2Palettes,mode=P.mode,dark=P.darkWorld()} +P.setMode('gbc');P.setDarkWorld(false) +local rows={};for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + rows[tod]={};for slot=1,8 do rows[tod][slot]={{i,slot,1},{i,slot,2},{i,slot,3},{i,slot,4}}end +end +Data.tilesets={JOHTO={palColorsByTod=rows,palMap={0,1,7}}};Data:publishGen2Palettes() +for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + T.eq(P.DAYTIMES[i],tod,'ROM row order '..tod) + T.eq(P.DAYTIME_ID[tod],i,'public daytime lookup '..tod) + -- Native external atlas call shape: preserve the effective world daytime, + -- then query the published data without changing the live native state. + local effective=tod + local daytime=P.daytimeFor({tileset='JOHTO'},12,false) + if P.DAYTIME_ID[effective]then daytime=effective end + local set,row=P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},daytime) + T.eq(set,rows[tod],'native external atlas receives actual row '..tod) + T.eq(row,tod,'resolved row name '..tod) +end +T.eq(P.DAYTIME_ID.UNKNOWN,nil,'unknown row not accepted') +T.eq(P.bgSet(Data.gen2Palettes,{tileset='MISSING'},'DAY'),nil,'missing tileset stays unavailable') +P.setMode('og');T.eq(P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},'DAY'),nil,'DMG color choice honored') +Data.tilesets,Data.gen2Palettes=original.tilesets,original.palettes;P.setMode(original.mode);P.setDarkWorld(original.dark) +T.finish('Gen2 public palette query rows') From b3ea11d27eea033276afeb7972f548ce020cfd3c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:00:38 +0100 Subject: [PATCH 03/68] Implement native GSC mail and PC core over existing saves --- docs/native-gen2-pc-mail.md | 27 ++++ src/core/Data.lua | 1 + src/core/ModCompat.lua | 3 +- src/core/SaveData.lua | 15 +- src/import/Gen2ScriptOps.lua | 8 +- src/import/RomExtractorGen2.lua | 35 ++++- src/import/RomImporter.lua | 2 +- src/inventory/Bag.lua | 6 + src/mods/Loader.lua | 1 + src/pokemon/Gen2Boxes.lua | 106 +++++++++++++ src/pokemon/Gen2Mail.lua | 224 ++++++++++++++++++++++++++++ src/script/Gen2Commands.lua | 40 ++++- src/script/Gen2ScriptVM.lua | 7 +- tests/engine/gen2_mail_bytecode.lua | 108 ++++++++++++++ tests/engine/gen2_pc_mail.lua | 119 +++++++++++++++ 15 files changed, 684 insertions(+), 18 deletions(-) create mode 100644 docs/native-gen2-pc-mail.md create mode 100644 src/pokemon/Gen2Boxes.lua create mode 100644 src/pokemon/Gen2Mail.lua create mode 100644 tests/engine/gen2_mail_bytecode.lua create mode 100644 tests/engine/gen2_pc_mail.lua diff --git a/docs/native-gen2-pc-mail.md b/docs/native-gen2-pc-mail.md new file mode 100644 index 000000000..217832c72 --- /dev/null +++ b/docs/native-gen2-pc-mail.md @@ -0,0 +1,27 @@ +# Native GSC PC/mail core + +The legacy Gen2 Mail module is absent and the Boxes alias has an incompatible +count contract. Native gift/delivery scripts are stubs; Gold/Silver mail +operands consume three bytes instead of two. + +Add native slot-indexed party letters, ten-entry mailbox operations and a +14x20 PC facade over the existing save tables. Mail follows team removal +and reordering APIs, held letters block deposit/ordinary item replacement, +and last-healthy restrictions use the actual native HP/stats. Save +validation retains detached letters from quarantined carriers. + +Raw ROM mail is decoded separately from dialogue and queued scripts. The +actual ScriptRunner gives letters and yields to the real party menu for all +five delivery outcomes. mail.read emits once per opening; mail.written +identifies compose/script. Item recognition uses imported semantic keys. +The primary pointer contracts are in pret/pokegold and pret/pokecrystal, +engine/overworld/scripting.asm (Script_givepokemail/Script_checkpokemail). + +Validation: 207 PC/mail/codec checks and 69 binary ROM/actual runner/public +SDK checks pass. The current upstream baseline also has existing suite +failures; comparative engine/SDK results are recorded in the PR. + +This standalone core contribution does not import the fork's separate UI, +capability facade, qualified runtime or packaged acceptance. Composition, +mailbox screens, SRAM conversion, stationery artwork and complete parity +are not claimed here. No private mod, ROM or generated artwork is included. diff --git a/src/core/Data.lua b/src/core/Data.lua index 26a842ce5..34a867895 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1877,6 +1877,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.pokemon.Gen2Mail").load(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..cf627ef11 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -66,7 +66,8 @@ ModCompat.ALIASES = { ["src.world.gen2.Palettes"] = "src.render.PaletteFX", -- save-side data - ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Boxes"] = "src.pokemon.Gen2Boxes", + ["src.core.gen2.Mail"] = "src.pokemon.Gen2Mail", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..066771db4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -2030,6 +2030,13 @@ local function scrubMonList(list, where, save, data, report, slots) if mon ~= nil and (type(mon) ~= "table" or not known(data.pokemon, mon.species)) then list[i] = nil + if where=="party" and GameVersion.isGen2() and type(mon)=="table" + and save.mail and save.mail.party and save.mail.party[i] + and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + -- Its letter moves to the mailbox/quarantine below; retaining a + -- second stationery on the quarantined carrier would duplicate it. + mon.item=nil + end ensureOrphaned(save) save.orphaned.mons[#save.orphaned.mons + 1] = mon report.lostMons[#report.lostMons + 1] = @@ -2160,6 +2167,10 @@ function SaveData.validate(save, data) end end scrubMaps(save, data, report) + if GameVersion.isGen2() then + local Mail=require("src.pokemon.Gen2Mail") + Mail.load(data);Mail.validate(save,report) + end local dex = save.pokedex if type(dex) == "table" then for _, key in ipairs({ "seen", "owned" }) do @@ -2228,7 +2239,8 @@ function SaveData.validate(save, data) -- an empty quarantine leaves no residue, so a vanilla save re-encodes -- byte-identically local orphaned = save.orphaned - if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 then + if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 + and #(orphaned.mail or {})==0 then save.orphaned = nil end return report @@ -2239,6 +2251,7 @@ function SaveData.emptyReport(report) -- restoreSave attaches one so a version bump alone still surfaces local diff = report.modsDiff return #report.lostMons == 0 and #report.lostItems == 0 + and #(report.lostMail or {})==0 and #(report.restoredMail or {})==0 and #report.remappedMaps == 0 and #report.restoredMons == 0 and #report.restoredItems == 0 and not report.recovered and (not diff or (#diff.added == 0 and #diff.removed == 0 and #diff.changed == 0)) diff --git a/src/import/Gen2ScriptOps.lua b/src/import/Gen2ScriptOps.lua index 7e7d90ce4..ec0ff1339 100644 --- a/src/import/Gen2ScriptOps.lua +++ b/src/import/Gen2ScriptOps.lua @@ -37,7 +37,7 @@ Gen2ScriptOps.COMMANDS = { { "takecoins", "w" }, { "checkcoins", "w" }, { "addcellnum", "b" }, { "delcellnum", "b" }, { "checkcellnum", "b" }, { "checktime", "b" }, { "checkpoke", "b" }, { "givepoke", "bbbbbD" }, { "giveegg", "bb" }, - { "givepokemail", "T" }, { "checkpokemail", "T" }, { "checkevent", "w" }, + { "givepokemail", "d" }, { "checkpokemail", "d" }, { "checkevent", "w" }, { "clearevent", "w" }, { "setevent", "w" }, { "checkflag", "w" }, { "clearflag", "w" }, { "setflag", "w" }, { "wildon", "" }, { "wildoff", "" }, { "xycompare", "d" }, { "warpmod", "bbb" }, @@ -95,7 +95,7 @@ Gen2ScriptOps.COMMANDS = { -- Crystal's ScriptCommandTable is NOT Gold's with entries appended. It -- inserts `farjumptext` at $52, which shifts all 88 commands after it down by --- one, and it changes the operand width of seven commands that kept their +-- one, and it changes the operand width of several commands that kept their -- names. Disassembling a Crystal ROM with the Gold table desyncs on the -- first shifted command and never recovers, so Crystal gets its own table. -- @@ -103,11 +103,9 @@ Gen2ScriptOps.COMMANDS = { -- _command` run gives the order, and each MACRO body gives the operand -- widths. Where a command kept both its name and its total width, Gold's -- spec letters are reused verbatim so the semantic distinctions (p vs t vs d) --- carry over; the seven that changed width are listed below. +-- carry over; the width differences are listed below. -- -- $10 memcallasm dba -> dw (D -> d) --- $2F givepokemail dba -> dw (T -> d) --- $30 checkpokemail dba -> dw (T -> d) -- $92 reloadend 0 args -> 1 ("" -> b) -- $98 phonecall dba -> dw (T -> w) -- $A0 swarm +map_id (bb -> bbb) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..38b7581f4 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -7705,6 +7705,35 @@ function RomExtractorGen2:gen2StoneTable(bank, address, pool) return ok and rows and #rows > 0 and rows or nil end +-- Mail operands are raw charmap records, not dialogue streams or scripts. +-- Both retail mail commands use a word in the current script bank. +function RomExtractorGen2:gen2MailOperand(bank,address,gift) + if not self:gen2InRom(bank,address) then return nil end + bank=self:gen2Home(bank,address) + self._gen2MailCharmap=self._gen2MailCharmap or self:readSourceTable("charmap") + local limit=bank==0 and 0x4000 or 0x8000 + local ok,result=pcall(function() + local item + if gift then + item=string.format("ITEM_%03d",self.rom:byte(bank,address));address=address+1 + end + local out={} + for offset=0,31 do + if address+offset>=limit then return nil end + local byte=self.rom:byte(bank,address+offset) + if byte==0x50 then break end + if byte==0x4E or byte==0x4F then out[#out+1]="\n" + else + local ch=self._gen2MailCharmap[tostring(byte)] + if type(ch)~="string" or ch:sub(1,1)=="<" then return nil end + out[#out+1]=ch + end + end + return {item=item,message=table.concat(out)} + end) + return ok and result or nil +end + function RomExtractorGen2:gen2DecodeScript(bank, address, label, pool) -- Crystal inserts farjumptext at $52 and shifts 88 commands after it, so -- the table has to be chosen per version -- decoding Crystal with Gold's @@ -7754,7 +7783,11 @@ function RomExtractorGen2:gen2DecodeScript(bank, address, label, pool) for i = 1, #spec do local kind = spec:sub(i, i) local value - if kind == "b" then + if name=="givepokemail" or name=="checkpokemail" then + local from,at=bank,self.rom:word(bank,pc) + if kind=="T" then from,at=self.rom:byte(bank,pc),self.rom:word(bank,pc+1) end + value=self:gen2MailOperand(from,at,name=="givepokemail") or false + elseif kind == "b" then value = self.rom:byte(bank, pc) elseif kind == "w" or kind == "d" then value = self.rom:word(bank, pc) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..05bf4929d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1306,7 +1306,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-field-encounters-v8:gen2-mail-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index b09bf37e8..13d60a7e2 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -272,6 +272,9 @@ local function heldItemForm(mon, data) end function Bag.giveHeld(save, mon, id, data) + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + return nil,"mail" + end local previous = mon.item mon.item = id Bag.remove(save, id, 1) @@ -283,6 +286,9 @@ end -- TakeItem: nil when the mon holds nothing, or nil + "full" when the pack has -- no room for it (the mon keeps holding it). function Bag.takeHeld(save, mon, data) + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + return nil,"mail" + end local id = mon.item if not id then return nil end if not Bag.add(save, id, 1, data) then return nil, "full" end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..847feeeff 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1316,6 +1316,7 @@ function Loader:load(data) -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require("src.pokemon.Gen2Mail").load(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do diff --git a/src/pokemon/Gen2Boxes.lua b/src/pokemon/Gen2Boxes.lua new file mode 100644 index 000000000..a7272c589 --- /dev/null +++ b/src/pokemon/Gen2Boxes.lua @@ -0,0 +1,106 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Party/storage operations over the native save. The legacy Gen2 count() +-- counts residents, unlike the shared storage module's box-count query. +local Storage=require("src.pokemon.Boxes") +local Mail=require("src.pokemon.Gen2Mail") +local Party=require("src.pokemon.Party") +local Strings=require("src.core.Strings") +local B={PARTY_SIZE=6} +local function valid(index,max) return type(index)=="number" and index>=1 and index<=max and index%1==0 end +function B.defaultName(n) return "BOX"..tostring(n) end +function B.name(save,n) return save and save.boxNames and save.boxNames[n] or B.defaultName(n) end +function B.rename(save,n,name) + if not save or not valid(n,Storage.count()) or type(name)~="string" then return false end + save.boxNames=save.boxNames or {};save.boxNames[n]=name;return true +end +function B.box(save,n) + if not save then return {} end + n=n or save.currentBox or 1 + if not valid(n,Storage.count()) then return {} end + return Storage.ensure(save)[n] +end +function B.count(save,n) return Storage.used(B.box(save,n)) end +function B.isFull(save,n) return B.count(save,n)>=Storage.capacity() end +function B.setCurrent(save,n) + if not save or not valid(n,Storage.count()) then return false end + Storage.ensure(save);save.currentBox=n;return true +end +function B.healthyCount(party) + local n=0;for _,mon in ipairs(party or {}) do if (mon.hp or 0)>0 and not Party.isEgg(mon) then n=n+1 end end + return n +end +function B.canUsePc(save) + if not save or #(save.party or {})==0 then return false,Strings("You'll need a\nPOKéMON to call\fwith.") end + return true +end +function B.canDeposit(save,n,box) + local mon=save and save.party and save.party[n] + if not mon then return false,"There is no POKéMON there." end + box=box or save.currentBox or 1 + if not valid(box,Storage.count()) then return false,"Invalid BOX." end + if B.isFull(save,box) then return false,"The BOX is full." end + if (mon.hp or 0)>0 and not Party.isEgg(mon) and B.healthyCount(save.party)<=1 then return false,Strings("You can't deposit\nthe last POKéMON!") end + if Mail.monHoldsMail(mon) then return false,"Remove MAIL." end + return true +end +function B.restorePP(mon,data) + require("src.pokemon.Pokemon").restorePP(mon,data and data.moves) +end +local function recover(mon,data) + if not mon.stats and data and data.pokemon then require("src.pokemon.Stats").ensure(data.pokemon[mon.species],mon) end + mon.status,mon.statusTurns=nil,nil + mon.hp=Party.isEgg(mon) and 0 or (mon.stats and mon.stats.hp or mon.maxHp or mon.hp) +end +function B.enterBox(mon,data) + if mon then B.restorePP(mon,data);recover(mon,data) end + return mon +end +local function remove(box,n) + local mon=box[n];box[n]=nil + -- Shift the occupied suffix without trusting Lua's length on sparse imports. + local nextSlot=n+1 + while nextSlot<=Storage.capacity() do + if box[nextSlot]~=nil then box[n]=box[nextSlot];box[nextSlot]=nil;n=n+1 end + nextSlot=nextSlot+1 + end + return mon +end +function B.deposit(save,n,box,data) + local ok,why=B.canDeposit(save,n,box);if not ok then return false,why end + local target=B.box(save,box);local to=Storage.firstFree(target) + local mon=table.remove(save.party,n);Mail.removeSlot(save,n) + target[to]=mon;B.enterBox(mon,data);return true,mon +end +function B.canWithdraw(save,box,n) + if not save or not valid(n,Storage.capacity()) or not B.box(save,box)[n] then return false,"There is no POKéMON there." end + if #(save.party or {})>=6 then return false,Strings("You can't take\nany more POKéMON.") end + return true +end +function B.withdraw(save,box,n,data) + local ok,why=B.canWithdraw(save,box,n);if not ok then return false,why end + local mon=remove(B.box(save,box),n);recover(mon,data) + save.party=save.party or {};save.party[#save.party+1]=mon;return true,mon +end +function B.release(save,box,n) + local target=B.box(save,box);if not valid(n,Storage.capacity()) or not target[n] then return false,"There is no POKéMON there." end + if Party.isEgg(target[n]) then return false,"You can't release an EGG!" end + return true,remove(target,n) +end +function B.releaseFromParty(save,n) + local mon=save and save.party and save.party[n] + if not mon then return false,"There is no POKéMON there." end + if Party.isEgg(mon) then return false,"You can't release an EGG!" end + local removed=table.remove(save.party,n);Mail.removeSlot(save,n);return true,removed +end +function B.move(save,from,n,to) + if from==to then return false,"It's already there." end + if not valid(to,Storage.count()) then return false,"Invalid BOX." end + local source=B.box(save,from);if not source[n] then return false,"There is no POKéMON there." end + if B.isFull(save,to) then return false,"The BOX is full." end + local target=B.box(save,to);local mon=remove(source,n) + target[Storage.firstFree(target)]=mon;return true,mon +end +return setmetatable(B,{__index=function(_,key) + if key=="NUM_BOXES" then return Storage.count() end + if key=="MONS_PER_BOX" then return Storage.capacity() end +end}) diff --git a/src/pokemon/Gen2Mail.lua b/src/pokemon/Gen2Mail.lua new file mode 100644 index 000000000..e85eed17a --- /dev/null +++ b/src/pokemon/Gen2Mail.lua @@ -0,0 +1,224 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC letters belong to party slots; the PC mailbox holds detached letters. +-- Native item IDs are resolved by the imported semantic key, never a label. +local Runtime = require("src.mods.Runtime") +local Mail = { + MAIL_MSG_LENGTH=32, MAIL_LINE_LENGTH=16, MAILBOX_CAPACITY=10, + AUTHOR_LENGTH=10, PARTY_LENGTH=6, + POKEMAIL_WRONG_MAIL=0, POKEMAIL_CORRECT=1, POKEMAIL_REFUSED=2, + POKEMAIL_NO_MAIL=3, POKEMAIL_LAST_MON=4, + ITEMS={"FLOWER_MAIL","SURF_MAIL","LITEBLUEMAIL","PORTRAITMAIL","LOVELY_MAIL", + "EON_MAIL","MORPH_MAIL","BLUESKY_MAIL","MUSIC_MAIL","MIRAGE_MAIL"}, + INDEX={}, +} +local named, native, lastRead = {}, {}, nil +for i,id in ipairs(Mail.ITEMS) do named[id]=true;Mail.INDEX[id]=i-1 end +local function slot(n, max) + return type(n)=="number" and n==math.floor(n) and n>=1 and n<=max +end +function Mail.load(data) + for id in pairs(native) do Mail.INDEX[id]=nil end + native={};lastRead=nil + for i,id in ipairs(Mail.ITEMS) do Mail.INDEX[id]=i-1 end + for id,def in pairs(data and data.items or {}) do + if type(def)=="table" and named[def.key] then + native[id]=def.key;Mail.INDEX[id]=Mail.INDEX[def.key] + end + end +end +function Mail.isMail(id) return named[id]==true or native[id]~=nil end +function Mail.monHoldsMail(mon) return type(mon)=="table" and Mail.isMail(mon.item) end +function Mail.anyMonHoldingMail(save) + for _,mon in ipairs(save and save.party or {}) do if Mail.monHoldsMail(mon) then return true end end + return false +end +function Mail.state(save) + if type(save)~="table" then return {party={},box={}} end + if type(save.mail)~="table" then save.mail={party={},box={}} end + if type(save.mail.party)~="table" then save.mail.party={} end + if type(save.mail.box)~="table" then save.mail.box={} end + return save.mail +end +function Mail.characters(text) + local out={} + for ch in tostring(text or ""):gmatch("[%z\1-\127\194-\244][\128-\191]*") do out[#out+1]=ch end + return out +end +local function limit(text,n) + local chars=Mail.characters(text);return table.concat(chars,"",1,math.min(n,#chars)) +end +function Mail.trim(text) return limit(text,Mail.MAIL_MSG_LENGTH) end +function Mail.entry(item,message,author,authorId,species) + return {type=item,message=message or "",author=author or "",authorId=authorId or 0,species=species} +end +function Mail.armRead() lastRead=nil end +function Mail.get(save,n) Mail.armRead();return Mail.state(save).party[n] end +function Mail.set(save,n,entry) + if not slot(n,Mail.PARTY_LENGTH) then return false end + Mail.state(save).party[n]=entry;return true +end +function Mail.clear(save,n) if slot(n,Mail.PARTY_LENGTH) then Mail.state(save).party[n]=nil end end +function Mail.removeSlot(save,n) + if not slot(n,Mail.PARTY_LENGTH) or not (save and save.mail) then return end + local letters=Mail.state(save).party + for i=n,Mail.PARTY_LENGTH-1 do letters[i]=letters[i+1] end + letters[Mail.PARTY_LENGTH]=nil +end +function Mail.swapSlots(save,a,b) + if not slot(a,6) or not slot(b,6) or not (save and save.mail) then return end + local letters=Mail.state(save).party;letters[a],letters[b]=letters[b],letters[a] +end +local function written(save,n,mon,item,text,author,id,source) + if not save or not slot(n,6) or not Mail.isMail(item) then return false end + local entry=Mail.entry(item,Mail.trim(text),limit(author,10),id,mon and mon.species) + Mail.set(save,n,entry) + if Runtime.wants("mail.written") then + Runtime.emit("mail.written",{entry=entry,slot=n,mon=mon,message=entry.message,author=entry.author,source=source}) + end + return true +end +function Mail.compose(save,n,text,mon,item) + local player=save and save.player or {} + return written(save,n,mon,item,text,player.name or "",player.id or 0,"compose") +end +function Mail.give(save,item,text) + local party=save and save.party or {};local n=#party;local mon=party[n] + if not mon or not slot(n,6) or not Mail.isMail(item) then return false end + mon.item=item + return written(save,n,mon,item,text,mon.otName or mon.ot or "",mon.otId or 0,"script") +end +function Mail.lines(entry) + local text=type(entry)=="table" and tostring(entry.message or "") or "" + local top,bottom=text:match("^(.-)\n(.*)$") + if not top then + local chars=Mail.characters(text);top=table.concat(chars,"",1,math.min(16,#chars)) + bottom=#chars>16 and table.concat(chars,"",17) or "" + end + if type(entry)=="table" and Runtime.wants("mail.read") and lastRead~=entry then + lastRead=entry;Runtime.emit("mail.read",{entry=entry,message=entry.message,author=entry.author,top=top,bottom=bottom}) + end + return top,bottom +end +function Mail.mailbox(save) Mail.armRead();return Mail.state(save).box end +function Mail.mailboxCount(save) return #Mail.state(save).box end +function Mail.mailboxFull(save) return Mail.mailboxCount(save)>=10 end +function Mail.sendToPc(save,n) + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) or Mail.mailboxFull(save) then return false end + local state=Mail.state(save) + state.box[#state.box+1]=state.party[n] or Mail.entry(mon.item,"","",0,mon.species) + state.party[n]=nil;mon.item=nil;return true +end +function Mail.deleteFromPc(save,n) + local box=Mail.state(save).box + if not slot(n,#box) then return nil end + return table.remove(box,n) +end +function Mail.moveFromPcToParty(save,n,to) + local mon=save and save.party and save.party[to];local box=Mail.state(save).box + local letter=slot(n,#box) and box[n] + if not letter or not slot(to,6) or not mon or mon.item or require("src.pokemon.Party").isEgg(mon) then return false end + if not Mail.isMail(letter.type) then return false end + Mail.set(save,to,letter);mon.item=letter.type;table.remove(box,n);return true +end +function Mail.detachToBag(save,n,data) + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) then return false,"no_mail" end + if not require("src.inventory.Bag").add(save,mon.item,1,data) then return false,"full" end + mon.item=nil;Mail.clear(save,n);return true +end +function Mail.attachFromBag(save,n,item,text,data) + local mon=save and save.party and save.party[n] + if not mon or not slot(n,6) or require("src.pokemon.Party").isEgg(mon) then return false,"target" end + if Mail.monHoldsMail(mon) then return false,"mail" end + if not Mail.isMail(item) or not save.inventory or (save.inventory[item] or 0)<1 then return false,"item" end + local Bag=require("src.inventory.Bag") + -- Bag rules are pure store operations. Check the complete exchange before + -- changing either held state or the live acquisition-ordered inventory. + local shadow={inventory={}} + for id,count in pairs(save.inventory)do shadow.inventory[id]=count end + if save.bagOrder then shadow.bagOrder={};for i,id in ipairs(save.bagOrder)do shadow.bagOrder[i]=id end end + Bag.remove(shadow,item,1) + if mon.item and not Bag.add(shadow,mon.item,1,data) then return false,"full" end + for id in pairs(save.inventory)do save.inventory[id]=nil end + for id,count in pairs(shadow.inventory)do save.inventory[id]=count end + if save.bagOrder then + for i=#save.bagOrder,1,-1 do save.bagOrder[i]=nil end + for i,id in ipairs(shadow.bagOrder or {})do save.bagOrder[i]=id end + else save.bagOrder=shadow.bagOrder end + mon.item=item;Mail.compose(save,n,text,mon,item);return true +end +-- A carrier absent from the loaded dataset cannot keep a live party slot. +-- Retain its detached letter in the existing quarantine until mailbox room +-- is available, rather than attaching it to a later occupant of that slot. +function Mail.reclaimQuarantine(save,report) + local pending=save and save.orphaned and save.orphaned.mail + if type(pending)~="table" or #pending==0 then return end + local box=Mail.state(save).box;local i=1 + while i<=#pending and #box<10 do + local letter=pending[i] + if type(letter)=="table" and Mail.isMail(letter.type) then + box[#box+1]=table.remove(pending,i) + if report then report.restoredMail=report.restoredMail or {};report.restoredMail[#report.restoredMail+1]={author=letter.author} end + else i=i+1 end + end +end +function Mail.checkPokeMail(save,n,expected) + if n==nil then return Mail.POKEMAIL_REFUSED end + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) then return Mail.POKEMAIL_NO_MAIL end + local entry=Mail.get(save,n);local text=entry and entry.message or "" + if type(expected)~="string" or expected=="" or text:sub(1,#expected)~=expected then return Mail.POKEMAIL_WRONG_MAIL end + local other=false + for i,member in ipairs(save.party) do + if i~=n and (member.hp or 0)>0 and not require("src.pokemon.Party").isEgg(member) then other=true end + end + if (mon.hp or 0)>0 and not other then return Mail.POKEMAIL_LAST_MON end + table.remove(save.party,n);Mail.removeSlot(save,n);return Mail.POKEMAIL_CORRECT +end +function Mail.validate(save,report) + local lost={};if report then report.lostMail=lost end + if not save then return lost end + if save.mail==nil then Mail.reclaimQuarantine(save,report);return lost end + local raw=save.mail + local function reject(where,n,why,entry) + lost[#lost+1]={where=where,slot=n,why=why,entry=entry} + end + if type(raw)~="table" then reject("mail",nil,"not a table",raw);save.mail=nil;return lost end + local function clean(entry,where,n) + if type(entry)~="table" or not Mail.isMail(entry.type) then reject(where,n,"invalid letter",entry);return end + local out={};for k,v in pairs(entry) do out[k]=v end + out.message=Mail.trim(entry.message);out.author=limit(entry.author,10) + local id=tonumber(entry.authorId) or 0 + out.authorId=slot(id+1,65536) and id or 0 + if out.message~=entry.message or out.author~=entry.author or out.authorId~=entry.authorId then reject(where,n,"letter repaired",entry) end + return out + end + local party,box={},{} + if type(raw.party)=="table" then + for key,entry in pairs(raw.party) do + local n=tonumber(key) + if not slot(n,6) or not (save.party and save.party[n]) then + local letter=clean(entry,"party",key) + reject("party",key,"carrier unavailable",entry) + if letter then + save.orphaned=save.orphaned or {mons={},items={}} + save.orphaned.mail=save.orphaned.mail or {} + save.orphaned.mail[#save.orphaned.mail+1]=letter + end + else party[n]=clean(entry,"party",n) end + end + elseif raw.party~=nil then reject("party",nil,"not a table",raw.party) end + if type(raw.box)=="table" then + local keys={};for n in pairs(raw.box) do + if type(n)=="number" and n>=1 and n%1==0 then keys[#keys+1]=n else reject("box",n,"invalid slot",raw.box[n]) end + end + table.sort(keys) + for _,n in ipairs(keys) do + if #box>=10 then reject("box",n,"MAILBOX full",raw.box[n]) + else local entry=clean(raw.box[n],"box",n);if entry then box[#box+1]=entry end end + end + elseif raw.box~=nil then reject("box",nil,"not a table",raw.box) end + raw.party,raw.box=party,box;Mail.reclaimQuarantine(save,report);return lost +end +return Mail diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..3a34e80ab 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -23,6 +23,30 @@ local Strings = require("src.core.Strings") local Gen2Commands = {} +function Commands.g2_give_poke_mail(ctx,letter) + if type(letter)~="table" or type(letter.message)~="string" then + Logger.warn("givepokemail: unresolved native letter; import this cartridge again") + return + end + require("src.pokemon.Gen2Mail").give(ctx.save,letter.item,letter.message) +end + +function Commands.g2_check_poke_mail(ctx,letter) + local Mail=require("src.pokemon.Gen2Mail") + local picked + require("src.ui.Screens").push(ctx.game,"PartyMenu",{ + pickOnly=true, + onSwitch=function(mon) picked=mon;ctx.runner:resume() end, + onCancel=function() ctx.runner:resume() end, + }) + ctx.runner:yield() + local slot + for i,mon in ipairs(ctx.save.party or {}) do if mon==picked then slot=i;break end end + local expected=type(letter)=="table" and letter.message or nil + local result=Mail.checkPokeMail(ctx.save,slot,expected) + ctx.g2Var,ctx.lastCheck=result,result~=0 +end + local function scriptVar(ctx) return ctx.g2Var or 0 end @@ -3138,7 +3162,9 @@ function Commands.g2_return_nobus_aggron(ctx) end if not othersHealthy then return result(3) end for i, mon in ipairs(ctx.save.party) do - if mon == picked then table.remove(ctx.save.party, i) break end + if mon == picked then + table.remove(ctx.save.party,i);require("src.pokemon.Gen2Mail").removeSlot(ctx.save,i);break + end end result(2) end @@ -3335,6 +3361,7 @@ function Commands.g2_delete_party_mon(ctx) local _, index = orphanChoice(ctx) if not index then return end table.remove(ctx.save.party, index) + require("src.pokemon.Gen2Mail").removeSlot(ctx.save,index) ctx.g2PartyMon = nil end @@ -3724,6 +3751,7 @@ function Commands.g2_trade(ctx, index) -- reading at all -- so they all came over empty-handed. if trade.item then newMon.item = trade.item end table.remove(party, slot) + require("src.pokemon.Gen2Mail").removeSlot(ctx.save,slot) table.insert(party, newMon) local dex = ctx.save.pokedex if dex then @@ -3783,7 +3811,9 @@ function Commands.g2_return_shuckie(ctx) if not othersHealthy then return result(4) end if (picked.happiness or 0) >= 150 then return result(3) end for i, mon in ipairs(ctx.save.party) do - if mon == picked then table.remove(ctx.save.party, i) break end + if mon == picked then + table.remove(ctx.save.party,i);require("src.pokemon.Gen2Mail").removeSlot(ctx.save,i);break + end end result(2) end @@ -4290,7 +4320,7 @@ function Gen2Commands.dayCareDeposit(ctx, which, introText, leftText) return Commands.show_text(ctx, t._CantAcceptEggText or Strings("Sorry, but I can't\naccept an EGG.")) end - if picked.mail then + if picked.mail or require("src.pokemon.Gen2Mail").monHoldsMail(picked) then return Commands.show_text(ctx, t._RemoveMailText or Strings("Remove the MAIL\nfrom that #MON.")) end @@ -4305,7 +4335,9 @@ function Gen2Commands.dayCareDeposit(ctx, which, introText, leftText) end for i, mon in ipairs(save.party) do - if mon == picked then table.remove(save.party, i) break end + if mon == picked then + table.remove(save.party,i);require("src.pokemon.Gen2Mail").removeSlot(save,i);break + end end DayCare.deposit(save, which, picked) game.stringBuffer = Gen2Commands.dayCareName(game, picked) diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..427e3b7ef 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -261,11 +261,8 @@ L.phonecall = function(ir, s) emit(s, { "show_text", ir[2] }) end -- box closes itself. L.hangup = function() end --- Script_checkpokemail (25:$7608): does the party hold this mail? The port --- has no mail model, so answer "no" rather than leaving the branch to fall --- through on a stale flag. -L.checkpokemail = function(_, s) emit(s, { "g2_false" }) end -L.givepokemail = function() end +L.checkpokemail = function(ir,s) emit(s,{"g2_check_poke_mail",ir[2]}) end +L.givepokemail = function(ir,s) emit(s,{"g2_give_poke_mail",ir[2]}) end -- ------------------------------------------------------------------ no-ops -- Each of these was verified to touch only state the port does not model. diff --git a/tests/engine/gen2_mail_bytecode.lua b/tests/engine/gen2_mail_bytecode.lua new file mode 100644 index 000000000..c876925a8 --- /dev/null +++ b/tests/engine/gen2_mail_bytecode.lua @@ -0,0 +1,108 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local original=V.get() +V.set('crystal') +local data=T.fixtures.fresh() +require('src.render.Font').load(data) +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Letter',pocket='ITEM'} +local Mail=require('src.pokemon.Gen2Mail');Mail.load(data) +local Pokemon=require('src.pokemon.Pokemon') +local function mon() + return Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) +end +local game={data=data,save={},stack={states={}}} +function game.stack:push(screen)self.states[#self.states+1]=screen;if screen.enter then screen:enter()end end +function game.stack:pop()return table.remove(self.states)end +function game.stack:top()return self.states[#self.states]end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function reset() + game.save={party={mon(),mon()},player={name='AUTHOR',id=17},inventory={ITEM_158=2},bagOrder={'ITEM_158'}} + game.stack.states={{parent=true}} +end +-- Exercise real party-menu callbacks and coroutine continuation, not a +-- substitute handler or a fabricated result variable. +local Runner=require('src.script.ScriptRunner') +local VM=require('src.script.Gen2ScriptVM') +for _,case in ipairs({ + {name='cancel',result=2}, {name='missing',result=3,pick=true}, + {name='wrong',result=0,pick=true,text='OTHER'}, + {name='last',result=4,pick=true,text='MATCH',last=true}, + {name='correct',result=1,pick=true,text='MATCH'}, +})do + reset();local carrier=game.save.party[1] + if case.last then game.save.party[2].hp=0 end + if case.text then carrier.item='ITEM_158';Mail.set(game.save,1,Mail.entry('ITEM_158',case.text,'R',2,carrier.species))end + local finish=0 + local scriptData={map_scripts={scripts={probe={{'checkpokemail',{message='MATCH'}},{'end'}}}}} + local rows=assert(VM.compile(scriptData,'probe')) + local runner=Runner.new(game,{}) + runner:run(rows,{onDone=function()finish=finish+1 end}) + T.check(runner:isRunning(),'mail check suspends for real party input: '..case.name) + T.eq(finish,0,'script does not continue before selection: '..case.name) + local picker=game.stack:top() + press(picker,case.pick and 'a' or 'b') + T.eq(runner.ctx.g2Var,case.result,'native bytecode result: '..case.name) + T.eq(finish,1,'script resumes once after real picker: '..case.name) + T.eq(runner:isRunning(),false,'mail script terminates: '..case.name) + T.eq(#game.save.party,case.result==1 and 1 or 2,'delivery changes party only for CORRECT: '..case.name) +end +reset() +local runner=Runner.new(game,{}) +local rows=assert(VM.compile({map_scripts={scripts={gift={{'givepokemail',{item='ITEM_158',message='GIFT'}},{'end'}}}}},'gift')) +runner:run(rows,{g2Var=91}) +T.eq(game.save.mail.party[2].message,'GIFT','compiled native gift targets the last party member') +T.eq(runner.ctx.g2Var,91,'gift does not overwrite the script result variable') + +-- Binary fixtures cover the pointer width which previously desynchronised +-- Gold/Silver and ensure mail data never enters the dialogue/script queues. +local Extractor=require('src.import.RomExtractorGen2') +local Rom=require('src.import.Rom');local Ops=require('src.import.Gen2ScriptOps') +local Writer=require('src.import.LuaWriter') +for _,version in ipairs({'gold','silver','crystal'})do + local codes={};for i,row in ipairs(Ops.commandsFor(version))do codes[row[1]]=i-1 end + local bytes={};for i=1,0x8000 do bytes[i]=0 end + local function put(at,values)for i,v in ipairs(values)do bytes[at+i]=v end end + put(0x4000,{codes.givepokemail,0x00,0x41,codes.checkpokemail,0x01,0x41,codes.setval,77,codes['end']}) + put(0x4100,{158,0x80,0x4e,0x81,0x50}) + local chunks={};for i=1,#bytes,256 do chunks[#chunks+1]=string.char(unpack(bytes,i,math.min(i+255,#bytes)))end + local extractor=setmetatable({version=version,rom=Rom.new(table.concat(chunks)),_gen2MailCharmap={['128']='A',['129']='B'}},Extractor) + local pool={scripts={},queue={},instructions=0,desyncs=0} + extractor:gen2DecodeScript(1,0x4000,'probe',pool) + local decoded=pool.scripts.probe + T.eq(#decoded,4,'word-sized mail operands retain following commands: '..version) + T.eq(decoded[1][2].item,'ITEM_158','gift stationery resolves to native ID: '..version) + T.eq(decoded[1][2].message,'A\nB','gift reads raw line break and terminator: '..version) + T.eq(decoded[2][2].message,'A\nB','expected message skips no first character: '..version) + T.eq(decoded[3][1],'setval','decoder remains aligned after both operands: '..version) + T.eq(decoded[3][2],77,'following command retains its actual byte: '..version) + T.eq(#pool.queue,0,'raw letters are not queued as scripts: '..version) + T.eq(pool.desyncs,0,'native mail bytecode has no desync: '..version) + local round=assert(loadstring(Writer.encodeSplit(pool)))() + T.eq(round.scripts.probe[2][2].message,'A\nB','actual LuaWriter retains semantic letter data: '..version) + T.eq(extractor:gen2MailOperand(1,0x8000,true),nil,'out-of-bank pointer is rejected: '..version) + T.eq(extractor:gen2MailOperand(1,0x7fff,true),nil,'truncated gift cannot read across bank boundary: '..version) +end +local run=T.sdk.loadMods({'mods/mail_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/mail_probe/manifest.json']='{"id":"mail_probe","name":"Mail probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/mail_probe/main.lua']=[[ + local mod=... + mod.exports.reads=0;mod.exports.writes=0 + mod.events:on("mail.read",function(e)mod.exports.reads=mod.exports.reads+1;mod.exports.message=e.message end) + mod.events:on("mail.written",function(e)mod.exports.writes=mod.exports.writes+1 end) + ]], +})}) +T.eq(#run.errors,0,'actual SDK mail event subscriptions load') +reset();Mail.load(data);Mail.compose(game.save,1,'PUBLIC',game.save.party[1],'ITEM_158') +Mail.lines(Mail.get(game.save,1));Mail.lines(game.save.mail.party[1]) +local exports=run.loader.exports.mail_probe +T.eq(exports.writes,1,'public SDK receives the native write once') +T.eq(exports.reads,1,'public SDK receives the native read once, not every redraw') +T.eq(exports.message,'PUBLIC','public SDK receives actual letter contents') +run.release() +V.set(original);Mail.load(nil) +T.finish('native GSC mail ROM bytecode, actual delivery runner and public SDK events') diff --git a/tests/engine/gen2_pc_mail.lua b/tests/engine/gen2_pc_mail.lua new file mode 100644 index 000000000..31a9c83ef --- /dev/null +++ b/tests/engine/gen2_pc_mail.lua @@ -0,0 +1,119 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local old=V.get() +require('src.core.ModCompat').install() +local Mail=require('src.core.gen2.Mail') +local Boxes=require('src.core.gen2.Boxes') +local Storage=require('src.pokemon.Boxes') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local data=T.fixtures.fresh() +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Translated stationery'} +data.items.ITEM_181={id='ITEM_181',key='SURF_MAIL',name='Another translation'} +local function mon(hp) + local m=Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) + if hp~=nil then m.hp=hp end + return m +end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version);Storage.load(nil);Mail.load(data) + T.eq(Boxes.NUM_BOXES,14,'native GSC has fourteen boxes') + T.eq(Boxes.MONS_PER_BOX,20,'native GSC has twenty residents per box') + T.eq(Boxes.PARTY_SIZE,6,'native party limit') + T.eq(Boxes,require('src.pokemon.Gen2Boxes'),'legacy namespace reaches the real native operations') + T.check(Boxes~=Storage,'incompatible count contracts do not alias the same table') + T.check(Mail.isMail('ITEM_158'),'native opaque item ID is identified from its semantic key') + T.check(Mail.isMail('FLOWER_MAIL'),'symbolic stationery remains accepted') + T.eq(Mail.isMail('ITEM_001'),false,'ordinary native item is not inferred from display text') + T.eq(Mail.INDEX.ITEM_158,0,'native stationery has the same graphics index') + local a,b,c=mon(),mon(),mon() + local s={party={a,b,c},player={name='PLAYER',id=123}} + a.ot,a.otId='GIVER',456 + a.item='ITEM_158';Mail.set(s,1,Mail.entry(a.item,'FIRST','GIVER',456,a.species)) + b.item='ITEM_181';Mail.set(s,2,Mail.entry(b.item,'SECOND','OTHER',789,b.species)) + T.eq(Boxes.canDeposit(s,1),false,'mail-bearing party member cannot enter a box') + T.eq(#s.party,3,'refused deposit leaves native party unchanged') + local ok,removed=Boxes.deposit(s,3,1,data) + T.check(ok and removed==c,'native deposit moves the actual member') + T.eq(Boxes.count(s,1),1,'legacy count reports stored mons, not box count') + T.eq(Storage.count(),14,'shared box-count query remains unchanged') + T.eq(Boxes.box(s,1)[1],c,'legacy and native storage are the same save table') + T.eq(s.mail.party[1].message,'FIRST','unrelated deposit retains first letter') + T.eq(s.mail.party[2].message,'SECOND','unrelated deposit retains second letter') + T.eq(Boxes.deposit(s,1,1,data),false,'mail gate remains enforced with room in the box') + T.check(Mail.sendToPc(s,1),'detaching to mailbox succeeds') + T.eq(a.item,nil,'mailbox move clears held stationery') + T.eq(s.mail.party[1],nil,'mailbox move clears only the source letter') + T.eq(s.mail.box[1].message,'FIRST','mailbox retains the original message') + T.check(Boxes.deposit(s,1,1,data),'now ordinary native deposit succeeds') + T.eq(s.party[1],b,'party compacts after native deposit') + T.eq(s.mail.party[1].message,'SECOND','letter shifts with its original carrier') + T.eq(s.mail.party[2],nil,'old source slot is cleared') + T.eq(Boxes.canDeposit(s,1),false,'last healthy mail carrier cannot be deposited') + c.hp=0;c.status='PSN';c.statusTurns=4;c.moves[1].pp=0 + local ok,back=Boxes.withdraw(s,1,1,data) + T.check(ok and back==c,'withdraw returns the actual boxed mon') + T.eq(c.hp,c.stats.hp,'withdraw restores native max HP rather than legacy maxHp') + T.eq(c.status,nil,'withdraw clears status') + T.eq(c.statusTurns,nil,'withdraw clears status duration') + T.check(Mail.moveFromPcToParty(s,1,2),'mailbox letter can attach to a vacant party item') + T.eq(c.item,'ITEM_158','native item ID survives mailbox round trip') + T.eq(s.mail.party[2].message,'FIRST','mailbox message follows its selected carrier') + T.eq(#s.mail.box,0,'mailbox compacts after successful attachment') + local encoded=Save.encode(s);local round=assert(Save.decode(encoded)) + T.eq(round.mail.party[1].message,'SECOND','real save codec retains slot one mail') + T.eq(round.mail.party[2].authorId,456,'real save codec retains author identity') + T.eq(round.party[2].item,'ITEM_158','real save codec retains native stationery') + s.party[1],s.party[2]=s.party[2],s.party[1];Mail.swapSlots(s,1,2) + T.eq(s.mail.party[1].message,'FIRST','party reorder follows the mon') + T.eq(s.mail.party[2].message,'SECOND','party reorder does not exchange letter ownership') + T.eq(Mail.checkPokeMail(s,nil,'FIRST'),2,'cancel is the actual POKEMAIL_REFUSED value') + T.eq(Mail.checkPokeMail(s,1,'WRONG'),0,'wrong letter cannot deliver a mon') + T.eq(#s.party,2,'wrong letter leaves party unchanged') + T.eq(Mail.checkPokeMail(s,1,'FIRST'),1,'correct delivery consumes the chosen mon') + T.eq(s.party[1],b,'delivery preserves the other party member') + T.eq(s.mail.party[1].message,'SECOND','delivery shifts following mail') + T.eq(Mail.checkPokeMail(s,1,'SECOND'),4,'last healthy recipient cannot be given away') + T.eq(Mail.checkPokeMail(s,1,nil),0,'unresolved old-cache expected message never grants a delivery') + s.party[1].item=nil + T.eq(Mail.checkPokeMail(s,1,'SECOND'),3,'no mail has a distinct result') + T.check(Mail.give(s,'ITEM_181','GIFT'),'script gift writes real native mail') + T.eq(s.party[1].item,'ITEM_181','script gift writes native held item') + T.eq(s.mail.party[1].message,'GIFT','script gift writes the letter') + T.check(Mail.compose(s,1,string.rep('é',33),s.party[1],'ITEM_181'),'composition accepts native stationery') + T.eq(#Mail.characters(s.mail.party[1].message),32,'native character limit is UTF-8 aware') + T.eq(s.mail.party[1].author,'PLAYER','composition records the actual save player') + T.eq(s.mail.party[1].authorId,123,'composition records actual trainer ID') + local top,bottom=Mail.lines(s.mail.party[1]) + T.eq(#Mail.characters(top),16,'first mail line has sixteen characters') + T.eq(#Mail.characters(bottom),16,'second mail line has sixteen characters') + for i=1,10 do s.mail.box[i]=Mail.entry('ITEM_158','LETTER '..i) end + local letter=s.mail.party[1] + T.eq(Mail.sendToPc(s,1),false,'full mailbox refuses atomically') + T.eq(s.mail.party[1],letter,'failed send retains the same letter') + T.eq(s.party[1].item,'ITEM_181','failed send retains stationery') + T.eq(Mail.moveFromPcToParty(s,1,1),false,'occupied item refuses attachment') + T.eq(#s.mail.box,10,'failed attachment retains every mailbox letter') + local singleton={party={mon(),mon(0)}} + T.eq(Boxes.canDeposit(singleton,1),false,'fainted teammate does not satisfy last-healthy rule') + T.eq(Boxes.canDeposit(singleton,2),true,'fainted mon may be deposited while a healthy one remains') + singleton.party[2].moves[1].pp=0;singleton.party[2].status='PAR' + local target=singleton.party[2];Boxes.deposit(singleton,2,1,data) + T.eq(target.moves[1].pp,data.moves[target.moves[1].id].pp,'deposit restores native move PP') + T.eq(target.status,nil,'deposit clears boxed status') +end +V.set('crystal');Mail.load(data) +local Events=require('src.mods.Events');local Runtime=require('src.mods.Runtime') +local bus=Events.new();local previous=Runtime.events;Runtime.events=bus +local writes,reads=0,0 +bus:on('mail.written',function(e)writes=writes+1;T.eq(e.source,'compose','native write event identifies producer')end) +bus:on('mail.read',function(e)reads=reads+1;T.eq(e.author,'P','native read event carries author')end) +local s={party={mon()},player={name='P',id=7}} +Mail.compose(s,1,'ABC',s.party[1],'ITEM_158') +T.eq(writes,1,'actual native composition emits mail.written') +local entry=Mail.get(s,1);Mail.lines(entry);Mail.lines(entry) +T.eq(reads,1,'redrawing a letter does not emit mail.read again') +Mail.get(s,1);Mail.lines(entry);T.eq(reads,2,'opening the same letter again emits a new read') +Runtime.events=previous;V.set(old);Storage.load(nil);Mail.load(nil) +T.finish('native GSC PC transfers, slot-indexed mail, save codec and native events') From a70e34bc5d3fc1d661588ae4f4601c43e2b6ba02 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:31:23 +0100 Subject: [PATCH 04/68] Expose the native Gen2 PC menu under its legacy module name --- docs/native-gen2-pc-mail.md | 2 +- src/core/ModCompat.lua | 1 + tests/engine/gen2_pc_mail.lua | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/native-gen2-pc-mail.md b/docs/native-gen2-pc-mail.md index 217832c72..e47827cda 100644 --- a/docs/native-gen2-pc-mail.md +++ b/docs/native-gen2-pc-mail.md @@ -17,7 +17,7 @@ identifies compose/script. Item recognition uses imported semantic keys. The primary pointer contracts are in pret/pokegold and pret/pokecrystal, engine/overworld/scripting.asm (Script_givepokemail/Script_checkpokemail). -Validation: 207 PC/mail/codec checks and 69 binary ROM/actual runner/public +Validation: 208 PC/mail/codec checks and 69 binary ROM/actual runner/public SDK checks pass. The current upstream baseline also has existing suite failures; comparative engine/SDK results are recorded in the PR. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index cf627ef11..d7d3e1115 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -68,6 +68,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Gen2Boxes", ["src.core.gen2.Mail"] = "src.pokemon.Gen2Mail", + ["src.ui.gen2.BoxMenu"] = "src.ui.BoxMenu", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/tests/engine/gen2_pc_mail.lua b/tests/engine/gen2_pc_mail.lua index 31a9c83ef..b5bdc9d6c 100644 --- a/tests/engine/gen2_pc_mail.lua +++ b/tests/engine/gen2_pc_mail.lua @@ -6,6 +6,7 @@ require('src.core.ModCompat').install() local Mail=require('src.core.gen2.Mail') local Boxes=require('src.core.gen2.Boxes') local Storage=require('src.pokemon.Boxes') +T.eq(require("src.ui.gen2.BoxMenu"),require("src.ui.BoxMenu"),"legacy Gen2 PC namespace resolves the real native factory") local Pokemon=require('src.pokemon.Pokemon') local Save=require('src.core.SaveData') local data=T.fixtures.fresh() From 7a2efa95388a4e3340c1e6f003192dd74d0c6f90 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:12:44 +0200 Subject: [PATCH 05/68] fix: adapt the public Gen2 naming screen contract for mods --- docs/mod-naming-compat.md | 23 +++++ src/ui/Gen2NamingScreen.lua | 68 +++++++++++++ tests/engine/gen2_mod_naming.lua | 160 +++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 docs/mod-naming-compat.md create mode 100644 src/ui/Gen2NamingScreen.lua create mode 100644 tests/engine/gen2_mod_naming.lua diff --git a/docs/mod-naming-compat.md b/docs/mod-naming-compat.md new file mode 100644 index 000000000..27ec99978 --- /dev/null +++ b/docs/mod-naming-compat.md @@ -0,0 +1,23 @@ +# Gen1Recomp Gen-2 naming screen compatibility + +API-2 mods may open `mod.ui.push(game, "Gen2NamingScreen", opts)`, the public +screen ID used by Anytime Rename 1.2.1. The adapter uses Gen2Recomped's native +keyboard and supports `type` (player, rival, mom, box, nickname), `prompt`, +`maxLength`, `initial`, `mon`, `monName` and `onDone(name)`. + +The completion callback owns closing this screen. It runs while the naming +state is still on the stack; a caller may pop it and return to its existing +parent menu. The ordinary `NamingScreen` API remains different: it pops itself +before calling `onDone`. Mapping the ID directly to that class would pop two +states when an existing Gen1Recomp mod closes its own screen. + +`text` reflects the editable name and may also be assigned. Initial values and +assignments are limited by glyph count, preserving UTF-8 and charmap tokens. +The adapter keeps the native keyboard's input, drawing and `ui.naming.grid` +hook; it does not reproduce Gen1Recomp's separate Gen-2 keyboard layout or +change native game save fields. Mods should use the native naming screen for +their target host when they do not need this compatibility contract. + +Regression coverage: `tests/engine/gen2_mod_naming.lua`. External verification +uses the unchanged Anytime Rename 1.2.1 entry point and its 55-check suite; +its native-menu flow suite additionally runs six games in two languages. diff --git a/src/ui/Gen2NamingScreen.lua b/src/ui/Gen2NamingScreen.lua new file mode 100644 index 000000000..3f003a05c --- /dev/null +++ b/src/ui/Gen2NamingScreen.lua @@ -0,0 +1,68 @@ +-- Public Gen1Recomp screen ID used by API-2 mods on a Gen-2 save. +-- Reuse this engine's native keyboard while preserving the caller-owned +-- completion contract: unlike NamingScreen, this screen never pops itself. +local NamingScreen = require("src.ui.NamingScreen") +local Font = require("src.render.Font") + +local Compat = {} +local TYPES = { + player = { prompt = "YOUR NAME?", length = 7, kind = "player" }, + rival = { prompt = "RIVAL'S NAME?", length = 7, kind = "rival" }, + mom = { prompt = "MOTHER'S NAME?", length = 7 }, + box = { prompt = "BOX NAME?", length = 8 }, + nickname = { prompt = "NICKNAME?", length = 10, kind = "mon" }, +} +local function glyphs(text, limit) + text = type(text) == "string" and text or "" + local out = {} + for _, span in ipairs(Font.split(text)) do + if #out >= limit then + break + end + out[#out + 1] = text:sub(span.from, span.to) + end + return out +end +local function accept(self) + if self.onDone then + self.onDone(table.concat(self.glyphs)) + end +end +local meta = { + __index = function(self, key) + if key == "text" then + return table.concat(self.glyphs) + end + if key == "accept" or key == "confirm" then + return accept + end + return NamingScreen[key] + end, + __newindex = function(self, key, value) + if key == "text" then + self.glyphs = glyphs(value, self.maxLen) + else + rawset(self, key, value) + end + end, +} + +function Compat.new(game, opts) + opts = opts or {} + local kind = TYPES[opts.type or "player"] or TYPES.player + local screen = NamingScreen.new(game, { + title = opts.prompt or require("src.core.Strings")(kind.prompt), + maxLen = opts.maxLength or kind.length, + kind = kind.kind, + mon = opts.mon, + onDone = opts.onDone, + }) + screen.maxLength = screen.maxLen + screen.prompt = screen.title + screen.monName = opts.monName + screen.onCancel = opts.onCancel + screen.glyphs = glyphs(opts.initial, screen.maxLen) + return setmetatable(screen, meta) +end + +return Compat diff --git a/tests/engine/gen2_mod_naming.lua b/tests/engine/gen2_mod_naming.lua new file mode 100644 index 000000000..377e0e3db --- /dev/null +++ b/tests/engine/gen2_mod_naming.lua @@ -0,0 +1,160 @@ +-- The public Gen1Recomp ID and caller-owned completion used by Anytime Rename. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Screens = require("src.ui.Screens") +local Font = require("src.render.Font") +local V = require("src.core.GameVersion") +local original = V.get() +V.set("gold") +local data = T.fixtures.fresh() +data.font.charmap[#data.font.charmap + 1] = { seq = "", code = 0xe1 } +Font.load(data) +local run = T.sdk.loadMods( + { "mods/rename_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/rename_probe/manifest.json"] = [[{ + "id":"rename_probe", "name":"Rename probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/rename_probe/main.lua"] = [[ + local mod = ... + mod.exports.open = function(game, opts) + return mod.ui.push(game, "Gen2NamingScreen", opts) + end + mod.hooks:wrap("ui.naming.grid", function(nextFn, grid, ctx) + local out = nextFn(grid, ctx) + if ctx.title == "LOCALIZED?" then + local copy = {} + for i, row in ipairs(out) do + copy[i] = {} + for j, cell in ipairs(row) do copy[i][j] = cell end + end + copy[1][1] = "Ä" + return copy + end + return out + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the API-2 screen probe loads") +local game = { data = data, save = { player = {} }, stack = { states = {} } } +function game.stack:push(s) + self.states[#self.states + 1] = s + if s.enter then + s:enter() + end +end +function game.stack:pop() + return table.remove(self.states) +end +function game.stack:top() + return self.states[#self.states] +end +local function press(screen, key) + game.input = { + wasPressed = function(_, k) + return k == key + end, + } + screen:update(1 / 60) +end +local open = run.loader.exports.rename_probe.open +for _, spec in ipairs({ + { "player", 7 }, + { "rival", 7 }, + { "mom", 7 }, + { "box", 8 }, + { "nickname", 10 }, +}) do + Screens.invalidate() + local parent = { name = "parent" } + game.stack.states = { parent } + local calls, result = 0, nil + local screen + screen = open(game, { + type = spec[1], + initial = "AB", + onDone = function(name) + calls, result = calls + 1, name + T.eq(game.stack:top(), screen, "the caller still owns the naming screen at completion") + game.stack:pop() -- exactly what the existing Gen1Recomp mod does + end, + }) + T.eq(screen.screenId, "Gen2NamingScreen", "the public screen ID resolves") + T.eq(screen.maxLength, spec[2], spec[1] .. " keeps its name limit") + T.eq(screen.text, "AB", "the initial name is editable") + press(screen, "b") + T.eq(screen.text, "A", "B deletes one initial glyph") + screen.row, screen.col = 1, 2 + press(screen, "a") + T.eq(screen.text, "AB", "native keyboard input updates the public text") + -- Native START confirms; Gen2Recomped may reuse its own keyboard layout. + press(screen, "start") + T.eq(calls, 1, "completion calls the caller once") + T.eq(result, "AB", "completion returns the edited name") + T.eq(game.stack:top(), parent, "completion preserves the parent state") +end + +Screens.invalidate() +local parent = {} +game.stack.states = { parent } +local result +local screen = open(game, { + type = "nickname", + prompt = "LOCALIZED?", + initial = "", + maxLength = 2, + monName = "FIXMON", + onDone = function(name) + result = name + end, +}) +T.eq(screen.prompt, "LOCALIZED?", "explicit localized prompt survives") +T.eq(screen.monName, "FIXMON", "the nickname header is retained") +press(screen, "a") +T.eq(screen.text, "Ä", "the public grid hook supplies a Unicode glyph") +screen.row, screen.col = 1, 2 +press(screen, "a") +T.eq(screen.text, "ÄB", "the name limit counts glyphs rather than bytes") +press(screen, "start") +T.eq(result, "ÄB", "Unicode completion keeps the full glyph") +T.eq(game.stack:top(), screen, "an observing callback does not implicitly close the screen") +press(screen, "b") +T.eq(screen.text, "Ä", "deletion keeps a multi-byte glyph whole") +screen.text = "ÄZ" +T.eq(screen.text, "Ä", "direct text assignment respects charmap glyphs and the limit") +T.eq(#screen.glyphs, 2, "the native keyboard receives two actual glyphs") + +-- The original, unqualified screen keeps its existing pop-before-callback API. +Screens.invalidate() +game.stack.states = { parent } +local ordinary = Screens.push(game, "NamingScreen", { + default = "RED", + onDone = function(name) + T.eq(game.stack:top(), parent, "ordinary NamingScreen still pops before callback") + T.eq(name, "RED", "ordinary NamingScreen retains its default-name behavior") + end, +}) +press(ordinary, "start") +T.eq(game.stack:top(), parent, "ordinary screen closes exactly once") + +-- A registry override remains authoritative for the compatibility ID. +Screens.invalidate() +local custom = { custom = true } +data.screens = data.screens or {} +data.screens.Gen2NamingScreen = { + new = function(_, opts) + T.eq(opts.initial, "OVERRIDE", "screen overrides receive original Gen2 options") + return custom + end, +} +T.eq(open(game, { initial = "OVERRIDE" }), custom, "registered screen overrides win") +T.eq(#run.errors, 0, "the native flows produce no mod errors") +run.release() +Screens.invalidate() +V.set(original) +T.finish("public Gen2 mod naming compatibility") From 1077ef9094afefa6af807b3d2e1dd5b53e4fc8fd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:31:59 +0100 Subject: [PATCH 06/68] Connect native GSC PC and mail menus to authoritative transactions --- docs/native-gen2-pc-mail-ui.md | 33 +++++++ src/ui/BagMenu.lua | 9 ++ src/ui/BoxMenu.lua | 64 ++++++++++--- src/ui/Gen2MailCompose.lua | 37 ++++++++ src/ui/Gen2MailMenu.lua | 44 +++++++++ src/ui/Gen2MailboxMenu.lua | 54 +++++++++++ src/ui/Gen2PokedexMenu.lua | 9 ++ src/ui/NamingScreen.lua | 4 +- src/ui/PartyMenu.lua | 8 +- src/ui/PlayerPC.lua | 10 ++- src/ui/QuarantineReport.lua | 9 ++ src/ui/Screens.lua | 1 + tests/engine/gen2_mail_ui.lua | 159 +++++++++++++++++++++++++++++++++ 13 files changed, 426 insertions(+), 15 deletions(-) create mode 100644 docs/native-gen2-pc-mail-ui.md create mode 100644 src/ui/Gen2MailCompose.lua create mode 100644 src/ui/Gen2MailMenu.lua create mode 100644 src/ui/Gen2MailboxMenu.lua create mode 100644 src/ui/Gen2PokedexMenu.lua create mode 100644 tests/engine/gen2_mail_ui.lua diff --git a/docs/native-gen2-pc-mail-ui.md b/docs/native-gen2-pc-mail-ui.md new file mode 100644 index 000000000..ac918f1c0 --- /dev/null +++ b/docs/native-gen2-pc-mail-ui.md @@ -0,0 +1,33 @@ +# Native GSC PC and mail menus + +This is the menu layer for the native PC/mail core offered in PR #264. +It also uses the caller-owned native Gen2 keyboard adapter offered in +PR #135. Those prerequisite changes are included in this branch for an +executable comparison; the new menu commit is separate from them. + +Native bag/party actions open the 32-character, two-line composition +keyboard. Cancelling consumes no stationery; acceptance commits the real +inventory exchange and party-slot letter together. Existing held mail +opens READ/TAKE choices; message destruction requires a default-No choice. +The player PC opens the real mailbox, which attaches the selected letter +through the native party picker and keeps dense row identities after removal. + +Bill's PC uses native GSC deposit/withdraw/release guards and exposes its +actual save for existing Gen2 host adapters. Legacy callers owning dismissal +receive their close callback while the PC is still on the stack; native +callers retain pop-before-callback behavior. Alternate-save menus do not +write the live profile. Summary/dex entry IDs route to existing native screens. +Party reorders move slot-indexed letters alongside their carriers. Native +quarantine UI reports retained/recovered letters instead of hiding repairs. + +Focused comparison: 208 core/codec checks, 69 bytecode/runner/SDK checks, +61 naming-adapter checks and 50 menu/inventory/codec/SDK checks pass. The new +menu suite drives actual production keyboard/menu input, failed/stale/cancelled +transactions, mailbox compaction, player-PC admission, caller-owned PC closing, +alternate saves and missing-carrier recovery. Synthetic fixtures only. + +The fork separately has source-bound unchanged-Johto/Crystal GPU evidence. +That evidence does not qualify this different upstream composition. Full +stationery art, SRAM mail conversion, every legacy PC mode, complete VASC +compatibility and real hardware/story acceptance are not claimed here. +No private mod code/assets, cartridge bytes, generated art or saves are included. diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4d229471d..70e373f79 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -976,6 +976,15 @@ local function handOver(game, mon, id, onChanged, opts) return end local held = mon.item + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + showMessages(game,{Strings("Remove MAIL first.")});return + end + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").isMail(id) then + game.stack:push(require("src.ui.Gen2MailCompose").new(game,{mon=mon,item=id,onDone=function() + if onChanged then onChanged() end + showMessages(game,{Strings("%s is now holding\n%s.",monName,name)}) + end}));return + end local function hand() local changed if opts and opts.giveHeld then diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 7547c03cb..aedc36314 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -12,6 +12,8 @@ local TextBox = require("src.render.TextBox") local Strings = require("src.core.Strings") local BoxMenu = {} +local function gen2() return require("src.core.GameVersion").isGen2() end +local Gen2Boxes = require("src.pokemon.Gen2Boxes") local function monLabel(game, mon) local def = game.data.pokemon[mon.species] @@ -127,9 +129,14 @@ local function withdraw(game) -- reaches the party menu with mon.stats nil and the HP bar draw -- nil-indexes it (#304, same family as #233). Already-shaped mons -- (everything the engine itself put in a box) pass through. - Stats.ensure(game.data.pokemon[mon.species], mon) - table.remove(box, index) - table.insert(game.save.party, mon) + if gen2() then + local ok,why=Gen2Boxes.withdraw(game.save,game.save.currentBox,index,game.data) + if not ok then list.footer=why;return end + else + Stats.ensure(game.data.pokemon[mon.species], mon) + table.remove(box, index) + table.insert(game.save.party, mon) + end local name = monName(game, mon) game.stringBuffer = name require("src.core.Sound").playCry(game.data, mon.species) @@ -142,7 +149,7 @@ end local function deposit(game) local t = game.data.text - if #game.save.party <= 1 then + if not gen2() and #game.save.party <= 1 then game.stack:push(TextBox.new(game, t._CantDepositLastMonText or Strings("You can't deposit\nthe last POKéMON!"))) return @@ -167,7 +174,7 @@ local function deposit(game) local index = list.index local mon = game.save.party[index] if not mon then return end - if #game.save.party <= 1 then + if not gen2() and #game.save.party <= 1 then list.footer = Strings("You need at least\none POKéMON!") return end @@ -176,8 +183,13 @@ local function deposit(game) list.footer = Strings("BOX %d is full!", game.save.currentBox) return end - table.remove(game.save.party, index) - table.insert(active, mon) + if gen2() then + local ok,why=Gen2Boxes.deposit(game.save,index,game.save.currentBox,game.data) + if not ok then list.footer=why;return end + else + table.remove(game.save.party, index) + table.insert(active, mon) + end -- PIKAHAPPY_DEPOSITED (engine/pokemon/bills_pc.asm:247) require("src.world.PikachuFollower") .modifyHappiness(game.save, "DEPOSITED", mon) @@ -218,7 +230,10 @@ local function release(game) defaultNo = true, noSound = true, choice = function(yes) if not yes then return end - table.remove(box, list.index) + if gen2() then + local ok,why=Gen2Boxes.release(game.save,game.save.currentBox,list.index) + if not ok then list.footer=why;return end + else table.remove(box, list.index) end require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, Strings("%s was\nreleased outside.\fBye %s!", name, name))) @@ -306,8 +321,16 @@ local function printBox(game) or Strings("Printer error!\n%s", tostring(err)))) end -function BoxMenu.new(game) +function BoxMenu.new(game, opts) + opts=opts or {} + if opts.save and opts.save~=game.save then + game=setmetatable({save=opts.save,writeSave=false},{__index=game}) + end Boxes.ensure(game.save) + if gen2() then + local ok,why=Gen2Boxes.canUsePc(game.save) + if not ok then return TextBox.new(game,why,opts.onClose or opts.onCancel or opts.onDone) end + end -- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior -- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW " need the -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ @@ -329,11 +352,30 @@ function BoxMenu.new(game) items[#items + 1] = { label = Strings("PRINT BOX"), keepOpen = true, onSelect = function() printBox(game) end } end - items[#items + 1] = { label = Strings("SEE YA!") } + local callerCloses=gen2() and type(opts.onClose)=="function" + local closed=false + local function onClose() + if closed then return end + closed=true + local callback=opts.onClose or opts.onCancel or opts.onDone + if callback then callback() end + end + items[#items + 1] = { label = Strings("SEE YA!"),keepOpen=callerCloses,onSelect=onClose } local menu = Menu.new(game, items, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- engine/menus/pokemon_pc.asm) - { tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true }) + { tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true,onCancel=onClose }) + menu.save,menu.opts=game.save,opts + if callerCloses then + -- Legacy Gen2 callers own dismissal. Native callers keep Menu's + -- normal pop-before-callback contract. + local update=menu.update + function menu:update(dt) + if self.game.input:wasPressed("b") then onClose();return end + return update(self,dt) + end + function menu:cancel()onClose()end + end local baseDraw = menu.draw function menu:draw() baseDraw(self) diff --git a/src/ui/Gen2MailCompose.lua b/src/ui/Gen2MailCompose.lua new file mode 100644 index 000000000..966e15895 --- /dev/null +++ b/src/ui/Gen2MailCompose.lua @@ -0,0 +1,37 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Uses the native keyboard with two 16-character rows. Accepting commits +-- the stationery exchange and letter together; cancelling changes neither. +local Mail=require("src.pokemon.Gen2Mail") +local Strings=require("src.core.Strings") +local M={} +function M.new(game,opts) + opts=opts or {} + local screen + local function close() + if game.stack:top()==screen then game.stack:pop() end + end + screen=require("src.ui.Gen2NamingScreen").new(game,{ + type="box",maxLength=32,prompt=require("src.core.Strings")("MAIL SELECT:QUIT"),initial="", + onDone=function(text) + local slot + for i,mon in ipairs(game.save.party or {})do if mon==opts.mon then slot=i;break end end + local ok,why=Mail.attachFromBag(game.save,slot,opts.item,text,game.data) + if not ok then + local message=why=="full" and Strings("The PACK is full.") or Strings("The MAIL couldn't\nbe attached.") + game.stack:push(require("src.render.TextBox").new(game,message));return + end + close();if opts.onDone then opts.onDone(true) end + end, + }) + screen.mailMode=true + local update=screen.update + function screen:update(dt) + local input=game.input + if input:wasPressed("select") or (input:wasPressed("b") and #self.glyphs==0) then + close();if opts.onCancel then opts.onCancel() end;return + end + return update(self,dt) + end + return screen +end +return M diff --git a/src/ui/Gen2MailMenu.lua b/src/ui/Gen2MailMenu.lua new file mode 100644 index 000000000..7996569ff --- /dev/null +++ b/src/ui/Gen2MailMenu.lua @@ -0,0 +1,44 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Mail=require("src.pokemon.Gen2Mail") +local Menu=require("src.ui.Menu") +local TextBox=require("src.render.TextBox") +local Strings=require("src.core.Strings") +local M={} +function M.read(game,entry) + local top,bottom=Mail.lines(entry) + local text=top.."\n"..bottom.."\f"..Strings("FROM:").."\n"..tostring(entry and entry.author or "") -- strings: authored letter data and localized sender heading + game.stack:push(TextBox.new(game,text)) +end +local function partySlot(game,mon) + for i,member in ipairs(game.save.party or {}) do if member==mon then return i end end +end +function M.new(game,opts) + opts=opts or {};local mon=opts.mon or game.save.party[opts.slot] + local function say(text)game.stack:push(TextBox.new(game,Strings(text)))end + return Menu.new(game,{ + {label=Strings("READ"),keepOpen=true,onSelect=function() + local slot=partySlot(game,mon) + if slot then M.read(game,Mail.get(game.save,slot) or Mail.entry(mon.item)) end + end}, + {label=Strings("TAKE"),onSelect=function() + game.stack:push(TextBox.new(game,Strings("Send the removed\nMAIL to your PC?"),nil,{ + choice=function(yes) + local slot=partySlot(game,mon);if not slot then return end + if yes then + if Mail.sendToPc(game.save,slot) then say(Strings("The MAIL was sent\nto your PC.")) + else say(Strings("Your PC's MAILBOX\nis full.")) end + else + game.stack:push(TextBox.new(game,Strings("The MAIL will lose\nits message. OK?"),nil,{ + defaultNo=true,choice=function(confirm) + if not confirm then return end + local current=partySlot(game,mon);if not current then return end + if Mail.detachToBag(game.save,current,game.data) then say("MAIL detached.") + else say(Strings("There's no space\nfor removing MAIL.")) end + end})) + end + end})) + end}, + {label=Strings("QUIT")}, + },{tx=9,ty=10,tw=11,th=8}) +end +return M diff --git a/src/ui/Gen2MailboxMenu.lua b/src/ui/Gen2MailboxMenu.lua new file mode 100644 index 000000000..52c0bfefa --- /dev/null +++ b/src/ui/Gen2MailboxMenu.lua @@ -0,0 +1,54 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Mail=require("src.pokemon.Gen2Mail") +local Menu=require("src.ui.Menu") +local ListMenu=require("src.ui.ListMenu") +local TextBox=require("src.render.TextBox") +local Strings=require("src.core.Strings") +local M={} +function M.new(game,opts) + opts=opts or {};Mail.reclaimQuarantine(game.save) + local entries={} + for i,letter in ipairs(Mail.mailbox(game.save)) do + entries[#entries+1]={label=(letter.author~="" and letter.author) or Strings("MAIL"),value=i} + end + if #entries==0 then return TextBox.new(game,Strings("There's no MAIL\nhere."),opts.onCancel) end + local list + local function say(text)game.stack:push(TextBox.new(game,Strings(text)))end + local function removed(index) + -- Both the displayed rows and native mailbox shift by the same index. + list.index=index;list:removeCurrent() + end + list=ListMenu.new(game,Strings("MAILBOX"),entries,{onCancel=opts.onCancel,onChoose=function(item) + local index=item.value + local letter=Mail.mailbox(game.save)[index];if not letter then return end + game.stack:push(Menu.new(game,{ + {label=Strings("READ"),onSelect=function()require("src.ui.Gen2MailMenu").read(game,letter)end}, + {label=Strings("GIVE"),onSelect=function() + require("src.ui.Screens").push(game,"PartyMenu",{pickOnly=true,onSwitch=function(mon) + local slot + for i,member in ipairs(game.save.party or {})do if mon==member then slot=i;break end end + if not slot or Mail.mailbox(game.save)[index]~=letter or not Mail.moveFromPcToParty(game.save,index,slot) then say(Strings("That POKéMON can't\nhold this MAIL."));return end + removed(index);say("MAIL attached.") + end}) + end}, + {label=Strings("MOVE TO BAG"),onSelect=function() + game.stack:push(TextBox.new(game,Strings("The MAIL will lose\nits message. OK?"),nil,{ + defaultNo=true,choice=function(yes) + if not yes then return end + local current=Mail.mailbox(game.save)[index];if current~=letter then return end + if not require("src.inventory.Bag").add(game.save,current.type,1,game.data) then say("The PACK is full.");return end + Mail.deleteFromPc(game.save,index);removed(index);say("MAIL moved to PACK.") + end})) + end}, + {label=Strings("CANCEL")}, + },{tx=7,ty=8,tw=13,th=10})) + end}) + -- Row values must follow the dense mailbox after every deletion. + local remove=list.removeCurrent + function list:removeCurrent() + remove(self) + for i,row in ipairs(self.items)do row.value=i end + end + return list +end +return M diff --git a/src/ui/Gen2PokedexMenu.lua b/src/ui/Gen2PokedexMenu.lua new file mode 100644 index 000000000..8fcafc57f --- /dev/null +++ b/src/ui/Gen2PokedexMenu.lua @@ -0,0 +1,9 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- The Gen2 host can request a stored mon's entry directly, or the whole dex. +local M={} +function M.new(game,opts) + opts=opts or {} + if opts.entrySpecies then return require("src.ui.DexEntryMenu").new(game,{species=opts.entrySpecies,forceOwned=true}) end + return require("src.ui.PokedexMenu").new(game,{onCancel=opts.onClose or opts.onCancel}) +end +return M diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 3fbcfc349..14909db2d 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -654,7 +654,9 @@ function NamingScreen:drawClassic() -- typed name with dashes for the empty slots, on the font's own pitch local slot = math.max(8, Font.width("W") + 2) for i = 1, self.maxLen do - Font.draw(self.glyphs[i] or "-", m.left + 40 + (i - 1) * slot, 26) + if self.mailMode then + Font.draw(self.glyphs[i] or "-",8+((i-1)%16)*8,24+math.floor((i-1)/16)*12) + else Font.draw(self.glyphs[i] or "-", m.left + 40 + (i - 1) * slot, 26) end end for r, row in ipairs(m.rows) do for c, cell in ipairs(row) do diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..00df97b23 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -386,6 +386,9 @@ end -- player picks what to hand over; TAKE moves the held item back. function PartyMenu:openItemMenu(mon) local game = self.game + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + game.stack:push(require("src.ui.Gen2MailMenu").new(game,{mon=mon}));return + end local TextBox = require("src.render.TextBox") local Bag = require("src.inventory.Bag") local Menu = require("src.ui.Menu") @@ -778,6 +781,9 @@ function PartyMenu:update(dt) elseif self.swapFrom then if self.swapFrom ~= self.index then party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + if require("src.core.GameVersion").isGen2() and party==self.game.save.party then + require("src.pokemon.Gen2Mail").swapSlots(self.game.save,self.swapFrom,self.index) + end require("src.core.Sound").play(self.game.data, "Swap") end self.swapFrom = nil @@ -882,7 +888,7 @@ function PartyMenu:update(dt) local stats, switch = table.remove(items, 1), table.remove(items, 1) items[#items + 1] = stats items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } + items[#items + 1] = { label = Strings(require("src.pokemon.Gen2Mail").monHoldsMail(mon) and "MAIL" or "ITEM"), action = "item" } items[#items + 1] = { label = Strings("QUIT"), action = "quit" } end end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 8203e0800..f1e5c9931 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -171,7 +171,7 @@ PlayerPC.toss = toss function PlayerPC.new(game) game.save.pcItems = game.save.pcItems or {} - return Menu.new(game, { + local items={ -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows @@ -181,7 +181,13 @@ function PlayerPC.new(game) { label = Strings("LOG OFF") }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + } + if require("src.core.GameVersion").isGen2() then + table.insert(items,#items,{label=Strings("MAILBOX"),keepOpen=true,onSelect=function() + game.stack:push(require("src.ui.Gen2MailboxMenu").new(game)) + end}) + end + return Menu.new(game,items,{tx=0,ty=0,tw=16,th=#items*2+2,noSound=true}) end return PlayerPC diff --git a/src/ui/QuarantineReport.lua b/src/ui/QuarantineReport.lua index 741114eda..075ab4a1f 100644 --- a/src/ui/QuarantineReport.lua +++ b/src/ui/QuarantineReport.lua @@ -33,6 +33,15 @@ end -- recovered, modsDiff } local function buildLines(report, meta) local lines = {} + if #(report.lostMail or {})>0 then + lines[#lines+1]=Strings("MAIL repairs: %d",#report.lostMail) + for _,entry in ipairs(report.lostMail)do + lines[#lines+1]=clip(" "..tostring(entry.why or "Invalid letter")) + end + end + if #(report.restoredMail or {})>0 then + lines[#lines+1]=Strings("MAIL restored: %d",#report.restoredMail) + end if report.recovered then lines[#lines + 1] = "Save recovered from" lines[#lines + 1] = clip(" the ." .. tostring(report.recovered) .. " backup copy") diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 704c642e3..87f8d4da8 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -12,6 +12,7 @@ local Screens = {} local BUILTIN = { ManagerState = "src.mods.ManagerState", Gen2StartMenu = "src.ui.StartMenu", + Gen2SummaryMenu = "src.ui.SummaryMenu", } local cache = {} diff --git a/tests/engine/gen2_mail_ui.lua b/tests/engine/gen2_mail_ui.lua new file mode 100644 index 000000000..84b431040 --- /dev/null +++ b/tests/engine/gen2_mail_ui.lua @@ -0,0 +1,159 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local original=V.get() +V.set('crystal') +local data=T.fixtures.fresh() +require('src.render.Font').load(data) +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Letter',pocket='ITEM'} +local Mail=require('src.pokemon.Gen2Mail');Mail.load(data) +local Pokemon=require('src.pokemon.Pokemon') +local function mon() + return Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) +end +local game={data=data,save={},stack={states={}}} +function game.stack:push(screen)self.states[#self.states+1]=screen;if screen.enter then screen:enter()end end +function game.stack:pop()return table.remove(self.states)end +function game.stack:top()return self.states[#self.states]end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function reset() + game.save={party={mon(),mon()},player={name='AUTHOR',id=17},inventory={ITEM_158=2},bagOrder={'ITEM_158'}} + game.stack.states={{parent=true}} +end +local Compose=require('src.ui.Gen2MailCompose') +reset() +local parent=game.stack:top();local inv,order=game.save.inventory,game.save.bagOrder +local cancelled=0 +local screen=Compose.new(game,{mon=game.save.party[1],item='ITEM_158',onCancel=function()cancelled=cancelled+1 end}) +game.stack:push(screen);screen.text='UNSENT';press(screen,'select') +T.eq(game.stack:top(),parent,'SELECT cancels the actual keyboard') +T.eq(game.save.inventory.ITEM_158,2,'cancel does not consume stationery') +T.eq(game.save.party[1].item,nil,'cancel leaves the held item unchanged') +T.eq(game.save.mail,nil,'cancel creates no pending letter') +T.eq(cancelled,1,'cancel callback runs once') +local completed=0 +screen=Compose.new(game,{mon=game.save.party[1],item='ITEM_158',onDone=function()completed=completed+1 end}) +game.stack:push(screen);screen.text=string.rep('X',33) +T.eq(#screen.glyphs,32,'actual native keyboard caps the message at 32 glyphs') +press(screen,'start') +T.eq(completed,1,'START completes once') +T.eq(game.stack:top(),parent,'composition closes its own screen exactly once') +T.eq(game.save.inventory,inv,'composition preserves live inventory identity') +T.eq(game.save.bagOrder,order,'composition preserves live acquisition order identity') +T.eq(game.save.inventory.ITEM_158,1,'composition consumes one stationery') +T.eq(game.save.party[1].item,'ITEM_158','composition gives the actual native held item') +T.eq(game.save.mail.party[1].message,string.rep('X',32),'actual keyboard result becomes the native letter') +T.eq(game.save.mail.party[1].authorId,17,'composition records the real trainer ID') +local Bag=require('src.inventory.Bag') +T.eq(Bag.takeHeld(game.save,game.save.party[1],data),nil,'ordinary take cannot destroy a held letter') +T.eq(game.save.mail.party[1].message,string.rep('X',32),'refused take keeps message') +reset() +local held='PREVIOUS_ITEM';data.items[held]={id=held,name='Held item',pocket='ITEM'} +game.save.party[1].item=held;game.save.inventory[held]=99 +local before=require('src.core.SaveData').encode(game.save) +T.eq(Mail.attachFromBag(game.save,1,'ITEM_158','FAIL',data),false,'full previous-item stack refuses the exchange') +T.eq(require('src.core.SaveData').encode(game.save),before,'failed exchange changes no persisted fields') +game.save.inventory[held]=98 +T.check(Mail.attachFromBag(game.save,1,'ITEM_158','SWAP',data),'previous held item can return to bag') +T.eq(game.save.inventory[held],99,'previous held item is returned exactly once') +reset() +local gone=game.save.party[1] +screen=Compose.new(game,{mon=gone,item='ITEM_158'});game.stack:push(screen) +game.save.party[1]=mon();screen.text='STALE';press(screen,'start') +T.eq(game.save.inventory.ITEM_158,2,'stale keyboard target consumes no stationery') +T.eq(game.save.party[1].item,nil,'stale target cannot affect a replacement mon') +T.eq(game.save.mail,nil,'stale target creates no letter') + +-- Native party switches keep letters pinned to their actual carriers. +reset() +local PartyMenu=require('src.ui.PartyMenu') +local carrier=game.save.party[1];carrier.item='ITEM_158' +Mail.set(game.save,1,Mail.entry('ITEM_158','CARRIER')) +local partyScreen=PartyMenu.new(game,{}) +game.stack:push(partyScreen);partyScreen.swapFrom=1;partyScreen.index=2;press(partyScreen,'a') +T.eq(game.save.party[2],carrier,'actual party screen reorders the carrier') +T.eq(game.save.mail.party[2].message,'CARRIER','actual party screen moves its letter') +T.eq(game.save.mail.party[1],nil,'actual party screen clears the old mail slot') + +-- Mailbox actions use the same save and preserve entries on failed moves. +local Mailbox=require('src.ui.Gen2MailboxMenu') +reset();game.save.mail={party={},box={Mail.entry('ITEM_158','ONE','A'),Mail.entry('ITEM_158','TWO','B'),Mail.entry('ITEM_158','THREE','C')}} +local list=Mailbox.new(game);game.stack:push(list);list.index=2;press(list,'a') +local actions=game.stack:top();actions.index=2;press(actions,'a') +local picker=game.stack:top();press(picker,'a') +T.eq(game.save.mail.party[1].message,'TWO','real mailbox GIVE attaches the selected letter') +T.eq(game.save.mail.box[2].message,'THREE','real mailbox compacts after middle deletion') +T.eq(list.items[2].value,2,'display rows follow compacted native indexes') +game.stack.states={parent,list};list.index=2;press(list,'a') +actions=game.stack:top();actions.index=2;press(actions,'a') +picker=game.stack:top();press(picker,'a') +T.eq(#game.save.mail.box,2,'occupied target preserves the mailbox') +T.eq(game.save.mail.party[1].message,'TWO','failed mailbox attachment preserves held letter') +game.stack.states={parent,list};list.index=2;press(list,'a') +actions=game.stack:top();actions.index=3;press(actions,'a') +local warning=game.stack:pop();warning.choice(false) +T.eq(#game.save.mail.box,2,'declining message loss retains mailbox contents') +T.eq(game.save.inventory.ITEM_158,2,'declining message loss adds no stationery') + +-- The compatibility PC's dismissal is caller-owned; its close callback may +-- pop exactly one screen, as existing Gen2 hosts do. +reset();Mail.state(game.save).box={Mail.entry('ITEM_158','PC ENTRY','A')} +local playerpc=require('src.ui.PlayerPC').new(game);game.stack:push(playerpc) +local mailboxRow +for i,row in ipairs(playerpc.items)do if row.label=='MAILBOX' then mailboxRow=i end end +T.check(mailboxRow~=nil,'native player PC contains the mailbox entry') +playerpc.index=mailboxRow;press(playerpc,'a') +T.eq(game.stack:top().items[1].value,1,'player PC opens the actual populated mailbox') + +local BoxMenu=require('src.ui.BoxMenu') +reset();parent=game.stack:top();local calls=0;local pc +pc=BoxMenu.new(game,{onClose=function() + calls=calls+1;T.eq(game.stack:top(),pc,'PC remains on stack during caller-owned close');game.stack:pop() +end}) +game.stack:push(pc);press(pc,'b') +T.eq(calls,1,'PC B invokes one close callback') +T.eq(game.stack:top(),parent,'PC does not pop the parent below its caller') +T.eq(pc.save,game.save,'native PC exposes the actual save required by Gen2 hosts') +local alternate={party={mon(),mon()}} +pc=BoxMenu.new(game,{save=alternate}) +T.eq(pc.save,alternate,'alternate-save native PC uses the supplied save') +T.eq(pc.game.writeSave,false,'alternate-save native PC cannot write the live profile') + +-- Unknown carriers are quarantined by the production save validator. Their +-- detached letters survive the real codec without duplicating stationery. +reset();local unknown=mon();unknown.species='REMOVED_MOD_SPECIES';unknown.item='ITEM_158' +game.save.party[2]=unknown +Mail.set(game.save,2,Mail.entry('ITEM_158','KEEP','MOD',9,unknown.species)) +local Save=require('src.core.SaveData') +local report=Save.validate(game.save,data) +T.eq(game.save.mail.box[1].message,'KEEP','missing-carrier letter is retained in native mailbox') +T.eq(unknown.item,nil,'quarantined carrier cannot duplicate detached stationery') +T.eq(#report.restoredMail,1,'mail recovery is reported') +T.eq(assert(Save.decode(Save.encode(game.save))).mail.box[1].message,'KEEP','quarantined letter survives actual save encoding') +local recovered={orphaned={mail={Mail.entry('ITEM_158','RECOVER')}}} +Mail.validate(recovered,{}) +T.eq(recovered.mail.box[1].message,'RECOVER','pending letters reclaim even when no live mail table exists') + +local run=T.sdk.loadMods({'mods/mail_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/mail_probe/manifest.json']='{"id":"mail_probe","name":"Mail probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/mail_probe/main.lua']=[[ + local mod=... + mod.exports.reads=0;mod.exports.writes=0 + mod.events:on("mail.read",function(e)mod.exports.reads=mod.exports.reads+1;mod.exports.message=e.message end) + mod.events:on("mail.written",function(e)mod.exports.writes=mod.exports.writes+1 end) + ]], +})}) +T.eq(#run.errors,0,'actual SDK mail event subscriptions load') +reset();Mail.load(data);Mail.compose(game.save,1,'PUBLIC',game.save.party[1],'ITEM_158') +Mail.lines(Mail.get(game.save,1));Mail.lines(game.save.mail.party[1]) +local exports=run.loader.exports.mail_probe +T.eq(exports.writes,1,'public SDK receives the native write once') +T.eq(exports.reads,1,'public SDK receives the native read once, not every redraw') +T.eq(exports.message,'PUBLIC','public SDK receives actual letter contents') +run.release() +V.set(original);Mail.load(nil) +T.finish('native GSC mail keyboard, mailbox, party and PC menu transactions') From 0cd5b7ab725ddc4fd672c4c8a57242ebc0ecc8ca Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:47:41 +0100 Subject: [PATCH 07/68] Run native GSC bedroom decorations through ROM tables, PC and room callbacks --- docs/native-gen2-decorations.md | 83 ++++++++++ src/import/RomExtractorGen2.lua | 134 +++++++++++++++- src/import/RomImporter.lua | 2 +- src/mods/DecorationContent.lua | 100 ++++++++++++ src/mods/Loader.lua | 9 +- src/mods/Schemas.lua | 7 + src/save_convert/Gen2Save.lua | 24 +++ src/script/Gen2Commands.lua | 27 ++++ src/script/Gen2ScriptVM.lua | 25 ++- src/script/Gen2Specials.lua | 14 +- src/ui/Gen2DecorationMenu.lua | 106 +++++++++++++ src/ui/PlayerPC.lua | 20 ++- src/world/Gen2Decorations.lua | 133 ++++++++++++++++ tests/engine/gen2_decoration_content.lua | 194 +++++++++++++++++++++++ tests/engine/gen2_decoration_import.lua | 113 +++++++++++++ 15 files changed, 974 insertions(+), 17 deletions(-) create mode 100644 docs/native-gen2-decorations.md create mode 100644 src/mods/DecorationContent.lua create mode 100644 src/ui/Gen2DecorationMenu.lua create mode 100644 src/world/Gen2Decorations.lua create mode 100644 tests/engine/gen2_decoration_content.lua create mode 100644 tests/engine/gen2_decoration_import.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md new file mode 100644 index 000000000..22c63ead3 --- /dev/null +++ b/docs/native-gen2-decorations.md @@ -0,0 +1,83 @@ +# Native GSC bedroom decorations + +The engine imports decoration attributes, display names, default placed bytes, +gift ordinals and room-description entry points from the user's current +Gold/Silver/Crystal cartridge. No retail attribute catalogue, dialogue or room +art is bundled. The virtual `decorations` registry writes the same DECO-byte +records that native room callbacks and the player's bedroom PC consume. + +```lua +local decorations = mod.content.decorations +local available, reason = decorations:supports() +if available then + decorations:patch("deco:2", { name = "CUSTOM" }) +end +``` + +Native API 1/2 use `deco:0` through `deco:255`. The number is +the cartridge's DECO byte, not a sorted list index. Records contain `type` +(1..6), `name`, optional `action`, `flag` (native event word) and `sprite` +(native object-sprite or map-block byte). Only the fourteen known +`SET_UP_`/`PUT_AWAY_` actions are accepted. Cancel and put-away rows do not grant +ownership. API views are detached; invalid/failed mod entries roll back and +reload retires owned writes individually while retaining external edits. +Removing a record removes both numeric and string aliases of its byte. + +The importer derives the attribute count from the adjacent native names-table +boundary and reads six bytes per row. Invalid/truncated attributes or names +publish no partial table. Species-backed names keep their native species ID, +so later species renames remain visible unless the decoration itself is +renamed. Native initialization is recognized only when the actual instruction +operands store valid bed/poster IDs to the corresponding WRAM symbols. Missing +initialization does not invent retail defaults. Existing placed state, +including an explicitly empty table, is retained. + +The house-PC option appears only with an imported native table. Owned records +form the native categories; their put-away headers and cancel choice follow +the records, with eight visible rows and the native right/left/cancel order +for ornaments. Feedback and side prompts enter the normal ROM text importer +and `TextBox` path. A replacement uses buffers three/four for old/new names. +State changes commit after feedback closes. Moving an ornament clears an +opposite duplicate; selecting the same ornament does not mutate the opposite +side. Ordinary item-storage PCs do not gain this option. + +`PlayersHousePC` waits until LOG OFF/B and returns the accumulated change bit. +The exact GSC `warp NONE` pair reloads the current map after the script ends, +without relocating the player to cell zero. Unknown map pairs are not reloads. +The existing native callback queue then applies placed object visibility, +variable sprites and room blocks. Description bytecode branches into the +actual imported poster/ornament/big-doll scripts rather than being skipped. +Block zero is valid for a nonzero DECO byte; carpet offsets wrap as native +bytes. An empty slot writes no block, leaving the newly loaded bare room. + +Raw SRAM conversion reads/writes the eight placed bytes using the selected +edition's WRAM-to-SRAM layout. The physical order ends with the big doll and +is distinct from the public slot list. Absent legacy decoration state leaves +the imported template intact; invalid placed bytes fail instead of wrapping. +Unreviewed hack layouts receive neither guessed retail tables nor guessed +save offsets. + +## Evidence and remaining scope + +ROM-free tests exercise actual importers, Loader, native script compiler and +runner, menus, TextBox, StateStack, ownership flags, callback commands and save +codec. The eight save offsets are independently checked against each shipped +GSC symbol manifest. Test cartridges and dialogue are authored synthetic data. +The callback/warp tests use controlled overworld presentation/queue boundaries; +they are not GPU-render or complete-game acceptance tests. + +A separate private read-only Crystal probe independently compares all 53 +attribute rows' numeric bytes, imports 45 gift ordinals and the native defaults, +executes the original house-PC/description scripts and room callbacks, and +round-trips all eight placed bytes. Original ROM hashes are checked unchanged. +The local font/audio/render harness is used; no visual acceptance is inferred. + +Full legacy `Decorations` facade parity, the complete gift/purchase lifecycle, +new graphical acceptance and a full unchanged VASC playthrough remain separate +open work. Importing the gift-ordinal table alone does not implement those +callers. The existing preview is not refreshed by this source change. + +Contract references: [Crystal decoration routines](https://github.com/pret/pokecrystal/blob/master/engine/overworld/decorations.asm), +[Gold bedroom script](https://github.com/pret/pokegold/blob/master/maps/PlayersHouse2F.asm). +Original fork additions: [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..c8f354e22 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3317,6 +3317,133 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Six native bytes per attribute row: kind/name/action/event-word/sprite. +-- Names immediately follow attributes in stock GSC; that boundary supplies +-- the row count instead of a copied retail catalogue. +function RomExtractorGen2:gen2Decorations() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} + local attr,names=self:symbol("DecorationAttributes"),self:symbol("DecorationNames") + local function fail(why)info.reason=why;return {},info end + if not attr or not names then return fail("missing decoration table symbols")end + local count=(names.address-attr.address)/6 + if attr.bank~=names.bank or count%1~=0 or count<1 or count>256 or + attr.address<0x4000 or names.address>0x8000 then return fail("invalid decoration table boundary")end + local Native=require("src.world.Gen2Decorations") + local ok,rows=pcall(function() + local raw,maxName={},0 + for i=0,count-1 do + local r=self.rom:bytes(attr.bank,attr.address+i*6,6) + assert(r[1]>=1 and r[1]<=6,"invalid decoration kind") + assert(r[3]==0 or Native.actionName(r[3]),"invalid decoration action") + assert(r[4]+r[5]*256<65535,"indirect decoration event") + raw[i]=r;if r[1]<=3 then maxName=math.max(maxName,r[2])end + end + local charmap=self:readSourceTable("charmap") + local words,pc={},names.address + for i=0,maxName do + local bytes,ended={},false + for _=1,128 do + assert(pc<0x8000,"decoration name crosses bank") + local b=self.rom:byte(names.bank,pc);pc=pc+1 + if b==0x50 then ended=true;break end + bytes[#bytes+1]=b + end + assert(ended,"unterminated decoration name") + words[i]=self:gen2GlyphBytes(bytes,charmap) + end + local pokemon=self:symbol("PokemonNames") + local out={} + for i=0,count-1 do + local r=raw[i];local name=words[r[2]] + if r[1]>=4 then + assert(pokemon and r[2]>=1,"missing decoration species name") + local address=pokemon.address+(r[2]-1)*10 + assert(address>=0x4000 and address+10<=0x8000,"decoration species name crosses bank") + local bytes={} + for n=0,9 do local b=self.rom:byte(pokemon.bank,address+n);if b==0x50 then break end;bytes[#bytes+1]=b end + name=self:gen2GlyphBytes(bytes,charmap) + info.species=info.species or{};info.sourceNames=info.sourceNames or{} + info.species[i]=string.format("SPECIES_%03d",r[2]);info.sourceNames[i]=name + end + assert(type(name)=="string" and name~="","missing decoration name") + out[i]={type=r[1],name=name,action=Native.actionName(r[3]),flag=r[4]+r[5]*256,sprite=r[6]} + end + return out + end) + if not ok then return fail(tostring(rows))end + local init=self:symbol("InitDecorations") + if init then + local valid,bytes=pcall(self.rom.bytes,self.rom,init.bank,init.address,11) + if valid and bytes[1]==0x3e and bytes[3]==0xea and bytes[6]==0x3e and bytes[8]==0xea and bytes[11]==0xc9 and + rows[bytes[2]] and rows[bytes[2]].action=="SET_UP_BED" and rows[bytes[7]] and rows[bytes[7]].action=="SET_UP_POSTER" then + local bed,poster=self:symbol("wDecoBed"),self:symbol("wDecoPoster") + if bed and poster and bytes[4]+bytes[5]*256==bed.address and bytes[9]+bytes[10]*256==poster.address then + info.defaults={bed=bytes[2],poster=bytes[7]};info.defaultsComplete=true + end + end + end + local gifts=self:symbol("DecorationIDs") + if gifts then + local valid,list=pcall(function() + local out={} + for n=0,255 do + assert(gifts.address+n<0x8000,"decoration gift table crosses bank") + local id=self.rom:byte(gifts.bank,gifts.address+n) + if id==255 then return out end + assert(rows[id] and Native.action(rows[id].action) and not Native.action(rows[id].action).put,"invalid gift decoration") + out[#out+1]=id + end + error("unterminated decoration gift table") + end) + if valid then info.gifts=list;info.giftsComplete=true end + end + info.complete=true;info.rowsRead=count + return rows,info +end + +function RomExtractorGen2:gen2QueueDecorationPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local roles={ornament="DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript", + big="DecorationDesc_GiantOrnament.BigDollScript",null="DecorationDesc_NullPoster"} + local found={} + for role,name in pairs(roles)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return nil end + found[role]=at + end + local pointers=self:symbol("DecorationDesc_PosterPointers") + if not pointers then return nil end + local ok,posters=pcall(function() + local out={} + for n=0,255 do + local pc=pointers.address+n*3;assert(pc<0x8000,"poster pointers cross bank") + local id=self.rom:byte(pointers.bank,pc) + if id==255 then return out end + assert(pc+2<0x8000,"truncated poster pointer") + local address=self.rom:word(pointers.bank,pc+1) + assert(address>=0x4000 and self:gen2InRom(pointers.bank,address)and not out[id],"invalid poster pointer") + out[id]={bank=pointers.bank,address=address} + end + error("unterminated poster pointers") + end) + if not ok then return nil end + local out={posters={},texts={}} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for role,name in pairs({put="PutAwayTheDecoText",empty="NothingToPutAwayText", + setup="SetUpTheDecoText",replace="PutAwayAndSetUpText",same="AlreadySetUpText", + noChoice="PopulateDecoCategoryMenu.NothingToChooseText", + sideSetup="WhichSidePutOnText",sidePut="WhichSidePutAwayText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then + out.texts[role]=self:gen2RegisterScriptText(at.bank,at.address) + end + end + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + for id,at in pairs(posters)do out.posters[id]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -8126,7 +8253,10 @@ function RomExtractorGen2:gen2ResolveScriptMapIds(maps, keys, pool) if slot and type(row[slot]) == "number" and type(row[slot + 1]) == "number" then local entry = mapIndex[row[slot] * 256 + row[slot + 1]] local key = entry and keyByLabel[entry.label] - if key then + if (row[1]=="warp"or row[1]=="warpfacing") and row[slot]==0 and row[slot+1]==0 and + (self.version=="gold"or self.version=="silver"or self.version=="crystal")then + row[slot]="NONE";row[slot+1]=0 + elseif key then row[slot] = key row[slot + 1] = 0 end @@ -8891,6 +9021,8 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.decorations,out.decorationInfo=self:gen2Decorations() + out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..d0e8c48bf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-decorations-native-v1:gen2-field-encounters-v8:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua new file mode 100644 index 000000000..e6d1ddd16 --- /dev/null +++ b/src/mods/DecorationContent.lua @@ -0,0 +1,100 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- A DECO byte is identity, not the sorted position of a mod record. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Decorations') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table'or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function byte(id) + local digits=type(id)=='string'and id:match('^deco:(%d+)$') + local n=digits and tonumber(digits) + assert(n and n>=0 and n<=255 and 'deco:'..n==id,'decorations: expected canonical deco: ID') + return n +end +local function finite(value,seen) + if type(value)=='number'then assert(value==value and math.abs(value)=0 and n<=255 and type(row)=='table'then + state.base['deco:'..n]=copy(data.map_scripts.decorations[n]or row) + end + end + end + mounted[loader]=state + loader.content.decorations.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.decorations;local state=mounted[loader] + if not(state and state.available)or not next(registry.ops)then return end + local pool=data.map_scripts;local before=pool.decorations;local out={} + for key,row in pairs(before)do if tonumber(key)==nil then out[key]=row end end + for id,value in registry:each()do out[byte(id)]=registry.ops[id]and record(registry,id,value)or copy(value)end + local changed={};local keys={} + for key in pairs(before)do keys[key]=true end;for key in pairs(out)do keys[key]=true end + for key in pairs(keys)do + if before[key]~=out[key]then changed[key]={before=before[key],value=out[key],snapshot=copy(out[key])}end + end + pool.decorations=out;previous[data]={pool=pool,before=before,after=out,changed=changed} +end +function M.api(loader,mod,native,generation) + local state=mounted[loader] + local available=generation==2 and state and state.available or false + local reason=not available and 'unsupported: decorations requires an imported native GSC attribute table'or nil + local registry=loader.content.decorations + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;byte(id);return copy(native:get(id))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each();return function()local id,value=nextRow();return id,copy(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + byte(id) + if verb=='remove'then return native:remove(id)end + finite(value);assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') + for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end + local effective=verb=='patch'and Merge.deepMerge(copy(registry:get(id)or{}),copy(value),'record')or value + effective=record(registry,id,effective);native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..22977dbfc 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,11 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name=="decorations"then + return require("src.mods.DecorationContent").api(loader,mod,api,loader:_generation()) + end + return api + end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1331,7 @@ function Loader:load(data) end end end + require("src.mods.DecorationContent").prepare(self,data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1551,6 +1557,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.DecorationContent").apply(self,data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..dd69d2166 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1257,6 +1257,13 @@ R.text_pointers = { example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', } +R.decorations = { + semantics="record",generations="2", + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0,65534),sprite=f.int(0,255)}, + example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', + notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", +} + -- ------- persistence -- compose, keyed by the owning mod id: the runner walks each owner's chain diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 79a875bd7..10ae779eb 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,7 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, partyCount = 0xDA22, partySpecies = 0xDA23, partyMons = 0xDA2A, @@ -268,6 +269,7 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, partyCount = 0xDCD7, partySpecies = 0xDCD8, partyMons = 0xDCDF, @@ -365,6 +367,11 @@ end function Gen2Save.setLayout(version) applyLayout((type(version) == "table" and version) or LAYOUTS[tostring(version)] or LAYOUT_GEN2) + -- An unreviewed hack's legacy fallback layout does not prove where its + -- decoration bytes live. Explicit custom layouts may name their own run. + if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then + O.decorations=nil + end end applyLayout(LAYOUT_GEN2) @@ -1024,6 +1031,9 @@ end -- decode: raw 32768-byte SRAM string -> save.lua-shaped table -- ------------------------------------------------------------------ +-- Physical WRAM order differs from the public semantic slot order. +local DECO_SLOTS={"bed","carpet","plant","poster","console","leftOrnament","rightOrnament","bigDoll"} + function Gen2Save.decode(bytes, data) assert(#bytes == Gen2Save.SAVE_SIZE, "expected a 32768-byte save") assert(charmap, "Gen2Save.setCharmap must be called before decode") @@ -1060,6 +1070,11 @@ function Gen2Save.decode(bytes, data) currentBox = 1, } + if O.decorations then + save.decorations={} + for i,key in ipairs(DECO_SLOTS)do save.decorations[key]=u8(bytes,O.decorations+i-1)end + end + -- bag: four pockets folded into one flat inventory local bagOrder = {} decodePairList(bytes, O.items, MAX_ITEMS, cw, save.inventory, bagOrder) @@ -1240,6 +1255,15 @@ function Gen2Save.encode(save, data, template) for i = 1, Gen2Save.SAVE_SIZE do buf[i] = zero end end + if O.decorations and type(save.decorations)=="table"then + for i,key in ipairs(DECO_SLOTS)do + local value=save.decorations[key]or 0 + assert(type(value)=="number"and value>=0 and value<=255 and value%1==0, + "invalid native decoration byte: "..key) + setByte(buf,O.decorations+i-1,value) + end + end + local player = save.player or {} local playerName = player.name or "GOLD" encodeName(buf, O.playerName, NAME_LENGTH, playerName) diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..9a394bc36 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -896,6 +896,20 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_deco_visibility(ctx) + require('src.world.Gen2Decorations').visibility(ctx) +end +function Commands.g2_deco_tiles(ctx) + require('src.world.Gen2Decorations').tiles(ctx) +end +function Commands.g2_deco_description(ctx,kind,allowed) + local target,name=require('src.world.Gen2Decorations').description(ctx.game.data,ctx.save,kind) + if target and allowed and allowed[target]then + if name then setBuffer(ctx.game,3,name)end + return target + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here @@ -1779,6 +1793,19 @@ end -- --------------------------------------------------------------------------- function Commands.g2_warp(ctx, mapId, x, y, facing) + if mapId=="NONE" then + local ow=ctx.overworld + if ow and ow.map and type(ow.reloadMap)=="function"then + local map=ow.map.id + ctx.afterScript=ctx.afterScript or{} + ctx.afterScript[#ctx.afterScript+1]=function() + if ow.player and DIRS[facing]then ow.player.facing=DIRS[facing]end + ow:reloadMap(map,"native-script") + end + return math.huge + end + return + end if type(mapId) ~= "string" or mapId == "" then return end Commands.warp(ctx, mapId, x, y, facing) end diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..08070b97b 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -278,19 +278,30 @@ L.givepokemail = function() end -- NPC instead. -- loadtemptrainer/randomwildmon/loadpikachudata build a battle out of WRAM -- scratch the port fills from its own encounter tables. --- _2dmenu/describedecoration are presentation the port has no +-- _2dmenu is presentation the port has no -- equivalent for; the surrounding dialogue still runs. -- autoinput replays a canned joypad script over a cutscene. -- (catchtutorial used to live here too; it is implemented now, below.) local function vmNoop() end for _, name in ipairs({ "xycompare", "writeobjectxy", "loadtemptrainer", "randomwildmon", - "loadpikachudata", "_2dmenu", "describedecoration", + "loadpikachudata", "_2dmenu", "autoinput", }) do L[name] = vmNoop end +L.describedecoration=function(ir,s) + local p=s.decorationPrograms + if type(p)~='table'then return end + local allowed={} + for _,label in pairs(p.posters or{})do local target=branch(s,label);if target then allowed[target]=true end end + for _,role in ipairs({'ornament','big','null'})do + local target=branch(s,p[role]);if target then allowed[target]=true end + end + emit(s,{'g2_deco_description',ir[2],allowed}) +end + L["end"] = function(_, s) emit(s, { "g2_return" }) end @@ -1080,6 +1091,12 @@ local SPECIALS = { PokemonCenterPC = "g2_pokemon_center_pc", Special_PokemonCenterPC = "g2_pokemon_center_pc", PlayersHousePC = "g2_players_house_pc", + ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleDecorationsVisibilitySpecial = "g2_deco_visibility", + Special_ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleMaptileDecorations = "g2_deco_tiles", + ToggleMaptileDecorationsSpecial = "g2_deco_tiles", + Special_ToggleMaptileDecorations = "g2_deco_tiles", Special_PlayersHousePC = "g2_players_house_pc", Special_KrissHousePC = "g2_players_house_pc", MoveTutor = "g2_move_tutor", @@ -1215,9 +1232,6 @@ local SPECIALS_NOOP = { PlayCurMonCry = true, PlaySlowCry = true, SurfStartStep = true, - -- decorations are not modelled, so toggling their visibility does nothing - ToggleDecorationsVisibility = true, - ToggleMaptileDecorations = true, UnusedDummySpecial = true, UnusedBattleTowerDummySpecial1 = true, UnusedBattleTowerDummySpecial2 = true, @@ -2380,6 +2394,7 @@ function Gen2ScriptVM.compile(data, entry) local state = { out = out, stds = pool.stds, + decorationPrograms = pool.decorationPrograms, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index b554b340e..4c3cd65df 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -716,12 +716,16 @@ function Commands.g2_pokemon_center_pc(ctx) answer(ctx, 0) end --- PlayerPC is a Menu of its own, so it closes on its LOG OFF row and on B. --- Neither reaches this script, so there is nothing to wait on: push it and --- answer, exactly as g2_card_flip does. +-- The house script branches on the accumulated decoration-change bit only +-- after LOG OFF/B. Returning before the menu closes silently skips its reload. function Commands.g2_players_house_pc(ctx) - require("src.ui.Screens").push(ctx.game, "PlayerPC") - answer(ctx, 0) + local runner=ctx.runner + local done,changed=false,false + require("src.ui.Screens").push(ctx.game,"PlayerPC",{house=true,onDone=function(value) + if done then return end;done=true;changed=value==true;runner:resume() + end}) + runner:yield() + answer(ctx,changed and 1 or 0) end -- --------------------------------------------------------------------------- diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua new file mode 100644 index 000000000..a091734cb --- /dev/null +++ b/src/ui/Gen2DecorationMenu.lua @@ -0,0 +1,106 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- The PC changes placed bytes. Room callbacks render them after native reload. +local Native=require('src.world.Gen2Decorations') +local Menu=require('src.ui.Menu') +local Runner=require('src.script.ScriptRunner') +local Strings=require('src.core.Strings') +local M={} +local categories={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.new(game,opts) + opts=opts or{} + local changed,finished=false,false + local programs=Native.pool(game.data)and Native.pool(game.data).decorationPrograms + local texts=programs and programs.texts or{} + local function finish() + if finished then return end;finished=true + if opts.onDone then opts.onDone(changed)end + end + local function say(role,onDone,extra) + local id=texts[role] + if not id then if onDone then onDone()end;return end + local r=Runner.new(game,game.overworld) + r:run({{'show_text',id,nil,extra}}, {onDone=onDone}) + return r + end + local function perform(id,side) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + local state=Native.state(game.data,game.save) + if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end + local slot=action.slot or(side=='left'and 'leftOrnament'or side=='right'and 'rightOrnament') + if not slot then return end + local old=state[slot]or 0 + game.stringBuffers=game.stringBuffers or{} + game.stringBuffers[3]=Native.name(game.data,old) + game.stringBuffers[4]=Native.name(game.data,id) + local role + if action.put then role=old==0 and 'empty'or 'put' + elseif old==id then role='same' + elseif old==0 then role='setup';game.stringBuffers[3]=Native.name(game.data,id) + else role='replace'end + say(role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + end + local function select(id) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + if not action then return end + if not action.ornament then perform(id);return end + local selected,question + local function close(side) + selected=side + if question then game.stack:pop();question:resume() + elseif side then perform(id,side)end + end + local function sides() + game.stack:push(Menu.new(game,{ + {label=Strings('RIGHT SIDE'),onSelect=function()close('right')end}, + {label=Strings('LEFT SIDE'),onSelect=function()close('left')end}, + {label=Strings('CANCEL'),onSelect=function()close()end}, + },{tx=6,ty=6,tw=14,th=8,noSound=true,onCancel=function()close()end})) + end + local role=action.put and 'sidePut'or 'sideSetup' + if texts[role]then + question=say(role,function()if selected then perform(id,selected)end end,{stay={onShown=sides}}) + else sides()end + end + local function category(key) + local items,headers,seen={},{},{} + for raw,row in pairs(Native.pool(game.data).decorations)do + local id=tonumber(raw);local action=Native.action(row.action) + local group=action and(action.slot or 'ornament') + if id and id~=0 and group==key and not seen[id]then + seen[id]=true + if action.put then headers[#headers+1]=id + elseif Native.owns(game.data,game.save,id)then items[#items+1]=id end + end + end + table.sort(items);table.sort(headers) + return items,headers + end + local rows={} + if Native.ready(game.data)and Native.state(game.data,game.save)then + for _,c in ipairs(categories)do + local key=c[2];local ids=category(key) + if #ids>0 then + rows[#rows+1]={label=Strings(c[1]),keepOpen=true,onSelect=function() + local owned,headers=category(key) + if #owned==0 then say('noChoice');return end + local items={} + for _,id in ipairs(owned)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + for _,id in ipairs(headers)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + items[#items+1]={label=Native.name(game.data,0)~=''and Native.name(game.data,0)or Strings('CANCEL')} + game.stack:push(Menu.new(game,items,{tx=0,ty=0,tw=20,maxVisible=8,noSound=true})) + end} + end + end + end + rows[#rows+1]={label=Strings('EXIT'),onSelect=finish} + return Menu.new(game,rows,{tx=5,ty=0,tw=15,maxVisible=8,noSound=true,onCancel=finish}) +end +return M diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 8203e0800..704b885a1 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -169,19 +169,31 @@ PlayerPC.withdraw = withdraw PlayerPC.deposit = deposit PlayerPC.toss = toss -function PlayerPC.new(game) +function PlayerPC.new(game,opts) + opts=opts or{} + local changed,done=false,false + local function finish() + if done then return end;done=true + if opts.onDone then opts.onDone(changed)end + end game.save.pcItems = game.save.pcItems or {} - return Menu.new(game, { + local items={ -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, - { label = Strings("LOG OFF") }, + { label = Strings("LOG OFF"),onSelect=finish }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + } + if opts.house and require("src.world.Gen2Decorations").ready(game.data)then + table.insert(items,#items,{label=Strings("DECORATION"),keepOpen=true,onSelect=function() + require("src.ui.Screens").push(game,"Gen2DecorationMenu",{onDone=function(value)changed=value or changed end}) + end}) + end + return Menu.new(game,items,{tx=0,ty=0,tw=16,maxVisible=8,noSound=true,onCancel=finish}) end return PlayerPC diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua new file mode 100644 index 000000000..d7eab2a3b --- /dev/null +++ b/src/world/Gen2Decorations.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native ownership flags, placed bytes and callback presentation are distinct. +-- Attribute/name rows come from the current cartridge, never a retail table. +local V=require('src.core.GameVersion') +local Strings=require('src.core.Strings') +local M={} +M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} +local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} +local actions={} +for _,kind in ipairs(kinds)do + actions[#actions+1]='SET_UP_'..kind;actions[#actions+1]='PUT_AWAY_'..kind +end +function M.actionName(n)return actions[n]end +function M.action(name) + if type(name)~='string'then return nil end + local kind=name:match('^SET_UP_(.+)$');local put=false + if not kind then kind=name:match('^PUT_AWAY_(.+)$');put=true end + if kind=='DOLL'then return{ornament=true,put=put}end + return slotFor[kind] and {slot=slotFor[kind],put=put}or nil +end +function M.pool(data)return data and data.map_scripts end +function M.ready(data) + local p=M.pool(data);local info=p and p.decorationInfo + return V.generation(V.get())==2 and type(p)=='table' and type(p.decorations)=='table' and + type(info)=='table' and info.complete==true or false +end +function M.attributes(data,id) + if not M.ready(data)then return nil end + local rows=M.pool(data).decorations + return rows[tonumber(id)]or rows[tostring(id)] +end +function M.state(data,save) + if not M.ready(data)or type(save)~='table'then return nil end + if save.decorations==nil then + local info=M.pool(data).decorationInfo + if not info.defaultsComplete then return nil end + save.decorations=require('src.mods.Merge').deepCopy(info.defaults or{}) + end + return type(save.decorations)=='table'and save.decorations or nil +end +local names={ + [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), + [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), +} +function M.name(data,id) + local row=M.attributes(data,id) + if not row then return ''end + local name=row.name + if row.type>=4 then + local info=M.pool(data).decorationInfo + local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name + local mon=data.pokemon and data.pokemon[key] + name=mon and mon.name or name + end + return names[row.type] and Strings(names[row.type],name)or name +end +function M.owns(data,save,id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, + require('src.script.Gen2Flags').eventFlag(row.flag))or false +end +function M.give(data,save,id) + local row=M.attributes(data,id) + if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end + save.flags=save.flags or{} + require('src.script.Flags').set(save,require('src.script.Gen2Flags').eventFlag(row.flag));return true +end +function M.giveFlag(data,save,n) + local info=M.pool(data)and M.pool(data).decorationInfo + local id=info and info.gifts and info.gifts[(tonumber(n)or -1)+1] + return id~=nil and M.give(data,save,id)or false +end +function M.choose(data,save,id,side) + local state=M.state(data,save);local row=M.attributes(data,id) + local action=row and M.action(row.action) + if id==0 or not(state and action)then return false end + if not action.put and not M.owns(data,save,id)then return false end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return false end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local value=action.put and 0 or id + local changed=(state[slot]or 0)~=value + state[slot]=value + if changed and action.ornament and not action.put then + local other=side=='left'and 'rightOrnament'or 'leftOrnament' + if state[other]==id then state[other]=0 end + end + return changed +end +-- These callbacks deliberately preserve the VM's current script/check vars. +function M.visibility(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') + for _,binding in ipairs({{'console',0,1857},{'leftOrnament',1,1858},{'rightOrnament',2,1859},{'bigDoll',3,1860}})do + local id=state[binding[1]]or 0;local row=M.attributes(data,id) + if id~=0 and row then + C.g2_variablesprite(ctx,binding[2],row.sprite);C.clear_flag(ctx,Flags.eventFlag(binding[3])) + else C.set_flag(ctx,Flags.eventFlag(binding[3]))end + end +end +function M.tiles(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands') + local function paint(slot,x,y) + local id=state[slot]or 0;local row=id~=0 and M.attributes(data,id) + if row then C.replace_block(ctx,x,y,row.sprite);return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + C.replace_block(ctx,0,1,(carpet+1)%256);C.replace_block(ctx,1,1,(carpet+2)%256) + C.replace_block(ctx,2,1,(carpet+1)%256) + end + local flag=require('src.script.Gen2Flags').eventFlag(716) + if (state.poster or 0)~=0 then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end +end +function M.description(data,save,kind) + local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms + if not state or type(programs)~='table'then return nil end + if kind==0 then return programs.posters and programs.posters[state.poster or 0]or programs.null end + if kind==3 then return programs.big end + local slot=({[1]='leftOrnament',[2]='rightOrnament',[4]='console'})[kind] + if slot then return programs.ornament,M.name(data,state[slot]or 0)end +end +return M diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua new file mode 100644 index 000000000..2b5162754 --- /dev/null +++ b/tests/engine/gen2_decoration_content.lua @@ -0,0 +1,194 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local Native=require('src.world.Gen2Decorations');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Stack=require('src.core.StateStack');local TextBox=require('src.render.TextBox') +local Merge=require('src.mods.Merge') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='CANCEL',flag=0,sprite=0}, + [1]={type=1,name='STORE BED',action='PUT_AWAY_BED',flag=0,sprite=0}, + [2]={type=2,name='SYNTHETIC',action='SET_UP_BED',flag=500,sprite=0}, + [3]={type=2,name='OTHER',action='SET_UP_BED',flag=501,sprite=44}, + [4]={type=1,name='STORE DOLL',action='PUT_AWAY_DOLL',flag=0,sprite=0}, + [5]={type=5,name='MODEL',action='SET_UP_DOLL',flag=502,sprite=18}, + [6]={type=1,name='CONSOLE',action='SET_UP_CONSOLE',flag=503,sprite=20}, + [7]={type=1,name='MAP',action='SET_UP_POSTER',flag=504,sprite=22}, + [8]={type=1,name='RUG',action='SET_UP_CARPET',flag=505,sprite=255}, + [9]={type=1,name='PLANT',action='SET_UP_PLANT',flag=506,sprite=24}, + [10]={type=6,name='MODEL',action='SET_UP_BIG_DOLL',flag=507,sprite=26}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=2,poster=7}, + giftsComplete=true,gifts={2,3,5},sourceNames={[5]='MODEL'},species={[5]='SPECIES_001'}}, + decorationPrograms={posters={[7]='POSTER'},null='NULL',ornament='ORNAMENT',big='BIG', + texts={put='PUT',empty='EMPTY',setup='SET',replace='REPLACE',same='SAME',sidePut='SIDE_PUT',sideSetup='SIDE_SET'}}, + scripts={CALLBACK={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'endcallback'}}, + DESC={{'describedecoration',1}},POSTER={{'setval',70},{'end'}},NULL={{'setval',71},{'end'}}, + ORNAMENT={{'setval',72},{'end'}},BIG={{'setval',73},{'end'}}, + PC={{'special',0,'PlayersHousePC'},{'iftrue','RELOAD'},{'end'}},RELOAD={{'warp','NONE',0,0,0},{'end'}}}} + d.text={PUT='{RAM:wStringBuffer3}',SET='{RAM:wStringBuffer3}',REPLACE='{RAM:wStringBuffer3}/{RAM:wStringBuffer4}', + SAME='SYNTHETIC SAME',EMPTY='SYNTHETIC EMPTY',SIDE_SET='SYNTHETIC SIDE SET',SIDE_PUT='SYNTHETIC SIDE PUT'} + d.pokemon.SPECIES_001={name='RENAMED'} + return d +end +local function load(d,code,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/deco/manifest.json']=Json.encode({id='deco',name='Synthetic decorations',version='1.0.0', + api=mode and mode[1]or 2,content_format=mode and mode[2]or 'native',entry='main.lua',affects_link=false}) + files['mods/deco/main.lua']='local mod=...;mod.exports.api=mod;'..code;paths={'mods/deco'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + local g={data=d,save={flags={},inventory={}},stack=stack,input={wasPressed=function()return false end}} + g.overworld={map={id='ROOM'},player={cellX=4,cellY=5,facing='left'},paint={},reloaded=0} + function g.overworld:replaceBlock(x,y,n)self.paint[x..','..y]=n end + function g.overworld:reloadMap(id)assert(id=='ROOM');self.reloaded=self.reloaded+1;Native.visibility({game=g,save=g.save});Native.tiles({game=g,save=g.save,overworld=self})end + return g +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local function choose(g,label) + local menu=g.stack:top() + for i,row in ipairs(menu.items)do if row.label==label then menu.index=i;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0) + g.input={wasPressed=function()return false end};return end end + error('missing menu choice '..label) +end +local function dismiss(g) + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local before=d.map_scripts.decorations + local r=load(d,[[ + local p=mod.content["decorations"] + assert(p:supports()) + local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) + p:patch('deco:2',{sprite=88}) + p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) + ]],mode) + local label=version..' API '..mode[1]..' '..mode[2] + T.eq(#r.errors,0,label..' actual Loader accepts native DECO registry') + T.eq(d.map_scripts.decorations[2].sprite,88,label..' patch reaches actual native table') + T.eq(d.map_scripts.decorations[200].flag,900,label..' new byte reaches actual native table') + local g=game(d);T.eq(Native.state(d,g.save).bed,2,label..' initializes cartridge default once') + T.check(Native.give(d,g.save,200),label..' give owns authored record through actual event') + T.eq(g.save.flags[Flags.eventFlag(900)],true,label..' ownership is the authored flag') + T.check(Native.choose(d,g.save,200),label..' owned authored placement succeeds') + run(g,'CALLBACK') + T.eq(g.overworld.paint['3,2'],77,label..' actual VM callback paints authored block') + T.eq(g.overworld.paint['0,2'],88,label..' actual VM callback paints patched bed') + T.eq(g.save.flags[Flags.eventFlag(1857)],true,label..' empty console object is hidden') + T.eq(g.save.flags[Flags.eventFlag(716)],true,label..' placed poster enables native event') + finish(r) + local clean=load(d) + T.eq(d.map_scripts.decorations,before,label..' disabling restores pristine table identity') + T.eq(d.map_scripts.decorations[200],nil,label..' disabling removes owned addition') + finish(clean) + end + local d=fixture();local g=game(d) + T.eq(Native.choose(d,g.save,3),false,version..' unowned placement fails') + T.eq(Native.give(d,g.save,0),false,version..' CANCEL cannot be awarded') + T.eq(Native.give(d,g.save,1),false,version..' PUT header cannot be awarded') + T.check(Native.giveFlag(d,g.save,1),version..' actual gift ordinal awards real DECO byte') + T.eq(Native.owns(d,g.save,3),true,version..' gift ownership reaches event bit') + T.eq(Native.name(d,5),'RENAMED DOLL',version..' imported species identity follows actual name') + d.map_scripts.decorations[5].name='AUTHORED';T.eq(Native.name(d,5),'AUTHORED DOLL',version..' authored name wins old imported source') + for id=2,10 do Native.give(d,g.save,id)end + local state=Native.state(d,g.save) + state.leftOrnament=5;state.rightOrnament=5 + T.eq(Native.choose(d,g.save,5,'left'),false,version..' already placed doll is not a change') + T.eq(state.rightOrnament,5,version..' already placed doll does not mutate opposite side') + state.leftOrnament=0 + T.eq(Native.choose(d,g.save,5,'left'),true,version..' owned doll moves sides') + T.eq(state.rightOrnament,0,version..' real move clears opposite duplicate') + Native.choose(d,g.save,6);Native.choose(d,g.save,8);Native.choose(d,g.save,10) + local callback=run(g,'CALLBACK') + T.eq(g.save.gen2VarSprites[0],20,version..' console writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[1],18,version..' left doll writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[3],26,version..' big doll writes actual native variable sprite') + T.eq(g.overworld.paint['0,2'],0,version..' zero is valid block for nonzero placed DECO') + T.eq(g.overworld.paint['0,1'],0,version..' carpet offsets wrap at byte boundary') + T.eq(g.overworld.paint['1,1'],1,version..' carpet second offset wraps at byte boundary') + local desc=run(g,'DESC') + T.eq(desc.ctx.g2Var,72,version..' native describedecoration jumps to imported ornament script') + T.eq(g.stringBuffers[3],'AUTHORED DOLL',version..' native description fills real buffer three') + -- Real menus, native VM, TextBox and StateStack; only render/audio are harness IO. + g=game(fixture());Native.give(g.data,g.save,3);Native.give(g.data,g.save,5) + local pc=run(g,'PC') + T.check(pc:isRunning(),version..' native house-PC script waits for menu closure') + T.eq(pc.ctx.g2Var,nil,version..' native result is not guessed before selection') + choose(g,'DECORATION');choose(g,'BED');choose(g,'OTHER BED') + T.eq(Native.state(g.data,g.save).bed,2,version..' selection waits for original feedback box') + T.eq(g.stringBuffers[3],'SYNTHETIC BED',version..' replacement feedback buffers old name') + T.eq(g.stringBuffers[4],'OTHER BED',version..' replacement feedback buffers new name') + dismiss(g) + T.eq(g.save.decorations.bed,3,version..' acknowledging feedback commits native placed byte') + T.eq(g.overworld.reloaded,0,version..' furniture remains unchanged before PC exit') + choose(g,'ORNAMENT');choose(g,'RENAMED DOLL') + local prompt=g.stack:top();prompt.done=true;prompt:update(0) + choose(g,'RIGHT SIDE');dismiss(g) + T.eq(g.save.decorations.rightOrnament,5,version..' actual native side menu commits right doll') + choose(g,'EXIT') + T.check(pc:isRunning(),version..' leaving decoration submenu does not resume PC script') + choose(g,'LOG OFF') + T.eq(pc:isRunning(),false,version..' LOG OFF completes actual native PC script') + T.eq(pc.ctx.g2Var,1,version..' changed bit returns after menu closes') + T.eq(g.overworld.reloaded,1,version..' native NONE warp reloads exactly once') + T.eq(g.overworld.player.cellX,4,version..' NONE reload retains current cell rather than zero coordinates') + T.eq(g.overworld.paint['0,2'],44,version..' reload callback uses final selected bed') + T.eq(g.save.gen2VarSprites[2],18,version..' reload callback uses selected right doll') + g.data.map_scripts.scripts.FACE={{'warpfacing',3,'NONE',0,0,0},{'end'}} + g.overworld.player.facing='down';run(g,'FACE') + T.eq(g.overworld.player.facing,'right',version..' native NONE warpfacing applies its explicit direction') + g=game(fixture());pc=run(g,'PC');g.input={wasPressed=function(_,key)return key=='b'end};g.stack:update(0) + T.eq(pc.ctx.g2Var,0,version..' PC cancellation returns unchanged') + T.eq(g.overworld.reloaded,0,version..' PC cancellation cannot reload') +end +V.set('crystal') +for _,code in ipairs({ + "p:patch('deco:2',{sprite=256})", "p:patch('deco:2',{flag=65535})", "p:patch('deco:2',{type=0})", + "p:patch('deco:2',{action='GUESS'})", "p:patch('deco:2',{sprite=0/0})", "p:patch('deco:2',{flag=math.huge})", + "p:patch('deco:02',{sprite=3})", "p:patch('deco:256',{sprite=3})", "p:patch('deco:2',{unknown=true})", + "p:patch('deco:2',{sprite=44});error('entry failed')", +})do + local d=fixture();local r=load(d,'local p=mod.content["decorations"];'..code) + T.check(#r.errors>0,'invalid or failed decoration contribution is rejected') + T.eq(d.map_scripts.decorations[2].sprite,0,'failed entry rolls back all native decoration writes') + finish(r) +end +do + local d=fixture();local original=d.map_scripts.decorations + original['2']=Merge.deepCopy(original[2]) + local r=load(d,[[local p=mod.content["decorations"];p:remove('deco:2');p:patch('deco:3',{sprite=66}); + p:register('deco:200',{type=1,name='EXTERNAL',flag=901,action='SET_UP_PLANT',sprite=77})]]) + T.eq(d.map_scripts.decorations[2],nil,'remove clears numeric DECO alias') + T.eq(d.map_scripts.decorations['2'],nil,'remove clears string DECO alias') + d.map_scripts.decorations[200].sprite=99 + finish(r);local clean=load(d) + T.eq(d.map_scripts.decorations[2].sprite,0,'reload restores removed native numeric record') + T.eq(d.map_scripts.decorations['2'].sprite,0,'reload restores removed native string alias') + T.eq(d.map_scripts.decorations[3].sprite,44,'reload retires an unchanged owned patch') + T.eq(d.map_scripts.decorations[200].sprite,99,'reload preserves externally edited authored record independently') + finish(clean) +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local d=fixture();if version=='prism'or version=='polishedcrystal'then d.map_scripts.decorationInfo.complete=false end + local before=d.map_scripts.decorations + local r=load(d,[[local p=mod.content["decorations"];assert(not p:supports());local v,why=p:patch('deco:2',{sprite=99});assert(v==nil and why)]]) + T.eq(#r.errors,0,version..' unsupported native decoration target declines explicitly') + T.eq(d.map_scripts.decorations,before,version..' unsupported generation does not write native namespace') + finish(r) +end +V.set(old);T.finish('native Gen2 decoration content, room callbacks and house PC') diff --git a/tests/engine/gen2_decoration_import.lua b/tests/engine/gen2_decoration_import.lua new file mode 100644 index 000000000..fe2409fb1 --- /dev/null +++ b/tests/engine/gen2_decoration_import.lua @@ -0,0 +1,113 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Native=require('src.world.Gen2Decorations') +local S=require('src.save_convert.Gen2Save');local Json=require('src.link.Json') +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={DecorationAttributes={1,0x4000},DecorationNames={1,0x401e}, + PokemonNames={1,0x4300},InitDecorations={1,0x4100},DecorationIDs={1,0x4200}, + wDecoBed={1,0xd000},wDecoPoster={1,0xd003},DecorationDesc_PosterPointers={1,0x4400}, + ['DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript']={1,0x4500}, + ['DecorationDesc_GiantOrnament.BigDollScript']={1,0x4501},DecorationDesc_NullPoster={1,0x4502}, + PutAwayTheDecoText={1,0x4600},NothingToPutAwayText={1,0x4602},SetUpTheDecoText={1,0x4604}} + -- Authored test cartridge, not a retail attribute catalogue. + put(0x4000,string.char(1,0,0,0,0,0,1,1,2,0,0,0,2,2,1,0x34,0x12,0, + 1,1,8,0,0,0,4,1,7,0x35,0x12,77)) + put(0x401e,string.char(128,0x50,129,0x50,130,0x50)) + put(0x4100,string.char(0x3e,2,0xea,0,0xd0,0x3e,4,0xea,3,0xd0,0xc9)) + put(0x4200,string.char(2,4,255));put(0x4300,string.char(131,0x50)) + put(0x4400,string.char(4,3,0x45,255));put(0x4500,string.char(0x91,0x91,0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols}) + function e:readSourceTable(k)assert(k=='charmap');return{['128']='X',['129']='Y',['130']='Z',['131']='Q'}end + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local rows,info=e:gen2Decorations() + T.check(info.complete,version..' native attribute boundary defines valid table') + T.eq(info.rowsRead,5,version..' reader imports exact authored row count') + T.eq(rows[0].name,'X',version..' DECO zero remains in actual native table') + T.eq(rows[2].name,'Z',version..' names use actual character decoder') + T.eq(rows[2].flag,0x1234,version..' event identity is native little-endian word') + T.eq(rows[2].sprite,0,version..' block zero survives real import') + T.eq(rows[4].name,'Q',version..' species name comes from cartridge indexed table') + T.eq(info.species[4],'SPECIES_001',version..' raw species identity has no National Dex inference') + T.eq(info.defaults.bed,2,version..' bed default comes from native init operands') + T.eq(info.defaults.poster,4,version..' poster default comes from native init operands') + T.eq(#info.gifts,2,version..' gift ordinal table is ROM-derived') + T.eq(info.gifts[2],4,version..' gift ordinal retains actual DECO byte') + local q={scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0} + local roles=e:gen2QueueDecorationPrograms(q) + T.eq(#q.queue,4,version..' actual queue includes three description roles and poster') + T.check(roles.texts.setup:match('^TEXT_S'),version..' native feedback enters shared text import') + T.check(e._gen2ScriptTexts[roles.texts.setup]~=nil,version..' feedback text is registered for actual extractor') + e:gen2QueueDecorationPrograms(q);T.eq(#q.queue,4,version..' duplicate queue calls are idempotent') + local n=1;while n<=#q.queue do local job=q.queue[n];e:gen2DecodeScript(job[1],job[2],job[3],q);n=n+1 end + T.eq(q.desyncs,0,version..' actual bytecode decoder accepts queued description programs') + for _,edit in ipairs({ + function(s)s.DecorationNames[2]=0x401f end, + function(s)s.DecorationNames[1]=2 end, + function(s)s.DecorationAttributes=nil end, + function(s,put)put(0x4000,string.char(0))end, + function(s,put)put(0x4002,string.char(15))end, + function(s,put)put(0x4003,string.char(255,255))end, + function(s,put)put(0x401e,string.rep(string.char(128),129))end, + function(s)s.PokemonNames=nil end, + })do + local bad,why=fixture(version,edit):gen2Decorations() + T.eq(why.complete,false,version..' malformed attribute/name input cannot become ready') + T.eq(next(bad),nil,version..' malformed attribute/name input publishes no prefix') + end + local _,missing=fixture(version,function(s)s.InitDecorations=nil end):gen2Decorations() + T.eq(missing.complete,true,version..' missing init does not erase valid attribute table') + T.eq(missing.defaultsComplete,false,version..' missing init cannot invent retail defaults') + local _,wrong=fixture(version,function(s)s.wDecoBed[2]=0xd100 end):gen2Decorations() + T.eq(wrong.defaultsComplete,false,version..' init stores must target actual WRAM symbols') + local _,badGifts=fixture(version,function(_,put)put(0x4200,string.rep(string.char(2),256))end):gen2Decorations() + T.eq(badGifts.giftsComplete,false,version..' unterminated gifts cannot be claimed complete') + T.eq(badGifts.gifts,nil,version..' malformed gifts publish no prefix') + q={scripts={},queue={}} + T.eq(fixture(version,function(s)s.DecorationDesc_NullPoster=nil end):gen2QueueDecorationPrograms(q),nil,version..' missing description role declines whole binding') + T.eq(#q.queue,0,version..' missing role is rejected before queue mutation') + q={scripts={},queue={}} + T.eq(fixture(version,function(_,put)put(0x4400,string.char(4,0,0))end):gen2QueueDecorationPrograms(q),nil,version..' invalid poster pointer declines binding') + T.eq(#q.queue,0,version..' invalid poster table leaves queue untouched') + -- Resolve the precise native NONE pair, without accepting an unknown map. + e=fixture(version);function e:gen2MapIndex()return{}end;function e:gen2Trainers()return{}end + local p={scripts={TEST={{'warp',0,0,0,0},{'warpfacing',2,0,0,0,0},{'warp',0,1,0,0},{'blackoutmod',0,0}}}} + e:gen2ResolveScriptMapIds({}, {},p) + T.eq(p.scripts.TEST[1][2],'NONE',version..' only actual NONE warp becomes reload') + T.eq(p.scripts.TEST[2][3],'NONE',version..' warpfacing uses its own map-pair slot') + T.eq(p.scripts.TEST[3][2],0,version..' unknown map is not turned into a reload') + T.eq(p.scripts.TEST[4][2],0,version..' non-warp zero pair is not reinterpreted') + -- Independent manifest WRAM addresses verify the save codec's physical order. + local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(Json.decode(f:read('*a')));f:close() + S.setLayout(version);S.setCharmap({['128']='A'}) + local d=T.fixtures.fresh();local save={player={name='A'},decorations={bed=1,carpet=2,plant=3,poster=4, + console=5,leftOrnament=6,rightOrnament=7,bigDoll=255}} + local raw=S.encode(save,d) + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + for _,key in ipairs(Native.slots)do + local symbol='wDeco'..key:sub(1,1):upper()..key:sub(2) + local address=manifest.symbols[symbol][2];local offset=0x2009+address-anchor + T.eq(raw:byte(offset+1),save.decorations[key],version..' export matches independent '..symbol..' address') + end + local decoded=S.decode(raw,d) + for _,key in ipairs(Native.slots)do T.eq(decoded.decorations[key],save.decorations[key],version..' import preserves exact '..key..' byte')end + local round=S.encode(decoded,d,raw);local warnings=S.decode(round,d).warnings + local checksumOk=true;for _,w in ipairs(warnings)do if w:match('checksum')then checksumOk=false end end + T.check(checksumOk,version..' decoration export retains valid native checksum') + local saved=decoded.decorations;decoded.decorations=nil + local untouched=S.encode(decoded,d,raw) + for i=0,7 do T.eq(untouched:byte(S.OFFSETS.decorations+i+1),raw:byte(S.OFFSETS.decorations+i+1),version..' absent legacy state preserves imported decoration template')end + decoded.decorations=saved;decoded.decorations.bed=256 + T.check(not pcall(S.encode,decoded,d,raw),version..' invalid placed byte cannot wrap and corrupt native export') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);T.eq(fixture(version):gen2Decorations(),nil,version..' unreviewed layout imports no stock catalogue') + S.setLayout(version);T.eq(S.OFFSETS.decorations,nil,version..' unknown save layout has no guessed decoration offset') +end +V.set(old);S.setLayout(old);T.finish('native Gen2 decoration ROM producers and eight-byte SRAM codec') From e49446d9b9beba591e4210302fa9e9a9eb1132d5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:22:40 +0100 Subject: [PATCH 08/68] Complete native bedroom acquisition through Mom savings and original shopping scripts --- docs/native-gen2-decorations.md | 3 +- docs/native-gen2-mom-shopping.md | 87 +++++++++++++ src/battle/BattleState.lua | 34 +++-- src/import/RomExtractorGen2.lua | 50 +++++++ src/import/RomImporter.lua | 2 +- src/save_convert/Gen2Save.lua | 39 ++++++ src/script/Gen2Commands.lua | 10 +- src/world/Gen2MomShopping.lua | 139 ++++++++++++++++++++ src/world/Gen2PhoneService.lua | 11 ++ src/world/OverworldController.lua | 41 ++++++ tests/engine/gen2_mom_import.lua | 98 ++++++++++++++ tests/engine/gen2_mom_shopping.lua | 201 +++++++++++++++++++++++++++++ 12 files changed, 691 insertions(+), 24 deletions(-) create mode 100644 docs/native-gen2-mom-shopping.md create mode 100644 src/world/Gen2MomShopping.lua create mode 100644 src/world/Gen2PhoneService.lua create mode 100644 tests/engine/gen2_mom_import.lua create mode 100644 tests/engine/gen2_mom_shopping.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md index 22c63ead3..e9d31d63e 100644 --- a/docs/native-gen2-decorations.md +++ b/docs/native-gen2-decorations.md @@ -72,7 +72,8 @@ executes the original house-PC/description scripts and room callbacks, and round-trips all eight placed bytes. Original ROM hashes are checked unchanged. The local font/audio/render harness is used; no visual acceptance is inferred. -Full legacy `Decorations` facade parity, the complete gift/purchase lifecycle, +Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). +Full legacy `Decorations` facade parity, the remaining gift lifecycle, new graphical acceptance and a full unchanged VASC playthrough remain separate open work. Importing the gift-ordinal table alone does not implement those callers. The existing preview is not refreshed by this source change. diff --git a/docs/native-gen2-mom-shopping.md b/docs/native-gen2-mom-shopping.md new file mode 100644 index 000000000..9a3277427 --- /dev/null +++ b/docs/native-gen2-mom-shopping.md @@ -0,0 +1,87 @@ +# Native GSC Mom accounts and purchases + +The existing native banking screen changed a separate saving boolean, while +trainer rewards went entirely to the wallet. The cartridge's shopping tables +and notification scripts were not imported. This connects the banking choice, +native flags, trainer reward accounts, ROM purchase rows, original phone +programs, decoration ownership and raw-save state. + +## Cartridge data and account state + +`map_scripts.momShopping` contains the user's cartridge's random and ladder +lists. Each row is eight bytes: big-endian three-byte trigger and price, then +kind and item/DECO bytes. End symbols determine the count. The random threshold +increment is read from the native instruction operands that populate +`hMoneyTemp`; no retail catalogue, purchase dialogue or guessed hack layout is +bundled. Both original item/doll phone programs enter the shared script/text +importer. Invalid or incomplete bindings publish no partial lists and do not +partially enqueue programs. The new GSC cache marker requires reimport. + +The native account fields are `g2MomMoney`, `g2MomSavingMode` and +`g2MomBankOpen`, alongside the existing boolean `g2MomSaving`. Saving bit zero +and the active bit agree with the same engine flags original scripts read. +The bank UI uses those shared fields; an imported active account is recognized +instead of silently opening a new account. Explicit start/stop choices select +SOME/none. Imported HALF/ALL settings retain their native low-bit meanings. + +Trainer rewards deal four quarters between the accounts. SOME sends one, +HALF two and ALL four to Mom; an already maxed Mom account sends everything to +the wallet. Each account saturates independently, so near-cap overflow is not +redirected. Held-item reward modification happens before this split. Original +reward-text entry points select the current mode and receive the real player +name and full prize total. Other generations and unreviewed hacks retain their +existing reward paths, including Prism's separate bank. + +## Purchase and phone lifecycle + +`g2MomShopping` retains the native ladder index, selected random-row byte and +three-byte trigger balance. The current ladder row has priority whenever its +trigger is met. Otherwise the threshold advances to/past the balance and only +an exact landing selects a random row; that threshold is consumed even when a +full PC refuses delivery. A random purchase does not advance the ladder. + +Items resolve by the current dataset's native index and enter the existing PC +store with the native stack/capacity rule. Dolls set the actual imported DECO +ownership event; they do not place themselves in the room. Successful delivery +precedes deduction and ladder advancement. No reception or an occupied native +reentry queue prevents a purchase before selection state changes. Missing +original phone programs do not grant an unannounced purchase. + +The common native trainer-return path attempts the purchase once. Its original +phone program waits behind the trainer's queued post-battle script and UI +overlays, then enters the existing native FIFO with Mom's current contact ID. +Caller state, ring/hang-up audio, and receive-delay reset use the existing native call path. +Where the host exposes an incoming-call notification seam, it is also used. Repeated battle +callbacks cannot duplicate the purchase, and a pending call from another +save/dataset cannot execute in the current session. Link and Battle Tower +returns do not trigger shopping. + +Raw SRAM conversion uses each GSC edition's actual WRAM-derived account, +mode, ladder, row-selection and threshold offsets. The account/threshold use +big-endian triples; surrounding mode bits and absent legacy template values +are retained. Unsupported layouts receive no guessed new offsets. + +## Verification and limits + +ROM-free tests exercise actual table/instruction readers, banking VM/UI, +trainer battle win, native world return, script FIFO, flags, capacity refusal, +threshold selection and SRAM conversion. Save addresses are independently +compared with the shipped GSC manifests. Synthetic cartridges, texts and +records are authored fixtures rather than retail data. + +A private read-only Crystal probe independently compares every numeric byte +of both shopping lists, executes all ten original ladder phone notifications, +checks actual item and DECO ownership delivery, resolves native reward slots +and round-trips the account/progress fields. It uses controlled dataset and +font/render/audio IO, not a GPU acceptance or full natural-story playthrough. +Original ROM hashes are checked unchanged. No ROM/private mod/save data is +included in the contribution. + +The legacy `src.core.gen2.MomShopping` facade, full legacy phone parity, +new GPU acceptance and a complete unchanged VASC playthrough remain open. +This source change does not refresh public preview assets. + +Contract references: [native purchase routine](https://github.com/pret/pokecrystal/blob/master/engine/events/mom_phone.asm), +[native trainer reward split](https://github.com/pret/pokecrystal/blob/master/engine/battle/core.asm). +Original additions use the [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..ecf65b90d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9093,20 +9093,8 @@ function BattleState:enemyMonFainted() end) return end - -- ComputeTrainerReward (0E:58D4) sets wBattleReward = the class's base - -- reward byte (TrainerClassAttributes+2) times wCurPartyLevel, the level - -- of the LAST mon the party reader loaded. That is only a QUARTER of the - -- payout though: WinTrainerBattle (0F:4F46) then runs `ld c, 4` and adds - -- wBattleReward once per iteration -- b of the four quarters through - -- .AddMoneyToMom, the remaining c through .AddMoneyToWallet -- so the - -- trainer is worth base * level * 4 in total. b comes from - -- wMomSavingMoney & 7 (0 = you keep it all, 3 = she banks all four), and - -- is forced to 0 when Mom's account is already maxed. Nothing here - -- models Mom's savings account, which is the `keep it all` branch, so all - -- four quarters land in the wallet -- Falkner pays 25 * 9 * 4 = 900 and a - -- Sprout Tower sage 8 * 3 * 4 = 96, instead of the 225 and 24 a single - -- quarter gives. Gen1's TrainerBattleVictory has no such split, so the - -- multiplier stays on the Gen2 side of the fence. + -- Native GSC rewards are four quarters. Their destination is selected + -- by Mom's saving bits; Gen2MomShopping applies the per-account cap. -- THE LEVEL THE PRIZE IS COUNTED FROM. -- -- Gen 1 and Gen 2 count from the mon that was out when the battle ended. @@ -9154,7 +9142,9 @@ function BattleState:enemyMonFainted() prize = P.prize(self.data, self.trainer, opts) if self.trainerB then prize = prize + P.prize(self.data, self.trainerB, opts) end end - self.game.save.money = self.game.save.money + prize + local momAward=require("src.world.Gen2MomShopping").reward(self.game.save,prize) + if not momAward then self.game.save.money=self.game.save.money+prize end + self.g2MomAward=momAward -- Prism's Spurge Bank ATM (event/bank.asm) offers DIRECT DEPOSIT: with it -- enabled, a quarter of what you win in battle is routed to the account -- instead of the wallet. The switch lives in the ATM special @@ -9215,9 +9205,17 @@ function BattleState:enemyMonFainted() -- Platinum's font, so a Sinnoh win read "got 円 480". This default is -- only reached when the dataset has no `_MoneyForWinningText` of its own, -- which is every Gen 3 and Gen 4 cartridge. - self:sayNext(self:romText("_MoneyForWinningText", - "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", - self.game.save.player.name, prize)) + local momPool=require("src.world.Gen2MomShopping").pool(self.data) + local moneyText=momAward and momPool and momPool.rewardTexts and momPool.rewardTexts[momAward.mode] + if moneyText and (self.data.text and self.data.text[moneyText])then + local text=self.data.text[moneyText] + text=text:gsub("{PLAYER}",function()return self.game.save.player.name end):gsub("{NUM}",tostring(prize)) + self:sayNext(text) + else + self:sayNext(self:romText("_MoneyForWinningText", + "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", + self.game.save.player.name, prize)) + end end self.result = "win" self.afterQueue = "finish" diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index c8f354e22..bc6d0ff51 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3320,6 +3320,52 @@ end -- Six native bytes per attribute row: kind/name/action/event-word/sprite. -- Names immediately follow attributes in stock GSC; that boundary supplies -- the row count instead of a copied retail catalogue. +-- Native Mom tables have eight bytes: two big-endian money triples and +-- kind/item bytes. End symbols supply counts, never a copied retail list. +function RomExtractorGen2:gen2MomShopping(pool) + if self.version~="gold"and self.version~="silver"and self.version~="crystal"then return nil end + local result={complete=false} + local ok,why=pcall(function() + for role,name in pairs({random="MomItems_1",ladder="MomItems_2"})do + local first,last=self:symbol(name),self:symbol(name..".End") + assert(first and last and first.bank==last.bank,"missing Mom table bounds") + local count=(last.address-first.address)/8 + assert(count%1==0 and count>0 and count<=255 and first.address>=0x4000 and last.address<=0x8000,"invalid Mom table bounds") + local rows={} + for i=0,count-1 do + local b=self.rom:bytes(first.bank,first.address+i*8,8) + local trigger=b[1]*65536+b[2]*256+b[3];local cost=b[4]*65536+b[5]*256+b[6] + assert(trigger<=999999 and cost<=999999 and(b[7]==1 or b[7]==2)and b[8]>0 and b[8]<255,"invalid Mom row") + rows[#rows+1]={trigger=trigger,cost=cost,kind=b[7],item=b[8]} + end + result[role]=rows + end + local at,ram=self:symbol("CheckBalance_MomItem2.check_have_2300"),self:symbol("hMoneyTemp") + assert(at and ram,"missing Mom trigger instructions") + local b=self.rom:bytes(at.bank,at.address,11) + assert(b[1]==0x21 and b[2]+b[3]*256==ram.address and b[4]==0x36 and b[6]==0x23 and + b[7]==0x36 and b[9]==0x23 and b[10]==0x36,"unrecognized Mom trigger instructions") + result.step=b[5]*65536+b[8]*256+b[11] + assert(result.step>0 and result.step<=999999,"invalid Mom trigger amount") + end) + if not ok then return{complete=false,reason=tostring(why)}end + local found={} + for role,name in pairs({item="Mom_GetScriptPointer.ItemScript",doll="Mom_GetScriptPointer.DollScript"})do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return{complete=false,reason="missing Mom phone program"}end + found[role]=at + end + result.programs={} + for role,at in pairs(found)do result.programs[role]=self:gen2QueueScript(at.bank,at.address,pool)end + result.rewardTexts={} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for mode,name in pairs({[0]="GotMoneyForWinningText",[1]="SentSomeToMomText",[2]="SentHalfToMomText",[3]="SentAllToMomText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then result.rewardTexts[mode]=self:gen2RegisterScriptText(at.bank,at.address)end + end + result.complete=true;return result +end + function RomExtractorGen2:gen2Decorations() if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} @@ -5649,6 +5695,8 @@ function RomExtractorGen2:gen2MapIndex() -- PALETTE_DARK, the only value ReplaceTimeOfDayPals answers with -- .NeedsFlash (23:$4400) palette = field(paletteAt, 0) % 16, + paletteBits = field(paletteAt,0), + phoneService = math.floor(field(paletteAt,0)/16)%16==0, } byGroupNumber[group * 256 + number] = entry byLabel[label] = byLabel[label] or entry @@ -6648,6 +6696,7 @@ function RomExtractorGen2:extractMapsFromRom() -- is why a Pokemon Center does not go dark at night on hardware -- and -- the Ruins of Alph chambers carry it too. def.mapPalette = header and header.palette or def.mapPalette + if header then def.mapPaletteBits=header.paletteBits;def.phoneService=header.phoneService end -- Byte 8 is the FISHGROUP_* constant, and 0 is FISHGROUP_NONE -- NOT -- FISHGROUP_SHORE, which is 1 (constants/map_data_constants.asm). -- gen2Fishing keys its table by that same constant, so 0 finds nothing @@ -9021,6 +9070,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.momShopping=self:gen2MomShopping(pool) out.decorations,out.decorationInfo=self:gen2Decorations() out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d0e8c48bf..55c4a44c7 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-decorations-native-v1:gen2-field-encounters-v8:" + revision = "gen2-mom-shopping-native-v1:gen2-decorations-native-v1:gen2-field-encounters-v8:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 10ae779eb..d313bddef 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,7 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + momMoney=0xD576,momSaving=0xD579,momWhich=0xD961,momSet=0xD962,momTrigger=0xD963, decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, @@ -269,6 +270,7 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + momMoney=0xD851,momSaving=0xD854,momWhich=0xDC17,momSet=0xDC18,momTrigger=0xDC19, decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, @@ -371,6 +373,7 @@ function Gen2Save.setLayout(version) -- decoration bytes live. Explicit custom layouts may name their own run. if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then O.decorations=nil + O.momMoney=nil;O.momSaving=nil;O.momWhich=nil;O.momSet=nil;O.momTrigger=nil end end @@ -1130,6 +1133,18 @@ function Gen2Save.decode(bytes, data) end end + if O.momMoney then + save.g2MomMoney=u24be(bytes,O.momMoney) + local mode=u8(bytes,O.momSaving) + save.g2MomSavingMode=mode%8 + save.g2MomSaving=save.g2MomSavingMode~=0 + save.g2MomBankOpen=hasBit(mode,7)or nil + save.flags.ENGINE_MOM_SAVING_MONEY=hasBit(mode,0) + save.flags.ENGINE_MOM_ACTIVE=hasBit(mode,7) + save.g2MomShopping={whichItem=u8(bytes,O.momWhich),whichSet=u8(bytes,O.momSet), + triggerBalance=u24be(bytes,O.momTrigger)} + end + -- wVisitedSpawns -> the FLY list. HasVisitedSpawn tests bit [spawn id]; -- this port spells the same set save.visited[mapId] (src/ui/TownMap.lua), -- so without this an imported save could not fly anywhere. @@ -1320,6 +1335,30 @@ function Gen2Save.encode(save, data, template) end end + if O.momMoney then + local function nativeNumber(value,limit,label) + assert(type(value)=="number"and value%1==0 and value>=0 and value<=limit,"invalid native Mom "..label) + return value + end + if save.g2MomMoney~=nil then setU24be(buf,O.momMoney,nativeNumber(save.g2MomMoney,999999,"balance"))end + local value=buf[O.momSaving+1]:byte() + if save.g2MomSavingMode~=nil or save.g2MomSaving~=nil then + local mode=save.g2MomSavingMode~=nil and nativeNumber(save.g2MomSavingMode,7,"saving mode")or(save.g2MomSaving and 1 or 0) + if save.flags and save.flags.ENGINE_MOM_SAVING_MONEY~=nil then + mode=mode-mode%2+(save.flags.ENGINE_MOM_SAVING_MONEY and 1 or 0) + end + value=value-value%8+mode + end + if save.g2MomBankOpen~=nil then value=withBit(value,7,save.g2MomBankOpen==true)end + setByte(buf,O.momSaving,value) + if type(save.g2MomShopping)=="table"then + local state=save.g2MomShopping + setByte(buf,O.momWhich,nativeNumber(state.whichItem,255,"ladder index")) + setByte(buf,O.momSet,nativeNumber(state.whichSet or 0,255,"random row")) + setU24be(buf,O.momTrigger,nativeNumber(state.triggerBalance,999999,"random threshold")) + end + end + -- wVisitedSpawns: the FLY destinations this save has reached local spawnFlags = data and data.field and data.field.spawnFlags if type(spawnFlags) == "table" then diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 9a394bc36..d5e0d0f69 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2317,16 +2317,18 @@ end function Commands.g2_bank_of_mom(ctx) local save = ctx.save + local Mom=require("src.world.Gen2MomShopping") + Mom.prepareBank(save) -- CheckIfBankInitialized: the first visit opens the account and asks if save.g2MomBankOpen == nil then - save.g2MomBankOpen = true + Mom.openBank(save) Commands.ask(ctx, momLine(ctx, "saveMoney", "Should I save money for you?")) if ctx.lastCheck then - save.g2MomSaving = true + Mom.setSaving(save,true) return momSay(ctx, "startSavingMoney", "OK, I'll save your money.") end - save.g2MomSaving = false + Mom.setSaving(save,false) return momSay(ctx, "justDoWhatYouCan", "Just do what you can.") end @@ -2391,7 +2393,7 @@ function Commands.g2_bank_of_mom(ctx) momSay(ctx, "takenMoney", "Took ¥" .. amount .. ".") end) elseif choice == 3 then - save.g2MomSaving = not save.g2MomSaving + Mom.setSaving(save,not save.g2MomSaving) if save.g2MomSaving then momSay(ctx, "startSavingMoney", "OK, I'll save your money.") else diff --git a/src/world/Gen2MomShopping.lua b/src/world/Gen2MomShopping.lua new file mode 100644 index 000000000..ab075abdf --- /dev/null +++ b/src/world/Gen2MomShopping.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native accounts, ROM shopping rows and original phone programs share one save. +local Version=require('src.core.GameVersion') +local M={MAX_MONEY=999999} +local function integer(value,lo,hi) + return type(value)=='number'and value%1==0 and value>=lo and value<=hi and value or nil +end +local function money(value)return integer(value,0,M.MAX_MONEY)or 0 end +function M.stock() + local v=Version.get();return v=='gold'or v=='silver'or v=='crystal' +end +function M.mode(save) + local mode=integer(save.g2MomSavingMode,0,3) + local flag=save.flags and save.flags.ENGINE_MOM_SAVING_MONEY + if mode==nil then mode=flag~=nil and(flag and 1 or 0)or(save.g2MomSaving and 1 or 0) + elseif flag~=nil then mode=mode-mode%2+(flag and 1 or 0)end + return mode +end +function M.prepareBank(save) + if not M.stock()then return end + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + local active=save.flags.ENGINE_MOM_ACTIVE + if active==nil and save.g2MomBankOpen==true then Flags.set(save,'ENGINE_MOM_ACTIVE') + elseif active==true then save.g2MomBankOpen=true + elseif active==false then save.g2MomBankOpen=nil end + if save.flags.ENGINE_MOM_SAVING_MONEY==nil and(save.g2MomSaving~=nil or save.g2MomSavingMode~=nil)then + if M.mode(save)%2==1 then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end + end + save.g2MomSaving=M.mode(save)~=0 +end +function M.setSaving(save,on) + save.g2MomSaving=on==true + if not M.stock()then return end + save.g2MomSavingMode=on and 1 or 0 + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + if on then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end +end +function M.openBank(save) + save.g2MomBankOpen=true + if M.stock()then + save.flags=save.flags or{};require('src.script.Flags').set(save,'ENGINE_MOM_ACTIVE') + end +end +-- WinTrainerBattle deals four quarters; near-cap overflow is lost in the +-- selected account, not redirected to the other one. +function M.reward(save,total) + if not M.stock()or not integer(total,0,0xffffff*4)then return nil end + M.prepareBank(save) + local quarter=math.floor(total/4) + local mode=money(save.g2MomMoney)0 and type(p.ladder)=='table'and #p.ladder>0 +end +function M.caller(data) + local found + for id,row in pairs(data and data.map_scripts and data.map_scripts.phone or{})do + if integer(id,1,255)and type(row)=='table'and(row.class==nil or row.class==0)and(row.contactNumber or row.trainer)==1 then + if found then return nil end;found=id + end + end + return found +end +function M.state(data,save) + if not M.ready(data)then return nil end + if save.g2MomShopping==nil then save.g2MomShopping={whichItem=0,whichSet=0,triggerBalance=M.pool(data).step}end + local state=save.g2MomShopping + if type(state)~='table'or not integer(state.whichItem,0,255)or not integer(state.triggerBalance,0,M.MAX_MONEY)then return nil end + return state +end +function M.pick(data,save,random) + local state=M.state(data,save);if not state then return nil end + local p=M.pool(data);local saved=money(save.g2MomMoney) + state.whichSet=0 + local row=p.ladder[state.whichItem+1] + if row and saved>=row.trigger then return{row=row,set=2}end + while state.triggerBalancecap then return false end + save.pcItems=pc;pc[id]=held+1;return true +end +function M.tryBuy(data,save,opts) + opts=opts or{} + if not M.ready(data)or opts.phoneService~=true or opts.queued then return nil end + local caller=M.caller(data);if not caller then return nil end + local p=M.pool(data);local VM=require('src.script.Gen2ScriptVM') + local itemScript=p.programs and VM.compile(data,p.programs.item) + local dollScript=p.programs and VM.compile(data,p.programs.doll) + if not(itemScript and dollScript)then return nil end + local choice=M.pick(data,save,opts.random);if not choice then return nil end + if not receive(data,save,choice.row)then return nil end + save.g2MomMoney=math.max(0,money(save.g2MomMoney)-choice.row.cost) + local state=M.state(data,save) + if choice.set==2 then state.whichItem=(state.whichItem+1)%256 end + return{kind=choice.row.kind,item=choice.row.item,cost=choice.row.cost,set=choice.set, + saved=save.g2MomMoney,caller=caller,script=choice.row.kind==2 and dollScript or itemScript} +end +return M diff --git a/src/world/Gen2PhoneService.lua b/src/world/Gen2PhoneService.lua new file mode 100644 index 000000000..bd9d96c66 --- /dev/null +++ b/src/world/Gen2PhoneService.lua @@ -0,0 +1,11 @@ +local PhoneService = {} +function PhoneService.available(def) + if not def then return false end + if def.phoneService~=nil then return def.phoneService==true end + -- New imports retain the complete byte. Older authored datasets may have + -- stored it in mapPalette itself; old ROM caches retain their legacy rule + -- until reimport because their discarded bits cannot be reconstructed. + local raw=tonumber(def.mapPaletteBits) or tonumber(def.mapPalette) or 0 + return math.floor(raw/16)%16==0 +end +return PhoneService diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 54ac1c659..5c5f0b589 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3300,6 +3300,33 @@ function OverworldState:checkSpecialPhoneCall() onDone = function() Game.save.g2CurCaller = nil end }) end +-- A purchase is already committed at trainer reload. Its original phone +-- program waits for the trainer's queued dialogue and overlays to finish. +function OverworldState:checkMomShoppingCall() + local pending=self.g2MomCallPending + if not pending then return false end + if pending.save~=Game.save or pending.data~=Game.data then self.g2MomCallPending=nil;return false end + local top=Game.stack and Game.stack.top and Game.stack:top() + if top and top~=self then return false end + if self.player and(self.player.moving or self.player.targetX or self.player.stairExit or self.player.inputLocked)then return false end + if self.transitioning or self.runner:isRunning()or #(self.scriptMoves or{})>0 or self.engaging or self.emote or + self.teleportOut or self.whirlSpin or self.gen4Waterfall or self.flyAnim or self.fishing or + self.pendingScripts and self.pendingScripts[1]then return false end + self.g2MomCallPending=nil + local purchase=pending.purchase + Game.save.g2CurCaller=purchase.caller + pcall(function()require("src.core.Sound").play(Game.data,"Call")end) + self:queueScript(purchase.script,{phoneCaller=purchase.caller,onDone=function() + if Game.save~=pending.save or Game.data~=pending.data then return end + Game.save.g2CurCaller=nil + require("src.script.Gen2Commands").resetCallDelay(Game.save,math.floor(require("src.core.SaveData").playSeconds(Game.save)/60)) + pcall(function()require("src.core.Sound").play(Game.data,"Hang_Up")end) + end}) + local commands=require("src.script.Gen2Commands") + if commands.announceIncomingCall then commands.announceIncomingCall(Game.data,purchase.caller)end + return true +end + -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step -- the player finishes off a warp tile. Gen2Commands.rollIncomingCall carries -- the ROM's own gating -- the receive-call delay, the one-in-two coin flip, the @@ -3666,6 +3693,7 @@ function OverworldState:update(dt) -- not an event, and the check is two flag reads. self:applyForcedBike() self:checkSpecialPhoneCall() + self:checkMomShoppingCall() self:checkBugContestClock() -- world.tick: the per-frame seam for mods that simulate something in the -- overworld rather than draw it. @@ -14458,6 +14486,19 @@ function OverworldState:gen4FieldResumed() end function OverworldState:afterBattle(result, battle) + if result=="win" and battle and battle.kind=="trainer" and not battle.linkBattle and not battle.battleTower and + not battle.g2MomHandled and require("src.world.Gen2MomShopping").stock()then + battle.g2MomHandled=true + if not self.g2MomCallPending then + local purchase=require("src.world.Gen2MomShopping").tryBuy(Game.data,Game.save,{ + phoneService=require("src.world.Gen2PhoneService").available(self.map and self.map.def), + queued=self.pendingScripts and self.pendingScripts[1]~=nil, + }) + if purchase then self.g2MomCallPending={save=Game.save,data=Game.data,purchase=purchase}end + end + end + + self.gen4EncounterAttempts = 0 -- a battle a script did not start (a wild one, a trainer's sight) comes -- back to the field here; a scripted one already did in its onFinish diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua new file mode 100644 index 000000000..036bc09e8 --- /dev/null +++ b/tests/engine/gen2_mom_import.lua @@ -0,0 +1,98 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if name=='markerFor'then markerFor=value;break end;if not name then break end end +assert(markerFor) +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, + ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, + ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} + put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, + 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) + put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) + put(0x4200,string.char(0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + return e +end +local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) + T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + T.eq(#rows.random,2,version..' random count comes from its end symbol') + T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') + T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') + T.eq(rows.ladder[2].kind,2,version..' kind distinguishes real DECO byte from native item') + T.eq(rows.ladder[2].item,14,version..' row retains actual byte rather than a guessed species') + T.eq(rows.step,17,version..' random threshold is read from actual instructions, not a retail literal') + T.eq(#q.queue,2,version..' both original phone scripts enter native decode queue') + T.check(e._gen2ScriptTexts[rows.rewardTexts[1]]~=nil,version..' native reward prose reaches shared text importer') + e:gen2MomShopping(q);T.eq(#q.queue,2,version..' repeated import queues each script once') + for _,job in ipairs(q.queue)do e:gen2DecodeScript(job[1],job[2],job[3],q)end + T.eq(q.desyncs,0,version..' native queued scripts decode without desync') + T.check(markerFor(version):find('gen2%-mom%-shopping%-native%-v1:')~=nil,version..' new cache requires purchase data') + for _,edit in ipairs({ + function(s)s.MomItems_1=nil end, + function(s)s['MomItems_2.End']=nil end, + function(s)s['MomItems_1.End'][2]=0x4011 end, + function(s)s['MomItems_1.End'][1]=2 end, + function(s)s['MomItems_2.End'][2]=0x400f end, + function(s,put)put(0x4000,string.char(255,255,255))end, + function(s,put)put(0x4003,string.char(255,255,255))end, + function(s,put)put(0x4006,string.char(3))end, + function(s,put)put(0x4007,string.char(0))end, + function(s,put)put(0x4104,string.char(0))put(0x4107,string.char(0))put(0x410a,string.char(0))end, + function(s)s.hMoneyTemp[2]=0xff81 end, + function(s)s['CheckBalance_MomItem2.check_have_2300']=nil end, + function(s)s['Mom_GetScriptPointer.DollScript']=nil end, + function(s)s['Mom_GetScriptPointer.ItemScript'][2]=0x100 end, + })do + local bad=fixture(version,edit);local q=pool();local result=bad:gen2MomShopping(q) + T.eq(result.complete,false,version..' malformed or incomplete input cannot claim a native purchase binding') + T.eq(result.random,nil,version..' malformed binding publishes no partial retail table') + T.eq(#q.queue,0,version..' invalid binding does not partially mutate script queue') + end +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + local q=pool();T.eq(fixture(version):gen2MomShopping(q),nil,version..' unreviewed layout has no inferred retail shopping') + T.eq(#q.queue,0,version..' unrelated script queue stays unchanged') + T.check(not markerFor(version):find('gen2%-mom%-shopping%-native%-v1:'),version..' unrelated cache remains unchanged') +end +local Service=require('src.world.Gen2PhoneService');local Writer=require('src.import.LuaWriter') +local function extractor(version,palette,compact) + local raw=string.rep('\0',0x8000) + local function put(at,v)raw=raw:sub(1,at)..string.char(v)..raw:sub(at+2)end + put(0x4000,2);put(0x4001,0x40) + local header=compact and {1,2,0,0x50,1,1,palette}or{1,1,2,0,0x50,1,1,palette,0} + for i,v in ipairs(header)do put(0x4001+i,v)end + local layout=compact and {mapHeaderBytes=7,mapHeaderAttrAt=2,mapHeaderTilesetAt=0,mapHeaderEnvAt=1,mapHeaderLandmarkAt=4,mapHeaderMusicAt=5,mapHeaderPaletteAt=6,mapHeaderFishAt=-1,mapAttributesBank=2}or{} + return E.new(raw,version,{symbols={MapGroupPointers={1,0x4000},Native_MapAttributes={compact and 2 or 1,0x5000}},layout=layout}) +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for raw=0,255 do + local e=extractor(version,raw);local _,labels=e:gen2MapIndex();local header=assert(labels.Native) + T.eq(header.palette,raw%16,'native header retains the unchanged palette nibble') + T.eq(header.paletteBits,raw,'native header retains the complete real byte') + T.eq(header.phoneService,raw<16,'native header decodes the actual no-service nibble') + T.eq(Service.available({mapPalette=header.palette,mapPaletteBits=header.paletteBits,phoneService=header.phoneService}),raw<16,'runtime consumes the native imported no-service verdict') + end + for _,raw in ipairs({1,0x11,0xF4})do + local e=extractor(version,raw);local maps={NATIVE={id='NATIVE',label='Native',width=1,height=1,warps={}}};local encoded + -- Input table/file output are IO seams. Header lookup, map lowering and + -- Lua serialization are the actual importer, including false booleans. + function e:readSourceTable(name)return name=='maps'and maps or{}end + function e:write(name,value)if name=='maps'then encoded=Writer.encode(value)end end + e:extractMapsFromRom();local decoded=assert(loadstring(assert(encoded)))().NATIVE + T.eq(decoded.mapPalette,raw%16,'full map lowering preserves the palette rule') + T.eq(decoded.mapPaletteBits,raw,'full map lowering retains header bits') + T.eq(decoded.phoneService,raw<16,'serialization does not lose an explicit no-service false') + end +end +V.set(old);T.finish('native Mom table boundaries, threshold instruction operands, script queues and cache revision') diff --git a/tests/engine/gen2_mom_shopping.lua b/tests/engine/gen2_mom_shopping.lua new file mode 100644 index 000000000..a140cce25 --- /dev/null +++ b/tests/engine/gen2_mom_shopping.lua @@ -0,0 +1,201 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Mom=require('src.world.Gen2MomShopping');local Native=require('src.world.Gen2Decorations') +local Flags=require('src.script.Gen2Flags');local Runner=require('src.script.ScriptRunner') +local W=require('src.world.OverworldController');local Stack=require('src.core.StateStack') +local Map=require('src.world.Map');local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState');local S=require('src.save_convert.Gen2Save') +local E=require('src.import.RomExtractorGen2');local J=require('src.link.Json') +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end -- audio device IO +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.afterBattle,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function data() + local d=T.fixtures.fresh() + d.items.ITEM_017={id='ITEM_017',index=17,name='SYNTHETIC A',pocket='ITEM'} + d.items.ITEM_018={id='ITEM_018',index=18,name='SYNTHETIC B',pocket='ITEM'} + d.map_scripts={momShopping={complete=true,step=17, + random={{trigger=0,cost=3,kind=1,item=17},{trigger=0,cost=5,kind=1,item=18}}, + ladder={{trigger=100,cost=20,kind=1,item=17},{trigger=200,cost=70,kind=2,item=14},{trigger=500,cost=30,kind=1,item=18}}, + programs={item='ITEM_PHONE',doll='DOLL_PHONE'},rewardTexts={[0]='REWARD0',[1]='REWARD1',[2]='REWARD2',[3]='REWARD3'}}, + phone={[3]={class=0,contactNumber=1,name='SYNTHETIC MOM'}}, + decorations={[14]={type=1,name='SYNTHETIC DOLL',action='SET_UP_DOLL',flag=900,sprite=18}}, + decorationInfo={complete=true},scripts={BANK={{'special',0,'BankOfMom'},{'end'}},ITEM_PHONE={{'setevent',901},{'end'}},DOLL_PHONE={{'setevent',902},{'end'}}}} + d.text={REWARD0='SYNTHETIC {PLAYER} TOTAL {NUM}',REWARD1='SYNTHETIC SOME {PLAYER} TOTAL {NUM}', + REWARD2='SYNTHETIC HALF {PLAYER} TOTAL {NUM}',REWARD3='SYNTHETIC ALL {PLAYER} TOTAL {NUM}'} + return d +end +local function save(balance) + return{flags={},money=0,g2MomMoney=balance or 0,inventory={},pcItems={},party={},player={name='QA'},pokedex={seen={},owned={}}} +end +local function buy(d,s,opts) + opts=opts or{};opts.phoneService=opts.phoneService~=false;return Mom.tryBuy(d,s,opts) +end +local function game(d,s) + local def={id='QA_MOM',generation=2,width=2,height=2,blocks={1,1,1,1},warps={},mapPalette=1,phoneService=true} + local ts={id='FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={},waterTiles={}} + d.maps.QA_MOM=def + local w=setmetatable({map=Map.new(def,ts),player={cellX=0,cellY=0,facing='down'},scriptMoves={},pendingScripts={},npcs={}},{__index=W}) + local stack=setmetatable({states={w}},{__index=Stack}) -- world already entered in this fixture + local g={data=d,save=s,stack=stack,world=w,overworld=w,input={wasPressed=function()return false end}};w.runner=Runner.new(g,w) + debug.setupvalue(W.afterBattle,slot,g);return g,w +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for mode=0,3 do + for _,coin in ipairs({1,2})do + local s=save(100);s.money=10;s.g2MomSavingMode=mode + local quarters=({[0]=0,1,2,4})[mode];local award=Mom.reward(s,900*coin) + T.eq(s.g2MomMoney,100+225*coin*quarters,version..' native mode '..mode..' sends actual quarters to Mom') + T.eq(s.money,10+225*coin*(4-quarters),version..' wallet receives only its actual share') + T.eq(award.total,900*coin,version..' displayed total remains complete prize') + T.eq(award.mode,mode,version..' native text uses actual savings mode') + end + end + local s=save(999999);s.g2MomSaving=true + T.eq(Mom.reward(s,900).mode,0,version..' maxed savings disables skimming') + T.eq(s.money,900,version..' maxed savings sends all quarters to wallet') + s=save(999998);s.g2MomSaving=true;s.money=999998 + Mom.reward(s,900) + T.eq(s.g2MomMoney,999999,version..' near-cap Mom account saturates') + T.eq(s.money,999999,version..' wallet saturates separately') + s=save();s.g2MomSaving=true;s.flags.ENGINE_MOM_SAVING_MONEY=false + T.eq(Mom.mode(s),0,version..' explicit native flag overrides legacy saving boolean') + s.g2MomSavingMode=2;T.eq(Mom.mode(s),2,version..' HALF bit survives an unset SOME bit') + s.flags.ENGINE_MOM_SAVING_MONEY=true;T.eq(Mom.mode(s),3,version..' native setflag changes only bit zero') + Mom.setSaving(s,false);T.eq(Mom.mode(s),0,version..' bank stop clears saving mode') + Mom.openBank(s);Mom.setSaving(s,true) + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' bank opening reaches native active flag') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' bank start reaches native SOME flag') + local previous=save();previous.g2MomBankOpen=true;previous.g2MomSaving=true + Mom.prepareBank(previous) + T.eq(previous.flags.ENGINE_MOM_ACTIVE,true,version..' earlier native preview account migrates to active flag') + T.eq(previous.flags.ENGINE_MOM_SAVING_MONEY,true,version..' earlier native preview saving choice migrates to native flag') + previous.flags.ENGINE_MOM_ACTIVE=false;Mom.prepareBank(previous) + T.eq(previous.g2MomBankOpen,nil,version..' explicit native reset overrides legacy open boolean') + local legacy=data();legacy.map_scripts.phone[3].contactNumber=nil;legacy.map_scripts.phone[3].trainer=1 + T.eq(Mom.caller(legacy),3,version..' original non-trainer contact byte remains supported') + local d=data();s=save() + local bankGame,bankWorld=game(d,s) + local bank=Runner.new(bankGame,bankWorld);bank:run(assert(require('src.script.Gen2ScriptVM').compile(d,'BANK'))) + local box=bankGame.stack:top();box.done=true;box:update(0) + local yes=bankGame.stack:top();bankGame.input={wasPressed=function(_,key)return key=='a'end};yes.index=1;yes:update(0);bankGame.input={wasPressed=function()return false end};for _=1,20 do if bankGame.stack:top()==yes then yes:update(0)end end + local feedback=bankGame.stack:top();feedback.done=true;bankGame.input={wasPressed=function(_,key)return key=='a'end};feedback:update(0) + T.eq(bank:isRunning(),false,version..' actual BankOfMom UI completes first saving choice') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' actual BankOfMom UI writes native SOME flag') + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' actual BankOfMom UI opens native account') + T.eq(Mom.mode(s),1,version..' actual BankOfMom UI affects reward allocation mode') + local d=data();s=save(100) + local purchase=buy(d,s) + T.eq(purchase.kind,1,version..' first ladder purchase is native item') + T.eq(s.pcItems.ITEM_017,1,version..' native item byte resolves current dataset') + T.eq(s.g2MomMoney,80,version..' price deducted once') + T.eq(s.g2MomShopping.whichItem,1,version..' ladder advances only on delivery') + T.eq(s.g2MomShopping.whichSet,0,version..' ladder uses native zero set') + T.eq(purchase.caller,3,version..' Mom caller is current native contact identity') + s.g2MomMoney=200;purchase=buy(d,s) + T.eq(s.flags[Flags.eventFlag(900)],true,version..' doll purchase grants actual ownership flag') + T.eq(s.g2MomMoney,130,version..' doll price is deducted') + T.eq(s.g2MomShopping.whichItem,2,version..' doll advances ladder once') + T.eq(s.decorations,nil,version..' doll ownership does not place it in the room') + local g,w=game(d,s);local r=Runner.new(g,w);r:run(purchase.script) + T.eq(s.flags[Flags.eventFlag(902)],true,version..' doll notification runs native compiled role') + s=save(101);purchase=buy(d,s) + T.check(purchase~=nil,version..' ladder takes precedence over random threshold') + s=save(34);s.g2MomShopping={whichItem=255,triggerBalance=17} + local called=0;purchase=buy(d,s,{random=function(n)called=called+1;return n-1 end}) + T.eq(purchase.item,18,version..' exact multiple selects current random table row') + T.eq(called,1,version..' random purchase consumes one bounded roll') + T.eq(s.g2MomShopping.triggerBalance,51,version..' consumed exact threshold advances once') + T.eq(s.g2MomShopping.whichSet,2,version..' native random set is selected row plus one') + T.eq(s.g2MomShopping.whichItem,255,version..' random buy cannot consume ladder index') + T.eq(s.g2MomMoney,29,version..' random purchase deducts its own price') + s=save(35);s.g2MomShopping={whichItem=255,triggerBalance=17} + T.eq(buy(d,s),nil,version..' overshot multiple cannot buy') + T.eq(s.g2MomShopping.triggerBalance,51,version..' overshot threshold advances above current balance') + s=save(100);d.field.pcItemCap=1;s.pcItems.ITEM_018=99 + T.eq(buy(d,s),nil,version..' full PC rejects new stack') + T.eq(s.g2MomMoney,100,version..' refusal does not spend money') + T.eq(s.g2MomShopping.whichItem,0,version..' refusal does not advance ladder') + s.pcItems={ITEM_017=98};T.check(buy(d,s)~=nil,version..' existing stack with room accepts purchase') + T.eq(s.pcItems.ITEM_017,99,version..' item reaches last slot in actual stack') + s=save(100);s.pcItems.ITEM_017=99 + T.eq(buy(d,s),nil,version..' a full same-item stack needs another native PC stack') + s=save(200);s.g2MomShopping={whichItem=1,triggerBalance=17};s.pcItems.ITEM_018=99 + T.check(buy(d,s)~=nil,version..' doll ownership is independent of PC capacity') + s=save(100);T.eq(buy(d,s,{phoneService=false}),nil,version..' no reception suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' no reception cannot consume threshold or initialize purchase state') + T.eq(buy(d,s,{queued=true}),nil,version..' occupied native map-reentry queue suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' occupied queue cannot mutate purchase state') + d.map_scripts.scripts.ITEM_PHONE=nil + T.eq(buy(d,s),nil,version..' missing original phone program cannot grant a silent purchase') + T.eq(s.pcItems.ITEM_017,nil,version..' missing native program leaves inventory unchanged') + -- Actual battle win uses this account split and the cartridge-selected text. + d=data();require('src.battle.TypeChart').load(d);s=save(10) + s.party={Pokemon.new(d,'FIXMON_A',10)};s.g2MomSavingMode=1 + g,w=game(d,s) + local battle=Battle.newTrainer(g,'OPP_FIX_YOUNGSTER',1);battle.participants={} + for _,mon in ipairs(battle.enemyParty)do mon.hp=0 end + battle.enemyIndex=#battle.enemyParty;battle.enemy.mon=battle.enemyParty[battle.enemyIndex] + battle:enemyMonFainted() + T.eq(battle.result,'win',version..' real battle native win branch completes') + T.check(s.g2MomMoney>10,version..' real battle win actually increases Mom account') + T.eq(battle.g2MomAward.mode,1,version..' real battle records applied savings mode') + local found=false + for _,row in ipairs(battle.queue)do if row.text and row.text:find('SYNTHETIC SOME QA TOTAL ',1,true)then found=true end end + T.check(found,version..' real battle presents actual imported SOME reward text') + -- Actual world post-battle path commits once and waits behind native dialogue. + d=data();s=save(200);s.party={Pokemon.new(d,'FIXMON_A',10)} + s.g2MomShopping={whichItem=1,triggerBalance=17};g,w=game(d,s) + battle={kind='trainer',leveledUp={}} + w:afterBattle('win',battle) + T.eq(s.g2MomMoney,130,version..' actual trainer return purchases eligible doll') + T.check(w.g2MomCallPending~=nil,version..' actual trainer return defers original notification') + w:afterBattle('win',battle);T.eq(s.g2MomMoney,130,version..' repeated callback cannot buy twice') + w.pendingScripts={{script={{'end'}}}} + T.eq(w:checkMomShoppingCall(),false,version..' trainer after-dialogue queue keeps phone waiting') + w.pendingScripts={};w.player.moving=true + T.eq(w:checkMomShoppingCall(),false,version..' native phone does not interrupt a partial movement') + w.player.moving=false;g.stack:push({name='dialogue'}) + T.eq(w:checkMomShoppingCall(),false,version..' actual overlay owner keeps phone waiting') + g.stack:pop();T.eq(w:checkMomShoppingCall(),true,version..' idle native world queues original doll script') + T.eq(s.g2CurCaller,3,version..' deferred native call sets actual caller') + T.eq(w:checkMomShoppingCall(),false,version..' notification queues once') + local entry=w.pendingScripts[1];T.check(entry~=nil,version..' actual native FIFO receives notification') + if entry then w.runner:run(entry.script,entry.extra)end + T.eq(s.flags[Flags.eventFlag(902)],true,version..' actual queued original role executes') + T.eq(s.g2CurCaller,nil,version..' completed native call releases caller') + T.eq(s.g2CallCycles,0,version..' completed native call resets receive timer') + -- Physical SRAM offsets checked against the independent shipped symbols. + S.setLayout(version);S.setCharmap({['128']='A'}) + s.g2MomMoney=123456;s.g2MomSavingMode=2;s.g2MomSaving=true;s.g2MomBankOpen=true + s.flags.ENGINE_MOM_SAVING_MONEY=false;s.flags.ENGINE_MOM_ACTIVE=true + s.g2MomShopping={whichItem=2,whichSet=1,triggerBalance=98765} + local bytes=S.encode(s,d);local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(J.decode(f:read('*a')));f:close() + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + local function offset(key)return 0x2009+manifest.symbols[key][2]-anchor end + local function three(at)local a,b,c=bytes:byte(at+1,at+3);return a*65536+b*256+c end + T.eq(three(offset('wMomsMoney')),123456,version..' Mom account uses actual three-byte SRAM field') + T.eq(bytes:byte(offset('wMomSavingMoney')+1),130,version..' saving mode and active bit preserve native byte') + T.eq(bytes:byte(offset('wWhichMomItem')+1),2,version..' native ladder index exports at independent address') + T.eq(three(offset('wMomItemTriggerBalance')),98765,version..' native threshold exports at independent address') + local back=S.decode(bytes,d) + T.eq(back.g2MomMoney,123456,version..' native balance round-trips') + T.eq(Mom.mode(back),2,version..' HALF mode round-trips without becoming SOME') + T.eq(back.g2MomBankOpen,true,version..' bank active state round-trips') + T.eq(back.g2MomShopping.whichItem,2,version..' ladder progress round-trips') + T.eq(back.g2MomShopping.whichSet,1,version..' random row round-trips') + T.eq(back.g2MomShopping.triggerBalance,98765,version..' threshold progress round-trips') + local again=S.encode(back,d,bytes);local decoded=S.decode(again,d) + local good=true;for _,warning in ipairs(decoded.warnings)do if warning:find('checksum')then good=false end end + T.check(good,version..' Mom fields remain covered by valid native checksum') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local s=save(10);T.eq(Mom.reward(s,900),nil,version..' native GSC account split is isolated') + T.eq(Mom.tryBuy(data(),s,{phoneService=true}),nil,version..' foreign edition cannot buy retail GSC gifts') + S.setLayout(version);T.eq(S.OFFSETS.momMoney,nil,version..' unreviewed SRAM layout has no guessed Mom fields') +end +Sound.play=oldSound;debug.setupvalue(W.afterBattle,slot,oldGame);V.set(original);S.setLayout(original) +T.finish('native GSC Mom accounts, purchases, trainer return, phone queue and SRAM') From 87f69596012dafa8d5feb216b87f8025a0f42736 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:27:10 +0100 Subject: [PATCH 09/68] Retain native non-trainer phone identity for original Mom purchase calls --- src/import/RomExtractorGen2.lua | 4 +++- tests/engine/gen2_mom_import.lua | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index bc6d0ff51..7e1867848 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -8669,7 +8669,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + -- Keep the non-trainer's native identity; names are localizable. + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + class = 0, contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua index 036bc09e8..5824fb69b 100644 --- a/tests/engine/gen2_mom_import.lua +++ b/tests/engine/gen2_mom_import.lua @@ -12,19 +12,25 @@ local function fixture(version,edit) local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + PhoneContacts={1,0x4400},SpecialPhoneCallList={1,0x4418},NonTrainerCallerNames={1,0x4500}, GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) put(0x4200,string.char(0x91,0x91)) + put(0x440c,string.char(0,1));put(0x4502,string.char(0x10,0x45));put(0x4510,string.char(128,0x50)) if edit then edit(symbols,put)end local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + function e:readSourceTable(key)assert(key=='charmap');return{['128']='Q'}end return e end local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end for _,version in ipairs({'gold','silver','crystal'})do V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + local phone=e:gen2PhoneContacts(nil) + T.eq(phone[1].contactNumber,1,version..' actual non-trainer contact identity survives localized name extraction') + T.eq(phone[1].class,0,version..' actual imported Mom contact retains native non-trainer class') T.eq(#rows.random,2,version..' random count comes from its end symbol') T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') From d0024cdb2ccf056bc87a740146094f8752deb72e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:51:44 +0100 Subject: [PATCH 10/68] Expose legacy decoration contracts over native GSC records and room state --- docs/native-gen2-decorations.md | 45 ++++++- src/compat/gen2/Decorations.lua | 138 ++++++++++++++++++++ src/core/Data.lua | 1 + src/core/GameVersion.lua | 5 + src/core/ModCompat.lua | 2 + src/mods/Loader.lua | 2 + src/ui/Gen2DecorationMenu.lua | 41 ++---- src/world/Gen2Decorations.lua | 124 +++++++++++++++--- tests/engine/gen2_decoration_facade.lua | 160 ++++++++++++++++++++++++ 9 files changed, 471 insertions(+), 47 deletions(-) create mode 100644 src/compat/gen2/Decorations.lua create mode 100644 tests/engine/gen2_decoration_facade.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md index e9d31d63e..702920fc3 100644 --- a/docs/native-gen2-decorations.md +++ b/docs/native-gen2-decorations.md @@ -57,6 +57,44 @@ the imported template intact; invalid placed bytes fail instead of wrapping. Unreviewed hack layouts receive neither guessed retail tables nor guessed save offsets. +## Legacy module contract + +`require("src.core.gen2.Decorations")` resolves to a signature adapter, not +directly to `src.world.Gen2Decorations`. It exposes `state(save)`, +`attributes(id)`, `name(id, monName)`, `idFor(id)`, `idForFlag(ordinal)`, +`registerInto(registry, data, owner)`, ownership/gift methods, +`ownedCategories(events)`, `rows(events, category)`, `apply(state, id, side, +monName)`, `clearOtherSide`, `visibility`, `tiles` and `posterVisible`. +The action/slot/description constants retain the legacy shapes. `ATTRIBUTES`, +`CATEGORIES` and `NOTHING_TO_CHOOSE` are read projections of the current +native dataset. Attribute rows remain the actual native records; registering +them into another registry passes copies. + +Data initialization and Loader entry bind the current native dataset. Reads +follow subsequent registry folding and rollback. Teardown or an edition +change retires the binding. Before a complete import, the facade returns no +catalogue/defaults instead of fabricating retail content. Removed records do +not fall back to a second catalogue. Both numeric bytes and numeric-string +bytes resolve and placements store numbers. + +Ownership accepts the legacy numeric `events:get/set` protocol or a native +save directly. `events(save)` supplies a numeric view backed by the same +canonical flags used by scripts and SRAM, with no parallel bitfield. That +view expires on rebinding/unload. Cancel/header rows cannot grant event zero. +Categories are rebuilt from native action identities; added rows participate +and removed headers are not invented. Existing empty placement is preserved. + +`apply` follows the legacy two-result contract `(changed, pages)` and does +not check ownership; the menu owns that check. Ornament side cancellation +does nothing, and `clearOtherSide` remains a separate operation for these +callers. The pages use the actual imported feedback text and buffers 3/4, +including authored replacements and explicit script-text removal. The native +PC and facade share placement planning and tile/object layout routines; the +PC still commits after feedback closes. A valid block zero and wrapped +carpet offsets retain native behavior rather than the old facade's filter. +This adapter does not turn a detached external event bitfield into the live +world, nor does wrapping a facade method patch every native module method. + ## Evidence and remaining scope ROM-free tests exercise actual importers, Loader, native script compiler and @@ -72,8 +110,13 @@ executes the original house-PC/description scripts and room callbacks, and round-trips all eight placed bytes. Original ROM hashes are checked unchanged. The local font/audio/render harness is used; no visual acceptance is inferred. +The public-module tests load a synthetic mod through the actual Loader, +change and remove records, grant ownership, place decorations and execute +the native room callback VM. They verify feedback pages, byte identity, +retirement on reload/edition changes, and rollback without a second catalogue. + Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). -Full legacy `Decorations` facade parity, the remaining gift lifecycle, +The remaining gift lifecycle, legacy MomShopping/phone contracts, new graphical acceptance and a full unchanged VASC playthrough remain separate open work. Importing the gift-ordinal table alone does not implement those callers. The existing preview is not refreshed by this source change. diff --git a/src/compat/gen2/Decorations.lua b/src/compat/gen2/Decorations.lua new file mode 100644 index 000000000..952a801a6 --- /dev/null +++ b/src/compat/gen2/Decorations.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Legacy argument/result contracts over the native cartridge and placed bytes. +-- No fallback catalogue, ownership bitfield or second decoration save exists. +local Native=require('src.world.Gen2Decorations') +local Version=require('src.core.GameVersion') +local Flags=require('src.script.Flags') +local Gen2Flags=require('src.script.Gen2Flags') +local Copy=require('src.mods.Merge').deepCopy +local M={SLOTS=Copy(Native.slots),OBJECT_SLOTS=Copy(Native.objectSlots),ACTIONS={}, + EVENT_PLAYERS_ROOM_POSTER=716,DECOFLAG_GOLD_TROPHY_DOLL=43,DECOFLAG_SILVER_TROPHY_DOLL=44, + DESC_SLOTS={DECODESC_POSTER={slot='poster'},DECODESC_LEFT_DOLL={slot='leftOrnament',named=true}, + DECODESC_RIGHT_DOLL={slot='rightOrnament',named=true},DECODESC_BIG_DOLL={slot='bigDoll'}, + DECODESC_CONSOLE={slot='console',named=true}}} +for n=1,14 do + local name=Native.actionName(n);local action=Native.action(name) + if not action.put then action.put=nil end + M.ACTIONS[name]=action +end +local bound,edition,epoch=nil,nil,0 +local function data() + return edition==Version.get()and Native.ready(bound)and bound or nil +end +function M.useRegistry(value) + epoch=epoch+1;bound=nil;edition=nil + if Native.ready(value)then bound=value;edition=Version.get()end + return bound~=nil +end +function M.idFor(id)return 'deco:'..tostring(id)end +function M.idForFlag(n) + n=n==nil and 0 or tonumber(n) + local d=data();local info=d and Native.pool(d).decorationInfo + if not(n and n%1==0 and n>=0 and n<=255 and info and info.giftsComplete)then return nil end + return info.gifts and info.gifts[n+1] +end +function M.attributes(id)return Native.attributes(data(),id)end +function M.state(save)return Native.state(data(),save)end +function M.name(id,monName)return Native.name(data(),id,monName)end +function M.registerInto(registry,source,owner) + local d=Native.ready(source)and source or data();local count=0 + if not d then return count end + local seen={} + for raw in pairs(Native.pool(d).decorations)do + local id=Native.byte(raw) + if id and not seen[id]then + seen[id]=true;registry:register(M.idFor(id),Copy(Native.attributes(d,id)),owner);count=count+1 + end + end + return count +end +-- Optional native adapter for callers of the numeric Events protocol. Its +-- get/set operations reach the same canonical flags as scripts and SRAM. +-- A retained adapter expires on rebind/unload; it never captures a new game. +function M.events(save) + if not data()or type(save)~='table'then return nil end + local generation=epoch + local function current(id) + return generation==epoch and data()and type(id)=='number'and id%1==0 and id>=0 and id<65535 + end + return{get=function(_,id) + return current(id)and type(save.flags)=='table'and Flags.get(save,Gen2Flags.eventFlag(id))or false + end,set=function(_,id,value) + if not current(id)then return false end + save.flags=save.flags or{} + if value then Flags.set(save,Gen2Flags.eventFlag(id))else Flags.clear(save,Gen2Flags.eventFlag(id))end + return true + end} +end +local function usable(id) + id=Native.byte(id) + local row=M.attributes(id);local action=row and Native.action(row.action) + return id and id~=0 and action and not action.put and row or nil +end +function M.owns(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.get)=='function'then return events:get(row.flag)and true or false end + return Native.owns(data(),events,id) +end +function M.give(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.set)=='function'then return events:set(row.flag,true)~=false end + return Native.give(data(),events,id) +end +function M.giveFlag(events,n)return M.give(events,M.idForFlag(n))end +function M.ownedCategories(events) + local out={} + for _,category in ipairs(Native.categories(data()))do + for _,id in ipairs(category.members)do + if M.owns(events,id)then out[#out+1]=category;break end + end + end + return out +end +function M.rows(events,category) + local out={} + -- Rebuild membership rather than trusting a category retained across reload. + for _,current in ipairs(Native.categories(data()))do + if category and current.id==category.id and(current.slot==category.slot or + category.slot==nil and category.id~=nil)then + for _,id in ipairs(current.members)do if M.owns(events,id)then out[#out+1]=id end end + if #out>0 then + for _,id in ipairs(current.headers)do out[#out+1]=id end + out[#out+1]=0 + end + break + end + end + return out +end +function M.apply(state,id,side,monName) + local placement=Native.placement(data(),state,id,side,monName) + if not placement then return false,{}end + state[placement.slot]=placement.value + return placement.changed,Native.feedbackPages(data(),placement) +end +function M.clearOtherSide(state,id,side) + id=Native.byte(id) + if not data()or type(state)~='table'or not id or id==0 or(side~='left'and side~='right')then return end + local other=side=='right'and 'leftOrnament'or 'rightOrnament' + if state[other]==id then state[other]=0 end +end +function M.visibility(state)return data()and Native.layoutVisibility(data(),state)or{}end +function M.tiles(state)return data()and Native.layoutTiles(data(),state)or{}end +function M.posterVisible(state)return data()~=nil and Native.posterVisible(state)end +return setmetatable(M,{__index=function(_,key) + if key=='CATEGORIES'then return Native.categories(data())end + if key=='ATTRIBUTES'then + local out={};local d=data() + for raw in pairs(d and Native.pool(d).decorations or{})do + local id=Native.byte(raw);if id then out[id]=Native.attributes(d,id)end + end + return out + end + if key=='NOTHING_TO_CHOOSE'then + local pages=Native.feedbackPages(data(),{role='noChoice',buffers={}}) + return pages[1] + end +end}) diff --git a/src/core/Data.lua b/src/core/Data.lua index ca41e93a0..6267ecb52 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1887,6 +1887,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.compat.gen2.Decorations").useRegistry(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..7ebabc4fd 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,12 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous = GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous ~= GameVersion.current then + local decorations = package.loaded["src.compat.gen2.Decorations"] + if decorations then decorations.useRegistry(nil) end + end return GameVersion.current end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..de815b24f 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,8 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + -- Explicit signature adapter: native operations take (data, save). + ["src.core.gen2.Decorations"] = "src.compat.gen2.Decorations", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 22977dbfc..f7c8474b9 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1321,6 +1321,7 @@ function Loader:load(data) -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require("src.compat.gen2.Decorations").useRegistry(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1660,6 +1661,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + require("src.compat.gen2.Decorations").useRegistry(nil) local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua index a091734cb..381b63b04 100644 --- a/src/ui/Gen2DecorationMenu.lua +++ b/src/ui/Gen2DecorationMenu.lua @@ -6,10 +6,6 @@ local Menu=require('src.ui.Menu') local Runner=require('src.script.ScriptRunner') local Strings=require('src.core.Strings') local M={} -local categories={ - {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, - {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, -} function M.new(game,opts) opts=opts or{} local changed,finished=false,false @@ -30,18 +26,11 @@ function M.new(game,opts) local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) local state=Native.state(game.data,game.save) if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end - local slot=action.slot or(side=='left'and 'leftOrnament'or side=='right'and 'rightOrnament') - if not slot then return end - local old=state[slot]or 0 + local placement=Native.placement(game.data,state,id,side) + if not placement then return end game.stringBuffers=game.stringBuffers or{} - game.stringBuffers[3]=Native.name(game.data,old) - game.stringBuffers[4]=Native.name(game.data,id) - local role - if action.put then role=old==0 and 'empty'or 'put' - elseif old==id then role='same' - elseif old==0 then role='setup';game.stringBuffers[3]=Native.name(game.data,id) - else role='replace'end - say(role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + for n,name in pairs(placement.buffers)do game.stringBuffers[n]=name end + say(placement.role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) end local function select(id) local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) @@ -66,25 +55,21 @@ function M.new(game,opts) else sides()end end local function category(key) - local items,headers,seen={},{},{} - for raw,row in pairs(Native.pool(game.data).decorations)do - local id=tonumber(raw);local action=Native.action(row.action) - local group=action and(action.slot or 'ornament') - if id and id~=0 and group==key and not seen[id]then - seen[id]=true - if action.put then headers[#headers+1]=id - elseif Native.owns(game.data,game.save,id)then items[#items+1]=id end + local items={} + for _,c in ipairs(Native.categories(game.data))do + if c.slot==key then + for _,id in ipairs(c.members)do if Native.owns(game.data,game.save,id)then items[#items+1]=id end end + return items,c.headers end end - table.sort(items);table.sort(headers) - return items,headers + return items,{} end local rows={} if Native.ready(game.data)and Native.state(game.data,game.save)then - for _,c in ipairs(categories)do - local key=c[2];local ids=category(key) + for _,c in ipairs(Native.categories(game.data))do + local key=c.slot;local ids=category(key) if #ids>0 then - rows[#rows+1]={label=Strings(c[1]),keepOpen=true,onSelect=function() + rows[#rows+1]={label=Strings(c.label),keepOpen=true,onSelect=function() local owned,headers=category(key) if #owned==0 then say('noChoice');return end local items={} diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua index d7eab2a3b..3f87b4942 100644 --- a/src/world/Gen2Decorations.lua +++ b/src/world/Gen2Decorations.lua @@ -6,6 +6,18 @@ local V=require('src.core.GameVersion') local Strings=require('src.core.Strings') local M={} M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +M.objectSlots={ + {slot='console',sprite=0,flag=1857},{slot='leftOrnament',sprite=1,flag=1858}, + {slot='rightOrnament',sprite=2,flag=1859},{slot='bigDoll',sprite=3,flag=1860}, +} +local groups={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.byte(id) + local n=(type(id)=='number'or type(id)=='string')and tonumber(id) + return n and n%1==0 and n>=0 and n<=255 and n or nil +end local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} local actions={} @@ -28,8 +40,9 @@ function M.ready(data) end function M.attributes(data,id) if not M.ready(data)then return nil end + id=M.byte(id);if not id then return nil end local rows=M.pool(data).decorations - return rows[tonumber(id)]or rows[tostring(id)] + return rows[id]or rows[tostring(id)] end function M.state(data,save) if not M.ready(data)or type(save)~='table'then return nil end @@ -44,24 +57,30 @@ local names={ [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), } -function M.name(data,id) +function M.name(data,id,monName) + id=M.byte(id) local row=M.attributes(data,id) if not row then return ''end local name=row.name if row.type>=4 then + if monName then name=monName(name)or name + else local info=M.pool(data).decorationInfo local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name local mon=data.pokemon and data.pokemon[key] name=mon and mon.name or name + end end return names[row.type] and Strings(names[row.type],name)or name end function M.owns(data,save,id) + id=M.byte(id) local row=M.attributes(data,id);local action=row and M.action(row.action) return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, require('src.script.Gen2Flags').eventFlag(row.flag))or false end function M.give(data,save,id) + id=M.byte(id) local row=M.attributes(data,id) if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end save.flags=save.flags or{} @@ -73,6 +92,7 @@ function M.giveFlag(data,save,n) return id~=nil and M.give(data,save,id)or false end function M.choose(data,save,id,side) + id=M.byte(id) local state=M.state(data,save);local row=M.attributes(data,id) local action=row and M.action(row.action) if id==0 or not(state and action)then return false end @@ -91,17 +111,94 @@ function M.choose(data,save,id,side) end return changed end +-- Public menu metadata is derived from the current native rows, including +-- registry additions/removals. No independent retail catalogue is seeded. +function M.categories(data) + if not M.ready(data)then return{}end + local indexed={} + for raw in pairs(M.pool(data).decorations)do + local id=M.byte(raw);if id and id~=0 then indexed[id]=true end + end + local out={} + for _,group in ipairs(groups)do + local category={label=group[1],slot=group[2],members={},headers={}} + for id in pairs(indexed)do + local row=M.attributes(data,id);local action=row and M.action(row.action) + if action and(action.slot or 'ornament')==category.slot then + local list=action.put and category.headers or category.members;list[#list+1]=id + end + end + table.sort(category.members);table.sort(category.headers) + category.id=category.headers[1];out[#out+1]=category + end + return out +end +function M.placement(data,state,id,side,monName) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + if type(state)~='table'or not action or id==0 then return nil end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return nil end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local old=M.byte(state[slot])or 0 + local value=action.put and 0 or id + local role=action.put and(old==0 and 'empty'or 'put')or + old==id and 'same'or old==0 and 'setup'or 'replace' + return{slot=slot,value=value,changed=old~=value,role=role, + buffers={[3]=M.name(data,role=='setup'and id or old,monName),[4]=M.name(data,id,monName)}} +end +function M.feedbackPages(data,placement) + if not placement then return{}end + local programs=M.pool(data)and M.pool(data).decorationPrograms + local id=programs and programs.texts and programs.texts[placement.role] + if not id then return{}end + local private=data._portableScriptText and data._portableScriptText[id] + local text=private and private.value or not private and data.text and data.text[id] + if type(text)~='string'then return{}end + text=text:gsub('{RAM:wStringBuffer([34])}',function(n)return placement.buffers[tonumber(n)]end) + text=text:gsub('{DONE}',''):gsub('{PROMPT}',''):gsub('\v','\n') + local pages={} + for page in(text..'\f'):gmatch('(.-)\f')do if page~=''then pages[#pages+1]=page end end + return pages +end +function M.layoutVisibility(data,state) + local out={} + for _,binding in ipairs(M.objectSlots)do + local id=type(state)=='table'and M.byte(state[binding.slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + out[#out+1]={sprite=binding.sprite,flag=binding.flag,hidden=not row,byte=row and row.sprite or nil} + end + return out +end +function M.layoutTiles(data,state) + local out={} + local function paint(slot,x,y) + local id=type(state)=='table'and M.byte(state[slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + if row then out[#out+1]={x=x,y=y,block=row.sprite};return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + out[#out+1]={x=0,y=1,block=(carpet+1)%256} + out[#out+1]={x=1,y=1,block=(carpet+2)%256} + out[#out+1]={x=2,y=1,block=(carpet+1)%256} + end + return out +end +function M.posterVisible(state)return((state and state.poster)or 0)~=0 end -- These callbacks deliberately preserve the VM's current script/check vars. function M.visibility(ctx) local data=ctx.game.data;local state=M.state(data,ctx.save) if not state then return end ctx.save.flags=ctx.save.flags or{} local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') - for _,binding in ipairs({{'console',0,1857},{'leftOrnament',1,1858},{'rightOrnament',2,1859},{'bigDoll',3,1860}})do - local id=state[binding[1]]or 0;local row=M.attributes(data,id) - if id~=0 and row then - C.g2_variablesprite(ctx,binding[2],row.sprite);C.clear_flag(ctx,Flags.eventFlag(binding[3])) - else C.set_flag(ctx,Flags.eventFlag(binding[3]))end + for _,row in ipairs(M.layoutVisibility(data,state))do + if not row.hidden then + C.g2_variablesprite(ctx,row.sprite,row.byte);C.clear_flag(ctx,Flags.eventFlag(row.flag)) + else C.set_flag(ctx,Flags.eventFlag(row.flag))end end end function M.tiles(ctx) @@ -109,18 +206,9 @@ function M.tiles(ctx) if not state then return end ctx.save.flags=ctx.save.flags or{} local C=require('src.script.Commands') - local function paint(slot,x,y) - local id=state[slot]or 0;local row=id~=0 and M.attributes(data,id) - if row then C.replace_block(ctx,x,y,row.sprite);return row.sprite end - end - paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) - local carpet=paint('carpet',0,0) - if carpet then - C.replace_block(ctx,0,1,(carpet+1)%256);C.replace_block(ctx,1,1,(carpet+2)%256) - C.replace_block(ctx,2,1,(carpet+1)%256) - end + for _,row in ipairs(M.layoutTiles(data,state))do C.replace_block(ctx,row.x,row.y,row.block)end local flag=require('src.script.Gen2Flags').eventFlag(716) - if (state.poster or 0)~=0 then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end + if M.posterVisible(state)then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end end function M.description(data,save,kind) local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms diff --git a/tests/engine/gen2_decoration_facade.lua b/tests/engine/gen2_decoration_facade.lua new file mode 100644 index 000000000..108caedad --- /dev/null +++ b/tests/engine/gen2_decoration_facade.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local D=require('src.core.gen2.Decorations') +local N=require('src.world.Gen2Decorations') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local F=require('src.script.Gen2Flags') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='BACK',flag=0,sprite=0}, + [40]={type=1,name='STORE',flag=0,action='PUT_AWAY_BED',sprite=0}, + [41]={type=2,name='ONE',flag=620,action='SET_UP_BED',sprite=0}, + [42]={type=2,name='TWO',flag=621,action='SET_UP_BED',sprite=22}, + [80]={type=1,name='STORE DOLL',flag=0,action='PUT_AWAY_DOLL',sprite=0}, + [81]={type=5,name='MODEL',flag=622,action='SET_UP_DOLL',sprite=31}, + [90]={type=1,name='RUG',flag=623,action='SET_UP_CARPET',sprite=255}, + [91]={type=1,name='SCREEN',flag=624,action='SET_UP_CONSOLE',sprite=33}, + [92]={type=4,name='MODEL',flag=625,action='SET_UP_POSTER',sprite=34}, + [93]={type=6,name='MODEL',flag=626,action='SET_UP_BIG_DOLL',sprite=35}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=41,poster=92}, + giftsComplete=true,gifts={42,81},species={[81]='SPECIES_001',[92]='SPECIES_001',[93]='SPECIES_001'}, + sourceNames={[81]='MODEL',[92]='MODEL',[93]='MODEL'}}, + decorationPrograms={texts={setup='SET',put='PUT',empty='EMPTY',same='SAME',replace='REPLACE',noChoice='NONE'}}, + scripts={ROOM={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'end'}}}} + d.pokemon.SPECIES_001={name='LIVE'} + d.text={SET='A:{RAM:wStringBuffer3}{DONE}',PUT='B:{RAM:wStringBuffer3}{PROMPT}', + EMPTY='C',SAME='D',REPLACE='E:{RAM:wStringBuffer3}\fF:{RAM:wStringBuffer4}{DONE}',NONE='G'} + return d +end +local function load(d,code) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/test/manifest.json']=Json.encode({id='test',name='Synthetic',version='1.0.0',api=2,entry='main.lua',affects_link=false}) + files['mods/test/main.lua']='local mod=...;mod.exports.deco=require("src.core.gen2.Decorations");'..code + paths={'mods/test'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function done(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local pristine=d.map_scripts.decorations + local r=load(d,[[ + assert(mod.exports.deco.ATTRIBUTES[41].sprite==0) + mod.content.decorations:patch('deco:42',{name='PATCHED',sprite=72}) + mod.content.decorations:register('deco:201',{type=1,name='AUTHORED',flag=900,action='SET_UP_BED',sprite=73}) + mod.content.decorations:remove('deco:41') + ]]) + T.eq(#r.errors,0,version..' real Loader imports public contract before entry executes') + T.eq(D,require('src.compat.gen2.Decorations'),version..' legacy module identity is the adapter') + T.check(D~=N,version..' incompatible signature is not a native alias') + T.eq(D.attributes(41),nil,version..' removed record never resurrects from retail fallback') + T.eq(D.ATTRIBUTES[41],nil,version..' current catalogue agrees with registry removal') + T.eq(D.attributes('42'),d.map_scripts.decorations[42],version..' string byte reads the one actual native record') + T.eq(D.name(42),'PATCHED BED',version..' merged name reaches public function') + T.eq(D.name('81'),'LIVE DOLL',version..' string identity still resolves imported species') + T.eq(D.name(81,function(base)return base=='MODEL'and 'CUSTOM'end),'CUSTOM DOLL',version..' legacy resolver is honored') + T.eq(D.name(92),'LIVE POSTER',version..' poster grammar') + T.eq(D.name(93),'BIG LIVE',version..' large doll grammar') + T.eq(D.name(999),'',version..' invalid byte has no invented name') + local save={flags={}};local state=D.state(save) + T.eq(state,save.decorations,version..' public state is the actual native placed-byte table') + T.same(state,{bed=41,poster=92},version..' defaults come from ROM despite non-retail identities') + T.same(D.state({decorations={}}),{},version..' explicitly empty native placement stays empty') + local events=D.events(save) + T.eq(D.owns(events,201),false,version..' new decoration starts unowned') + T.check(D.give(events,201),version..' numeric public event protocol awards authored record') + T.eq(save.flags[F.eventFlag(900)],true,version..' award reaches canonical native save flag') + T.eq(N.owns(d,save,201),true,version..' native ownership sees legacy award') + T.eq(D.give(events,0),false,version..' cancel cannot award flag zero') + T.eq(D.give(events,40),false,version..' put-away header cannot award flag zero') + T.eq(save.flags[F.eventFlag(0)],nil,version..' rejected award does not mutate map buffer') + T.eq(D.idForFlag(0),42,version..' gift ordinal follows native non-retail mapping') + T.check(D.giveFlag(events,1),version..' native ordinal awards doll') + T.eq(D.idForFlag(-1),nil,version..' invalid ordinal does not guess') + T.check(D.give(save,'42'),version..' direct native-save argument also grants ownership') + T.eq(N.owns(d,save,42),true,version..' direct award reaches actual native ownership') + local category=D.ownedCategories(events)[1] + T.same(category.members,{42,201},version..' categories contain actual merged IDs without removed member') + T.same(D.rows(events,category),{42,201,40,0},version..' owned members then actual header then cancel') + T.same(D.rows(events,{id=40,members={2}}),{42,201,40,0},version..' legacy plain category shape resolves native members') + T.eq(D.idFor(201),'deco:201',version..' canonical registry identity') + T.eq(D.NOTHING_TO_CHOOSE,'G',version..' feedback derives from actual ROM text ID') + local seen={};local owner={} + local count=D.registerInto({register=function(_,id,row,who)seen[id]=row;T.eq(who,owner,version..' seed keeps ownership')end},nil,owner) + T.eq(count,10,version..' seed uses current rows including additions and removals') + seen['deco:42'].name='DETACHED' + T.eq(D.name(42),'PATCHED BED',version..' seeding another registry cannot mutate native catalogue') + local changed,pages=D.apply(state,'42') + T.eq(changed,true,version..' public apply replaces placed native byte') + T.eq(state.bed,42,version..' numeric string never writes a string into SRAM state') + T.same(pages,{'E:','F:PATCHED BED'},version..' replacement uses both actual pages; removed old name is empty') + changed,pages=D.apply(state,42) + T.eq(changed,false,version..' same placement is not a change') + T.same(pages,{'D'},version..' same-placement feedback uses ROM role') + changed,pages=D.apply(state,40) + T.check(changed,version..' put-away changes filled slot') + T.same(pages,{'B:PATCHED BED'},version..' put-away names old item and removes end marker') + changed,pages=D.apply(state,40) + T.eq(changed,false,version..' empty put-away is not a change') + T.same(pages,{'C'},version..' empty feedback') + changed,pages=D.apply(state,201) + T.check(changed,version..' setup in empty slot') + T.same(pages,{'A:AUTHORED'},version..' setup uses original role with new item in buffer3') + changed,pages=D.apply(state,81,nil) + T.eq(changed,false,version..' cancelled side prompt has no placement') + T.same(pages,{},version..' cancelled side prompt has no feedback') + state.rightOrnament=81 + changed,pages=D.apply(state,81,'left') + T.check(changed,version..' chosen doll side changes') + T.eq(state.rightOrnament,81,version..' apply keeps separate legacy clearOtherSide contract') + D.clearOtherSide(state,81,'left');T.eq(state.rightOrnament,0,version..' explicit clear removes opposite duplicate') + state.console=91;state.bigDoll=93;state.carpet=90 + local paint={};local game={data=d,save=save};local world={replaceBlock=function(_,x,y,block)paint[x..','..y]=block end} + local runner=Runner.new(game,world);runner:run(assert(VM.compile(d,'ROOM'))) + for _,row in ipairs(D.tiles(state))do T.eq(paint[row.x..','..row.y],row.block,version..' actual VM tiles agree with public layout')end + T.eq(paint['0,2'],73,version..' legacy placement is visible to actual room callback') + T.eq(paint['0,1'],0,version..' wrapped carpet block remains a valid zero byte') + for _,row in ipairs(D.visibility(state))do + T.eq(save.flags[F.eventFlag(row.flag)],row.hidden,version..' actual room visibility matches public plan') + if not row.hidden then T.eq(save.gen2VarSprites[row.sprite],row.byte,version..' public sprite byte matches native callback')end + end + T.eq(D.posterVisible(state),true,version..' poster visibility agrees with native event') + T.eq(save.flags[F.eventFlag(716)],true,version..' native poster callback reads same placed state') + d.text.SET='H:{RAM:wStringBuffer3}{DONE}' + state.bed=0;local _,fresh=D.apply(state,42) + T.same(fresh,{'H:PATCHED BED'},version..' text edits after binding are live') + d._portableScriptText={SET={}};state.bed=0 + local _,masked=D.apply(state,42) + T.same(masked,{},version..' explicitly removed script text does not fall back') + done(r) + T.eq(D.attributes(42),nil,version..' teardown retires data binding') + T.eq(events:set(900,false),false,version..' retained numeric adapter cannot mutate after unload') + T.eq(save.flags[F.eventFlag(900)],true,version..' stale adapter leaves prior save intact') + local nextData=fixture();nextData.map_scripts.decorationInfo.defaults={bed=42} + local clean=load(nextData) + T.same(D.state({}),{bed=42},version..' next native dataset supplies its own defaults') + T.eq(D.attributes(201),nil,version..' old mod addition does not leak into new dataset') + T.eq(events:set(900,false),false,version..' old adapter cannot become live after rebinding') + V.set('red');V.set(version) + T.eq(D.attributes(42),nil,version..' edition switch and return cannot revive stale binding') + done(clean) + local reset=load(d) + T.eq(d.map_scripts.decorations,pristine,version..' reload preserves real registry rollback identity') + T.eq(D.attributes(41),pristine[41],version..' removed record returns only when native registry restores it') + done(reset) +end +V.set('crystal') +local unsupported={map_scripts={decorations={[2]={name='NOT IMPORTED'}}}} +T.eq(D.useRegistry(unsupported),false,'partial cache does not enable legacy native facade') +T.eq(D.state({}),nil,'missing native defaults do not invent retail bedroom') +T.same(D.CATEGORIES,{},'unbound catalogue has no copied retail categories') +V.set(original) +T.finish('native Gen2 decoration facade') From 3b170ce280c4bb1414ed1a6bfb6b45bf0102404b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:01:32 +0100 Subject: [PATCH 11/68] Expose raw RTC daytime alongside native Gen2 map palette queries --- src/render/PaletteFX.lua | 20 +++++++++----- tests/engine/gen2_palette_clock.lua | 43 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 tests/engine/gen2_palette_clock.lua diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index b07099033..0475e29a4 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -539,6 +539,18 @@ function PaletteFX.gen2Tod() return gen2Tod end -- bgSet fetches it (LoadMapPals). Neither reads or writes the live state, so -- calling them cannot disturb what the 2D atlas was baked with. +-- Public RTC-only query used by Gen2 scene renderers (including train rides). +-- It must not inherit the live map override, darkness, or atlas bake state. +function PaletteFX.clockDaytime(hour) + hour = tonumber(hour) + if not hour then hour = tonumber(os.date("%H")) end + hour = math.floor(hour or 12) % 24 + if hour < 4 then return "NITE" end + if hour < 10 then return "MORN" end + if hour < 18 then return "DAY" end + return "NITE" +end + -- Which of MORN / DAY / NITE / DARK a map takes. -- -- mapDef a record out of data.maps; its `mapPalette` is map header @@ -563,13 +575,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) if override then return override end -- AUTO: GetTimeOfDay's own table (5:$4032 .TimeOfDayTable). - hour = tonumber(hour) - if not hour then hour = tonumber(os.date("%H")) end - hour = math.floor(hour or 12) % 24 - if hour < 4 then return "NITE" end - if hour < 10 then return "MORN" end - if hour < 18 then return "DAY" end - return "NITE" + return PaletteFX.clockDaytime(hour) end -- The eight BG palettes a map wears on a given row. diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua new file mode 100644 index 000000000..1beb3dc13 --- /dev/null +++ b/tests/engine/gen2_palette_clock.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes') +local previous={dark=P.darkWorld(),tod=P.gen2Tod(),map=P.gen2MapPalette(),date=os.date} +T.eq(P,require('src.render.PaletteFX'),'public Gen2 palette spelling shares actual native module') +P.setDarkWorld(false);P.setGen2MapPalette(0) +local cases={{0,'NITE'},{3.99,'NITE'},{4,'MORN'},{9.99,'MORN'},{10,'DAY'},{17.99,'DAY'}, + {18,'NITE'},{23.99,'NITE'},{24,'NITE'},{28,'MORN'},{34,'DAY'},{-1,'NITE'}} +for _,case in ipairs(cases)do + T.eq(P.clockDaytime(case[1]),case[2],'RTC query boundary '..case[1]) + T.eq(P.daytimeFor({mapPalette=0},case[1],false),case[2],'native AUTO query shares boundary '..case[1]) +end +for _,v in ipairs({'gold','silver','crystal'})do + V.set(v) + os.date=function(format,...)if format=='%H'then return '19'end;return previous.date(format,...)end + P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) + T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') + T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DARK',v..' native darkness still wins before map override') + T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') + local Json=require('src.link.Json');local d=T.fixtures.fresh() + local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/clock/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + -- Authored scene contract: select a sky from the raw RTC, independently + -- of the current room's forced palette. No renderer/mod art is included. + local sky={MORN='early',DAY='sunny',NITE='late'} + mod.exports.sky=sky[Palettes.clockDaytime()] + mod.exports.override=Palettes.daytimeFor({mapPalette=1},nil,true) + ]]} + local r=T.sdk.loadMods({'mods/clock'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,v..' actual Loader accepts unchanged public clock call') + local exports=r.loader.exports.clock + T.eq(exports.sky,'late',v..' loaded scene gets RTC sky instead of nil-method failure') + T.eq(exports.override,'DAY',v..' loaded scene can separately query map palette') + require('src.mods.Loader').teardown();r.release() +end +os.date=previous.date;P.setDarkWorld(previous.dark);P.setGen2MapPalette(previous.map);P.setGen2Tod(previous.tod);V.set(version) +T.finish('Gen2 public scene RTC palette query') From f50726a5cde7be61d26685785a352a056e164249 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:37:00 +0100 Subject: [PATCH 12/68] Share native per-map palettes, roof rules and entered world context --- docs/native-gen2-palette-context.md | 53 +++++++++ src/core/Data.lua | 4 + src/core/GameVersion.lua | 5 + src/mods/Loader.lua | 4 + src/render/Gen2PaletteContext.lua | 74 ++++++++++++ src/render/PaletteFX.lua | 36 +++++- src/render/TileRenderer.lua | 98 ++++------------ src/world/OverworldController.lua | 21 +++- tests/engine/gen2_palette_clock.lua | 2 +- tests/engine/gen2_palette_context.lua | 158 ++++++++++++++++++++++++++ 10 files changed, 374 insertions(+), 81 deletions(-) create mode 100644 docs/native-gen2-palette-context.md create mode 100644 src/render/Gen2PaletteContext.lua create mode 100644 tests/engine/gen2_palette_context.lua diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md new file mode 100644 index 000000000..68450c9e8 --- /dev/null +++ b/docs/native-gen2-palette-context.md @@ -0,0 +1,53 @@ +# Native Gen2 map palette context + +Native GSC maps store the header's palette nibble in `mapPalette`. External +renderers also read the symbolic `def.palette`, `world.daytime` and +`world.flashUsed` contracts. These read views now expose native facts without +seeding another clock, Flash bit or palette catalogue. + +Map palette names are computed from the current byte. Explicit authored +values and existing record metatables are preserved. Unused native header +slots 5..7 follow AUTO; invalid/missing bytes do not invent a named palette. +The public world's effective daytime reads the actual palette bake state, +including darkness; Flash reads the actual `save.flashLit` field written by +the native script command. Explicit authored Lua fields retain precedence. +This does not implement the separate saved RTC-base protocol or every legacy +world mutation method. + +`PaletteFX.daytimeFor(def, hour, flashUsed)` resolves a known map from its own +header. A neighbouring room stays DAY while the current cave is dark, and an +unentered DARK cave stays dark until Flash is supplied. Metadata-free old +queries retain their prior live-state fallback. The native renderer uses +`gen2RowFor` to distinguish the entered map's actual presentation from a +neighbour's own header and the active clock/Flash presentation state. Native +map entry and Flash update that state; atlas cache keys include the selected +row. Animated overdraw receives the same selected palette as the static bake. +An omitted hour uses the entered world's native clock row, including its +`world.tod` selection; outside an entered context it keeps the host-clock +fallback. An explicit hour remains authoritative. Mod load/unload and edition +changes retire the entered context, so returning to the launcher cannot +revive a previous world's clock selection. + +`PaletteFX.bgSet` accepts the full native dataset or its published +`gen2Palettes` view. The latter carries a private association with its owner, +so imported special-map rules and roof pairs remain available. A superseded +view cannot combine old tileset rows with new metadata. Colourless caches and +DMG mode still decline colouring. + +Special-map rule ordering and roof selection are shared with `TileRenderer`. +The first matching special rule owns the decision even if its set is missing; +darkness skips specials. Roof replacement copies only the row list and two +middle roof colours, retaining endpoints and the source tileset. Live metadata +edits are observed through the normal native invalidation/rebuild lifecycle. +No ROM colours, dialogue, source art or private mod implementation is bundled. + +Synthetic tests exercise the actual Loader, public module calls, native Flash +writer and `TileRenderer` CPU atlas bake. Owned in-memory pixel/image adapters +replace graphics IO, and baked RGB samples are compared with the public query +for AUTO, forced rows, dark caves, special maps and roofs. These are not GPU +or complete unchanged-VASC gameplay acceptance tests. General legacy +`map.palette` row-hook semantics and saved RTC integration remain separate +parity work; the existing SGB palette-name hook is preserved. + +Original native selection routines retain Cedric's rights; additional +integration is covered by [the Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/core/Data.lua b/src/core/Data.lua index ca41e93a0..8863bbd8b 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -685,6 +685,9 @@ local function gen2PaletteRows(tileset) end function Data:publishGen2Palettes() + if require('src.core.GameVersion').isGen2()then + for _,def in pairs(self.maps or{})do require('src.render.Gen2PaletteContext').publish(def)end + end local tilesets = self.tilesets if type(tilesets) ~= "table" then return false end @@ -735,6 +738,7 @@ function Data:publishGen2Palettes() self.gen2Palettes = setmetatable({}, { __index = function(_, id) return gen2PaletteRows(tilesets[id]) end, }) + require('src.render.Gen2PaletteContext').bind(self.gen2Palettes,self) return true end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..6c92886cf 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,12 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous=GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous~=GameVersion.current then + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + end return GameVersion.current end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..cd3ab6876 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1310,6 +1310,8 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1653,6 +1655,8 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/render/Gen2PaletteContext.lua b/src/render/Gen2PaletteContext.lua new file mode 100644 index 000000000..d5631b5f4 --- /dev/null +++ b/src/render/Gen2PaletteContext.lua @@ -0,0 +1,74 @@ +-- Native special-map/roof selection adapted from TileRenderer.lua: +-- Copyright (c) 2026 Cedric. All rights reserved. See LICENSE.md. +-- Additional context/view integration (c) 2026 Roxas2712. +-- See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md; existing upstream rights remain unchanged. +local M={} +local names={[0]='PALETTE_AUTO','PALETTE_DAY','PALETTE_NITE','PALETTE_MORN','PALETTE_DARK', + 'PALETTE_AUTO','PALETTE_AUTO','PALETTE_AUTO'} +local ids={PALETTE_AUTO=0,PALETTE_DAY=1,PALETTE_NITE=2,PALETTE_MORN=3,PALETTE_DARK=4} +local views=setmetatable({},{__mode='k'}) +function M.id(def) + if type(def)~='table'then return nil end + local authored=rawget(def,'palette') + if ids[authored]~=nil then return ids[authored]end + local n=tonumber(def.mapPalette) + return n and n%1==0 and n>=0 and n<=7 and n or nil +end +function M.publish(def) + if type(def)~='table'or rawget(def,'palette')~=nil or getmetatable(def)~=nil then return false end + setmetatable(def,{__index=function(t,key) + if key=='palette'then return names[M.id(t)]end + end}) + return true +end +function M.bind(view,data)views[view]={data=data,tilesets=data.tilesets}end +function M.owner(data) + if type(data)~='table'then return nil end + local binding=views[data] + if binding then + local owner=binding.data + return owner.gen2Palettes==data and owner.tilesets==binding.tilesets and owner or nil,true + end + return type(data.tilesets)=='table'and data or nil,false +end +function M.special(data,def,tileset,row) + if row=='DARK'then return nil end + local special=data and data.field and data.field.gen2MapPalettes + if type(def)~='table'or type(special)~='table'or type(special.rules)~='table'then return nil end + for _,rule in ipairs(special.rules)do + local hit + if rule.kind=='map'then hit=rule.group==def.group and rule.number==def.number + elseif rule.kind=='landmark'then hit=rule.landmark==def.landmark + elseif rule.kind=='tileset'then hit=rule.tileset==tileset end + if hit and rule.landmarkMin then hit=type(def.landmark)=='number'and def.landmark>=rule.landmarkMin end + if hit and rule.landmarkEq then hit=def.landmark==rule.landmarkEq end + if hit then + local set=special.sets and special.sets[rule.set] + local rows=set and(set.ALL or set[row]or set.DAY) + if rows and #rows>0 then return '#pal:'..tostring(rule.set),rows end + return nil -- The first matching rule owns the decision, even if unavailable. + end + end +end +function M.roof(data,def,tileset,row) + local roofs=data and data.field and data.field.gen2Roofs + if type(roofs)~='table'or type(tileset)~='table'or + type(roofs.tilesets)~='table'or not roofs.tilesets[tileset.id]then return nil end + local group=def and def.group + local id=group~=nil and type(roofs.byGroup)=='table'and roofs.byGroup[group]or nil + if id==nil then return nil end + local image=type(roofs.images)=='table'and roofs.images[id]or nil + if not image then return nil end + local byTod=type(roofs.colors)=='table'and roofs.colors[group]or nil + local pair=byTod and(byTod[row]or byTod.DAY)or nil + return{image=image,pair=pair,slot=roofs.slot or 10,count=roofs.count or 9,palIndex=roofs.palIndex or 6, + variants=tileset.tileVariants,key='#roof:'..tostring(id)..'.'..tostring(group)} +end +function M.withRoof(rows,roof) + if not(roof and roof.pair)then return rows end + local out={};for i=1,#rows do out[i]=rows[i]end + local slot=roof.palIndex+1;local row=out[slot] + if row then out[slot]={row[1],roof.pair[1],roof.pair[2],row[4]}end + return out +end +return M diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 0475e29a4..c95d9d31e 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -489,6 +489,23 @@ local GEN2_TOD_ALIAS = { local GEN2_MAP_PALETTE_ROW = { [1] = "DAY", [2] = "NITE", [3] = "MORN", [4] = "NITE", } +local gen2FlashUsed=false +local gen2CurrentDef +function PaletteFX.clearGen2Context()gen2CurrentDef=nil;gen2FlashUsed=false end +function PaletteFX.bindGen2Context(def,flashUsed) + gen2CurrentDef=type(def)=='table'and def or nil;gen2FlashUsed=flashUsed==true +end +function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end +function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end +-- A neighbour uses its own header, the native hooked clock and the same Flash +-- presentation state. It must not inherit the current map's forced DAY row. +function PaletteFX.gen2RowFor(def) + if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end + local id=require('src.render.Gen2PaletteContext').id(def) + if id==nil then return PaletteFX.effectiveGen2Tod() end + if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or gen2ClockTod +end local function gen2RecomputeTod() local key = GEN2_MAP_PALETTE_ROW[gen2MapPalette] or gen2ClockTod @@ -506,7 +523,8 @@ end -- Called on every map change with the map's PALETTE_* byte (0 = AUTO). -- Same contract as setGen2Tod: true means the atlases are stale. -function PaletteFX.setGen2MapPalette(palette) +function PaletteFX.setGen2MapPalette(palette, mapDef) + gen2CurrentDef=type(mapDef)=='table'and mapDef or nil local value = tonumber(palette) or 0 if value < 0 or value > 7 then value = 0 end -- PALETTE_5..7 are unused slots that ReplaceTimeOfDayPals maps to $E4, the @@ -562,6 +580,11 @@ end -- a caller that has no flash flag to offer gets the engine's own -- darkWorld state, which already folds in save.flashLit. function PaletteFX.daytimeFor(mapDef, hour, flashUsed) + local id=require('src.render.Gen2PaletteContext').id(mapDef) + if id~=nil then + if id==4 then return flashUsed==true and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2ClockTod)or PaletteFX.clockDaytime(hour) + end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. if flashUsed ~= true and darkWorld then return "DARK" end @@ -606,9 +629,18 @@ function PaletteFX.bgSet(data, mapDef, daytime) end if type(rows) ~= "table" then return nil end - local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or gen2Tod + local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or PaletteFX.gen2RowFor(mapDef) local set = rows[row] or rows.DAY if type(set) ~= "table" or set[1] == nil then return nil end + local context=require('src.render.Gen2PaletteContext') + local owner,bound=context.owner(data) + if bound and not owner then return nil end + if owner then + local _,special=context.special(owner,mapDef,tilesetId,row) + if special then set=special end + local tileset=owner.tilesets and owner.tilesets[tilesetId] + set=context.withRoof(set,context.roof(owner,mapDef,tileset,row)) + end return set, row end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..a336f0035 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -568,19 +568,20 @@ end -- palColorsByTod is what the Gen2 importer now writes; palColors alone is the -- older single-row shape, kept working so a dataset extracted before this -- change still renders instead of falling back to grey. -local function gen2PalColors(tileset) +local function gen2PalColors(tileset, row) local byTod = tileset.palColorsByTod if type(byTod) ~= "table" then return tileset.palColors end - if PaletteFX.darkWorld() then return byTod.DARK or tileset.palColors end - return byTod[PaletteFX.gen2Tod()] or byTod.DAY or tileset.palColors + row=row or PaletteFX.effectiveGen2Tod() + if row=='DARK'then return byTod.DARK or tileset.palColors end + return byTod[row] or byTod.DAY or tileset.palColors end -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. -local function gen2AtlasKey(imagePath, roof, special) +local function gen2AtlasKey(imagePath, roof, special, row) return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" - .. (PaletteFX.darkWorld() and "DARK" or tostring(PaletteFX.gen2Tod())) + .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so -- leaving the roof out of the key would hand the second town the first @@ -601,36 +602,10 @@ end -- -- Skipped outright while the world is dark, which keeps the ROM's ordering: -- the darkness row is the first row in that table and beats every other. -local function gen2SpecialPalette(map, data) - if PaletteFX.darkWorld() then return nil end - local special = data and data.field and data.field.gen2MapPalettes - if type(special) ~= "table" or type(special.rules) ~= "table" then return nil end - local def = data.maps and map.id and data.maps[map.id] - if type(def) ~= "table" then return nil end - local tileset = map.tileset and map.tileset.id - for _, rule in ipairs(special.rules) do - local hit - if rule.kind == "map" then - hit = rule.group == def.group and rule.number == def.number - elseif rule.kind == "landmark" then - hit = rule.landmark == def.landmark - elseif rule.kind == "tileset" then - hit = rule.tileset == tileset - end - -- RegionCheck, as PokeCenterSpecialCase applies it - if hit and rule.landmarkMin then - hit = type(def.landmark) == "number" and def.landmark >= rule.landmarkMin - end - if hit and rule.landmarkEq then hit = def.landmark == rule.landmarkEq end - if hit then - local set = special.sets and special.sets[rule.set] - local rows = set and (set.ALL - or set[PaletteFX.gen2Tod()] or set.DAY) - if rows and #rows > 0 then return "#pal:" .. tostring(rule.set), rows end - return nil - end - end - return nil +local function gen2SpecialPalette(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def + return require('src.render.Gen2PaletteContext').special(data,def, + map.tileset and map.tileset.id,row or PaletteFX.effectiveGen2Tod()) end -- THE ROOF A MAP WEARS, or nil when it wears none. @@ -639,33 +614,10 @@ end -- tileset (see RomExtractorGen2:gen2Roofs for the cartridge routines). The -- art is a nine-tile strip on disk and the colour is a PAIR -- colours 1 and 2 -- of the roof palette row, which is all LoadMapPals ever writes. -local function gen2RoofFor(map, data) - local roofs = data and data.field and data.field.gen2Roofs - if type(roofs) ~= "table" then return nil end - local tileset = map and map.tileset - if not (tileset and type(roofs.tilesets) == "table" - and roofs.tilesets[tileset.id]) then - return nil - end - local def = data.maps and map.id and data.maps[map.id] - local group = (def and def.group) or map.group - local id = group ~= nil and type(roofs.byGroup) == "table" - and roofs.byGroup[group] or nil - if id == nil then return nil end - local image = type(roofs.images) == "table" and roofs.images[id] or nil - if not image then return nil end - local byTod = type(roofs.colors) == "table" and roofs.colors[group] or nil - local pair = byTod and (PaletteFX.darkWorld() and byTod.DARK - or byTod[PaletteFX.gen2Tod()] or byTod.DAY) or nil - return { - image = image, - pair = pair, - slot = roofs.slot or 10, - count = roofs.count or 9, - palIndex = roofs.palIndex or 6, - variants = tileset.tileVariants, - key = "#roof:" .. tostring(id) .. "." .. tostring(group), - } +local function gen2RoofFor(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def or map + return require('src.render.Gen2PaletteContext').roof(data,def,map.tileset, + row or PaletteFX.effectiveGen2Tod()) end local gen2AtlasCache = {} @@ -1368,14 +1320,17 @@ function TileRenderer.new(map, data) -- "show me the hardware", and the DMG/SGB-flavoured modes have to fall -- through to the raw sheet and their own shade treatment. Unconditional, it -- made COLORS a no-op for every Gen2 tile on screen. - local gen2Colors = PaletteFX.usesGen2BgPal() and gen2PalColors(map.tileset) + local gen2Def=data and data.maps and map.id and data.maps[map.id] or map.def + local gen2Enabled=PaletteFX.usesGen2BgPal() + local gen2Row=gen2Enabled and PaletteFX.gen2RowFor(gen2Def) + local gen2Colors = gen2Enabled and gen2PalColors(map.tileset,gen2Row) -- ...and this map's own seven, where the cartridge gives it seven of its -- own. Before the roof swap below, because the ROM does it in that order: -- LoadMapPals picks the palettes, special or environment, and only then -- writes the map group's roof pair into whichever set it ended up with. local specialKey if gen2Colors then - local key, rows = gen2SpecialPalette(map, data) + local key, rows = gen2SpecialPalette(map, data,gen2Row) if rows then gen2Colors, specialKey = rows, key end end if not self.gbcAtlas and map.tileset.palMap and #map.tileset.palMap > 0 @@ -1386,18 +1341,9 @@ function TileRenderer.new(map, data) -- list with just that pair replaced is the whole of it. A copy, because -- `gen2Colors` is the tileset def's own table and two groups sharing a -- tileset would otherwise overwrite each other's roof. - local roof = gen2RoofFor(map, data) - if roof and roof.pair then - local swapped = {} - for i = 1, #gen2Colors do swapped[i] = gen2Colors[i] end - local row = swapped[roof.palIndex + 1] - if row then - swapped[roof.palIndex + 1] = - { row[1], roof.pair[1], roof.pair[2], row[4] } - end - gen2Colors = swapped - end - local key = gen2AtlasKey(map.tileset.image, roof, specialKey) + local roof = gen2RoofFor(map, data,gen2Row) + gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, TileRenderer.borrowStart(map.tileset), roof) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 54ac1c659..48fa02855 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -67,6 +67,13 @@ end local OverworldState = { isOpaque = true, isOverworld = true } local Game -- set on enter (avoids circular require at load time) +-- Read the active native bake/Flash fields rather than copying another world. +setmetatable(OverworldState,{__index=function(world,key) + if(key=='daytime'or key=='flashUsed')and GameVersion.isGen2()and world.map then + if key=='daytime'then return PaletteFX.effectiveGen2Tod()end + return Game and Game.save and Game.save.flashLit==true or false + end +end}) -- AN EMPTY PLOT DRAWS NOTHING, asked once per entity per frame. Written -- with `pcall(function() ... require(...) ... end)` this was a closure, a @@ -167,6 +174,8 @@ local function isDarkMap(mapId) -- ReplaceTimeOfDayPals sends down .NeedsFlash. Reading Gen1's pokered -- list here matched nothing, so Dark Cave and the Whirl Islands were lit. local def = Game.data.maps[mapId] + local palette=require('src.render.Gen2PaletteContext').id(def) + if palette~=nil then return palette==4 end local dark = Game.data.field.gen2DarkMaps if (def and dark and dark[def.label]) == true then return true end -- Fallback when gen2DarkMaps was not extracted (stale cache): maps @@ -1164,7 +1173,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- and the Ruins of Alph chambers taking the NITE row after 6pm. if GameVersion.isGen2() then local paletteDef = Game.data.maps[mapId] - if PaletteFX.setGen2MapPalette(paletteDef and paletteDef.mapPalette or 0) + PaletteFX.setGen2FlashUsed(Game.save.flashLit == true) + if PaletteFX.setGen2MapPalette(require('src.render.Gen2PaletteContext').id(paletteDef) or 0,paletteDef) and PaletteFX.usesGen2BgPal() then MapLoader.invalidateAll() end @@ -2292,6 +2302,9 @@ end -- modes that do not use the ROM's palettes at all. function OverworldState:syncGen2Tod(tod) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + PaletteFX.bindGen2Context(self.map and self.map.def,Game and Game.save and Game.save.flashLit == true) + end if not PaletteFX.setGen2Tod(tod) then return end if not (GameVersion.isGen2() and PaletteFX.usesGen2BgPal()) then return end pcall(function() @@ -2339,7 +2352,10 @@ end function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") - local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) + -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. + local authored = map.def.palette + if GameVersion.isGen2() and type(authored)=='string'and authored:match('^PALETTE_')then authored=nil end + local name = authored or paletteLookup(palettes, map.id, map.def.tileset) if not name then -- Interiors inherit the outdoor map they sit in. Before the player has -- been outdoors at all, that is wLastMap's zero-fill -- map 0, @@ -2467,6 +2483,7 @@ end function OverworldState:setDark(on) on = on and true or false self.dark = on + if GameVersion.isGen2() then PaletteFX.setGen2FlashUsed(Game and Game.save and Game.save.flashLit == true) end if PaletteFX.setDarkWorld(on) and PaletteFX.bakesDarkness() and self.map then MapLoader.invalidateAll() self:reloadMap(self.map.id, "dark") diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua index 1beb3dc13..c9d0dc01b 100644 --- a/tests/engine/gen2_palette_clock.lua +++ b/tests/engine/gen2_palette_clock.lua @@ -20,7 +20,7 @@ for _,v in ipairs({'gold','silver','crystal'})do P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') - T.eq(P.daytimeFor({mapPalette=1},nil,false),'DARK',v..' native darkness still wins before map override') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DAY',v..' another map does not inherit live cave darkness') T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') local Json=require('src.link.Json');local d=T.fixtures.fresh() local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua new file mode 100644 index 000000000..270e60353 --- /dev/null +++ b/tests/engine/gen2_palette_context.lua @@ -0,0 +1,158 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local W=require('src.world.gen2.World');local TR=require('src.render.TileRenderer') +local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') +local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage} +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end + function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x] + if p then return unpack(p)end + local c=source and(1-x%4/3)or 0;return c,c,c,1 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:setFilter()end + function b:setWrap()end + function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(value)assert(type(value)=='table');return value end +Assets.image=function()return buffer(16,8,true)end +Assets.imageData=function()return buffer(16,8,true)end +local function palette(seed) + local set={} + for slot=1,8 do set[slot]={};for c=1,4 do set[slot][c]={seed+slot+c,slot*10+c,c*20}end end + return set +end +local function fixture(version) + local d=T.fixtures.fresh();local rows={} + for i,row in ipairs({'MORN','DAY','NITE','DARK'})do rows[row]=palette(i*20)end + local ts={id='QA_CONTEXT',image='qa-context-'..version,tilesPerRow=2,palMap={0,6},palColorsByTod=rows, + animatedTiles={},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={QA_CONTEXT=ts} + d.maps={} + for id,p in pairs({AUTO=0,ROOM=1,NIGHT=2,MORNING=3,CAVE=4,UNUSED=7})do + d.maps[id]={id=id,label=id,width=1,height=1,blocks={0},tileset=ts.id,environment=1,mapPalette=p,group=1,number=1} + end + d.maps.CENTER={id='CENTER',label='CENTER',width=1,height=1,blocks={0},tileset=ts.id,environment=3,mapPalette=1,group=2,number=1} + d.field.gen2MapPalettes={rules={{kind='map',group=2,number=1,set='CENTER'},{kind='landmark',landmark=99,set='OTHER'}}, + sets={CENTER={DAY=palette(120)},OTHER={ALL=palette(160)}}} + d.field.gen2Roofs={tilesets={[ts.id]=true},byGroup={[1]=0},images={[0]='synthetic-roof'},slot=10,count=0,palIndex=6, + colors={[1]={DAY={{201,2,3},{204,5,6}},NITE={{211,12,13},{214,15,16}},DARK={{221,22,23},{224,25,26}}}}} + Data.publishGen2Palettes(d) + return d,ts,rows +end +local function samePixels(renderer,colors,label) + for tile,slot in ipairs({1,7})do + for c=1,4 do + local r,g,b=renderer.image:getPixel((tile-1)*8+c-1,0) + T.same({r,g,b},{colors[slot][c][1]/255,colors[slot][c][2]/255,colors[slot][c][3]/255},label..' native baked tile '..tile..' shade '..c) + end + end +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);P.setMode('gbc');P.setGen2Tod('NITE');P.setGen2MapPalette(1);P.setGen2FlashUsed(false);P.setDarkWorld(false) + local d,ts,rows=fixture(version) + T.eq(d.maps.AUTO.palette,'PALETTE_AUTO',version..' publishes native clock identity') + T.eq(d.maps.ROOM.palette,'PALETTE_DAY',version..' publishes forced room identity') + T.eq(rawget(d.maps.ROOM,'palette'),nil,version..' projection never serializes a copied palette field') + d.maps.ROOM.mapPalette=3;T.eq(d.maps.ROOM.palette,'PALETTE_MORN',version..' byte edit updates public identity immediately') + d.maps.ROOM.palette='PALETTE_DAY';T.eq(Context.id(d.maps.ROOM),1,version..' explicit authored identity remains authoritative') + d.maps.ROOM.mapPalette=1 + local authored={mapPalette=1,palette='CUSTOM'};Context.publish(authored);T.eq(authored.palette,'CUSTOM','authored non-native name preserved') + local meta={__index={other=7}};local custom=setmetatable({mapPalette=2},meta) + T.eq(Context.publish(custom),false,'authored lookup metatable is not replaced');T.eq(getmetatable(custom),meta,'authored metatable identity retained') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned room remains day') + T.eq(P.daytimeFor(d.maps.CAVE,12,false),'DARK',version..' neighbouring cave is dark before entry') + P.setDarkWorld(true) + T.eq(P.daytimeFor(d.maps.AUTO,12,false),'DAY',version..' outdoor neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.CAVE,12,true),'NITE',version..' explicit Flash lights queried cave') + P.setDarkWorld(false) + P.setGen2MapPalette(4,d.maps.CAVE) + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') + T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent explicit hour follows active native clock even in pinned current map') + P.setGen2MapPalette(1) + for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do + local def=d.maps[id];local map=Map.new(def,ts) + local colors,chosen=P.bgSet(d.gen2Palettes,def,P.gen2RowFor(def)) + T.eq(chosen,row,version..' native header row '..id) + local renderer=TR.new(map,d) + T.eq(renderer.trueColor,true,version..' native constructor bakes actual colours '..id) + samePixels(renderer,colors,version..' '..id) + if id=='CENTER'then T.eq(colors[1][1][1],d.field.gen2MapPalettes.sets.CENTER.DAY[1][1][1],version..' Center special rule reaches public query')end + end + local before=rows.NITE[7][2] + local set=P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE') + T.same(set[7][2],{211,12,13},version..' native roof writes only imported middle pair') + T.eq(rows.NITE[7][2],before,version..' public roof query does not modify shared tileset') + T.eq(set[7][1],rows.NITE[7][1],version..' roof preserves endpoint zero') + T.eq(set[7][4],rows.NITE[7][4],version..' roof preserves endpoint three') + d.field.gen2Roofs.colors[1].NITE[1]={231,32,33} + T.same(P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE')[7][2],{231,32,33},version..' metadata view sees live roof replacements') + TR.invalidate() + samePixels(TR.new(Map.new(d.maps.AUTO,ts),d),P.bgSet(d,d.maps.AUTO,'NITE'),version..' native invalidation observes changed roof') + d.field.gen2MapPalettes.rules[1].set='MISSING';d.maps.CENTER.landmark=99 + T.eq(P.bgSet(d,d.maps.CENTER,'DAY')[1],rows.DAY[1],version..' unavailable first rule does not fall through to later match') + d.field.gen2MapPalettes.rules[1].set='CENTER' + local game={data=d,save={flags={}}};debug.setupvalue(W.setDark,slot,game) + W.map=Map.new(d.maps.CAVE,ts) + P.setGen2MapPalette(4,d.maps.CAVE) + -- Keep the actual presentation setter while controlling only reload IO. + local reload=W.reloadMap;W.reloadMap=function()end + W:setDark(true) + T.eq(W.daytime,'DARK',version..' public world reflects actual native darkness') + T.eq(W.flashUsed,false,version..' public Flash view reads canonical save state') + require('src.script.Gen2Commands') + require('src.script.Commands').g2_blinding_flash({game=game,save=game.save,overworld=W}) + T.eq(game.save.flashLit,true,version..' original native Flash writer sets actual save bit') + T.eq(W.flashUsed,true,version..' public Flash view follows native script write') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native neighbour context follows script Flash write') + T.eq(W.daytime,'NITE',version..' world view follows actual lit-cave palette bake') + W.reloadMap=reload + local oldView=d.gen2Palettes;Data.publishGen2Palettes(d) + T.eq(P.bgSet(oldView,d.maps.ROOM,'DAY'),nil,version..' retired published view cannot mix old/new metadata') + local files={['mods/context/manifest.json']=Json.encode({id='context',name='Synthetic context',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/context/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.query=function(world,map) + local row=Palettes.daytimeFor(map.def,20,world.flashUsed) + local current=world.map + local same=current.id==map.id + local clock=current.def.palette=='PALETTE_AUTO'and map.def.palette=='PALETTE_AUTO' + if(same or clock)and Palettes.DAYTIME_ID[world.daytime]then row=world.daytime end + return Palettes.bgSet(world.game.data.gen2Palettes,map.def,row),row + end + ]]} + local r=T.sdk.loadMods({'mods/context'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,version..' real Loader imports public atlas context') + W.game=game;W.map=Map.new(d.maps.AUTO,ts);P.setGen2MapPalette(0);P.setGen2Tod('NITE');W:setDark(false) + local query=r.loader.exports.context.query + local colors,row=query(W,Map.new(d.maps.ROOM,ts)) + T.eq(row,'DAY',version..' actual loaded public consumer keeps neighbouring room pinned') + T.same(colors,P.bgSet(d,d.maps.ROOM,'DAY'),version..' loaded consumer uses same native roof selection') + colors,row=query(W,W.map);T.eq(row,W.daytime,version..' loaded consumer uses actual current bake') + W:syncGen2Tod('NITE') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native clock sync rebinds canonical Flash after mod reload') + Loader.teardown();r.release() + T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') +end +Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) +P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) +T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') From 75aa131dc16124819704419d1a42e9ed110ab072 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:01:35 +0100 Subject: [PATCH 13/68] Use shared native saved RTC for GSC world scripts and clock card --- docs/native-gen2-clock.md | 41 ++++++++++++++++ src/core/GameVersion.lua | 2 + src/core/Gen2Clock.lua | 81 +++++++++++++++++++++++++++++++ src/core/ModCompat.lua | 1 + src/mods/Loader.lua | 4 ++ src/pokemon/Evolution.lua | 11 +++-- src/render/PaletteFX.lua | 4 ++ src/script/Gen2Commands.lua | 14 +++--- src/script/Gen2Daily.lua | 2 +- src/ui/PokegearMenu.lua | 8 +-- src/world/OverworldController.lua | 13 +++-- 11 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 docs/native-gen2-clock.md create mode 100644 src/core/Gen2Clock.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md new file mode 100644 index 000000000..bda4ee37a --- /dev/null +++ b/docs/native-gen2-clock.md @@ -0,0 +1,41 @@ +# Shared native Gen2 RTC + +The Pokégear previously displayed elapsed play time while world palettes, +object time masks and script variables used the host clock. The native +`Gen2Clock` now provides the same saved RTC-base reads to these consumers, +and the native happiness evolution gate. +The existing `src.core.gen2.Clock` name resolves to this native module. +`world.hour()`, `world.minute()` and `world.weekday()` read that same save. + +`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the +host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and +keeps the older native `g2DayOffset` field synchronized. Old saves without +an RTC base retain their host-hour and existing weekday-offset behavior. +Read calls never create clock data. Invalid/non-finite writes are rejected. +The normal native save codec preserves the offsets; play time is separate. + +World palettes, object-load period bits and `VAR_HOUR` share the saved hour; +the normal object-load freeze still applies. An explicit palette-query hour +retains precedence. The existing `world.tod` hook remains a presentation +override and does not rewrite the saved RTC. A raw scene-clock query uses +the current native save, independently of a room's forced palette. + +`clock.day_changed` is produced by actual weekday reads, including the live +world clock poll. It reports `day`, `previous` and `reason` (`rollover` or +`set`). A save's first observation is silent. Observations are per save, +reentrant-safe, subscription-aware and retired on mod/edition reload. +Native API 1/2 use the existing event dispatch boundary. The native GSC +producer remains generation-scoped. + +The clock card, native script readers, evolution time gate and world methods +are exercised directly in synthetic engine tests, including minute wrap, +weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback +path and actual mod loading. Graphics IO alone is replaced for the clock +card. These checks do not establish a complete original-story clock-setting +sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or +fresh GPU acceptance for this change. Existing daily date keys still use +host calendar dates; lottery weekday selection now shares the saved weekday. + +The public contract is compared with the read-only Gen1Recomp reference; +the implementation is native fork code. No VASC implementation, private +assets, game text or ROM-derived clock data is included. diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 6c92886cf..ff6316b05 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1087,6 +1087,8 @@ function GameVersion.set(id) if previous~=GameVersion.current then local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end end return GameVersion.current end diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua new file mode 100644 index 000000000..fc02c2953 --- /dev/null +++ b/src/core/Gen2Clock.lua @@ -0,0 +1,81 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Shared native GSC RTC reads. The public Clock spelling follows the +-- reference contract; no private mod implementation or clock is embedded. +local Clock = { MINUTES_PER_DAY = 1440, DAYS = 7, DEFAULT_HOUR = 10, DEFAULT_MINUTE = 0 } +local Strings = require("src.core.Strings") +Clock.DAY_NAMES = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY" } +local observed = setmetatable({}, { __mode = "k" }) +local function integer(value) + local n = tonumber(value) + if n and n == n and n ~= math.huge and n ~= -math.huge then return math.floor(n) end +end +local function rtc(save) + return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil +end +function Clock.hostMinutes() + return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +end +function Clock.hostWeekday(now) + return (integer(os.date("%w", now)) or 0) % 7 +end +function Clock.minutes(save) + local state = rtc(save) + return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 +end +function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end +function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.isSet(save) + local state = rtc(save) + return state ~= nil and integer(state.startMinute) ~= nil +end +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + hour, minute = integer(hour or 0), integer(minute or 0) + if not hour or not minute then return false end + save.rtc = rtc(save) or {} + save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + return true +end +local function note(save, day, reason) + if type(save) ~= "table" then return day end + local Runtime = require("src.mods.Runtime") + if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then + observed[save] = nil + return day + end + local previous = observed[save] + observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. + if previous ~= nil and previous ~= day then + Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + end + return day +end +function Clock.weekday(save, now) + local state = rtc(save) + local offset = state and integer(state.startDay) + if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") +end +function Clock.setWeekday(save, day) + day = integer(day) + if type(save) ~= "table" or not day then return false end + save.rtc = rtc(save) or {} + day = day % 7 + save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.dayOfWeek = day + -- Keep the existing native field in sync for older native consumers/saves. + save.g2DayOffset = save.rtc.startDay + note(save, day, "set") + return true +end +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end +function Clock.daytimeLabel(hour) + hour = integer(hour) or Clock.hour() + hour = hour % 24 + return Strings(hour < 4 and "NITE" or hour < 10 and "MORN" or hour < 18 and "DAY" or "NITE") +end +function Clock.resetObservations() observed = setmetatable({}, { __mode = "k" }) end +return Clock diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..2e92443a1 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Clock"] = "src.core.Gen2Clock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index cd3ab6876..5aa2d364e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1312,6 +1312,8 @@ end function Loader:load(data) local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1657,6 +1659,8 @@ end function Loader.teardown() local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..e9506843f 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -89,7 +89,7 @@ Evolution.METHODS = { return false end if not evo.timeOfDay then return true end - local night = Evolution.isNight() + local night = Evolution.isNight(game.save) if evo.timeOfDay == "NITE" then return night end return not night -- TR_MORNDAY: morn or day end, @@ -223,10 +223,11 @@ Evolution.HAPPINESS_TO_EVOLVE = 220 Evolution.BASE_HAPPINESS = 70 -- GSC time-of-day bands (constants/time_constants.asm): MORN 4-9, DAY --- 10-17, NITE 18-3. The port has no in-game clock, so the wall clock is --- what GSC's RTC would have been. -function Evolution.isNight() - local hour = tonumber(os.date("%H")) or 12 +-- 10-17, NITE 18-3. GSC reads the shared saved RTC offset; other editions +-- retain their existing host-clock behavior. +function Evolution.isNight(save) + local hour = require("src.core.GameVersion").isGen2() + and require("src.core.Gen2Clock").hour(save) or tonumber(os.date("%H")) or 12 return hour >= 18 or hour < 4 end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index c95d9d31e..a0260e00b 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -561,6 +561,10 @@ function PaletteFX.gen2Tod() return gen2Tod end -- It must not inherit the live map override, darkness, or atlas bake state. function PaletteFX.clockDaytime(hour) hour = tonumber(hour) + if not hour and GameVersion.isGen2() then + local game = package.loaded["src.core.Game"] + hour = require("src.core.Gen2Clock").hour(game and game.save) + end if not hour then hour = tonumber(os.date("%H")) end hour = math.floor(hour or 12) % 24 if hour < 4 then return "NITE" end diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..d41e01d32 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2628,12 +2628,9 @@ function Commands.g2_readvar(ctx, var) elseif var == 6 then ctx.g2Var = countKeys(ctx.save.pokedex and ctx.save.pokedex.seen) elseif var == 10 then - ctx.g2Var = tonumber(os.date("%H")) or 0 + ctx.g2Var = require("src.core.Gen2Clock").hour(ctx.save) elseif var == 11 then - -- g2DayOffset is what mom's SetDayOfWeek picker answered, as a shift off - -- the host day, so Arthur still shows up on a real Thursday. - ctx.g2Var = ((tonumber(os.date("%w")) or 0) - + (ctx.save.g2DayOffset or 0)) % 7 + ctx.g2Var = require("src.core.Gen2Clock").weekday(ctx.save) elseif var == 16 then -- BoxFreeSpace counts only the current box, not all available PC slots. local Boxes = require("src.pokemon.Boxes") @@ -2898,7 +2895,8 @@ local WEEKDAYS = { -- either way the day still rolls over at real midnight instead of freezing. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local host = tonumber(os.date("%w")) or 0 + local Clock = require("src.core.Gen2Clock") + local current = Clock.weekday(ctx.save) local picked repeat Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") @@ -2912,14 +2910,14 @@ function Commands.g2_set_day_of_week(ctx) -- no cancel: the ROM's loop watches A only, and onCancel is the only -- other path back to runner:resume() local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = host + 1 + menu.index = current + 1 menu:clampScroll() game.stack:push(menu) runner:yield() Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) until ctx.lastCheck - ctx.save.g2DayOffset = (picked - host) % 7 + Clock.setWeekday(ctx.save, picked) end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 96e54c666..6337f5cd9 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -45,7 +45,7 @@ end local function nextFriday(save, now) local date = os.date("*t", now) - local weekday = (date.wday - 1 + (tonumber(save.g2DayOffset) or 0)) % 7 + local weekday = require("src.core.Gen2Clock").weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..7d5cb0473 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -690,10 +690,10 @@ function PokegearMenu:drawClock() self:text("SWITCH", 13, 1) local save = self.game.save - local t = math.floor(require("src.core.SaveData").playSeconds(save)) - local hour = math.floor(t / 3600) % 24 - local minute = math.floor(t / 60) % 60 - local day = DAYS[(math.floor(t / 86400) % 7) + 1] + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + local hour, minute = math.floor(minutes / 60), minutes % 60 + local day = Clock.weekdayName(Clock.weekday(save) + 1) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 48fa02855..d996d5ce2 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local GameVersion = require("src.core.GameVersion") +local Gen2Clock = require("src.core.Gen2Clock") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -292,7 +293,7 @@ local function objectTodBit() local ow = Game and Game.overworld local bit = ow and tonumber(ow.objectTodBit) if bit then return bit end - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) if period and tonumber(period.bit) then return tonumber(period.bit) end return TOD_BITS[objectTimeOfDay()] or TOD_BITS.DAY end @@ -2275,7 +2276,7 @@ local function sameTod(tod) return tod end -- < 4 NITE, < 10 MORN, < 18 DAY, else NITE. Gen 1 has no clock at all, so -- only Gen 2 consults the host one. local function clockTimeOfDay() - local hour = tonumber(os.date("%H")) or 12 + local hour = Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) if hour < 4 then return "NITE" end if hour < 10 then return "MORNING" end if hour < 18 then return "DAY" end @@ -2288,7 +2289,7 @@ OverworldState.clockTimeOfDay = clockTimeOfDay -- The cartridge's MAPOBJECT_TIMEOFDAY bit for the hour on the clock right now. function OverworldState.clockTimeOfDayBit() - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) return (period and tonumber(period.bit)) or TOD_BITS.DAY end @@ -2314,6 +2315,7 @@ function OverworldState:syncGen2Tod(tod) end function OverworldState:timeOfDay() + if GameVersion.isGen2() then Gen2Clock.weekday(Game and Game.save) end local tod = GameVersion.isGen2() and clockTimeOfDay() or (self.tod or "DAY") if not Runtime.wantsHook("world.tod") then if tod ~= self.tod then @@ -2350,6 +2352,11 @@ function OverworldState:timeOfDay() return self.tod end +-- Shared native saved RTC views; no second clock state on World. +function OverworldState.hour() return Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.minute() return Gen2Clock.minute(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.weekday() return Gen2Clock.weekday(GameVersion.isGen2() and Game and Game.save or nil) end + function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. From d2cfa4dd52c9b89bb037db70bb799f50580f50c7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:01:57 +0100 Subject: [PATCH 14/68] Include native RTC contract regression in upstream test archive --- tests/engine/gen2_clock.lua | 119 ++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/engine/gen2_clock.lua diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua new file mode 100644 index 000000000..e3085943c --- /dev/null +++ b/tests/engine/gen2_clock.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion');local version = V.get() +require('src.core.ModCompat').install() +local Clock = require('src.core.gen2.Clock') +local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') +require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') +local Json = require('src.link.Json');local Save = require('src.core.SaveData') +local date = os.date;local host = { hour=13, minute=20, day=2 } +os.date = function(format, now) + if now then return date(format,now) end + if format=='%H' then return tostring(host.hour) end + if format=='%M' then return tostring(host.minute) end + if format=='%w' then return tostring(host.day) end + return date(format,now) +end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.timeOfDay,i) + if name=='Game' then slot=i;oldGame=value;break end + if not name then break end +end +assert(slot) +local nativeGame=require('src.core.Game');local oldSave=nativeGame.save +T.eq(Clock,require('src.core.Gen2Clock'),'public name shares actual native clock') +T.eq(Clock.minutes(),800,'no save reads host RTC') +T.eq(Clock.isSet({}),false,'old save has no fabricated clock anchor') +local malformed={rtc='invalid'} +T.eq(Clock.hour(malformed),13,'malformed optional clock falls back to host') +T.eq(Clock.setTime(malformed,0/0,10),false,'NaN cannot poison saved clock') +T.eq(malformed.rtc,'invalid','refused write is atomic') +T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') +T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') +T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') +for _,edition in ipairs({'gold','silver','crystal'}) do + V.set(edition);host.hour=13;host.minute=20;host.day=2 + local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) + T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') + T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') + T.eq(Json.encode(save),untouched,edition..' clock reads do not seed saved state') + T.eq(Clock.setTime(save,23,55),true,edition..' clock setting anchors native RTC') + T.eq(Clock.setWeekday(save,6),true,edition..' day picker stores native RTC base') + T.eq(Clock.hour(save),23,edition..' saved hour is observed immediately') + T.eq(Clock.minute(save),55,edition..' saved minute is observed immediately') + T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') + T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') + T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') + host.minute=30 + T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') + host.day=3 + T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + local round=assert(Save.decode(Save.encode(save))) + T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') + T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') + local game={data=T.fixtures.fresh(),save=save} + debug.setupvalue(W.timeOfDay,slot,game);nativeGame.save=save + local world=setmetatable({map={id='RTC',def={mapPalette=0}},player={cellX=0,cellY=0}}, {__index=W}) + -- Control only graphics IO; native clock, period selection and commands run. + world.syncGen2Tod=function()end + for _,case in ipairs({{3,59,'NITE',4},{4,0,'MORNING',1},{9,59,'MORNING',1}, + {10,0,'DAY',2},{17,59,'DAY',2},{18,0,'NITE',4}})do + Clock.setTime(save,case[1],case[2]) + T.eq(world:timeOfDay(),case[3],edition..' native world reads saved boundary '..case[1]) + T.eq(W.clockTimeOfDayBit(),case[4],edition..' object-load bit uses same saved boundary '..case[1]) + T.eq(world.hour(),case[1],edition..' legacy world dot hour agrees') + T.eq(world:minute(),case[2],edition..' legacy world colon minute agrees') + T.eq(P.clockDaytime(),case[3]=='MORNING' and 'MORN' or case[3],edition..' scene palette raw RTC agrees') + T.eq(P.clockDaytime(12),'DAY',edition..' explicit scene hour remains authoritative') + local ctx={save=save,overworld=world} + Commands.g2_readvar(ctx,10);T.eq(ctx.g2Var,case[1],edition..' actual VAR_HOUR agrees') + Commands.g2_readvar(ctx,11);T.eq(ctx.g2Var,Clock.weekday(save),edition..' actual VAR_WEEKDAY agrees') + Commands.g2_checktime(ctx,case[4]);T.eq(ctx.lastCheck,true,edition..' actual checktime agrees') + T.eq(require('src.pokemon.Evolution').isNight(save),case[4]==4,edition..' happiness time gate agrees') + end + -- Native clock-card composition must show RTC, even with zero play time. + Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local text={};local Gear=require('src.ui.PokegearMenu') + local gear=setmetatable({game=game,blink=0,gfx={cards={clock={}}}}, {__index=Gear}) + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.text=function(_,value,x,y)text[x..':'..y]=value end + gear:drawClock() + T.eq(text['6:6'],'THURSDAY',edition..' actual clock card shows selected weekday') + T.eq(text['6:8'],' 9',edition..' actual clock card shows RTC hour rather than play hours') + T.eq(text['9:8'],'47',edition..' actual clock card shows RTC minute rather than play minutes') + T.eq(text['12:8'],'PM',edition..' actual clock card shows RTC half-day') + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local files={['mods/rtc/manifest.json']=Json.encode({id='rtc',name='Native RTC observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/rtc/main.lua']=[[ + local mod=...; + local Clock=require('src.core.gen2.Clock');mod.exports.events={} + mod.exports.stop=mod.events:on("clock.day_changed",function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99;return false + end) + ]]} + local result=T.sdk.loadMods({'mods/rtc'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' actual loader admits public clock module/event') + local e=result.loader.exports.rtc;local own={};e.save=own + Clock.weekday(own);T.eq(#e.events,0,label..' first read is not a fictitious rollover') + host.day=(host.day+1)%7 + Clock.weekday(own);T.eq(#e.events,1,label..' real read observes rollover exactly once') + T.eq(e.events[1].reason,'rollover',label..' rollover reason supplied') + Clock.weekday(own);T.eq(#e.events,1,label..' reentrant/repeated reads do not repeat event') + Clock.weekday({});T.eq(#e.events,1,label..' another profile starts silently') + local selected=(Clock.weekday(own)+2)%7 + Clock.setWeekday(own,selected) + T.eq(#e.events,2,label..' native day setter observes one change') + T.eq(e.events[2].reason,'set',label..' explicit setter reason supplied') + T.eq(Clock.weekday(own),selected,label..' listener mutation cannot corrupt native weekday') + e.stop();host.day=(host.day+1)%7;Clock.weekday(own) + T.eq(#e.events,2,label..' unsubscribe retires actual observation') + Loader.teardown();result.release() + end +end +nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) +Clock.resetObservations();os.date=date;V.set(version) +T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') From 5cd6128762e71ec87574a50743567990d12a7804 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:06:35 +0100 Subject: [PATCH 15/68] Resolve AUTO neighbour palettes from shared RTC outside pinned scenes --- docs/native-gen2-clock.md | 4 ++++ docs/native-gen2-palette-context.md | 7 ++++--- src/render/PaletteFX.lua | 14 ++++++++++---- tests/engine/gen2_palette_context.lua | 11 +++++++++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index bda4ee37a..ae52c27d1 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -19,6 +19,10 @@ the normal object-load freeze still applies. An explicit palette-query hour retains precedence. The existing `world.tod` hook remains a presentation override and does not rewrite the saved RTC. A raw scene-clock query uses the current native save, independently of a room's forced palette. +AUTO neighbours share the effective clock presentation while the entered +map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead +uses the saved RTC; that room's unused world-TOD hook is not a neighbour +palette override. This keeps explicit native/external hour queries aligned. `clock.day_changed` is produced by actual weekday reads, including the live world clock poll. It reports `day`, `previous` and `reason` (`rollover` or diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md index 68450c9e8..bda03ba07 100644 --- a/docs/native-gen2-palette-context.md +++ b/docs/native-gen2-palette-context.md @@ -22,9 +22,10 @@ queries retain their prior live-state fallback. The native renderer uses neighbour's own header and the active clock/Flash presentation state. Native map entry and Flash update that state; atlas cache keys include the selected row. Animated overdraw receives the same selected palette as the static bake. -An omitted hour uses the entered world's native clock row, including its -`world.tod` selection; outside an entered context it keeps the host-clock -fallback. An explicit hour remains authoritative. Mod load/unload and edition +An omitted hour shares the entered world's `world.tod` selection when +that scene follows AUTO. From a pinned room/cave, AUTO neighbours instead +read the saved RTC. Outside an entered context the query reads that RTC +without inheriting a map presentation override. An explicit hour remains authoritative. Mod load/unload and edition changes retire the entered context, so returning to the launcher cannot revive a previous world's clock selection. diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index a0260e00b..52c994215 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -497,14 +497,20 @@ function PaletteFX.bindGen2Context(def,flashUsed) end function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end --- A neighbour uses its own header, the native hooked clock and the same Flash --- presentation state. It must not inherit the current map's forced DAY row. +-- AUTO neighbours share the entered AUTO scene's presentation override. +-- A pinned current room/cave has no outdoor clock presentation to share: +-- neighbouring AUTO maps read the saved RTC instead of its unused hook row. +local function gen2NeighbourClock() + local id=require('src.render.Gen2PaletteContext').id(gen2CurrentDef) + if GEN2_MAP_PALETTE_ROW[id] then return PaletteFX.clockDaytime() end + return gen2ClockTod +end function PaletteFX.gen2RowFor(def) if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end local id=require('src.render.Gen2PaletteContext').id(def) if id==nil then return PaletteFX.effectiveGen2Tod() end if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end - return GEN2_MAP_PALETTE_ROW[id]or gen2ClockTod + return GEN2_MAP_PALETTE_ROW[id]or gen2NeighbourClock() end local function gen2RecomputeTod() @@ -587,7 +593,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) local id=require('src.render.Gen2PaletteContext').id(mapDef) if id~=nil then if id==4 then return flashUsed==true and 'NITE'or 'DARK'end - return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2ClockTod)or PaletteFX.clockDaytime(hour) + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2NeighbourClock())or PaletteFX.clockDaytime(hour) end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua index 270e60353..34082161f 100644 --- a/tests/engine/gen2_palette_context.lua +++ b/tests/engine/gen2_palette_context.lua @@ -9,7 +9,8 @@ local W=require('src.world.gen2.World');local TR=require('src.render.TileRendere local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, - image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage} + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage,date=os.date} +os.date=function(format,...)if format=='%H'then return '20'end;return saved.date(format,...)end local slot,oldGame for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end assert(slot) @@ -86,7 +87,12 @@ for _,version in ipairs({'gold','silver','crystal'})do P.setGen2MapPalette(4,d.maps.CAVE) T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') - T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent explicit hour follows active native clock even in pinned current map') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent hour reads RTC outside a pinned current map') + P.setGen2Tod('MORN');P.setGen2MapPalette(1,d.maps.ROOM) + T.eq(P.gen2RowFor(d.maps.AUTO),'NITE',version..' pinned room cannot pass an unused morning override to outdoor neighbours') + P.setGen2MapPalette(0,d.maps.AUTO) + T.eq(P.gen2RowFor({mapPalette=0}),'MORN',version..' outdoor neighbours still share current AUTO presentation') + P.setGen2Tod('NITE') P.setGen2MapPalette(1) for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do local def=d.maps[id];local map=Map.new(def,ts) @@ -153,6 +159,7 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') end Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +os.date=saved.date W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') From a11d4e9af1e6210070de49e4e3c35fbb63be5198 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:25:36 +0100 Subject: [PATCH 16/68] Expose native clock card parts to existing external presenters --- docs/native-gen2-clock.md | 5 +++ src/ui/PokegearMenu.lua | 26 +++++++++--- tests/engine/gen2_clock_card.lua | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/engine/gen2_clock_card.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index ae52c27d1..0bf1f6f38 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -19,6 +19,11 @@ the normal object-load freeze still applies. An explicit palette-query hour retains precedence. The existing `world.tod` hook remains a presentation override and does not rewrite the saved RTC. A raw scene-clock query uses the current native save, independently of a room's forced palette. +`PokegearMenu:clockParts()` exposes hour/minute/one-based UI weekday from +the live native world or shared saved clock. The built-in card and external +presenters use this same getter, retaining authored card-clock overrides. +This prevents an external presenter from showing its fallback zero clock +while the internal RTC and built-in text are already correct. AUTO neighbours share the effective clock presentation while the entered map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead uses the saved RTC; that room's unused world-TOD hook is not a neighbour diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index 7d5cb0473..a87e3392d 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -370,6 +370,25 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Public clock presentation, shared by the native card and source renderers. +-- Keep the native save/world as the owner; weekday is one-based for UI lists. +function PokegearMenu:clockParts() + if type(self.clock) == "table" then + return self.clock.hour or 0, self.clock.minute or 0, self.clock.weekday or 1 + end + local game = self.game + local world = game and (game.overworld or game.world) + if world and type(world.hour) == "function" and type(world.minute) == "function" + and type(world.weekday) == "function" then + return world:hour(), world:minute(), world:weekday() % 7 + 1 + end + local save = game and game.save or self.save + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + return math.floor(minutes / 60), minutes % 60, Clock.weekday(save) + 1 +end + + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -689,11 +708,8 @@ function PokegearMenu:drawClock() self:drawStrip() self:text("SWITCH", 13, 1) - local save = self.game.save - local Clock = require("src.core.Gen2Clock") - local minutes = Clock.minutes(save) - local hour, minute = math.floor(minutes / 60), minutes % 60 - local day = Clock.weekdayName(Clock.weekday(save) + 1) + local hour, minute, weekday = self:clockParts() + local day = require("src.core.Gen2Clock").weekdayName(weekday) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/tests/engine/gen2_clock_card.lua b/tests/engine/gen2_clock_card.lua new file mode 100644 index 000000000..a36d76005 --- /dev/null +++ b/tests/engine/gen2_clock_card.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local Gear=require('src.ui.gen2.Pokegear');local Clock=require('src.core.Gen2Clock') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local date=os.date +os.date=function(format,...)if format=='%H'then return '12'end;if format=='%M'then return '05'end;if format=='%w'then return '1'end;return date(format,...)end +local function parts(screen)return {screen:clockParts()}end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition) + local save={playTime=0};Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local game={save=save,data=T.fixtures.fresh()} + local gear=setmetatable({game=game},{__index=Gear}) + local before=Json.encode(save) + T.same(parts(gear),{21,47,5},edition..' external card reads shared saved RTC, weekday one-based') + T.eq(Json.encode(save),before,edition..' clock presentation does not mutate saved state') + Clock.setTime(save,7,13);Clock.setWeekday(save,0) + T.same(parts(gear),{7,13,1},edition..' native clock edits are immediately visible') + local seen={};local current={hour=22,minute=8,day=6} + game.overworld={hour=function(self)seen.hour=self;return current.hour end, + minute=function(self)seen.minute=self;return current.minute end, + weekday=function(self)seen.weekday=self;return current.day end} + T.same(parts(gear),{22,8,7},edition..' live native world owns presentation') + T.eq(seen.hour,game.overworld,edition..' actual world hour receives its owner') + T.eq(seen.minute,game.overworld,edition..' actual world minute receives its owner') + T.eq(seen.weekday,game.overworld,edition..' actual world weekday receives its owner') + current.hour,current.minute,current.day=4,59,0 + T.same(parts(gear),{4,59,1},edition..' live world changes are not cached as UI state') + game.world=game.overworld;game.overworld=nil + T.same(parts(gear),{4,59,1},edition..' reference game.world spelling uses same read protocol') + game.overworld={hour=function()error('partial clock must not be mixed with saved clock')end} + T.same(parts(gear),{7,13,1},edition..' incomplete world falls back to one complete saved clock') + gear.clock={hour=10,minute=34,weekday=3} + T.same(parts(gear),{10,34,3},edition..' authored card clock override retains precedence') + gear.clock={} + T.same(parts(gear),{0,0,1},edition..' authored empty override retains reference defaults') + gear.clock=nil;game.overworld=nil;game.world=nil + local detached=setmetatable({save=save},{__index=Gear}) + T.same(parts(detached),{7,13,1},edition..' save-only reference card is supported') + -- The native draw and an unchanged external call use the same getter. + local values={};gear.blink=0;gear.gfx={cards={clock={}}} + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.clockParts=function()return 23,56,6 end + gear.text=function(_,value,x,y)values[x..':'..y]=value end + gear:drawClock() + T.eq(values['6:6'],'FRIDAY',edition..' native card uses public weekday projection') + T.eq(values['6:8'],'11',edition..' native card uses public hour projection') + T.eq(values['9:8'],'56',edition..' native card uses public minute projection') + T.eq(values['12:8'],'PM',edition..' native card keeps cartridge half-day display') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/card/manifest.json']=Json.encode({id='card',name='Native clock card reader',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/card/main.lua']=[[ + local mod=...;local Gear=require('src.ui.gen2.Pokegear') + mod.exports.read=function(game) + local screen=setmetatable({game=game},{__index=Gear}) + return screen:clockParts() + end + ]]} + local result=T.sdk.loadMods({'mods/card'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' public clock-card consumer loads unchanged') + T.same({result.loader.exports.card.read(game)},{7,13,1},label..' real loaded consumer reads native RTC rather than fallback zeroes') + Loader.teardown();result.release() + end +end +Clock.resetObservations();os.date=date;V.set(version) +T.finish('native and external Gen2 clock-card presentation over shared RTC') From ccdf628b6ce21e02c252e0dcf4f1ff7ffe647b45 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:55:56 +0100 Subject: [PATCH 17/68] Add native Pokemon palette queries and typed indexed draw compatibility --- docs/native-gen2-pokemon-palettes.md | 47 ++++++++ src/core/ModCompat.lua | 3 +- src/render/Gen2PaletteDraw.lua | 114 ++++++++++++++++++ src/render/Gen2PokemonPalettes.lua | 61 ++++++++++ src/render/PaletteFX.lua | 10 ++ tests/engine/gen2_palette_draw.lua | 105 ++++++++++++++++ tests/engine/gen2_pokemon_palette_queries.lua | 71 +++++++++++ tests/gpu/gen2_palette_draw.lua | 77 ++++++++++++ 8 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen2-pokemon-palettes.md create mode 100644 src/render/Gen2PaletteDraw.lua create mode 100644 src/render/Gen2PokemonPalettes.lua create mode 100644 tests/engine/gen2_palette_draw.lua create mode 100644 tests/engine/gen2_pokemon_palette_queries.lua create mode 100644 tests/gpu/gen2_palette_draw.lua diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md new file mode 100644 index 000000000..98e69c1f8 --- /dev/null +++ b/docs/native-gen2-pokemon-palettes.md @@ -0,0 +1,47 @@ +# Native Gen2 Pokemon palette drawing + +`src.world.gen2.Palettes.monColors(data, species, shiny)` reads the existing +native Pokemon/palette records. It accepts a full native dataset, its current +published `gen2Palettes` view, or reference palette-only normal/shiny pairs. +Native species keys resolve to the same native records. Existing dataset +aliases are honoured; this patch does not install another species catalogue. Four detached 0–255 RGB triples are returned; missing or +invalid colours return `nil`. Retired dataset views cannot revive old data. +The built-in `PaletteFX.monPal` uses the same native selection without copying +its source palette. Transformed Pokemon retain their existing precedence. + +`src.render.GbcPalette` now resolves to `src.render.Gen2PaletteDraw`, rather +than the unrelated LCD post-process `src.render.GBCFX`. Its indexed-image +shader recovers the four source grayscale shades and substitutes the supplied +palette while preserving texture alpha and drawing tint. `use`, `with`, +`useKeyed`, `keyedWith`, `useRaw`, `withRaw`, `shader`, `keyedShader`, +`available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes +source shade zero transparent. Normal drawing resolves native `PaletteFX` +display/register state; raw drawing bypasses that resolution. + +Scoped drawing restores both the previous shader object and managed palette +uniforms, including nested calls, callback failures and partial upload errors. +Public calls upload all four rows because callers can modify exposed shader +objects directly. No private renderer or ROM-derived colour catalogue is +embedded in the implementation. + +## Validation and remaining work + +ROM-free suites cover public Loader consumers, Gold/Silver/Crystal species +queries, native source identity, shiny/normal/transformed precedence, authored +changes, detached results, retired views, invalid values and graphics failure +paths. The GPU probe `tests/gpu/gen2_palette_draw.lua` compares actual canvas +pixels for every shade, alpha, tint, keying, native register remap, nested +success/error and foreign shader restoration on each tested backend. + +Real-ROM validation must additionally compare the native palette producer and +public queries with an independent decoder, using actual imported Pokemon +metadata. Those private inputs and outputs are excluded from the repository. +A successful pixel probe is not proof of every unchanged mod presentation or +full gameplay acceptance. + +The complete reference `GbcPalette` API is not yet implemented: its separate +mode/custom-ramp controls and backwards remapping of already coloured frames +still need integration with native display state. The LCD effects API remains +separate. Full colour art, UI layout, original story admission, broader hardware +and complete Gen1/VASC parity remain independent acceptance work. No VASC code +or assets are changed by this feature. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 2e92443a1..420182efc 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -98,7 +98,8 @@ ModCompat.ALIASES = { -- the Gold port splits its Game class per generation; this one does not ["src.core.Game2"] = "src.core.Game", - ["src.render.GbcPalette"] = "src.render.GBCFX", + -- Indexed image palettes and LCD screen effects are different contracts. + ["src.render.GbcPalette"] = "src.render.Gen2PaletteDraw", } local seen = {} diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua new file mode 100644 index 000000000..b0c271a45 --- /dev/null +++ b/src/render/Gen2PaletteDraw.lua @@ -0,0 +1,114 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Indexed image drawing, separate from the LCD post-process in GBCFX. +-- Palette resolution uses the native display/register state in PaletteFX. +local Palettes = require("src.render.PaletteFX") +local M = {} +local compiler, shaders, failed = nil, {}, {} +local sent = setmetatable({}, {__mode = "k"}) +local names = {"pal0", "pal1", "pal2", "pal3"} +local source = [[ +extern vec3 pal0; extern vec3 pal1; extern vec3 pal2; extern vec3 pal3; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + float shade = floor((1.0 - pixel.r) * 3.0 + 0.5); + vec3 rgb = shade < 0.5 ? pal0 : (shade < 1.5 ? pal1 : (shade < 2.5 ? pal2 : pal3)); + return vec4(rgb, ALPHA) * tint; +} +]] + +local function graphics() return love and love.graphics end +local function shader(keyed) + local g = graphics() + local newShader = g and g.newShader + if type(newShader) ~= "function" then return nil end + if compiler ~= newShader then + compiler, shaders, failed = newShader, {}, {} + end + local key = keyed and "keyed" or "normal" + if failed[key] then return nil end + if not shaders[key] then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + local ok, value = pcall(newShader, (source:gsub("ALPHA", alpha))) + if not ok or not value then failed[key] = true; return nil end + shaders[key] = value + end + return shaders[key] +end +function M.shader() return shader(false) end +function M.keyedShader() return shader(true) end +function M.available() return M.shader() ~= nil end + +local function copy(colors) + if type(colors) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local color = colors[i] + if type(color) ~= "table" then return nil end + local row = {} + for c = 1, 3 do + local n = color[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + row[c] = n + end + out[i] = row + end + return out +end +function M.resolve(colors) return Palettes.effectiveColors(colors) end +function M.color(colors, index) + local palette = M.resolve(colors) + return palette and palette[index] or nil +end + +local function upload(sh, colors) + -- Public shader objects can be edited externally. Upload every public call; + -- a cached last-send comparison could otherwise trust foreign uniforms. + sent[sh] = nil + for i = 1, 4 do + local row = colors[i] + sh:send(names[i], {row[1]/255, row[2]/255, row[3]/255}) + end + sent[sh] = colors +end +local function use(colors, keyed, raw) + local g = graphics() + if not (g and g.setShader) then return false end + colors = copy(raw and colors or M.resolve(colors)) + if not colors then return false end + local sh = shader(keyed) + if not sh then return false end + upload(sh, colors) + g.setShader(sh) + return true +end +function M.use(colors) return use(colors, false, false) end +function M.useRaw(colors) return use(colors, false, true) end +function M.useKeyed(colors) return use(colors, true, false) end +function M.clear() + local g = graphics() + if g and g.setShader then g.setShader() end +end + +local function scoped(colors, body, apply) + local g = graphics() + local previous = g and g.getShader and g.getShader() or nil + -- Restoring only the shader object is insufficient: nested calls reuse it + -- and change its uniforms. Save the palette values as well as its identity. + local previousColors = previous and sent[previous] + local ok, applied = pcall(function() + local active = apply(colors) + body() + return active + end) + local restored, why = pcall(function() + if previousColors then upload(previous, previousColors) end + if g and g.setShader then g.setShader(previous) end + end) + if not ok then error(applied, 0) end + if not restored then error(why, 0) end + return applied +end +function M.with(colors, body) return scoped(colors, body, M.use) end +function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end +function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +return M diff --git a/src/render/Gen2PokemonPalettes.lua b/src/render/Gen2PokemonPalettes.lua new file mode 100644 index 000000000..576e29589 --- /dev/null +++ b/src/render/Gen2PokemonPalettes.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public Gen2 colour reads over the native palette pack. No duplicate +-- species catalogue, ROM colours or private renderer implementation. +local Context = require("src.render.Gen2PaletteContext") +local M = {} + +-- Return the native source table without allocation for the built-in renderer. +-- Public consumers receive detached RGB values through colors() below. +function M.native(data, species, shiny) + local owner = Context.owner(data) + local pokemon = owner and owner.pokemon + local def = type(pokemon) == "table" and pokemon[species] + local pack = owner and owner.palettes + local palettes = type(pack) == "table" and pack.palettes + if type(def) ~= "table" or type(palettes) ~= "table" then return nil end + if shiny and def.shinyPalette and palettes[def.shinyPalette] then + return palettes[def.shinyPalette] + end + if def.palette and palettes[def.palette] then return palettes[def.palette] end + local names = pack.pokemon + local name = type(names) == "table" and (names[def.id or species] or names[species]) + return name and palettes[name] or nil +end + +local function color(value) + if type(value) ~= "table" then return nil end + local rgb = {} + for i = 1, 3 do + local n = value[i] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + rgb[i] = n + end + return rgb +end +local function detached(palette) + if type(palette) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + out[i] = color(palette[i]) + if not out[i] then return nil end + end + return out +end + +function M.colors(data, species, shiny) + if type(data) ~= "table" or type(species) ~= "string" then return nil end + local owner, bound = Context.owner(data) + if owner then return detached(M.native(data, species, shiny)) end + if bound then return nil end -- Never revive a retired dataset view. + -- Reference palette-only records carry the two ROM middle colours, rather + -- than native names into a four-colour pack. Preserve that public input. + local rows = data.pokemon + local entry = type(rows) == "table" and rows[species] + if type(entry) ~= "table" then return nil end + local pair = shiny and entry.shiny or entry.normal + if type(pair) ~= "table" then return nil end + local first, second = color(pair[1]), color(pair[2]) + if not first or not second then return nil end + return { {255,255,255}, first, second, {0,0,0} } +end +return M diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 52c994215..fd6aee72e 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -839,6 +839,10 @@ function PaletteFX.monPal(data, species, transformed, shiny) return p.palettes.GRAYMON or (data and data.palettes and data.palettes.palettes.GRAYMON) end + if GameVersion.isGen2() then + local native = require("src.render.Gen2PokemonPalettes").native(data, species, shiny) + if native then return native end + end local def = data and data.pokemon and data.pokemon[species] if shiny and def and def.shinyPalette then local pal = p.palettes[def.shinyPalette] @@ -880,6 +884,12 @@ function PaletteFX.monPal(data, species, transformed, shiny) return nil end +-- Legacy external Gen2 consumers ask the published palette view directly. +-- Resolve the same native row as monPal and return fresh RGB values. +function PaletteFX.monColors(data, species, shiny) + return require("src.render.Gen2PokemonPalettes").colors(data, species, shiny) +end + -- palette name a species currently resolves to (for image-cache keys) function PaletteFX.monPalName(data, species, transformed, shiny) if transformed then return "GRAYMON" end diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua new file mode 100644 index 000000000..8bc99a6e6 --- /dev/null +++ b/tests/engine/gen2_palette_draw.lua @@ -0,0 +1,105 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX') +local G=require('src.render.GbcPalette') +local LCD=require('src.render.GBCFX') +local V=require('src.core.GameVersion');local edition=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local old=love.graphics +local A={{243,241,239},{219,17,31},{19,197,83},{23,29,157}} +local B={{233,229,227},{11,61,211},{173,37,139},{3,7,13}} +local function normalized(p) + local out={};for i=1,4 do local r=p[i];out['pal'..i-1]={r[1]/255,r[2]/255,r[3]/255}end;return out +end +local active, builds, failSend=nil,0,nil +local function fakeShader(code) + builds=builds+1 + return {source=code,values={},send=function(self,name,value) + if name==failSend then failSend=nil;error('uniform upload failed')end + self.values[name]={unpack(value)} + end} +end +love.graphics={newShader=fakeShader,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G,require('src.render.Gen2PaletteDraw'),'public spelling has typed native implementation') +T.neq(G,LCD,'palette drawing must not resolve to LCD screen effect') +T.eq(type(LCD.applyOptions),'function','native LCD options retained') +T.check(G.available(),'indexed shader is available') +local standard,keyed=G.shader(),G.keyedShader() +T.neq(standard,keyed,'keyed drawing has separate alpha policy') +T.eq(builds,2,'one lazy shader per variant') +T.check(standard.source:find('pixel.a',1,true)~=nil,'source alpha retained') +T.check(standard.source:find('* tint',1,true)~=nil,'draw colour retained') +T.check(keyed.source:find('shade < 0.5 ? 0.0',1,true)~=nil,'keyed source shade zero is transparent') +for _,variant in ipairs({'with','withRaw','keyedWith'})do + active={external=true};local external=active + local wanted=variant=='keyedWith'and keyed or standard + T.check(G[variant](A,function() + T.eq(active,wanted,variant..' shader active inside callback') + T.same(active.values,normalized(A),variant..' all four RGB rows normalized') + G.withRaw(B,function()T.same(active.values,normalized(B),variant..' nested different palette active')end) + T.eq(active,wanted,variant..' nested return restores shader') + T.same(active.values,normalized(A),variant..' nested return restores uniforms') + local ok,err=pcall(function()G.withRaw(B,function()error('authored callback failure',0)end)end) + T.check(not ok and err=='authored callback failure',variant..' callback failure propagated') + T.eq(active,wanted,variant..' nested failure restores shader') + T.same(active.values,normalized(A),variant..' nested failure restores outer palette') + end),variant..' returns applied flag') + T.eq(active,external,variant..' restores foreign shader') +end +G.useRaw(A);A[2][1]=111 +G.withRaw(B,function()end) +local saved=normalized(A);saved.pal1[1]=219/255 +T.same(active.values,saved,'restoration does not borrow caller palette rows') +A[2][1]=219 +standard:send('pal1',{0,0,0});G.useRaw(A) +T.same(active.values,normalized(A),'later public call repairs externally edited uniforms') +G.useRaw(A);failSend='pal2' +local ok,err=pcall(function()G.withRaw(B,function()error('body must not run')end)end) +T.check(not ok and tostring(err):find('uniform upload failed',1,true)~=nil,'upload failure is propagated') +T.eq(active,standard,'partial upload restores previous shader') +T.same(active.values,normalized(A),'partial upload restores all previous uniforms') +local nativeMode,nativeMap=P.mode,P.shadeMap() +for _,game in ipairs({'red','gold','silver','crystal'})do + V.set(game) + for _,mode in ipairs(P.MODES)do + P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) + local resolved=P.effectiveColors(A) + T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') + G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) + G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) + T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') + end +end +P.setShadeMap(nativeMap);P.setMode(nativeMode);V.set(edition) +for _,bad in ipairs({false,{},{{1,2,3}},{{1,2,3},{1,2,3},{1,2,3},{0/0,2,3}},{{1,2,3},{1,2,3},{1,2,3},{256,2,3}}})do + active={foreign=true};local foreign=active;local called=false + T.eq(G.withRaw(bad,function()called=true;T.eq(active,foreign,'invalid palette retains previous shader in body')end),false,'invalid palette declines colourization') + T.check(called,'invalid palette still executes draw callback') + T.eq(active,foreign,'invalid palette restores previous shader') +end +G.clear();T.eq(active,nil,'clear restores ordinary drawing') +love.graphics={} +T.eq(G.available(),false,'headless renderer declines shader availability') +local called=false;T.eq(G.with(A,function()called=true end),false,'headless with declines colourization') +T.check(called,'headless callback still executes') +love.graphics={newShader=function()error('compiler unavailable')end,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.available(),false,'compile failure is an unavailable feature') +love.graphics.newShader=fakeShader +T.check(G.available(),'replacement compiler can recover after a failed compile') +for _,api in ipairs({1,2})do + local files={['mods/draw/manifest.json']=Json.encode({id='draw',name='Native indexed drawing consumer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/draw/main.lua']=[[ + local mod=...;local Gbc=require('src.render.GbcPalette') + mod.exports.draw=function(colors,body)return Gbc.with(colors,body)end + ]]} + local r=T.sdk.loadMods({'mods/draw'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,'API'..api..' unchanged indexed consumer loads') + local ran=false + T.check(r.loader.exports.draw.draw(A,function()ran=true end),'API'..api..' loaded consumer applies palette') + T.check(ran,'API'..api..' loaded draw callback executes') + Loader.teardown();r.release() +end +love.graphics=old +T.finish('native indexed palette drawing, nested uniform and shader restoration') diff --git a/tests/engine/gen2_pokemon_palette_queries.lua b/tests/engine/gen2_pokemon_palette_queries.lua new file mode 100644 index 000000000..6ce9ae5ff --- /dev/null +++ b/tests/engine/gen2_pokemon_palette_queries.lua @@ -0,0 +1,71 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local mode=P.mode +local function palette(seed)return {{255,255,255},{seed,2,3},{4,seed+5,6},{0,0,0}}end +local function fixture() + local d=T.fixtures.fresh();local normal,shiny,custom=palette(20),palette(60),palette(100) + d.tilesets={QUERY={palMap={0},palColorsByTod={DAY={normal}}}} + d.pokemon={SPECIES_133={id='SPECIES_133',index=133,dex=133,name='SPECIES_133',palette='QA_NORMAL',shinyPalette='QA_SHINY'}, + SPECIES_149={id='SPECIES_149',index=149,dex=149,name='DRAGONITE'}, + NO_COLORS={id='NO_COLORS'}} + d.palettes={palettes={QA_NORMAL=normal,QA_SHINY=shiny,QA_CUSTOM=custom,QA_MAPPED=palette(140), + MEWMON=palette(170),GRAYMON=palette(180)},pokemon={SPECIES_149='QA_MAPPED'}} + d.constants.speciesOrder={[133]='SPECIES_133',[149]='SPECIES_149'} + Data.publishGen2Palettes(d) + return d,normal,shiny,custom +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local d,normal,shiny,custom=fixture();local view=d.gen2Palettes + for _,colorMode in ipairs(P.MODES)do + P.setMode(colorMode) + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' public normal RGB in '..colorMode) + T.same(P.monColors(view,'SPECIES_133',true),shiny,edition..' public shiny RGB in '..colorMode) + T.same(P.monColors(d,'SPECIES_133'),normal,edition..' full native dataset accepted in '..colorMode) + T.eq(P.monPal(d,'SPECIES_133',false,false),normal,edition..' native renderer keeps source table, without copied palettes') + T.eq(P.monPal(d,'SPECIES_133',false,true),shiny,edition..' native shiny renderer shares source selection') + end + P.setMode('gbc') + T.same(P.monColors(view,'SPECIES_149'),d.palettes.palettes.QA_MAPPED,edition..' native species-to-palette map retained') + T.eq(P.monColors(view,'ABSENT'),nil,edition..' unknown species does not acquire arbitrary fallback colours') + T.eq(P.monColors(view,'NO_COLORS'),nil,edition..' existing species without native palette stays unavailable') + local copy=P.monColors(view,'SPECIES_133');copy[2][1]=0;copy[1]=nil + T.eq(normal[2][1],20,edition..' external recolouring cannot mutate native palette') + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' later read is detached from previous query') + d.pokemon.SPECIES_133.palette='QA_CUSTOM' + T.same(P.monColors(view,'SPECIES_133'),custom,edition..' live authored palette replacement reaches external view') + T.eq(P.monPal(d,'SPECIES_133',false,false),custom,edition..' same replacement reaches native renderer') + d.palettes.palettes.QA_SHINY=nil + T.same(P.monColors(view,'SPECIES_133',true),custom,edition..' unavailable shiny palette falls back to same native normal row') + T.eq(P.monPal(d,'SPECIES_133',true,true),d.palettes.palettes.GRAYMON,edition..' transformed native rendering keeps original precedence') + local bytes=Json.encode(d.palettes) + P.monColors(view,'SPECIES_133');T.eq(Json.encode(d.palettes),bytes,edition..' public read does not publish another palette catalogue') + Data.publishGen2Palettes(d) + T.eq(P.monColors(view,'SPECIES_133'),nil,edition..' retired view cannot revive a previous binding') + local legacy={pokemon={LOCAL={normal={{21,22,23},{31,32,33}},shiny={{41,42,43},{51,52,53}}}}} + T.same(P.monColors(legacy,'LOCAL'),{{255,255,255},{21,22,23},{31,32,33},{0,0,0}},edition..' reference pair input preserved') + T.same(P.monColors(legacy,'LOCAL',true),{{255,255,255},{41,42,43},{51,52,53},{0,0,0}},edition..' reference shiny pair preserved') + legacy.pokemon.LOCAL.shiny=nil + T.same(P.monColors(legacy,'LOCAL',true),P.monColors(legacy,'LOCAL'),edition..' reference absent shiny falls back to normal') + legacy.pokemon.LOCAL.normal[1][2]=0/0 + T.eq(P.monColors(legacy,'LOCAL'),nil,edition..' malformed colour is not handed to external shader') + for _,api in ipairs({1,2})do + local files={['mods/colors/manifest.json']=Json.encode({id='colors',name='Native Pokemon palette query',version='1.0.0',api=api,content_format=api==3 and 'unified' or 'native',entry='main.lua',affects_link=false}), + ['mods/colors/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.colors=function(data,species,shiny)return Palettes.monColors(data.gen2Palettes,species,shiny)end + ]]} + local fresh=fixture();local r=T.sdk.loadMods({'mods/colors'},{data=fresh,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,edition..' API'..api..' unchanged public consumer loads') + T.same(r.loader.exports.colors.colors(fresh,'SPECIES_133'),fresh.palettes.palettes.QA_NORMAL,edition..' API'..api..' loaded public query reads actual native palette') + Loader.teardown();r.release() + end +end +for _,args in ipairs({{}, {false}, {{},false}, {{pokemon={BAD=false}},'BAD'}})do + T.eq(P.monColors(unpack(args)),nil,'missing or invalid colour query declines') +end +P.setMode(mode);V.set(version) +T.finish('native Gen2 Pokemon palette queries and shared native selection') diff --git a/tests/gpu/gen2_palette_draw.lua b/tests/gpu/gen2_palette_draw.lua new file mode 100644 index 000000000..0c84afaf9 --- /dev/null +++ b/tests/gpu/gen2_palette_draw.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Call run() from an isolated LÖVE host, on each graphics backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local beforeVersion, beforeMode, beforeMap = V.get(), P.mode, P.shadeMap() + V.set('crystal') + local checks = 0 + local function check(ok, message) + checks = checks + 1 + assert(ok, message) + end + local A = {{247,239,229},{213,19,43},{23,193,79},{17,31,151}} + local B = {{233,227,211},{7,59,199},{163,41,137},{3,11,29}} + local shades = love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;shades:setPixel(x,0,n,n,n,1-x/4) end + local image = love.graphics.newImage(shades);image:setFilter('nearest','nearest') + local canvas = love.graphics.newCanvas(4,12,{format='rgba8',dpiscale=1}) + local expected = {} + local function draw(row, palette, keyed, tint) + love.graphics.setColor(unpack(tint or {1,1,1,1})) + love.graphics.draw(image,0,row) + expected[row] = {palette=palette,keyed=keyed,tint=tint or {1,1,1,1}} + end + love.graphics.push('all') + local ok, why = pcall(function() + love.graphics.setCanvas(canvas);love.graphics.clear(0,0,0,0) + love.graphics.setBlendMode('replace','premultiplied') + check(G.with(A,function()draw(0,A,false,{0.5,0.75,1,0.8})end),'normal draw unavailable') + check(G.keyedWith(A,function()draw(1,A,true)end),'keyed draw unavailable') + G.withRaw(A,function() + draw(2,A) + G.withRaw(B,function()draw(3,B)end) + draw(4,A) + local success,err=pcall(function()G.withRaw(B,function()error('nested failure',0)end)end) + check(not success and err=='nested failure','nested callback error lost') + draw(5,A) + G.keyedWith(B,function()draw(6,B,true)end) + draw(7,A) + end) + P.setShadeMap({[0]=3,[1]=2,[2]=1,[3]=0}) + G.with(A,function()draw(8,{A[4],A[3],A[2],A[1]})end) + G.withRaw(A,function()draw(9,A)end) + local foreign=love.graphics.newShader([[ + vec4 effect(vec4 tint,Image image,vec2 uv,vec2 screen){return vec4(0.2,0.4,0.6,0.8);} + ]]) + love.graphics.setShader(foreign) + G.withRaw(B,function()draw(10,B)end) + check(love.graphics.getShader()==foreign,'foreign shader identity not restored') + love.graphics.setColor(1,1,1,1);love.graphics.draw(image,0,11) + love.graphics.setCanvas();love.graphics.setShader() + local pixels=canvas:newImageData() + for row,expect in pairs(expected)do + for x=0,3 do + local r,g,b,a=pixels:getPixel(x,row) + local rgb=expect.palette[x+1];local tint=expect.tint + local wanted={rgb[1]/255*tint[1],rgb[2]/255*tint[2],rgb[3]/255*tint[3], + expect.keyed and x==0 and 0 or (1-x/4)*tint[4]} + for c,value in ipairs({r,g,b,a})do + check(math.abs(value-wanted[c])<=2/255,'pixel mismatch row '..row..' shade '..x..' channel '..c) + end + end + end + for x=0,3 do + local pixel={pixels:getPixel(x,11)} + for c,n in ipairs({0.2,0.4,0.6,0.8})do check(math.abs(pixel[c]-n)<=2/255,'foreign shader pixel not restored')end + end + end) + love.graphics.pop() + P.setShadeMap(beforeMap);P.setMode(beforeMode);V.set(beforeVersion) + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='GPU pixel equality for shade palette, tint, alpha, keying, nested success/error and foreign shader restoration'},canvas +end +return M From 287f6fabf8ed1fb6e29928b443767db0e7b45217 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:01:21 +0100 Subject: [PATCH 18/68] Expose native palette mode to preserve already-coloured evolution pictures --- docs/native-gen2-pokemon-palettes.md | 3 +++ src/render/Gen2PaletteDraw.lua | 11 +++++++++++ tests/engine/gen2_palette_draw.lua | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md index 98e69c1f8..d6537cc18 100644 --- a/docs/native-gen2-pokemon-palettes.md +++ b/docs/native-gen2-pokemon-palettes.md @@ -17,6 +17,9 @@ palette while preserving texture alpha and drawing tint. `use`, `with`, `available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes source shade zero transparent. Normal drawing resolves native `PaletteFX` display/register state; raw drawing bypasses that resolution. +The public `mode` read follows that native resolution, so external evolution +presenters can preserve already coloured art instead of passing it through a +grayscale-index shader. It does not create another display option. Scoped drawing restores both the previous shader object and managed palette uniforms, including nested calls, callback failures and partial upload errors. diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index b0c271a45..8d3b93f2e 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -2,7 +2,18 @@ -- Indexed image drawing, separate from the LCD post-process in GBCFX. -- Palette resolution uses the native display/register state in PaletteFX. local Palettes = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") local M = {} +-- Consumers use this view to leave already-coloured pictures untouched. +-- Gen2 currently resolves native ROM colours regardless of the Gen1 mode. +-- Do not invent a second mutable display setting for the external renderer. +setmetatable(M, {__index = function(_, key) + if key == "mode" then + if Version.isGen2() then return "gbc" end + if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end + return Palettes.mode == "classic" and "classic" or "gbc" + end +end}) local compiler, shaders, failed = nil, {}, {} local sent = setmetatable({}, {__mode = "k"}) local names = {"pal0", "pal1", "pal2", "pal3"} diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index 8bc99a6e6..76de1532e 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -66,6 +66,10 @@ for _,game in ipairs({'red','gold','silver','crystal'})do for _,mode in ipairs(P.MODES)do P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) local resolved=P.effectiveColors(A) + local display=game~='red'and'gbc'or(mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc')) + T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') + local trueColor=true + T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) From 529fa592cdce65c1fbce7984bd21894169703961 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:26:36 +0100 Subject: [PATCH 19/68] Keep native Gen2 daily services on the saved RTC midnight --- docs/native-gen2-clock.md | 42 ++++++--- src/core/Gen2Clock.lua | 77 +++++++++++++---- src/script/Gen2Daily.lua | 17 ++-- tests/engine/gen2_clock.lua | 15 ++-- tests/engine/gen2_clock_lifecycle.lua | 119 ++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 tests/engine/gen2_clock_lifecycle.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index 0bf1f6f38..5fc23558a 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -7,12 +7,31 @@ and the native happiness evolution gate. The existing `src.core.gen2.Clock` name resolves to this native module. `world.hour()`, `world.minute()` and `world.weekday()` read that same save. -`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the -host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and -keeps the older native `g2DayOffset` field synchronized. Old saves without -an RTC base retain their host-hour and existing weekday-offset behavior. -Read calls never create clock data. Invalid/non-finite writes are rejected. -The normal native save codec preserves the offsets; play time is separate. +`setTime(save, hour, minute)` anchors the chosen minute, current game +weekday and calendar date to elapsed RTC seconds. Hours, weekdays and daily +calendar identity cross the same in-game midnight, including after a saved +session has been closed for days. Automatic host DST and time-zone changes do +not add another hour to a clock the player already set. Explicit system clock +adjustments still affect the host RTC; this is not a monotonic stopwatch. + +The legacy `rtc.startMinute`, `rtc.startDay` and `g2DayOffset` projections are +retained for older readers. New writes also store `anchorTime`, `anchorMinute`, +`anchorDay` and `anchorDate` in the same `save.rtc` record. Consumers should use +the native setters/readers instead of mutating these projections directly. +`setWeekday` edits the current game weekday without changing elapsed calendar +age. Editing time reanchors the clock without resetting its current date. + +Old saves without the elapsed anchor retain their original host-hour and +weekday-offset semantics, including their old midnight relationship, until +the clock is explicitly set. Read calls never create or migrate clock data. +Invalid/non-finite writes are rejected. The normal native save codec retains +both formats; elapsed play time is separate. + +`calendarDate(save, now)` and `dateKey(save, now)` give native daily and weekly +services the calendar belonging to that RTC. Daily flags/fruit use and the +lottery now expire on game midnight and the selected game Friday instead of +an unrelated host-calendar boundary. The optional timestamp on clock reads +uses the same snapshot for minute, weekday and calendar queries. World palettes, object-load period bits and `VAR_HOUR` share the saved hour; the normal object-load freeze still applies. An explicit palette-query hour @@ -32,7 +51,9 @@ palette override. This keeps explicit native/external hour queries aligned. `clock.day_changed` is produced by actual weekday reads, including the live world clock poll. It reports `day`, `previous` and `reason` (`rollover` or `set`). A save's first observation is silent. Observations are per save, -reentrant-safe, subscription-aware and retired on mod/edition reload. +reentrant-safe, subscription-aware and retired on mod/edition reload. An +observed offline whole week still produces one change, even when the weekday +number is the same; its calendar date is different. Native API 1/2 use the existing event dispatch boundary. The native GSC producer remains generation-scoped. @@ -41,9 +62,10 @@ are exercised directly in synthetic engine tests, including minute wrap, weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback path and actual mod loading. Graphics IO alone is replaced for the clock card. These checks do not establish a complete original-story clock-setting -sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or -fresh GPU acceptance for this change. Existing daily date keys still use -host calendar dates; lottery weekday selection now shares the saved weekday. +sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC +conversion, all daily services or fresh GPU acceptance for this change. +Automatic host DST isolation is covered; the original story clock-setting +screen and remaining manual DST lifecycle still need native implementation. The public contract is compared with the read-only Gen1Recomp reference; the implementation is native fork code. No VASC implementation, private diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua index fc02c2953..dc7d75d17 100644 --- a/src/core/Gen2Clock.lua +++ b/src/core/Gen2Clock.lua @@ -12,18 +12,31 @@ end local function rtc(save) return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil end -function Clock.hostMinutes() - return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +function Clock.hostMinutes(now) + return ((integer(os.date("%H", now)) or 0) * 60 + (integer(os.date("%M", now)) or 0)) % 1440 end function Clock.hostWeekday(now) return (integer(os.date("%w", now)) or 0) % 7 end -function Clock.minutes(save) +-- Newly set clocks advance by elapsed RTC seconds, including while closed. +-- The old host-local offsets remain readable without migrating a save. Host +-- DST/time-zone changes must not silently change an explicitly set game clock. +local function anchored(state, now) + if not state then return nil end + local at, minute, day = integer(state.anchorTime), integer(state.anchorMinute), integer(state.anchorDay) + if not at or not minute or minute < 0 or minute >= 1440 or not day or day < 0 or day >= 7 then return nil end + now = integer(now or os.time()) + if not now then return nil end + return minute + math.floor((now - at) / 60), day +end +function Clock.minutes(save, now) local state = rtc(save) - return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 + local total = anchored(state, now) + if total then return total % 1440 end + return (Clock.hostMinutes(now) + (state and integer(state.startMinute) or 0)) % 1440 end -function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end -function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.hour(save, now) return math.floor(Clock.minutes(save, now) / 60) end +function Clock.minute(save, now) return Clock.minutes(save, now) % 60 end function Clock.isSet(save) local state = rtc(save) return state ~= nil and integer(state.startMinute) ~= nil @@ -32,42 +45,76 @@ function Clock.setTime(save, hour, minute) if type(save) ~= "table" then return false end hour, minute = integer(hour or 0), integer(minute or 0) if not hour or not minute then return false end + local now = os.time() + local day, date = Clock.weekday(save, now), Clock.dateKey(save, now) + local wanted = (hour % 24) * 60 + minute % 60 save.rtc = rtc(save) or {} - save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + save.rtc.startMinute = (wanted - Clock.hostMinutes(now)) % 1440 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.g2DayOffset = save.rtc.startDay + save.rtc.anchorTime, save.rtc.anchorMinute = now, wanted + save.rtc.anchorDay, save.rtc.anchorDate = day, date return true end -local function note(save, day, reason) +local function note(save, day, reason, now) if type(save) ~= "table" then return day end local Runtime = require("src.mods.Runtime") if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then observed[save] = nil return day end - local previous = observed[save] - observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. - if previous ~= nil and previous ~= day then - Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + local previous, date = observed[save], Clock.dateKey(save, now) + observed[save] = { day=day, date=date } -- Commit before dispatch; reentrant reads are silent. + if previous ~= nil and (previous.day ~= day or previous.date ~= date) then + Runtime.emit("clock.day_changed", { day = day, previous = previous.day, reason = reason }) end return day end function Clock.weekday(save, now) local state = rtc(save) + local total, day = anchored(state, now) + if total then return note(save, (day + math.floor(total / 1440)) % 7, "rollover", now) end local offset = state and integer(state.startDay) if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end - return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover", now) end function Clock.setWeekday(save, day) day = integer(day) if type(save) ~= "table" or not day then return false end + local now = os.time() save.rtc = rtc(save) or {} day = day % 7 - save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 save.rtc.dayOfWeek = day + local total = anchored(save.rtc, now) + if total then save.rtc.anchorDay = (day - math.floor(total / 1440)) % 7 end -- Keep the existing native field in sync for older native consumers/saves. save.g2DayOffset = save.rtc.startDay - note(save, day, "set") + note(save, day, "set", now) return true end +-- Calendar identity for daily/weekly timers. Only date arithmetic uses the +-- host calendar library; elapsed game days come from the same RTC as minutes +-- and weekday. Noon normalization avoids missing/duplicated local DST hours. +function Clock.calendarDate(save, now) + local state = rtc(save) + local total = anchored(state, now) + local year, month, day + if total and type(state.anchorDate) == "string" then + year, month, day = state.anchorDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + year, month, day = tonumber(year), tonumber(month), tonumber(day) + end + if year and month >= 1 and month <= 12 and day >= 1 and day <= 31 then + local ok, stamp = pcall(os.time, { year=year, month=month, + day=day + math.floor(total / 1440), hour=12, min=0, sec=0, isdst=nil }) + if ok and stamp then return os.date("*t", stamp) end + end + return os.date("*t", now) +end +function Clock.dateKey(save, now) + local date = Clock.calendarDate(save, now) + return ("%04d-%02d-%02d"):format(date.year, date.month, date.day) +end function Clock.weekdayName(day) local name = Clock.DAY_NAMES[day] return name and Strings(name) diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 6337f5cd9..36c041447 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -18,6 +18,9 @@ local Gen2Flags = require("src.script.Gen2Flags") local Gen2Daily = {} +-- Native RTC/calendar integration by Roxas2712; see contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +local Clock = require("src.core.Gen2Clock") local ENGINE_LUCKY_NUMBER_SHOW = 77 local GameVersion = require("src.core.GameVersion") @@ -39,19 +42,15 @@ local INITIAL_HIDDEN_EVENTS = { 1915, -- EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL } -local function todayKey(now) - return os.date("%Y-%m-%d", now) -end - local function nextFriday(save, now) - local date = os.date("*t", now) - local weekday = require("src.core.Gen2Clock").weekday(save, now) + local date = Clock.calendarDate(save, now) + local weekday = Clock.weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. date.day, date.hour, date.min, date.sec = date.day + days, 12, 0, 0 date.isdst = nil - return todayKey(os.time(date)) + return os.date("%Y-%m-%d", os.time(date)) end function Gen2Daily.lotteryExpired(save, now) @@ -59,7 +58,7 @@ function Gen2Daily.lotteryExpired(save, now) -- Old saves already have a drawn ID; retain it when adding the timer. save.g2LuckyNextFriday = nextFriday(save, now) end - return todayKey(now) >= save.g2LuckyNextFriday + return Clock.dateKey(save, now) >= save.g2LuckyNextFriday end function Gen2Daily.resetLottery(save, now) @@ -121,7 +120,7 @@ function Gen2Daily.poll(save, now) Gen2Daily.ensureRoam(save, nil) if not save then return end Gen2Daily.seedInitialObjectFlags(save) - local today = todayKey(now) + local today = Clock.dateKey(save, now) if save.g2DailyDay == today then return end save.g2DailyDay = today Gen2Daily.onNewDay(save) diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua index e3085943c..ecfe737fe 100644 --- a/tests/engine/gen2_clock.lua +++ b/tests/engine/gen2_clock.lua @@ -7,9 +7,11 @@ local Clock = require('src.core.gen2.Clock') local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') local Json = require('src.link.Json');local Save = require('src.core.SaveData') -local date = os.date;local host = { hour=13, minute=20, day=2 } +local date,time = os.date,os.time;local elapsed=0;local baseTime=time() +os.time=function(value)if value then return time(value)end;return baseTime+elapsed end +local host = { hour=13, minute=20, day=2 } os.date = function(format, now) - if now then return date(format,now) end + if now and now~=os.time() then return date(format,now) end if format=='%H' then return tostring(host.hour) end if format=='%M' then return tostring(host.minute) end if format=='%w' then return tostring(host.day) end @@ -33,7 +35,7 @@ T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') for _,edition in ipairs({'gold','silver','crystal'}) do - V.set(edition);host.hour=13;host.minute=20;host.day=2 + V.set(edition);host.hour=13;host.minute=20;host.day=2;elapsed=0 local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') @@ -45,10 +47,9 @@ for _,edition in ipairs({'gold','silver','crystal'}) do T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') - host.minute=30 + host.minute=30;elapsed=600 T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') - host.day=3 - T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + T.eq(Clock.weekday(save),0,edition..' saved weekday rolls over at game midnight before host midnight') local round=assert(Save.decode(Save.encode(save))) T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') @@ -115,5 +116,5 @@ for _,edition in ipairs({'gold','silver','crystal'}) do end end nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) -Clock.resetObservations();os.date=date;V.set(version) +Clock.resetObservations();os.date=date;os.time=time;V.set(version) T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua new file mode 100644 index 000000000..ab38d51c8 --- /dev/null +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +local C=require('src.core.Gen2Clock');local D=require('src.script.Gen2Daily') +local Save=require('src.core.SaveData');local Json=require('src.link.Json') +local date,time=os.date,os.time +-- A deterministic host clock. Date-table normalization still uses the OS +-- calendar, including real DST rules; elapsed time is independently advanced. +local now=time({year=2026,month=10,day=20,hour=13,min=20,sec=0}) +local origin=now;local shift=0 +os.time=function(value)if value then return time(value)end;return now end +os.date=function(format,stamp)return date(format,(stamp or now)+shift)end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);now=origin;shift=0 + local save={flags={},g2FruitTrees={used=true}} + T.eq(C.setTime(save,23,59),true,edition..' player can anchor before midnight') + T.eq(C.setWeekday(save,4),true,edition..' player chooses Thursday independently of host') + local startDate=C.dateKey(save);local offset=save.rtc.startMinute + T.eq(C.hour(save),23,edition..' explicit hour visible immediately') + T.eq(C.minute(save),59,edition..' explicit minute visible immediately') + D.poll(save);save.g2FruitTrees.used=true + local before=save.g2DailyDay + now=now+59;D.poll(save) + T.eq(C.minute(save),59,edition..' RTC waits for the full elapsed minute') + T.eq(C.weekday(save),4,edition..' weekday remains before game midnight') + T.eq(save.g2FruitTrees.used,true,edition..' daily use retained before midnight') + now=now+1;D.poll(save) + T.eq(C.hour(save),0,edition..' elapsed RTC crosses midnight') + T.eq(C.minute(save),0,edition..' minute wraps with the day') + T.eq(C.weekday(save),5,edition..' weekday crosses at same game midnight') + T.eq(C.hostWeekday(),C.hostWeekday(origin),edition..' host has not crossed midnight') + T.eq(save.g2FruitTrees.used,nil,edition..' native daily reset occurs at game midnight') + T.eq(save.g2DailyDay~=before,true,edition..' daily calendar uses the game boundary') + T.eq(C.dateKey(save)~=startDate,true,edition..' RTC calendar advances at same boundary') + save.g2FruitTrees.used=true;D.poll(save) + T.eq(save.g2FruitTrees.used,true,edition..' repeated poll cannot repeat daily reset') + local round=assert(Save.decode(Save.encode(save))) + now=now+7*86400+123*60 + T.eq(C.weekday(round),5,edition..' offline week retains proper weekday') + T.eq(C.hour(round),2,edition..' offline elapsed hours retained after save reload') + T.eq(C.minute(round),3,edition..' offline elapsed minutes retained after save reload') + local stale=round.g2DailyDay;D.poll(round) + T.eq(round.g2DailyDay~=stale,true,edition..' offline whole week still resets daily identity') + T.eq(round.g2FruitTrees.used,nil,edition..' offline reset does not depend on changed weekday') + local expected=C.minutes(round);local day=C.weekday(round);local calendar=C.dateKey(round) + shift=3600 + T.eq(C.minutes(round),expected,edition..' automatic host DST jump cannot double-advance game RTC') + T.eq(C.weekday(round),day,edition..' host DST cannot independently shift game weekday') + T.eq(C.dateKey(round),calendar,edition..' host DST cannot expire daily identity') + shift=-3600 + T.eq(C.minutes(round),expected,edition..' backward host DST jump cannot freeze game RTC') + shift=0 + T.eq(C.setTime(round,7,15),true,edition..' explicit edit reanchors current native clock') + T.eq(C.weekday(round),day,edition..' editing time preserves the chosen current weekday') + T.eq(C.dateKey(round),calendar,edition..' editing time preserves current game calendar date') + T.eq(C.hour(round),7,edition..' edited hour visible') + T.eq(C.minute(round),15,edition..' edited minute visible') + now=now+2*86400+45*60 + T.eq(C.weekday(round),(day+2)%7,edition..' future days follow new anchor') + T.eq(C.minutes(round),8*60,edition..' new anchor keeps ticking') + local oldDate=C.dateKey(round);C.setWeekday(round,0) + T.eq(C.weekday(round),0,edition..' weekday editing after offline elapsed days is immediate') + T.eq(C.dateKey(round),oldDate,edition..' weekday wheel does not rewrite calendar age') + C.setTime(round,23,59);now=now+60 + T.eq(C.weekday(round),1,edition..' edited weekday and time cross next midnight together') + -- The weekly service has to expire on the selected in-game Friday, not a + -- host Friday that is several hours out of phase. + now=origin;local lottery={flags={}};C.setTime(lottery,23,59);C.setWeekday(lottery,4) + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' fresh lottery remains active Thursday') + now=now+60 + T.eq(C.weekday(lottery),5,edition..' weekly service reaches selected Friday') + T.eq(D.lotteryExpired(lottery),true,edition..' native lottery expires at game Friday midnight') + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' Friday draw waits for next Friday') + now=now+6*86400 + T.eq(D.lotteryExpired(lottery),false,edition..' lottery does not expire a day early') + now=now+86400 + T.eq(D.lotteryExpired(lottery),true,edition..' next game Friday expires weekly draw') + -- Actual subscription dispatch must observe a game midnight even when + -- the host weekday is unchanged, and an offline full week too. + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/day/manifest.json']=Json.encode({id='day',name='Native midnight observer',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/day/main.lua']=[[ + local mod=...;mod.exports.events={} + local Clock=require('src.core.gen2.Clock') + mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99 + end) + ]]} + local loaded=T.sdk.loadMods({'mods/day'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,edition..' real loader admits midnight observer') + local export=loaded.loader.exports.day;local own={};export.save=own + now=origin;C.setTime(own,23,59);C.setWeekday(own,4) + -- Explicit staging changes may emit; only changes after the first settled + -- observation are under test here. + C.weekday(own);local n=#export.events;now=now+60;C.weekday(own) + T.eq(#export.events,n+1,edition..' midnight event dispatched once through actual runtime') + T.eq(export.events[n+1].day,5,edition..' midnight event carries new game weekday') + T.eq(export.events[n+1].previous,4,edition..' midnight event retains previous game weekday') + C.weekday(own);T.eq(#export.events,n+1,edition..' reentrant/repeated reads cannot repeat midnight') + now=now+7*86400;C.weekday(own) + T.eq(#export.events,n+2,edition..' offline full week still emits one observed day change') + T.eq(export.events[n+2].day,5,edition..' full-week event correctly has same weekday') + require('src.mods.Loader').teardown();loaded.release() + end + -- Old offsets and old saves remain read-only on load, without introducing + -- an epoch guessed from today's time into another player's save. + local old={rtc={startMinute=offset,startDay=2},g2DayOffset=2} + local bytes=Json.encode(old) + T.eq(C.minutes(old),(C.hostMinutes()+offset)%1440,edition..' old minute offset retains exact semantics') + T.eq(C.weekday(old),(C.hostWeekday()+2)%7,edition..' old weekday offset retains exact semantics') + C.dateKey(old);T.eq(Json.encode(old),bytes,edition..' old save reads do not migrate or mutate state') + local malformed={rtc={anchorTime='bad',anchorMinute=0,anchorDay=0,startMinute=0}} + T.eq(C.minutes(malformed),C.hostMinutes(),edition..' malformed optional anchor falls back to old clock') +end +os.date=date;os.time=time;C.resetObservations();V.set(version) +T.finish('native RTC midnight, offline calendar, host DST isolation and actual daily/lottery lifecycle') From 5c1eafa6c51e6a4c4c7dd6cdbcc7b6c0da816ea9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:47:37 +0100 Subject: [PATCH 20/68] Set the Gen2 clock through the native introduction and weekday screen --- docs/native-gen2-clock.md | 4 +- docs/native-gen2-init-clock.md | 37 +++++++ src/core/ModCompat.lua | 1 + src/script/Gen2Commands.lua | 50 +++------ src/ui/Gen2InitClock.lua | 176 +++++++++++++++++++++++++++++++ src/ui/OakSpeech.lua | 25 ++++- tests/engine/gen2_init_clock.lua | 129 ++++++++++++++++++++++ 7 files changed, 380 insertions(+), 42 deletions(-) create mode 100644 docs/native-gen2-init-clock.md create mode 100644 src/ui/Gen2InitClock.lua create mode 100644 tests/engine/gen2_init_clock.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index 5fc23558a..8efd85550 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -65,7 +65,9 @@ card. These checks do not establish a complete original-story clock-setting sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC conversion, all daily services or fresh GPU acceptance for this change. Automatic host DST isolation is covered; the original story clock-setting -screen and remaining manual DST lifecycle still need native implementation. +screen now has a native implementation described in +[native-gen2-init-clock.md](native-gen2-init-clock.md); fresh original-story/GPU +acceptance and the remaining manual DST lifecycle are separately gated. The public contract is compared with the read-only Gen1Recomp reference; the implementation is native fork code. No VASC implementation, private diff --git a/docs/native-gen2-init-clock.md b/docs/native-gen2-init-clock.md new file mode 100644 index 000000000..058717d8a --- /dev/null +++ b/docs/native-gen2-init-clock.md @@ -0,0 +1,37 @@ +# Native Gen2 initial clock and weekday selection + +Gold, Silver and Crystal now open their native Oak speech with an +`init_clock` step. This opens `Gen2InitClock` before the professor's welcome +and music. The required hour and minute pickers wrap in both directions, +retain their selection after NO, read the complete time back, and commit to +the shared saved RTC only after the final acknowledgement. Completion resumes +the original Oak parent once. Other Gen2 introductions keep their existing +step list. + +The native `SetDayOfWeek` special uses the same screen in day mode. It opens +on Sunday and remains suspended through selection and confirmation. NO +returns to the chosen value without writing the save or resuming the script; +YES writes through `Gen2Clock.setWeekday` and resumes the original runner once. + +`src.ui.gen2.InitClock` resolves to this actual native screen. Its constructor +accepts mode, save, hour/minute/day, onDone and autoConfirm options. Common +hour/time/response formatting helpers use the native clock labels. Native +TextBox and ChoiceBox own text paging, typing and YES/NO input; the clock +screen owns picker state, transitions, save commitment and optional fades. + +Dialogue is read from the active imported native text bank. Native wrapper +and underscore text spellings are supported; short engine prompts are used +only if an older cache lacks a line. No ROM dialogue, artwork, palette or +coordinate table is embedded in the implementation. The current panel layout +is engine-owned. This is not a claim of exact reference raster/layout helper +parity; the remaining reference drawing/metadata helpers and original clock +art require separate work. + +ROM-free coverage exercises the actual native widgets and stack, wrapped +selection and NO retries, final acknowledgement, save-codec retention, the +real special compiler/ScriptRunner yield/resume, the real Oak step and loaded +native API1/2 clients. These tests do not establish fresh +GPU or complete natural new-game/story acceptance. Manual DST dialogs/checks, +SRAM RTC conversion, broader story/hardware acceptance and full VASC/Gen1 +parity remain open. Private VASC code and protected Gen1/player instances are +unchanged. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 420182efc..1ccf52cb4 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -68,6 +68,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", ["src.core.gen2.Clock"] = "src.core.Gen2Clock", + ["src.ui.gen2.InitClock"] = "src.ui.Gen2InitClock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index d41e01d32..4de6372fc 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2879,45 +2879,21 @@ function Commands.g2_clear_dst(ctx) Commands.show_text(ctx, "InitialClearDSTFlag.Text") end --- The ROM spins these with the d-pad on a graphical clock-set background; --- a list menu is the port's equivalent and needs no ROM tiles. The names --- are engine text, not ROM strings: SetDayOfWeek.WeekdayStrings is raw --- charmap data, and the extractor only harvests symbols matching "Text". -local WEEKDAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} - --- `special SetDayOfWeek` ($25), the day picker in mom's clock talk. The GB --- has to ask because its RTC has no idea what day it started on; here --- readvar 11 reads the host clock, which already knows. So the answer is --- kept as an OFFSET from the host day rather than as the day itself: the --- cursor opens on today, a truthful answer stores 0 and changes nothing, and --- either way the day still rolls over at real midnight instead of freezing. +-- Native SetDayOfWeek shares the clock-setting screen and RTC owner with +-- the introduction. The script remains suspended through both selection +-- and confirmation; declining does not write the save or resume the script. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local Clock = require("src.core.Gen2Clock") - local current = Clock.weekday(ctx.save) - local picked - repeat - Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") - local items = {} - for index, day in ipairs(WEEKDAYS) do - items[index] = { - label = Strings(day), - onSelect = function() picked = index - 1 runner:resume() end, - } - end - -- no cancel: the ROM's loop watches A only, and onCancel is the only - -- other path back to runner:resume() - local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = current + 1 - menu:clampScroll() - game.stack:push(menu) - runner:yield() - Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) - .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) - until ctx.lastCheck - Clock.setWeekday(ctx.save, picked) + local screen + screen = require("src.ui.Gen2InitClock").new(game, { mode="day", save=ctx.save, + onDone=function() + if game.stack:top() == screen then game.stack:pop() end + ctx.lastCheck = true + runner:resume() + end, + }) + game.stack:push(screen) + runner:yield() end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua new file mode 100644 index 000000000..e98d9cc6c --- /dev/null +++ b/src/ui/Gen2InitClock.lua @@ -0,0 +1,176 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Native clock/day selection over the shared saved RTC and native widgets. +-- Dialogue comes from the active ROM cache; this layout is engine-owned. +local Clock = require("src.core.Gen2Clock") +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +local InitClock = {} +InitClock.__index = InitClock +InitClock.isOpaque = true +local function value(n, fallback, limit) + n = tonumber(n) + if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end + return math.floor(n) % limit +end +local function text(self, key, fallback) + local bank = self.game and self.game.data and self.game.data.text or {} + local found = bank[key] or bank["_" .. key:match("[^.]+$")] + if type(found) == "string" and found ~= "" then return found end + return Strings(fallback) +end +function InitClock.hourString(hour) + hour = value(hour, 0, 24) + local shown = hour % 12 + return ("%s %d"):format(Clock.daytimeLabel(hour), shown == 0 and 12 or shown) +end +function InitClock.oclockString(hour) return Strings("%s o'clock", InitClock.hourString(hour)) end +function InitClock.timeString(hour, minute) + return ("%s:%02d"):format(InitClock.hourString(hour), value(minute, 0, 60)) +end +function InitClock.responseKey(hour) + hour = value(hour, 0, 24) + if hour < 4 or hour >= 18 then return "soDark" end + return hour <= 10 and "overslept" or "yikes" +end +function InitClock.new(game, opts) + opts = opts or {} + local day = opts.mode == "day" + return setmetatable({ game=game, save=opts.save or game.save, + mode=day and "day" or "clock", phase=day and "day" or "intro", + isOpaque=not day, onDone=opts.onDone, autoConfirm=opts.autoConfirm == true, + fades=opts.fades == true and not day and not opts.autoConfirm, + hour=value(opts.hour, Clock.DEFAULT_HOUR, 24), + minute=value(opts.minute, Clock.DEFAULT_MINUTE, 60), day=value(opts.day, 0, 7), + }, InitClock) +end +function InitClock:wantsFillScale() return self.mode ~= "day" end +function InitClock:question() + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", "SET THE CLOCK") end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", "CHOOSE HOUR") end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", "CHOOSE MINUTE") end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", "CHOOSE DAY") end + if self.phase == "confirm-hour" then + return text(self, "OakTimeWhatHoursText", "CONFIRM HOUR") .. "\n" + .. InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", "?") + end + if self.phase == "confirm-minute" then + return text(self, "OakTimeWhoaMinutesText", "CONFIRM MINUTE") .. "\n" + .. Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", "?") + end + if self.phase == "confirm-day" then + return Clock.weekdayName(self.day + 1) + .. text(self, "SetDayOfWeek.OakTimeIsItText", "?") + end + local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } + if self.phase == "response" then + return InitClock.timeString(self.hour, self.minute) + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], "\nCLOCK SET") + end + return "" +end +function InitClock:display() + if self.phase == "hour" then return InitClock.oclockString(self.hour) end + if self.phase == "minute" then return Strings("%d min.", self.minute) end + if self.phase == "day" then return Clock.weekdayName(self.day + 1) end +end +function InitClock:confirming() return self.phase:match("^confirm%-") ~= nil end +function InitClock:showPhase() + if self.autoConfirm or self.done then return end + if self.phase == "intro" or self.phase == "response" then + self.game.stack:push(TextBox.new(self.game, self:question(), function() self:accept() end)) + elseif self:confirming() then + self.game.stack:push(TextBox.new(self.game, self:question(), nil, { + choice=function(yes) if yes then self:accept() else self:decline() end end, + })) + end +end +function InitClock:enter() + if self.fades then self.fade = { out=false, frame=0 } else self:showPhase() end +end +function InitClock:complete() + if self.done then return end + self.done = true + if self.onDone then + if self.mode == "day" then self.onDone(self.day) else self.onDone(self.hour, self.minute) end + elseif self.game.stack:top() == self then + self.game.stack:pop() + end +end +function InitClock:finish() + if self.done or self.finishing then return end + local ok + if self.mode == "day" then ok = Clock.setWeekday(self.save, self.day) + else ok = Clock.setTime(self.save, self.hour, self.minute) end + if not ok then return end + self.finishing = true + if self.fades then self.fade = { out=true, frame=0 } else self:complete() end +end +function InitClock:accept() + if self.done or self.finishing then return end + local nextPhase = { intro="hour", hour="confirm-hour", ["confirm-hour"]="minute", + minute="confirm-minute", ["confirm-minute"]="response", day="confirm-day" } + local next = nextPhase[self.phase] + if next then self.phase=next;self:showPhase() else self:finish() end +end +function InitClock:decline() + if self.done or self.finishing then return end + local picker = self.phase:match("^confirm%-(.+)$") + if picker then self.phase=picker;self:showPhase() end +end +function InitClock:value() + if self.phase == "hour" then return self.hour, 23 end + if self.phase == "minute" then return self.minute, 59 end + return self.day, 6 +end +function InitClock:step(delta) + local current, last = self:value() + local key = self.phase + if key == "hour" or key == "minute" or key == "day" then self[key]=(current+delta)%(last+1) end +end +function InitClock:update() + if self.done then return end + if self.fade then + local fade = self.fade + fade.frame = fade.frame + 1 + if fade.frame >= 8 then + self.fade = nil + if fade.out then self:complete() else self:showPhase() end + end + return + end + if self.finishing then return end + if self.autoConfirm then self:accept();return end + local input = self.game.input + if input:wasPressed("up") then self:step(1) + elseif input:wasPressed("down") then self:step(-1) + elseif input:wasPressed("a") then self:accept() end +end +function InitClock:sgbPalettes() + local P = require("src.render.PaletteFX") + if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end + return { P.trueColorZone(1, 4, 18, 9), P.trueColorZone(0, 12, 19, 17) } +end +function InitClock:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end + Font.drawBox(1, 4, 18, 6) + local shown = self:display() or InitClock.timeString(self.hour, self.minute) + Font.draw(shown, 16, 48) + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) + if self:display() then + Font.drawBox(0, 12, 20, 6) + Font.draw(self:question(), 8, 105) + end + if self.fade then + local alpha = self.fade.frame / 8 + G.setColor(0, 0, 0, self.fade.out and alpha or 1-alpha) + G.rectangle("fill", 0, 0, 160, 144) + end + G.setColor(1, 1, 1, 1) +end +return InitClock diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index ed0f11d16..d2bcda421 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -281,7 +281,7 @@ end -- over a Gen2 save. The keys are the ROM's own text labels, so textOr finds -- them in data.text instead of falling through. function OakSpeech.gen2Steps() - return { + local steps = { { id = "oak_welcome", kind = "say", @@ -384,6 +384,11 @@ function OakSpeech.gen2Steps() kind = "shrink", }, } + local version = GameVersion.get() + if version == "gold" or version == "silver" or version == "crystal" then + table.insert(steps, 1, { id="init_clock", kind="initclock" }) + end + return steps end -- list helpers for intro.oak_speech.build wrappers (also on ModUI) @@ -489,10 +494,12 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() - -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) - Music.play(self.game.data, self.cfg.music or "Music_Routes2") self.answers = {} self.steps = self:buildSteps() + -- The retail clock comes before the professor and his music. + if not (self.steps[1] and self.steps[1].kind == "initclock") then + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + end if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end @@ -660,7 +667,17 @@ end function OakSpeech:runStep(step) local kind = step.kind or "say" - if kind == "say" then + if kind == "initclock" then + local screen + screen = require("src.ui.Screens").push(self.game, "Gen2InitClock", { + save=self.game.save, fades=true, + onDone=function() + if self.game.stack:top() == screen then self.game.stack:pop() end + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + self:advance() + end, + }) + elseif kind == "say" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua new file mode 100644 index 000000000..c8ebbbe0d --- /dev/null +++ b/tests/engine/gen2_init_clock.lua @@ -0,0 +1,129 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2InitClock');local Clock=require('src.core.Gen2Clock') +local Stack=require('src.core.StateStack');local Box=require('src.render.TextBox');local Choice=require('src.ui.ChoiceBox') +local Timing=require('src.core.Timing');local Screens=require('src.ui.Screens') +local Json=require('src.link.Json');local Save=require('src.core.SaveData') +local function game() + local g={data=T.fixtures.fresh(),save={player={name='QA'},flags={},options={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end;g.input.isDown=function()return false end + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.data.text={OakTimeWokeUpText='QA opening page.\fQA next page.',OakTimeWhatTimeIsItText='QA choose hour.', + OakTimeHowManyMinutesText='QA choose minute.',OakTimeWhatHoursText='QA hour',OakTimeWhoaMinutesText='QA minute', + ['OakTimeWhatHoursText.OakTimeHoursQuestionMarkText']='?', + ['OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText']='?', + ['OakText_ResponseToSetTime.OakTimeSoDarkText']=' QA night response.', + ['SetDayOfWeek.OakTimeWhatDayIsItText']='QA choose weekday.',['SetDayOfWeek.OakTimeIsItText']=' QA confirm?'} + return g +end +local function tick(g,key,n) + g.input.queue=key and {[key]=true}or{} + for _=1,n or 1 do g.stack:update(1/60)end + g.input.queue={} +end +local function untilState(g,fn,label) + for _=1,2500 do if fn()then return end;tick(g,'a')end + error('native widgets did not reach '..label) +end +local function picker(g,screen,phase) + untilState(g,function()return g.stack:top()==screen and screen.phase==phase and not screen.fade end,phase) +end +local function choice(g) + untilState(g,function()return getmetatable(g.stack:top())==Choice end,'YES/NO over native question') + return g.stack:top() +end +local function answer(g,yes) + choice(g);tick(g,yes and 'a'or'b');tick(g,nil,Timing.YES_NO_ANSWER) +end +T.eq(require('src.ui.gen2.InitClock'),UI,'public reference spelling resolves actual native screen') +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local label=edition..' ' + T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'init_clock',label..' native speech opens with clock') + local g=game();local n=0;local parent={};g.stack:push(parent) + local ui=Screens.push(g,'Gen2InitClock',{fades=true,onDone=function()n=n+1;g.stack:pop()end}) + T.eq(getmetatable(ui),UI,label..' actual native screen factory used') + T.eq(Clock.isSet(g.save),false,label..' opening screen cannot set clock') + picker(g,ui,'hour') + T.eq(ui:question(),'QA choose hour.',label..' question uses imported native text') + T.eq(ui.hour,10,label..' initial picker has native default') + tick(g,'b');T.eq(g.stack:top(),ui,label..' B cannot abandon required picker') + for _=1,10 do tick(g,'down')end + tick(g,'down');T.eq(ui.hour,23,label..' hour wraps backwards through midnight') + tick(g,'up');T.eq(ui.hour,0,label..' hour wraps forward through midnight') + tick(g,'down');tick(g,'a');local c=choice(g) + T.eq(getmetatable(g.stack.states[#g.stack.states-1]),Box,label..' confirmation retains question under choice') + T.eq(Clock.isSet(g.save),false,label..' hour confirmation has not committed clock') + answer(g,false);T.eq(ui.phase,'hour',label..' declining returns to picker') + T.eq(ui.hour,23,label..' declining retains selected hour') + tick(g,'a');answer(g,true);picker(g,ui,'minute') + tick(g,'down');T.eq(ui.minute,59,label..' minute wraps backwards') + tick(g,'up');T.eq(ui.minute,0,label..' minute wraps forwards') + tick(g,'down');tick(g,'a');answer(g,false) + T.eq(ui.phase,'minute',label..' declining minutes returns without saving') + T.eq(Clock.isSet(g.save),false,label..' declining leaves save unchanged') + tick(g,'a');answer(g,true) + T.eq(ui.phase,'response',label..' both confirmed values are read back') + T.eq(ui:question():find('QA night response.',1,true)~=nil,true,label..' response uses imported native fragment') + T.eq(Clock.isSet(g.save),false,label..' response acknowledgement still owns commitment') + untilState(g,function()return n==1 end,'clock completion') + T.eq(g.stack:top(),parent,label..' completion restores exact parent') + T.eq(Clock.hour(g.save),23,label..' confirmed hour reaches native RTC') + T.eq(Clock.minute(g.save),59,label..' confirmed minute reaches native RTC') + local round=assert(Save.decode(Save.encode(g.save))) + T.eq(Clock.minute(round),59,label..' normal save codec preserves confirmed RTC') + ui:finish();ui:complete();T.eq(n,1,label..' duplicate finish cannot repeat callback') + -- Real special lowering and coroutine runner stay suspended through NO. + g=game();local Runner=require('src.script.ScriptRunner');local runner=Runner.new(g,{}) + local rows=assert(require('src.script.Gen2ScriptVM').compile({map_scripts={scripts={qa={ + {'special',37,'SetDayOfWeek'},{'end'}}}}},'qa')) + T.eq(#Runner.validate(rows),0,label..' native special lowers to valid production commands') + local completed=0;runner:run(rows,{onDone=function()completed=completed+1 end}) + local day=g.stack:top();T.eq(getmetatable(day),UI,label..' actual special opens shared native day mode') + T.eq(runner:isRunning(),true,label..' actual script waits for selection') + T.eq(day.day,0,label..' native day picker opens on Sunday') + tick(g,'down');T.eq(day.day,6,label..' weekday wraps backwards') + tick(g,'a');answer(g,false) + T.eq(runner:isRunning(),true,label..' NO cannot resume original script') + T.eq(g.save.rtc,nil,label..' NO cannot write weekday anchor') + tick(g,'up');T.eq(day.day,0,label..' weekday wraps forwards') + tick(g,'a');answer(g,true) + T.eq(Clock.weekday(g.save),0,label..' accepted special writes shared weekday') + T.eq(runner:isRunning(),false,label..' accepted special resumes real runner') + T.eq(completed,1,label..' real runner completes once') + day:finish();T.eq(completed,1,label..' repeated special finish cannot resume twice') + -- The production Oak step must yield to the same child and advance once. + g=game();local advanced=0;local speech={game=g,cfg={},advance=function()advanced=advanced+1 end} + g.stack:push(parent);require('src.ui.OakSpeech').runStep(speech,{kind='initclock'}) + local child=g.stack:top();T.eq(getmetatable(child),UI,label..' real Oak step opens native clock') + picker(g,child,'hour');tick(g,'a');answer(g,true);picker(g,child,'minute') + tick(g,'a');answer(g,true);untilState(g,function()return advanced==1 end,'Oak resumes') + T.eq(g.stack:top(),parent,label..' Oak clock completion preserves parent ownership') + T.eq(Clock.isSet(g.save),true,label..' actual Oak continuation follows committed RTC') + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/setclock/manifest.json']=Json.encode({id='setclock',name='Native clock UI client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/setclock/main.lua']=[[ + local mod=...;local Screen=require('src.ui.gen2.InitClock') + mod.exports.make=function(game)return Screen.new(game,{autoConfirm=true})end + ]]} + local loaded=T.sdk.loadMods({'mods/setclock'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,label..' actual loader admits native clock UI client') + local own=game();local screen=loaded.loader.exports.setclock.make(own) + T.eq(getmetatable(screen),UI,label..' loaded client uses native owner instead of a stub') + own.stack:push(screen) + untilState(own,function()return screen.done end,'loaded clock UI completion') + T.eq(Clock.isSet(own.save),true,label..' loaded UI client commits native RTC') + T.eq(Clock.hour(own.save),10,label..' loaded UI client keeps native default hour') + require('src.mods.Loader').teardown();loaded.release() + end +end +for _,edition in ipairs({'prism','polishedcrystal'})do + V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') +end +for _,hour in ipairs({0,3,4,10,11,17,18,23})do + local response=UI.responseKey(hour) + T.eq(response,(hour<4 or hour>=18)and'soDark'or(hour<=10 and'overslept'or'yikes'),'hour response boundary '..hour) +end +V.set(version);Clock.resetObservations() +T.finish('native initial clock/day widgets, original Oak admission and actual script continuation') From b2572b8aeb85cdb4a65e302fcd6b00bd0154a87a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:51:55 +0100 Subject: [PATCH 21/68] Register native clock fallback prompts for translation extraction --- src/ui/Gen2InitClock.lua | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index e98d9cc6c..8c5e9e7a2 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -8,6 +8,12 @@ local TextBox = require("src.render.TextBox") local InitClock = {} InitClock.__index = InitClock InitClock.isOpaque = true +local TEXT = { + intro=Strings.source("SET THE CLOCK"), hour=Strings.source("CHOOSE HOUR"), + minute=Strings.source("CHOOSE MINUTE"), day=Strings.source("CHOOSE DAY"), + confirmHour=Strings.source("CONFIRM HOUR"), confirmMinute=Strings.source("CONFIRM MINUTE"), + questionMark=Strings.source("?"), response=Strings.source("\nCLOCK SET"), +} local function value(n, fallback, limit) n = tonumber(n) if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end @@ -46,28 +52,28 @@ function InitClock.new(game, opts) end function InitClock:wantsFillScale() return self.mode ~= "day" end function InitClock:question() - if self.phase == "intro" then return text(self, "OakTimeWokeUpText", "SET THE CLOCK") end - if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", "CHOOSE HOUR") end - if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", "CHOOSE MINUTE") end - if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", "CHOOSE DAY") end + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", TEXT.intro) end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", TEXT.hour) end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end if self.phase == "confirm-hour" then - return text(self, "OakTimeWhatHoursText", "CONFIRM HOUR") .. "\n" + return text(self, "OakTimeWhatHoursText", TEXT.confirmHour) .. "\n" .. InitClock.oclockString(self.hour) - .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", "?") + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) end if self.phase == "confirm-minute" then - return text(self, "OakTimeWhoaMinutesText", "CONFIRM MINUTE") .. "\n" + return text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute) .. "\n" .. Strings("%d min.", self.minute) - .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", "?") + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) end if self.phase == "confirm-day" then return Clock.weekdayName(self.day + 1) - .. text(self, "SetDayOfWeek.OakTimeIsItText", "?") + .. text(self, "SetDayOfWeek.OakTimeIsItText", TEXT.questionMark) end local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } if self.phase == "response" then return InitClock.timeString(self.hour, self.minute) - .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], "\nCLOCK SET") + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], TEXT.response) end return "" end From 5fb4cc1da535b94e235925cbdc470f3d832ae6ef Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:56:58 +0100 Subject: [PATCH 22/68] Keep native clock text separators outside translatable prompts --- src/ui/Gen2InitClock.lua | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index 8c5e9e7a2..6b89d1a86 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -57,14 +57,16 @@ function InitClock:question() if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end if self.phase == "confirm-hour" then - return text(self, "OakTimeWhatHoursText", TEXT.confirmHour) .. "\n" - .. InitClock.oclockString(self.hour) - .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) + local lines = { text(self, "OakTimeWhatHoursText", TEXT.confirmHour), + InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") end if self.phase == "confirm-minute" then - return text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute) .. "\n" - .. Strings("%d min.", self.minute) - .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) + local lines = { text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute), + Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") end if self.phase == "confirm-day" then return Clock.weekdayName(self.day + 1) From d4f6aa5043a2409abacccc46ff7e52b4c03f2fad Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:06:59 +0100 Subject: [PATCH 23/68] Preserve other editions intro music and build-hook ordering --- src/ui/OakSpeech.lua | 6 +++++- tests/engine/gen2_init_clock.lua | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index d2bcda421..87fda58a2 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -494,10 +494,14 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() + local version = GameVersion.get() + local clockIntro = version == "gold" or version == "silver" or version == "crystal" + -- Other introductions retain their original music-before-build ordering. + if not clockIntro then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end self.answers = {} self.steps = self:buildSteps() -- The retail clock comes before the professor and his music. - if not (self.steps[1] and self.steps[1].kind == "initclock") then + if clockIntro and not (self.steps[1] and self.steps[1].kind == "initclock") then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end if Runtime.wants("intro.oak_speech.started") then diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua index c8ebbbe0d..15e36e5cb 100644 --- a/tests/engine/gen2_init_clock.lua +++ b/tests/engine/gen2_init_clock.lua @@ -118,6 +118,25 @@ for _,edition in ipairs({'gold','silver','crystal'})do require('src.mods.Loader').teardown();loaded.release() end end +-- The shared Oak class also serves other editions. Preserve their existing +-- music-before-build contract rather than changing Gen1 while adding GSC. +local Music=require('src.core.Music');local play=Music.play;local order +Music.play=function(...)order[#order+1]='music';return play(...)end +for _,edition in ipairs({'red','blue','yellow','prism','polishedcrystal','gold','silver','crystal'})do + V.set(edition);order={} + local native=edition=='gold'or edition=='silver'or edition=='crystal' + local speech={game=game(),cfg={},buildSteps=function() + order[#order+1]='build';return {{kind=native and'initclock'or'say'}} + end,advance=function()order[#order+1]='advance'end} + require('src.ui.OakSpeech').enter(speech) + T.same(order,native and{'build','advance'}or{'music','build','advance'},edition..' preserves appropriate native music/build ordering') + if native then + order={};speech.buildSteps=function()order[#order+1]='build';return {{kind='say'}}end + require('src.ui.OakSpeech').enter(speech) + T.same(order,{'build','music','advance'},edition..' explicit clock removal still starts professor music') + end +end +Music.play=play for _,edition in ipairs({'prism','polishedcrystal'})do V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') end From ae0384d3088d39b1198e0d11daba9fa87c6bf0fc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:08:32 +0100 Subject: [PATCH 24/68] Keep clock picker hints inside their panel and hide inactive controls --- src/ui/Gen2InitClock.lua | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index 6b89d1a86..bf37c8436 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -159,18 +159,19 @@ end function InitClock:sgbPalettes() local P = require("src.render.PaletteFX") if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end - return { P.trueColorZone(1, 4, 18, 9), P.trueColorZone(0, 12, 19, 17) } + return { P.trueColorZone(1, 4, 18, 11), P.trueColorZone(0, 12, 19, 17) } end function InitClock:draw() local G = love.graphics G.setColor(1, 1, 1, 1) if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end - Font.drawBox(1, 4, 18, 6) - local shown = self:display() or InitClock.timeString(self.hour, self.minute) + Font.drawBox(1, 4, 18, 8) + local picker = self:display() + local shown = picker or InitClock.timeString(self.hour, self.minute) Font.draw(shown, 16, 48) - Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) - Font.draw(Strings("A: CONFIRM"), 16, 80) - if self:display() then + if picker then + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) Font.drawBox(0, 12, 20, 6) Font.draw(self:question(), 8, 105) end From ef434b2b68cb24e95a2d61c95283c82811087fc9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:23:59 +0100 Subject: [PATCH 25/68] Add native Gen2 shared menu chrome with native palette/font ownership --- src/core/ModCompat.lua | 2 +- src/render/PaletteFX.lua | 6 +- src/ui/Gen2Chrome.lua | 664 ++++++++++++++++++++++++++++++ tests/engine/gen2_menu_chrome.lua | 79 ++++ 4 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/ui/Gen2Chrome.lua create mode 100644 tests/engine/gen2_menu_chrome.lua diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..5b234b263 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -32,7 +32,6 @@ -- hook, not one module -- src.world.gen2.Follower PikachuFollower is Yellow's, not a general -- party follower --- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent @@ -80,6 +79,7 @@ ModCompat.ALIASES = { ["src.battle.gen2.Effects"] = "src.battle.MoveEffects", ["src.battle.gen2.Mon"] = "src.pokemon.Pokemon", ["src.ui.gen2.BattleState"] = "src.battle.BattleState", + ["src.ui.gen2.Chrome"] = "src.ui.Gen2Chrome", -- menus. The Gold port names two of these differently rather than -- namespacing them, so both spellings land on the one implementation. diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..4bdfe5f67 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -1155,7 +1155,7 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.effectiveColors(c, invert) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c @@ -1176,6 +1176,10 @@ function PaletteFX.effectiveColors(c) -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too -- (#322). permute() is the identity (and returns `out` itself, which the -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end return PaletteFX.permute(out, shadeMap) end diff --git a/src/ui/Gen2Chrome.lua b/src/ui/Gen2Chrome.lua new file mode 100644 index 000000000..df557ba62 --- /dev/null +++ b/src/ui/Gen2Chrome.lua @@ -0,0 +1,664 @@ +-- Ported from Gen1Recomp 535a5ec0; palette, font and DPI use this engine. +-- Shared Gen 2 menu chrome: boxes, tile-grid text, and the scrolling cursor +-- list that nearly every Gold screen is built out of. +-- +-- Gold draws its menus through one routine (engine/menus/menu.asm SetUpMenu + +-- GetScrollingMenuJoypad), so the behaviour every screen inherits -- wrap at +-- the ends, a ▶ cursor in a one-tile gutter, B to back out, START as an +-- accept on some menus -- lives here once instead of in each screen. +-- +-- Everything is in 8px tile coordinates on the 20x18 GB grid, because that is +-- how the ASM addresses the screen (hlcoord x, y) and it makes a layout +-- transcribed from pokegold land on the same pixels. + +local Font = require("src.render.Font") +local PaletteFX = require("src.render.PaletteFX") +-- A view of the native palette renderer, with no extra mode or BGP state. +local Colors = {} +function Colors.available() return PaletteFX.shader() ~= nil end +function Colors.resolve(colors) return PaletteFX.effectiveColors(colors) end +function Colors.useRaw(colors) + local shader = PaletteFX.shader() + if not shader then return end + for i = 1, 4 do + local c = colors[i] + shader:send("c" .. (i - 1), { c[1] / 255, c[2] / 255, c[3] / 255 }) + end + love.graphics.setShader(shader) +end +function Colors.use(colors) Colors.useRaw(PaletteFX.effectiveColors(colors)) end + +local function drawGlyphCode(code, x, y, ink) + if code >= 0 then return Font.drawCode(code, x, y) end + -- This engine's UnicodeFont encodes rasterized glyphs as negative codes. + ink = ink or { 0, 0, 0 } + Font.pushStyle({ text = { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 } }) + local ok, err = pcall(Font.drawCode, code, x, y) + Font.popStyle() + if not ok then error(err, 0) end +end + +local WHITE = { 255, 255, 255 } +local BLACK = { 0, 0, 0 } + +local Chrome = {} + +-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the +-- text-advance arrow. +Chrome.CURSOR = 0xED +Chrome.CURSOR_HOLLOW = 0xEC +Chrome.DOWN_ARROW = 0xEE + +Chrome.SCREEN_W = 20 +Chrome.SCREEN_H = 18 + +-- charmap.asm "¥", the money field's own prefix tile. +local YEN = "\xc2\xa5" + +function Chrome.paletteFill(px, py, pw, ph, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + local G = love.graphics + G.setColor(1, 1, 1, 1) + if Colors.available() then + -- Colors.with without the closure: set, draw, restore. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + G.rectangle("fill", px, py, pw, ph) + G.setShader(previous) + else + G.rectangle("fill", px, py, pw, ph) + end + G.setColor(0, 0, 0, 1) +end + +-- Every drawWidescreen paints the window around its 160x144 panel through +-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the +-- screen was authored with and is what AUTO keeps. +Chrome.worldSurround = false + +function Chrome.letterbox(winW, winH, r, g, b) + if Chrome.worldSurround then return end + love.graphics.setColor(r or 1, g or 1, b or 1, 1) + love.graphics.rectangle("fill", 0, 0, winW, winH) +end + +function Chrome.clear() + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + +-- The blit scale every `drawWidescreen` paints its 160x144 panel at. +-- +-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid +-- everything here is laid out on stops landing on tile boundaries: at a +-- fractional scale some rows of a tile get one device pixel and their +-- neighbours get two, which is what breaks a box border into steps and eats +-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua +-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen +-- paints still fills the window, the PANEL is what stays on the grid. +local function playfieldRect(winW, winH) + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, x, y, w, h = pcall(Playfield.rect, winW, winH) + if okv and w and w >= 1 and h and h >= 1 then + return x, y, w, h + end + end + return 0, 0, winW or 0, winH or 0 +end + +Chrome.playfieldRect = playfieldRect + +local function pixelRatio() + local get = love.graphics.getDPIScale or (love.window and love.window.getDPIScale) + local d = get and get() or 1 + return type(d) == "number" and d > 0 and d or 1 +end + +Chrome.pixelRatio = pixelRatio + +function Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local _, _, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return math.max(1, math.floor(math.min(w * d / (tilesW * 8), + h * d / (tilesH * 8)) + 1e-6)) / d +end + +function Chrome.fitOriginFor(winW, winH, scale, tilesW, tilesH) + scale = scale or Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local x, y, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return x + math.floor((w - tilesW * 8 * scale) * d / 2 + 1e-6) / d, + y + math.floor((h - tilesH * 8 * scale) * d / 2 + 1e-6) / d + - Chrome.positionLift(winW, winH, scale) +end + +function Chrome.fitScale(winW, winH) + return Chrome.fitScaleFor(winW, winH, Chrome.SCREEN_W, Chrome.SCREEN_H) +end + +-- The centred origin that goes with it, so a caller does not re-derive it. +function Chrome.fitOrigin(winW, winH, scale) + scale = scale or Chrome.fitScale(winW, winH) + return Chrome.fitOriginFor(winW, winH, scale, Chrome.SCREEN_W, + Chrome.SCREEN_H) +end + +function Chrome.positionLift(winW, winH, scale) + local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition") + if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end + local _, _, _, h = playfieldRect(winW, winH) + local d = pixelRatio() + return ScreenPosition.lift(h * d, Chrome.SCREEN_H * 8 * (scale + or Chrome.fitScale(winW, winH)) * d, ScreenPosition.safeTop() * d) / d +end + +-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270 +local function clipTo(x, y, w, h) + local G = love.graphics + if G.transformPoint then + local x1, y1 = G.transformPoint(x, y) + local x2, y2 = G.transformPoint(x + w, y + h) + x, y = math.floor(math.min(x1, x2)), math.floor(math.min(y1, y2)) + w, h = math.ceil(math.abs(x2 - x1)), math.ceil(math.abs(y2 - y1)) + end + if G.intersectScissor then G.intersectScissor(x, y, w, h) + else G.setScissor(x, y, w, h) end +end + +Chrome.clipTo = clipTo + +function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) + local G = love.graphics + Chrome.letterbox(winW, winH, r, g, b) + scale = scale or Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push("all") + clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale) + G.translate(ox, oy) + G.scale(scale, scale) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +function Chrome.withClip(drawFn) + local G = love.graphics + G.push("all") + clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +Chrome.DEFAULT_BOX_PALETTE = { + { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, +} + +function Chrome.paletteBox(tx, ty, tw, th, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + if Colors.available() then + local G = love.graphics + G.setColor(1, 1, 1, 1) + -- Colors.with, restoring the caller's shader even if the box throws. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + local ok, err = pcall(Font.drawBox, tx, ty, tw, th) + G.setShader(previous) + if not ok then error(err, 0) end + else + Font.drawBox(tx, ty, tw, th) + end + love.graphics.setColor(0, 0, 0, 1) +end + +-- A bordered box, tile coords. Leaves the draw color black for text. +function Chrome.box(tx, ty, tw, th) + Chrome.paletteBox(tx, ty, tw, th) +end + +-- Gold's Textbox helper takes an interior width/height and draws the border +-- around it; b/c in the ASM are interior rows/columns, so a `lb bc, 4, 13` +-- box is 6 rows by 15 columns on screen. +function Chrome.textbox(tx, ty, interiorW, interiorH) + Chrome.box(tx, ty, interiorW + 2, interiorH + 2) +end + +local function flatPrint(text, tx, ty) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, tx * 8, ty * 8) +end + +function Chrome.print(text, tx, ty) + if not Colors.available() then return flatPrint(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +-- Prints a string the way a tilemap screen does: the glyph tiles replace the +-- cells they land on, and both their ink and their blank pixels go through +-- that screen's own BG palette. +-- +-- The font sheets here are black ink (shade 3) on transparent, and setColor +-- multiplies -- black times anything is still black -- so the ink colour +-- cannot come from a tint. Drawing through GbcPalette maps shade 3 to the +-- palette's colour 3, and painting colour 0 behind the string first supplies +-- the cell background the sheet does not carry. Without a shader this +-- degrades to the ordinary black print rather than drawing nothing. +-- +-- The palette printThrough actually draws with: COLOR mode resolved, +-- inverted if asked, and the active rBGP byte folded in last -- the same +-- order Colors.use follows. Split out so the fold can be checked +-- without a real shader (Colors.available() is false in a driverless +-- test harness, which used to hide this from every suite). +-- +-- The substitution has to happen before the reversal or an inverted string +-- would come back through an un-reversed grey ramp and print black on white, +-- and the rBGP fold has to come after both: it REORDERS the four entries +-- this palette already settled on (colour 0 in the low bits, so +-- `dc 3,2,1,0` packs to $e4), it does not tint them, so folding it before +-- the invert reversal would permute the wrong four colours. +function Chrome.throughPalette(palette, invert) + return PaletteFX.effectiveColors(palette, invert) +end + +function Chrome.rawPalette(palette, invert) + if invert then return { palette[4], palette[3], palette[2], palette[1] } end + return palette +end + +-- The per-glyph shader/tint switch printThrough needs, factored out so a +-- caller that positions its own glyphs (TextBox's typewriter/scroll +-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile +-- split. +-- +-- Returns (pal, drawGlyph, finish): +-- pal the resolved ramp, or nil if there's no shader to draw +-- through; a caller that also draws a paper rect should skip +-- it and take the plain Chrome.print degrade instead. +-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a +-- tile glyph or tinted with pal[4] for a TTF one (see the +-- inline comment below, gen1recomp#1642). +-- finish call once after the last glyph to restore the previous +-- shader and draw colour. +function Chrome.paletteGlyphs(palette, invert, raw) + if not (palette and Colors.available()) then + return nil, Font.drawCode, function() end + end + -- `pal` is used below and never touches the caller's palette again -- + -- including useRaw for the draw, since useRaw skips the fold Colors.use + -- would otherwise apply and folding it a second time would undo this. + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) + local ink = pal[4] or { 0, 0, 0 } + local previous = love.graphics.getShader() + local shaded = false + local function drawGlyph(code, x, y) + if code < 0 then + -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not. + -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain + -- white texture, which this shader reads back as shade 0 no matter how + -- solid the glyph looks, painting the character the SAME colour as the + -- paper rect just drawn above it: invisible (reported against a real + -- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF + -- glyph has no discrete shade to recover in the first place, so skip + -- the shader for it and tint it with the palette's own ink colour + -- (shade 3, the same entry `rgb = pal3` would have mapped a black tile + -- pixel to) directly. + if shaded then love.graphics.setShader(previous); shaded = false end + love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + love.graphics.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, x, y, ink) + end + local function finish() + if shaded then love.graphics.setShader(previous) end + love.graphics.setColor(0, 0, 0, 1) + end + return pal, drawGlyph, finish +end + +-- printThrough's resolved palette, or nil when it has to take the plain +-- degrade: paletteGlyphs' own test and fold, without its two closures. +local function glyphPalette(palette, invert, raw) + if not (palette and Colors.available()) then return nil end + return raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) +end + +-- The string's glyph codes and its width, off one encode: the width is the +-- sum of the same advances the pen walks, which is all Font.width is. +local function measure(text) + local codes = Font.encode(text) + local width = 0 + for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end + return codes, width +end + +-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and +-- finish inlined (see there for the TTF split, gen1recomp#1642). +local function drawThrough(codes, width, x, y, pal) + local G = love.graphics + local paper = pal[1] or WHITE + local ink = pal[4] or BLACK + local previous = G.getShader() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", x, y, width, 8) + local shaded = false + local pen = x + for i = 1, #codes do + local code = codes[i] + if code < 0 then + if shaded then G.setShader(previous); shaded = false end + G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + G.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, pen, y, ink) + pen = pen + Font.advanceOf(code) + end + if shaded then G.setShader(previous) end + G.setColor(0, 0, 0, 1) +end + +function Chrome.printThrough(text, tx, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.print(text, tx, ty) end + local codes, width = measure(text) + drawThrough(codes, width, tx * 8, ty * 8, pal) + return width +end + +-- The same, through an *inverted* font page. Pokedex_LoadInvertedFont xors +-- both bitplanes of the standard font, so a glyph pixel of shade s becomes +-- shade 3 - s before the palette sees it -- which is exactly the palette read +-- backwards, and is what makes the dex white on black. +function Chrome.printInverted(text, tx, ty, palette) + if not palette then return Chrome.print(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, palette, true) +end + +-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is +-- how Gold prints numbers (PrintNum fills from the right). +local function flatPrintRight(text, txEnd, ty) + local width = Font.width(text) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, txEnd * 8 - width, ty * 8) +end + +function Chrome.printRight(text, txEnd, ty) + if not Colors.available() then return flatPrintRight(text, txEnd, ty) end + return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.printRight(text, txEnd, ty) end + local codes, width = measure(text) + drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal) + return width +end + +-- Wrap text to `width` tiles, measuring with the real font so a proportional +-- page wraps where it actually overflows. Nothing on a 160px screen may print +-- past tile 20: text that does is drawn outside the GB frame entirely. +-- A "\n" in the text is the cart's OWN line break (data/text's `line` / `next` +-- control byte, e.g. "\nused !"), so it is a HARD break: each +-- segment wraps on its own and the break survives, rather than being eaten by +-- the %S+ tokenizer and re-wrapped by width. +function Chrome.wrap(text, width) + local budget = (width or Chrome.SCREEN_W) * 8 + local lines = {} + for segment in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do + local line = nil + for word in segment:gmatch("%S+") do + local candidate = line and (line .. " " .. word) or word + if line and Font.width(candidate) > budget then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line then lines[#lines + 1] = line end + end + return lines +end + +-- Print wrapped text from (tx, ty) downward, at most `rows` lines. +-- ../pokecrystal/home/text.asm:473 +function Chrome.printWrapped(text, tx, ty, width, rows, step, palette) + step = step or 1 + local lines = Chrome.wrap(text, width) + for i = 1, math.min(#lines, rows or #lines) do + if palette then + Chrome.printThrough(lines[i], tx, ty + (i - 1) * step, palette) + else + Chrome.print(lines[i], tx, ty + (i - 1) * step) + end + end + return #lines +end + +local function flatCursor(tx, ty, hollow) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) +end + +function Chrome.cursor(tx, ty, hollow) + if not Colors.available() then return flatCursor(tx, ty, hollow) end + return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow) +end + +-- The cursor glyph through a palette, the way Chrome.printThrough draws text. +-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont +-- xors both bitplanes, so it runs white on black) needs its cursor inverted +-- with everything else -- Chrome.cursor's flat black is invisible against that +-- ground, which is what made the dex's action arrow look absent. +function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw) + if not (palette and Colors.available()) then + return Chrome.cursor(tx, ty, hollow) + end + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8) + love.graphics.setColor(1, 1, 1, 1) + local previous = love.graphics.getShader() + Colors.useRaw(pal) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) + love.graphics.setShader(previous) + love.graphics.setColor(0, 0, 0, 1) +end + +-- Gold pads a numeric field with spaces, not zeroes, unless +-- PRINTNUM_LEADINGZEROS is set. +function Chrome.number(value, width, leadingZeros) + local text = tostring(math.floor(value or 0)) + local pad = math.max(0, (width or 0) - #text) + return (leadingZeros and ("0"):rep(pad) or (" "):rep(pad)) .. text +end + +-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS +-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST +-- significant digit rather than at a fixed column, and the field is six digits +-- wide, so the string is always seven tiles and the yen sign floats. +function Chrome.money(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return (" "):rep(first - 1) .. YEN .. digits:sub(first) +end + +-- ------------------------------------------------------- balance boxes +-- +-- engine/menus/menu_2.asm. These three are the boxes a SCRIPT puts up with a +-- `special` and then leaves standing: every one of them is immediately +-- followed by `loadmenu`, so the box is on screen for the whole of the static +-- menu that answers. They live here rather than in one menu module because +-- three different screens print them (the Game Corner prize counters and the +-- coin vendor through src/ui/gen2/ScriptMenu.lua, the prize counter screen +-- itself) and all three are laid out by the same two routines. +-- +-- DisplayCoinCaseBalance: Textbox at (11,0) with a 7x1 interior, "COIN" at +-- (12,0) -- yes, in the border row -- and the four-digit count at (13,1) with +-- PRINTNUM_LEADINGZEROS. +function Chrome.coinBalanceBox(coins) + Chrome.textbox(11, 0, 7, 1) + Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE) +end + +-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior +-- holding both fields, "MONEY" at (6,1) with the yen field at (12,1) and +-- "COIN" at (6,3) with the four-digit count at (15,3). +function Chrome.moneyAndCoinBalanceBox(money, coins) + Chrome.textbox(5, 0, 13, 3) + Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE) +end + +-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0, +-- SCREEN_WIDTH - 1, 2`, a MenuBox rather than a Textbox, and PlaceMoneyTextbox +-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1). +function Chrome.moneyBalanceBox(money) + Chrome.box(11, 0, 9, 3) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) +end + +-- A vertical cursor list. +-- +-- opts: +-- items array of strings, or of { label = , value = , disabled = } +-- x, y tile coords of the first row's *label* (cursor sits at x - 1) +-- spacing tile rows between entries (Gold uses 2 for most menus) +-- rows visible rows; a longer list scrolls (default: all) +-- wrap wrap past the ends (STATICMENU_WRAP) +-- startAccepts treat START like A (STATICMENU_ENABLE_START) +-- onChoose(value, index) +-- onCancel() +-- onMove(value, index) -- for the start menu's description box +local List = {} +List.__index = List +Chrome.List = List + +function List.new(opts) + local self = setmetatable({}, List) + self.items = {} + for i, entry in ipairs(opts.items or {}) do + if type(entry) == "table" then + self.items[i] = entry + else + self.items[i] = { label = tostring(entry), value = entry } + end + end + self.x = opts.x or 1 + self.y = opts.y or 1 + self.spacing = opts.spacing or 2 + self.rows = math.min(opts.rows or #self.items, #self.items) + self.wrap = opts.wrap ~= false + self.startAccepts = opts.startAccepts or false + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + self.onMove = opts.onMove + self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette + self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items))) + self.scroll = 0 + self:ensureVisible() + return self +end + +function List:current() + return self.items[self.index] +end + +function List:ensureVisible() + if self.rows <= 0 then return end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + self.rows then + self.scroll = self.index - self.rows + end + self.scroll = math.max(0, math.min(self.scroll, #self.items - self.rows)) +end + +function List:move(delta) + if #self.items == 0 then return end + local next_ = self.index + delta + if next_ < 1 then + if not self.wrap then return end + next_ = #self.items + elseif next_ > #self.items then + if not self.wrap then return end + next_ = 1 + end + self.index = next_ + self:ensureVisible() + if self.onMove then self.onMove(self:current() and self:current().value, self.index) end +end + +-- Returns true when the press was consumed, so a screen can layer its own +-- handling (left/right on the options rows) behind this. +function List:update(input) + if not input then return false end + if input:wasPressed("up") then + self:move(-1) + return true + elseif input:wasPressed("down") then + self:move(1) + return true + elseif input:wasPressed("a") + or (self.startAccepts and input:wasPressed("start")) then + local item = self:current() + if item and not item.disabled and self.onChoose then + self.onChoose(item.value, self.index) + end + return true + elseif input:wasPressed("b") then + if self.onCancel then self.onCancel() end + return true + end + return false +end + +function List:draw() + for row = 1, self.rows do + local i = row + self.scroll + local item = self.items[i] + if item then + local ty = self.y + (row - 1) * self.spacing + if i == self.index then + if self.palette then + Chrome.cursorThrough(self.x - 1, ty, self.palette) + else + flatCursor(self.x - 1, ty) + end + end + if self.palette then + Chrome.printThrough(item.label, self.x, ty, self.palette) + else + flatPrint(item.label, self.x, ty) + end + end + end + -- Scrolling lists get the ▼ hint Gold shows when there is more below. + if self.rows < #self.items and self.scroll + self.rows < #self.items then + if self.palette then + local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette) + drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + finish() + else + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + end + end +end + +return Chrome diff --git a/tests/engine/gen2_menu_chrome.lua b/tests/engine/gen2_menu_chrome.lua new file mode 100644 index 000000000..f1fd4be61 --- /dev/null +++ b/tests/engine/gen2_menu_chrome.lua @@ -0,0 +1,79 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local P = require('src.render.PaletteFX') +local Font = require('src.render.Font') +local V = require('src.core.GameVersion') +local C = require('src.ui.Gen2Chrome') +local before = {mode=P.mode, shade=P.shadeMap(), version=V.get()} +require('src.core.ModCompat').install() +T.eq(require('src.ui.gen2.Chrome'),C,'public Gen2 name resolves actual native menu renderer') +Font.load(T.fixtures.fresh()) +local ramp={{250,240,230},{180,170,160},{100,90,80},{20,10,0}} +local map={[0]=1,[1]=3,[2]=0,[3]=2} +P.setShadeMap(map) +for _,version in ipairs({'gold','silver','crystal','red','firered','emerald'})do + V.set(version) + for _,mode in ipairs({'gbc','gbc_inv','og','og_inv','classic'})do + P.setMode(mode) + T.same(C.throughPalette(ramp),P.effectiveColors(ramp),'menu uses actual native mode/register '..version..'/'..mode) + -- Inversion belongs BEFORE the noncommuting shade map. Compare against + -- the native resolved display ramp with the shade register disarmed. + P.setShadeMap(nil);local settled=P.effectiveColors(ramp);P.setShadeMap(map) + local reversed={settled[4],settled[3],settled[2],settled[1]} + T.same(C.throughPalette(ramp,true),P.permute(reversed,map),'inverted text then hardware shade register '..version..'/'..mode) + T.same(C.rawPalette(ramp,true),{ramp[4],ramp[3],ramp[2],ramp[1]},'raw palette skips native display substitutions') + end +end +P.setShadeMap(nil);P.setMode('gbc');V.set('crystal') +local dpi=love.graphics.getDPIScale +for _,d in ipairs({1,1.5,2})do + love.graphics.getDPIScale=function()return d end -- output metrics only + local scale=C.fitScale(801,601) + T.eq(scale*d,math.floor(scale*d),'native panel pixels land on framebuffer grid') + local x,y=C.fitOrigin(801,601,scale) + T.check(x>=0 and y>=0 and x+160*scale<=801 and y+144*scale<=601,'native panel fits its actual viewport') +end +love.graphics.getDPIScale=dpi +T.eq(C.number(23,4,false),' 23','native padded number') +T.eq(C.number(23,4,true),'0023','native zero-filled number') +T.eq(C.money(23),' ¥23','native floating yen field') +T.same(C.wrap('ONE TWO\nTHREE',7),{'ONE TWO','THREE'},'cart hard line break retained') +local chosen,cancel,moved +local list=C.List.new({items={{label='ONE',value=1},{label='TWO',value=2,disabled=true},{label='THREE',value=3}},rows=2,onChoose=function(v)chosen=v end,onCancel=function()cancel=true end,onMove=function(v)moved=v end}) +local input=require('src.core.Input');input:init() +local function tap(key)input:keypressed(key);input:step();local out=list:update(input);input:keyreleased(key);input:step();return out end +T.check(tap('down'),'real native down edge consumed') +T.eq(list.index,2,'list moves once per native edge');T.eq(moved,2,'movement callback follows actual item') +tap('z');T.eq(chosen,nil,'disabled entry cannot be selected') +tap('down');T.eq(list.scroll,1,'native list scrolls into actual item') +tap('z');T.eq(chosen,3,'real native A selects current item') +tap('down');T.eq(list.index,1,'native list wraps at end') +tap('x');T.eq(cancel,true,'real native B cancels list') + +-- Real native box and font calls, with only the output APIs recorded. +local draw,rect=love.graphics.draw,love.graphics.rectangle +local drawn,filled=0,0 +love.graphics.draw=function(...)drawn=drawn+1;return draw(...)end +love.graphics.rectangle=function(...)filled=filled+1;return rect(...)end +C.clear();C.textbox(0,12,18,4);C.print('ONE',1,13);C.cursor(1,1);list:draw() +T.check(drawn>0 and filled>0,'native chrome actually renders font, box and paper') +local shader={tag='caller'};love.graphics.setShader(shader) +T.raises(function()C.withPanel(800,600,1,1,1,function()love.graphics.setShader({});error('panel body probe')end)end,'panel body probe','panel propagates actual draw failure') +T.eq(love.graphics.getShader(),shader,'panel restores native graphics state on failure') +love.graphics.setShader();love.graphics.draw,love.graphics.rectangle=draw,rect +-- Exercise the shaded path too; only the GPU uniform sink is replaced. +local nativeShader=P.shader +local uniforms={} +local output={send=function(_,key,value)uniforms[key]=value end} +P.shader=function()return output end +P.setShadeMap(map) +local caller={tag='native caller'};love.graphics.setShader(caller) +local expected=P.effectiveColors(ramp,true) +C.printThrough('ONE',1,1,ramp,true) +T.same(uniforms.c0,{expected[1][1]/255,expected[1][2]/255,expected[1][3]/255},'glyph shader receives once-resolved native paper') +T.same(uniforms.c3,{expected[4][1]/255,expected[4][2]/255,expected[4][3]/255},'glyph shader receives once-resolved native ink') +T.eq(love.graphics.getShader(),caller,'palette text restores actual caller shader') +T.eq(P.shadeMap(),map,'chrome does not replace native shade register') +P.shader=nativeShader;love.graphics.setShader() +P.setMode(before.mode);P.setShadeMap(before.shade);V.set(before.version) +T.finish('native Gen2 shared menu chrome') From 72faf08f588a1bd2303b8c065d53d7b78b1cb2af Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:08:14 +0100 Subject: [PATCH 26/68] Bind Gen2 name entry to its native keyboard and class owner --- docs/native-gen2-naming.md | 41 ++++ src/core/ModCompat.lua | 1 + src/render/Font.lua | 10 + src/render/Gen2PaletteDraw.lua | 126 ++++++++++++ src/ui/Gen2NamingScreen.lua | 307 ++++++++++++++++++++++------ src/ui/NamingScreen.lua | 3 + tests/engine/gen2_mod_naming.lua | 12 +- tests/engine/gen2_native_naming.lua | 93 +++++++++ 8 files changed, 531 insertions(+), 62 deletions(-) create mode 100644 docs/native-gen2-naming.md create mode 100644 src/render/Gen2PaletteDraw.lua create mode 100644 tests/engine/gen2_native_naming.lua diff --git a/docs/native-gen2-naming.md b/docs/native-gen2-naming.md new file mode 100644 index 000000000..0c7f9ee42 --- /dev/null +++ b/docs/native-gen2-naming.md @@ -0,0 +1,41 @@ +# Native Gen2 naming owner + +`Gen2NamingScreen` now owns Gen2 entry state, input and presentation rather +than adapting the Gen1 keyboard. The zero-based cursor addresses four letter +rows (five for a box) and a bottom row of CASE, DEL and END targets. SELECT +changes case, B deletes a complete font glyph, START selects END, and A on END +accepts. Filling the field also selects END and waits for confirmation. Spaces +are actual cells. Unicode and charmap ligatures occupy complete name slots. + +The public constructor preserves caller-owned dismissal. The ordinary +`NamingScreen` constructor delegates to this same native owner on Gen2 while +preserving its existing pop-before-callback convention. Native scripts, +catches and Oak therefore use the real Gen2 model. Other generations retain +their existing constructors. Screen registry overrides remain authoritative. +The `src.ui.gen2.NamingScreen` class spelling resolves to this owner, so external +presenters can observe and draw its actual rows, cursor, text and field size. + +Name presets temporarily make the naming screen transparent and suppress its +keyboard until NEW NAME is chosen; the original parent remains underneath. +Native imported sprite and optional menu-art records can supply the header and +backdrop. Default board data is an engine fallback; a `data.gen2Naming` record +can supply normal/box upper/lower rows. `ui.naming.grid` retains its public +context and can replace those rows. + +Font aliases for ``, `` and `` resolve only to sequences actually +present in the active font. They neither assume retail glyph ordinals nor +replace explicitly authored aliases. Measurements, editing and drawing use +that same charmap. + +ROM-free tests cover actual stack/menu input, name types and glyph limits, +caller/native completion ownership, preset underlay suppression, Gen1 +retention and actual Loader clients through native API1/2. The own-fork composition additionally tests unified API2/3. +GPU acceptance and full reference raster/helper parity remain separate: +ROM naming-program/menu-art extraction, exact cursor/header rendering and the +separate Gen2 NamePick portrait animation are still open. This change does not +establish complete VASC, naming-story, save/link or full Gen1 parity. + +This standalone offer includes the shared native menu chrome already offered +in PR #224 and the indexed palette drawer already offered in PR #202. It +requires neither of those PR branches to have been merged. Original Gen1 +renderer provenance and applicable existing rights remain intact. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 5b234b263..10f1c6fb2 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -66,6 +66,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.ui.gen2.NamingScreen"] = "src.ui.Gen2NamingScreen", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/render/Font.lua b/src/render/Font.lua index 30b2e4a78..8733aeac8 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -194,6 +194,16 @@ function Font.load(data) end end + -- Legacy screen cells spell ligatures with angle brackets. Bind only + -- to a sequence the active font actually owns, so relocated hack glyphs + -- and explicitly authored bracket spellings retain their authority. + for token, native in pairs({[""]="PK",[""]="MN",[""]="."}) do + if not seqCode(token) then + local code=seqCode(native) + if code then bucket({seq=token,code=code})end + end + end + for _, entries in pairs(state.byFirstByte) do table.sort(entries, function(a, b) return #a.seq > #b.seq end) end diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua new file mode 100644 index 000000000..030785e4d --- /dev/null +++ b/src/render/Gen2PaletteDraw.lua @@ -0,0 +1,126 @@ +-- Original contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Indexed image drawing, separate from the LCD post-process in GBCFX. +-- Palette resolution uses the native display/register state in PaletteFX. +local Palettes = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") +local M = {} +-- Consumers use this view to leave already-coloured pictures untouched. +-- Gen2 currently resolves native ROM colours regardless of the Gen1 mode. +-- Do not invent a second mutable display setting for the external renderer. +setmetatable(M, {__index = function(_, key) + if key == "mode" then + if Version.isGen2() then return "gbc" end + if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end + return Palettes.mode == "classic" and "classic" or "gbc" + end +end}) +local compiler, shaders, failed = nil, {}, {} +local sent = setmetatable({}, {__mode = "k"}) +local names = {"pal0", "pal1", "pal2", "pal3"} +local source = [[ +extern vec3 pal0; extern vec3 pal1; extern vec3 pal2; extern vec3 pal3; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + float shade = floor((1.0 - pixel.r) * 3.0 + 0.5); + vec3 rgb = shade < 0.5 ? pal0 : (shade < 1.5 ? pal1 : (shade < 2.5 ? pal2 : pal3)); + return vec4(rgb, ALPHA) * tint; +} +]] + +local function graphics() return love and love.graphics end +local function shader(keyed) + local g = graphics() + local newShader = g and g.newShader + if type(newShader) ~= "function" then return nil end + if compiler ~= newShader then + compiler, shaders, failed = newShader, {}, {} + end + local key = keyed and "keyed" or "normal" + if failed[key] then return nil end + if not shaders[key] then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + local ok, value = pcall(newShader, (source:gsub("ALPHA", alpha))) + if not ok or not value then failed[key] = true; return nil end + shaders[key] = value + end + return shaders[key] +end +function M.shader() return shader(false) end +function M.keyedShader() return shader(true) end +function M.available() return M.shader() ~= nil end + +local function copy(colors) + if type(colors) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local color = colors[i] + if type(color) ~= "table" then return nil end + local row = {} + for c = 1, 3 do + local n = color[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + row[c] = n + end + out[i] = row + end + return out +end +function M.resolve(colors) return Palettes.effectiveColors(colors) end +function M.color(colors, index) + local palette = M.resolve(colors) + return palette and palette[index] or nil +end + +local function upload(sh, colors) + -- Public shader objects can be edited externally. Upload every public call; + -- a cached last-send comparison could otherwise trust foreign uniforms. + sent[sh] = nil + for i = 1, 4 do + local row = colors[i] + sh:send(names[i], {row[1]/255, row[2]/255, row[3]/255}) + end + sent[sh] = colors +end +local function use(colors, keyed, raw) + local g = graphics() + if not (g and g.setShader) then return false end + colors = copy(raw and colors or M.resolve(colors)) + if not colors then return false end + local sh = shader(keyed) + if not sh then return false end + upload(sh, colors) + g.setShader(sh) + return true +end +function M.use(colors) return use(colors, false, false) end +function M.useRaw(colors) return use(colors, false, true) end +function M.useKeyed(colors) return use(colors, true, false) end +function M.clear() + local g = graphics() + if g and g.setShader then g.setShader() end +end + +local function scoped(colors, body, apply) + local g = graphics() + local previous = g and g.getShader and g.getShader() or nil + -- Restoring only the shader object is insufficient: nested calls reuse it + -- and change its uniforms. Save the palette values as well as its identity. + local previousColors = previous and sent[previous] + local ok, applied = pcall(function() + local active = apply(colors) + body() + return active + end) + local restored, why = pcall(function() + if previousColors then upload(previous, previousColors) end + if g and g.setShader then g.setShader(previous) end + end) + if not ok then error(applied, 0) end + if not restored then error(why, 0) end + return applied +end +function M.with(colors, body) return scoped(colors, body, M.use) end +function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end +function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +return M diff --git a/src/ui/Gen2NamingScreen.lua b/src/ui/Gen2NamingScreen.lua index 3f003a05c..716914db4 100644 --- a/src/ui/Gen2NamingScreen.lua +++ b/src/ui/Gen2NamingScreen.lua @@ -1,68 +1,257 @@ --- Public Gen1Recomp screen ID used by API-2 mods on a Gen-2 save. --- Reuse this engine's native keyboard while preserving the caller-owned --- completion contract: unlike NamingScreen, this screen never pops itself. -local NamingScreen = require("src.ui.NamingScreen") -local Font = require("src.render.Font") - -local Compat = {} -local TYPES = { - player = { prompt = "YOUR NAME?", length = 7, kind = "player" }, - rival = { prompt = "RIVAL'S NAME?", length = 7, kind = "rival" }, - mom = { prompt = "MOTHER'S NAME?", length = 7 }, - box = { prompt = "BOX NAME?", length = 8 }, - nickname = { prompt = "NICKNAME?", length = 10, kind = "mon" }, +-- Original contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native Gen2 name entry. The public screen leaves completion to its caller; +-- ordinary NamingScreen admissions use this same owner with native dismissal. +local Font = require('src.render.Font') +local Strings = require('src.core.Strings') +local Runtime = require('src.mods.Runtime') +local Chrome = require('src.ui.Gen2Chrome') +local Assets = require('src.render.Assets') +local M = { isOpaque=true } +M.TYPES = { + player={prompt=Strings.source('YOUR NAME?'),maxLength=7,sprite='SPRITE_CHRIS',spriteFemale='SPRITE_KRIS'}, + rival={prompt=Strings.source("RIVAL'S NAME?"),maxLength=7,sprite='SPRITE_RIVAL'}, + mom={prompt=Strings.source("MOTHER'S NAME?"),maxLength=7,sprite='SPRITE_MOM'}, + box={prompt=Strings.source('BOX NAME?'),maxLength=8,isBox=true}, + nickname={prompt=Strings.source('NICKNAME?'),maxLength=10}, } -local function glyphs(text, limit) - text = type(text) == "string" and text or "" - local out = {} - for _, span in ipairs(Font.split(text)) do - if #out >= limit then - break - end - out[#out + 1] = text:sub(span.from, span.to) +local function limit(n, fallback) + n=tonumber(n) + if not n or n~=n or n==math.huge or n==-math.huge then return fallback end + return math.max(1,math.floor(n)) +end +local function split(text, count) + local out={} + for _,span in ipairs(Font.split(type(text)=='string' and text or ''))do + if #out>=count then break end + out[#out+1]=text:sub(span.from,span.to) end return out end -local function accept(self) - if self.onDone then - self.onDone(table.concat(self.glyphs)) +M.__index=function(self,key) + if key=='text'then return table.concat(self.glyphs) end + if key=='maxLen'then return self.maxLength end + if key=='title'then return self.prompt end + return M[key] +end +M.__newindex=function(self,key,value) + if key=='text'then rawset(self,'glyphs',split(value,self.maxLength)) + elseif key=='maxLen'then rawset(self,'maxLength',limit(value,self.maxLength)) + elseif key=='title'then rawset(self,'prompt',value) + else rawset(self,key,value)end +end +local function alphabet(lower) + local rows={{},{},{}} + for i=0,25 do rows[math.floor(i/9)+1][i%9+1]=string.char((lower and 97 or 65)+i)end + rows[3][9]=' ' + return rows +end +local function board(lower,box) + local rows=alphabet(lower) + local symbols={'×','(',')',':',';','[',']','',''} + if box then + rows[4]=lower and {'é',"'d","'l","'m","'r","'s","'t","'v",'0'} or symbols + rows[5]=lower and {'1','2','3','4','5','6','7','8','9'} or {'-','?','!','♂','♀','/','.',',','&'} + else rows[4]=lower and symbols or {'-','?','!','/','.',',',' ',' ',' '}end + return rows +end +local boards={ normal={board(false,false),board(true,false)},box={board(false,true),board(true,true)} } +local function sameGrid(grid)return grid end +function M:rows() + local data=self.game.data or {} + local authored=data.gen2Naming and data.gen2Naming[self.isBox and 'box' or 'normal'] + local base=authored and authored[self.lower and 2 or 1] or boards[self.isBox and 'box' or 'normal'][self.lower and 2 or 1] + if Runtime.wantsHook('ui.naming.grid')then + local hooked=Runtime.call('ui.naming.grid',sameGrid,base,{lower=self.lower,title=self.prompt,maxLen=self.maxLength,box=self.isBox,game=self.game}) + if type(hooked)=='table'and #hooked>0 then return hooked end end + return base end -local meta = { - __index = function(self, key) - if key == "text" then - return table.concat(self.glyphs) - end - if key == "accept" or key == "confirm" then - return accept +function M:keyboardTop()return self.isBox and 6 or 8 end +function M:bottomRow()return #self:rows() end +function M:onBottomRow()return self.row==self:bottomRow() end +function M:bottomTarget()return self.col<3 and 1 or self.col<6 and 2 or 3 end +function M:characterAt(col,row) + local line=self:rows()[row+1] + local ch=line and line[col+1] + return ch~='' and ch or nil +end +function M:cursorCharacter() + if self:onBottomRow()then return ({'CASE','DEL','END'})[self:bottomTarget()]end + return self:characterAt(self.col,self.row) +end +function M:addCharacter(ch) + if type(ch)~='string' or ch=='' or #self.glyphs>=self.maxLength then return false end + -- One cell can carry a charmap ligature or a Unicode character. Never cut + -- its bytes or count it as several name slots. + local cells=split(ch,self.maxLength-#self.glyphs) + for _,cell in ipairs(cells)do self.glyphs[#self.glyphs+1]=cell end + return #cells>0 +end +function M:deleteCharacter()return table.remove(self.glyphs) end +function M:toggleCase()self.lower=not self.lower end +function M:moveHorizontal(delta) + if self:onBottomRow()then self.col=((self:bottomTarget()-1+delta)%3)*3 + else self.col=(self.col+delta)%9 end +end +function M:moveVertical(delta) + self.row=(self.row+delta)%(self:bottomRow()+1) + if self:onBottomRow()then self.col=(self:bottomTarget()-1)*3 end +end +function M:jumpToEnd()self.row=self:bottomRow();self.col=6 end +function M:accept() + local name=self.text + if self._popOnDone then + if self._completed or self.game.stack:top()~=self then return false end + self._completed=true;self.game.stack:pop() + if name==''then name=self.default or (self.presets and self.presets[1]) or ''end + end + if self.onDone then self.onDone(name)end + return true +end +M.confirm=M.accept +function M.playerSprite(gender) + return (gender=='female' or gender=='girl')and M.TYPES.player.spriteFemale or M.TYPES.player.sprite +end +function M.new(game,opts) + opts=opts or {};local kind=M.TYPES[opts.type or 'player']or M.TYPES.player + local self=setmetatable({game=game,kind=kind,glyphs={},maxLength=limit(opts.maxLength,kind.maxLength), + prompt=opts.prompt or Strings(kind.prompt),gender=opts.gender or (game.save and game.save.player and game.save.player.gender), + isBox=opts.isBox or kind.isBox or false,lower=false,row=0,col=0,clock=0, + onDone=opts.onDone,onCancel=opts.onCancel,mon=opts.mon,monName=opts.monName, + presets=opts.presets,default=opts.default,_popOnDone=opts._popOnDone==true, + iconColors=opts.iconColors,palette=opts.palette, + },M) + self.text=opts.initial or '' + local data=game.data or {} + self.gfx=opts.menuGfx or data.gen2MenuGfx + if self.gfx and self.gfx.naming then self.gfx=self.gfx.naming end + self.tiles={} + for _,key in ipairs({'border','middleLine','underLine','cursor'})do + local path=self.gfx and self.gfx[key] + if path then local ok,img=pcall(Assets.image,path);if ok then self.tiles[key]=img end end + end + if opts.iconPath then local ok,img=pcall(Assets.image,opts.iconPath);if ok then self.iconImage=img end end + if self.mon then self:loadMonHeader(data,self.mon)end + if not self.iconImage and not self.mon then + local form=(data.field and data.field.playerForms or {})[self.gender] + local id=kind==M.TYPES.player and ((form and form.walk)or M.playerSprite(self.gender))or kind.sprite + local def=data.sprites and data.sprites[id] + if def and def.image then + local ok,img=pcall(Assets.image,def.image) + if ok then self.iconImage=img;self.iconTrueColor=def.trueColor end end - return NamingScreen[key] - end, - __newindex = function(self, key, value) - if key == "text" then - self.glyphs = glyphs(value, self.maxLen) + end + return self +end +-- Preserve the unqualified screen's pop-before-callback contract while giving +-- native scripts, catches and introductions the real Gen2 input model. +function M.native(game,opts) + opts=opts or {};local args={} + for k,v in pairs(opts)do args[k]=v end + args.type=args.type or (args.kind=='mon' and 'nickname')or args.kind or (tonumber(args.maxLen)==10 and 'nickname')or 'player' + args.prompt=args.prompt or args.title;args.maxLength=args.maxLength or args.maxLen + args._popOnDone=true + return M.new(game,args) +end +function M:enter() + if not(self.presets and #self.presets>0)then return end + self.choosing=true;self.isOpaque=false + local items={{label=Strings('NEW NAME'),onSelect=function()self.choosing=nil;self.isOpaque=true end}} + for _,preset in ipairs(self.presets)do + items[#items+1]={label=preset,onSelect=function() + self.text=preset;self:accept() + end} + end + self.game.stack:push(require('src.ui.Menu').new(self.game,items,{tx=4,ty=0,tw=12,th=#items*2+2,cancelable=false})) +end +function M:update() + self.clock=self.clock+1 + local input=self.game and self.game.input;if not input or self._completed then return end + if input:wasPressed('left')then self:moveHorizontal(-1) + elseif input:wasPressed('right')then self:moveHorizontal(1) + elseif input:wasPressed('up')then self:moveVertical(-1) + elseif input:wasPressed('down')then self:moveVertical(1) + elseif input:wasPressed('select')then self:toggleCase() + elseif input:wasPressed('start')then self:jumpToEnd() + elseif input:wasPressed('b')then self:deleteCharacter() + elseif input:wasPressed('a')then + if self:onBottomRow()then + local target=self:bottomTarget() + if target==1 then self:toggleCase()elseif target==2 then self:deleteCharacter()else self:accept()end else - rawset(self, key, value) + self:addCharacter(self:characterAt(self.col,self.row)) + if #self.glyphs>=self.maxLength then self:jumpToEnd()end end - end, -} - -function Compat.new(game, opts) - opts = opts or {} - local kind = TYPES[opts.type or "player"] or TYPES.player - local screen = NamingScreen.new(game, { - title = opts.prompt or require("src.core.Strings")(kind.prompt), - maxLen = opts.maxLength or kind.length, - kind = kind.kind, - mon = opts.mon, - onDone = opts.onDone, - }) - screen.maxLength = screen.maxLen - screen.prompt = screen.title - screen.monName = opts.monName - screen.onCancel = opts.onCancel - screen.glyphs = glyphs(opts.initial, screen.maxLen) - return setmetatable(screen, meta) -end - -return Compat + end +end +function M:loadMonHeader(data,mon) + local def=data.pokemon and data.pokemon[mon.species] + self.monName=self.monName or mon.name or (def and def.name)or mon.species + self.monGender=mon.gender;self.monIcon=true + local entry=data.icons and data.icons.bySpecies and data.icons.bySpecies[mon.species] + if type(entry)=='table'then entry=entry.image end + if type(entry)=='string'then + local path,trueColor=require('src.pokemon.Sprites').iconPath(data,mon,entry,{kind='naming'}) + if path then local ok,img=pcall(Assets.image,path);if ok then self.iconImage=img end end + self.iconTrueColor=trueColor + end +end +function M:genderGlyph()return self.monGender=='male'and '♂'or self.monGender=='female'and '♀'or nil end +M.ICON_FRAME_STEPS=18 +function M:iconFrame()return self.monIcon and math.floor(self.clock/M.ICON_FRAME_STEPS)%2 or 0 end +function M:iconOrigin()return 16,self.monIcon and 12 or 16 end +function M:wantsFillScale()return not self.choosing end +function M:drawsWidescreen()return not self.choosing end +function M:paperColor()return self.palette and require('src.render.Gen2PaletteDraw').color(self.palette,1)or Chrome.DEFAULT_BOX_PALETTE[1]end +function M:drawBackdrop() + local G=love.graphics;local p=self:paperColor();G.setColor(p[1]/255,p[2]/255,p[3]/255,1) + G.rectangle('fill',0,0,160,144) + if self.tiles.border then + G.setColor(1,1,1,1) + for y=0,17 do for x=0,19 do G.draw(self.tiles.border,x*8,y*8)end end + end +end +function M:clearPanel(tx,ty,tw,th) + local G=love.graphics;local p=self:paperColor();G.setColor(p[1]/255,p[2]/255,p[3]/255,1) + G.rectangle('fill',tx*8,ty*8,tw*8,th*8);G.setColor(0,0,0,1) +end +function M:drawEntry(tx,ty) + for i=1,self.maxLength do + local col=(i-1)%16;local row=math.floor((i-1)/16) + Font.draw(self.glyphs[i]or '-',(tx+col)*8,(ty+row*2)*8) + end +end +function M:cursorTile() + if self:onBottomRow()then return 2+(self:bottomTarget()-1)*6,self:keyboardTop()+self:bottomRow()*2,5 end + return 2+self.col*2,self:keyboardTop()+self.row*2,1 +end +function M:drawCursorBox(tx,ty,width)Chrome.cursor(tx-1,ty)end +function M:drawPanel() + self:drawBackdrop();self:clearPanel(1,1,18,self.isBox and 4 or 6) + self:clearPanel(1,self:keyboardTop(),18,self.isBox and 9 or 7);self:clearPanel(1,16,18,1) + Chrome.printThrough(Strings(self.prompt),5,2,self.palette) + self:drawEntry(5,self.isBox and 4 or 6) + if self.iconImage then + local G=love.graphics;local w,h=self.iconImage:getDimensions() + local y=self:iconFrame()*16;if y+16>h then y=0 end + local quad=self.iconQuad + if not quad then quad=G.newQuad(0,y,math.min(16,w),math.min(16,h),w,h);self.iconQuad=quad + elseif quad.setViewport then quad:setViewport(0,y,math.min(16,w),math.min(16,h),w,h)end + local function draw()G.setColor(1,1,1,1);G.draw(self.iconImage,quad,self:iconOrigin())end + local colors=self.iconColors;local Pal=require('src.render.Gen2PaletteDraw') + if colors and not(self.iconTrueColor and Pal.mode=='gbc')then Pal.with(colors,draw)else draw()end + end + for ri,row in ipairs(self:rows())do for ci,ch in ipairs(row)do + if ch~=''and ch~=' 'then Chrome.printThrough(Strings(ch),ci*2,self:keyboardTop()+(ri-1)*2,self.palette)end + end end + local labels={self.lower and Strings('UPPER')or Strings('lower'),Strings('DEL'),Strings('END')} + for i,label in ipairs(labels)do Chrome.printThrough(label,2+(i-1)*6,self:keyboardTop()+self:bottomRow()*2,self.palette)end + self:drawCursorBox(self:cursorTile()) +end +function M:draw()if not self.choosing then self:drawPanel()end end +function M:drawWidescreen(w,h) + if self.choosing then return end + Chrome.withPanel(w,h,1,1,1,function()self:drawPanel()end) +end +return M diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 3fbcfc349..b6dfd4ba9 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -321,6 +321,9 @@ end function NamingScreen.new(game, opts) opts = opts or {} + if require("src.core.GameVersion").isGen2() then + return require("src.ui.Gen2NamingScreen").native(game, opts) + end local self = setmetatable({}, NamingScreen) self.game = game self.title = opts.title or Strings("YOUR NAME?") diff --git a/tests/engine/gen2_mod_naming.lua b/tests/engine/gen2_mod_naming.lua index 377e0e3db..630d1ba21 100644 --- a/tests/engine/gen2_mod_naming.lua +++ b/tests/engine/gen2_mod_naming.lua @@ -89,11 +89,14 @@ for _, spec in ipairs({ T.eq(screen.text, "AB", "the initial name is editable") press(screen, "b") T.eq(screen.text, "A", "B deletes one initial glyph") - screen.row, screen.col = 1, 2 + screen.row, screen.col = 0, 1 press(screen, "a") T.eq(screen.text, "AB", "native keyboard input updates the public text") - -- Native START confirms; Gen2Recomped may reuse its own keyboard layout. + -- Gen2 START parks on END; only A on END completes the entry. press(screen, "start") + T.eq(calls, 0, "START does not complete Gen2 name entry") + T.eq(screen:cursorCharacter(), "END", "START selects the native END target") + press(screen, "a") T.eq(calls, 1, "completion calls the caller once") T.eq(result, "AB", "completion returns the edited name") T.eq(game.stack:top(), parent, "completion preserves the parent state") @@ -117,10 +120,11 @@ T.eq(screen.prompt, "LOCALIZED?", "explicit localized prompt survives") T.eq(screen.monName, "FIXMON", "the nickname header is retained") press(screen, "a") T.eq(screen.text, "Ä", "the public grid hook supplies a Unicode glyph") -screen.row, screen.col = 1, 2 +screen.row, screen.col = 0, 1 press(screen, "a") T.eq(screen.text, "ÄB", "the name limit counts glyphs rather than bytes") press(screen, "start") +press(screen, "a") T.eq(result, "ÄB", "Unicode completion keeps the full glyph") T.eq(game.stack:top(), screen, "an observing callback does not implicitly close the screen") press(screen, "b") @@ -140,6 +144,8 @@ local ordinary = Screens.push(game, "NamingScreen", { end, }) press(ordinary, "start") +T.eq(game.stack:top(), ordinary, "native START selects END before completion") +press(ordinary, "a") T.eq(game.stack:top(), parent, "ordinary screen closes exactly once") -- A registry override remains authoritative for the compatibility ID. diff --git a/tests/engine/gen2_native_naming.lua b/tests/engine/gen2_native_naming.lua new file mode 100644 index 000000000..a92d27e7d --- /dev/null +++ b/tests/engine/gen2_native_naming.lua @@ -0,0 +1,93 @@ +-- Original contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local edition=Version.get() +local UI=require('src.ui.Gen2NamingScreen');local Font=require('src.render.Font') +local Screens=require('src.ui.Screens');local Json=require('src.link.Json') +local data=T.fixtures.fresh() +-- Synthetic, relocated glyphs. Bracket aliases follow the font, never a +-- fixed retail tile ordinal, and a font's explicit alias has precedence. +data.font.charmap[#data.font.charmap+1]={seq='PK',code=0xd1} +data.font.charmap[#data.font.charmap+1]={seq='MN',code=0xd2} +Font.load(data) +T.eq(#Font.split(''),2,'legacy ligatures are two native glyphs') +T.eq(Font.split('')[1].code,0xd1,'PK follows its actual active font') +T.eq(Font.split('')[1].code,0xd2,'MN follows its actual active font') +T.eq(Font.width(''),Font.width('PK'),'measurement uses the same ligature as drawing') +data.font.charmap[#data.font.charmap+1]={seq='',code=0xd3} +Font.load(data);T.eq(Font.split('')[1].code,0xd3,'authored bracket glyph retains precedence') +local function game() + local g={data=data,save={player={gender='girl'}}} + g.stack=setmetatable({},{__index=require('src.core.StateStack')});g.stack:init() + return g +end +local function tap(g,s,key) + g.input={wasPressed=function(_,k)return key==k end};s:update() +end +for _,v in ipairs({'gold','silver','crystal'})do + Version.set(v) + for _,name in ipairs({'player','rival','mom','box','nickname'})do + local g=game();local parent={};g.stack:push(parent);local called=0 + local s;s=UI.new(g,{type=name,initial='Aé',onDone=function(text) + called=called+1;T.eq(g.stack:top(),s,'public completion leaves the native owner to its caller');g.stack:pop() + T.eq(text,'A',v..' glyph deletion retains complete ligature') + end});g.stack:push(s) + T.eq(s.row,0,'Gen2 cursor starts at zero-based first row');T.eq(s.col,0) + T.eq(s.maxLength,UI.TYPES[name].maxLength,name..' native field limit') + T.eq(#s.glyphs,3,'entry counts font glyphs, not encoded bytes') + tap(g,s,'b');T.eq(s.text,'A') + tap(g,s,'start');T.eq(s:cursorCharacter(),'END');T.eq(called,0,'START selects END without completing') + tap(g,s,'a');T.eq(called,1);T.eq(g.stack:top(),parent) + end + local g=game();local s=UI.new(g,{}) + T.eq(#s:rows(),4,'ordinary Gen2 board has four letter rows') + T.eq(s:characterAt(8,2),' ','a real trailing blank remains an input cell') + s.col=8;s.row=2;tap(g,s,'a');T.eq(s.text,' ') + tap(g,s,'select');T.eq(s.lower,true);T.eq(s:characterAt(7,3),'') + s.row=3;s.col=7;tap(g,s,'a');T.eq(s.text,' ') + tap(g,s,'start');tap(g,s,'left');T.eq(s:cursorCharacter(),'DEL');tap(g,s,'a');T.eq(s.text,' ') + tap(g,s,'left');T.eq(s:cursorCharacter(),'CASE');tap(g,s,'a');T.eq(s.lower,false) + tap(g,s,'left');T.eq(s:cursorCharacter(),'END','bottom targets wrap as three fat cells') + s.row=0;s.col=8;tap(g,s,'up');T.eq(s:cursorCharacter(),'END');tap(g,s,'down');T.eq(s.row,0) + s=UI.new(g,{type='box'});T.eq(#s:rows(),5,'box names have an extra character row') + s.text='12345678';tap(g,s,'b');T.eq(s.text,'1234567');tap(g,s,'select');T.eq(s:characterAt(8,4),'9') + s=UI.new(g,{maxLength=2});s:addCharacter('é');T.eq(s.text,'é');T.eq(s:addCharacter('X'),false) + s.row=0;s.col=0;tap(g,s,'a');T.eq(s:cursorCharacter(),'END','filling the name selects END but does not accept') + -- Actual ordinary constructor uses this same native screen while retaining + -- its distinct pop-before-callback contract, including empty nickname entry. + local popped=0;local parent={};g.stack:push(parent) + s=require('src.ui.NamingScreen').new(g,{kind='mon',maxLen=10,onDone=function(text) + popped=popped+1;T.eq(text,'');T.eq(g.stack:top(),parent) + end});g.stack:push(s) + T.eq(getmetatable(s),UI,'native admission is not a secondary keyboard simulation') + tap(g,s,'start');T.eq(popped,0);tap(g,s,'a');T.eq(popped,1) + tap(g,s,'a');T.eq(popped,1,'completed native admission cannot run again') + -- Presets draw the original parent, without a ghost keyboard behind them. + local keyboard=0;local old=UI.drawPanel;UI.drawPanel=function()keyboard=keyboard+1 end + local parent={};g.stack:init();g.stack:push(parent) + s=require('src.ui.NamingScreen').new(g,{presets={'FIXNAME'},onDone=function(n)T.eq(n,'A')end}) + g.stack:push(s);s:draw();s:drawWidescreen(800,720) + T.eq(s.choosing,true);T.eq(s.isOpaque,false);T.eq(keyboard,0,'no keyboard is drawn under name presets') + local menu=g.stack:top();tap(g,menu,'a');T.eq(g.stack:top(),s);T.eq(s.choosing,nil);T.eq(s.isOpaque,true) + s:draw();T.eq(keyboard,1,'NEW NAME activates the native keyboard') + tap(g,s,'a');tap(g,s,'start');tap(g,s,'a');T.eq(g.stack:top(),parent) + UI.drawPanel=old + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/naming/manifest.json']=Json.encode({id='naming',name='Native name client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/naming/main.lua']=[[ + local mod=...;local Screen=require('src.ui.gen2.NamingScreen') + mod.exports.make=function(game)return Screen.new(game,{type='box',initial='AB'})end + ]]} + local loaded=T.sdk.loadMods({'mods/naming'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,'actual loader admits native naming client') + local s=loaded.loader.exports.naming.make(game()) + T.eq(getmetatable(s),UI,'legacy class spelling targets the native Gen2 owner') + T.eq(s.text,'AB');T.eq(s:bottomRow(),5) + require('src.mods.Loader').teardown();loaded.release() + end +end +Version.set('red') +T.eq(getmetatable(require('src.ui.NamingScreen').new(game(),{})),require('src.ui.NamingScreen'),'Gen1 retains its own keyboard') +Version.set(edition);Screens.invalidate() +T.finish('native Gen2 keyboard input, glyphs, original admission and loaded class owner') From 9bd7727e6068df8318a7c85a29912bdc3108540b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:27:19 +0100 Subject: [PATCH 27/68] Keep native naming roles, mail layout and cursor resource ownership --- docs/native-gen2-naming.md | 17 +++++---- src/script/Gen2Commands.lua | 3 +- src/ui/Gen2NamingScreen.lua | 56 ++++++++++++++++++++++------- src/ui/OakSpeech.lua | 1 + tests/engine/gen2_native_naming.lua | 36 +++++++++++++++++++ 5 files changed, 93 insertions(+), 20 deletions(-) diff --git a/docs/native-gen2-naming.md b/docs/native-gen2-naming.md index 0c7f9ee42..f0ff81c7a 100644 --- a/docs/native-gen2-naming.md +++ b/docs/native-gen2-naming.md @@ -17,8 +17,10 @@ presenters can observe and draw its actual rows, cursor, text and field size. Name presets temporarily make the naming screen transparent and suppress its keyboard until NEW NAME is chosen; the original parent remains underneath. -Native imported sprite and optional menu-art records can supply the header and -backdrop. Default board data is an engine fallback; a `data.gen2Naming` record +Native imported sprite and optional menu-art records can supply the header, +backdrop and retained cursor quads. Mail entry keeps both sixteen-glyph rows +above its keyboard. Native Oak and rival/name-rater calls carry their actual +player/rival/Pokemon role instead of inferring it from a field length. Default board data is an engine fallback; a `data.gen2Naming` record can supply normal/box upper/lower rows. `ui.naming.grid` retains its public context and can replace those rows. @@ -29,13 +31,14 @@ that same charmap. ROM-free tests cover actual stack/menu input, name types and glyph limits, caller/native completion ownership, preset underlay suppression, Gen1 -retention and actual Loader clients through native API1/2. The own-fork composition additionally tests unified API2/3. +retention and actual Loader clients through native API1/2. GPU acceptance and full reference raster/helper parity remain separate: ROM naming-program/menu-art extraction, exact cursor/header rendering and the separate Gen2 NamePick portrait animation are still open. This change does not establish complete VASC, naming-story, save/link or full Gen1 parity. -This standalone offer includes the shared native menu chrome already offered -in PR #224 and the indexed palette drawer already offered in PR #202. It -requires neither of those PR branches to have been merged. Original Gen1 -renderer provenance and applicable existing rights remain intact. +This offer includes the shared Gen2 menu chrome already offered in PR #224 +and indexed palette drawer from PR #202 so its native screen can run without +either branch having merged. Existing Gen1 provenance and license notices are +preserved. Original contributions are offered under the specific permission at +https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..5ccfb4d3b 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -4141,6 +4141,7 @@ function Commands.g2_name_rival(ctx) local presets = boot and boot.namePresets and boot.namePresets.rival game.stack:push(require("src.ui.NamingScreen").new(game, { title = Strings("RIVAL'S NAME?"), + kind = "rival", maxLen = 7, -- PLAYER_NAME_LENGTH - 1 -- NamingScreen_InitNameEntry opens the grid blank and confirming nothing -- would leave the name empty, so fall back to the canonical one @@ -4214,7 +4215,7 @@ function Commands.g2_name_rater(ctx) or Strings("All right. What\nname should we\vgive it, then?")) local chosen game.stack:push(require("src.ui.NamingScreen").new(game, { - title = Strings("%s's NICKNAME?", nickOf(picked)), + title = Strings("NICKNAME?"), kind = "mon", mon = picked, maxLen = 10, default = nickOf(picked), onDone = function(name) chosen = name runner:resume() end, diff --git a/src/ui/Gen2NamingScreen.lua b/src/ui/Gen2NamingScreen.lua index 716914db4..241ca9a51 100644 --- a/src/ui/Gen2NamingScreen.lua +++ b/src/ui/Gen2NamingScreen.lua @@ -58,7 +58,7 @@ end local boards={ normal={board(false,false),board(true,false)},box={board(false,true),board(true,true)} } local function sameGrid(grid)return grid end function M:rows() - local data=self.game.data or {} + local data=self.game and self.game.data or {} local authored=data.gen2Naming and data.gen2Naming[self.isBox and 'box' or 'normal'] local base=authored and authored[self.lower and 2 or 1] or boards[self.isBox and 'box' or 'normal'][self.lower and 2 or 1] if Runtime.wantsHook('ui.naming.grid')then @@ -116,14 +116,14 @@ end function M.new(game,opts) opts=opts or {};local kind=M.TYPES[opts.type or 'player']or M.TYPES.player local self=setmetatable({game=game,kind=kind,glyphs={},maxLength=limit(opts.maxLength,kind.maxLength), - prompt=opts.prompt or Strings(kind.prompt),gender=opts.gender or (game.save and game.save.player and game.save.player.gender), + prompt=opts.prompt or Strings(kind.prompt),gender=opts.gender or (game and game.save and game.save.player and game.save.player.gender), isBox=opts.isBox or kind.isBox or false,lower=false,row=0,col=0,clock=0, onDone=opts.onDone,onCancel=opts.onCancel,mon=opts.mon,monName=opts.monName, presets=opts.presets,default=opts.default,_popOnDone=opts._popOnDone==true, iconColors=opts.iconColors,palette=opts.palette, },M) self.text=opts.initial or '' - local data=game.data or {} + local data=game and game.data or {} self.gfx=opts.menuGfx or data.gen2MenuGfx if self.gfx and self.gfx.naming then self.gfx=self.gfx.naming end self.tiles={} @@ -149,7 +149,8 @@ end function M.native(game,opts) opts=opts or {};local args={} for k,v in pairs(opts)do args[k]=v end - args.type=args.type or (args.kind=='mon' and 'nickname')or args.kind or (tonumber(args.maxLen)==10 and 'nickname')or 'player' + args.type=args.type or (args.kind=='mon' and 'nickname')or args.kind + or (tonumber(args.maxLen)==10 and 'nickname')or (tonumber(args.maxLen)==8 and 'box')or 'player' args.prompt=args.prompt or args.title;args.maxLength=args.maxLength or args.maxLen args._popOnDone=true return M.new(game,args) @@ -208,8 +209,12 @@ function M:drawBackdrop() local G=love.graphics;local p=self:paperColor();G.setColor(p[1]/255,p[2]/255,p[3]/255,1) G.rectangle('fill',0,0,160,144) if self.tiles.border then - G.setColor(1,1,1,1) - for y=0,17 do for x=0,19 do G.draw(self.tiles.border,x*8,y*8)end end + local function draw() + G.setColor(1,1,1,1) + for y=0,17 do for x=0,19 do G.draw(self.tiles.border,x*8,y*8)end end + end + local Pal=require('src.render.Gen2PaletteDraw') + if self.palette and Pal.available()then Pal.with(self.palette,draw)else draw()end end end function M:clearPanel(tx,ty,tw,th) @@ -219,18 +224,43 @@ end function M:drawEntry(tx,ty) for i=1,self.maxLength do local col=(i-1)%16;local row=math.floor((i-1)/16) - Font.draw(self.glyphs[i]or '-',(tx+col)*8,(ty+row*2)*8) + local x,y=(tx+col)*8,(ty+row*2)*8 + if self.mailMode then x,y=8+col*8,24+row*12 end + Font.draw(self.glyphs[i]or '-',x,y) end end function M:cursorTile() if self:onBottomRow()then return 2+(self:bottomTarget()-1)*6,self:keyboardTop()+self:bottomRow()*2,5 end return 2+self.col*2,self:keyboardTop()+self.row*2,1 end -function M:drawCursorBox(tx,ty,width)Chrome.cursor(tx-1,ty)end +function M:drawCursorTile(quad,x,y,flipX,flipY) + love.graphics.draw(self.tiles.cursor,quad,x,y,0,flipX and -1 or 1,flipY and -1 or 1,flipX and 8 or 0,flipY and 8 or 0) +end +function M:drawCursorBox(tx,ty,width) + local G=love.graphics;local image=self.tiles.cursor + if not image then Chrome.cursor(tx-1,ty);return end + local w,h=image:getDimensions() + if w<8 or h<8 then Chrome.cursor(tx-1,ty);return end + if not self.cursorQuads then + self.cursorQuads={corner=G.newQuad(0,0,8,8,w,h),edge=G.newQuad(0,h>=16 and 8 or 0,8,8,w,h)} + end + G.setColor(1,1,1,1) + local x,y=tx*8,ty*8;local corner,edge=self.cursorQuads.corner,self.cursorQuads.edge + local right=x+math.max(0,(width or 1)-1)*8 + self:drawCursorTile(corner,x-1,y-1,false,false);self:drawCursorTile(corner,right,y-1,true,false) + self:drawCursorTile(corner,x-1,y,false,true);self:drawCursorTile(corner,right,y,true,true) + for col=1,(width or 1)-2 do + self:drawCursorTile(edge,x+col*8,y-1,false,false);self:drawCursorTile(edge,x+col*8,y,false,true) + end +end function M:drawPanel() - self:drawBackdrop();self:clearPanel(1,1,18,self.isBox and 4 or 6) + self:drawBackdrop();self:clearPanel(1,1,18,self.mailMode and 5 or self.isBox and 4 or 6) self:clearPanel(1,self:keyboardTop(),18,self.isBox and 9 or 7);self:clearPanel(1,16,18,1) - Chrome.printThrough(Strings(self.prompt),5,2,self.palette) + if self.monName then + Chrome.printThrough(Strings("%s'S",self.monName),5,2,self.palette) + Chrome.printThrough(Strings(self.prompt),5,4,self.palette) + else Chrome.printThrough(Strings(self.prompt),5,2,self.palette)end + local gender=self:genderGlyph();if gender then Chrome.printThrough(gender,1,2,self.palette)end self:drawEntry(5,self.isBox and 4 or 6) if self.iconImage then local G=love.graphics;local w,h=self.iconImage:getDimensions() @@ -246,8 +276,10 @@ function M:drawPanel() if ch~=''and ch~=' 'then Chrome.printThrough(Strings(ch),ci*2,self:keyboardTop()+(ri-1)*2,self.palette)end end end local labels={self.lower and Strings('UPPER')or Strings('lower'),Strings('DEL'),Strings('END')} - for i,label in ipairs(labels)do Chrome.printThrough(label,2+(i-1)*6,self:keyboardTop()+self:bottomRow()*2,self.palette)end - self:drawCursorBox(self:cursorTile()) + for i,label in ipairs(labels)do Chrome.printThrough(label,i==1 and 2 or 3+(i-1)*6,self:keyboardTop()+self:bottomRow()*2,self.palette)end + local function cursor()self:drawCursorBox(self:cursorTile())end + local Pal=require('src.render.Gen2PaletteDraw') + if self.palette and Pal.available()then Pal.with(self.palette,cursor)else cursor()end end function M:draw()if not self.choosing then self:drawPanel()end end function M:drawWidescreen(w,h) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index ed0f11d16..03dd04b11 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -692,6 +692,7 @@ function OakSpeech:runStep(step) step.presetsFallback or { "RED" }) require("src.ui.Screens").push(self.game, "NamingScreen", { title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), + kind = who == "rival" and "rival" or "player", presets = presets, maxLen = step.maxLen or self.nameLen, onDone = function(name) diff --git a/tests/engine/gen2_native_naming.lua b/tests/engine/gen2_native_naming.lua index a92d27e7d..c1b5ab400 100644 --- a/tests/engine/gen2_native_naming.lua +++ b/tests/engine/gen2_native_naming.lua @@ -17,6 +17,18 @@ T.eq(Font.split('')[1].code,0xd2,'MN follows its actual active font') T.eq(Font.width(''),Font.width('PK'),'measurement uses the same ligature as drawing') data.font.charmap[#data.font.charmap+1]={seq='',code=0xd3} Font.load(data);T.eq(Font.split('')[1].code,0xd3,'authored bracket glyph retains precedence') +local preview=UI.new(nil,{initial='AB'}) +T.eq(preview.text,'AB','constructing a public preview does not require a live game') +T.eq(preview:characterAt(0,0),'A');preview:update();T.eq(preview.clock,1) +-- Mail entry must keep both sixteen-glyph rows above its keyboard. +local entry=UI.new({data=data},{type='box',maxLength=32}) +entry.mailMode=true;entry.text=string.rep('X',32) +local drawn={};local draw=Font.draw +Font.draw=function(text,x,y)drawn[#drawn+1]={text,x,y}end +entry:drawEntry(5,4);Font.draw=draw +T.eq(#drawn,32);T.eq(drawn[1][2],8);T.eq(drawn[16][2],128) +T.eq(drawn[17][2],8);T.eq(drawn[17][3],36) +for _,p in ipairs(drawn)do T.check(p[2]>=8 and p[2]+8<=152 and p[3]+8<=entry:keyboardTop()*8,'native mail glyph fits above keyboard')end local function game() local g={data=data,save={player={gender='girl'}}} g.stack=setmetatable({},{__index=require('src.core.StateStack')});g.stack:init() @@ -73,6 +85,19 @@ for _,v in ipairs({'gold','silver','crystal'})do s:draw();T.eq(keyboard,1,'NEW NAME activates the native keyboard') tap(g,s,'a');tap(g,s,'start');tap(g,s,'a');T.eq(g.stack:top(),parent) UI.drawPanel=old + -- Actual Oak name-step dispatch retains its semantic role even when a + -- mod changes the player field length to the usual box-name length. + for _,who in ipairs({'player','rival'})do + local g=game();local parent={};g.stack:push(parent);local advanced=0 + local speech={game=g,nameLen=8,recordAnswer=function()end,advance=function()advanced=advanced+1 end} + require('src.ui.OakSpeech').runStep(speech,{kind='name',who=who,presets={}}) + local own=g.stack:top();T.eq(getmetatable(own),UI) + T.eq(own.kind,UI.TYPES[who],'native Oak passes the actual naming role') + T.eq(own.isBox,false,'eight-letter player/rival fields do not become box keyboards') + tap(g,own,'a');tap(g,own,'start');tap(g,own,'a') + T.eq(g.save.player[who=='rival'and'rival'or'name'],'A') + T.eq(advanced,1);T.eq(g.stack:top(),parent) + end for _,api in ipairs({{1,'native'},{2,'native'}})do local files={['mods/naming/manifest.json']=Json.encode({id='naming',name='Native name client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), ['mods/naming/main.lua']=[[ @@ -87,6 +112,17 @@ for _,v in ipairs({'gold','silver','crystal'})do require('src.mods.Loader').teardown();loaded.release() end end +-- Imported cursor sheets are actually used and their quads are retained. +local cursor=UI.new(nil,{}) +cursor.tiles.cursor={getDimensions=function()return 8,16 end} +local newQuad,draw=love.graphics.newQuad,love.graphics.draw +local quads,draws=0,0 +love.graphics.newQuad=function(...)quads=quads+1;return newQuad(...)end +love.graphics.draw=function(image,...)T.eq(image,cursor.tiles.cursor);draws=draws+1 end +cursor:drawCursorBox(2,8,1);cursor:drawCursorBox(2,8,1);cursor:drawCursorBox(8,16,5) +T.eq(quads,2,'cursor quads are retained across frames and target sizes') +T.eq(draws,18,'letter corners and wide-target edges use the imported sheet') +love.graphics.newQuad,love.graphics.draw=newQuad,draw Version.set('red') T.eq(getmetatable(require('src.ui.NamingScreen').new(game(),{})),require('src.ui.NamingScreen'),'Gen1 retains its own keyboard') Version.set(edition);Screens.invalidate() From 7f73de8984b71198afd4a1c74f1fe9f46668a08e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:35:29 +0100 Subject: [PATCH 28/68] Own native Gen2 player name choice and blocking portrait movement --- docs/native-gen2-namepick.md | 28 +++++++ src/core/ModCompat.lua | 1 + src/ui/Gen2NamePick.lua | 105 ++++++++++++++++++++++++++ src/ui/OakSpeech.lua | 17 +++++ tests/engine/gen2_native_namepick.lua | 69 +++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 docs/native-gen2-namepick.md create mode 100644 src/ui/Gen2NamePick.lua create mode 100644 tests/engine/gen2_native_namepick.lua diff --git a/docs/native-gen2-namepick.md b/docs/native-gen2-namepick.md new file mode 100644 index 000000000..2c56bdebb --- /dev/null +++ b/docs/native-gen2-namepick.md @@ -0,0 +1,28 @@ +# Native Gen2 player-name choice + +The player-name step in Gen2 Oak speech now opens `Gen2NamePick`, rather than +using the generic preset menu attached to the naming keyboard. Its original +player portrait moves from tile column 6 to 13, one tile per update, before +input is admitted. A preset moves the portrait back before the original Oak +step resumes. B cannot dismiss this required choice. The class and tile layout +follow Gen1Recomp's separate Gen2 NamePick owner; original source provenance is +retained in the implementation. + +NEW NAME opens the existing native Gen2 keyboard through `Screens.push`, so +screen registry overrides still apply. It retains the actual gender and field +length without turning an eight-slot player field into box entry. START selects +END and A confirms; empty input keeps the actual supplied preset. Completion is +once-only, and a keyboard whose original caller was removed cannot commit to it. +The public picker constructor leaves dismissal to its caller. Native Oak owns +its picker dismissal, player-name write and original continuation. + +Portraits and form-specific names use the engine's actual imported player data. +The class spelling `src.ui.gen2.NamePick` addresses this same native owner. +Ordinary non-Gen2 and rival naming retain their existing route. Screens with no +presets can still open the keyboard directly. No mod-specific name flow is added. + +ROM-free tests cover input blocking during both directions of movement, input +wrap, B rejection, native keyboard/empty-name ownership and actual Oak dispatch +for Gold, Silver and Crystal. Exact-source GPU acceptance of the new picker, +ROM naming-program/menu-art import, remaining raster/header details and a full +unmodified-mod story/save/link playthrough are separate unfinished work. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 10f1c6fb2..336bb378c 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", ["src.ui.gen2.NamingScreen"] = "src.ui.Gen2NamingScreen", + ["src.ui.gen2.NamePick"] = "src.ui.Gen2NamePick", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/ui/Gen2NamePick.lua b/src/ui/Gen2NamePick.lua new file mode 100644 index 000000000..3b0496841 --- /dev/null +++ b/src/ui/Gen2NamePick.lua @@ -0,0 +1,105 @@ +-- Gen2 name selection and blocking player portrait movement. +-- Adapted from Gen1Recomp src/ui/gen2/NamePick.lua at +-- 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91; existing provenance/licenses apply. +-- Original integration contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +local Chrome = require('src.ui.Gen2Chrome') +local Font = require('src.render.Font') +local Strings = require('src.core.Strings') +local Screens = require('src.ui.Screens') +local Palette = require('src.render.Gen2PaletteDraw') +local M = {isOpaque=true}; M.__index=M +local PRESETS={gold={'GOLD','HIRO','TAYLOR','KARL'},silver={'SILVER','KAMON','OSCAR','MAX'},crystal={'CHRIS','MAT','ALLAN','JON'}} +local FEMALE={crystal={'KRIS','AMANDA','JUANA','JODI'}} +function M.presetsFor(gender,data) + local field=data and data.field or {} + local forms=field.playerForms + local form=forms and forms[gender] + if form and type(form.names)=='table' and #form.names>0 then return form.names end + local names=field.boot and field.boot.namePresets and field.boot.namePresets.player + if type(names)=='table' and #names>0 then return names end + local version=require('src.core.GameVersion').get() + return ((gender=='female' or gender=='girl') and FEMALE[version]) or PRESETS[version] or PRESETS.gold +end +function M.new(game,opts) + opts=opts or {} + local self=setmetatable({game=game,onDone=opts.onDone,pic=opts.pic,picColors=opts.picColors, + picTrueColor=opts.picTrueColor,gender=opts.gender or (game and game.save and game.save.player and game.save.player.gender), + title=opts.title,prompt=opts.prompt,maxLength=opts.maxLength,cursor=1,picX=6,slide='in'},M) + self.items={Strings.source('NEW NAME')} + for _,name in ipairs(opts.presets or M.presetsFor(self.gender,game and game.data))do self.items[#self.items+1]=name end + local font=opts.font or (game and game.data and game.data.font) + self.fontOk=font and pcall(Font.load,{font=font}) or false + return self +end +function M:choose(name) + if self._completed then return false end + self._completed=true + if self.onDone then self.onDone(name)end + return true +end +function M:defaultName() + return self.items[2] or M.presetsFor(self.gender,self.game and self.game.data)[1] +end +function M:openNaming() + if self._completed or self.game.stack:top()~=self then return end + local screen + screen=Screens.push(self.game,'Gen2NamingScreen',{ + type='player',gender=self.gender,prompt=self.prompt,maxLength=self.maxLength, + onDone=function(name) + if self._completed or self.game.stack:top()~=screen then return end + self.game.stack:pop() + if self.game.stack:top()~=self then return end + self:choose(name and name~='' and name or self:defaultName()) + end, + }) + return screen +end +function M:update() + if self._completed then return end + if self.slide=='in' then + if self.picX<13 then self.picX=self.picX+1 else self.slide=nil end + return + elseif self.slide=='out' then + if self.picX>6 then self.picX=self.picX-1 + else self.slide=nil;self:choose(self.pendingName)end + return + end + local input=self.game and self.game.input;if not input then return end + if input:wasPressed('up')then self.cursor=(self.cursor-2)%#self.items+1 + elseif input:wasPressed('down')then self.cursor=self.cursor%#self.items+1 + elseif input:wasPressed('a') or input:wasPressed('start')then + if self.cursor==1 then + if self.fontOk then self:openNaming()else self:choose(self:defaultName())end + else self.pendingName=self.items[self.cursor];self.slide='out'end + end +end +function M:wantsFillScale()return true end +function M:drawsWidescreen()return true end +function M:drawPic() + if not self.pic then return end + local G=love.graphics;local w,h=self.pic:getDimensions() + local x,y=self.picX*8,32+56-h + local function body()G.setColor(1,1,1,1);G.draw(self.pic,x,y)end + if self.picColors and not self.picTrueColor and Palette.available()then Palette.with(self.picColors,body)else body()end + if self.picTrueColor then require('src.render.PaletteFX').markTrueColor(x,y,w,h)end +end +function M:drawPanel() + Chrome.clear();self:drawPic() + if self.slide=='in' then return end + local title=Strings(self.title or 'NAME') + Chrome.textbox(0,0,9,10) + Chrome.paletteFill(16,0,Font.width(title),8) + Chrome.print(title,2,0) + for i,label in ipairs(self.items)do Chrome.print(i==1 and Strings(label)or label,2,2+(i-1)*2)end + Chrome.cursor(1,2+(self.cursor-1)*2) +end +function M:draw()self:drawPanel()end +function M:drawWidescreen(w,h) + Chrome.letterbox(w,h,1,1,1) + local G=love.graphics;local scale=Chrome.fitScale(w,h) + G.push('all');G.translate(Chrome.fitOrigin(w,h,scale));G.scale(scale,scale) + self:drawPanel();G.pop() +end +M.PRESETS=PRESETS;M.PRESETS_FEMALE=FEMALE +return M diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 03dd04b11..c2d9e6bdc 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -690,6 +690,23 @@ function OakSpeech:runStep(step) presets = presets or namePresets(self.game, step.presetsWho or who, step.presetsFallback or { "RED" }) + if GameVersion.isGen2() and who == "player" and #presets > 0 then + local screen + local gender = self.game.save.player.gender + local pic, trueColor = self:formPic(gender) + screen = require("src.ui.Screens").push(self.game, "Gen2NamePick", { + gender = gender, presets = presets, pic = pic, picTrueColor = trueColor, + font = self.game.fontData, prompt = step.title, maxLength = step.maxLen or self.nameLen, + onDone = function(name) + if self.game.stack:top() ~= screen then return end + self.game.stack:pop() + self.game.save.player.name = name + self:recordAnswer(step, 1, name, name) + self:advance() + end, + }) + return + end require("src.ui.Screens").push(self.game, "NamingScreen", { title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), kind = who == "rival" and "rival" or "player", diff --git a/tests/engine/gen2_native_namepick.lua b/tests/engine/gen2_native_namepick.lua new file mode 100644 index 000000000..d91a726e5 --- /dev/null +++ b/tests/engine/gen2_native_namepick.lua @@ -0,0 +1,69 @@ +-- Original integration contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local edition=Version.get() +local Pick=require('src.ui.Gen2NamePick');local Naming=require('src.ui.Gen2NamingScreen') +local Stack=require('src.core.StateStack');local Screens=require('src.ui.Screens') +local data=T.fixtures.fresh() +local function game() + local g={data=data,save={player={gender='girl'}},stack=setmetatable({},{__index=Stack})} + g.stack:init();return g +end +local function tap(g,s,key)g.input={wasPressed=function(_,k)return key==k end};s:update()end +local function settle(g,s) + for _=1,8 do tap(g,s,'a')end + T.eq(s.picX,13);T.eq(s.slide,nil);T.eq(g.stack:top(),s,'slide blocks keyboard/menu input') +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local g=game();local parent={};g.stack:push(parent);local done=0 + local s;s=Pick.new(g,{presets={'ONE','TWO'},onDone=function(name) + done=done+1;T.eq(name,'TWO');T.eq(g.stack:top(),s,'public picker leaves dismissal to its caller') + end});g.stack:push(s) + T.eq(s.picX,6);T.eq(s.cursor,1);settle(g,s) + tap(g,s,'b');T.eq(g.stack:top(),s);T.eq(done,0,'B cannot cancel the native name choice') + tap(g,s,'up');T.eq(s.cursor,3);tap(g,s,'a');T.eq(s.slide,'out');T.eq(done,0) + for x=12,6,-1 do tap(g,s,'a');T.eq(s.picX,x);T.eq(done,0,'preset waits for the return walk')end + tap(g,s,'a');T.eq(done,1);tap(g,s,'a');T.eq(done,1,'selection is once-only') + g=game();g.stack:push(parent);local received + s=Pick.new(g,{presets={'FALLBACK'},maxLength=8,onDone=function(name)received=name end});g.stack:push(s);settle(g,s) + tap(g,s,'start');local keyboard=g.stack:top() + T.eq(getmetatable(keyboard),Naming,'NEW NAME opens the real native Gen2 keyboard') + T.eq(keyboard.kind,Naming.TYPES.player);T.eq(keyboard.maxLength,8);T.eq(keyboard.isBox,false) + tap(g,keyboard,'a');tap(g,keyboard,'start');T.eq(received,nil);tap(g,keyboard,'a') + T.eq(received,'A');T.eq(g.stack:top(),s) + -- Empty input chooses the import-provided default, never an unrelated name. + g=game();s=Pick.new(g,{presets={'LOCAL DEFAULT'},onDone=function(name)received=name end}) + g.stack:push(s);settle(g,s);tap(g,s,'a');keyboard=g.stack:top();received=nil + tap(g,keyboard,'start');tap(g,keyboard,'a');T.eq(received,'LOCAL DEFAULT') + -- Real Oak dispatch owns the picker and its continuation, including a + -- changed field length, with no second naming/preset simulation. + g=game();g.stack:push(parent);local advanced=0 + local speech=setmetatable({game=g,nameLen=8,recordAnswer=function()end,advance=function()advanced=advanced+1 end},{__index=require('src.ui.OakSpeech')}) + speech:runStep({kind='name',who='player',presets={'LOCAL DEFAULT'}}) + s=g.stack:top();T.eq(getmetatable(s),Pick);settle(g,s) + tap(g,s,'a');keyboard=g.stack:top();tap(g,keyboard,'a');tap(g,keyboard,'start');tap(g,keyboard,'a') + T.eq(g.stack:top(),parent);T.eq(g.save.player.name,'A');T.eq(advanced,1) + T.eq(require('src.core.ModCompat').ALIASES['src.ui.gen2.NamePick'],'src.ui.Gen2NamePick') +end +Version.set('crystal') +T.eq(Pick.presetsFor('girl')[1],'KRIS');T.eq(Pick.presetsFor('female')[1],'KRIS') +T.eq(Pick.presetsFor('boy',{field={playerForms={boy={names={'CUSTOM'}}}}})[1],'CUSTOM') +T.eq(Pick.presetsFor('boy',{field={boot={namePresets={player={'BOOT'}}}}})[1],'BOOT') +local g=game();local completed=0 +local stale=Pick.new(g,{presets={'X'},onDone=function()completed=completed+1 end}) +g.stack:push(stale);settle(g,stale);tap(g,stale,'a') +local child=g.stack:pop();g.stack:pop();g.stack:push({});g.stack:push(child) +tap(g,child,'start');tap(g,child,'a') +T.eq(completed,0,'a keyboard whose original caller was removed cannot commit to it') +-- Rendering uses the actual supplied portrait at native tile positions and +-- retains true-colour ownership. No private picture data is needed. +local s=Pick.new(nil,{presets={'X'}});local draws={};local draw=love.graphics.draw +s.pic={getDimensions=function()return 56,40 end} +love.graphics.draw=function(image,x,y)draws[#draws+1]={image,x,y}end +s:drawPic();T.eq(draws[1][1],s.pic);T.eq(draws[1][2],48);T.eq(draws[1][3],48) +s.picX=13;s:drawPic();T.eq(draws[2][2],104) +love.graphics.draw=draw +Version.set(edition);Screens.invalidate() +T.finish('native Gen2 name choice, blocking portrait walk and original Oak continuation') From 2c891ba909399f447d64f84d4b404d00811ddad4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:41:40 +0100 Subject: [PATCH 29/68] Suppress decorated keyboard drawing until name entry is active --- docs/native-gen2-naming.md | 8 ++++++-- src/ui/Gen2NamingScreen.lua | 23 ++++++++++++++++++++--- tests/engine/gen2_native_naming.lua | 14 ++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/native-gen2-naming.md b/docs/native-gen2-naming.md index f0ff81c7a..fe8ca6192 100644 --- a/docs/native-gen2-naming.md +++ b/docs/native-gen2-naming.md @@ -17,6 +17,9 @@ presenters can observe and draw its actual rows, cursor, text and field size. Name presets temporarily make the naming screen transparent and suppress its keyboard until NEW NAME is chosen; the original parent remains underneath. +Suppression belongs to the active instance, so class-level external presenters +cannot draw the inactive keyboard through their wrappers. NEW NAME restores +the original instance/class presentation methods. Native imported sprite and optional menu-art records can supply the header, backdrop and retained cursor quads. Mail entry keeps both sixteen-glyph rows above its keyboard. Native Oak and rival/name-rater calls carry their actual @@ -33,8 +36,9 @@ ROM-free tests cover actual stack/menu input, name types and glyph limits, caller/native completion ownership, preset underlay suppression, Gen1 retention and actual Loader clients through native API1/2. GPU acceptance and full reference raster/helper parity remain separate: -ROM naming-program/menu-art extraction, exact cursor/header rendering and the -separate Gen2 NamePick portrait animation are still open. This change does not +ROM naming-program/menu-art extraction and exact cursor/header rendering are +still open. The separate native portrait selector is described in +`native-gen2-namepick.md`; its exact-source GPU acceptance remains open. This change does not establish complete VASC, naming-story, save/link or full Gen1 parity. This offer includes the shared Gen2 menu chrome already offered in PR #224 diff --git a/src/ui/Gen2NamingScreen.lua b/src/ui/Gen2NamingScreen.lua index 241ca9a51..f00d0bc5b 100644 --- a/src/ui/Gen2NamingScreen.lua +++ b/src/ui/Gen2NamingScreen.lua @@ -155,13 +155,30 @@ function M.native(game,opts) args._popOnDone=true return M.new(game,args) end +function M:setChoosing(on) + if on then + self.choosing=true;self.isOpaque=false + -- A presenter may wrap the class draw methods without reading native + -- preset state. This owner has no keyboard surface until NEW NAME, so + -- suppress drawing on the instance, after constructor decoration. + self._presetDrawState={draw=rawget(self,'draw'),wide=rawget(self,'drawWidescreen')} + self.draw=function()end;self.drawWidescreen=function()end + else + self.choosing=nil;self.isOpaque=true + local previous=self._presetDrawState + if previous then + rawset(self,'draw',previous.draw);rawset(self,'drawWidescreen',previous.wide) + self._presetDrawState=nil + end + end +end function M:enter() if not(self.presets and #self.presets>0)then return end - self.choosing=true;self.isOpaque=false - local items={{label=Strings('NEW NAME'),onSelect=function()self.choosing=nil;self.isOpaque=true end}} + self:setChoosing(true) + local items={{label=Strings('NEW NAME'),onSelect=function()self:setChoosing(false)end}} for _,preset in ipairs(self.presets)do items[#items+1]={label=preset,onSelect=function() - self.text=preset;self:accept() + self:setChoosing(false);self.text=preset;self:accept() end} end self.game.stack:push(require('src.ui.Menu').new(self.game,items,{tx=4,ty=0,tw=12,th=#items*2+2,cancelable=false})) diff --git a/tests/engine/gen2_native_naming.lua b/tests/engine/gen2_native_naming.lua index c1b5ab400..23457562e 100644 --- a/tests/engine/gen2_native_naming.lua +++ b/tests/engine/gen2_native_naming.lua @@ -123,6 +123,20 @@ cursor:drawCursorBox(2,8,1);cursor:drawCursorBox(2,8,1);cursor:drawCursorBox(8,1 T.eq(quads,2,'cursor quads are retained across frames and target sizes') T.eq(draws,18,'letter corners and wide-target edges use the imported sheet') love.graphics.newQuad,love.graphics.draw=newQuad,draw +-- A real external presenter can replace both class draw methods. Preset +-- ownership must suppress those too, without discarding the presenter once +-- NEW NAME makes the native keyboard active. +local oldDraw,oldWide=UI.draw,UI.drawWidescreen +local presented=0 +UI.draw=function()presented=presented+1 end +UI.drawWidescreen=function()presented=presented+1 end +local g=game();local parent={};g.stack:push(parent) +local s=require('src.ui.NamingScreen').new(g,{presets={'ONE'}});g.stack:push(s) +s:draw();s:drawWidescreen(800,720);T.eq(presented,0,'external keyboard presenter is suppressed below presets') +tap(g,g.stack:top(),'a');s:draw();s:drawWidescreen(800,720) +T.eq(presented,2,'the original external presenter resumes for actual name entry') +T.eq(rawget(s,'draw'),nil);T.eq(rawget(s,'drawWidescreen'),nil) +UI.draw,UI.drawWidescreen=oldDraw,oldWide Version.set('red') T.eq(getmetatable(require('src.ui.NamingScreen').new(game(),{})),require('src.ui.NamingScreen'),'Gen1 retains its own keyboard') Version.set(edition);Screens.invalidate() From 9db5531ad433f3e297ab84fe33724e44c0af691b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:46:18 +0100 Subject: [PATCH 30/68] Share native BGP ownership and remap already-coloured palette frames --- docs/NATIVE-GEN2-PALETTE-REGISTER.md | 66 +++++++++++ src/render/Gen2PaletteDraw.lua | 154 +++++++++++++++++++++++-- src/render/PaletteFX.lua | 46 ++++++-- tests/engine/gen2_palette_draw.lua | 2 +- tests/engine/gen2_palette_register.lua | 97 ++++++++++++++++ tests/gpu/gen2_palette_register.lua | 89 ++++++++++++++ 6 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 docs/NATIVE-GEN2-PALETTE-REGISTER.md create mode 100644 tests/engine/gen2_palette_register.lua create mode 100644 tests/gpu/gen2_palette_register.lua diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md new file mode 100644 index 000000000..10d840c11 --- /dev/null +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -0,0 +1,66 @@ +# Native palette register and frame remapping + +The public `src.render.GbcPalette` module resolves to the engine's +`Gen2PaletteDraw`. Its byte-register API shares `PaletteFX`'s native shade map; +there is no second register owned by a compatibility facade. + +## Forward drawing + +`BGP_IDENTITY` is `0xe4`. `bgpShades(byte)` returns the four two-bit fields, +with colour zero first, in a one-based array. `remap(colors, byte)` applies +those indices to a four-row RGB palette. Nil and identity preserve the original +palette table. + +`setBgp(byte)` returns the previous byte. Identity is represented by nil. +Reading or assigning `GbcPalette.bgp` accesses this same native state; native +`PaletteFX.setShadeMap` writes are immediately visible through it. The existing +native frame reset clears it as well. + +`resolve(colors)` selects the display palette without applying the register. +`color`, `use`, `with` and keyed draws apply the current register once. +`useRaw` and `withRaw` bypass display selection and the register. Native +`PaletteFX.sendColors` consumes the same state. The separate native palette-upload optimization, when +present, consumes that state as well. + +## Already-coloured frames + +`remapTable(bgPalettes, byte, objPalettes, ramp, objRamped)` returns source +colours, destination colours, a live count and an ambiguity count. Tables hold +0–255 RGB triples, padded to `REMAP_MAX` (64) with detached rows. BG palettes +take the explicit byte permutation. OBJ palettes normally retain their colours. +An optional forced ramp supplies BG destinations; selected `objRamped` entries +take its unpermuted colours. + +Shared RGB values can no longer identify the original palette after composition. +The first BG interpretation wins; conflicting destinations increase the ambiguity +count. This is a limitation of remapping RGB frames, not proof of indexed +per-pixel equivalence. Extra entries beyond 64 are omitted with BG slots first. + +`remapUniforms(...)` builds normalized shader inputs, or returns nil for an empty +table. `useRemapUniforms(inputs)` and `useRemap(...)` bind the actual remap shader +and return `true, ambiguityCount` on success. Matching uses squared RGB distance +below `(3/255)^2`; unmatched pixels retain their RGB. Alpha and draw tint are +preserved. Compilation/upload failure returns false, retaining the previous +shader. A failed remap shader can recover when the graphics compiler changes. + +Public uniform uploads are unconditional. Nested `with`, `withRaw` and +`keyedWith` restore both shader identity and prior palette/remap uniforms, +including after a callback or partial upload fails. + +## Verification and remaining display work + +`tests/engine/gen2_palette_register.lua` checks all 256 bytes against independent +field decoding in Red, Gold, Silver and Crystal, native/public register ownership, +BG/OBJ remapping, collisions, forced ramps, bounds, uniform ownership and failures. +`tests/gpu/gen2_palette_register.lua` verifies actual pixels for all 256 bytes +through public and native shaders, including the optimized native upload path, +OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated +LÖVE host separately on each backend. It requires no cartridge or private mod. + +This change does not implement full Gen1 display parity. Native Gen2 still needs +the DMG/Classic/custom display selections, their cached tile/sprite/battle +invalidation and option persistence. Those require the real rendering owners; +adding setters that only change a public mode string would not implement them. + +The public method protocol follows the Gen1Recomp reference interface. Existing +Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index 8d3b93f2e..f84798c13 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -13,6 +13,10 @@ setmetatable(M, {__index = function(_, key) if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end return Palettes.mode == "classic" and "classic" or "gbc" end + if key == "bgp" then return Palettes.bgp() end +end, __newindex = function(self, key, value) + if key == "bgp" then Palettes.setBgp(value) + else rawset(self, key, value) end end}) local compiler, shaders, failed = nil, {}, {} local sent = setmetatable({}, {__mode = "k"}) @@ -27,26 +31,59 @@ vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { } ]] +-- A composited CGB frame has lost its palette indices. Match only colours +-- present in its BG/OBJ tables; unrelated RGB art must keep its own colour. +-- Constant loop bounds also work on GLSL ES uniform-array implementations. +M.REMAP_MAX = 64 +M.REMAP_TOLERANCE = (3 / 255) ^ 2 +local remapSource = [[ +extern int remapCount; +extern float remapTol; +extern vec3 remapSrc[64]; +extern vec3 remapDst[64]; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + vec3 rgb = pixel.rgb; + float closest = remapTol; + for (int i = 0; i < 64; i++) { + if (i >= remapCount) break; + vec3 delta = pixel.rgb - remapSrc[i]; + float distanceSquared = dot(delta, delta); + if (distanceSquared < closest) { + closest = distanceSquared; + rgb = remapDst[i]; + } + } + return vec4(rgb, pixel.a) * tint; +} +]] + local function graphics() return love and love.graphics end -local function shader(keyed) +local function compile(key, code, keyed) local g = graphics() local newShader = g and g.newShader if type(newShader) ~= "function" then return nil end if compiler ~= newShader then compiler, shaders, failed = newShader, {}, {} end - local key = keyed and "keyed" or "normal" if failed[key] then return nil end if not shaders[key] then - local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" - local ok, value = pcall(newShader, (source:gsub("ALPHA", alpha))) + if key ~= "remap" then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + code = (code:gsub("ALPHA", alpha)) + end + local ok, value = pcall(newShader, code) if not ok or not value then failed[key] = true; return nil end shaders[key] = value end return shaders[key] end +local function shader(keyed) + return compile(keyed and "keyed" or "normal", source, keyed) +end function M.shader() return shader(false) end function M.keyedShader() return shader(true) end +function M.remapShader() return compile("remap", remapSource) end function M.available() return M.shader() ~= nil end local function copy(colors) @@ -65,9 +102,13 @@ local function copy(colors) end return out end -function M.resolve(colors) return Palettes.effectiveColors(colors) end +M.BGP_IDENTITY = Palettes.BGP_IDENTITY +M.bgpShades = Palettes.bgpShades +M.remap = Palettes.remap +M.setBgp = Palettes.setBgp +function M.resolve(colors) return Palettes.displayColors(colors) end function M.color(colors, index) - local palette = M.resolve(colors) + local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) return palette and palette[index] or nil end @@ -84,7 +125,7 @@ end local function use(colors, keyed, raw) local g = graphics() if not (g and g.setShader) then return false end - colors = copy(raw and colors or M.resolve(colors)) + colors = copy(raw and colors or Palettes.effectiveColors(colors)) if not colors then return false end local sh = shader(keyed) if not sh then return false end @@ -100,12 +141,110 @@ function M.clear() if g and g.setShader then g.setShader() end end +-- The first BG slot wins when the same RGB value has different meanings. +-- Report those collisions rather than pretending an RGB frame retains its +-- original indices. OBJ rows normally map to themselves, protecting sprites. +function M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, seen, ambiguous = {}, {}, {}, 0 + local function key(c) + return math.floor(c[1]) .. "," .. math.floor(c[2]) .. "," .. math.floor(c[3]) + end + local function add(colors, mapped) + colors, mapped = copy(colors), copy(mapped) + if not (colors and mapped) then return end + for i = 1, 4 do + local from, to = colors[i], mapped[i] + local k = key(from) + local at = seen[k] + if at then + if key(dst[at]) ~= key(to) then ambiguous = ambiguous + 1 end + elseif #src < M.REMAP_MAX then + src[#src + 1], dst[#dst + 1] = from, to + seen[k] = #src + end + end + end + local forced = ramp and M.resolve(copy(ramp)) + local forcedBg = forced and M.remap(forced, byte) + for _, colors in ipairs(bgPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forcedBg or M.remap(resolved, byte)) + end + if forced then add(forced, forcedBg) end + for i, colors in ipairs(objPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forced and objRamped and objRamped[i] and forced or resolved) + end + local count = #src + for i = count + 1, M.REMAP_MAX do + src[i] = src[1] and {unpack(src[1])} or {0, 0, 0} + dst[i] = dst[1] and {unpack(dst[1])} or {0, 0, 0} + end + return src, dst, count, ambiguous +end +function M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, count, ambiguous = M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + if count == 0 then return nil end + for i = 1, M.REMAP_MAX do + for c = 1, 3 do src[i][c], dst[i][c] = src[i][c]/255, dst[i][c]/255 end + end + return {src=src, dst=dst, count=count, ambiguous=ambiguous} +end +local remapSent = setmetatable({}, {__mode = "k"}) +local function uploadRemap(sh, uniforms) + remapSent[sh] = nil + sh:send("remapCount", uniforms.count) + sh:send("remapTol", M.REMAP_TOLERANCE) + sh:send("remapSrc", unpack(uniforms.src)) + sh:send("remapDst", unpack(uniforms.dst)) + remapSent[sh] = uniforms +end +local function copyUniforms(uniforms) + if type(uniforms) ~= "table" or type(uniforms.count) ~= "number" + or uniforms.count < 1 or uniforms.count > M.REMAP_MAX + or uniforms.count % 1 ~= 0 then return nil end + local result = {count=uniforms.count, ambiguous=uniforms.ambiguous, src={}, dst={}} + for _, name in ipairs({"src", "dst"}) do + if type(uniforms[name]) ~= "table" then return nil end + for i = 1, M.REMAP_MAX do + local row = uniforms[name][i] + if type(row) ~= "table" then return nil end + result[name][i] = {} + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 1 then return nil end + result[name][i][c] = n + end + end + end + return result +end +function M.useRemapUniforms(uniforms) + local g = graphics() + if not (g and g.setShader) then return false end + local sh = M.remapShader() + uniforms = copyUniforms(uniforms) + if not (sh and uniforms) then return false end + local previous = remapSent[sh] + if not pcall(uploadRemap, sh, uniforms) then + if previous then pcall(uploadRemap, sh, previous) end + failed.remap, shaders.remap = true, nil + return false + end + g.setShader(sh) + return true, uniforms.ambiguous +end +function M.useRemap(bgPalettes, byte, objPalettes, ramp, objRamped) + return M.useRemapUniforms(M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped)) +end + local function scoped(colors, body, apply) local g = graphics() local previous = g and g.getShader and g.getShader() or nil -- Restoring only the shader object is insufficient: nested calls reuse it -- and change its uniforms. Save the palette values as well as its identity. local previousColors = previous and sent[previous] + local previousRemap = previous and remapSent[previous] local ok, applied = pcall(function() local active = apply(colors) body() @@ -113,6 +252,7 @@ local function scoped(colors, body, apply) end) local restored, why = pcall(function() if previousColors then upload(previous, previousColors) end + if previousRemap then uploadRemap(previous, previousRemap) end if g and g.setShader then g.setShader(previous) end end) if not ok then error(applied, 0) end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index fd6aee72e..ad57bc3ba 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -1129,6 +1129,40 @@ function PaletteFX.shadeMap() return shadeMap end +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public byte-register access and native frame drawing share one state. +-- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; +-- a byte cannot silently survive a menu, map or battle frame. +PaletteFX.BGP_IDENTITY = 0xe4 +function PaletteFX.bgpShades(byte) + byte = byte or PaletteFX.BGP_IDENTITY + local out = {} + for i = 0, 3 do out[i + 1] = math.floor(byte / 4 ^ i) % 4 end + return out +end +function PaletteFX.bgp() + if not shadeMap then return nil end + local byte = 0 + for i = 0, 3 do byte = byte + shadeMap[i] * 4 ^ i end + return byte ~= PaletteFX.BGP_IDENTITY and byte or nil +end +function PaletteFX.setBgp(byte) + local previous = PaletteFX.bgp() + if byte == nil or byte == PaletteFX.BGP_IDENTITY then + PaletteFX.setShadeMap(nil) + else + local shades = PaletteFX.bgpShades(byte) + PaletteFX.setShadeMap({[0]=shades[1], shades[2], shades[3], shades[4]}) + end + return previous +end +function PaletteFX.remap(colors, byte) + if not colors or not byte or byte == PaletteFX.BGP_IDENTITY then return colors end + local shades, out = PaletteFX.bgpShades(byte), {} + for i = 1, 4 do out[i] = colors[shades[i] + 1] or colors[4] end + return out +end + function PaletteFX.setMode(mode) local prev = PaletteFX.mode local ok = false @@ -1217,7 +1251,7 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c @@ -1233,12 +1267,10 @@ function PaletteFX.effectiveColors(c) elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end - -- The shade map goes on LAST: rBGP is a hardware register write, so it - -- composes on top of whatever colors the display mode settled on -- a dark - -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too - -- (#322). permute() is the identity (and returns `out` itself, which the - -- mod-graphics parity check leans on) while nothing has armed one. - return PaletteFX.permute(out, shadeMap) + return out +end +function PaletteFX.effectiveColors(c) + return PaletteFX.permute(PaletteFX.displayColors(c), shadeMap) end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index 76de1532e..c8aeef1d8 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -70,7 +70,7 @@ for _,game in ipairs({'red','gold','silver','crystal'})do T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') local trueColor=true T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') - T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') + T.same(G.resolve(A),P.displayColors(A),game..' '..mode..' resolves mode without consuming the register') G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') diff --git a/tests/engine/gen2_palette_register.lua b/tests/engine/gen2_palette_register.lua new file mode 100644 index 000000000..bee18ef2d --- /dev/null +++ b/tests/engine/gen2_palette_register.lua @@ -0,0 +1,97 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local G=require('src.render.GbcPalette') +local P=require('src.render.PaletteFX') +local V=require('src.core.GameVersion') +local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} +local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} +local R={{229,199,167},{179,127,79},{109,61,23},{47,19,5}} +local active,fail +local function compiler(code) + return {code=code,values={},send=function(self,name,...) + if fail==name then fail=nil;error('driver rejected uniform')end + local args={...};local out={} + for i,v in ipairs(args)do out[i]=type(v)=='table'and{unpack(v)}or v end + self.values[name]=out + end} +end +love.graphics={newShader=compiler,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.BGP_IDENTITY,0xe4,'public identity byte') +T.same(G.bgpShades(),{0,1,2,3},'omitted byte is identity') +for _,edition in ipairs({'red','gold','silver','crystal'})do + V.set(edition);P.setMode('gbc');P.setShadeMap(nil) + for byte=0,255 do + local fields,expected={},{} + for i=0,3 do fields[i+1]=math.floor(byte/4^i)%4;expected[i+1]=A[fields[i+1]+1]end + local label=edition..' byte '..byte + T.same(G.bgpShades(byte),fields,label..' reads all two-bit fields') + T.same(G.remap(A,byte),expected,label..' explicit palette permutation') + local before=G.bgp + T.eq(G.setBgp(byte),before,label..' returns actual previous register') + T.eq(G.bgp,byte~=0xe4 and byte or nil,label..' public byte reflects native state') + T.same(P.effectiveColors(A),expected,label..' native renderer receives permutation') + T.eq(G.resolve(A),A,label..' inspecting colours does not consume BGP') + T.same(G.color(A,2),expected[2],label..' solid colours consume it exactly once') + G.use(A) + for i=1,4 do T.same(active.values['pal'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' indexed draw row '..i)end + local native=compiler('native');P.sendColors(native,A) + for i=1,4 do T.same(native.values['c'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' native uniform row '..i)end + end + P.setShadeMap({[0]=2,3,0,1});T.eq(G.bgp,0x4e,edition..' native register write visible publicly') + G.bgp=0xf9;T.eq(P.bgp(),0xf9,edition..' legacy property writes native register') + G.bgp=nil;T.eq(P.shadeMap(),nil,edition..' legacy nil clears native state') + G.setBgp(0);P.setShadeMap(nil);T.eq(G.bgp,nil,edition..' native frame reset clears public register') +end +T.eq(G.remap(A,nil),A,'nil remap retains identity') +T.eq(G.remap(A,0xe4),A,'identity remap retains table identity') +T.eq(G.remap(nil,0xf9),nil,'missing palette stays missing') +V.set('crystal');P.setShadeMap(nil) +local src,dst,count,ambiguous=G.remapTable({A},0xf9,{B}) +T.eq(count,8,'BG and OBJ rows both recognized') +T.eq(ambiguous,0,'disjoint palette mapping exact') +for i=1,4 do T.same(src[i],A[i],'BG source row');T.same(dst[i],A[({2,3,4,4})[i]],'BG byte applies own palette');T.same(dst[i+4],B[i],'OBJ row stays unchanged')end +T.eq(#src,64,'source bounded and padded');T.eq(#dst,64,'destination bounded and padded') +T.neq(src[9],src[1],'padding owns its row');T.same(src[9],src[1],'padding repeats first colour') +local _,collisionDst,_,collisions=G.remapTable({A},0xff,{A}) +T.eq(collisions,3,'overlapping BG/OBJ collision count is explicit') +T.same(collisionDst[1],A[4],'first BG interpretation wins') +local rampSrc,rampDst,rampCount,rampAmbiguous=G.remapTable({A},0xf9,{B},R,{true}) +T.eq(rampCount,12,'forced ramp included as already drawn source') +T.eq(rampAmbiguous,0,'disjoint forced ramp exact') +T.same(rampDst[1],R[2],'forced BG takes explicit byte') +T.same(rampDst[9],R[1],'ramped OBJ uses unpermuted forced ramp') +local _,unramped=G.remapTable({A},0xf9,{B},R) +T.same(unramped[9],B[1],'unramped OBJ retains original colour') +G.setBgp(0xff) +local u=G.remapUniforms({A},0xf9,{B}) +T.same(u.src[1],{A[1][1]/255,A[1][2]/255,A[1][3]/255},'explicit backward byte ignores standing forward register') +T.same(u.dst[1],{A[2][1]/255,A[2][2]/255,A[2][3]/255},'backward byte consumed once') +G.setBgp(nil) +local ok,n=G.useRemapUniforms(u);T.check(ok,'remap shader applies');T.eq(n,0,'successful apply reports ambiguity') +local sh=active;T.eq(sh,G.remapShader(),'actual remap shader active') +T.check(sh.code:find('vec4(rgb, pixel.a) * tint',1,true)~=nil,'backward shader retains alpha and draw tint') +T.same(sh.values.remapSrc,u.src,'all normalized source entries uploaded') +T.same(sh.values.remapDst,u.dst,'all normalized destination entries uploaded') +T.same(sh.values.remapCount,{8},'bounded live count uploaded') +local first=u.src[1][1];u.src[1][1]=0 +G.withRaw(A,function()G.useRemap({B},0xff)end) +T.eq(active,sh,'nested draw restores remap shader') +T.eq(sh.values.remapSrc[1][1],first,'nested draw restores detached original remap uniforms') +local did,why=pcall(function()G.withRaw(A,function()G.useRemap({B},0xff);error('draw failure',0)end)end) +T.check(not did and why=='draw failure','callback failure propagates') +T.eq(active,sh,'error restores remap shader');T.eq(sh.values.remapSrc[1][1],first,'error restores remap uniforms') +T.eq(G.useRemap({}),false,'empty palette leaves shader alone');T.eq(active,sh,'empty palette keeps prior shader') +local rows={};for p=1,20 do rows[p]={{p,1,1},{p,2,2},{p,3,3},{p,4,4}}end +local hugeSrc,hugeDst,hugeCount=G.remapTable(rows,0xff) +T.eq(hugeCount,64,'overfull palette tables bounded');T.eq(#hugeSrc,64,'source cannot overflow shader');T.eq(#hugeDst,64,'destination cannot overflow shader') +T.eq(G.remapUniforms({false,{},{{1,2,3}}},0xf9),nil,'malformed palette rows decline safely') +T.eq(G.useRemapUniforms({count=65,src={},dst={}}),false,'oversized borrowed uniforms rejected') +fail='remapDst';T.eq(G.useRemap({B},0xff),false,'driver upload failure declines effect') +T.eq(active,sh,'driver failure keeps previous shader');T.eq(sh.values.remapSrc[1][1],first,'partial driver failure restores previous uniforms') +T.eq(G.remapShader(),nil,'failed remap shader disabled for current compiler') +love.graphics.newShader=function(code)return compiler(code)end +T.check(G.useRemap({A},0xf9),'new compiler recovers from failed upload') +love.graphics={};T.eq(G.useRemap({A},0xf9),false,'headless remap declines effect') +T.finish('native palette byte register and composited BG/OBJ remapping') diff --git a/tests/gpu/gen2_palette_register.lua b/tests/gpu/gen2_palette_register.lua new file mode 100644 index 000000000..72814580b --- /dev/null +++ b/tests/gpu/gen2_palette_register.lua @@ -0,0 +1,89 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- ROM-free pixel verification in a separate LÖVE host on each backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local version, mode, map = V.get(), P.mode, P.shadeMap() + local g = love.graphics + local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} + local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} + local C={107,149,207} + local shades = love.image.newImageData(4,1,'rgba8') + local rgba = love.image.newImageData(9,1,'rgba8') + for i=0,3 do + local n=1-i/3;shades:setPixel(i,0,n,n,n,1-i/5) + for p,colors in ipairs({A,B}) do + local c=colors[i+1];rgba:setPixel((p-1)*4+i,0,c[1]/255,c[2]/255,c[3]/255,1-i/5) + end + end + rgba:setPixel(8,0,C[1]/255,C[2]/255,C[3]/255,0.4) + local indexed, colored = g.newImage(shades), g.newImage(rgba) + indexed:setFilter('nearest','nearest');colored:setFilter('nearest','nearest') + local canvas=g.newCanvas(17,257,{format='rgba8',dpiscale=1}) + local checks=0 + local function check(ok,why) checks=checks+1;assert(ok,why) end + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setMode('gbc') + g.setCanvas(canvas);g.clear(0,0,0,0);g.origin() + g.setBlendMode('replace','premultiplied');g.setColor(0.5,0.75,1,0.8) + local native = assert(P.shader(),'native palette shader unavailable') + for byte=0,255 do + G.setBgp(byte) + check(G.use(A),'forward shader unavailable') + g.draw(indexed,0,byte) + -- Native PaletteFX owns the same register and the real optimized + -- userdata upload path, rather than just a fake public shader object. + P.sendColors(native,A);g.setShader(native);g.draw(indexed,4,byte) + -- The explicit backward byte is independent of the standing register. + G.setBgp(0xff) + local applied,ambiguous=G.useRemap({A},byte,{B}) + check(applied and ambiguous==0,'backward shader unavailable or ambiguous') + g.draw(colored,8,byte) + end + G.setBgp(nil) + local display=P.displayColors + P.displayColors=function()return B end + local changed,changeError=pcall(function() + if P._beginColorBatch then check(P._beginColorBatch(native)==nil,'overridden display resolver entered a trusted native batch') end + P.sendColors(native,A);g.setShader(native);g.draw(indexed,0,256) + end) + P.displayColors=display + if not changed then error(changeError,0) end + g.setCanvas();g.setShader() + local pixels=g.readbackTexture and g.readbackTexture(canvas) or canvas:newImageData() + for byte=0,255 do + for x=0,16 do + local color,alpha + if x<8 then + local shade=x%4;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<12 then + local shade=x-8;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<16 then color,alpha=B[x-11],1-(x-12)/5 + else color,alpha=C,0.4 end + local wanted={color[1]/255*0.5,color[2]/255*0.75,color[3]/255,alpha*0.8} + -- PaletteFX's native final-zone shader deliberately ignores the + -- draw tint. The public indexed/remap shaders compose with it. + if x>=4 and x<8 then wanted={color[1]/255,color[2]/255,color[3]/255,alpha} end + local actual={pixels:getPixel(x,byte)} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255, + 'register '..byte..' pixel '..x..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + end + for x=0,3 do + local actual={pixels:getPixel(x,256)} + local wanted={B[x+1][1]/255,B[x+1][2]/255,B[x+1][3]/255,1-x/5} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,'native upload ignored replaced display resolver')end + end + end,debug.traceback) + g.pop();P.setShadeMap(map);P.setMode(mode);V.set(version) + indexed:release();colored:release();shades:release();rgba:release() + if not ok then canvas:release();error(why,0) end + return {passed=true,checks=checks,scope='All 256 BGP bytes: native and public indexed draws, composited BG permutation, OBJ preservation, unrelated RGB preservation, alpha and tint'},canvas +end +return M From ab7c7508f3bb43c7a6156433791c1aab59088690 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:28:25 +0100 Subject: [PATCH 31/68] Apply native Gen2 display selection and complete-frame palette presentation --- docs/NATIVE-GEN2-DISPLAY.md | 64 +++++++++++ docs/NATIVE-GEN2-PALETTE-REGISTER.md | 12 +- src/battle/BattleState.lua | 30 ++++- src/core/Game.lua | 8 +- src/render/Gen2PaletteDraw.lua | 25 ++++- src/render/Gen2Presentation.lua | 79 +++++++++++++ src/render/Palette.lua | 136 +++++++++++++++++++++++ src/render/PaletteFX.lua | 159 ++++++++++++++++++++------- src/render/Renderer.lua | 51 ++++++--- src/render/SpriteRenderer.lua | 5 + src/render/TileRenderer.lua | 11 +- src/ui/OptionsMenu.lua | 15 ++- tests/engine/gen2_display_loader.lua | 45 ++++++++ tests/engine/gen2_display_modes.lua | 138 +++++++++++++++++++++++ tests/engine/gen2_palette_draw.lua | 2 +- tests/engine/palette_library.lua | 50 +++++++++ tests/gpu/gen2_display_modes.lua | 153 ++++++++++++++++++++++++++ 17 files changed, 905 insertions(+), 78 deletions(-) create mode 100644 docs/NATIVE-GEN2-DISPLAY.md create mode 100644 src/render/Gen2Presentation.lua create mode 100644 src/render/Palette.lua create mode 100644 tests/engine/gen2_display_loader.lua create mode 100644 tests/engine/gen2_display_modes.lua create mode 100644 tests/engine/palette_library.lua create mode 100644 tests/gpu/gen2_display_modes.lua diff --git a/docs/NATIVE-GEN2-DISPLAY.md b/docs/NATIVE-GEN2-DISPLAY.md new file mode 100644 index 000000000..05e36f705 --- /dev/null +++ b/docs/NATIVE-GEN2-DISPLAY.md @@ -0,0 +1,64 @@ +# Native Gen2 display selection + +Gen2 display selection belongs to `PaletteFX`, the same owner used by the +native tile, sprite and battle renderers. `GbcPalette` exposes the Gen1Recomp +public vocabulary over that state: `gbc`, `dmg`, `classic`, and `custom`. +The generic native ID for DMG remains `og`, preserving existing option files. + +GBC uses the cartridge's indexed palettes. DMG retains the four original +indexed shades as grayscale. Classic uses that same grayscale intermediate +and maps the completed frame through the green ramp once. A custom ramp +replaces indexed palette rows; already-coloured art keeps its existing +true-colour contract. The unrelated Gen1 colourization packs and Gen3 +display behaviour retain their existing paths. + +`setMode`, `cycle(delta)`, `setCustomRamp`, `applyOptions`, `modeLabel`, and +`presentColors` are available through `GbcPalette`. The mode, custom ramp and +byte BGP all share native state. Raw indexed drawing bypasses display/BGP +selection. A custom ramp takes priority over the selected indexed mode, as +in the reference protocol; Classic's final presentation remains a separate +operation. Missing custom ramps fall back to grayscale. + +`PaletteFX.setDisplay(mode, ramp)` applies a selection atomically. It invalidates +tile and animation textures, OBJ images, battle images and cached maps, then +reloads the entered map. The shared `Assets.register` invalidation bus also +notifies external texture producers, including repeated ramps with the same mode +ID. Retained battle image references resolve through their +original palette and crop on the next draw. Reapplying an edited ramp table +also updates its key and invalidates dependent caches. Transient BGP writes do +not enter static atlas cache keys. Mono/custom world bakes retain cave darkness; +battle palette permutations and blackout apply after display selection. + +Native Gen2 options and hotkey 2 use the GBC → DMG → Classic ladder, with a +backward step supported. Cycling clears a selected custom ramp and saved +palette ID. Native `options.colors` and legacy `options.color` record the same +selection. Applying legacy mode plus `options.palette` performs one rebuild. + +`src.render.Palette` supplies the public palette ID/library operations. Mono IDs +are `m:rrggbb`; named IDs are `p:group/name`. An optional authored +`data.gb_palettes` module supplies named entries; no palette catalogue is +invented or extracted by this library. Categories, labels, lookup, swatches, +ramp caching and invalidation use the reference vocabulary. + +## Completed-frame zones and composition + +For native Gen2 gameplay, `render.zones` receives the final display zones once, +after native drawing has settled its palettes. Classic starts with one green +whole-frame zone; other modes start with nil. Consumers can replace, remove or +extend those zones. Each dispatch gets its own list. This final pass uses raw +palette destinations so Classic cannot resolve its own green ramp back to gray. + +The native Gen2 compositor first combines world and UI into one scene. Its +window-menu callback includes a window-resolution drawer when one is supplied; +this offer does not add the separate native window-menu classes or a palette-picker UI. `render.compose` then receives that completed texture in +`sceneCanvas`, `worldCanvas` and `uiCanvas`, with `generation = 2`, the native +renderer and an explicit `game` owner. The render target is different from the +source texture. A consumer taking composition ownership also owns presentation +of the supplied final zones; `Renderer:blitZones` provides the raw zone blit. +Otherwise the engine applies final zones before present/output effects. HUD and +touch controls retain their screen-space placement outside this game scene. + +This is display work toward Gen1 parity. It does not establish complete UI +raster parity, a full unchanged-mod playthrough, save/link acceptance, hardware +performance or full engine parity. Existing upstream/Gen1 licences and the +[Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) remain applicable. diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md index 10d840c11..9f5188eed 100644 --- a/docs/NATIVE-GEN2-PALETTE-REGISTER.md +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -19,8 +19,8 @@ native frame reset clears it as well. `resolve(colors)` selects the display palette without applying the register. `color`, `use`, `with` and keyed draws apply the current register once. `useRaw` and `withRaw` bypass display selection and the register. Native -`PaletteFX.sendColors` consumes the same state. The separate native palette-upload optimization, when -present, consumes that state as well. +`PaletteFX.sendColors` consumes the same state. The separate palette-upload +optimization uses this owner when present. ## Already-coloured frames @@ -57,10 +57,10 @@ through public and native shaders, including the optimized native upload path, OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated LÖVE host separately on each backend. It requires no cartridge or private mod. -This change does not implement full Gen1 display parity. Native Gen2 still needs -the DMG/Classic/custom display selections, their cached tile/sprite/battle -invalidation and option persistence. Those require the real rendering owners; -adding setters that only change a public mode string would not implement them. +This register change alone does not establish full Gen1 display parity. See +`NATIVE-GEN2-DISPLAY.md` for the native display-selection and cache/presentation +owners. Full unchanged-mod story/save/link acceptance and wider engine parity +remain separate requirements. The public method protocol follows the Gen1Recomp reference interface. Existing Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..ec68eed25 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -639,7 +639,16 @@ local imageMeta = setmetatable({}, WEAK_KEYS) local function getImage(path, pal, trueColor, crop) if not path then return nil end if trueColor then pal = nil end + local sourcePal, displayKey = pal + if GameVersion.isGen2() and not trueColor and pal then + local display = require("src.render.PaletteFX") + displayKey = display.displayKey() + if not pal.displayResolved then + pal = {name=pal.name, colors=display.displayColors(pal.colors), displayResolved=true} + end + end local key = pal and (path .. "#" .. pal.name) or path + if displayKey then key = key .. "#display:" .. displayKey end if crop then key = key .. "@" .. crop.index end if not imageCache[key] then local img, pad, padL = nil, 0, 0 @@ -693,7 +702,7 @@ local function getImage(path, pal, trueColor, crop) -- crop rides along so fadeImage / grayImage / blackImage re-bake the -- SAME cell of the strip, not the whole strip imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil, - crop = crop } + crop = crop, sourcePal = sourcePal, displayKey = displayKey } end return imageCache[key] end @@ -820,7 +829,7 @@ local function fadeImage(img, bgp) local name = (meta.pal and meta.pal.name or "GB") .. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3] return getImage(meta.path, - { name = name, colors = PaletteFX.permute(base, bgp) }, + { name = name, colors = PaletteFX.permute(base, bgp), displayResolved=GameVersion.isGen2() }, nil, meta.crop) end @@ -848,8 +857,12 @@ local function blackImage(data, img) local pack = PaletteFX.pack(data) local colors = pack and pack.palettes and pack.palettes.BLACK if not colors then return img end + if GameVersion.isGen2() then + local ramp = PaletteFX.displayColors(colors) + colors = {ramp[4],ramp[4],ramp[4],ramp[4]} + end local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK" - return getImage(meta.path, { name = name, colors = colors }, nil, meta.crop) + return getImage(meta.path, { name = name, colors = colors, displayResolved=GameVersion.isGen2() }, nil, meta.crop) or img end @@ -864,6 +877,15 @@ end -- the image a battler pic actually draws with this frame function BattleState:picImage(img) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + local meta = imageMeta[img] + -- Battlers retain image references across a live option change. Dropping + -- imageCache alone does not replace those pictures or animation cells. + if meta and not meta.trueColor and meta.sourcePal + and meta.displayKey ~= PaletteFX.displayKey() then + img = getImage(meta.path, meta.sourcePal, nil, meta.crop) or img + end + end -- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that -- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone -- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the @@ -878,7 +900,7 @@ function BattleState:picImage(img) -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" - if self.grayPics or mono then return grayImage(img) end + if self.grayPics or (mono and not GameVersion.isGen2()) then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the diff --git a/src/core/Game.lua b/src/core/Game.lua index 0ebe75e81..60bd2784c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1171,7 +1171,11 @@ function Game:_draw() end -- 14's render.zones: weather/lighting overlays and custom colorization -- recolor or add zones before the blit - if ModRuntime.wantsHook("render.zones") then + if require("src.core.GameVersion").isGen2() then + Renderer.gen2FrameManaged = true + Renderer.gen2FrameGame = self + Renderer.gen2FrameZones = require("src.render.Gen2Presentation").zones(self) + elseif ModRuntime.wantsHook("render.zones") then zones = ModRuntime.call("render.zones", sameZones, self, zones) end -- Keyed to whether the map actually DREW, not to whether it is the clear's @@ -1408,7 +1412,7 @@ function Game:keypressed(key) or ow.engaging or ow.emote))) if not busy then local PaletteFX = require("src.render.PaletteFX") - self.save.options.colors = PaletteFX.cycleMode() + PaletteFX.writeColorOption(self.save.options, PaletteFX.cycleMode()) self:writeOptions() end return diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index f84798c13..481165c12 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -1,21 +1,23 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Indexed image drawing, separate from the LCD post-process in GBCFX. -- Palette resolution uses the native display/register state in PaletteFX. local Palettes = require("src.render.PaletteFX") local Version = require("src.core.GameVersion") local M = {} --- Consumers use this view to leave already-coloured pictures untouched. --- Gen2 currently resolves native ROM colours regardless of the Gen1 mode. --- Do not invent a second mutable display setting for the external renderer. +-- Display state belongs to the same owner as the native map/OBJ/pic bakes. setmetatable(M, {__index = function(_, key) if key == "mode" then - if Version.isGen2() then return "gbc" end + if Version.isGen2() and Palettes.mode == "custom" then return "custom" end if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end return Palettes.mode == "classic" and "classic" or "gbc" end if key == "bgp" then return Palettes.bgp() end + if key == "customRamp" then return Palettes.customRamp() end end, __newindex = function(self, key, value) if key == "bgp" then Palettes.setBgp(value) + elseif key == "mode" then M.setMode(value) + elseif key == "customRamp" then Palettes.setCustomRamp(value) else rawset(self, key, value) end end}) local compiler, shaders, failed = nil, {}, {} @@ -106,6 +108,19 @@ M.BGP_IDENTITY = Palettes.BGP_IDENTITY M.bgpShades = Palettes.bgpShades M.remap = Palettes.remap M.setBgp = Palettes.setBgp +M.MODES = {"gbc", "dmg", "classic"} +M.MODE_LABELS = {gbc="GEN 2", dmg="DMG", classic="CLASSIC", custom="CUSTOM"} +M.CUSTOM_MODE = "custom" +M.setCustomRamp = Palettes.setCustomRamp +M.presentColors = Palettes.presentColors +function M.setMode(mode) + if mode~="gbc" and mode~="dmg" and mode~="classic" and mode~="custom" then mode="gbc" end + Palettes.setMode(mode) + return M.mode +end +function M.cycle(delta) Palettes.cycleMode(delta); return M.mode end +function M.applyOptions(opts) Palettes.applyOptions(opts); return M.mode end +function M.modeLabel(mode) return M.MODE_LABELS[mode or M.mode] or "GEN 2" end function M.resolve(colors) return Palettes.displayColors(colors) end function M.color(colors, index) local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) diff --git a/src/render/Gen2Presentation.lua b/src/render/Gen2Presentation.lua new file mode 100644 index 000000000..017a877da --- /dev/null +++ b/src/render/Gen2Presentation.lua @@ -0,0 +1,79 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Gen2 display zones belong to the completed colour frame, after indexed +-- native BG/OBJ/UI bakes and before present/output effects. In Classic those +-- bakes are grayscale and this pass supplies the green ramp exactly once. +local P = require("src.render.PaletteFX") +local Gbc = require("src.render.Gen2PaletteDraw") +local Runtime = require("src.mods.Runtime") +local M = {} +local target +local function identity(_, zones) return zones end +function M.zones(game) + local colors = P.presentColors() + local zones = colors and {{x=0,y=0,w=160,h=144,colors=colors}} or nil + if Runtime.wantsHook("render.zones") then + zones = Runtime.call("render.zones", identity, game, zones) + end + return zones +end +local function finite(n) return type(n)=="number" and n==n and math.abs(n)x1 and y2>y1 then + g.setScissor(x1,y1,x2-x1,y2-y1) + if zone.colors==false then g.setShader();g.draw(source,0,0) + else Gbc.withRaw(zone.colors,function()g.draw(source,0,0)end) end + end + end + end,debug.traceback) + g.pop() + if not ok then error(why,0) end + end) +end +function M.apply(source, zones, w, h) + if not (type(zones)=="table" and zones[1]) then return source end + local g=love.graphics + if not (g.newCanvas and Gbc.available()) then return source end + local dpi=source.getDPIScale and source:getDPIScale() or 1 + if not target or target:getWidth()~=w or target:getHeight()~=h + or (target.getDPIScale and target:getDPIScale()~=dpi) then + if target then target:release() end + target=g.newCanvas(w,h,{format="normal",dpiscale=dpi,msaa=0}) + target:setFilter("nearest","nearest") + end + local previous={g.getCanvas()} + Gbc.withRaw(nil,function() + g.push("all") + local ok,why=xpcall(function() + g.setCanvas(target);g.origin();g.setShader();g.setScissor() + g.setColorMask(true,true,true,true);g.setStencilTest();g.setDepthMode() + g.setBlendMode("alpha","premultiplied");g.clear(0,0,0,1) + M.blit(source,zones,w,h) + end,debug.traceback) + g.setCanvas(unpack(previous));g.pop() + if not ok then error(why,0) end + end) + return target +end +function M.reset() + if target then target:release();target=nil end +end +return M diff --git a/src/render/Palette.lua b/src/render/Palette.lua new file mode 100644 index 000000000..6982156b2 --- /dev/null +++ b/src/render/Palette.lua @@ -0,0 +1,136 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Palette IDs and category vocabulary follow Gen1Recomp's public protocol. +-- An optional authored data.gb_palettes library supplies named ramps; this +-- module contains no cartridge palette catalogue or private renderer data. +local P = {} +P.MONO_COLOURS = { + {"GREEN",155,188,15}, {"LIME",168,216,56}, {"OLIVE",166,172,132}, + {"AMBER",255,176,0}, {"ORANGE",255,128,48}, {"RED",255,80,80}, + {"ROSE",255,144,176}, {"MAGENTA",240,96,208}, {"PURPLE",176,112,240}, + {"INDIGO",128,128,240}, {"BLUE",80,160,255}, {"CYAN",64,224,224}, + {"TEAL",64,192,160}, {"MINT",144,224,176}, {"SAND",224,200,144}, + {"BROWN",192,144,80}, {"WHITE",255,255,255}, {"SILVER",208,216,224}, +} +P.GREY_CHROMA, P.HUE_ARC = 12, 50 +P.CATEGORIES = {{key="full",label="FULL COLOUR"},{key="single",label="SINGLE COLOUR"},{key="grey",label="GREYSCALE"}} +local library, index, buckets, lastId, lastRamp +function P.packs() + if library == nil then + local ok, value = pcall(require, "data.gb_palettes") + library = ok and type(value) == "table" and value or false + end + return library or {} +end +function P.packId(group, name) return "p:" .. group .. "/" .. name end +function P.monoId(r,g,b) return ("m:%02x%02x%02x"):format(r,g,b) end +function P.monoRamp(r,g,b) + local out = {} + for i, scale in ipairs({1,0.72,0.38,0.12}) do + out[i] = {math.floor(r*scale+0.5),math.floor(g*scale+0.5),math.floor(b*scale+0.5)} + end + return out +end +function P.unhex(text) + if type(text) ~= "string" or #text ~= 24 or text:find("[^%x]") then return nil end + local out = {} + for i=1,4 do + out[i] = {} + for c=1,3 do local at=(i-1)*6+(c-1)*2+1;out[i][c]=tonumber(text:sub(at,at+1),16) end + end + return out +end +function P.hueChroma(color) + local r,g,b=color[1] or 0,color[2] or 0,color[3] or 0 + local hi,lo=math.max(r,g,b),math.min(r,g,b) + local c=hi-lo + if c<=0 then return 0,0 end + local h + if hi==r then h=((g-b)/c)%6 elseif hi==g then h=(b-r)/c+2 else h=(r-g)/c+4 end + return h*60,c +end +function P.hueArc(hues) + local span=360 + for _,start in ipairs(hues) do + local width=0 + for _,hue in ipairs(hues) do width=math.max(width,(hue-start)%360) end + span=math.min(span,width) + end + return span +end +function P.categoryOf(ramp) + if type(ramp)~="table" or not ramp[4] then return nil end + local hues={} + for i=1,4 do + local h,c=P.hueChroma(ramp[i]) + if c>P.GREY_CHROMA then hues[#hues+1]=h end + end + if #hues==0 then return "grey" end + return P.hueArc(hues)<=P.HUE_ARC and "single" or "full" +end +local function ensureIndex() + if index then return end + index,buckets={},{full={},single={},grey={}} + for _,pack in ipairs(P.packs()) do + for _,entry in ipairs(pack.palettes or {}) do + if type(entry[1])=="string" then + local id=P.packId(pack.name,entry[1]);index[id]=entry + local ramp=P.unhex(entry[2]);local category=ramp and P.categoryOf(ramp) + if category then + local bucket=buckets[category] + bucket[#bucket+1]={name=entry[1],pack=pack.name,id=id} + end + end + end + end +end +function P.findPack(group,name) ensureIndex();return index[P.packId(group,name)] end +function P.category(key) + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + if category.key==key then return {key=key,name=category.label,palettes=buckets[key]} end + end +end +function P.categories() + local out={} + for _,category in ipairs(P.CATEGORIES) do out[#out+1]=P.category(category.key) end + return out +end +function P.locate(id) + if type(id)~="string" or id=="" then return nil end + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + for i,palette in ipairs(buckets[category.key]) do if palette.id==id then return category.key,i end end + end +end +function P.label(id) + if type(id)~="string" or id=="" then return nil end + local hex=id:match("^m:(%x+)$") + if hex then + local r,g,b=tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16) + for _,color in ipairs(P.MONO_COLOURS) do if r==color[2] and g==color[3] and b==color[4] then return color[1] end end + return "MONO" + end + return id:match("^p:.*/([^/]+)$") or id +end +function P.ramp(id) + if type(id)~="string" or id=="" then return nil end + if id==lastId then return lastRamp end + local ramp + local hex=id:match("^m:(%x%x%x%x%x%x)$") + if hex then ramp=P.monoRamp(tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16)) + else + local group,name=id:match("^p:(.*)/([^/]+)$") + local entry=group and P.findPack(group,name) + local raw=entry and P.unhex(entry[2]) + if raw then ramp={raw[4],raw[3],raw[2],raw[1]} end + end + lastId,lastRamp=id,ramp + return ramp +end +function P.swatch(id) + local ramp=P.ramp(id) + if ramp then return {ramp[4],ramp[3],ramp[2],ramp[1]} end +end +function P.invalidate() index,buckets,lastId,lastRamp=nil,nil,nil,nil end +return P diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index ad57bc3ba..5cea28f01 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -44,6 +44,31 @@ PaletteFX.MODE_LABELS = { } PaletteFX.mode = "gbc" +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +PaletteFX.GEN2_MODES = {"gbc", "og", "classic"} +local customRamp, customKey = nil, "" +function PaletteFX.customRamp() return customRamp end +function PaletteFX.displayKey() return tostring(PaletteFX.mode) .. customKey end +function PaletteFX.colorModes() + return GameVersion.isGen2() and PaletteFX.GEN2_MODES or PaletteFX.MODES +end +local function rampKey(ramp) + if ramp == nil then return "" end + if type(ramp) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local row = ramp[i] + if type(row) ~= "table" then return nil end + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + out[#out + 1] = ("%.17g"):format(n) + end + end + return "#ramp:" .. table.concat(out, ",") +end + -- ------- dark-cave state (wMapPalOffset) -- -- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED @@ -421,6 +446,7 @@ local GEN2_SHADE_MODES = { } function PaletteFX.usesGen2ObjPal(mode) + if GameVersion.isGen2() then return true end mode = mode or PaletteFX.mode return not GEN2_SHADE_MODES[mode] end @@ -1129,7 +1155,8 @@ function PaletteFX.shadeMap() return shadeMap end --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Public byte-register access and native frame drawing share one state. -- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; -- a byte cannot silently survive a menu, map or battle frame. @@ -1163,54 +1190,89 @@ function PaletteFX.remap(colors, byte) return out end -function PaletteFX.setMode(mode) - local prev = PaletteFX.mode - local ok = false - for _, m in ipairs(PaletteFX.MODES) do - if m == mode then - PaletteFX.mode = mode - ok = true - break +local function invalidateDisplay() + -- Register native producers before the shared flush. External producers + -- subscribe to the same Assets.register bus; private cache calls alone + -- leave their atlases stale when a second ramp keeps the same mode ID. + for _, name in ipairs({"src.battle.BattleState","src.render.SpriteRenderer", + "src.render.TileRenderer","src.world.MapLoader"}) do + pcall(require, name) + end + require("src.render.Assets").invalidate() + pcall(function() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") end + end) +end +local function normalizeMode(mode) + if GameVersion.isGen2() then + if mode == "dmg" then return "og" end + if mode == "custom" then return mode end end - if not ok then PaletteFX.mode = "gbc" end - -- battle pics and overworld sprites bake the active pack into ImageData; - -- drop those caches when the pack (or any COLORS mode) changes so the - -- next draw re-tints - if prev ~= PaletteFX.mode then - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) - -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once - -- per loaded map, so a mode toggle needs every cached Map/TileRenderer - -- dropped and the currently-visible one rebuilt in place -- otherwise - -- the on-screen map keeps its stale (wrong-mode) atlas until the next - -- map transition happens to reload it. - pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then - Game.overworld:reloadMap(Game.overworld.map.id, "colors") - end - end) + for _, m in ipairs(PaletteFX.MODES) do + if m == mode then return mode end end + return "gbc" +end +-- Set a complete selection atomically: applying persisted mode + ramp must +-- rebuild the entered map once, never briefly in the previous custom ramp. +function PaletteFX.setDisplay(mode, ramp) + local key = rampKey(ramp) + if key == nil then ramp, key = nil, "" end + mode = normalizeMode(mode) + local changed = mode ~= PaletteFX.mode or ramp ~= customRamp or key ~= customKey + PaletteFX.mode, customRamp, customKey = mode, ramp, key + if changed then invalidateDisplay() end + return mode +end +function PaletteFX.setMode(mode) + return PaletteFX.setDisplay(mode, customRamp) +end +function PaletteFX.setCustomRamp(ramp) + PaletteFX.setDisplay(PaletteFX.mode, ramp) + return customRamp end -function PaletteFX.cycleMode() +function PaletteFX.cycleMode(delta) local cur = PaletteFX.mode or "gbc" + if GameVersion.isGen2() and cur == "og_inv" then cur = "og" end + local modes = PaletteFX.colorModes() local idx = 1 - for i, m in ipairs(PaletteFX.MODES) do + for i, m in ipairs(modes) do if m == cur then idx = i; break end end - PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1]) + local nextMode = modes[(idx - 1 + (delta or 1)) % #modes + 1] + local ramp = customRamp + if GameVersion.isGen2() then ramp = nil end + PaletteFX.setDisplay(nextMode, ramp) return PaletteFX.mode end function PaletteFX.applyOptions(opts) - PaletteFX.setMode(opts and opts.colors or "gbc") + if GameVersion.isGen2() then + local ramp = opts and opts.palette and require("src.render.Palette").ramp(opts.palette) + return PaletteFX.setDisplay(opts and (opts.color or opts.colors) or "gbc", ramp) + end + return PaletteFX.setMode(opts and opts.colors or "gbc") +end +function PaletteFX.writeColorOption(opts, mode) + opts.colors = mode + if GameVersion.isGen2() then + opts.color = mode == "og" and "dmg" or mode + opts.palette = nil + end +end +function PaletteFX.presentColors() + if GameVersion.isGen2() and PaletteFX.mode == "classic" then return PaletteFX.CLASSIC end end function PaletteFX.modeLabel(mode) mode = mode or PaletteFX.mode + if GameVersion.isGen2() then + return ({gbc="GEN 2", og="DMG", dmg="DMG", classic="CLASSIC", custom="CUSTOM"})[mode] or "GEN 2" + end -- The GBC hardware mode wears the running game's name: OG RED / OG BLUE -- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes). if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end @@ -1255,22 +1317,45 @@ function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c - if gen2Native() then + if GameVersion.isGen3() then mode = "gbc" end - if mode == "og" then + if GameVersion.isGen2() and customRamp then return customRamp end + if mode == "og" or (GameVersion.isGen2() and mode == "custom") then out = PaletteFX.GRAYS elseif mode == "og_inv" then out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) elseif mode == "classic" then - out = PaletteFX.CLASSIC + out = GameVersion.isGen2() and PaletteFX.GRAYS or PaletteFX.CLASSIC elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end return out end -function PaletteFX.effectiveColors(c) - return PaletteFX.permute(PaletteFX.displayColors(c), shadeMap) +-- Static native BG/OBJ bakes resolve the display without a transient BGP. +-- DARK rows lose their darkness if replaced by a mono/custom ramp; fold the +-- world shift explicitly only for such replacements. Menus stay independent. +function PaletteFX.gen2BakeColors(c, dark) + local out = PaletteFX.displayColors(c) + local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" or PaletteFX.mode == "custom" + if GameVersion.isGen2() and dark and (mono or customRamp) then + out = PaletteFX.permute(out, PaletteFX.DARK_BGP) + end + return out +end +function PaletteFX.effectiveColors(c, invert) + local out = PaletteFX.displayColors(c) + if not out then return nil end + -- The shade map goes on LAST: rBGP is a hardware register write, so it + -- composes on top of whatever colors the display mode settled on -- a dark + -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too + -- (#322). permute() is the identity (and returns `out` itself, which the + -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end + return PaletteFX.permute(out, shadeMap) end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..b972b9d64 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 Gen2Presentation = require("src.render.Gen2Presentation") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -109,6 +110,7 @@ local function displayMetrics() end function Renderer:init() + Gen2Presentation.reset() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. @@ -320,6 +322,8 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.gen2FrameManaged, self.gen2FrameGame, self.gen2FrameZones = nil, nil, nil + self.gen2WindowDraw = nil -- a frame that ends early -- a state that returns before endFrame, an error -- caught upstream -- must not leak a queued native-resolution draw into the -- next frame's composite @@ -715,6 +719,10 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, love.graphics.setShader() end +function Renderer:blitZones(canvas,zones,w,h) + return Gen2Presentation.blit(canvas,zones,w,h) +end + -- THE BARS, FILLED WITH THE EDGE THEY FRAME. -- -- Asked for directly: "for all gen3 menus take the outside border 1px and @@ -874,7 +882,7 @@ function Renderer:endFrame(zones, worldZones) -- stacked Game Boy screens, or driving one onto a second physical display. -- The mod returns true to take over the whole window; anything else (or no -- mod wrapping the hook) falls through to the normal composite below. - if Runtime.wantsHook("render.compose") then + if not self.gen2FrameManaged and Runtime.wantsHook("render.compose") then local ctx = { renderer = self, worldCanvas = self.worldCanvas, uiCanvas = self.canvas, @@ -931,6 +939,9 @@ function Renderer:endFrame(zones, worldZones) and self.battleWipe.screenDraw local needPresent = GBCFX.active() or Pipelines.wantsPresent() or battleNeedsSource + or (self.gen2FrameManaged and (self.gen2WindowDraw + or (type(self.gen2FrameZones)=="table" and self.gen2FrameZones[1]) + or Runtime.wantsHook("render.compose"))) local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1351,6 +1362,10 @@ function Renderer:endFrame(zones, worldZones) -- over the finished composite rather than under the UI blit. On hardware -- it is the tilemap being overwritten -- there is nothing it does not cover. local battleSource = nil + if self.gen2FrameManaged and self.gen2WindowDraw then + self.gen2WindowDraw({width=ww,height=wh,gameX=ox,gameY=oy,gameWidth=vpw,gameHeight=vph,scale=Sp,dpiX=dpiX,dpiY=dpiY}) + end + if battleNeedsSource and present then -- Snapshot the untouched finished frame into a second screen-sized canvas. -- `present` is the current render target here, so sampling it directly in @@ -1394,21 +1409,25 @@ function Renderer:endFrame(zones, worldZones) if present then love.graphics.setCanvas() - -- 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 - -- grid itself. Each pass hands back a canvas; with none registered - -- this returns `present` unchanged and the frame is byte-identical. - local composed = Pipelines.present(present, - { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) - else - -- the present canvas only existed for the post-process, so put the - -- result on the screen at the same 1:1 unit mapping it was built at - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(composed, 0, 0) + local compositionHandled=false + if self.gen2FrameManaged and Runtime.wantsHook("render.compose") then + local ctx={renderer=self,game=self.gen2FrameGame,worldCanvas=present,uiCanvas=present,sceneCanvas=present, + worldActive=self.worldActive and true or false,generation=2,zones=self.gen2FrameZones,worldZones=nil, + ww=ww,wh=wh,pw=pw,ph=ph,ox=ox,oy=oy,vpw=vpw,vph=vph,uiw=uiw,uih=uih, + scale=Sp,Sx=Sx,Sy=Sy,dpiX=dpiX,dpiY=dpiY,secondScreen=require("src.render.SecondScreen")} + love.graphics.push("all") + local ok,value=xpcall(function() + return Runtime.call("render.compose",function()return false end,self,ctx)==true + end,debug.traceback) + love.graphics.pop() + if not ok then error(value,0)end + compositionHandled=value + end + if not compositionHandled then + local source=self.gen2FrameManaged and Gen2Presentation.apply(present,self.gen2FrameZones,ww,wh)or present + local composed=Pipelines.present(source,{width=ww,height=wh,scale=Sp,dpi=dpiY,dpiX=dpiX,dpiY=dpiY})or source + if GBCFX.active()then GBCFX.present(composed,Sp) + else love.graphics.setColor(1,1,1,1);love.graphics.draw(composed,0,0)end end end self.worldActive = false diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index f22e1907b..bd36be9f2 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -5,6 +5,7 @@ local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") +local GameVersion = require("src.core.GameVersion") local SpriteRenderer = {} SpriteRenderer.__index = SpriteRenderer @@ -46,6 +47,10 @@ end local obpCache = {} local function getObpImage(path, colors, group) + if GameVersion.isGen2() then + colors = PaletteFX.gen2BakeColors(colors, PaletteFX.darkWorld()) + group = tostring(group) .. "#" .. PaletteFX.displayKey() .. PaletteFX.darkKey() + end local key = path .. "#obp" .. group if not obpCache[key] then local img diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index a336f0035..30429842d 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -576,11 +576,18 @@ local function gen2PalColors(tileset, row) return byTod[row] or byTod.DAY or tileset.palColors end +local function gen2DisplayRows(rows, row) + if not (rows and require("src.core.GameVersion").isGen2()) then return rows end + local out = {} + for i, colors in ipairs(rows) do out[i] = PaletteFX.gen2BakeColors(colors, row == "DARK") end + return out +end + -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. local function gen2AtlasKey(imagePath, roof, special, row) - return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" + return imagePath .. "#gen2pal:" .. PaletteFX.displayKey() .. ":" .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so @@ -744,6 +751,7 @@ function TileRenderer.gen2AtlasFor(tileset) if not (tileset.palMap and #tileset.palMap > 0) then return nil end if not PaletteFX.usesGen2BgPal() then return nil end local colors = gen2PalColors(tileset) + colors = gen2DisplayRows(colors, PaletteFX.effectiveGen2Tod()) if not (colors and #colors > 0) then return nil end local perRow = tileset.tilesPerRow if not perRow or perRow < 1 then return nil end @@ -1343,6 +1351,7 @@ function TileRenderer.new(map, data) -- tileset would otherwise overwrite each other's roof. local roof = gen2RoofFor(map, data,gen2Row) gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + gen2Colors=gen2DisplayRows(gen2Colors,gen2Row) local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..92c9de220 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -104,8 +104,8 @@ local function stepVolume(v, dir) end local function colorIndex(opts) - local cur = opts.colors or "gbc" - for i, m in ipairs(PaletteFX.MODES) do + local cur = GameVersion.isGen2() and PaletteFX.mode or opts.colors or "gbc" + for i, m in ipairs(PaletteFX.colorModes()) do if m == cur then return i end end return 1 @@ -370,14 +370,17 @@ local function buildRows(game) end }, { id = "colors", label = Strings("COLORS"), value = function(g) - return PaletteFX.modeLabel(g.save.options.colors or "gbc") + local mode = GameVersion.isGen2() and PaletteFX.mode or g.save.options.colors or "gbc" + return PaletteFX.modeLabel(mode) end, step = function(g, dir) local o = g.save.options local i = colorIndex(o) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - o.colors = PaletteFX.MODES[i] - PaletteFX.setMode(o.colors) + local modes = PaletteFX.colorModes() + i = wrapIndex(i - 1 + dir, #modes) + 1 + PaletteFX.writeColorOption(o, modes[i]) + if GameVersion.isGen2() then PaletteFX.setDisplay(o.colors,nil) + else PaletteFX.setMode(o.colors) end return true end }, { id = "tilt", label = Strings("TILT"), diff --git a/tests/engine/gen2_display_loader.lua b/tests/engine/gen2_display_loader.lua new file mode 100644 index 000000000..9130c5514 --- /dev/null +++ b/tests/engine/gen2_display_loader.lua @@ -0,0 +1,45 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local A={{255,255,255},{193,41,71},{31,139,83},{0,0,0}} +local R={{239,223,197},{173,137,91},{97,59,29},{19,13,7}} +local S={{217,231,243},{137,171,209},{61,103,151},{11,23,41}} +V.set('crystal') +for _,api in ipairs({1,2})do + local files={['mods/palette-cache/manifest.json']=Json.encode({id='palette_cache',name='Registered indexed texture producer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/palette-cache/main.lua']=[[ + local mod=...;local G=require('src.render.GbcPalette');local Assets=require('src.render.Assets') + local cache,flushes={},0 + Assets.register(function()cache={};flushes=flushes+1 end) + mod.exports.palette=function(source) + local key=G.mode + if not cache[key]then + local resolved=G.resolve(source);local rows={} + for i=1,4 do rows[i]={resolved[i][1],resolved[i][2],resolved[i][3]}end + cache[key]=rows + end + return cache[key],flushes,key + end + ]]} + P.setDisplay('gbc',nil) + local loaded=T.sdk.loadMods({'mods/palette-cache'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,'API'..api..' actual texture producer loads') + local consumer=loaded.loader.exports.palette_cache + local initial=consumer.palette(A);T.same(initial,A,'API'..api..' initial native row') + P.setDisplay('custom',R) + local first,count,mode=consumer.palette(A) + T.same(first,R,'API'..api..' first display ramp reaches registered producer') + T.eq(mode,'custom','API'..api..' public mode follows native owner') + P.setCustomRamp(S) + local second,newCount,sameMode=consumer.palette(A) + T.eq(sameMode,mode,'API'..api..' second ramp retains public mode ID') + T.eq(newCount,count+1,'API'..api..' native change flushes registered cache once') + T.neq(second,first,'API'..api..' producer rebuilds baked colour rows') + T.same(second,S,'API'..api..' second ramp replaces stale atlas colours') + Loader.teardown();loaded.release() +end +P.setDisplay('gbc',nil) +T.finish('native Gen2 display changes invalidate loaded API 1/2 texture producers') diff --git a/tests/engine/gen2_display_modes.lua b/tests/engine/gen2_display_modes.lua new file mode 100644 index 000000000..a9b181046 --- /dev/null +++ b/tests/engine/gen2_display_modes.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') +local TR=require('src.render.TileRenderer');local SR=require('src.render.SpriteRenderer') +local ML=require('src.world.MapLoader');local Assets=require('src.render.Assets') +local Data=require('src.core.Data');local Game=require('src.core.Game') +local Battle=require('src.battle.BattleState') +local assetChanges=0 +Assets.register(function()assetChanges=assetChanges+1 end) +local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} +local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} +local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} +local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end;function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x];if p then return unpack(p)end + local n=source and 1-x%4/3 or 0;return n,n,n,source and 1 or 0 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:mapPixel(f)for y=0,self.h-1 do for x=0,self.w-1 do self:setPixel(x,y,f(x,y,self:getPixel(x,y)))end end end + function b:setFilter()end;function b:setWrap()end;function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(b)return b end +local function source(path) + if path=='display-tiles' then return buffer(16,8,true)end + if path=='display-roof' then return buffer(8,8,true)end + return buffer(16,96,true) +end +Assets.image=source;Assets.imageData=source +local function pixels(image,colors,start,keyed,label) + for i=1,4 do + local r,g,b,a=image:getPixel((start or 0)+i-1,0) + if keyed and i==1 then T.eq(a,0,label..' OBJ shade zero remains transparent') + else T.same({r,g,b,a},{colors[i][1]/255,colors[i][2]/255,colors[i][3]/255,1},label..' shade '..i)end + end +end +local function fixture() + local d=T.fixtures.fresh() + local ts={id='DISPLAY',image='display-tiles',tilesPerRow=2,palMap={0,1},palColorsByTod={DAY={A,B},NITE={B,A},DARK={A,B}}, + animatedTiles={{tile=0,kind='hshift',offsets={0,1},period=20}},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={DISPLAY=ts} + d.maps={DISPLAY={id='DISPLAY',label='DISPLAY',width=1,height=1,blocks={0},tileset='DISPLAY',environment=1,mapPalette=1,group=1,number=1}} + d.pokemon={TEST={id='TEST',name='TEST',index=1,dex=1,palette='QA_TEST',spriteFront='display-mon'}} + d.palettes={palettes={QA_TEST=A,MEWMON=A,GRAYMON=GRAY,BLACK={{0,0,0},{0,0,0},{0,0,0},{0,0,0}}},pokemon={TEST='QA_TEST'}} + d.field.gen2Roofs={tilesets={DISPLAY=true},byGroup={[1]=0},images={[0]='display-roof'},slot=1,count=1,palIndex=1, + colors={[1]={DAY={{53,63,73},{83,93,103}}}}} + Data.publishGen2Palettes(d) + return d,ts +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);P.setDisplay('gbc',nil);P.setShadeMap(nil);P.setDarkWorld(false);P.setGen2MapPalette(1);P.setGen2Tod('DAY') + local d,ts=fixture();ML.invalidateAll() + local mon={species='TEST',dvs={attack=1,defense=1,speed=1,special=1}} + local battle=setmetatable({data=d,game={save={party={}}}},Battle) + local factory + for i=1,100 do local name,value=debug.getupvalue(Battle.speciesSprite,i);if name=='getImage'then factory=value;break end end + assert(factory,'native battle image factory unavailable') + local originalPic=factory('display-mon',{name='QA_TEST',colors=A},false) + local sprite=SR.new({id='DISPLAY',image='display-sprite',gen2ObjPal=A}) + local reloads=0 + Game.overworld={map=ML.load(d,'DISPLAY'),reloadMap=function(self,id,reason) + T.eq(reason,'colors',edition..' native map reload identifies display change') + self.map=ML.load(d,id);self.tiles=TR.new(self.map,d);reloads=reloads+1 + end} + for _,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',GRAY},{'custom',R}})do + local name,wanted=choice[1],choice[2] + local oldMap=Game.overworld.map + if name=='custom' then P.setDisplay(name,R)else P.setDisplay(name,nil)end + T.eq(G.mode,name,edition..' native/public mode agrees '..name) + T.eq(G.customRamp,name=='custom'and R or nil,edition..' public ramp is actual native ramp') + if name~='gbc' then T.neq(Game.overworld.map,oldMap,edition..' current map cache replaced '..name)end + local tiles=TR.new(ML.load(d,'DISPLAY'),d) + pixels(tiles.image,wanted,0,false,edition..' actual atlas '..name) + local roof=name=='gbc'and{B[1],{53,63,73},{83,93,103},B[4]}or wanted + pixels(tiles.image,roof,8,false,edition..' actual roof '..name) + T.check(tiles.anims and tiles.anims[1],edition..' native water animation exists') + pixels(tiles.anims[1].textures[1],wanted,0,false,edition..' actual animated tile '..name) + pixels(sprite:resolveImage(),wanted,0,true,edition..' retained native sprite '..name) + pixels(battle:picImage(originalPic),wanted,0,false,edition..' retained native battle picture '..name) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false,edition..' battle fade after display '..name) + battle.fx=nil;battle.blackedOut=true + pixels(battle:picImage(originalPic),{wanted[4],wanted[4],wanted[4],wanted[4]},0,false,edition..' battle blackout '..name) + battle.blackedOut=false + if name=='classic' then T.eq(G.presentColors(),P.CLASSIC,edition..' Classic has one final green ramp') + else T.eq(G.presentColors(),nil,edition..' mode has no extra green pass '..name)end + end + local count=reloads;local assetCount=assetChanges;G.setCustomRamp(R);T.eq(reloads,count,edition..' identical ramp avoids reload') + T.eq(assetChanges,assetCount,edition..' identical ramp avoids shared asset churn') + local oldKey=P.displayKey();R[2][1]=R[2][1]+1;G.setCustomRamp(R) + T.neq(P.displayKey(),oldKey,edition..' edited ramp changes native cache key') + T.eq(reloads,count+1,edition..' reapplying edited ramp refreshes current map once') + T.eq(assetChanges,assetCount+1,edition..' edited ramp notifies registered external caches once') + pixels(sprite:resolveImage(),R,0,true,edition..' sprite rebakes edited ramp') + G.setBgp(0xff) + pixels(TR.new(ML.load(d,'DISPLAY'),d).image,R,0,false,edition..' transient BGP never baked into atlas') + G.setBgp(nil);P.setDarkWorld(true) + pixels(sprite:resolveImage(),{R[3],R[4],R[4],R[4]},0,true,edition..' custom world darkness preserved') + P.setDarkWorld(false) + T.eq(G.cycle(), 'dmg',edition..' custom cycle returns to native ladder') + T.eq(G.customRamp,nil,edition..' cycling removes custom ramp') + T.eq(G.cycle(),'classic',edition..' DMG advances to Classic') + T.eq(G.cycle(),'gbc',edition..' Classic advances to GBC') + T.eq(G.cycle(-1),'classic',edition..' backward cycle supported') + count=reloads;assetCount=assetChanges;G.applyOptions({color='custom',palette='m:80a0c0'}) + T.eq(reloads,count+1,edition..' saved mode and palette applied atomically') + T.eq(assetChanges,assetCount+1,edition..' saved mode and palette flush shared assets once') + T.same(G.customRamp,{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},edition..' saved RGB palette reaches native owner') + local opts={color='custom',colors='custom',palette='m:80a0c0'} + P.writeColorOption(opts,P.cycleMode());T.eq(opts.palette,nil,edition..' manual cycle clears saved palette') + T.eq(opts.color,'dmg',edition..' legacy option tracks actual new mode');T.eq(opts.colors,'og',edition..' native option tracks same mode') + G.applyOptions(opts);T.eq(G.mode,'dmg',edition..' reapply cannot revive stale custom selection') + local menuGame={data=d,save={options=opts}} + local menu=require('src.ui.OptionsMenu').new(menuGame) + local row;for _,candidate in ipairs(menu.rows)do if candidate.id=='colors'then row=candidate end end + T.check(row,edition..' actual native options has display row') + T.eq(row.value(menuGame),'DMG',edition..' options reports actual native selection') + row.step(menuGame,1) + T.eq(opts.color,'classic',edition..' options persists legacy Classic selection') + T.eq(opts.colors,'classic',edition..' options persists same native selection') + T.eq(G.mode,'classic',edition..' actual options changes native renderer mode') + T.eq(row.value(menuGame),'CLASSIC',edition..' options reports switched mode') + row.step(menuGame,-1);G.applyOptions(opts) + T.eq(G.mode,'dmg',edition..' backwards options choice survives apply') + Game.overworld=nil +end +V.set('emerald');P.setDisplay('classic',R);T.eq(P.displayColors(A),A,'Gen3 palette behavior preserved');T.eq(P.presentColors(),nil,'Gen3 receives no Classic pass') +V.set('red');P.setDisplay('classic',nil);T.eq(P.displayColors(A),P.CLASSIC,'Gen1 Classic remains its original palette path') +T.finish('native Gen2 modes, cached atlas/roof/animation/OBJ/battle bakes and saved options') diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index c8aeef1d8..c60ec63a5 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -66,7 +66,7 @@ for _,game in ipairs({'red','gold','silver','crystal'})do for _,mode in ipairs(P.MODES)do P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) local resolved=P.effectiveColors(A) - local display=game~='red'and'gbc'or(mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc')) + local display=mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc') T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') local trueColor=true T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') diff --git a/tests/engine/palette_library.lua b/tests/engine/palette_library.lua new file mode 100644 index 000000000..3ee468219 --- /dev/null +++ b/tests/engine/palette_library.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local library={{name='Authored',palettes={ + {'BLUE','00112233445566778899aabb'}, + {'GRAY','000000555555aaaaaaffffff'}, + {'MIX','ff000000ff000000ffffffff'}, + {'INVALID','not a ramp'}, +}}} +package.loaded['data.gb_palettes']=library +local P=require('src.render.Palette') +T.eq(P.packs(),library,'named ramps come from actual optional authored library') +T.eq(P.packId('Authored','BLUE'),'p:Authored/BLUE','named palette ID') +T.eq(P.monoId(128,160,192),'m:80a0c0','RGB palette ID') +T.same(P.ramp('m:80a0c0'),{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},'mono ramp uses public four-step intensities') +T.same(P.unhex('00112233445566778899aabb'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'hex row order') +T.same(P.ramp('p:Authored/BLUE'),{{153,170,187},{102,119,136},{51,68,85},{0,17,34}},'named library order reversed into light-to-dark drawing ramp') +T.same(P.swatch('p:Authored/BLUE'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'swatch reverses drawing ramp') +T.eq(P.findPack('Authored','BLUE'),library[1].palettes[1],'lookup keeps library ownership') +T.eq(P.label('p:Authored/BLUE'),'BLUE','named display label') +T.eq(P.label('m:9bbc0f'),'GREEN','known mono colour label') +T.eq(P.label('m:123456'),'MONO','authored mono colour label') +T.eq(P.label('unknown'),'unknown','unknown ID label remains observable') +T.eq(P.label(''),nil,'empty label absent') +for _,id in ipairs({'m:bad','m:1234567','m:xxxxxx','p:missing/entry','p:Authored/INVALID',''})do + T.eq(P.ramp(id),nil,'invalid/missing palette not invented: '..id) +end +for _,value in ipairs({false,'','00112233445566778899aabx','00112233445566778899aab'})do + T.eq(P.unhex(value),nil,'invalid hex refused') +end +T.eq(P.categoryOf(P.unhex('000000555555aaaaaaffffff')),'grey','gray category') +T.eq(P.categoryOf(P.unhex('00112233445566778899aabb')),'single','single hue category') +T.eq(P.categoryOf(P.unhex('ff000000ff000000ffffffff')),'full','full hue category') +T.eq(P.categoryOf({}),nil,'incomplete ramp has no category') +T.eq(P.hueArc({350,10}),20,'hue arc crosses zero') +T.eq(P.hueArc({}),360,'empty hue-arc protocol') +local hue,chroma=P.hueChroma({255,0,0});T.eq(hue,0,'red hue');T.eq(chroma,255,'red chroma') +local categories=P.categories();T.eq(#categories,3,'three public categories') +T.eq(categories[1].key,'full','category order preserved');T.eq(#categories[1].palettes,1,'invalid ramp omitted') +local key,at=P.locate('p:Authored/BLUE');T.eq(key,'single','locate category');T.eq(at,1,'locate index') +T.eq(P.category('single').palettes[1].pack,'Authored','category entry keeps source pack') +T.eq(P.category('unknown'),nil,'unknown category absent') +T.eq(P.locate('m:80a0c0'),nil,'mono ramp is not a named-library row') +local old=P.ramp('p:Authored/BLUE');T.eq(P.ramp('p:Authored/BLUE'),old,'same ramp ID retains cached table') +library[1].palettes[1][2]='112233445566778899aabbcc';P.invalidate() +local updated=P.ramp('p:Authored/BLUE');T.neq(updated,old,'invalidate rebuilds ramps') +T.same(updated[1],{170,187,204},'updated authored library reaches drawing ramp') +T.eq(P.packs(),library,'invalidating derived indices preserves library owner') +T.finish('native public palette IDs, authored libraries and colour categories') diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua new file mode 100644 index 000000000..798caa694 --- /dev/null +++ b/tests/gpu/gen2_display_modes.lua @@ -0,0 +1,153 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- ROM-free native GPU composition, including final CGB zones and window UI. +local M={} +function M.run() + require('src.core.ModCompat').install() + local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') + local V=require('src.core.GameVersion');local Renderer=require('src.render.Renderer') + local Present=require('src.render.Gen2Presentation');local haveViewport,Viewport=pcall(require,'src.render.GameViewport') + if not haveViewport then Viewport={setTarget=function()love.graphics.setCanvas()end}end + local Runtime=require('src.mods.Runtime');local Hooks=require('src.mods.Hooks') + local g=love.graphics + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} + local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} + local original={version=V.get(),mode=P.mode,ramp=P.customRamp(),shadeMap=P.shadeMap(),setTarget=Viewport.setTarget,setCanvas=g.setCanvas, + events=Runtime.events,hooks=Runtime.hooks,errors=Runtime.errors} + local w,h=g.getDimensions() + local output=g.newCanvas(w,h,{format='rgba8',dpiscale=1}) + local sheet=g.newCanvas(w,h*4,{format='rgba8',dpiscale=1}) + M.image=sheet + local data=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;data:setPixel(x,0,n,n,n,1)end + local image=g.newImage(data);image:setFilter('nearest','nearest') + local checks=0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function same(actual,wanted,label) + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,label..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + local function color(c)return {c[1]/255,c[2]/255,c[3]/255,1}end + local function sample(pixels,x,y)return {pixels:getPixel(math.floor(x),math.floor(y))}end + local game={nativeGpuOwner=true} + local bus=Hooks.new();local zoneCalls,composeCalls=0,0 + local nativeViewport + if haveViewport then Viewport.setTarget=function()g.setCanvas(output)end + else local setCanvas=g.setCanvas;g.setCanvas=function(canvas,...)return setCanvas(canvas or output,...)end end -- bounded output IO only + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setShadeMap(nil);P.setDarkWorld(false) + local haveCorrection,Correction=pcall(require,'src.render.GbcColorCorrection');if haveCorrection then Correction.setMode('off')end + require('src.render.GBCFX').setLevel(0) + local havePresets,Presets=pcall(require,'src.render.ShaderPresets');if havePresets then Presets:off()end + Runtime.install(original.events,bus,{}) + Renderer:init() + local function scene(windowMenu) + g.setCanvas(output);g.origin();g.clear(0,0,0,1) + Renderer:beginFrame() + g.setColor(1,1,1,1) + G.with(A,function()g.draw(image,0,0,0,40,72)end) + local native=P.shader() + local cached=require('src.core.Performance').paletteZoneCache + if cached~=nil then check(cached,'native optimized upload enabled')end + P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() + Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game + Renderer.gen2FrameZones=Present.zones(game) + if windowMenu then Renderer.gen2WindowDraw=function() + g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end end + nativeViewport=Renderer:endFrame(nil,nil) + g.setCanvas();g.setShader() + original.setCanvas() -- leave the bounded legacy output target before readback + return g.readbackTexture(output) + end + for i,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',P.CLASSIC},{'custom',R}})do + local mode,wanted=choice[1],choice[2] + P.setDisplay(mode,mode=='custom' and R or nil) + local pixels=scene(false) + for _,y in ipairs({36,108})do for x=0,3 do + same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+y*nativeViewport.scale),color(wanted[x+1]),'native/public composite '..mode..' shade '..x..' row '..y) + end end + g.setCanvas(sheet);g.origin();g.setShader();g.setColor(1,1,1,1);g.setBlendMode('replace','premultiplied');g.draw(output,0,(i-1)*h) + g.setCanvas();pixels:release() + end + P.setDisplay('classic',nil) + local pixels=scene(true) + same(sample(pixels,w-4,4),color(P.CLASSIC[1]),'native window-space menu included before Classic at top right') + same(sample(pixels,w-4,h-4),color(P.CLASSIC[4]),'native final image keeps bottom-right orientation') + pixels:release() + local detachZones=bus:wrap('render.zones',function(next,owner,zones) + zoneCalls=zoneCalls+1 + check(owner==game,'zone hook receives actual game owner') + check(zones[1].colors==P.CLASSIC,'zone hook observes final green ramp') + -- Override final Classic as the public seam permits. Resolve must not + -- turn this authored destination back into grayscale. + zones[1].colors=R + return next(owner,zones) + end,0,'gpu-palette-consumer') + pixels=scene(false) + check(zoneCalls==1,'zone hook called once for completed frame') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(R[x+1]),'authored final zone shade '..x)end + pixels:release();detachZones() + local detachCompose=bus:wrap('render.compose',function(_,renderer,ctx) + composeCalls=composeCalls+1 + check(renderer==Renderer and ctx.renderer==Renderer,'native renderer remains composition owner') + check(ctx.game==game and ctx.generation==2,'composition identifies actual game/generation') + check(ctx.worldCanvas==ctx.sceneCanvas and ctx.uiCanvas==ctx.sceneCanvas,'composition receives completed Gen2 scene') + check(ctx.zones[1].colors==P.CLASSIC,'composition receives resolved final palette') + check(g.getCanvas()~=ctx.sceneCanvas,'composition never reads its active render target') + -- Taking ownership bypasses the ordinary tint and post-process, so a + -- reference-style consumer can present the supplied zones itself. + renderer:blitZones(ctx.sceneCanvas,ctx.zones,ctx.ww,ctx.wh) + return true + end,0,'gpu-frame-consumer') + pixels=scene(false) + check(composeCalls==1,'compose invoked once after native scene') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(P.CLASSIC[x+1]),'composition-owned final shade '..x)end + pixels:release();detachCompose() + -- Empty/backwards/outside rectangles are valid no-op draws. A false + -- palette is a raw RGB opt-out, and scoped state must be restored. + local raw=g.newCanvas(4,1,{format='rgba8',dpiscale=1}) + g.setCanvas(raw);g.clear(0,0,0,1) + local external=G.shader();G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=-1,h=144,colors=R},{x=200,w=5,colors=R},{x=0,y=0,w=160,h=144,colors=false}},4,1) + check(g.getShader()==external,'public final blit restores existing shader') + g.setCanvas();g.setShader();local plain=g.readbackTexture(raw) + for x=0,3 do same(sample(plain,x,0),color(GRAY[x+1]),'false-zone raw RGB '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getShader()==external,'authored final blit restores shader identity') + g.draw(image,0,0);g.setCanvas();g.setShader() + local restored=g.readbackTexture(raw) + for x=0,3 do same(sample(restored,x,0),color(A[x+1]),'authored final blit restores managed uniforms '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.apply(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getCanvas()==raw and g.getShader()==external,'offscreen final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local applied=g.readbackTexture(raw) + for x=0,3 do same(sample(applied,x,0),color(A[x+1]),'offscreen final pass restores managed uniforms '..x)end + local draw=g.draw + g.draw=function(source,...) + if source==image then error('authored final draw failure',0)end + return draw(source,...) + end + g.setCanvas(raw);G.useRaw(A) + local failed,reason=pcall(Present.apply,image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + g.draw=draw + check(not failed and tostring(reason):find('authored final draw failure',1,true),'final draw error propagates') + check(g.getCanvas()==raw and g.getShader()==external,'failed final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local recovered=g.readbackTexture(raw) + for x=0,3 do same(sample(recovered,x,0),color(A[x+1]),'failed final pass restores managed uniforms '..x)end + recovered:release() + restored:release();applied:release() + raw:release();plain:release() + end,debug.traceback) + g.setCanvas();g.pop() + g.setCanvas=original.setCanvas;g.setCanvas();Viewport.setTarget=original.setTarget;Runtime.install(original.events,original.hooks,original.errors) + V.set(original.version);P.setDisplay(original.mode,original.ramp);P.setShadeMap(original.shadeMap) + output:release();image:release();data:release() + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='Native Gen2 GPU composition in GBC/DMG/Classic/custom, final Classic/zone hooks, completed-scene composition ownership, window-space menus, scoped raw blits'},sheet +end +return M From cff16628e51a52ad00d2e493395739181e676b96 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:13:16 +0100 Subject: [PATCH 32/68] Give native Gen2 saving its own confirmed, timed state --- docs/native-gen2-save-menu.md | 32 +++++++ src/core/ModCompat.lua | 2 +- src/render/TextBox.lua | 6 +- src/ui/Gen2SaveMenu.lua | 157 ++++++++++++++++++++++++++++++++ src/ui/StartMenu.lua | 7 ++ tests/engine/gen2_save_menu.lua | 112 +++++++++++++++++++++++ 6 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 docs/native-gen2-save-menu.md create mode 100644 src/ui/Gen2SaveMenu.lua create mode 100644 tests/engine/gen2_save_menu.lua diff --git a/docs/native-gen2-save-menu.md b/docs/native-gen2-save-menu.md new file mode 100644 index 000000000..0683b1278 --- /dev/null +++ b/docs/native-gen2-save-menu.md @@ -0,0 +1,32 @@ +# Native Gen2 save dialogue + +Gen2 START now opens `Gen2SaveMenu`, an actual state over the native text +printer and save pipeline. The public `src.ui.gen2.SaveMenu` spelling names +this same owner. Screen registry overrides retain authority over the native +`Gen2SaveMenu` entry. + +An existing active save slot requires a second overwrite answer. B/NO cannot +write. The timed saving message uses MID text cadence without held-button +acceleration; the write follows sixteen post-text logic frames. The saved +message follows another thirty-two frames and retains a thirty-frame tail, +waiting for its returned save sound with a bounded host failure escape. +Completion and writing are each guarded against duplicate calls. + +The default operation calls `Game:writeSave`, retaining save-write vetoes, +live overworld capture, metadata capture, mod notifications and the +existing safe serializer. Explicit alternate snapshots use `SaveData.save`; +an injected writer receives the selected snapshot. Failed or throwing writes +cannot produce a successful completion message. Slot detection uses the +same active-slot lookup as native saving; an indeterminate lookup asks for +overwrite confirmation. The panel counts native badge and owned-Dex records. + +ROM-free integration tests exercise Gold/Silver/Crystal through actual stack, +text printer, START input, screen resolution and the native Game writer, +including refusal, overwrite, fixed timing, duplicate suppression, writer +failure and a real save-hook veto. Gen1 retains its existing dialogue path. +Real-cartridge save/reload, unchanged-mod presentation, exact reference text +raster/typewriter helper APIs and full save/link parity remain unverified. +This state does not change save-format or link-protocol numbers. + +Original contributions by Roxas2712; existing upstream and third-party rights +and credits remain applicable. See NOTICE.md and LICENSE-ROXAS2712.md. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 336bb378c..8cb4114a0 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -32,7 +32,6 @@ -- hook, not one module -- src.world.gen2.Follower PikachuFollower is Yellow's, not a general -- party follower --- src.ui.gen2.SaveMenu the save flow is part of StartMenu here -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent @@ -68,6 +67,7 @@ ModCompat.ALIASES = { ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", ["src.ui.gen2.NamingScreen"] = "src.ui.Gen2NamingScreen", ["src.ui.gen2.NamePick"] = "src.ui.Gen2NamePick", + ["src.ui.gen2.SaveMenu"] = "src.ui.Gen2SaveMenu", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..c19ed59a5 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -55,6 +55,8 @@ function TextBox.new(game, text, onDone, opts) self.choiceNoSound = opts.noSound self.auto = opts.auto self.stay = opts.stay + self.textSpeed = opts.textSpeed + self.allowTextAcceleration = opts.allowTextAcceleration ~= false -- A few cartridge screens print through the same text engine without using -- the field's framed dialogue window. Teachy TV is one: teachy_tv.c gives -- it a 26x4 raw window at (2,15), fills it with palette colour $C and calls @@ -559,9 +561,9 @@ function TextBox:update(dt) -- typewriter cadence: one character every N frames, N = the OPTION -- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B -- prints every frame like the original's held-button fast path - local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3 + local delay = self.textSpeed or (self.game.save.options and self.game.save.options.textSpeed) or 3 if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end - if input:isDown("a") or input:isDown("b") then delay = 1 end + if self.allowTextAcceleration and (input:isDown("a") or input:isDown("b")) then delay = 1 end self.charTimer = (self.charTimer or 0) + 1 while self.charTimer >= delay do self.charTimer = self.charTimer - delay diff --git a/src/ui/Gen2SaveMenu.lua b/src/ui/Gen2SaveMenu.lua new file mode 100644 index 000000000..e5459484f --- /dev/null +++ b/src/ui/Gen2SaveMenu.lua @@ -0,0 +1,157 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GSC save dialogue over the shared text printer and save pipeline. +local TextBox = require('src.render.TextBox') +local Chrome = require('src.ui.Gen2Chrome') +local Strings = require('src.core.Strings') +local SaveData = require('src.core.SaveData') +local Sound = require('src.core.Sound') +local M = {isOpaque=false, SAVING_FRAMES=16, SAVED_GAP_FRAMES=32, + SAVED_TAIL_FRAMES=30, SFX_SAVE=0x25} +M.__index=M +M.SAVED_FRAMES=M.SAVED_GAP_FRAMES+M.SAVED_TAIL_FRAMES +M.OVERWRITE_PROMPT_SOURCE=Strings.source('There is already a\nsave file. Is it\vOK to overwrite?') +M.SAVING_PROMPT_SOURCE=Strings.source("SAVING… DON'T TURN\nOFF THE POWER.") +M.PANEL={x=4,y=0,w=16,h=10,labelX=5,labelDy=2,badgesX=17,badgesDy=4, + dexX=16,dexDy=6,timeX=13,timeDy=8} +M.DOWN_ARROW='▼';M.ARROW_X=18;M.ARROW_Y=17 +function M.pagesOf(text) + local result={} + for paragraph in (tostring(text)..'\f'):gmatch('(.-)\f') do + local visible;local pos=1;local scroll=false + while true do + local split=paragraph:find('[\n\v]',pos) + local line=paragraph:sub(pos,split and split-1 or #paragraph) + if line~='' then + if not visible then visible={line};result[#result+1]=visible + elseif scroll then visible={visible[#visible],line,scrolled=true};result[#result+1]=visible + elseif #visible==2 then visible={line};result[#result+1]=visible + else visible[#visible+1]=line end + end + if not split then break end + scroll=paragraph:sub(split,split)=='\v';pos=split+1 + end + end + return #result>0 and result or {{''}} +end +function M.new(game,opts) + opts=opts or {} + local existed=opts.existed + if existed==nil then existed=SaveData.saveFileExists((opts.save or game.save).version)~=false end + local self=setmetatable({game=game,save=opts.save or game.save, + onDone=opts.onDone,writer=opts.writer,existed=existed,choice=1,timer=0},M) + self:enterPhase('confirm') + return self +end +function M:wantsFillScale() return true end +function M:playerName() return (self.save.player or {}).name or 'GOLD' end +function M:promptText() + if self.phase=='overwrite' then return Strings(M.OVERWRITE_PROMPT_SOURCE) end + if self.phase=='saving' then return Strings(M.SAVING_PROMPT_SOURCE) end + if self.phase=='done' then + return self.saved and Strings('%s saved\nthe game.',self:playerName()) or Strings('Could not save.') + end + return Strings('Would you like to\nsave the game?') +end +function M:enterPhase(phase) + self.phase=phase;self.timer=0;self.typedPhase=phase + -- Embedded stay text never pushes or pops a state. The menu owns its + -- lifetime, while ordinary native glyph, page and scroll timing is shared. + local timed=phase=='saving' or phase=='done' + self.message=TextBox.new(self.game,self:promptText(),nil,{stay={}, + textSpeed=timed and 3 or nil,allowTextAcceleration=not timed}) + self.pages=M.pagesOf(self:promptText());self.page=1 +end +function M:prompt() return self.pages[self.page] or self.pages[1] end +function M:yesNoVisible() + return not self.finished and (self.phase=='confirm' or self.phase=='overwrite') and self.message.done==true +end +function M:finish(saved) + if self.finished then return false end + self.finished=true + if self.onDone then self.onDone(saved==true) end + return true +end +function M:accept() + if not self:yesNoVisible() then return false end + if self.choice==2 then return self:finish(false) end + if self.phase=='confirm' and self.existed then self.choice=1;self:enterPhase('overwrite') + else self:enterPhase('saving') end + return true +end +function M:writeNow() + if self.wrote then return self.saved end + self.wrote=true + local ok,result=pcall(function() + if self.writer then return self.writer(self.save) end + if self.game and self.game.save==self.save and self.game.writeSave then return self.game:writeSave() end + return SaveData.save(self.save) + end) + self.saved=ok and not not result + if not ok then + self.writeError=tostring(result) + require('src.core.Logger').warn('Gen2 save failed: %s',self.writeError) + end + return self.saved +end +function M.playSaveSfx(game,id) + local data=game and game.data;local audio=data and data.audio + local name=audio and audio.sfxOrder and audio.sfxOrder[(id or M.SFX_SAVE)+1] + if name and audio.sfx and audio.sfx[name] then return Sound.play(data,name) end +end +function M:playSfx(id) return M.playSaveSfx(self.game,id) end +function M:playSfxNamed(name) return Sound.play(self.game.data,name) end +function M:update(dt) + if self.finished then return end + local alreadyTyped=self.message.done + self.message:update(dt) + local page=0 + for p=1,self.message.pageIndex do + page=page+1 + local cont=(self.message.pages.contBefore or {})[p] or {} + local limit=p==self.message.pageIndex and self.message.lineIndex or #self.message.pages[p] + for line=2,limit do if cont[line] then page=page+1 end end + end + self.page=math.min(page,#self.pages) + if not alreadyTyped then return end + if self.phase=='saving' then + self.timer=self.timer+1 + if self.timer==M.SAVING_FRAMES then self:writeNow() end + if self.timer>=M.SAVING_FRAMES+M.SAVED_GAP_FRAMES then self:enterPhase('done') end + return + elseif self.phase=='done' then + if self.saved and not self.rang then + self.rang=true;self.saveSound=self:playSfx(M.SFX_SAVE);return + end + if self.saveSound and self.saveSound.isPlaying and self.saveSound:isPlaying() then + self.soundWait=(self.soundWait or 0)+1 + -- A broken host audio source cannot imprison the save menu forever. + if self.soundWait<600 then return end + end + self.timer=self.timer+1 + if self.timer>=M.SAVED_TAIL_FRAMES then self:finish(self.saved) end + return + end + local input=self.game.input + if input:wasPressed('up') or input:wasPressed('down') then self.choice=3-self.choice + elseif input:wasPressed('a') then self:accept() + elseif input:wasPressed('b') then self:finish(false) end +end +function M:drawPanel() + local save=self.save;local badges=require('src.inventory.Badges').count(self.game.data,save) + local caught=0 + for _ in pairs((save.pokedex or {}).owned or {}) do caught=caught+1 end + local seconds=math.floor(SaveData.playSeconds(save)) + Chrome.box(4,0,16,10) + Chrome.print(Strings('PLAYER %s',self:playerName()),5,2) + Chrome.print(Strings('BADGES'),5,4);Chrome.print(Chrome.number(badges,2),17,4) + Chrome.print(Strings('POKéDEX'),5,6);Chrome.print(Chrome.number(caught,3),16,6) + Chrome.print(Strings('TIME'),5,8) + Chrome.print(('%3d:%02d'):format(math.floor(seconds/3600),math.floor(seconds/60)%60),13,8) + self.message:draw() + if self:yesNoVisible() then + Chrome.box(0,7,6,5);Chrome.print(Strings('YES'),2,8);Chrome.print(Strings('NO'),2,10) + Chrome.cursor(1,self.choice==1 and 8 or 10) + end +end +function M:draw() self:drawPanel() end +return M diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index b898636a1..ce6c20d33 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -83,6 +83,13 @@ function StartMenu.new(game) -- below rather than here. if not inContest then table.insert(items, { label = Strings("SAVE"), onSelect = function() + if require('src.core.GameVersion').isGen2() then + local screen + screen=Screens.push(game,'Gen2SaveMenu',{onDone=function() + if game.stack:top()==screen then game.stack:pop() end + end}) + return + end local TextBox = require("src.render.TextBox") local badges = require("src.inventory.Badges").count(game.data, game.save) local owned = 0 diff --git a/tests/engine/gen2_save_menu.lua b/tests/engine/gen2_save_menu.lua new file mode 100644 index 000000000..238b8a1dd --- /dev/null +++ b/tests/engine/gen2_save_menu.lua @@ -0,0 +1,112 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2SaveMenu');local Box=require('src.render.TextBox') +local Stack=require('src.core.StateStack');local Screens=require('src.ui.Screens') +local Save=require('src.core.SaveData') +local function game(version) + local g={data=T.fixtures.fresh(),save={version=version,player={name='QA'},flags={},options={textSpeed='fast'},party={},pokedex={owned={}},inventory={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end + g.input.isDown=function(self,k)return self.queue[k]==true end + g.data.font.charmap={} + for code=32,126 do g.data.font.charmap[#g.data.font.charmap+1]={seq=string.char(code),code=0x80+(code-32)} end + g.data.font.charmap[#g.data.font.charmap+1]={seq='…',code=0xf0} + require('src.render.Font').load(g.data) + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.writeSave=function(self)self.writes=(self.writes or 0)+1;return self.result~=false end + return g +end +local function tick(g,key,n) + for _=1,n or 1 do g.input.queue=key and {[key]=true}or{};g.stack:update(1/60) end + g.input.queue={} +end +local function wait(g,pred,label) + for _=1,3000 do if pred() then return end;tick(g) end + error('save flow did not reach '..label) +end +local function ready(g,ui) + for _=1,3000 do + if ui:yesNoVisible() then return end + tick(g,ui.message.waiting and 'a' or nil) + end + error('save prompt never offered choices') +end +T.eq(require('src.ui.gen2.SaveMenu'),UI,'legacy class spelling resolves native save owner') +local pages=UI.pagesOf('A\nB\vC\fD\nE\nF') +T.eq(#pages,4,'paragraphs and scrolling retain independent visible pages') +T.eq(pages[2][1],'B','scroll retains previous visible lower line') +T.eq(pages[2][2],'C','scroll introduces next line') +T.eq(pages[2].scrolled,true,'scroll provenance is explicit') +T.eq(UI.pagesOf('')[1][1],'','empty prompt retains one visible page') +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition) + local g=game(edition);local parent={};g.stack:push(parent) + local done,n=nil,0 + local ui=Screens.push(g,'Gen2SaveMenu',{existed=false,onDone=function(saved)done=saved;n=n+1;g.stack:pop()end}) + T.eq(getmetatable(ui),UI,edition..' native screen registry fallback') + T.eq(getmetatable(ui.message),Box,'actual native text printer retained') + T.eq(g.stack:top(),ui,'embedded prompt cannot replace native menu') + ui:accept();T.eq(ui.phase,'confirm','cannot accept before prompt finishes') + ready(g,ui);tick(g,'b');T.eq(done,false,'B declines without writing') + T.eq(g.writes,nil,'cancel cannot reach save writer');T.eq(g.stack:top(),parent,'exact parent restored') + ui:finish(true);T.eq(n,1,'completion callback runs once') + -- Existing file: two separate confirmations and original text scrolling. + g=game(edition);n=0 + ui=Screens.push(g,'Gen2SaveMenu',{existed=true,onDone=function(saved)done=saved;n=n+1;g.stack:pop()end}) + ready(g,ui);tick(g,'a');T.eq(ui.phase,'overwrite','existing file requires independent overwrite answer') + T.eq(g.writes,nil,'first YES does not write existing slot') + ready(g,ui);T.eq(ui.message.lineIndex>=3,true,'overwrite scrolling finishes before YES/NO becomes active') + T.eq(ui.page,2,'native text progress reaches public scrolled page') + tick(g,'down');T.eq(ui.choice,2,'real menu input selects NO') + tick(g,'a');T.eq(done,false,'overwrite NO returns cancellation');T.eq(g.writes,nil,'overwrite refusal preserves slot') + -- No existing slot: fixed save delay and one authoritative write. + g=game(edition);ui=Screens.push(g,'Gen2SaveMenu',{existed=false,onDone=function(saved)done=saved;g.stack:pop()end}) + ready(g,ui);tick(g,'a');T.eq(ui.phase,'saving','YES enters timed native save phase') + T.eq(ui.message.textSpeed,3,'timed save message pins MID cadence') + tick(g,'b',2);T.eq(ui.message.charIndex,0,'held B cannot accelerate timed save text') + wait(g,function()return ui.message.done end,'saving text completion') + tick(g,'b',UI.SAVING_FRAMES-1);T.eq(g.writes,nil,'B cannot skip pre-write delay') + tick(g,'a');T.eq(g.writes,1,'sixteenth post-text frame writes exactly once') + ui:writeNow();T.eq(g.writes,1,'duplicate direct write does not rewrite save') + wait(g,function()return ui.finished end,'saved completion') + T.eq(done,true,'actual successful writer reported');T.eq(g.writes,1,'all later timed frames keep one write') + -- Failed and throwing writers cannot display a successful completion. + for _,writer in ipairs({function()return false end,function()error('QA rejected write')end})do + g=game(edition);ui=Screens.push(g,'Gen2SaveMenu',{existed=false,writer=writer,onDone=function(saved)done=saved;g.stack:pop()end}) + ready(g,ui);tick(g,'a');wait(g,function()return ui.finished end,'failed save completion') + T.eq(done,false,'write failure reaches caller as false') + T.eq(ui:promptText(),'Could not save.','failed writer never prints saved message') + end + -- START's ordinary native route uses this owner, rather than a substitute + -- class offered only to optional consumers. + g=game(edition);local start=Screens.push(g,'StartMenu');local row + for i,item in ipairs(start.items)do if item.label=='SAVE' then row=i end end + T.eq(type(row),'number','native START exposes SAVE') + start.index=row;tick(g,'a') + T.eq(getmetatable(g.stack:top()),UI,'actual START input enters native save screen') +end +V.set('red');local g=game('red');local start=Screens.push(g,'StartMenu');local row +for i,item in ipairs(start.items)do if item.label=='SAVE' then row=i end end +start.index=row;tick(g,'a');T.eq(getmetatable(g.stack:top()),Box,'Gen1 retains original save dialogue') +V.set(original) +-- This state must enter the real Game writer, retaining its capture and +-- metadata/resume work rather than calling a bare serializer in production. +V.set('crystal');g=game('crystal');g.writeSave=require('src.core.Game').writeSave +local captures,writes=0,0 +g.overworld={captureSave=function(_,save)captures=captures+1;save.qaCaptured=true end} +local originalSave=Save.save +Save.save=function(save)writes=writes+1;T.eq(save.qaCaptured,true,'native overworld captured before write');T.eq(type(save.meta),'table','native writer stamps metadata');return true end +local ui=Screens.push(g,'Gen2SaveMenu',{existed=false}) +ready(g,ui);tick(g,'a');wait(g,function()return ui.finished end,'native Game writer completion') +T.eq(captures,1,'actual native writer captures live state once');T.eq(writes,1,'actual native writer saves once') +local Runtime=require('src.mods.Runtime');local previousHooks=Runtime.hooks +local hooks=require('src.mods.Hooks').new();Runtime.hooks=hooks +hooks:wrap('save.write',function()return false end,0,'qa-save-veto') +ui=Screens.push(g,'Gen2SaveMenu',{existed=false}) +ready(g,ui);tick(g,'a');wait(g,function()return ui.finished end,'native save hook veto') +T.eq(ui.saved,false,'native save veto cannot report success') +T.eq(captures,1,'veto runs before overworld mutation');T.eq(writes,1,'veto never reaches serializer') +Runtime.hooks=previousHooks +Save.save=originalSave;V.set(original) +T.finish('native Gen2 save menu') From b4ebb7abad167d91047036bdaf58f5aa6f3c127c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:29:44 +0100 Subject: [PATCH 33/68] Keep native Gen2 START as the owner of its submenus --- docs/native-gen2-save-menu.md | 18 +- src/core/ModCompat.lua | 2 +- src/render/ScreenSpaceUI.lua | 54 ++++ src/ui/Gen2SaveMenu.lua | 7 + src/ui/Gen2StartMenu.lua | 481 +++++++++++++++++++++++++++++++ src/ui/Gen2Typer.lua | 186 ++++++++++++ src/ui/Screens.lua | 6 +- src/ui/StartMenu.lua | 3 + tests/engine/gen2_save_menu.lua | 4 +- tests/engine/gen2_start_menu.lua | 81 ++++++ 10 files changed, 834 insertions(+), 8 deletions(-) create mode 100644 src/render/ScreenSpaceUI.lua create mode 100644 src/ui/Gen2StartMenu.lua create mode 100644 src/ui/Gen2Typer.lua create mode 100644 tests/engine/gen2_start_menu.lua diff --git a/docs/native-gen2-save-menu.md b/docs/native-gen2-save-menu.md index 0683b1278..a55e8614a 100644 --- a/docs/native-gen2-save-menu.md +++ b/docs/native-gen2-save-menu.md @@ -1,6 +1,14 @@ -# Native Gen2 save dialogue +# Native Gen2 START and save dialogue -Gen2 START now opens `Gen2SaveMenu`, an actual state over the native text +Gen2 START has its own native `Gen2StartMenu` class. Its public +`src.ui.gen2.StartMenu` spelling identifies the actual field-menu owner. +It uses the cartridge row IDs, scrolling list, descriptions and quit prompts, +retains the same START instance below native submenus, and remembers the +cursor in the save. Gen1 keeps its existing generic menu path. The START +controller and reusable typewriter are adapted from the read-only reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; existing rights remain applicable. + +START opens `Gen2SaveMenu`, an actual state over the native text printer and save pipeline. The public `src.ui.gen2.SaveMenu` spelling names this same owner. Screen registry overrides retain authority over the native `Gen2SaveMenu` entry. @@ -24,8 +32,10 @@ ROM-free integration tests exercise Gold/Silver/Crystal through actual stack, text printer, START input, screen resolution and the native Game writer, including refusal, overwrite, fixed timing, duplicate suppression, writer failure and a real save-hook veto. Gen1 retains its existing dialogue path. -Real-cartridge save/reload, unchanged-mod presentation, exact reference text -raster/typewriter helper APIs and full save/link parity remain unverified. +This upstream branch has ROM-free coverage only. Real-cartridge save/reload, +unchanged-mod menu presentation, exact reference raster/typewriter helper +coverage and full story/save/link parity remain unverified. Supplying native +START does not assert that other PC/menu owner contracts are complete. This state does not change save-format or link-protocol numbers. Original contributions by Roxas2712; existing upstream and third-party rights diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 8cb4114a0..a811635f1 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -85,7 +85,7 @@ ModCompat.ALIASES = { -- menus. The Gold port names two of these differently rather than -- namespacing them, so both spellings land on the one implementation. - ["src.ui.gen2.StartMenu"] = "src.ui.StartMenu", + ["src.ui.gen2.StartMenu"] = "src.ui.Gen2StartMenu", ["src.ui.gen2.OptionsMenu"] = "src.ui.OptionsMenu", ["src.ui.gen2.PartyMenu"] = "src.ui.PartyMenu", ["src.ui.gen2.PokedexMenu"] = "src.ui.PokedexMenu", diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..4ca2a5bea --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,54 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/src/ui/Gen2SaveMenu.lua b/src/ui/Gen2SaveMenu.lua index e5459484f..9c0c7f5b3 100644 --- a/src/ui/Gen2SaveMenu.lua +++ b/src/ui/Gen2SaveMenu.lua @@ -154,4 +154,11 @@ function M:drawPanel() end end function M:draw() self:drawPanel() end +-- Native fallback also works when a presentation provider draws in window +-- coordinates. Keep the world visible around the cartridge-sized panel. +function M:drawsWidescreen() return false end +function M:drawWidescreen(width,height) + return require('src.render.ScreenSpaceUI').classic(function() self:drawPanel() end, + require('src.render.Renderer'),width,height) +end return M diff --git a/src/ui/Gen2StartMenu.lua b/src/ui/Gen2StartMenu.lua new file mode 100644 index 000000000..a753cb6fa --- /dev/null +++ b/src/ui/Gen2StartMenu.lua @@ -0,0 +1,481 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Original project rights and notices remain applicable; see NOTICE.md. +-- Gold's START menu (engine/menus/start_menu.asm StartMenu). +-- +-- The box is right-aligned -- menu_coords 10, 0, SCREEN_WIDTH - 1, +-- SCREEN_HEIGHT - 1 -- and grows to fit however many entries are currently +-- unlocked. #DEX and POKEGEAR only appear once the player owns them, so early +-- in the game the menu is short; the ASM builds wMenuItemsList each time it +-- opens for exactly that reason (.SetUpMenuItems). +-- +-- With MENU ACCOUNT on, a second box at the bottom-left describes the +-- highlighted entry (.MenuDesc), which is why every item carries two lines of +-- description text here. +-- +-- The cursor position is remembered between openings +-- (wBattleMenuCursorPosition), so reopening the menu lands where you left it. +-- +-- The assembled list runs through the ui.start_menu.items hook before the menu +-- opens, exactly as the Gen 1 port's does (src/ui/StartMenu.lua), so mods +-- insert, drop or reorder rows without patching this file. It is the SAME +-- hook name and the same (game, items) payload: Gold's POKEGEAR row is simply +-- one more entry in the list the hook receives. + +local BugContest = require("src.world.BugContest") +local Chrome = require("src.ui.Gen2Chrome") +local Font = require("src.render.Font") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local StartMenu = {} +StartMenu.__index = StartMenu +-- Not opaque: the overworld keeps drawing underneath, the way the real menu +-- slides a window over the map. +StartMenu.isOpaque = false + +-- SFX_MENU on open, SFX_READ_TEXT_2 (the click) on select +-- (StartMenu_Show's `ld de, SFX_MENU / call PlaySFX` and .Select's +-- PlayClickSFX). By LABEL, not index: the old numeric ids (2 and 3) landed +-- on whatever the sfx table had there -- the item-get jingle on every menu +-- open -- and a repointed table would drift again. + +-- STARTMENUITEM_* in the order .SetUpMenuItems appends them. `need` is the +-- save flag that unlocks the entry; nil means always shown. +-- +-- Labels are the ASM's own strings, with the compression bytes expanded the +-- way the cart expands them at print time: "#DEX" is POKé + DEX (seven tiles), +-- while "GEAR" is GEAR -- the and glyphs ($70/$71) are +-- one tile each, so that row is six tiles wide, not eight. That difference is +-- exactly what kept POKéGEAR hanging off the menu box's right edge. +local ITEMS = { + { + id = "pokedex", label = Strings.source("POKéDEX"), need = "pokedex", + desc = Strings.source("POKéMON\ndatabase"), + }, + { + id = "pokemon", label = Strings.source("POKéMON"), need = "party", + desc = Strings.source("Party \nstatus"), + }, + { + id = "pack", label = Strings.source("PACK"), need = "pack", + desc = Strings.source("Contains\nitems"), + }, + { + id = "pokegear", label = Strings.source("GEAR"), need = "pokegear", + desc = Strings.source("Trainer's\nkey device"), + }, + { + -- The player's own name is the label (.StatusString is ""). + id = "status", label = nil, + desc = Strings.source("Your own\nstatus"), + }, + { + id = "save", label = Strings.source("SAVE"), + desc = Strings.source("Save your\nprogress"), + }, + { + id = "option", label = Strings.source("OPTION"), + desc = Strings.source("Change\nsettings"), + }, + { + -- The mod manager's discoverable home, exactly as the Gen 1 start menu + -- carries it: the row only appears once at least one mod has been + -- discovered, so a vanilla install's menu is the cart's. + id = "mods", label = Strings.source("MODS"), need = "mods", + desc = Strings.source("Installed\nadd-ons"), + }, + { + -- The cart's EXIT just closed the menu (CloseStartMenu). A window with a + -- close button already covers that, so -- exactly as the Gen 1 port does + -- (src/ui/StartMenu.lua) -- this row is QUIT and power-cycles back to the + -- title after a confirmation that defaults to NO. + id = "quit", label = Strings.source("QUIT"), + desc = Strings.source("Return to\nthe title"), + }, +} + +-- The confirmation's yes/no box. A bare YesNoBox lands at YesNoMenuHeader's +-- own menu_coords 10, 5, 15, 9, which is exactly where the start menu is, so +-- this uses the other position the cart already places one at: +-- SaveTheGame_yesorno's `lb bc, 0, 7`, a 6x5 box at (0,7) clear of the menu. +-- Labels sit at (2,8) and (2,10) -- border + 1, plus a column for +-- STATICMENU_CURSOR and no extra row because STATICMENU_NO_TOP_SPACING is set. +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 + +-- Persisted across openings, like wBattleMenuCursorPosition. +StartMenu.lastIndex = 1 + +-- ui.start_menu.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +-- opts: save, onChoose(id), onClose(), unlocked (override table for tests) +function StartMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, StartMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.onQuit = opts.onQuit + self.onChoose = opts.onChoose or function(id) self:open(id) end + self.onClose = opts.onClose or function() + if self.game.stack:top() == self then self.game.stack:pop() end + end + self.unlocked = opts.unlocked + StartMenu.lastIndex = self.save.startMenuIndex or StartMenu.lastIndex + local items = self:visibleItems() + -- Unguarded, like the Gen 1 site: the menu is built once per opening, not + -- per frame, so there is nothing here worth a wantsHook fast path. A hook + -- that answers with anything but a table is degraded to the vanilla list + -- rather than emptying the player's menu. + local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.start_menu.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- Let hooks inspect the cart's stable source labels. Translate only the + -- built-in rows afterwards; mod-supplied labels are content and stay raw. + for _, item in ipairs(items) do + if item.translateLabel then item.label = Strings(item.label) end + if item.translateDesc then + local translated = Strings(item.desc) + item.desc = {} + for line in (translated .. "\n"):gmatch("(.-)\n") do + item.desc[#item.desc + 1] = line + end + end + end + self.items = items + local options = (self.save and self.save.options) or {} + self.showDescription = options.menuAccount ~= false + + self.list = Chrome.List.new({ + items = self.items, + -- GetMenuTextStartCoord: the box's left/top plus one for the border, plus + -- one more for the cursor column (STATICMENU_CURSOR) and one more for the + -- top spacing this menu does not opt out of -- so (10,0) becomes (12,2), + -- and Chrome.List puts the cursor at x - 1 = 11. + -- pokecrystal engine/menus/start_menu.asm:164 + x = 12, y = self.contest and 4 or 2, spacing = 2, + -- Two rows per entry inside an 18-row screen leaves room for eight, which + -- is exactly the vanilla count. A mod that adds a row scrolls rather than + -- drawing off the bottom of the frame; Chrome.List puts the ▼ hint on. + rows = math.min(#self.items, 8), + wrap = true, + startAccepts = true, + index = math.min(StartMenu.lastIndex, math.max(1, #self.items)), + onChoose = function(value, index) self:choose(value, index) end, + onCancel = function() self:close() end, + }) + return self +end + +-- Which entries the player has unlocked. Derived from the save so the menu +-- grows as the game does; `unlocked` overrides it wholesale for tests. +-- .SetUpMenuItems tests two ENGINE flag bits, and both arrive as `setflag` +-- ids in save.engineFlags (constants/engine_flags.asm const order, via +-- World:setEngineFlag): `bit STATUSFLAGS_POKEDEX_F, [wStatusFlags]` is +-- ENGINE_POKEDEX = 11, written by Oak at Mr. Pokemon's house, and +-- `bit POKEGEAR_OBTAINED_F, [wPokegearFlags]` is ENGINE_POKEGEAR = 4, +-- written by Mom on the way out the door. +local ENGINE_POKEGEAR, ENGINE_POKEDEX = 4, 11 + +function StartMenu:availability() + if self.unlocked then return self.unlocked end + local save = self.save or {} + local inventory = save.inventory or {} + local engine = save.engineFlags or {} + local status = self.game and self.game.modStatus + return { + mods = status ~= nil and #(status.available or {}) > 0, + -- ENGINE_POKEDEX first; pokedexReceived stays as the test/driver override. + pokedex = require("src.script.Flags").hasPokedex(save) + or engine[ENGINE_POKEDEX] == true or save.pokedexReceived == true, + party = #(save.party or {}) > 0, + -- The PACK exists from the start; the cart gates it on nothing. + pack = true, + pokegear = (save.flags or {}).EVENT_GOT_POKEGEAR == true + or (save.flags or {}).ENGINE_POKEGEAR == true or engine[ENGINE_POKEGEAR] == true + or (inventory.POKEGEAR or 0) > 0 or save.pokegearReceived == true, + } +end + +function StartMenu:visibleItems() + local available = self:availability() + local contest = BugContest.active(self.save) + self.contest = contest + local playerName = (self.save and self.save.player and self.save.player.name) + or "GOLD" + local out = {} + for _, item in ipairs(ITEMS) do + local id = item.id + -- pokecrystal engine/menus/start_menu.asm:309 + local hidden = contest and (id == "pack" or id == "quit") + if not hidden and (not item.need or available[item.need]) then + if contest and id == "save" then + -- pokecrystal engine/menus/start_menu.asm:330 + out[#out + 1] = { + label = Strings.source("QUIT"), value = "quitContest", + -- pokecrystal engine/menus/start_menu.asm:231 + desc = Strings.source("Quit and\nbe judged."), + translateLabel = true, + translateDesc = true, + } + else + out[#out + 1] = { + label = item.label or playerName, + value = id, + desc = item.desc, + translateLabel = item.label ~= nil, + translateDesc = item.desc ~= nil, + } + end + end + end + return out +end + +function StartMenu:playSfx(name) + local data = self.game and self.game.data + local audio = data and data.audio + if not audio then return end + if audio.sfx and audio.sfx[name] then Sound.play(data, name) end +end + +function StartMenu:enter() + self:playSfx("Sfx_Menu") +end + +-- index comes from Chrome.List (src/ui/gen2/Chrome.lua:346) so a hook-injected +-- entry can be found by position: a mod's row carries an onSelect callback and +-- no `value`, the way the Gen 1 menu's rows do (src/ui/StartMenu.lua:30), and +-- without this arm it falls off the end of the id chain in Game2:switch. +function StartMenu:choose(id, index) + StartMenu.lastIndex = self.list.index + self.save.startMenuIndex = self.list.index + local item = index and self.items[index] + if item and item.onSelect and item.value == nil then + self:playSfx("Sfx_ReadText2") + item.onSelect(self.game) + return + end + self:playSfx("Sfx_ReadText2") + if id == "quit" then + -- Ask before throwing away everything since the last save. NO is the + -- default, the way the Gen 1 port's QUIT is. + self.phase = "confirm" + self.confirmChoice = 2 + self:beginPrompt() + return + end + if id == "quitContest" then + -- pokecrystal engine/menus/start_menu.asm:411 + self.phase = "confirmContest" + self.confirmChoice = 1 + self:beginPrompt() + return + end + if self.onChoose then self.onChoose(id) end +end + +function StartMenu:confirmQuit() + self.phase = nil + if self.onQuit then + self.onQuit() + elseif self.game and self.game.returnToTitle then + self.game:returnToTitle() + end +end + +-- pokecrystal engine/events/bug_contest/contest.asm:31 +function StartMenu:confirmQuitContest() + self.phase = nil + self:close() + local world = self.game and self.game.overworld + if world and world.bugContestReturnToGate then world:bugContestReturnToGate() end +end + +function StartMenu:close() + if self.closed then return end + self.closed = true + StartMenu.lastIndex = self.list.index + self.save.startMenuIndex = self.list.index + if self.onClose then self.onClose() end +end + +function StartMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.phase == "confirm" or self.phase == "confirmContest" then + if self.typedPhase ~= self.phase then self:beginPrompt() end + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("up") or input:wasPressed("down") then + self.confirmChoice = self.confirmChoice == 1 and 2 or 1 + elseif input:wasPressed("a") then + if self.confirmChoice ~= 1 then + self.phase = nil + elseif self.phase == "confirmContest" then + self:confirmQuitContest() + else + self:confirmQuit() + end + elseif input:wasPressed("b") or input:wasPressed("start") then + self.phase = nil + end + return + end + self.typedPhase = nil + -- START closes the menu as well as opening it. + if input:wasPressed("start") then + self:close() + return + end + self.list:update(input) +end + +local promptWarned = {} +local function twoRowPrompt(text, what) + local lines = Chrome.wrap(text, 18) + if #lines > 2 and not promptWarned[what] then + promptWarned[what] = true + Logger.warn("%s prompt needs %d lines, the box holds 2: %s", + what, #lines, tostring(text)) + end + return { lines[1] or "", lines[2] } +end + +function StartMenu:promptText() + if self.phase == "confirmContest" then + -- pokecrystal data/text/common_2.asm:1381 + return Strings("Would you like to\nend the Contest?"), "contest end" + end + return Strings(Strings.source("Return to the\ntitle screen?")), "title screen" +end + +-- pokecrystal engine/pokemon/mon_menu.asm:441 +function StartMenu:beginPrompt() + self.typedPhase = self.phase + self.promptLines = twoRowPrompt(self:promptText()) + self.typer = Typer.new(self.game) + self.typer:start(self.promptLines) +end + +function StartMenu:promptTyping() + return self.typedPhase ~= self.phase or Typer.typing(self) +end + +function StartMenu:drawPrompt() + Chrome.textbox(0, 12, 18, 4) + if self.typedPhase ~= self.phase then return end + local lines = Typer.text(self, self.promptLines) + Chrome.print(lines[1] or "", 1, 14) + Chrome.print(lines[2] or "", 1, 16) +end + +-- pokecrystal data/text/common_2.asm ContestStatus: the labels and the +-- "None" placeholder are text, so they go through the catalog like the +-- prompt below. The caught mon's own name does not: it is a nickname or a +-- species the `pokemon` registry already renames. +-- The cart pins each value to its own column (x=8, 7, 8) because it knows how +-- wide its own labels are. A translated label can be wider -- "ATTRAPÉ" fills +-- the 7 tiles CAUGHT leaves, "GEFANGEN" and "ATRAPADO" one more -- so the value +-- starts after the label instead of drawing on top of it, keeping the cart's +-- column whenever the label fits. +local function valueColumn(label, cartColumn) + local labelTiles = math.ceil(Font.width(label) / 8) + return math.max(cartColumn, 1 + labelTiles + 1) +end + +function StartMenu:drawContestStatus() + Chrome.textbox(0, 0, 17, 5) + local caught = Strings("CAUGHT") + local balls = Strings("BALLS:") + Chrome.print(caught, 1, 1) + local mon = BugContest.caught(self.save) + Chrome.print(mon and (mon.nickname or mon.name or mon.species) + or Strings("None", "contest.caught"), valueColumn(caught, 8), 1) + if mon then + local level = Strings("LEVEL") + Chrome.print(level, 1, 3) + Chrome.print(tostring(mon.level or 1), valueColumn(level, 7), 3) + end + Chrome.print(balls, 1, 5) + Chrome.print(tostring(BugContest.ballsLeft(self.save)), valueColumn(balls, 8), 5) +end + +function StartMenu:draw() + -- AutomaticGetMenuBottomCoord: bottom = top + 2 * items + 1, so the box is + -- two rows per entry plus its two border rows. A menu that a mod has grown + -- past the screen scrolls instead of overflowing (Chrome.List draws the ▼ + -- hint when there is more below). + local top = self.contest and 2 or 0 + local height = math.min(#self.items * 2 + 2, Chrome.SCREEN_H - top) + if self.contest then self:drawContestStatus() end + Chrome.box(10, top, 10, height) + self.list:draw() + + if self.phase == "confirmContest" then + self:drawPrompt() + -- pokecrystal engine/pokemon/mon_menu.asm:442 + if self:promptTyping() then return end + -- pokecrystal home/menu.asm:418 + Chrome.box(14, 7, 6, 5) + Chrome.print(Strings("YES"), 16, 8) + Chrome.print(Strings("NO"), 16, 10) + Chrome.cursor(15, self.confirmChoice == 1 and 8 or 10) + return + end + + if self.phase == "confirm" then + self:drawPrompt() + if self:promptTyping() then return end + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, + YESNO_Y + (self.confirmChoice == 1 and 1 or 3)) + return + end + + if not self.showDescription then return end + local item = self.list:current() + local desc = item and item.desc + if not desc then return end + -- ._DrawMenuAccount ClearBox (0,13) 5 rows by 10, .PrintMenuAccount decoord + -- 0, 14 and the desc's `next` steps two rows (start_menu.asm:366-382). + Chrome.paletteFill(0, 13 * 8, 10 * 8, 5 * 8) + Chrome.print(desc[1] or "", 0, 14) + Chrome.print(desc[2] or "", 0, 16) +end + +-- The native START owner remains on the stack while its submenu runs. +-- This is also the public class that optional presentation modules decorate. +function StartMenu:open(id) + local Screens = require('src.ui.Screens') + local game = self.game + if id == 'save' then + local saving + saving = Screens.push(game, 'Gen2SaveMenu', {onDone=function() + if game.stack:top() == saving then game.stack:pop() end + self:close() + end}) + return saving + end + local target = {pokedex='PokedexMenu', pokemon='PartyMenu', pack='BagMenu', + pokegear='PokegearMenu', status='TrainerCard', option='OptionsMenu', mods='ManagerState'} + if target[id] then return Screens.push(game, target[id], {onCancel=function() end}) end + if id == 'exit' then return self:close() end +end +function StartMenu:drawsWidescreen() return false end +function StartMenu:drawWidescreen(width, height) + return require('src.render.ScreenSpaceUI').classic(function() self:draw() end, + require('src.render.Renderer'), width, height) +end + +StartMenu.ITEMS = ITEMS + +return StartMenu diff --git a/src/ui/Gen2Typer.lua b/src/ui/Gen2Typer.lua new file mode 100644 index 000000000..e038a76fc --- /dev/null +++ b/src/ui/Gen2Typer.lua @@ -0,0 +1,186 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Original project rights and notices remain applicable; see NOTICE.md. +-- ../pokecrystal/home/print_text.asm:1 PrintLetterDelay +-- ../pokecrystal/home/text.asm:660 PrintTextboxTextAt + +local Font = require("src.render.Font") + +local Typer = {} +Typer.__index = Typer + +-- ../pokecrystal/home/print_text.asm:5 +local DELAYS = { FAST = 1, MID = 3, SLOW = 5, fast = 1, medium = 3, slow = 5 } + +local function spansOf(text) + if Font.split then return Font.split(text) end + local spans = {} + local i, n = 1, #text + while i <= n do + local last = i + if text:byte(i) >= 0xC0 then + local k = i + 1 + while k <= n do + local b = text:byte(k) + if b < 0x80 or b > 0xBF then break end + last, k = k, k + 1 + end + end + spans[#spans + 1] = { from = i, to = last } + i = last + 1 + end + return spans +end + +local function linesOf(page) + if page == nil then return {} end + if type(page) == "string" then + local lines = {} + for part in (page .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = part end + return lines + end + return page +end + +-- ../pokecrystal/engine/events/pokecenter_pc.asm:314 +function Typer.new(game, opts) + opts = opts or {} + return setmetatable({ + game = game, + speed = opts.speed, + instant = opts.instant and true or false, + expand = opts.expand, + shown = 0, + total = 0, + timer = 0, + page = {}, + }, Typer) +end + +-- ../pokecrystal/home/text.asm:520 _ContTextNoPause +function Typer:start(page) + local out, spans, total, kept = {}, {}, 0, 0 + local scrolled = type(page) == "table" and page.scrolled + for i, line in ipairs(linesOf(page)) do + local text = self.expand and self.expand(line) or line + out[i] = text + -- Split once here: lines() runs every frame while the page types out. + local lineSpans = spansOf(text) + spans[i] = lineSpans + local n = #lineSpans + total = total + n + if scrolled and i == 1 then kept = n end + end + self.page = out + self.spans = spans + self.spansPage = out + self.linesOut, self.linesShown = nil, nil + self.total = total + self.shown = self.instant and total or kept + self.timer = 0 +end + +-- ../pokecrystal/home/print_text.asm:52 +function Typer:delay() + if self.instant then return 0 end + local options = self.game and self.game.save and self.game.save.options + local raw = self.speed or (options and options.textSpeed) + local delay = DELAYS[raw] or tonumber(raw) or 3 + if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end + local input = self.game and self.game.input + if input and input.isDown + and (input:isDown("a") or input:isDown("b")) then + delay = 1 + end + return delay +end + +function Typer:done() + return self.shown >= self.total +end + +function Typer:tick() + if self:done() then return true end + local delay = self:delay() + if delay <= 0 then + self.shown = self.total + return true + end + if self.timer >= delay then self.timer = delay - 1 end + self.timer = self.timer + 1 + while self.timer >= delay and self.shown < self.total do + self.timer = self.timer - delay + self.shown = self.shown + 1 + end + return self:done() +end + +function Typer:lines() + if self:done() then return self.page end + -- The same partial page is asked for every frame between two letters. + if self.linesOut and self.linesShown == self.shown + and self.linesPage == self.page then + return self.linesOut + end + local cached = self.spansPage == self.page and self.spans or nil + local out, left = {}, self.shown + self.linesOut, self.linesShown, self.linesPage = out, self.shown, self.page + for i, line in ipairs(self.page) do + local spans = cached and cached[i] or spansOf(line) + if left >= #spans then + out[i] = line + elseif left <= 0 then + out[i] = "" + else + out[i] = line:sub(1, spans[left].to) + end + left = left - #spans + end + return out +end + +-- ../pokecrystal/home/menu.asm:328 +function Typer.say(screen, pages, onDone, opts) + screen.message = { pages = pages or {}, page = 1, onDone = onDone } + screen.typer = Typer.new(screen.game, opts) + screen.typer:start(screen.message.pages[1]) + return screen.message +end + +function Typer.begin(screen, record, opts) + screen.typer = Typer.new(screen.game, opts) + screen.typer:start(record.pages and record.pages[record.page or 1]) + return record +end + +function Typer.turn(screen, record) + record.page = record.page + 1 + if screen.typer then screen.typer:start(record.pages[record.page]) end +end + +function Typer.step(screen) + screen.arrowBlink = ((screen.arrowBlink or 0) + 1) % 32 + local typer = screen.typer + if not typer then return true end + return typer:tick() +end + +-- ../pokegold/home/joypad.asm:430 +function Typer.arrowOn(screen) + return ((screen.arrowBlink or 0) % 32) < 16 +end + +-- ../pokecrystal/home/print_text.asm:59 +function Typer.typing(screen) + local typer = screen.typer + return typer ~= nil and not typer:done() +end + +function Typer.text(screen, fallback) + local typer = screen.typer + if not typer then return fallback end + return typer:lines() +end + +Typer.DELAYS = DELAYS + +return Typer diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 704c642e3..4011d5a23 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -11,7 +11,6 @@ local Screens = {} -- ids whose builtin module is not under src/ui/ local BUILTIN = { ManagerState = "src.mods.ManagerState", - Gen2StartMenu = "src.ui.StartMenu", } local cache = {} @@ -400,6 +399,11 @@ end -- The id a push should really open, and the arguments it should open with. -- Public so the suite can state the rule without driving a whole game. function Screens.resolveId(game, id, arg) + -- GSC owns a native START state, including its live submenu parent. + if id == 'StartMenu' and require('src.core.GameVersion').isGen2() then + local screens = game and game.data and game.data.screens + if not (screens and screens[id]) then return 'Gen2StartMenu', arg end + end -- BLACK AND WHITE'S OWN SCREENS, ahead of everything (a Gen 5 cache is not -- a Gen 4 one, so the table below would hand it the Game Boy's): so far the -- evolution scene, positional like Platinum's diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index ce6c20d33..999db2f14 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -17,6 +17,9 @@ local StartMenu = {} local function sameItems(_, items) return items end function StartMenu.new(game) + if require("src.core.GameVersion").isGen2() then + return require("src.ui.Gen2StartMenu").new(game) + end local flags = game.save.flags or {} local items = {} diff --git a/tests/engine/gen2_save_menu.lua b/tests/engine/gen2_save_menu.lua index 238b8a1dd..6d51cb02f 100644 --- a/tests/engine/gen2_save_menu.lua +++ b/tests/engine/gen2_save_menu.lua @@ -83,12 +83,12 @@ for _,edition in ipairs({'gold','silver','crystal'})do g=game(edition);local start=Screens.push(g,'StartMenu');local row for i,item in ipairs(start.items)do if item.label=='SAVE' then row=i end end T.eq(type(row),'number','native START exposes SAVE') - start.index=row;tick(g,'a') + if start.list then start.list.index=row else start.index=row end;tick(g,'a') T.eq(getmetatable(g.stack:top()),UI,'actual START input enters native save screen') end V.set('red');local g=game('red');local start=Screens.push(g,'StartMenu');local row for i,item in ipairs(start.items)do if item.label=='SAVE' then row=i end end -start.index=row;tick(g,'a');T.eq(getmetatable(g.stack:top()),Box,'Gen1 retains original save dialogue') +if start.list then start.list.index=row else start.index=row end;tick(g,'a');T.eq(getmetatable(g.stack:top()),Box,'Gen1 retains original save dialogue') V.set(original) -- This state must enter the real Game writer, retaining its capture and -- metadata/resume work rather than calling a bare serializer in production. diff --git a/tests/engine/gen2_start_menu.lua b/tests/engine/gen2_start_menu.lua new file mode 100644 index 000000000..ce554256e --- /dev/null +++ b/tests/engine/gen2_start_menu.lua @@ -0,0 +1,81 @@ +-- Native Gen2 START ownership, routing and input (ROM-free). +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2StartMenu');local Saving=require('src.ui.Gen2SaveMenu') +local Screens=require('src.ui.Screens');local Stack=require('src.core.StateStack') +local Generic=require('src.ui.Menu');local Font=require('src.render.Font') +local function game(version) + local g={data=T.fixtures.fresh(),save={version=version,player={name='QA'},flags={},options={},party={},pokedex={owned={}},inventory={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end + g.input.isDown=function(self,k)return self.queue[k]==true end + g.data.font.charmap={} + for code=32,126 do g.data.font.charmap[#g.data.font.charmap+1]={seq=string.char(code),code=0x80+(code-32)}end + Font.load(g.data) + g.stack=setmetatable({}, {__index=Stack});g.stack:init();g.world={};g.stack:push(g.world) + g.writeSave=function(self)self.writes=(self.writes or 0)+1;return true end + return g +end +local function tick(g,key) + g.input.queue=key and {[key]=true}or{};g.stack:update(1/60);g.input.queue={} +end +local function select(g,ui,value) + for _=1,#ui.items do + if ui.items[ui.list.index].value==value then tick(g,'a');return end + tick(g,'down') + end + error('native START missing row '..value) +end +local function ready(g,ui) + for _=1,3000 do + if ui:yesNoVisible()then return end + tick(g,ui.message.waiting and 'a' or nil) + end + error('save choices never ready') +end +T.eq(require('src.ui.gen2.StartMenu'),UI,'public spelling is the actual native START class') +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local g=game(version) + local ui=Screens.push(g,'StartMenu') + T.eq(ui.screenId,'Gen2StartMenu','ordinary field route identifies the native Gen2 owner') + T.eq(getmetatable(ui),UI,'ordinary START has the public class identity') + T.eq(ui.isOpaque,false,'START leaves live world behind it') + T.eq(ui.items[1].value,'pack','empty-party menu begins with native PACK') + T.eq(ui.items[1].label,'PACK','native GSC label') + tick(g,'up');T.eq(ui.list.index,#ui.items,'native cursor wraps upward') + tick(g,'down');T.eq(ui.list.index,1,'native cursor wraps downward') + select(g,ui,'save');local save=g.stack:top() + T.eq(getmetatable(save),Saving,'real input opens native save owner') + T.eq(g.stack.states[#g.stack.states-1],ui,'exact START instance remains below its save submenu') + ready(g,save);tick(g,'b') + T.eq(g.stack:top(),g.world,'save cancellation closes both owners and returns to world') + T.eq(g.writes,nil,'save cancellation never reaches writer') + ui=Screens.push(g,'StartMenu') + T.eq(ui.items[ui.list.index].value,'save','cursor survives a complete START/save round trip') + tick(g,'start');T.eq(g.stack:top(),g.world,'START closes without invoking highlighted action') + ui=Screens.push(g,'StartMenu');select(g,ui,'pack') + T.neq(g.stack:top(),ui,'pack is a real native submenu') + T.eq(g.stack.states[#g.stack.states-1],ui,'pack retains exact parent rather than reconstructing it') + tick(g,'b');T.eq(g.stack:top(),ui,'pack B restores original START owner') + tick(g,'b');T.eq(g.stack:top(),g.world,'START B restores world') + -- Engine flags are the cartridge stores; unlocks must not depend on an + -- optional presentation module inventing legacy convenience fields. + g.save.flags.ENGINE_POKEDEX=true;g.save.flags.ENGINE_POKEGEAR=true;g.save.party={{species='CYNDAQUIL',hp=10}} + ui=Screens.push(g,'StartMenu');local ids={} + for _,row in ipairs(ui.items)do ids[row.value]=true end + T.eq(ids.pokedex,true,'native dex bit unlocks Pokédex') + T.eq(ids.pokemon,true,'actual party unlocks Pokémon') + T.eq(ids.pokegear,true,'native gear bit unlocks Pokégear') + tick(g,'b') + local n=0;ui=UI.new(g,{onClose=function()n=n+1 end});ui:close();ui:close() + T.eq(n,1,'close callback is idempotent') +end +V.set('red');local g=game('red');local red=Screens.push(g,'StartMenu') +T.eq(getmetatable(red),Generic,'Gen1 keeps its generic START implementation') +T.eq(Screens.resolveId(g,'StartMenu'),'StartMenu','Gen1 screen routing remains unchanged') +V.set('crystal');g=game('crystal');g.data.screens={StartMenu={}} +T.eq(Screens.resolveId(g,'StartMenu'),'StartMenu','explicit registry owner still wins') +V.set(original) +T.finish('native Gen2 START owner') From f35c6df4e6ba888055fba97ebd2cdc2beb330fc4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:35:24 +0100 Subject: [PATCH 34/68] Scale native menu fallbacks on their own cartridge grid --- src/render/ScreenSpaceUI.lua | 54 --------------------------------- src/ui/Gen2Chrome.lua | 10 ++++-- src/ui/Gen2SaveMenu.lua | 3 +- src/ui/Gen2StartMenu.lua | 3 +- tests/gpu/gen2_menu_overlay.lua | 48 +++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 60 deletions(-) delete mode 100644 src/render/ScreenSpaceUI.lua create mode 100644 tests/gpu/gen2_menu_overlay.lua diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua deleted file mode 100644 index 4ca2a5bea..000000000 --- a/src/render/ScreenSpaceUI.lua +++ /dev/null @@ -1,54 +0,0 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. --- Optional window-resolution UI. Simulation/input remain native; only an --- explicitly advertised drawer leaves the cartridge-sized UI canvas. -local M = {} -local function visible(stack, state) - return not stack.renderVisible or stack:renderVisible(state) -end -function M.select(stack, scene, first) - for i = #scene.states, first, -1 do - local state = scene.states[i] - if visible(stack, state) and type(state.drawsWidescreen) == 'function' - and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then - return state, i - end - end -end -function M.classic(draw, renderer, width, height) - local G = love.graphics - local uw, uh = renderer:uiSize() - local p = renderer.uiPresentation - if not p then - local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) - p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } - end - G.push('all') - G.origin() - G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) - G.scale(p.scaleX,p.scaleY) - local ok, err = pcall(draw) - G.pop() - if not ok then error(err,0) end -end -function M.draw(owner, renderer, viewport, zones, overlay) - local G = love.graphics - G.push('all') - G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') - local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) - G.pop() - if not ok then error(err,0) end - -- The classic canvas contains only states above this owner. Reapply it - -- after the high-resolution drawer so a native textbox keeps its parent. - if overlay then - local p = renderer.uiPresentation - if not p then - p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} - end - G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') - local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, - p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) - G.pop() - if not overlayOK then error(overlayError,0) end - end -end -return M diff --git a/src/ui/Gen2Chrome.lua b/src/ui/Gen2Chrome.lua index df557ba62..b36f65206 100644 --- a/src/ui/Gen2Chrome.lua +++ b/src/ui/Gen2Chrome.lua @@ -167,9 +167,10 @@ end Chrome.clipTo = clipTo -function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) +-- Draw the native 20x18 panel without painting over its live surroundings. +-- Its grid is independent of any higher-resolution presentation provider. +function Chrome.withOverlayPanel(winW, winH, drawFn, scale) local G = love.graphics - Chrome.letterbox(winW, winH, r, g, b) scale = scale or Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push("all") @@ -181,6 +182,11 @@ function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) if not ok then error(err, 0) end end +function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) + Chrome.letterbox(winW, winH, r, g, b) + return Chrome.withOverlayPanel(winW, winH, drawFn, scale) +end + function Chrome.withClip(drawFn) local G = love.graphics G.push("all") diff --git a/src/ui/Gen2SaveMenu.lua b/src/ui/Gen2SaveMenu.lua index 9c0c7f5b3..9217a053a 100644 --- a/src/ui/Gen2SaveMenu.lua +++ b/src/ui/Gen2SaveMenu.lua @@ -158,7 +158,6 @@ function M:draw() self:drawPanel() end -- coordinates. Keep the world visible around the cartridge-sized panel. function M:drawsWidescreen() return false end function M:drawWidescreen(width,height) - return require('src.render.ScreenSpaceUI').classic(function() self:drawPanel() end, - require('src.render.Renderer'),width,height) + return Chrome.withOverlayPanel(width,height,function() self:drawPanel() end) end return M diff --git a/src/ui/Gen2StartMenu.lua b/src/ui/Gen2StartMenu.lua index a753cb6fa..d873e8758 100644 --- a/src/ui/Gen2StartMenu.lua +++ b/src/ui/Gen2StartMenu.lua @@ -472,8 +472,7 @@ function StartMenu:open(id) end function StartMenu:drawsWidescreen() return false end function StartMenu:drawWidescreen(width, height) - return require('src.render.ScreenSpaceUI').classic(function() self:draw() end, - require('src.render.Renderer'), width, height) + return Chrome.withOverlayPanel(width,height,function() self:draw() end) end StartMenu.ITEMS = ITEMS diff --git a/tests/gpu/gen2_menu_overlay.lua b/tests/gpu/gen2_menu_overlay.lua new file mode 100644 index 000000000..eb6b6f101 --- /dev/null +++ b/tests/gpu/gen2_menu_overlay.lua @@ -0,0 +1,48 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A native fallback must ignore a provider's larger UI coordinate system. +local M={} +function M.run() + local g=love.graphics + local Save=require('src.ui.Gen2SaveMenu') + local Start=require('src.ui.Gen2StartMenu') + local Renderer=require('src.render.Renderer') + local V=require('src.core.GameVersion');local version=V.get() + local oldSize,oldPresentation=Renderer.uiSize,Renderer.uiPresentation + local canvas=g.newCanvas(800,720,{format='rgba8',dpiscale=1});M.image=canvas + local checks=0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + g.push('all') + local ok,why=xpcall(function() + V.set('crystal') + -- A provider may advertise its own 512x288 grid to the compositor. It + -- must not shrink either native 160x144 fallback. + Renderer.uiSize=function()return 512,288 end + Renderer.uiPresentation={x=0,y=0,scaleX=1,scaleY=1} + for _,owner in ipairs({Save,Start})do + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.clear(.1,.2,.3,1) + local native=setmetatable({drawPanel=function() + g.setColor(1,0,0,1);g.rectangle('fill',32,16,8,8) + end},owner) + -- START's native method paints its controller; the marker occupies the + -- exact same cartridge coordinates without requiring ROM font assets. + native.draw=native.drawPanel + native:drawWidescreen(800,720) + g.setCanvas() + local pixels=g.readbackTexture(canvas) + local r,green,b=pixels:getPixel(160,80) + check(r>.99 and green<.01 and b<.01,'native fallback moved or shrank into provider coordinates') + r,green,b=pixels:getPixel(199,119) + check(r>.99 and green<.01 and b<.01,'native pixel extent lost its integer 5x scale') + r,green,b=pixels:getPixel(200,120) + check(math.abs(r-.1)<.01 and math.abs(green-.2)<.01 and math.abs(b-.3)<.01,'native fallback spills beyond its scaled pixel extent') + r,green,b=pixels:getPixel(0,0) + check(math.abs(r-.1)<.01 and math.abs(green-.2)<.01 and math.abs(b-.3)<.01,'fallback overwrote the live world surround') + pixels:release() + end + end,debug.traceback) + g.pop();Renderer.uiSize=oldSize;Renderer.uiPresentation=oldPresentation + V.set(version) + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='Real GPU native START/SAVE window fallback pixels with a larger provider UI grid'},canvas +end +return M From 2383d847bea75fe07c520ae9ceabbb89d73e6109 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:21:00 +0100 Subject: [PATCH 35/68] Draw native Gen2 menu owners at output resolution --- src/core/Game.lua | 30 ++++++++++- src/render/ScreenSpaceUI.lua | 93 ++++++++++++++++++++++++++++++++ tests/engine/screen_space_ui.lua | 68 +++++++++++++++++++++++ tests/gpu/gen2_display_modes.lua | 8 ++- tests/gpu/gen2_native_stack.lua | 41 ++++++++++++++ 5 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 src/render/ScreenSpaceUI.lua create mode 100644 tests/engine/screen_space_ui.lua create mode 100644 tests/gpu/gen2_native_stack.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 60bd2784c..4a3db7a15 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1013,6 +1013,13 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI=require('src.render.ScreenSpaceUI') + local scene=self.stack + local windowUI,windowUIIndex=ScreenSpaceUI.select(self.stack,scene,drawFrom) + local windowUIOverlay=false + local nativeWindowOverlays=windowUI and require('src.core.GameVersion').isGen2() + local nativeWindowStack=not windowUI and require('src.core.GameVersion').isGen2() + and ScreenSpaceUI.hasNativeStack(self.stack,scene,drawFrom,self.overworld) local worldDrawn = self.stack.states[drawFrom] == self.overworld -- 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 @@ -1095,7 +1102,13 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if nativeWindowOverlays and windowUIIndex and i>windowUIIndex then + if state and state.draw then windowUIOverlay=true end + elseif nativeWindowStack and state~=self.overworld then + -- Native controllers draw once on the full output surface below. + elseif state==windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1117,7 +1130,7 @@ function Game:_draw() end local baseState = self.stack.states[drawFrom] local SuperSample = require("src.render.SuperSample") - if not worldDrawn and baseState and require("src.core.GameVersion").isNds() + if not windowUI and not worldDrawn and baseState and require("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else @@ -1187,7 +1200,20 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end + if Renderer.gen2FrameManaged and (windowUI or nativeWindowStack) then + Renderer.gen2WindowDraw=function(viewport) + if windowUI then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,false) + if windowUIOverlay then ScreenSpaceUI.drawNativeStack(self.stack,scene,windowUIIndex+1, + self.overworld,Renderer,viewport,false)end + else ScreenSpaceUI.drawNativeStack(self.stack,scene,drawFrom,self.overworld, + Renderer,viewport,not worldDrawn)end + end + end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI and not Renderer.gen2FrameManaged then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..0f04a3bc7 --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,93 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +-- Gen2's reference renderer paints native overlays on its full-resolution +-- scene, under a cartridge-grid transform. A drawer that resets origin() +-- therefore gets real window pixels, not a 160x144 intermediate texture. +function M.hasNativeStack(stack, scene, first, world) + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then return true end + end + return false +end +function M.drawNativeStack(stack, scene, first, world, renderer, viewport, opaque) + local G=love.graphics + G.push('all');G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok,err=pcall(function() + if opaque then G.rectangle('fill',0,0,viewport.width,viewport.height) end + M.classic(function() + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then + G.push('all') + -- Declared larger native surfaces retain their own origin. Plain + -- GB states stay centred in a parent's expanded surface. + if type(state.uiSize)=='function' then + local sized,w,h=pcall(state.uiSize,state) + if sized and type(w)=='number' and w>0 and type(h)=='number' and h>0 then + G.translate((renderer.WIDTH-w)/2,(renderer.HEIGHT-h)/2) + end + end + local drawn,why=pcall(state.draw,state) + G.pop() + if not drawn then error(why,0) end + end + end + end,renderer,viewport.width,viewport.height) + end) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..c983542a4 --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +-- Native Gen2 controllers can draw on the full-size output without a +-- low-resolution intermediate, while retaining their native stack lifetime. +trace={};local world={draw=function()trace[#trace+1]='world' end} +local hidden={draw=function()error('hidden native state drawn')end} +local output={width=800,height=720} +local native={draw=function(self) + T.eq(love.graphics.getCanvas(),output,'native drawer receives output canvas directly') + trace[#trace+1]='native';love.graphics.setColor(0,1,0,1) +end} +local front={draw=function() + local r=love.graphics.getColor();T.eq(r,1,'preceding native drawer cannot leak its graphics state') + trace[#trace+1]='front' +end} +scene.states={world,hidden,native,front} +local filtered={renderVisible=function(_,s)return s~=hidden end} +T.eq(UI.hasNativeStack(filtered,scene,1,world),true,'visible native stack requests output-resolution drawing') +T.eq(UI.hasNativeStack(filtered,{states={world,hidden}},1,world),false,'world and mirrored states alone request no UI pass') +love.graphics.setCanvas(output);love.graphics.setColor(.2,.3,.4,1) +UI.drawNativeStack(filtered,scene,1,world,renderer,viewport,false) +T.same(trace,{'native','front'},'world stays single-pass and native stack draw order is retained') +T.eq(love.graphics.getCanvas(),output,'native pass restores output target') +T.eq(select(1,love.graphics.getColor()),.2,'native pass restores caller color') +trace={};UI.drawNativeStack(filtered,scene,4,world,renderer,viewport,false) +T.same(trace,{'front'},'native overlays above wide owner exclude that owner') +native.draw=function()love.graphics.setCanvas({});error('native drawer failure')end +T.raises(function()UI.drawNativeStack(filtered,scene,3,world,renderer,viewport,false)end,'native drawer failure','native errors remain visible') +T.eq(love.graphics.getCanvas(),output,'failed native drawer restores output target') +love.graphics.setCanvas() +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua index 798caa694..71826794c 100644 --- a/tests/gpu/gen2_display_modes.lua +++ b/tests/gpu/gen2_display_modes.lua @@ -53,8 +53,12 @@ function M.run() P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game Renderer.gen2FrameZones=Present.zones(game) - if windowMenu then Renderer.gen2WindowDraw=function() - g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + if windowMenu then Renderer.gen2WindowDraw=function(viewport) + local vector={draw=function() + g.origin();g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={vector}},1,nil,Renderer,viewport,false) end end nativeViewport=Renderer:endFrame(nil,nil) g.setCanvas();g.setShader() diff --git a/tests/gpu/gen2_native_stack.lua b/tests/gpu/gen2_native_stack.lua new file mode 100644 index 000000000..755993335 --- /dev/null +++ b/tests/gpu/gen2_native_stack.lua @@ -0,0 +1,41 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Output pixels for the native Gen2 menu layer, independently of screen providers. +local M={} +function M.run() + local g=love.graphics;local Renderer=require('src.render.Renderer') + local oldSize,oldPresentation=Renderer.uiSize,Renderer.uiPresentation + local canvas=g.newCanvas(800,720,{format='rgba8',dpiscale=1});M.image=canvas + local checks=0;local function check(ok,why)checks=checks+1;assert(ok,why)end + g.push('all') + local ok,why=xpcall(function() + -- Bitmap coordinates keep integer cartridge scaling; a vector drawer that + -- resets origin() paints one actual output pixel, without enlarged texels. + Renderer.uiSize=function()return 160,144 end + Renderer.uiPresentation={x=0,y=0,scaleX=5,scaleY=5} + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.clear(0,0,0,1) + local native={draw=function() + check(g.getCanvas()==canvas,'native stack left the full-size output target') + g.setColor(1,1,1,1);g.rectangle('fill',2,4,1,1) + end} + local vector={draw=function() + g.origin();g.setColor(1,0,0,1);g.rectangle('fill',23,31,1,1) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={native,vector}},1,nil,Renderer,{width=800,height=720},false) + g.setCanvas();local pixels=g.readbackTexture(canvas) + local r,green,b=pixels:getPixel(10,20) + check(r>.99 and green>.99 and b>.99,'native bitmap moved from cartridge pixel grid') + r,green,b=pixels:getPixel(14,24) + check(r>.99 and green>.99 and b>.99,'native bitmap scale changed') + r,green,b=pixels:getPixel(23,31) + check(r>.99 and green<.01 and b<.01,'vector drawer lost its output pixel coordinates') + r,green,b=pixels:getPixel(24,31) + check(r<.01 and green<.01 and b<.01,'one vector pixel enlarged through a low-resolution intermediate') + pixels:release() + end,debug.traceback) + g.pop();Renderer.uiSize=oldSize;Renderer.uiPresentation=oldPresentation + if not ok then error(why,0)end + return{passed=true,checks=checks,scope='Real GPU direct native/vector menu output pixels'},canvas +end +return M From 30c72b0a2330494f64ce53a434a855e1dcd30cbb Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:34:36 +0100 Subject: [PATCH 36/68] Preserve subscription order for equally ranked native UI events --- src/mods/Events.lua | 12 ++++++-- tests/engine/event_subscription_order.lua | 34 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 tests/engine/event_subscription_order.lua diff --git a/src/mods/Events.lua b/src/mods/Events.lua index 88d27e79b..ae3060b1d 100644 --- a/src/mods/Events.lua +++ b/src/mods/Events.lua @@ -15,8 +15,16 @@ function Events:on(name, callback, priority, owner) local list = self.listeners[name] or {} self.listeners[name] = list local entry = { callback = callback, priority = priority or 0, owner = owner } - list[#list + 1] = entry - table.sort(list, function(a, b) return a.priority > b.priority end) + -- Stable priority insertion: equally ranked listeners run in subscription + -- order. Re-sorting with a priority-only comparator permuted existing peers + -- whenever another listener joined, allowing an earlier generic decorator + -- to overwrite a later screen-specific one after a settled screen push. + local index = #list + 1 + while index > 1 and list[index - 1].priority < entry.priority do + list[index] = list[index - 1] + index = index - 1 + end + list[index] = entry return function() for i, candidate in ipairs(list) do if candidate == entry then table.remove(list, i) break end diff --git a/tests/engine/event_subscription_order.lua b/tests/engine/event_subscription_order.lua new file mode 100644 index 000000000..089971b1e --- /dev/null +++ b/tests/engine/event_subscription_order.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Equal-priority UI decorators must not change order when peers subscribe. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Events=require('src.mods.Events') +for count=2,40 do + local bus=Events.new();local seen={} + for n=1,count do + local id=n + bus:on('screen',function()seen[#seen+1]=id end,0,'owner'..n) + end + bus:on('screen',function()seen[#seen+1]='high'end,10,'high') + bus:on('screen',function()seen[#seen+1]='low'end,-10,'low') + bus:emit('screen',{}) + T.eq(seen[1],'high','explicit higher priority remains first') + for n=1,count do T.eq(seen[n+1],n,'same-priority callbacks retain subscription order with '..count..' peers') end + T.eq(seen[#seen],'low','explicit lower priority remains last') +end +local bus=Events.new();local state={};local seen={};local retire +bus:on('screen',function(s)s.panel='generic';seen[#seen+1]='first'end) +retire=bus:on('screen',function(s)s.panel='specific';seen[#seen+1]='second'end) +bus:once('screen',function() + seen[#seen+1]='once';retire() + bus:on('screen',function()seen[#seen+1]='late'end) +end) +bus:emit('screen',state) +T.eq(state.panel,'specific','the later specific decorator settles after the earlier generic decorator') +T.eq(table.concat(seen,','),'first,second,once','dispatch retains its snapshot during retirement and subscription') +seen={};bus:emit('screen',state) +T.eq(table.concat(seen,','),'first,late','new callbacks participate in the following emission only') +bus:removeOwner('missing');seen={};bus:emit('screen',state) +T.eq(table.concat(seen,','),'first,late','owner removal does not reorder retained callbacks') +T.finish('stable event subscription order') From e5230c12ad405639fe81b1fe72a40e339d7e55c9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:39:04 +0100 Subject: [PATCH 37/68] Expose native save summaries without legacy clock assumptions --- src/core/SaveData.lua | 23 ++++++++++++++++++ tests/engine/native_save_summary.lua | 35 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/engine/native_save_summary.lua diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..24884ad56 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -963,6 +963,29 @@ function SaveData.playSeconds(save) + ticks / 60 end +-- The native save owns a scalar play clock and an owned-species map. Expose +-- the reference UI summary without requiring callers to reinterpret either +-- representation, load a profile, or create a second save/time store. +function SaveData.summary(save, data) + if type(save) ~= "table" then return nil end + local caught = 0 + for _, has in pairs((save.pokedex and save.pokedex.owned) or {}) do + if has then caught = caught + 1 end + end + local seconds = SaveData.playSeconds(save) + if seconds ~= seconds or seconds == math.huge or seconds == -math.huge then seconds = 0 end + seconds = math.max(0, math.floor(seconds)) + local spawn = save.spawn + return { + name = save.player and save.player.name or "?", + badges = Badges.count(data, save, save.version), + caught = caught, + hours = math.floor(seconds / 3600), + minutes = math.floor(seconds / 60) % 60, + map = save.position and save.position.map or (type(spawn) == "table" and spawn.map or spawn), + } +end + function SaveData.slotSummary(save, version) if type(save) ~= "table" then return nil, nil end local name = save.player and save.player.name or nil diff --git a/tests/engine/native_save_summary.lua b/tests/engine/native_save_summary.lua new file mode 100644 index 000000000..6059faa05 --- /dev/null +++ b/tests/engine/native_save_summary.lua @@ -0,0 +1,35 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local previous=V.get() +require('src.core.ModCompat').install() +local Save=require('src.core.SaveData') +T.eq(Save,require('src.core.gen2.Save'),'legacy save spelling retains the native owner') +T.eq(Save.summary(nil),nil,'empty summary has no record') +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local owned={A=true,B=false,C=true} + local save={version=version,player={name='QA'},flags={ZEPHYRBADGE=true,BOULDERBADGE=true}, + pokedex={owned=owned},inventory={},position={map='ROOM'},playTime=3661.75} + local value=Save.summary(save) + T.eq(value.name,'QA','summary reads the actual native trainer') + T.eq(value.caught,2,'summary counts only owned species') + T.eq(value.badges,2,'summary counts both native badge regions') + T.eq(value.hours,1,'scalar seconds become whole hours') + T.eq(value.minutes,1,'scalar seconds become minute remainder') + T.eq(value.map,'ROOM','summary uses the persisted native map') + T.eq(save.playTime,3661.75,'summary does not rewrite the live clock') + T.eq(save.pokedex.owned,owned,'summary retains native map identity') + save.playTime={hours=2,minutes=3,seconds=4,frames=30} + value=Save.summary(save) + T.eq(value.hours,2,'legacy broken-down time remains readable') + T.eq(value.minutes,3,'legacy minutes retain their meaning') + T.eq(type(save.playTime),'table','legacy input is read without migration') + value.name='CHANGED';T.eq(save.player.name,'QA','summary results are detached') + save.playTime=0/0;T.eq(Save.summary(save).hours,0,'invalid play time cannot crash a menu') + save.position=nil;save.spawn={map='START'} + T.eq(Save.summary(save).map,'START','native spawn records expose their map') +end +V.set(previous) +T.finish('native save summary for external menu renderers') From f5490be28c818750c78c21a5a50c08661437309c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:26:36 +0100 Subject: [PATCH 38/68] Provide native Gen2 PC and pack owners with shared storage --- src/core/Gen2CommonText.lua | 126 +++ src/core/Gen2PokedexRating.lua | 92 ++ src/core/ModCompat.lua | 10 +- src/inventory/Bag.lua | 22 + src/inventory/Gen2PcItems.lua | 63 ++ src/script/Gen2Specials.lua | 17 +- src/ui/Gen2BoxMenu.lua | 1078 ++++++++++++++++++++ src/ui/Gen2CenterPcMenu.lua | 371 +++++++ src/ui/Gen2DecorationMenu.lua | 413 ++++++-- src/ui/Gen2ItemPcMenu.lua | 754 ++++++++++++++ src/ui/Gen2MailRead.lua | 106 ++ src/ui/Gen2MailboxMenu.lua | 480 ++++++++- src/ui/Gen2PackGfx.lua | 161 +++ src/ui/Gen2PackMenu.lua | 1164 ++++++++++++++++++++++ src/ui/Gen2PcMenu.lua | 561 +++++++++++ src/ui/Gen2StartMenu.lua | 3 +- src/ui/Gen2WaitPlaySFX.lua | 36 + src/ui/MenuRepeat.lua | 45 + src/ui/PlayerPC.lua | 8 + src/ui/Screens.lua | 19 +- src/world/OverworldController.lua | 6 + tests/engine/gen2_decoration_content.lua | 45 +- tests/engine/gen2_mail_ui.lua | 26 +- tests/engine/gen2_pc_owners.lua | 118 +++ 24 files changed, 5536 insertions(+), 188 deletions(-) create mode 100644 src/core/Gen2CommonText.lua create mode 100644 src/core/Gen2PokedexRating.lua create mode 100644 src/inventory/Gen2PcItems.lua create mode 100644 src/ui/Gen2BoxMenu.lua create mode 100644 src/ui/Gen2CenterPcMenu.lua create mode 100644 src/ui/Gen2ItemPcMenu.lua create mode 100644 src/ui/Gen2MailRead.lua create mode 100644 src/ui/Gen2PackGfx.lua create mode 100644 src/ui/Gen2PackMenu.lua create mode 100644 src/ui/Gen2PcMenu.lua create mode 100644 src/ui/Gen2WaitPlaySFX.lua create mode 100644 src/ui/MenuRepeat.lua create mode 100644 tests/engine/gen2_pc_owners.lua diff --git a/src/core/Gen2CommonText.lua b/src/core/Gen2CommonText.lua new file mode 100644 index 000000000..d0ccb9ec1 --- /dev/null +++ b/src/core/Gen2CommonText.lua @@ -0,0 +1,126 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- The strings an ENGINE routine prints, looked up by their pokegold label. +-- +-- data/generated/text.lua is keyed by "bank:addr", because every string in it +-- was found by following a script pointer. The Day-Care, the POKeMART and the +-- Hall of Fame are printed by asm instead (`ld hl, .SomeText / call +-- PrintText`), so RomExtractorGen2's NAMED_TEXT seeds the walker at those +-- symbols by name and writes text.labels[label] -> that key. This is the +-- lookup on the other side of that table: a screen asks for +-- "_MartWelcomeText" and never for an address, so a repointed string still +-- resolves and a cache built before the seed simply answers nil. +-- +-- `pages` puts the decoded stream back into the shape the Gen 2 speech box +-- draws it in -- up to two lines per screenful -- following home/text.asm: +-- +-- \n `line` / `next`: the box's second row. +-- \f `para`: PlaceString clears the box, so the next screenful starts empty. +-- \v `cont`: the box SCROLLS one row, so the line that was on the bottom +-- row is now on the top one and the new text lands under it. That is +-- why a `cont` shows up here as a page whose first line repeats the +-- previous page's second. +-- +-- `fill` substitutes the markers the decoder leaves behind for the values the +-- cart splices at runtime: {STRBUF} for a TX_RAM name and {NUM} for a +-- TX_DECIMAL PrintNum field, both in the order they appear, plus the named +-- {PLAYER} / {RIVAL}. + +local CommonText = {} + +-- The decoded string for a pokegold label, or nil when this cache predates +-- the seed (or the string is genuinely empty, like _DaycareDummyText). +function CommonText.get(text, label) + if type(text) ~= "table" or not label then return nil end + local labels = text.labels + local key = type(labels) == "table" and labels[label] + local body = key and text[key] + if type(body) ~= "string" or body == "" then return nil end + return body +end + +-- ../pokecrystal/home/text.asm:548 PromptText, :566 DoneText +function CommonText.plain(body) + if type(body) ~= "string" then return body end + return (body:gsub("{DONE}", ""):gsub("{PROMPT}", "")) +end + +-- One page is an array of one or two lines. +function CommonText.pages(body) + body = CommonText.plain(body) + if type(body) ~= "string" or body == "" then return nil end + local out = {} + local top, bottom = "", nil + local function flush() + if bottom then + out[#out + 1] = { top, bottom } + else + out[#out + 1] = { top } + end + end + local i = 1 + while i <= #body do + local marker = body:find("[\n\f\v]", i) + local chunk = body:sub(i, (marker or (#body + 1)) - 1) + if bottom then + bottom = bottom .. chunk + else + top = top .. chunk + end + if not marker then break end + local m = body:sub(marker, marker) + if m == "\n" then + bottom = bottom or "" + elseif m == "\f" then + flush() + top, bottom = "", nil + else -- "\v" + flush() + top, bottom = bottom or "", "" + end + i = marker + 1 + end + flush() + return out +end + +-- values: an array consumed in order by {STRBUF} and {NUM}, and optionally +-- values.player / values.rival for the two named markers. +function CommonText.fill(pages, values) + if not pages then return nil end + values = values or {} + local next_ = 1 + -- One pass over both markers, because the order they are CONSUMED in is the + -- order they appear in: _MartFinalPriceText opens on its {NUM} and + -- _BargainShopFinalPriceText on its {STRBUF}, and filling one kind before + -- the other would swap the price and the item name on one of them. + local function marker(name) + if name == "PLAYER" or name == "RIVAL" then + return values[name:lower()] or ("{" .. name .. "}") + end + if name ~= "STRBUF" and name ~= "NUM" then return "{" .. name .. "}" end + local v = values[next_] + next_ = next_ + 1 + return v ~= nil and tostring(v) or "" + end + local out = {} + for p, page in ipairs(pages) do + local lines = {} + for l, line in ipairs(page) do + lines[l] = (line:gsub("{(%u+)}", marker)) + end + out[p] = lines + end + return out +end + +-- The whole lookup in one call: nil when the cache has no such label, which +-- is every call site's cue to fall back to its own transcription. +function CommonText.of(text, label, values) + local pages = CommonText.pages(CommonText.get(text, label)) + if not pages then return nil end + if values then return CommonText.fill(pages, values) end + return pages +end + +return CommonText diff --git a/src/core/Gen2PokedexRating.lua b/src/core/Gen2PokedexRating.lua new file mode 100644 index 000000000..acb655c16 --- /dev/null +++ b/src/core/Gen2PokedexRating.lua @@ -0,0 +1,92 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +local Strings = require("src.core.Strings") +local OAK_PC_TEXT = { + completion = Strings.source("Current #DEX\ncompletion level:"), + counts = Strings.source( + "%d #MON seen\n%d #MON owned\fPROF.OAK's\nRating:"), +} + +-- OakRatings (data/events/pokedex_ratings.asm). Each row is (cap, sfx, +-- text); FindOakRating walks the table with `cp c / jr nc, .match` against +-- ascending caps, which is "the first row whose cap covers the caught +-- count" -- exactly what findOakRating below does. sfx is the Gold sfx +-- table's own label (audio/sfx_pointers.asm dba lines), not a pokered +-- fanfare name. +local OAK_RATINGS = { + { max = 9, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( + "Look for #MON\nin grassy areas!") }, + { max = 19, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( + "Good. I see you\nunderstand how to\vuse # BALLS.") }, + { max = 34, sfx = "Sfx_DexFanfare2049", text = Strings.source( + "You're getting\ngood at this.\fBut you have a\nlong way to go.") }, + { max = 49, sfx = "Sfx_DexFanfare2049", text = Strings.source( + "You need to fill\nup the #DEX.\fCatch different\nkinds of #MON!") }, + { max = 64, sfx = "Sfx_DexFanfare5079", text = Strings.source( + "You're trying--I\ncan see that.\fYour #DEX is\ncoming together.") }, + { max = 79, sfx = "Sfx_DexFanfare5079", text = Strings.source( + "To evolve, some\n#MON grow,\fothers use the\neffects of STONES.") }, + { max = 94, sfx = "Sfx_DexFanfare80109", text = Strings.source( + "Have you gotten a\nfishing ROD? You\fcan catch #MON\nby fishing.") }, + { max = 109, sfx = "Sfx_DexFanfare80109", text = Strings.source( + "Excellent! You\nseem to like col-\vlecting things!") }, + { max = 124, sfx = "Sfx_CaughtMon", text = Strings.source( + "Some #MON only\nappear during\fcertain times of\nthe day.") }, + { max = 139, sfx = "Sfx_CaughtMon", text = Strings.source( + "Your #DEX is\nfilling up. Keep\vup the good work!") }, + { max = 154, sfx = "Sfx_DexFanfare140169", text = Strings.source( + "I'm impressed.\nYou're evolving\f#MON, not just\ncatching them.") }, + { max = 169, sfx = "Sfx_DexFanfare140169", text = Strings.source( + "Have you met KURT?\nHis custom #\vBALLS should help.") }, + { max = 184, sfx = "Sfx_DexFanfare170199", text = Strings.source( + "Wow. You've found\nmore #MON than\fthe last #DEX\nresearch project.") }, + { max = 199, sfx = "Sfx_DexFanfare170199", text = Strings.source( + "Are you trading\nyour #MON?\fIt's tough to do\nthis alone!") }, + { max = 214, sfx = "Sfx_DexFanfare200229", text = Strings.source( + "Wow! You've hit\n200! Your #DEX\vis looking great!") }, + { max = 229, sfx = "Sfx_DexFanfare200229", text = Strings.source( + "You've found so\nmany #MON!\fYou've really\nhelped my studies!") }, + { max = 239, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Magnificent! You\ncould become a\f#MON professor\nright now!") }, + { max = 248, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Your #DEX is\namazing! You're\fready to turn\nprofessional!") }, + -- The top band (251 real species, the table's cap of 255 covers it): this + -- is the ONLY place the ROM checks "has the player finished the #DEX", and + -- it does it here rather than handing off to anything. The actual diploma + -- is not this special's business -- GameFreakGameDesignerScript in + -- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs + -- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and + -- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's + -- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua). + -- A finished #DEX here just means every rating after this one is this + -- same line. + { max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Whoa! A perfect\n#DEX! I've\fdreamt about this!\nCongratulations!") }, +} + +-- CountSetBits over wPokedexSeen/wPokedexCaught. The port's dex is a +-- species-keyed bool map (src/core/gen2/Save.lua), not a bitfield, so this +-- counts `true` entries the same way Save.summary counts wPokedexCaught for +-- the CONTINUE panel. +local function dexCounts(record) + local dex = record and record.pokedex + local seen, caught = 0, 0 + for _, has in pairs((dex and dex.seen) or {}) do + if has then seen = seen + 1 end + end + for _, has in pairs((dex and dex.owned) or {}) do + if has then caught = caught + 1 end + end + return seen, caught +end + +-- FindOakRating. NUM_POKEMON is 251, so `caught` never exceeds the table's +-- own top cap of 255 and this never falls off the end. +local function findOakRating(caught) + for _, row in ipairs(OAK_RATINGS) do + if caught <= row.max then return row end + end + return OAK_RATINGS[#OAK_RATINGS] +end + +return {OAK_PC_TEXT=OAK_PC_TEXT,dexCounts=dexCounts,findOakRating=findOakRating} diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 5464dd8ae..c309402b0 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -66,7 +66,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Gen2Boxes", ["src.core.gen2.Mail"] = "src.pokemon.Gen2Mail", - ["src.ui.gen2.BoxMenu"] = "src.ui.BoxMenu", + ["src.ui.gen2.BoxMenu"] = "src.ui.Gen2BoxMenu", ["src.ui.gen2.NamingScreen"] = "src.ui.Gen2NamingScreen", ["src.ui.gen2.NamePick"] = "src.ui.Gen2NamePick", ["src.ui.gen2.SaveMenu"] = "src.ui.Gen2SaveMenu", @@ -90,6 +90,12 @@ ModCompat.ALIASES = { -- menus. The Gold port names two of these differently rather than -- namespacing them, so both spellings land on the one implementation. + ["src.ui.gen2.PcMenu"] = "src.ui.Gen2PcMenu", + ["src.ui.gen2.CenterPcMenu"] = "src.ui.Gen2CenterPcMenu", + ["src.ui.gen2.ItemPcMenu"] = "src.ui.Gen2ItemPcMenu", + ["src.ui.gen2.MailboxMenu"] = "src.ui.Gen2MailboxMenu", + ["src.ui.gen2.DecorationMenu"] = "src.ui.Gen2DecorationMenu", + ["src.ui.gen2.MailRead"] = "src.ui.Gen2MailRead", ["src.ui.gen2.StartMenu"] = "src.ui.Gen2StartMenu", ["src.ui.gen2.OptionsMenu"] = "src.ui.OptionsMenu", ["src.ui.gen2.PartyMenu"] = "src.ui.PartyMenu", @@ -97,7 +103,7 @@ ModCompat.ALIASES = { ["src.ui.gen2.SummaryMenu"] = "src.ui.SummaryMenu", ["src.ui.gen2.TrainerCard"] = "src.ui.TrainerCard", ["src.ui.gen2.Pokegear"] = "src.ui.PokegearMenu", - ["src.ui.gen2.PackMenu"] = "src.ui.BagMenu", + ["src.ui.gen2.PackMenu"] = "src.ui.Gen2PackMenu", ["src.menu.PartyMenu"] = "src.ui.PartyMenu", ["src.ui.PartyScreen"] = "src.ui.PartyMenu", ["src.ui.party.PartyScreen"] = "src.ui.PartyMenu", diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index 13d60a7e2..8ea27f11a 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -138,6 +138,28 @@ function Bag.order(save) return order end +-- Move within one pocket while retaining the positions of every other +-- pocket in the shared acquisition order. Never move counts or item stores. +function Bag.move(save, id, pocket, targetIndex, data) + local order = Bag.order(save) + data = data or require("src.core.Data") + if pocketOf(id, data) ~= pocket then return false end + local positions, rows, from = {}, {}, nil + for i, item in ipairs(order) do + if pocketOf(item, data) == pocket then + positions[#positions+1], rows[#rows+1] = i, item + if item == id then from = #rows end + end + end + local n = tonumber(targetIndex) + if not from or not n or n ~= n or n == math.huge or n == -math.huge then return false end + local to = math.max(1, math.min(#rows, math.floor(n))) + if from == to then return false end + table.insert(rows, to, table.remove(rows, from)) + for i, index in ipairs(positions) do order[index] = rows[i] end + return true +end + -- Add qty of an item; returns false (and adds nothing) when a new slot -- is needed and the pocket (Gen2) or bag (Gen1) is full, or when the -- stack would pass 99 (AddItemToInventory's per-slot quantity cap). diff --git a/src/inventory/Gen2PcItems.lua b/src/inventory/Gen2PcItems.lua new file mode 100644 index 000000000..15e5ce769 --- /dev/null +++ b/src/inventory/Gen2PcItems.lua @@ -0,0 +1,63 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- ../pokecrystal/engine/events/pokecenter_pc.asm:568 +-- ../pokecrystal/engine/items/switch_items.asm:1 + +local PcItems = {} + +local function indexOf(items, id) + local def = items and items[id] + return (def and def.index) or math.huge +end + +local function byIndex(items) + return function(a, b) + local ia, ib = indexOf(items, a), indexOf(items, b) + if ia ~= ib then return ia < ib end + return a < b + end +end + +function PcItems.order(save, items) + save.pcItems = save.pcItems or {} + local order = save.pcOrder + if type(order) ~= "table" then + order = {} + for id in pairs(save.pcItems) do order[#order + 1] = id end + table.sort(order, byIndex(items)) + save.pcOrder = order + end + local seen = {} + for i = #order, 1, -1 do + local id = order[i] + if not save.pcItems[id] or seen[id] then + table.remove(order, i) + else + seen[id] = true + end + end + local added = {} + for id in pairs(save.pcItems) do + if not seen[id] then added[#added + 1] = id end + end + table.sort(added, byIndex(items)) + for _, id in ipairs(added) do order[#order + 1] = id end + return order +end + +-- engine/items/switch_items.asm:38, :64 +function PcItems.move(save, id, toIndex, items) + local order = PcItems.order(save, items) + local from + for i = 1, #order do + if order[i] == id then from = i break end + end + if not from then return false end + local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #order)) + -- engine/items/switch_items.asm:33 + if to == from then return false end + table.insert(order, to, table.remove(order, from)) + return true +end + +return PcItems diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index 4c3cd65df..f3aabecc7 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -700,18 +700,11 @@ local function pcRows(ctx, finish) end function Commands.g2_pokemon_center_pc(ctx) - -- PC_CheckPartyForPokemon: a player with no party gets nothing at all - if #(ctx.save.party or {}) == 0 then return answer(ctx, 0) end - local runner = ctx.runner - local done - local function finish() - if done then return end - done = true - runner:resume() - end - require("src.ui.Screens").push(ctx.game, "Menu", pcRows(ctx, finish), { - tx = 0, ty = 0, tw = 16, noSound = true, onCancel = finish, - }) + local runner, done = ctx.runner, false + require("src.ui.Screens").push(ctx.game, "Gen2CenterPcMenu", {onClose=function() + if done then return end;done=true + ctx.game.stack:pop();runner:resume() + end}) runner:yield() answer(ctx, 0) end diff --git a/src/ui/Gen2BoxMenu.lua b/src/ui/Gen2BoxMenu.lua new file mode 100644 index 000000000..2ff851ece --- /dev/null +++ b/src/ui/Gen2BoxMenu.lua @@ -0,0 +1,1078 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- Bill's PC withdraw / deposit list (engine/pokemon/bills_pc.asm). +-- +-- The screen is transcribed from the ASM's own coordinates rather than laid +-- out by eye, because that is what makes it line up on the 8px grid: +-- +-- BillsPC_BoxName Textbox at (8,0), interior 10x1 -- the box name +-- BillsPC_RefreshTextboxes +-- Textbox at (8,2), interior 10x10, then the two top +-- corners are overwritten with └ and ┘ so the list +-- box reads as hanging off the name box above it +-- .PlaceNickname five nicknames from (9,4), two rows apart +-- PCMonInfo clears (0,0) 8 wide x 15 tall for the left panel, +-- puts the front pic at (1,4) as 7x7 tiles, the level +-- at (1,12), the gender at (5,12) and the species +-- name at (1,14) +-- +-- `mode` picks which list is being browsed: "withdraw" reads the current box, +-- "deposit" reads the party, and "move" walks BOTH -- see below. +-- +-- MOVE POKéMON W/O MAIL (_MovePKMNWithoutMail, engine/pokemon/bills_pc.asm:480) +-- is not a one-press operation on the cart and must not be one here. Its +-- jumptable is .Init -> .Joypad -> .PrepSubmenu -> .MoveMonWOMailSubmenu -> +-- .PrepInsertCursor -> .Joypad2, i.e. +-- +-- 1. "Choose a ." over a list that left/right walks across the PARTY +-- (wBillsPC_LoadedBox == 0, BillsPC_BoxName's `.party` arm) and all +-- fourteen boxes, wrapping at both ends (BillsPC_PressLeft/PressRight) +-- 2. A on a mon opens MOVE / STATS / CANCEL under "What's up?" +-- (PCString_WhatsUp, .MoveMonWOMailSubmenu) +-- 3. MOVE backs the position up and asks "Move to where?" +-- (PCString_MoveToWhere) with an insert cursor the player drives to the +-- destination list AND the slot inside it +-- 4. A there is BillsPC_CheckSpaceInDestination then +-- MovePKMNWithoutMail_InsertMon, which prints "Saving… Leave ON!" and +-- writes the mon into its new home; B restores the backed-up position and +-- goes back to step 1 +-- +-- Collapsing that into "A sends the mon to the next box with room" -- which is +-- what this screen used to do -- reads as the PC EATING the mon: it vanishes +-- from the list with no destination named and no line of text, and the player +-- has fourteen boxes to search to find out it still exists. + +local Assets = require("src.render.Assets") +local Boxes = require("src.pokemon.Gen2Boxes") +local Chrome = require("src.ui.Gen2Chrome") +local CommonText = require("src.core.Gen2CommonText") +local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") +local GbcPalette = require("src.render.GbcPalette") +local Mail = require("src.pokemon.Gen2Mail") +local Palettes = require("src.render.PaletteFX") +local PartyMenu = require("src.ui.PartyMenu") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Sprites = require("src.pokemon.Sprites") +local Strings = require("src.core.Strings") +local Unown = require("src.compat.gen2.Unown") + +local BoxMenu = {} +BoxMenu.__index = BoxMenu +BoxMenu.isOpaque = true + +-- BillsPC_NumMonsOnScreen is 5 for the withdraw/deposit lists. +local VISIBLE_ROWS = 5 +local LIST_X, LIST_Y = 9, 4 +local LIST_SPACING = 2 +local PIC_X, PIC_Y = 1, 4 + +-- PadFrontpic pads a 5x5 or 6x6 pic into the 7x7 block, one blank column then +-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386). +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +-- gfx/pc/orange.pal +local BILLS_PC_ORANGE = { + { 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 }, +} + +-- PCMonInfo prints the held-item icon at hlcoord 7, 12 +-- (engine/pokemon/bills_pc.asm:1093). +local ICON_X, ICON_Y = 7, 12 + +-- $5f at hlcoord 8, 1 and $5e at hlcoord 19, 1, off a PCMailGFX sheet that +-- starts at $5c (engine/pokemon/bills_pc.asm:957-963). +local ARROW_ROW = 1 +local ARROW_LEFT = { 3, 8 } +local ARROW_RIGHT = { 2, 19 } + +-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the +-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list +-- each (BillsPC_BoxName reads the same byte for all three). +local PARTY_BOX = 0 + +-- .MoveMonWOMailSubmenu's .MenuData, verbatim. RELEASE is NOT one of these -- +-- it belongs to BillsPC_WithdrawMenu's four rows -- so nothing on this screen +-- can destroy a mon. +local MOVE_SUBMENU = { + Strings.source("MOVE"), Strings.source("STATS"), Strings.source("CANCEL"), +} + +-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows. +local WITHDRAW_SUBMENU = { + Strings.source("WITHDRAW"), Strings.source("STATS"), + Strings.source("RELEASE"), Strings.source("CANCEL"), +} + +-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240). +local DEPOSIT_SUBMENU = { + Strings.source("DEPOSIT"), Strings.source("STATS"), + Strings.source("RELEASE"), Strings.source("CANCEL"), +} + +function BoxMenu:submenuRows() + if self.mode == "move" then return MOVE_SUBMENU end + if self.mode == "deposit" then return DEPOSIT_SUBMENU end + return WITHDRAW_SUBMENU +end + +-- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while +-- the mon is written into its new home. It stays up here until a button +-- clears it, because it is also the only confirmation the player gets that the +-- mon moved and where it went. +local SAVING_LEAVE_ON = Strings.source("Saving… Leave ON!") + +-- PCString_NoReleasingEGGS, printed by BillsPC_IsMonAnEgg with SFX_WRONG +-- (engine/pokemon/bills_pc.asm:1615-1631, string at :2200). +local NO_RELEASING_EGGS = Strings.source("No releasing EGGS!") +local PARTY_TITLE = Strings.source("PARTY ") +local DEFAULT_BOX_TITLE = Strings.source("BOX%d") +local CHOOSE_PROMPT = Strings.source("Choose a .") +local WHATS_UP_PROMPT = Strings.source("What's up?") +local MOVE_WHERE_PROMPT = Strings.source("Move to where?") +local LAST_MON = Strings.source("It's your last !") +local NO_USABLE_MON = Strings.source("No more usable !") +local REMOVE_MAIL = Strings.source("Remove MAIL.") +local NO_ROOM = Strings.source("There's no room!") +local RELEASE_PROMPT = Strings.source("Release ?") +local RELEASED = Strings.source("Released .\fBye,\n%s!") +local GOT_MON = Strings.source("Got %s!") +local STORED_MON = Strings.source("Stored %s!") + +-- engine/pokemon/bills_pc.asm:1804 `ld c, 50 / call DelayFrames` +local STORE_MESSAGE_FRAMES = 50 + +-- Boxes.lua owns the storage mutation, so its finite refusals arrive here as +-- return values. Mark their complete text for the catalog and look them up +-- when they cross this UI boundary. +local BOX_FAILURE_SOURCES = { + Strings.source("No save."), + Strings.source("There is no POKéMON there."), + Strings.source("The BOX is full."), + Strings.source("You can't deposit\nthe last POKéMON!"), + Strings.source("You can't take\nany more POKéMON."), + Strings.source("It's already there."), + -- CommonText.pages() (below) only treats \n as the box's second row, not + -- a page break: a THIRD line needs \f (or \v) like every other multi-page + -- message in this file, not a second bare \n -- which used to just glue + -- this line onto the one above with no separator. + Strings.source("You'll need a\nPOKéMON to call\fwith."), +} + +-- \v (scroll-continue) needs the same page-break handling CommonText.pages +-- already gives every other Gen 2 screen (PackMenu/PrizeMenu route their +-- messages through it); this used to split only on \n/\f, so a translated +-- message that needed a \v scroll break rendered wrong here. +local function messagePages(text) + return CommonText.pages(text) or {} +end + +function BoxMenu:wantsFillScale() return true end +function BoxMenu:drawsWidescreen() return true end + +-- opts: save, mode ("withdraw" | "deposit" | "move"), onClose() +function BoxMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BoxMenu) + self.game = game + self.save = opts.save or (game and game.save) + if self.save ~= (game and game.save) then + self.game = setmetatable({save=self.save,writeSave=false},{__index=game}) + end + self.mode = opts.mode or "withdraw" + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + local data = game and game.data or {} + self.pokemon = opts.pokemon or data.pokemon + self.palettes = opts.palettes or data.gen2Palettes + -- EggPic has no data.pokemon row; it rides menu_gfx.eggHatch, and ICON_EGG + -- stands in for a cache built before the extractor learned it. + self.menuGfx = opts.menuGfx or data.gen2MenuGfx + self.icons = opts.icons or data.gen2Icons + self.boxIndex = self.save and self.save.currentBox or 1 + self.index = 1 + self.scroll = 0 + self.picCache = {} + self.message = nil + self.messageFrames = nil + self.cryWait = nil + -- nil while the list is being browsed; "submenu" while MOVE/STATS/CANCEL is + -- up, "insert" while the insert cursor is picking a destination. Only the + -- move screen has phases -- the other two lists act on A. + self.phase = nil + self.submenuIndex = 1 + return self +end + +-- The PARTY is box 0 on the move screen and nowhere else. +function BoxMenu:isParty(index) + if index == nil then index = self.boxIndex end + return self.mode == "move" and index == PARTY_BOX +end + +-- The mon list a given loaded box stands for. Boxes.box hands back a live +-- table (and creates it on demand), so an insert here lands on the save. +function BoxMenu:listAt(index) + if self:isParty(index) then + self.save.party = self.save.party or {} + return self.save.party + end + return Boxes.box(self.save, index) +end + +-- PARTY_LENGTH for box 0, MONS_PER_BOX for the rest +-- (BillsPC_CheckSpaceInDestination's `.party` arm). +function BoxMenu:capacityAt(index) + if self:isParty(index) then return Boxes.PARTY_SIZE end + return Boxes.MONS_PER_BOX +end + +-- BillsPC_BoxName: the party's name is a string of its own, a box's comes out +-- of wBoxNames. +function BoxMenu:nameAt(index) + -- .PartyPKMN is "PARTY @" -- eight tiles, because and are + -- one font glyph each. + if self:isParty(index) then return Strings(PARTY_TITLE) end + local names = self.save and self.save.boxNames + local custom = names and names[index] + if type(custom) == "string" and custom ~= "" then return custom end + return Strings(DEFAULT_BOX_TITLE, index) +end + +-- Which list this mode browses. +function BoxMenu:list() + if self.mode == "deposit" then return self.save.party or {} end + return self:listAt(self.boxIndex) +end + +function BoxMenu:title() + if self.mode == "deposit" then return Strings(PARTY_TITLE) end + -- While the insert cursor is up the header names the DESTINATION: the whole + -- screen has moved there (.PrepInsertCursor calls the same + -- BillsPC_MoveMonWOMail_BoxNameAndArrows with the new wBillsPC_LoadedBox). + return self:nameAt(self.boxIndex) +end + +-- The cart's own prompts (PCString_*): short, because the box they print in +-- is one row of 18 columns. +function BoxMenu:prompt() + -- engine/pokemon/bills_pc.asm:356-369: PrepSubmenu places PCString_WhatsUp. + if self.phase == "submenu" then return Strings(WHATS_UP_PROMPT) end + if self.mode == "move" then + -- .Init and .PrepInsertCursor each place their own string. + if self.phase == "insert" then return Strings(MOVE_WHERE_PROMPT) end + return Strings(CHOOSE_PROMPT) + end + -- PCString_ChooseaPKMN: _DepositPKMN.Init and BillsPC_Withdraw.Init both + -- place this exact string (engine/pokemon/bills_pc.asm:2185). + return Strings(CHOOSE_PROMPT) +end + +function BoxMenu:total() + return #self:list() + 1 -- CANCEL +end + +function BoxMenu:isCancel() + return self.index > #self:list() +end + +function BoxMenu:selected() + return self:list()[self.index] +end + +function BoxMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:total() - VISIBLE_ROWS))) +end + +function BoxMenu:clampIndex() + local total = self:total() + if self.index > total then self.index = total end + if self.index < 1 then self.index = 1 end + self:ensureVisible() +end + +function BoxMenu:act() + if self:isCancel() then + if self.onClose then self.onClose() end + return + end + -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu, + -- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102). + if not self:selected() then return end + self.phase = "submenu" + -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row. + self.submenuIndex = 1 +end + +-- ------------------------------------------------------------ MOVE, step 2 + +-- BillsPC_CheckMail_PreventBlackout (engine/pokemon/bills_pc.asm:1575), which +-- .Move runs BEFORE it backs up the cursor: three refusals, all of them about +-- the PARTY, so a boxed mon walks straight past. Returns true, or false and +-- the string the cart places. +function BoxMenu:checkMailPreventBlackout() + -- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes + -- that byte too, so its list is the party (bills_pc.asm:17-18). + if not (self:isParty() or self.mode == "deposit") then return true end + local party = self.save.party or {} + -- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send + -- one away at all, however healthy the rest of it is. + if #party < 3 then return false, LAST_MON end + -- CheckCurPartyMonFainted (engine/pokemon/bills_pc_top.asm:171) walks the + -- party skipping wCurPartyMon and answers carry when everything ELSE has + -- fainted -- taking this one out would white the player out on the next step. + local othersUsable = false + for i, mon in ipairs(party) do + if i ~= self.index and (mon.hp or 0) > 0 then othersUsable = true break end + end + if not othersUsable then return false, NO_USABLE_MON end + -- wBillsPC_MonHasMail, the byte PCMonInfo set while drawing the row. The + -- top menu already refused to open this screen at all while any party mon + -- holds a letter (BillsPC_MovePKMNMenu's IsAnyMonHoldingMail, + -- src/ui/gen2/PcMenu.lua), so this is the second of two nets. + if Mail.monHoldsMail(party[self.index]) then + return false, REMOVE_MAIL + end + return true +end + +-- .Move: back the position up (so B can restore it), keep the loaded box and +-- step to $4, .PrepInsertCursor. +function BoxMenu:beginMove() + local ok, reason = self:checkMailPreventBlackout() + if not ok then + -- BillsPC_PlaceString + SFX_WRONG + 50 frames, then `dec [hl]` drops back + -- to the submenu; here the message holds until a button clears it and the + -- list comes back, which is the same place the player ends up. + self.phase = nil + -- engine/pokemon/bills_pc.asm:1607 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(reason) + return + end + self.moveFrom = { box = self.boxIndex, slot = self.index } + self.backup = { box = self.boxIndex, index = self.index, scroll = self.scroll } + self.phase = "insert" + self:clampInsert() +end + +-- BillsPC_StatsScreen: the stats screen over the selected mon, then back to +-- the submenu (.Stats ends in PCMonInfo, not in a jumptable step). +function BoxMenu:openStats() + local mon = self:selected() + local game = self.game + if not (mon and game and game.stack) then return end + if not pcall(Screens.get, game, "Gen2SummaryMenu") then return end + Screens.push(game, "Gen2SummaryMenu", { + mon = mon, + save = self.save, + onClose = function() game.stack:pop() end, + }) +end + +-- engine/pokemon/bills_pc.asm:1785-1787 ClearBox over the left panel, then the +-- timed hold at :1804; .Init (:161) only runs after it. +function BoxMenu:holdMessage(text) + self.message = text + self.messagePage = 1 + self.messageFrames = STORE_MESSAGE_FRAMES + self.panelCleared = true +end + +function BoxMenu:isCrystal() + local version = (self.save and self.save.version) or GameVersion.get() + return GameVersion.isCrystal(version) +end + +-- ../pokecrystal/home/pokemon.asm:124-127; ../pokegold/home/pokemon.asm:101-107 +function BoxMenu:cryThenHold(src, mon, text) + if not (self:isCrystal() and src and src.isPlaying) then + return self:holdMessage(text) + end + self.cryWait = { src = src, mon = mon, text = text, t = 0 } +end + +-- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu. +function BoxMenu:doWithdraw() + local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) + if not ok then + -- engine/pokemon/bills_pc.asm:1845 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(result) + return + end + -- engine/pokemon/bills_pc.asm:1817 + local name = result.nickname or result.name or result.species or "?" + self:cryThenHold(self:playMonCry(result), result, Strings(GOT_MON, name)) +end + +-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit +function BoxMenu:doDeposit() + local mon = self:selected() + local name = mon and (mon.nickname or mon.name or mon.species) or "?" + local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex, + self.game and self.game.data) + if not ok then + -- engine/pokemon/bills_pc.asm:1790 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(result) + return + end + -- engine/pokemon/bills_pc.asm:1762 + self:cryThenHold(self:playMonCry(result), result, Strings(STORED_MON, name)) +end + +function BoxMenu:chooseSubmenu() + local row = self:submenuRows()[self.submenuIndex] + if row == "MOVE" then + self:beginMove() + elseif row == "DEPOSIT" then + self:doDeposit() + elseif row == "WITHDRAW" then + self:doWithdraw() + elseif row == "STATS" then + self:openStats() + elseif row == "RELEASE" then + self:askRelease() + else + -- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`. + self.phase = nil + end +end + +-- ------------------------------------------------------------ MOVE, step 3 + +-- How many insert positions the destination offers. BillsPC_PressDown stops +-- at wBillsPC_NumMonsInBox - 1, so the cursor always sits ON a mon (or on row +-- one of an empty list) and InsertSpeciesIntoBoxOrParty pushes the rest down. +function BoxMenu:insertPositions() + return math.max(1, #self:listAt(self.boxIndex)) +end + +function BoxMenu:clampInsert() + self.index = math.max(1, math.min(self.index, self:insertPositions())) + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:insertPositions() - VISIBLE_ROWS))) + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end +end + +-- BillsPC_CheckSpaceInDestination: a move inside one list is always allowed +-- (`.same_box`), and a move into another one needs a free slot there. The +-- ASM compares against MONS_PER_BOX + 1 / PARTY_LENGTH + 1, which can never be +-- reached -- the port refuses at the real capacity instead, because a 21st mon +-- in a box is a mon the save cannot hold. +function BoxMenu:checkSpaceInDestination() + local from = self.moveFrom + if from and from.box == self.boxIndex then return true end + if #self:listAt(self.boxIndex) >= self:capacityAt(self.boxIndex) then + return false, NO_ROOM + end + return true +end + +-- MovePKMNWithoutMail_InsertMon's .Jumptable: .BoxToBox / .PartyToBox / +-- .BoxToParty / .PartyToParty, all four of them .CopyFrom* (which is +-- RemoveMonFromPartyOrBox) followed by .CopyTo* (InsertPokemonIntoBox or +-- InsertPokemonIntoParty). This port keeps a mon as one Lua table in exactly +-- one list, so the four cases are one remove and one insert; what differs is +-- WHICH list each end names, and the mail bookkeeping on the party end. +function BoxMenu:insertMon() + local from = self.moveFrom + if not from then return end + local source = self:listAt(from.box) + local mon = source[from.slot] + if not mon then + self.phase = nil + self.moveFrom, self.backup = nil, nil + return + end + local destIndex = self.boxIndex + local target = math.min(self.index, self:insertPositions()) + table.remove(source, from.slot) + -- sPartyMail is keyed by party SLOT, so a mon leaving the party drags every + -- letter behind it up one (RemoveMonFromPartyOrBox's "Mail time!" tail, the + -- same call Boxes.deposit makes). No letter can actually be in the party + -- here -- IsAnyMonHoldingMail refused the whole screen -- so this keeps the + -- slots honest rather than moving anything. + if self:isParty(from.box) then Mail.removeSlot(self.save, from.slot) end + local dest = self:listAt(destIndex) + if from.box == destIndex and target > from.slot then + -- .CheckTrivialMove: the source was taken out first, so a destination slot + -- below it has already shuffled up one. + target = target - 1 + end + table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon) + -- .CopyToBox is InsertPokemonIntoBox, which tails into + -- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37). + if not self:isParty(destIndex) then + Boxes.enterBox(mon, self.game and self.game.data) + end + self.phase = nil + self.moveFrom, self.backup = nil, nil + self.index, self.scroll = 1, 0 + self:clampIndex() + self.message = Strings(SAVING_LEAVE_ON) +end + +-- .b_button_2: the backed-up scroll, cursor and loaded box all go back, and +-- the screen returns to step 1 with nothing moved. +function BoxMenu:cancelMove() + local backup = self.backup + if backup then + self.boxIndex, self.index, self.scroll = + backup.box, backup.index, backup.scroll + end + self.phase = nil + self.moveFrom, self.backup = nil, nil + self:clampIndex() +end + +-- BillsPC_PressLeft / BillsPC_PressRight, reached only from +-- MoveMonWithoutMail_DPad: the move screen wraps through box 0 (the PARTY). +function BoxMenu:stepBox(delta) + local low = self.mode == "move" and PARTY_BOX or 1 + local span = Boxes.NUM_BOXES - low + 1 + self.boxIndex = (self.boxIndex - low + delta) % span + low + -- .dpad / .dpad_2: both arms zero the cursor and the scroll. + self.index, self.scroll = 1, 0 + if self.phase == "insert" then self:clampInsert() end +end + +function BoxMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.cryWait then + local w = self.cryWait + w.t = w.t + 1 + local playing = w.src:isPlaying() + if w.t < 3 or (playing and w.t <= 180) then return end + self.cryWait = nil + self:holdMessage(w.text) + return + end + + -- engine/pokemon/bills_pc.asm:1804 DelayFrames reads no joypad; :161 zeroes + -- wJumptableIndex / cursor / scroll only once it returns. + if self.messageFrames then + self.messageFrames = self.messageFrames - 1 + if self.messageFrames > 0 then return end + self.messageFrames = nil + self.message, self.messagePage = nil, nil + self.panelCleared = nil + self.phase = nil + self.index, self.scroll = 1, 0 + self:clampIndex() + return + end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local page = (self.messagePage or 1) + 1 + if page <= #messagePages(self.message) then + self.messagePage = page + else + self.message, self.messagePage = nil, nil + end + end + return + end + + -- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry. + if self.phase == "submenu" then + local submenu = self:submenuRows() + if input:wasPressed("up") then + self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1 + or #submenu + elseif input:wasPressed("down") then + self.submenuIndex = self.submenuIndex < #submenu + and self.submenuIndex + 1 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self:chooseSubmenu() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.phase = nil + end + return + end + + -- .Joypad2: the insert cursor. Up/down walk the destination's slots, + -- left/right walk the destinations themselves, A inserts, B goes back. + if self.phase == "insert" then + local positions = self:insertPositions() + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or positions + self:clampInsert() + elseif input:wasPressed("down") then + self.index = self.index < positions and self.index + 1 or 1 + self:clampInsert() + elseif input:wasPressed("left") then + self:stepBox(-1) + elseif input:wasPressed("right") then + self:stepBox(1) + elseif input:wasPressed("a") then + local ok, reason = self:checkSpaceInDestination() + if not ok then + -- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor, + -- so the refusal leaves the cursor exactly where it was. + -- engine/pokemon/bills_pc.asm:1567 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(reason) + else + self:insertMon() + end + elseif input:wasPressed("b") then + self:cancelMove() + end + return + end + + local total = self:total() + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + self:ensureVisible() + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + self:ensureVisible() + -- Withdraw_UpDown reads PAD_UP and PAD_DOWN and nothing else; only + -- MoveMonWithoutMail_DPad walks the boxes (bills_pc.asm:806-820, :822-845). + elseif input:wasPressed("left") and self.mode == "move" then + self:stepBox(-1) + elseif input:wasPressed("right") and self.mode == "move" then + self:stepBox(1) + elseif input:wasPressed("a") then + self:act() + elseif input:wasPressed("b") then + if self.onClose then self.onClose() end + end +end + +function BoxMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then Sound.play(data, name) end +end + +-- engine/pokemon/bills_pc.asm:1608 +function BoxMenu:playRefusalSfx(name) + self:playSfx(name) +end + +-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:113-114) +function BoxMenu:playMonCry(mon) + local data = self.game and self.game.data + if not (data and mon and mon.species) or mon.isEgg then return end + local cries = data.audio and data.audio.cries + if cries and cries[mon.species] then + return Sound.playCry(data, mon.species) + end +end + +function BoxMenu:askRelease() + if self:isCancel() then return end + local mon = self:selected() + if not mon then return end + -- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check + -- (engine/pokemon/bills_pc.asm:183-187). + if self.mode == "deposit" then + local allowed, refusal = self:checkMailPreventBlackout() + if not allowed then + self.phase = nil + -- engine/pokemon/bills_pc.asm:1607 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(refusal) + return + end + end + -- Both release paths run BillsPC_IsMonAnEgg first, so the question is never + -- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428). + if mon.isEgg then + self.message = Strings(NO_RELEASING_EGGS) + self:playRefusalSfx("Sfx_Wrong") + return + end + local game = self.game + if not (game and game.stack) then return end + local ChoiceBox = require("src.ui.ChoiceBox") + local name = mon.nickname or mon.name or mon.species or "?" + self.message = Strings(RELEASE_PROMPT) + game.stack:push(ChoiceBox.new(game, function(yes) + self.message = nil + if not yes then return end + local ok, err + if self.mode == "deposit" then + ok, err = Boxes.releaseFromParty(self.save, self.index) + else + ok, err = Boxes.release(self.save, self.boxIndex, self.index) + end + if not ok then + self.message = Strings(err) + return + end + -- engine/pokemon/bills_pc.asm:1866 + self:playMonCry(mon) + self.message = Strings(RELEASED, name) + self.phase = nil + self:clampIndex() + end, { defaultNo = true })) +end + +function BoxMenu:image(path) + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- `and` truncates a multi-return, so the pcall has to stand alone. + local ok, image = pcall(Assets.image, path) + cached = (ok and image) or false + self.picCache[path] = cached + end + return cached or nil +end + +-- The selected mon's front pic, cached per species. +function BoxMenu:picFor(mon) + if not (mon and mon.species and self.pokemon) then return nil end + local data = self.game and self.game.data or {pokemon=self.pokemon} + local path, trueColor = Sprites.path(data, mon.species, "front", {mon=mon,kind="box"}) + return self:image(path), trueColor +end + +-- engine/gfx/cgb_layouts.asm:284-300, engine/pokemon/bills_pc.asm:356-369 +function BoxMenu:panelColors(speciesId, shiny) + if self.phase == "submenu" or self.phase == "insert" then + return self.palettes + and Palettes.monColors(self.palettes, speciesId, shiny) + end + local gfx = (self.menuGfx or {}).billsPc + return (gfx and gfx.orangePalette) or BILLS_PC_ORANGE +end + +-- ClearBox runs before `cp -1 / ret z` (engine/pokemon/bills_pc.asm:1009-1021) +function BoxMenu:fillPicBlock(colors) + local G = love.graphics + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) + G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8) + G.setColor(1, 1, 1, 1) +end + +-- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4 +-- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0. +function BoxMenu:drawPicBlock(image, colors, trueColor) + if not image then return end + local G = love.graphics + self:fillPicBlock(colors) + + local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7] + G.setColor(1, 1, 1, 1) + local function body() + G.draw(image, (PIC_X + pad[1]) * 8, (PIC_Y + pad[2]) * 8) + end + if colors and not (trueColor and GbcPalette.mode == "gbc") + and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function BoxMenu:drawPic(mon) + -- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box + -- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293). + local colors = self:panelColors(mon.species, mon.shiny) + local image, trueColor = self:picFor(mon) + -- engine/pokemon/bills_pc.asm:1009-1011 + if not image then return self:fillPicBlock(colors) end + self:drawPicBlock(image, colors, trueColor) +end + +-- GetFrontpic's `cp EGG / jr nz, .not_egg` arm hands back EggPic, never the +-- hatchling's pic (engine/gfx/load_pics.asm:88-91); it rides menu_gfx.eggHatch, +-- with the party list's ICON_EGG standing in for a cache built before that. +-- One reusable quad per role, re-aimed per draw rather than a new Quad a +-- frame. nil when the backend cannot make one (the old pcall's degrade). +local function reusedQuad(self, slot, x, y, w, h, sw, sh) + local quads = self.reusedQuads + if not quads then + quads = {} + self.reusedQuads = quads + end + local quad = quads[slot] + if quad then + quad:setViewport(x, y, w, h, sw, sh) + return quad + end + local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh) + if not ok then return nil end + quads[slot] = made + return made +end + +-- GbcPalette.with without a closure: set `colors` (when there is a shader), +-- returning what finishShade needs to put the caller's shader back. +local function startShade(colors) + if not (colors and GbcPalette.available()) then return false end + local G = love.graphics + local previous = G.getShader and G.getShader() or nil + GbcPalette.use(colors) + return true, previous +end + +local function finishShade(shaded, previous) + if shaded then love.graphics.setShader(previous) end +end + +function BoxMenu:drawEggPic(mon) + local G = love.graphics + local colors = self:panelColors("EGG", mon and mon.shiny) + local gfx = (self.menuGfx or {}).eggHatch + local image = self:image(gfx and gfx.egg) + if image then return self:drawPicBlock(image, colors) end + self:fillPicBlock(colors) + local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG + image = self:image(entry and entry.image) + if not image then return end + -- The ICON_EGG sheet stacks its frames; the first is the egg at rest. + local w = entry.width or 16 + local h = math.min(entry.height or 16, image:getHeight()) + if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end + local quad = reusedQuad(self, "egg", 0, 0, w, h, + image:getWidth(), image:getHeight()) + if not quad then return end + local x = PIC_X * 8 + math.floor((7 * 8 - w * 2) / 2) + local y = PIC_Y * 8 + math.floor((7 * 8 - h * 2) / 2) + G.setColor(1, 1, 1, 1) + local shaded, previous = startShade(colors) + G.draw(image, quad, x, y, 0, 2, 2) + finishShade(shaded, previous) + G.setColor(1, 1, 1, 1) +end + +-- ItemIsMail picks $5c over $5d at hlcoord 7, 12 +-- (engine/pokemon/bills_pc.asm:1079-1094) +function BoxMenu:drawHeldIcon(mon) + local row = mon and mon.item and (Mail.monHoldsMail(mon) and 0 or 1) + if not row then return end + local gfx = (self.menuGfx or {}).billsPc + local image = self:image(gfx and gfx.icons) + if not image then return end + local iw, ih = image:getDimensions() + local quad = reusedQuad(self, "held", row * 8, 0, 8, 8, iw, ih) + if not quad then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local shaded, previous = startShade(gfx and gfx.palette) + G.draw(image, quad, ICON_X * 8, ICON_Y * 8) + finishShade(shaded, previous) + G.setColor(1, 1, 1, 1) +end + +-- _MovePKMNWithoutMail only (engine/pokemon/bills_pc.asm:545, :698) +function BoxMenu:drawBoxArrows() + if self.mode ~= "move" then return end + local gfx = (self.menuGfx or {}).billsPc + local image = self:image(gfx and gfx.icons) + if not image then return end + local G = love.graphics + local iw, ih = image:getDimensions() + local left = reusedQuad(self, "arrowLeft", ARROW_LEFT[1] * 8, 0, 8, 8, iw, ih) + if not left then return end + local right = reusedQuad(self, "arrowRight", ARROW_RIGHT[1] * 8, 0, 8, 8, + iw, ih) + if not right then return end + G.setColor(1, 1, 1, 1) + local shaded, previous = startShade(gfx and gfx.palette) + G.draw(image, left, ARROW_LEFT[2] * 8, ARROW_ROW * 8) + G.draw(image, right, ARROW_RIGHT[2] * 8, ARROW_ROW * 8) + finishShade(shaded, previous) + G.setColor(1, 1, 1, 1) +end + +-- ../pokegold/engine/pokemon/bills_pc.asm:1439-1490 +-- ../pokecrystal/engine/pokemon/bills_pc.asm:1479-1503 +function BoxMenu:selectionFrameRect(row) + if self:isCrystal() then + return { x = 70, y = 29 + (row - 1) * 16, w = 83, h = 13, rounded = true } + end + return { x = 71, y = 25 + (row - 1) * 16, w = 80, h = 16, rounded = false } +end + +function BoxMenu:drawSelectionFrame(row) + local G = love.graphics + local r = self:selectionFrameRect(row) + G.setColor(0, 0, 0, 1) + if not r.rounded then + G.setLineWidth(1) + G.rectangle("line", r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1) + G.setLineWidth(1) + return + end + local x, y = r.x, r.y + G.rectangle("fill", x + 2, y, r.w - 4, 1) + G.rectangle("fill", x + 2, y + r.h - 1, r.w - 4, 1) + G.rectangle("fill", x, y + 2, 1, r.h - 4) + G.rectangle("fill", x + r.w - 1, y + 2, 1, r.h - 4) + G.rectangle("fill", x + 1, y + 1, 1, 1) + G.rectangle("fill", x + r.w - 2, y + 1, 1, 1) + G.rectangle("fill", x + 1, y + r.h - 2, 1, 1) + G.rectangle("fill", x + r.w - 2, y + r.h - 2, 1, 1) +end + +-- ../pokecrystal/engine/pokemon/bills_pc.asm:68, :117 +function BoxMenu:selectionFrameVisible() + return self.phase == nil and not self.message and not self.cryWait +end + +-- BillsPC_UpdateInsertCursor lays a DIFFERENT sprite frame from the selection +-- one -- a wedge between the rows rather than a box around one -- so the +-- insert cursor is drawn as a rule along the top edge of the row the mon is +-- going in front of. +function BoxMenu:drawInsertCursor(row) + local G = love.graphics + local x, y = 71, 31 + (row - 1) * 16 + G.setColor(0, 0, 0, 1) + G.rectangle("fill", x, y, 80, 2) +end + +-- The mon the whole screen is about while the insert cursor is up: .PrepSubmenu +-- ran PCMonInfo over it and .PrepInsertCursor does NOT run it again, so the +-- left panel keeps showing the mon in flight. +function BoxMenu:panelMon() + if self.cryWait then return self.cryWait.mon end + local from = self.moveFrom + if self.phase == "insert" and from then + return self:listAt(from.box)[from.slot] + end + return self:selected() +end + +-- engine/pokemon/bills_pc.asm:962-970, :1791-1793 +function BoxMenu:messageBox(page) + if #page <= 1 then return { 0, 15, 20, 3 } end + return { 0, 12, 20, 6 } +end + +function BoxMenu:drawPanel() + -- BillsPC_InitGFX loads FontsBattleExtra once for the whole screen and + -- never restores the standard font (engine/pokemon/bills_pc.asm:2169). + Chrome.clear() + + -- engine/pokemon/bills_pc.asm:1220-1227 + Chrome.box(8, 2, 12, 12) + -- engine/pokemon/bills_pc.asm:981-983 + Chrome.box(8, 0, 12, 3) + Chrome.print(self:title(), 10, 1) + self:drawBoxArrows() + + local list = self:list() + local inserting = self.phase == "insert" + local frameUp = self:selectionFrameVisible() + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = LIST_Y + (row - 1) * LIST_SPACING + if i <= #list then + local mon = list[i] + if i == self.index then + if inserting then + self:drawInsertCursor(row) + elseif frameUp then + self:drawSelectionFrame(row) + end + end + -- .PlaceNickname prints the stored nickname bytes verbatim, with no egg + -- check of its own (engine/pokemon/bills_pc.asm:1245-1356). + local label = mon.nickname or mon.name or mon.species or "?" + Chrome.print(label, LIST_X, ty) + elseif inserting then + -- An empty destination: the cursor is the only thing on the list. + if i == self.index then self:drawInsertCursor(row) end + elseif i == self:total() then + if i == self.index and frameUp then self:drawSelectionFrame(row) end + Chrome.print(Strings("CANCEL"), LIST_X, ty) + end + end + + -- The left panel: pic, level, gender, species -- blank on CANCEL, the way + -- PCMonInfo clears it when the selection is not a mon. + local mon = not self.panelCleared and self:panelMon() or nil + if mon then + -- `cp EGG / ret z` right after the frontpic: no name, no level, no gender + -- (engine/pokemon/bills_pc.asm:1057-1058). + if mon.isEgg then + self:drawEggPic(mon) + else + self:drawPic(mon) + -- PrintLevel always writes the single bold glyph, not ":L" + -- (home/pokemon.asm:178-183). + Chrome.print("" .. tostring(mon.level or 1), PIC_X, 12) + if mon.gender == "male" then + Chrome.print("\xe2\x99\x82", 5, 12) + elseif mon.gender == "female" then + Chrome.print("\xe2\x99\x80", 5, 12) + end + Chrome.print(mon.name or mon.species or "?", PIC_X, 14) + self:drawHeldIcon(mon) + end + else + self:fillPicBlock(self:panelColors()) + end + + -- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at + -- (1,16). A refusal is two lines on the cart, so those get a taller box of + -- their own rather than being cut to fit this one. + if self.message then + local page = messagePages(self.message)[self.messagePage or 1] or {} + local box = self:messageBox(page) + Chrome.box(box[1], box[2], box[3], box[4]) + for i, part in ipairs(page) do + Chrome.print(part, 1, box[2] + 1 + (i - 1) * 2) + end + else + Chrome.box(0, 15, 20, 3) + Chrome.print(self:prompt(), 1, 16) + end + + -- .MoveMonWOMailSubmenu's .MenuHeader is `menu_coords 9, 4, SCREEN_WIDTH - 1, + -- 13` -- it covers the nickname list, which is why the list is still drawn + -- underneath it and not instead of it. STATICMENU_CURSOR with the default + -- top spacing puts MOVE at (11,6), one row per two tiles. + if self.phase == "submenu" then + Chrome.box(9, 4, 11, 10) + for i, label in ipairs(self:submenuRows()) do + local ty = 6 + (i - 1) * 2 + if i == self.submenuIndex then Chrome.cursor(10, ty) end + Chrome.print(Strings(label), 11, ty) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +function BoxMenu:draw() + self:drawPanel() +end + +function BoxMenu:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return BoxMenu diff --git a/src/ui/Gen2CenterPcMenu.lua b/src/ui/Gen2CenterPcMenu.lua new file mode 100644 index 000000000..e1fbb788f --- /dev/null +++ b/src/ui/Gen2CenterPcMenu.lua @@ -0,0 +1,371 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- The Pokemon Center PC's whose-PC menu (engine/events/pokecenter_pc.asm +-- PokemonCenterPC). Every Pokecenter reaches it the same way: the PC is a +-- COLL_PC tile, the A press runs PCScript (engine/events/std_scripts.asm) and +-- its `special PokemonCenterPC` opens this screen through World:openPc. +-- +-- .ChooseWhichPCListToUse picks the row list: +-- +-- PCPC_BEFORE_POKEDEX BILL's PC / 's PC / TURN OFF +-- PCPC_BEFORE_HOF + PROF.OAK's PC (CheckReceivedDex) +-- PCPC_POSTGAME + HALL OF FAME (wHallOfFameCount > 0) +-- +-- BILL's PC opens the storage system (src/ui/gen2/PcMenu.lua, _BillsPC's own +-- five rows), 's PC the item PC (src/ui/gen2/ItemPcMenu.lua, +-- PLAYERSPC_NORMAL), PROF.OAK's PC the #DEX rating (ProfOaksPC, +-- engine/events/prof_oaks_pc.asm) and HALL OF FAME the roster viewer +-- (src/ui/gen2/HallOfFame.lua "view" mode, _HallOfFamePC). + +local Chrome = require("src.ui.Gen2Chrome") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Specials = require("src.core.Gen2PokedexRating") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local CenterPcMenu = {} +CenterPcMenu.__index = CenterPcMenu +-- ../pokecrystal/engine/events/pokecenter_pc.asm:15 +CenterPcMenu.isOpaque = false + +-- ENGINE_POKEDEX (constants/engine_flags.asm, index 11): the flag +-- CheckReceivedDex reads and Mr.Pokemon's `setflag ENGINE_POKEDEX` writes. +-- The port lands plain ENGINE_* ids on save.engineFlags +-- (World:setEngineFlag), so the same store answers here. +local ENGINE_POKEDEX = 11 + +-- Text owned by PokemonCenterPC/ProfOaksPC. Keep each cart message as one +-- catalog key so a translation may reflow or reorder it without inheriting +-- the English line fragments. +local TEXT = { + -- data/text/common_2.asm:725 _PokecenterPCCantUseText ends in `cont` + -- (\v scrolls "have a #MON to" up and lands "use this!" under it), not a + -- third bare \n line (which pagesOf() renders as its own one-line page + -- instead of a scroll). + noMon = Strings.source("Bzzzzt! You must\nhave a #MON to\vuse this!"), + turnedOn = Strings.source("{PLAYER} turned on\nthe PC."), + billsPc = Strings.source("BILL's PC"), + playersPc = Strings.source("%s's PC"), + oaksPc = Strings.source("PROF.OAK's PC"), + hallOfFame = Strings.source("HALL OF FAME"), + turnOff = Strings.source("TURN OFF"), + oakClosed = Strings.source("The link to PROF.\nOAK's PC closed."), + linkClosed = Strings.source("…\nLink closed…"), + billsOpened = Strings.source( + "BILL's PC\naccessed.\n\n#MON Storage\nSystem opened."), + ownOpened = Strings.source( + "Accessed own PC.\n\nItem Storage\nSystem opened."), + oakOpened = Strings.source( + "PROF.OAK's PC\naccessed.\n\n#DEX Rating\nSystem opened."), + rateDex = Strings.source("Want to get your\n#DEX rated?"), + accessWhose = Strings.source("Access whose PC?"), +} + +function CenterPcMenu:wantsFillScale() return true end + +-- `\f` = para (home/text.asm:403 Paragraph), `\v` = cont (home/text.asm:442 +-- _ContTextNoPause); two rows to a page (constants/text_constants.asm:32). +local function pagesOf(body) + local pages = {} + for chunk in (tostring(body) .. "\f"):gmatch("(.-)\f") do + local flat, pos, scrolled = {}, 1, false + while true do + local brk = chunk:find("[\n\v]", pos) + local line = brk and chunk:sub(pos, brk - 1) or chunk:sub(pos) + if line ~= "" then flat[#flat + 1] = { line, scrolled } end + if not brk then break end + scrolled = chunk:sub(brk, brk) == "\v" + pos = brk + 1 + end + local page + for _, entry in ipairs(flat) do + if not page then + page = { entry[1] } + pages[#pages + 1] = page + elseif entry[2] then + page = { page[#page], entry[1] } + pages[#pages + 1] = page + elseif #page >= 2 then + page = { entry[1] } + pages[#pages + 1] = page + else + page[#page + 1] = entry[1] + end + end + end + return pages +end + +local function translatedPages(source, ...) + return pagesOf(Strings(source, ...)) +end + +-- opts: save, events, items (items.lua), onClose() +function CenterPcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CenterPcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.data = game and game.data + self.events = opts.events + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + self.index = 1 + self.message = nil + self.confirm = nil + self.closed = false + self.booted = false + self:buildEntries() + local party = self.save and self.save.party + if not (party and #party > 0) then + -- PC_CheckPartyForPokemon: SFX_CHOOSE_PC_OPTION, the refusal, and the PC + -- never boots (`ret c` before PC_PlayBootSound). + -- data/text/common_2.asm:725 _PokecenterPCCantUseText ends in cont. + self:playSfx("Sfx_ChoosePcOption") + self:say(translatedPages(TEXT.noMon), + function() self:close() end) + else + -- PC_PlayBootSound + _PokecenterPCTurnOnText. + self:playSfx("Sfx_BootPc") + -- ../pokecrystal/engine/events/pokecenter_pc.asm:22 + self:say(translatedPages(TEXT.turnedOn), + function() self.booted = true end) + end + return self +end + +function CenterPcMenu:playSfx(name) + local data = self.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then + Sound.play(data, name) + end +end + +function CenterPcMenu:playerName() + local player = self.save and self.save.player + return (player and player.name) or "GOLD" +end + +-- .WhichPC, gated the way .ChooseWhichPCListToUse gates it. TURN OFF is +-- always last; the extra rows slot in above it in PCPCITEM_* order. +function CenterPcMenu:buildEntries() + local save = self.save + local hasDex = save and save.flags and save.flags.ENGINE_POKEDEX == true + local hofCount = (save and (type(save.hallOfFame)=="table" and (save.hallOfFame.count or #save.hallOfFame) or save.hallOfFameCount or save.hallOfFame)) or 0 + local entries = { + { id = "bills", label = Strings(TEXT.billsPc) }, + { id = "players", label = Strings(TEXT.playersPc, self:playerName()) }, + } + if hasDex then + entries[#entries + 1] = { id = "oaks", label = Strings(TEXT.oaksPc) } + if hofCount > 0 then + entries[#entries + 1] = { id = "hof", label = Strings(TEXT.hallOfFame) } + end + end + entries[#entries + 1] = { id = "turnoff", label = Strings(TEXT.turnOff) } + self.entries = entries +end + +-- pages is a list of pages, each a list of lines; a page may carry `sfx`, +-- played the moment it comes up (FindOakRating hands PlaySFX its fanfare +-- right before the rating text prints). +function CenterPcMenu:say(pages, onDone) + Typer.say(self, pages, onDone, { expand = function(line) + return (line:gsub("{PLAYER}", self:playerName())) + end }) + local first = pages[1] + if first and first.sfx then self:playSfx(first.sfx) end +end + +function CenterPcMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +-- .shutdown: PC_PlayShutdownSound, then the menu is gone. The party refusal +-- never reaches this -- the PC never booted. +function CenterPcMenu:shutdown() + self:playSfx("Sfx_ShutDownPc") + self:close() +end + +-- ProfOaksPC's `.shutdown`: _OakPCText4 either way, then back to the menu +-- loop (`jr nc, .loop` in PokemonCenterPC -- the OaksPC row answers nc). +function CenterPcMenu:oakClosed() + self:say(translatedPages(TEXT.oakClosed)) +end + +-- ProfOaksPCBoot, inside a screen rather than a script: the counts, the +-- rating pick and the texts come from src/script/gen2/Specials.lua so the two +-- callers cannot drift apart. +function CenterPcMenu:oakRate() + local seen, caught = Specials.dexCounts(self.save) + local rating = Specials.findOakRating(caught) + local pages = pagesOf(Strings(Specials.OAK_PC_TEXT.completion)) + for _, page in ipairs(pagesOf( + Strings(Specials.OAK_PC_TEXT.counts, seen, caught))) do + pages[#pages + 1] = page + end + local ratingPages = pagesOf(Strings(rating.text)) + if ratingPages[1] then ratingPages[1].sfx = rating.sfx end + for _, page in ipairs(ratingPages) do pages[#pages + 1] = page end + self:say(pages, function() self:oakClosed() end) +end + +function CenterPcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + if entry.id == "turnoff" then + -- TurnOffPC: PokecenterPCOaksClosedText, then carry into .shutdown. + self:say(translatedPages(TEXT.linkClosed), + function() self:shutdown() end) + return + end + -- PC_PlayChoosePCSound opens all four of the other rows. + self:playSfx("Sfx_ChoosePcOption") + if entry.id == "bills" then + self:say(translatedPages(TEXT.billsOpened), function() + if not (game and game.stack) then return end + Screens.push(game, "Gen2PcMenu", { + save = self.save, + bills = true, + onClose = function() game.stack:pop() end, + }) + end) + elseif entry.id == "players" then + self:say(translatedPages(TEXT.ownOpened), function() + if not (game and game.stack) then return end + Screens.push(game, "Gen2ItemPcMenu", { + save = self.save, + items = self.items, + onClose = function() game.stack:pop() end, + }) + end) + elseif entry.id == "oaks" then + self:say(translatedPages(TEXT.oakOpened), function() + -- _OakPCText1's yes/no; NO is the same `.shutdown` as a finished rating. + self.confirm = { + prompt = translatedPages(TEXT.rateDex)[1], + choice = 1, + onYes = function() self:oakRate() end, + onNo = function() self:oakClosed() end, + } + end) + elseif entry.id == "hof" then + if not (game and game.stack) then return end + -- HallOfFamePC: FadeToMenu, _HallOfFamePC, CloseSubmenu. + Screens.push(game, "HallOfFame", { + save = self.save, + mode = "view", + onDone = function() game.stack:pop() end, + }) + end +end + +function CenterPcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("a") or input:wasPressed("b") then + local m = self.message + if m.page < #m.pages then + Typer.turn(self, m) + local page = m.pages[m.page] + if page and page.sfx then self:playSfx(page.sfx) end + return + end + self.message = nil + if m.onDone then m.onDone() end + end + return + end + + if self.confirm then + local c = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + c.choice = c.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.onNo then c.onNo() end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.choice == 1 then + if c.onYes then c.onYes() end + elseif c.onNo then + c.onNo() + end + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:choose() + elseif input:wasPressed("b") then + -- DoNthMenu's carry lands straight in .shutdown, no text. + self:shutdown() + end +end + +function CenterPcMenu:drawBottomLines(lines) + Chrome.box(0, 12, 20, 6) + if not lines then return end + local name = self:playerName() + -- constants/text_constants.asm:32 TEXTBOX_INNERY: rows 14 and 16 only. + for i = 1, math.min(#lines, 2) do + Chrome.print((lines[i]:gsub("{PLAYER}", name)), 1, 14 + (i - 1) * 2) + end +end + +function CenterPcMenu:drawPanel() + if self.booted then + -- _PokecenterPCWhoseText stays up under the menu + -- (PC_DisplayTextWaitMenu leaves it there); the menu window is drawn on + -- top of it, the way the cart's windows stack. + self:drawBottomLines(translatedPages(TEXT.accessWhose)[1]) + -- .TopMenu is menu_coords 0, 0, 15, 12. + Chrome.box(0, 0, 16, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.label, 2, ty) + end + end + + if self.message then + -- ../pokecrystal/engine/events/pokecenter_pc.asm:652 + self:drawBottomLines( + Typer.text(self, self.message.pages[self.message.page])) + elseif self.confirm then + self:drawBottomLines(self.confirm.prompt) + Chrome.box(14, 7, 6, 5) + Chrome.print(Strings("YES"), 16, 8) + Chrome.print(Strings("NO"), 16, 10) + Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + elseif not self.booted then + self:drawBottomLines(nil) + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function CenterPcMenu:draw() + self:drawPanel() +end + +CenterPcMenu.ENGINE_POKEDEX = ENGINE_POKEDEX + +return CenterPcMenu diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua index 381b63b04..237de04a0 100644 --- a/src/ui/Gen2DecorationMenu.lua +++ b/src/ui/Gen2DecorationMenu.lua @@ -1,91 +1,326 @@ --- Copyright (c) 2026 Roxas2712. See contribution permission: --- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md --- The PC changes placed bytes. Room callbacks render them after native reload. -local Native=require('src.world.Gen2Decorations') -local Menu=require('src.ui.Menu') -local Runner=require('src.script.ScriptRunner') -local Strings=require('src.core.Strings') -local M={} -function M.new(game,opts) - opts=opts or{} - local changed,finished=false,false - local programs=Native.pool(game.data)and Native.pool(game.data).decorationPrograms - local texts=programs and programs.texts or{} - local function finish() - if finished then return end;finished=true - if opts.onDone then opts.onDone(changed)end - end - local function say(role,onDone,extra) - local id=texts[role] - if not id then if onDone then onDone()end;return end - local r=Runner.new(game,game.overworld) - r:run({{'show_text',id,nil,extra}}, {onDone=onDone}) - return r - end - local function perform(id,side) - local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) - local state=Native.state(game.data,game.save) - if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end - local placement=Native.placement(game.data,state,id,side) +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The bedroom PC's DECORATION option: _PlayerDecorationMenu +-- (engine/overworld/decorations.asm), reached from PLAYERSPCITEM_DECORATION in +-- the PLAYERSPC_HOUSE list (engine/events/pokecenter_pc.asm). +-- +-- Two menus stacked, plus one question: +-- +-- .MenuHeader the categories the player owns something in, at +-- menu_coords 5, 0, 19, 17, with EXIT always last +-- .NonscrollingMenuHeader / .ScrollingMenuHeader +-- that category's decorations, then its PUT IT AWAY +-- row, then CANCEL. The cart swaps to the scrolling +-- menu past eight rows; one list that scrolls covers +-- both, since the rows are identical either way +-- DecoSideMenuHeader RIGHT SIDE / LEFT SIDE / CANCEL, for the two +-- ornament slots +-- +-- Nothing here touches the map. wChangedDecorations is carried back through +-- onDone so the PC can answer TRUE to `special PlayersHousePC`, whose script +-- then does `warp NONE, 0, 0` -- the map reload that runs the two callbacks +-- and is the only thing that makes a placement visible. + +local Native = require("src.world.Gen2Decorations") +local Runner = require("src.script.ScriptRunner") +local Menu = require("src.ui.Menu") +local Chrome = require("src.ui.Gen2Chrome") +local Decorations = require("src.compat.gen2.Decorations") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local DecorationMenu = {} +DecorationMenu.__index = DecorationMenu +-- ../pokecrystal/engine/overworld/decorations.asm:8 +DecorationMenu.isOpaque = false + +-- .category_pointers' eighth row, which is not a category and is always shown. +local EXIT = Strings.source("EXIT") +-- DecoSideMenuHeader's three items, in its order. +local SIDES = { + { id = "right", label = Strings.source("RIGHT SIDE") }, + { id = "left", label = Strings.source("LEFT SIDE") }, + { id = nil, label = Strings.source("CANCEL") }, +} + +-- The scrolling list shows eight rows (.ScrollingMenuData's `db 8, 0`). +local VISIBLE = 8 + +function DecorationMenu:wantsFillScale() return true end + +-- opts: save, events (the wEventFlags bitfield), onDone(changed) +function DecorationMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, DecorationMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.events = opts.events or Decorations.events(self.save) + self.onDone = opts.onDone or function() game.stack:pop() end + self.state = Native.state(game and game.data, self.save) + -- wChangedDecorations, cleared once on the way in. + self.changed = false + self.mode = "category" + self.index = 1 + self.scroll = 0 + self.pages = nil + self:buildCategories() + return self +end + +function DecorationMenu:buildCategories() + self.categories = {} + for _, category in ipairs(Native.categories(self.game.data)) do + for _, id in ipairs(category.members) do + if Native.owns(self.game.data, self.save, id) then + self.categories[#self.categories+1] = category;break + end + end + end + self.index = math.min(self.index, #self.categories + 1) +end + +function DecorationMenu:finish() + if self.done then return end + self.done = true + if self.onDone then self.onDone(self.changed) end +end + +-- The name of one row of the item list. Row 0 is CANCEL and the category's +-- own row is PUT IT AWAY; both are plain DecorationAttributes rows, so they +-- spell themselves. +function DecorationMenu:rowName(decoId) + return Native.name(self.game.data, decoId) +end + +function DecorationMenu:openCategory(category) + local rows = {} + for _,id in ipairs(category.members or {}) do + if Native.owns(self.game.data, self.save, id) then rows[#rows+1]=id end + end + if #rows>0 then + for _,id in ipairs(category.headers or {}) do rows[#rows+1]=id end + rows[#rows+1]=0 + end + if #rows == 0 then + -- PopulateDecoCategoryMenu .empty. Cannot happen from the filtered + -- category list, but the routine is reachable from a driver that sets a + -- flag by hand, and the cart answers rather than opening an empty menu. + self:say(Native.feedbackPages(self.game.data,{role="noChoice",buffers={}})) + return + end + self.category = category + self.rows = rows + self.mode = "items" + self.index = 1 + self.scroll = 0 +end + +function DecorationMenu:say(pages) + if not pages or #pages == 0 then return end + self.pages = pages + self.pageIndex = 1 + -- ../pokecrystal/home/menu.asm:328 MenuTextboxBackup + self.typer = Typer.new(self.game) + self.typer:start(pages[1]) +end + +-- MenuTextboxBackup returns to whatever menu was underneath, so a message is +-- not a mode of its own: it sits over the list it was printed from. +function DecorationMenu:advanceMessage() + self.pageIndex = self.pageIndex + 1 + if self.pageIndex <= #self.pages then + if self.typer then self.typer:start(self.pages[self.pageIndex]) end + return + end + self.pages = nil + self.typer = nil +end + +-- One row of the item list chosen: DoDecorationAction2. An ornament row asks +-- which side first, and only then applies. +function DecorationMenu:chooseRow(decoId) + local attr = Native.attributes(self.game.data, decoId) + local action = attr and attr.action and Native.action(attr.action) + if not action then + -- DecoAction_nothing sets carry, which drops the player back to the + -- category list without a word. + self.mode = "category" + self:buildCategories() + return + end + if action.ornament then + local selected, question + local function close(side) + selected = side + if question then self.game.stack:pop();question:resume() + elseif side then self:applyRow(decoId, side) end + end + local function sides() + local rows={} + for _,side in ipairs(SIDES) do + local value=side.id + rows[#rows+1]={label=Strings(side.label),onSelect=function()close(value)end} + end + self.game.stack:push(Menu.new(self.game,rows,{tx=6,ty=6,tw=14,th=8, + noSound=true,onCancel=function()close()end})) + end + local programs=Native.pool(self.game.data) + local texts=programs and programs.decorationPrograms and programs.decorationPrograms.texts or {} + local id=texts[action.put and 'sidePut' or 'sideSetup'] + if id then + question=Runner.new(self.game,self.game.overworld) + question:run({{'show_text',id,nil,{stay={onShown=sides}}}}, {onDone=function() + if selected then self:applyRow(decoId,selected) end + end}) + else sides() end + return + end + self:applyRow(decoId, nil) +end + +function DecorationMenu:applyRow(decoId, side) + local row=Native.attributes(self.game.data,decoId) + local action=row and Native.action(row.action) + if not action or (not action.put and not Native.owns(self.game.data,self.save,decoId)) then return end + local placement=Native.placement(self.game.data,self.state,decoId,side) if not placement then return end - game.stringBuffers=game.stringBuffers or{} - for n,name in pairs(placement.buffers)do game.stringBuffers[n]=name end - say(placement.role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) - end - local function select(id) - local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) - if not action then return end - if not action.ornament then perform(id);return end - local selected,question - local function close(side) - selected=side - if question then game.stack:pop();question:resume() - elseif side then perform(id,side)end - end - local function sides() - game.stack:push(Menu.new(game,{ - {label=Strings('RIGHT SIDE'),onSelect=function()close('right')end}, - {label=Strings('LEFT SIDE'),onSelect=function()close('left')end}, - {label=Strings('CANCEL'),onSelect=function()close()end}, - },{tx=6,ty=6,tw=14,th=8,noSound=true,onCancel=function()close()end})) - end - local role=action.put and 'sidePut'or 'sideSetup' - if texts[role]then - question=say(role,function()if selected then perform(id,selected)end end,{stay={onShown=sides}}) - else sides()end - end - local function category(key) - local items={} - for _,c in ipairs(Native.categories(game.data))do - if c.slot==key then - for _,id in ipairs(c.members)do if Native.owns(game.data,game.save,id)then items[#items+1]=id end end - return items,c.headers - end - end - return items,{} - end - local rows={} - if Native.ready(game.data)and Native.state(game.data,game.save)then - for _,c in ipairs(Native.categories(game.data))do - local key=c.slot;local ids=category(key) - if #ids>0 then - rows[#rows+1]={label=Strings(c.label),keepOpen=true,onSelect=function() - local owned,headers=category(key) - if #owned==0 then say('noChoice');return end - local items={} - for _,id in ipairs(owned)do - local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} - end - for _,id in ipairs(headers)do - local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} - end - items[#items+1]={label=Native.name(game.data,0)~=''and Native.name(game.data,0)or Strings('CANCEL')} - game.stack:push(Menu.new(game,items,{tx=0,ty=0,tw=20,maxVisible=8,noSound=true})) - end} - end - end - end - rows[#rows+1]={label=Strings('EXIT'),onSelect=finish} - return Menu.new(game,rows,{tx=5,ty=0,tw=15,maxVisible=8,noSound=true,onCancel=finish}) + self.game.stringBuffers=self.game.stringBuffers or {} + for n,name in pairs(placement.buffers) do self.game.stringBuffers[n]=name end + local function commit() + self.changed=Native.choose(self.game.data,self.save,decoId,side) or self.changed + end + local programs=Native.pool(self.game.data) + local texts=programs and programs.decorationPrograms and programs.decorationPrograms.texts or {} + if texts[placement.role] then + local runner=Runner.new(self.game,self.game.overworld) + runner:run({{'show_text',texts[placement.role]}},{onDone=commit}) + else commit() end +end + +function DecorationMenu:update(_dt) + local input = self.game and self.game.input + if not (input and not self.done) then return end + + if self.pages then + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("a") or input:wasPressed("b") then + self:advanceMessage() + end + return + end + + if self.mode == "side" then + if input:wasPressed("up") then + self.sideIndex = self.sideIndex > 1 and self.sideIndex - 1 or #SIDES + elseif input:wasPressed("down") then + self.sideIndex = self.sideIndex < #SIDES and self.sideIndex + 1 or 1 + elseif input:wasPressed("a") then + local pick = SIDES[self.sideIndex] + self.mode = "items" + -- .nope: CANCEL and B are the same arm, and neither prints anything. + self:applyRow(self.pendingDeco, pick and pick.id) + self.pendingDeco = nil + elseif input:wasPressed("b") then + self.mode = "items" + self.pendingDeco = nil + end + return + end + + if self.mode == "items" then + local total = #self.rows + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:chooseRow(self.rows[self.index]) + elseif input:wasPressed("b") then + self.mode = "category" + self:buildCategories() + end + self.scroll = math.max(0, + math.min(self.scroll, math.max(0, total - VISIBLE))) + if self.index - 1 < self.scroll then self.scroll = self.index - 1 end + if self.index > self.scroll + VISIBLE then + self.scroll = self.index - VISIBLE + end + return + end + + local total = #self.categories + 1 + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + local category = self.categories[self.index] + if category then + self:openCategory(category) + else + self:finish() -- DecoExitMenu: `scf`, which leaves the whole menu + end + elseif input:wasPressed("b") then + self:finish() + end +end + +function DecorationMenu:drawList(x, y, w, h, labels, index, scroll) + Chrome.box(x, y, w, h) + -- GetMenuTextStartCoord: the first label sits one row inside the border and + -- one more down for STATICMENU_CURSOR, and rows are two apart -- the same + -- spacing every other Gold list menu in this port draws with. + for row = 1, math.min(#labels - scroll, VISIBLE) do + local i = row + scroll + local ty = y + row * 2 + if i == index then Chrome.cursor(x + 1, ty) end + Chrome.print(labels[i], x + 2, ty) + end +end + +function DecorationMenu:drawPanel() + if self.mode == "category" then + local labels = {} + for i, category in ipairs(self.categories) do labels[i] = category.label end + labels[#labels + 1] = Strings(EXIT) + -- menu_coords 5, 0, 19, 17: the list hugs the right edge and the room + -- stays visible down the left, which is the whole point of the offset. + self:drawList(5, 0, 15, 18, labels, self.index, 0) + elseif self.mode == "items" then + local labels = {} + for i, decoId in ipairs(self.rows) do labels[i] = self:rowName(decoId) end + self:drawList(0, 0, 20, 18, labels, self.index, self.scroll) + end + + if self.mode == "side" then + local labels = {} + for i, side in ipairs(SIDES) do labels[i] = Strings(side.label) end + -- menu_coords 0, 0, 12, 7 + self:drawList(0, 0, 13, 8, labels, self.sideIndex, 0) + end + + if self.pages then + Chrome.box(0, 12, 20, 6) + local line = 14 + local shown = self.typer and self.typer:lines() + if not shown then + shown = {} + for part in ((self.pages[self.pageIndex] or "") .. "\n"):gmatch("(.-)\n") do + shown[#shown + 1] = part + end + end + for _, part in ipairs(shown) do + Chrome.print(part, 1, line) + line = line + 2 + end + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function DecorationMenu:draw() + self:drawPanel() end -return M + +return DecorationMenu diff --git a/src/ui/Gen2ItemPcMenu.lua b/src/ui/Gen2ItemPcMenu.lua new file mode 100644 index 000000000..12ce13467 --- /dev/null +++ b/src/ui/Gen2ItemPcMenu.lua @@ -0,0 +1,754 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- The player's item PC: _PlayersPC and the three item rows behind it +-- (engine/events/pokecenter_pc.asm PlayerWithdrawItemMenu / +-- PlayerDepositItemMenu / PlayerTossItemMenu), plus TossItemFromPC +-- (engine/pokemon/mon_menu.asm). Both of the cart's callers land here: +-- +-- PlayersPC PLAYERSPC_NORMAL -- the 's PC row of the +-- Pokecenter's whose-PC menu (src/ui/gen2/CenterPcMenu.lua): +-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / MAIL BOX / +-- LOG OFF +-- _PlayersHousePC PLAYERSPC_HOUSE -- the bedroom PC's whole screen: the +-- boot sound and PlayersPCTurnOnText first, DECORATION on +-- the list, TURN OFF instead of LOG OFF, and the answer +-- carried back out (TRUE only when a decoration moved) so +-- PlayersHousePCScript can take its `.Warp` arm +-- +-- The menu's rows run through the ui.pc.items hook -- the same name and the +-- same (game, items) payload the Gen 1 PC uses +-- (src/world/OverworldController.lua openPC) -- with the exit row appended +-- after it, the way that site appends LOG OFF. +-- +-- Items live on save.pcItems, the id -> count map ReceiveItem's PC half +-- already writes (src/core/gen2/MomShopping.lua receiveItemToPc): fifty +-- distinct stacks of at most 99, like wPCItems. DEPOSIT opens the PACK as a +-- chooser held by this screen, exactly the arrangement the mart's sell flow +-- uses (src/ui/gen2/MartMenu.lua enterSell), because DepositSellPack is the +-- same routine on the cart. + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.Gen2Chrome") +local Logger = require("src.core.Logger") +local PcItems = require("src.inventory.Gen2PcItems") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") +local WaitPlaySFX = require("src.ui.Gen2WaitPlaySFX") + +local ItemPcMenu = {} +ItemPcMenu.__index = ItemPcMenu +-- ../pokecrystal/engine/events/pokecenter_pc.asm:231 +ItemPcMenu.isOpaque = false + +-- ../pokecrystal/engine/events/pokecenter_pc.asm:330 +local CLEARS_SCREEN = { withdraw = true, deposit = true, toss = true } + +-- MAX_PC_ITEMS stacks of at most MAX_ITEM_STACK (constants/item_constants.asm), +-- the same pair src/core/gen2/MomShopping.lua enforces for Mom's deliveries. +local PC_ITEM_CAPACITY = 50 +local MAX_STACK = 99 + +local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end + +-- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks +-- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE +-- carries DECORATION and ends on TURN OFF. +local ENTRIES = { + { id = "withdraw", label = Strings.source("WITHDRAW ITEM"), builtin = true }, + { id = "deposit", label = Strings.source("DEPOSIT ITEM"), builtin = true }, + { id = "toss", label = Strings.source("TOSS ITEM"), builtin = true }, + { id = "mailbox", label = Strings.source("MAIL BOX"), builtin = true }, +} +local LOG_OFF = { + id = "logoff", label = Strings.source("LOG OFF"), builtin = true, +} +local DECORATION = { + id = "decoration", label = Strings.source("DECORATION"), builtin = true, +} +local TURN_OFF = { + id = "turnoff", label = Strings.source("TURN OFF"), builtin = true, +} + +-- _PlayersPC's player-facing text. Dynamic item names are format arguments, +-- not catalog keys, so registry-provided names remain untouched while a +-- language may reorder the quantity/name around them. +local TEXT = { + turnedOn = Strings.source("{PLAYER} turned on\nthe PC."), + noBagRoom = Strings.source("There's no room\nfor more items."), + withdrew = Strings.source("Withdrew %d\n%s(S)."), + withdrawHowMany = Strings.source("How many do you\nwant to withdraw?"), + noPcRoom = Strings.source("There's no room to\nstore items."), + deposited = Strings.source("Deposited %d\n%s(S)."), + noItems = Strings.source("No items here!"), + depositHowMany = Strings.source("How many do you\nwant to deposit?"), + tooImportant = Strings.source("That's too impor-\ntant to toss out!"), + tossHowMany = Strings.source("Toss out how many\n%s(S)?"), + throwAway = Strings.source("Throw away %d\n%s(S)?"), + discarded = Strings.source("Discarded\n%s(S)."), + whatDo = Strings.source("What do you want\nto do?"), +} + +local function translatedLines(source, ...) + local lines = {} + for line in (Strings(source, ...) .. "\n"):gmatch("(.-)\n") do + lines[#lines + 1] = line + end + return lines +end + +local function translatedPages(source, ...) + return { translatedLines(source, ...) } +end + +-- ui.pc.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +-- PCItemsJoypad's ScrollingMenu is `db 4, 8 ; rows, columns`. +local VISIBLE_ROWS = 4 + +-- .PCItemsMenuData is menu_coords 4, 1 (engine/events/pokecenter_pc.asm:641), +-- and ScrollingMenu_UpdateDisplay (engine/menus/scrolling_menu.asm:359) adds +local LIST_X = 5 + +-- charmap.asm: the quantity glyph. +local TIMES = "\xc3\x97" + +function ItemPcMenu:wantsFillScale() return true end + +function ItemPcMenu:setPhase(phase) + self.phase = phase + self.isOpaque = CLEARS_SCREEN[phase] or false +end + +-- opts: save, items (items.lua), house (PLAYERSPC_HOUSE: boot text, +-- DECORATION row, TURN OFF), events (wEventFlags, for the decoration +-- menu), onClose(changedDecorations) +function ItemPcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, ItemPcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.data = game and game.data + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + self.house = opts.house and true or false + self.events = opts.events + -- wChangedDecorations, carried out so `special PlayersHousePC` can answer + -- TRUE and PlayersHousePCScript can take its `.Warp` arm. + self.changedDecorations = false + if self.save then self.save.pcItems = self.save.pcItems or {} end + local entries = {} + for i, entry in ipairs(ENTRIES) do entries[i] = entry end + if self.house then entries[#entries + 1] = DECORATION end + -- Same hook name and same (game, items) payload as the Gen 1 PC menu + -- (src/world/OverworldController.lua openPC), so one mod source can add a + -- row to both generations' PCs. Unguarded, like that site: the list is + -- built once per session, not per frame. A hook that answers with anything + -- but a table is degraded to the vanilla list. + local hooked = Runtime.call("ui.pc.items", sameItems, game, entries) + if type(hooked) == "table" then + entries = hooked + else + Logger.error("ui.pc.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- LOG OFF / TURN OFF is appended AFTER the hook, exactly as the Gen 1 site + -- appends LOG OFF: a mod cannot orphan the way out of the PC. + entries[#entries + 1] = self.house and TURN_OFF or LOG_OFF + self.entries = entries + self.index = 1 + self:setPhase("menu") + self.rows = {} + self.listIndex = 1 + self.scroll = 0 + self.message = nil + self.qtyState = nil + self.confirm = nil + if self.house then + -- _PlayersHousePC: PC_PlayBootSound, then PlayersPCTurnOnText. + self:playPcSfx("Sfx_BootPc") + self:say(translatedPages(TEXT.turnedOn)) + end + return self +end + +function ItemPcMenu:playSfx(name) + local data = self.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then + Sound.play(data, name) + end +end + +-- engine/events/pokecenter_pc.asm:200 +function ItemPcMenu:playPcSfx(name) + self:playSfx(name) +end + +-- engine/events/pokecenter_pc.asm:195 +function ItemPcMenu:playPcSfxTwice(name) + self:playPcSfx(name) + self.repeatSfx = WaitPlaySFX.arm(name) +end + +function ItemPcMenu:tickRepeatSfx() + local pending = self.repeatSfx + if not pending then return false end + if WaitPlaySFX.waiting(pending, self.game) then return true end + self.repeatSfx = nil + self:playPcSfx(pending.name) + return false +end + +function ItemPcMenu:playerName() + local player = self.save and self.save.player + return (player and player.name) or "GOLD" +end + +-- A queue of text pages, each a list of lines; A or B turns them, and the last +-- one runs onDone. The item PC's messages never log off by themselves, which +-- is _PlayersPC's `.loop`: a refusal drops back into the same menu. +function ItemPcMenu:say(pages, onDone) + Typer.say(self, pages, onDone, { expand = function(line) + return (line:gsub("{PLAYER}", self:playerName())) + end }) +end + +function ItemPcMenu:close() + if self.closed then return end + self.closed = true + -- _PlayersHousePC plays PC_PlayShutdownSound only on the unchanged arm; + -- `.changed_deco_tiles` leaves for the map reload without it. + if self.house and not self.changedDecorations then + self:playPcSfx("Sfx_ShutDownPc") + end + if self.onClose then self.onClose(self.changedDecorations) end +end + +-- ---------------------------------------------------------------- the items + +function ItemPcMenu:def(id) + return self.items and self.items[id] +end + +-- _CheckTossableItem: KEY ITEMs and HMs answer non-zero. The extractor +-- carries that as `canToss = false`; an id with no attributes row counts as +-- tossable, the way the PACK's sell gate treats it. +function ItemPcMenu:cantToss(id) + local def = self:def(id) + return def ~= nil and (def.canToss == false or def.cantToss == true or def.keyItem == true + or (def.machine and def.machine.kind == "HM")) +end + +-- engine/events/pokecenter_pc.asm:647 +function ItemPcMenu:rebuild() + local pc = (self.save and self.save.pcItems) or {} + local order = self.save and PcItems.order(self.save, self.items) or {} + local rows = {} + for slot = 1, #order do + local id = order[slot] + local count = pc[id] or 0 + if count > 0 then + local def = self:def(id) + local remaining = count + while remaining > 0 do + local n = math.min(remaining, MAX_STACK) + rows[#rows + 1] = { + id = id, count = n, + name = (def and def.name) or id, + slot = slot, + } + remaining = remaining - n + end + end + end + self.rows = rows + if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end + if self.listIndex < 1 then self.listIndex = 1 end + self:ensureVisible() +end + +function ItemPcMenu:listTotal() + return #self.rows + 1 -- CANCEL +end + +function ItemPcMenu:ensureVisible() + if self.listIndex <= self.scroll then + self.scroll = self.listIndex - 1 + elseif self.listIndex > self.scroll + VISIBLE_ROWS then + self.scroll = self.listIndex - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:listTotal() - VISIBLE_ROWS))) +end + +-- ReceiveItem over wPCItems: the add tops up every existing stack of that id +-- and spills the rest into a new one, so it only needs a free stack when the +-- room in place is short. False is the no-carry the deposit turns into +-- _PlayersPCNoRoomDepositText. +-- engine/items/items.asm:156 PutItemInPocket +function ItemPcMenu:pcAdd(id, qty) + local pc = self.save.pcItems + local held = pc[id] or 0 + local used = 0 + for _, count in pairs(pc) do used = used + stacksFor(count) end + local need = stacksFor(held + qty) - stacksFor(held) + if used + need > ((self.data and self.data.field and self.data.field.pcItemCap) or PC_ITEM_CAPACITY) then return false end + pc[id] = held + qty + return true +end + +function ItemPcMenu:pcRemove(id, qty) + local pc = self.save.pcItems + local held = (pc[id] or 0) - qty + pc[id] = held > 0 and held or nil +end + +-- HasNoItems (engine/pokemon/mon_menu.asm): every pocket, TM/HMs included -- +-- which the flat inventory answers in one walk. Badges share the table but +-- are not bag items. +local function bagIsEmpty(save) + for id, count in pairs((save and save.inventory) or {}) do + if (count or 0) > 0 and not Bag.isBadge(id) then return false end + end + return true +end + +-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm): up and down +-- wrap through the ends, left and right step by ten and clamp. +local function qtyStep(qty, max, delta) + local n = qty + delta + if delta == 1 then + if n > max then n = 1 end + elseif delta == -1 then + if n < 1 then n = max end + elseif delta > 0 then + if n > max then n = max end + else + if n <= 0 then n = 1 end + end + return n +end + +-- prompt is the two lines under the selector; onAccept(qty) commits. +function ItemPcMenu:askQuantity(max, prompt, onAccept) + self.qtyState = { qty = 1, max = max, prompt = prompt, onAccept = onAccept } +end + +-- ------------------------------------------------------------ the three rows + +function ItemPcMenu:withdraw(row, qty) + -- PlayerWithdrawItemMenu .withdraw: ReceiveItem into the bag first; only a + -- carry tosses the stack out of the PC. + if not Bag.add(self.save, row.id, qty, self.data) then + self:say(translatedPages(TEXT.noBagRoom)) + return + end + self:pcRemove(row.id, qty) + self:rebuild() + self:say(translatedPages(TEXT.withdrew, qty, row.name)) +end + +function ItemPcMenu:chooseWithdraw() + local row = self.rows[self.listIndex] + if not row then + self:setPhase("menu") + return + end + -- .Submenu: an item without a quantity attribute (a KEY ITEM in the PC) is + -- always x1; everything else asks _PlayersPCHowManyWithdrawText. + if self:cantToss(row.id) then + self:withdraw(row, 1) + return + end + self:askQuantity(row.count, + translatedLines(TEXT.withdrawHowMany), + function(qty) self:withdraw(row, qty) end) +end + +function ItemPcMenu:deposit(id, name, qty) + if not self:pcAdd(id, qty) then + self:say(translatedPages(TEXT.noPcRoom)) + return + end + Bag.remove(self.save, id, qty) + if self.pack then self.pack:rebuild() end + self:say(translatedPages(TEXT.deposited, qty, name)) +end + +function ItemPcMenu:enterDeposit() + -- .CheckItemsInBag: an empty bag never opens the PACK. + if bagIsEmpty(self.save) then + self:say(translatedPages(TEXT.noItems)) + return + end + self:setPhase("deposit") + -- DepositSellPack: the PACK as a chooser, held and drawn by this screen the + -- way the mart holds its sell PACK. `world = {}` keeps field items inert. + self.pack = Screens.build(self.game, "Gen2PackMenu", { + save = self.save, + items = self.items, + world = {}, + onChoose = function(id, count) self:offerToDeposit(id, count) end, + onClose = function() self:leaveDeposit() end, + }) +end + +function ItemPcMenu:leaveDeposit() + self.pack = nil + self:setPhase("menu") +end + +function ItemPcMenu:offerToDeposit(id, count) + if (count or 0) < 1 then return end + local def = self:def(id) + local name = (def and def.name) or id + -- .DepositItem (engine/events/pokecenter_pc.asm:504): an item with no + -- quantity is always x1 and never reaches .AskQuantity. + if self:cantToss(id) then + self:deposit(id, name, 1) + return + end + self:askQuantity(count, + translatedLines(TEXT.depositHowMany), + function(qty) self:deposit(id, name, qty) end) +end + +function ItemPcMenu:chooseToss() + local row = self.rows[self.listIndex] + if not row then + self:setPhase("menu") + return + end + -- TossItemFromPC .key_item -> .CantToss. + if self:cantToss(row.id) then + self:say(translatedPages(TEXT.tooImportant)) + return + end + self:askQuantity(row.count, + translatedLines(TEXT.tossHowMany, row.name), + function(qty) + -- .ItemsThrowAwayText's yes/no sits between the count and the toss. + self.confirm = { + prompt = translatedLines(TEXT.throwAway, qty, row.name), + choice = 1, + onYes = function() + self:pcRemove(row.id, qty) + self:rebuild() + self:say(translatedPages(TEXT.discarded, row.name)) + end, + } + end) +end + +-- ------------------------------------------------------------------ the menu + +function ItemPcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + if entry.id == "withdraw" or entry.id == "toss" then + self:setPhase(entry.id) + self.listIndex = 1 + self.scroll = 0 + -- engine/events/pokecenter_pc.asm:569 + self.switching = nil + self:rebuild() + return + end + if entry.id == "deposit" then + self:enterDeposit() + return + end + if entry.id == "mailbox" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2MailboxMenu", { + save = self.save, + onClose = function() game.stack:pop() end, + }) + return + end + if entry.id == "decoration" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2DecorationMenu", { + save = self.save, + events = self.events, + onDone = function(changed) + self.changedDecorations = self.changedDecorations or changed or false + game.stack:pop() + end, + }) + return + end + -- logoff / turnoff: PlayerLogOffMenu serves both rows. + self:close() +end + + +-- engine/events/pokecenter_pc.asm:622 +-- engine/items/switch_items.asm:27 +function ItemPcMenu:armSwitch() + if not self.rows[self.listIndex] then return end + self.switching = self.listIndex +end + +-- engine/events/pokecenter_pc.asm:604 +function ItemPcMenu:updateSwitch(input) + if input:wasPressed("up") then + self.listIndex = self.listIndex > 1 and self.listIndex - 1 + or self:listTotal() + self:ensureVisible() + elseif input:wasPressed("down") then + self.listIndex = self.listIndex < self:listTotal() and self.listIndex + 1 + or 1 + self:ensureVisible() + elseif input:wasPressed("a") or input:wasPressed("select") then + self:placeSwitch() + elseif input:wasPressed("b") then + -- engine/events/pokecenter_pc.asm:615 + self.switching = nil + end +end + +-- engine/events/pokecenter_pc.asm:620 +-- engine/items/switch_items.asm:12 +function ItemPcMenu:placeSwitch() + local held = self.rows[self.switching] + local target = self.rows[self.listIndex] + self:playPcSfxTwice("Sfx_SwitchPokemon") + if not target then return end + if held and self.save and target.slot ~= held.slot then + PcItems.move(self.save, held.id, target.slot, self.items) + self:rebuild() + end + self.switching = nil +end + +-- ------------------------------------------------------------------- update + +function ItemPcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + -- engine/events/pokecenter_pc.asm:195 + if self:tickRepeatSfx() then return end + + if self.message then + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("a") or input:wasPressed("b") then + local m = self.message + if m.page < #m.pages then + Typer.turn(self, m) + return + end + self.message = nil + if m.onDone then m.onDone() end + end + return + end + + if self.qtyState then + local q = self.qtyState + if input:wasPressed("up") then + q.qty = qtyStep(q.qty, q.max, 1) + elseif input:wasPressed("down") then + q.qty = qtyStep(q.qty, q.max, -1) + elseif input:wasPressed("right") then + q.qty = qtyStep(q.qty, q.max, 10) + elseif input:wasPressed("left") then + q.qty = qtyStep(q.qty, q.max, -10) + elseif input:wasPressed("b") then + self.qtyState = nil + elseif input:wasPressed("a") then + self.qtyState = nil + q.onAccept(q.qty) + end + return + end + + if self.confirm then + local c = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + c.choice = c.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.onNo then c.onNo() end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.choice == 1 then + if c.onYes then c.onYes() end + elseif c.onNo then + c.onNo() + end + end + return + end + + if self.phase == "deposit" then + if self.pack then + self.pack:update(_dt) + else + self:setPhase("menu") + end + return + end + + if self.phase == "withdraw" or self.phase == "toss" then + if self.switching then + self:updateSwitch(input) + return + end + if input:wasPressed("up") then + self.listIndex = self.listIndex > 1 and self.listIndex - 1 + or self:listTotal() + self:ensureVisible() + elseif input:wasPressed("down") then + self.listIndex = self.listIndex < self:listTotal() and self.listIndex + 1 + or 1 + self:ensureVisible() + elseif input:wasPressed("b") then + -- engine/menus/scrolling_menu.asm:24 + self:playSfx("Sfx_ReadText2") + self:setPhase("menu") + self.switching = nil + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self.phase == "withdraw" then + self:chooseWithdraw() + else + self:chooseToss() + end + elseif input:wasPressed("select") then + self:armSwitch() + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:476 + self:playSfx("Sfx_ReadText2") + self:choose() + elseif input:wasPressed("b") then + -- DoNthMenu's carry is `.turn_off`. + self:playSfx("Sfx_ReadText2") + self:close() + end +end + +-- --------------------------------------------------------------------- draw + +function ItemPcMenu:drawBottomLines(lines) + Chrome.box(0, 12, 20, 6) + if not lines then return end + local name = self:playerName() + local startY = #lines >= 3 and 13 or 14 + for i, line in ipairs(lines) do + Chrome.print((line:gsub("{PLAYER}", name)), 1, startY + (i - 1) * 2) + end +end + +function ItemPcMenu:drawList() + Chrome.box(0, 0, 20, 12) + -- engine/events/pokecenter_pc.asm:628 .a_1 -> home/menu.asm:50 + -- engine/events/pokecenter_pc.asm:605 .moving_stuff_around + local picked = not self.switching + and (self.message or self.qtyState or self.confirm) and true or false + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = row * 2 + if i <= #self.rows then + local entry = self.rows[i] + -- home/menu.asm:50 + if i == self.listIndex then + Chrome.cursor(LIST_X - 1, ty, picked) + elseif i == self.switching then + Chrome.cursor(LIST_X - 1, ty, true) + end + Chrome.print(entry.name, LIST_X, ty) + -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24) + if not self:cantToss(entry.id) then + Chrome.print(TIMES .. Chrome.number(entry.count, 2), LIST_X + 9, ty + 1) + end + elseif i == self:listTotal() then + if i == self.listIndex then Chrome.cursor(LIST_X - 1, ty, picked) end + Chrome.print(Strings("CANCEL"), LIST_X, ty) + end + end + -- UpdateItemDescription under the list. + local row = self.rows[self.listIndex] + local def = row and self:def(row.id) + local description = def and def.description + if description then + local first, second = description:match("^(.-)(.*)$") + if not first then first, second = description:match("^(.-)\n(.*)$") end + Chrome.box(0, 12, 20, 6) + Chrome.print(first or description, 1, 14) + if second then Chrome.print(second, 1, 16) end + else + Chrome.box(0, 12, 20, 6) + end +end + +function ItemPcMenu:drawPanel() + -- ../pokecrystal/engine/pokemon/bills_pc_top.asm:231 + if self.isOpaque then Chrome.clear() end + + if self.phase == "deposit" and self.pack then + self.pack:drawPanel() + elseif self.phase == "withdraw" or self.phase == "toss" then + self:drawList() + else + -- _PlayersPCAskWhatDoText, printed under the list the whole time. The + -- box goes down first: the menu window overlays it where the house's + -- six-row list runs past row 12, the way the cart's windows stack. + self:drawBottomLines(translatedLines(TEXT.whatDo)) + -- PlayersPCMenuData is menu_coords 0, 0, 15, 12; the house list is one + -- row taller than that box, so size it to the entries. + Chrome.box(0, 0, 16, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.builtin and Strings(entry.label) or entry.label, 2, ty) + end + end + + if self.qtyState then + local q = self.qtyState + self:drawBottomLines(q.prompt) + -- engine/items/buy_sell_toss.asm:205 TossItem_MenuHeader, :133 + Chrome.box(15, 9, 5, 3) + Chrome.print(TIMES, 16, 10) + Chrome.print(Chrome.number(q.qty, 2, true), 17, 10) + elseif self.confirm then + self:drawBottomLines(self.confirm.prompt) + Chrome.box(14, 7, 6, 5) + Chrome.print(Strings("YES"), 16, 8) + Chrome.print(Strings("NO"), 16, 10) + Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + elseif self.message then + self:drawBottomLines( + Typer.text(self, self.message.pages[self.message.page])) + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function ItemPcMenu:draw() + self:drawPanel() +end + +ItemPcMenu.ENTRIES = ENTRIES +ItemPcMenu.PC_ITEM_CAPACITY = PC_ITEM_CAPACITY + +return ItemPcMenu diff --git a/src/ui/Gen2MailRead.lua b/src/ui/Gen2MailRead.lua new file mode 100644 index 000000000..542a88ae1 --- /dev/null +++ b/src/ui/Gen2MailRead.lua @@ -0,0 +1,106 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- Reading a letter: ReadAnyMail / ReadPartyMonMail (engine/pokemon/mail_2.asm). +-- +-- The screen is one full-page piece of stationery -- ten of them, one per mail +-- item, each its own Load*MailGFX routine painting a border, a mon and a +-- scattering of icons out of gfx/mail.asm's 1bpp art. None of that art is in +-- the cache (the extractor has never followed gfx/mail.asm), so this draws the +-- page as a plain full-screen box and puts the two pieces that are DATA rather +-- than tiles exactly where MailGFX_PlaceMessage puts them: +-- +-- message hlcoord 2, 7 -- one PlaceString, so the '' stored at offset +-- MAIL_LINE_LENGTH lands the second line on row 8 +-- author hlcoord 5, 14, except hlcoord 8, 14 for PORTRAITMAIL_INDEX and +-- hlcoord 6, 14 for MORPH_MAIL_INDEX -- those two stationeries have +-- art where the name would otherwise sit +-- +-- .loop reads A, B and START and exits on any of them (START is the printer, +-- which this port has no path for; the VC builds mask it off entirely with +-- the Forbid_printing_mail patch, so treating it as an exit is the VC +-- behaviour rather than an invention). + +local Chrome = require("src.ui.Gen2Chrome") +local Mail = require("src.pokemon.Gen2Mail") + +local MailRead = {} +MailRead.__index = MailRead +MailRead.isOpaque = true + +local MESSAGE_X, MESSAGE_Y = 2, 7 +local AUTHOR_Y = 14 + +function MailRead:wantsFillScale() return true end +function MailRead:drawsWidescreen() return true end + +-- opts: entry (a `mailmsg` from src/core/gen2/Mail.lua), onClose() +function MailRead.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailRead) + self.game = game + self.entry = opts.entry + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + return self +end + +-- MailGFX_PlaceMessage's three author columns, picked by wCurMailIndex -- +-- which .LoadGFX set from the mail's TYPE byte by walking MailGFXPointers, so +-- it is the *_MAIL_INDEX of the stationery and nothing else. +function MailRead:authorColumn() + local index = Mail.INDEX[self.entry and self.entry.type or ""] + if index == Mail.INDEX.PORTRAITMAIL then return 8 end + if index == Mail.INDEX.MORPH_MAIL then return 6 end + return 5 +end + +function MailRead:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +function MailRead:update(_dt) + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start") then + self:close() + end +end + +function MailRead:drawPanel() + Chrome.clear() + -- DrawMailBorder frames the whole 20x18 page; without the stationery tiles + -- the shared box frame is the honest stand-in. + Chrome.box(0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) + + local top, bottom = Mail.lines(self.entry) + Chrome.print(top, MESSAGE_X, MESSAGE_Y) + if bottom ~= "" then Chrome.print(bottom, MESSAGE_X, MESSAGE_Y + 1) end + + -- MailGFX_PlaceMessage returns early when the author field is empty + -- (`ld a, [de] / and a / ret z`), so a blank author draws nothing at all + -- rather than an empty line under the message. + local author = self.entry and self.entry.author or "" + if author ~= "" then + Chrome.print(author, self:authorColumn(), AUTHOR_Y) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function MailRead:draw() + self:drawPanel() +end + +function MailRead:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return MailRead diff --git a/src/ui/Gen2MailboxMenu.lua b/src/ui/Gen2MailboxMenu.lua index 52c0bfefa..49e613aef 100644 --- a/src/ui/Gen2MailboxMenu.lua +++ b/src/ui/Gen2MailboxMenu.lua @@ -1,54 +1,426 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. -local Mail=require("src.pokemon.Gen2Mail") -local Menu=require("src.ui.Menu") -local ListMenu=require("src.ui.ListMenu") -local TextBox=require("src.render.TextBox") -local Strings=require("src.core.Strings") -local M={} -function M.new(game,opts) - opts=opts or {};Mail.reclaimQuarantine(game.save) - local entries={} - for i,letter in ipairs(Mail.mailbox(game.save)) do - entries[#entries+1]={label=(letter.author~="" and letter.author) or Strings("MAIL"),value=i} - end - if #entries==0 then return TextBox.new(game,Strings("There's no MAIL\nhere."),opts.onCancel) end - local list - local function say(text)game.stack:push(TextBox.new(game,Strings(text)))end - local function removed(index) - -- Both the displayed rows and native mailbox shift by the same index. - list.index=index;list:removeCurrent() - end - list=ListMenu.new(game,Strings("MAILBOX"),entries,{onCancel=opts.onCancel,onChoose=function(item) - local index=item.value - local letter=Mail.mailbox(game.save)[index];if not letter then return end - game.stack:push(Menu.new(game,{ - {label=Strings("READ"),onSelect=function()require("src.ui.Gen2MailMenu").read(game,letter)end}, - {label=Strings("GIVE"),onSelect=function() - require("src.ui.Screens").push(game,"PartyMenu",{pickOnly=true,onSwitch=function(mon) - local slot - for i,member in ipairs(game.save.party or {})do if mon==member then slot=i;break end end - if not slot or Mail.mailbox(game.save)[index]~=letter or not Mail.moveFromPcToParty(game.save,index,slot) then say(Strings("That POKéMON can't\nhold this MAIL."));return end - removed(index);say("MAIL attached.") - end}) - end}, - {label=Strings("MOVE TO BAG"),onSelect=function() - game.stack:push(TextBox.new(game,Strings("The MAIL will lose\nits message. OK?"),nil,{ - defaultNo=true,choice=function(yes) - if not yes then return end - local current=Mail.mailbox(game.save)[index];if current~=letter then return end - if not require("src.inventory.Bag").add(game.save,current.type,1,game.data) then say("The PACK is full.");return end - Mail.deleteFromPc(game.save,index);removed(index);say("MAIL moved to PACK.") - end})) - end}, - {label=Strings("CANCEL")}, - },{tx=7,ty=8,tw=13,th=10})) - end}) - -- Row values must follow the dense mailbox after every deletion. - local remove=list.removeCurrent - function list:removeCurrent() - remove(self) - for i,row in ipairs(self.items)do row.value=i end - end - return list -end -return M +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- The MAILBOX in the player's PC: _PlayerMailBoxMenu and MailboxPC +-- (engine/pokemon/mail.asm). +-- +-- InitMail runs first and answers z when sMailboxCount is 0, which is the one +-- branch that never opens a menu at all -- an empty MAILBOX is +-- _EmptyMailboxText and nothing else. +-- +-- Layout, from the two headers: +-- .TopMenuHeader menu_coords 8, 1, 18, 10 -- a scrolling menu, four rows, +-- each printed by MailboxPC_PrintMailAuthor, i.e. the +-- AUTHOR of the letter and not its message +-- .SubMenuHeader menu_coords 0, 0, 13, 9 with STATICMENU_CURSOR, so its +-- four labels start at (2,2) and step two rows +-- +-- The submenu's four rows are .Jumptable's four routines: +-- +-- READ MAIL ReadMailMessage, which is the Gen2MailRead screen +-- PUT IN PACK "message will be lost. OK?" -> ReceiveItem, then +-- DeleteMailFromPC. The letter is destroyed and the +-- STATIONERY goes back in the bag, which is why the question +-- is asked before the bag is even checked +-- ATTACH MAIL the party list, refusing an EGG and a mon that is already +-- holding anything, then MoveMailFromPCToParty. The cart +-- LOOPS on both refusals (`jr .try_again`) rather than backing +-- out, so the list comes straight back up +-- CANCEL `ret` +-- +-- Drawn over whatever opened the PC, so this state is not opaque. + +local Bag = require("src.inventory.Bag") +local Breeding = require("src.pokemon.Party") +local Chrome = require("src.ui.Gen2Chrome") +local CommonText = require("src.core.Gen2CommonText") +local Mail = require("src.pokemon.Gen2Mail") +local Screens = require("src.ui.Screens") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local MailboxMenu = {} +MailboxMenu.__index = MailboxMenu +MailboxMenu.isOpaque = false + +local LIST_X, LIST_Y, LIST_W, LIST_H = 8, 1, 11, 10 +-- InitScrollingMenu lays its rows two inside the box's corner and steps two. +local ROW_X, ROW_Y, ROW_STEP = LIST_X + 2, LIST_Y + 2, 2 +local VISIBLE_ROWS = 4 + +local SUB_X, SUB_Y, SUB_W, SUB_H = 0, 0, 14, 10 +local SUB_LABEL_X, SUB_LABEL_Y = SUB_X + 2, SUB_Y + 2 + +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 +local DOWN_ARROW = "\xe2\x96\xbc" +local ARROW_X, ARROW_Y = 18, 17 + +-- .SubMenuData, verbatim. +local SUB_ENTRIES = { + { id = "read", label = Strings.source("READ MAIL") }, + { id = "pack", label = Strings.source("PUT IN PACK") }, + { id = "attach", label = Strings.source("ATTACH MAIL") }, + { id = "cancel", label = Strings.source("CANCEL") }, +} + +local function page(...) return { ... } end +local function pages(...) return { ... } end + +-- data/text/common_2.asm's _EmptyMailboxText .. _MailMovedFromBoxText block. +local TEXT = { + empty = pages(page("There's no MAIL", "here.")), + messageLost = pages(page("The MAIL's message", "will be lost. OK?")), + packFull = pages(page("The PACK is full.")), + putAway = pages(page("The cleared MAIL", "was put away.")), + alreadyHolding = pages(page("It's already hold-", "ing an item.")), + egg = pages(page("An EGG can't hold", "any MAIL.")), + moved = pages(page("The MAIL was moved", "from the MAILBOX.")), +} + +local LABELS = { + empty = "_EmptyMailboxText", + messageLost = "_MailMessageLostText", + packFull = "_MailPackFullText", + putAway = "_MailClearedPutAwayText", + alreadyHolding = "_MailAlreadyHoldingItemText", + egg = "_MailEggText", + moved = "_MailMovedFromBoxText", +} + +MailboxMenu.TEXT = TEXT +MailboxMenu.LABELS = LABELS +MailboxMenu.SUB_ENTRIES = SUB_ENTRIES + +local function extractedText(text) + local out = setmetatable({}, { __index = TEXT }) + for key, label in pairs(LABELS) do + local list = CommonText.of(text, label) + if list then out[key] = list end + end + return out +end + +-- opts: save, text (text.lua), onClose() +function MailboxMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailboxMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.textData = opts.text or (game and game.data and game.data.text) + Mail.reclaimQuarantine(self.save) + self.TEXT = extractedText(self.textData) + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + -- wCurMessageIndex / wCurMessageScrollPosition, both reset by MailboxPC + -- before its loop. + self.index = 1 + self.scroll = 0 + self.submenu = nil + self.message = nil + self.confirm = nil + self.picking = false + -- InitMail's z branch: no menu, one line, gone. + if Mail.mailboxCount(self.save) == 0 then + self:say(self.TEXT.empty, function() self:close() end) + end + return self +end + +function MailboxMenu:box() + return Mail.mailbox(self.save) +end + +function MailboxMenu:count() + return Mail.mailboxCount(self.save) +end + +function MailboxMenu:selected() + return self:box()[self.index] +end + +function MailboxMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +function MailboxMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function MailboxMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +function MailboxMenu:clampIndex() + local total = self:count() + if total == 0 then + self.index, self.scroll = 1, 0 + return + end + if self.index > total then self.index = total end + if self.index < 1 then self.index = 1 end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, total - VISIBLE_ROWS))) +end + +-- ------------------------------------------------------------- submenu rows + +function MailboxMenu:readMail() + local game = self.game + local entry = self:selected() + if not (game and game.stack and entry) then return end + self.picking = true + Screens.push(game, "Gen2MailRead", { + entry = entry, + onClose = function() + game.stack:pop() + self.picking = false + self.submenu = nil + end, + }) +end + +-- .PutInPack. The yes/no comes first, then ReceiveItem -- and only a bag that +-- accepted the stationery gets as far as DeleteMailFromPC, so a full PACK +-- leaves the letter in the MAILBOX intact. +function MailboxMenu:putInPack() + self:ask(self.TEXT.messageLost, function() + local entry = self:selected() + if not entry then + self.submenu = nil + return + end + local data = self.game and self.game.data + if not Bag.add(self.save, entry.type, 1, data) then + return self:say(self.TEXT.packFull, function() self.submenu = nil end) + end + Mail.deleteFromPc(self.save, self.index) + self:clampIndex() + self:say(self.TEXT.putAway, function() + self.submenu = nil + if self:count() == 0 then + -- The next .loop calls InitMail again, which now answers z. + self:say(self.TEXT.empty, function() self:close() end) + end + end) + end, function() + -- `ret c`: the question was the whole thing. + self.submenu = nil + end) +end + +-- .AttachMail's loop. Both refusals go back to the party list rather than out +-- of the submenu, which is exactly what `jr .try_again` does. +function MailboxMenu:attachMail() + local game = self.game + if not (game and game.stack) then + self.submenu = nil + return + end + self.picking = true + Screens.push(game, "PartyMenu", { + pickOnly = true, + save = self.save, + party = self.save.party, + prompt = "choose", + onSwitch = function(mon) + local slot + for i,member in ipairs(self.save.party or {}) do if member==mon then slot=i;break end end + self.picking = false + if Breeding.isEgg(mon) then + return self:say(self.TEXT.egg, function() self:attachMail() end) + end + if mon and mon.item then + return self:say(self.TEXT.alreadyHolding, + function() self:attachMail() end) + end + if not Mail.moveFromPcToParty(self.save, self.index, slot) then + self.submenu = nil + return + end + self:clampIndex() + self:say(self.TEXT.moved, function() + self.submenu = nil + if self:count() == 0 then + self:say(self.TEXT.empty, function() self:close() end) + end + end) + end, + onCancel = function() + self.picking = false + -- `.exit2` -> CloseSubmenu: back to the letter list, not out of the PC. + self.submenu = nil + end, + }) +end + +function MailboxMenu:chooseSub() + local entry = SUB_ENTRIES[self.submenu.index] + if not entry then return end + if entry.id == "read" then return self:readMail() end + if entry.id == "pack" then return self:putInPack() end + if entry.id == "attach" then return self:attachMail() end + self.submenu = nil +end + +-- ------------------------------------------------------------------ update + +function MailboxMenu:updateMessage(input) + Typer.step(self) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function MailboxMenu:updateConfirm(input) + Typer.step(self) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function MailboxMenu:updateSubmenu(input) + local total = #SUB_ENTRIES + if input:wasPressed("up") then + self.submenu.index = self.submenu.index > 1 and self.submenu.index - 1 + or total + elseif input:wasPressed("down") then + self.submenu.index = self.submenu.index < total and self.submenu.index + 1 + or 1 + elseif input:wasPressed("a") then + self:chooseSub() + elseif input:wasPressed("b") then + self.submenu = nil + end +end + +function MailboxMenu:update(_dt) + if self.picking then return end + local input = self.game and self.game.input + if not input then return end + if self.message then return self:updateMessage(input) end + if self.confirm then return self:updateConfirm(input) end + if self.submenu then return self:updateSubmenu(input) end + + local total = self:count() + if total == 0 then return self:close() end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + self:clampIndex() + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + self:clampIndex() + elseif input:wasPressed("a") then + self.submenu = { index = 1 } + elseif input:wasPressed("b") then + -- .exit: PAD_B out of the scrolling menu ends _PlayerMailBoxMenu. + self:close() + end +end + +-- -------------------------------------------------------------------- draw + +function MailboxMenu:drawTextBox(lines) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function MailboxMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +function MailboxMenu:drawList() + Chrome.box(LIST_X, LIST_Y, LIST_W, LIST_H) + local box = self:box() + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local entry = box[i] + if entry then + local ty = ROW_Y + (row - 1) * ROW_STEP + if i == self.index then Chrome.cursor(ROW_X - 1, ty) end + -- MailboxPC_PrintMailAuthor copies NAME_LENGTH - 1 bytes out of the + -- struct's Author field and terminates it; a blank author draws a blank + -- row rather than the message. + Chrome.print(entry.author or "", ROW_X, ty) + end + end +end + +function MailboxMenu:drawSubmenu() + Chrome.box(SUB_X, SUB_Y, SUB_W, SUB_H) + for row, entry in ipairs(SUB_ENTRIES) do + local ty = SUB_LABEL_Y + (row - 1) * 2 + if row == self.submenu.index then Chrome.cursor(SUB_LABEL_X - 1, ty) end + Chrome.print(Strings(entry.label), SUB_LABEL_X, ty) + end +end + +function MailboxMenu:drawPanel() + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + if self.message.page < #self.message.pages + and Typer.arrowOn(self) then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + if self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + elseif Typer.arrowOn(self) then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + self:drawList() + if self.submenu then self:drawSubmenu() end + love.graphics.setColor(1, 1, 1, 1) +end + +function MailboxMenu:draw() + self:drawPanel() +end + +return MailboxMenu diff --git a/src/ui/Gen2PackGfx.lua b/src/ui/Gen2PackGfx.lua new file mode 100644 index 000000000..b04e7632a --- /dev/null +++ b/src/ui/Gen2PackGfx.lua @@ -0,0 +1,161 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- The PACK screen's chrome, drawn from the cart's own tiles. +-- +-- engine/items/pack.asm builds this screen out of tiles, not rectangles, and +-- transcribing it that way is what makes the bag picture, the pocket plaque and +-- the ◀▶/▼▲ header land on the 8px grid by construction: +-- +-- Pack_InitGFX copies PackMenuGFX ($60 tiles) to vTiles2 tile $00, fills +-- rows 1-11 with the background tile $24, clears the item +-- area at (5,1) 15x11, and lays the header at (0,0) as the +-- 20 running tiles $28..$3b +-- PlacePackGFX lays $50..$5e as a 5x3 block at (0,3) -- the bag picture, +-- which DrawPackGFX swaps per pocket out of PackGFX +-- DrawPocketName lays a 5x3 block at (0,7) from its own 5x12 tilemap +-- _CGB_PackPals loads six BG palettes and colours five rectangles with +-- them: the two header halves, the CURSOR column (7,2) 1x9 +-- whose colour 3 is red, the pocket plaque (red) and the bag +-- picture (green) -- engine/gfx/cgb_layouts.asm:715-734 +-- +-- A cache from before the pack stage simply has no `pack` table; PackMenu +-- falls back to its plain boxes then. + +local Assets = require("src.render.Assets") +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.Gen2Chrome") + +local PackGfx = {} +PackGfx.__index = PackGfx + +local SCREEN_W, SCREEN_H = 20, 18 +-- Textbox(0, TEXTBOX_Y - 2) with a 4-row interior: rows 12..17. +local DESCRIPTION_Y = 12 + +function PackGfx.new(menuGfx) + local self = setmetatable({}, PackGfx) + self.gfx = menuGfx and menuGfx.pack or nil + self.images = {} + self.quads = {} + if self.gfx then + -- paletteZones is a list of rectangles; flatten it to a per-cell lookup so + -- drawing a tile is one table read rather than a scan. + self.zone = {} + for _, z in ipairs(self.gfx.paletteZones or {}) do + local x0, y0, w, h, pal = z[1], z[2], z[3], z[4], z[5] + for y = y0, y0 + h - 1 do + for x = x0, x0 + w - 1 do + self.zone[y * SCREEN_W + x] = pal + end + end + end + end + return self +end + +function PackGfx:available() + return self.gfx ~= nil and self:image("menu") ~= nil +end + +function PackGfx:image(key) + local path = self.gfx and self.gfx[key] + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.images[path] = cached + end + return cached or nil +end + +-- One 8x8 tile out of a sheet `tilesWide` tiles across. +function PackGfx:quad(image, tilesWide, index) + local key = tostring(image) .. ":" .. tilesWide .. ":" .. index + local quad = self.quads[key] + if not quad then + local w, h = image:getDimensions() + quad = love.graphics.newQuad( + (index % tilesWide) * 8, math.floor(index / tilesWide) * 8, 8, 8, w, h) + self.quads[key] = quad + end + return quad +end + +-- The palette a screen cell draws with: its attrmap zone, else palette 0. +function PackGfx:colorsAt(tx, ty) + local pals = self.gfx and self.gfx.palettes + if not pals then return nil end + local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 1 + return pals[index] +end + +function PackGfx:blit(image, tilesWide, index, tx, ty) + if not image then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local colors = self:colorsAt(tx, ty) + local function body() + G.draw(image, self:quad(image, tilesWide, index), tx * 8, ty * 8) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- A tile out of PackMenuGFX, addressed by its VRAM id ($00 is the sheet's +-- first tile, so id and index are the same here). +function PackGfx:menuTile(tile, tx, ty) + self:blit(self:image("menu"), self.gfx.menuTilesWide or 16, tile, tx, ty) +end + +-- Everything behind the item list: the header strip, the background column, +-- the bag picture for this pocket, and the pocket plaque. +function PackGfx:draw(pocketId) + Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE) + + -- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28. + local header = self.gfx.headerFirstTile or 0x28 + for tx = 0, SCREEN_W - 1 do + self:menuTile(header + tx, tx, 0) + end + + -- Rows 1-11 are filled with $24; the item area at (5,1) is then cleared, so + -- only the left five columns keep the pattern. + local background = self.gfx.backgroundTile or 0x24 + for ty = 1, 11 do + for tx = 0, 4 do + self:menuTile(background, tx, ty) + end + end + + -- The bag picture: 15 tiles, 5 across, from this pocket's row in PackGFX. + local packImage = self:image("pack") + local firstRow = self.gfx.pocketPicture and self.gfx.pocketPicture[pocketId] + if packImage and firstRow then + local wide = self.gfx.packTilesWide or 5 + for i = 0, wide * (self.gfx.packTilesHigh or 3) - 1 do + self:blit(packImage, wide, firstRow + i, + i % wide, 3 + math.floor(i / wide)) + end + end + + -- The pocket plaque, from DrawPocketName's own tilemap. + local order = self.gfx.pocketOrder or {} + local block + for i, id in ipairs(order) do + if id == pocketId then block = self.gfx.pocketName[i] end + end + if block then + for i, tile in ipairs(block) do + self:menuTile(tile, (i - 1) % 5, 7 + math.floor((i - 1) / 5)) + end + end +end + +PackGfx.DESCRIPTION_Y = DESCRIPTION_Y +PackGfx.SCREEN_W = SCREEN_W + +return PackGfx diff --git a/src/ui/Gen2PackMenu.lua b/src/ui/Gen2PackMenu.lua new file mode 100644 index 000000000..b0edbfdb2 --- /dev/null +++ b/src/ui/Gen2PackMenu.lua @@ -0,0 +1,1164 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- Gen 2 PACK: four pockets instead of Gen 1's one bag. +-- +-- constants/item_data_constants.asm orders them ITEM, KEY_ITEM, BALL, TM_HM, +-- and each item's own ItemAttributes row says which pocket it lives in -- so +-- the flat id->count inventory the engine already keeps is bucketed here at +-- draw time rather than stored four ways. +-- +-- Left/right switch pockets, up/down scroll the list, A selects, B closes. +-- A TM/HM row shows the move it teaches (attributes carry `teaches`), which is +-- the whole reason the TM pocket is readable at all -- the item names are just +-- "TM01".."HM07". + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.Gen2Chrome") +local CommonText = require("src.core.Gen2CommonText") +local GameVersion = require("src.core.GameVersion") +local PackGfx = require("src.ui.Gen2PackGfx") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local WaitPlaySFX = require("src.ui.Gen2WaitPlaySFX") +local MenuRepeat = require("src.ui.MenuRepeat") + +-- constants/sfx_constants.asm:3, :28 +local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25 + +local LIST_DIRS = { "up", "down" } + +local PackMenu = {} +PackMenu.__index = PackMenu +PackMenu.isOpaque = true + +-- ItemsPocketMenuHeader (engine/items/pack.asm): menu_coords 7, 1, 19, 11 -- +-- so the list body starts one row and one column inside that box, five rows of +-- two, with the quantity on each entry's second line. +local LIST_X = 8 +local LIST_Y = 2 +local LIST_SPACING = 2 + +-- Display order and titles. The cart shows the pocket name in a tab strip +-- across the top; these are the strings it uses. +local POCKETS = { + { id = "ITEM", label = Strings.source("ITEMS") }, + { id = "BALL", label = Strings.source("POKé BALLS") }, + { id = "KEY_ITEM", label = Strings.source("KEY ITEMS") }, + { id = "TM_HM", label = Strings.source("TM/HM") }, +} + +-- Five item rows fit under the tab strip, two lines each. +local VISIBLE_ROWS = 5 + +-- The item submenu (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243). +-- A on a row does NOT use the item on the cart: it opens a menu whose rows are +-- picked from the item's own ITEMATTR_PERMISSIONS bits and its field-menu +-- nibble, and the six headers between MenuHeader_UsableKeyItem and +-- MenuHeader_HoldableItem are every combination of them: +-- +-- CAN toss + CAN select + usable USE / GIVE / TOSS / SEL / QUIT +-- CAN toss + CAN select + NOUSE GIVE / TOSS / SEL / QUIT +-- CAN toss + cant select + usable USE / GIVE / TOSS / QUIT +-- CAN toss + cant select + NOUSE GIVE / TOSS / QUIT +-- cant toss + cant select USE / QUIT +-- cant toss + CAN select USE / SEL / QUIT +-- +-- (the labels read backwards against the header names -- _CheckTossableItem +-- and CheckSelectableItem both answer NON-zero for the item that CANNOT, so +-- pack.asm's `.tossable` arm is the untossable one.) The TM/HM pocket has a +-- pair of its own, .MenuHeader1 / .MenuHeader2 at pack.asm:160. +-- +-- Without this menu a TOSS is unreachable and the PACK is a one-verb screen, +-- which is what "the pack only offers USE" is. +local SUBMENU_LABEL = { + use = Strings.source("USE"), give = Strings.source("GIVE"), + toss = Strings.source("TOSS"), sel = Strings.source("SEL"), + quit = Strings.source("QUIT"), +} + +-- _AskThrowAwayText / _AskQuantityThrowAwayText / _ThrewAwayText +-- (data/text/common_2.asm), the three lines TossMenu prints in order. +local TOSS_HOW_MANY = Strings.source("Throw away how\nmany?") + +-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem +-- holds a row and the cursor is looking for its new home. +local ASK_ITEM_MOVE = Strings.source("Where should this\nbe moved to?") + +-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals. +local NO_POKEMON = Strings.source("You don't have a\n#MON!") +local EGG_CANT_HOLD = Strings.source("An EGG can't hold\nan item.") + +-- _CGB_PackPals' .KrisPackPals arm, and the BATTLETYPE_TUTORIAL test above it +-- that forces the DUDE's (../pokecrystal/engine/gfx/cgb_layouts.asm:770-786). +local function packGfxFor(menuGfx, save, tutorial) + local pack = menuGfx and menuGfx.pack + if not (pack and pack.palettesFemale) then return menuGfx end + if tutorial or not (save and save.player and save.player.gender == "girl") then return menuGfx end + local female = {} + for key, value in pairs(pack) do female[key] = value end + female.palettes = pack.palettesFemale + local out = {} + for key, value in pairs(menuGfx) do out[key] = value end + out.pack = female + return out +end + +-- The PACK's cursor bytes. Every pocket menu restores its own cursor and +-- scroll before ScrollingMenu and writes them back after -- `ld a, +-- [wItemsPocketCursor] / ld [wMenuCursorPosition], a` ... `ld a, [wMenuCursorY] +-- / ld [wItemsPocketCursor], a` (engine/items/pack.asm:76), and the same pair +-- for wKeyItemsPocketCursor, wBallsPocketCursor and wTMHMPocketCursor -- while +-- InitPackBuffers opens the PACK on wLastPocket, which Pack's own exit path +-- stored. They are WRAM, not save data: they last for the session and must not +-- survive a reload, and CleanUpBattleRAM is the only thing that clears them +-- (engine/battle/core.asm:7994, which pointedly leaves the TM/HM pair in place). +local function cursorStore(game) + if not game then return nil end + local mem = game.packCursor + if not mem then + mem = { cursor = {}, scroll = {} } + game.packCursor = mem + end + return mem +end + +-- home/text.asm:424 +local PAGE, SCROLL, LINE = "\f", "\v", "\n" -- strings: decoded text control bytes + +local function messageTokens(text) + if type(text) ~= "string" then return text end + local out, start = {}, 1 + while start <= #text + 1 do + local marker = text:find("[\n\f\v]", start) + out[#out + 1] = text:sub(start, (marker or (#text + 1)) - 1) + if not marker then break end + local code = text:sub(marker, marker) + if code == PAGE then out[#out + 1] = PAGE + elseif code == SCROLL then out[#out + 1] = SCROLL end + start = marker + 1 + end + return out +end + +-- home/text.asm:397 +local function messagePages(lines) + if type(lines) == "string" then return CommonText.pages(lines) or {} end + local pages, rows = {}, {} + local function flush(scroll) + if #rows > 0 then pages[#pages + 1] = rows end + rows = scroll and { rows[#rows] or "" } or {} + end + for _, line in ipairs(lines or {}) do + if line == PAGE then flush(false) + elseif line == SCROLL then flush(true) + else rows[#rows + 1] = line end + end + flush(false) + return pages +end + +-- data/text/common_2.asm:627 +local OAK_THIS_ISNT_THE_TIME = Strings.source( + "OAK: {PLAYER}!\nThis isn't the\vtime to use that!") + +-- RepelUsedEarlierIsStillInEffectText (data/text/common_3.asm): static, and +-- names REPEL no matter which of the three repel items is the one actually +-- still ticking down -- the cart never reads the active item back out to +-- print it. +-- ../pokecrystal/data/text/common_3.asm:1270 -- `cont` again, so two pages. +local REPEL_STILL_ACTIVE = Strings.source( + "The REPEL used\nearlier is still\vin effect.") + +function PackMenu:wantsFillScale() return true end +function PackMenu:drawsWidescreen() return true end + +-- opts: save, items (items.lua), onChoose(itemId, count), onClose(), +-- pocket (starting pocket id), world (the overworld a field item acts on; +-- defaults to game.world, which is where Game2 keeps it), +-- give (DepositSellPack: the PACK is a CHOOSER, so selecting a row hands the +-- id back instead of running the item's field effect), +-- battle (BattlePack rather than the field Pack: a different jumptable, which +-- dispatches on the item's BATTLE menu nibble and never runs a field effect) +function PackMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PackMenu) + self.game = game + self.save = opts.save or (game and game.save) + if self.save ~= (game and game.save) then + self.game = setmetatable({save=self.save,writeSave=false},{__index=game}) + end + self.items = opts.items or (game and game.data and game.data.items) + -- Bag.order / Bag.move read `.items` off an injectable data table, and the + -- one this screen draws from is not always the Data singleton. + self.bagData = setmetatable({ items = self.items }, {__index=game and game.data or {}}) + self.world = opts.world or (game and game.overworld) + self.onChoose = opts.onChoose + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + -- DepositSellPack rather than the PACK's own UseItem: a chooser must not run + -- a rod or the ITEMFINDER on the way past (src/ui/gen2/HeldItemMenu.lua). + self.give = opts.give and true or false + self.battle = opts.battle and true or false + -- engine/items/pack.asm:1068 TutorialPack + self.tutorial = opts.tutorial and true or false + self.cursorStore = cursorStore(game) + -- engine/menus/scrolling_menu.asm:6 + self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE) + self.pocketIndex = 1 + -- wLastPocket, unless the caller names one: DepositSellInitPackBuffers writes + -- ITEM_POCKET over it, so an explicit pocket still wins. + local startPocket = opts.pocket + or (self.cursorStore and self.cursorStore.pocket) + if startPocket then + for i, p in ipairs(POCKETS) do + if p.id == startPocket then self.pocketIndex = i break end + end + end + self:restoreCursor() + -- The cart's own PACK tiles, when the cache has them. + self.gfx = PackGfx.new(packGfxFor( + game and game.data and game.data.gen2MenuGfx, self.save, opts.tutorial)) + self:rebuild() + return self +end + +function PackMenu:close() + self:storeCursor() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +function PackMenu:pocket() + return POCKETS[self.pocketIndex] +end + +-- The pair of loads each pocket menu runs before ScrollingMenu. rebuild() +-- clamps the row afterwards, so a pocket that shrank while the PACK was closed +-- lands on its last entry rather than past it. +function PackMenu:restoreCursor() + local mem = self.cursorStore + local pocketId = self:pocket().id + self.index = (mem and mem.cursor[pocketId]) or 1 + self.scroll = (mem and mem.scroll[pocketId]) or 0 +end + +-- And the pair of stores after it, plus Pack's `.done` writing wCurPocket into +-- wLastPocket. +function PackMenu:storeCursor() + local mem = self.cursorStore + if not mem then return end + local pocketId = self:pocket().id + mem.cursor[pocketId] = self.index + mem.scroll[pocketId] = self.scroll + mem.pocket = pocketId +end + +-- Which pocket an item belongs to. Items imported before attributes existed +-- have no `pocket`; treat those as general items rather than dropping them, +-- so an older cache still shows a full bag. +function PackMenu:pocketOf(itemId) + local def = self.items and self.items[itemId] + return (def and def.pocket) or "ITEM" +end + +-- engine/items/tmhm.asm:341 +function PackMenu:tmhmKey(itemId) + local def = self.items and self.items[itemId] + local n = def and tonumber(def.tmNumber or (def.machine and def.machine.number)) + if n then return n + ((def.machine and def.machine.kind == "HM") and 100 or 0) end + return 1000 + ((def and tonumber(def.index)) or 0) +end + +-- The name on the row. An inventory key with no ItemAttributes row behind it +-- (an older cache, a mod's own item, a driver seeding an id that is not in +-- items.lua) still has to draw something a person can read, so the id stands +-- in for the name with its underscores opened out. +function PackMenu.label(itemId, def) + if def and def.name then return def.name end + return (tostring(itemId):gsub("_", " ")) +end + +-- TMHM_DisplayPocketItems (engine/items/tmhm.asm:381-385) places GetMoveName's +-- string three tiles right of the row's number, so a TM/HM row reads as the +-- MOVE's name and the TM's own item name is never printed. +function PackMenu:moveLabel(moveId) + if not moveId then return nil end + local moves = self.game and self.game.data and self.game.data.moves + local def = moves and moves[moveId] + return (def and def.name) or (tostring(moveId):gsub("_", " ")) +end + +-- engine/items/tmhm.asm:357-375 -- the number a TM/HM row prints: a TM with +-- PRINTNUM_LEADINGZEROS, an HM as 'H' and its own left-aligned ordinal. +local function tmhmLabelFor(itemId, def) + local digits = tostring((def and def.tmLabel) or (def and def.name) + or itemId):match("(%d+)") + local n = tonumber(digits) + if not n then return nil end + if tostring(itemId):sub(1, 3) == "HM_" then return "H" .. n end + return ("%02d"):format(n) +end + +function PackMenu:rebuild() + local pocket = self:pocket().id + local rows = {} + local save = self.save + local inventory = (save and save.inventory) or {} + -- Row order IS wBagItems' order, which is what SELECT rewrites. + local order = (save and save.inventory) + and Bag.order(save, self.bagData) or {} + for _, itemId in ipairs(order) do + local raw = inventory[itemId] + -- A count that is not a number at all (a hand-written save, a mod, an old + -- migration) counts as one rather than raising out of the draw. + local count = tonumber(raw) or (raw and 1) or 0 + if count > 0 and self:pocketOf(itemId) == pocket then + local def = self.items and self.items[itemId] + rows[#rows + 1] = { + id = itemId, + count = count, + name = PackMenu.label(itemId, def), + teaches = self:moveLabel(self:moveOf(itemId)), + tmNumber = def and (def.tmNumber or (def.machine and def.machine.number)), + tmhmLabel = (pocket == "TM_HM" and tmhmLabelFor(itemId, def)) or nil, + -- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the + -- count for an HM only -- a TM prints ×NN like any other stack. + showCount = pocket == "ITEM" or pocket == "BALL" + or (pocket == "TM_HM" and not (def and def.machine and def.machine.kind == "HM") and tostring(itemId):sub(1, 3) ~= "HM_"), + } + end + end + -- engine/items/tmhm.asm:341 + if pocket == "TM_HM" then + table.sort(rows, function(a, b) + local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id) + if ka ~= kb then return ka < kb end + return a.id < b.id + end) + end + self.rows = rows + self.index = math.min(self.index, #rows + 1) + if self.index < 1 then self.index = 1 end + self:ensureVisible() +end + +function PackMenu:total() + return #self.rows + 1 -- CANCEL +end + +function PackMenu:isCancel() + return self.index > #self.rows +end + +-- home/menu.asm:746, :758 +function PackMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then Sound.play(data, name) end +end + +-- engine/items/pack.asm:1312, home/audio.asm:220 +function PackMenu:playSfxTwice(name) + self:playSfx(name) + self.repeatSfx = WaitPlaySFX.arm(name) +end + +function PackMenu:tickRepeatSfx() + local pending = self.repeatSfx + if not pending then return false end + if WaitPlaySFX.waiting(pending, self.game) then return true end + self.repeatSfx = nil + self:playSfx(pending.name) + return false +end + +function PackMenu:showMessage(lines) + lines = messageTokens(lines) + self.message, self.messagePage = lines, 1 + self.pagesSource, self.pages = lines, messagePages(lines) +end + +function PackMenu:pagesFor(lines) + if self.pagesSource ~= lines then + self.pagesSource, self.pages = lines, messagePages(lines) + end + return self.pages +end + +function PackMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:total() - VISIBLE_ROWS))) +end + +function PackMenu:switchPocket(delta) + -- The pocket being left keeps its own cursor and scroll; the one being + -- entered restores its own (pack.asm:76). + self:storeCursor() + self.pocketIndex = (self.pocketIndex - 1 + delta) % #POCKETS + 1 + self:restoreCursor() + self:rebuild() + self:storeCursor() + -- engine/items/pack.asm:1268 + self:playSfx("Sfx_SwitchPockets") +end + +-- The player name OakThisIsntTheTimeText addresses, same fallback the SAVE +-- screen uses when a driver runs without a named save. +function PackMenu:playerName() + return (self.save and self.save.player and self.save.player.name) or "GOLD" +end + +-- .Field (engine/items/pack.asm UseItem): a field-usable item runs its effect, +-- and only a NON-ZERO wItemEffectSucceeded sets PACKSTATE_QUITRUNSCRIPT -- +-- which quits the PACK, and with it the START menu it was opened from, so the +-- script the effect queued can run in the overworld. A zero drops into .Oak +-- instead, which prints inside the PACK and leaves it exactly where it was. +function PackMenu:exitToField() + self:storeCursor() + local stack = self.game and self.game.stack + if stack and stack.clear then + stack:clear() + -- ../pokecrystal/home/map.asm:1927-1940 + local world = self.game.world + if world and world.exitMenusFade and not world.mapSetup then + world:exitMenusFade() + end + elseif self.onClose then + -- No clear on this stack (a test harness, or a screen pushed on its own): + -- at least give the pack back. + self.onClose() + end +end + +-- Whether this pack is BattlePack (engine/items/pack.asm:627) rather than the +-- field Pack. The two are separate jumptables: nothing opened over a battle +-- may reach a field effect, so the live overworld's own flag counts as well as +-- the caller saying so. +function PackMenu:inBattle() + if self.battle then return true end + return (self.world and self.world.battleActive) and true or false +end + +-- A on a row. A field item the world claims never reaches onChoose: the world +-- has already run its effect, and all that is left is which of UseItem's two +-- endings the PACK takes. +function PackMenu:useSelected() + local row = self.rows[self.index] + if not row then return end + -- ScrollingMenu has returned by the time a row is acted on, so the pocket's + -- cursor bytes are already written back before the submenu opens. + self:storeCursor() + if self.give then + if self.onChoose then self.onChoose(row.id, row.count) end + return + end + -- BattlePack's .Use dispatches on the item's BATTLE menu nibble, and the + -- first four entries of its .ItemFunctionJumptable are all .Oak: a battle- + -- NOUSE item prints OakThisIsntTheTimeText inside the pack and goes nowhere. + -- Everything else is handed to the screen that opened this one + -- (src/ui/gen2/BattleState.lua), which owns the balls, the X items and the + -- party-target heals. The FIELD jumptable is not on this path at all. + if self:inBattle() then + local def = self.items and self.items[row.id] + if def and def.battleMenu == "ITEMMENU_NOUSE" then + self:showMessage(Strings(OAK_THIS_ISNT_THE_TIME)) + return + end + if self.onChoose then + self.staleRows = true + self.onChoose(row.id, row.count) + end + return + end + if self.world == (self.game and self.game.overworld) then + self.staleRows = true + local owner = self + return require("src.ui.BagMenu").useItem(self.game, nil, row.id, { + close=function() owner:close() end, + rebuild=function() owner:rebuild() end, + items={}, index=1, + }) + end + local world = self.world + local result, extra = nil, nil + if world and world.useFieldItem then result, extra = world:useFieldItem(row.id) end + if result then + if result == "nowhere" then + self:showMessage(Strings(OAK_THIS_ISNT_THE_TIME)) + elseif result == "coin_case" then + -- _CoinCaseCountText (data/text/common_3.asm:336): "Coins:" then the + -- count, text_decimal 4 digits with PRINTNUM_LEFTALIGN_F so no padding. + self:showMessage(Strings(Strings.source("Coins:\n%d"), extra or 0)) + elseif result == "blue_card" then + -- _BlueCardBalanceText (../pokecrystal/data/text/common_3.asm:1297). + self:showMessage(Strings(Strings.source("You now have\n%d points."), + extra or 0)) + elseif result == "repel_used" then + -- ItemUsedText (data/text/common_3.asm): " used the\n." + -- World already wrote the counter and took the item out of the bag, so + -- the row list is rebuilt under the message the way a TOSS would. + self:showMessage(Strings(Strings.source("{PLAYER} used the\n%s."), row.name)) + self:rebuild() + elseif result == "repel_active" then + self:showMessage(Strings(REPEL_STILL_ACTIVE)) + elseif result == "trophy_sent" then + -- ../pokecrystal/data/text/common_3.asm:1338 + if world.playSfxNamed then + world:playSfxNamed("Sfx_DexFanfare5079", SFX_DEX_FANFARE_50_79) + end + self:showMessage(Strings(Strings.source( + "There was a trophy\ninside!\f{PLAYER} sent the\ntrophy home."))) + self:rebuild() + else + self:exitToField() + end + return + end + -- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens + -- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL + -- used from the field PACK -- prints OakThisIsntTheTimeText and goes + -- nowhere (UseItem's jumptable's first four entries are all .Oak). Both + -- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the + -- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four + -- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands + -- its row to onChoose like every other item instead of opening the teach + -- party. The battle pack returned above, and the catch tutorial's DUDE + -- pack carries a stub world with no useFieldItem at all -- its POKE BALL + -- is field-NOUSE and must still reach the throw. + if world and world.useFieldItem then + local def = self.items and self.items[row.id] + if def and def.teaches then + self:openTeachParty(row) + return + end + if def and def.fieldMenu == "ITEMMENU_NOUSE" then + self:showMessage(Strings(OAK_THIS_ISNT_THE_TIME)) + return + end + end + if self.onChoose then + -- The .Party flow runs OVER this pack and UseDisposableItem spends the + -- item out from under the row list; rebuild on the first frame the pack + -- owns again, which is UseItem .Party's own Pack_InitGFX redraw. + self.staleRows = true + self.onChoose(row.id, row.count) + end +end + +-- ------------------------------------------------------------- the submenu + +-- +-- DepositSellPack (pack.asm:931) -- the mart's SELL, the item PC's DEPOSIT +-- and HeldItemMenu's GIVE. Its jumptable is four ScrollingMenus and +-- nothing else, which is why `give` and the empty-world callers answer +-- their chooser directly. +-- TutorialPack (pack.asm:1068) -- the DUDE's pack, same shape. +-- +-- engine/items/pack.asm:627 BattlePack +-- ItemPcMenu:enterDeposit both pass `world = {}` precisely so no field effect +-- can fire, and Game2's START-menu PACK passes the real overworld. +function PackMenu:hasSubmenu() + if self.give then return false end + if self.tutorial then return false end + if self:inBattle() then return true end + local world = self.world + return (world and (world == (self.game and self.game.overworld) or world.useFieldItem)) and true or false +end + +-- ITEMATTR_PERMISSIONS' two bits and the field-menu nibble, as the cart reads +-- them. An id with no attributes row at all (an older cache, a mod's item) +-- counts as tossable and unusable-for-SEL, which is the same lean the sell +-- gate and ItemPcMenu:cantToss take. +function PackMenu:submenuRows(itemId) + local def = self.items and self.items[itemId] + if self:inBattle() then + -- engine/items/pack.asm:783 ItemSubmenu, :745 .TMHMPocketMenu + local usable = not (def and def.battleMenu == "ITEMMENU_NOUSE") + if self:pocket().id == "TM_HM" then usable = false end + if usable then return { "use", "quit" } end + return { "quit" } + end + local canToss = not (def and (def.canToss == false or def.cantToss or def.keyItem + or (def.machine and def.machine.kind == "HM"))) + local canSelect = def ~= nil and (def.canSelect == true or def.registerable == true) + local usable = not (def and def.fieldMenu == "ITEMMENU_NOUSE") + local rows = {} + local function add(id) rows[#rows + 1] = id end + if self:pocket().id == "TM_HM" then + -- .TMHMPocketMenu's own pair: an HM cannot be tossed and gets USE / QUIT, + -- a TM gets USE / GIVE / QUIT. Neither has a TOSS row. + add("use") + if canToss then add("give") end + add("quit") + return rows + end + if not canToss then + -- MenuHeader_UnusableItem / MenuHeader_UnusableKeyItem: the untossable arm + -- never looks at the menu nibble, so a key item always offers USE. + add("use") + if canSelect then add("sel") end + add("quit") + return rows + end + if usable then add("use") end + add("give") + add("toss") + if canSelect then add("sel") end + add("quit") + return rows +end + +function PackMenu:openSubmenu() + local row = self.rows[self.index] + if not row then return end + -- ScrollingMenu has returned by the time the submenu opens, so the pocket's + -- cursor bytes are written back first (pack.asm:76). + self:storeCursor() + self.submenu = { + row = row, + rows = self:submenuRows(row.id), + index = 1, -- `db 1 ; default option` + } +end + +function PackMenu:closeSubmenu() + self.submenu = nil +end + +function PackMenu:chooseSubmenu() + local menu = self.submenu + if not menu then return end + local id = menu.rows[menu.index] + local row = menu.row + if id == "quit" then + -- QuitItemSubmenu: a bare `ret`, back to the pocket list. + self:closeSubmenu() + elseif id == "use" then + self:closeSubmenu() + self:useSelected() + elseif id == "sel" then + self:closeSubmenu() + self:registerSelected() + elseif id == "toss" then + self:closeSubmenu() + self:tossItem(row) + elseif id == "give" then + self:closeSubmenu() + self:giveItem(row) + end +end + +-- ------------------------------------------------------------------- TOSS + +-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm): up and down +-- wrap through the ends, left and right step by ten and clamp. The same +-- stepper src/ui/gen2/ItemPcMenu.lua uses, because it is the same loop. +local function qtyStep(qty, max, delta) + local n = qty + delta + if delta == 1 then + if n > max then n = 1 end + elseif delta == -1 then + if n < 1 then n = max end + elseif delta > 0 then + if n > max then n = max end + else + if n <= 0 then n = 1 end + end + return n +end + +-- TossMenu (engine/items/pack.asm:477): "Throw away how many?" over +-- SelectQuantityToToss, then the count in a yes/no, then TossItem and +-- "Threw away (S)." Backing out of either question is `jr c, .finish` +-- -- the item is untouched and the PACK is exactly where it was. +function PackMenu:tossItem(row) + if not row then return end + self:showMessage(Strings(TOSS_HOW_MANY)) + self.qtyState = { + row = row, + qty = 1, + max = row.count or 1, + } +end + +function PackMenu:confirmToss() + local state = self.qtyState + if not state then return end + self.qtyState = nil + local row, qty = state.row, state.qty + self.confirm = { + prompt = messageTokens(Strings(Strings.source( + "Throw away %d\n%s(S)?"), qty, row.name)), + -- YesNoBox opens on YES; B and NO are the same `jr c, .finish`. + choice = 1, + onYes = function() + Bag.remove(self.save, row.id, qty) + self:rebuild() + self:showMessage(Strings(Strings.source("Threw away\n%s(S)."), row.name)) + end, + } +end + +-- ------------------------------------------------------------------- GIVE + +-- GiveItem (engine/items/pack.asm:562): the party list under +-- PARTYMENUACTION_GIVE_ITEM ("To which ?"), an EGG refused with +-- .AnEggCantHoldAnItemText, and everything else handed to +-- TryGiveItemToPartymon -- which is exactly what the party's own GIVE row runs +-- (src/ui/gen2/HeldItemMenu.lua), so the two doors share one routine rather +-- than each growing a copy of the swap question and the mail keyboard. +function PackMenu:giveItem(row) + self.staleRows = true + return require("src.ui.BagMenu").giveItem(self.game, row.id, function() self:rebuild() end) +end + +function PackMenu:giveToSlot(slot, row) + local mon = self.save and self.save.party and self.save.party[slot] + if not mon then return false end + return require("src.ui.BagMenu").handOver(self.game, mon, row.id, function() self:rebuild() end) +end + +function PackMenu:openTeachParty(row) + self.staleRows = true + local owner = self + return require("src.ui.BagMenu").useItem(self.game, nil, row.id, { + close=function() owner:close() end, rebuild=function() owner:rebuild() end, + items={}, index=1, + }) +end + +function PackMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.staleRows then + self.staleRows = nil + self:rebuild() + end + -- engine/items/pack.asm:1312, home/audio.asm:225 + if self:tickRepeatSfx() then return end + -- Pack_PrintTextNoScroll ends on a `prompt`, so the message holds the PACK + -- until a button clears it and the list is untouchable underneath. The + -- quantity selector is the one thing drawn OVER a message rather than under + -- it: "Throw away how many?" is printed and SelectQuantityToToss runs on top + -- of it, so that pair is stepped before the message is cleared. + if self.qtyState then + self:updateQuantity(input) + return + end + -- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item + if self.switching then + self:updateSwitch(input) + return + end + -- home/joypad.asm:383 + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local page = (self.messagePage or 1) + 1 + if page <= #self:pagesFor(self.message) then + self.messagePage = page + self:playSfx("Sfx_ReadText2") + else + self.message, self.messagePage = nil, nil + end + end + return + end + if self.confirm then + self:updateConfirm(input) + return + end + if self.submenu then + self:updateSubmenu(input) + return + end + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) + if input:wasPressed("left") then + self:switchPocket(-1) + return + elseif input:wasPressed("right") then + self:switchPocket(1) + return + elseif dir == "up" then + self:stepCursor(-1, edge) + return + elseif dir == "down" then + self:stepCursor(1, edge) + return + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:storeCursor() + self:close() + return + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self:isCancel() then + self:storeCursor() + self:close() + elseif self:hasSubmenu() then + -- Pack_InterpretJoypad's A falls through to .ItemBallsKey_LoadSubmenu: + -- the row is chosen, not used. + self:openSubmenu() + else + self:useSelected() + end + return + elseif input:wasPressed("select") then + self:armSwitch() + return + end +end + +-- engine/menus/scrolling_menu.asm +function PackMenu:stepCursor(delta, edge) + local total = self:total() + local next = self.index + delta + if next < 1 then + next = edge and total or 1 + elseif next > total then + next = edge and 1 or total + end + self.index = next + self:ensureVisible() +end + +-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select +-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT. +function PackMenu:armSwitch() + if self:pocket().id == "TM_HM" then return end + if self:isCancel() then return end + if not self.rows[self.index] then return end + self.switching = self.index + self:showMessage(Strings(ASK_ITEM_MOVE)) +end + +-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs +-- out, and left/right cannot leave the pocket mid-move. +function PackMenu:updateSwitch(input) + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) + if dir == "up" then + self:stepCursor(-1, edge) + elseif dir == "down" then + self:stepCursor(1, edge) + elseif input:wasPressed("a") or input:wasPressed("select") then + self:placeSwitch() + elseif input:wasPressed("b") then + self:endSwitch() + end +end + +-- engine/items/pack.asm:1307 .place_insert / .end_switch +function PackMenu:placeSwitch() + local from = self.switching + local row = self.rows[from] + if row and not self:isCancel() and self.index ~= from then + Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData) + self:rebuild() + self:storeCursor() + end + -- engine/items/pack.asm:1309, :1311 + self:playSfxTwice("Sfx_SwitchPokemon") + self:endSwitch() +end + +function PackMenu:endSwitch() + self.switching = nil + self.message, self.messagePage = nil, nil +end + +-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry +-- that ExitMenu answers with (`ret c`), which is QUIT by another name. +function PackMenu:updateSubmenu(input) + local menu = self.submenu + local total = #menu.rows + if input:wasPressed("up") then + menu.index = menu.index > 1 and menu.index - 1 or total + elseif input:wasPressed("down") then + menu.index = menu.index < total and menu.index + 1 or 1 + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:chooseSubmenu() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:closeSubmenu() + end +end + +-- Toss_Sell_Loop: the count is stepped until A takes it or B backs out, and +-- backing out is the whole toss cancelled. +function PackMenu:updateQuantity(input) + local state = self.qtyState + if input:wasPressed("up") then + state.qty = qtyStep(state.qty, state.max, 1) + elseif input:wasPressed("down") then + state.qty = qtyStep(state.qty, state.max, -1) + elseif input:wasPressed("right") then + state.qty = qtyStep(state.qty, state.max, 10) + elseif input:wasPressed("left") then + state.qty = qtyStep(state.qty, state.max, -10) + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self.message, self.messagePage = nil, nil + self:confirmToss() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.qtyState = nil + self.message, self.messagePage = nil, nil + end +end + +-- YesNoBox: up/down flip, A takes the highlighted row, B is NO. +function PackMenu:updateConfirm(input) + local confirm = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if confirm.onNo then confirm.onNo() end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY +-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch). +-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything +-- CANT_SELECT_F refuses), so it cannot register what the cart would not. +function PackMenu:registerSelected() + if self:isCancel() then return end + local row = self.rows[self.index] + if not row then return end + -- BattlePack shares Pack_InterpretJoypad, whose SELECT arm is the bag's own + -- item shuffle rather than the field pack's item submenu, so nothing over a + -- battle registers anything. + local world = not self:inBattle() and self.world or nil + local ok + if world == (self.game and self.game.overworld) then + ok = require("src.ui.BagMenu").canRegister(self.game, row.id) + if ok then self.save.registeredItem = row.id end + elseif world and world.registerItem then ok = world:registerItem(row.id) end + if ok then + -- engine/items/pack.asm:551 + self:playSfx("Sfx_FullHeal") + -- RegisteredItemText: "Registered the\n." + self:showMessage(Strings(Strings.source("Registered the\n%s."), row.name)) + else + -- CantRegisterText: "You can't register\nthat item." + self:showMessage(Strings(Strings.source("You can't register\nthat item."))) + end +end + +-- A TM or HM's `move` is what it teaches; the extractor carries it on the item +-- record, and moves.lua carries that move's own description. +function PackMenu:moveOf(itemId) + local def = itemId and self.items and self.items[itemId] + -- The extractor calls it `teaches`. + return def and (def.teaches or (def.machine and def.machine.move)) or nil +end + +-- The description under the list. A TM shows the MOVE's description rather +-- than the item's -- which is what the cart's TM pocket does, and the whole +-- reason move descriptions are worth extracting. +function PackMenu:description() + if self:isCancel() then return nil end + local row = self.rows[self.index] + if not row then return nil end + local moveId = self:moveOf(row.id) + if moveId then + local moves = self.game and self.game.data and self.game.data.moves + local moveDef = moves and moves[moveId] + if moveDef and moveDef.description then return moveDef.description end + end + local def = self.items and self.items[row.id] + return def and def.description or nil +end + +-- engine/gfx/cgb_layouts.asm:723-726 -- the cursor column (7,2) 1x9 takes +-- palette $3, whose colour 3 is red (gfx/pack/pack.pal). +function PackMenu:cursorAt(tx, ty, hollow) + local palette = self.gfx and self.gfx:available() + and self.gfx:colorsAt(tx, ty) + if palette then + Chrome.cursorThrough(tx, ty, palette, false, hollow) + else + Chrome.cursor(tx, ty, hollow) + end +end + +-- The list, description and cursor, on top of whatever chrome was drawn. +-- +-- ScrollingMenu_CallFunctions1and2 (engine/menus/scrolling_menu.asm:424-429) +-- steps the coord on by the header's `db 5, 8` COLUMN count before +-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:19-25) adds SCREEN_WIDTH + 1, +-- so the ×N sits at name + 9 on the row BELOW the name, flush right in every +-- pocket (#1425, #1693). Its `lb bc, 1, 2` is a TWO-digit field with the +-- leading digit blanked. engine/items/tmhm.asm:392-403 writes that same +-- column for a TM. +-- +-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the +-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking. +function PackMenu:drawList(listX, listY) + -- engine/menus/scrolling_menu.asm:86 .a_button -> home/menu.asm:50 + -- engine/items/pack.asm:1301 .select + local picked = not self.switching + and (self.submenu or self.qtyState or self.confirm or self.message) + and true or false + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = listY + (row - 1) * LIST_SPACING + if i <= #self.rows then + local entry = self.rows[i] + -- engine/items/tmhm.asm:355-385 (#1695) + if entry.tmhmLabel then + Chrome.printThrough(entry.tmhmLabel, listX - 3, ty, Chrome.DEFAULT_BOX_PALETTE) + end + if i == self.index then + self:cursorAt(listX - 1, ty, picked) + elseif i == self.switching then + self:cursorAt(listX - 1, ty, true) + end + Chrome.printThrough((entry.tmhmLabel and entry.teaches) or entry.name, + listX, ty, Chrome.DEFAULT_BOX_PALETTE) + if entry.showCount then + Chrome.printThrough("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 9, ty + 1, Chrome.DEFAULT_BOX_PALETTE) + end + elseif i == self:total() then + if i == self.index then self:cursorAt(listX - 1, ty, picked) end + Chrome.printThrough(Strings("CANCEL"), listX, ty, Chrome.DEFAULT_BOX_PALETTE) + end + end +end + +function PackMenu:drawDescription(ty) + -- Pack_PrintTextNoScroll writes over the same box the description lives in, + -- so while a message is up it IS the box's contents. TossMenu's yes/no + -- (AskQuantityThrowAwayText through MenuTextbox) is the same box: the + -- question is printed there and the YES/NO window opens over the list. + local lines = self.message or (self.confirm and self.confirm.prompt) + if lines then + local name = self:playerName() + -- home/text.asm:397 + local page = self:pagesFor(lines)[self.messagePage or 1] or {} + for i, line in ipairs(page) do + Chrome.printThrough((line:gsub("{PLAYER}", name)), 1, ty + (i - 1) * 2, + Chrome.DEFAULT_BOX_PALETTE) + end + return + end + local description = self:description() + if not description then return end + -- Item descriptions join their two lines with the '' the extractor + -- leaves in place, and $4e steps SCREEN_WIDTH * 2 from the line's own + -- start (home/text.asm NextLineChar) -- TWO rows, the same metric every + -- text box uses. PrintItemDescription writes them from decoord 1, 14, so + -- the second line is row 16. '\n' covers hand-written data. + local first, second = description:match("^(.-)(.*)$") + if not first then first, second = description:match("^(.-)" .. LINE .. "(.*)$") end + Chrome.printThrough(first or description, 1, ty, Chrome.DEFAULT_BOX_PALETTE) + if second then Chrome.printThrough(second, 1, ty + 2, Chrome.DEFAULT_BOX_PALETTE) end +end + +-- ../pokecrystal/engine/items/pack.asm:162, :178, :313, :335, :355, :371, +-- TEXTBOX_Y - 1`; ../pokegold/engine/items/pack.asm has the same ten at +function PackMenu:submenuColumn() + local version = (self.save and self.save.version) or GameVersion.get() + return GameVersion.isCrystal(version) and 13 or 0 +end + +function PackMenu:drawSubmenu() + local menu = self.submenu + local count = #menu.rows + local x = self:submenuColumn() + -- ../pokegold/engine/items/pack.asm:313 ends on TEXTBOX_Y, pokecrystal:313 + local bottom = (count >= 5 and x == 0) and 12 or 11 + local top = bottom - count * 2 + Chrome.box(x, top, 7, bottom - top + 1) + for i, id in ipairs(menu.rows) do + local ty = top + 1 + (i - 1) * 2 + if i == menu.index then Chrome.cursorThrough(x + 1, ty, Chrome.DEFAULT_BOX_PALETTE) end + Chrome.printThrough(Strings(SUBMENU_LABEL[id] or id), x + 2, ty, + Chrome.DEFAULT_BOX_PALETTE) + end +end + +-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay +function PackMenu:drawQuantity() + Chrome.box(15, 9, 5, 3) + Chrome.printThrough("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10, + Chrome.DEFAULT_BOX_PALETTE) +end + +-- YesNoBox's own coords, the same box every other Gen 2 screen here draws. +function PackMenu:drawYesNo() + Chrome.box(14, 7, 6, 5) + Chrome.printThrough(Strings("YES"), 16, 8, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Strings("NO"), 16, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.cursorThrough(15, self.confirm.choice == 1 and 8 or 10, Chrome.DEFAULT_BOX_PALETTE) +end + +function PackMenu:drawOverlays() + if self.submenu then self:drawSubmenu() end + if self.qtyState then self:drawQuantity() end + if self.confirm then self:drawYesNo() end +end + +function PackMenu:drawPanel() + if self.gfx:available() then + -- Pack_InitGFX's screen: the header strip, the patterned left column, the + -- bag picture for this pocket and the pocket plaque, then Textbox at + -- (0,12) for the item description. + self.gfx:draw(self:pocket().id) + Chrome.box(0, PackGfx.DESCRIPTION_Y, 20, 6) + self:drawList(LIST_X, LIST_Y) + self:drawDescription(PackGfx.DESCRIPTION_Y + 2) + self:drawOverlays() + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- No pack tiles in the cache (an import from before the pack stage): plain + -- boxes, the layout this screen shipped with. + Chrome.clear() + Chrome.box(0, 0, 20, 3) + Chrome.printThrough(Strings(self:pocket().label), 2, 1, + Chrome.DEFAULT_BOX_PALETTE) + Chrome.box(0, 3, 20, 12) + self:drawList(5, 4) + Chrome.box(0, 12, 20, 6) + self:drawDescription(14) + self:drawOverlays() + love.graphics.setColor(1, 1, 1, 1) +end + +function PackMenu:draw() + self:drawPanel() +end + +function PackMenu:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +PackMenu.POCKETS = POCKETS + +return PackMenu diff --git a/src/ui/Gen2PcMenu.lua b/src/ui/Gen2PcMenu.lua new file mode 100644 index 000000000..7a35633be --- /dev/null +++ b/src/ui/Gen2PcMenu.lua @@ -0,0 +1,561 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- The Pokemon PC's top menu (engine/pokemon/bills_pc_top.asm _BillsPC). +-- +-- WITHDRAW POKéMON / DEPOSIT POKéMON / CHANGE BOX / +-- MOVE POKéMON W/O MAIL / MAIL BOX / SEE YA! +-- +-- MAIL BOX is the item PC's PLAYERSPCITEM_MAIL_BOX row rather than one of +-- _BillsPC's five; it is here because this port used to fold both PCs into +-- one menu, and a directly-constructed PcMenu still folds them. The +-- Pokecenter's whose-PC menu (src/ui/gen2/CenterPcMenu.lua) opens this as +-- BILL's PC with `bills = true`, which shows the cart's own five rows and +-- leaves the MAIL BOX to 's PC (src/ui/gen2/ItemPcMenu.lua). +-- +-- .MenuHeader is menu_coords 0, 0, 19, 17 -- the menu owns the whole screen, +-- with "What do you want to do?" in a text box along the bottom. Choosing an +-- entry pushes BoxMenu (the withdraw/deposit list) or the box picker. +-- +-- The assembled rows run through the ui.pc.items hook, the same name and the +-- same (game, items) payload the Gen 1 PC uses +-- (src/world/OverworldController.lua openPC), with SEE YA! appended after it +-- the way that site appends LOG OFF. + +local Boxes = require("src.pokemon.Gen2Boxes") +local Chrome = require("src.ui.Gen2Chrome") +local Logger = require("src.core.Logger") +local Mail = require("src.pokemon.Gen2Mail") +local Runtime = require("src.mods.Runtime") +local Save = require("src.core.SaveData") +local SaveMenu = require("src.ui.Gen2SaveMenu") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +-- _PCMonHoldingMailText (data/text/common_2.asm), the refusal +-- BillsPC_MovePKMNMenu prints instead of opening the list. Two pages, because +-- the ASM has a `para` in the middle of it. Declared up here and looked up at +-- the call site, so Strings.source is what puts both in the catalog. +local MON_HOLDING_MAIL = { + Strings.source("There is a POKéMON\nholding MAIL."), + Strings.source("Please remove the\nMAIL."), +} + +-- ../pokecrystal/data/text/common_3.asm:219 _ChangeBoxSaveText +-- ../pokegold/data/text/common_2.asm:1306 _ChangeBoxSaveText +local CHANGE_BOX_SAVE_SOURCE = Strings.source("When you change a\n#MON BOX, data\vwill be saved. OK?") + +-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383). +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + +local PcMenu = {} +PcMenu.__index = PcMenu +PcMenu.isOpaque = true + +-- .strings, verbatim. and are real font glyphs (codes $e1/$e2) and +-- Font.split matches charmap sequences, so writing them the way the ASM does +-- draws two tiles rather than seven -- which is the only reason "MOVE +-- W/O MAIL" fits inside a 20-tile screen. +local ENTRIES = { + { id = "withdraw", label = Strings.source("WITHDRAW "), builtin = true }, + { id = "deposit", label = Strings.source("DEPOSIT "), builtin = true }, + { id = "changebox", label = Strings.source("CHANGE BOX"), builtin = true }, + { id = "move", label = Strings.source("MOVE W/O MAIL"), builtin = true }, + -- PLAYERSPCITEM_MAIL_BOX (engine/events/pokecenter_pc.asm), which BOTH + -- .WhichPC lists carry: the MAILBOX is on the item PC in a Pokecenter and in + -- the bedroom alike, unlike DECORATION below. It sits here because this + -- port folds the item PC's menu into the storage one. + { id = "mailbox", label = Strings.source("MAIL BOX"), builtin = true }, + { id = "seeya", label = Strings.source("SEE YA!"), builtin = true }, +} + +-- PLAYERSPCITEM_DECORATION, the one row the bedroom's PC has that a +-- Pokecenter's does not (engine/events/pokecenter_pc.asm: PLAYERSPC_HOUSE +-- carries it, PLAYERSPC_NORMAL does not). It belongs to the item PC's menu on +-- the cart, which this port folds into the storage menu the same way both PCs +-- are folded -- so it hangs off the same list, gated on `house`. +local DECORATION = { + id = "decoration", label = Strings.source("DECORATION"), builtin = true, +} + +-- The exit row. It is a member of ENTRIES (it is one of _BillsPC's five), but +-- the list is assembled without it and it is put back on the end AFTER the +-- ui.pc.items hook has run, exactly as the Gen 1 site appends LOG OFF +-- (src/world/OverworldController.lua openPC): a mod may add, drop or reorder +-- anything it likes and still cannot orphan the way out. +local EXIT_ID = "seeya" + +-- ui.pc.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +function PcMenu:wantsFillScale() return true end +function PcMenu:drawsWidescreen() return true end + +-- opts: save, house (the bedroom's PC, which also does decorations), events +-- (the wEventFlags bitfield the decoration menu reads ownership from), +-- bills (_BillsPC's own five rows, no MAIL BOX: what the whose-PC +-- menu's BILL's PC entry opens, src/ui/gen2/CenterPcMenu.lua), +-- onClose(changedDecorations) +function PcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + self.house = opts.house and true or false + self.events = opts.events + -- The same route the start menu's SAVE row takes (src/core/Game2.lua:435), + -- so the save.write veto and the save.writing event fire here too. + self.writer = opts.writer + or (game and self.save == game.save and type(game.writeSave) == "function" + and function() return game:writeSave() end) + or function() return false end + self.saveExists = opts.saveExists + -- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu + -- reaches that through 's PC (src/ui/gen2/ItemPcMenu.lua), so + -- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the + -- MAILBOX is on both .WhichPC lists. + local dropMailbox = opts.bills and not self.house + local entries = {} + for _, entry in ipairs(ENTRIES) do + local drop = entry.id == EXIT_ID + or (dropMailbox and entry.id == "mailbox") + if not drop then entries[#entries + 1] = entry end + end + if self.house then + entries[#entries + 1] = DECORATION + end + -- Same hook name and same (game, items) payload as the Gen 1 PC menu + -- (src/world/OverworldController.lua openPC), so one mod source can add a + -- row to both generations' PCs. Unguarded, like that site: the list is + -- built once per session, not per frame. A hook that answers with anything + -- but a table is degraded to the vanilla list. + local hooked = Runtime.call("ui.pc.items", sameItems, game, entries) + if type(hooked) == "table" then + entries = hooked + else + Logger.error("ui.pc.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- SEE YA! goes back on last: it is the row B lands on, and TURN OFF sits at + -- the bottom of the cart's house list too. + for _, entry in ipairs(ENTRIES) do + if entry.id == EXIT_ID then entries[#entries + 1] = entry end + end + self.entries = entries + -- wChangedDecorations, carried out to `special PlayersHousePC` so its + -- script's `iftrue` can reload the map. + self.changedDecorations = false + self.index = 1 + self.message = nil + -- Whether clearing the message also logs off. .CheckCanUsePC's does (the PC + -- never opened at all); BillsPC_MovePKMNMenu's mail refusal does not -- its + -- `.quit` returns with the carry clear, which drops back into the _BillsPC + -- loop and redraws this menu. + self.messageCloses = true + -- .CheckCanUsePC: an empty party gets the "You'll need a POKéMON" line and + -- the PC never opens. Kept here rather than at the call site so every route + -- into the PC (the overworld script, a driver, a mod) gets the same gate. + -- Boxes.lua's own \f is the same catalog key BoxMenu.lua's + -- BOX_FAILURE_SOURCES already declares for this string; split the + -- translated result on it into notice()'s page-per-string shape rather + -- than calling notice() itself, which would also reset messageCloses to + -- false -- this refusal closes the PC, the mail-holding one does not. + local ok, reason = Boxes.canUsePc(self.save) + if not ok then + local pages = {} + for page in (Strings(reason) .. "\f"):gmatch("(.-)\f") do + pages[#pages + 1] = page + end + self.message = pages[1] + self.messagePages = pages + self.messagePage = 1 + end + return self +end + +-- A refusal that leaves the PC open: the message replaces the menu until a +-- button clears it, and then the menu is back exactly where it was. `pages` +-- is a list because a `para` in the ASM is a screenful of its own -- the text +-- box holds two rows and the fourth line of _PCMonHoldingMailText would +-- otherwise be drawn off the bottom of the screen. +function PcMenu:notice(pages) + self.message = pages[1] + self.messagePages = pages + self.messagePage = 1 + self.messageCloses = false +end + +-- home/menu.asm:746 +function PcMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then Sound.play(data, name) end +end + +function PcMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose(self.changedDecorations) end +end + +-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch +-- engine/menus/save.asm:40 ChangeBoxSaveGame +function PcMenu:beginChangeBox(index) + self.changeBox = index + self:setSavePhase("confirm") + self.saveChoice = 1 + self.saved = nil + local existed = self.saveExists + if existed == nil then existed = Save.saveFileExists(self.save.version) end + self.existed = existed +end + +-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up. +function PcMenu:refuseChangeBox() + self.savePhase, self.changeBox = nil, nil + self.saveTimer = 0 + self.typer = nil +end + +-- ../pokecrystal/engine/menus/save.asm:336 SavingDontTurnOffThePower +-- ../pokecrystal/engine/menus/save.asm:241 SavedTheGame +function PcMenu:setSavePhase(phase) + self.savePhase = phase + self.savePage = 1 + self.saveTimer = 0 + local pinned = (phase == "saving" or phase == "done") and "MID" or nil + self.typer = Typer.new(self.game, { speed = pinned }) + self.typer:start(self:savePages()[1]) +end + +function PcMenu:acceptChangeBox() + if self.saveChoice == 2 then return self:refuseChangeBox() end + if self.savePhase == "confirm" and self.existed then + self:setSavePhase("overwrite") + self.saveChoice = 1 + return + end + self:setSavePhase("saving") +end + +-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and +-- SavingDontTurnOffThePower, so the new index rides the file that is written. +function PcMenu:writeChangeBox() + local previous = self.save.currentBox + if not Boxes.setCurrent(self.save, self.changeBox) then self.saved=false;return end + local called, result = pcall(self.writer, self.save) + local ok = called and result + self.saved = ok and true or false + if not ok then self.save.currentBox = previous end + -- engine/menus/save.asm:266 + if ok then + SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) + end +end + +function PcMenu:savePromptText() + if self.savePhase == "overwrite" then + return Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE) + end + if self.savePhase == "saving" then + return Strings(SaveMenu.SAVING_PROMPT_SOURCE) + end + if self.savePhase == "done" then + if self.saved then + local name = (self.save.player and self.save.player.name) or "GOLD" + return Strings("%s saved\nthe game.", name) + end + return Strings("Could not save.") + end + return Strings(CHANGE_BOX_SAVE_SOURCE) +end + +function PcMenu:savePages() + local text = self:savePromptText() + if self.savePagesText ~= text or not self.savePagesCache then + self.savePagesText = text + self.savePagesCache = SaveMenu.pagesOf(text) + end + return self.savePagesCache +end + +function PcMenu:savePrompt() + local pages = self:savePages() + return pages[self.savePage or 1] or pages[1] +end + +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +function PcMenu:saveYesNoVisible(pages) + if self.savePhase ~= "confirm" and self.savePhase ~= "overwrite" then + return false + end + if Typer.typing(self) then return false end + return (self.savePage or 1) >= #(pages or self:savePages()) +end + +function PcMenu:updateChangeBox() + local typed = Typer.step(self) + -- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does + -- anything until the sequence runs out (engine/menus/save.asm:55). + if self.savePhase == "saving" then + if not typed then return end + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVING_FRAMES then + self:writeChangeBox() + self:setSavePhase("done") + end + return + end + if self.savePhase == "done" then + if not typed then return end + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVED_FRAMES then + self.savePhase, self.changeBox = nil, nil + self.picking = false + self.typer = nil + end + return + end + + local input = self.game and self.game.input + if not input then return end + if Typer.typing(self) then return end + -- ../pokecrystal/home/text.asm:502 _ContText + if not self:saveYesNoVisible() then + if input:wasPressed("a") or input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.savePage = (self.savePage or 1) + 1 + if self.typer then self.typer:start(self:savePages()[self.savePage]) end + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + self.saveChoice = self.saveChoice == 1 and 2 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self:acceptChangeBox() + elseif input:wasPressed("b") then + -- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry). + self:playSfx("Sfx_ReadText2") + self:refuseChangeBox() + end +end + +function PcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + -- A hook- or monkey-patch-injected row carries label + onSelect and no id + -- the ladder below knows, so without this arm it draws and does nothing. + -- Same shape and same argument order as src/ui/gen2/PartyMenu.lua:433. + if type(entry.onSelect) == "function" then + entry.onSelect(self, game) + return + end + if entry.id == "seeya" then + self:close() + return + end + if entry.id == "decoration" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2DecorationMenu", { + save = self.save, + events = self.events, + onDone = function(changed) + self.changedDecorations = self.changedDecorations or changed or false + game.stack:pop() + end, + }) + return + end + if entry.id == "mailbox" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2MailboxMenu", { + save = self.save, + onClose = function() game.stack:pop() end, + }) + return + end + if entry.id == "changebox" then + self.picking = true + self.pickIndex = self.save.currentBox or 1 + return + end + -- BillsPC_MovePKMNMenu asks IsAnyMonHoldingMail BEFORE it opens the list and + -- refuses outright: MOVE POKéMON W/O MAIL is a whole-party operation, so one + -- letter anywhere in the party stops it. + if entry.id == "move" and Mail.anyMonHoldingMail(self.save) then + self:notice({ Strings(MON_HOLDING_MAIL[1]), Strings(MON_HOLDING_MAIL[2]) }) + return + end + if not (game and game.stack) then return end + Screens.push(game, "Gen2BoxMenu", { + save = self.save, + mode = entry.id, -- "withdraw" | "deposit" | "move" + onClose = function() game.stack:pop() end, + }) +end + +function PcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local pages = self.messagePages + if pages and self.messagePage < #pages then + self.messagePage = self.messagePage + 1 + self.message = pages[self.messagePage] + return + end + local closes = self.messageCloses + self.message, self.messagePages, self.messagePage = nil, nil, nil + self.messageCloses = true + if closes then self:close() end + end + return + end + + if self.savePhase then + self:updateChangeBox() + return + end + + if self.picking then + local total = Boxes.NUM_BOXES + if input:wasPressed("up") then + self.pickIndex = self.pickIndex > 1 and self.pickIndex - 1 or total + elseif input:wasPressed("down") then + self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1 + elseif input:wasPressed("a") then + -- engine/menus/scrolling_menu.asm:24 + self:playSfx("Sfx_ReadText2") + if self.pickIndex == (self.save.currentBox or 1) then + self.picking = false + else + self:beginChangeBox(self.pickIndex) + end + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.picking = false + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:476 + self:playSfx("Sfx_ReadText2") + self:choose() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:close() + end +end + +function PcMenu:drawPanel() + Chrome.clear() + if self.message then + Chrome.box(0, 12, 20, 6) + -- A text box's two lines sit two tile rows apart, not one -- the same + -- spacing src/render/TextBox.lua uses (line1 = ty+2, line2 = ty+4). + local line = 14 + for part in (self.message .. "\n"):gmatch("(.-)\n") do + Chrome.print(part, 1, line) + line = line + 2 + end + love.graphics.setColor(1, 1, 1, 1) + return + end + + if self.picking then + -- CHANGE BOX: the 14 box names with how full each one is, six at a time. + Chrome.box(0, 0, 20, 14) + local rows = 6 + local scroll = math.max(0, math.min(self.pickIndex - rows, + Boxes.NUM_BOXES - rows)) + for row = 1, rows do + local i = row + scroll + local ty = row * 2 - 1 + if i == self.pickIndex then Chrome.cursor(1, ty) end + Chrome.print(Boxes.name(self.save, i), 2, ty) + Chrome.printRight( + ("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX), + 18, ty) + end + if self.savePhase then + -- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list. + Chrome.box(0, 12, 20, 6) + local pages = self:savePages() + local lines = Typer.text(self, pages[self.savePage or 1] or pages[1]) + Chrome.print(lines[1] or "", 1, 14) + Chrome.print(lines[2] or "", 1, 16) + if not Typer.typing(self) and (self.savePage or 1) < #pages + and Typer.arrowOn(self) then + Chrome.print(SaveMenu.DOWN_ARROW, SaveMenu.ARROW_X, SaveMenu.ARROW_Y) + end + if self:saveYesNoVisible(pages) then + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, + YESNO_Y + (self.saveChoice == 1 and 1 or 3)) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.box(0, 14, 20, 4) + Chrome.print(Strings("Which BOX?"), 1, 16) + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- .LogIn prints _PCWhatText into the (0,12) box, and that text is one word: + -- "What?". PrintText starts at (1,14). The box goes down first because + -- the menu window is drawn over it wherever a folded list runs past row 12, + -- the way the cart's windows stack (src/ui/gen2/ItemPcMenu.lua does the + -- same with the house's six-row item list). + Chrome.box(0, 12, 20, 6) + Chrome.print(Strings("What?"), 1, 14) + + -- ClearPCItemScreen: Textbox at (0,0) with a 10x18 interior, and a second + -- at (0,12) with a 4x18 one. GetMenuTextStartCoord then puts the first + -- label at (left+1+1, top+1+1) = (2,2) because STATICMENU_CURSOR is set and + -- the menu does not ask for NO_TOP_SPACING; rows are two apart and the + -- cursor sits one column left of the label. _BillsPC's own five rows end + -- at row 10 and fit that box exactly; the folded MAIL BOX (and the + -- bedroom's DECORATION) put rows below it, so the window is sized to the + -- list rather than to the five the cart ships. + Chrome.box(0, 0, 20, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.builtin and Strings(entry.label) or entry.label, 2, ty) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function PcMenu:draw() + self:drawPanel() +end + +function PcMenu:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +PcMenu.ENTRIES = ENTRIES + +return PcMenu diff --git a/src/ui/Gen2StartMenu.lua b/src/ui/Gen2StartMenu.lua index d873e8758..47d8301fb 100644 --- a/src/ui/Gen2StartMenu.lua +++ b/src/ui/Gen2StartMenu.lua @@ -465,8 +465,9 @@ function StartMenu:open(id) end}) return saving end - local target = {pokedex='PokedexMenu', pokemon='PartyMenu', pack='BagMenu', + local target = {pokedex='PokedexMenu', pokemon='PartyMenu', pack='Gen2PackMenu', pokegear='PokegearMenu', status='TrainerCard', option='OptionsMenu', mods='ManagerState'} + if id == 'pack' then return Screens.push(game, target[id]) end if target[id] then return Screens.push(game, target[id], {onCancel=function() end}) end if id == 'exit' then return self:close() end end diff --git a/src/ui/Gen2WaitPlaySFX.lua b/src/ui/Gen2WaitPlaySFX.lua new file mode 100644 index 000000000..0f49a5303 --- /dev/null +++ b/src/ui/Gen2WaitPlaySFX.lua @@ -0,0 +1,36 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +local WaitPlaySFX = {} + +function WaitPlaySFX.step(game) + local speed = game and type(game.logicSpeed) == "function" and game:logicSpeed() or 1 + if type(speed) ~= "number" or speed ~= speed or speed < 1 or speed == math.huge then speed = 1 end + return 1 / speed +end + +local function sound() + local ok, mod = pcall(require, "src.core.Sound") + return ok and type(mod) == "table" and mod or nil +end + +-- home/audio.asm:220 WaitPlaySFX +-- home/delay.asm:15 +function WaitPlaySFX.arm(name, fallback) + local Sound = sound() + -- Wait on the engine's actual source; the frame budget bounds a stuck + -- device. Missing/headless effects finish immediately. + return { name = name, left = math.max(1, tonumber(fallback) or 180) } +end + +-- home/audio.asm:225 WaitSFX +function WaitPlaySFX.waiting(pending, game) + if not pending then return false end + pending.left = (pending.left or 0) - WaitPlaySFX.step(game) + if pending.left <= 0 then return false end + local Sound = sound() + if not Sound then return false end + if Sound.sfxBusy and Sound.sfxBusy() then return true end + return (Sound.isPlaying and Sound.isPlaying(pending.name)) or false +end + +return WaitPlaySFX diff --git a/src/ui/MenuRepeat.lua b/src/ui/MenuRepeat.lua new file mode 100644 index 000000000..00498b2ae --- /dev/null +++ b/src/ui/MenuRepeat.lua @@ -0,0 +1,45 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and notices remain applicable; see NOTICE.md. +-- home/joypad2.asm:16-53 JoypadLowSensitivity (hJoy7 = 1, 30 then 5) +-- pokegold home/joypad.asm:313-340 JoyTextDelay (hInMenu, 15 then 5) + +local MenuRepeat = {} + +MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE = 30, 5 +MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE = 15, 5 + +local ALL_DIRS = { "up", "down", "left", "right" } + +function MenuRepeat.new(delay, rate, enabled) + return { + delay = tonumber(delay) or MenuRepeat.GEN1_DELAY, + rate = math.max(1, tonumber(rate) or MenuRepeat.GEN1_RATE), + enabled = enabled ~= false, + dir = nil, + frames = 0, + } +end + +function MenuRepeat.reset(state) + state.dir, state.frames = nil, 0 +end + +function MenuRepeat.direction(state, input, dirs) + for _, dir in ipairs(dirs or ALL_DIRS) do + if input:wasPressed(dir) then + state.dir, state.frames = dir, 0 + return dir, true + end + end + local dir = state.dir + if not (dir and state.enabled and input.isDown and input:isDown(dir)) then + MenuRepeat.reset(state) + return nil, false + end + state.frames = state.frames + 1 + local afterDelay = state.frames - state.delay + if afterDelay >= 0 and afterDelay % state.rate == 0 then return dir, false end + return nil, false +end + +return MenuRepeat diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index f6680d58f..d891e7778 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -171,6 +171,14 @@ PlayerPC.toss = toss function PlayerPC.new(game,opts) opts=opts or{} + if require("src.core.GameVersion").isGen2() then + local arg={};for key,value in pairs(opts) do arg[key]=value end + arg.onClose=opts.onClose or function(changed) + game.stack:pop() + if opts.onDone then opts.onDone(changed) end + end + return require("src.ui.Gen2ItemPcMenu").new(game,arg) + end local changed,done=false,false local function finish() if done then return end;done=true diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 3ccd0f326..ee9f6831d 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -555,7 +555,7 @@ function Screens.get(game, id) return resolve(game, id) end -local function pushWith(game, id, ...) +local function buildWith(game, id, ...) local factory = resolve(game, id) local inst if factory.__modOwned then @@ -582,24 +582,31 @@ local function pushWith(game, id, ...) inst = factory.new(game, ...) end inst.screenId = inst.screenId or id - game.stack:push(inst) return inst end -function Screens.push(game, id, ...) +function Screens.build(game, id, ...) -- A POSITIONAL alias keeps every argument, because its screen is not an -- options-table screen -- MoveLearnMenu takes (mon, moveId, onDone) and -- dropping the last two would open it with nothing to teach. if Screens.positionalAlias(id) then local resolvedId = Screens.resolveId(game, id, ...) - return pushWith(game, resolvedId, ...) + return buildWith(game, resolvedId, ...) end -- Otherwise only the FIRST argument decides: every other screen in the -- alias table takes one options table and nothing else, and a caller -- passing more is by that fact not talking to one of them. local resolvedId, resolvedArg = Screens.resolveId(game, id, ...) - if resolvedId ~= id then return pushWith(game, resolvedId, resolvedArg) end - return pushWith(game, id, ...) + if resolvedId ~= id then return buildWith(game, resolvedId, resolvedArg) end + return buildWith(game, id, ...) +end + +-- Embedded screens use the identical factory/registry/error policy without +-- changing stack ownership or emitting a premature screen.pushed event. +function Screens.push(game, id, ...) + local inst = Screens.build(game, id, ...) + game.stack:push(inst) + return inst end function Screens.invalidate() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 7f0a4f5d6..53ea8804b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -11536,6 +11536,12 @@ function OverworldState:openPC(onDone) if GameVersion.isGen3() then return self:openGen3PC(onDone) end + if GameVersion.isGen2() then + return Screens.push(Game, "Gen2CenterPcMenu", {onClose=function() + Game.stack:pop() + if onDone then onDone() end + end}) + end require("src.core.Sound").play(Game.data, "Turn_On_PC") local Menu = require("src.ui.Menu") local done = onDone or function() end diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua index 2b5162754..e32f60331 100644 --- a/tests/engine/gen2_decoration_content.lua +++ b/tests/engine/gen2_decoration_content.lua @@ -60,20 +60,51 @@ local function run(g,label) end local function choose(g,label) local menu=g.stack:top() - for i,row in ipairs(menu.items)do if row.label==label then menu.index=i;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0) - g.input={wasPressed=function()return false end};return end end + -- Drive the native house PC boot message before selecting a row. It owns + -- its typewriter rather than a separate generic TextBox on the stack. + for _=1,3000 do + if not menu.message then break end + local original=g.input.wasPressed + g.input.wasPressed=function(_,key)return key=='a'end + menu:update(1/60);g.input.wasPressed=original + end + if menu.entries then + for i,row in ipairs(menu.entries)do + if row.label==label then menu.index=i;menu:choose();return end + end + elseif menu.categories then + if menu.mode=='items'then + local leave=label=='EXIT' + for _,category in ipairs(menu.categories)do if category.label==label then leave=true end end + if leave then + local original=g.input.wasPressed;g.input.wasPressed=function(_,key)return key=='b'end + menu:update(1/60);g.input.wasPressed=original + end + end + if menu.mode=='category'then + if label=='EXIT'then menu:finish();return end + for _,category in ipairs(menu.categories)do if category.label==label then menu:openCategory(category);return end end + else + for _,id in ipairs(menu.rows or{})do + if menu:rowName(id)==label then menu:chooseRow(id);return end + end + end + else + for i,row in ipairs(menu.items or{})do if row.label==label then menu.index=i;menu:choose();return end end + end error('missing menu choice '..label) end + local function dismiss(g) - local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;box:advance() end for _,version in ipairs({'gold','silver','crystal'})do V.set(version) - for _,mode in ipairs({{1,'native'},{2,'native'}})do + for _,mode in ipairs({{1,'native'},{2,'native'},{2,'unified'},{2,'unified'}})do local d=fixture();local before=d.map_scripts.decorations local r=load(d,[[ local p=mod.content["decorations"] - assert(p:supports()) + assert(p:supports() and mod:supports('content','decorations')) local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) p:patch('deco:2',{sprite=88}) p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) @@ -142,7 +173,7 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(g.save.decorations.rightOrnament,5,version..' actual native side menu commits right doll') choose(g,'EXIT') T.check(pc:isRunning(),version..' leaving decoration submenu does not resume PC script') - choose(g,'LOG OFF') + choose(g,'TURN OFF') T.eq(pc:isRunning(),false,version..' LOG OFF completes actual native PC script') T.eq(pc.ctx.g2Var,1,version..' changed bit returns after menu closes') T.eq(g.overworld.reloaded,1,version..' native NONE warp reloads exactly once') @@ -152,7 +183,7 @@ for _,version in ipairs({'gold','silver','crystal'})do g.data.map_scripts.scripts.FACE={{'warpfacing',3,'NONE',0,0,0},{'end'}} g.overworld.player.facing='down';run(g,'FACE') T.eq(g.overworld.player.facing,'right',version..' native NONE warpfacing applies its explicit direction') - g=game(fixture());pc=run(g,'PC');g.input={wasPressed=function(_,key)return key=='b'end};g.stack:update(0) + g=game(fixture());pc=run(g,'PC');choose(g,'TURN OFF') T.eq(pc.ctx.g2Var,0,version..' PC cancellation returns unchanged') T.eq(g.overworld.reloaded,0,version..' PC cancellation cannot reload') end diff --git a/tests/engine/gen2_mail_ui.lua b/tests/engine/gen2_mail_ui.lua index 84b431040..c54c560fc 100644 --- a/tests/engine/gen2_mail_ui.lua +++ b/tests/engine/gen2_mail_ui.lua @@ -82,34 +82,26 @@ T.eq(game.save.mail.party[1],nil,'actual party screen clears the old mail slot') -- Mailbox actions use the same save and preserve entries on failed moves. local Mailbox=require('src.ui.Gen2MailboxMenu') reset();game.save.mail={party={},box={Mail.entry('ITEM_158','ONE','A'),Mail.entry('ITEM_158','TWO','B'),Mail.entry('ITEM_158','THREE','C')}} -local list=Mailbox.new(game);game.stack:push(list);list.index=2;press(list,'a') -local actions=game.stack:top();actions.index=2;press(actions,'a') +local list=Mailbox.new(game);game.stack:push(list);list.index=2 +list:attachMail() local picker=game.stack:top();press(picker,'a') T.eq(game.save.mail.party[1].message,'TWO','real mailbox GIVE attaches the selected letter') T.eq(game.save.mail.box[2].message,'THREE','real mailbox compacts after middle deletion') -T.eq(list.items[2].value,2,'display rows follow compacted native indexes') -game.stack.states={parent,list};list.index=2;press(list,'a') -actions=game.stack:top();actions.index=2;press(actions,'a') +T.eq(list:selected().message,'THREE','native cursor follows the compacted mailbox index') +game.stack.states={parent,list};list.message=nil;list.index=2;list:attachMail() picker=game.stack:top();press(picker,'a') T.eq(#game.save.mail.box,2,'occupied target preserves the mailbox') T.eq(game.save.mail.party[1].message,'TWO','failed mailbox attachment preserves held letter') -game.stack.states={parent,list};list.index=2;press(list,'a') -actions=game.stack:top();actions.index=3;press(actions,'a') -local warning=game.stack:pop();warning.choice(false) +game.stack.states={parent,list};list.message=nil;list.index=2;list:putInPack() +press(list,'b') T.eq(#game.save.mail.box,2,'declining message loss retains mailbox contents') T.eq(game.save.inventory.ITEM_158,2,'declining message loss adds no stationery') -- The compatibility PC's dismissal is caller-owned; its close callback may -- pop exactly one screen, as existing Gen2 hosts do. -reset();Mail.state(game.save).box={Mail.entry('ITEM_158','PC ENTRY','A')} -local playerpc=require('src.ui.PlayerPC').new(game);game.stack:push(playerpc) -local mailboxRow -for i,row in ipairs(playerpc.items)do if row.label=='MAILBOX' then mailboxRow=i end end -T.check(mailboxRow~=nil,'native player PC contains the mailbox entry') -playerpc.index=mailboxRow;press(playerpc,'a') -T.eq(game.stack:top().items[1].value,1,'player PC opens the actual populated mailbox') - -local BoxMenu=require('src.ui.BoxMenu') +require('src.core.ModCompat').install() +local BoxMenu=require('src.ui.gen2.BoxMenu') +T.eq(BoxMenu,require('src.ui.Gen2BoxMenu'),'legacy Gen2 PC namespace reaches the actual native factory') reset();parent=game.stack:top();local calls=0;local pc pc=BoxMenu.new(game,{onClose=function() calls=calls+1;T.eq(game.stack:top(),pc,'PC remains on stack during caller-owned close');game.stack:pop() diff --git a/tests/engine/gen2_pc_owners.lua b/tests/engine/gen2_pc_owners.lua new file mode 100644 index 000000000..41692153b --- /dev/null +++ b/tests/engine/gen2_pc_owners.lua @@ -0,0 +1,118 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual native owners, registry construction and the shared save stores. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local old=Version.get() +require('src.core.ModCompat').install() +local Screens=require('src.ui.Screens');local Stack=require('src.core.StateStack') +local Bag=require('src.inventory.Bag');local Mail=require('src.pokemon.Gen2Mail') +local Storage=require('src.pokemon.Boxes');local Font=require('src.render.Font') +local function game(version) + local data=T.fixtures.fresh() + data.items.POTION={name='POTION',pocket='ITEM',canToss=true} + data.items.ANTIDOTE={name='ANTIDOTE',pocket='ITEM',canToss=true} + data.items.POKE_BALL={name='POKé BALL',pocket='BALL',canToss=true} + data.items.FLOWER_MAIL={name='FLOWER MAIL',pocket='ITEM'} + data.font.charmap={} + for n=32,126 do data.font.charmap[#data.font.charmap+1]={seq=string.char(n),code=n} end + Font.load(data);Mail.load(data);Storage.load(nil) + local g={data=data,input={key=nil},save={version=version,flags={},inventory={POTION=5,ANTIDOTE=2,POKE_BALL=3}, + pcItems={POTION=2},options={},player={name='QA'},party={},pokedex={seen={},owned={}}}} + function g.input:wasPressed(key)return self.key==key end + function g.input:isDown(key)return self.key==key end + g.stack=setmetatable({},{__index=Stack});g.stack:init();g.overworld={};g.stack:push(g.overworld) + return g +end +local function tick(g,key)g.input.key=key;g.stack:update(1/60);g.input.key=nil end +for _,version in ipairs({'gold','silver','crystal'}) do + Version.set(version);local g=game(version) + for _,name in ipairs({'PcMenu','CenterPcMenu','ItemPcMenu','MailboxMenu','DecorationMenu','PackMenu','BoxMenu','MailRead'}) do + local owner=require('src.ui.Gen2'..name) + T.eq(require('src.ui.gen2.'..name),owner,'public spelling retains '..name..' class identity') + local before=#g.stack.states + local instance=Screens.build(g,'Gen2'..name) + T.eq(getmetatable(instance),owner,'factory builds actual '..name..' state') + T.eq(instance.screenId,'Gen2'..name,'resolved owner ID is available before stack insertion') + T.eq(#g.stack.states,before,'embedded construction never pushes '..name) + end + local Item=require('src.ui.Gen2ItemPcMenu') + g.data.items.KEY={name='KEY',pocket='KEY_ITEM',keyItem=true,cantToss=true,registerable=true} + g.data.items.HM={name='HM01',pocket='TM_HM',machine={kind='HM',number=1,move='CUT'}} + g.data.items.TM={name='TM02',pocket='TM_HM',machine={kind='TM',number=2,move='MOVE_001'}} + local pack=require('src.ui.Gen2PackMenu').new(g) + T.eq(pack:moveOf('HM'),'CUT','pack reads imported native machine records') + T.eq(pack:moveOf('TM'),'MOVE_001','TM uses native move identity') + T.eq(table.concat(pack:submenuRows('KEY'),','),'use,sel,quit','native CANT_TOSS/CAN_SELECT bits govern key-item actions') + T.check(Item.new(g):cantToss('HM'),'PC cannot toss an imported HM') + T.check(Item.new(g):cantToss('KEY'),'PC cannot toss an imported key item') + + local pc=Screens.push(g,'Gen2ItemPcMenu') + T.eq(getmetatable(pc),Item,'ordinary PC push uses its native owner') + pc:enterDeposit() + T.eq(getmetatable(pc.pack),require('src.ui.Gen2PackMenu'),'deposit embeds an actual pack without a second stack state') + T.eq(g.stack:top(),pc,'embedded PACK leaves PC as stack owner') + pc:deposit('POTION','POTION',2) + T.eq(g.save.inventory.POTION,3,'deposit removes the real bag quantity') + T.eq(g.save.pcItems.POTION,4,'deposit grows the canonical PC store') + pc:withdraw({id='POTION',name='POTION'},1) + T.eq(g.save.inventory.POTION,4,'withdraw returns real items to the bag') + T.eq(g.save.pcItems.POTION,3,'withdraw debits the same PC store') + pc:leaveDeposit();pc:close();T.eq(g.stack:top(),g.overworld,'PC default close returns to the world') + local s=g.save;s.bagOrder={'POTION','POKE_BALL','ANTIDOTE'} + T.check(Bag.move(s,'POTION','ITEM',2,g.data),'SELECT reorders inside its pocket') + T.eq(table.concat(s.bagOrder,','),'ANTIDOTE,POKE_BALL,POTION','another pocket keeps its global acquisition position') + T.eq(Bag.move(s,'POKE_BALL','ITEM',1,g.data),false,'cross-pocket move is refused') + T.eq(Bag.move(s,'MISSING','ITEM',1,g.data),false,'unknown rows never create items') + T.eq(s.inventory.POTION,4,'reorder never changes quantities') + local Rating=require('src.core.Gen2PokedexRating') + s.pokedex={seen={A=true,B=true},owned={A=true}} + local seen,owned=Rating.dexCounts(s) + T.eq(seen,2,'Oak counts the native seen map');T.eq(owned,1,'Oak counts native owned rather than a second caught map') + T.eq(Rating.findOakRating(9).max,9,'rating covers its upper boundary') + T.eq(Rating.findOakRating(10).max,19,'rating advances past the boundary') + local letter=Mail.entry('FLOWER_MAIL','ONE\nTWO','SENDER',123) + s.mail={party={},box={letter}};s.party={{species=T.fixtures.ids.species[1],hp=10,moves={},stats={hp=10}}} + local mailbox=Screens.push(g,'Gen2MailboxMenu');mailbox:attachMail() + local party=g.stack:top();T.eq(party.pickOnly,true,'native mailbox requests actual party selection') + tick(g,'a') + T.eq(g.stack:top(),mailbox,'party selection pops exactly one state') + T.eq(s.party[1].item,'FLOWER_MAIL','attachment retains stationery ID') + T.eq(s.mail.party[1],letter,'attachment retains the exact letter') + T.eq(#s.mail.box,0,'attachment removes only the selected mailbox entry') + mailbox:close();T.eq(g.stack:top(),g.overworld,'mailbox close leaves the underlying world intact') + local pcmon=require('src.ui.Gen2PcMenu').new(g,{saveExists=false,writer=function()return false end}) + s.currentBox=1;pcmon:beginChangeBox(2);pcmon:writeChangeBox() + T.eq(pcmon.saved,false,'failed change-box persistence is reported') + T.eq(s.currentBox,1,'failed write restores the live current-box selection') + local Pokemon=require('src.pokemon.Pokemon') + local species=T.fixtures.ids.species[1] + local function mon()return Pokemon.new(g.data,species,5,function(lo)return lo end)end + local a,b,c=mon(),mon(),mon();s.party={a,b,c};s.mail={party={},box={}} + local box=Screens.build(g,'Gen2BoxMenu',{mode='deposit'}) + box:doDeposit() + T.eq(#s.party,2,'native deposit screen removes one party member') + T.eq(require('src.pokemon.Gen2Boxes').box(s,1)[1],a,'deposit screen stores the exact selected member') + local withdrawal=Screens.build(g,'Gen2BoxMenu',{mode='withdraw'}) + withdrawal:doWithdraw() + T.eq(s.party[3],a,'native withdrawal screen returns the actual stored member') + T.eq(require('src.pokemon.Gen2Boxes').count(s,1),0,'withdraw screen empties the source slot') + local move=Screens.build(g,'Gen2BoxMenu',{mode='move'}) + move.boxIndex=0;move.index=1;move:beginMove() + T.eq(move.phase,'insert','party move admits a valid destination chooser') + move:stepBox(1);move:insertMon() + T.eq(require('src.pokemon.Gen2Boxes').box(s,1)[1],b,'move screen inserts the selected live mon into its destination') + T.eq(#s.party,2,'cross-list move retains party cardinality') + T.eq(s.party[1],c,'cross-list move compacts the party in order') + local alternate={version=version,party={},inventory={},player={}} + local alternatePC=require('src.ui.Gen2PcMenu').new(g,{save=alternate,saveExists=false}) + alternatePC:beginChangeBox(2);alternatePC:writeChangeBox() + T.eq(alternatePC.saved,false,'alternate PC never falls back to the live profile writer') +end +Version.set('red');local g=game('red');local value={} +g.data.screens={Probe={new=function(_,a,b)T.eq(a,11,'build preserves first positional argument');T.eq(b,value,'build preserves trailing argument');return {}end}} +Screens.invalidate();local state=Screens.build(g,'Probe',11,value) +T.eq(g.stack:top(),g.overworld,'registry build leaves stack untouched') +T.eq(state.screenId,'Probe','registry build records resolved identity') +T.eq(Screens.push(g,'Probe',11,value),g.stack:top(),'push uses the same construction policy before insertion') +Version.set(old) +T.finish('native Gen2 PC owners') From 3156e4d83e3573f4e27c1b3ef72bbd465f503305 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:27:41 +0100 Subject: [PATCH 39/68] Retain native world and data context across PC and pack exits --- src/ui/Gen2BoxMenu.lua | 4 ++-- src/ui/Gen2PackMenu.lua | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/Gen2BoxMenu.lua b/src/ui/Gen2BoxMenu.lua index 2ff851ece..be5df9e42 100644 --- a/src/ui/Gen2BoxMenu.lua +++ b/src/ui/Gen2BoxMenu.lua @@ -184,7 +184,7 @@ function BoxMenu.new(game, opts) self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end local data = game and game.data or {} self.pokemon = opts.pokemon or data.pokemon - self.palettes = opts.palettes or data.gen2Palettes + self.palettes = opts.palettes or data.gen2Palettes or data -- EggPic has no data.pokemon row; it rides menu_gfx.eggHatch, and ICON_EGG -- stands in for a cache built before the extractor learned it. self.menuGfx = opts.menuGfx or data.gen2MenuGfx @@ -400,7 +400,7 @@ end -- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu. function BoxMenu:doWithdraw() - local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) + local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index, self.game and self.game.data) if not ok then -- engine/pokemon/bills_pc.asm:1845 self:playRefusalSfx("Sfx_Wrong") diff --git a/src/ui/Gen2PackMenu.lua b/src/ui/Gen2PackMenu.lua index b0edbfdb2..f186fa158 100644 --- a/src/ui/Gen2PackMenu.lua +++ b/src/ui/Gen2PackMenu.lua @@ -192,7 +192,7 @@ function PackMenu.new(game, opts) -- Bag.order / Bag.move read `.items` off an injectable data table, and the -- one this screen draws from is not always the Data singleton. self.bagData = setmetatable({ items = self.items }, {__index=game and game.data or {}}) - self.world = opts.world or (game and game.overworld) + self.world = opts.world or (self.save == (game and game.save) and game.overworld) or {} self.onChoose = opts.onChoose self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end -- DepositSellPack rather than the PACK's own UseItem: a chooser must not run @@ -422,10 +422,10 @@ end function PackMenu:exitToField() self:storeCursor() local stack = self.game and self.game.stack - if stack and stack.clear then - stack:clear() + if stack and stack.pop and stack.top then + while stack:top() and stack:top() ~= self.game.overworld do stack:pop() end -- ../pokecrystal/home/map.asm:1927-1940 - local world = self.game.world + local world = self.game.overworld if world and world.exitMenusFade and not world.mapSetup then world:exitMenusFade() end From 0796574c1fbab772e39c8ef58d2cc93a9e967898 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:58:23 +0100 Subject: [PATCH 40/68] Adapt PC owner dependencies and retain upstream API test scope --- src/core/Gen2CommonText.lua | 2 +- src/core/Gen2PokedexRating.lua | 2 +- src/inventory/Gen2PcItems.lua | 2 +- src/ui/Gen2BoxMenu.lua | 4 +--- src/ui/Gen2CenterPcMenu.lua | 2 +- src/ui/Gen2ItemPcMenu.lua | 2 +- src/ui/Gen2MailRead.lua | 2 +- src/ui/Gen2MailboxMenu.lua | 2 +- src/ui/Gen2PackGfx.lua | 2 +- src/ui/Gen2PackMenu.lua | 2 +- src/ui/Gen2PcMenu.lua | 2 +- src/ui/Gen2WaitPlaySFX.lua | 2 +- src/ui/MenuRepeat.lua | 2 +- tests/engine/gen2_decoration_content.lua | 6 +++--- tests/engine/gen2_pc_owners.lua | 3 ++- 15 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/core/Gen2CommonText.lua b/src/core/Gen2CommonText.lua index d0ccb9ec1..d4b837b61 100644 --- a/src/core/Gen2CommonText.lua +++ b/src/core/Gen2CommonText.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- The strings an ENGINE routine prints, looked up by their pokegold label. -- -- data/generated/text.lua is keyed by "bank:addr", because every string in it diff --git a/src/core/Gen2PokedexRating.lua b/src/core/Gen2PokedexRating.lua index acb655c16..21d93c09d 100644 --- a/src/core/Gen2PokedexRating.lua +++ b/src/core/Gen2PokedexRating.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. local Strings = require("src.core.Strings") local OAK_PC_TEXT = { completion = Strings.source("Current #DEX\ncompletion level:"), diff --git a/src/inventory/Gen2PcItems.lua b/src/inventory/Gen2PcItems.lua index 15e5ce769..4f9ddf475 100644 --- a/src/inventory/Gen2PcItems.lua +++ b/src/inventory/Gen2PcItems.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- ../pokecrystal/engine/events/pokecenter_pc.asm:568 -- ../pokecrystal/engine/items/switch_items.asm:1 diff --git a/src/ui/Gen2BoxMenu.lua b/src/ui/Gen2BoxMenu.lua index be5df9e42..fe4f35753 100644 --- a/src/ui/Gen2BoxMenu.lua +++ b/src/ui/Gen2BoxMenu.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- Bill's PC withdraw / deposit list (engine/pokemon/bills_pc.asm). -- -- The screen is transcribed from the ASM's own coordinates rather than laid @@ -51,12 +51,10 @@ local GameVersion = require("src.core.GameVersion") local GbcPalette = require("src.render.GbcPalette") local Mail = require("src.pokemon.Gen2Mail") local Palettes = require("src.render.PaletteFX") -local PartyMenu = require("src.ui.PartyMenu") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Sprites = require("src.pokemon.Sprites") local Strings = require("src.core.Strings") -local Unown = require("src.compat.gen2.Unown") local BoxMenu = {} BoxMenu.__index = BoxMenu diff --git a/src/ui/Gen2CenterPcMenu.lua b/src/ui/Gen2CenterPcMenu.lua index e1fbb788f..38c2e0ef1 100644 --- a/src/ui/Gen2CenterPcMenu.lua +++ b/src/ui/Gen2CenterPcMenu.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- The Pokemon Center PC's whose-PC menu (engine/events/pokecenter_pc.asm -- PokemonCenterPC). Every Pokecenter reaches it the same way: the PC is a -- COLL_PC tile, the A press runs PCScript (engine/events/std_scripts.asm) and diff --git a/src/ui/Gen2ItemPcMenu.lua b/src/ui/Gen2ItemPcMenu.lua index 12ce13467..3a7bec076 100644 --- a/src/ui/Gen2ItemPcMenu.lua +++ b/src/ui/Gen2ItemPcMenu.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- The player's item PC: _PlayersPC and the three item rows behind it -- (engine/events/pokecenter_pc.asm PlayerWithdrawItemMenu / -- PlayerDepositItemMenu / PlayerTossItemMenu), plus TossItemFromPC diff --git a/src/ui/Gen2MailRead.lua b/src/ui/Gen2MailRead.lua index 542a88ae1..63e014a9f 100644 --- a/src/ui/Gen2MailRead.lua +++ b/src/ui/Gen2MailRead.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- Reading a letter: ReadAnyMail / ReadPartyMonMail (engine/pokemon/mail_2.asm). -- -- The screen is one full-page piece of stationery -- ten of them, one per mail diff --git a/src/ui/Gen2MailboxMenu.lua b/src/ui/Gen2MailboxMenu.lua index 49e613aef..69410f544 100644 --- a/src/ui/Gen2MailboxMenu.lua +++ b/src/ui/Gen2MailboxMenu.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- The MAILBOX in the player's PC: _PlayerMailBoxMenu and MailboxPC -- (engine/pokemon/mail.asm). -- diff --git a/src/ui/Gen2PackGfx.lua b/src/ui/Gen2PackGfx.lua index b04e7632a..0766dd977 100644 --- a/src/ui/Gen2PackGfx.lua +++ b/src/ui/Gen2PackGfx.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- The PACK screen's chrome, drawn from the cart's own tiles. -- -- engine/items/pack.asm builds this screen out of tiles, not rectangles, and diff --git a/src/ui/Gen2PackMenu.lua b/src/ui/Gen2PackMenu.lua index f186fa158..e84f3a315 100644 --- a/src/ui/Gen2PackMenu.lua +++ b/src/ui/Gen2PackMenu.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- Gen 2 PACK: four pockets instead of Gen 1's one bag. -- -- constants/item_data_constants.asm orders them ITEM, KEY_ITEM, BALL, TM_HM, diff --git a/src/ui/Gen2PcMenu.lua b/src/ui/Gen2PcMenu.lua index 7a35633be..3b9a2aabc 100644 --- a/src/ui/Gen2PcMenu.lua +++ b/src/ui/Gen2PcMenu.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- The Pokemon PC's top menu (engine/pokemon/bills_pc_top.asm _BillsPC). -- -- WITHDRAW POKéMON / DEPOSIT POKéMON / CHANGE BOX / diff --git a/src/ui/Gen2WaitPlaySFX.lua b/src/ui/Gen2WaitPlaySFX.lua index 0f49a5303..65b9ed2d0 100644 --- a/src/ui/Gen2WaitPlaySFX.lua +++ b/src/ui/Gen2WaitPlaySFX.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. local WaitPlaySFX = {} function WaitPlaySFX.step(game) diff --git a/src/ui/MenuRepeat.lua b/src/ui/MenuRepeat.lua index 00498b2ae..ce8110bb4 100644 --- a/src/ui/MenuRepeat.lua +++ b/src/ui/MenuRepeat.lua @@ -1,5 +1,5 @@ -- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. --- Existing project rights and notices remain applicable; see NOTICE.md. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. -- home/joypad2.asm:16-53 JoypadLowSensitivity (hJoy7 = 1, 30 then 5) -- pokegold home/joypad.asm:313-340 JoyTextDelay (hInMenu, 15 then 5) diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua index e32f60331..d0f4c1768 100644 --- a/tests/engine/gen2_decoration_content.lua +++ b/tests/engine/gen2_decoration_content.lua @@ -96,15 +96,15 @@ local function choose(g,label) end local function dismiss(g) - local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;box:advance() + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} end for _,version in ipairs({'gold','silver','crystal'})do V.set(version) - for _,mode in ipairs({{1,'native'},{2,'native'},{2,'unified'},{2,'unified'}})do + for _,mode in ipairs({{1,'native'},{2,'native'}})do local d=fixture();local before=d.map_scripts.decorations local r=load(d,[[ local p=mod.content["decorations"] - assert(p:supports() and mod:supports('content','decorations')) + assert(p:supports()) local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) p:patch('deco:2',{sprite=88}) p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) diff --git a/tests/engine/gen2_pc_owners.lua b/tests/engine/gen2_pc_owners.lua index 41692153b..42be48460 100644 --- a/tests/engine/gen2_pc_owners.lua +++ b/tests/engine/gen2_pc_owners.lua @@ -1,4 +1,5 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Actual native owners, registry construction and the shared save stores. package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') From 0f86f7a89a5c01bfaa270a1ca59a83253f4a3ebe Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:03:14 +0100 Subject: [PATCH 41/68] Exercise native PC owners and END confirmation in upstream tests --- tests/engine/gen2_decoration_content.lua | 4 +++- tests/engine/gen2_mail_ui.lua | 6 ++++-- tests/engine/gen2_pc_mail.lua | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua index d0f4c1768..0e6f06550 100644 --- a/tests/engine/gen2_decoration_content.lua +++ b/tests/engine/gen2_decoration_content.lua @@ -90,7 +90,9 @@ local function choose(g,label) end end else - for i,row in ipairs(menu.items or{})do if row.label==label then menu.index=i;menu:choose();return end end + for i,row in ipairs(menu.items or{})do if row.label==label then + menu.index=i;local original=g.input.wasPressed;g.input.wasPressed=function(_,key)return key=='a'end + g.stack:update(0);g.input.wasPressed=original;return end end end error('missing menu choice '..label) end diff --git a/tests/engine/gen2_mail_ui.lua b/tests/engine/gen2_mail_ui.lua index c54c560fc..595126241 100644 --- a/tests/engine/gen2_mail_ui.lua +++ b/tests/engine/gen2_mail_ui.lua @@ -40,7 +40,9 @@ screen=Compose.new(game,{mon=game.save.party[1],item='ITEM_158',onDone=function( game.stack:push(screen);screen.text=string.rep('X',33) T.eq(#screen.glyphs,32,'actual native keyboard caps the message at 32 glyphs') press(screen,'start') -T.eq(completed,1,'START completes once') +T.eq(completed,0,'START selects END before completing the letter') +press(screen,'a') +T.eq(completed,1,'A on END completes once') T.eq(game.stack:top(),parent,'composition closes its own screen exactly once') T.eq(game.save.inventory,inv,'composition preserves live inventory identity') T.eq(game.save.bagOrder,order,'composition preserves live acquisition order identity') @@ -63,7 +65,7 @@ T.eq(game.save.inventory[held],99,'previous held item is returned exactly once') reset() local gone=game.save.party[1] screen=Compose.new(game,{mon=gone,item='ITEM_158'});game.stack:push(screen) -game.save.party[1]=mon();screen.text='STALE';press(screen,'start') +game.save.party[1]=mon();screen.text='STALE';press(screen,'start');press(screen,'a') T.eq(game.save.inventory.ITEM_158,2,'stale keyboard target consumes no stationery') T.eq(game.save.party[1].item,nil,'stale target cannot affect a replacement mon') T.eq(game.save.mail,nil,'stale target creates no letter') diff --git a/tests/engine/gen2_pc_mail.lua b/tests/engine/gen2_pc_mail.lua index b5bdc9d6c..4eb34d3b2 100644 --- a/tests/engine/gen2_pc_mail.lua +++ b/tests/engine/gen2_pc_mail.lua @@ -6,7 +6,7 @@ require('src.core.ModCompat').install() local Mail=require('src.core.gen2.Mail') local Boxes=require('src.core.gen2.Boxes') local Storage=require('src.pokemon.Boxes') -T.eq(require("src.ui.gen2.BoxMenu"),require("src.ui.BoxMenu"),"legacy Gen2 PC namespace resolves the real native factory") +T.eq(require("src.ui.gen2.BoxMenu"),require("src.ui.Gen2BoxMenu"),"legacy Gen2 PC namespace resolves the real native factory") local Pokemon=require('src.pokemon.Pokemon') local Save=require('src.core.SaveData') local data=T.fixtures.fresh() From 4dd65b6f589cc85e27c0f261d777c4d8f03ef72d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:41:37 +0100 Subject: [PATCH 42/68] Read the authoritative native map in save summaries --- src/core/SaveData.lua | 3 ++- tests/engine/native_save_summary.lua | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 24884ad56..c5eda740a 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -982,7 +982,8 @@ function SaveData.summary(save, data) caught = caught, hours = math.floor(seconds / 3600), minutes = math.floor(seconds / 60) % 60, - map = save.position and save.position.map or (type(spawn) == "table" and spawn.map or spawn), + map = save.player and save.player.map or (save.position and save.position.map) + or (type(spawn) == "table" and spawn.map or spawn), } end diff --git a/tests/engine/native_save_summary.lua b/tests/engine/native_save_summary.lua index 6059faa05..c227ad34b 100644 --- a/tests/engine/native_save_summary.lua +++ b/tests/engine/native_save_summary.lua @@ -10,7 +10,7 @@ T.eq(Save.summary(nil),nil,'empty summary has no record') for _,version in ipairs({'gold','silver','crystal'})do V.set(version) local owned={A=true,B=false,C=true} - local save={version=version,player={name='QA'},flags={ZEPHYRBADGE=true,BOULDERBADGE=true}, + local save={version=version,player={name='QA',map='NATIVE_ROOM'},flags={ZEPHYRBADGE=true,BOULDERBADGE=true}, pokedex={owned=owned},inventory={},position={map='ROOM'},playTime=3661.75} local value=Save.summary(save) T.eq(value.name,'QA','summary reads the actual native trainer') @@ -18,7 +18,7 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(value.badges,2,'summary counts both native badge regions') T.eq(value.hours,1,'scalar seconds become whole hours') T.eq(value.minutes,1,'scalar seconds become minute remainder') - T.eq(value.map,'ROOM','summary uses the persisted native map') + T.eq(value.map,'NATIVE_ROOM','summary uses the authoritative native player location before legacy position') T.eq(save.playTime,3661.75,'summary does not rewrite the live clock') T.eq(save.pokedex.owned,owned,'summary retains native map identity') save.playTime={hours=2,minutes=3,seconds=4,frames=30} @@ -28,8 +28,11 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(type(save.playTime),'table','legacy input is read without migration') value.name='CHANGED';T.eq(save.player.name,'QA','summary results are detached') save.playTime=0/0;T.eq(Save.summary(save).hours,0,'invalid play time cannot crash a menu') + save.player.map=nil;T.eq(Save.summary(save).map,'ROOM','legacy position remains readable without a native player location') save.position=nil;save.spawn={map='START'} T.eq(Save.summary(save).map,'START','native spawn records expose their map') + local native=Save.newGame({version=version,startMap='REAL_NATIVE_ROOM'}) + T.eq(Save.summary(native).map,'REAL_NATIVE_ROOM','a real native new-game save exposes its actual map without a facade-shaped position') end V.set(previous) T.finish('native save summary for external menu renderers') From 7bddc5a933ed144443aa6b7bdef0ad7dccbd525c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:42:33 +0100 Subject: [PATCH 43/68] Keep native script callbacks alive while foreground overlays await input --- src/world/OverworldController.lua | 13 ++++- tests/engine/gen2_picbox_script_wait.lua | 60 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_picbox_script_wait.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 53ea8804b..fb6138bc0 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3005,9 +3005,18 @@ end local STUCK_WARN_FRAMES = 360 -- six seconds at sixty local STUCK_GIVE_UP_FRAMES = 720 +local function foregroundOwnsField(world) + local stack = Game and Game.stack + local top = stack and stack.top and stack:top() + return top ~= nil and top ~= world +end + function OverworldState:watchStuckScript() local runner = self.runner - if not runner:isRunning() or self.transitioning + -- PicBox and FadeOverlay deliberately pump the field while they own the + -- foreground. Waiting for their callback is legitimate even though update + -- reaches this watchdog; a player may inspect a starter for any duration. + if foregroundOwnsField(self) or not runner:isRunning() or self.transitioning or runner.waitingFrames or runner.waitingCheck or #self.scriptMoves > 0 or (self.player and self.player.moving) @@ -4282,7 +4291,7 @@ function OverworldState:update(dt) -- it, name the condition that is holding it, once. Ten seconds is far -- past any real cutscene beat in either cartridge and still short enough -- that the line is in the log next to whatever the player last did. - self.lockedFrames = (self.lockedFrames or 0) + 1 + self.lockedFrames = foregroundOwnsField(self) and 0 or (self.lockedFrames or 0) + 1 if self.lockedFrames == 600 then local why = {} if self.runner:isRunning() then diff --git a/tests/engine/gen2_picbox_script_wait.lua b/tests/engine/gen2_picbox_script_wait.lua new file mode 100644 index 000000000..726598fbf --- /dev/null +++ b/tests/engine/gen2_picbox_script_wait.lua @@ -0,0 +1,60 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A visible starter portrait must retain its script beyond watchdog expiry. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local World=require('src.world.OverworldController') +local Stack=require('src.core.StateStack') +local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands') +require('src.script.Gen2Commands') +local PicBox=require('src.ui.PicBox') +local game={data={},save={flags={}},input={wasPressed=function()return false end}} +game.stack=setmetatable({}, {__index=Stack});game.stack:init() +local slot,previous +for i=1,100 do local name,value=debug.getupvalue(World.handleInput,i) + if not name then break end + if name=='Game'then slot=i;previous=value;debug.setupvalue(World.handleInput,i,game);break end +end +assert(slot,'native field game owner') +local world=setmetatable({map={id='ELMS_LAB'},scriptMoves={},player={}}, {__index=World}) +game.overworld=world +world.runner=Runner.new(game,world) +-- Use the real portrait and script owners; replace only rendering/field IO. +world.update=function(self)self:watchStuckScript();self.runner:update()end +local pic=setmetatable({game=game,overworld=world,passive=true},PicBox) +world.pokepicBox=pic +game.stack:push(pic) +local resumed=0 +local old=Commands.qa_portrait_followup +Commands.qa_portrait_followup=function()resumed=resumed+1 end +world.runner:run({{'g2_close_pokepic'},{'qa_portrait_followup'}}) +world.runner:update() +T.eq(world.runner:isRunning(),true,'actual closepokepic yields until user acknowledgment') +T.eq(pic.passive,false,'actual closepokepic arms the portrait') +for _=1,1800 do game.stack:update(1/60)end +T.eq(world.runner:isRunning(),true,'thirty seconds inspecting a visible portrait retains its real runner') +T.eq(world.stuckFrames,nil,'foreground wait does not accumulate stranded-script frames') +T.eq(resumed,0,'foreground wait does not skip remaining story rows') +game.input.wasPressed=function(_,button)return button=='a'end +game.stack:update(1/60) +game.input.wasPressed=function()return false end +world.runner:update() +T.eq(game.stack:top(),nil,'A removes the actual portrait') +T.eq(resumed,1,'A resumes remaining native story exactly once') +T.eq(world.runner:isRunning(),false,'completed portrait script releases its input gate') +-- The watchdog still detects a genuinely stranded native world script. +world.runner.co=coroutine.create(function()coroutine.yield()end) +coroutine.resume(world.runner.co) +world.runner.ctx={};world.runner.waitingFrames=nil;world.runner.waitingCheck=nil +game.stack.worldState=world +for _=1,720 do world:watchStuckScript()end +T.eq(world.runner:isRunning(),false,'persistent bare world still retires a truly stranded runner') +-- Legacy stacks with the world itself on top are not a foreground wait. +world.runner.co=coroutine.create(function()coroutine.yield()end) +coroutine.resume(world.runner.co) +game.stack.states[1]=world -- model the legacy stack without running map-entry IO +for _=1,720 do world:watchStuckScript()end +T.eq(world.runner:isRunning(),false,'legacy world-on-stack watchdog remains active') +Commands.qa_portrait_followup=old +debug.setupvalue(World.handleInput,slot,previous) +T.finish('native portrait foreground waits and stranded-script recovery') From 7167d021fdbf28a8d8c6e3169a5675f4f65fd4c2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:23:34 +0100 Subject: [PATCH 44/68] Route native Gen2 HP beats through the owned presentation callback --- docs/NATIVE-GEN2-HP.md | 32 ++++++ src/battle/BattleState.lua | 8 ++ src/battle/Gen2HpPresentation.lua | 110 +++++++++++++++++++ tests/engine/gen2_hp_presentation.lua | 152 ++++++++++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 docs/NATIVE-GEN2-HP.md create mode 100644 src/battle/Gen2HpPresentation.lua create mode 100644 tests/engine/gen2_hp_presentation.lua diff --git a/docs/NATIVE-GEN2-HP.md b/docs/NATIVE-GEN2-HP.md new file mode 100644 index 000000000..d9fcea9d9 --- /dev/null +++ b/docs/NATIVE-GEN2-HP.md @@ -0,0 +1,32 @@ +# Native Gen2 HP presentation + +The shared battle engine resolves damage and queues the presentation of each +damage or healing beat. Gen2 now dispatches that actual queue row through +`BattleState:stepHpAnim()`, with `hpAnim.side` and `hpAnim.to` describing the +active beat. `shownHp.player` and `shownHp.enemy` read the real battlers' +displayed HP. Writes are admitted only inside that callback, for its actual +side and Pokemon, with finite values within the Pokemon's maximum HP. + +The default callback retains the Gen1Recomp Gen2 implementation's 48-pixel +short/long loops, two-frame pixel cadence and high-HP integer division. A +decorator may choose different display increments for a wider HUD. The native +queue still waits for the actual target and retirement of `hpAnim`; returning +early cannot grant the next battle command. Display writes never mutate the +Pokemon's resolved HP. Queued multi-hit stops and battler switches preserve +their native owners. Gen1 and Gen3 retain their existing drain path. + +`tests/engine/gen2_hp_presentation.lua` exercises the real shared battle class, +queue dispatch, multi-hit floors, healing, fainting, fractional display steps, +invalid writes, callback failure cleanup, battler replacement and the other +generations' unchanged path. `gen2_battle_live_view.lua` continues to check the +native screen/model boundary. These tests do not prove full mod playability, +move animation or capture rendering; those require separate implementation +and gameplay acceptance. This upstream contribution binds the HP view directly +in the native constructor; the fork's additional live battle-model and sprite +capture view is a separate integration. The HP regression stands on its own. + +Pixel stepping adapts Gen1Recomp's `src/battle/gen2/HpBar.lua` and +`src/ui/gen2/BattleState.lua`. Existing upstream and Gen1Recomp rights and +credits remain applicable; see `NOTICE.md`. Native-owner integration and +regressions are contributions by Roxas2712, under `LICENSE-ROXAS2712.md` to the +extent of rights held. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e1cd0a916..0cb680bf5 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1350,6 +1350,7 @@ local function newBattle(game) self.menuIndex = 1 self.moveIndex = 1 self.frame = 0 + if GameVersion.isGen2() then require('src.battle.Gen2HpPresentation').bind(self) end return self end @@ -2469,6 +2470,7 @@ end -- -- Returns true while animating. function BattleState:stepHPDrain() + if GameVersion.isGen2() then return require('src.battle.Gen2HpPresentation').drain(self) end local busy = false for _, b in activeBattlers(self) do if b and b.shownHP then @@ -2510,6 +2512,10 @@ function BattleState:stepHPDrain() return busy end +function BattleState:stepHpAnim() + return require('src.battle.Gen2HpPresentation').step(self) +end + -- AnimateExpBar (engine/battle/experience.asm) creeps the bar to its new -- length a pixel at a time with a tick of sound behind it, instead of -- redrawing it at the new length; the bar drawing itself off the live exp is @@ -2707,6 +2713,7 @@ function BattleState:updateQueue() if self.draining then if self:stepHPDrain() then return true end self.draining = nil + if GameVersion.isGen2() then require('src.battle.Gen2HpPresentation').retire(self) end if self.player then self.player.drainFloor = nil end if self.enemy then self.enemy.drainFloor = nil end end @@ -2764,6 +2771,7 @@ function BattleState:updateQueue() end if item.drain then self.draining = true + if GameVersion.isGen2() then self._gen2HpDrainBattler=item.battler end if item.battler then item.battler.drainFloor = item.stopAt end return true end diff --git a/src/battle/Gen2HpPresentation.lua b/src/battle/Gen2HpPresentation.lua new file mode 100644 index 000000000..2992f0a80 --- /dev/null +++ b/src/battle/Gen2HpPresentation.lua @@ -0,0 +1,110 @@ +-- Native GSC HP presentation over the real shared-engine battlers. +-- Pixel stepping adapts Gen1Recomp src/battle/gen2/HpBar.lua and +-- src/ui/gen2/BattleState.lua; retain their provenance under NOTICE.md. +-- Native-owner integration: Roxas2712 (LICENSE-ROXAS2712.md). +local Hp = {} +local pending = setmetatable({}, {__mode='k'}) +local admissions = setmetatable({}, {__mode='k'}) +local function battler(screen, side) + if side=='player' or side=='enemy' then return screen[side] end +end +local function finite(value) + return type(value)=='number' and value==value and math.abs(value)=256 then product=math.floor(product/4);divisor=math.max(1,math.floor(divisor/4))end + return math.min(48,math.max(1,math.floor(product/divisor))) +end +local function newBar(from,to,maximum) + -- A wide presentation may have left a fractional value when its owner + -- changed. The cartridge loop advances integers and must still terminate. + from=from>to and math.ceil(from) or math.floor(from) + return {hp=from,to=to,maximum=maximum,px=Hp.pixels(from,maximum), + toPx=Hp.pixels(to,maximum),dir=to=48,low=math.min(from,to),high=math.max(from,to)} +end +local function stepBar(bar) + if bar.long then + while bar.hp~=bar.to do + bar.hp=bar.hp+bar.dir + local px=Hp.pixels(bar.hp,bar.maximum) + if px~=bar.px then bar.px=px;return false end + end + return true + end + if bar.px==bar.toPx then return true end + bar.px=bar.px+bar.dir + local hp + if bar.px>=48 then hp=bar.maximum + elseif bar.px<=0 then hp=0 + else hp=math.floor(bar.maximum*bar.px/48)+1;hp=math.max(bar.low,math.min(bar.high,hp))end + bar.hp=hp + return false +end +function Hp.bind(screen) + screen.shownHp=setmetatable({}, { + __index=function(_,side)local b=battler(screen,side);return b and b.shownHP end, + __newindex=function(_,side,value) + local active=admissions[screen];local b=battler(screen,side) + assert(active and active.side==side and b==active.battler and b.mon==active.mon, + 'native Gen2 shown HP is owned by stepHPDrain/stepHpAnim') + local maximum=math.max(1,b.mon.stats.hp) + assert(finite(value) and value>=0 and value<=maximum,'invalid native Gen2 displayed HP') + b.shownHP=value + end, + }) +end +-- This is the actual native display step, called through the public screen +-- method while its queue owns the HP row. It never changes battle damage. +function Hp.step(screen) + local anim=screen.hpAnim;local active=admissions[screen] + if not anim then return false end + assert(active and active.anim==anim,'native Gen2 HP step has no admitted row') + if not anim.bar then + anim.bar=newBar(screen.shownHp[anim.side],anim.to,active.mon.stats.hp) + end + if (anim.hold or 0)>0 then anim.hold=anim.hold-1;return true end + if stepBar(anim.bar)then + screen.shownHp[anim.side]=anim.to;screen.hpAnim=nil;return false + end + screen.shownHp[anim.side]=anim.bar.hp;anim.hold=1;return true +end +local function goalOf(b) + local goal=b.mon.hp + if b.drainFloor and b.drainFloor>goal and b.shownHP>=b.drainFloor then goal=b.drainFloor end + return goal +end +function Hp.drain(screen) + local selected=screen._gen2HpDrainBattler + local side + for _,candidate in ipairs({'player','enemy'})do + local b=battler(screen,candidate) + if b and b.shownHP~=nil and (not selected or b==selected) + and (b.shownHP~=goalOf(b) or pending[screen] and pending[screen].battler==b)then + side=candidate;break + end + end + if not side then screen.hpAnim=nil;pending[screen]=nil;return false end + local b=battler(screen,side);local goal=goalOf(b);local active=pending[screen] + if not active or active.battler~=b or active.mon~=b.mon or active.goal~=goal + or screen.hpAnim~=active.anim then + local anim={side=side,to=goal} + active={side=side,battler=b,mon=b.mon,goal=goal,anim=anim} + pending[screen]=active;screen.hpAnim=anim + end + admissions[screen]=active + local ok,why=pcall(screen.stepHpAnim,screen) + admissions[screen]=nil + if not ok then error(why,0)end + -- A decorator cannot release a queued damage beat before its display has + -- reached that beat's authoritative stop. No second timer or damage loop. + if b.shownHP==goal and screen.hpAnim==nil then pending[screen]=nil;return false end + return true +end +function Hp.retire(screen) + pending[screen],admissions[screen]=nil,nil + screen.hpAnim=nil;screen._gen2HpDrainBattler=nil +end +return Hp diff --git a/tests/engine/gen2_hp_presentation.lua b/tests/engine/gen2_hp_presentation.lua new file mode 100644 index 000000000..dc876db42 --- /dev/null +++ b/tests/engine/gen2_hp_presentation.lua @@ -0,0 +1,152 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Battle = require('src.battle.BattleState') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Hp = require('src.battle.Gen2HpPresentation') +local previous = Version.get() +local function fixture(version) + Version.set(version or 'crystal') + local data = T.fixtures.fresh() + data.constants.gen = Version.generation(Version.get()) + require('src.render.Font').load(data) + local save = Save.newGame() + save.party = {Pokemon.new(data, 'FIXMON_A', 30), Pokemon.new(data, 'FIXMON_B', 18)} + local game = {data=data, save=save, stack={top=function()end}} + local screen = Battle.newWild(game, 'FIXMON_C', 23) + screen.queue, screen.current, screen.waitFrames = {}, nil, nil + return screen +end +local function start(screen, side, from, to, maximum, floor) + local b = screen[side] + b.mon.stats.hp, b.mon.hp, b.shownHP = maximum, to, from + screen:drainNext(b, floor) + T.eq(screen:updateQueue(), true, 'actual native queue dispatches the HP row') + T.eq(screen._gen2HpDrainBattler, b, 'drain row owns its actual battler') + return b +end +local function finish(screen) + local frames = 0 + while screen:updateQueuePresentation() and frames < 2000 do frames=frames+1 end + T.check(frames < 2000, 'native HP queue terminates') + T.eq(screen.hpAnim, nil, 'completed native row retires its presentation') + T.eq(screen._gen2HpDrainBattler, nil, 'completed row releases admission owner') + return frames +end + +T.eq(Hp.pixels(0, 19), 0, 'fainted bar is empty') +T.eq(Hp.pixels(1, 999), 1, 'living bar retains at least one pixel') +T.eq(Hp.pixels(191, 383), 24, 'high-HP one-byte divisor retains cartridge rounding') +T.eq(Hp.pixels(190, 383), 24, 'coarse high-HP divisor differs from exact ratio') + +-- These are actual shared-engine battlers and queue rows, not a second HP +-- controller. The short loop advances pixels even while the number repeats. +do + local s = fixture() + local enemy = start(s, 'enemy', 19, 16, 19) + local trace = {} + for i=1,6 do + T.eq(s:updateQueuePresentation(), true, 'native row holds each pixel beat') + trace[i]=s.shownHp.enemy + end + T.same(trace, {19,19,19,19,18,18}, 'short bar preserves two-frame pixel cadence') + finish(s) + T.eq(enemy.shownHP, 16, 'short bar reaches exact authoritative HP') + T.eq(enemy.mon.hp, 16, 'presentation never changes resolved damage') +end +for _, version in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) do + for _, case in ipairs({{383,0,383}, {0,383,383}, {1,0,19}, {16,19,19}, {255,256,256}}) do + local s=fixture(version) + local b=start(s,'enemy',case[1],case[2],case[3]) + local exp=s.player.mon.exp + finish(s) + T.eq(b.shownHP,case[2],version..' damage/heal/faint reaches exact target') + T.eq(b.mon.hp,case[2],'animation does not resolve another damage command') + T.eq(s.player.mon.exp,exp,'HP presentation cannot award EXP') + end +end + +-- Later damage may already be resolved. Each queued multi-hit beat must stop +-- at its own floor; the other side waits for its own row as well. +do + local s=fixture() + s.player.mon.hp,s.player.shownHP=2,10 + local b=s.enemy + b.mon.stats.hp,b.mon.hp,b.shownHP=19,3,19 + s:drainNext(b,12) + s:drainNext(b,3) + s:drainNext(s.player) + s:updateQueue() + finish(s) + T.eq(b.shownHP,12,'first multi-hit row stops at its queued floor') + T.eq(s.player.shownHP,10,'enemy row cannot drain a later player row') + s:updateQueue();finish(s) + T.eq(b.shownHP,3,'next native row drains remaining multi-hit damage') + s:updateQueue();finish(s) + T.eq(s.player.shownHP,2,'player row owns its own drain') +end + +-- A mod may choose a wider display step only during the real admitted HP +-- callback. Its write changes the display, never the saved Pokemon. +do + local s=fixture() + local b=start(s,'enemy',383,0,383) + local calls=0 + s.stepHpAnim=function(self) + calls=calls+1 + local anim=self.hpAnim + T.eq(anim.side,'enemy','decorator sees the real queued side') + T.eq(anim.to,0,'decorator sees authoritative target') + T.raises(function() self.shownHp.player=0 end,'owned','cannot write another side') + T.raises(function() self.shownHp.enemy=0/0 end,'invalid','cannot write NaN') + T.raises(function() self.shownHp.enemy=384 end,'invalid','cannot exceed actual maximum') + self.shownHp.enemy=math.max(anim.to,self.shownHp.enemy-383/72) + if self.shownHp.enemy==anim.to then self.hpAnim=nil end + return true + end + finish(s) + T.check(calls>=72 and calls<=73,'wide display uses one native callback per frame') + T.eq(b.shownHP,0,'fractional wide display ends exactly') + T.eq(b.mon.hp,0,'wide display cannot undo resolved damage') + T.raises(function() s.shownHp.enemy=1 end,'owned','no write admission after row') +end +do + local s=fixture() + local b=start(s,'enemy',19,3,19) + local native=s.stepHpAnim + s.stepHpAnim=function(self) self.hpAnim=nil;return false end + T.eq(s:updateQueuePresentation(),true,'early decorator return cannot release damage row') + T.eq(b.shownHP,19,'early return did not fabricate display progress') + s.stepHpAnim=function() error('decorator fault') end + T.raises(function() s:updateQueuePresentation() end,'decorator fault','callback error remains visible') + T.raises(function() s.shownHp.enemy=3 end,'owned','error always releases write admission') + s.stepHpAnim=native + b.shownHP=18.5 + finish(s) + T.eq(b.shownHP,3,'returning from fractional display to cartridge stepping terminates') +end +do + local s=fixture() + local outgoing=start(s,'player',19,3,19) + s:updateQueuePresentation() + s:switchPlayerInto(Battle.POS.PLAYER_LEFT,s.game.save.party[2]) + local incoming=s.player.shownHP + finish(s) + T.neq(s.player,outgoing,'native switch replaces the actual battler') + T.eq(s.player.shownHP,incoming,'stale HP row cannot drain the incoming Pokemon') +end +for _,version in ipairs({'red','firered','emerald'}) do + local s=fixture(version) + local called=false + s.stepHpAnim=function() called=true;error('Gen2 callback used by another generation') end + s.enemy.shownHP=s.enemy.mon.hp + s.enemy.mon.hp=s.enemy.mon.hp-3 + local frames=0 + while s:stepHPDrain() and frames<2000 do frames=frames+1 end + T.check(frames>0 and frames<2000,version..' existing native drain completes') + T.eq(called,false,version..' retains its native presentation owner') + T.eq(s.shownHp,nil,version..' gets no Gen2 HP view') +end +Version.set(previous) +T.finish('native Gen2 admitted HP presentation and queue ownership') From 3dc815d3ada0c161f302b092e2b3ca864ae6754a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:25:52 +0100 Subject: [PATCH 45/68] Clarify the independently offered native HP owner scope --- docs/NATIVE-GEN2-HP.md | 3 +-- src/battle/BattleState.lua | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/NATIVE-GEN2-HP.md b/docs/NATIVE-GEN2-HP.md index d9fcea9d9..280a47a37 100644 --- a/docs/NATIVE-GEN2-HP.md +++ b/docs/NATIVE-GEN2-HP.md @@ -18,8 +18,7 @@ their native owners. Gen1 and Gen3 retain their existing drain path. `tests/engine/gen2_hp_presentation.lua` exercises the real shared battle class, queue dispatch, multi-hit floors, healing, fainting, fractional display steps, invalid writes, callback failure cleanup, battler replacement and the other -generations' unchanged path. `gen2_battle_live_view.lua` continues to check the -native screen/model boundary. These tests do not prove full mod playability, +generations' unchanged path. These tests do not prove full mod playability, move animation or capture rendering; those require separate implementation and gameplay acceptance. This upstream contribution binds the HP view directly in the native constructor; the fork's additional live battle-model and sprite diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0cb680bf5..6c52faa21 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2470,6 +2470,7 @@ end -- -- Returns true while animating. function BattleState:stepHPDrain() + -- GSC has its own short/long pixel loop and public display callback. if GameVersion.isGen2() then return require('src.battle.Gen2HpPresentation').drain(self) end local busy = false for _, b in activeBattlers(self) do From 27010e5ea6f5e66092f7480f6ee1cba462aa4c1c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:29:20 +0100 Subject: [PATCH 46/68] Expose the actual native display hold before queue dispatch --- src/battle/BattleState.lua | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6c52faa21..d9ea71aed 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2633,11 +2633,9 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end -function BattleState:updateQueue() - if self.waitingUI then - if self.game.stack:top() ~= self then return true end - self.waitingUI = nil - end +-- One display-only step of the existing native queue hold. It cannot consume +-- commands; updateQueue remains the sole dispatcher after this gate. +function BattleState:updateQueuePresentation() -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2723,6 +2721,15 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end + return false +end + +function BattleState:updateQueue() + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + end + if self:updateQueuePresentation() then return true end if self.animPlaying then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then From fa3704579d77b47f9106d6878a7d1745761c58ce Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:44:24 +0100 Subject: [PATCH 47/68] Render native Gen2 HP pixel beats and cartridge palette boundaries --- docs/NATIVE-GEN2-HP.md | 14 ++++ src/battle/BattleState.lua | 25 ++++++- src/battle/Gen2HpPresentation.lua | 18 +++++ src/render/HudTiles.lua | 43 +++++++---- src/render/PaletteFX.lua | 7 +- tests/engine/gen2_hp_pixels.lua | 117 ++++++++++++++++++++++++++++++ 6 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 tests/engine/gen2_hp_pixels.lua diff --git a/docs/NATIVE-GEN2-HP.md b/docs/NATIVE-GEN2-HP.md index 280a47a37..86a36cd14 100644 --- a/docs/NATIVE-GEN2-HP.md +++ b/docs/NATIVE-GEN2-HP.md @@ -24,6 +24,20 @@ and gameplay acceptance. This upstream contribution binds the HP view directly in the native constructor; the fork's additional live battle-model and sprite capture view is a separate integration. The HP regression stands on its own. +`hudHp` and `hudHpPixels` expose the displayed number and the independent +animated pixel cursor. Both native HUD tile output and its color-zone selection +read that cursor, including short-loop frames whose HP number repeats. Party +and summary bars use GSC's high-HP byte division and half-HP green boundary; +explicit imported hack geometry and other generations retain their defaults. +Changing between a wider decorated HUD and the native step discards a stale +pixel cursor, so returning to the native HUD cannot reverse displayed damage. + +`gen2_hp_pixels.lua` checks actual native HUD tile output, palette boundaries +and display-owner handoff. `tests/gpu/gen2_hp_pixels.lua` reads actual GPU pixels +from the native queue and tile renderer using a generated test atlas in Gold, +Silver and Crystal, with color and grayscale output. It does not establish +whole-mod or final OS-window acceptance. + Pixel stepping adapts Gen1Recomp's `src/battle/gen2/HpBar.lua` and `src/ui/gen2/BattleState.lua`. Existing upstream and Gen1Recomp rights and credits remain applicable; see `NOTICE.md`. Native-owner integration and diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index d9ea71aed..025313110 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2517,6 +2517,14 @@ function BattleState:stepHpAnim() return require('src.battle.Gen2HpPresentation').step(self) end +function BattleState:hudHp(mon, side) + return require('src.battle.Gen2HpPresentation').shown(self, mon, side) +end + +function BattleState:hudHpPixels(mon, side) + return require('src.battle.Gen2HpPresentation').fill(self, mon, side) +end + -- AnimateExpBar (engine/battle/experience.asm) creeps the bar to its new -- length a pixel at a time with a tick of sound behind it, instead of -- redrawing it at the new length; the bar drawing itself off the live exp is @@ -10925,6 +10933,16 @@ local HudTiles = require("src.render.HudTiles") local hudTile = HudTiles.tile local drawHPBar = HudTiles.drawHPBar +local function nativeHpPixels(screen, b, side) + if GameVersion.isGen2() then + -- The public GSC animation owns a 48-pixel bar. Respect a cartridge's + -- wider HUD geometry when writing its actual tile row. + local pixels = screen:hudHpPixels(b.mon, side) + return pixels > 0 and math.max(1, math.floor(pixels + * HudTiles.geometry().hpBarTiles / 6)) or 0 + end +end + -- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile. -- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one -- charmap sequence per glyph, and byte length would push it a tile left. @@ -11930,7 +11948,8 @@ function BattleState:sgbBattlePals() local function bar(b) if not b then return pals.GREENBAR end local hp = b.shownHP or b.mon.hp - return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR + return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, + nativeHpPixels(self, b, b.isPlayer and 'player' or 'enemy'))] or pals.GREENBAR end local function mon(b, placeholder) if placeholder or not b then return pals.MEWMON or pals.GREENBAR end @@ -12562,7 +12581,7 @@ function BattleState:drawHUDs(slide) hudTile(0x73, 8, 16) drawHPBar(barData, 2, 2, { hp = shownHP(self.enemy), stats = self.enemy.mon.stats }, - nil, grayFill) + nil, grayFill, nil, nativeHpPixels(self, self.enemy, 'enemy')) hudTile(0x74, 8, 24) for i = 2, 9 do hudTile(0x76, i * 8, 24) end hudTile(0x78, 80, 24) @@ -12643,7 +12662,7 @@ function BattleState:drawHUDs(slide) end drawHPBar(barData, 10, 9, { hp = shownHP(self.player), stats = self.player.mon.stats }, - 1, grayFill) -- wHPBarType 1: the $6D cap + 1, grayFill, nil, nativeHpPixels(self, self.player, 'player')) -- wHPBarType 1: the $6D cap Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80) -- The block's right edge follows the HP bar: two label tiles from column -- 10, then the segments, then the cap -- column 18 with Crystal's diff --git a/src/battle/Gen2HpPresentation.lua b/src/battle/Gen2HpPresentation.lua index 2992f0a80..a5cefa595 100644 --- a/src/battle/Gen2HpPresentation.lua +++ b/src/battle/Gen2HpPresentation.lua @@ -17,6 +17,17 @@ function Hp.pixels(hp, maximum) if divisor>=256 then product=math.floor(product/4);divisor=math.max(1,math.floor(divisor/4))end return math.min(48,math.max(1,math.floor(product/divisor))) end +function Hp.shown(screen, mon, side) + local shown=screen.shownHp and screen.shownHp[side] + if shown~=nil then return shown end + return mon and mon.hp or 0 +end +function Hp.fill(screen, mon, side) + local anim=screen.hpAnim + if anim and anim.side==side and anim.bar then return anim.bar.px end + local maximum=mon and (mon.maxHp or mon.stats and mon.stats.hp) or 0 + return Hp.pixels(Hp.shown(screen,mon,side),maximum) +end local function newBar(from,to,maximum) -- A wide presentation may have left a fractional value when its owner -- changed. The cartridge loop advances integers and must still terminate. @@ -98,6 +109,13 @@ function Hp.drain(screen) local ok,why=pcall(screen.stepHpAnim,screen) admissions[screen]=nil if not ok then error(why,0)end + local anim=screen.hpAnim + if anim==active.anim and anim.bar and anim.bar.hp~=b.shownHP then + -- A wider HUD decorated this beat after (or instead of) native stepping. + -- Its display number is authoritative for presentation. Discard the old + -- pixel cursor so returning to the cartridge HUD cannot jump backward. + anim.bar,anim.hold=nil,nil + end -- A decorator cannot release a queued damage beat before its display has -- reached that beat's authoritative stop. No second timer or damage loop. if b.shownHP==goal and screen.hpAnim==nil then pending[screen]=nil;return false end diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 8cc908415..ae209c51a 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -72,6 +72,12 @@ local HUD_DEFAULTS = { hpBarYellowPixels = 10, expBarTiles = 8, } +local GSC_DEFAULTS = { + hpBarTiles = 6, + hpBarGreenPixels = 24, + hpBarYellowPixels = 10, + expBarTiles = 8, +} -- ASKED SIX TIMES A ROW. drawHPBar alone calls this twice per bar, and a -- party screen draws six of them a frame -- so this built the same nine-field @@ -79,18 +85,19 @@ local HUD_DEFAULTS = { -- answer that only changes when the font record is reloaded. It is memoised -- on the record it was built from, so a reload rebuilds it and HudTiles. -- invalidate (already registered with Assets) clears it outright. -local geoCache, geoFrom +local geoCache, geoFrom, geoDefaults local function hudGeometry() + local defaults = require('src.core.GameVersion').isGen2() and GSC_DEFAULTS or HUD_DEFAULTS local font = require("src.core.Data").font local hud = type(font) == "table" and font.hud or nil - if type(hud) ~= "table" then return HUD_DEFAULTS end - if geoCache and geoFrom == hud then return geoCache end - geoFrom = hud + if type(hud) ~= "table" then return defaults end + if geoCache and geoFrom == hud and geoDefaults == defaults then return geoCache end + geoFrom, geoDefaults = hud, defaults geoCache = { - hpBarTiles = hud.hpBarTiles or HUD_DEFAULTS.hpBarTiles, - hpBarGreenPixels = hud.hpBarGreenPixels or HUD_DEFAULTS.hpBarGreenPixels, - hpBarYellowPixels = hud.hpBarYellowPixels or HUD_DEFAULTS.hpBarYellowPixels, - expBarTiles = hud.expBarTiles or HUD_DEFAULTS.expBarTiles, + hpBarTiles = hud.hpBarTiles or defaults.hpBarTiles, + hpBarGreenPixels = hud.hpBarGreenPixels or defaults.hpBarGreenPixels, + hpBarYellowPixels = hud.hpBarYellowPixels or defaults.hpBarYellowPixels, + expBarTiles = hud.expBarTiles or defaults.expBarTiles, expBarEmptyTile = hud.expBarEmptyTile, -- the stats screen's page-tile block, and the two tiles in it that close -- the exp bar on that screen (RomExtractorGen2:gen2StatsExpCaps) @@ -250,6 +257,16 @@ function HudTiles.capTile(barType) return barType == 1 and 0x6D or 0x6C end +function HudTiles.hpPixels(hp, maximum, segments) + if maximum <= 0 or hp <= 0 then return 0 end + segments = segments or hudGeometry().hpBarTiles + if require('src.core.GameVersion').isGen2() then + return math.max(1, math.floor(require('src.battle.Gen2HpPresentation').pixels(hp, maximum) + * segments / 6)) + end + return math.max(1, math.floor(hp * segments * 8 / maximum)) +end + -- Tile HP bar (home/pokemon.asm DrawHPBar): "HP" ($71) + ":[" ($62), -- six 8px segments ($63 empty, +n partial, $6B full), then the -- wHPBarType right cap. A nonzero HP always shows at least a @@ -271,15 +288,15 @@ end -- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has -- red channel 0, so the tint zeroes the whole bar's red and the zone's -- red-channel-keyed shade shader then maps every pixel to color 3 = black. -function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments) +function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels) local x, y = tx * 8, ty * 8 segments = math.max(1, math.floor(segments or hudGeometry().hpBarTiles)) HudTiles.tile(0x71, x, y) HudTiles.tile(0x62, x + 8, y) - local px = 0 - if mon.stats.hp > 0 and mon.hp > 0 then - px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp)) - end + -- A short GSC loop moves pixels while its displayed HP number can repeat. + -- Recomputing the fill from that number would hide those animation beats. + local px = pixels ~= nil and math.max(0, math.min(segments * 8, math.floor(pixels))) + or HudTiles.hpPixels(mon.hp, mon.stats.hp, segments) local paint if not grayFill then local PaletteFX = require("src.render.PaletteFX") diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 5cea28f01..aa39bc8a9 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -1083,13 +1083,12 @@ function PaletteFX.spriteObp(spriteDef, seed) end -- GetHPPal (home/hp_pal.asm) on this cartridge's bar. Crystal's is 48 pixels --- wide and turns green at 27 and yellow at 10; a cartridge that widened the +-- wide and turns green at 24 and yellow at 10; a cartridge that widened the -- bar moved both, and reading Prism's 56-pixel bar through Crystal's numbers -- showed yellow between half and 56% health where the cartridge shows green. -function PaletteFX.barPalName(hp, maxHp) +function PaletteFX.barPalName(hp, maxHp, pixels) local geo = require("src.render.HudTiles").geometry() - local px = maxHp > 0 and math.floor(hp * geo.hpBarTiles * 8 / maxHp) or 0 - if hp > 0 and px < 1 then px = 1 end + local px = pixels ~= nil and pixels or require('src.render.HudTiles').hpPixels(hp, maxHp) return px >= geo.hpBarGreenPixels and "GREENBAR" or px >= geo.hpBarYellowPixels and "YELLOWBAR" or "REDBAR" end diff --git a/tests/engine/gen2_hp_pixels.lua b/tests/engine/gen2_hp_pixels.lua new file mode 100644 index 000000000..5be0e452d --- /dev/null +++ b/tests/engine/gen2_hp_pixels.lua @@ -0,0 +1,117 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Data=require('src.core.Data') +local Hud=require('src.render.HudTiles') +local Palette=require('src.render.PaletteFX') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local previous,oldFont=Version.get(),Data.font +local function fixture(version) + Version.set(version) + local data=T.fixtures.fresh();data.constants.gen=Version.generation(version) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local b=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + b.queue,b.current={},nil + b.introSlide,b.introBalls,b.enemySendingOut,b.showEnemyTrainer=0,nil,nil,nil + b.showPlayerBack,b.sendingOut=nil,nil + return b +end +local function tiles(screen) + local actual=Hud.tile;local rows={} + -- Capture only the final native tile output. HP, palette selection and + -- queue stepping are real; no battle or HP implementation is mocked. + Hud.tile=function(code,x,y,_,paint) rows[#rows+1]={code=code,x=x,y=y,paint=paint}end + local ok,why=pcall(screen.drawHUDs,screen,0) + Hud.tile=actual;assert(ok,why) + return rows +end +local function fill(rows,side) + local x,y=side=='enemy' and 32 or 96,side=='enemy' and 16 or 72 + local pixels=0 + for _,row in ipairs(rows)do + if row.y==y and row.x>=x and row.x=0x63 and row.code<=0x6b then + pixels=pixels+row.code-0x63 + end + end + return pixels +end +for _,edition in ipairs({'gold','silver','crystal'})do + local b=fixture(edition) + local mon=b.enemy.mon + mon.stats.hp,mon.hp,b.enemy.shownHP=19,16,19 + b:drainNext(b.enemy);b:updateQueue() + T.eq(b:hudHp(mon,'enemy'),19,'public HUD reads real pre-drain displayed HP') + for i,expected in ipairs({47,47,46,46,45,45})do + b:updateQueuePresentation() + T.eq(b:hudHpPixels(mon,'enemy'),expected,edition..' public pixel view follows real animation') + T.eq(fill(tiles(b),'enemy'),expected,edition..' native HUD writes every short-loop pixel') + if i==1 then T.eq(b:hudHp(mon,'enemy'),19,'number repeats while actual fill moves')end + end + local frames=0 + while b:updateQueuePresentation()and frames<2000 do frames=frames+1 end + T.eq(b:hudHp(mon,'enemy'),16,'retired HUD keeps displayed native target') + T.eq(fill(tiles(b),'enemy'),40,'retired bar uses exact GSC pixel ratio') + mon.stats.hp,mon.hp,b.enemy.shownHP=383,190,190 + T.eq(b:hudHpPixels(mon,'enemy'),24,'public view retains high-HP byte-divisor rounding') + T.eq(fill(tiles(b),'enemy'),24,'native HUD retains high-HP byte-divisor rounding') + T.eq(Palette.barPalName(190,383),'GREENBAR','party/summary and battle agree on GSC half-HP green') + T.eq(Palette.barPalName(9,48),'REDBAR','GSC red ends below ten pixels') + T.eq(Palette.barPalName(10,48),'YELLOWBAR','GSC yellow begins at ten pixels') + T.eq(Palette.barPalName(23,48),'YELLOWBAR','GSC yellow ends below half HP') + T.eq(Palette.barPalName(24,48),'GREENBAR','GSC green begins at half HP') + T.eq(Palette.barPalName(19,19,23),'YELLOWBAR','zone color uses animated pixels while the number repeats') + -- Reading/drawing cannot run damage, spend PP, or advance the HP timeline. + local hp,anim=b.enemy.shownHP,b.hpAnim + tiles(b);tiles(b) + T.eq(b.enemy.shownHP,hp,'draws leave the displayed HP clock alone') + T.eq(b.hpAnim,anim,'draws preserve native animation identity') +end +do + local b=fixture('crystal');local mon=b.enemy.mon + mon.stats.hp,mon.hp,b.enemy.shownHP=19,3,19 + b:drainNext(b.enemy);b:updateQueue();b:updateQueuePresentation() + T.eq(b:hudHpPixels(mon,'enemy'),47,'native short loop starts its actual pixel cursor') + local native=b.stepHpAnim + b.stepHpAnim=function(self) + self.shownHp.enemy=self.shownHp.enemy-2 + return true + end + b:updateQueuePresentation() + T.eq(b:hudHp(mon,'enemy'),17,'wide decorator owns its displayed increment') + T.eq(b.hpAnim.bar,nil,'wide increment retires the stale cartridge pixel cursor') + T.eq(fill(tiles(b),'enemy'),42,'fallback HUD follows the wider displayed value') + b.stepHpAnim=native;b:updateQueuePresentation() + T.check(b:hudHp(mon,'enemy')<=17,'returning to native stepping never reverses damage display') + T.eq(fill(tiles(b),'enemy'),41,'native cursor resumes from the actual wider displayed value') + local frames=0 + while b:updateQueuePresentation()and frames<2000 do frames=frames+1 end + T.check(frames<2000,'display owner handoff still completes its actual queue beat') + T.eq(mon.hp,3,'handoff never changes resolved damage') +end +do + Version.set('crystal') + -- Keep imported hack geometry, including its explicit palette thresholds. + Data.font={hud={hpBarTiles=7,hpBarGreenPixels=28,hpBarYellowPixels=12}} + T.eq(Hud.hpPixels(383,383),56,'wider imported HUD retains all seven full segments') + T.eq(Hud.hpPixels(1,999),1,'wider living bar retains one pixel') + T.eq(Palette.barPalName(190,383),'GREENBAR','explicit hack threshold uses its scaled fill') + T.eq(Hud.geometry().hpBarYellowPixels,12,'explicit imported yellow threshold retained') + -- Changing editions with the SAME font object must not keep GSC defaults. + Data.font={hud={}} + T.eq(Hud.geometry().hpBarGreenPixels,24,'GSC defaults apply without imported override') + Version.set('red') + T.eq(Hud.geometry().hpBarGreenPixels,27,'Gen1 cache switches back to its own defaults') + T.eq(Palette.barPalName(24,48),'YELLOWBAR','Gen1 existing threshold retained') + Version.set('crystal') + T.eq(Hud.geometry().hpBarGreenPixels,24,'returning to GSC restores native defaults') +end +for _,edition in ipairs({'red','firered','emerald'})do + Version.set(edition);Data.font=nil + T.eq(Hud.hpPixels(190,383),23,edition..' existing ratio remains unchanged') + T.eq(Palette.barPalName(24,48),'YELLOWBAR',edition..' existing threshold remains unchanged') +end +Data.font=oldFont;Hud.invalidate();Version.set(previous) +T.finish('native GSC HUD pixel output, palette boundaries and live HP view') From 3637fe5cff6c737446280d9c26e47591055bde5a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:44:46 +0100 Subject: [PATCH 48/68] Exercise real GPU output of native Gen2 HP beats --- tests/gpu/gen2_hp_pixels.lua | 90 ++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/gpu/gen2_hp_pixels.lua diff --git a/tests/gpu/gen2_hp_pixels.lua b/tests/gpu/gen2_hp_pixels.lua new file mode 100644 index 000000000..c1cd2b901 --- /dev/null +++ b/tests/gpu/gen2_hp_pixels.lua @@ -0,0 +1,90 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual native HP queue and HUD tile rendering on each GPU backend. +-- The atlas below is generated test art, not cartridge or mod content. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Data=require('src.core.Data') + local Hud=require('src.render.HudTiles') + local Palette=require('src.render.PaletteFX') + local Hp=require('src.battle.Gen2HpPresentation') + local Battle=require('src.battle.BattleState') + local g=love.graphics + local before={version=Version.get(),font=Data.font,mode=Palette.mode} + local checks,frames=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local colors={GREENBAR={0,189,0},YELLOWBAR={239,181,0},REDBAR={223,37,19}} + local data={palettes={palettes={}}} + for name,color in pairs(colors)do + data.palettes.palettes[name]={{255,255,255},{170,170,170},color,{0,0,0}} + end + local atlas=love.image.newImageData(32*8,8,'rgba8') + for tile=0,31 do + local code=0x62+tile + for y=0,7 do for x=0,7 do + local value=1 + if code>=0x63 and code<=0x6b then + if y==1 or y==5 then value=0 + elseif y>=2 and y<=4 and x Date: Tue, 27 Oct 2026 09:13:43 +0100 Subject: [PATCH 49/68] Expose native Gen2 option window presentation --- src/ui/OptionsMenu.lua | 15 +++++++++++++- tests/engine/gen2_option_window.lua | 32 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/engine/gen2_option_window.lua diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 92c9de220..d3bbd85b5 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -827,11 +827,24 @@ function OptionsMenu:update(dt) #rows, cancelRow) end -function OptionsMenu:draw() +local function drawPanel(self) OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, "CANCEL", #self.rows + 1) end +function OptionsMenu:draw() + return drawPanel(self) +end + +-- Expose the actual window-sized presentation seam for Gen2 providers, +-- retaining the existing native panel as a DPI-aware fallback. +function OptionsMenu:drawsWidescreen() return GameVersion.isGen2() end +function OptionsMenu:wantsFillScale() return GameVersion.isGen2() end +function OptionsMenu:drawWidescreen(width, height) + return require("src.ui.Gen2Chrome").withPanel(width, height, 1, 1, 1, + function() drawPanel(self) end) +end + -- see the note above buildRows: the Gen 3 OPTION screen appends these OptionsMenu.buildRows = buildRows diff --git a/tests/engine/gen2_option_window.lua b/tests/engine/gen2_option_window.lua new file mode 100644 index 000000000..4f828b1bc --- /dev/null +++ b/tests/engine/gen2_option_window.lua @@ -0,0 +1,32 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local before=Version.get() +local Menu=require('src.ui.OptionsMenu') +local UI=require('src.render.ScreenSpaceUI') +local Stack=require('src.core.StateStack') +local Save=require('src.core.SaveData') +require('src.render.Font').load(T.fixtures.fresh()) +for _,edition in ipairs({'gold','silver','crystal','red','emerald'})do + Version.set(edition) + local game={data=T.fixtures.fresh(),save=Save.newGame()} + game.stack=setmetatable({},{__index=Stack});game.stack:init() + local menu=Menu.new(game);game.stack:push(menu) + local gen2=Version.isGen2() + T.eq(menu:drawsWidescreen(),gen2,edition..' window drawing is edition scoped') + T.eq(menu:wantsFillScale(),gen2,edition..' fill contract is edition scoped') + T.eq(UI.select(game.stack,{states={menu}},1),gen2 and menu or nil,edition..' actual compositor selects declared owner') + if gen2 then + local glyphs=0;local draw=love.graphics.draw;local wide=Menu.drawWidescreen + love.graphics.draw=function(...)glyphs=glyphs+1;return draw(...)end + Menu.drawWidescreen=function(self,w,h) + self.windowReceipt={w,h};return wide(self,w,h) + end + UI.draw(menu,{}, {width=800,height=720},nil,false) + love.graphics.draw=draw;Menu.drawWidescreen=wide + T.same(menu.windowReceipt,{800,720},edition..' provider wrapper receives actual window dimensions') + T.check(glyphs>0,edition..' fallback renders native panel glyphs') + end +end +Version.set(before) +T.finish('native Gen2 options window presentation') From bf0539a00f31eedabf13e63ca4f32876bc2aedc6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:30:22 +0100 Subject: [PATCH 50/68] Use the native Gen2 PACK for battle item selection --- src/battle/BattleState.lua | 13 +++++ tests/engine/gen2_battle_pack.lua | 97 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/engine/gen2_battle_pack.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 025313110..36ee79077 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9995,6 +9995,19 @@ function BattleState:openItems() return end self:ui(function() + if GameVersion.isGen2() then + -- Gen2's native PACK owns pockets, battle eligibility and cursor state. + -- It must not route through a generic list skin or a live field world. + local pack + pack = self:buildScreen("Gen2PackMenu", { + battle = true, + world = {}, + onChoose = function(id) + require("src.ui.BagMenu").useItem(self.game, self, id, pack) + end, + }) + return pack + end return self:buildScreen(Gen4Battle.bagScreenId(self) or "BagMenu", { battle = self }) end) end diff --git a/tests/engine/gen2_battle_pack.lua b/tests/engine/gen2_battle_pack.lua new file mode 100644 index 000000000..c6bf9d8ae --- /dev/null +++ b/tests/engine/gen2_battle_pack.lua @@ -0,0 +1,97 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pack=require('src.ui.Gen2PackMenu') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Bag=require('src.inventory.Bag') +local Screens=require('src.ui.Screens') +local oldVersion=V.get() + +local function fixture(version) + V.set(version);Screens.invalidate() + local data=T.fixtures.fresh();data.constants.gen=V.generation() + data.items.POTION={id='POTION',name='POTION',index=18,pocket='ITEM',battleMenu='ITEMMENU_PARTY'} + require('src.render.Font').load(data) + local save=Save.newGame();save.options.battleAnimations=false + -- This older upstream menu branch defaults to Gen1 unlimited enemy PP. + -- Select its existing finite-PP ruleset for the item-turn assertion. + save.options.ruleset="modern_clean" + save.party={Pokemon.new(data,'FIXMON_A',30)} + Bag.add(save,'POTION',1,data) + local input={pressed={}} + function input:wasPressed(key)return self.pressed[key]==true end + function input:isDown()return false end + local stack={states={}} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()return table.remove(self.states)end + local world={useFieldItem=function()error('battle PACK reached field effects')end} + local game={data=data,save=save,input=input,stack=stack,overworld=world,world=world} + local b=Battle.newWild(game,'FIXMON_C',3) + b.phase,b.queue,b.current,b.introSlide,b.sendingOut='menu',{},nil,0,false + b.player.mon.hp=1;b.player.shownHP=1 + b.enemy.mon.moves={{id='FIX_TACKLE',pp=35}};b.enemy.curMoves=b.enemy.mon.moves + stack:push(b) + local function step(key) + input.pressed=key and{[key]=true}or{} + stack:top():update(1/60) + input.pressed={} + end + local function untilTrue(predicate) + for tick=1,8000 do + if predicate()then return true end + local top=stack:top() + local key;if not(top==b and b.phase=='menu')then key='a'end + step(key) + end + return false + end + local function open() + for i,choice in ipairs(b:menuActions())do if choice=='item'then b.menuIndex=i end end -- fixture starts at native ITEM + step('a') + T.check(untilTrue(function()return stack:top()~=b end),'actual ITEM input opens a child screen') + return stack:top() + end + return b,game,step,untilTrue,open +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,step,wait,open=fixture(version) + local pp,enemyPP=b.player.curMoves[1].pp,b.enemy.curMoves[1].pp + local pack=open() + T.eq(getmetatable(pack),Pack,version..' opens the actual native Gen2 PACK controller') + T.eq(pack.screenId,'Gen2PackMenu','native screen identity matches the Gen2 contract') + if getmetatable(pack)==Pack then + T.eq(pack:inBattle(),true,'native PACK uses battle eligibility') + T.neq(pack.world,game.overworld,'battle PACK cannot invoke the live field world') + T.eq(type(pack.onChoose),'function','native PACK has a native item-selection continuation') + T.eq(pack.rows[1].id,'POTION','rows come from the actual native bag') + local pocket=pack.pocketIndex + step('right');T.neq(pack.pocketIndex,pocket,'actual input switches native pockets') + step('left');T.eq(pack.pocketIndex,pocket,'actual input returns to the item pocket') + step('b') + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'native cancel releases the queued UI owner') + T.eq(game.save.inventory.POTION,1,'cancel keeps inventory') + T.eq(b.player.mon.hp,1,'cancel cannot heal or spend a turn') + T.eq(b.player.curMoves[1].pp,pp,'cancel keeps move PP') + pack=open();step('a') -- native USE row + if game.stack:top()==pack and pack.submenu then step('a')end + T.check(game.stack:top()~=pack,'actual USE opens native target selection') + step('a') -- choose the actual first party Pokemon + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'actual item turn returns to the battle owner') + T.eq(game.save.inventory.POTION,nil,'actual healing consumes exactly the owned potion') + T.check(b.player.mon.hp>1,'actual native item effect heals the chosen party Pokemon') + T.eq(b.player.curMoves[1].pp,pp,'item use does not spend a move PP') + T.eq(b.enemy.curMoves[1].pp,enemyPP-1,'item use gives the native enemy exactly one action') + T.eq(game.stack:top(),b,'item/target/message screens leave no orphaned owner') + end +end +for _,version in ipairs({'red','firered','emerald'})do + local b,game,step,wait,open=fixture(version) + local screen=open() + T.neq(getmetatable(screen),Pack,version..' retains its existing bag controller') +end +V.set(oldVersion);Screens.invalidate() +T.finish('native Gen2 battle PACK selection and item turn ownership') From 1fc51225ff64b54289a2344e3ddc0a94184bfe15 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:37:34 +0100 Subject: [PATCH 51/68] Expose live party data and native Gen2 submenu ownership --- src/battle/BattleState.lua | 7 +++- src/ui/PartyMenu.lua | 15 ++++++++- tests/engine/gen2_battle_submenu.lua | 44 ++++++++++++++++++++++++ tests/engine/party_live_data.lua | 50 ++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_battle_submenu.lua create mode 100644 tests/engine/party_live_data.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 36ee79077..bdcaf597a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2736,6 +2736,9 @@ function BattleState:updateQueue() if self.waitingUI then if self.game.stack:top() ~= self then return true end self.waitingUI = nil + if GameVersion.isGen2() and self.phase == "submenu" then + self.phase = "messages" + end end if self:updateQueuePresentation() then return true end if self.animPlaying then @@ -2782,6 +2785,8 @@ function BattleState:updateQueue() end if item.ui then self.waitingUI = true + -- Publish native interactive context before constructing the child. + if GameVersion.isGen2() then self.phase = "submenu" end self.game.stack:push(item.ui()) return true end @@ -4406,7 +4411,7 @@ function BattleState:update(dt) end end - if self.phase == "messages" then + if self.phase == "messages" or (GameVersion.isGen2() and self.phase == "submenu") then -- Nothing queued starts until the silhouettes have finished sliding in: -- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar -- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 00df97b23..fb9caf92f 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -441,7 +441,20 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + -- Public renderers and native input must see the same live party. Wild + -- battles normally use save.party; playerParty exists only for a scoped + -- battle selection (for example a link copy). Leaving this field nil made + -- native input work via its fallback while a presentation saw no Pokemon. + local battleParty = type(opts.battle) == "table" and opts.battle.playerParty + self.party = opts.party or battleParty or (game.save and game.save.party) or {} + if require("src.core.GameVersion").isGen2() then + -- Gen2's native party boundary refreshes the species-name copy, just as + -- its Mon.refreshStats does. Keep nicknames and the actual mon identity. + for _, mon in ipairs(self.party) do + local def = game.data and game.data.pokemon and game.data.pokemon[mon.species] + if def then mon.name = def.name or mon.species end + end + end self.swapFrom = nil self.submenu = nil self.subIndex = 1 diff --git a/tests/engine/gen2_battle_submenu.lua b/tests/engine/gen2_battle_submenu.lua new file mode 100644 index 000000000..a81e80636 --- /dev/null +++ b/tests/engine/gen2_battle_submenu.lua @@ -0,0 +1,44 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal','red','emerald'}do + V.set(version) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',5)} + local stack={states={}} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()return table.remove(self.states)end + local game={data=data,save=save,stack=stack,input={wasPressed=function()return false end,isDown=function()return false end}} + local b=Battle.newWild(game,'FIXMON_C',3) + b.phase,b.queue,b.current,b.introSlide,b.sendingOut='messages',{},nil,0,false + b.afterQueue='menu';stack:push(b) + local before,child,nested,actions + actions=0 + b:ui(function() + before=b.phase;child={};return child + end) + b:act(function()actions=actions+1 end) + b:update(1/60) + T.eq(before,V.isGen2()and'submenu'or'messages',version..' constructor sees the native interactive phase') + T.eq(b.phase,before,'the public phase stays stable while the child owns input') + T.eq(stack:top(),child,'the actual queue pushes its child') + T.eq(actions,0,'queue does not run a battle action during construction') + T.eq(b:updateQueue(),true,'queue remains suspended while the child owns the stack') + T.eq(actions,0,'child input cannot spend the continuation') + nested={};stack:push(nested) + T.eq(b:updateQueue(),true,'nested target picker keeps the same native queue suspended') + stack:pop();T.eq(b:updateQueue(),true,'returning from the nested picker does not resume the battle') + stack:pop() + for _=1,5 do b:update(1/60)end + T.eq(actions,1,'the native continuation resumes exactly once after all children close') + T.eq(b.phase,'menu','the native queue returns to the decision menu') + T.eq(b.waitingUI,nil,'no orphaned UI owner survives cancel/selection') + T.eq(stack:top(),b,'the battle remains the real stack owner') +end +V.set(old) +T.finish('native Gen2 interactive submenu queue ownership') diff --git a/tests/engine/party_live_data.lua b/tests/engine/party_live_data.lua new file mode 100644 index 000000000..8ebd270c4 --- /dev/null +++ b/tests/engine/party_live_data.lua @@ -0,0 +1,50 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local PartyMenu=require('src.ui.PartyMenu') +local Pokemon=require('src.pokemon.Pokemon') +local old=Version.get() +for _,version in ipairs{'red','gold','silver','crystal','prism','polishedcrystal'}do + Version.set(version) + local data=T.fixtures.fresh() + local party={Pokemon.new(data,'FIXMON_A',5),Pokemon.new(data,'FIXMON_B',8)} + local game={data=data,save={party=party},input={pressed={}}} + function game.input:wasPressed(k)return self.pressed[k]==true end + local stack={states={}} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()return table.remove(self.states)end + game.stack=stack + local function press(menu,key)game.input.pressed={[key]=true};menu:update(1/60);game.input.pressed={}end + party[1].name='STALE SPECIES NAME';party[1].nickname='EARNED NAME' + local selected,cancelled + local menu=PartyMenu.new(game,{battle={},pickOnly=true,onSwitch=function(mon)selected=mon end}) + stack:push(menu) + T.eq(menu.party,party,version..' presentation reads the real save party') + if Version.isGen2()then + T.eq(party[1].name,data.pokemon[party[1].species].name,'native Gen2 boundary refreshes the catalog species name') + T.eq(party[1].nickname,'EARNED NAME','catalog refresh preserves earned nickname') + end + T.eq(#(menu.party or{}),2,'presentation cannot show an empty team while input selects a mon') + party[2].hp=1;T.eq(menu.party[2].hp,1,'live HP is not copied or cached') + press(menu,'down');press(menu,'a') + T.eq(selected,party[2],'ordinary native input selects exactly the displayed Pokemon') + T.eq(stack:top(),nil,'selection releases its menu owner') + menu=PartyMenu.new(game,{battle={},onCancel=function()cancelled=true end});stack:push(menu) + local hp,pp=party[1].hp,party[1].moves[1].pp + press(menu,'b');T.check(cancelled,'native cancel continuation fires') + T.eq(party[1].hp,hp,'cancel preserves HP');T.eq(party[1].moves[1].pp,pp,'cancel preserves PP') + local scoped={party[2]} + T.eq(PartyMenu.new(game,{battle={playerParty=scoped}}).party,scoped,'scoped battle copy beats save party') + local explicit={party[1]} + T.eq(PartyMenu.new(game,{battle={playerParty=scoped},party=explicit}).party,explicit,'explicit picker selection beats battle scope') + T.eq(PartyMenu.new(game,{party={}}).party[1],nil,'explicit empty party stays empty') + T.eq(game.save.party,party,'menu never replaces the save party') + T.eq(#party,2,'menu never shrinks or reorders the save party') + local ok,native=pcall(PartyMenu.new,game,{battle=true}) + T.check(ok,'native boolean battle option does not index a boolean') + T.eq(ok and native.party,party,'native boolean battle option reads the live party') + T.eq(#PartyMenu.new({}).party,0,'empty preview has a concrete empty party') +end +Version.set(old) +T.finish('native party presentation and input share the live party') From 09cc4bb3e55b4d215fc295b6085cd7609775aa4d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:55:07 +0100 Subject: [PATCH 52/68] Bind Gen2 presentation to actual native battle Pokemon and extensible model views --- docs/native-gen2-battle-model.md | 16 +++ src/battle/BattleState.lua | 11 +- src/battle/Gen2BattleView.lua | 186 +++++++++++++++++++++++++ tests/engine/gen2_battle_live_view.lua | 138 ++++++++++++++++++ 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-battle-model.md create mode 100644 src/battle/Gen2BattleView.lua create mode 100644 tests/engine/gen2_battle_live_view.lua diff --git a/docs/native-gen2-battle-model.md b/docs/native-gen2-battle-model.md new file mode 100644 index 000000000..52ebe3856 --- /dev/null +++ b/docs/native-gen2-battle-model.md @@ -0,0 +1,16 @@ +# Live native Gen2 battle model + +A Gen2 screen exposes actual current Pokemon, parties, save, catalog and outcome +through a live model view rather than treating the shared battler-wrapper screen +as that model. HP presentation remains on the existing native drain owner. +Pokemon picture captures use their real native slots, source image, crop, status +and effects without replacing a live rear sprite with its isolated front view. + +Native model reader keys remain protected. Presentation consumers may attach and +release transient metadata on the view, without writing to commands or saves. + +Independent checks on this PR composition pass: 366 live-model/HP/sprite-capture, +84 submenu-owner, 111 PACK and 9 FireRed checks (570 total). The new model test +fails before integration. These are ROM-free tests; no unchanged-consumer GPU, +release, full-PR or complete Gen1 parity acceptance is claimed. Existing engine +and Gen1 rights and attribution are retained. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index bdcaf597a..36cfef234 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -37,6 +37,7 @@ local Weather = require("src.battle.Weather") local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") +local Gen2BattleView = require("src.battle.Gen2BattleView") local GameVersion = require("src.core.GameVersion") -- FORWARD-DECLARED, because they are defined next to the other doubles @@ -1191,7 +1192,7 @@ local function newBattle(game) -- that explicit so anything holding "a thing with a .battle" (the party menu -- during a switch, mods walking the state stack) can treat the battle screen -- and the party menu uniformly instead of special-casing this one screen. - self.battle = self + if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end -- BLACK AND WHITE: their move effects named for this engine, their species' -- abilities and their ruleset, before the ruleset below is chosen -- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only) @@ -1350,7 +1351,6 @@ local function newBattle(game) self.menuIndex = 1 self.moveIndex = 1 self.frame = 0 - if GameVersion.isGen2() then require('src.battle.Gen2HpPresentation').bind(self) end return self end @@ -13036,4 +13036,11 @@ function BattleState:drawClassic() end end +Gen2BattleView.install(BattleState, { + load = getImage, + palette = monPalette, + path = imagePathOf, + trueColor = function(image) local meta = imageMeta[image]; return meta and meta.trueColor or false end, +}) + return BattleState diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..e8d893120 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,186 @@ +-- Gen2's public screen/model boundary over the shared native battle engine. +-- The screen owns execution, wrappers and presentation clocks; readers of +-- screen.battle get the actual party Pokemon, including after a queued switch. +local View = {} +local Hp = require('src.battle.Gen2HpPresentation') + +local function partyOf(screen) + return screen.playerParty or screen.game.save.party or {} +end + +local function battler(screen, side) + if side == "player" or side == "enemy" then return screen[side] end +end + +local function indexOf(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +function View.bind(screen) + -- Bind once; callers read the current native catalog without copying a + -- move, caching a definition, or switching the controller's moveset. + local function moveDef(_, move) + if type(move) == "string" then return screen.data.moves[move] end + if move == nil then return nil end + return screen:moveDef(move) + end + local readers = { + moveDef = function() return moveDef end, + player = function() return screen.player and screen.player.mon end, + enemy = function() return screen.enemy and screen.enemy.mon end, + party = function() return partyOf(screen) end, + enemyParty = function() return screen.enemyParty end, + save = function() return screen.game.save end, + data = function() return screen.data end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + over = function() return screen.result ~= nil or screen.dead == true end, + outcome = function() return screen.result end, + battleType = function() return screen.battleType end, + trainer = function() return screen.trainer end, + tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, + contest = function() return screen.contest end, + safari = function() return screen.safari and true or false end, + link = function() return screen.kind == "link" end, + } + screen.battle = setmetatable({}, { + __index = function(_, key) local read = readers[key]; if read then return read() end end, + __newindex = function(model, key, value) + if readers[key] then error("native Gen2 battle view is read-only", 2) end + -- Native combat fields stay owned by the engine. Presentation consumers + -- may attach and release their own transient owners, as on the native + -- Gen2 model; these extensions never write to a battler or save. + rawset(model, key, value) + end, + }) + Hp.bind(screen) +end + +function View.activeMon(screen, side) + local b = battler(screen, side) + return b and b.mon +end + +local function owner(screen, mon) + for _, side in ipairs({ "player", "enemy" }) do + local b = battler(screen, side) + if b and b.mon == mon then return b, side end + end +end + +-- These are drawing operations, not another battle controller. In particular +-- front-art captures of the player keep the REAL battler as the key for blink, +-- substitute, faint and grow-in effects, and never replace its native rear. +function View.install(Screen, images) + Screen.activeMon = View.activeMon + + -- Older/restored native Pokemon need not carry a stamped gender field. + -- Read the existing identity authority rather than changing the live mon + -- merely to paint a HUD. It also owns explicit overrides and PID editions. + function Screen:genderSymbol(mon) + local gender = require("src.pokemon.Pokemon").genderOf(self.data, mon) + if gender == "male" then return "\226\153\130" end + if gender == "female" then return "\226\153\128" end + end + + function Screen:pic(mon, back) + if not mon then return nil end + local b = owner(self, mon) + if b and b.isPlayer == back then + return self:picImage(b.sprite), images.trueColor(b.sprite), images.path(b.sprite) + end + local species = b and b.species or mon.species + if b and species ~= mon.species then + local image = self:speciesSprite(species, back) + return self:picImage(image), images.trueColor(image), images.path(image) + end + local path, trueColor = require("src.pokemon.Sprites").path(self.data, species, + back and "back" or "front", { mon = mon, kind = "battle" }) + local image = images.load(path, images.palette(self.data, species, + require("src.pokemon.Pokemon").isShiny(mon)), trueColor) + return self:picImage(image), trueColor, path + end + + function Screen:picScale(path, mon, back) + return Screen.resolveBattleScale(self.data, back and "back" or "front", + path, mon and mon.species) + end + + function Screen:frontAnimFrame(mon) + local b = owner(self, mon) + -- The shared engine owns the entrance runner. Reading a capture does not + -- advance its clock, restart it, or animate a player's unrelated rear. + if not (b and not b.isPlayer and b.picAnim and b.picAnim:frame() > 0) then return nil end + local image = self:battlerPic(b) + if not image then return nil end + return image, nil, image:getWidth() + end + + function Screen:animPicState(side) + local b = battler(self, side) + if not b then return nil end + local pf = self.picFx and self.picFx[b] + return { + hidden = (side == "enemy" and (self.enemyHidden or self.enemySendingOut)) + or (side == "player" and self.sendingOut) or self:fxHidden(b) + or (b.fainted and not self:fxFaintActive(b)) or (pf and pf.hidden) or false, + pic = b.substituteHP and "substitute" or nil, + slide = pf and pf.ox or 0, + } + end + + function Screen:drawPic(mon, back) + local b, side = owner(self, mon) + if not b then return end + local state = self:animPicState(side) + if state and state.hidden then return end + if (back and self.slidingBackpic) or (self.picHidden and self.picHidden[side]) then return end + local trainer = back and self.showPlayerBack and self.playerBackPic + or (not back and self.showEnemyTrainer and self.trainerPic) + local image, _, path = self:pic(mon, back) + if trainer then image, path = self:picImage(trainer), images.path(trainer) end + if not image then return end + local frame = not trainer and not back and self:frontAnimFrame(mon) + if frame then image = frame end + local scale = self:picScale(path, not trainer and mon or nil, back) + local grow = not trainer and self:growInScale(b) + if grow then scale = scale * grow end + if scale <= 0 then return end + local w, h = image:getDimensions() + local x = (back and 16 or 96) + math.max(0, math.floor(((back and 48 or 56) - w) / 2)) + local y = (back and 96 or 56) - h + x, y = x + math.floor(w * (1 - scale) / 2), y + math.floor(h * (1 - scale)) + if trainer then love.graphics.draw(image, x, y, 0, scale, scale); return end + local screen = self + local capture = setmetatable({ + -- This isolated window owns the clip below. The native effect drawer's + -- rear-side window would erase a front capture of the SAME player. + wideRegion = true, + battlerPic = function() return image end, + -- Sprite extraction must stay a sprite even with native Stadium enabled. + drawStadiumBattlerPic = function() return false end, + drawSubstituteDoll = function(_, actual) + local g = love.graphics + local dx = (back and 32 or 112) - (actual.isPlayer and 32 or 112) + local dy = (back and 72 or 32) - (actual.isPlayer and 72 or 32) + g.push(); g.translate(dx, dy) + local ok, err = pcall(screen.drawSubstituteDoll, screen, actual) + g.pop(); if not ok then error(err, 0) end + end, + }, { __index = self }) + local g = love.graphics + g.push("all") + if g.intersectScissor then + g.intersectScissor(back and 16 or 96, back and 48 or 0, + back and 48 or 56, back and 48 or 56) + end + local ok, err = pcall(capture.drawBattlerPic, capture, b, x, y, scale) + g.pop() + if not ok then error(err, 0) end + end +end + +return View diff --git a/tests/engine/gen2_battle_live_view.lua b/tests/engine/gen2_battle_live_view.lua new file mode 100644 index 000000000..1f6d25801 --- /dev/null +++ b/tests/engine/gen2_battle_live_view.lua @@ -0,0 +1,138 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion') +local Battle = require('src.battle.BattleState') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local before = V.get() +local function fixture(version) + V.set(version) + local data = T.fixtures.fresh(); data.constants.gen = V.generation(version) + require('src.render.Font').load(data) + local save = Save.newGame() + save.party = {Pokemon.new(data, 'FIXMON_A', 30), Pokemon.new(data, 'FIXMON_B', 18)} + local game = {data=data, save=save, stack={top=function()end}} + return Battle.newWild(game, 'FIXMON_C', 23), game +end +for _, version in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) do + local b, game = fixture(version) + local model, hp = rawget(b,'battle'), b.shownHp + T.check(model ~= b, version .. ' publishes a distinct native model view') + T.eq(model.player, game.save.party[1], 'model player is the actual saved Pokemon') + T.eq(model.enemy, b.enemy.mon, 'model enemy is actual generated wild Pokemon') + T.eq(model.enemy.level, 23, 'enemy level is live, not a wrapper default') + T.eq(model.player.level, 30, 'player level is live') + T.eq(model.party, game.save.party, 'no copied party') + T.eq(model.save, game.save, 'actual save owner') + T.eq(model.data, game.data, 'actual data owner') + T.eq(model.wild, true, 'native wild classification') + T.eq(model.playerIndex, 1, 'actual active party index') + local owner = {} + local attached = pcall(function() model._presentationOwner = owner end) + T.check(attached, 'native model admits consumer-owned transient presentation state') + T.eq(model._presentationOwner, owner, 'presentation owner retains identity') + local released = pcall(function() model._presentationOwner = nil end) + T.check(released, 'presentation can release its own owner') + T.eq(model._presentationOwner, nil, 'released metadata does not survive') + T.eq(game.save._presentationOwner, nil, 'presentation metadata never enters the save') + T.eq(b._presentationOwner, nil, 'presentation metadata does not change native command state') + T.raises(function() model.over = true end, 'read-only', 'nil and false native readers stay owned') + T.raises(function() model.moveDef = function()end end, 'read-only', 'extensions cannot replace the catalog authority') + T.eq(b:activeMon('enemy'), model.enemy, 'presentation follows current native slot') + T.eq(b:activeMon('unknown'), nil, 'unknown side is not another active mon') + T.eq(hp.enemy, b.enemy.shownHP, 'shown HP reads native drain') + T.eq(type(model.moveDef),'function',version .. ' exposes the native model definition reader') + if type(model.moveDef)=='function' then + local move=b.player.curMoves[1] + local def=b:moveDef(move) + local reader=model.moveDef + T.eq(model:moveDef(move.id),def,'model ID lookup is the actual native move catalog row') + T.eq(model:moveDef(move),def,'native instance lookup shares the same authority') + T.eq(model.moveDef,reader,'drawing does not allocate a new reader each access') + T.eq(model:moveDef('UNKNOWN_NATIVE_MOVE'),nil,'unknown move has no invented presentation record') + T.eq(model:moveDef(nil),nil,'missing move has no invented presentation record') + T.check(type(def.pp)=='number' and type(def.type)=='string','cards receive actual maximum PP and type') + local pp=move.pp + local replacement={name='LIVE CATALOG',pp=17,type='WATER'} + game.data.moves[move.id]=replacement + T.eq(model:moveDef(move.id),replacement,'catalog updates are live rather than cached at battle entry') + T.eq(move.pp,pp,'metadata reads cannot spend native PP') + game.data.moves[move.id]=def + end + local initial = b.enemy.mon.hp + b:performMove(b.player, b.enemy, {id='FIX_TACKLE',pp=10}) + T.check(b.enemy.mon.hp < initial, 'actual native move reduces HP') + T.eq(model.enemy.hp, b.enemy.mon.hp, 'model exposes resolved HP') + T.eq(hp.enemy, initial, 'presentation retains pre-drain HP') + local frames = 0 + while b:stepHPDrain() and frames < 2000 do + frames=frames+1 + T.eq(hp.enemy, b.enemy.shownHP, 'every displayed HP step is native owned') + end + T.check(frames > 0 and frames < 2000, 'actual drain completes') + T.eq(hp.enemy, model.enemy.hp, 'shown HP reaches actual HP') + local old = b.player + b:switchPlayerInto(Battle.POS.PLAYER_LEFT, game.save.party[2]) + T.eq(b.battle, model, 'model identity survives actual native switch') + T.eq(b.shownHp, hp, 'HP view identity survives switch') + T.eq(model.player, game.save.party[2], 'model follows incoming actual Pokemon') + T.eq(model.playerIndex, 2, 'live party index follows switch') + T.eq(b:activeMon('player'), game.save.party[2], 'drawn mon follows native deployment') + T.eq(hp.player, b.player.shownHP, 'new slot uses its own HP drain') + T.neq(b.player, old, 'wrapper actually replaced') + T.eq(game.save.party[1].shownHP, nil, 'no presentation fields added to saved Pokemon') + T.raises(function() model.player = old.mon end, 'read-only', 'model cannot write a detached Pokemon') + T.raises(function() hp.player = 0 end, 'stepHPDrain', 'view cannot race native HP clock') + b.result='run' + T.eq(model.over, true, 'actual result closes model') + T.eq(model.outcome, 'run', 'actual outcome exposed') +end + +-- The new render boundary must use front art for isolated player captures +-- without changing the native rear, slot identity, or animation ownership. +do + local b = fixture('crystal'); b.introSlide=0; b.sendingOut=false + local original, player = b.player.sprite, b.player + local draws, draw = {}, love.graphics.draw + love.graphics.draw=function(...) draws[#draws+1]={...} end -- output only + b:drawPic(player.mon, false) + T.eq(#draws, 1, 'player front capture draws real native sprite') + local front, _, path = b:pic(player.mon, false) + T.eq(draws[1][1], front, 'front capture uses actual front image') + T.check(path:find('front',1,true) ~= nil, 'front request resolves front asset') + T.eq(b.player, player, 'rendering preserves native battler identity') + T.eq(player.sprite, original, 'native rear image is retained') + T.eq(draws[1][3] + front:getHeight(), 56, 'front feet end at native seven-tile baseline') + draws={}; b:drawPic(player.mon, true) + T.eq(draws[1][1], b:picImage(original), 'native rear remains available') + local pf=b:picFxFor(player);pf.hidden=true + draws={};b:drawPic(player.mon,false) + T.eq(#draws,0,'native vanish effect hides isolated front too') + pf.hidden=nil;b.fx={blink={target=player,frames=10}};b.frame=0 + draws={};b:drawPic(player.mon,false) + T.eq(#draws,0,'native damage blink stays keyed to real player') + b.fx={faint={battler=player,frames=7}};player.fainted=true + draws={};b:drawPic(player.mon,false) + T.eq(#draws,1,'real native faint sink can still draw its remaining rows') + b.fx.faint.frames=0;draws={};b:drawPic(player.mon,false) + T.eq(#draws,0,'completed native faint stays hidden') + player.fainted=nil;b.fx=nil;pf.kind='shakeBF' + local intersect,get=love.graphics.intersectScissor,love.graphics.getScissor + local clips={} + love.graphics.intersectScissor=function(...)clips[#clips+1]={...}end + love.graphics.getScissor=function()end + b:drawPic(player.mon,false) + T.check(#clips>0,'effect capture has an actual bounded picture window') + for _,clip in ipairs(clips)do + T.check(clip[1]>=96,'front player effect is never clipped by native rear-side window') + end + love.graphics.intersectScissor,love.graphics.getScissor=intersect,get + love.graphics.draw=draw +end +for _, version in ipairs({'red','firered','emerald'}) do + local b = fixture(version) + T.eq(b.battle,b,version .. ' retains its existing model identity') + T.eq(b.shownHp,nil,'other generations retain existing HP presentation') +end +V.set(before) +T.finish('native Gen2 live battle model and sprite capture') From 0e6109d2f9436ed8c8c612aada5ff0dc59c7f768 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:24:43 +0100 Subject: [PATCH 53/68] Preserve actual Gen2 summary, page and move-management ownership --- docs/native-gen2-summary-contract.md | 29 +++++ src/ui/Gen2SummaryView.lua | 153 +++++++++++++++++++++++++ src/ui/LevelDisplay.lua | 16 +++ src/ui/SummaryMenu.lua | 112 ++++++++++-------- tests/engine/gen2_summary_contract.lua | 125 ++++++++++++++++++++ 5 files changed, 388 insertions(+), 47 deletions(-) create mode 100644 docs/native-gen2-summary-contract.md create mode 100644 src/ui/Gen2SummaryView.lua create mode 100644 src/ui/LevelDisplay.lua create mode 100644 tests/engine/gen2_summary_contract.lua diff --git a/docs/native-gen2-summary-contract.md b/docs/native-gen2-summary-contract.md new file mode 100644 index 000000000..34af63e38 --- /dev/null +++ b/docs/native-gen2-summary-contract.md @@ -0,0 +1,29 @@ +# Native Gen2 summary and move-screen ownership + +The Gen2 summary alias now accepts native `party`/`index` options as well as +an isolated `mon` and the existing positional shared constructor. It resolves +the actual Pokemon and native catalogs instead of treating an options table +as a Pokemon. Party navigation refreshes pictures and native identity in +place, preserving installed draw methods, surface ownership and callbacks. + +Native status pages retain directional wrapping; A closes the final blue +page. SELECT on the green page opens the real move manager. Moving a slot +reorders the existing move record, preserving PP and PP Ups; cancellation, +egg skipping and close continuations retain their native owners. Species, +move, held-item, type, OT and growth queries read the actual engine data. +The move-detail frame uses native border glyphs rather than requiring Unicode +box characters in the imported text font. No consumer or mod patch is used. + +The ROM-free `tests/engine/gen2_summary_contract.lua` regression fails on the +preceding implementation and passes 321 checks across five Gen2 editions, +with shared Red/FireRed/Emerald navigation regressions. Together with the +existing live battle model, party and FireRed suites, 802 checks pass on this +independently adapted upstream-PR composition. These are source/input +checks with synthetic fixtures, not GPU, full-client, Windows or release +acceptance. Existing GPU evidence remains bound to its earlier source. + +Layout/input provenance: Gen1Recomp `535a5ec0`, +`src/ui/gen2/SummaryMenu.lua`. Existing author rights and notices remain +applicable; integration additions by Roxas2712 follow the contribution terms +published in +. diff --git a/src/ui/Gen2SummaryView.lua b/src/ui/Gen2SummaryView.lua new file mode 100644 index 000000000..efa6ab601 --- /dev/null +++ b/src/ui/Gen2SummaryView.lua @@ -0,0 +1,153 @@ +-- Gen2 status/move-screen contracts over this engine's actual Pokemon. +-- Layout and input follow Gen1Recomp 535a5ec0 src/ui/gen2/SummaryMenu.lua; +-- existing engine/Gen1 rights remain applicable (NOTICE.md). +-- Native-owner integration: Roxas2712 (LICENSE-ROXAS2712.md). +local Font = require('src.render.Font') +local Strings = require('src.core.Strings') +local TypeChart = require('src.battle.TypeChart') +local Pokemon = require('src.pokemon.Pokemon') +local Growth = require('src.pokemon.Growth') +local WaitSfx = require('src.ui.Gen2WaitPlaySFX') +local M = {} +function M.install(Summary) + function Summary:speciesDef() + return self.mon and self.pokemon and self.pokemon[self.mon.species] + end + function Summary:moveDef(id) + if type(id)=='table' then id=id.id end + return id and self.moves and self.moves[id] or nil + end + function Summary:moveList() return self.mon and self.mon.moves or {} end + function Summary:moveName(entry) + local def=entry and self:moveDef(entry.id) + return def and def.name or entry and entry.id + end + function Summary:growth() + local def=self:speciesDef() + return def and self.game.data.growth_rates and self.game.data.growth_rates[def.growthRate] + end + function Summary:expToNext() + local mon,def=self.mon,self:speciesDef() + if not(mon and def)or mon.level>=100 then return 0 end + return math.max(0,Growth.expForLevel(def.growthRate,mon.level+1,self.game.data.growth_rates)-(mon.exp or 0)) + end + function Summary:otName() + return self.mon.ot or self.mon.otName or (self.save and self.save.player or {}).name or 'GOLD' + end + function Summary:otId() return self.mon.otId or (self.save and self.save.player or {}).id or 0 end + function Summary:itemName() + local item=self.mon.item;local def=item and self.items and self.items[item] + return def and def.name or item + end + function Summary:typeNames() + local def=self:speciesDef();local types=self.mon.types or def and def.types or {} + local a,b=types[1],types[2] + return a and TypeChart.displayName(a,self.game.data),b and b~=a and TypeChart.displayName(b,self.game.data)or nil + end + function Summary:close() + if self.closed then return end + self.closed=true + if self.onClose then self.onClose() else self.game.stack:pop() end + end + function Summary:turnPage(delta) self.page=(self.page-1+delta)%3+1 end + function Summary:switchMon(delta) return self:moveMon(delta) end + function Summary:switchMonPastEggs(delta) + local target=self.index+delta + while self.party[target] and require('src.pokemon.Party').isEgg(self.party[target])do target=target+delta end + if not self.party[target]then return false end + return self:moveMon(target-self.index) + end + function Summary:swapMoves(from,to) + local moves=self:moveList() + if from==to or not(moves[from] and moves[to])then return false end + moves[from],moves[to]=moves[to],moves[from] + return true + end + function Summary:playSwapSfx() + local Sound=require('src.core.Sound');local data=self.game.data + local sfx=data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data,'Sfx_SwitchPokemon')]then Sound.play(data,'Sfx_SwitchPokemon')end + end + function Summary:tickRepeatSfx() + if not self.repeatSfx then return false end + if WaitSfx.waiting(self.repeatSfx,self.game)then return true end + self.repeatSfx=nil;self:playSwapSfx();return false + end + function Summary:updateMoveDetail(input) + local moves=self:moveList();local count=math.max(1,#moves) + if input:wasPressed('up')then self.moveIndex=self.moveIndex>1 and self.moveIndex-1 or count + elseif input:wasPressed('down')then self.moveIndex=self.moveIndex'..tostring(mon.level),5+Font.width(name)/8,1) + end + local moves=self:moveList() + for slot=1,4 do + local move=moves[slot];local y=3+(slot-1)*2 + put(move and self:moveName(move)or'-',2,y) + if move then + put(Strings('PP'),10,y+1);put(('%2d/%2d'):format(move.pp or 0,Pokemon.maxPP(self:moveDef(move.id),move)),13,y+1) + else put('--',10,y+1)end + end + put('┌─────┐',0,10);put(self.swapFrom and'│ └'or'│'..Strings('TYPE/')..'└',0,11) + if self.swapFrom then put(Strings('Where?'),1,12);return out end + local move=moves[self.moveIndex];local def=move and self:moveDef(move.id) + put(def and TypeChart.displayName(def.type,self.game.data)or'---',2,12) + put(Strings('ATTACK/'),11,12) + put(def and(def.power or 0)>=2 and('%3d'):format(def.power)or'---',16,12) + local y=14 + for line in ((def and def.description or'')..''):gmatch('(.-)')do + if y>16 then break end + if line~=''then put(line,1,y)end;y=y+2 + end + return out + end + function Summary:drawMoveDetail() + local Chrome=require('src.ui.Gen2Chrome');Chrome.clear() + Chrome.textbox(0,1,18,9);Chrome.textbox(0,11,18,5) + local placements=self:moveDetailPlacements() + -- Name/level and the TYPE plaque deliberately replace border cells. + Chrome.paletteFill(40,8,math.min(120,Font.width(placements[1].text)+32),8) + Chrome.paletteFill(0,80,56,16) + if self.index>1 then Chrome.print('◀',16,0)end + if self.index<#self.party then Chrome.print('▶',18,0)end + for _,v in ipairs(placements)do + -- The native border table owns these cells. A reduced font need not + -- advertise Unicode box characters in its text charmap. + if v.x==0 and v.y==10 then + local b=Font.BORDER;love.graphics.setColor(1,1,1,1) + Font.drawCode(b.tl,0,80) + for x=1,5 do Font.drawCode(b.t or b.h,x*8,80)end + Font.drawCode(b.tr,48,80) + elseif v.x==0 and v.y==11 then + local b=Font.BORDER;love.graphics.setColor(1,1,1,1) + Font.drawCode(b.l or b.v,0,88);Font.drawCode(b.bl,48,88) + if not self.swapFrom then Chrome.print(Strings('TYPE/'),1,11)end + else Chrome.print(v.text,v.x,v.y)end + end + local count=#self:moveList() + if count>0 then + if self.swapFrom then Chrome.cursor(1,3+(self.swapFrom-1)*2,true)end + Chrome.cursor(1,3+(math.min(self.moveIndex,count)-1)*2) + end + end +end +return M diff --git a/src/ui/LevelDisplay.lua b/src/ui/LevelDisplay.lua new file mode 100644 index 000000000..0092e81c8 --- /dev/null +++ b/src/ui/LevelDisplay.lua @@ -0,0 +1,16 @@ +-- Shared level-display predicate for native GB/GBA screens. Only literal +-- false hides the complete level label; callers retain status/egg eligibility. +local Runtime = require("src.mods.Runtime") +local LevelDisplay = {} +local function visibleByDefault() + return true +end + +-- where: "battle.enemy" | "battle.player" | "party" | "summary" +function LevelDisplay.visible(mon, where, game) + if not Runtime.wantsHook("pokemon.level_visible") then + return true + end + return Runtime.call("pokemon.level_visible", visibleByDefault, mon, { where = where, game = game }) ~= false +end +return LevelDisplay diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 090401b37..b465b9694 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -115,29 +115,9 @@ function SummaryMenu:sgbPalettes(game) return out end -function SummaryMenu.new(game, mon, opts) - -- A FALLBACK MUST BE ABLE TO READ WHAT IT IS HANDED. - -- - -- This screen is the end of the line for `Screens.push("SummaryMenu", ...)`: - -- when the Gen 3 or Gen 4 alias declines a push, the arguments arrive here - -- unchanged. But those generations' screens take `(game, opts)` while this - -- one takes `(game, mon, opts)` POSITIONALLY, so a declined push arrives - -- with its options table sitting in `mon` -- `mon.species` nil, - -- `data.pokemon[nil]` nil, and the draw dies on "attempt to index local - -- 'def' (a nil value)" several frames later, miles from the cause. - -- - -- That is exactly what the Gen 4 party menu's SUMMARY row did until the - -- alias learned `readOnlyMoves` (see src/ui/Screens.lua). The routing bug - -- is fixed there; this is the other half, because the `choose` push still - -- falls through here BY DESIGN and a fallback that cannot read its argument - -- is not a fallback. - -- - -- Unwrapped rather than refused: the options table carries the Pokemon in - -- `mon`, which is everything this screen needs. - if type(mon) == "table" and mon.species == nil and type(mon.mon) == "table" then - opts, mon = mon, mon.mon - end - opts = opts or {} +local function loadMon(self) + local game, mon = self.game, self.mon + self.sprite, self.spriteTrueColor, self.picAnim = nil, nil, nil -- status_screen.asm:66-76: StatusScreen recalculates the stat block before -- it draws anything when the mon came from a box or the daycare ("mon is -- in a box or daycare" -> CalcStats), because box_struct carries none. @@ -148,14 +128,6 @@ function SummaryMenu.new(game, mon, opts) -- SaveData.validate has run over a loaded save, but this is the site the -- original recomputes at, and it also covers a mon handed in by a mod. Stats.ensure(game.data.pokemon[mon.species], mon) - local self = setmetatable({ - game = game, - mon = mon, - page = 1, - mons = opts.mons, - monIndex = opts.index, - onMonChange = opts.onMonChange, - }, SummaryMenu) self.isEgg = require("src.pokemon.Party").isEgg(mon) and true or false local Sprites = require("src.pokemon.Sprites") if self.isEgg then @@ -189,6 +161,49 @@ function SummaryMenu.new(game, mon, opts) return self end +function SummaryMenu.new(game, mon, opts) + -- Gen2's native constructor accepts a party/index or an isolated mon. + -- The positional shared signature remains valid for existing callers. + if isGen2() and type(mon) == "table" and mon.species == nil then + opts = mon + local save = opts.save or game.save + local party = opts.party or opts.mons or (save and save.party) or {} + if opts.mon and not (opts.party or opts.mons) then party = { opts.mon } end + local index = math.max(1, math.min(opts.index or 1, math.max(1, #party))) + mon = party[index] + opts = setmetatable({ mons = party, index = index }, { __index = opts }) + elseif type(mon) == "table" and mon.species == nil and type(mon.mon) == "table" then + opts, mon = mon, mon.mon + end + opts = opts or {} + assert(type(mon) == "table" and mon.species, "summary requires an actual Pokemon") + local party = opts.mons or opts.party or { mon } + local index = opts.index + if not index then + for i, candidate in ipairs(party) do if candidate == mon then index = i; break end end + end + index = index or 1 + local self = setmetatable({ + game = game, + mon = mon, + page = isGen2() and math.max(1, math.min(3, opts.page or 1)) or 1, + mons = party, + party = party, + monIndex = index, + index = index, + save = opts.save or game.save, + pokemon = opts.pokemon or game.data.pokemon, + moves = opts.moves or game.data.moves, + items = opts.items or game.data.items, + onMonChange = opts.onMonChange, + onClose = opts.onClose or opts.onCancel, + moveScreen = isGen2() and opts.moveScreen == true, + moveDetail = isGen2() and opts.moveScreen == true, + moveIndex = 1, + }, SummaryMenu) + return loadMon(self) +end + local function sourceIndex(self) local mons = self.mons if type(mons) ~= "table" then return nil end @@ -208,17 +223,11 @@ function SummaryMenu:moveMon(delta) local nextIndex = index + delta if nextIndex < 1 or nextIndex > #self.mons then return false end - local page, screenId = self.page, self.screenId - local fresh = SummaryMenu.new(self.game, self.mons[nextIndex], { - mons = self.mons, - index = nextIndex, - onMonChange = self.onMonChange, - }) - fresh.page = page - fresh.screenId = screenId - - for key in pairs(self) do self[key] = nil end - for key, value in pairs(fresh) do self[key] = value end + -- Refresh native Pokemon resources in place. Presentation methods and + -- callback ownership belong to this instance and must survive navigation. + self.mon, self.monIndex, self.index = self.mons[nextIndex], nextIndex, nextIndex + self.moveIndex = 1 + loadMon(self) if self.onMonChange then self.onMonChange(nextIndex, self.mon) end return true @@ -226,10 +235,12 @@ end function SummaryMenu:update(dt) local input = self.game.input + if isGen2() and self:tickRepeatSfx() then return end + if isGen2() and self.moveDetail then self:updateMoveDetail(input); return end if self.picAnim then self.picAnim:update(dt) end if self.isEgg then if input:wasPressed("a") or input:wasPressed("b") then - self.game.stack:pop() + self:close() elseif isGen2() and input:wasPressed("up") then self:moveMon(-1) elseif isGen2() and input:wasPressed("down") then @@ -241,15 +252,19 @@ function SummaryMenu:update(dt) -- StatsScreen_JoypadAction: LEFT/RIGHT wrap through the three pages, A -- steps forward, B leaves. if input:wasPressed("b") then - self.game.stack:pop() - elseif input:wasPressed("right") or input:wasPressed("a") then - self.page = self.page % 3 + 1 + self:close() + elseif input:wasPressed("a") then + if self.page == 3 then self:close() else self:turnPage(1) end + elseif input:wasPressed("right") then + self:turnPage(1) elseif input:wasPressed("left") then - self.page = (self.page + 1) % 3 + 1 + self:turnPage(-1) elseif input:wasPressed("up") then self:moveMon(-1) elseif input:wasPressed("down") then self:moveMon(1) + elseif input:wasPressed("select") and self.page == 2 then + self.moveDetail, self.moveIndex = true, 1 end return end @@ -524,6 +539,7 @@ function SummaryMenu:drawGen2BluePage() end function SummaryMenu:drawGen2() + if self.moveDetail then return self:drawMoveDetail() end -- The stats pages are white on hardware: _CGB_StatsScreenHPPals wipes the -- attrmap to the HP palette and only the three page squares get a colour. -- There is no page title either -- the squares are the whole indicator. @@ -671,4 +687,6 @@ function SummaryMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +require("src.ui.Gen2SummaryView").install(SummaryMenu) + return SummaryMenu diff --git a/tests/engine/gen2_summary_contract.lua b/tests/engine/gen2_summary_contract.lua new file mode 100644 index 000000000..befb133fd --- /dev/null +++ b/tests/engine/gen2_summary_contract.lua @@ -0,0 +1,125 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screens=require('src.ui.Screens') +local Summary=require('src.ui.SummaryMenu') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +local function fixture(version) + V.set(version);Screens.invalidate() + local data=T.fixtures.fresh();data.constants.gen=V.generation();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',20),Pokemon.new(data,'FIXMON_C',15)} + for _,mon in ipairs(save.party)do mon.moves={{id='FIX_TACKLE',pp=17,ppUps=2},{id='FIX_SCRATCH',pp=13,ppUps=1}}end + local input={pressed={}} + function input:wasPressed(k)return self.pressed[k]==true end + local stack={states={},pops=0} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()self.pops=self.pops+1;return table.remove(self.states)end + local parent={};stack:push(parent) + local game={data=data,save=save,input=input,stack=stack} + local function press(menu,key)input.pressed={[key]=true};menu:update(1/60);input.pressed={}end + return game,press,parent +end +for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local game,press,parent=fixture(version);local party=game.save.party + local closed=0 + local ok,menu=pcall(Screens.push,game,'Gen2SummaryMenu',{party=party,index=2,page=2,onClose=function()closed=closed+1;game.stack:pop()end}) + T.check(ok,version..' actual screen resolver accepts native party/index constructor') + T.eq(ok and menu.mon,party[2],'summary keeps the actual requested Pokemon') + if ok and menu.mon==party[2]then + T.eq(menu.party,party,'public native party is the same real selection') + T.eq(menu.screenId,'Gen2SummaryMenu','native screen resolution retains its public identity') + T.eq(menu.index,2,'public index is actual selected slot') + T.eq(menu.page,2,'native requested page is honored') + T.eq(menu:speciesDef(),game.data.pokemon[party[2].species],'species reader returns the actual catalog row') + T.eq(menu:moveList(),party[2].moves,'move list is not a presentation copy') + T.eq(menu:moveDef('FIX_TACKLE'),game.data.moves.FIX_TACKLE,'native move catalog reader') + T.eq(menu:moveName(party[2].moves[1]),game.data.moves.FIX_TACKLE.name,'move name comes from native catalog') + local tag,draw,wide={},function()return 'consumer-draw'end,function()return 512,288 end + menu.presentationOwner=tag;menu.draw=draw;menu.uiSize=wide + local callback + menu.onMonChange=function(index,mon)callback={index,mon}end + local hp,pp,exp=party[2].hp,party[2].moves[1].pp,party[2].exp + press(menu,'down') + T.eq(menu.mon,party[3],'normal native input changes the actual Pokemon') + T.eq(menu.index,3,'public index advances with native selection') + T.eq(menu.presentationOwner,tag,'Pokemon navigation preserves consumer ownership') + T.eq(menu.draw,draw,'Pokemon navigation preserves installed draw method') + T.eq(menu:draw(),'consumer-draw','retained consumer draw method still executes on the actual instance') + T.eq(menu.uiSize,wide,'Pokemon navigation preserves installed surface owner') + T.eq(menu.onMonChange~=nil,true,'native change continuation survives navigation') + T.eq(callback[1],3,'native change continuation gets current slot') + T.eq(callback[2],party[3],'native change continuation gets actual Pokemon') + T.eq(menu.page,2,'Pokemon change preserves the status page') + press(menu,'down');T.eq(menu.mon,party[3],'native party navigation does not wrap past end') + press(menu,'up');T.eq(menu.mon,party[2],'up returns to actual prior Pokemon') + T.eq(party[2].hp,hp,'browsing cannot heal or damage a Pokemon') + T.eq(party[2].moves[1].pp,pp,'browsing cannot spend PP') + T.eq(party[2].exp,exp,'browsing cannot award EXP') + party[2].item='POTION';game.data.items.POTION={name='NATIVE POTION'} + T.eq(menu:itemName(),'NATIVE POTION','held-item reader uses native catalog') + party[2].ot='NATIVE OT';party[2].otId=9876 + T.eq(menu:otName(),'NATIVE OT','native OT is preserved') + T.eq(menu:otId(),9876,'native OT identifier is preserved') + local def=menu:speciesDef();def.types={'FIRE','FIRE'} + local a,b=menu:typeNames();T.eq(a,require('src.battle.TypeChart').displayName('FIRE',game.data),'type display follows native type registry');T.eq(b,nil,'duplicate second type is suppressed') + def.growthRate='QA_NATIVE_CURVE';game.data.growth_rates=game.data.growth_rates or{} + game.data.growth_rates.QA_NATIVE_CURVE={expForLevel=function(n)return n*1000 end} + T.eq(menu:growth(),game.data.growth_rates.QA_NATIVE_CURVE,'growth reader is the native registry row') + T.eq(menu:expToNext(),math.max(0,21000-party[2].exp),'next EXP reads actual native growth curve') + press(menu,'right');T.eq(menu.page,3,'right advances to blue page') + press(menu,'right');T.eq(menu.page,1,'right wraps after blue page') + press(menu,'left');T.eq(menu.page,3,'left wraps to blue page') + press(menu,'a');T.eq(closed,1,'A on final Gen2 page calls native close once') + T.eq(game.stack:top(),parent,'close callback owns exactly its screen pop') + menu:close();T.eq(closed,1,'repeat close does not call an already-released owner') + local single=Summary.new(game,{mon=party[2]}) + T.eq(single.mon,party[2],'native isolated-mon constructor selects the actual Pokemon') + T.eq(#single.party,1,'isolated summary cannot browse unrelated save Pokemon') + local manager=Screens.push(game,'Gen2SummaryMenu',{party=party,index=1,moveScreen=true}) + T.check(manager.moveDetail,'native MOVE request opens actual move-management mode') + local first,second=party[1].moves[1],party[1].moves[2] + press(manager,'a');T.eq(manager.swapFrom,1,'actual A picks up a move slot') + press(manager,'down');T.eq(manager.moveIndex,2,'actual down chooses the destination') + press(manager,'right');T.eq(manager.mon,party[1],'held move blocks party switching') + press(manager,'a') + T.eq(party[1].moves[1],second,'native swap moves the whole destination slot') + T.eq(party[1].moves[2],first,'native swap retains original PP and PP-Up record identity') + T.eq(first.pp,17,'move rearrangement does not restore or spend PP') + T.eq(first.ppUps,2,'move rearrangement preserves PP Ups') + T.eq(manager.swapFrom,nil,'completed native swap releases its lift owner') + press(manager,'a');press(manager,'up');press(manager,'b') + T.eq(manager.moveIndex,2,'B while holding restores cursor to lifted row') + T.eq(manager.swapFrom,nil,'B cancels only the lifted move') + T.eq(game.stack:top(),manager,'cancelling a lift does not exit the manager') + T.eq(party[1].moves[1],second,'cancel leaves actual move order intact') + party[2].isEgg=true + press(manager,'right');T.eq(manager.mon,party[3],'move-screen native party navigation skips eggs') + press(manager,'right');T.eq(manager.mon,party[3],'move-screen party navigation does not wrap') + press(manager,'left');T.eq(manager.mon,party[1],'move-screen left skips egg in reverse') + local Font=require('src.render.Font');local nativeDraw=Font.draw;local unicodeFrame=false + Font.draw=function(text,...) + for _,edge in ipairs{'┌','─','┐','│','└'}do if tostring(text):find(edge,1,true)then unicodeFrame=true end end + return nativeDraw(text,...) + end + local painted,paintError=pcall(manager.drawMoveDetail,manager);Font.draw=nativeDraw + T.check(painted,'actual native move-screen drawer accepts live slots and descriptions: '..tostring(paintError)) + T.eq(unicodeFrame,false,'native border glyphs do not depend on Unicode text-font coverage') + press(manager,'b');T.eq(game.stack:top(),parent,'B exits native standalone move screen') + local status=Screens.push(game,'Gen2SummaryMenu',{party=party,index=1,page=2}) + press(status,'select');T.check(status.moveDetail,'SELECT enters move details from native green page') + press(status,'b');T.eq(status.moveDetail,false,'B returns from detail to the original status page') + T.eq(game.stack:top(),status,'detail return preserves status screen owner') + press(status,'b');T.eq(game.stack:top(),parent,'next B releases status screen') + end +end +for _,version in ipairs{'red','firered','emerald'}do + local game,press,parent=fixture(version) + local menu=Summary.new(game,game.save.party[1]);game.stack:push(menu) + press(menu,'a');T.eq(menu.page,2,version..' keeps shared two-page navigation') + press(menu,'a');T.eq(game.stack:top(),parent,version..' shared A closes second page') +end +V.set(old);Screens.invalidate() +T.finish('native Gen2 summary constructor, navigation and move-management ownership') From f8948165881fd75b5c26c46c969a8a3cd8b15787 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:47:08 +0100 Subject: [PATCH 54/68] Route native Gen2 move-swap audio through imported cue ownership --- docs/native-gen2-summary-contract.md | 16 ++++++++++++---- src/ui/Gen2SummaryView.lua | 3 ++- tests/engine/gen2_summary_contract.lua | 6 +++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/native-gen2-summary-contract.md b/docs/native-gen2-summary-contract.md index 34af63e38..af2b95b5e 100644 --- a/docs/native-gen2-summary-contract.md +++ b/docs/native-gen2-summary-contract.md @@ -15,12 +15,20 @@ The move-detail frame uses native border glyphs rather than requiring Unicode box characters in the imported text font. No consumer or mod patch is used. The ROM-free `tests/engine/gen2_summary_contract.lua` regression fails on the -preceding implementation and passes 321 checks across five Gen2 editions, +preceding implementation and passes 326 checks across five Gen2 editions, with shared Red/FireRed/Emerald navigation regressions. Together with the -existing live battle model, party and FireRed suites, 802 checks pass on this +existing live-model, party and FireRed suites, 807 checks pass on this independently adapted upstream-PR composition. These are source/input -checks with synthetic fixtures, not GPU, full-client, Windows or release -acceptance. Existing GPU evidence remains bound to its earlier source. +checks with synthetic fixtures, not full-client, Windows or release acceptance. + +A separate fork-only unchanged-client Metal smoke reached the Team and status/move pages, +then exposed an unavailable reference-engine sound resolver during the first +actual move swap. That candidate is rejected. This fork's importer already +installs the native sound label, so the adapted swap now uses this branch’s existing Sound.play +path directly. The regression includes a present imported cue even with no +audio device and fails on the preceding candidate. Visible swap/close/Potion +acceptance for the correction is still pending; the earlier visible pages do +not qualify that complete workflow or the new package. Layout/input provenance: Gen1Recomp `535a5ec0`, `src/ui/gen2/SummaryMenu.lua`. Existing author rights and notices remain diff --git a/src/ui/Gen2SummaryView.lua b/src/ui/Gen2SummaryView.lua index efa6ab601..b758ca853 100644 --- a/src/ui/Gen2SummaryView.lua +++ b/src/ui/Gen2SummaryView.lua @@ -66,7 +66,8 @@ function M.install(Summary) function Summary:playSwapSfx() local Sound=require('src.core.Sound');local data=self.game.data local sfx=data.audio and data.audio.sfx - if sfx and sfx[Sound.resolve(data,'Sfx_SwitchPokemon')]then Sound.play(data,'Sfx_SwitchPokemon')end + -- The importer already installs native labels and shared-role aliases. + if sfx and sfx.Sfx_SwitchPokemon then Sound.play(data,'Sfx_SwitchPokemon')end end function Summary:tickRepeatSfx() if not self.repeatSfx then return false end diff --git a/tests/engine/gen2_summary_contract.lua b/tests/engine/gen2_summary_contract.lua index befb133fd..b4fe906f9 100644 --- a/tests/engine/gen2_summary_contract.lua +++ b/tests/engine/gen2_summary_contract.lua @@ -9,6 +9,9 @@ local old=V.get() local function fixture(version) V.set(version);Screens.invalidate() local data=T.fixtures.fresh();data.constants.gen=V.generation();require('src.render.Font').load(data) + -- Imported audio is present in real sessions. Keep the cue in this device-free fixture too. + data.audio=data.audio or{};data.audio.sfx=data.audio.sfx or{} + data.audio.sfx.Sfx_SwitchPokemon='fixture-native-switch.wav' local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',20),Pokemon.new(data,'FIXMON_C',15)} for _,mon in ipairs(save.party)do mon.moves={{id='FIX_TACKLE',pp=17,ppUps=2},{id='FIX_SCRATCH',pp=13,ppUps=1}}end local input={pressed={}} @@ -84,7 +87,8 @@ for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do press(manager,'a');T.eq(manager.swapFrom,1,'actual A picks up a move slot') press(manager,'down');T.eq(manager.moveIndex,2,'actual down chooses the destination') press(manager,'right');T.eq(manager.mon,party[1],'held move blocks party switching') - press(manager,'a') + local swapped,swapError=pcall(press,manager,'a') + T.check(swapped,'native move swap with an imported sound cue must not call unavailable audio APIs: '..tostring(swapError)) T.eq(party[1].moves[1],second,'native swap moves the whole destination slot') T.eq(party[1].moves[2],first,'native swap retains original PP and PP-Up record identity') T.eq(first.pp,17,'move rearrangement does not restore or spend PP') From 13d1e974126c7826d0fd87845a8b02d3046881af Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:20:47 +0100 Subject: [PATCH 55/68] Keep native Gen2 item targets and medicine text on the actual battle owner --- docs/native-gen2-battle-item-presentation.md | 23 ++++++++++++++++ src/battle/BattleState.lua | 7 ++++- src/ui/BagMenu.lua | 27 +++++++++++++----- tests/engine/gen2_battle_pack.lua | 29 ++++++++++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 docs/native-gen2-battle-item-presentation.md diff --git a/docs/native-gen2-battle-item-presentation.md b/docs/native-gen2-battle-item-presentation.md new file mode 100644 index 000000000..18dcd5c56 --- /dev/null +++ b/docs/native-gen2-battle-item-presentation.md @@ -0,0 +1,23 @@ +# Native Gen2 battle item presentation ownership + +BattlePack must close before constructing a party-item target. Leaving it +underneath the picker makes a resolved presentation see the PACK's boolean +battle flag where the native battle model should be. The Gen2 controller now +closes the actual PACK first and lends its real battle context to the picker. +Cancellation queues a new native PACK, restoring its session pocket/cursor +without consuming inventory, PP or a turn. + +Successful medicine and no-effect refusals now use the existing native battle +text queue. A standalone field TextBox has no battlefield composition owner +and produced a white backing over the decorated battle. Effects still run +through BagMenu's existing item-use dispatch; success delegates to itemUsed +once, while refusal queues text without a native enemy action. Other +cartridges keep their existing field-text path. + +The updated native PACK regression fails 35 assertions on the preceding +implementation and passes 191 after the change. Live native model, submenu, summary and FireRed +regressions also pass on this independently adapted upstream-PR composition +(976 checks together). These ROM-free source/input +checks do not establish unchanged-client GPU, full Gen1 visual/story parity +or final package acceptance. Native party-result animation/timing and broader +item-family presentation remain subject to reference acceptance. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 36cfef234..dd4dd8a5a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10008,7 +10008,12 @@ function BattleState:openItems() battle = true, world = {}, onChoose = function(id) - require("src.ui.BagMenu").useItem(self.game, self, id, pack) + -- Close BattlePack before target construction: the actual caller is + -- the battle model, not the PACK's boolean eligibility flag. + pack:close() + require("src.ui.BagMenu").useItem(self.game, self, id, pack, { + onCancel = function() self:openItems() end, + }) end, }) return pack diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 09ecee818..debf151cf 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -743,7 +743,12 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end if battle then list:close() - showMessages(game, payload, function() battle:itemUsed({}) end) + if require("src.core.GameVersion").isGen2() then + -- The native battle queue owns this message and its composition. + battle:itemUsed(payload) + else + showMessages(game, payload, function() battle:itemUsed({}) end) + end else showMessages(game, payload, closePicker) end @@ -752,10 +757,16 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) -- .healingItemNoEffect prints over the still-drawn party menu too, so the -- refusal closes the picker the same way (#252) - showMessages(game, payload, closePicker) -- failed + if battle and require("src.core.GameVersion").isGen2() then + closePicker() + list:close() + for _, message in ipairs(payload or {}) do battle:say(message) end + else + showMessages(game, payload, closePicker) -- failed + end end -local function pickTargetAndUse(game, battle, id, list) +local function pickTargetAndUse(game, battle, id, list, options) -- pick a target from the party -- the ETHERs and PP UP open the move menu after picking a mon -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move @@ -764,6 +775,8 @@ local function pickTargetAndUse(game, battle, id, list) local wantsMove = key == "ETHER" or key == "MAX_ETHER" or key == "PP_UP" local opts = { pickOnly = true, + battle = require("src.core.GameVersion").isGen2() and battle or nil, + onCancel = options and options.onCancel, -- HP medicine animates its bar with the picker still up (#252). Only -- out of battle: the in-battle tail closes the bag list underneath -- first, which needs the picker already gone. @@ -854,7 +867,7 @@ local BAG_TEXT_BOOTED_TM = 58 local BAG_TEXT_BOOTED_HM = 59 local BAG_TEXT_CONTAINED = 60 -local function useItem(game, battle, id, list) +local function useItem(game, battle, id, list, options) -- HGSS: the Escape Rope / Water Stone at a Ruins of Alph chamber wall -- (src/world/HgssAlphChambers.lua) runs the chamber's script instead if not battle and game.data and game.data.isGen4Cache @@ -937,7 +950,7 @@ local function useItem(game, battle, id, list) -- which is where "Teach {MOVE} to a POKeMON?" died. game.stack:push(TextBox2.new(game, asks, nil, { choice = function(yes) - if yes then pickTargetAndUse(game, battle, id, list) end + if yes then pickTargetAndUse(game, battle, id, list, options) end end, })) end @@ -947,10 +960,10 @@ local function useItem(game, battle, id, list) local booted = def.machine.kind == "HM" and "Booted up an HM!" or Strings("Booted up a TM!") showMessages(game, { booted, Strings("It contained\n%s!", moveName) }, - function() pickTargetAndUse(game, battle, id, list) end) + function() pickTargetAndUse(game, battle, id, list, options) end) return end - pickTargetAndUse(game, battle, id, list) + pickTargetAndUse(game, battle, id, list, options) else useOn(game, battle, id, nil, list) end diff --git a/tests/engine/gen2_battle_pack.lua b/tests/engine/gen2_battle_pack.lua index c6bf9d8ae..860e211e3 100644 --- a/tests/engine/gen2_battle_pack.lua +++ b/tests/engine/gen2_battle_pack.lua @@ -79,7 +79,25 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do pack=open();step('a') -- native USE row if game.stack:top()==pack and pack.submenu then step('a')end T.check(game.stack:top()~=pack,'actual USE opens native target selection') + local picker=game.stack:top() + T.eq(game.stack.states[#game.stack.states-1],b,'target is built directly over the actual native battle') + T.eq(picker.battle,b,'target receives the actual battle context') + T.eq(pack.closed,true,'native PACK has released its owner before target selection') + local pocket=pack.pocketIndex + step('b') + T.check(wait(function()return game.stack:top()~=b end),'target cancellation reopens native PACK') + pack=game.stack:top() + T.eq(getmetatable(pack),Pack,'cancel constructs the actual native PACK again') + T.eq(pack.pocketIndex,pocket,'cancel preserves the native pocket cursor') + T.eq(game.save.inventory.POTION,1,'target cancellation consumes no item') + T.eq(b.player.mon.hp,1,'target cancellation changes no HP') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'target cancellation grants no enemy action') + step('a');if game.stack:top()==pack and pack.submenu then step('a')end step('a') -- choose the actual first party Pokemon + T.eq(game.stack:top(),b,'successful medicine returns presentation to the native battle, not a field TextBox') + local queuedText=false + for _,entry in ipairs(b.queue)do if entry.text then queuedText=true end end + T.check(queuedText,'actual medicine text is owned by the native battle queue') T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'actual item turn returns to the battle owner') T.eq(game.save.inventory.POTION,nil,'actual healing consumes exactly the owned potion') T.check(b.player.mon.hp>1,'actual native item effect heals the chosen party Pokemon') @@ -88,6 +106,17 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.eq(game.stack:top(),b,'item/target/message screens leave no orphaned owner') end end +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,step,wait,open=fixture(version) + b.player.mon.hp=b.player.mon.stats.hp;b.player.shownHP=b.player.mon.hp + local enemyPP=b.enemy.curMoves[1].pp + local pack=open();step('a');if game.stack:top()==pack and pack.submenu then step('a')end + step('a') + T.eq(game.stack:top(),b,version..' no-effect medicine uses the actual battle text owner') + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'no-effect medicine returns to native commands') + T.eq(game.save.inventory.POTION,1,'no-effect medicine preserves inventory') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'no-effect medicine grants no enemy action') +end for _,version in ipairs({'red','firered','emerald'})do local b,game,step,wait,open=fixture(version) local screen=open() From 1dc6158f214dafc56bba501d872eb55adc789243 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:55:24 +0100 Subject: [PATCH 56/68] Publish native Gen2 resolving and message fields from the actual queue --- .../workflows/gen2-queue-screen-contract.yml | 25 +++ docs/native-gen2-queue-screen.md | 19 +++ src/battle/BattleQueuePhase.lua | 15 ++ src/battle/BattleState.lua | 87 +++++++---- src/battle/WideBattle.lua | 3 +- src/link/LinkBattle.lua | 21 +-- src/ui/BagMenu.lua | 3 +- tests/engine/gen2_battle_queue_view.lua | 147 ++++++++++++++++++ 8 files changed, 276 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/gen2-queue-screen-contract.yml create mode 100644 docs/native-gen2-queue-screen.md create mode 100644 src/battle/BattleQueuePhase.lua create mode 100644 tests/engine/gen2_battle_queue_view.lua diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml new file mode 100644 index 000000000..e98532762 --- /dev/null +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -0,0 +1,25 @@ +name: native Gen2 queue screen contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen2-queue-screen-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Install ROM-free runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native queue, screen and available menu owners + run: | + luajit tests/engine/gen2_battle_queue_view.lua + luajit tests/engine/gen2_battle_pack.lua + luajit tests/engine/gen2_battle_live_view.lua + luajit tests/engine/gen2_battle_submenu.lua + luajit tests/engine/gen2_summary_contract.lua + luajit tests/engine/frlg_battle_gender.lua diff --git a/docs/native-gen2-queue-screen.md b/docs/native-gen2-queue-screen.md new file mode 100644 index 000000000..f5497bffe --- /dev/null +++ b/docs/native-gen2-queue-screen.md @@ -0,0 +1,19 @@ +# Native Gen2 resolution/message screen contract + +Gen2 queue writers publish `resolving`, retaining `messages` for other editions. +The native queue accepts earlier Gen2 records and publishes its actual current +text through `message` and its manual acknowledgment hold through `messageTimer`. +The existing typer, prompt delays, choice callback and queue remain authoritative. +Decision boundaries clear the text; classic/wide composition and link completion +recognize the same queue predicate. No mod or game data is included. + +The screen contract was checked against Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/ui/gen2/BattleState.lua`. Existing project rights and notices remain applicable. + +`tests/engine/gen2_battle_queue_view.lua` exercises actual native refusal, +manual text advancement, legacy queued children and delayed YES/NO ownership +across five Gen2 editions, with Red/FireRed/Emerald regressions. This independent +upstream adaptation uses only its available native modules. It does not imply +a new client/GPU check, whole-PR acceptance or complete event/page/animation API +parity. The separately observed white view still needs causal GPU diagnosis. diff --git a/src/battle/BattleQueuePhase.lua b/src/battle/BattleQueuePhase.lua new file mode 100644 index 000000000..e9925582c --- /dev/null +++ b/src/battle/BattleQueuePhase.lua @@ -0,0 +1,15 @@ +-- One native queue, with the public Gen2 resolution phase. Earlier shared +-- checkpoints retain their stored spelling until the next native update. +local Version = require("src.core.GameVersion") +local M = {} + +function M.name() + return Version.isGen2() and "resolving" or "messages" +end + +function M.active(screen) + return screen.phase == "messages" + or (screen.phase == "resolving" and Version.isGen2()) +end + +return M diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index dd4dd8a5a..81dc8858e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10,6 +10,7 @@ -- bide, recharge, confusion, screens, substitute, transform, ...) is -- ported from engine/battle/core.asm; see docs/behavior-porting-notes.md. +local BattleQueuePhase = require("src.battle.BattleQueuePhase") local Assets = require("src.render.Assets") local Catching = require("src.battle.Catching") local Damage = require("src.battle.Damage") @@ -1347,6 +1348,7 @@ local function newBattle(game) end end self.queue = {} + if GameVersion.isGen2() then self.messageTimer = 0 end self.phase = "intro" self.menuIndex = 1 self.moveIndex = 1 @@ -2587,6 +2589,11 @@ local function shownHP(b) return math.floor(shown) end +-- Release native text at the decision owned by this queue. +function BattleState:clearQueueMessage() + if GameVersion.isGen2() then self.message, self.messageTimer = nil, 0 end +end + -- Parse a battle message into its rendered lines. The extractor marks -- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking -- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted / @@ -2598,6 +2605,10 @@ end -- instead of drawing it off-screen at y=144 (#216). function BattleState:startMessage(item) self.current = item + if GameVersion.isGen2() then + -- The existing native typer/prompt owns this hold; no second clock. + self.message, self.messageTimer = item.text or "", 48 + end self.lines = {} self.total = 0 local text = item.text or "" @@ -2733,11 +2744,18 @@ function BattleState:updateQueuePresentation() end function BattleState:updateQueue() + -- Resume the earlier shared queue spelling at its actual native owner. + if GameVersion.isGen2() and self.phase == "messages" then + self.phase = BattleQueuePhase.name() + if self.current then + self.message, self.messageTimer = self.current.text or "", 48 + end + end if self.waitingUI then if self.game.stack:top() ~= self then return true end self.waitingUI = nil if GameVersion.isGen2() and self.phase == "submenu" then - self.phase = "messages" + self.phase = BattleQueuePhase.name() end end if self:updateQueuePresentation() then return true end @@ -3073,6 +3091,7 @@ function BattleState:updateQueue() self.game.stack:push(ChoiceBox.new(self.game, function(yes) local fn = item.choice battle.current = nil + if GameVersion.isGen2() then battle.messageTimer = 0 end fn(yes) end)) return true @@ -3098,6 +3117,7 @@ function BattleState:updateQueue() elseif input:wasPressed("a") or input:wasPressed("b") then self.msgPrompt = nil self.current = nil + if GameVersion.isGen2() then self.messageTimer = 0 end end end end @@ -3920,7 +3940,7 @@ function BattleState:enter() end) self:markParticipant() end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:syncSides() -- THE FIRST SEND-OUT COUNTS TOO. ABILITYEFFECT_ON_SWITCHIN runs the @@ -4411,7 +4431,7 @@ function BattleState:update(dt) end end - if self.phase == "messages" or (GameVersion.isGen2() and self.phase == "submenu") then + if BattleQueuePhase.active(self) or (GameVersion.isGen2() and self.phase == "submenu") then -- Nothing queued starts until the silhouettes have finished sliding in: -- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar -- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy @@ -4424,6 +4444,7 @@ function BattleState:update(dt) if not self:updateQueue() then if self.afterQueue == "menu" then self.phase = "menu" + self:clearQueueMessage() elseif self.afterQueue == "finish" then self:finish() end @@ -4499,7 +4520,7 @@ function BattleState:update(dt) if self.phase == "menu" and self.safari then if self.safari.balls <= 0 then self:say(Strings("PA: You're out of\nSAFARI BALLs!\nGame over!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.result = "run" self.afterQueue = "finish" return @@ -4620,7 +4641,7 @@ function BattleState:update(dt) end if choice == "fight" and self.ghost then self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) @@ -4688,7 +4709,7 @@ function BattleState:update(dt) local mvDef = mv and self.data.moves[mv.id] if chooser.disabledSlot == self.moveIndex then self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- TAUNT and TORMENT refuse the move at SELECTION rather than after the -- fact, the way DISABLE already does, so the player is never told to @@ -4696,26 +4717,26 @@ function BattleState:update(dt) elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif self:tauntBlocks(chooser, mvDef) then self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif self:tormentBlocks(chooser, mvDef) then self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then -- PLATINUM / HGSS: Gravity and Heal Block (BattleSystem_CanUseMove) self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif mv.pp <= 0 then self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then -- WHICH OF THE TWO, when the move takes one and there are two. @@ -4783,7 +4804,7 @@ function BattleState:update(dt) local pick = moves[self.mimicIndex] local ctx = self.mimicCtx self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.nextInsert = 0 -- the copy's anim + text go to the queue head self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) end @@ -4924,7 +4945,7 @@ function BattleState:gen3TutorialBag() return true end if require("src.core.GameVersion").isGen4() then - self.phase, self.afterQueue = "messages", "menu" + self.phase, self.afterQueue = BattleQueuePhase.name(), "menu" self:ui(function() local bag = require("src.ui.Gen4BagMenu").new(self.game, { pocket = "POKé BALLS" }) bag.rows = { { id = 4, label = "POKé BALL", qty = 20 } } @@ -4946,7 +4967,7 @@ function BattleState:gen3TutorialBag() return false end local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() local bag @@ -4973,7 +4994,7 @@ function BattleState:openOldManBag() if self:gen3TutorialBag() then return end local ListMenu = require("src.ui.ListMenu") local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() local list @@ -5007,7 +5028,7 @@ end -- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037); -- no ball is consumed (.done returns early, :576-578). function BattleState:oldManThrow() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "finish" self.result = "run" -- nothing is kept; wBattleResult only ends the demo local DsDemo = require("src.battle.CatchTutorialDemo") @@ -5053,7 +5074,7 @@ function BattleState:queuePokedudeVoices(indices) end if queued then pd.timer = 0 - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" end return queued @@ -5241,7 +5262,7 @@ function BattleState:updatePokedudeMenu() -- control to the player with the temporary party still installed. self.result = self.result or "run" self.afterQueue = "finish" - self.phase = "messages" + self.phase = BattleQueuePhase.name() return end @@ -5416,7 +5437,7 @@ function BattleState:pokedudeUseAntidote(step) local pd = self.pokedudeDemo if not pd then return end pd.enemyMove = step.enemyMove - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:say(Strings("POKé DUDE used\nANTIDOTE!")) local result, messages = require("src.inventory.ItemEffects") @@ -5833,7 +5854,7 @@ function BattleState:resolveTurn(playerAction) if entry[1] then self.turnActions[entry[1]] = entry[3] end end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- PLATINUM / HGSS: a TRICK ROOM used this turn resorts everyone still @@ -5910,11 +5931,11 @@ function BattleState:resolveSwitch(newMon) if self.player and self.player.ingrained and self.player.mon.hp > 0 then self:say(Strings("%s anchored\nitself with its\nroots!", displayName(self.player))) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" return end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- BLACK AND WHITE: the foe chooses first, and a Pursuit catches the -- Pokemon leaving (src/battle/Gen5MoveRules.lua) @@ -9572,7 +9593,7 @@ end -- out with no free enemy move. function BattleState:openReplacementMenu() local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" local function onSwitch(mon) if Party.isEgg(mon) then @@ -9641,7 +9662,7 @@ end -- engine/items/item_effects.asm. function BattleState:safariAction(choice) if self.gen4Safari then return self:gen4SafariAction(choice) end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" local st = self.safari local playerName = self.game.save.player.name @@ -9711,7 +9732,7 @@ function BattleState:gen4SafariAction(choice) end local playerName = self.game.save.player.name local foe = self.enemy.name - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" if choice == "run" then @@ -9913,7 +9934,7 @@ end -- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle) function BattleState:tryRun() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- A wild battle the script says cannot be escaped. There is exactly one in -- Emerald -- the first -- and the roll is skipped rather than rigged, so a @@ -9992,7 +10013,7 @@ function BattleState:tryRun() end function BattleState:openItems() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- BATTLE_TYPE_FRONTIER (all but the Pyramid): "Items can't be used now." if self.noItems then @@ -10648,12 +10669,12 @@ function BattleState:bugContestBall() -- kept throwing past twenty, so refuse here as well -- one guard for the -- last ball of a battle, one for the run. if BugContest.ballsLeft(save) <= 0 then - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:say(Strings("You have no\nPARK BALLs left!")) return end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" BugContest.useBall(save) self:say(self:romText("_ItemUseText001", "%s used\n%s!", @@ -10740,7 +10761,7 @@ function BattleState:storeContestMon() end function BattleState:openParty() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() return self:buildScreen(Gen4Battle.partyScreenId(self) or "PartyMenu", { @@ -10875,7 +10896,7 @@ function BattleState:finish() if type(text) == "string" and text ~= "" then pd.voiceDone[index] = true self:say(text) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "finish" return end @@ -10891,7 +10912,7 @@ function BattleState:finish() self.game.save.player.name, self.payDay)) self.payDay = nil self.afterQueue = "finish" - self.phase = "messages" + self.phase = BattleQueuePhase.name() return end -- Invariant: a battle can never hand the overworld a party with nothing @@ -12753,7 +12774,7 @@ end function BattleState:drawTextAreaInner() Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) - if self.phase == "messages" and (self.current or self.animPlaying) then + if BattleQueuePhase.active(self) and (self.current or self.animPlaying) then -- during the move animation self.current is nil but shown still holds -- the "used X!" lines; keep drawing them like pokered, whose move -- animations only touch sprites and never the textbox tilemap (#296) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..3f891b8a8 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -11,6 +11,7 @@ -- with their status lower right, a full-width message window, a split -- prompt/command window, and a 2x2 move menu with an attached PP/type panel. +local BattleQueuePhase = require("src.battle.BattleQueuePhase") local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") @@ -359,7 +360,7 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) - if battle.phase == "messages" and (battle.current or battle.animPlaying) then + if BattleQueuePhase.active(battle) and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then drawCommandMenu(battle) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 21881d63f..9b056f52e 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -14,6 +14,7 @@ -- boosts don't apply on either side (divergence: Gen 1 famously kept -- them in link battles). +local BattleQueuePhase = require("src.battle.BattleQueuePhase") local Fingerprint = require("src.link.Fingerprint") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") @@ -271,7 +272,7 @@ function LinkBattle.new(game, net, opts) if s.linkEnded then return end s.result = "draw" s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -282,7 +283,7 @@ function LinkBattle.new(game, net, opts) if s.linkEnded then return end s.result = result s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -395,7 +396,7 @@ function LinkBattle.new(game, net, opts) endAsDraw(s, Strings("%s ran from\nthe battle!", who)) return end - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "linkNext" s.turnCount = (s.turnCount or 0) + 1 @@ -514,7 +515,7 @@ function LinkBattle.new(game, net, opts) -- the party menu must offer the clamped link copies self.openParty = function(s) - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "menu" s:ui(function() return s:buildScreen("PartyMenu", { @@ -535,7 +536,7 @@ function LinkBattle.new(game, net, opts) self.openItems = function(s) s:say(Strings("Items can't be\nused in a link\nbattle!")) - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "menu" end @@ -617,11 +618,12 @@ function LinkBattle.new(game, net, opts) s:tickFx() return -- the other side is still choosing end - if s.phase == "messages" and s.afterQueue == "linkNext" then + if BattleQueuePhase.active(s) and s.afterQueue == "linkNext" then s:tickFx() if not s:updateQueue() then s.afterQueue = "menu" s.phase = "menu" + s:clearQueueMessage() end return end @@ -734,7 +736,7 @@ function LinkBattle.newSpectator(game, net, opts) if s.linkEnded then return end s.result = s.result or "ended" s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -780,7 +782,7 @@ function LinkBattle.newSpectator(game, net, opts) endSpectate(s, "The match ended.") return end - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "linkNext" s.turnCount = (s.turnCount or 0) + 1 @@ -901,11 +903,12 @@ function LinkBattle.newSpectator(game, net, opts) -- same as the real-participant loop: every path that skips baseUpdate -- still has to advance the presentational clock, and a spectator sits in -- waitBoth between every single turn - if s.phase == "messages" and s.afterQueue == "linkNext" then + if BattleQueuePhase.active(s) and s.afterQueue == "linkNext" then s:tickFx() if not s:updateQueue() then s.afterQueue = "waitBoth" s.phase = "waitBoth" + s:clearQueueMessage() end return end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index debf151cf..6771b07f9 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -2,6 +2,7 @@ -- opts.battle = BattleState when opened mid-battle (balls throwable, -- using an item consumes the turn). +local BattleQueuePhase = require("src.battle.BattleQueuePhase") local ItemEffects = require("src.inventory.ItemEffects") local ListMenu = require("src.ui.ListMenu") local TextBox = require("src.render.TextBox") @@ -253,7 +254,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) battle.pokeDollEscape = true battle.result = "run" battle.afterQueue = "finish" - battle.phase = "messages" + battle.phase = BattleQueuePhase.name() end) return end diff --git a/tests/engine/gen2_battle_queue_view.lua b/tests/engine/gen2_battle_queue_view.lua new file mode 100644 index 000000000..31a1975cb --- /dev/null +++ b/tests/engine/gen2_battle_queue_view.lua @@ -0,0 +1,147 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native queue transitions and screen fields, without a second controller, +-- a second typewriter or consumer-side phase changes. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Battle = require('src.battle.BattleState') +local QueuePhase = require('src.battle.BattleQueuePhase') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Font = require('src.render.Font') +local before = Version.get() + +local function fixture(version) + Version.set(version) + local data = T.fixtures.fresh(); data.constants.gen = Version.generation() + local run = T.sdk.loadNone({data=data}) + Font.load(data) + local save = Save.newGame(); save.options.battleAnimations = false; save.options.ruleset = "modern_clean" + save.party = {Pokemon.new(data, 'FIXMON_A', 30)} + save.party[1].moves = {{id='FIX_TACKLE',pp=0},{id='FIX_SCRATCH',pp=40}} + local input = {pressed={}} + function input:wasPressed(key) return self.pressed[key] == true end + function input:isDown() return false end + local stack = {states={}} + function stack:top() return self.states[#self.states] end + function stack:push(state) self.states[#self.states+1] = state end + function stack:pop() return table.remove(self.states) end + local game = {data=data,save=save,input=input,stack=stack} + local b = Battle.newWild(game, 'FIXMON_C', 20) + stack:push(b) + b.phase,b.queue,b.current,b.introSlide,b.sendingOut = 'menu',{},nil,0,false + b.menuIndex,b.moveIndex = 1,1 + local function step(key) + input.pressed = key and {[key]=true} or {} + stack:top():update(1/60) + input.pressed = {} + end + local function wait(predicate) + for _=1,6000 do if predicate() then return true end; step() end + return predicate() + end + return b,game,step,wait +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal','red','firered','emerald'}) do + local b,game,step,wait = fixture(version) + local gen2 = Version.isGen2() + local phase = gen2 and 'resolving' or 'messages' + local hp,enemyHp,pp = b.player.mon.hp,b.enemy.mon.hp,b.player.curMoves[2].pp + local turn = b.turnCount + step('a'); step('a') -- actual FIGHT, then the empty-PP learned move + T.eq(b.phase,phase,version .. ' actual native refusal enters the edition queue phase') + T.eq(b.turnCount,turn,'selection refusal spends no turn') + step() + local current = b.current + T.check(current and type(current.text)=='string','actual queue starts its native refusal row') + if current then + if gen2 then + T.eq(b.message,current.text,'public Gen2 message is the actual current source text') + T.eq(b.messageTimer,48,'native message hold is visible while typing') + else + T.eq(rawget(b,'message'),nil,'other editions keep their existing screen fields') + T.eq(rawget(b,'messageTimer'),nil,'Gen2 message hold does not leak to other editions') + end + if gen2 then + -- Earlier queue records can already be mid-type when resumed. + b.phase,b.message,b.messageTimer = 'messages',nil,nil + local chars = b.charIndex + step() + T.eq(b.phase,'resolving','an already active legacy row resumes at its native owner') + T.eq(b.current,current,'resuming does not replace or duplicate the current row') + T.eq(b.message,current.text,'an already active legacy row exposes its actual text') + T.eq(b.messageTimer,48,'resuming restores the same native text hold') + T.check(b.charIndex-chars<=1,'resuming advances only the one native typewriter tick') + end + local index,queue = b.charIndex,#b.queue + b:drawTextArea() + require('src.battle.WideBattle').draw(b) + T.eq(b.charIndex,index,'classic/wide drawing cannot type another glyph') + T.eq(#b.queue,queue,'drawing cannot advance a native queue row') + T.eq(b.current,current,'drawing retains the actual current row') + T.check(wait(function()return b.msgPrompt and (b.msgPromptWait or 0)<=0 end),'native text reaches its own A/B prompt') + for _=1,60 do step() end + T.eq(b.current,current,'message cannot time out without native input') + if gen2 then T.eq(b.messageTimer,48,'public hold does not independently count down') end + step('b') + T.eq(b.current,nil,'actual B retires exactly the native text row') + if gen2 then T.eq(b.messageTimer,0,'the native acknowledgment retires its public hold') end + local decision = b.afterQueue=='moveSelect' and (gen2 and 'moves' or 'moveSelect') or 'menu' + T.check(wait(function()return b.phase==decision end),'refusal returns to its native edition decision') + T.eq(b.message,nil,'no stale source text covers the restored menu') + if gen2 then T.eq(b.messageTimer,0,'restored native menu has no message hold') end + end + T.eq(b.player.mon.hp,hp,'refusal does not mutate own HP') + T.eq(b.enemy.mon.hp,enemyHp,'refusal does not damage the opponent') + T.eq(b.player.curMoves[2].pp,pp,'refusal spends no other move PP') + T.eq(b.turnCount,turn,'reading, drawing and acknowledging spend no battle turn') + + if gen2 then + -- Resume a native queue stored by earlier shared-engine checkpoints. + b.phase,b.afterQueue,b.queue,b.current = 'messages','menu',{},nil + b:say('First native page') + local child + b:ui(function() + child = require('src.ui.ChoiceBox').new(game,function()end) + return child + end) + b:say('Second native page') + step() + T.eq(b.phase,'resolving','legacy message queue resumes on its real Gen2 owner') + T.eq(b.message,'First native page','legacy row publishes the actual source text') + T.check(wait(function()return b.msgPrompt and (b.msgPromptWait or 0)<=0 end),'first queued page finishes typing') + step('a'); step() + T.eq(b.phase,'submenu','actual queued UI retains its native child phase') + T.eq(game.stack:top(),child,'actual queue pushes the same native child') + step('b') + T.check(wait(function()return game.stack:top()==b end),'native choice child releases its parent') + step() + T.eq(b.phase,'resolving','the same queue resumes after the child') + T.eq(b.message,'Second native page','next actual source row replaces the retired text') + T.check(wait(function()return b.msgPrompt and (b.msgPromptWait or 0)<=0 end),'second queued page finishes typing') + step('a'); step() + T.eq(b.phase,'menu','child continuation returns to the actual native menu') + T.eq(b.message,nil,'child continuation leaves no stale message on the menu') + T.eq(b.messageTimer,0,'child continuation releases the public text hold') + + -- Native battle YES/NO uses its real answer delay and queue callback. + local answer + b.phase,b.afterQueue = 'messages','menu' + b:sayChoice('Continue?',function(yes)answer=yes end) + T.check(wait(function()return game.stack:top()~=b end),'actual typed choice opens the native YES/NO owner') + T.eq(b.message,'Continue?','choice keeps its actual text under the child') + step('b') + T.check(wait(function()return game.stack:top()==b end),'actual B answers after the native YES/NO hold') + T.eq(answer,false,'the actual callback receives the chosen native answer') + T.eq(b.messageTimer,0,'choice completion releases the same public text hold') + step() + T.eq(b.phase,'menu','choice returns to the same native decision') + T.eq(b.message,nil,'choice text clears at the decision boundary') + else + b.phase = 'resolving' + T.eq(QueuePhase.active(b),false,'Gen2 resolution spelling cannot admit other editions') + end +end +Version.set(before) +T.finish('native Gen2 queue phase, message ownership and shared regressions') From 2727365b0c38495717c688708d521cf8150cfec4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:25:48 +0100 Subject: [PATCH 57/68] Connect native Gen2 classic and wide scenes to the battle window owner --- .../workflows/gen2-queue-screen-contract.yml | 3 + docs/native-gen2-battle-window.md | 24 ++++ src/battle/BattleState.lua | 3 + src/battle/Gen2BattleScene.lua | 134 ++++++++++++++++++ src/battle/Gen2BattleWindow.lua | 43 ++++++ src/battle/WideBattle.lua | 15 +- src/render/GraphicsScope.lua | 57 ++++++++ tests/engine/gen2_battle_scene.lua | 65 +++++++++ tests/engine/gen2_battle_window.lua | 61 ++++++++ 9 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-battle-window.md create mode 100644 src/battle/Gen2BattleScene.lua create mode 100644 src/battle/Gen2BattleWindow.lua create mode 100644 src/render/GraphicsScope.lua create mode 100644 tests/engine/gen2_battle_scene.lua create mode 100644 tests/engine/gen2_battle_window.lua diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index e98532762..09d227442 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -18,6 +18,9 @@ jobs: - name: Native queue, screen and available menu owners run: | luajit tests/engine/gen2_battle_queue_view.lua + luajit tests/engine/gen2_battle_window.lua + luajit tests/engine/gen2_battle_scene.lua + luajit tests/engine/wide_battle_shake_bug562.lua luajit tests/engine/gen2_battle_pack.lua luajit tests/engine/gen2_battle_live_view.lua luajit tests/engine/gen2_battle_submenu.lua diff --git a/docs/native-gen2-battle-window.md b/docs/native-gen2-battle-window.md new file mode 100644 index 000000000..305d82b4c --- /dev/null +++ b/docs/native-gen2-battle-window.md @@ -0,0 +1,24 @@ +# Native Gen2 scene/window owner + +Expose `drawWidescreen` and explicit Gen2-only compositor admission on the actual +shared battle class. This upstream adaptation also splits the existing Gen2 +classic/wide BG, objects and final-overlay composition into `drawPanel`, +`drawSceneBody` and `drawScene`; the older branch did not provide them. The +same native scene, chrome, layout and fixed/fill setting remain authoritative. +The graphics guard restores caller canvas/state even after leaked pushes. +Other editions retain the original classic/wide route. No mod or game data +is changed or included. + +The public contract was checked against Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/ui/gen2/BattleState.lua`. The extracted native composition retains the +existing BattleState/WideBattle provenance and applicable project rights. +The additional graphics guard is an independently owned Roxas2712 contribution +offered under the permission described in this PR. + +Source tests cover native window admission, multiple viewport/DPI sizes, +classic/wide scene ownership, fixed/fill options, caller-state restoration +and native menu/model/summary regressions. The separate fork's earlier +54 functional Metal checks and12 inspected captures diagnose a missing native +drawer and a white fallback on an older source; they do not qualify this +upstream composition, current visual parity, packages or a release. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 81dc8858e..1ac6e3f07 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -12949,6 +12949,7 @@ function BattleState:drawBattleField() end function BattleState:drawClassic() + if GameVersion.isGen2() then return self:drawScene() end -- AskName: ClearSprites + wild ClearScreenArea -- white field under the -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. if self.blankForAskName then @@ -13062,6 +13063,8 @@ function BattleState:drawClassic() end end +require('src.battle.Gen2BattleScene').install(BattleState) +require('src.battle.Gen2BattleWindow').install(BattleState) Gen2BattleView.install(BattleState, { load = getImage, palette = monPalette, diff --git a/src/battle/Gen2BattleScene.lua b/src/battle/Gen2BattleScene.lua new file mode 100644 index 000000000..9f93267c5 --- /dev/null +++ b/src/battle/Gen2BattleScene.lua @@ -0,0 +1,134 @@ +-- Native classic composition retained from src/battle/BattleState.lua. +-- The Gen2 scene-owner split reuses its real panels, FX and OAM; it does not +-- own battle decisions or animation time. Existing source provenance remains. +local Timing=require('src.core.Timing') +local Runtime=require('src.mods.Runtime') +local M={} +function M.install(Screen) + function Screen:drawPanel() + -- AskName: ClearSprites + wild ClearScreenArea -- white field under the + -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. + if self.blankForAskName then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + return + end + local fx = self.fx + -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); + -- the animations-off fallback keeps the old +-2 alternation + local sx = (fx and fx.shakeX) or 0 + local sy = (fx and fx.shakeY) or 0 + if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then + sx = self.frame % 4 < 2 and 2 or -2 + end + -- intro slide-in offset: 2 px per frame, so 144 px over 72 frames + local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME + + if self:colorMode() then + -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the + -- BGP fade -> mon pics -> OAM anim sprites (never BGP-faded) + local g = love.graphics + local wavy = fx and fx.wavy + -- push("all")/pop restores the bound canvas, a mod's depth set included + g.push("all") + g.setCanvas(self.bgCanvas) + -- The white paper the whole battle is drawn on -- unless something has + -- put a WORLD where the paper goes, in which case painting it is what + -- hides the world. Clearing to transparent instead leaves only what is + -- actually drawn (HUDs, boxes, pics) and lets the 3D frame underneath + -- show between them. The zone shader passes alpha through unchanged + -- (PaletteFX.shader: `vec4(mapped, p.a)`), so transparency survives the + -- colour pass rather than being remapped to a palette shade. + if self:worldBackdrop() then + g.clear(0, 0, 0, 0) + else + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, 160, 144) + end + g.setColor(1, 1, 1, 1) + -- the party ball rows are OBJ sprites on hardware (see drawBallRow): + -- collect them here and draw them over the finished zone pass instead + local balls = self:gen2BallColors() and {} or nil + self.deferBalls = balls + self:drawHUDs(slide) + self.deferBalls = nil + self:drawTextArea() + if wavy then + -- the mon pics are BG tiles on the GB, so SE_WAVY_SCREEN bends + -- them too: bake them into the canvas as DMG grays and let the + -- zone pass color them by region (exactly what the SGB did) + self.grayPics = true + g.setScissor(0, 0, 160, 96) -- BG pics live above the text box + self:drawPicsLayer(slide, 0, 0) + g.setScissor() + self.grayPics = nil + end + g.pop() + self:drawZonePass(self:applyWavy(self.bgCanvas), sx, sy) + if balls and balls[1] then + -- they ride the window shake with the HUD chrome they sit in + local shifted = sx ~= 0 or sy ~= 0 + if shifted then g.push() g.translate(sx, sy) end + g.setColor(1, 1, 1, 1) + for _, row in ipairs(balls) do + self:drawBallRow(row[1], row[2], row[3], row[4]) + end + if shifted then g.pop() end + end + if not wavy then + -- the pics are BG tiles in rows 0-11 on the GB: they can never + -- cover the text box, whatever the SE offsets do (a vertical + -- window shake moves the box down with everything else) + g.setScissor(0, 0, 160, 96 + math.max(0, sy)) + self:drawPicsLayer(slide, sx, sy) + g.setScissor() + end + else + -- flat fallback (headless / no shader support): pre-colorized pics + -- on white, no palette fades -- and the same exception as above, so the + -- two paths agree about when there is paper to paint + love.graphics.setColor(1, 1, 1, 1) + if not self:worldBackdrop() then + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + local shaking = sx ~= 0 or sy ~= 0 + if shaking then + love.graphics.push() + love.graphics.translate(sx, sy) + end + self:drawPicsLayer(slide, 0, 0) + self:drawHUDs(slide) + self:drawTextArea() + if shaking then + love.graphics.pop() + end + end + end + + -- panelFn replaces the BG panel; native wide layout also supplies its + -- translated OAM pass and surface dimensions through the same real site. + function Screen:drawSceneBody(panelFn,objectsFn,width,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + if panelFn then panelFn()else self:drawPanel()end + if self.blankForAskName then return end + if objectsFn then objectsFn()else self:drawAnimLayer(self:colorMode())end + local fx=self.fx + if fx and fx.flash and fx.flash>0 and self.frame%4<2 then + g.setShader();g.setColor(1,1,1,.85) + g.rectangle('fill',0,0,width or 160,height or 144) + end + end) + g.pop() + if not ok then error(why,0)end + end + function Screen:drawScene(bodyFn) + if bodyFn then bodyFn()else self:drawSceneBody()end + love.graphics.setColor(1,1,1,1) + if Runtime.wantsHook('battle.overlay')then + Runtime.call('battle.overlay',function()end,self) + end + end +end +return M diff --git a/src/battle/Gen2BattleWindow.lua b/src/battle/Gen2BattleWindow.lua new file mode 100644 index 000000000..f70a5a2a0 --- /dev/null +++ b/src/battle/Gen2BattleWindow.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native Gen2 window drawer over the existing scene/queue. Contract checked +-- against Gen1Recomp 535a5ec0 src/ui/gen2/BattleState.lua; existing project +-- rights and notices remain applicable. No separate battle renderer/controller. +local Version = require('src.core.GameVersion') +local Chrome = require('src.ui.Gen2Chrome') +local Scope = require('src.render.GraphicsScope') +local M = {} + +function M.install(Screen) + function Screen:drawsWidescreen() return Version.isGen2() end + + function Screen:panelSize() return self:uiSize() end + + function Screen:battlePanelScale(width, height) + local pw, ph = self:panelSize() + if self:wantsFillScale() then + local _, _, w, h = Chrome.playfieldRect(width, height) + return math.min(w / pw, h / ph) + end + return Chrome.fitScaleFor(width, height, pw / 8, ph / 8) + end + + function Screen:drawWidescreen(width, height) + local ok, why = Scope.call(function() + local G = love.graphics + G.origin() + local scale = self:battlePanelScale(width, height) + if not self:wideLayout() then + return Chrome.withPanel(width, height, 1, 1, 1, + function() self:drawScene() end, scale) + end + local pw, ph = self:panelSize() + Chrome.letterbox(width, height, 1, 1, 1) + local x, y = Chrome.fitOriginFor(width, height, scale, pw / 8, ph / 8) + G.translate(x, y); G.scale(scale, scale) + Chrome.clipTo(0, 0, pw, ph) + return require('src.battle.WideBattle').draw(self) + end) + if not ok then error(why, 0) end + end +end +return M diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 3f891b8a8..56515824a 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -448,7 +448,7 @@ local function drawAnimationLayer(battle) end -- The whole 304x144 composition for one frame. -function WideBattle.draw(battle) +local function drawPanel(battle,withObjects) local g = love.graphics -- The field is the display mode's paper. Under a forced-mono mode the -- whole surface is remapped downstream (WideBattle.zones), so the field @@ -504,9 +504,20 @@ function WideBattle.draw(battle) g.pop() end shaken(function() drawHUDs(battle, slide) end) - drawAnimationLayer(battle) + if withObjects then drawAnimationLayer(battle) end shaken(function() drawTextArea(battle) end) +end +function WideBattle.draw(battle) + if require('src.core.GameVersion').isGen2() then + return battle:drawScene(function() + battle:drawSceneBody(function()drawPanel(battle,false)end, + function()drawAnimationLayer(battle)end,WideBattle.WIDTH,WideBattle.HEIGHT) + end) + end + drawPanel(battle,true) + if battle.blankForAskName then return end + local g,fx=love.graphics,battle.fx 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) diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..f4380d928 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,57 @@ +-- Copyright (c) 2026 Roxas2712. See the contribution permission in PR271. +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local current +-- Opaque lifetime for paired helpers inside a guarded render callback. +function S.token() return current end +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function S.call(fn, ...) + local G = love.graphics + local canvas = pack(G.getCanvas()) + local realPush, realPop = G.push, G.pop + local before = G.getStackDepth and G.getStackDepth() + local pushed, why = pcall(realPush, "all") + if not pushed then + return false, why + end + local depth = 0 + G.push = function(...) + local result = pack(realPush(...)) + depth = depth + 1 + return unpack(result, 1, result.n) + end + G.pop = function(...) + if depth <= 0 then + error("render callback cannot pop caller graphics state", 0) + end + local result = pack(realPop(...)) + depth = depth - 1 + return unpack(result, 1, result.n) + end + local parent = current + current = {} + local out = pack(pcall(fn, ...)) + current = parent + G.push, G.pop = realPush, realPop + if before and G.getStackDepth then + local current = G.getStackDepth() + if current < before + 1 then + G.setCanvas(unpack(canvas, 1, canvas.n)) + return false, "render callback removed its graphics guard" + end + depth = current - before - 1 + end + for _ = 1, depth do + pcall(realPop) + end + local restored, err = pcall(realPop) + G.setCanvas(unpack(canvas, 1, canvas.n)) + if not restored then + return false, err + end + return unpack(out, 1, out.n) +end +return S diff --git a/tests/engine/gen2_battle_scene.lua b/tests/engine/gen2_battle_scene.lua new file mode 100644 index 000000000..5cf2d0ae4 --- /dev/null +++ b/tests/engine/gen2_battle_scene.lua @@ -0,0 +1,65 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Runtime=require('src.mods.Runtime') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=Version.get() +local wants,call=Runtime.wantsHook,Runtime.call +local overlays,expected=0 +Runtime.wantsHook=function(name)if name=='battle.overlay'then return true end;return wants(name)end +Runtime.call=function(name,vanilla,screen,...) + if name=='battle.overlay'then overlays=overlays+1;T.eq(screen,expected,'overlay receives actual native screen');return vanilla(screen)end + return call(name,vanilla,screen,...) +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + expected=s + local layers={};local scene,body=s.drawScene,s.drawSceneBody + s.drawScene=function(self,...)layers[#layers+1]='scene';return scene(self,...)end + s.drawSceneBody=function(self,...)layers[#layers+1]='body';return body(self,...)end + s.drawPicsLayer=function()layers[#layers+1]='pics'end + s.drawHUDs=function()layers[#layers+1]='hud'end + s.drawTextArea=function()layers[#layers+1]='text'end + s.drawAnimLayer=function()layers[#layers+1]='objects'end + s.colorMode=function()return false end + local count=overlays;s:draw() + T.same(layers,{'scene','body','pics','hud','text','objects'},'actual classic draw reaches native scene/BG/OAM owners once') + T.eq(overlays,count+1,'actual scene emits one final overlay') + layers={};count=overlays + s:drawSceneBody(function()layers[#layers+1]='custom'end) + T.same(layers,{'body','custom','objects'},'replacement panel retains native object pass') + T.eq(overlays,count,'scene body does not emit duplicate final overlay') + layers={};s:drawScene(function()s:drawSceneBody(function()layers[#layers+1]='supplied'end)end) + T.same(layers,{'scene','body','supplied','objects'},'supplied scene body stays inside actual outer owner') + layers={};s.blankForAskName=true;s:drawSceneBody() + T.same(layers,{'body'},'native naming blank suppresses battle pictures and objects') + s.blankForAskName=nil + local g=love.graphics;local sentinel={};g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + T.raises(function()s:drawSceneBody(function()g.setShader();g.setColor(0,0,0,1);error('panel draw failed')end)end,'panel draw failed','native scene propagates panel error') + T.eq(g.getShader(),sentinel,'failed panel restores graphics owner') + T.same({g.getColor()},{.2,.3,.4,.5},'failed panel restores caller tint') + g.setShader();g.setColor(1,1,1,1) + local nativeBody=s.drawSceneBody;local widths={};layers={};count=overlays + s.drawSceneBody=function(self,panel,objects,w,h) + widths={w,h};return nativeBody(self,panel,objects,w,h) + end + Wide.draw(s) + T.same(widths,{304,144},'actual native widescreen enters same composition owner with real surface') + T.eq(layers[1],'scene','wide layout enters native outer scene') + T.eq(layers[2],'body','wide layout enters native BG/OAM scene') + T.eq(overlays,count+1,'wide layout emits one final overlay') + -- Red keeps its existing layout route. Presence of a Gen2 method must not + -- redirect other games simply because they share the native screen class. + Version.set('red');layers={};s:drawClassic() + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 classic route remains native original route') + layers={};Wide.draw(s) + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 wide route remains native original route') +end +Runtime.wantsHook,Runtime.call=wants,call;Version.set(before) +T.finish('native Gen2 classic/wide scene owner, panel replacement and layer lifecycle') diff --git a/tests/engine/gen2_battle_window.lua b/tests/engine/gen2_battle_window.lua new file mode 100644 index 000000000..303ff9863 --- /dev/null +++ b/tests/engine/gen2_battle_window.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Chrome=require('src.ui.Gen2Chrome') +local UI=require('src.render.ScreenSpaceUI') +local before=V.get() +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal','red','firered','emerald'})do + V.set(version) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local game={data=data,save=save,stack={states={}}} + local b=Battle.newWild(game,'FIXMON_C',20);game.stack.states={b} + T.eq(type(b.drawWidescreen),'function',version..' actual native battle exposes its window drawer') + T.eq(type(b.drawsWidescreen),'function','window admission is explicit on the actual screen') + if type(b.drawWidescreen)=='function'and type(b.drawsWidescreen)=='function'then + T.eq(b:drawsWidescreen(),V.isGen2(),'only Gen2 opts into native window composition') + T.eq(UI.select(game.stack,game.stack,1),V.isGen2()and b or nil,'actual compositor selection preserves other edition routes') + if V.isGen2()then + local native=b.drawScene;local scenes=0 + b.drawScene=function(self,...)scenes=scenes+1;T.eq(self,b,'window drawing retains the actual battle owner');return native(self,...)end + b.colorMode=function()return false end + local g=love.graphics;local dpi=g.getDPIScale + for _,ratio in ipairs({1,2})do + g.getDPIScale=function()return ratio end + for _,layout in ipairs({'classic','wide'})do + save.options.battleLayout=layout + local pw,ph=b:uiSize() + T.same({b:panelSize()},{pw,ph},'window uses the actual native layout dimensions') + for _,size in ipairs({{1280,960},{900,1500},{640,480}})do + local w,h=size[1],size[2] + save.options.battleFit='fixed' + T.eq(b:battlePanelScale(w,h),Chrome.fitScaleFor(w,h,pw/8,ph/8),'fixed native scale obeys physical pixel grid and layout') + local count=scenes;b:drawWidescreen(w,h) + T.eq(scenes,count+1,'actual classic/wide window enters the native scene exactly once') + save.options.battleFit='fill' + local _,_,fw,fh=Chrome.playfieldRect(w,h) + T.eq(b:battlePanelScale(w,h),math.min(fw/pw,fh/ph),'existing fill option fits the real panel') + count=scenes;b:drawWidescreen(w,h) + T.eq(scenes,count+1,'fill keeps the same native scene owner') + end + end + end + g.getDPIScale=dpi + save.options.battleLayout='classic';save.options.battleFit='fixed' + local shader={};local canvas=g.newCanvas(17,19);local other=g.newCanvas(11,13) + g.setCanvas(canvas);g.setShader(shader);g.setColor(.2,.3,.4,.5) + b.drawScene=function()g.push('all');g.setCanvas(other);g.setShader();g.setColor(0,0,0,1);error('native window scene failure')end + T.raises(function()b:drawWidescreen(1280,960)end,'native window scene failure','real native window propagates a failed scene') + T.eq(g.getCanvas(),canvas,'failed scene releases its canvas and caller owner') + T.eq(g.getShader(),shader,'failed scene restores caller shader') + T.same({g.getColor()},{.2,.3,.4,.5},'failed scene restores caller tint despite a leaked push') + g.setCanvas();g.setShader();g.setColor(1,1,1,1) + end + end +end +V.set(before) +T.finish('native Gen2 battle window, physical scale, scene owner and error boundary') From 4f54ab389c771461bb72823cd0bc5c661e3c9fcc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:52:11 +0100 Subject: [PATCH 58/68] Complete native Gen2 party item results on available upstream owners --- .../workflows/gen2-queue-screen-contract.yml | 3 + docs/native-gen2-party-item-results.md | 36 +++++++ src/battle/BattleState.lua | 20 +++- src/core/Game.lua | 5 +- src/core/StateStack.lua | 3 +- src/render/NativeScreenOverlay.lua | 33 +++++++ src/render/ScreenSpaceUI.lua | 5 +- src/ui/BagMenu.lua | 52 +++++----- src/ui/Gen2PartyItemResult.lua | 95 +++++++++++++++++++ src/ui/PartyMenu.lua | 11 ++- tests/engine/gen2_battle_pack.lua | 82 ++++++++++++++-- 11 files changed, 305 insertions(+), 40 deletions(-) create mode 100644 docs/native-gen2-party-item-results.md create mode 100644 src/render/NativeScreenOverlay.lua create mode 100644 src/ui/Gen2PartyItemResult.lua diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index 09d227442..b951ed87d 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -22,6 +22,9 @@ jobs: luajit tests/engine/gen2_battle_scene.lua luajit tests/engine/wide_battle_shake_bug562.lua luajit tests/engine/gen2_battle_pack.lua + luajit tests/engine/gen2_party_item_result.lua + luajit tests/engine/screen_space_ui.lua + luajit tests/engine/party_live_data.lua luajit tests/engine/gen2_battle_live_view.lua luajit tests/engine/gen2_battle_submenu.lua luajit tests/engine/gen2_summary_contract.lua diff --git a/docs/native-gen2-party-item-results.md b/docs/native-gen2-party-item-results.md new file mode 100644 index 000000000..5ce4ba63d --- /dev/null +++ b/docs/native-gen2-party-item-results.md @@ -0,0 +1,36 @@ +# Native Gen2 party item results + +Gen2 party-target items keep the actual PartyMenu above the battle through +HP fill and result confirmation. The effect and inventory mutation still run +once in ItemEffects. Only after native A/B acknowledgment does the same battle +controller queue its normal enemy action and end-of-turn effects. + +The native result surface follows Gen1Recomp 535a5ec0 PartyMenu/HpBar: +`showItemResult`, `itemResultClimbing`, `shownHpFor`, and `updateItemResult`. +HP display walks one point per tick below 48 maximum HP, otherwise +`ceil(maxHp / 48)`, then holds confirmation for 50 ticks. Native row rendering +reads that display value without changing the real party mon. Status and PP +results use the same owner with no HP animation. Longer shared-engine text +continues on another native two-row page instead of silently dropping its tail. +The item effect already plays the imported heal cue; result presentation does +not replay it. + +The shared `heal` hold refers to the same result object, so existing party +navigation cannot advance a second input/animation clock. Once the party result +finishes, the active battler's displayed HP catches up before battle drawing +resumes. The normal low-health alarm can then reevaluate the actual HP. + +A controller-owned post-draw pass preserves the result text when a consumer +replaces the party view. It runs after that view, before later modal children, +in classic, expanded native and window-resolution rendering. The graphics +boundary restores caller state even if a replaceable view leaks a push or +changes canvas. A consumer drawing HP directly from `mon.hp` still displays +that real final value; the engine does not rewrite gameplay HP to animate an +independently authored view. Such a view can use `shownHpFor` for native timing. + +Targeted contracts cover actual PACK/target/move-selection input, Potion, +status cure, revive and PP restoration; native timing, cancellation, no-effect +refusal, one item and one enemy action; render ordering and error restoration. +Local source-frozen execution and unchanged-client GPU qualification must be +recorded separately. This document does not claim release or full parity +acceptance from the presence of these tests. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1ac6e3f07..5bbf810d8 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -7388,7 +7388,7 @@ end function BattleState:lowHealthAlarmActive() local p = self.player if not p or self.safari or self.demo or self.result - or self.lowHealthAlarmDisabled then return false end + or self.lowHealthAlarmDisabled or self.partyItemHealing then return false end if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end if p.fainted then return false end -- A siren that is ALREADY sounding follows the drawn bar, not the @@ -10050,6 +10050,24 @@ function BattleState:gen4ItemTargetScreen() return Gen4Battle.partyScreenId(self) end +-- Gen2's party item screen owns HealHP_SFX_GFX and its confirmation. +-- These methods use the existing battlers and turn queue; the effect and +-- inventory mutation have already happened exactly once in ItemEffects. +function BattleState:beginPartyItemHeal() + self.partyItemHealing = true + self.lowHealthAlarmOn = false + Sound.stopLoop("Low_Health_Alarm") +end + +function BattleState:finishPartyItemTurn(itemId, mon) + if mon and self.player and mon == self.player.mon then + self.player.shownHP = mon.hp + self.player.drainFloor = nil + end + self.partyItemHealing = nil + self:itemUsed({}) +end + -- called by BagMenu after an item is used in battle (consumes the turn) function BattleState:itemUsed(messages) -- bag cures clear mon.status before the message UI; refresh the HUD diff --git a/src/core/Game.lua b/src/core/Game.lua index e70c2262d..39d3e4a53 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -13,6 +13,7 @@ local FrameProfile = require("src.core.FrameProfile") local TouchControls = require("src.core.TouchControls") local ModLoader = require("src.mods.Loader") local ModRuntime = require("src.mods.Runtime") +local NativeScreenOverlay = require("src.render.NativeScreenOverlay") local Screens = require("src.ui.Screens") local Game = {} @@ -1120,10 +1121,10 @@ function Game:_draw() if ox ~= 0 or oy ~= 0 then love.graphics.push() love.graphics.translate(ox, oy) - state:draw() + NativeScreenOverlay.screen(state) love.graphics.pop() else - state:draw() + NativeScreenOverlay.screen(state) end end end diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..c013795f5 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -2,6 +2,7 @@ -- (so a text box can overlay the overworld, a battle replaces it, etc). -- States are tables with optional enter/exit/update/draw/isOpaque. +local NativeScreenOverlay = require("src.render.NativeScreenOverlay") local Runtime = require("src.mods.Runtime") local Logger = require("src.core.Logger") @@ -191,7 +192,7 @@ end function StateStack:draw() for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + if self.states[i].draw then NativeScreenOverlay.screen(self.states[i]) end end end diff --git a/src/render/NativeScreenOverlay.lua b/src/render/NativeScreenOverlay.lua new file mode 100644 index 000000000..dc1a7a8d6 --- /dev/null +++ b/src/render/NativeScreenOverlay.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Controller-owned feedback is painted after its replaceable screen view. +-- The same state keeps input/continuations; no extra modal is pushed. +local Scope = require('src.render.GraphicsScope') +local M = {} +function M.draw(state, width, height) + local draw = state.drawNativeOverlay + if type(draw) ~= 'function' then return end + if state.wantsNativeOverlay and not state:wantsNativeOverlay() then return end + local ok, why = Scope.call(draw, state, width, height) + if not ok then error(why, 0) end +end +local function view(state, fn, ...) + -- Ordinary screens keep their original route. A controller with feedback + -- fences its replaceable view before drawing its native continuation. + if type(state.drawNativeOverlay) == 'function' and + (not state.wantsNativeOverlay or state:wantsNativeOverlay()) then + local ok, why = Scope.call(fn, state, ...) + if not ok then error(why, 0) end + else + fn(state, ...) + end +end +function M.screen(state, width, height) + view(state, state.draw) + return M.draw(state, width, height) +end +function M.window(state, width, height) + view(state, state.drawWidescreen, width, height) + return M.draw(state, width, height) +end +return M diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua index 0f04a3bc7..14609b1aa 100644 --- a/src/render/ScreenSpaceUI.lua +++ b/src/render/ScreenSpaceUI.lua @@ -2,6 +2,7 @@ -- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Optional window-resolution UI. Simulation/input remain native; only an -- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local NativeScreenOverlay = require('src.render.NativeScreenOverlay') local M = {} local function visible(stack, state) return not stack.renderVisible or stack:renderVisible(state) @@ -59,7 +60,7 @@ function M.drawNativeStack(stack, scene, first, world, renderer, viewport, opaqu G.translate((renderer.WIDTH-w)/2,(renderer.HEIGHT-h)/2) end end - local drawn,why=pcall(state.draw,state) + local drawn,why=pcall(NativeScreenOverlay.screen,state,viewport.width,viewport.height) G.pop() if not drawn then error(why,0) end end @@ -73,7 +74,7 @@ function M.draw(owner, renderer, viewport, zones, overlay) local G = love.graphics G.push('all') G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') - local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + local ok, err = pcall(NativeScreenOverlay.window, owner, viewport.width, viewport.height) G.pop() if not ok then error(err,0) end -- The classic canvas contains only states above this owner. Reapply it diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 6771b07f9..27a42b768 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -720,21 +720,30 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end -- refresh counts on whichever screen asked refreshCount(game, list, id) - -- HP medicine: fill the bar in the still-open picker first, then print - -- and close, the order item_effects.asm .doneHealing runs in - -- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message). - -- Only a keepOpen picker is still on the stack to animate: every other - -- item, and every in-battle use, popped it in PartyMenu before onSwitch, - -- and takes the pop-then-print path below -- which is the path that - -- spends the battle turn. #252, #379 - -- ...AND ONLY A PICKER THAT CAN ANIMATE IS ASKED TO. - -- - -- This screen is whichever party picker the generation opened, and it - -- took `animateTo` on faith: Hoenn's opens Gen3PartyMenu, which had no - -- such method, and every out-of-battle potion in the game crashed here. - -- Gen3PartyMenu has one now -- but the message must print either way, so - -- a picker that cannot animate falls through to the plain path rather - -- than taking the game down with it. + -- Native Gen2 keeps the party screen through HP fill, result text + -- and acknowledgment before the normal enemy action can be queued. + if battle and require("src.core.GameVersion").isGen2() + and picker and picker.keepOpen and type(picker.showItemResult) == "function" then + local slot + for i, mon in ipairs(picker.party or game.save.party) do + if mon == target then slot = i; break end + end + local before = extra and extra.healedFrom + local climbs = before ~= nil and target and before ~= target.hp + if climbs then battle:beginPartyItemHeal() end + picker:showItemResult(slot, { + fromHp = climbs and before or nil, + toHp = climbs and target.hp or nil, + -- ItemEffects already played Heal_HP (the imported Sfx_Potion alias). + -- Replaying it here would double the same native cue. + text = table.concat(payload or {}, "\n"), + onDone = function() + closePicker() + battle:finishPartyItemTurn(id, target) + end, + }) + return + end if picker and picker.keepOpen and extra and extra.healedFrom and target and type(picker.animateTo) == "function" then picker:animateTo(target, extra.healedFrom, function() @@ -778,12 +787,11 @@ local function pickTargetAndUse(game, battle, id, list, options) pickOnly = true, battle = require("src.core.GameVersion").isGen2() and battle or nil, onCancel = options and options.onCancel, - -- HP medicine animates its bar with the picker still up (#252). Only - -- out of battle: the in-battle tail closes the bag list underneath - -- first, which needs the picker already gone. - keepOpen = (not battle) and (ItemEffects.healsHP(id) - or (id == "RARE_CANDY" - and require("src.core.GameVersion").get() == "firered")), + -- Native Gen2 keeps the same party owner through its result and + -- confirmation. Other editions retain their existing field flow. + keepOpen = (battle and require("src.core.GameVersion").isGen2()) + or ((not battle) and (ItemEffects.healsHP(id) + or (id == "RARE_CANDY" and require("src.core.GameVersion").isFRLG()))), onSwitch = function(mon, picker) if not wantsMove then useOn(game, battle, id, mon, list, nil, picker) @@ -828,7 +836,7 @@ local function pickTargetAndUse(game, battle, id, list, options) game.stack:push(ListMenu.new(game, "Which move?", rows, { onChoose = function(row, l) l:close() - useOn(game, battle, id, mon, list, row.value) + useOn(game, battle, id, mon, list, row.value, picker) end, })) end, diff --git a/src/ui/Gen2PartyItemResult.lua b/src/ui/Gen2PartyItemResult.lua new file mode 100644 index 000000000..57eff0cca --- /dev/null +++ b/src/ui/Gen2PartyItemResult.lua @@ -0,0 +1,95 @@ +-- Native Gen2 party-result presentation over the real shared party screen. +-- Contract/HP stepping adapted from Gen1Recomp 535a5ec0 +-- src/ui/gen2/PartyMenu.lua and src/battle/gen2/HpBar.lua. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +local Chrome = require('src.ui.Gen2Chrome') +local M = {} +local function maximum(mon) + return mon and (mon.maxHp or mon.stats and mon.stats.hp) or 0 +end +function M.install(Screen) + Screen.ACTION_TEXT_DELAY = 50 + function Screen:playSfx(name) + local data = self.game and self.game.data + -- The importer already owns native cue aliases. Do not invent a second + -- resolver (the shared Sound service has no Sound.resolve method). + if data and data.audio and data.audio.sfx and data.audio.sfx[name] then + return require('src.core.Sound').play(data, name) + end + end + function Screen:showItemResult(slot, opts) + opts = opts or {} + local mon = slot and self.party[slot] + local result = {slot=slot,mon=mon,from=opts.fromHp,shown=opts.fromHp, + target=opts.toHp,text=opts.text,delay=opts.delay or Screen.ACTION_TEXT_DELAY, + onDone=opts.onDone,auto=opts.auto,holdSlot=opts.holdSlot,holdHp=opts.holdHp, + lines=Chrome.wrap(opts.text,18),line=1} + self.itemResult = result + -- The shared party screen's existing display hold and the Gen2 result + -- are this same owner. Neither path may advance a second HP clock. + self.heal = result + if opts.sfx then self:playSfx(opts.sfx) end + end + function Screen:itemResultClimbing() + local r = self.itemResult + return r ~= nil and r.shown ~= nil and r.target ~= nil and r.shown ~= r.target + end + function Screen:shownHpFor(slot, mon) + local r = self.itemResult + if r then + if r.slot == slot and r.shown ~= nil then return r.shown end + if r.holdSlot == slot and r.holdHp ~= nil then return r.holdHp end + end + return mon and mon.hp + end + function Screen:updateItemResult(input) + local r = self.itemResult + if not r then return end + if self:itemResultClimbing() then + local maxHp = maximum(self.party[r.slot]) + local step = maxHp >= 48 and math.max(1, math.ceil(maxHp / 48)) or 1 + if r.shown < r.target then r.shown = math.min(r.target, r.shown + step) + else r.shown = math.max(r.target, r.shown - step) end + return + end + if not r.auto then + if r.delay > 0 then r.delay = r.delay - 1; return end + if not (input:wasPressed('a') or input:wasPressed('b')) then return end + -- Shared effects can produce more than the cartridge panel's two + -- rows. Keep their complete native text instead of dropping a tail. + if r.line + 1 < #r.lines then + r.line = r.line + 2 + r.delay = Screen.ACTION_TEXT_DELAY + return + end + end + self.itemResult = nil + if self.heal == r then self.heal = nil end + if r.onDone then r.onDone() end + end + function Screen:wantsNativeOverlay() + return self.itemResult ~= nil and self.itemResult.text ~= nil + and not self:itemResultClimbing() + end + function Screen:drawNativeOverlay(width, height) + if not self:wantsNativeOverlay() then return end + local function draw() + Chrome.textbox(0, 12, 18, 4) + local r = self.itemResult + for row=0,1 do + local text = r.lines[r.line+row] + if text then Chrome.print(text, 1, 14+row*2) end + end + end + if width and height then + love.graphics.origin() + return Chrome.withOverlayPanel(width, height, draw) + end + local w, h = 160, 144 + if self.uiSize then w, h = self:uiSize() end + if w ~= 160 or h ~= 144 then return Chrome.withOverlayPanel(w, h, draw) end + return draw() + end +end +return M diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index fb9caf92f..d6531a7ad 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -71,7 +71,7 @@ function PartyMenu:sgbPalettes(game) -- RedrawPartyMenu snaps it green when the message prints. Hold the -- starting HP here for exactly that window (#252). local hp = mon.hp - if self.heal and self.heal.mon == mon then hp = self.heal.from end + if self.heal and self.heal.mon == mon and self.heal.from ~= nil then hp = self.heal.from end local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp)) if bar then zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1) @@ -493,6 +493,10 @@ function PartyMenu:update(dt) self.blink = ((self.blink or 0) + 1) % 320 -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking -- predef in item_effects.asm, so no button is read until it lands (#252). + if self.itemResult then + self:updateItemResult(self.game.input) + return + end local heal = self.heal if heal then heal.shown = math.min(heal.mon.hp, @@ -1027,7 +1031,9 @@ function PartyMenu:draw() -- only .hp and .stats, so a shim table is enough and the real mon is -- never mutated for display (#252). local shown = mon - if self.heal and self.heal.mon == mon then + if self.itemResult then + shown = { hp = self:shownHpFor(i, mon), stats = mon.stats } + elseif self.heal and self.heal.mon == mon then shown = { hp = math.floor(self.heal.shown), stats = mon.stats } end love.graphics.setColor(1, 1, 1, 1) @@ -1092,4 +1098,5 @@ function PartyMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +require('src.ui.Gen2PartyItemResult').install(PartyMenu) return PartyMenu diff --git a/tests/engine/gen2_battle_pack.lua b/tests/engine/gen2_battle_pack.lua index 860e211e3..be74af9c9 100644 --- a/tests/engine/gen2_battle_pack.lua +++ b/tests/engine/gen2_battle_pack.lua @@ -8,18 +8,21 @@ local Save=require('src.core.SaveData') local Bag=require('src.inventory.Bag') local Screens=require('src.ui.Screens') local oldVersion=V.get() +local Sound=require('src.core.Sound');local soundPlay=Sound.play;local heals=0 +Sound.play=function(data,name,...) + if name=='Heal_HP'or name=='Sfx_Potion'then heals=heals+1 end + return soundPlay(data,name,...) +end -local function fixture(version) +local function fixture(version, itemId) V.set(version);Screens.invalidate() local data=T.fixtures.fresh();data.constants.gen=V.generation() - data.items.POTION={id='POTION',name='POTION',index=18,pocket='ITEM',battleMenu='ITEMMENU_PARTY'} + itemId=itemId or 'POTION' + data.items[itemId]={id=itemId,name=itemId,index=18,pocket='ITEM',battleMenu='ITEMMENU_PARTY'} require('src.render.Font').load(data) local save=Save.newGame();save.options.battleAnimations=false - -- This older upstream menu branch defaults to Gen1 unlimited enemy PP. - -- Select its existing finite-PP ruleset for the item-turn assertion. - save.options.ruleset="modern_clean" save.party={Pokemon.new(data,'FIXMON_A',30)} - Bag.add(save,'POTION',1,data) + Bag.add(save,itemId,1,data) local input={pressed={}} function input:wasPressed(key)return self.pressed[key]==true end function input:isDown()return false end @@ -93,11 +96,27 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.eq(b.player.mon.hp,1,'target cancellation changes no HP') T.eq(b.enemy.curMoves[1].pp,enemyPP,'target cancellation grants no enemy action') step('a');if game.stack:top()==pack and pack.submenu then step('a')end + picker=game.stack:top() + local beforeCue=heals step('a') -- choose the actual first party Pokemon - T.eq(game.stack:top(),b,'successful medicine returns presentation to the native battle, not a field TextBox') - local queuedText=false - for _,entry in ipairs(b.queue)do if entry.text then queuedText=true end end - T.check(queuedText,'actual medicine text is owned by the native battle queue') + T.eq(heals,beforeCue+1,'native healing plays its imported cue exactly once') + local resultOwner=game.stack:top() + T.eq(resultOwner,picker,'successful medicine retains the actual native party controller') + T.check(resultOwner.itemResult~=nil,'native party owns the result until confirmation') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'enemy cannot act before native result confirmation') + if resultOwner.itemResult then + local targetHP=b.player.mon.hp + T.eq(resultOwner.itemResult.shown,1,'native HP display starts at pre-effect HP') + T.eq(resultOwner.itemResult.target,targetHP,'native result targets the actual healed HP') + for tick=1,12 do step('a') end + T.eq(game.stack:top(),resultOwner,'early A cannot skip HP fill or native result delay') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'early confirmation cannot spend an extra enemy turn') + T.eq(b.player.mon.hp,targetHP,'presentation never rewrites real HP') + T.check(wait(function()return game.stack:top()==b end),'native acknowledgment releases only the party owner') + T.eq(b.player.shownHP,targetHP,'battle resumes with the HP already displayed by the native party result') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'enemy action starts after actual acknowledgment, not during its callback') + T.eq(b.partyItemHealing,nil,'result acknowledgment releases low-health alarm suppression') + end T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'actual item turn returns to the battle owner') T.eq(game.save.inventory.POTION,nil,'actual healing consumes exactly the owned potion') T.check(b.player.mon.hp>1,'actual native item effect heals the chosen party Pokemon') @@ -117,10 +136,53 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.eq(game.save.inventory.POTION,1,'no-effect medicine preserves inventory') T.eq(b.enemy.curMoves[1].pp,enemyPP,'no-effect medicine grants no enemy action') end +-- Other native party item families must use the same result owner. Their +-- actual effects, inventory and enemy continuation remain the shared engine's. +for _,id in ipairs({'FULL_HEAL','REVIVE','ETHER','ELIXER'})do + local b,game,step,wait,open=fixture('crystal',id) + local target=b.player.mon + target.hp=target.stats.hp;b.player.shownHP=target.hp + if id=='REVIVE'then + target=Pokemon.new(game.data,'FIXMON_B',30);target.hp=0 + game.save.party[2]=target + elseif id=='FULL_HEAL'then target.status='PSN' + else target.moves[1].pp=1 end + local enemyPP=b.enemy.curMoves[1].pp + local pack=open();step('a');if game.stack:top()==pack and pack.submenu then step('a')end + local picker=game.stack:top() + if id=='REVIVE'then step('down')end + step('a') + if id=='ETHER'then + T.neq(game.stack:top(),picker,'ETHER opens its real move selector above the same party') + step('b');T.eq(game.stack:top(),picker,'PP move cancel returns to the retained native party') + T.eq(game.save.inventory[id],1,'PP move cancellation preserves inventory') + T.eq(target.moves[1].pp,1,'PP move cancellation preserves PP') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'PP cancellation spends no enemy action') + step('a');step('a') + end + T.eq(game.stack:top(),picker,id..' keeps the original native result owner') + T.check(picker.itemResult~=nil,id..' exposes actual native result state') + T.eq(picker.party[ picker.index ],target,'result keeps the selected real party Pokemon') + T.eq(game.save.inventory[id],nil,'successful native item consumes one owned copy') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'item effect alone cannot start an enemy turn') + if picker.itemResult then + local hp=target.hp;local pp=target.moves[1].pp + for tick=1,50 do step()end + T.eq(game.stack:top(),picker,'time alone does not confirm the party result') + T.eq(target.hp,hp,'result hold preserves real HP') + T.eq(target.moves[1].pp,pp,'result hold preserves restored PP') + end + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),id..' result acknowledgment completes the native item turn') + T.eq(b.enemy.curMoves[1].pp,enemyPP-1,'acknowledgment gives exactly one native enemy action') + T.eq(game.save.inventory[id],nil,'acknowledgment cannot consume a second item') + if id=='FULL_HEAL'then T.eq(target.status,nil,'real status effect remains cured')end + if id=='REVIVE'then T.check(target.hp>0,'actual bench Pokemon remains revived')end +end for _,version in ipairs({'red','firered','emerald'})do local b,game,step,wait,open=fixture(version) local screen=open() T.neq(getmetatable(screen),Pack,version..' retains its existing bag controller') end +Sound.play=soundPlay V.set(oldVersion);Screens.invalidate() T.finish('native Gen2 battle PACK selection and item turn ownership') From 8cd70cf833919c3d141b9c78dfdeb1c3b70186e6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:52:24 +0100 Subject: [PATCH 59/68] Include native party result regression in upstream qualification --- tests/engine/gen2_party_item_result.lua | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/engine/gen2_party_item_result.lua diff --git a/tests/engine/gen2_party_item_result.lua b/tests/engine/gen2_party_item_result.lua new file mode 100644 index 000000000..6b19af98a --- /dev/null +++ b/tests/engine/gen2_party_item_result.lua @@ -0,0 +1,139 @@ +-- Contract adapted from Gen1Recomp 535a5ec0 native PartyMenu/HpBar. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Party=require('src.ui.PartyMenu') +local Pokemon=require('src.pokemon.Pokemon') +local Font=require('src.render.Font') +local Chrome=require('src.ui.Gen2Chrome') +local Hud=require('src.render.HudTiles') +local Overlay=require('src.render.NativeScreenOverlay') +local UI=require('src.render.ScreenSpaceUI') +local Stack=require('src.core.StateStack') +local Sound=require('src.core.Sound') +local oldVersion=V.get();V.set('crystal') +local data=T.fixtures.fresh();Font.load(data) +data.audio=data.audio or{};data.audio.sfx=data.audio.sfx or{} +data.audio.sfx.Sfx_Potion='fixture-native-potion.wav' +local input={pressed={}} +function input:wasPressed(k)return self.pressed[k]==true end +local game={data=data,save={party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',20)}},input=input} +game.stack=setmetatable({},{__index=Stack});game.stack:init() +local menu=Party.new(game,{pickOnly=true,keepOpen=true});game.stack:push(menu) +T.eq(type(menu.showItemResult),'function','actual PartyMenu offers native result owner') +if type(menu.showItemResult)=='function'then + for _,maxHp in ipairs({19,47,48,49,96,301})do + local mon=menu.party[1];mon.stats.hp=maxHp;mon.hp=maxHp + local pp=mon.moves[1].pp;local done=0 + menu:showItemResult(1,{fromHp=0,toHp=maxHp,text='NATIVE RESULT',onDone=function()done=done+1 end}) + local result=menu.itemResult + T.eq(menu.heal,result,'one actual HP hold prevents a second animation/input clock') + T.eq(menu:shownHpFor(1,mon),0,'zero pre-revive HP is a real display value') + local ticks=0;local stride=maxHp>=48 and math.ceil(maxHp/48)or 1 + input.pressed={a=true} + while menu:itemResultClimbing()and ticks<400 do + local before=result.shown;menu:update(1/60);ticks=ticks+1 + T.eq(result.shown,math.min(maxHp,before+stride),'native HP step matches reference at this max HP') + T.eq(mon.hp,maxHp,'bar stepping never fabricates gameplay HP') + T.eq(mon.moves[1].pp,pp,'bar stepping cannot spend PP') + T.eq(done,0,'early A cannot acknowledge a climbing bar') + end + T.eq(ticks,math.ceil(maxHp/stride),'native fill duration follows HP step count') + T.eq(menu.itemResult.delay,50,'fill does not spend any native message hold') + T.eq(menu:wantsNativeOverlay(),true,'result text becomes visible after the bar lands') + for i=1,50 do menu:update(1/60)end + T.eq(done,0,'A during the 50-frame delay cannot skip the hold') + input.pressed={};menu:update(1/60) + T.eq(done,0,'elapsed delay alone does not confirm') + input.pressed={b=true};menu:update(1/60);input.pressed={} + T.eq(done,1,'native B acknowledges once after the full delay') + T.eq(menu.itemResult,nil,'acknowledgment retires native result owner') + T.eq(menu.heal,nil,'same acknowledgment retires shared HP hold') + T.eq(game.stack:top(),menu,'controller callback alone owns screen lifetime') + end + local done=0 + menu:showItemResult(1,{fromHp=5,toHp=2,auto=true,onDone=function()done=done+1 end}) + for i=1,3 do menu:update(1/60)end + T.eq(done,0,'auto result still waits for all downward HP steps') + menu:update(1/60);T.eq(done,1,'auto result completes on the tick after fill') + menu:showItemResult(2,{text='STATUS RESULT',delay=0,holdSlot=1,holdHp=0,onDone=function()done=done+1 end}) + T.eq(menu:shownHpFor(1,menu.party[1]),0,'held other slot preserves explicit zero') + T.eq(menu:shownHpFor(2,menu.party[2]),menu.party[2].hp,'status result uses actual HP when no animation requested') + input.pressed={a=true};menu:update(1/60);input.pressed={} + T.eq(done,2,'zero delay respects immediate native confirmation') + local pages=0 + menu:showItemResult(1,{text='FIRST ROW\nSECOND ROW\nTHIRD ROW',delay=0,onDone=function()pages=pages+1 end}) + input.pressed={a=true};menu:update(1/60);input.pressed={} + T.eq(pages,0,'long native result cannot lose its final rows or release the enemy early') + T.eq(menu.itemResult.line,3,'native result acknowledgment advances to remaining text') + T.eq(menu.itemResult.delay,50,'remaining native page receives its own confirmation hold') + for i=1,50 do menu:update(1/60)end + input.pressed={b=true};menu:update(1/60);input.pressed={} + T.eq(pages,1,'last native page acknowledgment releases exactly one continuation') + local played={};local soundPlay=Sound.play + Sound.play=function(actual,name)T.eq(actual,data,'native cue uses actual imported data');played[#played+1]=name end + menu:showItemResult(1,{text='HP RESTORED',sfx='Sfx_Potion'}) + T.same(played,{'Sfx_Potion'},'native result cue uses existing Sound.play with no invented resolver') + menu:playSfx('missing');T.eq(#played,1,'missing optional native cue is not invented') + Sound.play=soundPlay + -- Native party rows must consume the real result's shown HP, not change the mon. + local hp=menu.party[1].hp + menu:showItemResult(1,{fromHp=0,toHp=hp,text='HP RESTORED'}) + local bars={};local drawBar=Hud.drawHPBar + Hud.drawHPBar=function(d,x,y,mon,...)bars[#bars+1]=mon.hp;return drawBar(d,x,y,mon,...)end + menu:draw();Hud.drawHPBar=drawBar + T.eq(bars[1],0,'actual native row draws zero displayed HP during revive') + T.eq(menu.party[1].hp,hp,'drawing cannot replace real HP') + -- A replaceable view may reset origin and omit feedback. The engine's + -- controller-owned pass must still paint it once, before modal children. + menu:showItemResult(1,{text='HP RESTORED\nNATIVE CONFIRM'}) + local print=Chrome.print;local box=Chrome.textbox;local panel=Chrome.withOverlayPanel + local trace={};Chrome.print=function(text)trace[#trace+1]=text end + Chrome.textbox=function()trace[#trace+1]='native-box'end + menu.draw=function()trace[#trace+1]='custom-view';love.graphics.origin()end + menu.uiSize=function()return 512,288 end + local dimensions={};Chrome.withOverlayPanel=function(w,h,draw)dimensions={w,h};draw()end + Overlay.screen(menu) + T.same(trace,{'custom-view','native-box','HP RESTORED','NATIVE CONFIRM'},'engine paints native confirmation after the replaceable party view') + T.same(dimensions,{512,288},'classic expanded view uses its actual declared surface') + trace={};game.stack:draw() + T.same(trace,{'custom-view','native-box','HP RESTORED','NATIVE CONFIRM'},'actual state stack paints feedback exactly once') + local front={draw=function()trace[#trace+1]='modal-child'end};game.stack:push(front) + trace={};game.stack:draw() + T.eq(trace[#trace],'modal-child','native modal children stay above controller feedback') + game.stack:pop() + local viewport={width=1280,height=960} + local renderer={WIDTH=512,HEIGHT=288,uiPresentation={x=0,y=0,scaleX=1,scaleY=1}} + function renderer:uiSize()return 512,288 end + trace={};UI.drawNativeStack(game.stack,game.stack,1,nil,renderer,viewport,false) + T.same(dimensions,{1280,960},'native full-resolution pass uses actual window dimensions') + T.same(trace,{'custom-view','native-box','HP RESTORED','NATIVE CONFIRM'},'native full-resolution pass paints feedback once') + menu.drawWidescreen=function()trace[#trace+1]='custom-window'end + trace={};UI.draw(menu,renderer,viewport,nil,false) + T.same(trace,{'custom-window','native-box','HP RESTORED','NATIVE CONFIRM'},'window-resolution owner cannot drop native confirmation') + trace={};local hidden={renderVisible=function()return false end} + UI.drawNativeStack(hidden,game.stack,1,nil,renderer,viewport,false) + T.eq(#trace,0,'hidden owner cannot resurrect feedback') + -- Guard both view leaks and native feedback errors, including leaked pushes. + local g=love.graphics;local canvas=g.newCanvas(9,11);local other=g.newCanvas(5,7) + g.setCanvas(canvas);g.setColor(.2,.3,.4,.5) + menu.draw=function()g.push('all');g.setCanvas(other);g.setColor(0,0,0,1)end + Overlay.screen(menu) + T.eq(g.getCanvas(),canvas,'view leaking a canvas cannot steal confirmation output') + T.same({g.getColor()},{.2,.3,.4,.5},'view tint/push cannot leak into caller') + menu.drawWidescreen=menu.draw + Overlay.window(menu,1280,960) + T.eq(g.getCanvas(),canvas,'window view leaking a canvas cannot steal native feedback') + T.same({g.getColor()},{.2,.3,.4,.5},'window view restores tint and leaked pushes') + Chrome.textbox=function()g.push('all');g.setCanvas(other);error('native result draw failure')end + T.raises(function()Overlay.screen(menu)end,'native result draw failure','native feedback errors remain observable') + T.raises(function()UI.draw(menu,renderer,viewport,nil,false)end,'native result draw failure','window feedback errors remain observable') + T.eq(g.getCanvas(),canvas,'failed feedback restores actual caller target') + T.same({g.getColor()},{.2,.3,.4,.5},'failed feedback restores tint and leaked pushes') + Chrome.print=print;Chrome.textbox=box;Chrome.withOverlayPanel=panel + g.setCanvas();g.setColor(1,1,1,1) +end +V.set(oldVersion) +T.finish('native Gen2 party item result, timing, input and render ownership') From 463ce6038011c63eae3b903472ec8251229e6139 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:52:44 +0100 Subject: [PATCH 60/68] Prove item result regression against preceding upstream adaptation --- .../workflows/gen2-queue-screen-contract.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index b951ed87d..9138b9f25 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -15,6 +15,25 @@ jobs: - uses: actions/checkout@v7 - name: Install ROM-free runtime run: sudo apt-get update && sudo apt-get install -y luajit + - name: Previous native source exposes the item-result regressions + shell: bash + run: | + git -c gc.auto=0 fetch --no-tags --depth=1 origin 2727365b0c38495717c688708d521cf8150cfec4 + baseline="$RUNNER_TEMP/gen2-party-result-baseline" + mkdir -p "$baseline" + git archive 2727365b0c38495717c688708d521cf8150cfec4 src tests data tools examples | tar -x -C "$baseline" + cp src/render/NativeScreenOverlay.lua "$baseline/src/render/NativeScreenOverlay.lua" + cp src/ui/Gen2PartyItemResult.lua "$baseline/src/ui/Gen2PartyItemResult.lua" + cp tests/engine/gen2_battle_pack.lua tests/engine/gen2_party_item_result.lua "$baseline/tests/engine/" + for suite in gen2_party_item_result gen2_battle_pack; do + if (cd "$baseline" && luajit "tests/engine/$suite.lua") > "$RUNNER_TEMP/$suite-baseline.log" 2>&1; then + cat "$RUNNER_TEMP/$suite-baseline.log" + echo "Previous source unexpectedly passed $suite" + exit 1 + fi + cat "$RUNNER_TEMP/$suite-baseline.log" + grep -q '^FAIL' "$RUNNER_TEMP/$suite-baseline.log" + done - name: Native queue, screen and available menu owners run: | luajit tests/engine/gen2_battle_queue_view.lua From 89e265a5aed1a3e9effda1e1be0745a80655b95a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:00:07 +0100 Subject: [PATCH 61/68] Resolve native audio owner and collect every candidate suite --- .../workflows/gen2-queue-screen-contract.yml | 17 +++++------------ src/battle/BattleState.lua | 2 +- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index 9138b9f25..edb7617ca 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -36,15 +36,8 @@ jobs: done - name: Native queue, screen and available menu owners run: | - luajit tests/engine/gen2_battle_queue_view.lua - luajit tests/engine/gen2_battle_window.lua - luajit tests/engine/gen2_battle_scene.lua - luajit tests/engine/wide_battle_shake_bug562.lua - luajit tests/engine/gen2_battle_pack.lua - luajit tests/engine/gen2_party_item_result.lua - luajit tests/engine/screen_space_ui.lua - luajit tests/engine/party_live_data.lua - luajit tests/engine/gen2_battle_live_view.lua - luajit tests/engine/gen2_battle_submenu.lua - luajit tests/engine/gen2_summary_contract.lua - luajit tests/engine/frlg_battle_gender.lua + failed=0 + for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do + if ! luajit "tests/engine/$suite.lua"; then failed=1; fi + done + exit "$failed" diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5bbf810d8..6df3664f4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10056,7 +10056,7 @@ end function BattleState:beginPartyItemHeal() self.partyItemHealing = true self.lowHealthAlarmOn = false - Sound.stopLoop("Low_Health_Alarm") + require("src.core.Sound").stopLoop("Low_Health_Alarm") end function BattleState:finishPartyItemTurn(itemId, mon) From 03c0534ec13607d4d36ee094e09c81308c0580bd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:30:06 +0100 Subject: [PATCH 62/68] Restore native Gen2 PP selection using available upstream producers --- .../workflows/gen2-queue-screen-contract.yml | 7 +- docs/native-gen2-pp-selection.md | 34 +++++ src/battle/BattleState.lua | 2 +- src/battle/HeldItems.lua | 2 +- src/battle/WideBattle.lua | 2 +- src/inventory/ItemEffects.lua | 14 +- src/link/Protocol.lua | 2 +- src/pokemon/Gen2PP.lua | 28 ++++ src/pokemon/Pokemon.lua | 11 +- src/script/Gen2Commands.lua | 2 +- src/ui/BagMenu.lua | 16 +- src/ui/Gen2ForgetMoveList.lua | 44 ++++++ src/ui/Gen2MoveDeleter.lua | 137 ++++++++++++++++++ tests/engine/gen2_battle_pack.lua | 5 +- tests/engine/gen2_party_item_result.lua | 1 + tests/engine/gen2_pp_selection.lua | 110 ++++++++++++++ 16 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 docs/native-gen2-pp-selection.md create mode 100644 src/pokemon/Gen2PP.lua create mode 100644 src/ui/Gen2ForgetMoveList.lua create mode 100644 src/ui/Gen2MoveDeleter.lua create mode 100644 tests/engine/gen2_pp_selection.lua diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index edb7617ca..78167938f 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -24,8 +24,11 @@ jobs: git archive 2727365b0c38495717c688708d521cf8150cfec4 src tests data tools examples | tar -x -C "$baseline" cp src/render/NativeScreenOverlay.lua "$baseline/src/render/NativeScreenOverlay.lua" cp src/ui/Gen2PartyItemResult.lua "$baseline/src/ui/Gen2PartyItemResult.lua" + cp src/ui/Gen2MoveDeleter.lua src/ui/Gen2ForgetMoveList.lua "$baseline/src/ui/" + cp src/pokemon/Gen2PP.lua "$baseline/src/pokemon/Gen2PP.lua" + cp tests/engine/gen2_pp_selection.lua "$baseline/tests/engine/gen2_pp_selection.lua" cp tests/engine/gen2_battle_pack.lua tests/engine/gen2_party_item_result.lua "$baseline/tests/engine/" - for suite in gen2_party_item_result gen2_battle_pack; do + for suite in gen2_party_item_result gen2_battle_pack gen2_pp_selection; do if (cd "$baseline" && luajit "tests/engine/$suite.lua") > "$RUNNER_TEMP/$suite-baseline.log" 2>&1; then cat "$RUNNER_TEMP/$suite-baseline.log" echo "Previous source unexpectedly passed $suite" @@ -37,7 +40,7 @@ jobs: - name: Native queue, screen and available menu owners run: | failed=0 - for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do + for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result gen2_pp_selection screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do if ! luajit "tests/engine/$suite.lua"; then failed=1; fi done exit "$failed" diff --git a/docs/native-gen2-pp-selection.md b/docs/native-gen2-pp-selection.md new file mode 100644 index 000000000..65986c23a --- /dev/null +++ b/docs/native-gen2-pp-selection.md @@ -0,0 +1,34 @@ +# Native Gen2 PP selection and maximum + +The Gen2 party-target ETHER, MAX ETHER, MYSTERYBERRY and PP UP route uses the native `Gen2MoveDeleter` screen from +Gen1Recomp 535a5ec0. It retains the actual party mon and moves. The ordinary +PP cursor stops at both ends; the native forget layout wraps. Only the +caller's normal choose/cancel continuation controls stack lifetime. The +move-list window preserves the party underneath, and B drops only that list. +Field PP selection also keeps its party parent until its existing result +flow closes it. No consumer files need changing. + +`Gen2PP` owns Gen2's `ComputeMaxPP` arithmetic. Each PP Up adds +`min(floor(basePP / 5), 7)`, so a move with 40 base PP and three PP Ups has +61 maximum PP. Legacy slots with a valid stored `maxPp` infer their existing +bonus without a mutation during reads. PP Up refuses Sketch and a fourth +bonus. Shared maximum readers accept an explicit destination generation for +cross-game reconstruction, while other generations retain their existing +maximum rule. + +Item restoration, PP Up, party healing, daycare retrieval, classic/wide battle +labels, summary, held-item restoration and link reconstruction use the common +maximum. Fork-only public records and Bank modules are not present in this +upstream branch; this adaptation does not invent or claim those interfaces. + +The native move-list and forget-list layouts preserve reference provenance. +Sound uses the existing imported cue and actual Sound.play service; the +reference's unavailable Sound.resolve is not introduced. + +Contracts exercise the real screen registry, native input, bounds and forget +wrapping, cartridge drawing coordinates, callback/cue lifetime and immutable +party reads; actual item effects, native maxima/legacy counters, healing, +link clamping. The battle PACK suite additionally +requires its real ETHER route to open this controller. Qualification results, +including baseline failures and unchanged-client GPU acceptance, must be +recorded separately; the presence of contracts does not prove full parity. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6df3664f4..529385c9c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -12919,7 +12919,7 @@ function BattleState:drawTextAreaInner() -- the type record's display name (a mod type shows its name, and -- PSYCHIC_TYPE prints PSYCHIC like the original) Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80) - local maxPP = def.pp + (sel.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(def, sel) Font.draw(("%2d/%2d"):format(sel.pp, maxPP), 40, 88) end end diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index 195f4ad0b..9b5a559b1 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -387,7 +387,7 @@ local function restorePP(data, holder) local def = data.moves and data.moves[move.id] local base = def and tonumber(def.pp) local maxPP = base - if base then maxPP = base + (tonumber(move.ppUps) or 0) * math.floor(base / 5) end + if base then maxPP = require("src.pokemon.Pokemon").maxPP(def,move,data.constants and data.constants.gen) end local amount = norm(move.id) == "SKETCH" and 1 or 5 move.pp = maxPP and math.min(maxPP, amount) or amount if holder.curMoves and holder.curMoves ~= mon.moves and holder.curMoves[i] then diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 56515824a..79a4efb9e 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -326,7 +326,7 @@ local function drawMoveDetails(battle, move) if not move then return end local def = battle.data.moves[move.id] if not def then return end - local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(def, move) love.graphics.setColor(0, 0, 0, 1) Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112) Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index c9119139b..8e6fd9e3b 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -1118,7 +1118,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local amount = itemId == "MYSTERYBERRY" and 5 or 10 local function restore(mv) local mdef = data.moves[mv.id] - local maxPP = mdef and (mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)) + local maxPP = mdef and require("src.pokemon.Pokemon").maxPP(mdef, mv) if maxPP and mv.pp < maxPP then mv.pp = full and maxPP or math.min(maxPP, mv.pp + amount) return true @@ -1316,10 +1316,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if not target then return "failed", { Strings("It won't have\nany effect.") } end local mv = target.moves[moveIndex or 1] local mdef = mv and data.moves[mv.id] - if mdef and (mv.ppUps or 0) < 3 then - mv.ppUps = (mv.ppUps or 0) + 1 - -- each PP UP adds maxPP/5 uses on top of the base maximum - mv.pp = mv.pp + math.floor(mdef.pp / 5) + local gen2 = require("src.core.GameVersion").isGen2() + local ups = gen2 and require("src.pokemon.Gen2PP").ups(mdef, mv) or mv and (mv.ppUps or 0) or 0 + if mdef and ups < 3 and not (gen2 and mv.id == "SKETCH") then + mv.ppUps = ups + 1 + local bonus = math.floor(mdef.pp / 5) + if gen2 then bonus = require("src.pokemon.Gen2PP").bonus(mdef.pp) end + mv.pp = mv.pp + bonus + if gen2 then mv.maxPp = require("src.pokemon.Pokemon").maxPP(mdef, mv, 2) end return "consumed", { Strings("%s's PP\nincreased!", mdef.name) } end return "failed", { Strings("It won't have\nany effect.") } diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 787c37899..0e8398c8c 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -114,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts) local mdef = data.moves[mv.id] if mdef and #moves < 4 then local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) - local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(mdef, { ppUps=ppUps }, data.constants and data.constants.gen) local entry = { id = mv.id, pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) } if mv.ppUps ~= nil then entry.ppUps = ppUps end diff --git a/src/pokemon/Gen2PP.lua b/src/pokemon/Gen2PP.lua new file mode 100644 index 000000000..9711420e3 --- /dev/null +++ b/src/pokemon/Gen2PP.lua @@ -0,0 +1,28 @@ +-- Native Gen2 PP arithmetic from Gen1Recomp 535a5ec0 Mon.lua. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +local P = {} +function P.bonus(base) return math.min(math.floor(base / 5), 7) end +function P.ups(def, slot) + if type(slot) ~= 'table' then return 0 end + local ups = tonumber(slot.ppUps) + if ups == nil then + local base, stored = def and tonumber(def.pp), tonumber(slot.maxPp) + ups = 0 + if base and stored and stored > base then + local bonus = P.bonus(base) + if bonus > 0 and (stored - base) % bonus == 0 then + ups = (stored - base) / bonus + end + end + end + return math.max(0, math.min(3, math.floor(ups))) +end +function P.maximum(def, slot) + local base = def and tonumber(def.pp) + if not base then + return type(slot) == 'table' and (tonumber(slot.maxPp) or tonumber(slot.pp)) or 0 + end + return base + P.ups(def, slot) * P.bonus(base) +end +return P diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 17c5685c0..e51f49d51 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -2,6 +2,7 @@ local Growth = require("src.pokemon.Growth") local Stats = require("src.pokemon.Stats") +local Gen2PP = require("src.pokemon.Gen2PP") local Pokemon = {} @@ -450,7 +451,15 @@ end -- Pokémon Center / blackout heal (engine/events/heal_party.asm -- HealParty): full HP, status cleared, and every move's PP restored to -- its base plus the PP-Up bonus (RestoreBonusPP adds maxPP/5 per PP UP). -function Pokemon.maxPP(def, slot) +function Pokemon.ppUps(def, slot, generation) + generation = tonumber(generation) or require('src.core.GameVersion').generation() + if generation == 2 then return Gen2PP.ups(def, slot) end + return type(slot)=='table' and math.min(3,math.max(0,math.floor(tonumber(slot.ppUps) or 0))) or 0 +end + +function Pokemon.maxPP(def, slot, generation) + generation = tonumber(generation) or require('src.core.GameVersion').generation() + if generation == 2 then return Gen2PP.maximum(def, slot) end local base = math.max(0,tonumber(def and def.pp) or 0) local ups = type(slot)=='table' and math.min(3,math.max(0,math.floor(tonumber(slot.ppUps) or 0))) or 0 return base + ups * math.floor(base / 5) diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 0cfdbed7e..da1451f84 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -4420,7 +4420,7 @@ function Gen2Commands.dayCareWithdraw(ctx, which) mon.status = nil for _, move in ipairs(mon.moves or {}) do local moveDef = game.data.moves and game.data.moves[move.id] - if moveDef then move.pp = moveDef.pp + (move.ppUps or 0) * math.floor(moveDef.pp / 5) end + if moveDef then move.pp = Pokemon.maxPP(moveDef, move, 2) end end end table.insert(save.party, mon) diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 27a42b768..e397cfe5c 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -783,13 +783,14 @@ local function pickTargetAndUse(game, battle, id, list, options) local def = game.data.items[id] local key = ItemEffects.alias(id, def) local wantsMove = key == "ETHER" or key == "MAX_ETHER" or key == "PP_UP" + or (key == "MYSTERYBERRY" and require("src.core.GameVersion").isGen2()) local opts = { pickOnly = true, battle = require("src.core.GameVersion").isGen2() and battle or nil, onCancel = options and options.onCancel, -- Native Gen2 keeps the same party owner through its result and -- confirmation. Other editions retain their existing field flow. - keepOpen = (battle and require("src.core.GameVersion").isGen2()) + keepOpen = ((battle or wantsMove) and require("src.core.GameVersion").isGen2()) or ((not battle) and (ItemEffects.healsHP(id) or (id == "RARE_CANDY" and require("src.core.GameVersion").isFRLG()))), onSwitch = function(mon, picker) @@ -797,6 +798,19 @@ local function pickTargetAndUse(game, battle, id, list, options) useOn(game, battle, id, mon, list, nil, picker) return end + if require("src.core.GameVersion").isGen2() then + -- RestorePPEffect uses ChooseMoveToDelete's real native four-slot + -- window. Cancel drops only this move list and preserves the party. + require("src.ui.Screens").push(game, "Gen2MoveDeleter", { + mon = mon, moves = game.data.moves, + onCancel = function() game.stack:pop() end, + onChoose = function(slot) + game.stack:pop() + useOn(game, battle, id, mon, list, slot, picker) + end, + }) + return + end local rows = {} for mi, mv in ipairs(mon.moves) do local mdef = game.data.moves[mv.id] diff --git a/src/ui/Gen2ForgetMoveList.lua b/src/ui/Gen2ForgetMoveList.lua new file mode 100644 index 000000000..79dfa770d --- /dev/null +++ b/src/ui/Gen2ForgetMoveList.lua @@ -0,0 +1,44 @@ +-- Adapted from Gen1Recomp 535a5ec0 native Gen2 ForgetMoveList. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- engine/pokemon/learn.asm:135-166 + +local Chrome = require("src.ui.Gen2Chrome") + +local ForgetMoveList = { + x = 5, y = 2, interiorW = 13, interiorH = 8, + cursorX = 6, nameX = 7, y0 = 4, step = 2, rows = 4, +} + +function ForgetMoveList.rowY(slot) + return ForgetMoveList.y0 + (slot - 1) * ForgetMoveList.step +end + +function ForgetMoveList.label(entry, moveData) + if not entry then return "-" end + local def = moveData and moveData[entry.id] + return (def and def.name) or entry.id +end + +function ForgetMoveList.draw(moves, cursor, moveData, palette) + Chrome.textbox(ForgetMoveList.x, ForgetMoveList.y, + ForgetMoveList.interiorW, ForgetMoveList.interiorH) + for slot = 1, ForgetMoveList.rows do + local ty = ForgetMoveList.rowY(slot) + local label = ForgetMoveList.label(moves and moves[slot], moveData) + if palette then + Chrome.printThrough(label, ForgetMoveList.nameX, ty, palette) + else + Chrome.print(label, ForgetMoveList.nameX, ty) + end + if slot == cursor then + if palette then + Chrome.cursorThrough(ForgetMoveList.cursorX, ty, palette) + else + Chrome.cursor(ForgetMoveList.cursorX, ty) + end + end + end +end + +return ForgetMoveList diff --git a/src/ui/Gen2MoveDeleter.lua b/src/ui/Gen2MoveDeleter.lua new file mode 100644 index 000000000..a1c1a536f --- /dev/null +++ b/src/ui/Gen2MoveDeleter.lua @@ -0,0 +1,137 @@ +-- Adapted from Gen1Recomp 535a5ec0 native Gen2 MoveDeleter. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- The Blackthorn move deleter's move list (engine/events/move_deleter.asm +-- ChooseMoveToDelete, engine/pokemon/mon_menu.asm). ChooseMoveToDelete shares +-- SetUpMoveScreenBG / SetUpMoveList with the summary screen's move page +-- (src/ui/gen2/SummaryMenu.lua moveDetailPlacements), so the four-slot list +-- geometry is the same box: names at (2, 3 + 2*slot), PP at +-- (10/13/15/16, nameY + 1). What ChooseMoveToDelete does NOT share is the +-- type/power/accuracy plaque under it -- DeleteMoveScreen2DMenuData is a bare +-- `db 3, 1` / `dn 2, 0` menu (3 rows, cursor offset column 2), so only the +-- move list itself is drawn here. +-- +-- The special that opens this (src/script/gen2/Specials.lua H.MoveDeletion) +-- has already run the "which move should it forget" line and refused a mon +-- with only one move before this screen is ever pushed, so this only has to +-- answer a row. + +local Chrome = require("src.ui.Gen2Chrome") +local ForgetMoveList = require("src.ui.Gen2ForgetMoveList") +local Pokemon = require("src.pokemon.Pokemon") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local MoveDeleter = {} +MoveDeleter.__index = MoveDeleter +MoveDeleter.isOpaque = false + +function MoveDeleter:wantsFillScale() return true end + +-- opts: mon (the party mon whose moves are listed), moves (data.moves), +-- onChoose(index) -- 1-based move slot, onCancel() +function MoveDeleter.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MoveDeleter) + self.game = game + local data = (game and game.data) or {} + self.forget = opts.layout == "forget" + self.mon = opts.mon + self.moves = opts.moves or data.moves + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + -- Shared battlers keep transformed moves in curMoves; party mon.moves is + -- already the actual original party list used by ItemEffects. + self.list = self.mon and self.mon.moves or {} + self.row = 1 + return self +end + +function MoveDeleter:moveName(entry) + if not entry then return "-" end + local def = self.moves and self.moves[entry.id] + return (def and def.name) or entry.id +end + +function MoveDeleter:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then + Sound.play(data, name) + end +end + +function MoveDeleter:finish(index) + if self.done then return end + self.done = true + if index then + if self.onChoose then self.onChoose(index) end + elseif self.onCancel then + self.onCancel() + end +end + +-- ScrollingMenuJoypad, no wrap: DeleteMoveScreen2DMenuData sets no +-- _2DMENU_WRAP bit, so the cursor stops at the ends the way every other +-- unwrapped 2D menu in Gold does. +function MoveDeleter:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + local n = #self.list + if n == 0 then + if input:wasPressed("a") or input:wasPressed("b") then self:finish(nil) end + return + end + -- engine/pokemon/learn.asm:160 + if input:wasPressed("up") then + if self.row > 1 then + self.row = self.row - 1 + elseif self.forget then + self.row = n + end + elseif input:wasPressed("down") then + if self.row < n then + self.row = self.row + 1 + elseif self.forget then + self.row = 1 + end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:finish(self.row) + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:finish(nil) + end +end + +function MoveDeleter:draw() + if self.forget then + ForgetMoveList.draw(self.list, self.row, self.moves) + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.textbox(0, 1, 18, 9) + for slot = 1, 4 do + local nameY = 3 + (slot - 1) * 2 + local ppY = nameY + 1 + local entry = self.list[slot] + if entry then + Chrome.print(self:moveName(entry), 2, nameY) + Chrome.print(Strings("PP"), 10, ppY) + Chrome.print(Chrome.number(entry.pp, 2, true), 13, ppY) + Chrome.print("/", 15, ppY) + Chrome.print(Chrome.number(Pokemon.maxPP(self.moves and self.moves[entry.id], entry, 2), + 2, true), 16, ppY) + else + Chrome.print("-", 2, nameY) + Chrome.print("--", 10, ppY) + end + end + if self.row >= 1 and self.row <= #self.list then + Chrome.cursor(1, 3 + (self.row - 1) * 2) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return MoveDeleter diff --git a/tests/engine/gen2_battle_pack.lua b/tests/engine/gen2_battle_pack.lua index be74af9c9..19f5c0d73 100644 --- a/tests/engine/gen2_battle_pack.lua +++ b/tests/engine/gen2_battle_pack.lua @@ -138,7 +138,7 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do end -- Other native party item families must use the same result owner. Their -- actual effects, inventory and enemy continuation remain the shared engine's. -for _,id in ipairs({'FULL_HEAL','REVIVE','ETHER','ELIXER'})do +for _,id in ipairs({'FULL_HEAL','REVIVE','ETHER','ELIXER','MYSTERYBERRY'})do local b,game,step,wait,open=fixture('crystal',id) local target=b.player.mon target.hp=target.stats.hp;b.player.shownHP=target.hp @@ -152,8 +152,9 @@ for _,id in ipairs({'FULL_HEAL','REVIVE','ETHER','ELIXER'})do local picker=game.stack:top() if id=='REVIVE'then step('down')end step('a') - if id=='ETHER'then + if id=='ETHER'or id=='MYSTERYBERRY'then T.neq(game.stack:top(),picker,'ETHER opens its real move selector above the same party') + T.eq(game.stack:top().screenId,'Gen2MoveDeleter','native ETHER uses the cartridge move-list controller') step('b');T.eq(game.stack:top(),picker,'PP move cancel returns to the retained native party') T.eq(game.save.inventory[id],1,'PP move cancellation preserves inventory') T.eq(target.moves[1].pp,1,'PP move cancellation preserves PP') diff --git a/tests/engine/gen2_party_item_result.lua b/tests/engine/gen2_party_item_result.lua index 6b19af98a..5250eed61 100644 --- a/tests/engine/gen2_party_item_result.lua +++ b/tests/engine/gen2_party_item_result.lua @@ -54,6 +54,7 @@ if type(menu.showItemResult)=='function'then T.eq(game.stack:top(),menu,'controller callback alone owns screen lifetime') end local done=0 + menu.party[1].stats.hp=19;menu.party[1].hp=19 -- this case requires one-HP native steps menu:showItemResult(1,{fromHp=5,toHp=2,auto=true,onDone=function()done=done+1 end}) for i=1,3 do menu:update(1/60)end T.eq(done,0,'auto result still waits for all downward HP steps') diff --git a/tests/engine/gen2_pp_selection.lua b/tests/engine/gen2_pp_selection.lua new file mode 100644 index 000000000..3b82a1789 --- /dev/null +++ b/tests/engine/gen2_pp_selection.lua @@ -0,0 +1,110 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Pokemon=require('src.pokemon.Pokemon') +local Effects=require('src.inventory.ItemEffects') +local Screens=require('src.ui.Screens') +local Chrome=require('src.ui.Gen2Chrome') +local Sound=require('src.core.Sound') +local Protocol=require('src.link.Protocol') +local oldVersion=V.get() +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + V.set(version);Screens.invalidate() + local data=T.fixtures.fresh();data.constants.gen=2;require('src.render.Font').load(data) + local mon=Pokemon.new(data,'FIXMON_A',30) + local game={data=data,save={party={mon}},input={pressed={}},stack={states={}}} + function game.input:wasPressed(key)return self.pressed[key]==true end + function game.stack:top()return self.states[#self.states]end + function game.stack:push(state)self.states[#self.states+1]=state end + function game.stack:pop()return table.remove(self.states)end + mon.moves={{id='FIX_TACKLE',pp=9,ppUps=2},{id='FIX_SCRATCH',pp=3,ppUps=1}} + local picked,cancelled=0,0 + local menu=Screens.build(game,'Gen2MoveDeleter',{mon=mon,moves=data.moves, + onChoose=function(slot)picked=slot end,onCancel=function()cancelled=cancelled+1 end}) + T.eq(menu.mon,mon,version..' screen registry resolves the actual native move controller') + T.eq(menu.list,mon.moves,'native selector reads the actual party move list') + if menu.mon==mon and menu.list==mon.moves then + local function press(key)game.input.pressed={[key]=true};menu:update(1/60);game.input.pressed={}end + local hp,pp=mon.hp,mon.moves[1].pp + press('up');T.eq(menu.row,1,'native PP selector stops at the first row') + press('down');T.eq(menu.row,2,'ordinary native input selects the second actual move') + press('down');T.eq(menu.row,2,'native PP selector stops at the last row') + local trace={};local print,box,cursor=Chrome.print,Chrome.textbox,Chrome.cursor + Chrome.print=function(text,x,y)trace[#trace+1]={text,x,y}end + Chrome.textbox=function(...)trace.box={...}end + Chrome.cursor=function(...)trace.cursor={...}end + menu:draw() + T.same(trace.box,{0,1,18,9},'native move list uses cartridge SetUpMoveList geometry') + T.same(trace.cursor,{1,5},'native cursor tracks the actual selected second row') + T.same(trace[1],{data.moves.FIX_TACKLE.name,2,3},'first move uses native name placement') + T.same(trace[6],{data.moves.FIX_SCRATCH.name,2,5},'second move uses native name placement') + Chrome.print,Chrome.textbox,Chrome.cursor=print,box,cursor + T.eq(mon.hp,hp,'drawing a PP choice cannot heal') + T.eq(mon.moves[1].pp,pp,'browsing a PP choice cannot spend or restore PP') + data.audio=data.audio or{};data.audio.sfx=data.audio.sfx or{};data.audio.sfx.Sfx_ReadText2='fixture-native-select.wav' + local cue=0;local play=Sound.play + Sound.play=function(actual,name)T.eq(actual,data,'native selection uses actual imported audio');T.eq(name,'Sfx_ReadText2','native selection uses the real cue');cue=cue+1 end + press('a');press('a');T.eq(picked,2,'selection returns actual move slot once');T.eq(cue,1,'completed selection cannot replay its cue') + Sound.play=play + menu=Screens.build(game,'Gen2MoveDeleter',{mon=mon,onCancel=function()cancelled=cancelled+1 end}) + game.input.pressed={b=true};menu:update(1/60);menu:update(1/60);game.input.pressed={} + T.eq(cancelled,1,'native cancel continuation fires exactly once') + menu=Screens.build(game,'Gen2MoveDeleter',{mon=mon,layout='forget'}) + game.input.pressed={up=true};menu:update(1/60);game.input.pressed={} + T.eq(menu.row,2,'native forget layout alone wraps upward') + game.input.pressed={down=true};menu:update(1/60);game.input.pressed={} + T.eq(menu.row,1,'native forget layout alone wraps downward') + T.check(pcall(function()menu:draw()end),'native forget layout draws its actual four-slot window') + end + for _,base in ipairs({5,10,15,20,25,30,35,40})do + local def={pp=base};local bonus=math.min(math.floor(base/5),7) + for ups=0,3 do + local slot={pp=0,ppUps=ups};local expected=base+ups*bonus + T.eq(Pokemon.maxPP(def,slot),expected,'native maximum follows ComputeMaxPP, including capped high-base bonus') + T.eq(Pokemon.ppUps and Pokemon.ppUps(def,slot),ups,'native PP-Up reader uses the real counter') + local stored={pp=0,maxPp=expected} + T.eq(Pokemon.maxPP(def,stored),expected,'legacy stored maximum infers the existing native bonus') + T.eq(stored.ppUps,nil,'maximum read never migrates or mutates the saved counter') + end + end + data.moves.FIX_TACKLE.pp=40 + mon.moves={{id='FIX_TACKLE',pp=59,ppUps=3},{id='FIX_SCRATCH',pp=0}} + local result=Effects.use(data,game.save,'ETHER',mon,nil,1) + T.eq(result,'consumed','actual ETHER restores a partially depleted native move') + T.eq(mon.moves[1].pp,61,'actual ETHER caps restoration at61 instead of64') + T.eq(Effects.use(data,game.save,'ETHER',mon,nil,1),'failed','full native61PP refuses another ETHER') + mon.moves[1].pp=58 + T.eq(Effects.use(data,game.save,'MYSTERYBERRY',mon,nil,1),'consumed','actual MysteryBerry restores the chosen native move') + T.eq(mon.moves[1].pp,61,'MysteryBerry also clamps at the native61PP maximum') + T.eq(mon.moves[2].pp,0,'single-move PP item cannot restore another slot') + mon.moves[1].pp=0 + T.eq(Effects.use(data,game.save,'MAX_ETHER',mon,nil,1),'consumed','actual MAX ETHER restores the selected move') + T.eq(mon.moves[1].pp,61,'actual MAX ETHER uses the same native maximum') + mon.moves[1].pp=0 + Pokemon.restorePP(mon,data.moves) + T.eq(mon.moves[1].pp,61,'actual party healing uses the same capped maximum') + mon.moves[1]={id='FIX_TACKLE',pp=52,maxPp=54} + T.eq(Effects.use(data,game.save,'PP_UP',mon,nil,1),'consumed','actual PP UP accepts a valid legacy two-bonus slot') + T.eq(mon.moves[1].ppUps,3,'PP UP increments the real inferred native counter') + T.eq(mon.moves[1].maxPp,61,'PP UP writes the actual native maximum') + T.eq(mon.moves[1].pp,59,'PP UP adds seven to the actual remaining PP') + T.eq(Effects.use(data,game.save,'PP_UP',mon,nil,1),'failed','native three-bonus slot refuses a fourth PP UP') + data.moves.SKETCH={id='SKETCH',name='SKETCH',pp=1};mon.moves[1]={id='SKETCH',pp=1} + T.eq(Effects.use(data,game.save,'PP_UP',mon,nil,1),'failed','native SKETCH refuses PP UP without consuming it') + T.eq(mon.moves[1].ppUps,nil,'SKETCH refusal leaves its counter unchanged') + mon.moves[1]={id='FIX_TACKLE',pp=61,maxPp=61} + local packet=Protocol.packMon(mon);packet.moves[1].pp=64;packet.moves[1].ppUps=3 + local unpacked=Protocol.unpackMon(data,packet) + T.check(unpacked~=nil,'actual native link reconstruction returns a valid Pokemon') + T.eq(unpacked and unpacked.moves[1].pp,61,'actual link reconstruction clamps to native Gen2 maximum') + T.eq(mon.moves[1].pp,61,'detached link reconstruction cannot rewrite the original party') +end +for _,version in ipairs({'red','firered','emerald','platinum','heartgold','black'})do + V.set(version) + T.eq(Pokemon.maxPP({pp=40},{ppUps=3}),64,'other editions retain their existing PP rule') + T.eq(Pokemon.maxPP({pp=40},{ppUps=3},2),61,'explicit Gen2 target is independent of the current launcher game') +end +V.set(oldVersion);Screens.invalidate() +T.finish('native Gen2 PP selection, arithmetic, actual item effects, healing and link boundary') From 850e647cfdf718c0a81b7648fc6097bfa3a84a41 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:32:37 +0100 Subject: [PATCH 63/68] Freeze only source directories available in upstream --- .github/workflows/gen2-queue-screen-contract.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index 78167938f..a10395235 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -21,7 +21,7 @@ jobs: git -c gc.auto=0 fetch --no-tags --depth=1 origin 2727365b0c38495717c688708d521cf8150cfec4 baseline="$RUNNER_TEMP/gen2-party-result-baseline" mkdir -p "$baseline" - git archive 2727365b0c38495717c688708d521cf8150cfec4 src tests data tools examples | tar -x -C "$baseline" + git archive 2727365b0c38495717c688708d521cf8150cfec4 src tests data tools | tar -x -C "$baseline" cp src/render/NativeScreenOverlay.lua "$baseline/src/render/NativeScreenOverlay.lua" cp src/ui/Gen2PartyItemResult.lua "$baseline/src/ui/Gen2PartyItemResult.lua" cp src/ui/Gen2MoveDeleter.lua src/ui/Gen2ForgetMoveList.lua "$baseline/src/ui/" From db989edc46b04602661e61f534cd97055f753b7a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:38:22 +0100 Subject: [PATCH 64/68] Resume the actual upstream item turn after native party confirmation --- src/battle/BattleState.lua | 3 +++ tests/engine/gen2_battle_pack.lua | 1 + 2 files changed, 4 insertions(+) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 529385c9c..a09b6b97e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10066,6 +10066,9 @@ function BattleState:finishPartyItemTurn(itemId, mon) end self.partyItemHealing = nil self:itemUsed({}) + -- This producer commits the real item turn. Resume that queue explicitly + -- after the party owner closes, as the native Gen2 controller does. + self.phase = BattleQueuePhase.name() end -- called by BagMenu after an item is used in battle (consumes the turn) diff --git a/tests/engine/gen2_battle_pack.lua b/tests/engine/gen2_battle_pack.lua index 19f5c0d73..bf727a6f9 100644 --- a/tests/engine/gen2_battle_pack.lua +++ b/tests/engine/gen2_battle_pack.lua @@ -114,6 +114,7 @@ for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do T.eq(b.player.mon.hp,targetHP,'presentation never rewrites real HP') T.check(wait(function()return game.stack:top()==b end),'native acknowledgment releases only the party owner') T.eq(b.player.shownHP,targetHP,'battle resumes with the HP already displayed by the native party result') + T.eq(b.phase,'resolving','actual native acknowledgment resumes the committed turn queue') T.eq(b.enemy.curMoves[1].pp,enemyPP,'enemy action starts after actual acknowledgment, not during its callback') T.eq(b.partyItemHealing,nil,'result acknowledgment releases low-health alarm suppression') end From 2c5212592aa9cd34ea8fb82419794fee0d1fd532 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:44:38 +0100 Subject: [PATCH 65/68] Select native finite enemy PP by default while preserving explicit rules --- src/battle/BattleState.lua | 5 ++- src/battle/RulesetDefaults.lua | 16 ++++++--- src/battle/rulesets/gen2_finite_pp.lua | 9 +++++ src/core/SaveData.lua | 2 +- src/mods/Builtins.lua | 1 + src/ui/OptionsMenu.lua | 11 +++--- tests/engine/gen2_pp_defaults.lua | 50 ++++++++++++++++++++++++++ 7 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 src/battle/rulesets/gen2_finite_pp.lua create mode 100644 tests/engine/gen2_pp_defaults.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a09b6b97e..a8449a055 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -600,6 +600,9 @@ end local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + gen2_finite_pp = require("src.battle.rulesets.gen2_finite_pp"), + gen3_emerald = require("src.battle.rulesets.gen3_emerald"), + gen4_platinum = require("src.battle.rulesets.gen4_platinum"), modern_clean = require("src.battle.rulesets.modern_clean"), } @@ -1205,7 +1208,7 @@ local function newBattle(game) -- records on a mod-free boot); an unknown save value falls back to the -- default with a notice instead of silently switching behavior local rulesets = game.data.rulesets or Rulesets - local selected = game.save.options and game.save.options.ruleset + local selected = require("src.battle.RulesetDefaults").selection(game) -- THE FALLBACK IS THE CARTRIDGE'S, not Kanto's. It used to read -- `constants.defaultRuleset or "gen1_faithful"`, and nothing has ever -- written that constant -- so Hoenn fought under Generation One's rules on diff --git a/src/battle/RulesetDefaults.lua b/src/battle/RulesetDefaults.lua index 1850ce880..c68fc7343 100644 --- a/src/battle/RulesetDefaults.lua +++ b/src/battle/RulesetDefaults.lua @@ -51,19 +51,25 @@ local RulesetDefaults = {} -- is not. Everything else matched. A player on the old default was therefore -- playing something very close to right, and wrong in every double battle. -- --- GEN 2 IS STILL ON GEN 1'S, and stays there until somebody does to Johto --- what this change did to Sinnoh. There is no Gen 2 ruleset in this repo, and --- handing Johto one written for another region would trade a known wrong --- answer for an unknown one. +-- Gen2's compatibility default tracks enemy PP, matching the native Gold +-- battle loop. All other previous host rule fields remain unchanged; this +-- is not a claim of complete Gen2 battle-rule parity. RulesetDefaults.DEFAULT_BY_GENERATION = { [1] = "gen1_faithful", - [2] = "gen1_faithful", + [2] = "gen2_finite_pp", [3] = "gen3_emerald", [4] = "gen4_platinum", } RulesetDefaults.FALLBACK = "gen1_faithful" +RulesetDefaults.AUTO = false +function RulesetDefaults.selection(game) + local selected = game and game.save and game.save.options and game.save.options.ruleset + if selected == RulesetDefaults.AUTO then return nil end + return selected +end + -- The id this cartridge should use when the save has no choice of its own. function RulesetDefaults.defaultFor(game) local constants = game and game.data and game.data.constants diff --git a/src/battle/rulesets/gen2_finite_pp.lua b/src/battle/rulesets/gen2_finite_pp.lua new file mode 100644 index 000000000..78325ee61 --- /dev/null +++ b/src/battle/rulesets/gen2_finite_pp.lua @@ -0,0 +1,9 @@ +-- Current Gen2Recomped Gen-2 rules with finite enemy PP. This is deliberately +-- not named faithful: other differences from the reference Gold engine remain. +local out = {} +for key, value in pairs(require("src.battle.rulesets.gen1_faithful")) do + out[key] = value +end +out.name = "gen2_finite_pp" +out.enemyUnlimitedPP = false +return out diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2c357a3a5..387dbf6e1 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -445,7 +445,7 @@ function SaveData.defaultOptions() -- moves the screen furniture, so it is opt-in. -- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale. uiLayout = "centered", - ruleset = "gen1_faithful", + ruleset = false, -- automatic cartridge default; explicit saved ids remain valid -- 0-7 like the GB's NR50 master volume musicVol = 7, sfxVol = 7, diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 470f67ad7..1bde4bf0c 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -32,6 +32,7 @@ local REGISTRANTS = { { name = "tokens", from = "src.render.TextBox" }, -- plain data files with no owning module: registered from here { name = "rulesets", modules = { "src.battle.rulesets.gen1_faithful", + "src.battle.rulesets.gen2_finite_pp", "src.battle.rulesets.modern_clean", "src.battle.rulesets.gen3_emerald", "src.battle.rulesets.gen4_platinum" }, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d3bbd85b5..ede47dda6 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -45,6 +45,7 @@ local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } -- no-loader fallback for the ruleset row, same pair BattleState keeps local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + gen2_finite_pp = require("src.battle.rulesets.gen2_finite_pp"), modern_clean = require("src.battle.rulesets.modern_clean"), } local FILTERS = { "OFF", "1X", "2X", "3X" } @@ -68,15 +69,12 @@ local function rulesetIds(game) if not record.hidden then ids[#ids + 1] = id end end table.sort(ids) + table.insert(ids, 1, false) return ids end local function rulesetIndex(game, ids) - -- The same fallback the battle uses, from the same place. When these two - -- disagreed the row showed GEN 1 FAITHFUL on a cartridge that was about to - -- fight under it -- which was true, and was the bug rather than the display. - local cur = game.save.options.ruleset - or require("src.battle.RulesetDefaults").defaultFor(game) + local cur = require("src.battle.RulesetDefaults").selection(game) or false for i, id in ipairs(ids) do if id == cur then return i end end @@ -86,7 +84,8 @@ end local function rulesetName(game) local rulesets = game.data and game.data.rulesets or Rulesets local ids = rulesetIds(game) - local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset + local id = ids[rulesetIndex(game, ids)] + if id == false then return Strings("AUTO") end local record = id and rulesets[id] return Strings(record and record.name or id or "----") end diff --git a/tests/engine/gen2_pp_defaults.lua b/tests/engine/gen2_pp_defaults.lua new file mode 100644 index 000000000..d52e4be1a --- /dev/null +++ b/tests/engine/gen2_pp_defaults.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Save=require('src.core.SaveData') +local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState') +local Defaults=require('src.battle.RulesetDefaults') +local old=V.get() +local function fixture(version,choice) + V.set(version) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + if choice~=nil then save.options.ruleset=choice end + local game={data=data,save=save,input={wasPressed=function()return false end},stack={top=function()end}} + return game,Battle.newWild(game,'FIXMON_C',3) +end +T.eq(Save.defaultOptions().ruleset,false,'new settings retain automatic cartridge selection') +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local game,b=fixture(version) + T.eq(Defaults.defaultFor(game),'gen2_finite_pp','native generation has a bounded PP compatibility default') + T.eq(b.ruleset.name,'gen2_finite_pp','actual fresh battle constructor chooses native finite PP') + local move=b.enemy.curMoves[1];move.pp=1 + b:executeAction(b.enemy,b.player,move) + T.eq(move.pp,0,'actual native enemy spends its final PP') + T.eq(b:enemyAction().id,'STRUGGLE','actual enemy selection falls back after PP exhaustion') + game,b=fixture(version,'gen1_faithful') + T.eq(b.ruleset.name,'gen1_faithful','an explicitly saved Gen1 rule remains selected') + move=b.enemy.curMoves[1];move.pp=1;b:executeAction(b.enemy,b.player,move) + T.eq(move.pp,1,'explicit unlimited enemy PP choice is honored') + game,b=fixture(version,'modern_clean') + T.eq(b.ruleset.name,'modern_clean','explicit alternative rule remains selected') + move=b.enemy.curMoves[1];move.pp=1;b:executeAction(b.enemy,b.player,move) + T.eq(move.pp,0,'explicit finite alternative still spends native PP') + local Options=require('src.ui.OptionsMenu');game,b=fixture(version) + local menu=Options.new(game);local row + for _,r in ipairs(menu.rows)do if r.id=='ruleset'then row=r end end + T.check(row~=nil,'actual options screen retains ruleset selection') + if row then + T.eq(row.value(game),'AUTO','automatic choice is displayed honestly') + row.step(game,1);T.check(game.save.options.ruleset~=false,'ordinary option input reaches explicit rules') + row.step(game,-1);T.eq(game.save.options.ruleset,false,'ordinary option input can return to automatic') + end +end +local game,b=fixture('red');T.eq(b.ruleset.name,'gen1_faithful','native Gen1 keeps its original default behavior') +local move=b.enemy.curMoves[1];move.pp=1;b:executeAction(b.enemy,b.player,move) +T.eq(move.pp,1,'native Gen1 retains unlimited enemy PP') +V.set(old) +T.finish('native upstream Gen2 finite enemy PP defaults and explicit choices') From 49c1c1358f47981723792b862f89ee0bdd97d2a2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:45:24 +0100 Subject: [PATCH 66/68] Qualify native PP defaults alongside party result owners --- .github/workflows/gen2-queue-screen-contract.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index a10395235..5dc9e739e 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -26,7 +26,8 @@ jobs: cp src/ui/Gen2PartyItemResult.lua "$baseline/src/ui/Gen2PartyItemResult.lua" cp src/ui/Gen2MoveDeleter.lua src/ui/Gen2ForgetMoveList.lua "$baseline/src/ui/" cp src/pokemon/Gen2PP.lua "$baseline/src/pokemon/Gen2PP.lua" - cp tests/engine/gen2_pp_selection.lua "$baseline/tests/engine/gen2_pp_selection.lua" + cp src/battle/rulesets/gen2_finite_pp.lua "$baseline/src/battle/rulesets/gen2_finite_pp.lua" + cp tests/engine/gen2_pp_selection.lua tests/engine/gen2_pp_defaults.lua "$baseline/tests/engine/" cp tests/engine/gen2_battle_pack.lua tests/engine/gen2_party_item_result.lua "$baseline/tests/engine/" for suite in gen2_party_item_result gen2_battle_pack gen2_pp_selection; do if (cd "$baseline" && luajit "tests/engine/$suite.lua") > "$RUNNER_TEMP/$suite-baseline.log" 2>&1; then @@ -40,7 +41,7 @@ jobs: - name: Native queue, screen and available menu owners run: | failed=0 - for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result gen2_pp_selection screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do + for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result gen2_pp_selection gen2_pp_defaults screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do if ! luajit "tests/engine/$suite.lua"; then failed=1; fi done exit "$failed" From 1aa9c1f66a63e9f3b693a8ff70ad609f0851ee9a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:47:50 +0100 Subject: [PATCH 67/68] Document the bounded native Gen2 PP default scope --- docs/native-gen2-pp-selection.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/native-gen2-pp-selection.md b/docs/native-gen2-pp-selection.md index 65986c23a..d990c71de 100644 --- a/docs/native-gen2-pp-selection.md +++ b/docs/native-gen2-pp-selection.md @@ -21,6 +21,12 @@ labels, summary, held-item restoration and link reconstruction use the common maximum. Fork-only public records and Bank modules are not present in this upstream branch; this adaptation does not invent or claim those interfaces. +Fresh automatic settings select the finite-enemy-PP Gen2 compatibility rule. +An explicit saved ruleset remains effective, including an intentional Gen1 +unlimited-PP choice. The options row can return to AUTO without reserving a +string id from mods. Only the enemy PP policy differs from the previous Gen2 +host rule fields; complete Gen2 battle-rule parity remains unproven. + The native move-list and forget-list layouts preserve reference provenance. Sound uses the existing imported cue and actual Sound.play service; the reference's unavailable Sound.resolve is not introduced. From 99534ceb0a216a043cd32dc4d97b4f9e4d485128 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:07:16 +0100 Subject: [PATCH 68/68] Qualify wide animation anchors and continuous capture motion --- .../workflows/gen2-queue-screen-contract.yml | 2 +- tests/engine/wide_battle_layout.lua | 45 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml index 5dc9e739e..631e86847 100644 --- a/.github/workflows/gen2-queue-screen-contract.yml +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -41,7 +41,7 @@ jobs: - name: Native queue, screen and available menu owners run: | failed=0 - for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result gen2_pp_selection gen2_pp_defaults screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do + for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_layout wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result gen2_pp_selection gen2_pp_defaults screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do if ! luajit "tests/engine/$suite.lua"; then failed=1; fi done exit "$failed" diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..804a7f816 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,44 @@ T.eq(WideBattle.navigate(1, 4, pressing("right")), 2, T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") --- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) -T.eq(px, 20, "a player-side frame lands on the player anchor") -T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) -T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") -T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") +-- OAM x is the right edge of an 8px sprite. These groups are centered on +-- the authored player (26) and enemy (124) anchors, rather than merely +-- falling somewhere on their half of the 160px screen. +local px, py = WideBattle.animationOffset({ { x = 14 }, { x = 46 } }) +T.eq(px, 20, "the player anchor maps to its wide-layout offset") +T.eq(py, 8, "the player anchor maps to its wide-layout baseline") +local ex, ey = WideBattle.animationOffset({ { x = 112 }, { x = 144 } }) +T.eq(ex, 136, "the enemy anchor maps to its wide-layout offset") +T.eq(ey, 0, "the enemy anchor maps to its wide-layout baseline") + +local function offset(x, wantX, wantY, label) + local dx, dy = WideBattle.animationOffset({ { x = x } }) + T.eq(dx, wantX, label .. " horizontal offset") + T.eq(dy, wantY, label .. " vertical offset") +end +offset(30, 20, 8, "single sprite at the player anchor") +offset(128, 136, 0, "single sprite at the enemy anchor") +offset(79, 78, 4, "midpoint between anchors") +-- Wobble must continue across the anchor instead of sticking at it. +offset(29, 19, 8, "one pixel before the player anchor") +offset(31, 21, 8, "one pixel after the player anchor") +offset(127, 135, 0, "one pixel before the enemy anchor") +offset(129, 137, 0, "one pixel after the enemy anchor") +-- Bounded overshoot lets balls leave the picture without unbounded offsets. +offset(-1000, -3, 10, "far left overshoot") +offset(1000, 159, -2, "far right overshoot") +local rx, ry = WideBattle.animationOffset({ { x = 46 }, { x = 14 } }) +T.eq(rx, px, "sprite ordering does not change the rigid frame offset") +T.eq(ry, py, "sprite ordering does not change the baseline") +local emptyX, emptyY = WideBattle.animationOffset({}) +T.eq(emptyX, 0, "an empty frame has no horizontal offset") +T.eq(emptyY, 0, "an empty frame has no vertical offset") + +local mx, my = WideBattle.animationOffset({ { x = 79 } }) +T.eq(mx, 78, "a crossing frame interpolates halfway between picture anchors") +T.eq(my, 4, "the crossing baseline interpolates with the same fraction") +local ox = WideBattle.animationOffset({ { x = 20 } }) +T.check(ox < 20, "motion can overshoot the player anchor without the old 40px clamp") -- the surface: a request outside the bounds falls back to the GB screen, -- and the canvas is only reallocated when the size actually changes