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.
346 lines
14 KiB
Lua
346 lines
14 KiB
Lua
-- The Unown font (gfx/font/unown_font.png) and the ALPH RUINS STAMP viewer
|
|
-- (engine/events/print_unown.asm _UnownPrinter).
|
|
-- GOLD_CACHE="..." luajit tests/gen2_unown_printer_test.lua
|
|
--
|
|
-- Two leftovers from the Ruins of Alph, and they share a suite because they
|
|
-- share a subject: the letters. The font is the alphabet UNOWN MODE prints
|
|
-- its ring and its word in, and the viewer is the screen that lays the caught
|
|
-- forms out for a printer this port does not have.
|
|
--
|
|
-- Three things are worth pinning about the font: that the extractor block
|
|
-- exists and is reached from a stage run() actually calls, that the symbol it
|
|
-- reads is in the curated manifest set (a label missing from REQUIRED_SYMBOLS
|
|
-- makes self.symbols[label] nil and the block silently writes nothing -- the
|
|
-- exact way givepokemail and the trade texts were lost), and that what landed
|
|
-- in the cache has the shape of ../pokegold/gfx/font/unown_font.png.
|
|
--
|
|
-- And two about the viewer: that its wheel is the cart's (27 slots, the 27th
|
|
-- VACANT, wrapping both ways) and that it is REACHED -- Screens id, World
|
|
-- hook, and a `special UnownPrinter` dispatched by name.
|
|
--
|
|
-- ROM-free. The decomp section SKIPs with no ../pokegold beside the repo and
|
|
-- the cache section SKIPs (or asks for a re-import) with no Gold cache.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 unown printer")
|
|
local check, eq = S.check, S.eq
|
|
|
|
love = require("tests.love_stub")
|
|
|
|
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
|
|
local Screens = require("src.ui.Screens")
|
|
local Specials = require("src.script.gen2.Specials")
|
|
local Unown = require("src.core.gen2.Unown")
|
|
local UnownPrinter = require("src.ui.gen2.UnownPrinter")
|
|
local Vm = require("src.script.gen2.Vm")
|
|
|
|
-- NUM_UNOWN + 1 tiles on pret's 3-wide sheet, and FIRST_UNOWN_CHAR is $40.
|
|
local UNOWN_TILES = 27
|
|
local UNOWN_WIDE = 3
|
|
local FIRST_UNOWN_CHAR = 0x40
|
|
|
|
local function readFile(path, mode)
|
|
local f = io.open(path, mode or "rb")
|
|
if not f then return nil end
|
|
local body = f:read("*a")
|
|
f:close()
|
|
return body
|
|
end
|
|
|
|
-- PNG IHDR: width and height are big-endian at bytes 17 and 21.
|
|
local function pngSize(body)
|
|
local function be32(s, i)
|
|
local a, b, c, d = s:byte(i, i + 3)
|
|
return ((a * 256 + b) * 256 + c) * 256 + d
|
|
end
|
|
return be32(body, 17), be32(body, 21)
|
|
end
|
|
|
|
-- ---- the extractor --------------------------------------------------------
|
|
-- The font is written inside the Fonts stage rather than as a stage of its
|
|
-- own, so what has to be proved here is that the block is in extractFont and
|
|
-- that extractFont is still called from run().
|
|
local extractorSource = assert(readFile("src/import/RomExtractorGen2.lua", "r"))
|
|
check(extractorSource:find("results.font = self:extractFont()", 1, true) ~= nil,
|
|
"RomExtractorGen2:run still calls extractFont")
|
|
check(extractorSource:find('if self.symbols["UnownFont"] then', 1, true) ~= nil,
|
|
"and extractFont reads UnownFont out of the manifest")
|
|
check(extractorSource:find('"fonts/unown_font.png"', 1, true) ~= nil,
|
|
"and writes it as fonts/unown_font.png")
|
|
check(extractorSource:find("local UNOWN_FONT_TILES = 27", 1, true) ~= nil,
|
|
"27 tiles: NUM_UNOWN letters plus the cursor")
|
|
check(extractorSource:find("local UNOWN_FONT_WIDE = 3", 1, true) ~= nil,
|
|
"on pret's own 3-tile-wide sheet, so the decode IS the pret PNG")
|
|
|
|
do
|
|
local manifestSource = assert(readFile("tools/make_gold_manifest.py", "r"))
|
|
check(manifestSource:find('"UnownFont"', 1, true) ~= nil,
|
|
"UnownFont is in make_gold_manifest.py's REQUIRED_SYMBOLS")
|
|
end
|
|
|
|
local Json = require("src.link.Json")
|
|
do
|
|
local body = readFile("tools/rom_manifest_gold.json", "r")
|
|
if not body then
|
|
check(true, "no tools/rom_manifest_gold.json (SKIP)")
|
|
else
|
|
local symbols = (Json.decode(body).symbols) or {}
|
|
local got = symbols.UnownFont
|
|
if not got then
|
|
check(false, "the generated manifest carries UnownFont")
|
|
else
|
|
-- pokegold.sym: 3e:730e UnownFont.
|
|
eq(got[1], 0x3e, "UnownFont is in the bank pokegold.sym says")
|
|
eq(got[2], 0x730e, "and at the address pokegold.sym says")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- the decomp -----------------------------------------------------------
|
|
-- gfx/font/unown_font.png is the source of truth for the sheet's shape. Its
|
|
-- PIXELS were checked against the ROM at UnownFont directly (a row-major
|
|
-- 2bpp decode of 27 tiles is the PNG byte for byte, both planes equal, so the
|
|
-- glyphs are shade 3 on shade 0); what a Lua suite can read without a PNG
|
|
-- decoder is the header, which is what pins 3x9 tiles here.
|
|
do
|
|
local png = readFile("../pokegold/gfx/font/unown_font.png")
|
|
if not png then
|
|
check(true, "no ../pokegold: the font's shape is not pinned (SKIP)")
|
|
else
|
|
local w, h = pngSize(png)
|
|
eq(w, UNOWN_WIDE * 8, "gfx/font/unown_font.png is 3 tiles across")
|
|
eq(h, UNOWN_TILES / UNOWN_WIDE * 8, "and 9 down, which is 27 tiles")
|
|
end
|
|
end
|
|
|
|
-- ---- the cache ------------------------------------------------------------
|
|
local cache = os.getenv("GOLD_CACHE")
|
|
or ((os.getenv("HOME") or "")
|
|
.. "/Library/Application Support/LOVE/gold-dev/gold")
|
|
local fontData = (function()
|
|
local chunk = loadfile(cache .. "/data/generated/font.lua")
|
|
return chunk and chunk() or nil
|
|
end)()
|
|
|
|
if not fontData then
|
|
check(true, "no Gold cache: the font table is not read (SKIP)")
|
|
elseif not fontData.imageUnown then
|
|
check(true,
|
|
"cache predates the Unown font : re-import for gfx/font/unown_font (SKIP)")
|
|
else
|
|
eq(fontData.imageUnown, "assets/generated/fonts/unown_font.png",
|
|
"the sheet went where the #DEX looks for it")
|
|
eq(fontData.unownTiles, UNOWN_TILES, "27 tiles came out")
|
|
eq(fontData.unownWide, UNOWN_WIDE, "as a 3-wide sheet")
|
|
eq(fontData.unownBase, FIRST_UNOWN_CHAR,
|
|
"based at FIRST_UNOWN_CHAR, the VRAM tile Pokedex_LoadUnownFont loads at")
|
|
local png = readFile(cache .. "/assets/generated/fonts/unown_font.png")
|
|
if not png then
|
|
check(false, "the sheet PNG is actually in the cache")
|
|
else
|
|
local w, h = pngSize(png)
|
|
eq(w, UNOWN_WIDE * 8, "the cached sheet is 24px wide")
|
|
eq(h, UNOWN_TILES / UNOWN_WIDE * 8,
|
|
"and 72px tall, which is the pret PNG's own shape")
|
|
end
|
|
end
|
|
|
|
-- ---- UNOWN MODE draws through it ------------------------------------------
|
|
-- The ring of letters is `add FIRST_UNOWN_CHAR - 1` and the cursor is
|
|
-- FIRST_UNOWN_CHAR + NUM_UNOWN, so the mapping from a letter to a tile is the
|
|
-- whole of what this screen has to get right. The sheet itself never loads
|
|
-- in a harness with no real love, so it is replaced with a recorder.
|
|
do
|
|
local dexGame = {
|
|
data = {
|
|
pokemon = {},
|
|
gen2MenuGfx = { pokedex = { tiles = "x.png",
|
|
palette = { { 255, 255, 255 }, { 200, 100, 0 }, { 100, 0, 0 },
|
|
{ 0, 0, 0 } } } },
|
|
font = { imageUnown = "assets/generated/fonts/unown_font.png",
|
|
unownWide = UNOWN_WIDE, unownBase = FIRST_UNOWN_CHAR },
|
|
},
|
|
save = { pokedex = { seen = {}, caught = {} }, unownDex = { 3, 1 } },
|
|
}
|
|
local dex = PokedexMenu.new(dexGame, {})
|
|
check(dex.unownFont ~= nil,
|
|
"the #DEX builds an Unown font sheet when the cache carries one")
|
|
eq(dex.unownFontBase, FIRST_UNOWN_CHAR, "based at FIRST_UNOWN_CHAR")
|
|
|
|
-- The dex's own sheet is a recorder too: love_stub's quads have no
|
|
-- viewport, so a real TileSheet cannot draw in this harness and the chrome
|
|
-- around the ring is not what is under test here.
|
|
local quiet = { available = function() return true end,
|
|
draw = function() return true end }
|
|
dex.sheet, dex.objs = quiet, quiet
|
|
|
|
local drawn = {}
|
|
dex.unownFont = {
|
|
available = function() return true end,
|
|
draw = function(_, tile, tx, ty)
|
|
drawn[#drawn + 1] = { tile = tile, tx = tx, ty = ty }
|
|
return true
|
|
end,
|
|
}
|
|
dex:unownText("AZ", 4, 11)
|
|
eq(#drawn, 2, "both letters went through the Unown sheet")
|
|
eq(drawn[1].tile, FIRST_UNOWN_CHAR, "A is FIRST_UNOWN_CHAR")
|
|
eq(drawn[2].tile, FIRST_UNOWN_CHAR + 25, "Z is FIRST_UNOWN_CHAR + 25")
|
|
eq(drawn[2].tx, 5, "and the second glyph is one cell along")
|
|
drawn = {}
|
|
dex:unownCursor(3, 11)
|
|
eq(drawn[1] and drawn[1].tile, FIRST_UNOWN_CHAR + Unown.NUM_UNOWN,
|
|
"the cursor is the 27th tile, FIRST_UNOWN_CHAR + NUM_UNOWN")
|
|
|
|
-- The word under the picture is spelled in the same font: unown_words.asm's
|
|
-- `unownword` macro is `CHARVAL(...) - 'A' + FIRST_UNOWN_CHAR`.
|
|
drawn = {}
|
|
dex.view = "unown"
|
|
local ok = pcall(function() dex:drawUnown() end)
|
|
check(ok, "UNOWN MODE draws")
|
|
local sawWord = false
|
|
for _, row in ipairs(drawn) do
|
|
if row.ty == 15 then sawWord = true end
|
|
end
|
|
check(sawWord, "and the word at hlcoord 4, 15 is Unown-font tiles too")
|
|
|
|
-- A cache with no Unown font at all still prints the ring, in the ordinary
|
|
-- inverted font, exactly as this screen did before the sheet existed.
|
|
dexGame.data.font = {}
|
|
local plain = PokedexMenu.new(dexGame, {})
|
|
check(plain.unownFont == nil, "no sheet in the cache, no sheet on the screen")
|
|
plain.sheet, plain.objs = quiet, quiet
|
|
plain.view = "unown"
|
|
check(pcall(function() plain:drawUnown() end),
|
|
"and UNOWN MODE still draws without it")
|
|
end
|
|
|
|
-- ---- the viewer -----------------------------------------------------------
|
|
local hasId = false
|
|
for _, id in ipairs(Screens.GEN2_IDS) do
|
|
if id == "Gen2UnownPrinter" then hasId = true end
|
|
end
|
|
check(hasId, "the viewer is pushed through the Gen2UnownPrinter Screens id")
|
|
|
|
local function newViewer()
|
|
local pressed = {}
|
|
local game = {
|
|
data = { pokemon = {}, gen2Palettes = {} },
|
|
input = { wasPressed = function(_, button) return pressed[button] end },
|
|
}
|
|
local closed = 0
|
|
local screen = UnownPrinter.new(game, { onClose = function()
|
|
closed = closed + 1
|
|
end })
|
|
return screen, pressed, function() return closed end
|
|
end
|
|
|
|
do
|
|
local screen, pressed = newViewer()
|
|
eq(screen.index, 0, "the wheel starts on the first letter")
|
|
eq(screen:slots(), Unown.NUM_UNOWN + 1,
|
|
"27 slots: the 26 forms and the vacant stamp")
|
|
eq(screen:letter(), 1, "slot 0 is letter A")
|
|
|
|
pressed.left = true
|
|
screen:update(0)
|
|
eq(screen.index, Unown.NUM_UNOWN,
|
|
"LEFT off the first slot wraps to the vacant one (.press_left)")
|
|
eq(screen:letter(), nil, "which has no letter to show")
|
|
pressed.left = nil
|
|
|
|
pressed.right = true
|
|
screen:update(0)
|
|
eq(screen.index, 0, "RIGHT past the vacant slot comes back to A")
|
|
screen:update(0)
|
|
eq(screen.index, 1, "and otherwise steps one letter at a time")
|
|
pressed.right = nil
|
|
end
|
|
|
|
do
|
|
local screen, pressed, closed = newViewer()
|
|
pressed.a = true
|
|
screen:update(0)
|
|
eq(closed(), 0,
|
|
"A is the print, and with no Game Boy Printer it does nothing at all")
|
|
check(not screen.done, "the viewer stays up, the way .pressed_a loops back")
|
|
pressed.a = nil
|
|
pressed.b = true
|
|
screen:update(0)
|
|
eq(closed(), 1, "B closes it (.pressed_b, ReturnToMapFromSubmenu)")
|
|
screen:update(0)
|
|
eq(closed(), 1, "and closing twice is not two closes")
|
|
end
|
|
|
|
do
|
|
-- Drawing with no art at all: every pic lookup fails in this harness, so
|
|
-- this is a crash check, not a layout one.
|
|
local screen = newViewer()
|
|
check(pcall(function() screen:draw() end), "a letter slot draws")
|
|
screen.index = Unown.NUM_UNOWN
|
|
check(pcall(function() screen:draw() end), "and so does the vacant one")
|
|
end
|
|
|
|
-- ---- the special ----------------------------------------------------------
|
|
check(Specials.HANDLERS.UnownPrinter ~= nil,
|
|
"UnownPrinter has a handler: the viewer half needs no printer")
|
|
eq(Specials.STUBS.UnownPrinter, nil, "and is not also stubbed")
|
|
check(Specials.HANDLERS.PrintDiploma ~= nil,
|
|
"PrintDiploma took the same route: the diploma page is drawn on the "
|
|
.. "cartridge and only the send wanted a printer")
|
|
|
|
-- The hook a Specials handler reaches for lives in World:specialHooks, NOT in
|
|
-- the table handed to Vm.new -- a hook registered in the wrong one is
|
|
-- reachable by nobody.
|
|
do
|
|
local worldSource = assert(readFile("src/world/gen2/World.lua", "r"))
|
|
local hooks = worldSource:find("function World:specialHooks", 1, true)
|
|
local hook = worldSource:find("showUnownPrinter = function(onDone)", 1, true)
|
|
check(hooks ~= nil and hook ~= nil and hook > hooks,
|
|
"World:specialHooks is where showUnownPrinter is registered")
|
|
check(worldSource:find('self:pushScreen("Gen2UnownPrinter"', 1, true) ~= nil,
|
|
"and World:showUnownPrinter pushes the screen through the registry")
|
|
end
|
|
|
|
-- Dispatch by NAME through the cache's specialOrder, the way every other
|
|
-- special is dispatched.
|
|
do
|
|
local ORDER = { [40] = "UnownPrinter" } -- 0-based id 39 in the real cache
|
|
local opened = 0
|
|
local vm = Vm.new({}, {}, {}, {
|
|
specialOrder = ORDER,
|
|
specials = {
|
|
save = function() return { unownDex = { 1 } } end,
|
|
showUnownPrinter = function(done) opened = opened + 1 done() end,
|
|
},
|
|
})
|
|
vm.scriptVar = 7
|
|
vm:runSpecial(39)
|
|
eq(opened, 1, "`special UnownPrinter` opens the viewer")
|
|
eq(vm.scriptVar, 7,
|
|
"and leaves wScriptVar alone, because the routine never writes it")
|
|
|
|
-- `ld a, [wUnownDex] / and a / ret z`: no Unown caught, no screen.
|
|
local empty = 0
|
|
local gated = Vm.new({}, {}, {}, {
|
|
specialOrder = ORDER,
|
|
specials = {
|
|
save = function() return { unownDex = {} } end,
|
|
showUnownPrinter = function(done) empty = empty + 1 done() end,
|
|
},
|
|
})
|
|
gated.scriptVar = 4
|
|
gated:runSpecial(39)
|
|
eq(empty, 0, "an empty #DEX returns before the screen is drawn")
|
|
eq(gated.scriptVar, 4, "still without touching wScriptVar")
|
|
|
|
-- No hook at all (a Vm with no World behind it) has to return, not hang.
|
|
local bare = Vm.new({}, {}, {},
|
|
{ specialOrder = ORDER, specials = {} })
|
|
bare.scriptVar = 2
|
|
bare:runSpecial(39)
|
|
eq(bare.scriptVar, 2, "and a hookless VM carries on untouched")
|
|
end
|
|
|
|
S.finish()
|