mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +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.
311 lines
12 KiB
Lua
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()
|