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/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/docs/native-gen2-naming.md b/docs/native-gen2-naming.md new file mode 100644 index 000000000..fe8ca6192 --- /dev/null +++ b/docs/native-gen2-naming.md @@ -0,0 +1,48 @@ +# 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. +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 +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. + +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. +GPU acceptance and full reference raster/helper parity remain separate: +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 +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/docs/native-gen2-save-menu.md b/docs/native-gen2-save-menu.md new file mode 100644 index 000000000..a55e8614a --- /dev/null +++ b/docs/native-gen2-save-menu.md @@ -0,0 +1,42 @@ +# Native Gen2 START and save dialogue + +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. + +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. +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 +and credits remain applicable. See NOTICE.md and LICENSE-ROXAS2712.md. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..a811635f1 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -32,8 +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 -- src.ui.PartyPanel no equivalent @@ -67,6 +65,9 @@ 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.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", @@ -80,10 +81,11 @@ 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. - ["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/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..c5eda740a 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -963,6 +963,30 @@ 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.player and save.player.map or (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/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/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/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/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/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/Gen2Chrome.lua b/src/ui/Gen2Chrome.lua new file mode 100644 index 000000000..b36f65206 --- /dev/null +++ b/src/ui/Gen2Chrome.lua @@ -0,0 +1,670 @@ +-- 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 + +-- 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 + 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.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") + 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/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/Gen2NamingScreen.lua b/src/ui/Gen2NamingScreen.lua new file mode 100644 index 000000000..f00d0bc5b --- /dev/null +++ b/src/ui/Gen2NamingScreen.lua @@ -0,0 +1,306 @@ +-- 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 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 +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 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 + 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 +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 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 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={} + 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 + 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 (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) +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: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: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})) +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 + self:addCharacter(self:characterAt(self.col,self.row)) + if #self.glyphs>=self.maxLength then self:jumpToEnd()end + end + 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 + 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) + 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) + 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: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.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) + 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() + 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,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) + 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/Gen2SaveMenu.lua b/src/ui/Gen2SaveMenu.lua new file mode 100644 index 000000000..9217a053a --- /dev/null +++ b/src/ui/Gen2SaveMenu.lua @@ -0,0 +1,163 @@ +-- 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 +-- 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 Chrome.withOverlayPanel(width,height,function() self:drawPanel() end) +end +return M diff --git a/src/ui/Gen2StartMenu.lua b/src/ui/Gen2StartMenu.lua new file mode 100644 index 000000000..d873e8758 --- /dev/null +++ b/src/ui/Gen2StartMenu.lua @@ -0,0 +1,480 @@ +-- 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 Chrome.withOverlayPanel(width,height,function() self:draw() end) +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/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/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index ed0f11d16..c2d9e6bdc 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -690,8 +690,26 @@ 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", presets = presets, maxLen = step.maxLen or self.nameLen, onDone = function(name) 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 b898636a1..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 = {} @@ -83,6 +86,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/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') 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') diff --git a/tests/engine/gen2_mod_naming.lua b/tests/engine/gen2_mod_naming.lua new file mode 100644 index 000000000..630d1ba21 --- /dev/null +++ b/tests/engine/gen2_mod_naming.lua @@ -0,0 +1,166 @@ +-- 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 = 0, 1 + press(screen, "a") + T.eq(screen.text, "AB", "native keyboard input updates the public text") + -- 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") +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 = 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") +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(), 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. +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") 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') diff --git a/tests/engine/gen2_native_naming.lua b/tests/engine/gen2_native_naming.lua new file mode 100644 index 000000000..23457562e --- /dev/null +++ b/tests/engine/gen2_native_naming.lua @@ -0,0 +1,143 @@ +-- 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 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() + 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 + -- 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']=[[ + 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 +-- 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 +-- 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() +T.finish('native Gen2 keyboard input, glyphs, original admission and loaded class owner') diff --git a/tests/engine/gen2_save_menu.lua b/tests/engine/gen2_save_menu.lua new file mode 100644 index 000000000..6d51cb02f --- /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') + 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 +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. +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') 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') diff --git a/tests/engine/native_save_summary.lua b/tests/engine/native_save_summary.lua new file mode 100644 index 000000000..c227ad34b --- /dev/null +++ b/tests/engine/native_save_summary.lua @@ -0,0 +1,38 @@ +-- 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',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') + 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,'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} + 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.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') 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