From def3f68f8a775b5df1955e6f243aa21b4fe4a41a Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sat, 19 Sep 2026 12:51:48 +0100 Subject: [PATCH 1/3] Draw the Gen 3 nickname mon icon at its real 32x32 size The mon branch of drawPlayerIcon reused the player overworld sprite's 16x32 box to letterbox the icon, so a 32x32 Pokemon.icon came out at scale 0.5 -- half size inside the frame baked into bg.png. The 64x64 frontPic fallback was worse still, at 0.25. Give the mon its own box in the layout table (pret naming_screen.c:1422 CreateMonIcon(species, SpriteCallbackDummy, 56, 40) draws it unscaled on that centre), so the icon fills the frame 1:1 and the fallback shrinks to fit the same box. Also pin the frame to quads[0]: pret's dummy callback leaves the icon on its first frame, not the 1px-shifted second pose. The player/rival overworld slot keeps its 16x32 box untouched. Fixes #2319 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/ui/game3/naming.lua | 18 ++- .../nickname_mon_icon_scale_bug2319.lua | 123 ++++++++++++++++++ 2 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 tests/engine/nickname_mon_icon_scale_bug2319.lua diff --git a/src/ui/game3/naming.lua b/src/ui/game3/naming.lua index ba223177..4c11f955 100644 --- a/src/ui/game3/naming.lua +++ b/src/ui/game3/naming.lua @@ -93,6 +93,12 @@ local L = { -- Player/rival icon CreateSprite(56,37); 16×32 → TL (48,21) iconCX = 56, iconCY = 37, iconW = 16, iconH = 32, + -- Mon icon CreateMonIcon(species, SpriteCallbackDummy, 56, 40) is a 32×32 + -- sprite (Versions.MON_ICON_W/H) drawn unscaled, centred on the frame baked + -- into bg.png — pokefirered/src/naming_screen.c:1422. Reusing the 16×32 + -- player box above would letterbox it to half size. + monIconCX = 56, monIconCY = 40, + monIconW = 32, monIconH = 32, charY = 49, -- Underscore CreateSprite(base+3,60) 8×8 → TL (base-1, 56) underscoreBaseY = 56, @@ -310,13 +316,15 @@ local function drawPlayerIcon(st) if entry and entry.image then local iw = entry.w or entry.image:getWidth() local ih = entry.h or (entry.quads and entry.h) or entry.image:getHeight() - local sc = math.min(L.iconW / iw, L.iconH / ih) - local q = entry.quads and (entry.quads[1] or entry.quads[0]) - local cx, cy = L.iconCX, 40 -- pokefirered/src/naming_screen.c:1422 + -- A mon icon is 32×32 and fills its frame 1:1; the frontPic fallback + -- is 64×64 and shrinks into the same box. + local sc = math.min(L.monIconW / iw, L.monIconH / ih) + -- pret passes SpriteCallbackDummy, so the icon shows its frame 0. + local q = entry.quads and entry.quads[0] if q then - love.graphics.draw(entry.image, q, cx, cy, 0, sc, sc, iw / 2, ih / 2) + love.graphics.draw(entry.image, q, L.monIconCX, L.monIconCY, 0, sc, sc, iw / 2, ih / 2) else - love.graphics.draw(entry.image, cx, cy, 0, sc, sc, iw / 2, ih / 2) + love.graphics.draw(entry.image, L.monIconCX, L.monIconCY, 0, sc, sc, iw / 2, ih / 2) end return end diff --git a/tests/engine/nickname_mon_icon_scale_bug2319.lua b/tests/engine/nickname_mon_icon_scale_bug2319.lua new file mode 100644 index 00000000..bcdf503c --- /dev/null +++ b/tests/engine/nickname_mon_icon_scale_bug2319.lua @@ -0,0 +1,123 @@ +-- #2319: the Gen 3 nickname screen drew the mon's 32x32 menu icon at half +-- size, because the mon branch reused the player OW sprite's 16x32 box. +-- pret pokefirered/src/naming_screen.c:1422 CreateMonIcon(species, +-- SpriteCallbackDummy, 56, 40, ...) draws it unscaled, showing frame 0. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local function fakeImage(w, h) + return { + w = w, h = h, + getWidth = function() return w end, + getHeight = function() return h end, + getDimensions = function() return w, h end, + } +end + +-- 32x32 icon frames stacked two high; 64x64 front pic fallback. +local ICON_IMG = fakeImage(32, 64) +local PIC_IMG = fakeImage(64, 64) +local QUAD0, QUAD1 = { frame = 0 }, { frame = 1 } + +local monIcon, monFrontPic +package.loaded["src.core.game3.pokemon"] = { + icon = function() return monIcon end, + frontPic = function() return monFrontPic end, +} +package.loaded["src.ui.game3.stack"] = { + push = function() end, pop = function() end, + busy = function() return false end, drawOrder = function() return {} end, +} + +local Versions = require("src.import.gba.versions") +local Naming = require("src.ui.game3.naming") + +eq(Naming.L.monIconW, Versions.MON_ICON_W, + "mon box width matches the extracted icon width") +eq(Naming.L.monIconH, Versions.MON_ICON_H, + "mon box height matches the extracted icon height") + +-- Normalise both draw signatures into one record: +-- draw(img, quad, x, y, rot, sx, sy, ox, oy) and draw(img, x, y, rot, sx, sy, ox, oy) +local draws = {} +local realDraw = love.graphics.draw +love.graphics.draw = function(a, b, c, d, e, f, g, h, i) + local rec + if type(b) == "table" then + rec = { img = a, quad = b, x = c, y = d, rot = e, sx = f, sy = g, ox = h, oy = i } + else + rec = { img = a, x = b, y = c, rot = d, sx = e, sy = f, ox = g, oy = h } + end + draws[#draws + 1] = rec +end + +local function render(opts) + draws = {} + Naming.open(opts) + local ok, err = pcall(Naming.draw) + Naming.dismiss() + return draws, ok, err +end + +local function drawsOf(ds, img) + local out = {} + for _, d in ipairs(ds) do + if d.img == img then out[#out + 1] = d end + end + return out +end + +-- The mon icon: 1:1 on the (56, 40) frame centre, static frame 0. +monIcon = { image = ICON_IMG, w = 32, h = 32, sheetH = 64, frames = 2, + quads = { [0] = QUAD0, [1] = QUAD1 } } +monFrontPic = nil +local ds, ok, err = render({ template = "NICKNAME", species = 1, maxLen = 10 }) +check(ok, "nickname draw runs headless: " .. tostring(err)) +local icon = drawsOf(ds, ICON_IMG) +eq(#icon, 1, "the mon icon is drawn once") +local d = icon[1] or {} +eq(d.quad, QUAD0, "static frame 0, not the 1px-shifted frame 1") +eq(d.x, 56, "icon centre x = 56 (naming_screen.c:1422)") +eq(d.y, 40, "icon centre y = 40 (naming_screen.c:1422)") +eq(d.rot, 0, "no rotation") +eq(d.sx, 1, "32x32 icon drawn 1:1, not the player box's 0.5") +eq(d.sy, 1, "uniform scale") +eq(d.ox, 16, "origin x is the frame centre, so the draw covers the frame") +eq(d.oy, 16, "origin y is the frame centre") + +-- frontPic fallback: 64x64 shrunk into the same 32x32 box (was 0.25). +monIcon = nil +monFrontPic = { image = PIC_IMG, w = 64, h = 64 } +local ds2 = render({ template = "CAUGHT_MON", species = 4 }) +local pic = drawsOf(ds2, PIC_IMG) +eq(#pic, 1, "the front-pic fallback is drawn once") +eq(pic[1] and pic[1].x, 56, "fallback keeps the mon centre x") +eq(pic[1] and pic[1].y, 40, "fallback keeps the mon centre y") +eq(pic[1] and pic[1].sx, 0.5, "64x64 fallback fills the 32x32 mon box") +eq(pic[1] and pic[1].sy, 0.5, "uniform fallback scale") +eq(pic[1] and pic[1].ox, 32, "fallback origin is the pic centre") + +-- Player/rival slot: still the 16x32 OW box. +monIcon, monFrontPic = nil, nil +local portrait = fakeImage(64, 64) +local ds3 = render({ template = "PLAYER", icon = portrait }) +local p = drawsOf(ds3, portrait) +eq(#p, 1, "the player-slot portrait is drawn once") +eq(p[1] and p[1].x, 56, "player slot keeps its OW centre x") +eq(p[1] and p[1].y, 37, "player slot keeps its OW centre y") +eq(p[1] and p[1].sx, 0.25, "player OW box math is unchanged (16/64)") +eq(p[1] and p[1].sy, 0.25, "player OW box scale is uniform") +eq(p[1] and p[1].ox, 32, "player OW origin is the portrait centre") + +-- Guard: the mon icon is never letterboxed below 1:1 again. +for _, dl in ipairs({ ds, ds2 }) do + for _, e in ipairs(drawsOf(dl, ICON_IMG)) do + check((e.sx or 0) >= 1, "mon icon scale is never below 1:1") + end +end + +love.graphics.draw = realDraw +T.finish() From 7e505fdf81c9699cd0c9e011de3a3d588f2a1e57 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sat, 19 Sep 2026 13:02:22 +0100 Subject: [PATCH 2/3] Keep the nickname screen's title inside its text box Follow-up to #2319, found while testing it: the catch flow hand-wrote "YOUR POKEMON'S NICKNAME?" into the text-entry window. That string is 141px wide, but sWindowTemplates[WIN_TEXT_ENTRY_BOX] only gives 127px from x=73, so the title ran ~12px over the frame's right edge. pret composes the title instead -- DrawMonTextEntryBox prints gSpeciesNames[mon] followed by gText_PkmnsNickname ("'s nickname?"), which fits for every species (worst case 123px). - Naming.monTitle() is now the single source of that string, used by both the catch flow and special:158. - Naming.draw clamps the title to L.titleMaxW, mirroring the GBA's per-window glyph clip, so an over-long name can never escape the frame (the default 240px maxWidth is wider than the box itself). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/core/game3/battle/init.lua | 5 +- src/core/game3/scripting/natives.lua | 2 +- src/ui/game3/naming.lua | 17 ++- .../nickname_title_fits_box_bug2319.lua | 132 ++++++++++++++++++ tests/game3_nickname_test.lua | 2 +- 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tests/engine/nickname_title_fits_box_bug2319.lua diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 3cea3777..2e0d8aca 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -2100,7 +2100,10 @@ local function start_post_catch_flow(catchRes) gender = gender, personality = personality, seed = ename, - title = "YOUR POKEMON'S NICKNAME?", + -- pret naming_screen.c:1712 DrawMonTextEntryBox: gSpeciesNames[mon] + -- + gText_PkmnsNickname. The hand-written "YOUR POKEMON'S NICKNAME?" + -- was 141px wide and spilled over the frame's right edge. + title = Naming.monTitle(Pokemon.name(sp)), onDone = function(nick) if nick and nick ~= "" and nick ~= ename then if mon then mon.nickname = nick end diff --git a/src/core/game3/scripting/natives.lua b/src/core/game3/scripting/natives.lua index 05d15918..0d676d36 100644 --- a/src/core/game3/scripting/natives.lua +++ b/src/core/game3/scripting/natives.lua @@ -261,7 +261,7 @@ Natives.ALLOW = { end) local sname = (Pokemon.name and Pokemon.name(species)) or "POKéMON" adapters.openNaming({ - title = sname .. "'s nickname?", + title = require("src.ui.game3.naming").monTitle(sname), template = "NICKNAME", maxLen = 10, -- pret POKEMON_NAME_LENGTH species = species, diff --git a/src/ui/game3/naming.lua b/src/ui/game3/naming.lua index 4c11f955..208536dc 100644 --- a/src/ui/game3/naming.lua +++ b/src/ui/game3/naming.lua @@ -25,6 +25,15 @@ Naming.TEMPLATE = { NICKNAME = "NICKNAME", } +-- pret gText_PkmnsNickname ("'s nickname?"), prepended with gSpeciesNames[mon] +-- by DrawMonTextEntryBox — pokefirered/src/naming_screen.c:1712. Used by both +-- mon naming templates (CAUGHT_MON and NICKNAME). +function Naming.monTitle(speciesName) + local s = tostring(speciesName or "") + if s == "" then s = "POKéMON" end + return s .. "'s nickname?" +end + -- pret sKeyboardChars + sPageColumnXPos (cursor). Letters drawn via ROW_TEXT CLEARs. local PAGES = { { @@ -90,6 +99,10 @@ local SIDE = { "PAGE", "BACK", "OK" } -- Values below are on-screen top-left blit positions (px). local L = { titleX = 73, titleY = 33, + -- WIN_TEXT_ENTRY_BOX = {tilemapLeft 9, tilemapTop 4, width 16} → screen + -- x 72..200; the title prints at (1,1) inside it, so it has 127px before the + -- GBA's per-window clip (CopyGlyphToWindow) would truncate it. + titleMaxW = 127, -- Player/rival icon CreateSprite(56,37); 16×32 → TL (48,21) iconCX = 56, iconCY = 37, iconW = 16, iconH = 32, @@ -713,7 +726,9 @@ function Naming.draw() -- 5) Title + icon + typed name (above KB) love.graphics.setColor(1, 1, 1, 1) - drawText(st.title, L.titleX, L.titleY) + -- Clamp to the text-entry window so an over-long title cannot spill over the + -- frame (pret blits glyphs into the window buffer and clips there). + drawText(st.title, L.titleX, L.titleY, { maxWidth = L.titleMaxW }) drawPlayerIcon(st) diff --git a/tests/engine/nickname_title_fits_box_bug2319.lua b/tests/engine/nickname_title_fits_box_bug2319.lua new file mode 100644 index 00000000..7061e6cc --- /dev/null +++ b/tests/engine/nickname_title_fits_box_bug2319.lua @@ -0,0 +1,132 @@ +-- #2319 (second report): the nickname screen's title ran past the right edge of +-- the text box. The catch flow hand-wrote "YOUR POKEMON'S NICKNAME?" (141px in a +-- 127px window), where pret composes gSpeciesNames[mon] + gText_PkmnsNickname: +-- pokefirered/src/naming_screen.c:1712 DrawMonTextEntryBox +-- pokefirered/src/strings.c:772 gText_PkmnsNickname = "'s nickname?" +-- sWindowTemplates[WIN_TEXT_ENTRY_BOX] = {tilemapLeft 9, width 16} → screen +-- x 72..200, text printed at (1,1) → x 73, so the budget is 127px. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- --- exact GBA advance widths, from the extracted FRLG latin font ------------ +local function loadTable(path, pattern) + local f = io.open(path, "r") + if not f then return nil end + local src = f:read("*a") + f:close() + local t = {} + for a, b in src:gmatch(pattern) do t[tonumber(a)] = tonumber(b) end + return t +end + +local widths = loadTable("src/import/gba/chrome/fonts/latin_widths.lua", "%[(%d+)%]%s*=%s*(%d+)") +check(widths ~= nil and next(widths) ~= nil, "latin glyph widths are loadable") + +-- TextIR.CHARMAP: glyph id -> character, inverted to character -> glyph id. +local charmap = require("src.core.game3.scripting.text_ir").CHARMAP +local glyphOf = {} +for gid, ch in pairs(charmap) do + if type(gid) == "number" and type(ch) == "string" and #ch == 1 then + glyphOf[ch] = gid + end +end +check(next(glyphOf) ~= nil, "TextIR.CHARMAP is invertible to character -> glyph id") + +local function px(s) + local total = 0 + for i = 1, #s do + local gid = glyphOf[s:sub(i, i)] + if gid then total = total + (widths[gid] or 0) end + end + return total +end + +local LEFT, BUDGET = 73, 127 -- text-entry window starts at x=73, 127px of room +local function endsAt(s) return LEFT + px(s) end + +-- --- the reported string genuinely overflowed ------------------------------- +local reported = "YOUR POKEMON'S NICKNAME?" +check(px(reported) > BUDGET, + ("the old hardcoded title (%dpx) really did overflow the %dpx window"):format(px(reported), BUDGET)) +check(endsAt(reported) > 200, + "the old title ran past the window's right edge (x=200)") + +-- --- Naming.monTitle mirrors pret ------------------------------------------- +local Naming = require("src.ui.game3.naming") +eq(type(Naming.monTitle), "function", "Naming.monTitle exists") +eq(Naming.monTitle("CHARMANDER"), "CHARMANDER's nickname?", + "mon title is gSpeciesNames[mon] + gText_PkmnsNickname") +eq(Naming.monTitle("BULBASAUR"), "BULBASAUR's nickname?", "title tracks the species") +check(Naming.monTitle("CHARMANDER") ~= Naming.monTitle("SQUIRTLE"), + "title is species-derived, not a fixed caption") +eq(Naming.monTitle(nil), "POKéMON's nickname?", "a missing name falls back safely") + +-- --- every real species title fits the window ------------------------------- +-- Longest real species names (both 10 characters) plus the widest 10-character +-- name the font can produce: a strict upper bound over all caps names. +for _, name in ipairs({ "CHARMANDER", "FORRETRESS", "MASQUERAIN", "BUTTERFREE" }) do + local title = Naming.monTitle(name) + check(endsAt(title) <= 200, + ("%q ends at x=%d, inside the box (<=200)"):format(title, endsAt(title))) +end + +local widest = {} +for ch in pairs(glyphOf) do + if ch:match("^[A-Z]$") then widest[#widest + 1] = ch end +end +table.sort(widest, function(a, b) + local wa, wb = widths[glyphOf[a]] or 0, widths[glyphOf[b]] or 0 + if wa == wb then return a < b end + return wa > wb +end) +local worstName = table.concat({ widest[1], widest[2], widest[3], widest[4], widest[5], + widest[6], widest[7], widest[8], widest[9], widest[10] }) +local worstTitle = Naming.monTitle(worstName) +check(endsAt(worstTitle) <= 200, + ("worst-case 10-char name %q ends at x=%d, still inside the box"):format(worstTitle, endsAt(worstTitle))) + +-- --- the screen clamps the title to the window ------------------------------ +-- pret blits glyphs into the window buffer (CopyGlyphToWindow), which clips at +-- the window edge; the port must pass that window as maxWidth rather than the +-- generic 240 default, which would let text run over the frame art. +eq(Naming.L.titleMaxW, BUDGET, "L.titleMaxW is the text-entry window's usable width") +eq(Naming.L.titleX, LEFT, "the title still starts at the window's left edge") + +local FrlgFont = require("src.ui.game3.frlg_font") +local realDraw = FrlgFont.draw +local seen = {} +FrlgFont.draw = function(text, x, y, opts) + seen[#seen + 1] = { text = text, x = x, y = y, maxWidth = opts and opts.maxWidth } + return 0 +end + +package.loaded["src.core.game3.pokemon"] = { + icon = function() return nil end, + frontPic = function() return nil end, +} +package.loaded["src.ui.game3.stack"] = { + push = function() end, pop = function() end, + busy = function() return false end, drawOrder = function() return {} end, +} + +local title = Naming.monTitle("BUTTERFREE") +Naming.open({ template = "CAUGHT_MON", species = 12, maxLen = 10, title = title }) +local ok, err = pcall(Naming.draw) +Naming.dismiss() +FrlgFont.draw = realDraw +check(ok, "nickname draw runs headless: " .. tostring(err)) + +local drawn +for _, s in ipairs(seen) do + if s.text == title then drawn = s end +end +check(drawn ~= nil, "the title is drawn as its own run") +eq(drawn and drawn.x, LEFT, "title drawn at the window's left edge") +eq(drawn and drawn.y, 33, "title drawn at the template's y") +eq(drawn and drawn.maxWidth, BUDGET, + "title is clamped to the window, not the 240px default that overran the frame") + +T.finish() diff --git a/tests/game3_nickname_test.lua b/tests/game3_nickname_test.lua index 44c7a23a..b2332806 100644 --- a/tests/game3_nickname_test.lua +++ b/tests/game3_nickname_test.lua @@ -124,7 +124,7 @@ for _, key in ipairs(expectedKeys) do end -- Check Naming screen layout coordinates match pokefirered -Naming.open({ template = "NICKNAME", species = 1, maxLen = 10, title = "YOUR POKEMON'S NICKNAME?" }) +Naming.open({ template = "NICKNAME", species = 1, maxLen = 10, title = Naming.monTitle("BULBASAUR") }) check(Naming.isOpen(), "Naming screen is open") -- Verify species icon positioning at (56, 40) From 6326aaabbe76d097f36147ef881a3f07ece982b6 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sat, 19 Sep 2026 13:05:09 +0100 Subject: [PATCH 3/3] ci: retrigger after transient Maven Central 403 The Android APK job failed resolving every artifact from repo.maven.apache.org with 403 Forbidden, before any build work ran. The same job passed in the four preceding runs on this repo, and the Nexhas28 token has no re-run rights on the upstream repository, so this empty commit is the only available way to retrigger the workflow. No source changes.