CLOSES #1562, CLOSES #1804, CLOSES #1805, CLOSES #1810, CLOSES #1811, CLOSES #1814, CLOSES #1817, CLOSES #1818, CLOSES #1819, CLOSES #1821, CLOSES #1826, CLOSES #1827, CLOSES #1829, CLOSES #1833, CLOSES #1840, CLOSES #1842, CLOSES #1845, CLOSES #1846, CLOSES #1847, CLOSES #1848, CLOSES #1849, CLOSES #1853, CLOSES #1858, CLOSES #1862

This commit is contained in:
bryanthaboi
2026-08-27 05:09:10 -04:00
parent 5051cb902d
commit 48d8a4e922
68 changed files with 3979 additions and 328 deletions
+19 -12
View File
@@ -1251,19 +1251,26 @@ local function pokemonTower2FRivalScript(playerX)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "face_player" },
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
{ "jump_if_true", "beaten" },
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" },
-- .DefeatedText rides BIT_PRINT_END_BATTLE_TEXT, so it prints on the
-- battle screen -- scripts/PokemonTower2F.asm:145-150
{ "save_end_battle_text", "_PokemonTower2FRivalDefeatedText" },
{ "rival_battle", "OPP_RIVAL2", 4 },
{ "jump_if_false", "end" }, -- loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
-- the win re-runs DisplayTextID on the beaten branch
-- scripts/PokemonTower2F.asm:72-75, :137-140
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
{ "walk_npc", 1, exitDirs },
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" },
{ "play_default_music" }, -- scripts/PokemonTower2F.asm:124
{ "jump", "end" },
{ "label", "beaten" },
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
}
end
+20 -21
View File
@@ -167,13 +167,11 @@ M.POKEMON_TOWER_6F = {
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
-- calmed line; the port dropped the first text and the cry
-- (#867). play_cry arms the next show_text, so the cry rides
-- the calmed box's open with the button prompt kept, and the
-- wait row stands in for the asm's 30-frame gap.
-- (#867). text/PokemonTower6F.asm:1-5 (#1849)
local rows = {
{ "play_cry", "MAROWAK" },
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
{ "play_cry", "MAROWAK", true },
{ "wait", 30 },
{ "show_text", t._PokemonTower6FSoulWasCalmedText
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
@@ -191,7 +189,8 @@ M.POKEMON_TOWER_6F = {
end
ow:afterBattle(result, battle)
end
game.stack:push(battle)
-- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702)
ow:pushBattle(battle)
end))
return true
end,
@@ -443,6 +442,9 @@ M.ROCKET_HIDEOUT_B4F = {
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
-- SaveEndBattleTextPointers (scripts/RocketHideoutB4F.asm:99-120):
-- PrintEndBattleText prints it on the battle screen (#1817)
battle.endBattleText = TextBox.substitute(game, cannotBe)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
@@ -451,22 +453,19 @@ M.ROCKET_HIDEOUT_B4F = {
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
-- BeatGiovanniScript (scripts/RocketHideoutB4F.asm)
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end
+11 -9
View File
@@ -104,7 +104,9 @@ local SE_FRAMES = {
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
SE_TRANSFORM_MON = 4,
SE_SUBSTITUTE_MON = 3,
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
-- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner
-- loop exits twice per displayed frame (animations.asm:1884-1903)
SE_WAVY_SCREEN = 128,
}
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
@@ -502,15 +504,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
local transform = resolveTransform(sub.type, attackerIsPlayer)
local first, last, dir = 1, #sub.blocks, 1
if transform == "REVERSE" then first, last, dir = last, first, -1 end
-- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK
-- and a 40-frame pause, then rewinds the same subanimation; the
-- mode-4 frame blocks persist, so the resting ball stays visible
-- through the pauses between wobbles
-- DoBallShakeSpecialEffects: animations.asm:739-747, :623-627
local pendingTink = false
for _ = 1, (opts and opts.shakes) or 1 do
if opts and opts.shakes then
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
emit(40)
end
pendingTink = opts and opts.shakes ~= nil
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
local nblocks = math.abs(last - first) + 1
local played = 0
@@ -568,6 +565,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
-- DoSpecialEffectByAnimationId runs after every frame
-- block with wSubAnimCounter = blocks remaining
played = played + 1
if pendingTink then
pendingTink = false
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
emit(40)
end
if ballFlicker then obp0Flip = not obp0Flip end
if idFx then
local counter = nblocks - played + 1
+43 -10
View File
@@ -340,6 +340,22 @@ local function monPalette(data, species)
return { name = name, colors = colors }
end
-- MarowakAnim OBJ pics under OAM_PAL1:
-- engine/battle/ghost_marowak_anim.asm:3-5,77
local function objPicPalette()
local PaletteFX = require("src.render.PaletteFX")
if not PaletteFX.usesSpriteObp() then return nil end
local colors, group = PaletteFX.ogObj()
if not colors then return nil end
return { name = "obp1:" .. tostring(group), colors = colors }
end
local function objPic(path, trueColor)
local pal = objPicPalette()
if not pal then return nil end
return getImage(path, pal, trueColor)
end
-- a named palette from the active COLORS pack as a getImage pal
local function namedPalette(data, name)
local PaletteFX = require("src.render.PaletteFX")
@@ -897,7 +913,8 @@ local function disguiseAsGhost(self)
self.enemy.name = "GHOST"
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
-- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article
self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name)
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
@@ -941,7 +958,11 @@ function BattleState:queueScopeReveal()
local unveiled = self.data.text and self.data.text._UnveiledGhostText
self:say(unveiled
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
self:act(function()
self.ghostReveal = { t = 0 }
local ghostObj = objPic("assets/generated/battle/front/ghost.png")
if ghostObj then self.enemy.sprite = ghostObj end
end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
@@ -3264,9 +3285,9 @@ function BattleState:applyAnimEffect(ev)
end
fx.hudShakeProg = prog
elseif e == "SE_WAVY_SCREEN" then
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
-- walking WavyScreenLineOffsets
fx.wavy = { left = 255, phase = 0 }
-- AnimationWavyScreen: 255 outer passes, two per displayed frame
-- (animations.asm:1884-1903), walking WavyScreenLineOffsets
fx.wavy = { left = 128, phase = 0 }
-- ---------------------------------------------- mon pic effects
elseif e == "SE_SLIDE_MON_OFF" then
@@ -3650,7 +3671,7 @@ function BattleState:updateFx()
end
if fx.wavy then
fx.wavy.left = fx.wavy.left - 1
fx.wavy.phase = fx.wavy.phase + 1
fx.wavy.phase = fx.wavy.phase + 2
if fx.wavy.left <= 0 then fx.wavy = nil end
end
end
@@ -3704,12 +3725,22 @@ function BattleState:updateFx()
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
self.enemy.sprite = real.sprite or self.enemy.sprite
gr.bgSprite = real.sprite or self.enemy.sprite
local objReal
if objPicPalette() then
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(self.data, self.enemy.mon.species,
"front", { mon = self.enemy.mon, kind = "battle" })
objReal = path and objPic(path, tc)
end
self.enemy.sprite = objReal or gr.bgSprite
end
end
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
end
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
-- home/clear_sprites.asm:1
if gr.bgSprite then self.enemy.sprite = gr.bgSprite end
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
end
end
@@ -3825,6 +3856,9 @@ function BattleState:executeAction(user, target, action)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
-- EnemySendOut (core.asm:1276-1289): only the mon on the field stays flagged
self.participants = {}
self:markParticipant()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
@@ -5950,8 +5984,7 @@ local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1,
0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2,
-1, -1, -1 }
-- wave the BG canvas one scanline at a time; the offset table walks
-- one entry per frame like the asm's advancing pointer
-- WavyScreen_SetSCX: animations.asm:1916-1927
function BattleState:applyWavy(src)
local wavy = self.fx and self.fx.wavy
if not wavy then return src end
@@ -5964,7 +5997,7 @@ function BattleState:applyWavy(src)
for line = 0, 143 do
self.waveQuad:setViewport(0, line, 160, 1)
g.draw(src, self.waveQuad,
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
WAVY_OFFSETS[(line * 2 + wavy.phase) % 32 + 1], line)
end
g.setCanvas(prev)
return self.waveCanvas
+1
View File
@@ -88,6 +88,7 @@ function AnimRunner.new(opts)
}
self.objects = AnimObjects.new(self.data, self.constants, self.env)
self.bg = BgEffects.new(self.constants, self.env)
self.animId = opts.animId -- wFXAnimID
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
self.sfxOrder = opts.sfxOrder or {}
+8 -2
View File
@@ -186,7 +186,8 @@ function Game:makeTitleState()
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
self:restoreSave(loaded, recovered, { freshBoot = true })
self:restoreSave(loaded, recovered,
{ freshBoot = true, continued = true })
end
end,
onExit = self.onExit,
@@ -1322,6 +1323,9 @@ function Game:restoreSave(loaded, recovered, opts)
report.recovered = recovered
report.modsDiff = modsDiff
self.save = loaded
-- the START cursor lives outside sGameData (ram/sram.asm:17-21)
loaded.startMenuIndex = nil
self.startMenuIndex = nil
self:adoptSave(loaded)
-- SaveData.load already attached the standalone options.lua table
self:applyOptions(loaded.options)
@@ -1336,8 +1340,10 @@ function Game:restoreSave(loaded, recovered, opts)
-- freshBoot threads through from the caller (onContinue and F2 both set
-- it); a future caller that doesn't ask for it keeps the ordinary
-- crossfade by default.
-- engine/menus/main_menu.asm:110 (CONTINUE forces PLAYER_DIR_DOWN)
local facing = (opts and opts.continued) and "down" or loaded.player.facing
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing,
loaded.player.x, loaded.player.y, facing,
{ via = "boot", freshBoot = opts and opts.freshBoot })
self.saveReport = report
if not SaveData.emptyReport(report) then
+5 -2
View File
@@ -94,9 +94,11 @@ function ItemEffects.healsHP(id)
end
-- .useRareCandy prints over the still-drawn party menu
-- (engine/items/item_effects.asm:1392-1418)
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322)
function ItemEffects.keepsPartyMenuOpen(id)
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
or VITAMINS[id] ~= nil
end
function ItemEffects.isBattleMedicine(id)
@@ -526,8 +528,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- _VitaminStatRoseText's slot order is localization-dependent (the
-- Spanish ROM puts the stat before the name), so the extracted line
-- cannot be filled positionally; the engine wording stands
-- engine/items/item_effects.asm:1313
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
Strings(STAT_LABEL[vitaminStat])) }
Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
+26 -4
View File
@@ -23,6 +23,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local LinkBattle = {}
@@ -473,11 +474,22 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function() sendOutPlayer(s, myParty[idx]) end)
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423); a post-faint
-- replacement shares sendOutPlayer and prints neither line
s:act(function()
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
s:queueRetreatAnim()
s:actNext(function() sendOutPlayer(s, myParty[idx]) end)
end)
myAction = nil
end
if theirSwitch then
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
s:act(function()
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
theirName, s.enemy.name))
s:actNext(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
end)
end
s:act(function()
@@ -885,11 +897,21 @@ function LinkBattle.newSpectator(game, net, opts)
if hostMsg.kind == "switch" then
local idx = hostMsg.index
s:act(function() sendOutHost(s, hostParty[idx]) end)
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423)
s:act(function()
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
s:queueRetreatAnim()
s:actNext(function() sendOutHost(s, hostParty[idx]) end)
end)
end
if guestMsg.kind == "switch" then
local idx = guestMsg.index
s:act(function() sendOutGuest(s, guestParty[idx]) end)
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
s:act(function()
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
guestName, s.enemy.name))
s:actNext(function() sendOutGuest(s, guestParty[idx]) end)
end)
end
s:act(function()
+3 -3
View File
@@ -1711,8 +1711,8 @@ local function buildStartMenu()
function overrides.new(game)
local g = live() or game
local save = g and g.save
if save and save.startMenuIndex then
Start2.lastIndex = save.startMenuIndex
if g and g.startMenuIndex then
Start2.lastIndex = g.startMenuIndex
end
return newOrig(g, {
save = save,
@@ -1741,7 +1741,7 @@ COVERAGE["src.ui.StartMenu"] = {
new = "synthesises onClose (stack:pop) and onChoose "
.. "(Game2:openStartMenuItem); without them the menu cannot be left",
index = "the cursor is menu.list.index on Gold (a Chrome.List)",
["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
.. "and never copied back: Gold's cursor is a class field, not save data",
tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/"
.. "maxVisible/startCloses/noSound do not exist and writes are inert",
+13
View File
@@ -332,6 +332,7 @@ end
-- transparent: the world pass shows through (UI pass draws overlays only)
function Renderer:beginFrame(transparent)
self.uiOpaque = not transparent
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
@@ -699,6 +700,18 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
return
end
love.graphics.setShader(shader)
-- data/sgb/sgb_packets.asm:123-127: zone 1 is the whole-screen ATTR_BLK,
-- so the underpaint is only drawn when it leaves part of the box bare (#1866)
local first = zoneList[1]
if canvas == self.canvas and self.uiOpaque and first.colors
and not (bx + first.x * zoneSx <= boxX
and by + first.y * zoneSy <= boxY
and bx + (first.x + first.w) * zoneSx >= boxX + boxW
and by + (first.y + first.h) * zoneSy >= boxY + boxH) then
PaletteFX.sendColors(shader, first.colors)
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
+75
View File
@@ -28,6 +28,52 @@ local MAX_COLS = 18
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- TextCommand_PAUSE (home/text.asm:492-504): a mid-string wait callers
-- embed as TextBox.PAUSE, stripped here and re-anchored after pagination.
TextBox.PAUSE = "\1"
local PAUSE_FRAMES = 30
local function glyphs(str)
return #Font.split((str:gsub("[\n\v\f]", "")))
end
local function stripPauses(text)
if not text:find(TextBox.PAUSE, 1, true) then return text, nil end
local out, marks, count, pos = {}, {}, 0, 1
while true do
local i = text:find(TextBox.PAUSE, pos, true)
if not i then
out[#out + 1] = text:sub(pos)
break
end
local chunk = text:sub(pos, i - 1)
out[#out + 1] = chunk
count = count + glyphs(chunk)
marks[#marks + 1] = count
pos = i + 1
end
return table.concat(out), marks
end
-- glyph offsets into the whole text -> [page][line][char]
local function mapPauses(pages, marks)
local at, acc, mi = {}, 0, 1
for pi, page in ipairs(pages) do
for li, line in ipairs(page) do
local n = #Font.split(line)
while mi <= #marks and marks[mi] <= acc + n do
local ci = marks[mi] - acc
at[pi] = at[pi] or {}
at[pi][li] = at[pi][li] or {}
at[pi][li][ci] = mi
mi = mi + 1
end
acc = acc + n
end
end
return at
end
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
-- the box then closes and choice(yes) runs instead of onDone.
@@ -81,7 +127,13 @@ function TextBox.new(game, text, onDone, opts)
self.line1Y = (self.boxTy + 2) * 8
self.line2Y = (self.boxTy + 4) * 8
text = TextBox.substitute(game, text)
local marks
text, marks = stripPauses(text)
self.pages = TextBox.paginate(text, self.maxCols)
-- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is
-- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213)
self.pauseSounds = opts and opts.pauseSounds
self.pauseAt = marks and mapPauses(self.pages, marks) or nil
self.pageIndex = 1
self.lineIndex = 1
self.charIndex = 0
@@ -297,6 +349,20 @@ function TextBox:update(dt)
self.holdFrames = self.holdFrames - 1
return
end
-- home/text.asm:492
if self.pauseFrames then
if self.pauseFrames > 0 then
self.pauseFrames = self.pauseFrames - 1
return
end
self.pauseFrames = nil
local snd = self.pauseSounds and self.pauseSounds[self.pauseMark]
if type(snd) == "function" then
snd()
elseif snd then
require("src.core.Sound").play(self.game.data, snd)
end
end
if self.done then
-- opts.stay: the box is finished but stays up under whatever the caller
-- pushed over it; StateStack updates the top state only, so this runs
@@ -430,6 +496,15 @@ function TextBox:update(dt)
self.charIndex = self.charIndex + 1
local line = self.shown[#self.shown]
line[#line + 1] = self.codes[self.charIndex]
local marks = self.pauseAt and self.pauseAt[self.pageIndex]
marks = marks and marks[self.lineIndex]
if marks and marks[self.charIndex] then
self.pauseMark = marks[self.charIndex]
-- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait
self.pauseFrames = (input:isDown("a") or input:isDown("b"))
and 0 or PAUSE_FRAMES
break
end
else
-- line finished
local page = self.pages[self.pageIndex]
+6 -5
View File
@@ -451,12 +451,13 @@ function H.CutDownTreeOrGrass(ctx)
end
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
-- port owns as World:playWhirlpoolSound. #1717
-- OWCutAnimation, and the block only reaches the screen after the sound
-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862)
function H.DisappearWhirlpool(ctx)
local ret = H.CutDownTreeOrGrass(ctx)
call(ctx, "playWhirlpoolSound")
return ret
local index = ctx and ctx.cutWhirlpoolBlockIndex
local blockId = ctx and ctx.cutWhirlpoolReplacement
call(ctx, "playWhirlpoolSound", index, blockId)
return nil
end
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
+3 -1
View File
@@ -360,6 +360,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- .useItem_partyMenu jumps back to StartMenu_Item once UseItem
-- returns, cursor still on the candy (start_sub_menus.asm) -- so
-- mashing A burns through a stack of them (#796)
-- RareCandyText carries sound_get_item_1
-- (engine/menus/party_menu.asm:289-293)
showMessages(game, payload, function()
local StatBox = require("src.battle.BattleState").StatBox
game.stack:push(StatBox.new(game, target, function()
@@ -400,7 +402,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
nextStep()
end))
end)
end, TextBox.soundOpts(game, "Get_Item1"))
return
end
-- HP medicine: fill the bar in the still-open picker first, then print
+85 -41
View File
@@ -13,9 +13,21 @@ local Strings = require("src.core.Strings")
local BoxMenu = {}
local function monLabel(game, mon)
-- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel
-- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461)
local function monRow(game, mon, value)
local def = game.data.pokemon[mon.species]
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
return {
label = mon.nickname or def.name,
sub = Strings(":L%d", mon.level),
value = value,
}
end
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
local function withCancel(items)
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
local function monName(game, mon)
@@ -61,14 +73,15 @@ local function withdraw(game)
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_withdraw",
-- DisplayMonListMenu draws LIST_MENU_BOX over the PC menu, which stays
-- visible around it (home/list_menu.asm:29-31)
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = box[item.value]
if not mon then return end
monSubmenu(game, "WITHDRAW", mon, function()
@@ -111,12 +124,14 @@ local function deposit(game)
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
table.insert(items, monRow(game, mon, i))
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_deposit",
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = game.save.party[item.value]
if not mon then return end
local Follower = require("src.world.PikachuFollower")
@@ -163,14 +178,13 @@ local function release(game)
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_release",
onChoose = function(_, list)
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
@@ -202,37 +216,67 @@ local function release(game)
}))
end
local function changeBox(game)
-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506
local function changeBoxMenu(game)
local boxes = Boxes.ensure(game.save)
local items = {}
for i = 1, Boxes.COUNT do
local mark = i == game.save.currentBox and "*" or " "
table.insert(items, {
label = Strings("%sBOX %2d", mark, i),
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
value = i,
})
items[i] = {
label = Strings("BOX%2d", i),
onSelect = function()
game.save.currentBox = i
if game.writeSave then game:writeSave() end
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
require("src.core.Sound").play(game.data, "Save")
end,
}
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_change",
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
game.stack:push(TextBox.new(game,
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
noSound = true,
choice = function(yes)
if not yes then return end
game.save.currentBox = item.value
if game.writeSave then game:writeSave() end
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
require("src.core.Sound").play(game.data, "Save")
list:close()
end,
}))
local menu = Menu.new(game, items, {
tx = 11, ty = 0, tw = 9, th = 14, rowStep = 1, itemY = 1, noSound = true,
})
menu.kind = "pc_box_change" -- screen.render_visible identity
menu.index = math.max(1, math.min(Boxes.COUNT, game.save.currentBox or 1))
local t = game.data.text
local baseDraw = menu.draw
function menu:draw()
-- ChooseABoxText goes to the standard box, over BillsPCMenu's "What?"
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local y = 112
for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX."))
.. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, y)
y = y + 16
end
Font.drawBox(0, 0, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BOX No."), 8, 16)
local n = game.save.currentBox or 1
Font.draw(tostring(n), n >= 10 and 64 or 72, 16)
baseDraw(self)
love.graphics.setColor(0, 0, 0, 1)
for i = 1, Boxes.COUNT do
if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end
end
love.graphics.setColor(1, 1, 1, 1)
end
game.stack:push(menu)
end
-- ChangeBox (engine/menus/save.asm:358-368) asks before showing the menu
-- and returns to the PC menu on No.
local function changeBox(game)
local t = game.data.text
local ask = TextBox.new(game, t._WhenYouChangeBoxText
or Strings("When you change a\nPOKéMON BOX, data\vwill be saved.\fIs that okay?"),
nil, {
noSound = true,
choice = function(yes)
if yes then changeBoxMenu(game) end
end,
}))
})
ask.kind = "pc_box_change"
game.stack:push(ask)
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
+37 -5
View File
@@ -87,6 +87,15 @@ local function frameSheet(game)
return frameCache[path]
end
-- one tile of the sheet, for the CONTENTS screen's divider column
-- (engine/menus/pokedex.asm:350, DrawPokedexVerticalLine)
function DexEntryMenu.tile(game, code, tx, ty)
local frame = frameSheet(game)
if not (frame and code) then return end
local quad = frame.quads[code - 0x60]
if quad then love.graphics.draw(frame.img, quad, tx * 8, ty * 8) end
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
@@ -106,14 +115,28 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
self.blink = 0
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species)
-- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148
self.crySrc = require("src.core.Sound").playCry(game.data, species)
return self
end
-- home/pokemon.asm:148 (jp WaitForSoundToFinish)
function DexEntryMenu:crying()
local src = self.crySrc
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
if ok and playing then return true end
self.crySrc = nil
return false
end
function DexEntryMenu:update(dt)
local input = self.game.input
self.blink = ((self.blink or 0) + 1) % 60
if self:crying() then return end
if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
@@ -127,15 +150,18 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor, self.page)
self.spriteTrueColor, self.page,
{ crying = self:crying(),
arrow = (self.blink or 0) < 30 })
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
-- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, state)
page = page or 1
state = state or {}
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
@@ -185,6 +211,12 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
-- engine/menus/pokedex.asm:516: everything below the divider waits on the
-- cry the line above it started
if state.crying then
love.graphics.setColor(1, 1, 1, 1)
return
end
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
if e.heightM then
@@ -202,8 +234,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245
if page < #pages then
-- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it
if page < #pages and state.arrow ~= false then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
+41 -26
View File
@@ -120,8 +120,9 @@ function ListMenu.new(game, title, items, opts)
-- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false
-- the bag's item list: a partial box the map stays visible around, not a
-- screen of its own (home/list_menu.asm:29-31)
self.itemBox = opts.itemBox or false
-- screen of its own (home/list_menu.asm:29-31). Every DisplayListMenuID
-- caller gets the same box, the PC item lists included (#1845).
self.itemBox = opts.itemBox or opts.messageBox or false
if self.itemBox then
self.isOpaque = false
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
@@ -256,6 +257,34 @@ function ListMenu:close()
if top == self then self.game.stack:pop() end
end
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
local function drawMessageBox(self)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if not self.footer then return end
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
-- the Pokédex owned-ball marker tile, also the CHANGE BOX screen's
-- PokeballTileGraphics marker (engine/menus/save.asm:495-499)
function ListMenu.drawBall(x, y)
local r, g, b, a = love.graphics.getColor()
love.graphics.circle("fill", x, y, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", x - 3.5, y - 0.5, 7, 1)
love.graphics.circle("fill", x, y, 1.2)
love.graphics.setColor(r, g, b, a)
end
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
function ListMenu:drawItemBox()
@@ -274,7 +303,10 @@ function ListMenu:drawItemBox()
if item.cancel then sawCancel = true end
local y = ITEM_TOP_Y + (row - 1) * 16
Font.draw(item.label, ITEM_NAME_X, y)
if item.right then
if item.sub then
-- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461)
Font.draw(item.sub, ITEM_QTY_X, y + 8)
elseif item.right then
-- '×' at column 14, PrintNumber's two right-aligned digits after it
-- (home/list_menu.asm:479-490)
local count = item.right:sub(2)
@@ -294,6 +326,9 @@ function ListMenu:drawItemBox()
if shown == self.rows and not sawCancel then
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
end
-- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID,
-- so the bottom box sits under the list from the first frame
if self.messageBox or self.footer then drawMessageBox(self) end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -326,13 +361,7 @@ function ListMenu:draw()
-- one blank glyph after the name, measured in glyph advances rather
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
local bx = 16 + Font.width(label) + 8 + 3
local by = y + 3
love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
love.graphics.circle("fill", bx, by, 1.2)
love.graphics.setColor(unpack(textColor))
ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3)
end
if item.right then
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
@@ -360,22 +389,8 @@ function ListMenu:draw()
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
end
if self.dialogue or (self.messageBox and self.footer) then
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
if self.dialogue then
drawMessageBox(self)
elseif self.footer then
-- bare footer (bag money line, etc.)
local flat = {}
+5 -2
View File
@@ -120,14 +120,17 @@ function MoveLearnMenu:finish(learned)
local opts
if learned then
-- pokered pages this as four texts in a row; _ForgotAndText carries
-- the "And..." tail
-- the "And..." tail. Each text_pause holds the box, and the first one
-- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222).
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
.. TextBox.PAUSE
.. romText(game.data, "_PoofText", " Poof!")
.. TextBox.PAUSE
.. romText(game.data, "_ForgotAndText",
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
.. "\f" .. romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name)
opts = { auto = { sound = function()
opts = { pauseSounds = { "Swap" }, auto = { sound = function()
return require("src.core.Sound").play(game.data, self.learnedSound)
end, wait = true } }
else
+21 -5
View File
@@ -71,6 +71,12 @@ function PartyMenu:sgbPalettes(game)
return zones
end
-- data/moves/field_moves.asm: leftmost tile per field move name
local FIELD_MOVE_X = {
cut = 12, fly = 12, surf = 12, flash = 12, DIG = 12,
strength = 10, TELEPORT = 10, softboiled = 8,
}
local function sameItems(_, items) return items end
local function followerUnavailable(game, mon)
@@ -706,6 +712,9 @@ function PartyMenu:update(dt)
-- (text_box.asm .donePrintingNames). #768
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
-- CANCEL closes the whole party menu (start_sub_menus.asm:71-75
-- .exitMenu), where B returns to the party list. #1833
items[#items + 1] = { label = Strings("CANCEL"), action = "cancel" }
end
local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems,
@@ -873,12 +882,19 @@ function PartyMenu:draw()
end
if self.submenu then
local n = #self.subItems
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
local y0 = (17 - n * 2) * 8
for si, entry in ipairs(self.subItems) do
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
-- engine/menus/text_box.asm:397-440, data/text_boxes.asm:33 #1819
local lx = 12
for _, entry in ipairs(self.subItems) do
local mx = FIELD_MOVE_X[entry.move or entry.action]
if mx and mx < lx then lx = mx end
end
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
local top = n > 3 and math.max(0, 16 - n * 2) or 11
Font.drawBox(lx - 1, top, 21 - lx, 18 - top)
local y0 = (18 - n * 2) * 8
for si, entry in ipairs(self.subItems) do
Font.draw(entry.label, (lx + 1) * 8, y0 + (si - 1) * 16)
end
Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
end
+46 -3
View File
@@ -11,6 +11,8 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
local romText = require("src.core.RomText")
local PlayerPC = {}
@@ -36,9 +38,20 @@ local function buildItems(game, store, order)
})
end
end
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
local function leftOnCancel(item, list)
if item and item.cancel then
list:close()
return true
end
return false
end
-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText →
-- DisplayChooseQuantityMenu) capped at the stack count. Key items and
-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm).
@@ -76,11 +89,21 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
-- players_pc.asm:144-149: an empty list is never opened
if next(pc) == nil then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
kind = "pc_item_withdraw",
messageBox = true,
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
footer = romText(game.data, "_WhatToWithdrawText",
"What do you want\nto withdraw?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty, game.data) then
@@ -112,11 +135,21 @@ local function deposit(game)
local Bag = require("src.inventory.Bag")
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
local order = Bag.order(game.save, game.data)
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
-- players_pc.asm:90-95: an empty bag never reaches the list
if #order == 0 then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingToDepositText",
"You have nothing\nto deposit."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, inv, order), {
kind = "pc_item_deposit",
messageBox = true,
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
footer = romText(game.data, "_WhatToDepositText",
"What do you want\nto deposit?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
list.footer = Strings("No room left to\nstore items.")
@@ -134,11 +167,21 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
-- players_pc.asm:196-201: an empty list is never opened
if next(pc) == nil then
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
kind = "pc_item_toss",
messageBox = true,
-- players_pc.asm:205-206 WhatToTossText, printed before the list
footer = romText(game.data, "_WhatToTossText",
"What do you want\nto toss away?"),
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
list.footer = Strings("That's too impor-\ntant to toss!")
+273 -82
View File
@@ -1,9 +1,38 @@
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348
-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336).
local ListMenu = require("src.ui.ListMenu")
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local PokedexMenu = {}
PokedexMenu.__index = PokedexMenu
PokedexMenu.isOpaque = true
-- engine/menus/pokedex.asm:226 (`ld d, 7`), :21 (top menu item Y 3, X 0)
local ROWS = 7
local FIRST_ROW_Y = 24
local NUM_X, BALL_X, NAME_X, CURSOR_X = 8, 24, 32, 0
-- engine/menus/pokedex.asm:163-199
-- (Yellow: pokeyellow engine/menus/pokedex.asm:266-306)
local RED_ROWS = { seen = 2, own = 5, rule = 8, items = 10 }
local YELLOW_ROWS = { seen = 1, own = 4, rule = 6, items = 8 }
-- engine/menus/pokedex.asm:350 (DrawPokedexVerticalLine): column 14, $71
-- toggling to $70 down each nine-tile run, both runs restarting on $71
local function dividerCodes()
local codes = { [0] = 0x71 }
for _, top in ipairs({ 1, 9 }) do
local code = 0x71
for i = 0, 8 do
codes[top + i] = code
code = code == 0x71 and 0x70 or 0x71
end
end
return codes
end
local DIVIDER = dividerCodes()
-- SGB: PalPacket_Pokedex, whole screen
function PokedexMenu:sgbPalettes(game)
@@ -12,108 +41,270 @@ end
function PokedexMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PokedexMenu)
self.game = game
self.onCancel = opts.onCancel -- B returns to the start menu when opened from it
self.index = 1
self.scroll = 0
self.rowsAt = require("src.core.GameVersion").isYellow()
and YELLOW_ROWS or RED_ROWS
local dex = game.save.pokedex or { seen = {}, owned = {} }
local byDex = {}
for species, def in pairs(game.data.pokemon) do
if def.dex then byDex[def.dex] = def end
end
local items = {}
local seen, owned = 0, 0
-- dex bound and number width come from constants; the fallbacks keep a
-- cache imported before those keys existed on the Kanto numbering
local constants = game.data.constants or {}
local numFmt = ("%%0%dd"):format(constants.dexDigits or 3)
for n = 1, constants.dexSize or 151 do
local dexSize = constants.dexSize or 151
-- engine/menus/pokedex.asm:200-216: the list stops at wDexMaxSeenMon, the
-- highest number the player has seen, not at the end of the roster
local maxSeen = 0
local seen, owned = 0, 0
for n = 1, dexSize do
local def = byDex[n]
if def then
local label
if dex.owned[def.id] then
label = (numFmt .. " %s"):format(n, def.name)
owned = owned + 1
seen = seen + 1
maxSeen = n
elseif dex.seen[def.id] then
label = (numFmt .. " %s"):format(n, def.name)
seen = seen + 1
else
label = (numFmt .. " -----"):format(n)
maxSeen = n
end
table.insert(items, {
label = label,
end
end
local items = {}
for n = 1, math.min(dexSize, maxSeen) do
local def = byDex[n]
if def then
local known = dex.owned[def.id] or dex.seen[def.id]
-- engine/menus/pokedex.asm:265 (.dashedLine) for anything unseen
local name = known and def.name or "----------"
items[#items + 1] = {
num = numFmt:format(n),
name = name,
label = (numFmt .. " %s"):format(n, name),
-- owned entries carry the pokéball marker like the original
-- list; seen-only entries are just the name
ball = dex.owned[def.id] or nil,
value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil,
})
value = known and def.id or nil,
}
end
end
local list = ListMenu.new(game, "POKéDEX", items, {
-- SEEN / OWN in the original's fixed three-digit field: engine/menus/
-- pokedex.asm HandlePokedexListMenu prints both counts with
-- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText
-- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing
-- here: a bare ListMenu footer goes through the 18-column text wrap,
-- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first
-- half landed on the list's last row at y=120 (#639).
footer = Strings("SEEN %3d OWN %3d", seen, owned),
pageJump = true, -- Left/Right page jumps like the original
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
onChoose = function(item, dexList)
if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
-- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original. B is what returns to the list: HandlePokedexSideMenu
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value)
end },
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
}
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
-- stood in for by a PNG of the entry page saved under prints/.
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- .exitPokedex: drop the dex list too and hand back to whoever
-- opened it (the start menu, whose saved cursor is still on
-- POKéDEX -- wBattleAndStartSavedMenuItem)
dexList:close()
if opts.onCancel then opts.onCancel() end
end }
game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 }))
end,
})
list.sgbPalettes = PokedexMenu.sgbPalettes
return list
self.items = items
-- SEEN / OWN, `lb bc, 1, 3` at hlcoord 16,3 and 16,6: engine/menus/
-- pokedex.asm HandlePokedexListMenu (#639)
self.seenCount, self.ownedCount = seen, owned
self.footer = Strings("SEEN %3d OWN %3d", seen, owned)
return self
end
-- HandleMenuInput_ replays SFX_PRESS_AB for A and B (home/window.asm)
local function beep(self)
if not (self.game and self.game.data) then return end
require("src.core.Sound").play(self.game.data, "Press_AB")
end
function PokedexMenu:rows()
return math.min(ROWS, #self.items)
end
-- engine/menus/pokedex.asm:290-313
function PokedexMenu:syncScroll()
local rows = self:rows()
if rows == 0 then
self.index, self.scroll = 1, 0
return
end
self.index = math.max(1, math.min(#self.items, self.index))
if self.index - self.scroll > rows then self.scroll = self.index - rows end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- engine/menus/pokedex.asm:314-342: Left and Right move wListScrollOffset
-- by seven and never touch wCurrentMenuItem, so the cursor keeps its row
function PokedexMenu:pageScroll(dir)
local n = #self.items
if n < ROWS then return end
local row = self.index - self.scroll
local scroll = self.scroll + dir * ROWS
scroll = math.max(0, math.min(n - ROWS, scroll))
self.scroll = scroll
self.index = math.min(n, scroll + row)
end
function PokedexMenu:close()
local top = self.game.stack:top()
if top == self then self.game.stack:pop() end
end
function PokedexMenu:update(dt)
-- back on top of the stack, so the side menu's hollow '▷' is gone
-- (engine/menus/pokedex.asm:61, PlaceUnfilledArrowMenuCursor)
self.hollowIndex = nil
local input = self.game.input
if #self.items == 0 then
if input:wasPressed("a") or input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
return
end
if input:wasPressed("up") then
self.index = self.index - 1
elseif input:wasPressed("down") then
self.index = self.index + 1
elseif input:wasPressed("left") then
self:pageScroll(-1)
elseif input:wasPressed("right") then
self:pageScroll(1)
elseif input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif input:wasPressed("a") then
beep(self)
self.onChoose(self.items[self.index], self)
return
end
self:syncScroll()
end
-- engine/menus/pokedex.asm:371-375, :81-85
function PokedexMenu:sideItems()
local labels = { Strings("DATA"), Strings("CRY"), Strings("AREA") }
if require("src.core.GameVersion").isYellow() then
labels[#labels + 1] = Strings("PRNT")
end
labels[#labels + 1] = Strings("QUIT")
return labels
end
local function sideRowY(menu, row)
return (menu.ty + menu.th - 2 - (#menu.items - row) * 2) * 8
end
local function drawSideMenu(self)
love.graphics.setColor(0, 0, 0, 1)
for row = 1, #self.items do
Font.draw(self.items[row].label, (self.tx + 2) * 8, sideRowY(self, row))
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, sideRowY(self, self.index))
love.graphics.setColor(1, 1, 1, 1)
end
-- engine/menus/pokedex.asm HandlePokedexSideMenu, ShowPokedexMenu
-- .exitPokedex (#571)
local function chooseEntry(item, dexList)
local game = dexList.game
-- engine/menus/pokedex.asm:77-80: an unseen row hands back b=2 at once
if not item.value then return end
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value)
end },
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
}
-- pokeyellow engine/menus/pokedex.asm PokedexMenuItemsText,
-- PrintPokedexEntry
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- engine/menus/pokedex.asm .exitPokedex
dexList:close()
if dexList.onCancel then dexList.onCancel() end
end }
local ty = dexList.rowsAt.items - 2
local side = Menu.new(game, entries, { tx = 14, ty = ty, tw = 6 })
-- the block is already on screen, so the side menu is the cursor alone:
-- text at column 16, cursor at 15 (engine/menus/pokedex.asm:82-86)
side.tx, side.tw, side.ty, side.th = 14, 6, ty, #entries * 2 + 2
side.draw = drawSideMenu
dexList.hollowIndex = dexList.index
game.stack:push(side)
end
PokedexMenu.onChoose = chooseEntry
-- engine/menus/pokedex.asm:256 writes tile $72 in the column left of the
-- name; the port paints the same marker rather than carry a one-tile sheet
local function drawBall(x, y)
love.graphics.circle("fill", x + 4, y + 4, 3.5)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", x + 0.5, y + 3.5, 7, 1)
love.graphics.circle("fill", x + 4, y + 4, 1.2)
love.graphics.setColor(0, 0, 0, 1)
end
function PokedexMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local at = self.rowsAt
local DexEntryMenu = require("src.ui.DexEntryMenu")
-- engine/menus/pokedex.asm:159-176: the divider column and the rule that
-- fences SEEN / OWN off from the side-menu block
for ty = 0, 17 do
DexEntryMenu.tile(self.game, DIVIDER[ty], 14, ty)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw((""):rep(5), 120, at.rule * 8)
Font.draw(Strings("CONTENTS"), 8, 8)
Font.draw(Strings("SEEN"), 128, at.seen * 8)
Font.draw(Strings("OWN"), 128, at.own * 8)
-- PrintNumber's fixed three-digit field ends on column 19
local function count(n, ty)
local text = tostring(n)
Font.draw(text, 152 - Font.width(text), ty * 8)
end
count(self.seenCount, at.seen + 1)
count(self.ownedCount, at.own + 1)
local labels = self:sideItems()
for i, label in ipairs(labels) do
Font.draw(label, 128, (at.items + (i - 1) * 2) * 8)
end
for row = 1, self:rows() do
local i = self.scroll + row
local item = self.items[i]
if not item then break end
local y = FIRST_ROW_Y + (row - 1) * 16
-- the number sits one row above its name (engine/menus/pokedex.asm:242-246)
Font.draw(item.num, NUM_X, y - 8)
if item.ball then drawBall(BALL_X, y) end
Font.draw(item.name, NAME_X, y)
if i == self.index then
Font.drawCode(self.hollowIndex == i
and Theme.cursorHollow or Theme.cursor, CURSOR_X, y)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
return PokedexMenu
+14 -23
View File
@@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky)
local self = setmetatable({}, SlotMachine)
self.game = game
self.wheels = game.data.field.slotWheels
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
self.stage = "intro"
-- bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
self.stage = "bet"
self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem
-- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to
-- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem.
@@ -417,7 +417,7 @@ function SlotMachine:startPayout()
self.flash = false
end
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
-- the "One more go?" YES/NO prompt.
function SlotMachine:updateYesNo(onYes)
local input = self.game.input
if input:wasPressed("up") or input:wasPressed("down") then
@@ -435,12 +435,6 @@ function SlotMachine:update(dt)
local input = self.game.input
local save = self.game.save
if self.stage == "intro" then
-- PromptUserToPlaySlots: "A slot machine! Want to play?"
self:updateYesNo(function() self:enterBet() end)
return
end
if self.stage == "message" then
if self.exitTimer then
-- OutOfCoinsSlotMachineText: DelayFrames 60, then leave
@@ -638,9 +632,7 @@ end
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
function SlotMachine:drawBottom()
local lines
if self.stage == "intro" then
lines = { "A slot machine!", "Want to play?" }
elseif self.stage == "bet" then
if self.stage == "bet" then
lines = { "Bet how many", "coins?" }
elseif self.stage == "onemore" then
lines = { "One more", "go?" }
@@ -661,22 +653,21 @@ function SlotMachine:drawBottom()
Font.draw(lines[1] or "", 8, 14 * 8)
Font.draw(lines[2] or "", 8, 16 * 8)
if self.stage == "bet" then
Font.drawBox(14, 11, 6, 5)
-- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86
Font.drawBox(14, 11, 6, 7)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("×3", 16 * 8, 12 * 8)
Font.draw("×2", 16 * 8, 13 * 8)
Font.draw("×1", 16 * 8, 14 * 8)
Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8)
elseif self.stage == "intro" or self.stage == "onemore" then
-- "One more go?" sits at the right of the box (hlcoord 14,12); the longer
-- "A slot machine!" prompt would clip against it, so the intro's YES/NO
-- floats above the reels instead.
local by = self.stage == "intro" and 6 or 11
Font.drawBox(13, by, 6, 5)
elseif self.stage == "onemore" then
-- hlcoord 14,12 with the YES_NO_MENU 4x3 body
-- engine/slots/slot_machine.asm:136-138, data/yes_no_menu_strings.asm:10
Font.drawBox(14, 12, 6, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
Font.draw(Strings("YES"), 16 * 8, 13 * 8)
Font.draw(Strings("NO"), 16 * 8, 14 * 8)
Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 1)) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
+2 -2
View File
@@ -200,12 +200,12 @@ function StartMenu.new(game)
anchor = "topright" })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu.index = math.min(game.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
game.save.startMenuIndex = self.index
game.startMenuIndex = self.index
end
-- inside the Safari Zone the start menu also shows remaining steps and
+43 -27
View File
@@ -130,6 +130,25 @@ local function markerXY(loc)
return loc.x * 8 + 16, loc.y * 8 + 8
end
-- the marker wears its own sheet's OBJ palette and shade-0 keying
-- engine/items/town_map.asm:342
local function markerSheet(def, seed)
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(def, seed)
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage, def and def.image, colors, group)
if not (ok and img) then return nil, nil end
return img, love.graphics.newQuad(0, 0, 16, 16, img:getDimensions())
end
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
function TownMap:bannerText(loc)
@@ -224,32 +243,18 @@ function TownMap.new(game, opts)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
-- palette and shade-0 keying -- engine/items/town_map.asm:342
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(red, "player")
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage,
red and red.image, colors, group)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
self.playerSheet, self.playerQuad =
markerSheet(sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED,
"player")
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks
-- the destination with it -- engine/items/town_map.asm:146-149, 177-179
if self.fly then
self.birdSheet, self.birdQuad =
markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"]
or sprites.SPRITE_BIRD, "bird")
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
@@ -402,8 +407,19 @@ function TownMap:draw()
end
end
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
-- LoadTownMap_Fly's .inputLoop has no blinking animation
-- engine/items/town_map.asm:190-198
local showCursor = true
if GameVersion.generation() == 1 then
if self.fly and self.birdSheet then
if selected then
local x, y = markerXY(selected)
love.graphics.draw(self.birdSheet, self.birdQuad, x - 4, y - 3)
if PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack() then
PaletteFX.markUiSpriteRedraw(self.birdSheet, self.birdQuad, x - 4, y - 3)
end
end
showCursor = false
elseif GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
+46 -3
View File
@@ -611,17 +611,60 @@ function BattleState:animData(mon)
return def.anim
end
-- ../pokecrystal/engine/gfx/load_pics.asm:105-131
function BattleState:frontPicReplaced(mon)
local def = self.pokemon and mon and self.pokemon[mon.species]
local vanilla = def and def.spriteFront
if mon and mon.species == Unown.SPECIES then
vanilla = Unown.formSprite(self.pokemon, Unown.monLetter(mon), false)
or vanilla
end
if type(vanilla) ~= "string" then return false end
local _, _, path = self:pic(mon, false)
if type(path) == "string" and path ~= vanilla then return true end
return Assets.resolve(vanilla) ~= vanilla
end
-- one cart asset, two files on the pokemon.sprite seam (#1827)
-- ../pokecrystal/engine/gfx/load_pics.asm:132-158
function BattleState:animSheetPath(mon, data)
local path = data and data.sheet
if type(path) ~= "string" then return nil, false end
if not Runtime.wantsHook("pokemon.sprite") then
return path, Assets.resolve(path) ~= path
end
local letter
if mon and mon.species == Unown.SPECIES then letter = Unown.monLetter(mon) end
local hooked = Runtime.call("pokemon.sprite",
function(value) return value end, path, {
species = mon and mon.species,
side = "front",
kind = "battle_anim",
mon = mon,
data = (self.game and self.game.data) or nil,
letter = letter,
shiny = mon and mon.shiny and true or false,
})
if type(hooked) == "string" and hooked ~= "" and hooked ~= path then
return hooked, true
end
return path, Assets.resolve(path) ~= path
end
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
-- ../pokecrystal/engine/battle/core.asm:9112-9113
function BattleState:startFrontAnim(mon)
self.frontAnim = nil
local data = self:animData(mon)
if not data then return end
local cached = self.picCache[data.sheet]
local sheet, sheetReplaced = self:animSheetPath(mon, data)
if not sheet then return end
if not sheetReplaced and self:frontPicReplaced(mon) then return end
local cached = self.picCache[sheet]
if cached == nil then
local ok, image = pcall(Assets.image, data.sheet)
local ok, image = pcall(Assets.image, sheet)
cached = ok and image or false
self.picCache[data.sheet] = cached
self.picCache[sheet] = cached
end
if not cached then return end
local runner = MonAnim.new(data, "battle")
+10 -5
View File
@@ -1105,9 +1105,14 @@ function SummaryMenu:drawEggIconFallback(colors)
G.setColor(1, 1, 1, 1)
end
function SummaryMenu:drawPlacements(list)
-- engine/gfx/color.asm:342-359
function SummaryMenu:drawPlacements(list, palette)
for _, entry in ipairs(list) do
Chrome.print(entry.text, entry.x, entry.y)
if palette then
Chrome.printThrough(entry.text, entry.x, entry.y, palette)
else
Chrome.print(entry.text, entry.x, entry.y)
end
end
end
@@ -1168,7 +1173,7 @@ function SummaryMenu:drawPinkPage()
HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font)
end
self:drawVerticalDivider(9)
self:drawPlacements(self:pinkPlacements())
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
-- FillInExpBar is handed (11,16), adds 7 to reach the rightmost cell and
-- fills eight of them walking left, with the $40/$41 caps outside at (10,16)
@@ -1188,12 +1193,12 @@ function SummaryMenu:drawPinkPage()
end
function SummaryMenu:drawGreenPage()
self:drawPlacements(self:greenPlacements())
self:drawPlacements(self:greenPlacements(), self:lowerColors())
end
function SummaryMenu:drawBluePage()
self:drawVerticalDivider(10)
self:drawPlacements(self:bluePlacements())
self:drawPlacements(self:bluePlacements(), self:lowerColors())
end
function SummaryMenu:drawMoveDetail()
+51 -5
View File
@@ -48,6 +48,10 @@ local WILD_ENCOUNTER_GRACE_STEPS = 3
-- of the GB screen center: FLY_ANCHOR is the pair where the original has
-- the player's sprite, so path1 starts exactly on the player.
local FLY_ANCHOR = { 0x3C, 0x48 }
-- StopMusic's fade-out control byte and the 7 volume steps it waits out
-- engine/overworld/player_animations.asm:123, home/fade_audio.asm:36
local FLY_FADE_CONTROL = 4
local FLY_FADE_FRAMES = FLY_FADE_CONTROL * 7
local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right
{ 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 },
{ 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 },
@@ -1189,6 +1193,17 @@ function OverworldState:update(dt)
end
return
end
-- StopMusic busy-waits on the fade before LoadBirdSpriteGraphics, so the
-- world holds and the bird is not on screen yet -- home/overworld.asm:772
if self.flyFade then
self.flyFade = self.flyFade - 1
if self.flyFade <= 0 then
self.flyFade = nil
self.flyAnim = { phase = "flap", t = 0 }
end
self.player:update()
return
end
if self.flyAnim then
-- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the
-- in-place flap is 8 pairs, then the two paths with a 40-frame beat
@@ -2070,7 +2085,9 @@ function OverworldState:flyTo(mapId)
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
-- the exit over the top-left -- the warp fades only once the bird is
-- gone (#702). fxBird draws it; the player hides for the whole flight.
self.flyAnim = { phase = "flap", t = 0 }
-- engine/overworld/player_animations.asm:123, home/overworld.asm:772
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
self.flyFade = FLY_FADE_FRAMES
self.player.inputLocked = true
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
end
@@ -2093,6 +2110,9 @@ function OverworldState:beginTeleportOut(onDone)
if onDone then onDone() end
return
end
-- StopMusic sits above both _LeaveMapAnim branches
-- engine/overworld/player_animations.asm:123
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
self:syncSurfingPikachu()
@@ -2315,6 +2335,15 @@ function OverworldState.benchGuyText(data, save, label)
return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""]
end
-- HiddenCoins pays a BCD constant chosen by the argument, and the 40 case
-- falls into .bcd20 -- engine/events/hidden_items.asm:79
function OverworldState.hiddenCoinPayout(amount)
amount = tonumber(amount) or 0
if amount == 10 then return 10 end
if amount == 20 or amount == 40 then return 20 end
return 100
end
-- Hidden events at the faced cell (data/events/hidden_events.asm):
-- HiddenItems give their item once, HiddenCoins fill the COIN CASE,
-- StartSlotMachine seats open the minigame. Taken spots persist in
@@ -2359,9 +2388,10 @@ function OverworldState:tryHiddenObject(fx, fy)
if save.hiddenTaken[key] then return false end
if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true
save.coins = math.min(9999, (save.coins or 0) + h.coins)
local paid = OverworldState.hiddenCoinPayout(h.coins)
save.coins = math.min(9999, (save.coins or 0) + paid)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%d coins!", save.player.name, h.coins),
Strings("%s found\n%d coins!", save.player.name, paid),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
@@ -2391,7 +2421,19 @@ function OverworldState:tryHiddenObject(fx, fy)
else
-- one machine per visit is secretly lucky
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm)
Screens.push(Game, "SlotMachine", seatIndex == self.luckySlot)
local lucky = seatIndex == self.luckySlot
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
Game.stack:push(TextBox.new(Game, txt._PlaySlotMachineText
or romText(Game.data, "_PlaySlotMachineText",
"A slot machine!\nWant to play?"), nil, {
choice = function(yes)
if not yes then return end
self.emote = {
npc = self.player, frames = 60, bubble = 3,
onDone = function() Screens.push(Game, "SlotMachine", lucky) end,
}
end,
}))
end
return true
end
@@ -3829,6 +3871,10 @@ function OverworldState:checkTrainerSight()
if self.player.moving or self.engaging then return end
if Game.stack:top() ~= self then return end
local p = self.player
-- a map contribution opts a trainer out of CheckFightingMapTrainers with
-- `noSight = { TEXT_... = true }` -- home/trainers.asm:129
local view = mapScripts.get and mapScripts.get(self.map.id)
local noSight = view and view.noSight
local cancelled = self.cancelledTrainerSight
if cancelled and (cancelled.playerX ~= p.cellX
or cancelled.playerY ~= p.cellY) then
@@ -3842,7 +3888,7 @@ function OverworldState:checkTrainerSight()
if d.trainerClass and not npc.moving
and not (cancelled and cancelled.npcId == npc.id)
and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text)
and not (noSight and d.text and noSight[d.text])
and trainerSpriteOnScreen(npc, p) then
local header = Game.data:trainerHeader(self.map.def.label, d.index)
local range = header and header.range or 0
+8 -5
View File
@@ -5618,15 +5618,16 @@ end
function World:runWhirlpool(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
self:replaceBlock(result.blockIndex, result.replacement)
self:playWhirlpoolSound()
self:playWhirlpoolSound(result.blockIndex, result.replacement)
end)
end
-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX
-- -- engine/events/field_moves.asm:5-10 (#1717)
function World:playWhirlpoolSound()
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 }
-- -- engine/events/field_moves.asm:5-10 (#1717). The block swap lands after
-- it -- engine/events/overworld.asm:1157-1164
function World:playWhirlpoolSound(blockIndex, replacement)
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180,
blockIndex = blockIndex, replacement = replacement }
end
-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it
@@ -6193,6 +6194,8 @@ function World:updateFieldMove()
end
if Sound.sfxBusy() and st.left > 0 then return end
self.fieldMove = nil
-- engine/events/overworld.asm:1163-1164
if st.blockIndex then self:replaceBlock(st.blockIndex, st.replacement) end
return
end
if st.phase == "strength" then
+101
View File
@@ -0,0 +1,101 @@
-- Manual check of the FLY picker's bird marker and the departure fade (#1840).
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks the
-- destination with it, with no blink (engine/items/town_map.asm:146-149,
-- 177-179, 190-198); _LeaveMapAnim stops the music with fade control $4 before
-- the bird appears (engine/overworld/player_animations.asm:123, home/overworld.asm:772).
-- POKEPORT_DRIVER=tests/drivers/fly_bird_bug1840_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local Music = require("src.core.Music")
local TownMap = require("src.ui.TownMap")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local vol = game.save.options and game.save.options.musicVol
if vol == 0 then
U.log("music volume is 0 in options; raise it or the fade is inaudible")
end
if (game.save.options and game.save.options.sfxVol) == 0 then
U.log("sfx volume is 0 in options; the bird's SFX_FLY will be silent too")
end
-- BuildFlyLocationsList only offers visited towns
game.save.visited = game.save.visited or {}
for _, town in ipairs({ "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY",
"CERULEAN_CITY", "CELADON_CITY" }) do
game.save.visited[town] = true
end
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
U.wait(30)
local flown = nil
Screens.push(game, "TownMap", { fly = true,
onFly = function(mapId) flown = mapId end })
U.wait(20)
local map = game.stack:top()
check("the FLY picker opened", getmetatable(map) == TownMap)
check("it is in fly mode", map.fly == true)
check("the destination marker is the bird sheet", map.birdSheet ~= nil)
check("the blinking cursor art is still loaded for the plain viewer",
map.bg == nil or map.bg.cursor ~= nil)
U.log("fly destinations:", #(map.locs or {}))
U.shot(game, SHOT_DIR .. "/bug1840_flypicker.png")
-- the marker must not blink: sample the same screen a few frames apart
map.blink = 0
U.wait(2)
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_a.png")
map.blink = 30
U.wait(2)
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_b.png")
U.log("compare", SHOT_DIR .. "/bug1840_flypicker_a.png",
"with _b.png: they must be identical")
U.tap(game, "up")
U.wait(20)
U.tap(game, "a")
U.wait(20)
check("A picks a destination", flown ~= nil)
U.log("flying to", tostring(flown))
local faded = false
local realFade = Music.fadeOut
Music.fadeOut = function(control, pending)
faded = control
return realFade(control, pending)
end
local ow = game.overworld
if flown then ow:flyTo(flown) end
U.wait(1)
check("departure asks the music to fade", faded ~= false)
check("with StopMusic's control byte $4", faded == 4)
check("the world holds during the fade", ow.flyFade ~= nil)
check("and the bird is not on screen yet", ow.flyAnim == nil)
U.shot(game, SHOT_DIR .. "/bug1840_fading.png")
local flapping = false
for _ = 1, 120 do
if ow.flyAnim then flapping = true break end
U.wait(1)
end
check("the bird appears once the fade is done", flapping)
Music.fadeOut = realFade
U.wait(6)
U.shot(game, SHOT_DIR .. "/bug1840_bird.png")
U.log("captured", SHOT_DIR .. "/bug1840_{flypicker,fading,bird}.png")
U.log("Right: the picker marks the town with a small bird that holds still,")
U.log("and pressing A drops the map theme to silence before the bird flaps.")
U.log("The near-miss is a square bracket cursor blinking on the town, or the")
U.log("theme still playing under the flap and cutting off at the warp.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,129 @@
-- Eye/ear check on the Pokemon Tower 6F ghost scene (#1849): entry wipe,
-- the "GHOST appeared!" wording, the SILPH SCOPE unveil colours, and the
-- cry landing on the CUBONE's-mother line.
-- pokered engine/battle/core.asm:6695-6702, data/text/text_2.asm:1251-1255,
-- engine/battle/ghost_marowak_anim.asm:3-5,77, scripts/PokemonTower6F.asm:137-148.
-- POKEPORT_DRIVER=tests/drivers/ghost_marowak_bug1849_test.lua POKEPORT_IDENTITY=bug1849 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- PokemonTower6FMarowakCoords (scripts/PokemonTower6F.asm:43-45) is
-- (10, 16); the 7F stairs at (9, 16) sit right beside it, so the cell
-- north of the trigger is open floor.
local MAP = "POKEMON_TOWER_6F"
local TRIGGER = { x = 10, y = 16 }
local STAND = { x = 10, y = 15, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: the MAROWAK cry is the audible half of this")
U.log(" check and will not be heard. Set SFX to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 40) }
game.save.inventory = game.save.inventory or {}
game.save.inventory.SILPH_SCOPE = 1
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
check("the SILPH SCOPE is in the bag (this is the unveil path)",
(game.save.inventory.SILPH_SCOPE or 0) > 0)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
check("standing on " .. MAP, ow.map.id == MAP)
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the floor: take any free neighbour of the trigger
for _, d in ipairs({ { 0, -1 }, { -1, 0 }, { 1, 0 }, { 0, 1 } }) do
local cx, cy = TRIGGER.x + d[1], TRIGGER.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
local facing = (d[2] == -1 and "down") or (d[2] == 1 and "up")
or (d[1] == -1 and "right") or "left"
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy, "facing", facing)
U.teleport(game, MAP, cx, cy, facing)
U.wait(20)
ow = game.overworld
break
end
end
end
-- walk onto the trigger cell ourselves; the Be gone... box opens on arrival
local BattleTransition = require("src.render.BattleTransition")
local sawWipe = false
local battle
for _ = 1, 240 do
U.hold(game, "down", 2)
U.tap(game, "a")
U.wait(4)
local top = game.stack:top()
if getmetatable(top) == BattleTransition then
sawWipe = true
U.shot(game, DIR .. "/bug1849_entry_wipe.png")
end
if getmetatable(top) == BattleState then
battle = top
break
end
end
check("the ghost battle started", battle ~= nil)
check("an entry wipe ran before it (pushBattle, not a bare stack push)",
sawWipe)
if battle then
check("the foe is disguised as the GHOST", battle.enemy.name == "GHOST")
U.log("intro line reads:", (tostring(battle.introText):gsub("\n", " / ")))
check("the intro line is \"GHOST appeared!\", with no article",
battle.introText == "GHOST\nappeared!")
check("the scope unveil is queued", battle.scopeReveal == true)
-- ride the unveil: mash A through the boxes and shoot the anim
local shotFlash, shotFade, shotDone = false, false, false
for _ = 1, 900 do
U.tap(game, "a")
U.wait(2)
local gr = battle.ghostReveal
if gr then
if not shotFlash and gr.t >= 20 then
shotFlash = true
U.shot(game, DIR .. "/bug1849_unveil_flash.png")
end
if not shotFade and gr.t >= 100 then
shotFade = true
U.shot(game, DIR .. "/bug1849_unveil_fadein.png")
end
elseif shotFade and not shotDone then
shotDone = true
U.shot(game, DIR .. "/bug1849_unveiled.png")
break
end
end
check("the unveil animation played", shotFlash and shotFade)
check("the real name came back: " .. tostring(battle.enemy.name),
battle.enemy.name ~= "GHOST")
end
U.log("Shots are in " .. DIR .. ": bug1849_entry_wipe, _unveil_flash,")
U.log("_unveil_fadein, _unveiled.png. In OG RED mode MarowakAnim draws the")
U.log("pic as a sprite under OBJ palette 1, so the flashing ghost and the")
U.log("fading-in MAROWAK are green (pink in Blue) and only settle back to")
U.log("the red background palette once the animation ends. The near-miss is")
U.log("a pic that stays red the whole way through.")
U.log("Beat the MAROWAK and listen: the cry belongs on the CUBONE's-mother")
U.log("box, not on the one after it.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,156 @@
-- #1858: stats-screen strings stamped white paper over the tinted lower half.
-- Rows 8-17 stay on BG palette 0, whose colour 0 LoadStatsScreenPals sets to
-- the page tint, so a glyph's blank pixels show that tint -- engine/gfx/
-- color.asm:342-359, engine/gfx/cgb_layouts.asm:167-192. Never POKEPORT_SPEED
-- here: the page redraw and its cry are being judged in order.
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_stats_text_paper_bug1858_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1858 love .
local U = require("tests.drivers.util")
local GbcPalette = require("src.render.GbcPalette")
local Mon = require("src.battle.gen2.Mon")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
-- The rows the lower half prints on: EXP POINTS / LEVEL UP / TO on the pink
-- page, ITEM and the move list on green, <ID>№. and OT/ on blue.
local TEXT_ROWS = {
[SummaryMenu.PINK_PAGE] = {
{ "the EXP POINTS row", 80, 72, 159, 79 },
{ "the STATUS/ row", 0, 96, 55, 103 },
{ "the LEVEL UP row", 80, 96, 159, 103 },
},
[SummaryMenu.GREEN_PAGE] = {
{ "the ITEM row", 0, 64, 159, 71 },
{ "the first move name", 64, 80, 159, 87 },
{ "the first PP row", 96, 88, 159, 95 },
},
[SummaryMenu.BLUE_PAGE] = {
{ "the ID No. row", 0, 72, 79, 79 },
{ "the OT/ row", 0, 96, 79, 103 },
{ "the ATTACK row", 88, 64, 159, 71 },
},
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1858"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
local function tap(button)
U.tap(game, button)
U.wait(4)
end
local function top() return game.stack:top() end
U.wait(45)
if not (game.world and game.world.map) then
U.log("FAIL the gold world never booted, nothing to open a summary over")
while true do coroutine.yield() end
end
local save = game.save
save.player.name = "GOLD"
save.party = { Mon.new(game.data, "CYNDAQUIL", 22),
Mon.new(game.data, "TOTODILE", 18) }
local lead = save.party[1]
lead.item = "BERRY"
lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45))
claim(GbcPalette.available(), "the GBC shade-remap shader compiled")
game.options = game.options or {}
game.options.color = "gbc"
save.options = game.options
GbcPalette.applyOptions(game.options)
claim(GbcPalette.mode == "gbc", "COLOR is GBC for the run")
-- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes
-- a GB pixel a pixel and the paper countable.
local function frameOf(screen)
local G = love.graphics
local canvas = G.newCanvas(160, 144)
G.setCanvas(canvas)
G.clear(0, 0, 0, 1)
G.setColor(1, 1, 1, 1)
screen:draw()
G.setCanvas()
G.setColor(1, 1, 1, 1)
return canvas:newImageData()
end
local function whitePct(img, rect)
local white, total = 0, 0
for y = rect[3], rect[5] do
for x = rect[2], rect[4] do
local r, g, b = img:getPixel(x, y)
total = total + 1
if r * 255 > 250 and g * 255 > 250 and b * 255 > 250 then
white = white + 1
end
end
end
return white / total * 100
end
tap("start")
local menu = top()
if not claim(menu and menu.screenId == "Gen2StartMenu",
"START opened the menu") then
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
for _ = 1, 10 do
if menu.list:current().value == "pokemon" then break end
tap("down")
end
tap("a")
local party = top()
if not claim(party and party.screenId == "Gen2PartyMenu",
"that opened the party list") then
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
tap("a")
tap("a")
local summary = top()
if not claim(summary and summary.screenId == "Gen2SummaryMenu",
"STATS opened the summary") then
for _, line in ipairs(lines) do U.log(line) end
while true do coroutine.yield() end
end
local NAMES = { "pink", "green", "blue" }
local function inspect(shot)
local img = frameOf(summary)
local page = summary.page
for _, rect in ipairs(TEXT_ROWS[page] or {}) do
local pct = whitePct(img, rect)
U.log((" %-22s %5.1f%% white"):format(rect[1], pct))
claim(pct <= 1, ("%s (%s page) has no white paper behind it"):format(
rect[1], NAMES[page] or "?"))
end
U.shot(game, ("%s/%s.png"):format(out, shot))
end
inspect("01-pink-page")
tap("right")
claim(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page")
inspect("02-green-page")
tap("right")
claim(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page")
inspect("03-blue-page")
tap("right")
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
if fails > 0 then
U.log("a FAIL above means the run is not showing what it claims to")
end
U.log("what right looks like: everything under the rule is one flat wash of")
U.log("the page colour, with the letters and numbers sitting straight on it.")
U.log("a white box the width of each string is the bug; a white box behind")
U.log("only the numbers, or only the upper half tinted, is a half fix.")
U.log("left and right turn the page; the pad is yours.")
while true do coroutine.yield() end
end
@@ -0,0 +1,141 @@
-- #1862: the whirlpool must stay on screen for the whole surf wash.
-- DisappearWhirlpool blanks hBGMapMode before the redraw, plays the sound and
-- only then buffers the screen -- engine/events/overworld.asm:1150-1165,
-- engine/events/field_moves.asm:5-10. No POKEPORT_SPEED: audio runs on its
-- own real-time accumulator and the ordering is the whole point.
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_whirlpool_block_bug1862_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1862 love .
local U = require("tests.drivers.util")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Mon = require("src.battle.gen2.Mon")
local Permissions = require("src.world.gen2.Permissions")
-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30)
-- and (28,48). Stand one cell north of the first and face it.
local MAP = "ROUTE_41"
local WHIRL = { x = 22, y = 12 }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1862"
local fails, lines = 0, {}
local function claim(ok, text)
if not ok then fails = fails + 1 end
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
return ok
end
local function report()
for _, line in ipairs(lines) do U.log(line) end
U.log(("%d checks, %d failed"):format(#lines, fails))
end
U.wait(45)
local world = game.world
if not (world and world.map) then
U.log("FAIL the gold world never booted, nothing to drain")
while true do coroutine.yield() end
end
local vol = game.save.options and game.save.options.sfxVol
claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol)))
if vol == 0 then
U.log("SFX VOL is ZERO, so the wash this is timed against is inaudible;")
U.log("turn it up in OPTION before judging anything by ear.")
end
local badges = game.save.player.badges or {}
game.save.player.badges = badges
for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end
local swimmer = Mon.new(game.data, "LAPRAS", 30,
{ moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } })
claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL")
game.save.party = { swimmer }
world:applyPlayerState(FieldMoves.PLAYER_SURF)
world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down")
U.wait(15)
world.noWildEncounters = true
local ctx = world:fieldContext()
local facing = Permissions.isWhirlpool(ctx.facingColl)
if not facing then
-- A re-import moved it: take any whirlpool with open water above it.
local def = world.maps[MAP]
for y = 1, (def.height or 0) * 2 - 1 do
for x = 0, (def.width or 0) * 2 - 1 do
if not facing and Permissions.isWhirlpool(world.map:cellCollision(x, y))
and Permissions.isWater(world.map:cellCollision(x, y - 1)) then
WHIRL.x, WHIRL.y = x, y
world:setMap(MAP, x, y - 1, "down")
world:applyPlayerState(FieldMoves.PLAYER_SURF)
U.wait(10)
ctx = world:fieldContext()
facing = Permissions.isWhirlpool(ctx.facingColl)
U.log(("note: using the whirlpool at (%d,%d)"):format(x, y))
end
end
end
end
claim(facing, ("facing the whirlpool at (%d,%d) on %s"):format(
WHIRL.x, WHIRL.y, MAP))
local blocks = world.maps[MAP] and world.maps[MAP].blocks
local index = ctx.facingBlockIndex
local before = blocks and index and blocks[index]
claim(before ~= nil, "and the block behind it is readable")
if not (facing and before) then
report()
U.log("nothing to drive; stopping rather than faking the moment")
while true do coroutine.yield() end
end
U.shot(game, out .. "/01-facing.png")
local function tap(button, gap)
table.insert(game.input.pressQueue, button)
game.input.state[button] = true
coroutine.yield()
game.input.state[button] = false
for _ = 1, (gap or 8) do coroutine.yield() end
end
-- A into the whirlpool, then A through the ask box (YES is the default) and
-- the USED WHIRLPOOL line.
for _ = 1, 24 do
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end
tap("a")
end
claim(world.fieldMove and world.fieldMove.phase == "whirlpoolsfx",
"the drain reached PlayWhirlpoolSound")
claim(blocks[index] == before,
"the whirlpool is still in the block buffer as the wash starts")
U.shot(game, out .. "/02-wash-starts.png")
local swapped, phaseFrames = nil, 0
for _ = 1, 300 do
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then
phaseFrames = phaseFrames + 1
if blocks[index] ~= before and not swapped then swapped = phaseFrames end
if phaseFrames == 6 then U.shot(game, out .. "/03-mid-wash.png") end
end
if not world:busy() then break end
coroutine.yield()
end
claim(swapped == nil,
"and it stayed there for every frame of the wash")
claim(blocks[index] ~= before,
"the block was swapped once the wash ended")
claim(phaseFrames > 0 and phaseFrames < 180,
("the wash ended on its own after %d frames"):format(phaseFrames))
U.wait(4)
U.shot(game, out .. "/04-drained.png")
report()
if fails > 0 then
U.log("a FAIL above means the run is not showing what it claims to")
end
U.log("what right looks like: the whirlpool keeps spinning through the whole")
U.log("surf wash and only pops out of the water when the sound has finished.")
U.log("water where the whirlpool was while the wash is still playing is the")
U.log("bug; a whirlpool still there after control comes back is the overshoot.")
U.log("the pad is yours -- the other whirlpools are further south.")
while true do coroutine.yield() end
end
@@ -0,0 +1,122 @@
-- Eye check on NIGHT_SHADE's wavy screen (#1848). AnimationWavyScreen's
-- `ld c, $ff` counts outer passes and the inner loop exits twice a frame
-- (pokered engine/battle/animations.asm:1884-1903, 1916-1927), so the
-- wobble runs ~128 frames with a 16-line wave, not 255 with a 32-line one.
-- POKEPORT_DRIVER=tests/drivers/nightshade_anim_bug1848_test.lua POKEPORT_IDENTITY=bug1848 POKEPORT_TOUCH=0 love .
-- No POKEPORT_SPEED: fast-forward scales the logic clock only.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local AnimPlayer = require("src.battle.AnimPlayer")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- data/generated/maps.lua ROUTE_1: open path south of the first grass
-- patch; pokered data/maps/objects/Route1.asm parks its youngsters at
-- (5, 24) and (15, 13), so nobody is standing here or watching.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 5, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0, so the flash that opens the animation is")
U.log(" silent. Set SFX to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
if opts.animations == false then
U.log("FAIL OPTION has animations off, which skips every queued anim row.")
end
check("battle animations are on", opts.animations ~= false)
local anims = (game.data.battle_anims or {}).moveAnims or {}
check("NIGHT_SHADE has an animation", anims.NIGHT_SHADE ~= nil)
-- what the animation player budgets for the effect, ahead of the battle
local probe = AnimPlayer.new(game.data.battle_anims)
probe:start("NIGHT_SHADE", true)
local budget
for _, e in ipairs(probe.events) do
if e.effect == "SE_WAVY_SCREEN" then budget = e.dur end
end
check(("SE_WAVY_SCREEN is budgeted %s frames (want 128)"):format(tostring(budget)),
budget == 128)
local lead = Pokemon.new(game.data, "GASTLY", 25)
lead.moves = { { id = "NIGHT_SHADE", pp = 15 } }
game.save.party = { lead }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(15)
local ow = game.overworld
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(15)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow.map.id == MAP)
local battle = BattleState.newWild(game, "PIDGEY", 6)
battle.onFinish = function() end
ow:pushBattle(battle)
local function waitPhase(phase, tries)
for _ = 1, tries do
if battle.phase == phase then return true end
U.tap(game, "a")
U.wait(6)
end
return battle.phase == phase
end
check("the battle reached the menu", waitPhase("menu", 60))
U.tap(game, "a")
check("NIGHT_SHADE is the move on the list", waitPhase("moveSelect", 20))
U.tap(game, "a")
-- ride the animation and time the wave from the fx layer itself
local started, frames, phases = false, 0, {}
local shots = { [4] = "start", [64] = "middle", [124] = "end" }
for _ = 1, 400 do
local wavy = battle.fx and battle.fx.wavy
if wavy then
started = true
frames = frames + 1
phases[#phases + 1] = wavy.phase
local tag = shots[frames]
if tag then
U.shot(game, ("%s/bug1848_wavy_%s.png"):format(DIR, tag))
end
elseif started then
break
end
U.wait(1)
end
check("the wave actually ran", started)
check(("the wave lasted %d frames (want 128)"):format(frames),
frames >= 120 and frames <= 136)
local step = (#phases >= 2) and (phases[2] - phases[1]) or 0
check(("the offset pointer advances %d entries a frame (want 2)"):format(step),
step == 2)
U.log("Three shots are in " .. DIR .. ": bug1848_wavy_start/middle/end.png.")
U.log("The screen should ripple with about nine full waves top to bottom,")
U.log("scrolling upward, and settle after roughly two seconds. The near-miss")
U.log("to watch for is half that many waves crawling for twice as long.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,118 @@
-- Party submenu box geometry (#1819) with PokemonMenuEntries in full (#1833).
-- pokered anchors this box to the bottom row of the screen and grows it
-- upward two rows per field move, indenting it by the widest field move name
-- (engine/menus/text_box.asm:397-440, data/moves/field_moves.asm,
-- data/text_boxes.asm:33).
-- POKEPORT_DRIVER=tests/drivers/party_submenu_box_bug1819_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Font = require("src.render.Font")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local mon = Pokemon.new(game.data, "CHANSEY", 40)
game.save.party = { mon }
game.save.player.name = "bryan"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- record the boxes one real draw pass puts on screen; the submenu is the
-- only one that does not start at column 0
local function boxThisFrame()
local seen = {}
local real = Font.drawBox
Font.drawBox = function(tx, ty, tw, th, ...)
seen[#seen + 1] = { tx, ty, tw, th }
return real(tx, ty, tw, th, ...)
end
U.wait(2)
Font.drawBox = real
for i = #seen, 1, -1 do
if seen[i][1] > 0 then return seen[i] end
end
return nil
end
local function openSubmenu(moves)
mon.moves = moves
Screens.push(game, "PartyMenu", {})
U.wait(12)
U.tap(game, "a")
U.wait(12)
return game.stack:top()
end
local function closeSubmenu()
U.tap(game, "b")
U.wait(8)
U.tap(game, "b")
U.wait(8)
end
local function move(id)
local def = game.data.moves[id]
return { id = id, pp = def and def.pp or 10 }
end
-- tx, ty, tw, th the port should hand Font.drawBox, per case
local cases = {
{ name = "no field moves", shot = "bug1819_submenu_plain.png",
moves = { move("POUND") }, rows = 3, box = { 11, 11, 9, 7 } },
{ name = "one field move (STRENGTH indents to tile 10)",
shot = "bug1819_submenu_strength.png",
moves = { move("STRENGTH") }, rows = 4, box = { 9, 8, 11, 10 } },
{ name = "SOFTBOILED, the widest name (tile 8)",
shot = "bug1819_submenu_softboiled.png",
moves = { move("SOFTBOILED") }, rows = 4, box = { 7, 8, 13, 10 } },
}
for _, c in ipairs(cases) do
local pm = openSubmenu(c.moves)
local ok = pm and pm.submenu
check(c.name .. ": the submenu opened", ok == true)
if ok then
local labels = {}
for i, e in ipairs(pm.subItems) do labels[i] = e.label end
U.log(c.name .. " lists:", table.concat(labels, " / "))
check(c.name .. ": " .. c.rows .. " rows ending in CANCEL",
#pm.subItems == c.rows
and pm.subItems[#pm.subItems].action == "cancel")
local b = boxThisFrame()
if not b then
check(c.name .. ": a submenu box was drawn", false)
else
U.log(c.name .. " box tx,ty,tw,th:", b[1], b[2], b[3], b[4])
check(c.name .. ": box is " .. table.concat(c.box, ","),
b[1] == c.box[1] and b[2] == c.box[2]
and b[3] == c.box[3] and b[4] == c.box[4])
check(c.name .. ": its bottom border is on tile row 17",
b[2] + b[4] - 1 == 17)
check(c.name .. ": its right edge is on tile column 19",
b[1] + b[3] - 1 == 19)
end
U.shot(game, SHOT_DIR .. "/" .. c.shot)
U.log("captured", SHOT_DIR .. "/" .. c.shot)
end
closeSubmenu()
end
-- leave the last one up for the eye
openSubmenu({ move("SOFTBOILED") })
U.log("The submenu on screen should sit flush against the bottom of the")
U.log("screen, its lower border sharing the screen's last pixel row, with")
U.log("SOFTBOILED / STATS / SWITCH / CANCEL inside it and one blank row")
U.log("above SOFTBOILED. The near-miss to watch for is an 8px gap under")
U.log("the box that shows the message box's own bottom line, so the two")
U.log("borders read as a doubled or detached line.")
while true do
coroutine.yield()
end
end
+5 -1
View File
@@ -72,7 +72,11 @@ return function(game)
check(howY == 112, "How many? sits on text-box first line (y=112)")
check(textBox ~= nil, "bottom PrintText box drawn")
check(qtyBox ~= nil, "quantity box at pokered row 9")
check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)")
-- LIST_MENU_BOX 4,2 - 19,12: the fourth row's quantity sits at y=88
-- (data/text_boxes.asm:13, engine/menus/players_pc.asm)
check(maxItemY == 88, "last visible item row at y=88")
check(textBox and maxItemY and maxItemY + 8 <= textBox.ty * 8,
"last visible item row clears the bottom text box (#174)")
if maxItemY and howY then
check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?")
end
@@ -0,0 +1,206 @@
-- The four PC list screens: the player's PC item lists (#1845) and Bill's
-- PC mon lists plus CHANGE BOX (#1847). Both are LIST_MENU_BOX screens in
-- the original (home/list_menu.asm:29-31, engine/menus/save.asm:437-506),
-- not full-screen lists of their own.
-- SHOT_DIR=/tmp/pc_lists POKEPORT_DRIVER=tests/drivers/pc_lists_bug1845_bug1847_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Boxes = require("src.pokemon.Boxes")
local BoxMenu = require("src.ui.BoxMenu")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local PlayerPC = require("src.ui.PlayerPC")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local pass = true
local function check(label, ok)
if ok then U.log("PASS", label) else pass = false; U.log("FAIL", label) end
return ok
end
-- record every Font.drawBox/Font.draw of one frame so the geometry can be
-- asserted instead of eyeballed
local function capture()
local out = {}
local savedDraw, savedBox = Font.draw, Font.drawBox
Font.draw = function(text, x, y)
out[#out + 1] = { kind = "text", text = tostring(text), x = x, y = y }
return savedDraw(text, x, y)
end
Font.drawBox = function(tx, ty, tw, th)
out[#out + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th }
return savedBox(tx, ty, tw, th)
end
game.stack:draw()
Font.draw, Font.drawBox = savedDraw, savedBox
return out
end
local function hasBox(drawn, tx, ty, tw, th)
for _, d in ipairs(drawn) do
if d.kind == "box" and d.tx == tx and d.ty == ty
and d.tw == tw and d.th == th then return true end
end
return false
end
local function textOnRow(drawn, y)
for _, d in ipairs(drawn) do
if d.kind == "text" and d.y == y and d.text ~= "" then return d end
end
return nil
end
local function hasText(drawn, want)
for _, d in ipairs(drawn) do
if d.kind == "text" and d.text == want then return d end
end
return nil
end
-- pokered data/maps/objects/ViridianPokecenter.asm keeps the floor east of
-- the counter free; the PC itself is a hidden object, so the screens are
-- pushed directly rather than bumped into
local MAP, STAND = "VIRIDIAN_POKECENTER", { x = 13, y = 4 }
U.teleport(game, MAP, STAND.x, STAND.y, "up")
U.wait(6)
local function freeNeighbour(ow)
for dy = -2, 2 do
for dx = -2, 2 do
local cx, cy = STAND.x + dx, STAND.y + dy
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
return cx, cy
end
end
end
end
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
local cx, cy = freeNeighbour(ow)
if cx then
U.log("stand cell blocked, standing on", cx, cy)
U.teleport(game, MAP, cx, cy, "up")
U.wait(6)
end
end
-- #1845: the player's PC item lists
game.save.pcItems = { POTION = 3, ANTIDOTE = 1, REPEL = 5, ESCAPE_ROPE = 2 }
game.stack:push(PlayerPC.new(game))
U.wait(4)
U.tap(game, "a") -- WITHDRAW ITEM
U.wait(6)
local list = game.stack:top()
check("WITHDRAW ITEM opened a list", getmetatable(list) == ListMenu)
if getmetatable(list) == ListMenu then
check("the list is drawn as LIST_MENU_BOX, not a screen", list.itemBox == true)
check("the PC menu underneath stays visible", list.isOpaque == false)
check("PrintListMenuEntries' four rows", list.rows == 4)
check("the list ends on the CANCEL terminator",
list.items[#list.items].cancel == true)
check("the withdraw prompt is up before anything is chosen",
type(list.footer) == "string" and list.footer:find("withdraw", 1, true) ~= nil)
local drawn = capture()
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
check("the prompt's text box at tile 0,12 (20x6)", hasBox(drawn, 0, 12, 20, 6))
check("nothing on the invented title row (y=4)", textOnRow(drawn, 4) == nil)
local name = hasText(drawn, list.items[1].label)
check("first name at hlcoord 6,4 (48,32)",
name ~= nil and name.x == 48 and name.y == 32)
end
U.shot(game, DIR .. "/pc_1845_withdraw_item.png")
U.tap(game, "b") -- back to the PC menu
U.wait(4)
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
U.wait(2)
U.tap(game, "a") -- LOG OFF
U.wait(6)
-- #1847: Bill's PC mon lists and CHANGE BOX
local boxes = Boxes.ensure(game.save)
boxes[1] = {
Pokemon.new(game.data, "PIDGEY", 12),
Pokemon.new(game.data, "RATTATA", 7),
Pokemon.new(game.data, "NIDORAN_M", 15),
Pokemon.new(game.data, "ZUBAT", 9),
Pokemon.new(game.data, "ONIX", 22),
}
boxes[3] = { Pokemon.new(game.data, "MAGIKARP", 5) }
game.save.currentBox = 1
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 14) }
game.stack:push(BoxMenu.new(game))
U.wait(4)
U.tap(game, "a") -- WITHDRAW POKéMON
U.wait(6)
local mons = game.stack:top()
check("WITHDRAW POKéMON opened a list", getmetatable(mons) == ListMenu)
if getmetatable(mons) == ListMenu then
check("the mon list is drawn as LIST_MENU_BOX", mons.itemBox == true)
check("the mon list ends on CANCEL",
mons.items[#mons.items].cancel == true)
check("the level is a row of its own, not part of the name",
mons.items[1].sub == ":L12"
and mons.items[1].label:find(":L") == nil)
local drawn = capture()
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
check("no invented BOX 1 (WITHDRAW) title",
hasText(drawn, "BOX 1 (WITHDRAW)") == nil)
local lvl = hasText(drawn, ":L12")
check("PrintLevel one row down, 8 columns right (112,40)",
lvl ~= nil and lvl.x == 112 and lvl.y == 40)
end
U.shot(game, DIR .. "/pc_1847_withdraw_mon.png")
U.tap(game, "b")
U.wait(4)
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
U.wait(2)
U.tap(game, "a") -- CHANGE BOX
U.wait(30)
local ask = game.stack:top()
check("CHANGE BOX asks before it shows the box list",
getmetatable(ask) == TextBox)
U.shot(game, DIR .. "/pc_1847_change_box_prompt.png")
-- page through "When you change a POKéMON BOX..." and take the YES
local function isPicker(s)
return getmetatable(s) == Menu and s.kind == "pc_box_change"
end
for _ = 1, 12 do
if isPicker(game.stack:top()) then break end
U.tap(game, "a")
U.wait(12)
end
local picker = game.stack:top()
check("YES opens the box picker", isPicker(picker))
if isPicker(picker) then
check("twelve boxes", #picker.items == 12)
check("BoxNames spelling", picker.items[1].label == "BOX 1"
and picker.items[12].label == "BOX12")
check("the cursor starts on the current box",
picker.index == game.save.currentBox)
local drawn = capture()
check("box list at tile 11,0 (9x14)", hasBox(drawn, 11, 0, 9, 14))
check("BOX No. panel at tile 0,0 (11x4)", hasBox(drawn, 0, 0, 11, 4))
local first = hasText(drawn, "BOX 1")
check("BoxNames at hlcoord 13,1 (104,8)",
first ~= nil and first.x == 104 and first.y == 8)
end
U.shot(game, DIR .. "/pc_1847_change_box.png")
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
U.log("On screen now: the CHANGE BOX picker. BOX 1..BOX12 sit in a tall")
U.log("box on the right, BOX No. 1 in a small panel top-left, and a")
U.log("pokeball marks BOX 1 and BOX 3 only, the two that hold POKéMON.")
U.log("The near-miss to watch for is a marker on all twelve boxes, or on")
U.log("none, and BOX10-12 spelled with the space of BOX 1 to BOX 9.")
U.log("Shots are in " .. DIR .. "; the two list shots must show the")
U.log("PC menu and the What? box around a bordered list, never a white")
U.log("page with a title row.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,119 @@
-- Eye/ear check for the Pokédex CONTENTS screen and entry page (#1829):
-- screen furniture, the seven rows, the side-menu cursor, the cry the entry
-- page waits on and the blinking page arrow.
-- engine/menus/pokedex.asm:161-199, :242-273, :500-506; home/joypad2.asm:55-83.
-- No POKEPORT_SPEED: the cry runs on the real-time audio clock.
-- POKEPORT_DRIVER=tests/drivers/pokedex_contents_bug1829_test.lua POKEPORT_IDENTITY=bug1829 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local opts = game.save.options or {}
if (opts.sfxVol or 1) == 0 then
U.log("WARNING sfxVol is 0, so the entry page's cry will be silent and")
U.log("WARNING the pause before the height/weight lines will look like a hang")
end
-- dex numbers -> species, so the driver never hardcodes an id
local byDex = {}
for _, def in pairs(game.data.pokemon) do
if def.dex then byDex[def.dex] = def end
end
check("the cache carries a Kanto numbering", byDex[1] ~= nil and byDex[20] ~= nil)
game.save.pokedex = { seen = {}, owned = {} }
for n = 1, 20 do
local def = byDex[n]
if def and n ~= 5 then game.save.pokedex.seen[def.id] = true end
end
for _, n in ipairs({ 1, 2, 3, 7, 8 }) do
local def = byDex[n]
if def then
game.save.pokedex.seen[def.id] = true
game.save.pokedex.owned[def.id] = true
end
end
game.save.player.name = "bryan"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(20)
local dex = Screens.push(game, "PokedexMenu")
U.wait(20)
check("the dex list is on the stack", game.stack:top() == dex)
check("the list stops at the highest seen number (20)", #dex.items == 20)
check("dex 005 was left unseen, so its row is a dashed line",
dex.items[5] and dex.items[5].name == "----------")
check("dex 001 is owned, so its row carries the ball marker",
dex.items[1] and dex.items[1].ball == true)
U.shot(game, SHOT_DIR .. "/bug1829_contents.png")
-- Right pages the list without moving the cursor off its screen row
local rowBefore = dex.index - dex.scroll
U.tap(game, "right")
U.wait(10)
check("Right scrolled the list", dex.scroll == 7)
check("the cursor kept its screen row", dex.index - dex.scroll == rowBefore)
U.shot(game, SHOT_DIR .. "/bug1829_contents_paged.png")
U.tap(game, "left")
U.wait(10)
check("Left paged back to the top", dex.scroll == 0)
-- A on an owned row opens the side menu: the cursor moves onto the
-- DATA/CRY/AREA/QUIT block that was already on screen
U.tap(game, "a")
U.wait(20)
local side = game.stack:top()
check("A opened the side menu", side ~= dex)
check("the list row kept its hollow cursor", dex.hollowIndex == dex.index)
U.shot(game, SHOT_DIR .. "/bug1829_side_menu.png")
-- DATA is the first row, so A again opens the entry page
U.tap(game, "a")
U.wait(2)
local page = game.stack:top()
check("DATA opened the entry page", page ~= side and page.def ~= nil)
U.shot(game, SHOT_DIR .. "/bug1829_entry_cry.png")
local waited = 0
while page.crying and page:crying() and waited < 300 do
waited = waited + 1
coroutine.yield()
end
U.log("the cry held the page for", waited, "frames")
U.wait(10)
U.shot(game, SHOT_DIR .. "/bug1829_entry_full.png")
check("the entry has more than one page, so the arrow is drawn",
(page.pageCount or 1) > 1)
local function shotAtBlink(target, path)
for _ = 1, 120 do
if (page.blink or 0) == target then break end
coroutine.yield()
end
U.shot(game, path)
end
shotAtBlink(8, SHOT_DIR .. "/bug1829_arrow_on.png")
shotAtBlink(38, SHOT_DIR .. "/bug1829_arrow_off.png")
U.log("Shots are in " .. SHOT_DIR .. ". The CONTENTS screen should read")
U.log("CONTENTS top left, a vertical rule down column 14 with SEEN and OWN")
U.log("counts beside it and DATA/CRY/AREA/QUIT under them, and seven rows")
U.log("of number-above-name with the ball left of the owned names.")
U.log("bug1829_entry_cry should show the frame, name, kind, No. and pic")
U.log("with nothing below the divider while the cry sounds; the height,")
U.log("weight and description arrive in bug1829_entry_full once it ends.")
U.log("The near-miss to watch for: all of it painted at once with the cry")
U.log("playing over the top, and a page arrow that never blinks off")
U.log("(bug1829_arrow_on has it, bug1829_arrow_off should not).")
while true do
coroutine.yield()
end
end
+71
View File
@@ -0,0 +1,71 @@
-- Rare Candy's "grew to level N!" line carries a jingle (#1846): pokered's
-- RareCandyText is text_far _RareCandyText / sound_get_item_1 /
-- text_promptbutton (engine/menus/party_menu.asm:289-293), and outside
-- battle that command plays SFX_GET_ITEM_1 (home/text.asm:546).
-- POKEPORT_DRIVER=tests/drivers/rare_candy_bug1846_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Bag = require("src.inventory.Bag")
local Sound = require("src.core.Sound")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
end
local mon = Pokemon.new(game.data, "NIDORINO", 20)
game.save.party = { mon }
game.save.player.name = "bryan"
game.save.inventory = {}
Bag.add(game.save, "RARE_CANDY", 3, game.data)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name, ...)
heard[#heard + 1] = name
return realPlay(data, name, ...)
end
-- bag -> RARE CANDY -> USE -> the only party member
Screens.push(game, "BagMenu", {})
U.wait(12)
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(12)
local level = mon.level
U.tap(game, "a")
U.wait(40)
Sound.play = realPlay
check("the candy raised the level", mon.level == level + 1)
U.log("sounds heard:", table.concat(heard, " "))
local jingle = false
for _, name in ipairs(heard) do
if name == "Get_Item1" then jingle = true end
end
check("the level line carried SFX_GET_ITEM_1", jingle)
U.shot(game, SHOT_DIR .. "/bug1846_rare_candy.png")
U.log("captured", SHOT_DIR .. "/bug1846_rare_candy.png")
U.log("NIDORINO grew to level 21 just now: the short item fanfare should")
U.log("sound as that line finishes typing, and the box waits for A after")
U.log("it. Silence there is the bug. The other near-miss is the longer")
U.log("level-up fanfare, which only belongs to a candy used in battle.")
U.log("Two candies are left in the bag to hear it again.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,100 @@
-- Manual check that Hideout B4F's Rocket3 engages on sight (#1814).
-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...`
-- (scripts/RocketHideoutB4F.asm:95-96), so CheckSpriteCanSeePlayer engages him
-- from the tile below. The LIFT KEY half (his after-battle talk still reveals
-- the ball) is asserted in tests/parity_rocket3_sight_bug1814.lua.
-- POKEPORT_DRIVER=tests/drivers/rocket3_sight_bug1814_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- pokered data/maps/objects/RocketHideoutB4F.asm: ROCKET3 stands at (11,2)
-- facing DOWN, so the sight tile is the one below him
local MAP = "ROCKET_HIDEOUT_B4F"
local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3"
local function rocketIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.text == TEXT then return n end
end
end
-- a party that can take a hit, so the battle the sight starts is playable
if #(game.save.party or {}) == 0 then
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "NIDOKING", 45) }
end
U.teleport(game, MAP, 13, 3, "left")
U.wait(10)
local ow = game.overworld
local rocket = rocketIn(ow)
check("Rocket3 is on the map", rocket ~= nil)
if rocket then
U.log(("Rocket3 at (%d, %d) facing %s")
:format(rocket.cellX, rocket.cellY, tostring(rocket.facing)))
end
local header = rocket
and game.data:trainerHeader(ow.map.def.label, rocket.def.index)
check("his extracted view range is 1 tile", header and header.range == 1)
-- walk north into his line of sight; a map edit that moved him is a
-- different failure than a sight check that never fires, so aim at the
-- cell below wherever he actually stands
local target = rocket and { rocket.cellX, rocket.cellY + 1 } or { 11, 3 }
local steps = 0
for _ = 1, 8 do
local p = game.overworld.player
if (p.cellX == target[1] and p.cellY == target[2])
or game.overworld.engaging then break end
if p.cellX > target[1] then U.hold(game, "left", 16)
elseif p.cellX < target[1] then U.hold(game, "right", 16)
elseif p.cellY > target[2] then U.hold(game, "up", 16)
else U.hold(game, "down", 16) end
steps = steps + 1
U.wait(4)
end
U.log("walked", steps, "steps toward", target[1], target[2])
local sighted = false
for _ = 1, 90 do
ow = game.overworld
if ow.engaging or ow.emote then sighted = true break end
U.wait(1)
end
check("stepping into his line of sight engages him", sighted)
if game.overworld.emote then
U.shot(game, SHOT_DIR .. "/bug1814_sighted.png")
U.log("captured", SHOT_DIR .. "/bug1814_sighted.png")
end
local BattleState = require("src.battle.BattleState")
-- his line prints first and holds on a button wait (home/text_script.asm:96)
local fought = false
for _ = 1, 60 do
if getmetatable(game.stack:top()) == BattleState then fought = true break end
U.tap(game, "a")
U.wait(8)
end
check("the walk-up runs into the battle", fought)
if fought then
U.wait(60)
U.shot(game, SHOT_DIR .. "/bug1814_battle.png")
U.log("captured", SHOT_DIR .. "/bug1814_battle.png")
end
U.log("Right: the grunt notices the player one tile below him, the bubble")
U.log("pops, he marches down and the fight starts on its own. The near-miss")
U.log("is nothing happening until the player presses A at him, which is the")
U.log("bug. After winning, talk to him again: he drops the LIFT KEY ball.")
while true do
coroutine.yield()
end
end
+113
View File
@@ -0,0 +1,113 @@
-- Manual check of the Game Corner slot prompt, emote and menus (#1811).
-- PromptUserToPlaySlots prints "Want to play?" and floats SMILE_BUBBLE over
-- the player on the map, and only then loads the slot screen, whose bet menu
-- is hlcoord 14,11 b=5 c=4 (engine/slots/slot_machine.asm:9-23, :83-86).
-- Box geometry is asserted in tests/engine/slot_machine_boxes_bug1811.lua.
-- POKEPORT_DRIVER=tests/drivers/slots_prompt_bug1811_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local SlotMachine = require("src.ui.SlotMachine")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfx volume is 0 in options; raise it or the menu beeps are silent")
end
-- AbleToPlaySlotsCheck wants a COIN CASE with coins in it
game.save.inventory = game.save.inventory or {}
game.save.inventory.COIN_CASE = 1
game.save.coins = math.max(game.save.coins or 0, 50)
-- the seats are hidden events on the machine tiles (data/events/
-- hidden_events.asm StartSlotMachine); stand on any walkable neighbour
local seats = (game.data.field.slotMachines or {}).GAME_CORNER or {}
local placed = nil
U.teleport(game, "GAME_CORNER", 9, 15, "down")
local ow = game.overworld
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, seat in ipairs(seats) do
if seat.state == "ok" then
for _, s in ipairs(sides) do
local cx, cy = seat.x + s[1], seat.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.teleport(game, "GAME_CORNER", cx, cy, s[3])
U.wait(10)
ow = game.overworld
local fx, fy = ow.player:facingCell()
if fx == seat.x and fy == seat.y then
placed = { seat = seat, x = cx, y = cy, facing = s[3] }
end
break
end
end
end
if placed then break end
end
check("standing at a working slot machine", placed ~= nil)
if placed then
U.log(("machine at (%d, %d), player at (%d, %d) facing %s")
:format(placed.seat.x, placed.seat.y, placed.x, placed.y,
placed.facing))
end
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
local isBox = getmetatable(top) == TextBox
check("A opens a text box, not the slot screen", isBox)
check("the slot screen is not on the stack yet",
getmetatable(top) ~= SlotMachine)
if isBox then
local shown = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
U.log("box reads:", table.concat(shown, " / "))
end
U.shot(game, SHOT_DIR .. "/bug1811_prompt.png")
-- through the text, then YES
for _ = 1, 8 do
if game.overworld.emote then break end
U.tap(game, "a")
U.wait(12)
end
U.wait(4)
local emote = game.overworld.emote
check("YES floats an emotion bubble over the player", emote ~= nil)
check("it is SMILE_BUBBLE, the third crop", emote and emote.bubble == 3)
check("and it is over the player, not an NPC",
emote and emote.npc == game.overworld.player)
U.shot(game, SHOT_DIR .. "/bug1811_smile.png")
for _ = 1, 120 do
if getmetatable(game.stack:top()) == SlotMachine then break end
U.wait(2)
end
local slots = game.stack:top()
check("the slot screen opens after the bubble",
getmetatable(slots) == SlotMachine)
check("and it opens on the bet menu", slots.stage == "bet")
U.wait(10)
U.shot(game, SHOT_DIR .. "/bug1811_bet.png")
U.log("captured", SHOT_DIR .. "/bug1811_{prompt,smile,bet}.png")
U.log("Right: the prompt box sits over the still-drawn Game Corner floor,")
U.log("then a smiley pops over the player, then the machine appears with a")
U.log("bet menu whose bottom edge is the last row of the screen. The")
U.log("near-miss is the machine already drawn behind the prompt, or a bet")
U.log("box two rows short of the bottom.")
while true do
coroutine.yield()
end
end
+155
View File
@@ -0,0 +1,155 @@
-- Manual check for #1866: black hairlines at the title's SGB zone boundaries
-- (canvas rows 64 and 80, BlkPacket_Titlescreen, data/sgb/sgb_packets.asm:
-- 123-127) at window heights that are not a whole multiple of 144.
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1866_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local Renderer = require("src.render.Renderer")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local title
for _ = 1, 120 do
local top = game.stack:top()
if top and top.screenId == "TitleState" and top.sgbPalettes then
title = top
break
end
U.tap(game, "start")
U.wait(9)
end
check("the title screen is on top", title ~= nil)
if not (title and title.sgbPalettes) then
U.log("No title state to look at; nothing below can run.")
while true do coroutine.yield() end
end
local opts = game.save.options
local mode = opts and opts.colors or PaletteFX.mode
if PaletteFX.mode ~= "gbc" then
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
PaletteFX.setMode("gbc")
U.wait(20)
end
local zones = PaletteFX.ensureZones(title:sgbPalettes(game))
check("the title builds three SGB zones", zones ~= nil and #zones == 3)
-- Every height below is odd and none is a multiple of 144, so the picture
-- never lands on a whole number of screen pixels per GB pixel. "fill"
-- additionally drops the integer fit scale, which is the shape the report's
-- screenshot was taken in (about 5.98 screen pixels per GB pixel).
local HEIGHTS = { 721, 823, 907, 1013 }
local WIDTH = 1157
local worstRows, worstTag = 0, "none"
-- the control: a zone list with a real hole in it, drawn the way the
-- per-zone pass alone would draw it. It proves the sampler below can see
-- a seam at all, and that the underpaint is what closes this one.
local gapped = {}
for i, z in ipairs(zones) do
gapped[i] = { x = z.x, y = z.y, w = z.w, h = z.h, colors = z.colors }
end
if gapped[2] then
gapped[2].y = gapped[2].y + 1
gapped[2].h = gapped[2].h - 2
end
local function drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
local shader = PaletteFX.shader()
love.graphics.setShader(shader)
for _, z in ipairs(zoneList) do
PaletteFX.sendColors(shader, z.colors)
love.graphics.setScissor(uox + z.x * Ux, uoy + z.y * Uy,
z.w * Ux, z.h * Uy)
love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy)
end
love.graphics.setScissor()
love.graphics.setShader()
end
local function probeAt(ph, fill, zoneList, zonesOnly)
local dpi = 1
local probe = love.graphics.newCanvas(WIDTH, ph, { dpiscale = dpi })
local Up
if fill then
Up = math.min(ph / 144, WIDTH / 160)
else
Up = math.max(1, math.floor(math.min(WIDTH / 160, ph / 144)))
end
local Ux, Uy = Up / dpi, Up / dpi
local uox = math.floor((WIDTH - 160 * Up) / 2) / dpi
local uoy = math.floor((ph - 144 * Up) / 2) / dpi
local uvpw, uvph = 160 * Ux, 144 * Uy
love.graphics.setCanvas(probe)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
if zonesOnly then
drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
else
Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zoneList, Ux, Uy,
uox, uoy, uox, uoy, uvpw, uvph, dpi, dpi)
end
love.graphics.setCanvas()
local data = probe:newImageData()
local top = math.floor(uoy * dpi) + 1
local bottom = math.min(math.floor((uoy + uvph) * dpi) - 1, data:getHeight() - 1)
local col = math.floor(uox * dpi) + 2
local rows = {}
for py = top, bottom do
local r, g, b = data:getPixel(col, py)
if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end
end
return rows, ("%dx%d %s"):format(WIDTH, ph, fill and "fill" or "fit")
end
for _, ph in ipairs(HEIGHTS) do
for _, fill in ipairs({ false, true }) do
local rows, tag = probeAt(ph, fill, zones, false)
if #rows > worstRows then worstRows, worstTag = #rows, tag end
check(tag .. ": no letterbox row shows through a zone boundary",
#rows == 0)
if #rows > 0 then
U.log(" black rows:", table.concat(rows, ","))
end
end
end
U.log("worst case:", worstRows, "black rows at", worstTag)
local holeRows = probeAt(HEIGHTS[3], true, gapped, true)
check("a zone list with a hole in it does show the letterbox clear",
#holeRows > 0)
local filledRows = probeAt(HEIGHTS[3], true, gapped, false)
check("and the same hole drawn through the renderer shows none",
#filledRows == 0)
-- and the live window at one of those heights, for the eye
local ok = pcall(love.window.setMode, WIDTH, HEIGHTS[3],
{ resizable = true, vsync = 0 })
check("the window resized to " .. WIDTH .. "x" .. HEIGHTS[3], ok)
U.wait(20)
check("wrote " .. SHOT_DIR .. "/bug1866_title_907.png",
U.shot(game, SHOT_DIR .. "/bug1866_title_907.png"))
pcall(love.window.setMode, WIDTH, HEIGHTS[4], { resizable = true, vsync = 0 })
U.wait(20)
check("wrote " .. SHOT_DIR .. "/bug1866_title_1013.png",
U.shot(game, SHOT_DIR .. "/bug1866_title_1013.png"))
PaletteFX.setMode(mode)
U.wait(10)
U.log("Look along the title picture where the POKeMON logo meets the red")
U.log("VERSION ribbon, and where the ribbon meets RED and the copyright: the")
U.log("off-white background must run through both boundaries unbroken.")
U.log("The near-miss is a hairline one or two pixels tall that is dark grey")
U.log("or slightly off-white rather than black; that is still the seam.")
while true do
coroutine.yield()
end
end
+78
View File
@@ -0,0 +1,78 @@
-- Vitamins (#1805): pokered's .useVitamin plays SFX_HEAL_AILMENT, then
-- prints VitaminStatRoseText over the still-drawn party menu and ends at
-- RemoveUsedItem, which only strips the item from the bag
-- (engine/items/item_effects.asm:1313-1322, :2274).
-- POKEPORT_DRIVER=tests/drivers/vitamin_bug1805_test.lua POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Bag = require("src.inventory.Bag")
local Sound = require("src.core.Sound")
local PartyMenu = require("src.ui.PartyMenu")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
end
local mon = Pokemon.new(game.data, "NIDORINO", 20)
game.save.party = { mon }
game.save.player.name = "bryan"
game.save.inventory = {}
Bag.add(game.save, "HP_UP", 3, game.data)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local heard = {}
local realPlay = Sound.play
Sound.play = function(data, name, ...)
heard[#heard + 1] = name
return realPlay(data, name, ...)
end
-- bag -> HP UP -> USE -> the only party member
Screens.push(game, "BagMenu", {})
U.wait(12)
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(12)
U.tap(game, "a")
U.wait(30)
Sound.play = realPlay
check("the vitamin was consumed", (game.save.inventory.HP_UP or 0) == 2)
check("HP stat exp went up", (mon.statExp and mon.statExp.hp or 0) == 2560)
U.log("sounds heard:", table.concat(heard, " "))
local jingle = false
for _, name in ipairs(heard) do
if name == "Heal_Ailment" then jingle = true end
end
check("the stat line carried SFX_HEAL_AILMENT", jingle)
local partyUp = false
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == PartyMenu then partyUp = true end
end
check("the party menu is still drawn under the message", partyUp)
U.shot(game, SHOT_DIR .. "/bug1805_vitamin.png")
U.log("captured", SHOT_DIR .. "/bug1805_vitamin.png")
U.log("NIDORINO's HEALTH rose just now: the short cure jingle should sound")
U.log("as that line types, with the party list still on screen behind the")
U.log("box, and only then does the menu come down. The bug was silence")
U.log("plus the list vanishing to the map the instant A was pressed.")
U.log("Two HP UPs are left in the bag to try it again.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,41 @@
-- data/moves/animations.asm:401-403
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local UI = require("src.ui.gen2.BattleState")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local runner = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
T.eq(runner.animId, "ANIM_THROW_POKE_BALL",
"the runner exposes the anim id BattleState latches on")
T.eq(runner.env.animId, "ANIM_THROW_POKE_BALL",
"the object/bg env still sees the same id")
-- A real runner skipped with B must still hide the caught mon.
do
local s = setmetatable({
anim = runner,
ballThrow = { caught = true },
picHidden = { player = false, enemy = false },
}, { __index = UI })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, true, "a B-skipped catch latches with a real runner")
end
do
local free = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
local s = setmetatable({
anim = free,
ballThrow = { caught = false },
picHidden = { player = false, enemy = false },
}, { __index = UI })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, false, "a B-skipped break-free still does not latch")
end
T.finish("gen2 ball anim id bug 1804")
@@ -0,0 +1,79 @@
-- ../pokecrystal/engine/gfx/load_pics.asm:105-158
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Assets = require("src.render.Assets")
local UI = require("src.ui.gen2.BattleState")
local FRONT = "assets/generated/battle/front/cyndaquil.png"
local SHEET = "assets/generated/battle/anim/cyndaquil.png"
local MOD_FRONT = "mods/skin/overrides/battle/front/cyndaquil.png"
local MOD_SHEET = "mods/skin/overrides/battle/anim/cyndaquil.png"
local image = { getDimensions = function() return 40, 200 end }
Assets.image = function() return image end
local overrides = {}
Assets.resolve = function(path) return overrides[path] or path end
local mon = { species = "CYNDAQUIL" }
local anim = { sheet = SHEET, tiles = 5, play = { { 1 } } }
local function newSelf(picPath)
local self = setmetatable({
pokemon = { CYNDAQUIL = { spriteFront = FRONT, anim = anim } },
picCache = {},
}, { __index = UI })
if picPath then
self.pic = function() return image, false, picPath end
end
return self
end
-- Stock cache, no mod: the sheet still animates.
do
overrides = {}
local s = newSelf()
s:startFrontAnim(mon)
T.check(s.frontAnim ~= nil, "an unmodded Crystal front pic still animates")
T.eq(s.frontAnim and s.frontAnim.sheet, image, "off the extracted sheet")
end
-- Gold and Silver have no anim row at all and never reach any of this.
do
overrides = {}
local s = newSelf()
s.pokemon.CYNDAQUIL.anim = nil
s:startFrontAnim(mon)
T.eq(s.frontAnim, nil, "a cache with no anim row is untouched")
s.pokemon.CYNDAQUIL.anim = anim
end
-- The bug: the static pic replaced, the sheet left stock. Holding the static
-- pic for the whole scene is the only way not to flicker stock art over it.
do
overrides = { [FRONT] = MOD_FRONT }
local s = newSelf()
s:startFrontAnim(mon)
T.eq(s.frontAnim, nil, "an overridden front pic suppresses the stock frames")
end
do
overrides = {}
local s = newSelf(MOD_FRONT)
s:startFrontAnim(mon)
T.eq(s.frontAnim, nil, "a pokemon.sprite path also suppresses them")
end
-- A mod that ships both halves keeps its animation.
do
overrides = { [FRONT] = MOD_FRONT, [SHEET] = MOD_SHEET }
local s = newSelf()
s:startFrontAnim(mon)
T.check(s.frontAnim ~= nil, "a mod that ships a sheet as well still animates")
end
T.finish("gen2 front anim mod sheet bug 1827")
@@ -0,0 +1,72 @@
-- engine/gfx/color.asm:342-359 / engine/gfx/cgb_layouts.asm:167-192
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
local Chrome = require("src.ui.gen2.Chrome")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
local printed, through
local realPrint, realThrough = Chrome.print, Chrome.printThrough
Chrome.print = function(text, tx, ty)
printed[#printed + 1] = { text = text, x = tx, y = ty }
return 0
end
Chrome.printThrough = function(text, tx, ty, palette)
through[#through + 1] = { text = text, x = tx, y = ty, palette = palette }
return 0
end
local function fake(page, placements)
local self = setmetatable({ page = page, mon = {}, party = {} },
{ __index = SummaryMenu })
self.greenPlacements = function() return placements end
self.bluePlacements = function() return placements end
self.pinkPlacements = function() return placements end
self.drawVerticalDivider = function() end
return self
end
local rows = {
{ text = "ITEM", x = 0, y = 8 },
{ text = "MOVE", x = 0, y = 10 },
}
for _, page in ipairs({ SummaryMenu.PINK_PAGE, SummaryMenu.GREEN_PAGE,
SummaryMenu.BLUE_PAGE }) do
local self = fake(page, rows)
printed, through = {}, {}
if page == SummaryMenu.GREEN_PAGE then
self:drawGreenPage()
elseif page == SummaryMenu.BLUE_PAGE then
self:drawBluePage()
else
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
end
T.eq(#printed, 0, "no lower-half string prints on the white default paper")
T.eq(#through, #rows, "every lower-half string prints through a palette")
local tint = SummaryMenu.PAGE_TINTS[page]
for _, call in ipairs(through) do
T.eq(call.palette and call.palette[1][1], tint[1], "paper red is the page tint")
T.eq(call.palette and call.palette[1][2], tint[2], "paper green is the page tint")
T.eq(call.palette and call.palette[1][3], tint[3], "paper blue is the page tint")
T.eq(call.palette and call.palette[4][1], 0, "ink stays black")
end
end
-- Rows 0-7 are filled with palette $1, whose colour 0 is white, so the upper
-- half keeps the default paper.
do
local self = fake(SummaryMenu.PINK_PAGE, rows)
printed, through = {}, {}
self:drawPlacements(rows)
T.eq(#printed, #rows, "upper-half strings still take the default paper")
T.eq(#through, 0, "upper-half strings do not force a page tint")
end
Chrome.print, Chrome.printThrough = realPrint, realThrough
T.finish("gen2 stats text tint bug 1858")
@@ -0,0 +1,56 @@
-- engine/events/overworld.asm:1150-1165, engine/events/field_moves.asm:5-10
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Sound = require("src.core.Sound")
local World = require("src.world.gen2.World")
local busy = false
Sound.sfxBusy = function() return busy end
local log = {}
local world = setmetatable({}, { __index = World })
world.setNickname = function() end
world.showText = function(_, _, done) if done then done() end end
world.playSfxNamed = function() log[#log + 1] = "sfx" end
world.replaceBlock = function(_, index, blockId)
log[#log + 1] = "block:" .. tostring(index) .. ":" .. tostring(blockId)
return true
end
world:runWhirlpool({ text = "USED WHIRLPOOL", blockIndex = 7, replacement = 3 })
T.eq(#log, 0, "the box closing does not swap the block yet")
T.eq(world.fieldMove and world.fieldMove.phase, "whirlpoolsfx",
"closing the box parks the WaitSFX phase")
busy = false
world:updateFieldMove()
T.same(log, { "sfx" }, "the first WaitSFX passes and SFX_SURF starts")
busy = true
world:updateFieldMove()
world:updateFieldMove()
T.same(log, { "sfx" }, "the whirlpool is still on screen while the sfx plays")
busy = false
world:updateFieldMove()
T.same(log, { "sfx", "block:7:3" },
"the block is swapped once the second WaitSFX clears")
T.eq(world.fieldMove, nil, "and the phase is done")
-- The callback path (DisappearWhirlpool via callasm) carries no block, and
-- must not invent one.
do
log = {}
world.fieldMove = nil
world:playWhirlpoolSound()
busy = false
world:updateFieldMove()
world:updateFieldMove()
T.same(log, { "sfx" }, "a bare sound call swaps nothing")
end
T.finish("gen2 whirlpool block after sfx bug 1862")
@@ -0,0 +1,105 @@
-- A link switch runs SwitchPlayerMon on the switcher and SwitchEnemyMon on
-- the peer (core.asm:2418-2422, trainer_ai.asm:596-599) (#1562).
-- luajit tests/run_engine.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Link = require("tests.modkit.link")
local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol")
local LinkBattle = require("src.link.LinkBattle")
math.randomseed(4242)
local Input = Link.prepare(Data)
local gameA = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "RED" })
local gameB = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "BLUE" })
local netA, netB = Net.loopbackPair()
local packedA = Protocol.packParty(gameA.save.party)
local packedB = Protocol.packParty(gameB.save.party)
local battleA = LinkBattle.newHost(gameA, netA, {
myParty = packedA, theirParty = packedB,
theirName = "BLUE", seed = 987654321,
})
local battleB = LinkBattle.newGuest(gameB, netB, {
myParty = packedB, theirParty = packedA,
theirName = "RED", seed = 987654321,
})
local function watch(battle)
local seen = {}
local sayNext, sayNextAuto = battle.sayNext, battle.sayNextAuto
battle.sayNext = function(s, text) seen[#seen + 1] = text; return sayNext(s, text) end
battle.sayNextAuto = function(s, text, d)
seen[#seen + 1] = text
return sayNextAuto(s, text, d)
end
return seen
end
local seenA, seenB = watch(battleA), watch(battleB)
local function count(list, needle)
local n = 0
for _, text in ipairs(list) do
if text and text:find(needle, 1, true) then n = n + 1 end
end
return n
end
local function firstIndex(list, needle)
for i, text in ipairs(list) do
if text and text:find(needle, 1, true) then return i end
end
end
local resA, resB
battleA.onFinish = function(r) resA = r end
battleB.onFinish = function(r) resB = r end
gameA.stack:push(battleA)
gameB.stack:push(battleB)
local switched, retreatSeen = false, false
for _ = 1, 60000 do
if resA and resB then break end
Input.pressed = { a = true }
if not switched and battleA.phase == "menu" and battleA.playerParty[2] then
battleA:resolveSwitch(battleA.playerParty[2])
switched = true
end
for _, g in ipairs({ gameA, gameB }) do
local top = g.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
end
g.stack:update(1 / 60)
end
if battleA.shrinkOut then retreatSeen = true end
end
T.check(switched, "the host got a menu phase to switch from")
T.eq(count(seenA, "Come back!"), 1,
"RetreatMon prints once, for the voluntary switch only")
T.check(retreatSeen, "AnimateRetreatingPlayerMon runs on the switcher")
T.eq(count(seenB, "drew"), 1, "the peer prints AIBattleWithdrawText once")
local withdrawA = firstIndex(seenA, "Come back!")
local sendA = firstIndex(seenA, "Go! ") or firstIndex(seenA, "Do it! ")
or firstIndex(seenA, "Get'm! ")
T.check(sendA ~= nil and withdrawA < sendA, "the recall line comes before the send-out")
local withdrawB = firstIndex(seenB, "drew")
local sendB = firstIndex(seenB, "sent\nout")
T.check(sendB ~= nil and withdrawB < sendB, "and so does the peer's withdraw line")
T.check(battleA.localHashes ~= nil and battleB.localHashes ~= nil,
"both sides still record per-turn hashes")
local mismatch
for turn, hash in pairs(battleA.localHashes) do
local other = battleB.localHashes[turn]
if other and other ~= hash then mismatch = mismatch or turn end
end
T.check(mismatch == nil, "the added lines leave the lockstep state hash alone")
T.finish("link switch prints the recall lines (#1562)")
@@ -5,7 +5,7 @@
-- GetMonFieldMoves (engine/menus/text_box.asm, called from
-- start_sub_menus.asm) walks wPartyMon1Moves in slot order -- so a mon
-- with STRENGTH in slot 3 and SURF in slot 4 shows STRENGTH then SURF on
-- top, with STATS/SWITCH closing the list. The port used to build the
-- top, with STATS/SWITCH/CANCEL closing the list. The port used to build the
-- submenu as STATS/SWITCH first and tack the field moves on the bottom.
-- ROM-free: drives the real PartyMenu over stub game state.
-- luajit tests/engine/party_fieldmove_order_bug792.lua
@@ -74,7 +74,8 @@ local pm = PartyMenu.new(game, {})
game.stack:push(pm)
openSubmenu(pm)
check(pm.submenu, "A on a party mon opens the submenu")
same(actions(pm.subItems), { "strength", "surf", "stats", "switch" },
same(actions(pm.subItems),
{ "strength", "surf", "stats", "switch", "cancel" },
"field moves sit above STATS/SWITCH in move-list order (#792)")
eq(pm.subItems[1].label, "STRENGTH", "slot 3's STRENGTH leads the submenu")
eq(pm.subItems[2].label, "SURF", "slot 4's SURF follows it")
@@ -84,8 +85,8 @@ local plain = newGame({ { id = "TACKLE", pp = 35 } })
local pm2 = PartyMenu.new(plain, {})
plain.stack:push(pm2)
openSubmenu(pm2)
same(actions(pm2.subItems), { "stats", "switch" },
"no field moves: the submenu is just STATS/SWITCH")
same(actions(pm2.subItems), { "stats", "switch", "cancel" },
"no field moves: the submenu is just PokemonMenuEntries")
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
-- still lists both HMs, and .outOfBattleMovePointers refuses on
@@ -95,7 +96,8 @@ local noBadges = newGame(
local pm3 = PartyMenu.new(noBadges, {})
noBadges.stack:push(pm3)
openSubmenu(pm3)
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
same(actions(pm3.subItems),
{ "strength", "surf", "stats", "switch", "cancel" },
"the HM moves are listed without the badges (#1022)")
T.finish("party_fieldmove_order_bug792")
@@ -0,0 +1,98 @@
-- Out of battle the per-mon submenu ends STATS/SWITCH/CANCEL: pokered
-- prints one string, PokemonMenuEntries (engine/menus/text_box.asm:504-507),
-- under whatever field moves the mon has, and start_sub_menus.asm:71-75
-- treats the last row as a real selection that leaves the party menu
-- (.exitMenu, start_sub_menus.asm:26-30) where B only returns to the list.
-- The port stopped at SWITCH (#1833). ROM-free: drives the real PartyMenu
-- over stub game state.
-- luajit tests/engine/party_submenu_cancel_bug1833.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq, same = T.check, T.eq, T.same
love = love or require("tests.love_stub")
local PartyMenu = require("src.ui.PartyMenu")
local function newGame(moves)
local game = {
data = { pokemon = { LAPRAS = { name = "LAPRAS" } } },
save = {
party = { { species = "LAPRAS", hp = 50, stats = { hp = 50 },
level = 30, moves = moves } },
inventory = {}, options = {}, flags = {},
},
overworld = { map = { def = { tileset = "OVERWORLD" },
id = "PALLET_TOWN" },
dark = false },
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game
end
local function press(pm, btn)
pm.game.input.queue = { [btn] = true }
pm:update(1 / 60)
pm.game.input.queue = {}
end
local function actions(items)
local out = {}
for i, item in ipairs(items or {}) do out[i] = item.action end
return out
end
local game = newGame({ { id = "TACKLE", pp = 35 } })
local cancelled = false
local pm = PartyMenu.new(game, { onCancel = function() cancelled = true end })
game.stack:push(pm)
press(pm, "a")
check(pm.submenu, "A on a party mon opens the submenu")
same(actions(pm.subItems), { "stats", "switch", "cancel" },
"the plain list is PokemonMenuEntries in full (#1833)")
eq(pm.subItems[3].label, "CANCEL", "CANCEL closes the list")
-- CANCEL sits at the bottom, so one wrap upward reaches it
press(pm, "up")
eq(pm.subIndex, 3, "up from STATS wraps onto CANCEL")
press(pm, "a")
eq(#game.stack.states, 0, "choosing CANCEL leaves the party menu (.exitMenu)")
check(cancelled, "and reports the cancel to the caller")
-- B out of the submenu is the other path: back to the party list, menu up
local game2 = newGame({ { id = "TACKLE", pp = 35 } })
local pm2 = PartyMenu.new(game2, {})
game2.stack:push(pm2)
press(pm2, "a")
press(pm2, "b")
check(not pm2.submenu, "B closes the submenu")
eq(#game2.stack.states, 1, "B keeps the party menu up (start_sub_menus.asm:68-69)")
-- field moves still lead, with the three fixed rows under them
local game3 = newGame({ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
local pm3 = PartyMenu.new(game3, {})
game3.stack:push(pm3)
press(pm3, "a")
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch", "cancel" },
"field moves sit above the whole of PokemonMenuEntries")
-- the in-battle list is its own three-row template and is unchanged
local game4 = newGame({ { id = "SURF", pp = 15 } })
local pm4 = PartyMenu.new(game4, { battle = {}, onSwitch = function() end })
game4.stack:push(pm4)
press(pm4, "a")
same(actions(pm4.subItems), { "battle_switch", "stats", "cancel" },
"battle keeps SwitchStatsCancelText's order (data/text_boxes.asm:33)")
T.finish()
+127
View File
@@ -0,0 +1,127 @@
-- The Pokédex list was a generic ListMenu with a title and a footer instead
-- of the cart's CONTENTS screen, and Left/Right dragged the cursor with the
-- page (#1829).
-- engine/menus/pokedex.asm:161-199 (furniture), :242-273 (rows), :314-342
-- (Left/Right move wListScrollOffset only), :208-222 (wDexMaxSeenMon).
-- luajit tests/engine/pokedex_contents_bug1829.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- stub Font: the screen draws through Font.draw/Font.drawCode only, and the
-- real Font needs loaded page images this suite has no reason to touch.
local calls = {}
package.loaded["src.render.Font"] = {
draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end,
width = function(text) return #text * 8 end,
split = function(text)
local out = {}
for i = 1, #text do out[i] = { from = i, to = i } end
return out
end,
}
local PokedexMenu = require("src.ui.PokedexMenu")
local Theme = require("src.ui.Theme")
local function hasText(text, x, y)
for _, c in ipairs(calls) do
if c.text == text and c.x == x and c.y == y then return true end
end
return false
end
local function hasCode(code, x, y)
for _, c in ipairs(calls) do
if c.code == code and c.x == x and c.y == y then return true end
end
return false
end
local DEX_SIZE = 151
local data = {
pokemon = {},
constants = { dexSize = DEX_SIZE, dexDigits = 3 },
}
for n = 1, DEX_SIZE do
local id = ("DEXMON_%03d"):format(n)
data.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n }
end
-- seen 1..20, owned 1..3, so the list stops at 20 and row 2 carries a ball
local function newGame()
local save = { pokedex = { seen = {}, owned = {} } }
for n = 1, 20 do save.pokedex.seen[("DEXMON_%03d"):format(n)] = true end
for n = 1, 3 do save.pokedex.owned[("DEXMON_%03d"):format(n)] = true end
return { data = data, save = save,
stack = { push = function() end, pop = function() end,
top = function() end } }
end
local dex = PokedexMenu.new(newGame(), {})
-- ------------------------------------------------ wDexMaxSeenMon caps it
eq(#dex.items, 20, "the list stops at the highest seen number, not at 151")
eq(dex.items[20].name, "MON020", "the last row is the highest seen species")
-- ------------------------------------------------ Left / Right scroll only
dex.index, dex.scroll = 3, 0
dex:pageScroll(1)
eq(dex.scroll, 7, "Right pages the scroll offset down seven rows")
eq(dex.index - dex.scroll, 3, "the cursor keeps the screen row it was on")
dex:pageScroll(1)
eq(dex.scroll, 13, "the second page clamps to wDexMaxSeenMon - 7")
eq(dex.index - dex.scroll, 3, "the clamped page still keeps the cursor row")
dex:pageScroll(-1)
eq(dex.scroll, 6, "Left pages back seven rows")
eq(dex.index - dex.scroll, 3, "Left keeps the cursor row too")
dex:pageScroll(-1)
eq(dex.scroll, 0, "Left stops at the top of the list")
eq(dex.index, 3, "the cursor is back where it started")
-- the witness for the bug: a whole-page jump used to move the cursor to the
-- bottom row, which is what a page jump on any other list does
check(dex.index - dex.scroll ~= 7,
"the cursor did not slide to the last screen row (the ListMenu bug)")
-- ------------------------------------------------------------- the screen
dex.index, dex.scroll = 1, 0
calls = {}
dex:draw()
check(hasText("CONTENTS", 8, 8), "CONTENTS at hlcoord 1,1, not a POKéDEX title")
check(hasText("SEEN", 128, 16), "SEEN label at hlcoord 16,2")
check(hasText("20", 136, 24), "the seen count right-aligned in its 3-tile field")
check(hasText("OWN", 128, 40), "OWN label at hlcoord 16,5")
check(hasText("3", 144, 48), "the owned count right-aligned under it")
check(not hasText("SEEN 20 OWN 3", 8, 136),
"the counts are not a bottom footer line any more")
check(hasText("DATA", 128, 80) and hasText("CRY", 128, 96)
and hasText("AREA", 128, 112) and hasText("QUIT", 128, 128),
"DATA/CRY/AREA/QUIT are permanent furniture at hlcoord 16,10")
check(hasText("001", 8, 16), "row 1's number sits one row above its name")
check(hasText("MON001", 32, 24), "row 1's name is at column 4")
check(hasCode(Theme.cursor, 0, 24), "the cursor is at column 0 on row 3")
check(hasText("007", 8, 112) and hasText("MON007", 32, 120),
"seven rows, two tiles apart, end on row 15")
check(not hasText("MON008", 32, 136), "there is no eighth row")
-- the hollow ▷ pokered leaves under the side menu (PlaceUnfilledArrowMenuCursor)
dex.hollowIndex = 1
calls = {}
dex:draw()
check(hasCode(Theme.cursorHollow, 0, 24),
"the chosen row keeps a hollow cursor while the side menu is open")
-- ------------------------------------------------------ unseen dashed rows
local partial = newGame()
partial.save.pokedex.seen.DEXMON_005 = nil
local gapped = PokedexMenu.new(partial, {})
eq(gapped.items[5].name, "----------",
"an unseen number inside the range prints the dashed line")
check(gapped.items[5].value == nil, "and cannot be chosen")
T.finish("pokedex contents bug 1829")
+13 -1
View File
@@ -23,7 +23,7 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local check, eq, same = T.check, T.eq, T.same
love = love or require("tests.love_stub")
-- Lazily-required inside the use branches, so seeding package.loaded before
@@ -34,8 +34,15 @@ package.loaded["src.core.Sound"] = {
}
-- Real TextBoxes want a Font atlas. This flow only cares that a message
-- opened, what it says, and what its onDone does.
local jingles = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
soundOpts = function(_, sound, opts)
jingles[#jingles + 1] = sound
opts = opts or {}
opts.auto = { sound = sound, wait = true }
return opts
end,
}
-- BagMenu and PartyMenu bind TextBox at require time, so they load against
-- the stub; Screens caches its factory per id and must be told to forget.
@@ -133,6 +140,7 @@ end
-- The bug: three candies in the bag, use one in the field.
do
local game, mon = freshGame(3)
jingles = {}
local list, why = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
@@ -156,6 +164,10 @@ do
check(box.text:find("level 6", 1, true) ~= nil,
"and it names the new level: " .. tostring(box.text))
end
-- RareCandyText: text_far, sound_get_item_1, text_promptbutton
-- (engine/menus/party_menu.asm:289-293)
same(jingles, { "Get_Item1" },
"the level-up line carries sound_get_item_1 and nothing else")
end
end
+100
View File
@@ -0,0 +1,100 @@
-- The slot machine's menus sit where the original draws them (#1811).
--
-- The bet menu is `hlcoord 14,11 / b=5 / c=4 / TextBoxBorder`
-- (engine/slots/slot_machine.asm:83-86), i.e. a 6x7 box whose bottom edge is
-- the last row of the screen, with the multipliers at hlcoord 16,12 and the
-- cursor at wTopMenuItemX 15 (asm:77-78, :87). "One more go?" is a
-- TWO_OPTION_MENU at hlcoord 14,12 (asm:136-138) over the YES_NO_MENU body
-- (width 4, height 3 -> a 6x5 box; data/yes_no_menu_strings.asm:10).
-- The "Want to play?" prompt is not this screen's at all: PromptUserToPlaySlots
-- asks it before LoadSlotMachineTiles (asm:9-23), so the screen opens on the
-- bet menu.
-- luajit tests/engine/slot_machine_boxes_bug1811.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- Font wants a real atlas and this suite is about which box lands on which
-- row, so it records the calls instead (tests/engine/bag_item_box_bug1521.lua's
-- shape)
local calls = {}
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
width = function(text) return #tostring(text) * 8 end,
}
package.loaded["src.core.Sound"] = { play = function() end }
package.loaded["src.ui.SlotMachine"] = nil
local SlotMachine = require("src.ui.SlotMachine")
local game = {
data = { field = { slotWheels = {} }, text = {} },
save = { coins = 100 },
}
local function record(fn)
calls = {}
fn()
return calls
end
local function box(list, tx, ty)
for _, c in ipairs(list) do
if c[1] == "box" and c[2] == tx and c[3] == ty then return c end
end
end
local function drawn(list, text)
for _, c in ipairs(list) do
if c[1] == "draw" and c[2] == text then return c end
end
end
local function cursor(list)
for _, c in ipairs(list) do
if c[1] == "code" and c[2] == 0xED then return c end
end
end
local slots = SlotMachine.new(game, false)
eq(slots.stage, "bet", "the screen opens on the bet menu, not a prompt")
local bet = record(function() slots:drawBottom() end)
local betBox = box(bet, 14, 11)
check(betBox ~= nil, "the bet menu box starts at (14,11)")
eq(betBox and betBox[4], 6, "it is 6 tiles wide (c = 4)")
eq(betBox and betBox[5], 7, "and 7 tall (b = 5), down to the last row")
eq(betBox and (betBox[3] + betBox[5]), 18, "so its bottom edge is the screen bottom")
local x3 = drawn(bet, "×3")
eq(x3 and x3[3], 16 * 8, "the multipliers print at column 16")
eq(x3 and x3[4], 12 * 8, "on row 12")
local betCursor = cursor(bet)
eq(betCursor and betCursor[3], 15 * 8, "the cursor sits at column 15")
eq(betCursor and betCursor[4], 12 * 8, "on the ×3 row by default")
slots.stage = "onemore"
slots.yesno = 1
local more = record(function() slots:drawBottom() end)
local moreBox = box(more, 14, 12)
check(moreBox ~= nil, "the One more go? menu box starts at (14,12)")
eq(moreBox and moreBox[4], 6, "it is 6 tiles wide")
eq(moreBox and moreBox[5], 5, "and 5 tall (YES_NO_MENU 4x3)")
check(box(more, 13, 12) == nil, "and not one column left of that")
local yes, no = drawn(more, "YES"), drawn(more, "NO")
eq(yes and yes[3], 16 * 8, "YES prints at column 16")
eq(yes and yes[4], 13 * 8, "on row 13")
eq(no and no[4], 14 * 8, "NO one row below it")
local moreCursor = cursor(more)
eq(moreCursor and moreCursor[3], 15 * 8, "its cursor sits at column 15")
eq(moreCursor and moreCursor[4], 13 * 8, "starting on YES")
slots.yesno = 2
local onNo = record(function() slots:drawBottom() end)
eq(cursor(onNo) and cursor(onNo)[4], 14 * 8, "and moves to the NO row")
T.finish()
@@ -0,0 +1,52 @@
-- The START cursor is unsaved WRAM (ram/wram.asm:238-242) and lies outside
-- sGameData (ram/sram.asm:17-21), so it must not ride the save file (#1821).
-- luajit tests/run_engine.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local SaveData = require("src.core.SaveData")
local StartMenu = require("src.ui.StartMenu")
local function stubGame(save)
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return {
data = Data, save = save, stack = stack,
input = { wasPressed = function() return false end,
isDown = function() return false end },
}
end
do
local save = SaveData.newGame()
local game = stubGame(save)
local menu = StartMenu.new(game)
menu.index = math.min(3, #menu.items)
menu:update(1 / 60)
T.eq(game.startMenuIndex, menu.index, "the cursor slot lands on the game")
T.check(save.startMenuIndex == nil, "and never on the save table")
local reopened = StartMenu.new(stubGame(save))
T.eq(reopened.index, 1, "a fresh session opens on the top row")
local same = StartMenu.new(game)
T.eq(same.index, menu.index, "the same session reopens where it was")
end
do
local save = SaveData.newGame()
save.startMenuIndex = 5
local encoded = SaveData.encodeForTest and SaveData.encodeForTest(save) or nil
T.check(encoded == nil or encoded:find("startMenuIndex") == nil,
"no serializer writes the cursor back out")
local game = stubGame(save)
local menu = StartMenu.new(game)
T.eq(menu.index, 1,
"a legacy save file's stored slot no longer reopens the menu on it")
end
T.finish("START menu cursor is session state (#1821)")
+113
View File
@@ -0,0 +1,113 @@
-- The forget-a-move text is not one uninterrupted string: pokered's
-- OneTwoAndText holds the box with text_pause, plays SFX_SWAP, prints
-- " Poof!" and holds it again before the paragraph break
-- (engine/pokemon/learn_move.asm:208-222, home/text.asm:492-504). The port
-- typed all of it straight through in silence (#1818). TextBox.PAUSE now
-- carries those waits inside the string, with opts.pauseSounds naming the
-- sfx each one fires. ROM-free.
-- luajit tests/engine/textbox_pause_bug1818.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local TextBox = require("src.render.TextBox")
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
local Sound = require("src.core.Sound")
local played = {}
local realPlay = Sound.play
Sound.play = function(data, name)
played[#played + 1] = name
return nil
end
local function newGame()
local game = {
save = { player = {}, options = { textSpeed = "FAST" } },
data = { text = {}, moves = { MEGA_PUNCH = { name = "MEGA PUNCH" } },
pokemon = { NIDORINO = { name = "NIDORINO" } } },
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function(self, btn) return self.queue[btn] or false end,
}
return game
end
local game = newGame()
local box = TextBox.new(game, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
nil, { pauseSounds = { "Swap" } })
eq(box.pages[1][1], "1, 2 and... Poof!",
"the marker is stripped out of the line the font ever sees")
check(box.pauseAt and box.pauseAt[1] and box.pauseAt[1][1],
"the pause is re-anchored onto page 1, line 1")
eq(box.pauseAt[1][1][11], 1, "it sits on the 11th glyph, the last of \"1, 2 and...\"")
-- type up to the marker
local frames = 0
while not box.pauseFrames and frames < 600 do
box:update(1 / 60)
frames = frames + 1
end
check(box.pauseFrames, "typing stops at the marker")
eq(box.charIndex, 11, "it stops with \"1, 2 and...\" printed and nothing after it")
eq(#played, 0, "SFX_SWAP has not sounded yet -- the wait comes first")
local frozen = box.charIndex
for _ = 1, 31 do
box:update(1 / 60)
if #played > 0 then break end
eq(box.charIndex, frozen, "nothing types while the pause runs")
end
eq(played[1], "Swap", "the wait ends with SFX_SWAP (learn_move.asm:210-213)")
for _ = 1, 600 do
if box.done then break end
box:update(1 / 60)
end
check(box.done, "\" Poof!\" types out after the sound")
eq(box.charIndex, 17, "the whole line printed, marker included in nothing")
-- a held A/B skips the wait: TextCommand_PAUSE reads hJoyHeld
local held = newGame()
held.input.queue = { a = true }
local box2 = TextBox.new(held, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
nil, { pauseSounds = { "Swap" } })
local before = #played
for _ = 1, 40 do
if box2.done then break end
box2:update(1 / 60)
end
check(box2.done, "with A held the box runs straight through the pause")
check(#played > before, "the sound still plays on the skipped wait")
-- and the real caller wires both waits with the swap on the first
local mlm = MoveLearnMenu.new(game, { species = "NIDORINO", level = 20,
moves = {} }, "MEGA_PUNCH")
mlm.forgot = "HORN ATTACK"
game.stack:push(mlm)
mlm:finish(true)
local learned = game.stack:top()
check(learned.pauseAt, "the learned-move box carries text_pause waits")
eq(learned.pauseSounds[1], "Swap", "the first wait carries SFX_SWAP")
local count = 0
for _, lines in pairs(learned.pauseAt) do
for _, chars in pairs(lines) do
for _ in pairs(chars) do count = count + 1 end
end
end
eq(count, 2, "both of learn_move.asm's text_pause commands survive")
Sound.play = realPlay
T.finish()
+7 -5
View File
@@ -133,16 +133,18 @@ check(ow.player.spinDrop ~= true and ow.player.spinning == false,
check(not playerHidden(ow), "player drawable again after the dig landing")
-- ------------------------------------------------------------------ fly
-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim,
-- then the fade, then the bird swoops in (flyArrive). Same invariant.
-- the StopMusic fade hold (28) then flap (24) + path1 (36) + hold (40) +
-- path2 (33) = 161 frames of flyAnim, then the fade, then the bird swoops
-- in (flyArrive). Same invariant.
Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 }
ow = newOW()
ow:flyTo("FIX_ROUTE")
st = drive(ow, 260)
check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins")
-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the
-- warp fires on frame 133's update, so the fade is on top from loop frame 134
eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path")
-- fade hold (4*7) + flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3)
-- = 161 frames; the warp fires on frame 161's update, so the fade is on top
-- from loop frame 162 (player_animations.asm:123 StopMusic) #1840
eq(st.warpFrame, 162, "fly fade begins right after the bird''s exit path")
check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)")
eq(st.gapFrames, 0,
"no fly fade frame leaves the player standing bare (#916)")
+5 -1
View File
@@ -922,8 +922,12 @@ check(poolWorld:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "whirlpool cleared")
runFrames(poolWorld, 1)
eq(poolWorld.log[1], T.USE_WHIRLPOOL, "UseWhirlpoolText")
advanceText(poolWorld)
-- engine/events/overworld.asm:1142-1164
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x07,
"the whirlpool block is still on screen while the sfx plays (#1862)")
runFrames(poolWorld, 4)
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x36,
"DisappearWhirlpool swaps block $07 for $36")
"DisappearWhirlpool swaps block $07 for $36 once the sfx ends")
local wrongBlock = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER)
check(not wrongBlock:useFieldMove("WHIRLPOOL", SPINNER[1]).ok,
"a whirlpool collision over the wrong block is refused")
+6 -5
View File
@@ -558,8 +558,9 @@ local pm = PartyMenu.new(pgame)
pm.game = pgame
pgame.stack:push(pm)
press(pm, "a")
check(pm.submenu and #pm.subItems == 2
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH",
check(pm.submenu and #pm.subItems == 3
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH"
and pm.subItems[3].label == "CANCEL",
"vanilla party submenu unchanged with no hooks")
pm.submenu = nil
@@ -570,9 +571,9 @@ hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx)
return nextFn(game, items, mon, ctx)
end, 0, "fixture")
press(pm, "a")
check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS",
check(#pm.subItems == 4 and pm.subItems[4].label == "QUESTS",
"hook appends a party submenu entry")
pm.subIndex = 3
pm.subIndex = 4
press(pm, "a")
check(ranWith == pgame.save.party[1],
"an injected entry's onSelect runs with the focused mon")
@@ -581,7 +582,7 @@ hooks:removeOwner("fixture")
hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad")
press(pm, "a")
check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
check(#pm.subItems == 3, "a non-table submenu result keeps the vanilla list")
hooks:removeOwner("bad")
pm.submenu = nil
+82
View File
@@ -0,0 +1,82 @@
-- Parity test: an AI trainer switch clears the exp participant flags.
--
-- pokered's AI switch path calls EnemySendOut, which zeroes
-- wPartyGainExpFlags and re-flags only wPlayerMonNumber before falling
-- into EnemySendOutFirstMon (engine/battle/core.asm:1276-1292). The port
-- kept the old participants, so a mon that had fought the withdrawn foe
-- still halved the exp for the newly sent-out one (#1826).
--
-- Self-contained; run via `luajit tests/parity_ai_switch_exp.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity ai switch exp")
local check, eq = S.check, S.eq
local function freshGame()
return {
data = Data,
save = {
party = { Pokemon.new(Data, "BULBASAUR", 50), Pokemon.new(Data, "CHARMANDER", 50) },
player = { name = "RED", id = 1234 },
inventory = {},
options = { battleStyle = "set" },
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
-- Both party mons fought the first foe; the AI then withdraws it.
local function battleAfterAiSwitch()
local Game = freshGame()
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
check(#b.enemyParty >= 2, "the trainer has a reserve to switch to")
b.participants = { [Game.save.party[1]] = true, [Game.save.party[2]] = true }
b:executeAction(b.enemy, b.player, { special = "aiSwitch", index = 2 })
return Game, b
end
do
local Game, b = battleAfterAiSwitch()
eq(b.participants[Game.save.party[1]], true,
"the mon on the field is the one participant after the switch")
eq(b.participants[Game.save.party[2]], nil,
"the benched mon lost its gain-exp flag (core.asm:1276-1289)")
end
-- The divisor follows: the on-field mon takes the whole share, and the
-- benched one earns nothing off the new foe's KO.
do
local Game, b = battleAfterAiSwitch()
b.enemy.mon.hp = 0
local before1 = Game.save.party[1].exp
local before2 = Game.save.party[2].exp
b:awardExp()
local gainedSwitched = Game.save.party[1].exp - before1
check(gainedSwitched > 0, "the mon on the field is paid for the KO")
eq(Game.save.party[2].exp - before2, 0, "the benched mon is paid nothing")
-- Reference: the same KO with a single flagged participant from the start.
local Game2 = freshGame()
local b2 = BattleState.newTrainer(Game2, "OPP_AGATHA", 1)
b2:executeAction(b2.enemy, b2.player, { special = "aiSwitch", index = 2 })
b2.participants = { [Game2.save.party[1]] = true }
b2.enemy.mon.hp = 0
local ref = Game2.save.party[1].exp
b2:awardExp()
eq(gainedSwitched, Game2.save.party[1].exp - ref,
"the share is undivided, not halved by the stale participant")
end
S.finish()
+47
View File
@@ -0,0 +1,47 @@
-- Parity test: the ball-shake pause keeps the resting ball on screen.
--
-- PlaySubanimation draws the frame block and only then runs
-- DoSpecialEffectByAnimationId (engine/battle/animations.asm:623-627), so
-- DoBallShakeSpecialEffects' SFX_TINK + DelayFrames 40 (animations.asm:
-- 739-747) lands with the ball already drawn. The port paused first and
-- showed 40 blank frames per wobble (#1853).
--
-- Self-contained; run via `luajit tests/parity_ball_shake_anim.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity ball shake anim")
local check, eq = S.check, S.eq
local AnimPlayer = require("src.battle.AnimPlayer")
local player = AnimPlayer.new(require("data.generated.battle_anims"))
player:start("SHAKE_ANIM", true, { shakes = 3 })
local tinks = {}
for _, e in ipairs(player.events) do
if e.effect == "SFX_TINK" then tinks[#tinks + 1] = e.frame end
end
eq(#tinks, 3, "one SFX_TINK per requested shake")
-- the step a given frame falls in
local function stepAt(frame)
local at = 0
for _, st in ipairs(player.steps) do
if frame >= at and frame < at + st.dur then return st end
at = at + st.dur
end
end
for i, frame in ipairs(tinks) do
local st = stepAt(frame)
check(st ~= nil, "shake " .. i .. " has a step at its tink frame")
eq(st.dur, 40, "shake " .. i .. " pauses 40 frames on the tink")
check(#st.sprites > 0,
"shake " .. i .. " shows the resting ball through that pause")
end
check(tinks[1] > 0,
"the first tink is not the very first thing the animation does")
S.finish()
+4 -1
View File
@@ -259,7 +259,10 @@ do
withdraw(game)
check(captured ~= nil, "withdraw opened its box list")
if captured then
eq(#captured.items, 1, "the list has the one box mon in it")
-- engine/menus/players_pc.asm: the list ends in CANCEL (#1847)
eq(#captured.items, 2, "the list has the one box mon in it, plus CANCEL")
check(captured.items[2] and captured.items[2].cancel,
"the last row is the CANCEL terminator")
captured.opts.onChoose(captured.items[1],
{ index = 1, close = function() end })
eq(#save.boxes[1], 0, "the mon left the box")
+112
View File
@@ -0,0 +1,112 @@
-- Parity test: the Pokemon Tower 6F Marowak ghost scene.
--
-- InitWildBattle runs the battle transition before the disguise
-- (engine/battle/core.asm:6695-6702), _EnemyAppearedText carries no
-- article (data/text/text_2.asm:1251-1255), and the CUBONE's-mother text
-- ends in `done` so PlayCry lands on it (scripts/PokemonTower6F.asm:
-- 137-148, text/PokemonTower6F.asm:1-5). See #1849.
--
-- Self-contained; run via `luajit tests/parity_ghost_marowak.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local S = require("tests.harness").suite("parity marowak scene")
local check, eq = S.check, S.eq
do
local RealBattleState = require("src.battle.BattleState")
local game = {
data = Data,
save = {
party = { Pokemon.new(Data, "BULBASAUR", 30) },
player = { name = "RED" },
inventory = {},
options = {},
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
local b = RealBattleState.newWild(game, "MAROWAK", 30)
b:makeGhost()
eq(b.enemy.name, "GHOST", "the disguise renames the foe")
eq(b.introText, "GHOST\nappeared!",
"_EnemyAppearedText has no article in front of the nick")
end
-- The script side: entry wipe and cry ordering.
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
local pushed = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, cb)
local box = { text = text, cb = cb }
table.insert(pushed, box)
return box
end,
substitute = function(_, text) return text end,
}
local battles = {}
local stubBattle = {}
stubBattle.__index = stubBattle
function stubBattle:makeGhost() self.ghost = true end
function stubBattle:makeUnveiledGhost() self.scopeReveal = true end
package.loaded["src.battle.BattleState"] = {
newWild = function(_, species, level)
local b = setmetatable({ species = species, level = level }, stubBattle)
table.insert(battles, b)
return b
end,
}
local scripts = dofile("data/scripts/story3.lua")
local onStep = scripts.POKEMON_TOWER_6F.onStep
check(onStep ~= nil, "the tower 6F step trigger is registered")
local game = {
data = { text = {} },
save = { flags = {}, inventory = {} },
stack = { push = function() end },
}
local rows
local pushedBattles, stackBattles = 0, 0
local ow = {
pushBattle = function() pushedBattles = pushedBattles + 1 end,
afterBattle = function() end,
scriptMove = function() end,
runner = { run = function(_, r) rows = r end },
}
game.stack.push = function(_, thing)
if getmetatable(thing) == stubBattle then stackBattles = stackBattles + 1 end
end
eq(onStep(game, ow, 10, 16), true, "stepping on the trigger cell fires it")
eq(#pushed, 1, "the Be gone... line opens the scene")
pushed[1].cb()
eq(#battles, 1, "the ghost battle is built")
eq(pushedBattles, 1, "the battle goes through the entry wipe (pushBattle)")
eq(stackBattles, 0, "and is never pushed straight onto the stack")
battles[1].onFinish("win")
check(rows ~= nil, "victory queues the departed script")
eq(rows[1][1], "play_cry", "PlayCry is armed before the CUBONE's-mother box")
eq(rows[1][2], "MAROWAK", "and it is the RESTLESS SOUL's cry")
eq(rows[1][3], nil, "with no button wait -- that text ends in `done`")
eq(rows[2][1], "show_text", "the CUBONE's-mother line follows")
check(rows[2][2]:find("CUBONE") ~= nil, "and it is that line")
eq(rows[3][1], "wait", "then the DelayFrames 30 gap")
eq(rows[4][1], "show_text", "then the soul-was-calmed line")
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
S.finish()
+76
View File
@@ -0,0 +1,76 @@
-- Parity test: Giovanni's "WHAT! This cannot be!" is an end-battle line.
--
-- RocketHideoutB4FGiovanniText arms it with SaveEndBattleTextPointers
-- before EngageMapTrainer (scripts/RocketHideoutB4F.asm:99-120), so
-- PrintEndBattleText prints it on the battle screen between
-- TrainerDefeatedText and MoneyForWinningText. The port pushed it as an
-- overworld TextBox from onFinish, i.e. after the map was back (#1817).
--
-- Self-contained; run via `luajit tests/parity_giovanni_end_battle.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity giovanni end battle")
local check, eq = S.check, S.eq
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
local pushed = {}
package.loaded["src.render.TextBox"] = {
new = function(_, text, cb)
local box = { text = text, cb = cb }
table.insert(pushed, box)
return box
end,
substitute = function(_, text) return text end,
}
local battles = {}
package.loaded["src.battle.BattleState"] = {
newTrainer = function(_, class, index)
local b = { trainerClass = class, partyIndex = index }
table.insert(battles, b)
return b
end,
}
local scripts = dofile("data/scripts/story3.lua")
local handler = scripts.ROCKET_HIDEOUT_B4F.talk.TEXT_ROCKETHIDEOUTB4F_GIOVANNI
check(handler ~= nil, "the Giovanni talk handler is registered")
local game = {
data = { text = {} },
save = { defeatedTrainers = {}, flags = {}, player = { name = "RED" } },
stack = { push = function() end },
}
local ow = {
trainerDefeated = function() return false end,
pushBattle = function() end,
afterBattle = function() end,
}
handler(game, ow, { id = 7 }, function() end)
eq(#pushed, 1, "the impressed line opens the scene")
pushed[1].cb()
eq(#battles, 1, "talking starts the Giovanni battle")
local battle = battles[1]
check(battle.endBattleText ~= nil,
"the loss line is handed to the battle (SaveEndBattleTextPointers)")
check(battle.endBattleText:find("cannot be") ~= nil,
"and it is the WHAT!/This cannot be! text")
pushed = {}
battle.onFinish("win")
eq(#pushed, 1, "victory pushes exactly one overworld box")
check(pushed[1].text:find("cannot be") == nil,
"the cannot-be line no longer reprints on the map")
check(pushed[1].text:find("meet") ~= nil,
"BeatGiovanniScript's hope-we-meet-again text follows instead")
eq(game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI, true,
"the beat-Giovanni event flag is set")
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
S.finish()
+38
View File
@@ -0,0 +1,38 @@
-- Parity: HiddenCoins pays BCD constants, and the COIN+40 tile pays 20 (#1810).
--
-- engine/events/hidden_items.asm:79-96 branches the hidden-event argument
-- into .bcd10 ($10), .bcd20 ($20) or .bcd100 ($0100); `cp 40 / jr z, .bcd20`
-- is pokered's own typo, so .bcd40 at :90 ("due to a typo, this is never
-- used") never runs and the Game Corner's COIN+40 tile (data/events/
-- hidden_events.asm:294, GAME_CORNER 11,7) credits 20 coins on hardware.
--
-- Self-contained; run via `luajit tests/parity_hidden_coins_bcd_bug1810.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity hidden coins bcd")
local check, eq = S.check, S.eq
local OverworldState = require("src.world.OverworldController")
local pay = OverworldState.hiddenCoinPayout
eq(pay(10), 10, "COIN + 10 pays BCD $10")
eq(pay(20), 20, "COIN + 20 pays BCD $20")
eq(pay(40), 20, "COIN + 40 falls into .bcd20 and pays 20")
eq(pay(100), 100, "COIN + 100 pays BCD $0100")
eq(pay(0), 100, "anything the branch chain misses pays 100")
-- the generated data still carries the raw argument: the runtime mapping,
-- not the extractor, is what turns the 40 tile into 20
local field = dofile("data/generated/field.lua")
local corner = field.hiddenCoins and field.hiddenCoins.GAME_CORNER
check(corner ~= nil, "GAME_CORNER has hidden coin tiles")
local forty
for _, h in ipairs(corner or {}) do
if h.x == 11 and h.y == 7 then forty = h end
end
check(forty ~= nil, "the (11,7) hidden coin tile is present")
eq(forty and forty.coins, 40, "it is extracted as the COIN + 40 argument")
eq(pay(forty and forty.coins), 20, "and the award path credits 20 for it")
S.finish()
+5 -2
View File
@@ -62,12 +62,15 @@ local function gameWith(inventory, flags)
end
local function owWith()
local moved, after = {}, {}
local moved, after, wiped = {}, {}, {}
return {
player = { facing = "up" },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
afterBattle = function(_, result) after[#after + 1] = result end,
}, moved, after
-- InitWildBattle runs the transition before the disguise
-- (engine/battle/core.asm:6695-6702), so the script uses pushBattle
pushBattle = function(_, battle) wiped[#wiped + 1] = battle end,
}, moved, after, wiped
end
-- Walk the trigger: run onStep, then the Be-gone text's done() to push
@@ -0,0 +1,79 @@
-- Parity: the Tower 2F rival scene arms its in-battle line, prints
-- HowsYourDex on the win, and restores the map music (#1842).
--
-- PokemonTower2FRivalText arms SaveEndBattleTextPointers with .DefeatedText
-- before the fight (scripts/PokemonTower2F.asm:145-150), the win re-runs
-- DisplayTextID so the beaten branch prints .HowsYourDexText (asm:72-75,
-- :137-140), and PokemonTower2FRivalExitsScript hides him and calls
-- PlayDefaultMusic (asm:115-124).
--
-- Self-contained; run via `luajit tests/parity_pokemon_tower_rival_bug1842.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity pokemon tower rival")
local check, eq = S.check, S.eq
local story = dofile("data/scripts/story.lua")
local tower = story.POKEMON_TOWER_2F
check(tower ~= nil and tower.rivalScript ~= nil, "POKEMON_TOWER_2F rivalScript exists")
local rows = tower.rivalScript(15)
local function find(verb, arg)
for i, row in ipairs(rows) do
if row[1] == verb and (arg == nil or row[2] == arg) then return i, row end
end
end
local iBattle = find("rival_battle")
local iArm = find("save_end_battle_text", "_PokemonTower2FRivalDefeatedText")
check(iArm ~= nil, "_PokemonTower2FRivalDefeatedText is armed for the battle")
check(iBattle and iArm == iBattle - 1,
"SaveEndBattleTextPointers runs just before the battle")
check(find("show_text", "_PokemonTower2FRivalDefeatedText") == nil,
"and it is not also printed on the map afterwards")
local iSet = find("set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL")
local iDex = find("show_text", "_PokemonTower2FRivalHowsYourDexText")
check(iSet and iDex and iDex > iSet,
"the win prints HowsYourDex after setting the event")
local iHide = find("hide_object")
local iDefault = find("play_default_music")
check(iDefault ~= nil, "the exit calls play_default_music")
check(iHide and iDefault and iDefault > iHide,
"PlayDefaultMusic follows HideObject")
local iWalk = find("walk_npc")
check(iWalk and iHide and iHide > iWalk, "the walk-out runs before the hide")
-- the beaten branch: every jump target resolves, and talking again after the
-- win reaches HowsYourDex instead of falling off the end
local ScriptRunner = require("src.script.ScriptRunner")
local problems = ScriptRunner.validate(rows)
eq(#problems, 0, "rival script validates: " .. table.concat(problems, "; "))
local iJumpIfTrue, jumpRow = find("jump_if_true")
check(iJumpIfTrue ~= nil, "the beaten check jumps")
local labelRow
for i, row in ipairs(rows) do
if row[1] == "label" and row[2] == (jumpRow and jumpRow[2]) then labelRow = i end
end
check(labelRow ~= nil, "it jumps to a real label, not a stale row number")
local tail
for i = labelRow or 0, #rows do
if rows[i] and rows[i][1] == "show_text" then tail = rows[i][2] break end
end
eq(tail, "_PokemonTower2FRivalHowsYourDexText",
"the beaten branch prints HowsYourDex")
-- both exit paths are still distinct (ON_LEFT vs not, asm:76-82)
local left = tower.rivalScript(15)
local right = tower.rivalScript(14)
local _, lWalk = find("walk_npc")
local rWalk
for _, row in ipairs(right) do if row[1] == "walk_npc" then rWalk = row end end
check(lWalk and rWalk and lWalk[3][1] ~= rWalk[3][1],
"the two exit movements still differ")
eq(#left, #right, "both variants are the same script shape")
S.finish()
+80
View File
@@ -0,0 +1,80 @@
-- Parity: Rocket3 in the Hideout B4F engages on sight, and the LIFT KEY
-- still drops after a sight-triggered battle (#1814).
--
-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...`
-- (scripts/RocketHideoutB4F.asm:95-96) and the `trainer` macro stores arg 2
-- as `db \2 << 4` (macros/scripts/maps.asm:107-127), so CheckSpriteCanSeePlayer
-- (engine/overworld/trainer_sight.asm:257-262) engages him from one tile away
-- through SCRIPT_ROCKETHIDEOUTB4F_DEFAULT (asm:42, home/trainers.asm:129).
-- The port used to skip any trainer with a hand-ported talk script; only an
-- explicit `noSight` opt-out may do that now. His LIFT KEY lives in
-- Rocket3AfterBattleText (asm:189-199), i.e. the next talk, so a sight battle
-- does not lose it.
--
-- Self-contained; run via `luajit tests/parity_rocket3_sight_bug1814.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity rocket3 sight")
local check, eq = S.check, S.eq
local MAP = "ROCKET_HIDEOUT_B4F"
local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3"
-- the sight range the engine reads
local headers = dofile("data/generated/trainer_headers.lua")
local h = headers.RocketHideoutB4F and headers.RocketHideoutB4F[4]
check(h ~= nil, "RocketHideoutB4F header 4 is extracted")
eq(h and h.range, 1, "Rocket3's view range is one tile")
local maps = dofile("data/generated/maps.lua")
local rocket3
for _, o in ipairs(maps[MAP].objects or {}) do
if o.text == TEXT then rocket3 = o end
end
check(rocket3 ~= nil, "Rocket3 is object " .. TEXT)
eq(rocket3 and rocket3.index, 4, "he is object index 4, the header's row")
check(rocket3 and rocket3.trainerClass ~= nil, "and he is a trainer sprite")
eq(rocket3 and rocket3.movement, "STAY", "he holds his tile")
eq(rocket3 and rocket3.range, "DOWN", "facing down, so the sight line is below him")
eq(rocket3 and rocket3.x, 11, "standing at x 11")
eq(rocket3 and rocket3.y, 2, "standing at y 2")
-- the gate the fix uses: having a talk script is NOT an opt-out; only an
-- explicit noSight set is
local story3 = dofile("data/scripts/story3.lua")
local hideout = story3[MAP]
check(hideout ~= nil and hideout.talk[TEXT] ~= nil,
"Rocket3 still has his hand-ported talk script")
check(hideout.noSight == nil or hideout.noSight[TEXT] ~= true,
"and he is not opted out of CheckFightingMapTrainers")
-- the LIFT KEY half: after the battle -- however it started -- the next talk
-- reveals the ball
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
soundOpts = function() return nil end,
substitute = function(_, t) return t end,
}
local pushed
local game = {
data = { text = { _RocketHideoutB4FRocket3AfterBattleText = "Oh no!" } },
save = { flags = {} },
stack = { push = function(_, box) pushed = box end },
}
local ow = {
map = { id = MAP, def = { objects = {} } },
npcs = {}, entities = {},
trainerDefeated = function() return true end,
}
local finished = false
hideout.talk[TEXT](game, ow, { def = rocket3 }, function() finished = true end)
check(pushed ~= nil, "talking after the win prints the after-battle text")
if pushed and pushed.done then pushed.done() end
check(game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
"EVENT_ROCKET_DROPPED_LIFT_KEY is set")
local toggles = game.save.objectToggles and game.save.objectToggles[MAP]
check(toggles and toggles.ROCKETHIDEOUTB4F_LIFT_KEY == true,
"the LIFT KEY ball is shown")
check(finished, "and the talk hands control back")
S.finish()
+9 -3
View File
@@ -64,16 +64,22 @@ do
"player at x=15 uses DownThenRightMovement")
end
-- (2) win path: flag, defeat text, walk, hide -- in order
-- (2) win path: flag, post-win text, walk, hide -- in order.
-- DefeatedText rides SaveEndBattleTextPointers, so it prints in battle and the
-- map-side box after the win is HowsYourDex (#1842).
-- scripts/PokemonTower2F.asm:71-75, :137-140
do
local rows = tower.rivalScript(14)
local preds = {
{ "save_end_battle_text DefeatedText",
function(r) return r[1] == "save_end_battle_text"
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
{ "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL",
function(r) return r[1] == "set_flag"
and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end },
{ "show_text DefeatedText",
{ "show_text HowsYourDexText",
function(r) return r[1] == "show_text"
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
and r[2] == "_PokemonTower2FRivalHowsYourDexText" end },
{ "walk_npc exit",
function(r) return r[1] == "walk_npc" end },
{ "hide_object POKEMONTOWER2F_RIVAL",
+35
View File
@@ -0,0 +1,35 @@
-- Parity test: SE_WAVY_SCREEN is timed in displayed frames.
--
-- AnimationWavyScreen's `ld c, $ff` counts outer passes, and the inner
-- loop only exits when rLY reaches 143, which happens twice per displayed
-- frame (engine/battle/animations.asm:1884-1903). The port ran it for
-- 255 frames, roughly twice as long as the original (#1848).
--
-- Self-contained; run via `luajit tests/parity_wavy_screen.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity wavy screen")
local check, eq = S.check, S.eq
local AnimPlayer = require("src.battle.AnimPlayer")
local player = AnimPlayer.new(require("data.generated.battle_anims"))
player:start("NIGHT_SHADE", true)
local wavy
for _, e in ipairs(player.events) do
if e.effect == "SE_WAVY_SCREEN" then wavy = e end
end
check(wavy ~= nil, "NIGHT_SHADE plays SE_WAVY_SCREEN")
eq(wavy.dur, 128, "255 outer passes at two a frame is 128 displayed frames")
local BattleState = require("src.battle.BattleState")
local side = setmetatable({}, { __index = BattleState })
BattleState.applyAnimEffect(side, { effect = "SE_WAVY_SCREEN" })
check(side.fx and side.fx.wavy ~= nil, "the effect arms the wave")
eq(side.fx.wavy.left, wavy.dur,
"the fx layer and the animation player agree on the length")
eq(side.fx.wavy.phase, 0, "the offset pointer starts at the table head")
S.finish()
+2 -1
View File
@@ -2927,7 +2927,8 @@ do
qpressed = { a = true }
qmenu:update(1 / 60)
qpressed = {}
eq(qsave.startMenuIndex, quitIdx, "QUIT selection persists the cursor slot")
eq(qg.startMenuIndex, quitIdx, "QUIT selection persists the cursor slot")
check(qsave.startMenuIndex == nil, "and keeps it off the saved data")
local qbox = qstack:top()
check(qbox ~= qmenu and qbox ~= nil and qbox.pages ~= nil,
"QUIT pushes a confirmation textbox")