From 4457007e2a15c20f3d094ca71aaa398156c433fa Mon Sep 17 00:00:00 2001 From: Lokesh-shiva Date: Thu, 1 Oct 2026 21:42:39 +0530 Subject: [PATCH 1/2] fix(firered): match naming screen and rival intro visuals --- src/import/RomExtractorGen3.lua | 49 ++++++++++++++++++++++++++ src/import/RomImporter.lua | 7 +++- src/render/Font.lua | 17 ++++++--- src/ui/Gen3OakSpeechFRLG.lua | 18 ++++++++-- src/ui/Menu.lua | 40 ++++++++++++++++++--- src/ui/NamingScreen.lua | 61 ++++++++++++++++++++++++--------- 6 files changed, 163 insertions(+), 29 deletions(-) diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 6e558291f..f742150c4 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -46049,6 +46049,9 @@ RomExtractorGen3.FRLG_NAMING = { GFX = 0xE980E4, BG_MAP = 0xE982BC, KB_MAPS = { upper = 0xE98398, lower = 0xE98458, symbols = 0xE98518 }, MENU_PAL = 0xE98024, KB_PAL = 0xE97FE4, + -- text.c gKeypadIconTiles is a 128x32 4bpp sheet (64 tiles). Naming's + -- WIN_BANNER loads GetTextWindowPalette(2), the third 16-colour row here. + KEYPAD_GFX = 0x1EA700, TEXT_WINDOW_PALS = 0x471DEC, -- naming_screen.c's private rival sheet is a second exact copy of -- gObjectEventPic_Blue (0x38A428) in the retail ROM. Its dedicated palette -- sits between gNamingScreenKeyboard_Pal and gNamingScreenMenu_Pal in @@ -46119,6 +46122,39 @@ function RomExtractorGen3:extractFireRedNaming() end pcall(layer, "bg", N.BG_MAP, true) for key, at in pairs(N.KB_MAPS) do pcall(layer, "kb_" .. key, at, false) end + + -- PrintControls does not draw button-shaped boxes. The {DPAD_ANY}, + -- {A_BUTTON} and {B_BUTTON} control codes call DrawKeypadIcon, which blits + -- these exact pixels from gKeypadIconTiles with colour 0 keyed out. Pull + -- those three icons so the runtime can compose gText_MoveOkBack with the + -- cartridge's own small-font glyphs and keypad art. + pcall(function() + local raw = rom:bytes(N.KEYPAD_GFX, 64 * 32) + local px = RomGba.tiles4bpp(raw, 16, 4) + local palRaw = rom:bytes(N.TEXT_WINDOW_PALS + 2 * 32, 32) + local bannerPal = {} + for i = 0, 15 do + bannerPal[i] = { RomGba.bgr555(palRaw[i * 2 + 1] + palRaw[i * 2 + 2] * 256) } + end + local function keypad(key, tile, w, h) + local sx, sy = (tile % 16) * 8, math.floor(tile / 16) * 8 + local img = ImageWriter.blank(w, h) + for y = 0, h - 1 do + for x = 0, w - 1 do + local v = px[sy + y + 1][sx + x + 1] + local col = v ~= 0 and bannerPal[v] + if col then + img:setPixel(x, y, col[1] / 255, col[2] / 255, col[3] / 255, 1) + end + end + end + save(key, img) + end + keypad("help_a", 0x00, 8, 12) + keypad("help_b", 0x01, 8, 12) + keypad("help_dpad", 0x22, 8, 12) -- CHAR_DPAD_NONE / {DPAD_ANY} + end) + for key, spec in pairs(N.SPRITES) do pcall(function() local at, w, h, bank = spec[1], spec[2], spec[3], spec[4] @@ -46126,14 +46162,21 @@ function RomExtractorGen3:extractFireRedNaming() local raw = rom:bytes(at, cols * rws * 32) local px = RomGba.tiles4bpp(raw, cols, rws) local img = ImageWriter.blank(w, h) + -- SpriteCB_Cursor changes ONLY OBJ palette entry 1 while the keyboard + -- cursor idles. Keep those pixels in a separate white mask so the + -- runtime can replace that one colour every frame without tinting the + -- rest of the cartridge-ripped cursor art. + local pulse = key == "cursor" and ImageWriter.blank(w, h) or nil for y = 1, h do for x = 1, w do local v = px[y][x] local col = v ~= 0 and pal[bank * 16 + v] if col then img:setPixel(x - 1, y - 1, col[1] / 255, col[2] / 255, col[3] / 255, 1) end + if pulse and v == 1 then pulse:setPixel(x - 1, y - 1, 1, 1, 1, 1) end end end save(key, img) + if pulse then save("cursor_pulse", pulse) end end) end -- RIVAL's naming icon is not the ordinary object-event sprite at runtime: @@ -46177,9 +46220,15 @@ function RomExtractorGen3:extractFireRedNaming() end) local function c(i) local t = pal[i] return { t[1], t[2], t[3] } end local constants = self._constants or {} + local cursorWord = rom:u16(N.MENU_PAL + 5 * 32 + 2) -- palette 5, entry 1 constants.gen3FRLGNaming = { images = images, rivalIcon = rivalIcon, + cursorPulseBase = { + cursorWord % 32, + math.floor(cursorWord / 32) % 32, + math.floor(cursorWord / 1024) % 32, + }, colors = { fill = { upper = c(10 * 16 + 13), lower = c(10 * 16 + 14), symbols = c(10 * 16 + 15) }, key = { c(10 * 16 + 1), c(10 * 16 + 2) }, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8296bf5d7..151866cc8 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1222,7 +1222,12 @@ end -- the one gate that forces a re-import, and main's change alters what a -- FireRed cache CONTAINS -- so keeping v359 would let a FireRed cache -- built before the tutor fix pass as current. v360 invalidates both. -local CACHE_FORMAT = "rom-cache-v360:" +-- v361: FireRed naming fidelity now extracts the cartridge's keypad icons and +-- the cursor-pulse mask/base colour. An older cache has none of those +-- files/fields, so without a format bump an updated runtime would keep +-- drawing the pre-fix handmade help icons and static selection cursor +-- until the user happened to force a ROM re-import manually. +local CACHE_FORMAT = "rom-cache-v361:" -- The completion marker is written under each version's cache prefix -- (rom-cache.complete for Red, blue/rom-cache.complete for Blue). local MARKER_PATH = "rom-cache.complete" diff --git a/src/render/Font.lua b/src/render/Font.lua index 4145dc770..a27c4f25b 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -799,11 +799,20 @@ local function frameIndex(def) return want end -local function sheetFrame(tx, ty, tw, th) +local function sheetFrame(tx, ty, tw, th, frameOverride) local def = state and state.def or {} local quads, image = frameQuads(def) if not quads then return false end - local set = quads[frameIndex(def)] + local want = tonumber(frameOverride) + if want then + want = math.floor(want) + local count = (def.frames and def.frames.count) or 1 + if want < 1 then want = 1 end + if want > count then want = count end + else + want = frameIndex(def) + end + local set = quads[want] if not set then return false end local tile = (def.frames.tile) or 8 local x, y = tx * 8, ty * 8 @@ -844,11 +853,11 @@ local function drawnFrame(tx, ty, tw, th, style) end -- Draw a Game Boy style bordered box in tile coordinates. -function Font.drawBox(tx, ty, tw, th) +function Font.drawBox(tx, ty, tw, th, frameOverride) if not FALLBACK.enabled and state and state.def then -- the cartridge's own nine-slice first; the drawn rectangle is what a -- dataset without one falls back to - if state.def.frames and sheetFrame(tx, ty, tw, th) then return end + if state.def.frames and sheetFrame(tx, ty, tw, th, frameOverride) then return end if state.def.frame == "drawn" or state.def.frame == "sheet" then return drawnFrame(tx, ty, tw, th, activeStyle) end diff --git a/src/ui/Gen3OakSpeechFRLG.lua b/src/ui/Gen3OakSpeechFRLG.lua index b67c201a0..f32084ce7 100644 --- a/src/ui/Gen3OakSpeechFRLG.lua +++ b/src/ui/Gen3OakSpeechFRLG.lua @@ -68,7 +68,11 @@ function Speech.new(game, onDone) self.black = 1 -- whole-screen fade to black self.picAlpha = 0 -- trainer pic + platform self.picTarget = 0 - self.picOffset = 0 -- slide left while the name list is up + -- Visual offset of BG2 trainer art + the platform OBJ sprites. Oak speech's + -- task variable counts DOWN to -60, but BG2 HOFS=-60 displays the bitmap + -- sixty pixels to the RIGHT, while gSpriteCoordOffsetX is explicitly +60. + -- Store the screen-space result here so draw() does not invert it again. + self.picOffset = 0 self.picScale = 1 self.picWhite = 0 self.nidoAlpha, self.nidoScale, self.nidoWhite = 0, 0, 0 @@ -199,6 +203,7 @@ end function Speech:menu(labels, opts) local Menu = require("src.ui.Menu") local choice + opts = opts or {} local items = {} for i, label in ipairs(labels) do items[i] = { label = label, onSelect = function() @@ -207,6 +212,15 @@ function Speech:menu(labels, opts) self.waitFrames = 1 end } end + -- oak_speech.c passes WindowTemplate CONTENT coordinates to these menus. + -- Their frame is one tile outside the template on every side and every row + -- starts at y=1 with a 16-pixel pitch. A fresh FireRed save has Frame Type + -- 1 (optionsWindowFrameType=0), so keep the professor intro on that frame + -- even though this port deliberately persists desktop options across New + -- Game. + opts.gbaWindow = true + opts.th = opts.th or (#labels * 2) + opts.frameIndex = 1 opts.cancelable = opts.onCancelIndex ~= nil if opts.onCancelIndex then opts.onCancel = function() choice = opts.onCancelIndex; self.waitFrames = 1 end @@ -366,7 +380,7 @@ function Speech:run() while true do box = self:ask(question) question = "rivalNameAgain" - if self.picOffset > -60 then self:tween("picOffset", -60, 2) end + if self.picOffset < 60 then self:tween("picOffset", 60, 2) end local labels = { clean(self.text.newName) or Strings("NEW NAME") } for i = 1, 4 do labels[#labels + 1] = rivals[i] end local pick = self:menu(labels, { tx = 2, ty = 2, tw = 12 }) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 361118158..e9fc095c6 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -19,6 +19,13 @@ function Menu.new(game, items, opts) self.tx = opts.tx or 10 self.ty = opts.ty or 0 self.tw = opts.tw or 10 + -- Gen 3 WindowTemplate coordinates describe the CONTENT rectangle. Its + -- standard frame is drawn one tile outside on every side. Most of this + -- engine's menus use the older convention where tx/ty/tw/th already name + -- the OUTER framed rectangle, so keep that as the default and opt the few + -- cartridge-window callers into the GBA convention explicitly. + self.gbaWindow = opts.gbaWindow or false + self.frameIndex = opts.frameIndex -- grow the box to the widest label so longer (e.g. localized) labels don't -- overflow the frame; nudge tx left to keep the box on-screen (20 tiles). do @@ -30,7 +37,7 @@ function Menu.new(game, items, opts) end end local needed = widest + 3 - if needed > self.tw then self.tw = needed end + if not self.gbaWindow and needed > self.tw then self.tw = needed end -- ...ON THE SCREEN THAT IS ACTUALLY THERE. Twenty tiles is the Game Boy's -- width and was written in here as a constant; Hoenn's screen is thirty, -- so a box the cartridge places near the right-hand side was shoved left @@ -38,7 +45,7 @@ function Menu.new(game, items, opts) -- for, and it returns the Game Boy's own 160 for Gen 1 and Gen 2, so -- nothing there moves. local cols = math.floor(select(1, Theme.uiSize()) / 8) - if self.tx + self.tw > cols then + if not self.gbaWindow and self.tx + self.tw > cols then self.tx = math.max(0, cols - self.tw) end end @@ -157,11 +164,16 @@ function Menu:draw() -- edge (the START menu asks for "topright"). Only menus that ask for it -- move; every other menu is placed exactly as before. local r = self.anchor and self.game and self.game.renderer + local frameTx, frameTy, frameTw, frameTh = self.tx, self.ty, self.tw, self.th + if self.gbaWindow then + frameTx, frameTy = self.tx - 1, self.ty - 1 + frameTw, frameTh = self.tw + 2, self.th + 2 + end if r and r.setUIAnchor then - r:setUIAnchor(self.tx * 8, self.ty * 8, - self.tw * 8, self.th * 8, self.anchor) + r:setUIAnchor(frameTx * 8, frameTy * 8, + frameTw * 8, frameTh * 8, self.anchor) end - Font.drawBox(self.tx, self.ty, self.tw, self.th) + Font.drawBox(frameTx, frameTy, frameTw, frameTh, self.frameIndex) love.graphics.setColor(0, 0, 0, 1) local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) or #self.items @@ -186,6 +198,24 @@ function Menu:draw() -- overhang, which is nothing at all for an 8-pixel font: every Game Boy -- menu in this engine draws where it always did, and a taller face lands -- with its last descender flush on the inner edge. + if self.gbaWindow then + -- FireRed Menu_InitCursor + AddTextPrinterParameterized3: the selector is + -- at x=0 in the window, labels at x=8, both start one pixel down, and rows + -- are sixteen pixels apart in the Oak intro menus. + for row = 1, visible do + local item = self.items[self.scroll + row] + if not item then break end + local y = self.ty * 8 + 1 + (row - 1) * self.rowStep * 8 + Font.draw(item.label, self.tx * 8 + 8, y) + end + local cursorRow = self.index - self.scroll + Font.drawCode(Theme.cursor, self.tx * 8, + self.ty * 8 + 1 + (cursorRow - 1) * self.rowStep * 8) + self:drawDescription() + love.graphics.setColor(1, 1, 1, 1) + return + end + local lastY = (self.ty + self.th - 2) * 8 local lift = math.max(0, (lastY + Font.glyphHeight()) - (self.ty + self.th - 1) * 8) diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index c848b32da..a58113f61 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -767,25 +767,30 @@ function NamingScreen:drawFireRed(rec) local bg = self:frlgImage("bg") if bg then g.draw(bg, 0, 0) end - -- the banner: window (0,0) 30x2, "{DPAD}MOVE {A}OK {B}BACK" right-aligned + -- the banner: PrintControls renders the exact + -- "{DPAD_ANY}MOVE {A_BUTTON}OK {B_BUTTON}BACK" string in FONT_SMALL and + -- right-aligns it four pixels from the edge. The control codes are bitmap + -- keypad icons, not outlined button boxes. g.setColor(0, 123 / 255, 197 / 255, 1) g.rectangle("fill", 0, 0, 240, 16) local white, darkGray = { 1, 1, 1, 1 }, { 98 / 255, 98 / 255, 98 / 255, 1 } - local hints = { { "+", Strings("MOVE") }, { "A", Strings("OK") }, { "B", Strings("BACK") } } + local help = { + { self:frlgImage("help_dpad"), Strings("MOVE") .. " " }, + { self:frlgImage("help_a"), Strings("OK") .. " " }, + { self:frlgImage("help_b"), Strings("BACK") }, + } local faced = Font.hasFace and Font.hasFace("small") and Font.pushFace("small") local width = 0 - for _, h in ipairs(hints) do width = width + 12 + Font.width(h[2]) + 4 end - if faced then Font.popFace() end - local hx = 240 - 4 - width + 4 - for _, h in ipairs(hints) do - g.setColor(white) - g.rectangle("line", hx + 0.5, 2.5, 10, 9, 3, 3) - text(h[1], hx + 2, 0, white, darkGray, true) - text(h[2], hx + 12, 0, white, darkGray, true) - local f2 = Font.hasFace and Font.hasFace("small") and Font.pushFace("small") - hx = hx + 12 + Font.width(h[2]) + 4 - if f2 then Font.popFace() end + for _, h in ipairs(help) do width = width + 8 + Font.width(h[2]) end + local hx = 240 - 4 - width + for _, h in ipairs(help) do + g.setColor(1, 1, 1, 1) + if h[1] then g.draw(h[1], hx, 0) end + hx = hx + 8 + text(h[2], hx, 0, white, darkGray, false) + hx = hx + Font.width(h[2]) end + if faced then Font.popFace() end -- the question and the typed name, on the plate BG3 already draws local entryInk = cc.entry and rgb(cc.entry[2]) or darkGray @@ -799,9 +804,13 @@ function NamingScreen:drawFireRed(rec) if glyph then text(Strings(glyph), x, 48 + 1, entryInk, entryShadow) end if underscore then g.draw(underscore, x + 3 - 4, 60 - 4) end end - local at = math.min(#self.glyphs + 1, self.maxLen) - if arrow and math.floor((self.blink or 0) / 30) % 2 == 0 then - g.draw(arrow, base + (at - 1) * 8 - 4, 56 - 4) + if arrow then + -- CreateTextEntrySprites puts this sprite at centre (base-5, 56), and it + -- stays there regardless of how many letters are already typed. Its + -- callback cycles x2={0,-4,-2,-1} every eight frames; it does not blink. + local wobble = { 0, -4, -2, -1 } + local wi = math.floor((self.blink or 0) / 8) % #wobble + 1 + g.draw(arrow, base - 9 + wobble[wi], 56 - 4) end -- the page: BG1's frame, the window's fill, the keys @@ -856,7 +865,25 @@ function NamingScreen:drawFireRed(rec) -- the cursor, on a key local cursor = self:frlgImage("cursor") if cursor and self.row <= m.keyRows then - g.draw(cursor, 30 + (cols[self.col] or 0), 80 + (self.row - 1) * 16) + local x, y = 30 + (cols[self.col] or 0), 80 + (self.row - 1) * 16 + g.setColor(1, 1, 1, 1) + g.draw(cursor, x, y) + local pulse = self:frlgImage("cursor_pulse") + local base5 = rec.cursorPulseBase + if pulse and type(base5) == "table" then + -- SpriteCB_Cursor advances 2/4/.../16/14/.../0, each value lasting + -- two frames. MultiplyInvertedPaletteRGBComponents moves green/blue + -- toward white by that amount and red by half as much. + local k = math.floor((self.blink or 0) / 2) % 16 + local n = (k < 8) and ((k + 1) * 2) or ((15 - k) * 2) + local function brighten(v, amount) + v = tonumber(v) or 0 + return (v + math.floor((31 - v) * amount / 16)) / 31 + end + g.setColor(brighten(base5[1], math.floor(n / 2)), + brighten(base5[2], n), brighten(base5[3], n), 1) + g.draw(pulse, x, y) + end end g.setColor(1, 1, 1, 1) end From 9f6d5aca8d207cb8664e036171495c7ae972cf5c Mon Sep 17 00:00:00 2001 From: Lokesh-shiva Date: Mon, 12 Oct 2026 16:29:17 +0530 Subject: [PATCH 2/2] fix(firered): match Oak intro and PC visuals --- src/core/Data.lua | 6 ++ src/import/RomExtractorGen3.lua | 129 ++++++++++++++++++++++++++++++-- src/import/RomImporter.lua | 2 +- src/render/TextBox.lua | 30 +++++++- src/ui/Gen3OakSpeechFRLG.lua | 69 ++++++++++++----- src/ui/Gen3PlayerPC.lua | 20 ++++- src/ui/Menu.lua | 19 ++++- 7 files changed, 243 insertions(+), 32 deletions(-) diff --git a/src/core/Data.lua b/src/core/Data.lua index 714d4d154..75caf95e2 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1154,6 +1154,12 @@ function Data:seedDefaults() maxCols = win.width or 26, } end + local oakSpeech = (self.constants or {}).gen3FRLGOakSpeech + if type(oakSpeech) == "table" and type(oakSpeech.dialogueArrow) == "table" then + local box = self.field.theme.textBox + box.promptArrow = oakSpeech.dialogueArrow + box.promptArrowAtPen = true + end local dialogue = self.font and self.font.dialogueFrame if type(dialogue) == "table" and dialogue.layout ~= "gen4" then local box = self.field.theme.textBox diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index f742150c4..3b0f5fcdf 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -5427,13 +5427,24 @@ function RomExtractorGen3:extractConstants() local spawn = self.manifest.newGame and self.manifest.newGame.spawn if type(spawn) == "table" and spawn.group and spawn.number then + local fireRed = (self.manifest or {}).frlgItemMenu ~= nil + -- FireRed does not use Emerald's WarpToTruck/centre-of-layout start. Its + -- WarpToPlayersRoom calls SetWarpDestination(..., -1, 6, 6) explicitly + -- (pokefirered src/new_game.c), on the same bedroom map identified by the + -- manifest. Keeping the Emerald-discovered y=3 put a new player beside + -- the PC instead of in the middle of the rug. + local startX = fireRed and 6 or (spawn.x or 0) + local startY = fireRed and 6 or (spawn.y or 0) constants.gen3NewGameSpawn = { map = ("MAP_G%02d_N%02d"):format(spawn.group, spawn.number), - x = spawn.x or 0, y = spawn.y or 0, - source = ("ROM:NewGameInitData -> WarpToTruck(group %d, map %d), " - .. "centre of a %dx%d layout") - :format(spawn.group, spawn.number, - spawn.width or 0, spawn.height or 0), + x = startX, y = startY, + source = fireRed + and ("ROM:NewGameInitData -> WarpToPlayersRoom(group %d, map %d), (6,6)") + :format(spawn.group, spawn.number) + or (("ROM:NewGameInitData -> WarpToTruck(group %d, map %d), " + .. "centre of a %dx%d layout") + :format(spawn.group, spawn.number, + spawn.width or 0, spawn.height or 0)), } end @@ -13443,6 +13454,9 @@ RomExtractorGen3.PC_MENU = { AT_ORDER = 0x04, -- ldr r0, = AT_COUNT = 0x0A, -- mov r0, # MOV_R0 = 0x2000, + -- FireRed's BedroomPC/PlayerPC call DisplayItemMessageOnField with this + -- string before opening the top menu. The symbol is gText_WhatWouldYouLikeToDo. + FRLG_TOP_PROMPT = 0x416262, -- THE SCREEN ITSELF, which blinks on when the PC is used. Both metatile -- sets are read out of the one function that writes them (08138D88), the -- blink out of the task that calls it (08138D10). @@ -13965,6 +13979,14 @@ function RomExtractorGen3:extractPCMenu() end end + local topPrompt + if fireRed then + local okPrompt, text = pcall(self.readText, self, P.FRLG_TOP_PROMPT, 80) + if okPrompt and type(text) == "string" and text ~= "" then + topPrompt = text + end + end + -- ---- ...AND THE POKEMON STORAGE MENU ---------------------------------- -- -- Reported from play: "for the storage missing deposit, withdrawal, and @@ -14095,6 +14117,7 @@ function RomExtractorGen3:extractPCMenu() itemStorage = storage, mailbox = mailbox, describe = describe, + topPrompt = topPrompt, text = said, storage = box, orders = orders, @@ -47035,6 +47058,12 @@ RomExtractorGen3.FRLG_OAK_SPEECH = { GUIDE_TILES = 0x4605E8, PIKACHU_MAP = 0x460BA8, OAK_TILES = 0x460CA4, OAK_MAP = 0x460CE8, GUIDE_PAGE2 = 0x460D94, GUIDE_PAGE3 = 0x460E34, + -- The guide's top bar uses GetTextWindowPalette(2), and its A/B control + -- codes use text.c's shared keypad sheet. The bobbing red page cursor is + -- sDoubleArrowTiles1 with gStandardMenuPalette. + KEYPAD_GFX = 0x1EA700, TEXT_WINDOW_PALS = 0x471DEC, + TEXT_CURSOR_GFX = 0x1EA54C, TEXT_CURSOR_PAL = 0x41F408, + DIALOGUE_ARROW_GFX = 0x1EA14C, PICS = { -- {pal, tiles, palette bank} red = { 0x4615FC, 0x46163C, 4 }, leaf = { 0x460ED4, 0x460F14, 4 }, oak = { 0x461CD4, 0x461D14, 6 }, rival = { 0x4623AC, 0x4623EC, 6 }, @@ -47094,6 +47123,96 @@ function RomExtractorGen3:extractFireRedOakSpeech() record.images[key] = "assets/generated/oak_speech_frlg/" .. key .. ".png" end + -- Top-bar palette + the two control-code glyphs. TopBarWindowPrintString + -- fills with palette index 15, uses indices 1/2 for the small-font ink and + -- shadow, and DrawKeypadIcon blits the same palette over gKeypadIconTiles. + pcall(function() + local palRaw = rom:bytes(S.TEXT_WINDOW_PALS + 2 * 32, 32) + local textPal = {} + for i = 0, 15 do + textPal[i] = { RomGba.bgr555(palRaw[i * 2 + 1] + palRaw[i * 2 + 2] * 256) } + end + record.colors = { + topBar = textPal[15], topInk = textPal[1], topShadow = textPal[2], + } + -- NewGameScene's setup copies GetTextWindowPalette(2)[15] over BG palette + -- 0 colour 0 before the controls guide is shown. Tile 0 is entirely index + -- 0, so this is also the guide's steady-state body colour and the colour + -- behind transparent-looking portions of the guide tilemaps. + bgColors[1] = textPal[15] + local raw = rom:bytes(S.KEYPAD_GFX, 64 * 32) + local px = RomGba.tiles4bpp(raw, 16, 4) + local function keypad(key, tile) + local sx, sy = (tile % 16) * 8, math.floor(tile / 16) * 8 + local img = ImageWriter.blank(8, 12) + for y = 0, 11 do + for x = 0, 7 do + local v = px[sy + y + 1][sx + x + 1] + local col = v ~= 0 and textPal[v] + if col then + img:setPixel(x, y, col[1] / 255, col[2] / 255, col[3] / 255, 1) + end + end + end + save(key, img) + end + keypad("guideAButton", 0x00) + keypad("guideBButton", 0x01) + end) + + -- CreateTextCursorSprite(sheet 0, 230,149) uses this 16x16 four-tile sprite. + pcall(function() + local raw = rom:bytes(S.TEXT_CURSOR_GFX, 0x80) + local px = RomGba.tiles4bpp(raw, 2, 2) + local palRaw = rom:bytes(S.TEXT_CURSOR_PAL, 32) + local pal = {} + for i = 0, 15 do + pal[i] = { RomGba.bgr555(palRaw[i * 2 + 1] + palRaw[i * 2 + 2] * 256) } + end + local img = ImageWriter.blank(16, 16) + for y = 0, 15 do + for x = 0, 15 do + local v = px[y + 1][x + 1] + local col = v ~= 0 and pal[v] + if col then + img:setPixel(x, y, col[1] / 255, col[2] / 255, col[3] / 255, 1) + end + end + end + save("guideCursor", img) + end) + + -- FireRed's ordinary text-printer prompt is not a font glyph. text.c blits + -- a 10x12 rectangle from sDownArrowTiles at currentX/currentY, cycling source + -- x offsets 0,16,32,16. Preserve the three distinct cartridge frames in one + -- strip; TextBox places them at the live pen rather than on the box border. + pcall(function() + local raw = rom:bytes(S.DIALOGUE_ARROW_GFX, 0x400) + local px = RomGba.tiles4bpp(raw, 16, 2) -- 128x16 source bitmap + local palRaw = rom:bytes(S.TEXT_WINDOW_PALS + 2 * 32, 32) + local pal = {} + for i = 0, 15 do + pal[i] = { RomGba.bgr555(palRaw[i * 2 + 1] + palRaw[i * 2 + 2] * 256) } + end + local img = ImageWriter.blank(30, 12) + for frame, sx in ipairs({ 0, 16, 32 }) do + for y = 0, 11 do + for x = 0, 9 do + local v = px[y + 1][sx + x + 1] + local col = v ~= 0 and pal[v] + if col then + img:setPixel((frame - 1) * 10 + x, y, + col[1] / 255, col[2] / 255, col[3] / 255, 1) + end + end + end + end + save("dialogueArrow", img) + record.dialogueArrow = { + path = record.images.dialogueArrow, frames = 3, width = 10, height = 12, + } + end) + -- a 4bpp tilemap, `cols` wide, placed at a tile offset on a 240x160 canvas local function compose(key, tiles, map, cols, rows, ox, oy) local img = ImageWriter.blank(240, 160) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 151866cc8..0591ac8f9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1227,7 +1227,7 @@ end -- files/fields, so without a format bump an updated runtime would keep -- drawing the pre-fix handmade help icons and static selection cursor -- until the user happened to force a ROM re-import manually. -local CACHE_FORMAT = "rom-cache-v361:" +local CACHE_FORMAT = "rom-cache-v362:" -- The completion marker is written under each version's cache prefix -- (rom-cache.complete for Red, blue/rom-cache.complete for Blue). local MARKER_PATH = "rom-cache.complete" diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 9fa41a883..bb7db70ba 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -7,6 +7,7 @@ -- the text is exhausted and A is pressed, then calls onDone. local Logger = require("src.core.Logger") +local Assets = require("src.render.Assets") local Font = require("src.render.Font") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") @@ -89,6 +90,9 @@ function TextBox.new(game, text, onDone, opts) self.drawFrame = opts.drawFrame ~= false self.fillColor = opts.fillColor self.textStyle = opts.style + self.promptArrow = opts.promptArrow or box.promptArrow + self.promptArrowAtPen = opts.promptArrowAtPen == true or box.promptArrowAtPen == true + self.promptArrowImage = nil self.clipToBox = opts.clipToBox == true self.uiWidth, self.uiHeight = opts.uiWidth, opts.uiHeight -- WHERE THE TWO LINES SIT, which stopped being a constant when the font @@ -652,6 +656,7 @@ function TextBox:draw() -- and a line the wrapper measured as fitting runs off the end of the box. -- The wrapper already measures with Font.advanceOf, so this is what makes -- the drawing agree with the measuring rather than merely look better. + local lastPen, lastY for i, line in ipairs(self.shown) do local y = (ys[i] or self.line2Y) + (i == 1 and off or 0) local pen = self.textX @@ -659,10 +664,29 @@ function TextBox:draw() Font.drawCode(code, pen, y) pen = pen + Font.advanceOf(code) + self.letterSpacing end + lastPen, lastY = pen, y end - if (self.waiting or (self.done and not self.choice and not self.auto - and not self.stay)) - and self.blink < 30 then + local prompt = self.waiting or (self.done and not self.choice and not self.auto + and not self.stay) + if prompt and self.promptArrowAtPen and type(self.promptArrow) == "table" + and type(self.promptArrow.path) == "string" and lastPen and lastY then + if self.promptArrowImage == nil then + local ok, img = pcall(Assets.image, self.promptArrow.path) + self.promptArrowImage = ok and img or false + if self.promptArrowImage then self.promptArrowImage:setFilter("nearest", "nearest") end + end + if self.promptArrowImage then + local fw = tonumber(self.promptArrow.width) or 10 + local fh = tonumber(self.promptArrow.height) or 12 + local frames = tonumber(self.promptArrow.frames) or 3 + local seq = { 0, 1, 2, 1 } + local frame = math.min(frames - 1, seq[math.floor(self.blink / 9) % 4 + 1]) + local iw, ih = self.promptArrowImage:getDimensions() + local quad = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.promptArrowImage, quad, lastPen, lastY) + end + elseif prompt and self.blink < 30 then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) Font.drawCode(Theme.moreArrow or 0xEE, diff --git a/src/ui/Gen3OakSpeechFRLG.lua b/src/ui/Gen3OakSpeechFRLG.lua index f32084ce7..0147d56fb 100644 --- a/src/ui/Gen3OakSpeechFRLG.lua +++ b/src/ui/Gen3OakSpeechFRLG.lua @@ -507,38 +507,61 @@ local function drawLines(text, x, y, pitch, ink, shadow, alpha) end function Speech:drawTopBar(right) - love.graphics.setColor(LIGHT[1], LIGHT[2], LIGHT[3], 1) + local colors = self.rec.colors or {} + local bar = colors.topBar or { 0, 123, 197 } + local ink = colors.topInk or { 255, 255, 255 } + local shadow = colors.topShadow or { 99, 99, 99 } + love.graphics.setColor(bar[1] / 255, bar[2] / 255, bar[3] / 255, 1) love.graphics.rectangle("fill", 0, 0, GBA_W, 16) love.graphics.setColor(1, 1, 1, 1) if self.phase == "guide" and self.page == 1 then - drawLines(clean(self.text.controls) or "CONTROLS", 4, 1, 14, DARK, WHITE, 1) - end - -- {A_BUTTON} NEXT / {B_BUTTON} BACK: the button glyphs are control codes the - -- text reader drops, so they are drawn here as the small keypad icons - local function icon(letter, x) - love.graphics.setColor(DARK[1], DARK[2], DARK[3], 1) - love.graphics.rectangle("fill", x, 3, 11, 10, 3, 3) - love.graphics.setColor(1, 1, 1, 1) - drawLines(letter, x + 3, 1, 14, WHITE, DARK, 1) - return x + 13 + drawLines(clean(self.text.controls) or "CONTROLS", 4, 1, 14, + { ink[1] / 255, ink[2] / 255, ink[3] / 255 }, + { shadow[1] / 255, shadow[2] / 255, shadow[3] / 255 }, 1) end + -- TopBarWindowPrintString uses FONT_SMALL and right-aligns the literal + -- {A_BUTTON}/{B_BUTTON} control-code icons four pixels from the edge. local nextWord = ((clean(self.text.aNext) or "NEXT"):gsub("^%s+", "")) - local parts = { { "A", nextWord } } - if self.page > 1 then parts[2] = { "B", "BACK" } end + local parts = { { self:image("guideAButton"), nextWord .. (self.page > 1 and " " or "") } } + if self.page > 1 then parts[2] = { self:image("guideBButton"), "BACK" } end + local faced = Font.hasFace and Font.hasFace("small") and Font.pushFace("small") local width = 0 - for _, p in ipairs(parts) do width = width + 13 + Font.width(p[2]) + 6 end - local x = GBA_W - width - 2 + for _, p in ipairs(parts) do width = width + 8 + Font.width(p[2]) end + local x = GBA_W - 4 - width + local topInk = { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 } + local topShadow = { shadow[1] / 255, shadow[2] / 255, shadow[3] / 255, 1 } for _, p in ipairs(parts) do - x = icon(p[1], x) - drawLines(p[2], x, 1, 14, DARK, WHITE, 1) - x = x + Font.width(p[2]) + 6 - end + love.graphics.setColor(1, 1, 1, 1) + if p[1] then love.graphics.draw(p[1], x, 1) end + x = x + 8 + local two = Font.beginTwoTone(topInk, topShadow) + if not two then love.graphics.setColor(topInk) end + Font.draw(p[2], x, 1) + if two then Font.endTwoTone() end + x = x + Font.width(p[2]) + end + if faced then Font.popFace() end + love.graphics.setColor(1, 1, 1, 1) end function Speech:drawGuide() local bg = self.rec.backdrop or { 16, 115, 230 } love.graphics.setColor(bg[1] / 255, bg[2] / 255, bg[3] / 255, 1) love.graphics.rectangle("fill", 0, 0, GBA_W, GBA_H) + -- oak_speech.c fills BG1's row below the top-bar with tile 2/palette 13 and + -- the last screen row with tile 14/palette 13. Their decoded scanlines are + -- solid, so reproduce the cartridge pixels directly rather than leaving the + -- guide's plain backdrop visible through those two bands. + local function band(y, h, r, g, b) + love.graphics.setColor(r / 255, g / 255, b / 255, 1) + love.graphics.rectangle("fill", 0, y, GBA_W, h) + end + band(16, 2, 0, 82, 115) + band(18, 1, 16, 172, 222) + band(19, 5, 0, 123, 197) + band(152, 5, 0, 123, 197) + band(157, 1, 0, 115, 139) + band(158, 2, 0, 82, 115) love.graphics.setColor(1, 1, 1, 1) local icons = self.page == 2 and self:image("guidePage2") or self.page == 3 and self:image("guidePage3") or nil @@ -550,6 +573,14 @@ function Speech:drawGuide() drawLines(clean(self.text[t.key]), t.x, t.y, 16, WHITE, DARK, self.textAlpha) end self:drawTopBar() + -- CreateTextCursorSprite(0, 230,149) becomes a 16x16 sprite at (225,145). + -- SpriteCB_TextCursor advances 0,1,2,1 px every nine frames. + local cursor = self:image("guideCursor") + if cursor then + local bob = ({ 0, 1, 2, 1 })[math.floor(self.pikaTimer / 9) % 4 + 1] + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(cursor, 225, 145 + bob) + end end function Speech:drawPikachu() diff --git a/src/ui/Gen3PlayerPC.lua b/src/ui/Gen3PlayerPC.lua index 7292f8440..b72253f16 100644 --- a/src/ui/Gen3PlayerPC.lua +++ b/src/ui/Gen3PlayerPC.lua @@ -55,6 +55,14 @@ local FRLG_FALLBACK = { mailbox = { "READ", "MOVE TO BAG", "GIVE", "EXIT" }, } +-- sStandardTextBox_WindowTemplates[0] in new_menu_helpers.c. Both the static +-- top-menu prompt and the highlighted Item Storage description are printed in +-- this same field-dialogue window on FireRed. +local FRLG_DIALOGUE_BOX = { + tx = 2, ty = 15, tw = 26, th = 4, + gbaWindow = true, textX = 0, textY = 1, +} + function Gen3PlayerPC.words(game, menu) local c = game and game.data and game.data.constants or {} local r = record(game) @@ -389,6 +397,7 @@ local function itemStorage(game, onCancel) or nil rows[#rows + 1] = { label = Strings(label), + describe = describe[i], keepOpen = flow ~= nil, onSelect = flow and function() flow(game) end or nil, } @@ -404,6 +413,7 @@ local function itemStorage(game, onCancel) end -- sWindowTemplate_ItemStorageSubmenu: (1,1), 14 wide inside its frame local sub = Menu.new(game, rows, { noSound = true, tx = 0, ty = 0, tw = 16, + describeBox = fireRed and FRLG_DIALOGUE_BOX or nil, onCancel = onCancel }) -- placed on the GBA's own 240x160, not the Game Boy's centred 160x144 function sub:uiSize() return 240, 160 end @@ -475,6 +485,13 @@ function Gen3PlayerPC.new(game, opts) opts = opts or {} game.save.pcItems = game.save.pcItems or {} local words = Gen3PlayerPC.words(game, "main") + local constants = (game.data or {}).constants or {} + local bagScreen = constants.gen3BagScreen + local fireRed = constants.gen3FRLGItemPc ~= nil + or (type(bagScreen) == "table" and bagScreen.layout == "frlg") + local pcRecord = record(game) or {} + local topPrompt = pcRecord.topPrompt + or (fireRed and "What would you like to do?" or nil) local menu local function close() local done = opts.onDone @@ -484,7 +501,7 @@ function Gen3PlayerPC.new(game, opts) local rows = {} for _, i in ipairs(Gen3PlayerPC.order(game, opts.order, #words)) do local label = words[i] - local row = { label = Strings(label or "") } + local row = { label = Strings(label or ""), describe = topPrompt } local word = tostring(label or ""):upper() if word:find("ITEM STORAGE", 1, true) then row.keepOpen = true @@ -512,6 +529,7 @@ function Gen3PlayerPC.new(game, opts) noSound = true, -- sWindowTemplate_TopMenu_*: (1,1), 13 wide inside its frame tx = 0, ty = 0, tw = 15, + describeBox = fireRed and FRLG_DIALOGUE_BOX or nil, onCancel = close, }) function menu:uiSize() return 240, 160 end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index e9fc095c6..a699dfb66 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -259,11 +259,24 @@ function Menu:drawDescription() local bw = box.tw or cols local bx = box.tx or 0 local by = box.ty or (rowsTall - bh) - Font.drawBox(bx, by, bw, bh) + local frameX, frameY, frameW, frameH = bx, by, bw, bh + -- A Gen 3 WindowTemplate describes the CONTENT rectangle. Field dialogue + -- window 0, for example, is (2,15) 26x4 and DrawDialogueFrame adds one tile + -- around it. PC descriptions use that exact window, so let callers opt the + -- description strip into the same convention instead of approximating it as + -- a full-width outer box. + if box.gbaWindow then + frameX, frameY = bx - 1, by - 1 + frameW, frameH = bw + 2, bh + 2 + end + Font.drawBox(frameX, frameY, frameW, frameH, box.frameIndex) love.graphics.setColor(0, 0, 0, 1) - local y = (by + 1) * 8 + local textX = box.gbaWindow and (bx * 8 + (box.textX or 0)) + or ((bx + 1) * 8 + (box.textX or 0)) + local y = box.gbaWindow and (by * 8 + (box.textY or 0)) + or ((by + 1) * 8 + (box.textY or 0)) for line in text:gmatch("[^\n]+") do - Font.draw(line, (bx + 1) * 8, y) + Font.draw(line, textX, y) y = y + 16 end end