mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
ca4d3d283c
Crystal boots from a user-supplied ROM, imports a full cache and is playable: copyright, the Crystal intro movie, the animated title, gender select, Oak, and out into Johto. 122 of the cart's 169 script specials are implemented. Import and data - tools/make_crystal_manifest.py derives the manifest by importing make_gold_manifest as a library, with three additive keyword seams. Gold and Silver still regenerate byte-identical, which is the standing requirement for touching that generator. - crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta: Crystal renames the credits mons, splits the trainer card, Pokegear and pack-pal blocks by gender, and replaces the intro and title outright. - Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the badge block sits one higher) and unownCharmap (the main charmap parser stops at the first newcharmap so the two cannot contaminate each other). Extractor - RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK 0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole, seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim steps with per-row degrade, and the column-major trainer card portraits. - New: animated front sprites (frames, bitmasks, play and idle scripts), the Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal intro and title via src/import/CrystalMovie.lua. Engine - GameVersion gains engine(id) and fixes(id). Gold and Silver keep their original bugs where the bug is not hardware dependent; Crystal gets the fixes Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the Reflect and Light Screen defence overflow. - Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower including the wInBattleTowerBattle badge-boost guard. - Kris and the gender flag, animated fronts in battle and the summary screen, and mon caught data. Verification - Every extracted asset is pixel-compared against pret's own source PNGs. - Gold caches are byte-identical before and after, file for file. - New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
425 lines
17 KiB
Lua
425 lines
17 KiB
Lua
-- Glyph pages and the four screens that read them.
|
|
--
|
|
-- GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" \
|
|
-- luajit tests/gen2_font_ui_test.lua
|
|
--
|
|
-- The $60-$7f slot holds ONE of two sheets (engine/gfx/load_font.asm): the
|
|
-- normal FontExtra, or FontBattleExtra where the same codes are different
|
|
-- glyphs -- $73 is a closing quote in one and the "ID" ligature in the other,
|
|
-- $74 a middle dot and the "No" ligature. A screen that places either
|
|
-- ligature has to have swapped the sheet in first, and LoadFontsBattleExtra
|
|
-- swaps only 25 tiles before `jr LoadFrame` puts the textbox frame back, so
|
|
-- the border glyphs are the SAME on both.
|
|
--
|
|
-- '#' is not a glyph at all: charmap.asm gives it $54 and home/text.asm
|
|
-- places the four characters "POKé" for it, which is how the cart's own
|
|
-- strings spell POKéMON (data/credits_strings.asm Credits_Staff).
|
|
--
|
|
-- The screen half of the suite is layout that a placement list cannot show:
|
|
-- which SHEET a print resolves against, which cells a box border may not
|
|
-- keep, and how tall a menu window is for the rows actually in it. Every
|
|
-- assertion runs a real `drawPanel` with the chrome recorded.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
love = love or {}
|
|
love.graphics = love.graphics or {}
|
|
local G = love.graphics
|
|
G.getColor = G.getColor or function() return 1, 1, 1, 1 end
|
|
G.setColor = G.setColor or function() end
|
|
G.rectangle = G.rectangle or function() end
|
|
G.print = G.print or function() end
|
|
G.printf = G.printf or function() end
|
|
G.draw = G.draw or function() end
|
|
G.newQuad = G.newQuad or function() return {} end
|
|
G.newImage = G.newImage or function() return nil end
|
|
G.getShader = G.getShader or function() return nil end
|
|
G.setShader = G.setShader or function() end
|
|
G.newShader = G.newShader or function() error("no shaders in this harness") end
|
|
G.getDimensions = G.getDimensions or function() return 160, 144 end
|
|
G.push = G.push or function() end
|
|
G.pop = G.pop or function() end
|
|
G.translate = G.translate or function() end
|
|
G.scale = G.scale or function() end
|
|
G.circle = G.circle or function() end
|
|
G.clear = G.clear or function() end
|
|
G.setLineWidth = G.setLineWidth or function() end
|
|
G.getFont = G.getFont or function() return nil end
|
|
G.setFont = G.setFont or function() end
|
|
love.math = love.math or { random = function(a, b) return b and a or 0.5 end }
|
|
love.image = love.image or {}
|
|
love.filesystem = love.filesystem or {
|
|
load = function() return nil end,
|
|
getInfo = function() return nil end,
|
|
read = function() return nil end,
|
|
write = function() return true end,
|
|
remove = function() return true end,
|
|
}
|
|
love.timer = love.timer or { getTime = function() return 0 end }
|
|
|
|
require("src.core.Logger").warn = function() end
|
|
|
|
local S = require("tests.harness").suite("gen2 font + ui chrome")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Chrome = require("src.ui.gen2.Chrome")
|
|
local Credits = require("src.ui.gen2.Credits")
|
|
local Font = require("src.render.Font")
|
|
local HallOfFame = require("src.ui.gen2.HallOfFame")
|
|
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
|
local PcMenu = require("src.ui.gen2.PcMenu")
|
|
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
|
|
|
-- --------------------------------------------------------------- recording
|
|
--
|
|
-- One log for boxes, prints and raw fills, so ORDER can be asserted: a cell
|
|
-- blanked before the string that owns it lands is the whole point.
|
|
|
|
local log = {}
|
|
local real = {}
|
|
|
|
local function startRecording()
|
|
log = {}
|
|
real.print, real.box, real.cursor = Chrome.print, Chrome.box, Chrome.cursor
|
|
real.clear, real.rect = Chrome.clear, love.graphics.rectangle
|
|
Chrome.print = function(text, tx, ty)
|
|
log[#log + 1] = { kind = "print", text = tostring(text), x = tx, y = ty,
|
|
battleExtra = Font.battleExtraActive() }
|
|
return 0
|
|
end
|
|
Chrome.box = function(tx, ty, tw, th)
|
|
log[#log + 1] = { kind = "box", x = tx, y = ty, w = tw, h = th }
|
|
end
|
|
Chrome.cursor = function(tx, ty)
|
|
log[#log + 1] = { kind = "cursor", x = tx, y = ty }
|
|
end
|
|
Chrome.clear = function() log[#log + 1] = { kind = "clear" } end
|
|
love.graphics.rectangle = function(mode, x, y, w, h)
|
|
log[#log + 1] = { kind = "fill", mode = mode, x = x, y = y, w = w, h = h }
|
|
end
|
|
end
|
|
|
|
local function stopRecording()
|
|
Chrome.print, Chrome.box, Chrome.cursor = real.print, real.box, real.cursor
|
|
Chrome.clear, love.graphics.rectangle = real.clear, real.rect
|
|
end
|
|
|
|
local function find(kind, predicate)
|
|
for i, entry in ipairs(log) do
|
|
if entry.kind == kind and (not predicate or predicate(entry)) then
|
|
return entry, i
|
|
end
|
|
end
|
|
return nil, nil
|
|
end
|
|
|
|
local function findPrint(text)
|
|
return find("print", function(e) return e.text == text end)
|
|
end
|
|
|
|
-- A fill that covers the whole of a tile-coordinate rectangle.
|
|
local function coveringFill(tx, ty, tw, th)
|
|
return find("fill", function(e)
|
|
return e.mode == "fill" and e.x <= tx * 8 and e.y <= ty * 8
|
|
and e.x + e.w >= (tx + tw) * 8 and e.y + e.h >= (ty + th) * 8
|
|
-- the panel-wide clear is not a cell blank
|
|
and e.w < 20 * 8
|
|
end)
|
|
end
|
|
|
|
-- --------------------------------------------------------------- the sheets
|
|
--
|
|
-- Real cache, real charmap: the codes and the page routing are the thing
|
|
-- under test, so a fixture font would assert nothing.
|
|
|
|
local cacheDir = os.getenv("GOLD_CACHE")
|
|
if not cacheDir or cacheDir == "" then
|
|
cacheDir = (os.getenv("HOME") or "") ..
|
|
"/Library/Application Support/LOVE/gold-dev/gold"
|
|
end
|
|
|
|
local fontDef = loadfile(cacheDir .. "/data/generated/font.lua")
|
|
fontDef = fontDef and fontDef()
|
|
|
|
-- Font is a singleton and this suite is also dofile'd into
|
|
-- tests/run_tests.lua, which put Red's Data through it; that goes back at the
|
|
-- end of the section.
|
|
local haveData, Data = pcall(require, "src.core.Data")
|
|
haveData = haveData and type(Data) == "table" and type(Data.font) == "table"
|
|
|
|
if not fontDef then
|
|
check(true, "no Gold cache (SKIP the glyph page checks)")
|
|
else
|
|
local images = {}
|
|
local realImage = require("src.render.Assets").image
|
|
require("src.render.Assets").image = function(path)
|
|
images[path] = images[path] or
|
|
{ path = path, getDimensions = function(self) return 128, 16 end }
|
|
if path:find("font%.png$") then
|
|
images[path].getDimensions = function() return 128, 64 end
|
|
end
|
|
-- One row per frame style, all eight (pokegold/gfx/font.asm:10).
|
|
if path:find("frames%.png$") then
|
|
images[path].getDimensions = function() return 48, 64 end
|
|
end
|
|
return images[path]
|
|
end
|
|
Font.load({ font = fontDef })
|
|
require("src.render.Assets").image = realImage
|
|
-- currentFrame is a Font module-local that outlives a suite, and this file
|
|
-- is also dofile'd into tests/run_tests.lua after screens that cycle it.
|
|
Font.setFrame(1)
|
|
|
|
-- Which sheet a code lands on, by the image drawCode reaches for.
|
|
local drawn
|
|
local realDraw = love.graphics.draw
|
|
love.graphics.draw = function(image) drawn = image and image.path end
|
|
local function sheetOf(code)
|
|
drawn = nil
|
|
Font.drawCode(code, 0, 0)
|
|
return (drawn or "none"):match("[^/]+$")
|
|
end
|
|
|
|
Font.useBattleExtra(false)
|
|
eq(sheetOf(0x73), "font_extra.png", "$73 is FontExtra's closing quote by default")
|
|
eq(sheetOf(0x74), "font_extra.png", "$74 is FontExtra's middle dot by default")
|
|
|
|
Font.useBattleExtra(true)
|
|
eq(sheetOf(0x73), "font_battle_extra.png",
|
|
"LoadFontsBattleExtra puts the <ID> ligature at $73")
|
|
eq(sheetOf(0x74), "font_battle_extra.png",
|
|
"and the No ligature at $74")
|
|
eq(sheetOf(0x78), "font_battle_extra.png",
|
|
"the swap runs to the last of the 25 tiles it loads")
|
|
-- `jr LoadFrame` at the end of _LoadFontsBattleExtra: $79-$7e are the
|
|
-- textbox frame on a battle-sheet screen too, so a box drawn there is the
|
|
-- same box as everywhere else. LoadFrame reads the `Frames` label, not
|
|
-- FontExtra (pokegold/engine/gfx/load_font.asm:29-39).
|
|
-- A cache built before the extractor split Frames out has no imageFrames
|
|
-- row, and its $79-$7e fall back to the extra sheet's baked-in frame 1.
|
|
if not fontDef.imageFrames then
|
|
check(true, "cache predates the frames sheet (SKIP the border routing)")
|
|
else
|
|
for code, name in pairs({ [0x79] = "tl", [0x7a] = "h", [0x7b] = "tr",
|
|
[0x7c] = "v", [0x7d] = "bl", [0x7e] = "br" }) do
|
|
eq(sheetOf(code), "frames.png",
|
|
("LoadFrame keeps the %s border glyph off the battle sheet"):format(name))
|
|
end
|
|
end
|
|
Font.useBattleExtra(false)
|
|
love.graphics.draw = realDraw
|
|
|
|
-- '#' places "POKé": four glyphs, on the byte the command sits on.
|
|
local codes = Font.encode("#MON")
|
|
eq(#codes, 7, "'#MON' is seven glyphs")
|
|
eq(codes[1], 0x8F, "P")
|
|
eq(codes[2], 0x8E, "O")
|
|
eq(codes[3], 0x8A, "K")
|
|
eq(codes[4], 0xEA, "é")
|
|
eq(codes[5], 0x8C, "M")
|
|
eq(codes[7], 0x8D, "N")
|
|
eq(Font.width("#MON"), 7 * 8, "and seven tiles wide")
|
|
local spans = Font.split("#MON")
|
|
eq(spans[1].from, 1, "the expansion stays on the '#' byte")
|
|
eq(spans[4].to, 1, "all four of it, so a cut never lands inside")
|
|
eq(spans[5].from, 2, "the next glyph resumes after the command")
|
|
|
|
-- Credits_Staff is `db " #MON"` verbatim; six spaces centre the seven
|
|
-- tiles POKéMON occupies on the 20-tile row.
|
|
local staff = Credits.STRINGS[Credits.STAFF]
|
|
eq(type(staff), "table", "the STAFF heading is three lines")
|
|
eq(#Font.split(staff[1]), 13, "its first line is six spaces plus POKéMON")
|
|
local line = Font.encode(staff[1])
|
|
eq(line[7], 0x8F, "which starts at column 6 with a P")
|
|
eq(line[10], 0xEA, "and carries the é of POKé")
|
|
|
|
-- The Hall of Fame roster line: '№' '.' then the dex number, and
|
|
-- '<ID>' '№' '/' then the trainer id (halloffame.asm :472 and :504).
|
|
eq(Font.encode("№.")[1], 0x74, "the roster's No ligature is $74")
|
|
local id = Font.encode("<ID>№/")
|
|
eq(#id, 3, "IDNo/ is three tiles")
|
|
eq(id[1], 0x73, "<ID> is $73")
|
|
eq(id[2], 0x74, "No is $74")
|
|
|
|
if haveData then Font.load(Data) end
|
|
end
|
|
|
|
-- ------------------------------------------------------- naming keyboard
|
|
--
|
|
-- data/text/name_input_chars.asm: the last row of NameInputUpper is
|
|
-- "lower DEL END" and the last row of NameInputLower is
|
|
-- "UPPER DEL END". The case target names the board it switches TO.
|
|
|
|
local naming = NamingScreen.new({}, { type = "player" })
|
|
eq(naming.lower, false, "the keyboard opens on the uppercase board")
|
|
|
|
startRecording()
|
|
naming:drawPanel()
|
|
stopRecording()
|
|
local caseLabel = find("print", function(e) return e.y == 16 and e.x == 2 end)
|
|
eq(caseLabel and caseLabel.text, "lower",
|
|
"the uppercase board offers 'lower'")
|
|
check(findPrint("A") ~= nil, "and it really is the uppercase board")
|
|
eq((find("print", function(e) return e.y == 16 and e.x == 9 end) or {}).text,
|
|
"DEL", "DEL keeps its column")
|
|
eq((find("print", function(e) return e.y == 16 and e.x == 15 end) or {}).text,
|
|
"END", "END keeps its column")
|
|
|
|
naming:toggleCase()
|
|
startRecording()
|
|
naming:drawPanel()
|
|
stopRecording()
|
|
eq((find("print", function(e) return e.y == 16 and e.x == 2 end) or {}).text,
|
|
"UPPER", "the lowercase board offers 'UPPER'")
|
|
check(findPrint("a") ~= nil, "and it really is the lowercase board")
|
|
|
|
-- ------------------------------------------------- summary move detail
|
|
--
|
|
-- SetUpMoveScreenBG draws Textbox (0,1) and Textbox (0,11) and only THEN
|
|
-- places the nickname at (5,1) and PlaceMoveData the TYPE plaque at (0,10) /
|
|
-- (0,11). Both land on a border row of a box already on screen, and a
|
|
-- tilemap write replaces the cell it lands on.
|
|
|
|
local MOVES = {
|
|
LEER = { name = "LEER", type = "NORMAL", power = 0,
|
|
description = "Reduces the foe's<NEXT>DEFENSE." },
|
|
}
|
|
local CYNDA = {
|
|
species = "CYNDAQUIL", nickname = "CYNDAQUIL", level = 22, hp = 50,
|
|
maxHp = 50, stats = {}, otName = "GOLD", otId = 52049,
|
|
moves = { { id = "LEER", pp = 27, maxPp = 30 } },
|
|
}
|
|
local summary = SummaryMenu.new({ data = { moves = MOVES } },
|
|
{ party = { CYNDA }, index = 1, moves = MOVES })
|
|
summary.moveDetail = true
|
|
|
|
startRecording()
|
|
summary:drawMoveDetail()
|
|
stopRecording()
|
|
|
|
local header, headerAt = findPrint("CYNDAQUIL")
|
|
check(header ~= nil and header.y == 1 and header.x == 5,
|
|
"the nickname is placed at (5,1), the upper box's top border row")
|
|
local upperBox, upperAt = find("box", function(e) return e.y == 1 end)
|
|
check(upperBox ~= nil, "the upper box is drawn")
|
|
-- "CYNDAQUIL" is 9 tiles and "<LV>22" three, so the header owns (5,1)-(16,1).
|
|
local headerClear, headerClearAt = coveringFill(5, 1, 12, 1)
|
|
check(headerClear ~= nil, "the cells the header owns are blanked")
|
|
check(upperAt and headerClearAt and upperAt < headerClearAt,
|
|
"after the box goes down, the way SetUpMoveScreenBG orders them")
|
|
check(headerClearAt and headerAt and headerClearAt < headerAt,
|
|
"and before the header prints, so no border runs through the letters")
|
|
|
|
local plaque, plaqueAt = findPrint("│TYPE/└")
|
|
check(plaque ~= nil and plaque.x == 0 and plaque.y == 11,
|
|
"String_MoveType_Bottom sits on the lower box's top border row")
|
|
local plaqueClear, plaqueClearAt = coveringFill(0, 10, 7, 2)
|
|
check(plaqueClear ~= nil, "the seven cells of the TYPE plaque are blanked")
|
|
check(plaqueClearAt and plaqueAt and plaqueClearAt < plaqueAt,
|
|
"before either of its two lines prints")
|
|
|
|
-- Nothing outside the strings' own cells is touched: the move list keeps the
|
|
-- box it is drawn in.
|
|
check(coveringFill(1, 3, 8, 1) == nil, "the move rows are not blanked")
|
|
|
|
-- ---------------------------------------------------------- storage menu
|
|
--
|
|
-- _BillsPC's five rows fit ClearPCItemScreen's (0,0) 10x18 box exactly, with
|
|
-- SEE YA! on row 10. The folded MAIL BOX makes six, and a window sized for
|
|
-- five closes under the fifth.
|
|
|
|
local function pcRows(opts)
|
|
local menu = PcMenu.new({ save = { party = { { species = "CYNDAQUIL" } } } },
|
|
opts)
|
|
startRecording()
|
|
menu:drawPanel()
|
|
stopRecording()
|
|
return menu
|
|
end
|
|
|
|
local bills = pcRows({ bills = true })
|
|
eq(#bills.entries, 5, "BILL's PC shows the cart's own five rows")
|
|
local billsBox = find("box", function(e) return e.y == 0 end)
|
|
eq(billsBox and billsBox.h, 12, "in ClearPCItemScreen's own 12-row window")
|
|
local seeya = findPrint("SEE YA!")
|
|
check(seeya ~= nil and seeya.y == 10, "SEE YA! on row 10")
|
|
check(seeya and billsBox and seeya.y <= billsBox.y + billsBox.h - 2,
|
|
"inside the window's interior")
|
|
|
|
local folded = pcRows({})
|
|
eq(#folded.entries, 6, "a directly-built PC folds the item PC's MAIL BOX in")
|
|
local foldedBox = find("box", function(e) return e.y == 0 end)
|
|
eq(foldedBox and foldedBox.h, 14, "and the window grows a row for it")
|
|
local seeya6 = findPrint("SEE YA!")
|
|
check(seeya6 ~= nil and seeya6.y == 12, "SEE YA! moves to row 12")
|
|
check(seeya6 and foldedBox and seeya6.y <= foldedBox.y + foldedBox.h - 2,
|
|
"still inside the window's interior")
|
|
-- .LogIn's text box goes down first so the taller window overlays it.
|
|
local whatBox, whatAt = find("box", function(e) return e.y == 12 end)
|
|
local topAt = select(2, find("box", function(e) return e.y == 0 end))
|
|
check(whatBox ~= nil and whatAt and topAt and whatAt < topAt,
|
|
"the What? box is drawn under the menu window, not over it")
|
|
check(findPrint("What?") ~= nil, "and its one word still prints")
|
|
|
|
-- ------------------------------------------------------- hall of fame
|
|
--
|
|
-- InitDisplayForHallOfFame (engine/movie/init_hof_credits.asm) and the top of
|
|
-- _HallOfFamePC both call LoadFontsBattleExtra, so every string on this
|
|
-- screen resolves against the battle sheet.
|
|
|
|
local hof = HallOfFame.new({ data = {} }, {
|
|
mode = "induct",
|
|
save = { player = { name = "GOLD", id = 52049 }, playTime = {} },
|
|
entry = { winCount = 1, mons = { {
|
|
species = "TYPHLOSION", nickname = "BLAZE", level = 50, otId = 52049,
|
|
} } },
|
|
})
|
|
hof.phase = "display"
|
|
|
|
Font.useBattleExtra(false)
|
|
startRecording()
|
|
hof:drawPanel()
|
|
stopRecording()
|
|
|
|
local dexLine = findPrint("№.")
|
|
check(dexLine ~= nil, "the roster line places the No ligature")
|
|
local idLine = findPrint("<ID>№/")
|
|
-- Which sheet a glyph resolves against needs the cache's real font rows.
|
|
if not fontDef then
|
|
check(true, "no Gold cache (SKIP the battle-sheet routing)")
|
|
else
|
|
check(dexLine and dexLine.battleExtra == true,
|
|
"with the battle sheet in the $60 slot, where that glyph lives")
|
|
check(idLine ~= nil and idLine.battleExtra == true,
|
|
"and so does the trainer id line")
|
|
end
|
|
eq(Font.battleExtraActive(), false,
|
|
"the sheet the caller had is put back when the screen is done")
|
|
|
|
-- The photo studio's card is the same three glyphs out of the same sheet:
|
|
-- PrintPartyMonPage1 (engine/printer/print_party.asm) opens with
|
|
-- LoadFontsBattleExtra before it writes any of them.
|
|
local PhotoStudio = require("src.ui.gen2.PhotoStudio")
|
|
local photo = PhotoStudio.new({ data = {} }, {
|
|
mon = { species = "CYNDAQUIL", nickname = "CYNDAQUIL", level = 22,
|
|
maxHp = 55, otId = 52049, moves = { { id = "TACKLE" } } },
|
|
playerName = "GOLD",
|
|
})
|
|
|
|
startRecording()
|
|
photo:draw()
|
|
stopRecording()
|
|
|
|
local photoDex = findPrint("№.")
|
|
local photoId = findPrint("<ID>№")
|
|
if not fontDef then
|
|
check(true, "no Gold cache (SKIP the photo card's battle-sheet routing)")
|
|
else
|
|
check(photoDex ~= nil and photoDex.battleExtra == true,
|
|
"the photo card's No ligature resolves against the battle sheet")
|
|
check(photoId ~= nil and photoId.battleExtra == true, "and its <ID> does too")
|
|
end
|
|
eq(Font.battleExtraActive(), false, "and the card puts the sheet back")
|
|
|
|
S.finish()
|