Files
gen1recomp/src/ui/gen2/UnownPrinter.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

206 lines
7.2 KiB
Lua

-- The ALPH RUINS STAMP viewer (engine/events/print_unown.asm _UnownPrinter),
-- reached through `special UnownPrinter` (src/script/gen2/Specials.lua
-- H.UnownPrinter) from the Ruins of Alph research centre's printer once every
-- Unown form has been caught (RuinsOfAlphResearchCenterPrinter's `readvar
-- VAR_UNOWNCOUNT / ifequal NUM_UNOWN`).
--
-- _UnownPrinter is two halves and only one of them needs a peripheral: the
-- VIEWER -- the stamp sheet the player scrolls through with LEFT and RIGHT --
-- is drawn and driven entirely on the cartridge, and only the A press
-- (`farcall PrintUnownStamp`, engine/printer/printer.asm) walks the screen
-- out the serial port to a Game Boy Printer. There is no printer here, the
-- same reason PrintDiploma's own print goes nowhere, so A lands on the
-- same arm a cartridge with nothing plugged into its link port takes: the
-- stamp does not print and the screen stays up. Everything else is the
-- cart's.
--
-- RotateUnownFrontpic (engine/events/print_unown_2.asm) belongs to that
-- stubbed half and is deliberately not ported: it writes a 90-degree rotated
-- copy of the pic to vTiles2 tile $31, which nothing but
-- PlaceUnownPrinterFrontpic -- the PRINTED page -- ever reads. The pic on
-- screen is the upright one at tiles $00-$30.
--
-- Coordinates below are the literal hlcoord operands _UnownPrinter writes at:
--
-- hlcoord 0, 0 Textbox, 3 rows by 18 columns
-- hlcoord 0, 5 Textbox, 7 by 7 -- the frame round the stamp
-- hlcoord 0, 14 Textbox, 2 by 18
-- hlcoord 1, 2 " ALPH RUINS STAMP"
-- hlcoord 1, 16 "Do what?"
-- hlcoord 10, 6 the four-row menu, one row per `next`
-- hlcoord 1, 6 the 7x7 frontpic, or ClearBox + "VACANT" at hlcoord 1, 9
--
-- The menu's first two rows start with the bold A and B tiles
-- (gfx/printer/bold_a.1bpp / bold_b.1bpp, requested into vTiles0 at the ♂/♀
-- character cells). Those two tiles are not extracted -- they exist only for
-- this screen -- so the ordinary font's A and B stand in for them, which is
-- the same pair of letters in a lighter weight.
--
-- THE SHEET IS ALL 26 FORMS, not the caught ones: .UpdateUnownFrontpic loads
-- `wJumptableIndex + 1` as the letter whatever the #DEX holds, and the 27th
-- slot is the blank stamp the cart labels VACANT. The gate is on the way in
-- instead (H.UnownPrinter's `ld a, [wUnownDex] / and a / ret z`).
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
local Strings = require("src.core.Strings")
local Unown = require("src.core.gen2.Unown")
local UnownPrinter = {}
UnownPrinter.__index = UnownPrinter
UnownPrinter.isOpaque = true
function UnownPrinter:wantsFillScale() return true end
-- AlphRuinsStampString keeps its leading space: the string starts at column 1
-- and the space is what puts the S of STAMP under the box's own edge.
local TEXT = {
title = Strings.source(" ALPH RUINS STAMP"),
doWhat = Strings.source("Do what?"),
vacant = Strings.source("VACANT"),
menu = {
Strings.source("A▶PRINT"),
Strings.source("B▶CANCEL"),
Strings.source("L▶BEFORE"),
Strings.source("R▶NEXT"),
},
}
-- opts: pokemon, palettes, onClose()
function UnownPrinter.new(game, opts)
opts = opts or {}
local self = setmetatable({}, UnownPrinter)
self.game = game
local data = (game and game.data) or {}
self.pokemon = opts.pokemon or data.pokemon
self.palettes = opts.palettes or data.gen2Palettes
self.onClose = opts.onClose
-- wJumptableIndex: 0..NUM_UNOWN - 1 are the letters and NUM_UNOWN is the
-- vacant slot, so the wheel is 27 long.
self.index = 0
self.done = false
self.picCache = {}
return self
end
function UnownPrinter:slots()
return Unown.NUM_UNOWN + 1
end
-- The letter this slot shows, or nil for the vacant one.
function UnownPrinter:letter()
if self.index >= Unown.NUM_UNOWN then return nil end
return self.index + 1
end
function UnownPrinter:finish()
if self.done then return end
self.done = true
if self.onClose then self.onClose() end
end
-- .LeftRight, and the two wraps below it: RIGHT past the vacant slot comes
-- back to A, LEFT off A goes to the vacant slot. The cart reads hJoyLast
-- rather than hJoyPressed for these two, so holding the pad scrolls; edge
-- detection is close enough here and is what every other Gold menu in the
-- port uses.
function UnownPrinter:update(_dt)
if self.done then return end
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("b") then
-- .pressed_b: restore hInMenu/wOptions and ReturnToMapFromSubmenu.
self:finish()
return
end
if input:wasPressed("a") then
-- .pressed_a is `farcall PrintUnownStamp / call RestartMapMusic` and then
-- straight back into the joypad loop. With no printer there is nothing
-- to send and nothing to restart, so this is deliberately a no-op rather
-- than a screen the player has to back out of twice.
return
end
if input:wasPressed("right") then
self.index = (self.index + 1) % self:slots()
elseif input:wasPressed("left") then
self.index = (self.index - 1) % self:slots()
end
end
function UnownPrinter:picFor(letter)
local path = Unown.formSprite(self.pokemon, letter, false)
if not path then return nil end
local cached = self.picCache[path]
if cached == nil then
-- `and` truncates a multi-return, so the pcall has to stand alone.
local ok, image = pcall(Assets.image, path)
cached = (ok and image) or false
self.picCache[path] = cached
end
return cached or nil
end
-- .UpdateUnownFrontpic: PlaceGraphic of the form's 7x7 pic at hlcoord 1, 6.
function UnownPrinter:drawPic(letter)
local image = self:picFor(letter)
if not image then return end
local colors = self.palettes
and Palettes.monColors(self.palettes, Unown.SPECIES)
local G = love.graphics
G.setColor(1, 1, 1, 1)
local function body() G.draw(image, 1 * 8, 6 * 8) end
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
G.setColor(1, 1, 1, 1)
end
function UnownPrinter:drawPanel()
Chrome.clear()
Chrome.textbox(0, 0, 18, 3)
Chrome.textbox(0, 5, 7, 7)
Chrome.textbox(0, 14, 18, 2)
Chrome.print(Strings(TEXT.title), 1, 2)
Chrome.print(Strings(TEXT.doWhat), 1, 16)
for i, row in ipairs(TEXT.menu) do
Chrome.print(Strings(row), 10, 5 + i)
end
local letter = self:letter()
if letter then
self:drawPic(letter)
else
-- .vacant: ClearBox over the 7x7 block, then VACANT across its middle.
Chrome.print(Strings(TEXT.vacant), 1, 9)
end
end
function UnownPrinter:draw()
self:drawPanel()
end
-- _PrintUnown opens on ClearBGPalettes / ClearTilemap
-- (engine/events/print_unown.asm:17-18), so the #DEX screen underneath is
-- gone. The panel starts from Chrome.clear(), so white IS the surround.
function UnownPrinter:drawsWidescreen() return true end
function UnownPrinter:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return UnownPrinter