mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 05:32:07 -04:00
446 lines
17 KiB
Lua
446 lines
17 KiB
Lua
-- Crystal's player gender: the InitGender screen, the byte it writes, and the
|
|
-- eight places that read it back.
|
|
-- luajit tests/gen2_crystal_gender_test.lua
|
|
--
|
|
-- Gold and Silver have no Kris to extract, so every assertion comes in a
|
|
-- Crystal half and a Gold half: Gold must still boot straight into Oak with no
|
|
-- gender beat in the speech and Chris on the field.
|
|
-- The cache half SKIPs when no crystal cache is present.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 crystal gender")
|
|
local check, eq = S.check, S.eq
|
|
|
|
love = require("tests.love_stub")
|
|
|
|
local FieldMoves = require("src.world.gen2.FieldMoves")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local GenderSelect = require("src.ui.gen2.GenderSelect")
|
|
local InitClock = require("src.ui.gen2.InitClock")
|
|
local NamePick = require("src.ui.gen2.NamePick")
|
|
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
|
local OakSpeech = require("src.ui.gen2.OakSpeech")
|
|
local Pokegear = require("src.ui.gen2.Pokegear")
|
|
local Save = require("src.core.gen2.Save")
|
|
local Screens = require("src.ui.Screens")
|
|
local TrainerCard = require("src.ui.gen2.TrainerCard")
|
|
local Strings = require("src.core.Strings")
|
|
|
|
local priorVersion = GameVersion.get()
|
|
|
|
-- ------------------------------------------------- the two sprite tables
|
|
|
|
-- data/sprites/player_sprites.asm
|
|
eq(FieldMoves.stateSprite("normal", "male"), "SPRITE_CHRIS", "Chris on foot")
|
|
eq(FieldMoves.stateSprite("bike", "male"), "SPRITE_CHRIS_BIKE", "Chris biking")
|
|
eq(FieldMoves.stateSprite("normal", "female"), "SPRITE_KRIS", "Kris on foot")
|
|
eq(FieldMoves.stateSprite("bike", "female"), "SPRITE_KRIS_BIKE", "Kris biking")
|
|
eq(FieldMoves.stateSprite("surf", "female"), "SPRITE_SURF",
|
|
"both share the Lapras")
|
|
eq(FieldMoves.stateSprite("surf_pika", "female"), "SPRITE_SURFING_PIKACHU",
|
|
"and the surfing PIKACHU")
|
|
eq(FieldMoves.stateSprite("normal", nil), "SPRITE_CHRIS",
|
|
"no recorded gender is PLAYERGENDER_MALE")
|
|
eq(FieldMoves.stateSprite("skate", "female"), "SPRITE_KRIS",
|
|
"a state with no row falls back to PLAYER_NORMAL's")
|
|
eq(FieldMoves.playerSprite("female"), "SPRITE_KRIS", "playerSprite is the same")
|
|
check(FieldMoves.isFemale("female"), "female reads as PLAYERGENDER_FEMALE_F")
|
|
check(not FieldMoves.isFemale("male"), "male does not")
|
|
check(not FieldMoves.isFemale(nil), "and neither does an absent byte")
|
|
|
|
check(FieldMoves.hasGenderChoice({ SPRITE_KRIS = {} }),
|
|
"a cache with SPRITE_KRIS offers the choice")
|
|
check(not FieldMoves.hasGenderChoice({ SPRITE_CHRIS = {} }),
|
|
"a Gold cache does not")
|
|
check(not FieldMoves.hasGenderChoice(nil), "and neither does no cache at all")
|
|
|
|
-- ------------------------------------------------- the save field (CT-5)
|
|
|
|
eq(Save.defaultPlayerName("crystal", "male"), "CHRIS", "MalePlayerNameArray[0]")
|
|
eq(Save.defaultPlayerName("crystal", "female"), "KRIS",
|
|
"FemalePlayerNameArray[0]")
|
|
eq(Save.defaultPlayerName("gold", "female"), "GOLD",
|
|
"Gold has no female array to fall into")
|
|
eq(Save.defaultPlayerName("silver", "female"), "SILVER", "nor does Silver")
|
|
eq(Save.defaultPlayerName("crystal"), "CHRIS", "and no gender is Chris")
|
|
|
|
check(Save.isFemale({ player = { gender = "female" } }), "isFemale on a save")
|
|
check(not Save.isFemale({ player = { gender = "male" } }), "and not on Chris")
|
|
check(not Save.isFemale({}), "nor on a save with no player block")
|
|
|
|
GameVersion.set("crystal")
|
|
local kris = Save.newGame({ gender = "female" })
|
|
eq(kris.player.gender, "female", "newGame records the answer")
|
|
eq(kris.player.name, "KRIS", "and lays down .Kris as the default name")
|
|
local chris = Save.newGame({})
|
|
eq(chris.player.gender, "male", "InitCrystalData zeroes the byte")
|
|
eq(chris.player.name, "CHRIS", "so a fresh save is Chris")
|
|
eq(Save.newGame({ gender = "female", playerName = "ZZ" }).player.name, "ZZ",
|
|
"an explicit name still wins")
|
|
|
|
local normalized = Save.normalize({ version = "crystal",
|
|
player = { gender = "female" } })
|
|
eq(normalized.player.name, "KRIS", "normalize fills the gendered default")
|
|
eq(Save.normalize({ version = "crystal", player = {} }).player.name, "CHRIS",
|
|
"and the male one with no byte")
|
|
|
|
-- ------------------------------------------------- the name presets
|
|
|
|
GameVersion.set("crystal")
|
|
eq(NamePick.presetsFor("male")[1], "CHRIS", "ChrisNameMenuHeader row 1")
|
|
eq(NamePick.presetsFor("male")[4], "JON", "and row 4")
|
|
eq(NamePick.presetsFor("female")[1], "KRIS", "KrisNameMenuHeader row 1")
|
|
eq(NamePick.presetsFor("female")[4], "JODI", "and row 4")
|
|
eq(NamePick.presetsFor()[1], "CHRIS", "no gender is the male header")
|
|
GameVersion.set("gold")
|
|
eq(NamePick.presetsFor("female")[1], "GOLD",
|
|
"Gold keeps PlayerNameArray whatever is asked for")
|
|
GameVersion.set("silver")
|
|
eq(NamePick.presetsFor("female")[1], "SILVER", "and so does Silver")
|
|
GameVersion.set("crystal")
|
|
|
|
eq(NamingScreen.playerSprite("female"), "SPRITE_KRIS",
|
|
"GetPlayerIcon's female sheet")
|
|
eq(NamingScreen.playerSprite("male"), "SPRITE_CHRIS", "and its male one")
|
|
|
|
-- ------------------------------------------------- the screen itself
|
|
|
|
check(Screens.GEN2_IDS ~= nil, "the registry lists the Gen 2 screens")
|
|
local registered = {}
|
|
for _, id in ipairs(Screens.GEN2_IDS) do registered[id] = true end
|
|
check(registered.Gen2GenderSelect, "Gen2GenderSelect has an id")
|
|
eq(Screens.get({ data = {} }, "Gen2GenderSelect"), GenderSelect,
|
|
"and resolves to the builtin")
|
|
|
|
eq(GenderSelect.OPTIONS[1].label, "Boy", ".MenuData item 1")
|
|
eq(GenderSelect.OPTIONS[1].gender, "male", "writes wPlayerGender 0")
|
|
eq(GenderSelect.OPTIONS[2].label, "Girl", ".MenuData item 2")
|
|
eq(GenderSelect.OPTIONS[2].gender, "female", "writes wPlayerGender 1")
|
|
|
|
-- A joypad that answers one press then goes quiet, the way the harness fakes
|
|
-- input for every other screen suite here.
|
|
local function fakeInput(button)
|
|
local pressed = button
|
|
return {
|
|
wasPressed = function(_, name)
|
|
if pressed and name == pressed then
|
|
pressed = nil
|
|
return true
|
|
end
|
|
return false
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function screenGame(save, button)
|
|
return { save = save, data = {}, input = fakeInput(button) }
|
|
end
|
|
|
|
-- pokecrystal/engine/menus/init_gender.asm:29-34
|
|
local function openMenu(screen)
|
|
for _ = 1, 600 do
|
|
if screen:menuOpen() then break end
|
|
screen:update(0)
|
|
end
|
|
return screen
|
|
end
|
|
|
|
local save = Save.newGame({})
|
|
local answered
|
|
local game = screenGame(save, "a")
|
|
local screen = GenderSelect.new(game, { onDone = function(g) answered = g end })
|
|
eq(screen.cursor, 1, "`db 1 ; default option` opens on Boy")
|
|
screen:update(0)
|
|
eq(screen.chosen, nil, "A while the prompt types answers nothing")
|
|
openMenu(screen)
|
|
screen:update(0)
|
|
eq(save.player.gender, "male", "A on Boy writes PLAYERGENDER_MALE")
|
|
eq(answered, nil, "and the DelayFrames 10 has not run out yet")
|
|
for _ = 1, 10 do screen:update(0) end
|
|
eq(answered, "male", "onDone fires once the ten frames are gone")
|
|
|
|
save = Save.newGame({})
|
|
game = screenGame(save, "down")
|
|
screen = GenderSelect.new(game, { onDone = function() end })
|
|
openMenu(screen)
|
|
screen:update(0)
|
|
eq(screen.cursor, 2, "down walks to Girl")
|
|
game.input = fakeInput("down")
|
|
screen:update(0)
|
|
eq(screen.cursor, 1, "STATICMENU_WRAP wraps at the bottom")
|
|
game.input = fakeInput("up")
|
|
screen:update(0)
|
|
eq(screen.cursor, 2, "and at the top")
|
|
game.input = fakeInput("b")
|
|
screen:update(0)
|
|
eq(save.player.gender, "male", "STATICMENU_DISABLE_B: B answers nothing")
|
|
game.input = fakeInput("a")
|
|
screen:update(0)
|
|
eq(save.player.gender, "female", "A on Girl writes PLAYERGENDER_FEMALE")
|
|
|
|
check(type(screen.text) == "string" and screen.text:find("boy"),
|
|
"the prompt is _AreYouABoyOrAreYouAGirlText")
|
|
|
|
-- The ROM prompt remains the source of truth while the two finite menu rows
|
|
-- go through the translation catalog.
|
|
do
|
|
local Chrome = require("src.ui.gen2.Chrome")
|
|
local priorPrint = Chrome.print
|
|
local printed = {}
|
|
Chrome.print = function(text) printed[#printed + 1] = text end
|
|
Strings.load({ strings = { Boy = "Garçon", Girl = "Fille" } })
|
|
local translated = GenderSelect.new({ data = { text = {
|
|
_AreYouABoyOrAreYouAGirlText = "ROM prompt stays intact",
|
|
} } })
|
|
eq(translated.text, "ROM prompt stays intact",
|
|
"the extracted RomText prompt is not replaced by a catalog fallback")
|
|
translated.text = ""
|
|
openMenu(translated)
|
|
translated:drawPanel()
|
|
check(table.concat(printed, "|"):find("Garçon", 1, true) ~= nil,
|
|
"Boy is translated when drawn")
|
|
check(table.concat(printed, "|"):find("Fille", 1, true) ~= nil,
|
|
"Girl is translated when drawn")
|
|
Strings.load({})
|
|
Chrome.print = priorPrint
|
|
end
|
|
|
|
-- home/text.asm:566
|
|
do
|
|
local Chrome = require("src.ui.gen2.Chrome")
|
|
local priorPrint = Chrome.print
|
|
local printed, rows = {}, {}
|
|
Chrome.print = function(text, _tx, ty)
|
|
printed[#printed + 1] = text
|
|
rows[text] = ty
|
|
end
|
|
local done = GenderSelect.new({ data = { text = {
|
|
_AreYouABoyOrAreYouAGirlText = "Are you a boy?\nOr are you a girl?{DONE}",
|
|
} } })
|
|
eq(done.text, "Are you a boy?\nOr are you a girl?",
|
|
"the {DONE} terminator is stripped from the prompt")
|
|
for _ = 1, 300 do
|
|
if done.typer:done() then break end
|
|
done:update(0)
|
|
end
|
|
done:drawPanel()
|
|
check(not table.concat(printed, "|"):find("DONE", 1, true),
|
|
"and never reaches the tile grid")
|
|
-- ../pokecrystal/home/text.asm:473
|
|
eq(rows["Are you a boy?"], 14, "the prompt's first line sits on row 14")
|
|
eq(rows["Or are you a girl?"], 16, "and `line` drops the second to row 16")
|
|
local prompt = GenderSelect.new({ data = { text = {
|
|
_AreYouABoyOrAreYouAGirlText = "Are you a boy?{PROMPT}",
|
|
} } })
|
|
eq(prompt.text, "Are you a boy?", "nor is {PROMPT}")
|
|
-- ../pokecrystal/home/print_text.asm:5
|
|
local slow = GenderSelect.new({
|
|
save = { options = { textSpeed = "SLOW" } },
|
|
data = { text = { _AreYouABoyOrAreYouAGirlText = "Are you a boy?" } },
|
|
})
|
|
check(slow.typer ~= nil and not slow.typer:done(),
|
|
"the prompt opens with nothing shown")
|
|
for _ = 1, 15 do slow:update(0) end
|
|
eq(slow.typer.shown, 3, "SLOW is one character every five frames")
|
|
printed = {}
|
|
slow:drawPanel()
|
|
check(table.concat(printed, "|"):find("Are", 1, true) ~= nil
|
|
and not table.concat(printed, "|"):find("boy", 1, true),
|
|
"and only the typed part reaches the tile grid")
|
|
local fast = GenderSelect.new({
|
|
save = { options = { textSpeed = "FAST" } },
|
|
data = { text = { _AreYouABoyOrAreYouAGirlText = "Are you a boy?" } },
|
|
})
|
|
for _ = 1, 15 do fast:update(0) end
|
|
eq(fast.typer.shown, 14, "FAST is one a frame, and the line is 14 long")
|
|
Chrome.print = priorPrint
|
|
end
|
|
|
|
-- ------------------------------------------------- the beat in Oak's speech
|
|
|
|
local function speechFor(sprites)
|
|
return { game = { data = { gen2Sprites = sprites } } }
|
|
end
|
|
|
|
local crystalSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_KRIS = {} }))
|
|
eq(crystalSteps[1].id, "gender_select",
|
|
"PlayerProfileSetup's InitGender runs before OakSpeech")
|
|
eq(crystalSteps[1].kind, "gender", "as its own step kind")
|
|
eq(crystalSteps[2].id, "init_clock", "and the clock follows it")
|
|
|
|
local goldSteps = OakSpeech.defaultSteps(speechFor({ SPRITE_CHRIS = {} }))
|
|
eq(goldSteps[1].id, "init_clock", "Gold opens on the clock, as it always did")
|
|
for _, step in ipairs(goldSteps) do
|
|
check(step.kind ~= "gender", "and never grows a gender beat")
|
|
end
|
|
eq(OakSpeech.defaultSteps()[1].id, "init_clock",
|
|
"a bare call (no speech) is the Gold list")
|
|
eq(#goldSteps + 1, #crystalSteps, "the Crystal list is the Gold list plus one")
|
|
|
|
-- ../pokecrystal/engine/menus/intro_menu.asm:875
|
|
do
|
|
local G = love.graphics
|
|
local priorScissor, clips = G.setScissor, {}
|
|
G.setScissor = function(x, y, w, h) clips[#clips + 1] = { x, y, w, h } end
|
|
for _, screen in ipairs({
|
|
setmetatable({}, OakSpeech),
|
|
GenderSelect.new({ data = {} }),
|
|
InitClock.new({}, { save = {} }),
|
|
}) do
|
|
clips = {}
|
|
screen:draw()
|
|
eq(#clips, 1, "draw scissors once")
|
|
eq(clips[1][1], 0, "at the panel origin")
|
|
eq(clips[1][2], 0, "at the panel origin")
|
|
eq(clips[1][3], 160, "over the LCD's own width")
|
|
eq(clips[1][4], 144, "and its height")
|
|
end
|
|
G.setScissor = priorScissor
|
|
end
|
|
|
|
-- ------------------------------------------------- the trainer card
|
|
|
|
local CARD_GFX = {
|
|
card = "card.png", cardFemale = "card_f.png", cardTilesWide = 16,
|
|
leaderClasses = { "FALKNER" },
|
|
-- the male attrmap the extractor writes: portrait on $0, corner on $1
|
|
paletteZones = { { 14, 1, 5, 7, 1 }, { 18, 1, 1, 1, 2 } },
|
|
}
|
|
|
|
local function cardFor(gender)
|
|
return TrainerCard.new({ data = {} }, {
|
|
save = { player = { gender = gender, badges = {}, kantoBadges = {} } },
|
|
menuGfx = { trainerCard = CARD_GFX },
|
|
palettes = { trainers = { PLAYER = { { 1, 1, 1 }, { 2, 2, 2 } },
|
|
FALKNER = { { 3, 3, 3 }, { 4, 4, 4 } } } },
|
|
})
|
|
end
|
|
|
|
local male = cardFor("male")
|
|
check(not male.female, "Chris keeps ChrisCardPic")
|
|
eq(male.zoneDefault, 2, "and the border wears Falkner's row")
|
|
eq(male.zone[1 * 20 + 14], 1, "with the portrait on the player's")
|
|
|
|
local female = cardFor("female")
|
|
check(female.female, "Kris takes KrisCardPic")
|
|
eq(female.zoneDefault, 1, "the border swaps to Chris's row")
|
|
eq(female.zone[1 * 20 + 14], 2, "the portrait to Falkner's, which is Kris's")
|
|
eq(female.zone[1 * 20 + 18], 1, "the top-right corner follows the border")
|
|
eq(female.zone[14 * 20 + 14], 2, "and Clair borrows Kris's palette")
|
|
eq(female.zone[14 * 20 + 10], nil, "Pryce's box is untouched")
|
|
|
|
local noFemaleArt = TrainerCard.new({ data = {} }, {
|
|
save = { player = { gender = "female" } },
|
|
menuGfx = { trainerCard = { card = "card.png", paletteZones = {} } },
|
|
})
|
|
check(not noFemaleArt.female,
|
|
"a Gold cache with no cardFemale draws the card it has")
|
|
|
|
-- ------------------------------------------------- the Pokegear
|
|
|
|
local GEAR_GFX = {
|
|
tiles = "gear.png",
|
|
palettes = { { { 1, 1, 1 } } },
|
|
palettesFemale = { { { 9, 9, 9 } } },
|
|
}
|
|
|
|
local function gearFor(gender)
|
|
return Pokegear.new({ data = {} }, {
|
|
save = { player = { gender = gender }, pokegearFlags = {} },
|
|
menuGfx = { pokegear = GEAR_GFX },
|
|
})
|
|
end
|
|
|
|
eq(gearFor("male"):pals(), GEAR_GFX.palettes, "MalePokegearPals for Chris")
|
|
eq(gearFor("female"):pals(), GEAR_GFX.palettesFemale,
|
|
"FemalePokegearPals for Kris")
|
|
local noFemalePals = Pokegear.new({ data = {} }, {
|
|
save = { player = { gender = "female" }, pokegearFlags = {} },
|
|
menuGfx = { pokegear = { tiles = "gear.png", palettes = GEAR_GFX.palettes } },
|
|
})
|
|
eq(noFemalePals:pals(), GEAR_GFX.palettes,
|
|
"a Gold cache has one set and uses it")
|
|
|
|
-- ------------------------------------------------- the magnet train
|
|
|
|
local MagnetTrainRide = require("src.ui.gen2.MagnetTrainRide")
|
|
local SPRITES = { SPRITE_CHRIS = { image = "chris.png" },
|
|
SPRITE_KRIS = { image = "kris.png" } }
|
|
eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES },
|
|
gender = "female" }), SPRITES.SPRITE_KRIS, "Kris rides MAGNET_TRAIN_BLUE")
|
|
eq(MagnetTrainRide.playerSpriteDef({ data = { gen2Sprites = SPRITES },
|
|
gender = "male" }), SPRITES.SPRITE_CHRIS, "Chris rides MAGNET_TRAIN_RED")
|
|
eq(MagnetTrainRide.playerSpriteDef({
|
|
data = { gen2Sprites = { SPRITE_CHRIS = SPRITES.SPRITE_CHRIS } },
|
|
gender = "female" }), SPRITES.SPRITE_CHRIS,
|
|
"a Gold cache has only the one sheet")
|
|
|
|
-- ------------------------------------------------------------ cache-gated
|
|
|
|
local cache = os.getenv("CRYSTAL_CACHE")
|
|
if not cache then
|
|
local home = os.getenv("HOME") or ""
|
|
cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal"
|
|
end
|
|
|
|
local function loadLua(rel)
|
|
local chunk = loadfile(cache .. "/" .. rel)
|
|
if not chunk then return nil end
|
|
local ok, value = pcall(chunk)
|
|
return ok and value or nil
|
|
end
|
|
|
|
local function exists(rel)
|
|
local f = io.open(cache .. "/" .. rel, "rb")
|
|
if not f then return false end
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
local sprites = loadLua("data/generated/sprites.lua")
|
|
if not sprites then
|
|
check(true, "crystal cache absent : SKIP")
|
|
GameVersion.set(priorVersion)
|
|
S.finish()
|
|
return
|
|
end
|
|
|
|
check(sprites.SPRITE_KRIS ~= nil, "the cache carries SPRITE_KRIS")
|
|
check(sprites.SPRITE_KRIS_BIKE ~= nil, "and SPRITE_KRIS_BIKE")
|
|
eq(sprites.SPRITE_KRIS.palette, "PAL_OW_BLUE", "Kris is PAL_NPC_BLUE")
|
|
eq(sprites.SPRITE_CHRIS.palette, "PAL_OW_RED", "Chris is PAL_NPC_RED")
|
|
check(FieldMoves.hasGenderChoice(sprites),
|
|
"so a real Crystal cache offers the choice")
|
|
|
|
for _, rel in ipairs({
|
|
"assets/generated/intro/chris.png",
|
|
"assets/generated/intro/kris.png",
|
|
"assets/generated/trainer_card/card.png",
|
|
"assets/generated/trainer_card/card_f.png",
|
|
"assets/generated/sprites/kris.png",
|
|
"assets/generated/sprites/kris_bike.png",
|
|
}) do
|
|
check(exists(rel), "the cache carries " .. rel)
|
|
end
|
|
|
|
local menuGfx = loadLua("data/generated/menu_gfx.lua") or {}
|
|
check(menuGfx.trainerCard and menuGfx.trainerCard.cardFemale ~= nil,
|
|
"menu_gfx.trainerCard.cardFemale is extracted")
|
|
check(menuGfx.pokegear and menuGfx.pokegear.palettesFemale ~= nil,
|
|
"menu_gfx.pokegear.palettesFemale is extracted")
|
|
check(menuGfx.pack and menuGfx.pack.palettesFemale ~= nil,
|
|
"menu_gfx.pack.palettesFemale is extracted")
|
|
|
|
local palettes = loadLua("data/generated/palettes.lua") or {}
|
|
check(palettes.trainers and palettes.trainers.PLAYER ~= nil,
|
|
"PlayerPalette is row 0")
|
|
check(palettes.trainers and palettes.trainers.FALKNER ~= nil,
|
|
"and KrisPalette is Falkner's")
|
|
|
|
local romText = loadLua("data/generated/rom_text.lua") or {}
|
|
check(romText._AreYouABoyOrAreYouAGirlText ~= nil,
|
|
"the prompt is in the extracted text")
|
|
|
|
GameVersion.set(priorVersion)
|
|
S.finish()
|