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 ba223177..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,9 +99,19 @@ 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, + -- 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 +329,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 @@ -705,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_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() 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)