Files
gen1recomp/tests/gen2_unown_words_test.lua
T
bryanthaboi ca4d3d283c Add Pokemon Crystal as a sixth supported version
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.
2026-08-23 12:10:28 -04:00

311 lines
12 KiB
Lua

-- The Ruins of Alph wall words: engine/events/unown_walls.asm:102
-- DisplayUnownWords and data/events/unown_walls.asm:7 UnownWalls.
-- CRYSTAL_CACHE="..." luajit tests/gen2_unown_words_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 unown words")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local Json = require("src.link.Json")
local UnownWords = require("src.world.gen2.UnownWords")
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
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
local function loadTable(path)
local chunk = loadfile(path)
return chunk and chunk() or nil
end
-- engine/events/unown_walls.asm:201 .ConvertChar
do
local tl, tr, bl, br = UnownWords.square(0x00) -- 'A'
eq(tl, 0x80, "A's top-left is bank 1 tile $00")
eq(tr, 0x81, "top-right is the next tile")
eq(bl, 0x90, "bottom-left is a 16-tile row down")
eq(br, 0x91, "and bottom-right beside it")
eq((UnownWords.square(0x0e)), 0x8e, "H is bank 1 tile $0e")
eq((UnownWords.square(0x20)), 0xa0, "I steps a whole row: bank 1 tile $20")
eq((UnownWords.square(0x4e)), 0xce, "X is the last computed letter")
local y1, y2, y3, y4 = UnownWords.square(0x60)
eq(y1 * 0x1000000 + y2 * 0x10000 + y3 * 0x100 + y4,
0x5b5c4d5d, "Y is .YChar's four bank 0 tiles")
local z1, z2, z3, z4 = UnownWords.square(0x62)
eq(z1 * 0x1000000 + z2 * 0x10000 + z3 * 0x100 + z4,
0x4e4f5e5f, "Z is .ZChar's")
local d1, d2, d3, d4 = UnownWords.square(0x64)
eq(d1 * 0x1000000 + d2 * 0x10000 + d3 * 0x100 + d4,
0x02030302, "and the dash is .DashChar's")
end
-- data/events/unown_walls.asm:15 MenuHeaders_UnownWalls, the n = 6 row.
do
local escape = { x1 = 3, y1 = 4, x2 = 16, y2 = 9,
chars = { 0x08, 0x44, 0x04, 0x00, 0x2e, 0x08 } }
local bx, by, bw, bh = UnownWords.boxRect(escape)
eq(bx, 3, "the box starts at column 3")
eq(by, 4, "row 4")
eq(bw, 14, "and spans the coordinate pair inclusive: 14 columns")
eq(bh, 6, "by 6 rows")
local ox, oy = UnownWords.origin(escape)
eq(ox, 4, "the first square is one column inside the frame")
eq(oy, 6, "and two rows down")
local squares = UnownWords.layout(escape)
eq(#squares, 6, "ESCAPE is six squares")
eq(squares[1].tx, 4, "the E starts at column 4")
eq(squares[2].tx, 6, "and every letter is two tiles wide")
eq(squares[6].tx, 14, "so the last one ends on the frame's inner edge")
eq(squares[1].ty, 6, "all of them on the same row")
eq(squares[4].tl, 0x80, "the A in ESCAPE is bank 1 tile $00")
end
-- constants/charmap.asm:424 the `unown` charmap.
do
local body = readFile("tools/rom_manifest_crystal.json", "r")
if not body then
check(true, "no tools/rom_manifest_crystal.json (SKIP)")
else
local manifest = Json.decode(body)
local map = manifest.unownCharmap
if not map then
check(false, "the Crystal manifest carries unownCharmap")
else
eq(map["0"], "A", "$00 is A")
eq(map["2"], "B", "$02 is B, two tiles along")
eq(map["14"], "H", "$0e is H, the last of the first row")
eq(map["32"], "I", "$20 is I: the row step is $10 every eight letters")
eq(map["78"], "X", "$4e is X")
eq(map["96"], "Y", "$60 is Y")
eq(map["98"], "Z", "$62 is Z")
eq(map["100"], "-", "$64 is the dash")
eq(map["255"], "@", "and $ff terminates a word")
local count = 0
for _ in pairs(map) do count = count + 1 end
eq(count, 28, "27 printable characters plus the terminator")
end
-- constants/charmap.asm:5 -- $00 is <NULL> in the main charmap.
local main = manifest.charmap or {}
check(main["0"] ~= "A", "the main charmap did not absorb the Unown rows")
eq(manifest.symbols.UnownWalls[1], 0x22,
"UnownWalls is in the bank pokecrystal.sym says")
eq(manifest.symbols.UnownWalls[2], 0x6ebc, "and at its address")
eq(manifest.symbols.MenuHeaders_UnownWalls[2], 0x6ed5,
"with the menu headers immediately after it")
end
end
do
for _, edition in ipairs({ "gold", "silver" }) do
local body = readFile("tools/rom_manifest_" .. edition .. ".json", "r")
if not body then
check(true, "no " .. edition .. " manifest (SKIP)")
else
local manifest = Json.decode(body)
check(manifest.unownCharmap == nil,
edition .. " has no Unown charmap: the block is Crystal only")
check(manifest.symbols.UnownWalls == nil,
edition .. " has no UnownWalls either")
end
end
end
-- constants/charmap.asm:423,433 the two `pushc` blocks the main parser skips.
do
local source = readFile("tools/make_gold_manifest.py", "r")
if not source then
check(true, "no tools/make_gold_manifest.py (SKIP)")
else
check(source:find('if re.match(r"(pushc|newcharmap)\\b", stripped):',
1, true) ~= nil, "the main charmap parser still stops at newcharmap")
end
local crystal = readFile("tools/make_crystal_manifest.py", "r")
if not crystal then
check(true, "no tools/make_crystal_manifest.py (SKIP)")
else
check(crystal:find('data["unownCharmap"] = unown_charmap(', 1, true) ~= nil,
"and the Unown one is generated beside it, under its own key")
check(crystal:find("PRINTABLE_UNOWN", 1, true) == nil,
"with the letter set read out of charmap.asm, not copied here")
end
end
-- home/map.asm:1357-1370 LoadTilesetGFX's two CopyBytes.
do
local source = assert(readFile("src/import/RomExtractorGen2.lua", "r"))
check(source:find("local function crystalTilesetSheet(pixels)", 1, true)
~= nil, "the Crystal sheet lays both VRAM banks out at their tile ids")
check(source:find("if twoBank then pixels = crystalTilesetSheet(pixels) end",
1, true) ~= nil, "and extractTilesets uses it")
check(source:find("local CRYSTAL_PAL_MAP_BYTES = 112", 1, true) ~= nil,
"the PalMap read covers the bank 1 rows too")
check(source:find("out.unownWalls = walls", 1, true) ~= nil,
"and readEventTables emits the wall words")
end
-- gfx/tilesets/ruins_of_alph.png, whose bottom half is the Unown alphabet.
do
local png = readFile(
"../pokecrystal/gfx/tilesets/ruins_of_alph.png")
if not png then
check(true, "no ../pokecrystal: the tileset's shape is not pinned (SKIP)")
else
local w, h = pngSize(png)
eq(w, 128, "pret's ruins_of_alph.png is 16 tiles across")
eq(h, 96, "and 12 down: $60 tiles per VRAM bank, two banks")
end
end
local cache = os.getenv("CRYSTAL_CACHE")
if not cache then
cache = (os.getenv("HOME") or "")
.. "/Library/Application Support/LOVE/crystal-dev/crystal"
end
local events = loadTable(cache .. "/data/generated/events.lua")
local tilesets = loadTable(cache .. "/data/generated/tilesets.lua")
-- data/events/unown_walls.asm:2-5
local EXPECTED = {
{ word = "ESCAPE", x1 = 3, y1 = 4, x2 = 16, y2 = 9 },
{ word = "LIGHT", x1 = 4, y1 = 4, x2 = 15, y2 = 9 },
{ word = "WATER", x1 = 4, y1 = 4, x2 = 15, y2 = 9 },
{ word = "HO-OH", x1 = 4, y1 = 4, x2 = 15, y2 = 9 },
}
if not events then
check(true, "no Crystal cache: the wall words are not read (SKIP)")
elseif not events.unownWalls then
check(true, "cache predates the wall words: re-import Crystal (SKIP)")
else
local walls = events.unownWalls
eq(#walls, 4, "NUM_UNOWN_WALLS rows came out")
for index, want in ipairs(EXPECTED) do
local got = walls[index] or {}
eq(got.word, want.word, want.word .. " decoded through the Unown charmap")
eq(got.id, index - 1, "at the UNOWNWORDS_* value the setval uses")
eq(#(got.chars or {}), #want.word, "with one character byte per letter")
eq(got.x1, want.x1, want.word .. "'s box left edge")
eq(got.y1, want.y1, "top edge")
eq(got.x2, want.x2, "right edge")
eq(got.y2, want.y2, "bottom edge")
-- data/events/unown_walls.asm:20 MENU_BACKUP_TILES
eq(got.flags, 0x40, "and MENU_BACKUP_TILES set")
-- data/events/unown_walls.asm:21 `menu_coords 9 - n, 4, 10 + n, 9`
local _, _, bw = UnownWords.boxRect(got)
eq(bw - 2, #want.word * 2, "the frame is 2 columns per letter wide")
end
-- engine/events/unown_walls.asm:249 .DashChar
local dash = UnownWords.layout(walls[4])[3]
eq(dash.tl, 0x02, "HO-OH's dash comes out of .DashChar, not the alphabet")
end
if not tilesets then
check(true, "no Crystal cache: the tileset sheet is not read (SKIP)")
else
local roa = tilesets.TILESET_RUINS_OF_ALPH
if not roa then
check(false, "the Ruins of Alph tileset is in the cache")
elseif roa.imageHeight ~= 128 then
check(true, "cache predates the two-bank sheet: re-import Crystal (SKIP)")
else
eq(roa.imageWidth, 128, "the sheet is 16 tiles across")
eq(roa.imageHeight, 128, "and 16 down: 256 tile ids, both VRAM banks")
eq(roa.tilesPerRow, 16, "so a tile id indexes it directly")
eq(#roa.tilePalettes, 224, "the PalMap covers every id the blocks use")
local png = readFile(cache .. "/assets/generated/tilesets/ruins_of_alph.png")
if not png then
check(false, "the sheet PNG is actually in the cache")
else
local w, h = pngSize(png)
eq(w, 128, "the written PNG is 128 wide")
eq(h, 128, "and 128 tall")
end
-- constants/tileset_constants.asm:34-38, the five Crystal-only tilesets.
for _, name in ipairs({ "TILESET_KABUTO_WORD_ROOM",
"TILESET_OMANYTE_WORD_ROOM", "TILESET_AERODACTYL_WORD_ROOM",
"TILESET_HO_OH_WORD_ROOM", "TILESET_BETA_WORD_ROOM" }) do
local set = tilesets[name]
if not set then
check(false, name .. " extracted")
else
eq(set.imageHeight, 128, name .. " is a two-bank sheet")
-- engine/tilesets/map_palettes.asm:40 -- bit 7 is the VRAM bank.
local high = 0
for _, block in ipairs(set.blocks) do
for _, tile in ipairs(block) do
if tile >= 0x80 and tile < 0xe0 then high = high + 1 end
end
end
check(high > 0, name .. " really does draw out of bank 1")
end
end
end
end
-- data/events/special_pointers.asm:151 add_special DisplayUnownWords
do
local Specials = require("src.script.gen2.Specials")
check(type(Specials.ALL.DisplayUnownWords) == "function",
"DisplayUnownWords is a handler and not a stub")
eq(Specials.HANDLER_SOURCE.DisplayUnownWords,
"specials/unown_words.lua", "owned by this unit's module")
end
do
local maps = loadTable(cache .. "/data/generated/maps.lua")
local scripts = loadTable(cache .. "/data/generated/scripts.lua")
local consts = loadTable(cache .. "/data/generated/constants.lua")
if not (maps and scripts and consts and events and events.unownWalls) then
check(true, "no Crystal cache: the chambers are not walked (SKIP)")
else
local index
for id, name in pairs(consts.specialOrder or {}) do
if name == "DisplayUnownWords" then index = id - 1 end
end
check(index ~= nil, "the cache's specialOrder names DisplayUnownWords")
local chambers = {
RUINS_OF_ALPH_KABUTO_CHAMBER = 0,
RUINS_OF_ALPH_AERODACTYL_CHAMBER = 1,
RUINS_OF_ALPH_OMANYTE_CHAMBER = 2,
RUINS_OF_ALPH_HO_OH_CHAMBER = 3,
}
for mapId, want in pairs(chambers) do
local def = maps[mapId]
local walls = 0
for _, bg in ipairs((def or {}).bgEvents or {}) do
local rows = bg.scriptKey and scripts[bg.scriptKey]
local pending
for _, row in ipairs(rows or {}) do
if row.op == "setval" then
pending = row.value or (row.args and row.args[1])
elseif row.op == "special" and row.id == index then
walls = walls + 1
eq(pending, want, mapId .. " asks for its own word")
check(UnownWords.wallFor(
{ gen2EventTables = events }, pending) ~= nil,
"and that value indexes a real wall row")
end
end
end
eq(walls, 2, mapId .. " has both of its wall patterns")
end
end
end
S.finish()