CLOSES #2649, CLOSES #2650, CLOSES #2652, CLOSES #2653, CLOSES #2654, CLOSES #2657, CLOSES #2658, CLOSES #2659, CLOSES #2660, CLOSES #2662, CLOSES #2664, CLOSES #2665, CLOSES #2666, CLOSES #2669, CLOSES #2671, CLOSES #2672, CLOSES #2674, CLOSES #2676, CLOSES #2682, CLOSES #2683, CLOSES #2684, CLOSES #2685, CLOSES #2686, CLOSES #2687, CLOSES #2688, CLOSES #2689

CLOSES #2649, CLOSES #2650, CLOSES #2652, CLOSES #2653, CLOSES #2654, CLOSES #2657, CLOSES #2658, CLOSES #2659, CLOSES #2660, CLOSES #2662, CLOSES #2664, CLOSES #2665, CLOSES #2666, CLOSES #2669, CLOSES #2671, CLOSES #2672, CLOSES #2674, CLOSES #2676, CLOSES #2682, CLOSES #2683, CLOSES #2684, CLOSES #2685, CLOSES #2686, CLOSES #2687, CLOSES #2688, CLOSES #2689
This commit is contained in:
bryanthaboi
2026-10-05 11:06:01 -04:00
parent 160895c6bb
commit 72f2294d8d
110 changed files with 8042 additions and 694 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 385 KiB

+5 -25
View File
@@ -1,31 +1,11 @@
-- Route 23's badge-gate guards (pokered scripts/Route23.asm). Each
-- guard/swimmer stands beside the staircase to Victory Road and, when
-- talked to, checks whether the player holds the badge matching that
-- step; the automatic walk-into-the-guard blocking script
-- (Route23DefaultScript / Route23CheckForBadgeScript) is not ported
-- here -- only the talk-triggered text_asm bodies, which share that
-- same subroutine (Route23CheckForBadgeScript) and text
-- (Route23YouDontHaveTheBadgeYetText / Route23OhThatIsTheBadgeText,
-- pokered/text/Route23.asm _Route23YouDontHaveTheBadgeYetText /
-- _Route23OhThatIsTheBadgeText). Once shown the badge, pokered sets
-- EVENT_PASSED_<BADGE>_CHECK so the walk-through gate (when ported)
-- won't re-ask; we mirror that with set_flag for fidelity even though
-- no onStep gate currently reads it.
-- scripts/Route23.asm:153
local M = {}
-- rows: check_item(badge) -> have it? show "Oh! That is the X!" and
-- set EVENT_PASSED_X_CHECK : show "You don't have the X yet!"
-- scripts/Route23.asm:195
local function badgeGuard(badge, passFlag)
local subs = { RAM = badge }
return {
{ "check_item", badge }, -- 1
{ "jump_if_true", 5 }, -- 2
{ "show_text", "_Route23YouDontHaveTheBadgeYetText", subs }, -- 3
{ "jump", 7 }, -- 4 (end)
{ "show_text", "_Route23OhThatIsTheBadgeText", subs }, -- 5
{ "set_flag", passFlag }, -- 6
}
return function(game, ow, npc, done)
ow:route23BadgeCheck(badge, passFlag, done)
end
end
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
+9 -8
View File
@@ -34,18 +34,19 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", "end" }, -- 7
-- scripts/OaksLab.asm:919
{ "show_text", "_OaksLabMonEnergeticText" }, -- 8
-- scripts/OaksLab.asm:918
{ "hide_object", "OAKS_LAB", ownBall }, -- 8
-- scripts/OaksLab.asm:921
{ "show_text", "_OaksLabMonEnergeticText" }, -- 9
-- OaksLab.asm: ReceivedMon (sound_get_key_item) then AddPartyMon; the
-- jingle fires once the box has typed and holds it (#668)
{ "text_sound", "Get_Key_Item" }, -- 9
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
{ "give_pokemon", species, 5 }, -- 11
{ "set_flag", "EVENT_GOT_STARTER" }, -- 12
{ "set_flag", choseFlag }, -- 13
{ "text_sound", "Get_Key_Item" }, -- 10
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "give_pokemon", species, 5 }, -- 12
{ "set_flag", "EVENT_GOT_STARTER" }, -- 13
-- POKé BALLs come later, at OaksLabOak1Text's .give_poke_balls beat
-- once the Route 22 rival is beaten (see TEXT_OAKSLAB_OAK1 below)
{ "hide_object", "OAKS_LAB", ownBall }, -- 14
{ "set_flag", choseFlag }, -- 14
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 15
{ "face_object", 1, "up" }, -- 16
+7 -5
View File
@@ -1061,7 +1061,8 @@ M.VICTORY_ROAD_3F = {
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- (played back to front: up 3, right 1, up 1) before
-- ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
@@ -1090,6 +1091,7 @@ local championsRoomRivalScript = {
-- playBattle("final") then no-ops on the same song, so the theme stays
-- continuous into the fight
{ "play_music", "Music_FinalBattle" }, -- 5
{ "save_end_battle_text", "_RivalDefeatedText" }, -- scripts/ChampionsRoom.asm:65
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
-- losing halts here; the numeric target this replaced pointed at the
-- closing warp, which inducted a player who had just lost the fight (#704)
@@ -1098,8 +1100,7 @@ local championsRoomRivalScript = {
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
-- _ChampionsRoomRivalAfterBattleText.
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
@@ -1153,10 +1154,11 @@ M.CHAMPIONS_ROOM = {
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
-- home/overworld.asm:1844
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
{ "move_player", "right", 1 },
{ "move_player", "up", 1 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
+35 -33
View File
@@ -867,20 +867,19 @@ M.SAFFRON_CITY = {
-- -------------------------------------------------------------------
local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
local function door(game, ow)
ow:replaceBlock(2, 0, game.save.flags[flag] and openBlock or closedBlock)
end
local seal = function(game, ow)
local open = game.save.flags[flag]
ow:replaceBlock(2, 0, open and openBlock or closedBlock)
door(game, ow)
-- the auto walk-in on first (south) entry (LoreleiScriptWalkIntoRoom)
if autoFlag and not game.save.flags[autoFlag] and ow.player.cellY >= 10 then
game.save.flags[autoFlag] = true
ow:scriptMove(ow.player, "up", 6)
end
end
-- onVictory re-runs the seal so the door opens right after the
-- battle, like pokered's post-battle map reload
return {
onEnter = seal,
onVictory = seal,
-- retreating toward the entrance gets "Don't run away!" and a
-- shove back up (the entrance coords rows in each room script)
onStep = function(game, ow, x, y)
@@ -892,14 +891,14 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
end))
return true
end,
}
}, door
end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
local function e4LeaderTalk(afterLabel, door)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
@@ -912,26 +911,33 @@ local function e4LeaderTalk(afterLabel)
done()
return
end
-- scripts/AgathasRoom.asm:2
local function reopen()
door(game, ow)
done()
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
if after then push(game, after, reopen) else reopen() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
local loreleiDoor, brunoDoor, agathaDoor
M.LORELEIS_ROOM, loreleiDoor = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText",
loreleiDoor),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
M.BRUNOS_ROOM, brunoDoor = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText", brunoDoor),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
M.AGATHAS_ROOM, agathaDoor = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText", agathaDoor),
}
-- -------------------------------------------------------------------
@@ -953,21 +959,15 @@ M.AGATHAS_ROOM.talk = {
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- landing on (6,11). Vanilla WalkToLance_RLEList is
-- up 12 / left 12 / down 7 / left 6 and skips collision
-- through void tiles; our camera follows that literally
-- and briefly shows the empty upper chamber (reads as
-- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- (24,16) the entrance staircase -> WalkToLance
-- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
-- scripts/LancesRoom.asm:111
-- home/overworld.asm:1845
local function lanceWalkInRoute(yellow)
return { { "left", 6 }, { "down", 7 }, { "left", 12 }, { "up", yellow and 13 or 12 } }
end
local LANCE_WALK_IN = lanceWalkInRoute(false)
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
@@ -983,15 +983,17 @@ local function lanceLockDoor(game, ow)
end
local function lanceWalkIn(game, ow)
local route = lanceWalkInRoute(require("src.core.GameVersion").isYellow())
local i = 0
local function step()
i = i + 1
local seg = LANCE_WALK_IN[i]
local seg = route[i]
if not seg then
-- lands on (6,11); vanilla's per-frame coord poll then locks the
-- door at once. scriptMove landings do not fire onStep, so lock
-- here.
lanceLockDoor(game, ow)
-- scripts/LancesRoom.asm:82
local p = ow.player
if (p.cellX == 5 or p.cellX == 6) and p.cellY == 11 then
lanceLockDoor(game, ow)
end
return
end
ow:scriptMove(ow.player, seg[1], seg[2], step)
@@ -1001,6 +1003,7 @@ end
M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
walkInRouteFor = lanceWalkInRoute,
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
@@ -1019,7 +1022,6 @@ M.LANCES_ROOM = {
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
+67 -13
View File
@@ -1268,8 +1268,9 @@ end
-- Returns true while animating.
function BattleState:stepHPDrain()
local busy = false
local only = self.drainOnly
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP then
if b and b.shownHP and (not only or b == only) then
-- drainFloor is the stop the running row carries (see drainNext)
local goal = b.mon.hp
if b.drainFloor and b.drainFloor > goal
@@ -1393,6 +1394,19 @@ function BattleState:beginMsgLine()
self.shown[#self.shown + 1] = {}
end
-- home/text.asm:264
function BattleState:arrowOwnsCell()
local save = self.game and self.game.save
if save and (save.generation == 2 or save.version == "gold") then return false end
return not self:wideLayout()
end
-- home/text.asm:270
function BattleState:blankArrowCell()
local line = self.shown and self.shown[2]
if line and line[18] and self:arrowOwnsCell() then line[18] = 0x7F end
end
function BattleState:visibleText()
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
return nil
@@ -1436,7 +1450,7 @@ function BattleState:updateQueue()
-- an HP-bar drain holds the queue until the bar catches up
if self.draining then
if self:stepHPDrain() then return true end
self.draining = nil
self.draining, self.drainOnly = nil, nil
if self.player then self.player.drainFloor = nil end
if self.enemy then self.enemy.drainFloor = nil end
end
@@ -1478,7 +1492,7 @@ function BattleState:updateQueue()
return true
end
if item.drain then
self.draining = true
self.draining, self.drainOnly = true, item.battler
if item.battler then item.battler.drainFloor = item.stopAt end
return true
end
@@ -1618,6 +1632,7 @@ function BattleState:updateQueue()
end
if input:wasPressed("a") or input:wasPressed("b") then
self.msgWaiting = nil
self:blankArrowCell()
self:beginMsgLine()
-- then the two ScrollTextUpOneLine calls block for 5 frames each
-- (home/text.asm:280-305) before the next line starts typing
@@ -1714,6 +1729,7 @@ function BattleState:updateQueue()
elseif input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:218
self.msgPrompt = nil
self:blankArrowCell()
self.msgHold = true
self.current = nil
end
@@ -1941,7 +1957,7 @@ function BattleState:enter()
-- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The
-- draw is still gated on the slide having landed, so nothing shows while
-- the silhouettes are still coming in.
self.introBalls = true
self.introBalls = "pending"
-- SGB: the player-side battle palette while the back pic is up is
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
@@ -1997,7 +2013,17 @@ function BattleState:enter()
table.insert(self.queue, { waitSound = function() return self.introSfx end })
table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP })
end
-- engine/battle/common_text.asm:25
if not self.ghost and not self.scopeReveal
and not (require("src.core.GameVersion").isYellow()
and (self.safari or self.demo)) then
self:act(function() self.introBalls = true end)
end
self:say(self.introText)
-- engine/battle/common_text.asm:46
if self.ghost then
self:say(self:romText("_GhostCantBeIDdText", "Darn! The GHOST\ncan't be ID'd!"))
end
-- the unveil rides on that same box, before _InitBattleCommon clears the
-- intro chrome below (#492)
if self.scopeReveal then self:queueScopeReveal() end
@@ -4553,6 +4579,7 @@ function BattleState:continueTrapping(user, target)
-- through the attacker's final hit (endOfTurn nils it)
user.trappingTurns = user.trappingTurns - 1
self:applyDamage(target, user.trapDamage or 1)
self:handleBuildingRage(target)
if target.mon.hp <= 0 then self:onFaint(target) end
end
@@ -4573,6 +4600,7 @@ function BattleState:continueBide(user, target)
-- here, after UnleashedEnergyText and before the damage (#375)
self:animNext("BIDE", user.isPlayer)
self:applyDamage(target, dmg)
self:handleBuildingRage(target)
if target.mon.hp <= 0 then self:onFaint(target) end
end
@@ -4581,7 +4609,7 @@ function BattleState:selfDestruct(user)
self:onFaint(user)
end
-- Applies damage honoring Substitute, Bide storage and Rage; returns the
-- Applies damage honoring Substitute and Bide storage; returns the
-- amount that counts as dealt (for recoil/drain).
function BattleState:applyDamage(target, dmg)
if target.substituteHP then
@@ -4601,13 +4629,28 @@ function BattleState:applyDamage(target, dmg)
if target.bideTurns then
target.bideDamage = (target.bideDamage or 0) + dealt
end
if target.rageMove and dealt > 0 then
target.stages.attack = math.min(6, (target.stages.attack or 0) + 1)
self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target)))
end
return dealt
end
-- engine/battle/move_effects/recoil.asm:28
function BattleState:applyRecoil(user, recoil)
local mon = user.mon
local before = mon.hp
mon.hp = math.max(0, mon.hp - recoil)
if mon.hp ~= before then self:drainNext(user, mon.hp) end
self:sayNext(self:romText("_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
end
-- engine/battle/core.asm:4913
function BattleState:handleBuildingRage(target)
if not target.rageMove or target.mon.hp <= 0 then return end
if (target.stages.attack or 0) >= 6 then return end
self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target)))
for _, m in ipairs(MoveEffects.changeStage(self, target, "attack", 1, false)) do
self:sayNext(m)
end
end
-- ---------------------------------------------------------------------
-- fainting / exp / party
-- ---------------------------------------------------------------------
@@ -5809,14 +5852,20 @@ end
-- PlayBattleVictoryMusic (core.asm:959-967) + EndLowHealthAlarm
-- (core.asm:864-872): winning stops the low-health alarm and disables
-- it for the rest of the battle (wLowHealthAlarmDisabled), then starts
-- the victory theme once; gym leaders, Lance and the final rival share
-- the victory theme once; gym leaders and the final rival share
-- MUSIC_DEFEATED_GYM_LEADER (core.asm:917-926).
function BattleState:playVictoryMusic()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
self.lowHealthAlarmDisabled = true
if self.victoryMusicPlayed then return end
self.victoryMusicPlayed = true
local kind = self.musicKind == "final" and "gym" or (self.musicKind or "wild")
local kind = self.musicKind or "wild"
-- engine/battle/core.asm:917
if kind == "final" or self.isGymLeader then
kind = "gym"
elseif kind == "gym" then
kind = "trainer"
end
require("src.core.Music").playVictory(self.data, kind)
end
@@ -6885,7 +6934,7 @@ function BattleState:drawHUDs(slide)
-- rows coming back when the beaten trainer scrolls in (#282):
-- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches
-- OAM, which ClearSprites emptied when the intro text was dismissed.
local showIntroBalls = self.introBalls and slide == 0
local showIntroBalls = self.introBalls == true and slide == 0
if showIntroBalls then
if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then
-- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78
@@ -6975,10 +7024,15 @@ function BattleState:drawTextArea()
-- view instead of drawing off-screen at y=144 (#216).
local off = self.scrollPx or 0
local ys = { 112, 128 }
-- home/text.asm:264
local arrowCell = (self.msgWaiting or self.msgPrompt) and off == 0
and self:arrowOwnsCell()
for li, line in ipairs(self.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
drawGlyph(line[i], 8 + (i - 1) * 8, y)
if not (arrowCell and li == 2 and i == 18) then
drawGlyph(line[i], 8 + (i - 1) * 8, y)
end
end
end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
+20 -5
View File
@@ -26,6 +26,11 @@ local function missBeat(battle, record)
battle:waitNext(Timing.MOVE_STATUS_OR_MISS)
end
-- engine/battle/core.asm:3224
local function explodeMissRage(battle, record, target)
if record and record.explode then battle:handleBuildingRage(target) end
end
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
@@ -131,6 +136,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- runs the explode effect ("even if Explosion or Selfdestruct
-- missed, its effect still needs to be activated", core.asm:3223),
-- so the user faints against a mid-Fly/Dig target too (#528)
explodeMissRage(battle, record, target)
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
return
end
@@ -153,6 +159,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not (record and record.explode) then battle:cancelMoveAnim() end
missBeat(battle, record)
battle:sayNext(romText(battle.data, "_AttackMissedText", "%s's\nattack missed!", displayName(user)))
explodeMissRage(battle, record, target)
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil
@@ -205,6 +212,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not (record and record.explode) then battle:cancelMoveAnim() end
missBeat(battle, record)
battle:sayNext(romText(battle.data, "_DoesntAffectMonText", "It doesn't affect\n%s!", displayName(target)))
explodeMissRage(battle, record, target)
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
end
@@ -213,6 +221,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not (record and record.explode) then battle:cancelMoveAnim() end
missBeat(battle, record)
battle:sayNext(romText(battle.data, "_AttackMissedText", "%s's\nattack missed!", displayName(user)))
explodeMissRage(battle, record, target)
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
end
@@ -261,6 +270,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
local totalDealt = 0
local landed, brokeSub = 0, false
local critPending, ohkoPending = info.crit, info.ohko
local multi = hits > 1
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hitRow
@@ -310,6 +320,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
damage = dealt, crit = info.crit, typeMult = info.typeMult,
})
end
-- engine/battle/core.asm:3243
if multi then battle:handleBuildingRage(target) end
if hadSub and not target.substituteHP then
-- AttackSubstitute: breaking the substitute ends a multi-hit move
brokeSub = true
@@ -317,7 +329,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
end
hits = landed > 0 and landed or hits
if hits > 1 then
-- engine/battle/core.asm:3242
if hits > 1 and target.mon.hp > 0 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
battle:sayNext(romText(battle.data, "_MultiHitText", "Hit the enemy\n%d times!", hits))
@@ -330,13 +343,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
ctx.brokeSub, ctx.hits = brokeSub, hits
ctx.hitSfx = hitSfx
local explode = record and record.explode
if not multi and explode then battle:handleBuildingRage(target) end
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
local recoil = math.max(1, math.floor(totalDealt / 2))
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil)
elseif moveInst.struggle and not brokeSub then
battle:applyRecoil(user, math.max(1, math.floor(totalDealt / 2)))
end
-- engine/battle/core.asm:3243
if not multi and not explode then battle:handleBuildingRage(target) end
-- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary"
+7 -3
View File
@@ -522,11 +522,14 @@ end
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
local function drainHalf(label, text)
return function(ctx)
-- engine/battle/core.asm:4899
if ctx.brokeSub then return end
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
ctx.battle.lastDamage = heal
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain()
-- engine/battle/move_effects/drain_hp.asm:81
ctx.battle:drainNext(ctx.user, mon.hp)
-- `text` arrives as a source string (Strings.source at the call
-- site keeps it in the catalog); the ROM's own line wins when the
-- import carries it, and both are resolved here, at use time
@@ -606,11 +609,12 @@ MoveEffects.full = {
RECOIL_EFFECT = {
afterDamage = function(ctx)
-- engine/battle/core.asm:4899
if ctx.brokeSub then return end
-- engine/battle/move_effects/recoil.asm
local recoil = math.max(1, math.floor(ctx.totalDealt
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
ctx.battle:applyRecoil(ctx.user, recoil)
end,
},
DRAIN_HP_EFFECT = {
+1 -1
View File
@@ -149,7 +149,7 @@ end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
-- out to the wide screen's own corners
local function drawIntroBalls(battle)
if not battle.introBalls then return end
if battle.introBalls ~= true then return end
if battle.enemyParty and
(battle.kind == "trainer" or battle.kind == "link") then
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
+1 -1
View File
@@ -362,7 +362,7 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
battle.hitSfxWait = nil
battle.hitSfxWait, battle.drainOnly = nil, nil
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
battle.player.shownHP, battle.player.shownStatus =
+356 -66
View File
@@ -56,17 +56,296 @@ end
-- menu) can exclude badges the same way the bag does
Bag.isBadge = isBadge
local MAX_STACK = 99
local function splits(id, data)
if require("src.core.GameVersion").generation() ~= 1 then return false end
data = data or require("src.core.Data")
local def = data and data.items and data.items[id]
return not (def and def.pocket)
end
local function stackList(stacks, id, total)
local list = stacks and stacks[id]
if type(list) ~= "table" or #list == 0 or type(total) ~= "number" then return nil end
local sum = 0
for i = 1, #list do
local c = list[i]
if type(c) ~= "number" or c < 1 or c > MAX_STACK or c % 1 ~= 0 then return nil end
sum = sum + c
end
if sum ~= total then return nil end
return list
end
-- engine/items/inventory.asm:59
local function slotsFor(id, n, data, stacks)
if not n or isBadge(id) then return 0 end
if type(n) ~= "number" then return 1 end
if n <= 0 then return 0 end
if not splits(id, data) then return 1 end
local list = stackList(stacks, id, n)
if list then return #list end
if n <= MAX_STACK then return 1 end
return math.ceil(n / MAX_STACK)
end
Bag.slotsFor = slotsFor
local function derivedCount(total, k, n)
if type(total) ~= "number" or n <= 1 then return total end
if k < n then return MAX_STACK end
return total - MAX_STACK * (n - 1)
end
local function isDerived(counts)
for k = 1, #counts - 1 do
if counts[k] ~= MAX_STACK then return false end
end
return true
end
local function countsOf(store, id, data, stacks)
local total = store[id]
local n = slotsFor(id, total, data, stacks)
local list = stackList(stacks, id, total)
local out = {}
for k = 1, n do out[k] = (list and list[k]) or derivedCount(total, k, n) end
return out
end
local function setCounts(store, id, counts, stacks)
local sum = 0
for i = 1, #counts do sum = sum + counts[i] end
store[id] = sum > 0 and sum or nil
if stacks then
stacks[id] = (#counts > 1 and not isDerived(counts)) and counts or nil
end
end
local function occurrences(order, id)
local at = {}
for i = 1, #order do
if order[i] == id then at[#at + 1] = i end
end
return at
end
local function byIndex(data)
data = data or require("src.core.Data")
local items = data and data.items
local function key(id)
local def = items and items[id]
return (def and (def.index or def.itemId)) or math.huge
end
return function(a, b)
local ia, ib = key(a), key(b)
if ia ~= ib then
if type(ia) == type(ib) then return ia < ib end
return tostring(ia) < tostring(ib)
end
if type(a) == type(b) then return a < b end
return tostring(a) < tostring(b)
end
end
local function normalize(store, order, data, stacks)
if stacks then
for id in pairs(stacks) do
local list = splits(id, data) and stackList(stacks, id, store[id])
if not list or #list < 2 or isDerived(list) then stacks[id] = nil end
end
end
local seen, w = {}, 0
for i = 1, #order do
local id = order[i]
local k = (seen[id] or 0) + 1
if k <= slotsFor(id, store[id], data, stacks) then
seen[id] = k
w = w + 1
order[w] = id
end
end
for i = #order, w + 1, -1 do order[i] = nil end
local missing = {}
for id, n in pairs(store) do
for _ = (seen[id] or 0) + 1, slotsFor(id, n, data, stacks) do
missing[#missing + 1] = id
end
end
if #missing > 1 then table.sort(missing, byIndex(data)) end
for _, id in ipairs(missing) do order[#order + 1] = id end
return order
end
Bag.normalize = normalize
local function storeSlots(store, data, keep, stacks)
local n = 0
for id, count in pairs(store or {}) do
if not keep or keep(id) then n = n + slotsFor(id, count, data, stacks) end
end
return n
end
Bag.storeSlots = storeSlots
function Bag.stackRows(store, order, data, stacks)
local rows, seen = {}, {}
for i, id in ipairs(order) do
local k = (seen[id] or 0) + 1
seen[id] = k
local total = store[id]
local list = stackList(stacks, id, total)
rows[#rows + 1] = { id = id, slot = k, index = i,
count = (list and list[k])
or derivedCount(total, k, slotsFor(id, total, data, stacks)) }
end
return rows
end
function Bag.setRows(store, order, rows, data, stacks)
local per = {}
for i = 1, #rows do
local id = rows[i].id
order[i] = id
if splits(id, data) and type(rows[i].count) == "number" then
local counts = per[id] or {}
counts[#counts + 1] = rows[i].count
per[id] = counts
end
end
for i = #order, #rows + 1, -1 do order[i] = nil end
for id, counts in pairs(per) do setCounts(store, id, counts, stacks) end
end
-- engine/items/inventory.asm:7
function Bag.addTo(store, order, id, qty, data, used, cap, stacks)
qty = qty or 1
local old = store[id]
if type(old) == "number" and old <= 0 then old = nil end
if isBadge(id) then
store[id] = (old or 0) + qty
return true
end
if not splits(id, data) then
if not old and used >= cap then return false end
if (old or 0) + qty > MAX_STACK then return false end
store[id] = (old or 0) + qty
if not old then order[#order + 1] = id end
return true
end
local free = cap - used
local counts = old and countsOf(store, id, data, stacks) or {}
local left = qty
for k = 1, #counts do
local sum = counts[k] + left
if sum < MAX_STACK + 1 then
counts[k] = sum
left = 0
break
end
-- engine/items/inventory.asm:72
if free <= 0 then return false end
counts[k] = MAX_STACK
left = sum - MAX_STACK
end
while left > 0 do
-- engine/items/inventory.asm:44
if free <= 0 then return false end
counts[#counts + 1] = math.min(left, MAX_STACK)
left = left - MAX_STACK
end
local before = #occurrences(order, id)
setCounts(store, id, counts, stacks)
for _ = before + 1, #counts do order[#order + 1] = id end
return true
end
-- engine/items/inventory.asm:100
function Bag.removeFrom(store, order, id, qty, data, slot, stacks)
qty = qty or 1
local old = store[id]
if old ~= nil and type(old) ~= "number" then return end
if not old or old <= 0 then
store[id] = nil
if order then
for i = #order, 1, -1 do
if order[i] == id then table.remove(order, i) end
end
end
return
end
if isBadge(id) or not splits(id, data) then
local left = old - qty
store[id] = left > 0 and left or nil
if order and not store[id] then
for i = #order, 1, -1 do
if order[i] == id then table.remove(order, i) end
end
end
return
end
local counts = countsOf(store, id, data, stacks)
local k = (slot and counts[slot]) and slot or 1
local take = math.min(qty, counts[k])
counts[k] = counts[k] - take
qty = qty - take
for i = 1, #counts do
if qty <= 0 then break end
take = math.min(qty, counts[i])
counts[i] = counts[i] - take
qty = qty - take
end
if order then
local at = occurrences(order, id)
for i = #at, 1, -1 do
if counts[i] ~= nil and counts[i] <= 0 then table.remove(order, at[i]) end
end
end
local kept = {}
for i = 1, #counts do
if counts[i] > 0 then kept[#kept + 1] = counts[i] end
end
setCounts(store, id, kept, stacks)
end
-- engine/menus/swap_items.asm:78
function Bag.swapSlots(store, order, first, second, data, stacks)
if first == second or not order[first] or not order[second] then return false end
local rows = Bag.stackRows(store, order, data, stacks)
local a, b = rows[first], rows[second]
if a.id ~= b.id or type(a.count) ~= "number" or type(b.count) ~= "number"
or not splits(a.id, data) then
rows[first], rows[second] = b, a
else
-- engine/menus/swap_items.asm:98
local sum = a.count + b.count
if sum < MAX_STACK + 1 then
b.count = sum
table.remove(rows, first)
else
a.count = sum - MAX_STACK
b.count = MAX_STACK
end
end
Bag.setRows(store, order, rows, data, stacks)
return true
end
local function stacksOf(save, key)
local t = save[key]
if type(t) ~= "table" then t = {} end
return t
end
local function keepStacks(save, key, t)
save[key] = next(t) ~= nil and t or nil
end
-- Occupied slots, of one pocket when named or of the whole inventory when not.
function Bag.slots(save, data, pocket)
if not save or not save.inventory then return 0 end
local n = 0
for id in pairs(save.inventory) do
if not isBadge(id)
and (not pocket or pocketOf(id, data) == pocket) then
n = n + 1
end
end
return n
return storeSlots(save.inventory, data, pocket and function(id)
return pocketOf(id, data) == pocket
end, save.bagStacks)
end
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
@@ -76,40 +355,67 @@ function Bag.order(save, data)
save.inventory = save.inventory or {}
local order = save.bagOrder
if not order then
local items = (data or require("src.core.Data")).items
order = {}
for id in pairs(save.inventory) do
if not isBadge(id) then table.insert(order, id) end
end
table.sort(order, function(a, b)
local ia = (items and items[a] and (items[a].index or items[a].itemId)) or math.huge
local ib = (items and items[b] and (items[b].index or items[b].itemId)) or math.huge
if ia ~= ib then
if type(ia) == type(ib) then return ia < ib end
return tostring(ia) < tostring(ib)
end
if type(a) == type(b) then return a < b end
return tostring(a) < tostring(b)
end)
save.bagOrder = order
end
-- drop stale ids, append unknown ones (defensive against direct
-- inventory writes)
local seen = {}
for i = #order, 1, -1 do
local id = order[i]
if not save.inventory[id] or seen[id] then
table.remove(order, i)
else
seen[id] = true
end
end
for id in pairs(save.inventory) do
if not isBadge(id) and not seen[id] then table.insert(order, id) end
end
local stacks = stacksOf(save, "bagStacks")
normalize(save.inventory, order, data, stacks)
keepStacks(save, "bagStacks", stacks)
return order
end
function Bag.rows(save, data)
if not save then return {} end
local order = Bag.order(save, data)
return Bag.stackRows(save.inventory, order, data, save.bagStacks)
end
function Bag.swap(save, first, second, data)
if not save or not save.inventory then return false end
local order = Bag.order(save, data)
local stacks = stacksOf(save, "bagStacks")
local ok = Bag.swapSlots(save.inventory, order, first, second, data, stacks)
keepStacks(save, "bagStacks", stacks)
return ok
end
-- ram/wram.asm:1895
function Bag.pcOrder(save, data)
if not save then return {} end
save.pcItems = save.pcItems or {}
local order = type(save.pcOrder) == "table" and save.pcOrder or {}
save.pcOrder = order
local stacks = stacksOf(save, "pcStacks")
normalize(save.pcItems, order, data, stacks)
keepStacks(save, "pcStacks", stacks)
return order
end
function Bag.pcRows(save, data)
if not save then return {} end
local order = Bag.pcOrder(save, data)
return Bag.stackRows(save.pcItems, order, data, save.pcStacks)
end
function Bag.pcAdd(save, id, qty, data, cap)
if not save then return false end
local order = Bag.pcOrder(save, data)
local stacks = stacksOf(save, "pcStacks")
local ok = Bag.addTo(save.pcItems, order, id, qty, data,
storeSlots(save.pcItems, data, nil, stacks), cap or 50, stacks)
keepStacks(save, "pcStacks", stacks)
return ok
end
function Bag.pcRemove(save, id, qty, data, slot)
if not save or not save.pcItems then return end
local stacks = stacksOf(save, "pcStacks")
Bag.removeFrom(save.pcItems, save.pcOrder, id, qty, data, slot, stacks)
keepStacks(save, "pcStacks", stacks)
end
-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the
-- rotate the PACK's SELECT performs, over the rows of ONE pocket.
function Bag.move(save, id, pocket, toIndex, data)
@@ -133,49 +439,33 @@ function Bag.move(save, id, pocket, toIndex, data)
return true
end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
-- engine/items/inventory.asm:59
function Bag.add(save, id, qty, data)
if not save then return false end
save.inventory = save.inventory or {}
local inv = save.inventory
if isBadge(id) then
inv[id] = (inv[id] or 0) + (qty or 1)
return true
end
-- Only the item's OWN pocket has to have room -- a full ITEM pocket does not
-- keep a KEY_ITEM or an HM out, which is the whole point of pockets.
local pocket = pocketOf(id, data)
if not inv[id] and not isBadge(id)
and Bag.slots(save, data, pocket) >= Bag.capacity(data, pocket) then
return false
end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
return false
end
-- Insert into the order BEFORE the inventory write: Bag.order's defensive
-- append reads save.inventory, so writing first made it add the id and the
-- table.insert below add it again -- one pickup, two bag rows, until the
-- next order() pass deduped it (and a save taken in between kept both).
local isNew = not inv[id]
if isNew and not isBadge(id) then
table.insert(Bag.order(save, data), id)
end
inv[id] = (inv[id] or 0) + (qty or 1)
return true
local order = Bag.order(save, data)
local stacks = stacksOf(save, "bagStacks")
local ok = Bag.addTo(inv, order, id, qty, data,
Bag.slots(save, data, pocket), Bag.capacity(data, pocket), stacks)
keepStacks(save, "bagStacks", stacks)
return ok
end
-- Remove qty (default 1); clears the slot and its order entry at zero.
function Bag.remove(save, id, qty)
-- engine/menus/pc.asm:118
function Bag.remove(save, id, qty, data, slot)
if not save or not save.inventory then return end
local inv = save.inventory
inv[id] = (inv[id] or 0) - (qty or 1)
if inv[id] <= 0 then
inv[id] = nil
local order = save.bagOrder
if order then
for i, oid in ipairs(order) do
if oid == id then table.remove(order, i) break end
end
end
end
local stacks = stacksOf(save, "bagStacks")
Bag.removeFrom(save.inventory, save.bagOrder, id, qty, data, slot, stacks)
keepStacks(save, "bagStacks", stacks)
end
return Bag
+56 -6
View File
@@ -159,9 +159,52 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
nextStep()
end
local function clearScreenLayer()
local layer = { isOpaque = true, evoClear = true }
layer.update = function() end
layer.draw = function()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
-- engine/movie/evolution.asm:68
layer.sgbPalettes = function(_, g)
local P = require("src.render.PaletteFX")
local c = layer.species and P.monPal(g.data, layer.species)
if c then return { P.whole(c) } end
return P.wholeNamed(g.data, "MEWMON")
end
return layer
end
-- engine/pokemon/evos_moves.asm:156
function Evolution.clearScreen(game, species)
while game.stack:top() and game.stack:top().evoIntro do game.stack:pop() end
local top = game.stack:top()
if not (top and top.evoClear) then
top = clearScreenLayer()
game.stack:push(top)
end
if species then top.species = species end
end
local function dropClearScreen(game)
local top = game.stack:top()
while top and (top.evoClear or top.evoIntro) do
game.stack:pop()
top = game.stack:top()
end
end
-- engine/pokemon/evos_moves.asm:245
local function finishEvolution(game)
dropClearScreen(game)
-- engine/link/cable_club.asm:290
Music.restoreMap(game.data)
end
-- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via)
function Evolution.evolve(game, mon, newSpecies, onDone, via, batch)
local oldName = mon.nickname or game.data.pokemon[mon.species].name
-- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11
-- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134)
@@ -181,8 +224,11 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
-- forward `via` so trade evolutions stay non-cancelable while
-- others accept B (evos_moves.asm:72-75) (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, function()
-- the result/cancel box owns the intro box's pop (#1596)
if game.stack:top() == intro then game.stack:pop() end
if batch then
Evolution.clearScreen(game)
else
finishEvolution(game)
end
if onDone then onDone() end
end, via)
end
@@ -190,6 +236,7 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
game.stack:push(hold)
end,
} })
intro.evoIntro = true
game.stack:push(intro)
return
end
@@ -201,10 +248,12 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
.. romText(game.data, "_IntoText", "\ninto %s!",
game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
-- re-run the evolved species' level-up learn check before onDone
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
Evolution.learnEvolutionMoves(game, mon, onDone)
Evolution.learnEvolutionMoves(game, mon, function()
if not batch then finishEvolution(game) end
if onDone then onDone() end
end)
end, TextBox.soundOpts(game, "Get_Item2")))
end
@@ -245,10 +294,11 @@ function Evolution.checkParty(game, onDone, leveledUp)
i = i + 1
local p = pending[i]
if not p then
dropClearScreen(game)
if onDone then onDone() end
return
end
Evolution.evolve(game, p.mon, p.to, nextOne, p.via)
Evolution.evolve(game, p.mon, p.to, nextOne, p.via, true)
end
nextOne()
return #pending
+6
View File
@@ -251,6 +251,12 @@ function PaletteFX.ogObjNormal()
return OG_OBJ_NORMAL, "gbcobjnormal_soft"
end
-- home/fade.asm:68
function PaletteFX.ogObjWorld()
if darkWorld or fadeObpMap then return PaletteFX.ogObj() end
return PaletteFX.ogObjNormal()
end
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
+5 -5
View File
@@ -319,10 +319,10 @@ function SpriteRenderer:resolveImage()
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObjWorld
-- returns colors + a version-distinct cache group so the two never
-- collide in obpCache) -- see issue #155
return getObpImage(self.def.image, PaletteFX.ogObj())
return getObpImage(self.def.image, PaletteFX.ogObjWorld())
end
-- Every other mode (SGB and the mono/inverted novelties) leaves the sprite
-- in DMG shades so the zone shader colors it out of the map's own palette,
@@ -435,11 +435,11 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
-- palette -- green over Red's red background, pink over Blue's blue
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
-- background (PaletteFX.ogObjWorld, #155). The BG zone shader still runs over
-- the world canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
-- top.
image = getObpImage(self.def.image, PaletteFX.ogObj())
image = getObpImage(self.def.image, PaletteFX.ogObjWorld())
redraw = true
else
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
@@ -529,7 +529,7 @@ function SpriteRenderer:drawTile(path, x, y, flip, quad)
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then image = getObpImage(path, colors, group) end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
image, redraw = getObpImage(path, PaletteFX.ogObj()), true
image, redraw = getObpImage(path, PaletteFX.ogObjWorld()), true
else
image = getObpImage(path, PaletteFX.dmgObj())
end
+25 -1
View File
@@ -444,6 +444,22 @@ function TextBox:arrowPos()
gold and (self.boxTy + self.boxTh - 1) * 8 or self.line2Y
end
-- home/text.asm:270
function TextBox:blankArrowCell()
if not self.fixedGen1Rows then return end
local line = self.shown[#self.shown]
if not line or #self.shown < 2 then return end
local ax = self:arrowPos()
local pen = self.textX
for i, code in ipairs(line) do
if pen == ax then
line[i] = 0x7F
return
end
pen = pen + Font.advanceOf(code)
end
end
-- scripts/MtMoonPokecenter.asm:30
function TextBox:moneyVisible()
if not self.money then return false end
@@ -640,6 +656,7 @@ function step(self, dt)
if self.contAdvance then
-- ContText / ManualTextScroll: keep the box, scroll one line
self.contAdvance = false
self:blankArrowCell()
self.lineIndex = self.lineIndex + 1
self:beginLine()
-- ScrollTextUpOneLine is 5 blocking frames and, as its own comment
@@ -760,13 +777,20 @@ function TextBox:draw()
-- nothing in the original is ever drawn between two rows.
local off = self.scrollPx or 0
local ys = { self.line1Y, self.line2Y }
-- home/text.asm:264
local coverX, coverY
if self.fixedGen1Rows and self:arrowVisible() then
coverX, coverY = arrowX, arrowY
end
for i, line in ipairs(self.shown) do
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
-- the pen advances per glyph, matching the pixel budget paginate
-- measured with; every fixed-width page still lands on the 8px grid
local pen = self.textX
for _, code in ipairs(line) do
drawGlyph(code, pen, y)
if not (pen == coverX and y == coverY) then
drawGlyph(code, pen, y)
end
pen = pen + Font.advanceOf(code)
end
end
+79 -31
View File
@@ -733,48 +733,92 @@ local function getKeyedTile(self, tile)
return img or nil
end
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
local ty = cy * 2 + 1
for i = 0, 1 do
local tx = cx * 2 + i
local tile = self.map:tileAt(tx, ty)
local keyed = tile and self.gbcCtx and getKeyedTile(self, tile)
if keyed then
love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY)
else
local quad = self.quads[tile]
if quad then
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
end
-- data/sprites/facings.asm:51
-- engine/gfx/sprite_oam.asm:127
function TileRenderer.feetStrip(px, py, sprite)
local fw = sprite and sprite.frameWidth or 16
local fh = sprite and sprite.frameHeight or 16
local ax = sprite and sprite.anchorX or fw / 2
local ay = sprite and sprite.anchorY or fh
local x0 = math.floor(px + 8 - ax)
local top = math.floor(py + 12 - ay)
local y0 = top + math.max(0, fh - 8)
return x0, y0, x0 + fw, y0 + math.min(8, fh)
end
function TileRenderer.eachStripSpan(x0, y0, x1, y1, fn, a, b, c, d)
for ty = math.floor(y0 / 8), math.floor((y1 - 1) / 8) do
local top, bottom = math.max(y0, ty * 8), math.min(y1, ty * 8 + 8)
for tx = math.floor(x0 / 8), math.floor((x1 - 1) / 8) do
local left, right = math.max(x0, tx * 8), math.min(x1, tx * 8 + 8)
fn(tx, ty, left, top, left - tx * 8, top - ty * 8,
right - left, bottom - top, a, b, c, d)
end
end
end
-- redraw a cell's bottom tile row (tall grass hides the lower half of
-- sprites standing in it, like the GB sprite-priority trick)
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
local function stripQuad(self, image, tile, ox, oy, w, h)
local cache = self.stripQuads
if not cache then
cache = {}
self.stripQuads = cache
end
local sub = ((ox * 9 + w) * 8 + oy) * 9 + h
local key = image == self.image and tile * 6000 + sub or -1 - sub
local q = cache[key]
if q then return q end
local iw, ih = image:getDimensions()
local sx, sy = 0, 0
if image == self.image then
local perRow = self.map.tileset.tilesPerRow
sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
end
q = love.graphics.newQuad(sx + ox, sy + oy, w, h, iw, ih)
cache[key] = q
return q
end
local function drawStripTile(tx, ty, x, y, ox, oy, w, h, self, camX, camY)
local tile = self.map:tileAt(tx, ty)
if not (tile and self.quads[tile]) then return end
local img = self.gbcCtx and getKeyedTile(self, tile) or self.image
love.graphics.draw(img, stripQuad(self, img, tile, ox, oy, w, h),
x - camX, y - camY)
end
local function markStripTile(tx, ty, x, y, ox, oy, w, h, self, camX, camY, colors)
local tile = self.map:tileAt(tx, ty)
if not (tile and self.quads[tile]) then return end
PaletteFX.markSpriteRedraw(self.image,
stripQuad(self, self.image, tile, ox, oy, w, h),
x - math.floor(camX), y - math.floor(camY),
1, colors, true)
end
function TileRenderer:drawStripRaw(x0, y0, x1, y1, camX, camY)
TileRenderer.eachStripSpan(x0, y0, x1, y1, drawStripTile, self, camX, camY)
end
function TileRenderer:drawStrip(x0, y0, x1, y1, camX, camY)
-- the RED++ path is pre-keyed; the white test would be a no-op there at
-- best, and a false hit on some other group's near-white color 0 at worst
local shader = not self.gbcCtx and getColor0KeyShader() or nil
if shader then love.graphics.setShader(shader) end
self:drawCellBottomRaw(cx, cy, camX, camY)
self:drawStripRaw(x0, y0, x1, y1, camX, camY)
if shader then love.graphics.setShader() end
end
-- queue the same bottom tile row for the post-zone sprite-redraw pass
-- (GBC mode: OBP-baked sprites replay after the zone shader, so the
-- grass patch that hides their feet must replay over them, colorized
-- with the map's palette and color-0 keyed)
function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
local ty = cy * 2 + 1
for i = 0, 1 do
local tx = cx * 2 + i
local quad = self.quads[self.map:tileAt(tx, ty)]
if quad then
PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX),
ty * 8 - math.floor(camY), 1, colors, true)
end
end
function TileRenderer:markStripRedraw(x0, y0, x1, y1, camX, camY, colors)
TileRenderer.eachStripSpan(x0, y0, x1, y1, markStripTile,
self, camX, camY, colors)
end
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
self:drawStripRaw(cx * 16, cy * 16 + 8, cx * 16 + 16, cy * 16 + 16, camX, camY)
end
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
self:drawStrip(cx * 16, cy * 16 + 8, cx * 16 + 16, cy * 16 + 16, camX, camY)
end
@@ -1024,6 +1068,10 @@ function TileRenderer:releaseBatches()
for _, q in pairs(self.quads) do safeRelease(q) end
self.quads = nil
end
if self.stripQuads then
for _, q in pairs(self.stripQuads) do safeRelease(q) end
self.stripQuads = nil
end
if self.anims then
for _, a in ipairs(self.anims) do
safeRelease(a.batch); a.batch = nil
+77 -29
View File
@@ -845,43 +845,91 @@ local function decodeItemRows(bytes, off, capacity)
return rows
end
local function stackCounts(stacks, id, qty)
local list = type(stacks) == "table" and stacks[id]
if type(list) ~= "table" or #list < 2 then return nil end
local sum = 0
for i = 1, #list do
local c = list[i]
if type(c) ~= "number" or c < 1 or c > 99 or c % 1 ~= 0 then return nil end
sum = sum + c
end
if sum ~= qty then return nil end
return list
end
local function foldRows(rows, cw)
local inventory, order = {}, {}
local inventory, order, counts = {}, {}, {}
for _, r in ipairs(rows) do
local id = cw.itemsByIndex[r[1]]
if id and not BADGE_BY_BIT_SET[id] and r[2] > 0 then
if not inventory[id] then order[#order + 1] = id end
order[#order + 1] = id
inventory[id] = (inventory[id] or 0) + r[2]
local list = counts[id] or {}
list[#list + 1] = r[2]
counts[id] = list
end
end
return inventory, order
local stacks = {}
for id, list in pairs(counts) do
local derived = true
for k = 1, #list - 1 do
if list[k] ~= 99 then derived = false end
end
if not derived and stackCounts({ [id] = list }, id, inventory[id]) then stacks[id] = list end
end
return inventory, order, next(stacks) ~= nil and stacks or nil
end
local function modelRows(inventory, order, capacity, cw)
local rows, seen, ids, rest = {}, {}, {}, {}
for _, id in ipairs(type(order) == "table" and order or {}) do
if not seen[id] then seen[id] = true; ids[#ids + 1] = id end
-- engine/items/inventory.asm:64
local function modelRows(inventory, order, capacity, cw, stacks)
inventory = inventory or {}
local need, lists = {}, {}
local function slots(id)
if need[id] == nil then
local qty = tonumber(inventory[id])
qty = qty and math.floor(qty) or 0
lists[id] = stackCounts(stacks, id, qty)
need[id] = (qty > 0 and cw.itemsIndex[id] and not BADGE_BY_BIT_SET[id])
and (lists[id] and #lists[id] or math.ceil(qty / 99)) or 0
end
return need[id]
end
for id in pairs(inventory or {}) do
if not seen[id] then rest[#rest + 1] = id end
local kept, have = {}, {}
for _, id in ipairs(type(order) == "table" and order or {}) do
if (have[id] or 0) < slots(id) then
have[id] = (have[id] or 0) + 1
kept[#kept + 1] = id
end
end
local ids, at = {}, {}
for _, id in ipairs(kept) do
at[id] = (at[id] or 0) + 1
ids[#ids + 1] = id
if at[id] == have[id] then
for _ = have[id] + 1, slots(id) do ids[#ids + 1] = id end
end
end
local rest = {}
for id in pairs(inventory) do
if not have[id] and slots(id) > 0 then rest[#rest + 1] = id end
end
table.sort(rest, function(a, b)
local ia, ib = cw.itemsIndex[a] or 1e9, cw.itemsIndex[b] or 1e9
if ia ~= ib then return ia < ib end
return tostring(a) < tostring(b)
end)
for _, id in ipairs(rest) do ids[#ids + 1] = id end
for _, id in ipairs(rest) do
for _ = 1, slots(id) do ids[#ids + 1] = id end
end
local rows, k = {}, {}
for _, id in ipairs(ids) do
local qty = tonumber((inventory or {})[id])
local idx = cw.itemsIndex[id]
if qty and qty > 0 and idx and not BADGE_BY_BIT_SET[id] then
qty = math.floor(qty)
while qty > 0 and #rows < capacity do
local q = math.min(qty, 99)
rows[#rows + 1] = { idx, q }
qty = qty - q
end
end
if #rows >= capacity then break end
k[id] = (k[id] or 0) + 1
local n = slots(id)
local q = lists[id] and lists[id][k[id]]
or (k[id] < n and 99 or math.floor(tonumber(inventory[id])) - 99 * (n - 1))
rows[#rows + 1] = { cw.itemsIndex[id], q }
end
return rows
end
@@ -904,14 +952,14 @@ end
local function decodeItems(bytes, off, capacity, cw)
local rows = decodeItemRows(bytes, off, capacity)
local inventory, order = foldRows(rows, cw)
local inventory, order, stacks = foldRows(rows, cw)
local carrier = nil
if not sameRows(modelRows(inventory, order, capacity, cw), rows) then carrier = rows end
return inventory, order, carrier
if not sameRows(modelRows(inventory, order, capacity, cw, stacks), rows) then carrier, stacks = rows, nil end
return inventory, order, carrier, stacks
end
local function encodeItems(buf, countOff, off, capacity, inventory, order, carrier, cw)
local rows = modelRows(inventory, order, capacity, cw)
local function encodeItems(buf, countOff, off, capacity, inventory, order, carrier, cw, stacks)
local rows = modelRows(inventory, order, capacity, cw, stacks)
carrier = validRows(carrier)
if carrier then
local inv, ord = foldRows(carrier, cw)
@@ -1023,8 +1071,8 @@ function GenSave.decode(bytes, data, opts)
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
end
save.inventory, save.bagOrder, save.cartBag = decodeItems(bytes, O.bagItems, 20, cw)
save.pcItems, save.pcOrder, save.cartPc = decodeItems(bytes, O.pcItems, 50, cw)
save.inventory, save.bagOrder, save.cartBag, save.bagStacks = decodeItems(bytes, O.bagItems, 20, cw)
save.pcItems, save.pcOrder, save.cartPc, save.pcStacks = decodeItems(bytes, O.pcItems, 50, cw)
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
-- set AFTER the bag decode since that call replaces save.inventory
@@ -1424,8 +1472,8 @@ function GenSave.encode(save, data, template)
-- wBagItems (they only ever live in wObtainedBadges, already encoded
-- above), so they must be filtered out here or they'd corrupt the bag
-- with bogus "badge items".
encodeItems(buf, O.numBagItems, O.bagItems, 20, save.inventory or {}, save.bagOrder, save.cartBag, cw)
encodeItems(buf, O.numPcItems, O.pcItems, 50, save.pcItems or {}, save.pcOrder, save.cartPc, cw)
encodeItems(buf, O.numBagItems, O.bagItems, 20, save.inventory or {}, save.bagOrder, save.cartBag, cw, save.bagStacks)
encodeItems(buf, O.numPcItems, O.pcItems, 50, save.pcItems or {}, save.pcOrder, save.cartPc, cw, save.pcStacks)
local flags = type(save.flags) == "table" and save.flags or {}
local events = data.eventFlags
+45 -21
View File
@@ -19,7 +19,8 @@ local BAG_RETURN_WHITE = 19
-- acquisition order like wBagItems (Bag.order), not alphabetical
local function buildItems(game)
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
for _, row in ipairs(Bag.rows(game.save, game.data)) do
local id = row.id
local def = game.data.items[id]
-- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns:
-- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641)
@@ -27,7 +28,9 @@ local function buildItems(game)
table.insert(items, {
value = id,
label = def and def.name or id,
count = (not unsellable) and game.save.inventory[id] or nil,
count = (not unsellable) and row.count or nil,
slot = row.slot,
stack = row.count,
})
end
-- the $ff terminator's row: CANCEL is selectable and exits like B
@@ -37,9 +40,12 @@ local function buildItems(game)
end
local function consume(game, id, list)
Bag.remove(game.save, id, 1)
local before = #Bag.rows(game.save, game.data)
local row = list and list.items and list.items[list.index]
local slot = row and row.value == id and row.slot or nil
Bag.remove(game.save, id, 1, game.data, slot)
-- engine/items/inventory.asm:131-136
if not game.save.inventory[id] then
if #Bag.rows(game.save, game.data) < before then
game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0
if list then list.index, list.scroll = 1, 0 end
end
@@ -362,13 +368,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local function removeUsed()
consume(game, id, list)
-- refresh counts in the list
for i, it in ipairs(list.items) do
if it.value == id then
local left = game.save.inventory[id]
if left then it.count = left else table.remove(list.items, i) end
break
end
end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
end
if not battle and not target and not picker then
@@ -516,6 +516,8 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
local function pickTargetAndUse(game, battle, id, list)
-- engine/menus/start_sub_menus.asm:416
if list then list.optionBox = nil end
-- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
@@ -616,11 +618,13 @@ function BagMenu.new(game, opts)
l.swapIndex = l.index
return
end
local order = Bag.order(game.save)
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
-- engine/menus/swap_items.asm:45
if l.swapIndex == l.index then return end
Bag.swap(game.save, l.swapIndex, l.index, game.data)
l.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
l.items = buildItems(game)
l.index = math.max(1, math.min(l.index, #l.items))
end,
onChoose = function(item)
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
@@ -632,11 +636,13 @@ function BagMenu.new(game, opts)
local id = item.value
local def = game.data.items[id]
if list.swapIndex then -- A also completes a pending swap
local order = Bag.order(game.save)
order[list.swapIndex], order[list.index] = order[list.index], order[list.swapIndex]
-- engine/menus/swap_items.asm:45
if list.swapIndex == list.index then return end
Bag.swap(game.save, list.swapIndex, list.index, game.data)
list.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
list.items = buildItems(game)
list.index = math.max(1, math.min(list.index, #list.items))
return
end
if battle then -- no tossing mid-battle
@@ -659,8 +665,12 @@ function BagMenu.new(game, opts)
-- (engine/menus/start_sub_menus.asm:330-337; home/window.asm:193-206)
list.hollowIndex = list.index
local Menu = require("src.ui.Menu")
game.stack:push(Menu.new(game, {
local optionBox
optionBox = Menu.new(game, {
{ label = Strings("USE"), onSelect = function()
-- engine/menus/start_sub_menus.asm:362
optionBox.hollowIndex = optionBox.index
list.optionBox = optionBox
useItem(game, battle, id, list)
end },
{ label = Strings("TOSS"), keepOpen = true, onSelect = function()
@@ -670,12 +680,13 @@ function BagMenu.new(game, opts)
-- engine/menus/start_sub_menus.asm:298-300, 438-439
local function itemMenuLoop(pops)
for _ = 1, pops do game.stack:pop() end
if not game.save.inventory[id] then
local fresh = buildItems(game)
if #fresh < #list.items then
-- engine/items/inventory.asm:131
game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0
list.index, list.scroll = 1, 0
end
list.items = buildItems(game)
list.items = fresh
list.index = math.min(list.index, math.max(1, #list.items))
end
-- KeyItemFlags + HMs decide tossability (not price:
@@ -689,7 +700,7 @@ function BagMenu.new(game, opts)
end
local QuantityBox = require("src.ui.QuantityBox")
game.stack:push(QuantityBox.new(game, {
max = game.save.inventory[id] or 1,
max = item.stack or game.save.inventory[id] or 1,
keepOpen = true,
onDone = function(qty)
if not qty then itemMenuLoop(2) return end
@@ -703,7 +714,7 @@ function BagMenu.new(game, opts)
game.stack:push(ChoiceBox.new(game, function(yes)
game.stack:pop()
if not yes then itemMenuLoop(2) return end
Bag.remove(game.save, id, qty)
Bag.remove(game.save, id, qty, game.data, item.slot)
showMessages(game, {
((t._ThrewAwayItemText or Strings("Threw away\n%s.", name))
:gsub("{RAM:wNameBuffer}", name)) },
@@ -713,7 +724,8 @@ function BagMenu.new(game, opts)
end,
}))
end },
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
}, { tx = 13, ty = 10, tw = 7, th = 5 })
game.stack:push(optionBox)
end,
})
-- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and
@@ -727,10 +739,22 @@ function BagMenu.new(game, opts)
end
local baseUpdate = list.update
list.update = function(self, dt)
self.optionBox = nil
baseUpdate(self, dt)
game.bagListScrollOffset = self.scroll
game.bagSavedMenuItem = self.index - self.scroll - 1
end
local baseDraw = list.draw
list.draw = function(self)
-- engine/menus/start_sub_menus.asm:299
if game.stack:top() == self then
self.optionBox = nil
self.hollowIndex = nil
end
baseDraw(self)
-- engine/menus/start_sub_menus.asm:298
if self.optionBox then self.optionBox:draw() end
end
list.closeStartMenu = opts.onClose
-- the item box overlaps the kept-open START menu box, so neither docks to
-- a screen edge on its own (start_sub_menus.asm:302-329) #1745
+78 -27
View File
@@ -6,6 +6,7 @@
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local SpriteRenderer = require("src.render.SpriteRenderer")
@@ -14,6 +15,7 @@ local Strings = require("src.core.Strings")
-- engine/events/diploma.asm:65
local OBP0_90 = { { 255, 255, 255 }, { 255, 255, 255 },
{ 170, 170, 170 }, { 85, 85, 85 } }
local PIC_X, PIC_Y = 115, 80
local Diploma = {}
Diploma.__index = Diploma
@@ -69,6 +71,57 @@ local function drawFrameBox(frame, tx, ty, tw, th)
end
end
local function playerName(game)
return (game.save.player and game.save.player.name) or "RED"
end
local function drawLines(game)
-- engine/events/diploma.asm:95
Font.draw(Strings("Player"), 24, 32)
-- engine/events/diploma.asm:39
Font.draw(playerName(game), 80, 32)
-- engine/events/diploma.asm:97
local congrats = {
{ text = Strings.source("Congrats! This"), y = 48 },
{ text = Strings.source("diploma certifies"), y = 64 },
{ text = Strings.source("that you have"), y = 80 },
{ text = Strings.source("completed your"), y = 96 },
{ text = Strings.source("POKéDEX."), y = 112 },
}
for _, line in ipairs(congrats) do
Font.draw(Strings(line.text), 16, line.y)
end
-- engine/events/diploma.asm:98
Font.draw(Strings("GAME FREAK"), 72, 128)
end
local overlayCanvas, overlayKey
local function textOverlay(game)
local g = love.graphics
if not (g.newCanvas and g.setCanvas and g.push and g.pop) then return nil end
local key = playerName(game) .. "\0" .. tostring(Strings.active())
if overlayCanvas and overlayKey == key then return overlayCanvas end
if not overlayCanvas then
local ok, c = pcall(g.newCanvas, 160, 144)
if not ok or not c then return nil end
overlayCanvas = c
end
g.push("all")
g.setCanvas(overlayCanvas)
g.origin()
g.setScissor()
g.setShader()
g.clear(0, 0, 0, 0)
g.setColor(0, 0, 0, 1)
drawLines(game)
g.pop()
overlayKey = key
return overlayCanvas
end
function Diploma.new(game, onDone)
local self = setmetatable({
game = game,
@@ -102,25 +155,32 @@ function Diploma.render(game)
titlePlayer or "assets/generated/title/player.png",
{ side = "front", kind = "diploma", data = game.data })
local pic
local ogObp = false
if picPath then
if picTrueColor then
pic = tryImage(picPath)
else
local ok, faded = pcall(SpriteRenderer.obpImage, picPath, OBP0_90,
"diploma")
ogObp = PaletteFX.usesSpriteObp() and PaletteFX.pass() == "ui"
local ramp, group = OBP0_90, "diploma"
if ogObp then
local c = PaletteFX.ogObjBase()
-- engine/events/diploma.asm:65
ramp = { c[1], c[1], c[2], c[3] }
group = GameVersion.isBlue() and "diploma_og_blue" or "diploma_og"
end
local ok, faded = pcall(SpriteRenderer.obpImage, picPath, ramp, group)
pic = (ok and faded) or tryImage(picPath)
ogObp = ogObp and ok and faded ~= nil
end
end
if pic then
love.graphics.setColor(1, 1, 1, 1)
local sx, sy, sw, sh = love.graphics.getScissor()
-- engine/events/diploma.asm:44
love.graphics.setScissor(8, 8, 144, 128)
love.graphics.draw(pic, 115, 80)
if sx then love.graphics.setScissor(sx, sy, sw, sh)
else love.graphics.setScissor() end
love.graphics.draw(pic, PIC_X, PIC_Y)
if picTrueColor then
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
PaletteFX.markTrueColor(PIC_X, PIC_Y, pic:getDimensions())
elseif ogObp then
PaletteFX.markUiSpriteRedraw(pic, nil, PIC_X, PIC_Y)
end
end
@@ -133,26 +193,17 @@ function Diploma.render(game)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
Font.draw(Strings("Player"), 24, 32)
local playerName = (game.save.player and game.save.player.name) or "RED"
Font.draw(playerName, 80, 32)
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
local congrats = {
{ text = Strings.source("Congrats! This"), y = 48 }, -- hlcoord 2, 6
{ text = Strings.source("diploma certifies"), y = 64 }, -- hlcoord 2, 8
{ text = Strings.source("that you have"), y = 80 }, -- hlcoord 2, 10
{ text = Strings.source("completed your"), y = 96 }, -- hlcoord 2, 12
{ text = Strings.source("POKéDEX."), y = 112 }, -- hlcoord 2, 14
}
for _, line in ipairs(congrats) do
Font.draw(Strings(line.text), 16, line.y)
end
-- 6. Developer signature: hlcoord 9, 16
Font.draw(Strings("GAME FREAK"), 72, 128)
drawLines(game)
love.graphics.setColor(1, 1, 1, 1)
if ogObp then
local overlay = textOverlay(game)
if overlay then
-- engine/events/diploma.asm:44
PaletteFX.markUiSpriteRedraw(overlay, nil, 0, 0,
{ clip = { PIC_X, PIC_Y, pic:getWidth(), pic:getHeight() } })
end
end
end
function Diploma:draw()
+5 -3
View File
@@ -126,26 +126,28 @@ end
function EvolutionState:pushDoneText()
local game = self.game
local TextBox = require("src.render.TextBox")
local Evolution = require("src.pokemon.Evolution")
if self.canceled then
game.stack:push(TextBox.new(game,
romText(game.data, "_StoppedEvolvingText",
"Huh? %s\nstopped evolving!", self.oldName),
function()
Music.restoreMap(game.data)
game.stack:pop()
-- engine/pokemon/evos_moves.asm:296
Evolution.clearScreen(game, self.mon.species)
if self.onDone then self.onDone() end
end))
return
end
local Evolution = require("src.pokemon.Evolution")
local newName = game.data.pokemon[self.newSpecies].name
-- engine/pokemon/evos_moves.asm:136-153
local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
.. romText(game.data, "_IntoText", "\ninto %s!", newName)
game.stack:push(TextBox.new(game, msg,
function()
Music.restoreMap(game.data)
game.stack:pop()
-- engine/pokemon/evos_moves.asm:156
Evolution.clearScreen(game, self.newSpecies)
-- engine/pokemon/evos_moves.asm:212 (#12)
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
end,
+17 -2
View File
@@ -150,8 +150,18 @@ function NamingScreen:enter()
end
end
-- engine/menus/naming_screen.asm:88
local REPROMPT_WHITE_FRAMES = 45
function NamingScreen:confirm()
local name = table.concat(self.glyphs)
if name == "" and self.presets and #self.presets > 0 then
-- engine/movie/oak_speech/oak_speech2.asm:20
self.row, self.col = 1, 1
self.anim = 0
self.whiteout = REPROMPT_WHITE_FRAMES
return
end
if name == "" then
-- An empty confirm (START, or the ED cell with nothing typed) must not
-- invent a letter (#833). DisplayNamingScreen seeds wStringBuffer with
@@ -163,10 +173,10 @@ function NamingScreen:confirm()
-- .playerCancelled and keeps the old nick, which is why an explicit
-- opts.default still wins here. Player/rival naming
-- (oak_speech2.asm ChoosePlayerName) re-opens on '@' and never accepts an
-- empty result; the port keeps its preset fallback for that.
-- empty result.
-- Contract for callers: "" means NO name -- BattleState:askNicknameUI and
-- Commands.give_pokemon both guard on #name > 0 before setting nickname.
name = (self.presets and self.presets[1]) or self.default or ""
name = self.default or ""
end
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
@@ -193,6 +203,10 @@ function NamingScreen:jumpToEnd()
end
function NamingScreen:update(dt)
if (self.whiteout or 0) > 0 then
self.whiteout = self.whiteout - 1
return
end
-- engine/menus/naming_screen.asm:131
self.anim = (self.anim or 0) + 1
local GRID = self:grid()
@@ -252,6 +266,7 @@ function NamingScreen:draw()
if self.choosing then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if (self.whiteout or 0) > 0 then return end
love.graphics.setColor(0, 0, 0, 1)
-- engine/menus/naming_screen.asm:453
if self.mon then
+71 -23
View File
@@ -253,6 +253,8 @@ function OakSpeech.defaultSteps(speech)
pic = "player",
-- oak_speech.asm:106-108
reveal = "fade_white_in",
-- data/text/text_2.asm:1753
auto = true,
},
{
id = "shrink",
@@ -331,6 +333,8 @@ function OakSpeech.new(game, onDone)
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED
self.walkSheet = tryImage(red and red.image)
self.walkDef = red
self.walkPath = red and red.image
return self
end
@@ -453,7 +457,8 @@ function OakSpeech:runStep(step)
self.holdBox = box
self.game.stack:push(box)
else
self:sayText(self:stepText(step), function() self:advance() end)
self:sayText(self:stepText(step), function() self:advance() end,
step.auto and { auto = { delay = 0 } } or nil)
end
end)
elseif kind == "demo" then
@@ -687,14 +692,23 @@ function OakSpeech:finish()
if self.onDone then self.onDone() end
end
-- Shrink timeline (oak_speech.asm .next):
-- frames 1-4 RedPicFront still up (ld c, 4 / DelayFrames)
-- frames 5-8 ShrinkPic1 (ld c, 4 / DelayFrames)
-- frames 9-28 ShrinkPic2, music fades (wAudioFadeOutControl; ld c, 20)
-- frames 29-78 pic area cleared, walking sprite at the standard
-- player screen spot (ResetPlayerSpriteData /
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
-- engine/movie/oak_speech/oak_speech.asm:125
local SHRINK_PIC1_AT = 36
local SHRINK_PIC2_AT = 76
-- engine/movie/oak_speech/oak_speech.asm:154
local SHRINK_WALK_AT = 108
local SHRINK_FADE_AT = 158
local SHRINK_END = SHRINK_FADE_AT + 3 * WHITE_FADE_STEP
-- home/fade.asm:71
local SHRINK_FADE_BGP = { 0x90, 0x40, 0x00 }
local SHRINK_FADE_OBP0 = { 0x80, 0x40, 0x00 }
OakSpeech.SHRINK_PIC1_AT = SHRINK_PIC1_AT
OakSpeech.SHRINK_PIC2_AT = SHRINK_PIC2_AT
OakSpeech.SHRINK_WALK_AT = SHRINK_WALK_AT
OakSpeech.SHRINK_FADE_AT = SHRINK_FADE_AT
OakSpeech.SHRINK_END = SHRINK_END
function OakSpeech:update(dt)
local r = self.picReveal
if r then
@@ -708,24 +722,24 @@ function OakSpeech:update(dt)
if not self.shrink then return end
local s = self.shrink
s.frame = s.frame + 1
if s.frame == 5 then
if s.frame == SHRINK_PIC1_AT then
self.pic = self.shrinkPic1 or self.pic
self.picTrueColor = false
elseif s.frame == 9 then
elseif s.frame == SHRINK_PIC2_AT then
self.pic = self.shrinkPic2 or self.pic
self.picTrueColor = false
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
-- frames (7 levels x 10), reaching 0 shortly before the fade-to-white
-- begins, instead of a hard cut (oak_speech.asm:145-149,
-- home/fade_audio.asm)
Music.fadeOut(10)
elseif s.frame == 29 then
elseif s.frame == SHRINK_WALK_AT then
self.pic = nil
self.picTrueColor = false
self.walkVisible = true
elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
elseif s.frame > 102 then
elseif s.frame >= SHRINK_FADE_AT and s.frame < SHRINK_END then
self.fadeLevel = math.floor((s.frame - SHRINK_FADE_AT) / WHITE_FADE_STEP) + 1
elseif s.frame >= SHRINK_END then
-- clear before finish(): a finished-listener that pushes a state gets
-- ITS state popped in the speech's place, and a live shrink would call
-- finish() again next frame, re-firing the event every frame (#308)
@@ -734,6 +748,33 @@ function OakSpeech:update(dt)
end
end
-- engine/movie/oak_speech/oak_speech.asm:162
function OakSpeech:walkImage()
if not self.walkPath then return self.walkSheet, false end
local PF = require("src.render.PaletteFX")
local colors, group
if PF.usesGbcPack() then
colors, group = PF.spriteObp(self.walkDef)
if colors then colors, group = PF.fadeObp(colors, group) end
end
if not colors then
if PF.usesSpriteObp() then
if self.fadeLevel then
colors, group = PF.ogObjLit()
else
-- home/fade.asm:68
colors, group = PF.ogObjNormal()
end
else
colors, group = PF.dmgObjLit()
end
end
local ok, img = pcall(require("src.render.SpriteRenderer").obpImage,
self.walkPath, colors, group)
if not (ok and img) then return self.walkSheet, false end
return img, PF.usesSpriteObp() or PF.usesGbcPack()
end
function OakSpeech:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -743,6 +784,14 @@ function OakSpeech:draw()
require("src.render.PaletteFX").setShadeMap(
require("src.render.Transition").shadeMapFor(revealBgp))
end
if self.fadeLevel then
-- home/fade.asm:26
local PF = require("src.render.PaletteFX")
local Transition = require("src.render.Transition")
local i = math.min(#SHRINK_FADE_BGP, self.fadeLevel)
PF.setShadeMap(Transition.shadeMapFor(SHRINK_FADE_BGP[i]))
PF.setFadeObp(Transition.shadeMapFor(SHRINK_FADE_OBP0[i]))
end
if self.pic then
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
@@ -773,7 +822,11 @@ function OakSpeech:draw()
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
self.walkQuad = self.walkQuad
or love.graphics.newQuad(0, 0, 16, 16, self.walkSheet:getDimensions())
love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60)
local img, redraw = self:walkImage()
love.graphics.draw(img, self.walkQuad, 64, 60)
if redraw then
require("src.render.PaletteFX").markUiSpriteRedraw(img, self.walkQuad, 64, 60)
end
end
if self.shrinkText then
-- This is a REPLICA of the dialogue box that just closed, redrawn at
@@ -795,11 +848,6 @@ function OakSpeech:draw()
end
love.graphics.setColor(1, 1, 1, 1)
end
if self.fadeLevel then
love.graphics.setColor(1, 1, 1, self.fadeLevel / 3)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
end
return OakSpeech
+23 -12
View File
@@ -35,14 +35,11 @@ PartyMenu.isOpaque = true
-- MEWMON instead painted every bar with MEWMON's shades, which is why a
-- full bar came out black and a low one purple (#274, absorbing #272).
--
-- Two rects differ from the packet's, both because this port draws pixels
-- One rect differs from the packet's, because this port draws pixels
-- where the hardware drew OAM over BG:
-- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the
-- message box's top edge, which on hardware was BG under an OBJ-free
-- part of the block; here it would take MEWMON instead of the base.
-- * the bar blocks sit one tile right of the packet's 05-11 because this
-- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at
-- 4; the span is the same "left cap + six fill tiles".
function PartyMenu:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local base = P.pal(game.data, "GREENBAR")
@@ -65,7 +62,7 @@ function PartyMenu:sgbPalettes(game)
if self.heal and self.heal.mon == mon then hp = self.heal.from end
local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp))
if bar then
zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1)
zones[#zones + 1] = P.zone(bar, 5, i * 2 - 1, 11, i * 2 - 1)
end
end
end
@@ -466,7 +463,10 @@ end
function PartyMenu:update(dt)
-- icon animation counter; 320 = a whole cycle at every HP speed
self.blink = ((self.blink or 0) + 1) % 320
-- home/pokemon.asm:243
if not (self.submenu or self.chosenHollow or self.heal or self.swapAnim) then
self.blink = ((self.blink or 0) + 1) % 320
end
-- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking
-- predef in item_effects.asm, so no button is read until it lands (#252).
local heal = self.heal
@@ -497,6 +497,7 @@ function PartyMenu:update(dt)
end
if anim.frames < 10 or (playing and anim.frames < 30) then return end
self.swapAnim = nil
self.blink = 0 -- home/window.asm:16
if self.game.data then
require("src.core.Sound").play(self.game.data, "Swap")
end
@@ -524,6 +525,7 @@ function PartyMenu:update(dt)
self.subIndex = self.subIndex < n and self.subIndex + 1 or 1
elseif input:wasPressed("b") then
self.submenu = nil
self.blink = 0 -- home/window.asm:16
elseif input:wasPressed("a") then
local mon = party[self.index]
if followerUnavailable(self.game, mon) then
@@ -595,6 +597,7 @@ function PartyMenu:update(dt)
return
end
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
self.chosenHollow = true
ow:useFlashFieldMove(function() self:close() end)
return
elseif action == "surf" then
@@ -614,6 +617,7 @@ function PartyMenu:update(dt)
-- trySurf closes this menu when its text does (#385)
local fx, fy = ow.player:facingCell()
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
self.chosenHollow = true
ow:trySurf(fx, fy, function() self:close() end)
return
end
@@ -687,6 +691,7 @@ function PartyMenu:update(dt)
return
end
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
self.chosenHollow = true
ow:useStrengthFieldMove(mon, function() self:close() end)
return
elseif ow and ow.useFieldMove then
@@ -739,6 +744,7 @@ function PartyMenu:update(dt)
return
end
self.submenu = nil
self.blink = 0 -- home/window.asm:16
end
return
end
@@ -754,12 +760,15 @@ function PartyMenu:update(dt)
if grid then
self.index = grid
self.game.partyMenuSavedIndex = self.index
self.blink = 0 -- home/window.asm:16
elseif input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
self.blink = 0 -- home/window.asm:16
elseif input:wasPressed("down") then
self.index = self.index < #party and self.index + 1 or 1
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
self.blink = 0 -- home/window.asm:16
elseif input:wasPressed("b") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
@@ -964,11 +973,11 @@ function PartyMenu:draw()
for _, m in ipairs(def.tmhm or {}) do
if m == self.tmhm.move then can = true break end
end
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
-- engine/menus/party_menu.asm:93
if can then
Font.draw(Strings("ABLE"), 120, y + 8)
Font.draw(Strings("ABLE"), 96, y + 8)
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
Font.draw(Strings("NOT ABLE"), 96, y + 8)
end
elseif self.evoStone then
-- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching
@@ -979,10 +988,11 @@ function PartyMenu:draw()
can = true break
end
end
-- engine/menus/party_menu.asm:160
if can then
Font.draw(Strings("ABLE"), 120, y + 8)
Font.draw(Strings("ABLE"), 96, y + 8)
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
Font.draw(Strings("NOT ABLE"), 96, y + 8)
end
else
if mon.hp <= 0 then
@@ -1006,7 +1016,8 @@ function PartyMenu:draw()
shown = { hp = math.floor(self.heal.shown), stats = mon.stats }
end
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned)
-- engine/menus/party_menu.asm:71
HudTiles.drawHPBar(self.game.data, 4, (y + 8) / 8, shown, nil, barZoned)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8)
end
+29 -82
View File
@@ -6,6 +6,7 @@
-- move one); toss discards after a YES/NO confirm. Follows the
-- BoxMenu/BagMenu list idioms.
local Bag = require("src.inventory.Bag")
local ChoiceBox = require("src.ui.ChoiceBox")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
@@ -23,49 +24,19 @@ local function itemName(game, id)
return def and def.name or id
end
local function pcOrder(game)
local save, pc = game.save, game.save.pcItems
local order = type(save.pcOrder) == "table" and save.pcOrder or {}
local kept, seen = {}, {}
for _, id in ipairs(order) do
if pc[id] and not seen[id] then kept[#kept + 1], seen[id] = id, true end
end
local added = {}
for id in pairs(pc) do if not seen[id] then added[#added + 1] = id end end
table.sort(added, function(a, b)
local da, db = game.data.items[a], game.data.items[b]
local ia = da and (da.index or da.itemId) or math.huge
local ib = db and (db.index or db.itemId) or math.huge
if ia ~= ib then
if type(ia) == type(ib) then return ia < ib end
return tostring(ia) < tostring(ib)
end
if type(a) == type(b) then return a < b end
return tostring(a) < tostring(b)
end)
for _, id in ipairs(added) do kept[#kept + 1] = id end
for i = #order, 1, -1 do order[i] = nil end
for i, id in ipairs(kept) do order[i] = id end
save.pcOrder = order
return order
end
local function buildItems(game, store, order)
local function buildItems(game, rows)
local items = {}
local ids = order
if not ids then
ids = {}
for id in pairs(store) do table.insert(ids, id) end
table.sort(ids)
end
for _, id in ipairs(ids) do
if store[id] then
for _, row in ipairs(rows) do
local id = row.id
if row.count ~= nil then
local def = game.data.items[id]
local keyItem = (def and def.keyItem) or id:find("^HM_") ~= nil
table.insert(items, {
value = id,
label = itemName(game, id),
count = not keyItem and store[id] or nil,
count = not keyItem and row.count or nil,
slot = row.slot,
stack = row.count,
})
end
end
@@ -104,19 +75,11 @@ local function askQuantity(game, list, count, id, cb)
}))
end
-- refresh the chosen row's count from `store` (or drop the row)
local function refreshRow(list, store, id)
for i, it in ipairs(list.items) do
if it.value == id then
if store[id] then
it.count = store[id]
else
table.remove(list.items, i)
list.index, list.scroll = 1, 0 -- engine/items/inventory.asm:131
end
break
end
local function refreshRows(list, items)
if #items < #list.items then
list.index, list.scroll = 1, 0 -- engine/items/inventory.asm:131
end
list.items = items
list.index = math.max(1, math.min(list.index, #list.items))
end
@@ -166,7 +129,7 @@ local function withdraw(game)
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(pcList(game, buildItems(game, pc, pcOrder(game)), {
game.stack:push(pcList(game, buildItems(game, Bag.pcRows(game.save, game.data)), {
kind = "pc_item_withdraw",
messageBox = true,
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
@@ -176,39 +139,30 @@ local function withdraw(game)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
list.hollowIndex = list.index -- home/list_menu.asm:91
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
askQuantity(game, list, item.stack or pc[item.value] or 1, item.value, function(qty)
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = Strings("You can't carry\nany more items.")
return
end
pc[item.value] = pc[item.value] - qty
if pc[item.value] <= 0 then
pc[item.value] = nil
pcOrder(game)
end
Bag.pcRemove(game.save, item.value, qty, game.data, item.slot)
Sound.play(game.data, "Withdraw_Deposit")
list:showCompletion(romText(game.data, "_WithdrewItemText",
"Withdrew\n%s.{PROMPT}", itemName(game, item.value)),
function() refreshRow(list, pc, item.value) end) -- engine/menus/players_pc.asm:191
function() refreshRows(list, buildItems(game, Bag.pcRows(game.save, game.data))) end) -- engine/menus/players_pc.asm:191
end)
end,
}))
end
-- wNumBoxItems capacity: 50 stacks (PC_ITEM_CAPACITY)
local function pcFull(game, pc, id)
if pc[id] then return false end -- growing an existing stack is fine
local function storeInPC(game, pc, id, qty)
local cap = game.data.field.pcItemCap or 50
local stacks = 0
for _ in pairs(pc) do stacks = stacks + 1 end
return stacks >= cap
return Bag.pcAdd(game.save, id, qty, game.data, cap)
end
local function deposit(game)
local pc = game.save.pcItems
local inv = game.save.inventory
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)
-- players_pc.asm:90-95: an empty bag never reaches the list
@@ -217,7 +171,7 @@ local function deposit(game)
"You have nothing\nto deposit."), nil, { noSound = true }))
return
end
game.stack:push(pcList(game, buildItems(game, inv, order), {
game.stack:push(pcList(game, buildItems(game, Bag.rows(game.save, game.data)), {
kind = "pc_item_deposit",
messageBox = true,
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
@@ -227,21 +181,18 @@ local function deposit(game)
onChoose = function(item, list)
if leftOnCancel(item, list) then return end
list.hollowIndex = list.index -- home/list_menu.asm:91
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
askQuantity(game, list, item.stack or inv[item.value] or 1, item.value, function(qty)
if not storeInPC(game, pc, item.value, qty) then
list.footer = Strings("No room left to\nstore items.")
return
end
if not pc[item.value] then
local order = pcOrder(game)
order[#order + 1] = item.value
end
require("src.inventory.Bag").remove(game.save, item.value, qty)
pc[item.value] = (pc[item.value] or 0) + qty
Bag.remove(game.save, item.value, qty, game.data, item.slot)
Sound.play(game.data, "Withdraw_Deposit")
list:showCompletion(romText(game.data, "_ItemWasStoredText",
"%s was\nstored via PC.{PROMPT}", itemName(game, item.value)),
function() refreshRow(list, inv, item.value) end) -- engine/menus/players_pc.asm:137
function()
refreshRows(list, buildItems(game, Bag.rows(game.save, game.data)))
end) -- engine/menus/players_pc.asm:137
end)
end,
}))
@@ -255,7 +206,7 @@ local function toss(game)
"There is nothing\nstored."), nil, { noSound = true }))
return
end
game.stack:push(pcList(game, buildItems(game, pc, pcOrder(game)), {
game.stack:push(pcList(game, buildItems(game, Bag.pcRows(game.save, game.data)), {
kind = "pc_item_toss",
messageBox = true,
-- players_pc.asm:205-206 WhatToTossText, printed before the list
@@ -272,20 +223,16 @@ local function toss(game)
end
local QuantityBox = require("src.ui.QuantityBox")
game.stack:push(QuantityBox.new(game, {
max = pc[item.value] or 1,
max = item.stack or pc[item.value] or 1,
onDone = function(qty)
if not qty then return end
list.footer = Strings("Toss %s?", itemName(game, item.value))
game.stack:push(ChoiceBox.new(game, function(yes)
if yes then
pc[item.value] = pc[item.value] - qty
if pc[item.value] <= 0 then
pc[item.value] = nil
pcOrder(game)
end
Bag.pcRemove(game.save, item.value, qty, game.data, item.slot)
list:showCompletion(romText(game.data, "_ThrewAwayItemText",
"Threw away\n%s.{PROMPT}", itemName(game, item.value)),
function() refreshRow(list, pc, item.value) end) -- engine/menus/players_pc.asm:240
function() refreshRows(list, buildItems(game, Bag.pcRows(game.save, game.data))) end) -- engine/menus/players_pc.asm:240
else
list.footer = nil
end
+17 -11
View File
@@ -152,13 +152,16 @@ end
-- home/list_menu.asm:472-477
local function sellItems(game)
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
for _, row in ipairs(Bag.rows(game.save, game.data)) do
local id = row.id
local def = game.data.items[id]
local keyed = (def and def.keyItem) or id:find("^HM_") ~= nil
table.insert(items, {
value = id,
label = def and def.name or id,
count = (not keyed) and game.save.inventory[id] or nil,
count = (not keyed) and row.count or nil,
slot = row.slot,
stack = row.count,
})
end
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
@@ -196,11 +199,13 @@ local function sell(game, menu)
l.swapIndex = l.index
return
end
local order = Bag.order(game.save)
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
-- engine/menus/swap_items.asm:45
if l.swapIndex == l.index then return end
Bag.swap(game.save, l.swapIndex, l.index, game.data)
l.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
l.items = sellItems(game)
l.index = math.max(1, math.min(l.index, #l.items))
end,
onChoose = function(item)
-- home/list_menu.asm:105-110, 523-528
@@ -227,7 +232,7 @@ local function sell(game, menu)
if game.stack:top() == qtyBox then game.stack:pop() end
end
qtyBox = QuantityBox.new(game, {
max = game.save.inventory[item.value] or 1,
max = item.stack or game.save.inventory[item.value] or 1,
unitPrice = unit,
keepOpen = true,
onDone = function(qty)
@@ -250,13 +255,14 @@ local function sell(game, menu)
game.save.money = game.save.money + unit * qty
-- home/inventory.asm:15
require("src.core.Sound").play(game.data, "Purchase")
Bag.remove(game.save, item.value, qty)
local left = game.save.inventory[item.value]
if left then
item.count = left
else
list:removeCurrent()
Bag.remove(game.save, item.value, qty, game.data, item.slot)
local fresh = sellItems(game)
if #fresh < #list.items then
-- engine/items/inventory.asm:131
list.index, list.scroll = 1, 0
end
list.items = fresh
list.index = math.max(1, math.min(list.index, #list.items))
-- a sale prints nothing -- engine/events/pokemart.asm:112
list.footer = greet
end }))
+4 -2
View File
@@ -516,13 +516,15 @@ function ContinueInfo:draw()
math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72)
local badges = require("src.inventory.Badges").count(self.game.data, save)
Font.draw(Strings("BADGES"), 40, 88)
Font.draw(("%2d"):format(badges), 128, 88)
-- engine/menus/main_menu.asm:370
Font.draw(("%2d"):format(badges), 17 * 8, 11 * 8)
local owned = 0
for _ in pairs(save.pokedex and save.pokedex.owned or {}) do
owned = owned + 1
end
Font.draw(Strings("POKéDEX"), 40, 104)
Font.draw(("%3d"):format(owned), 120, 104)
-- engine/menus/main_menu.asm:372
Font.draw(("%3d"):format(owned), 16 * 8, 13 * 8)
local t = math.floor(save.playTime or 0)
Font.draw(Strings("TIME"), 40, 120)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
+11 -1
View File
@@ -64,7 +64,9 @@ function Rows.build(ctx, skip)
local rows = {}
local function add(row) rows[#rows + 1] = row end
local function addCart(id, make)
if not (skip and skip[id]) then add(make()) end
local swap = skip and skip[id]
if type(swap) == "table" then add(swap)
elseif not swap then add(make()) end
end
addCart("textSpeed", function() return {
@@ -498,4 +500,12 @@ function Rows.group(rows, openPage)
return view
end
function Rows.withCart(ctx, cartRows, openPage, exclude)
local rows = {}
for _, r in ipairs(Rows.build(ctx, cartRows)) do
if not (exclude and exclude[r.id]) then rows[#rows + 1] = r end
end
return Rows.group(rows, openPage)
end
return Rows
+39 -21
View File
@@ -14,11 +14,15 @@ Menu.__index = Menu
Menu.TYPE = { HAS_NO_SAVED_GAME = 0, HAS_SAVED_GAME = 1, HAS_MYSTERY_EVENT = 2 }
local T = Menu.TYPE
local LABEL = { CONTINUE = "gMainMenuString_Continue", NEW_GAME = "gMainMenuString_NewGame",
OPTION = "gMainMenuString_Option", MYSTERY_EVENTS = "gMainMenuString_MysteryEvents" }
OPTION = "gMainMenuString_Option", MYSTERY_EVENTS = "gMainMenuString_MysteryEvents", EXIT = "SystemText_Exit" }
local SCROLL_PX = 32
function Menu.items(kind)
if kind == T.HAS_MYSTERY_EVENT then return { "CONTINUE", "NEW_GAME", "MYSTERY_EVENTS", "OPTION" } end
if kind == T.HAS_SAVED_GAME then return { "CONTINUE", "NEW_GAME", "OPTION" } end
return { "NEW_GAME", "OPTION" }
local rows
if kind == T.HAS_MYSTERY_EVENT then rows = { "CONTINUE", "NEW_GAME", "MYSTERY_EVENTS", "OPTION" }
elseif kind == T.HAS_SAVED_GAME then rows = { "CONTINUE", "NEW_GAME", "OPTION" }
else rows = { "NEW_GAME", "OPTION" } end
rows[#rows + 1] = "EXIT"
return rows
end
function Menu.menuType(hasContinue, info, status)
if not hasContinue or status == "invalid" or status == "empty" or status == "no_flash" then return T.HAS_NO_SAVED_GAME end
@@ -66,7 +70,7 @@ function Menu.new(opts, ctx)
opts = opts or {}
local self = setmetatable({ info = opts.continueInfo, game = opts.game, bootState = opts.bootState,
textSpeed = tonumber(opts.textSpeed) or 1, saveStatus = opts.saveStatus or "ok", pal = Pal.new(),
step = Kit.stepper(), state = "check_save", cursor = 1,
step = Kit.stepper(), state = "check_save", cursor = 1, scroll = 0, blink = 0,
manifest = assert(Kit.manifest("birch"), "RS menu palette missing"),
frameType = opts.continueInfo and opts.continueInfo.frameType or 0 }, Menu)
self.menuType = opts.menuType or Menu.menuType(opts.hasContinue, self.info, self.saveStatus)
@@ -74,6 +78,12 @@ function Menu.new(opts, ctx)
self.pal:beginFade(Pal.ALL, 0, 16, 0, opts.returningFromOptions and Pal.BLACK or Pal.WHITE)
return self
end
function Menu:_fixScroll()
local w = Menu.windowFor(self.menuType, self.cursor)
local bottom, topPx = (w.top + w.height + 1) * 8, (w.top - 1) * 8
while bottom - self.scroll > 160 do self.scroll = self.scroll + SCROLL_PX end
while topPx - self.scroll < 0 and self.scroll > 0 do self.scroll = self.scroll - SCROLL_PX end
end
function Menu:_message(key)
self.printer = Kit.printer(key, { speed = 2, canSpeedUp = false, textSpeedOption = self.textSpeed })
end
@@ -84,6 +94,7 @@ function Menu:_openOptions()
end
function Menu:frame(inp)
local state, result = self.state
self.blink = self.blink + 1
if state == "check_save" and not self.pal:fadeActive() then
local key = self.saveStatus == "invalid" and "gSaveFileDeletedMessage"
or self.saveStatus == "error" and "gSaveFileCorruptMessage"
@@ -108,13 +119,14 @@ function Menu:frame(inp)
Kit.playSe("SE_SELECT"); self.pal:beginFade(Pal.ALL, 0, 0, 16, Pal.BLACK); self.state = "pressed_a"
elseif inp.new.b then
Kit.playSe("SE_SELECT"); self.pal:beginFade(Pal.ALL, 0, 0, 16, Pal.WHITE); self.state = "pressed_b"
elseif inp.new.up and self.cursor > 1 then self.cursor = self.cursor - 1; self.state = "highlight"
elseif inp.new.down and self.cursor < #self.items then self.cursor = self.cursor + 1; self.state = "highlight" end
elseif inp.new.up and self.cursor > 1 then self.cursor = self.cursor - 1; self:_fixScroll(); self.state = "highlight"
elseif inp.new.down and self.cursor < #self.items then self.cursor = self.cursor + 1; self:_fixScroll(); self.state = "highlight" end
elseif state == "pressed_a" and not self.pal:fadeActive() then
local item = self.items[self.cursor]
if item == "CONTINUE" then result = { action = "continue" }
elseif item == "NEW_GAME" then result = "newGame"
elseif item == "OPTION" then self:_openOptions()
elseif item == "EXIT" then result = { action = "exit" }
elseif item == "MYSTERY_EVENTS" then
require("src.core.game3.audio").fadeOutBgm(0)
self.printer = nil
@@ -126,7 +138,7 @@ function Menu:frame(inp)
local opts = self.game and self.game.options
if opts then self.textSpeed = require("src.core.game3.options").block(opts).textSpeed end
if self.bootState then self.bootState.textSpeed = self.textSpeed end
self.options, self.cursor, self.state, self.saveStatus = nil, 1, "check_save", "ok"
self.options, self.cursor, self.scroll, self.state, self.saveStatus = nil, 1, 0, "check_save", "ok"
self.pal:beginFade(Pal.ALL, 0, 16, 0, Pal.BLACK)
end
self.pal:updateFade()
@@ -148,17 +160,18 @@ function Menu:colors()
info = { bg = normal.bg, fg = Kit.color555(gender), shadow = normal.shadow }, fill = normal.bg,
backdrop = Kit.color555(self.manifest.mainMenuPalette[1]) }
end
function Menu:_drawContinue(c)
function Menu:_drawContinue(c, dy)
local i = self.info or {}
Font.draw(RomText.plain("gMainMenuString_Player"), 16, 24, { colors = c.info })
Font.draw(i.name or "", 72, 24, { colors = c.info })
Font.draw(RomText.plain("gMainMenuString_Time"), 128, 24, { colors = c.info })
local y1, y2 = 24 - dy, 40 - dy
Font.draw(RomText.plain("gMainMenuString_Player"), 16, y1, { colors = c.info })
Font.draw(i.name or "", 72, y1, { colors = c.info })
Font.draw(RomText.plain("gMainMenuString_Time"), 128, y1, { colors = c.info })
local time = string.format("%d:%02d", i.hours or 0, i.minutes or 0)
Font.draw(time, 224 - Font.measure(time), 24, { colors = c.info })
Font.draw(RomText.plain("gMainMenuString_Pokedex"), 16, 40, { colors = c.info })
Font.draw(tostring(i.dexCount or 0), 72, 40, { colors = c.info })
Font.draw(RomText.plain("gMainMenuString_Badges"), 128, 40, { colors = c.info })
Font.draw(tostring(i.badges or 0), 205, 40, { colors = c.info })
Font.draw(time, 224 - Font.measure(time), y1, { colors = c.info })
Font.draw(RomText.plain("gMainMenuString_Pokedex"), 16, y2, { colors = c.info })
Font.draw(tostring(i.dexCount or 0), 72, y2, { colors = c.info })
Font.draw(RomText.plain("gMainMenuString_Badges"), 128, y2, { colors = c.info })
Font.draw(tostring(i.badges or 0), 205, y2, { colors = c.info })
end
local canvas
local function composite(img, rect)
@@ -187,17 +200,22 @@ function Menu:draw()
canvas = canvas or love.graphics.newCanvas(240, 160)
canvas:setFilter("nearest", "nearest")
love.graphics.push("all"); love.graphics.setCanvas(canvas); love.graphics.origin(); love.graphics.clear(0, 0, 0, 0)
local dy = self.scroll or 0
for index, item in ipairs(self.items) do
local w = Menu.windowFor(self.menuType, index)
Chrome.stdFrame(w.left, w.top, w.width, w.height)
Font.draw(RomText.plain(LABEL[item]), w.left * 8, w.top * 8, { colors = c.headers })
if item == "CONTINUE" then self:_drawContinue(c) end
Chrome.stdFrame(w.left, w.top - dy / 8, w.width, w.height)
Font.draw(RomText.plain(LABEL[item]), w.left * 8, w.top * 8 - dy, { colors = c.headers })
if item == "CONTINUE" then self:_drawContinue(c, dy) end
end
love.graphics.pop()
local w = Menu.windowFor(self.menuType, self.cursor)
local rect = { 9, (w.top - 1) * 8 + 1, 231, (w.top + w.height + 1) * 8 - 1 }
local rect = { 9, (w.top - 1) * 8 + 1 - dy, 231, (w.top + w.height + 1) * 8 - 1 - dy }
if self.state == "pressed_b" then rect = { 0, 0, 240, 160 } end
composite(canvas, rect)
local last = Menu.windowFor(self.menuType, #self.items)
local Arrow = require("src.ui.game3.rs.scroll_arrow")
if (last.top + last.height + 1) * 8 - dy > 160 then Arrow.draw("down", 0x78, 0x98, self.blink) end
if dy > 0 then Arrow.draw("up", 0x78, 8, self.blink) end
end
if error then
Chrome.stdFrame(3, 15, 24, 4)
+13 -33
View File
@@ -23,28 +23,16 @@ local function page()
return Menu._pages and Menu._pages[#Menu._pages]
end
local function portRows()
local skip = {}
for _, key in ipairs(KEYS) do skip[key] = true end
for key in pairs(Menu.EXCLUDE) do skip[key] = true end
local rows = {}
for _, r in ipairs(Rows.build(ctx(), skip)) do
if not skip[r.id] then rows[#rows + 1] = r end
end
return Rows.group(rows, function(title, members)
Menu._pages[#Menu._pages + 1] = {title = title, rows = members, index = 1, scroll = 0}
end)
end
local function topRows()
local rows = {}
for i = 1, NATIVE do rows[i] = {native = i} end
for _, r in ipairs(portRows()) do rows[#rows + 1] = {port = r} end
return rows
local cartRows = {}
for i, key in ipairs(KEYS) do cartRows[key] = {id = key, native = i} end
return Rows.withCart(ctx(), cartRows, function(title, members)
Menu._pages[#Menu._pages + 1] = {title = title, rows = members, index = 1, scroll = 0}
end, Menu.EXCLUDE)
end
local function rowCount(p)
return p.top and #p.rows or #p.rows + 1
return #p.rows + 1
end
local function clampScroll(p)
@@ -162,20 +150,14 @@ function Menu.handleInput(input)
local total = rowCount(p)
local row = p.rows[p.index]
if pressed(input, "a") then
if p.top and row.native == NATIVE then Menu.back()
elseif p.top and row.port then activatePort(row.port)
elseif not p.top then
if row then activatePort(row) else Menu.back() end
end
if not row then Menu.back()
elseif not row.native then activatePort(row) end
elseif pressed(input, "b") then Menu.back()
elseif pressed(input, "up") then p.index = (p.index - 2) % total + 1
elseif pressed(input, "down") then p.index = p.index % total + 1
elseif p.top and row.native and row.native < NATIVE then stepNative(row.native, input)
else
local port = p.top and row.port or (not p.top and row)
if port and port.step and (pressed(input, "left") or pressed(input, "right")) then
if port.step(ctx(), pressed(input, "left") and -1 or 1) then persist() end
end
elseif row and row.native then stepNative(row.native, input)
elseif row and row.step and (pressed(input, "left") or pressed(input, "right")) then
if row.step(ctx(), pressed(input, "left") and -1 or 1) then persist() end
end
local cur = page()
if cur then clampScroll(cur) end
@@ -228,10 +210,8 @@ local function drawContents()
if idx > rowCount(p) then break end
local y = baseY + (slot - 1) * 16
local row = p.rows[idx]
if p.top and row.native then
drawNative(row.native, -p.scroll * 16)
elseif p.top then
drawPort(row.port, y)
if row and row.native then
drawNative(row.native, y - d.rows[row.native].y)
elseif row then
drawPort(row, y)
else
+1 -1
View File
@@ -46,7 +46,7 @@ end
Data.window = {left = 23, top = 1, width = 6}
Data.textX, Data.textY, Data.rowPitch = 0, 8, 16
Data.maxVisible, Data.dexNeedsSeen, Data.exitConfirms = 8, true, false
Data.maxVisible, Data.dexNeedsSeen, Data.exitConfirms = 8, true, true
function Data.drawCursor(x, y)
require("src.ui.game3.rs.menu_cursor").draw(x, y, 48)
end
+1 -9
View File
@@ -64,15 +64,7 @@ end
local function portRows()
local cartRows = {}
for _, r in ipairs(OptionMenu.CART) do cartRows[r.id] = { id = r.id, cart = r } end
local rows = {}
for _, r in ipairs(Rows.build(st.ctx)) do
rows[#rows + 1] = cartRows[r.id] or r
cartRows[r.id] = nil
end
for _, r in ipairs(OptionMenu.CART) do
if cartRows[r.id] then rows[#rows + 1] = cartRows[r.id] end
end
return Rows.group(rows, function(title, members)
return Rows.withCart(st.ctx, cartRows, function(title, members)
st.pages[#st.pages + 1] = { title = title, rows = members, index = 1, scroll = 0 }
end)
end
+148 -66
View File
@@ -149,6 +149,9 @@ local HEAL_FLASH_MAP_GBC = { [0] = 0, [1] = 0, [2] = 1, [3] = 2 }
-- engine/overworld/healing_machine.asm:13-14
local HEAL_OBP1_MAP = { [0] = 0, [1] = 0, [2] = 2, [3] = 3 }
-- home/fade.asm:67
local OBP_IDENTITY = { [0] = 0, [1] = 1, [2] = 2, [3] = 3 }
-- engine/overworld/healing_machine.asm:50
local HEAL_OBP1_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 0, [3] = 3 }
@@ -2658,9 +2661,8 @@ function OverworldState:tryBookshelf(fx, fy)
Game.stack:push(TextBox.new(Game, t._ElevatorText
or Strings("An elevator!")))
elseif kind == "statues" then
-- IndigoPlateauStatues: the plaque, then one of the two lines
-- keyed by the statue's column (XCoord bit 0)
local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2
-- engine/events/hidden_events/indigo_plateau_statues.asm:5
local line = (self.player.cellX % 2 == 1) and t._IndigoPlateauStatuesText2
or t._IndigoPlateauStatuesText3
Game.stack:push(TextBox.new(Game,
(t._IndigoPlateauStatuesText1 or romText(Game.data, "_IndigoPlateauStatuesText1", "INDIGO PLATEAU")) .. "\f"
@@ -3735,6 +3737,24 @@ function OverworldState:openOaksPC(onDone)
end))
end
-- audio/pokedex_rating_sfx.asm:26
local DEX_RATING_SFX = {
{ 10, "Denied" },
{ 40, "Pokedex_Rating" },
{ 60, "Get_Item1" },
{ 90, "Caught_Mon" },
{ 120, "Level_Up" },
{ 150, "Get_Key_Item" },
{ math.huge, "Get_Item2" },
}
-- audio/pokedex_rating_sfx.asm:1
function OverworldState.dexRatingSfx(owned)
for _, row in ipairs(DEX_RATING_SFX) do
if owned < row[1] then return row[2] end
end
end
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
-- the completion line with seen AND owned counts, then the per-decade
-- rating text.
@@ -3761,7 +3781,14 @@ function OverworldState:dexRating(onDone)
-- the box to the plain A/B path once the jingle has sounded.
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(Game.data, "Pokedex_Rating")
local Sound = require("src.core.Sound")
local name = OverworldState.dexRatingSfx(owned)
local src = Sound.play(Game.data, name)
-- audio/pokedex_rating_sfx.asm:13
if src and not Sound.ducksMusic(Game.data, name) then
require("src.core.Music").duckForFanfare(src)
end
return src
end },
}))
end
@@ -4930,13 +4957,12 @@ function OverworldState:checkBadgeGate()
end
return false
end
-- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED
require("src.core.Sound").play(Game.data, "Denied")
-- scripts/Route22Gate.asm:79
Game.stack:push(TextBox.new(Game,
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
TextBox.strip(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
.. TextBox.PAUSE .. (t._Route22GateGuardICantLetYouPassText or ""), function()
self:scriptMove(p, "down", 1, nil, { collide = true })
end))
end, { pauseSounds = { "Denied" }, pauseSoundWait = true }))
return true
end
end
@@ -4947,31 +4973,39 @@ function OverworldState:checkBadgeGate()
for _, guard in ipairs(g.guards) do
if p.cellY == guard.y and (not guard.maxX or p.cellX <= guard.maxX)
and not Game.save.flags[guard.event] then
local badgeName = Game.data.items[guard.badge]
and Game.data.items[guard.badge].name or guard.badge
if Game.save.inventory[guard.badge] then
Flags.set(Game.save, guard.event)
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
local text = (t["_" .. g.passText] or
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text,
nil, TextBox.soundOpts(Game, "Get_Item1")))
return false
end
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
require("src.core.Sound").play(Game.data, "Denied")
local text = (t["_" .. g.failText] or
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text, function()
self:scriptMove(p, "down", 1, nil, { collide = true })
end))
return true
return not self:route23BadgeCheck(guard.badge, guard.event)
end
end
end
return false
end
-- scripts/Route23.asm:195
function OverworldState:route23BadgeCheck(badge, event, onDone)
local gates = Game.data.field.badgeGates
local g = gates and gates[self.map.id] or {}
local t = Game.data.text
local badgeName = Game.data.items[badge] and Game.data.items[badge].name or badge
if Game.save.inventory[badge] then
Flags.set(Game.save, event)
-- scripts/Route23.asm:237
local text = (t["_" .. (g.passText or "Route23OhThatIsTheBadgeText")] or
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game,
TextBox.strip(text) .. TextBox.PAUSE .. (t._Route23GoRightAheadText or ""),
onDone, { pauseSounds = { "Get_Item1" }, pauseSoundWait = true }))
return true
end
-- scripts/Route23.asm:229
local text = (t["_" .. (g.failText or "Route23YouDontHaveTheBadgeYetText")] or
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text, function()
-- scripts/Route23.asm:123
self:scriptMove(self.player, "down", 1, onDone, { collide = true })
end, TextBox.soundOpts(Game, "Denied")))
return false
end
-- Forced bike/surf tiles (data/maps/force_bike_surf.asm): the Cycling
-- Road entrances force you onto the BICYCLE (or turn you back without
-- one); the Seafoam current mouths force surfing.
@@ -6014,6 +6048,40 @@ local function fieldFxImage(path, image, group)
return SpriteRenderer.obpImage(path, colors, name)
end
-- engine/overworld/dust_smoke.asm:21
local OBP1_FLASH = { [0] = 0, [1] = 0, [2] = 0, [3] = 2 }
local FLASH_GRAYS = PaletteFX.permute(PaletteFX.GRAYS, OBP1_FLASH)
local fieldFxFlashColors = setmetatable({}, { __mode = "k" })
local fieldFxFlashNames = {}
local function fieldFxFlashImage(path, group)
local colors, key = FLASH_GRAYS, "gray"
if PaletteFX.usesGbcPack() then
local pack = PaletteFX.worldPack()
local obj = pack and pack.spritePalettes and pack.spritePalettes[group]
if obj then
colors = fieldFxFlashColors[obj]
if not colors then
colors = PaletteFX.permute(obj, OBP1_FLASH)
fieldFxFlashColors[obj] = colors
end
key = group
end
end
local version = GameVersion.get()
local names = fieldFxFlashNames[version]
if not names then
names = {}
fieldFxFlashNames[version] = names
end
local name = names[key]
if not name then
name = "fieldfxflash:" .. version .. ":" .. key
names[key] = name
end
return SpriteRenderer.obpImage(path, colors, name)
end
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
local function fxDust(self, cam)
@@ -6027,12 +6095,19 @@ local function fxDust(self, cam)
end
if self.smokeImg then
local da = self.dustAnim
local img = fieldFxImage(smoke.path, self.smokeImg, da.boulder and 7 or 6)
local group = da.boulder and 7 or 6
local flash
if da.boulder then
flash = da.faded
else
-- engine/overworld/cut2.asm:68
flash = da.frames % 2 == 1
end
local img = flash and fieldFxFlashImage(smoke.path, group)
or fieldFxImage(smoke.path, self.smokeImg, group)
local dx = da.x * 16 + (da.ox or 0) - cam.x
local dy = da.y * 16 + (da.oy or 0) - cam.y
local flicker = math.floor(da.frames / 4) % 2 == 0
if da.boulder then flicker = not da.faded end
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
love.graphics.setColor(1, 1, 1, 1)
for i = 0, 1 do
for j = 0, 1 do
love.graphics.draw(img, dx + i * 8, dy + j * 8)
@@ -6068,8 +6143,9 @@ local function fxCutTree(self, cam)
local off = (ca.total or 8) - ca.frames
local dx = ca.x * 16 - cam.x
local dy = ca.y * 16 - cam.y
local flicker = ca.frames % 2 == 0
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
-- engine/overworld/cut2.asm:18
if ca.frames % 2 == 1 then img = fieldFxFlashImage(tree.path, 6) end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
love.graphics.setColor(1, 1, 1, 1)
@@ -6223,22 +6299,31 @@ local function entityDrawOrder(a, b)
return a.pikachuFollower == true and b.pikachuFollower ~= true
end
-- Tall-grass feet overdraw queue for the flat path: each distinct grass
-- cell an entity stands on or steps into, in first-queued order. A
-- scratch pair of coordinate arrays reused every frame (the queue holds a
-- few cells, so the duplicate check is a short linear scan) instead of a
-- "cx:cy" string key and a { cx, cy } table per cell per frame.
local function queueGrass(self, q, cx, cy)
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
return
end
-- engine/overworld/movement.asm:90
local function grassStrip(self, e)
local map = self.map
local inGrass = (e.cellX ~= nil and e.cellY ~= nil
and map:isGrassCell(e.cellX, e.cellY))
or (e.targetX ~= nil and e.targetY ~= nil
and map:isGrassCell(e.targetX, e.targetY))
if not inGrass then return nil end
local sprite, px, py = nil, e.px, e.py
if e.pose then sprite, px, py = e:pose() end
return map.renderer.feetStrip(px or 0, py or 0, sprite)
end
local function queueGrass(self, q, e)
local x0, y0, x1, y1 = grassStrip(self, e)
if not x0 then return end
local n = q.n
local xs, ys = q.x, q.y
local qx0, qy0, qx1, qy1 = q.x0, q.y0, q.x1, q.y1
for i = 1, n do
if xs[i] == cx and ys[i] == cy then return end
if qx0[i] == x0 and qy0[i] == y0 and qx1[i] == x1 and qy1[i] == y1 then
return
end
end
n = n + 1
xs[n], ys[n] = cx, cy
qx0[n], qy0[n], qx1[n], qy1[n] = x0, y0, x1, y1
q.n = n
end
@@ -6289,7 +6374,7 @@ function OverworldState:drawWorld()
-- home/fade.asm:52-58
-- home/fade.asm:65-73
local fadeObp = fade.obp0 and fade:obp0() or fadeBgp
PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp))
PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp) or OBP_IDENTITY)
fade.paletteStepped = true
elseif self.emote and self.emote.bgp and not battleOverWorld then
-- engine/pikachu/pikachu_pic_animation.asm:847
@@ -6498,10 +6583,10 @@ function OverworldState:drawWorld()
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
local boulderDust = self.dustAnim and self.dustAnim.boulder
local grass = self.grassScratch
local grass = self.grassStripScratch
if not grass then
grass = { n = 0, x = {}, y = {} }
self.grassScratch = grass
grass = { n = 0, x0 = {}, y0 = {}, x1 = {}, y1 = {} }
self.grassStripScratch = grass
end
grass.n = 0
if boulderDust then fxDust(self, cam) end
@@ -6517,18 +6602,18 @@ function OverworldState:drawWorld()
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
and e == self.player) and not self:oamCulled(e) then
e:draw(cam.x, cam.y)
queueGrass(self, grass, e.cellX, e.cellY)
queueGrass(self, grass, e.targetX, e.targetY)
queueGrass(self, grass, e)
end
end
love.graphics.setColor(1, 1, 1, 1)
local gxs, gys = grass.x, grass.y
local shake = bgY - cam.y
for i = 1, grass.n do
local cx, cy = gxs[i], gys[i]
self.map.renderer:drawCellBottom(cx, cy, cam.x, bgY)
local x0, y0 = grass.x0[i], grass.y0[i] + shake
local x1, y1 = grass.x1[i], grass.y1[i] + shake
self.map.renderer:drawStrip(x0, y0, x1, y1, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(cx, cy,
cam.x, bgY, grassColors)
self.map.renderer:markStripRedraw(x0, y0, x1, y1,
cam.x, bgY, grassColors)
end
end
fxHeal(self, cam)
@@ -6588,18 +6673,15 @@ function OverworldState:drawWorld()
function() e:draw(cam.x, cam.y) end)
-- tall-grass feet overdraw glued to the sprite: same anchor + depth
-- so it keeps hiding the feet, color-0-keyed palette so its white
-- gaps still show the sprite through (drawCellBottomRaw lets the
-- gaps still show the sprite through (drawStripRaw lets the
-- billboard own the shader; bgY keeps the elevator-shake offset).
if self.map:isGrassCell(e.cellX, e.cellY) then
local x0, y0, x1, y1 = grassStrip(self, e)
if x0 then
local shake = bgY - cam.y
self:billboard(fx, fy, vw, vh, colors, true, function()
love.graphics.setColor(1, 1, 1, 1)
self.map.renderer:drawCellBottomRaw(e.cellX, e.cellY, cam.x, bgY)
end)
end
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
self:billboard(fx, fy, vw, vh, colors, true, function()
love.graphics.setColor(1, 1, 1, 1)
self.map.renderer:drawCellBottomRaw(e.targetX, e.targetY, cam.x, bgY)
self.map.renderer:drawStripRaw(x0, y0 + shake, x1, y1 + shake,
cam.x, bgY)
end)
end
end
+140
View File
@@ -0,0 +1,140 @@
-- engine/menus/start_sub_menus.asm:298
-- engine/items/item_effects.asm:2172
-- tools/run_driver.sh red <identity> tests/drivers/bag_use_box_bug2657.lua <shot dir>
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local Bag = require("src.inventory.Bag")
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
local Menu = require("src.ui.Menu")
local ChoiceBox = require("src.ui.ChoiceBox")
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
if not pass then ok = false end
return pass
end
local function finish()
U.log(ok and "PASS bag_use_box_2657" or "FAIL bag_use_box_2657")
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
local function onStack(state)
for _, s in ipairs(game.stack.states) do
if s == state then return true end
end
return false
end
local function boxText(box)
local lines = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return table.concat(lines, " / ")
end
local function waitFor(pred, n)
for _ = 1, n or 300 do
if pred(game.stack:top()) then return game.stack:top() end
U.wait(1)
end
return nil
end
local function stepTo(state, wants, what, dir)
for _ = 1, 30 do
if wants(state) then return true end
U.tap(game, dir or "down")
U.wait(6)
end
check("found " .. what .. " in the menu", false)
return false
end
U.newGame(game)
if not check("new game reached the overworld", game.overworld ~= nil) then finish() end
game.save.inventory = {}
Bag.add(game.save, "POKE_BALL", 5)
Bag.add(game.save, "TM_TOXIC", 1)
U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up")
U.wait(20)
U.tap(game, "start")
U.wait(20)
local start = game.stack:top()
local ITEM = Strings("ITEM")
if not check("START opened a menu", start and start.items ~= nil) then finish() end
if stepTo(start, function(m) return m.items[m.index].label == ITEM end, "ITEM") then
U.tap(game, "a")
U.wait(20)
end
local bag = game.stack:top()
if not check("the bag opened", bag and bag.items and bag.items[1] ~= nil) then finish() end
local function useOn(id, dir)
if not stepTo(bag, function(b) return b.items[b.index].value == id end, id, dir) then finish() end
U.tap(game, "a")
U.wait(10)
local sub = game.stack:top()
if not check(id .. ": USE/TOSS opened", getmetatable(sub) == Menu and #sub.items == 2) then finish() end
U.tap(game, "a")
U.wait(2)
return sub
end
local sub = useOn("TM_TOXIC")
local booted = waitFor(function(t)
return getmetatable(t) == TextBox and (t.waiting or t.done)
and boxText(t):find("Booted up", 1, true)
end)
if not check("TM: Booted up text printed", booted ~= nil) then
local t = game.stack:top()
U.log("top:", tostring(getmetatable(t) == TextBox), t and t.done, t and t.pages and boxText(t))
finish()
end
check("TM: USE/TOSS box left painted on the bag", bag.optionBox == sub and not onStack(sub))
check("TM: hollow cursor on USE", sub.hollowIndex == 1 and sub.index == 1)
U.wait(10)
check("shot 1", U.still(game, DIR .. "/2657_01_booted_up_box_kept.png"))
local yesno = nil
for _ = 1, 40 do
yesno = getmetatable(game.stack:top()) == ChoiceBox and game.stack:top() or nil
if yesno then break end
U.tap(game, "a")
U.wait(8)
end
if not check("TM: Teach YES/NO up", yesno ~= nil) then finish() end
check("TM: box still painted under YES/NO", bag.optionBox == sub)
U.wait(10)
check("shot 2", U.still(game, DIR .. "/2657_02_teach_yes_no_over_box.png"))
U.tap(game, "b")
waitFor(function(t) return t == bag end, 120)
U.wait(5)
check("TM: NO returns to the bag list", game.stack:top() == bag)
check("TM: the list's redraw erased the box", bag.optionBox == nil)
check("shot 3", U.still(game, DIR .. "/2657_03_list_redrawn.png"))
local sub2 = useOn("POKE_BALL", "up")
local refusal = waitFor(function(t)
return getmetatable(t) == TextBox and (t.waiting or t.done)
end)
if check("BALL: refusal text printed", refusal ~= nil) then
U.log("the box reads:", boxText(refusal))
end
check("BALL: USE/TOSS box left painted on the bag", bag.optionBox == sub2)
U.wait(10)
check("shot 4", U.still(game, DIR .. "/2657_04_refusal_box_kept.png"))
for _ = 1, 20 do
if game.stack:top() == bag then break end
U.tap(game, "a")
U.wait(8)
end
U.wait(3)
check("BALL: back on the list", game.stack:top() == bag)
check("BALL: box erased", bag.optionBox == nil)
finish()
end
@@ -0,0 +1,156 @@
-- engine/battle/common_text.asm:10
-- engine/battle/common_text.asm:43
local U = require("tests.drivers.util")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local Version = require("src.core.GameVersion")
local Font = require("src.render.Font")
return function(game)
local deadline = love.timer.getTime() + 60
local fails = 0
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
return ok
end
local ok, err = xpcall(function()
assert(Version.generation() == 1, "Gen1 required")
local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required")
local function wait(n)
for _ = 1, n do
assert(love.timer.getTime() < deadline, "#2662 deadline")
U.wait(1)
end
end
local function snapshot(battle)
local rows, strings = 0, {}
local realRow, realFont, realDraw = battle.drawBallRow, Font.draw, love.graphics.draw
battle.drawBallRow = function() rows = rows + 1 end
Font.draw = function(text) strings[#strings + 1] = tostring(text) return 0 end
love.graphics.draw = function() end
pcall(battle.drawHUDs, battle, 0)
battle.drawBallRow, Font.draw, love.graphics.draw = realRow, realFont, realDraw
local hud = false
for _, s in ipairs(strings) do
if s:find(battle.enemy.name, 1, true) then hud = true end
end
return rows, hud
end
game:startNewGame({ intro = false })
game.save.options.textSpeed = 1
local function freshParty()
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 12),
Pokemon.new(game.data, "PIDGEY", 9) }
end
local function introText(b)
local cur = b.current and b.current.text
if cur == b.introText then return true end
return b.current == nil and b.queue[1] ~= nil and b.queue[1].text == b.introText
end
local function watch(label, battle, holdShot)
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
wait(5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
local r = { early = false, during = false, hudEarly = false, rowsEarly = false,
heldFrames = 0, holdShot = false }
local update = battle.update
battle.update = function(self, ...)
update(self, ...)
if (self.introSlide or 0) ~= 0 or game.stack:top() ~= self then return end
local atText = introText(self)
if self.introBalls == true and not atText then r.early = true end
if self.introBalls == true and self.waitingSound then r.during = true end
if not atText and not r.reachedText then
local rows, hud = snapshot(self)
if rows > 0 then r.rowsEarly = true end
if hud then r.hudEarly = true end
if self.waitingSound or (self.waitFrames or 0) > 0 then
r.heldFrames = r.heldFrames + 1
end
end
if atText then r.reachedText = true end
end
local shotHeld = false
for _ = 1, 3000 do
wait(1)
if game.stack:top() == battle and (battle.introSlide or 0) == 0
and not shotHeld and (battle.waitingSound or (battle.waitFrames or 0) > 0)
and not introText(battle) then
shotHeld = U.still(game, out .. "/" .. holdShot)
r.holdShot = shotHeld and battle.introBalls ~= true
end
if battle.current and battle.current.text == battle.introText then break end
end
return r
end
local function waitPrompt(b)
for _ = 1, 600 do
if b.msgPrompt then return true end
wait(1)
end
return false
end
freshParty()
local wild = BattleState.newWild(game, "RATTATA", 3)
local r = watch("wild", wild, "2662_01_wild_cry_no_balls.png")
check("wild: the cry held the queue after the slide landed", r.heldFrames > 0)
check("wild: captured the cry frame with no ball window", r.holdShot)
check("wild: no ball window before the intro text", not r.early)
check("wild: no ball window while the cry sounds", not r.during)
check("wild: no ball row drawn during the cry", not r.rowsEarly)
check("wild: no enemy HUD drawn during the cry", not r.hudEarly)
check("wild: the ball window is open with the intro text", wild.introBalls == true)
waitPrompt(wild)
check("wild: balls and text shot", U.still(game, out .. "/2662_02_wild_balls_with_text.png"))
freshParty()
local tr = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
r = watch("trainer", tr, "2662_03_trainer_sfx_no_balls.png")
check("trainer: the sfx and gap held the queue", r.heldFrames > 0)
check("trainer: captured the sfx frame with no ball window", r.holdShot)
check("trainer: no ball window before the intro text", not r.early)
check("trainer: no ball window while the sfx sounds", not r.during)
check("trainer: no ball rows drawn during the sfx and gap", not r.rowsEarly)
check("trainer: the ball window is open with the intro text", tr.introBalls == true)
waitPrompt(tr)
check("trainer: balls and text shot", U.still(game, out .. "/2662_04_trainer_balls_with_text.png"))
freshParty()
local ghost = BattleState.newWild(game, "GASTLY", 20)
ghost:makeGhost()
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
wait(5)
ghost.onFinish = function() end
game.overworld:pushBattle(ghost)
local ghostBalls, sawDarn = false, false
for _ = 1, 3000 do
wait(1)
if ghost.introBalls == true then ghostBalls = true end
local cur = ghost.current and ghost.current.text
if cur and cur:find("ID'd", 1, true) then sawDarn = true end
if ghost.msgPrompt then
if sawDarn then break end
wait(8)
U.tap(game, "a")
end
end
check("ghost: GhostCantBeIDdText follows the intro text", sawDarn)
check("ghost: DrawAllPokeballs never runs", not ghostBalls)
if sawDarn then
check("ghost: cant be IDd shot", U.still(game, out .. "/2662_05_ghost_cant_be_idd.png"))
end
end, debug.traceback)
if not ok then print("FAIL #2662 driver error: " .. tostring(err)) fails = fails + 1 end
love.event.quit(fails == 0 and 0 or 1)
end
+10 -8
View File
@@ -6,7 +6,7 @@
-- POKEPORT_DRIVER=tests/drivers/battle_intro_bug317_test.lua POKEPORT_IDENTITY=bug317 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local HudTiles = require("src.render.HudTiles")
@@ -19,8 +19,10 @@ return function(game)
local TRAINER = "ROUTE3_YOUNGSTER1"
local STAND = { x = 13, y = 6, facing = "left" }
local fails = 0
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then fails = fails + 1 end
return ok
end
@@ -103,8 +105,9 @@ return function(game)
local wild = BattleState.newWild(game, "RATTATA", 3)
wild.onFinish = function() end
if ow then ow:pushBattle(wild) end
for _ = 1, 400 do
if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end
for _ = 1, 600 do
if game.stack:top() == wild and (wild.introSlide or 0) == 0
and wild.current and wild.current.text == wild.introText then break end
U.wait(1)
end
check("the wild battle reached the screen",
@@ -226,8 +229,9 @@ return function(game)
if ow then ow:pushBattle(battle) end
end
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
for _ = 1, 600 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0
and battle.current and battle.current.text == battle.introText then break end
U.wait(1)
end
check("a trainer battle is on screen", liveBattle() ~= nil)
@@ -249,7 +253,5 @@ return function(game)
U.log("own edge before its send-out text prints (#317).")
U.log("Screenshots of the wild half: " .. DIR .. "/bug317_*.png")
while true do
coroutine.yield()
end
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,150 @@
-- engine/overworld/dust_smoke.asm:21
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local OW = require("src.world.OverworldController")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "SEAFOAM_ISLANDS_1F"
local DIRS = { "right", "left", "down", "up" }
local STEP = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } }
local PUSH = "left"
local ok = true
local function check(label, pass)
print((pass and "PASS " or "FAIL ") .. label)
if not pass then ok = false end
return pass
end
local realDraw = love.graphics.draw
local function finish()
love.graphics.draw = realDraw
for _, d in ipairs(DIRS) do game.input.state[d] = false end
print(ok and "PASS boulder_dust_color_2650" or "FAIL boulder_dust_color_2650")
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
local function upvalue(fn, wanted)
for i = 1, 200 do
local name, value = debug.getupvalue(fn, i)
if not name then return nil end
if name == wanted then return value end
end
end
local fxDust = upvalue(OW.drawWorld, "fxDust")
local flashImage = fxDust and upvalue(fxDust, "fieldFxFlashImage")
if not check("fxDust has an OBP1 flash bake helper", flashImage ~= nil) then finish() end
U.teleport(game, MAP, 17, 10, "right")
U.wait(10)
PaletteFX.setMode("ogred")
local ow = game.overworld
game.data.encounters[MAP] = nil
local rock = ow and ow:npcAtCell(18, 10)
if not check("boulder present at (18,10) on " .. MAP,
rock ~= nil and rock.def.sprite == "SPRITE_BOULDER") then
finish()
end
ow.strengthActive = true
local function free(x, y)
if not (ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then return false end
local n = ow:npcAtCell(x, y)
return n == nil or n == rock
end
local s = STEP[PUSH]
local cx, cy
for y = 9, 40 do
for x = 0, 60 do
if not cx and free(x, y) and free(x + s[1], y + s[2]) and free(x + 2 * s[1], y + 2 * s[2]) then
cx, cy = x, y
end
end
end
if not check("found an open lane for a push", cx ~= nil) then finish() end
local function setup()
for _, d in ipairs(DIRS) do game.input.state[d] = false end
ow.dustAnim = nil
ow.boulderTried = nil
rock.cellX, rock.cellY = cx + s[1], cy + s[2]
rock.px, rock.py = rock.cellX * 16, rock.cellY * 16
rock.moving, rock.targetX, rock.targetY = false, nil, nil
local p = ow.player
p.cellX, p.cellY = cx, cy
p.px, p.py = cx * 16, cy * 16
p.moving, p.targetX, p.targetY = false, nil, nil
p.facing = PUSH
U.wait(8)
end
local function pushUntilDust()
local first = true
for _ = 1, 300 do
if ow.dustAnim and ow.dustAnim.boulder then return ow.dustAnim end
if first then table.insert(game.input.pressQueue, PUSH); first = false end
game.input.state[PUSH] = true
U.wait(1)
end
return nil
end
local rec
love.graphics.draw = function(img, ...)
if rec and (img == rec.flash or img == ow.smokeImg) then
local _, _, _, a = love.graphics.getColor()
rec.n = rec.n + 1
if (img == rec.flash) ~= rec.faded then rec.wrongImg = rec.wrongImg + 1 end
if a ~= 1 then rec.translucent = rec.translucent + 1 end
end
return realDraw(img, ...)
end
local function renderFrame(da, flash)
local update = rawget(game, "update")
game.update = function() end
rec = { flash = flash, faded = da.faded, n = 0, wrongImg = 0, translucent = 0 }
for _ = 1, 4000 do
if rec.n >= 4 then break end
coroutine.yield()
end
game.update = update
local r = rec
rec = nil
return r
end
setup()
local da = pushUntilDust()
if not check("boulder dust starts after the push", da ~= nil) then finish() end
game.input.state[PUSH] = false
local flash = flashImage(game.data.field.overworldFx.smoke.path, 7)
local frames, flashFrames, wrong, translucent, missing = 0, 0, 0, 0, 0
while ow.dustAnim == da and frames < 60 do
local r = renderFrame(da, flash)
frames = frames + 1
if r.faded then flashFrames = flashFrames + 1 end
if r.n < 4 then missing = missing + 1 end
wrong = wrong + r.wrongImg
translucent = translucent + r.translucent
U.wait(1)
end
print(("[driver] dust frames %d flash %d wrong %d translucent %d missing %d")
:format(frames, flashFrames, wrong, translucent, missing))
check("every dust frame rendered", missing == 0 and frames >= 23)
check("OBP1 %10000000 frames use the white/shade-2 flash bake", flashFrames >= 12 and wrong == 0)
check("dust is never drawn translucent", translucent == 0)
U.wait(30)
setup()
da = pushUntilDust()
game.input.state[PUSH] = false
if da then
while ow.dustAnim == da and not da.faded do U.wait(1) end
U.still(game, DIR .. "/2650_01_dust_flash_white.png")
while ow.dustAnim == da and da.faded do U.wait(1) end
if ow.dustAnim == da then
U.still(game, DIR .. "/2650_02_dust_identity_floor_ramp.png")
end
end
U.wait(30)
finish()
end
+107
View File
@@ -0,0 +1,107 @@
-- scripts/ChampionsRoom.asm:65
-- tools/run_driver.sh red <identity> tests/drivers/champion_end_text_bug2660.lua <shotdir>
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("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
print((ok and "PASS " or "FAIL ") .. label)
return ok
end
local function oneline(s)
return (tostring(s):gsub("[\n\v\f]", " / "))
end
local function finish()
U.log("done:", failures, "failure(s)")
love.event.quit(failures == 0 and 0 or 1)
while true do coroutine.yield() end
end
local expected = game.data.text and game.data.text._RivalDefeatedText
check("cache carries _RivalDefeatedText", type(expected) == "string")
local mon = Pokemon.new(game.data, "MEWTWO", 100)
mon.moves = { { id = "PSYCHIC_M", pp = 10, ppUps = 0 } }
if not game.data.moves.PSYCHIC_M then
mon.moves = { { id = "PSYCHIC", pp = 10, ppUps = 0 } }
end
game.save.party = { mon }
game.save.player.name = "RED"
game.save.player.rival = "BLUE"
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
game.save.flags.EVENT_BEAT_CHAMPION_RIVAL = nil
U.teleport(game, "CHAMPIONS_ROOM", 4, 7, "up")
local battle, armed
local sawDefeated, sawLoss, sawMoney
local lossText, lossShot, headShot, lastText = nil, false, false, nil
for _ = 1, 20000 do
local top = game.stack:top()
if getmetatable(top) == BattleState then
if not battle then
battle = top
armed = top.endBattleText
U.log("battle pushed; endBattleText:", oneline(armed))
for _, m in ipairs(top.enemyParty or {}) do m.hp = 1 end
if top.enemy and top.enemy.mon then top.enemy.mon.hp = 1 end
end
local cur = top.current
local text = cur and cur.text
if text and text ~= lastText then
lastText = text
if text:find("defeated", 1, true) then sawDefeated = sawDefeated or U.frame() end
if text:find("NO!", 1, true) and text:find("That can't be!", 1, true) then
sawLoss = sawLoss or U.frame()
lossText = text
end
if text:find("for winning", 1, true) then sawMoney = sawMoney or U.frame() end
end
local typingLoss = lossText and text == lossText
if typingLoss and not headShot then
if top.lineIndex == 2 and top.msgWaiting
and (top.frame or 0) % 60 < 30 then
headShot = U.still(game, DIR .. "/2660_01_rival_loss_first_page.png")
end
end
if typingLoss and not lossShot
and top.charIndex and top.total and top.charIndex >= top.total then
lossShot = U.still(game, DIR .. "/2660_02_rival_loss_scrolled.png")
end
if sawMoney then break end
if typingLoss and not headShot then
U.wait(1)
else
U.tap(game, "a")
U.wait(3)
end
elseif battle then
break
else
U.tap(game, "a")
U.wait(3)
end
end
check("champion battle pushed", battle ~= nil)
check("battle armed with _RivalDefeatedText",
type(armed) == "string" and armed:find("That can't be!", 1, true) ~= nil)
check("defeated line printed", sawDefeated ~= nil)
check("rival loss line printed on the battle screen", sawLoss ~= nil)
if lossText then
U.log("loss line:", oneline(lossText))
check("loss line opens with the rival name tag", lossText:sub(1, 6) == "BLUE: ")
end
check("money line printed", sawMoney ~= nil)
check("loss line sits between defeated and money",
sawDefeated ~= nil and sawLoss ~= nil and sawMoney ~= nil
and sawDefeated < sawLoss and sawLoss < sawMoney)
check("loss line first page screenshotted", headShot)
check("loss line screenshotted", lossShot)
finish()
end
@@ -0,0 +1,65 @@
-- scripts/ChampionsRoom.asm:30
local U = require('tests.drivers.util')
local Version = require('src.core.GameVersion')
local TextBox = require('src.render.TextBox')
local WANT = '3,7 3,6 3,5 3,4 4,4 4,3'
return function(game)
local deadline = love.timer.getTime() + 90
local ok, err = xpcall(function()
assert(Version.generation() == 1, 'Gen1 edition required')
local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required')
love.audio.setVolume(0)
game:startNewGame({ intro = false })
local function run(shootAt)
game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
U.teleport(game, 'CHAMPIONS_ROOM', 3, 7, 'up')
local ow = assert(game.overworld)
local cells, last = {}, nil
local frames, turnFrame = 0, nil
for _ = 1, 1200 do
assert(love.timer.getTime() < deadline, '#2666 deadline')
local p = ow.player
local key = p.cellX .. ',' .. p.cellY
if key ~= last then
cells[#cells + 1] = key
last = key
if key == '3,4' and not turnFrame then
turnFrame = frames
if shootAt then
assert(U.still(game, out .. '/2666_01_turn_point_left_column.png'), 'capture failed')
end
end
end
if getmetatable(game.stack:top()) == TextBox then
return table.concat(cells, ' '), turnFrame, p.facing
end
U.wait(1)
frames = frames + 1
end
error('rival text never opened; walked ' .. table.concat(cells, ' '))
end
local path, turnFrame, facing = run(false)
if path == WANT then
print('PASS #2666 entrance walk ' .. path)
else
error('entrance walk ' .. path .. ' want ' .. WANT)
end
if facing == 'up' then
print('PASS #2666 player faces the rival')
else
error('player faces ' .. tostring(facing))
end
assert(turnFrame, 'never reached (3,4)')
run(true)
U.wait(40)
assert(U.still(game, out .. '/2666_02_rival_hey.png'), 'capture failed')
print('PASS #2666 turn point shot at frame ' .. turnFrame)
end, debug.traceback)
if not ok then print('FAIL #2666 ' .. tostring(err)) end
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,180 @@
-- engine/menus/main_menu.asm:357
return function(game)
local U = dofile("tests/drivers/util.lua")
local Font = require("src.render.Font")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local TitleState = require("src.ui.TitleState")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
or "/tmp/shots/2684"
local fails = 0
local function check(label, ok, detail)
print((ok and "PASS " or "FAIL ") .. label .. (detail and (" " .. tostring(detail)) or ""))
if not ok then fails = fails + 1 end
return ok
end
local function finish()
love.event.quit(fails == 0 and 0 or 1)
while true do coroutine.yield() end
end
local rendered = 0
local hostDraw = love.draw
love.draw = function(...)
local r = hostDraw(...)
rendered = rendered + 1
return r
end
local function waitRendered(n)
local target = rendered + (n or 2)
for _ = 1, 2400 do
if rendered >= target then return true end
U.wait(1)
end
return false
end
local function waitFor(pred, limit)
for _ = 1, limit or 1800 do
if pred() then return true end
U.wait(1)
end
return false
end
local function letterbox(img)
local W, H = img:getWidth(), img:getHeight()
local x1, y1, x2, y2 = W, H, -1, -1
for y = 0, H - 1, 2 do
for x = 0, W - 1, 2 do
local r, g, b = img:getPixel(x, y)
if r > 0.02 or g > 0.02 or b > 0.02 then
if x < x1 then x1 = x end
if x > x2 then x2 = x end
if y < y1 then y1 = y end
if y > y2 then y2 = y end
end
end
end
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
return x1, y1, bw / 160, bh / 144
end
local s = math.min(W / 160, H / 144)
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s, s
end
local function inkInTile(img, ox, oy, sx, sy, col, row)
local W, H = img:getWidth(), img:getHeight()
local n = 0
for py = 1, 6 do
for px = 1, 6 do
local x = math.floor(ox + (col * 8 + px + 0.5) * sx)
local y = math.floor(oy + (row * 8 + py + 0.5) * sy)
if x >= 0 and y >= 0 and x < W and y < H then
local r, g, b = img:getPixel(x, y)
if (r + g + b) / 3 < 0.35 then n = n + 1 end
end
end
end
return n
end
U.wait(5)
local title
local reached = false
for _ = 1, 120 do
title = game.stack:top()
if title ~= nil and title.screenId == "TitleState"
and type(title.openMenu) == "function" then
reached = true
break
end
U.tap(game, "start")
U.wait(10)
end
if not check("reached the title screen", reached) then
print("top state: " .. tostring(title and title.screenId))
return finish()
end
check("title reached its loop", waitFor(function()
return title.phase == "loop"
end))
waitRendered(3)
local saveName = SaveData.saveFilename(GameVersion.get())
if not love.filesystem.getInfo(saveName) then
love.filesystem.write(saveName, "return {}")
end
local owned = {}
for i = 1, 151 do owned[i] = true end
local inventory = {}
for _, id in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE",
"EARTHBADGE" }) do
inventory[id] = 1
end
local savedLoad = SaveData.load
SaveData.load = function()
return { player = { name = "RED" }, inventory = inventory,
pokedex = { owned = owned }, playTime = 47 * 3600 + 27 * 60 }
end
U.tap(game, "start")
check("START leaves the title for the main menu", waitFor(function()
local top = game.stack:top()
return title.menuOpen and top ~= title and top and top.titleUiBox ~= nil
end, 1200))
waitRendered(3)
local menu = game.stack:top()
check("main menu opens on CONTINUE", menu and menu.items
and menu.items[1] and menu.items[1].label == require("src.core.Strings")("CONTINUE"))
U.tap(game, "a")
local info
check("CONTINUE opens the info window", waitFor(function()
info = game.stack:top()
return info ~= menu and info and info.titleUiBox
and info.titleUiBox[1] == 4 and info.titleUiBox[2] == 7
end, 300))
SaveData.load = savedLoad
if not info or info == menu then return finish() end
local drawn = {}
local realDraw = Font.draw
Font.draw = function(text, x, y, ...)
drawn[text] = { x, y }
return realDraw(text, x, y, ...)
end
waitRendered(3)
Font.draw = realDraw
local b, d, t = drawn[" 8"], drawn["151"], drawn[" 47:27"]
check("badge count drawn at hlcoord 17,11",
b and b[1] == 17 * 8 and b[2] == 11 * 8, b and (b[1] .. "," .. b[2]))
check("dex count drawn at hlcoord 16,13",
d and d[1] == 16 * 8 and d[2] == 13 * 8, d and (d[1] .. "," .. d[2]))
check("play time drawn at hlcoord 13,15",
t and t[1] == 13 * 8 and t[2] == 15 * 8, t and (t[1] .. "," .. t[2]))
local shot = SHOT_DIR .. "/2684_01_continue_counts_flush_right.png"
if check("info window shot", U.still(game, shot)) then
local f = io.open(shot, "rb")
local bytes = f and f:read("*a")
if f then f:close() end
local ok, img = pcall(function()
return love.image.newImageData(love.filesystem.newFileData(bytes, "s.png"))
end)
if check("shot decodes", ok and img) then
local ox, oy, sx, sy = letterbox(img)
check("badge 8 inks col 18 row 11", inkInTile(img, ox, oy, sx, sy, 18, 11) > 0)
check("col 17 row 11 stays blank", inkInTile(img, ox, oy, sx, sy, 17, 11) == 0)
check("dex 151 inks col 18 row 13", inkInTile(img, ox, oy, sx, sy, 18, 13) > 0)
check("col 15 row 13 stays blank", inkInTile(img, ox, oy, sx, sy, 15, 13) == 0)
check("time inks col 18 row 15", inkInTile(img, ox, oy, sx, sy, 18, 15) > 0)
end
end
game.stack:pop()
waitRendered(2)
print(fails == 0 and "PASS continue_info_counts_bug2684" or "FAIL continue_info_counts_bug2684")
return finish()
end
@@ -0,0 +1,98 @@
-- engine/events/pokedex_rating.asm:33
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local Sound = require("src.core.Sound")
local Timing = require("src.core.Timing")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local ok = true
local function check(label, pass)
print((pass and "PASS " or "FAIL ") .. label)
if not pass then ok = false end
return pass
end
local realPlay = Sound.play
local plays = {}
Sound.play = function(data, name, ...)
plays[#plays + 1] = name
return realPlay(data, name, ...)
end
local function finish()
Sound.play = realPlay
print(ok and "PASS dex_rating_complete_2674" or "FAIL dex_rating_complete_2674")
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
U.teleport(game, "OAKS_LAB", 5, 3, "up")
U.wait(10)
local ow = game.overworld
if not check("overworld is up", ow ~= nil) then finish() end
local full = {}
for i = 1, 151 do full[i] = true end
game.save.pokedex = { seen = full, owned = full }
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local closed = false
ow:dexRating(function() closed = true end)
local box = game.stack:top()
if not check("rating box pushed", getmetatable(box) == TextBox) then finish() end
local SPACE = 0x7F
local target
for _ = 1, 6000 do
U.wait(1)
if box.waiting and box.contAdvance and #box.shown == 2 and #box.shown[2] == 18 then
target = true
break
end
if box.waiting and not box.contAdvance and (box.preWait or 0) == 0 then
U.tap(game, "a")
end
if box.done then break end
end
if not check("reached the cont wait under an 18-tile line", target) then finish() end
check("the cont line is the Own150To151 text",
(box.pages[box.pageIndex][box.lineIndex] or ""):find("complete", 1, true) ~= nil)
check("arrow is up over cell (18,16)", box:arrowVisible() and box.fixedGen1Rows)
local early = false
for _, name in ipairs(plays) do
if name == "Get_Item2" or name == "Pokedex_Rating" then early = true end
end
check("no rating jingle yet", not early)
for _ = 1, 120 do
if box.blink % 60 < 30 then break end
U.wait(1)
end
U.still(game, DIR .. "/2674_01_arrow_replaces_bang.png")
for _ = 1, Timing.TEXT_PRE_ADVANCE + 2 do U.wait(1) end
U.tap(game, "a")
U.wait(2)
check("scrolled line carries a space in column 18",
box.shown[1] and box.shown[1][18] == SPACE)
for _ = 1, 6000 do
if box.done and not box.auto then break end
U.wait(1)
end
check("box finished typing and the jingle wait is over", box.done and not box.auto)
local rated
for _, name in ipairs(plays) do
if name == "Get_Item2" then rated = true end
if name == "Pokedex_Rating" then rated = false break end
end
check("151 owned plays SFX_GET_ITEM_2", rated == true)
for _ = 1, 120 do
if box.blink % 60 < 30 then break end
U.wait(1)
end
U.still(game, DIR .. "/2674_02_complete_without_bang.png")
U.tap(game, "a")
U.wait(5)
check("A closes the rating", closed)
finish()
end
+170
View File
@@ -0,0 +1,170 @@
-- engine/events/diploma.asm:44
return function(game)
local U = dofile("tests/drivers/util.lua")
local P = require("src.render.PaletteFX")
local Diploma = require("src.ui.Diploma")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
or "/tmp/shots"
local fails = 0
local function check(label, ok, detail)
print((ok and "PASS " or "FAIL ") .. label
.. (detail ~= nil and (" [" .. tostring(detail) .. "]") or ""))
if not ok then fails = fails + 1 end
return ok
end
local rendered = 0
local hostDraw = love.draw
love.draw = function(...)
local r = hostDraw(...)
rendered = rendered + 1
return r
end
local function waitRendered(n)
local target = rendered + (n or 2)
for _ = 1, 2400 do
if rendered >= target then return true end
U.wait(1)
end
return false
end
local function near(R, G, B, c)
return math.abs(R - c[1]) <= 12 and math.abs(G - c[2]) <= 12
and math.abs(B - c[3]) <= 12
end
local function letterbox(img)
local W, H = img:getWidth(), img:getHeight()
local x1, y1, x2, y2 = W, H, -1, -1
for y = 0, H - 1 do
for x = 0, W - 1 do
local r, g, b = img:getPixel(x, y)
if r > 0.02 or g > 0.02 or b > 0.02 then
if x < x1 then x1 = x end
if x > x2 then x2 = x end
if y < y1 then y1 = y end
if y > y2 then y2 = y end
end
end
end
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
return x1, y1, bh / 144
end
local s = math.min(W / 160, H / 144)
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s
end
local function load(path)
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
local ok, img = pcall(function()
return love.image.newImageData(
love.filesystem.newFileData(bytes, "shot.png"))
end)
if not ok then return nil end
return img
end
local function census(img, objRamp, bgRamp, bx1, by1, bx2, by2)
local W, H = img:getWidth(), img:getHeight()
local ox, oy, s = letterbox(img)
local c = { obj = 0, bg = 0, grey = 0, ink = 0, nonwhite = 0 }
for gy = by1, by2 - 1 do
for gx = bx1, bx2 - 1 do
local x = ox + math.floor((gx + 0.5) * s)
local y = oy + math.floor((gy + 0.5) * s)
if x >= 0 and x < W and y >= 0 and y < H then
local r, g, b = img:getPixel(x, y)
local R = math.floor(r * 255 + 0.5)
local G = math.floor(g * 255 + 0.5)
local B = math.floor(b * 255 + 0.5)
if R < 30 and G < 30 and B < 30 then c.ink = c.ink + 1 end
if not (R > 230 and G > 230 and B > 230) then
c.nonwhite = c.nonwhite + 1
end
if R == G and G == B and R > 60 and R < 240 then c.grey = c.grey + 1 end
for _, k in ipairs(objRamp or {}) do
if near(R, G, B, k) then c.obj = c.obj + 1 break end
end
for _, k in ipairs(bgRamp or {}) do
if near(R, G, B, k) then c.bg = c.bg + 1 break end
end
end
end
end
return c
end
U.wait(5)
U.teleport(game, "CELADON_MANSION_3F", 2, 4, "up")
game.save.player.name = "RED"
local d = Diploma.new(game)
game.stack:push(d)
game.save.options.colors = "ogred"
P.applyOptions(game.save.options)
check("COLORS ogred uses the sprite OBJ path", P.usesSpriteObp() == true,
P.mode)
local objRamp = { P.ogObjBase()[2], P.ogObjBase()[3] }
local bgRamp = { P.ogBg()[2], P.ogBg()[3] }
waitRendered(4)
local maxRedraws = 0
for _ = 1, 4 do
waitRendered(1)
local n = #P.uiSpriteRedraws()
if n > maxRedraws then maxRedraws = n end
end
check("ogred records the pic and the text overlay for replay",
maxRedraws == 2, maxRedraws)
local shot1 = SHOT_DIR .. "/2676_01_ogred_player_obj_green.png"
if check("shot ogred", U.shot(game, shot1)) then
local img = load(shot1)
if check("ogred shot decodes", img ~= nil) then
local body = census(img, objRamp, bgRamp, 115, 80, 152, 136)
check("ogred player wears the OBJ ramp", body.obj > 200, body.obj)
check("ogred player has no BG-red pixels", body.bg == 0, body.bg)
check("ogred player has no DMG grey", body.grey == 0, body.grey)
local fist = census(img, objRamp, nil, 152, 100, 155, 116)
check("ogred fist reaches columns 152-154", fist.obj > 0, fist.obj)
local r = census(img, objRamp, nil, 120, 96, 128, 104)
check("ogred the r of your stays in front of the player", r.ink > 4,
r.ink)
local gf = census(img, objRamp, nil, 115, 128, 152, 136)
check("ogred GAME FREAK stays in front of the player", gf.ink > 20,
gf.ink)
end
end
P.applyOptions({ colors = "gbc" })
waitRendered(4)
local gbcRedraws = 0
for _ = 1, 4 do
waitRendered(1)
local n = #P.uiSpriteRedraws()
if n > gbcRedraws then gbcRedraws = n end
end
check("gbc records no OBJ replay", gbcRedraws == 0, gbcRedraws)
local shot2 = SHOT_DIR .. "/2676_02_gbc_fist_uncropped.png"
if check("shot gbc", U.shot(game, shot2)) then
local img = load(shot2)
if check("gbc shot decodes", img ~= nil) then
local body = census(img, objRamp, nil, 115, 80, 152, 136)
check("gbc player never wears the OG OBJ ramp", body.obj == 0, body.obj)
local fist = census(img, nil, nil, 152, 100, 155, 116)
check("gbc fist reaches columns 152-154", fist.nonwhite > 0,
fist.nonwhite)
end
end
game.save.options.colors = "ogred"
P.applyOptions(game.save.options)
waitRendered(2)
print(fails == 0 and "PASS diploma_2676_all" or "FAIL diploma_2676_all")
love.event.quit(fails == 0 and 0 or 1)
end
@@ -0,0 +1,90 @@
-- scripts/AgathasRoom.asm:2
-- scripts/AgathasRoom.asm:108
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local CLOSED, OPEN = 0x3b, 0x0e
local ok = true
local function check(label, cond)
print((cond and "PASS " or "FAIL ") .. label)
if not cond then ok = false end
return cond
end
local function finish()
U.wait(5)
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
local mon = Pokemon.new(game.data, "MEWTWO", 100)
mon.moves = { { id = "PSYCHIC_M", pp = 63 } }
game.save.party = { mon }
game.save.flags.EVENT_AUTOWALKED_INTO_AGATHAS_ROOM = true
game.save.flags.EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 = nil
game.save.defeatedTrainers = game.save.defeatedTrainers or {}
game.save.defeatedTrainers["AGATHAS_ROOM_obj_1"] = nil
U.teleport(game, "AGATHAS_ROOM", 5, 3, "up")
U.wait(30)
local ow = game.overworld
if not check("2658 overworld up in AGATHAS_ROOM", ow and ow.map.id == "AGATHAS_ROOM") then
return finish()
end
check("2658 exit shut before the fight", ow.map:blockAt(2, 0) == CLOSED)
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top ~= ow and top and top.enemy then battle = top break end
U.tap(game, "a")
U.wait(2)
end
if not check("2658 talking to Agatha starts the battle", battle ~= nil) then
return finish()
end
for _ = 1, 20000 do
if game.stack:top() ~= battle then
local inStack = false
for _, s in ipairs(game.stack.states or {}) do
if s == battle then inStack = true break end
end
if not inStack then break end
end
if battle.enemy and battle.enemy.mon and (battle.enemy.mon.hp or 0) > 1 then
battle.enemy.mon.hp = 1
end
U.tap(game, "a")
U.wait(2)
end
local box
for _ = 1, 3000 do
local top = game.stack:top()
if top ~= battle and getmetatable(top) == TextBox then box = top break end
U.wait(1)
end
if not check("2658 after-battle text box shows in the overworld", box ~= nil) then
return finish()
end
U.wait(240)
check("2658 Agatha beaten", game.save.flags.EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 and true or false)
check("2658 exit still shut while after-battle text is up",
ow.map:blockAt(2, 0) == CLOSED)
U.still(game, DIR .. "/2658_01_after_text_exit_shut.png")
for _ = 1, 600 do
if game.stack:top() == ow then break end
U.tap(game, "a")
U.wait(4)
end
check("2658 after-battle text closed", game.stack:top() == ow)
U.wait(10)
check("2658 exit opens after the text closes", ow.map:blockAt(2, 0) == OPEN)
U.still(game, DIR .. "/2658_02_exit_open_after_text.png")
finish()
end
+119
View File
@@ -0,0 +1,119 @@
-- engine/pokemon/evos_moves.asm:156
-- engine/battle/end_of_battle.asm:42
-- POKEPORT_DRIVER=tests/drivers/evolution_double_bug2664.lua POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local BattleState = require("src.battle.BattleState")
local Music = require("src.core.Music")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
or "/tmp/evo2664"
local failed = false
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failed = true end
return ok
end
local ok, err = pcall(function()
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local pin = function(_, b) return b end
local dratini = Pokemon.new(game.data, "DRATINI", 29, pin)
local def = game.data.pokemon.DRATINI
dratini.exp = Growth.expForLevel(def.growthRate, 30, game.data.growth_rates) - 1
dratini.moves = { { id = dratini.moves[1].id, pp = dratini.moves[1].pp } }
local tentacool = Pokemon.new(game.data, "TENTACOOL", 30, pin)
tentacool.moves = { tentacool.moves[1] }
game.save.party = { dratini, tentacool }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local restores = 0
local realRestore = Music.restoreMap
Music.restoreMap = function(...)
restores = restores + 1
return realRestore(...)
end
local battle = BattleState.newWild(game, "RATTATA", 2)
battle.rng = function(a, _) return a end
battle.enemy.mon.hp = 1
battle.enemy.mon.stats.speed = 1
battle.leveledUp = { [tentacool] = true }
ow:pushBattle(battle)
for _ = 1, 400 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(3)
end
if not check("reached the FIGHT menu", battle.phase == "menu") then return end
U.tap(game, "a")
for _ = 1, 60 do
if battle.phase == "moveSelect" then break end
U.wait(1)
end
U.tap(game, "a")
local function inStack(state)
for i, s in ipairs(game.stack.states) do
if s == state then return i end
end
end
local firstIntro, secondIntro, shot = nil, nil, false
local belowSecond, restoresAtSecond, baseAtSecond, battleAtSecond
for _ = 1, 20000 do
local top = game.stack:top()
if top and top.evoIntro then
if not firstIntro then
firstIntro = top
elseif top ~= firstIntro and not secondIntro then
secondIntro = top
local i = inStack(top)
belowSecond = game.stack.states[i - 1]
restoresAtSecond = restores
baseAtSecond = game.stack:visibleBase()
battleAtSecond = inStack(battle)
end
end
if secondIntro and not shot then
local i = inStack(secondIntro)
if i and i == #game.stack.states - 1 then
shot = U.still(game, DIR .. "/2664_01_second_intro_on_blank.png")
end
end
if not inStack(battle) then break end
U.tap(game, "a")
U.wait(2)
end
check("first mon evolved", dratini.species == "DRAGONAIR")
check("second mon evolved", tentacool.species == "TENTACRUEL")
if check("second IsEvolvingText box opened", secondIntro ~= nil) then
check("second intro sits on an opaque blank screen",
belowSecond ~= nil and belowSecond.evoClear == true
and belowSecond.isOpaque == true)
check("battle screen is not drawn under the second intro",
battleAtSecond ~= nil and baseAtSecond > battleAtSecond)
check("no map music between the two evolutions", restoresAtSecond == 0)
end
check("shot of the second intro captured", shot)
check("battle closed after the evolutions", inStack(battle) == nil)
local stray = false
for _, s in ipairs(game.stack.states) do
if s.evoClear or s.evoIntro then stray = true end
end
check("no evolution layers left on the stack", not stray)
Music.restoreMap = realRestore
end)
if not ok then
print("FAIL driver error: " .. tostring(err))
failed = true
end
love.event.quit(failed and 1 or 0)
end
+1 -1
View File
@@ -161,7 +161,7 @@ return function(game)
local top = Option._pages[1]
local gi
for i, row in ipairs(top.rows) do
if row.port and row.port.id == "group.graphics" then gi = i end
if row.id == "group.graphics" then gi = i end
end
if check(gi ~= nil, version .. " RS OPTION has a visible GRAPHICS row") then
top.index = gi
+144
View File
@@ -0,0 +1,144 @@
-- data/sprites/facings.asm:51
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local Assets = require("src.render.Assets")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "."
local fails = 0
local function check(cond, label)
if cond then print("PASS " .. label) else
fails = fails + 1
print("FAIL " .. label)
end
end
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
game.save.options = game.save.options or {}
local savedSpeed = game.speedOverride
game.speedOverride = 1
local function grab()
local got
love.graphics.captureScreenshot(function(id) got = id end)
for _ = 1, 600 do
if got then return got end
coroutine.yield()
end
return nil
end
local function frameRows(sprite)
local ok, id = pcall(Assets.imageData, sprite.def.image)
if not ok or not id then return 0, 15 end
local first, last
for y = 0, math.min(sprite.frameHeight, id:getHeight()) - 1 do
for x = 0, math.min(sprite.frameWidth, id:getWidth()) - 1 do
local r = id:getPixel(x, y)
if r < 0.83 then
first = first or y
last = y
break
end
end
end
return first or 0, last or 15
end
local function differs(a, b, x, y)
local r1, g1, b1 = a:getPixel(x, y)
local r2, g2, b2 = b:getPixel(x, y)
return math.abs(r1 - r2) + math.abs(g1 - g2) + math.abs(b1 - b2) > 0.05
end
local function measure(tag)
local ow = game.overworld
local r = ow.map.renderer
U.wait(4)
local withGrass = grab()
r.drawStrip = function() end
r.markStripRedraw = function() end
U.wait(2)
local noGrass = grab()
ow.playerHidden = true
U.wait(2)
local noPlayer = grab()
ow.playerHidden = false
r.drawStrip = nil
r.markStripRedraw = nil
U.wait(2)
if not (withGrass and noGrass and noPlayer) then
check(false, tag .. " captured three frames")
return
end
local w, h = withGrass:getDimensions()
local x0, y0, x1, y1
for y = 0, h - 1 do
for x = 0, w - 1 do
if differs(noGrass, noPlayer, x, y) then
x0 = math.min(x0 or x, x); x1 = math.max(x1 or x, x)
y0 = math.min(y0 or y, y); y1 = math.max(y1 or y, y)
end
end
end
if not y0 then
check(false, tag .. " found the player sprite on screen")
return
end
local first, last = frameRows(ow.player.sprite)
local scale = (y1 - y0 + 1) / (last - first + 1)
local top = y0 - first * scale
local rows = {}
for y = math.floor(top), math.floor(top + 20 * scale) do
if y >= 0 and y < h then
for x = x0, x1 do
if differs(withGrass, noGrass, x, y) then
local row = math.floor((y - top) / scale)
rows[row] = (rows[row] or 0) + 1
end
end
end
end
local above, mid, low = 0, 0, 0
for row, n in pairs(rows) do
if row < 8 then above = above + n
elseif row < 12 then mid = mid + n
else low = low + n end
end
U.log(tag, "scale=" .. scale, "rows0-7=" .. above, "rows8-11=" .. mid,
"rows12-15=" .. low)
check(above == 0, tag .. " grass leaves sprite rows 0-7 alone")
check(mid > 0, tag .. " grass covers sprite rows 8-11")
check(low > 0, tag .. " grass covers sprite rows 12-15")
end
local modes = {
{ "gbc", "sgb" }, { "redpp", "advanced" }, { "ogred", "ogred" }, { "og", "og" },
}
for _, m in ipairs(modes) do
game.save.options.colors = m[1]
PaletteFX.setMode(m[1])
U.teleport(game, "ROUTE_1", 10, 6, "down")
U.wait(20)
check(game.overworld.map:isGrassCell(game.overworld.player.cellX,
game.overworld.player.cellY),
m[2] .. " player stands in tall grass")
measure(m[2])
end
for i, m in ipairs(modes) do
game.save.options.colors = m[1]
PaletteFX.setMode(m[1])
U.teleport(game, "ROUTE_1", 10, 6, "down")
U.wait(20)
U.shot(game, DIR .. "/2688_0" .. i .. "_" .. m[2] .. "_grass_from_shoulders_down.png")
end
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
game.speedOverride = savedSpeed
U.wait(2)
love.event.quit(fails == 0 and 0 or 1)
end
+80
View File
@@ -0,0 +1,80 @@
local U = require('tests.drivers.util')
local Version = require('src.core.GameVersion')
local TextBox = require('src.render.TextBox')
local function flatten(t, out)
out = out or {}
if type(t) == 'string' then out[#out + 1] = t
elseif type(t) == 'table' then for _, v in ipairs(t) do flatten(v, out) end end
return out
end
return function(game)
local deadline = love.timer.getTime() + 60
local ok, err = xpcall(function()
assert(Version.generation() == 1, 'Gen1 edition required')
local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required')
love.audio.setVolume(0)
local function wait(n)
for _ = 1, n do
assert(love.timer.getTime() < deadline, '#2683 deadline')
U.wait(1)
end
end
local function untilReady(fn, limit, label)
for _ = 1, limit do if fn() then return end; wait(1) end
error(label)
end
local text = game.data.text
local want = {
odd = assert(text._IndigoPlateauStatuesText2, 'Text2 missing from cache'),
even = assert(text._IndigoPlateauStatuesText3, 'Text3 missing from cache'),
}
game:startNewGame({ intro = false })
U.teleport(game, 'INDIGO_PLATEAU', 9, 8, 'down')
local ow = assert(game.overworld)
local map = ow.map
local spots = {}
for cy = 0, map.heightCells - 1 do
for cx = 0, map.widthCells - 1 do
if map:inBounds(cx, cy) and map:inBounds(cx, cy + 1)
and map:cellTile(cx, cy) == 0x30 and map:isWalkableCell(cx, cy + 1) then
local key = (cx % 2 == 1) and 'odd' or 'even'
if not spots[key] then spots[key] = { cx, cy + 1 } end
end
end
end
assert(spots.odd and spots.even, 'no statue spots found on INDIGO_PLATEAU')
for i, key in ipairs({ 'odd', 'even' }) do
local x, y = spots[key][1], spots[key][2]
U.teleport(game, 'INDIGO_PLATEAU', x, y, 'up')
ow = assert(game.overworld)
untilReady(function()
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
end, 400, 'field did not settle')
assert(ow.player.cellX == x and ow.player.facing == 'up', 'player not placed facing the statue')
U.tap(game, 'a')
untilReady(function() return getmetatable(game.stack:top()) == TextBox end, 120, 'statue text never opened')
local box = game.stack:top()
local joined = table.concat(flatten(box.pages), ' ')
local expect = TextBox.strip(TextBox.substitute(game, want[key])):gsub('[\n\f]', ' ')
local firstWord = expect:match('%S+%s+%S+')
assert(joined:find(firstWord, 1, true), key .. ' X ' .. x .. ' printed the wrong caption: ' .. joined)
untilReady(function()
if box.pageIndex == #box.pages and box.waiting then return true end
if box.waiting then U.tap(game, 'a') end
return false
end, 1200, 'caption page never settled')
local name = string.format('2683_%02d_%s_x%d_%s.png', i, key, x,
key == 'odd' and 'ultimate_goal' or 'highest_authority')
assert(U.still(game, out .. '/' .. name), 'capture failed')
print('PASS #2683 ' .. Version.get() .. ' ' .. key .. ' X=' .. x .. ' shows ' .. (key == 'odd' and 'Text2' or 'Text3'))
for _ = 1, 20 do
if game.stack:top() ~= box then break end
U.tap(game, 'a'); wait(4)
end
end
end, debug.traceback)
if not ok then print('FAIL #2683 ' .. tostring(err)) end
love.event.quit(ok and 0 or 1)
end
@@ -0,0 +1,57 @@
-- scripts/LancesRoom.asm:54
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local ok = true
local function check(label, cond)
print((cond and "PASS " or "FAIL ") .. label)
if not cond then ok = false end
return cond
end
local function finish()
U.wait(5)
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
local function findLance(ow)
for _, npc in ipairs(ow.npcs or {}) do
if npc.def and npc.def.name == "LANCESROOM_LANCE" then return npc end
end
end
local function approach(fromX, fromY, dir, label, shotName)
U.teleport(game, "LANCES_ROOM", fromX, fromY, dir)
local ow = game.overworld
if not check(label .. " overworld up in LANCES_ROOM", ow and ow.map.id == "LANCES_ROOM") then
return false
end
local lance = findLance(ow)
if not check(label .. " Lance present", lance ~= nil) then return false end
check(label .. " Lance starts facing down", lance.facing == "down")
U.hold(game, dir, 4)
local opened = false
for _ = 1, 600 do
if game.stack:top() ~= ow then opened = true break end
U.wait(1)
end
check(label .. " pre-battle text opens", opened)
U.wait(40)
check(label .. " Lance still faces down during his text", lance.facing == "down")
if lance.facing ~= "down" then U.log(label, "lance facing", lance.facing) end
U.still(game, DIR .. "/" .. shotName)
return true
end
game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) }
game.save.flags.EVENT_BEAT_LANCE = nil
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
approach(5, 2, "up", "2669 side (5,1)", "2669_01_side_tile_lance_faces_down.png")
approach(6, 3, "up", "2669 front (6,2)", "2669_02_front_tile_lance_faces_down.png")
finish()
end
@@ -0,0 +1,85 @@
-- POKEPORT_DRIVER=tests/drivers/lance_victory_music_bug2659.lua POKEPORT_SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local Timing = require("src.core.Timing")
os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null')
local failed = false
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failed = true end
return ok
end
local played = {}
local realPlayBattle = Music.playBattle
Music.playBattle = function(data, kind, trainerId, song)
played[#played + 1] = { call = "battle", kind = kind }
return realPlayBattle(data, kind, trainerId, song)
end
local realPlayVictory = Music.playVictory
Music.playVictory = function(data, kind, trainerId)
played[#played + 1] = { call = "victory", kind = kind }
return realPlayVictory(data, kind, trainerId)
end
local function lastKind(call)
local k
for _, p in ipairs(played) do
if p.call == call then k = p.kind end
end
return k
end
U.newGame(game)
local mon = Pokemon.new(game.data, "MEWTWO", 100)
mon.moves = { { id = "SWIFT", pp = 20, maxPp = 20, ppUps = 0 } }
game.save.party = { mon }
game.save.player.name = "RED"
U.teleport(game, "LANCES_ROOM", 5, 3, "up")
U.wait(20)
local ow = game.overworld
check("overworld up in LANCES_ROOM", ow ~= nil)
if not ow then love.event.quit(1) return end
local battle = BattleState.newTrainer(game, "OPP_LANCE", 1)
battle.onFinish = function() end
for i, m in ipairs(battle.enemyParty) do
m.hp = (i == 1) and 1 or 0
end
battle.enemy.shownHP = 1
battle.enemy.shownPx = Timing.hpBarPixels(1, math.max(1, battle.enemy.mon.stats.hp))
ow:pushBattle(battle)
for _ = 1, 900 do
if game.stack:top() == battle and battle.musicKind then break end
U.wait(1)
end
check("lance battle on screen", game.stack:top() == battle)
check("lance battle theme is gym (play_battle_music.asm)",
battle.musicKind == "gym" and lastKind("battle") == "gym")
check("lance is not a wGymLeaderNo fight", not battle.isGymLeader)
local won = false
for _ = 1, 3000 do
if lastKind("victory") then won = true break end
U.tap(game, "a")
U.wait(6)
end
check("lance defeated, victory theme requested", won)
check("lance victory theme is trainer (TrainerBattleVictory)",
lastKind("victory") == "trainer")
local b = game.data.audio and game.data.audio.battle
check("MUSIC_DEFEATED_TRAINER is playing",
b ~= nil and Music.current() == b.trainerWin
and b.trainerWin ~= b.gymWin)
U.wait(40)
U.shot(game, DIR .. "/2659_01_lance_defeated_trainer_theme.png")
Music.playBattle, Music.playVictory = realPlayBattle, realPlayVictory
love.event.quit(failed and 1 or 0)
end
+77
View File
@@ -0,0 +1,77 @@
-- scripts/LancesRoom.asm:96
-- scripts/LancesRoom.asm:111
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local ok = true
local function check(label, cond)
print((cond and "PASS " or "FAIL ") .. label)
if not cond then ok = false end
return cond
end
local function finish()
U.wait(5)
love.event.quit(ok and 0 or 1)
while true do coroutine.yield() end
end
local mon = Pokemon.new(game.data, "MEWTWO", 100)
game.save.party = { mon }
game.save.flags.EVENT_BEAT_LANCE = nil
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = nil
U.teleport(game, "LANCES_ROOM", 24, 16, "up")
local ow = game.overworld
if not check("2665 overworld up in LANCES_ROOM", ow and ow.map.id == "LANCES_ROOM") then
return finish()
end
local shots = {
["21,16"] = "2665_01_left_off_the_stairs.png",
["18,20"] = "2665_02_down_the_right_corridor.png",
["12,23"] = "2665_03_left_along_the_bottom.png",
["6,16"] = "2665_04_up_the_middle_column.png",
}
local cells = { "24,16" }
local lastKey = "24,16"
for _ = 1, 20000 do
local p = ow.player
local key = p.cellX .. "," .. p.cellY
if key ~= lastKey then
cells[#cells + 1] = key
lastKey = key
if shots[key] then
U.still(game, DIR .. "/" .. shots[key])
shots[key] = nil
end
end
if #(ow.scriptMoves or {}) == 0 and #cells > 1 and not p.moving then break end
U.wait(1)
end
local expected = { "24,16" }
local x, y = 24, 16
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 } }
for _, seg in ipairs({ { "left", 6 }, { "down", 7 }, { "left", 12 }, { "up", 12 } }) do
for _ = 1, seg[2] do
x, y = x + D[seg[1]][1], y + D[seg[1]][2]
expected[#expected + 1] = x .. "," .. y
end
end
check("2665 first step leaves the stairs going left", cells[2] == "23,16")
check("2665 walk-in follows the reversed WalkToLance list cell for cell",
table.concat(cells, " ") == table.concat(expected, " "))
if table.concat(cells, " ") ~= table.concat(expected, " ") then
U.log("got", table.concat(cells, " "))
end
check("2665 lands on (6,11)", ow.player.cellX == 6 and ow.player.cellY == 11)
check("2665 door locks on landing", game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR == true)
check("2665 door blocks closed", ow.map:blockAt(2, 6) == 0x72 and ow.map:blockAt(3, 6) == 0x73)
U.wait(10)
U.still(game, DIR .. "/2665_05_landed_door_locked.png")
finish()
end
+128
View File
@@ -0,0 +1,128 @@
-- engine/events/pokemart.asm:152
-- engine/items/inventory.asm:59
-- POKEPORT_DRIVER=tests/drivers/mart_split_stack_bug2671.lua POKEPORT_IDENTITY=bsa1005-red-2671 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local Bag = require("src.inventory.Bag")
local Menu = require("src.ui.Menu")
local ListMenu = require("src.ui.ListMenu")
local QuantityBox = require("src.ui.QuantityBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function top() return game.stack:top() end
local function topIs(cls) return getmetatable(top()) == cls end
local function waitFor(pred, frames)
for _ = 1, frames or 300 do
if pred() then return true end
U.wait(1)
end
return pred()
end
local function rows()
local out = {}
for _, r in ipairs(Bag.rows(game.save, game.data)) do
out[#out + 1] = r.id .. "x" .. tostring(r.count)
end
return table.concat(out, ",")
end
local function finish()
U.log(failures == 0 and "PASS mart_split_stack_bug2671"
or ("FAIL mart_split_stack_bug2671 (" .. failures .. " failures)"))
love.event.quit(failures == 0 and 0 or 1)
U.wait(600)
end
local MONEY = 104276
local ok, err = pcall(function()
game.save.money = MONEY
game.save.inventory = { X_ACCURACY = 2, POTION = 3 }
game.save.bagOrder = { "X_ACCURACY", "POTION" }
U.teleport(game, "CELADON_MART_5F", 5, 5, "up")
U.wait(10)
for _ = 1, 60 do
if topIs(Menu) then break end
U.tap(game, "a")
U.wait(4)
end
if not check("the X item clerk's mart menu opens", topIs(Menu)) then return end
local menu = top()
U.tap(game, "a")
if not check("BUY opens the list", waitFor(function() return topIs(ListMenu) end, 300)) then return end
local list = top()
check("the cursor is on X ACCURACY", list.items[list.index].value == "X_ACCURACY")
U.wait(4)
U.tap(game, "a")
if not check("the quantity box opens", waitFor(function() return topIs(QuantityBox) end, 30)) then return end
local qty = top()
check("the selector max stays 99", qty.max == 99)
U.wait(4)
U.tap(game, "down")
U.wait(4)
check("DOWN wraps to x99", qty.qty == 99)
U.still(game, DIR .. "/2671_01_buy_x99.png")
U.tap(game, "a")
if not check("YES/NO opens", waitFor(function()
if topIs(TextBox) and top().waiting then U.tap(game, "a") end
return topIs(ChoiceBox)
end, 600)) then return end
U.wait(4)
U.tap(game, "a")
local receipt
check("the clerk hands the items over", waitFor(function()
local t = top()
if getmetatable(t) == TextBox and t.pages then
local text = table.concat(t.pages[1] or {}, " ")
if text:find("Here you are", 1, true) then receipt = t return true end
if text:find("carry", 1, true) then return true end
end
return false
end, 600) and receipt ~= nil)
check("94050 is paid", game.save.money == MONEY - 94050)
check("the bag holds 101 X ACCURACY", game.save.inventory.X_ACCURACY == 101)
check("the overflow sits in a new last slot: " .. rows(),
rows() == "X_ACCURACYx99,POTIONx3,X_ACCURACYx2")
waitFor(function() return receipt.done end, 600)
U.still(game, DIR .. "/2671_02_here_you_are.png")
for _ = 1, 30 do
if top() == list then break end
U.tap(game, "a")
U.wait(4)
end
U.tap(game, "b")
if not check("B returns to BUY/SELL/QUIT", waitFor(function()
if topIs(TextBox) and top().waiting then U.tap(game, "a") end
return top() == menu
end, 600)) then return end
U.wait(4)
U.tap(game, "down")
U.wait(4)
U.tap(game, "a")
if not check("SELL opens the bag list", waitFor(function() return topIs(ListMenu) end, 300)) then return end
local sellList = top()
U.wait(30)
local counts = {}
for _, it in ipairs(sellList.items) do
if not it.cancel then counts[#counts + 1] = it.value .. "x" .. tostring(it.count) end
end
check("the sell list shows two X ACCURACY rows: " .. table.concat(counts, ","),
table.concat(counts, ",") == "X_ACCURACYx99,POTIONx3,X_ACCURACYx2")
U.still(game, DIR .. "/2671_03_sell_list_split_rows.png")
end)
if not ok then
failures = failures + 1
U.log("FAIL driver error:", tostring(err))
end
finish()
end
@@ -0,0 +1,89 @@
-- engine/movie/oak_speech/oak_speech2.asm:20
return function(game)
local U = dofile("tests/drivers/util.lua")
local NamingScreen = require("src.ui.NamingScreen")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
or "/tmp/shots"
local failed = false
local function check(label, ok)
if ok then print("PASS " .. label) else print("FAIL " .. label) failed = true end
return ok
end
local function finish()
print(failed and "FAIL oak_naming_empty_start_bug2687"
or "PASS oak_naming_empty_start_bug2687")
love.event.quit(failed and 1 or 0)
while true do coroutine.yield() end
end
local function namingScreen()
local states = game.stack.states or {}
for i = #states, 1, -1 do
if getmetatable(states[i]) == NamingScreen then return states[i] end
end
return nil
end
local function topIsGrid(ns)
return ns ~= nil and game.stack:top() == ns and not ns.choosing
end
U.wait(5)
U.tap(game, "start")
U.wait(10)
local title = game.stack:top()
for _ = 1, 60 do
U.tap(game, "a")
U.wait(5)
if game.stack:top() ~= title then break end
end
U.tap(game, "a")
U.wait(10)
local ns
for _ = 1, 3000 do
ns = namingScreen()
if ns and ns.choosing then break end
if game.overworld and game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(2)
end
if not check("reached_player_name_menu", ns ~= nil and ns.choosing) then finish() end
U.wait(30)
U.shot(game, DIR .. "/2687_01_name_menu.png")
U.tap(game, "a")
U.wait(30)
check("new_name_opens_empty_grid", topIsGrid(ns) and #ns.glyphs == 0)
ns.row, ns.col = 3, 4
U.shot(game, DIR .. "/2687_02_empty_grid_before_start.png")
U.tap(game, "start")
check("empty_start_keeps_grid_up", game.stack:top() == ns)
check("empty_start_whites_out", (ns.whiteout or 0) > 0)
U.still(game, DIR .. "/2687_03_white_reentry.png")
U.wait(60)
check("whiteout_ends", (ns.whiteout or 0) == 0)
check("grid_back_with_cursor_on_A", topIsGrid(ns) and ns.row == 1 and ns.col == 1)
U.shot(game, DIR .. "/2687_04_grid_back_cursor_on_A.png")
U.tap(game, "start")
U.wait(60)
check("second_empty_start_still_on_grid", topIsGrid(ns) and #ns.glyphs == 0)
ns.row, ns.col = 1, 1
U.tap(game, "a")
U.wait(5)
check("typed_letter_A", #ns.glyphs == 1 and ns.glyphs[1] == "A")
U.shot(game, DIR .. "/2687_05_typed_A.png")
U.tap(game, "start")
U.wait(30)
check("typed_name_confirms", namingScreen() == nil)
check("player_named_A", game.save.player.name == "A")
U.shot(game, DIR .. "/2687_06_your_name_is_A.png")
finish()
end
+96
View File
@@ -0,0 +1,96 @@
return function(game)
local U = dofile("tests/drivers/util.lua")
local Oak = require("src.ui.OakSpeech")
local TB = require("src.render.TextBox")
local PF = require("src.render.PaletteFX")
local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local failed = false
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
failed = failed or not ok
return ok
end
local origMode = PF.mode
while game.stack:top() do game.stack:pop() end
game.save.options.textSpeed = 1
local speech = Oak.new(game, function() end)
game.stack:push(speech)
local legend
for i, step in ipairs(speech.steps) do if step.id == "legend" then legend = i end end
for _ = 1, 4000 do
if speech.step == legend then break end
U.tap(game, "a")
U.wait(2)
end
check("reached legend step", speech.step == legend)
local arrowOnLast, aPresses = false, 0
for _ = 1, 2000 do
if speech.shrink then break end
local top = game.stack:top()
if getmetatable(top) == TB then
if top.waiting then
U.tap(game, "a")
aPresses = aPresses + 1
else
if top.done and top:arrowVisible() then arrowOnLast = true end
U.wait(1)
end
else
U.wait(1)
end
end
check("legend last page has no arrow", not arrowOnLast)
check("legend closes into the shrink with no A press", speech.shrink ~= nil)
local firstSeen = {}
local function tag()
if speech.pic == speech.playerPic then return "red" end
if speech.pic == speech.shrinkPic1 then return "p1" end
if speech.pic == speech.shrinkPic2 then return "p2" end
if speech.walkVisible then return "walk" end
return "none"
end
local shots = {}
local replayOg, bakedOg = false, false
for _ = 1, 400 do
local s = speech.shrink
if not s then break end
local f = s.frame
local t = tag()
if f > 0 and not firstSeen[t] then firstSeen[t] = f end
if f == Oak.SHRINK_PIC1_AT + 20 and not shots.p1 then
shots.p1 = U.still(game, dir .. "/2685_01_shrinkpic1_held.png")
elseif f == Oak.SHRINK_PIC2_AT + 16 and not shots.p2 then
shots.p2 = U.still(game, dir .. "/2685_02_shrinkpic2_held.png")
elseif f == Oak.SHRINK_WALK_AT + 10 and not shots.walk then
shots.walk = U.still(game, dir .. "/2685_03_walk_sprite_" .. origMode .. ".png")
PF.setMode("ogred")
U.wait(1)
elseif f == Oak.SHRINK_WALK_AT + 20 and not shots.walkOg then
shots.walkOg = U.still(game, dir .. "/2685_04_walk_sprite_ogred_green.png")
local r = PF.uiSpriteRedraws()
replayOg = r[1] ~= nil
bakedOg = r[1] ~= nil and r[1].image ~= speech.walkSheet
elseif f == Oak.SHRINK_FADE_AT + 10 and not shots.fade then
shots.fade = U.still(game, dir .. "/2685_05_fade_ogred_sprite_lightened.png")
else
U.wait(1)
end
end
PF.setMode(origMode)
U.log("first frames red/p1/p2/walk:", tostring(firstSeen.red), tostring(firstSeen.p1),
tostring(firstSeen.p2), tostring(firstSeen.walk))
check("RedPicFront held >= 30 frames", (firstSeen.p1 or 0) - 1 >= 30)
check("ShrinkPic1 held >= 30 frames",
firstSeen.p1 and firstSeen.p2 and firstSeen.p2 - firstSeen.p1 >= 30)
check("ShrinkPic2 held >= 25 frames",
firstSeen.p2 and firstSeen.walk and firstSeen.walk - firstSeen.p2 >= 25)
check("OG RED walk sprite is OBJ-baked and replayed", replayOg and bakedOg)
check("shrink finished", speech.shrink == nil)
check("shots written", shots.p1 and shots.p2 and shots.walk and shots.walkOg and shots.fade)
love.event.quit(failed and 1 or 0)
end
@@ -0,0 +1,141 @@
-- scripts/OaksLab.asm:918
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
or "/tmp/shots"
local MAP = "OAKS_LAB"
local OWN = "OAKSLAB_SQUIRTLE_POKE_BALL"
local RIVAL = "OAKSLAB_BULBASAUR_POKE_BALL"
local LIMIT = 120000
local failed = false
local function check(label, ok)
if ok then print("PASS " .. label) else print("FAIL " .. label) failed = true end
return ok
end
local function finish()
print(failed and "FAIL oak_starter_ball_hide_bug2686"
or "PASS oak_starter_ball_hide_bug2686")
love.event.quit(failed and 1 or 0)
while true do coroutine.yield() end
end
local function ballShown(name)
local ow = game.overworld
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.name == name then return true end
end
return false
end
local function boxText()
local states = game.stack.states or {}
for i = #states, 1, -1 do
local s = states[i]
if getmetatable(s) == TextBox then
local out = {}
for _, page in ipairs(s.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " "):lower(), s
end
end
return "", nil
end
local function scriptRunning()
local ow = game.overworld
return ow and ow.runner and ow.runner:isRunning()
end
local save = game.save
save.player.name = (save.player.name and save.player.name ~= "")
and save.player.name or "RED"
save.party = {}
save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
save.flags.EVENT_GOT_STARTER = nil
save.objectToggles = save.objectToggles or {}
save.objectToggles[MAP] = save.objectToggles[MAP] or {}
local tog = save.objectToggles[MAP]
tog.OAKSLAB_OAK1 = true
tog.OAKSLAB_OAK2 = false
tog.OAKSLAB_RIVAL = true
tog.OAKSLAB_CHARMANDER_POKE_BALL = true
tog.OAKSLAB_SQUIRTLE_POKE_BALL = true
tog.OAKSLAB_BULBASAUR_POKE_BALL = true
U.teleport(game, MAP, 7, 4, "up")
U.wait(20)
check("squirtle_ball_on_table_before_pick", ballShown(OWN))
check("rival_counter_ball_on_table_before_pick", ballShown(RIVAL))
U.tap(game, "a")
U.wait(10)
local sawEnergetic = false
for _ = 1, LIMIT do
local txt = boxText()
if txt:find("energetic", 1, true) then sawEnergetic = true break end
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
end
check("energetic_line_reached", sawEnergetic)
if not sawEnergetic then finish() end
check("ball_gone_when_energetic_line_opens", not ballShown(OWN))
check("rival_ball_still_there_on_energetic_line", ballShown(RIVAL))
check("no_starter_yet_on_energetic_line", #save.party == 0)
U.wait(90)
U.shot(game, DIR .. "/2686_01_energetic_line_ball_gone.png")
local sawReceived = false
for _ = 1, LIMIT do
local txt = boxText()
if txt:find("received", 1, true) then sawReceived = true break end
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
end
check("received_line_reached", sawReceived)
check("ball_gone_on_received_line", not ballShown(OWN))
U.wait(120)
U.shot(game, DIR .. "/2686_02_received_line_ball_gone.png")
local sawNick, nickBox = false, nil
for _ = 1, LIMIT do
local txt, box = boxText()
if txt:find("nickname", 1, true) then sawNick, nickBox = true, box break end
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
end
check("nickname_prompt_reached", sawNick)
if not sawNick then finish() end
check("ball_gone_on_nickname_prompt", not ballShown(OWN))
check("rival_ball_still_there_on_nickname_prompt", ballShown(RIVAL))
for _ = 1, LIMIT do
if nickBox.choicePushed then break end
U.wait(1)
end
U.wait(20)
U.shot(game, DIR .. "/2686_03_nickname_prompt_ball_gone.png")
for _ = 1, LIMIT do
local _, box = boxText()
if box ~= nickBox then break end
if U.frame() % 6 == 0 then U.tap(game, "b") else U.wait(1) end
end
for _ = 1, LIMIT do
if not scriptRunning() and game.stack:top() == game.overworld then break end
local _, box = boxText()
if box and box.done and not box.choice then U.tap(game, "a") else U.wait(1) end
end
U.wait(20)
check("script_finished", not scriptRunning())
check("player_got_squirtle",
save.party[1] ~= nil and save.party[1].species == "SQUIRTLE")
check("own_ball_still_hidden_after_pick", not ballShown(OWN))
check("rival_took_bulbasaur_ball", not ballShown(RIVAL))
check("charmander_ball_left_on_table",
ballShown("OAKSLAB_CHARMANDER_POKE_BALL"))
U.shot(game, DIR .. "/2686_04_after_rival_pick.png")
finish()
end
+48
View File
@@ -0,0 +1,48 @@
return function(game)
local U = dofile("tests/drivers/util.lua")
local Probe = dofile("tests/drivers/shot_probe.lua")
local PaletteFX = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "."
local fails = 0
local function check(cond, label)
if not cond then fails = fails + 1 end
print((cond and "PASS " or "FAIL ") .. label)
end
local savedVer = GameVersion.get()
game.save.options = game.save.options or {}
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
local function visit(version, shotName)
GameVersion.set(version)
U.teleport(game, "OAKS_LAB", 5, 5, "up")
U.wait(60)
local base = PaletteFX.ogObjBase()
local shot = Probe.grab()
if not shot then
check(false, version .. " screen probe available")
else
local counts = Probe.count(shot, {
light = base[2], dark = base[3],
}, 1)
U.log(version, "OBJ light px", counts.light, "OBJ dark px", counts.dark)
check(counts.dark == 0, version .. " no dark OBJ shade on overworld sprites")
check(counts.light > 0, version .. " light OBJ shade drawn on overworld sprites")
end
local colors = PaletteFX.ogObjWorld()
check(colors[2] == base[1] and colors[3] == base[2] and colors[4] == base[4],
version .. " overworld bake is rOBP0 $D0")
check(U.shot(game, DIR .. "/" .. shotName), version .. " shot reached disk")
end
local ok, err = pcall(function()
visit("red", "2689_01_red_oaks_lab_white_faces.png")
visit("blue", "2689_02_blue_oaks_lab_white_faces.png")
end)
if not ok then check(false, "driver ran: " .. tostring(err)) end
GameVersion.set(savedVer)
love.event.quit(fails == 0 and 0 or 1)
end
+106
View File
@@ -0,0 +1,106 @@
-- engine/menus/party_menu.asm:71
-- engine/menus/party_menu.asm:93
-- engine/pokemon/status_screen.asm:46
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Font = require("src.render.Font")
local failed = false
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failed = true end
return ok
end
local party = {}
for i, sp in ipairs({ "NIDOKING", "BULBASAUR", "MAGIKARP" }) do
local mon = Pokemon.new(game.data, sp, 50)
mon.stats.hp = 266
mon.hp = 266
party[i] = mon
end
game.save.party = party
game.save.player.name = game.save.player.name or "RED"
U.teleport(game, "PALLET_TOWN", 10, 12, "down")
U.wait(10)
local function render(drawFn)
local c = love.graphics.newCanvas(160, 144)
love.graphics.setCanvas(c)
love.graphics.clear(1, 1, 1, 1)
love.graphics.setColor(1, 1, 1, 1)
drawFn()
love.graphics.setCanvas()
love.graphics.setColor(1, 1, 1, 1)
return c:newImageData()
end
local function ink(id, x0, y0, x1, y1)
local n = 0
for y = y0, y1 do
for x = x0, x1 do
local r, g, b = id:getPixel(x, y)
if r + g + b < 2.7 then n = n + 1 end
end
end
return n
end
local function sameInk(a, b, x0, y0, x1, y1)
for y = y0, y1 do
for x = x0, x1 do
local ra, ga, ba = a:getPixel(x, y)
local rb, gb, bb = b:getPixel(x, y)
if (ra + ga + ba < 2.7) ~= (rb + gb + bb < 2.7) then return false end
end
end
return true
end
Screens.push(game, "PartyMenu", {})
U.wait(12)
local pm = game.stack:top()
check("party_list_open", getmetatable(pm) == PartyMenu)
if getmetatable(pm) == PartyMenu then
local id = render(function() pm:draw() end)
for i = 1, #party do
local hy = PartyMenu.entryY(i) + 8
check(("row%d_col3_blank_under_name_start"):format(i),
ink(id, 24, hy, 31, hy + 7) == 0)
check(("row%d_hp_tile_at_col4"):format(i), ink(id, 32, hy, 39, hy + 7) > 0)
check(("row%d_cap_at_col12"):format(i), ink(id, 96, hy, 103, hy + 7) > 0)
local frac = render(function()
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(party[i].hp, party[i].stats.hp), 104, hy)
end)
check(("row%d_col13_is_fraction_only"):format(i),
sameInk(id, frac, 104, hy, 111, hy + 7))
end
U.still(game, DIR .. "/2672_01_party_hp_bar_gap_before_fraction.png")
end
game.stack:pop()
U.wait(4)
Screens.push(game, "PartyMenu", { tmhm = { move = "SWORDS_DANCE", kind = "TM" } })
U.wait(12)
local tm = game.stack:top()
check("tm_list_open", getmetatable(tm) == PartyMenu and tm.tmhm ~= nil)
if getmetatable(tm) == PartyMenu then
local id = render(function() tm:draw() end)
for i = 1, #party do
local hy = PartyMenu.entryY(i) + 8
check(("tm_row%d_learn_text_starts_col12"):format(i),
ink(id, 88, hy, 95, hy + 7) == 0 and ink(id, 96, hy, 103, hy + 7) > 0)
end
U.still(game, DIR .. "/2672_02_tm_able_not_able_col12.png")
end
print(failed and "FAIL party_hp_columns_bug2672" or "PASS party_hp_columns_bug2672")
love.event.quit(failed and 1 or 0)
end
@@ -6,15 +6,17 @@
-- POKEPORT_IDENTITY=bug274 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local PaletteFX = require("src.render.PaletteFX")
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local failed = false
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then failed = true end
return ok
end
@@ -159,7 +161,7 @@ return function(game)
:format(i, row.hp, row.pal),
z ~= nil and z.colors == pal(row.pal))
check(("row %d's block covers the bar's cap + six fill tiles"):format(i),
z ~= nil and z.x == 48 and z.w == 56 and z.y == (i * 2 - 1) * 8
z ~= nil and z.x == 40 and z.w == 56 and z.y == (i * 2 - 1) * 8
and z.h == 8)
end
@@ -227,14 +229,12 @@ return function(game)
check("the icon column is drawn (not blank)", flesh > 0)
-- (b) the bars: each row's fill must be ITS OWN bar color and none of the
-- other two. Fill tiles run x 56..103 (drawHPBar at tile 5: "HP" 40,
-- ":[" 48, six fill tiles 56..103, cap 104).
for i, row in ipairs(ROWS) do
local y0 = (i * 2 - 1) * 8
local own = pal(row.pal)
-- the three bar palettes share color 0/1/3; only color 2, the fill
-- shade, tells them apart
local mine = countColor(id, own and own[3], 56, y0, 103, y0 + 7)
local mine = countColor(id, own and own[3], 48, y0, 95, y0 + 7)
U.log((" row %d (%d/48 HP) %s fill pixels: %d")
:format(i, row.hp, row.pal, mine))
if row.fill then
@@ -243,7 +243,7 @@ return function(game)
for _, other in ipairs({ "GREENBAR", "YELLOWBAR", "REDBAR" }) do
if other ~= row.pal then
local c = pal(other)
local n = countColor(id, c and c[3], 56, y0, 103, y0 + 7)
local n = countColor(id, c and c[3], 48, y0, 95, y0 + 7)
check(("row %d's bar carries no %s"):format(i, other), n == 0)
end
end
@@ -275,7 +275,5 @@ return function(game)
U.log("OPTION -> COLORS cycles modes; OG and CLASSIC are mono by design.")
U.log("Captures: bug274_party_advanced.png and bug274_party_sgb.png in " .. DIR)
while true do
coroutine.yield()
end
love.event.quit(failed and 1 or 0)
end
+114
View File
@@ -0,0 +1,114 @@
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local PartyMenu = require("src.ui.PartyMenu")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local fails = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
return ok
end
local function finish()
love.event.quit(fails == 0 and 0 or 1)
while true do coroutine.yield() end
end
if not U.newGame(game) then
check(false, "2682 reached the overworld")
finish()
end
game.save.options = game.save.options or {}
game.save.options.textSpeed = 5
game.save.inventory.SOULBADGE = true
local surfer = Pokemon.new(game.data, "SQUIRTLE", 30)
surfer.moves = { { id = "SURF", pp = 15 } }
local other = Pokemon.new(game.data, "PIDGEY", 30)
game.save.party = { surfer, other }
game.partyMenuSavedIndex = 1
U.teleport(game, "PALLET_TOWN", 4, 13, "down")
local ow = game.overworld
ow.player.surfing = false
check(ow:useSurfFieldMove() == "ok", "2682 (4,13) faces surfable water")
U.tap(game, "start")
U.wait(12)
local menu = game.stack:top()
local row
for i, it in ipairs(menu and menu.items or {}) do
if tostring(it.label):upper():find("MON", 1, true) then row = i break end
end
if not check(row ~= nil, "2682 START menu lists POKEMON") then finish() end
for _ = 2, row do U.tap(game, "down"); U.wait(2) end
U.tap(game, "a")
U.wait(12)
local pm = game.stack:top()
if not check(getmetatable(pm) == PartyMenu, "2682 party menu open") then finish() end
local b0 = pm.blink
U.wait(20)
check(pm.blink ~= b0, "2682 cursor icon animates while the list takes input")
U.tap(game, "a")
U.wait(8)
if not check(pm.submenu, "2682 A opens the field-move submenu") then finish() end
local frozen = pm.blink
U.wait(40)
check(pm.blink == frozen, "2682 icon frozen behind the submenu")
U.still(game, DIR .. "/2682_01_submenu_icon_frozen.png")
U.wait(10)
check(pm.blink == frozen, "2682 icon still frozen ten frames later")
U.still(game, DIR .. "/2682_02_submenu_icon_frozen_later.png")
U.tap(game, "b")
U.wait(1)
check(not pm.submenu and pm.blink <= 1, "2682 B back to the list restarts the icon")
U.wait(9)
U.tap(game, "down")
check(pm.index == 2 and pm.blink <= 1, "2682 cursor move restarts the icon on its rest frame")
U.wait(4)
U.tap(game, "up")
U.wait(4)
check(pm.index == 1, "2682 cursor back on the surfer")
U.tap(game, "a")
U.wait(8)
local sub
for i, it in ipairs(pm.subItems or {}) do
if it.action == "surf" then sub = i break end
end
if not check(sub ~= nil, "2682 SURF listed in the submenu") then finish() end
for _ = 2, sub do U.tap(game, "down"); U.wait(2) end
U.tap(game, "a")
U.wait(6)
local top = game.stack:top()
check(top ~= pm and top and top.pages ~= nil, "2682 got-on text is up")
check(game.stack.states[game.stack:visibleBase()] == pm,
"2682 party list is the backdrop of the got-on text")
check(not pm.submenu and pm.chosenHollow == true,
"2682 party cursor stays hollow under the got-on text")
local textBlink = pm.blink
local printed = false
for _ = 1, 600 do
if top.done or top.waiting then printed = true break end
U.wait(1)
end
check(printed, "2682 got-on text finished printing")
U.wait(4)
check(pm.blink == textBlink, "2682 icon frozen under the got-on text")
U.still(game, DIR .. "/2682_03_surf_text_hollow_cursor.png")
for _ = 1, 400 do
local t = game.stack:top()
if not (t and t.pages) then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(60)
check(ow.player.surfing == true, "2682 SURF mounted after the text")
finish()
end
+135
View File
@@ -0,0 +1,135 @@
-- engine/battle/core.asm:3227
-- engine/battle/core.asm:4913
local U = require("tests.drivers.util")
local Battle = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Version = require("src.core.GameVersion")
return function(game)
local deadline = love.timer.getTime() + 25
local old = {}
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do old[k] = rawget(game, k) end
local keys = { "save", "saveOptions", "saveLiveOptions", "writeSlot", "writeCartSlot" }
local writers = {}
for _, k in ipairs(keys) do writers[k] = SaveData[k] end
local volume = love.audio.getVolume()
local fails = 0
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
return ok
end
local ok, err = xpcall(function()
assert(Version.generation() == 1, "Gen1 required")
local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required")
local function forbidden() error("#2652 unexpected persistent write") end
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = forbidden end
for _, k in ipairs(keys) do SaveData[k] = forbidden end
love.audio.setVolume(0)
local function wait(n)
for _ = 1, n do
assert(love.timer.getTime() < deadline, "#2652 deadline")
U.wait(1)
end
end
local function fullyTyped(b)
local row = b.shown and b.shown[#b.shown]
return b.current and row and b.codes and #row == #b.codes and b.lineIndex == #b.lines
and b.total and b.total > 0 and b.charIndex >= b.total and not b.scrollPx and not b.msgWaiting
end
local function move(id)
local def = assert(game.data.moves[id], "required imported move " .. id)
return { id = id, pp = def.pp, maxPp = def.pp }
end
game:startNewGame({ intro = false })
game.save.options.animations = false
game.save.options.textSpeed = 1
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
local player = Pokemon.new(game.data, "WARTORTLE", 30)
player.moves = { move("SURF") }
game.save.party = { player }
local battle = Battle.newWild(game, "CHARMELEON", 50)
battle.enemy.mon.moves = { move("RAGE") }
battle.enemy.curMoves = battle.enemy.mon.moves
battle.enemy.rageMove = battle.enemy.curMoves[1]
battle.rng = function(a) return a or 0 end
local seen = {}
local seenRows = {}
local update = battle.update
battle.update = function(self, ...)
update(self, ...)
local row = self.current
if row and row.text and not seenRows[row] then
seenRows[row] = true
seen[#seen + 1] = row.text
end
end
game.stack:push(battle)
local function advanceUntil(fn, label)
for _ = 1, 3000 do
if fn() then return true end
if battle.current and not battle.current.auto and fullyTyped(battle) then U.tap(game, "a") end
wait(1)
end
error(label)
end
local function settledOn(fragment)
return function()
return battle.current and battle.current.text
and battle.current.text:find(fragment, 1, true) and fullyTyped(battle)
end
end
local function pickSurf()
advanceUntil(function() return battle.phase == "menu" end, "menu did not settle")
U.tap(game, "a")
advanceUntil(function() return battle.phase == "moveSelect" end, "move list missing")
U.tap(game, "a")
end
local function find(needle, from)
for i = from or 1, #seen do
if seen[i]:find(needle, 1, true) then return i end
end
end
pickSurf()
local surfAt = #seen + 1
advanceUntil(settledOn("RAGE is building!"), "rage page never settled")
assert(U.still(game, out .. "/2654_01_rage_after_super_effective.png"), "capture failed")
U.tap(game, "a")
advanceUntil(settledOn("ATTACK rose!"), "rose page never settled")
assert(U.still(game, out .. "/2654_02_attack_rose.png"), "capture failed")
local used = find("used SURF", surfAt)
local super = find("super", used)
local rage = find("RAGE is building!", used)
local rose = find("ATTACK rose!", used)
check("#2654 Surf, super effective, rage and rose pages all shown", used and super and rage and rose)
check("#2654 super effective prints before RAGE is building", super and rage and super < rage)
check("#2654 ATTACK rose prints after RAGE is building", rage and rose and rage < rose)
check("#2654 rage raised the enemy attack stage", (battle.enemy.stages.attack or 0) >= 1)
advanceUntil(function() return battle.phase == "menu" end, "turn 1 did not finish")
battle.enemy.mon.hp, battle.enemy.shownHP = 1, 1
local koFrom = #seen + 1
pickSurf()
advanceUntil(settledOn("fainted!"), "faint page never settled")
assert(U.still(game, out .. "/2652_03_ko_faint_no_rage.png"), "capture failed")
local koUsed = find("used SURF", koFrom)
check("#2652 the KO turn reached Surf and the faint page", koUsed ~= nil)
check("#2652 no RAGE is building page on the fainting hit", koUsed and find("RAGE is building!", koUsed) == nil)
check("#2652 no ATTACK rose page on the fainting hit", koUsed and find("rose!", koUsed) == nil)
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
end, debug.traceback)
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = old[k] end
for _, k in ipairs(keys) do SaveData[k] = writers[k] end
love.audio.setVolume(volume)
if not ok then print("FAIL #2652 " .. tostring(err)) end
love.event.quit((ok and fails == 0) and 0 or 1)
end
@@ -0,0 +1,157 @@
-- scripts/Route23.asm:237
local U = require('tests.drivers.util')
local Version = require('src.core.GameVersion')
local TextBox = require('src.render.TextBox')
local function pageText(box, i)
local page = box.pages and box.pages[i]
return page and table.concat(page, ' ') or ''
end
return function(game)
local deadline = love.timer.getTime() + 90
local ok, err = xpcall(function()
assert(Version.generation() == 1, 'Gen1 edition required')
local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required')
love.audio.setVolume(0)
local function wait(n)
for _ = 1, n do
assert(love.timer.getTime() < deadline, '#2649 deadline')
U.wait(1)
end
end
local function untilReady(fn, limit, label)
for _ = 1, limit do if fn() then return end; wait(1) end
error(label)
end
local function isBox(s) return s and getmetatable(s) == TextBox end
assert(game.data.text._Route23GoRightAheadText, '_Route23GoRightAheadText missing from cache')
game:startNewGame({ intro = false })
game.save.inventory.CASCADEBADGE = 1
U.teleport(game, 'ROUTE_23', 7, 137, 'up')
local ow = assert(game.overworld)
untilReady(function()
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
end, 400, 'field did not settle')
U.hold(game, 'up', 4)
untilReady(function() return isBox(game.stack:top()) end, 200, 'guard text never opened')
local box = game.stack:top()
assert(#box.pages == 3, 'step path pages: ' .. #box.pages)
assert(pageText(box, 2):find('Oh! That is the', 1, true), 'page 2 is not the badge line')
assert(pageText(box, 3):find('OK then! Please,', 1, true), 'page 3 is not the go-ahead line')
print('PASS #2649 step path shows three pages ending in OK then! Please, go right ahead!')
local sawFanfare = false
untilReady(function()
if box.pauseSrc or (box.pauseMark and box.pageIndex == 2) then sawFanfare = true end
if box.pageIndex == 2 and box.waiting and not box.pauseSrc then return true end
if box.pageIndex == 1 and box.waiting then U.tap(game, 'a') end
return false
end, 1200, 'badge page never settled')
assert(sawFanfare, 'Get_Item1 mark never fired on the badge page')
print('PASS #2649 Get_Item1 fires after Oh! That is the CASCADEBADGE!')
assert(U.still(game, out .. '/2649_01_badge_page_after_fanfare.png'), 'capture failed')
untilReady(function()
if box.pageIndex < 3 and box.waiting then U.tap(game, 'a') end
return box.pageIndex == 3 and (box.done or box.waiting)
end, 600, 'go-ahead page never typed: page ' .. tostring(box.pageIndex))
assert(U.still(game, out .. '/2649_02_step_go_right_ahead.png'), 'capture failed')
print('PASS #2649 step path go-ahead page typed in the same box')
untilReady(function()
if game.stack:top() == box then U.tap(game, 'a') end
return game.stack:top() == ow
end, 300, 'step box never closed')
assert(ow.player.cellY == 136, 'player was turned back: y=' .. ow.player.cellY)
assert(game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'pass flag not set')
print('PASS #2649 step path lets the player through and sets EVENT_PASSED_CASCADEBADGE_CHECK')
U.teleport(game, 'ROUTE_23', 7, 136, 'right')
ow = assert(game.overworld)
untilReady(function()
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
end, 400, 'field did not settle for talk')
U.tap(game, 'a')
untilReady(function() return isBox(game.stack:top()) end, 200, 'talk text never opened')
local seenOh, goBox = false, nil
untilReady(function()
local top = game.stack:top()
if isBox(top) then
for i = 1, #top.pages do
if pageText(top, i):find('Oh! That is the', 1, true) then seenOh = true end
if seenOh and pageText(top, i):find('OK then! Please,', 1, true) then goBox = top end
end
if goBox and goBox.pageIndex == #goBox.pages and (goBox.done or goBox.waiting) then
return true
end
if top.waiting or (top.done and not top.pauseSrc) then U.tap(game, 'a') end
end
return false
end, 2400, 'talk path never reached the go-ahead text')
assert(U.still(game, out .. '/2649_03_talk_go_right_ahead.png'), 'capture failed')
print('PASS #2649 talking to the guard also ends in OK then! Please, go right ahead!')
assert(#goBox.pages == 3, 'talk path pages: ' .. #goBox.pages)
assert(pageText(goBox, 2):find('Oh! That is the', 1, true), 'talk path: page 2 is not the badge line')
assert(goBox.pauseAt, 'talk path: no fanfare mark in the box')
print('PASS #2649 talk path is one box: badge line, fanfare mark, go-ahead page')
untilReady(function()
if game.stack:top() ~= ow then U.tap(game, 'a') end
return game.stack:top() == ow
end, 300, 'talk box never closed')
local function failBox(label)
local fb = game.stack:top()
assert(isBox(fb), label .. ': no box')
assert(fb.auto and fb.auto.sound, label .. ': SFX_DENIED not armed after the text')
assert(not fb.autoStarted, label .. ': sound fired before the text typed')
untilReady(function()
if fb.autoStarted then return true end
if fb.waiting then U.tap(game, 'a') end
return false
end, 1200, label .. ': SFX_DENIED never fired after the text')
assert(fb.done, label .. ': sound fired before the last page typed')
print('PASS #2649 ' .. label .. ' plays SFX_DENIED after the text')
untilReady(function()
if game.stack:top() == fb then U.tap(game, 'a') end
return game.stack:top() == ow
end, 300, label .. ': fail box never closed')
untilReady(function() return not ow.player.moving and #ow.scriptMoves == 0 end,
200, label .. ': walk-down never settled')
end
game.save.inventory.CASCADEBADGE = nil
game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK = nil
U.teleport(game, 'ROUTE_23', 7, 137, 'up')
ow = assert(game.overworld)
untilReady(function()
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
end, 400, 'field did not settle for the step fail')
U.hold(game, 'up', 4)
untilReady(function() return isBox(game.stack:top()) end, 200, 'step fail text never opened')
failBox('step fail')
assert(ow.player.cellY == 137, 'step fail: not pushed back down, y=' .. ow.player.cellY)
assert(ow.player.facing == 'down', 'step fail: facing ' .. tostring(ow.player.facing))
assert(not game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'step fail set the pass flag')
print('PASS #2649 step fail walks the player back down')
U.teleport(game, 'ROUTE_23', 8, 137, 'up')
ow = assert(game.overworld)
untilReady(function()
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
end, 400, 'field did not settle for the talk fail')
local canStepDown = ow.map:isWalkableCell(8, 138)
U.tap(game, 'a')
untilReady(function() return isBox(game.stack:top()) end, 200, 'talk fail text never opened')
failBox('talk fail')
assert(ow.player.facing == 'down', 'talk fail: facing ' .. tostring(ow.player.facing))
assert(ow.player.cellY == (canStepDown and 138 or 137),
'talk fail: y=' .. ow.player.cellY .. ' walkable=' .. tostring(canStepDown))
assert(not game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'talk fail set the pass flag')
print('PASS #2649 talk fail runs Route23MovePlayerDownScript')
end, debug.traceback)
if not ok then print('FAIL #2649 ' .. tostring(err)) end
love.event.quit(ok and 0 or 1)
end
+176
View File
@@ -0,0 +1,176 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/rs_menus_parity"
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
print((failures == 0 and "PASS" or "FAIL") .. " rs_menus_parity failures=" .. failures)
love.event.quit(failures == 0 and 0 or 1)
U.wait(10)
end
local function shot(game, name)
U.wait(3)
U.shot(game, DIR .. "/" .. name .. ".png")
U.wait(2)
end
local function until_(n, fn)
for _ = 1, n do
if fn() then return true end
U.wait(1)
end
return fn()
end
local function demoMenus(game)
local Boot = require("src.ui.game3.boot")
local Menu = require("src.ui.game3.rs.main_menu")
local Pal = require("src.core.game3.pal_fade")
local origDraw = Boot.draw
local info = { name = "BRENDAN", gender = 0, hours = 12, minutes = 34, badges = 3, dexCount = 27,
mysteryEvents = true, frameType = 0 }
local cases = {
{ "00-main-no-save", { hasContinue = false } },
{ "01-main-saved", { hasContinue = true, continueInfo = info, menuType = Menu.TYPE.HAS_SAVED_GAME } },
{ "02-main-mystery-events", { hasContinue = true, continueInfo = info, menuType = Menu.TYPE.HAS_MYSTERY_EVENT } },
}
for _, c in ipairs(cases) do
local demo = Menu.new(c[2])
demo.pal, demo.state = Pal.new(), "input"
Boot.draw = function() demo:draw() end
check(demo.items[#demo.items] == "EXIT", c[1] .. " ends with EXIT")
for _ = 1, #demo.items - 1 do
demo:frame({ new = { down = true } })
demo.state = "input"
end
shot(game, c[1] .. "-exit")
local w = Menu.windowFor(demo.menuType, demo.cursor)
check((w.top + w.height + 1) * 8 - demo.scroll <= 160, c[1] .. " EXIT window on screen")
end
Boot.draw = origDraw
end
local function realMainMenuExit(game)
local Boot = require("src.ui.game3.boot")
local BootModules = require("src.ui.game3.boot_modules")
BootModules.startMenu(Boot, game.boot)
local menu = game.boot.custom.menu
check(until_(300, function() return menu.state == "input" end), "real RS main menu reaches input")
for _ = 1, #menu.items do U.tap(game, "down"); U.wait(2) end
check(menu.items[menu.cursor] == "EXIT", "cursor on EXIT")
shot(game, "03-real-main-menu-exit")
local exited = false
game.returnToLauncher = function() exited = true end
U.tap(game, "a")
check(until_(200, function() return exited end), "A on EXIT returns to the launcher")
game.returnToLauncher = nil
end
local function enterWorld(game)
local MapIds = require("src.core.game3.map_ids")
local Map = require("src.core.game3.map")
local Player = require("src.core.game3.player")
local Runtime = require("src.core.game3.runtime")
local Warp = require("src.core.game3.warp")
local Message = require("src.ui.game3.message")
local function settle()
until_(600, function()
if Message.isOpen and Message.isOpen() then U.tap(game, "b") end
return not (Warp.isBusy() or (Message.isOpen and Message.isOpen()))
end)
end
pcall(function() game:_handleBootAction({ action = "new_game", name = "RUBY", gender = 0 }) end)
U.wait(60)
settle()
pcall(function()
Map.load(nil, game, MapIds.forConst("MAP_LITTLEROOT_TOWN"), { x = 10, y = 10, facing = "down" })
end)
local s = Runtime.getSession()
if s then s.x, s.y, s.facing = 10, 10, "down" end
Player.cellX, Player.cellY, Player.px, Player.py = 10, 10, 160, 160
Player.targetX, Player.targetY, Player.facing = 10, 10, "down"
U.wait(60)
settle()
return s
end
local function moveTo(game, page, target)
for _ = 1, 64 do
if page.index == target then return true end
U.tap(game, page.index < target and "down" or "up")
U.wait(1)
end
return page.index == target
end
local function options(game, session)
local Menu = require("src.ui.game3.rs.option_menu")
local Option = require("src.ui.game3.screens").get("option", session)
check(Option == Menu, "ruby OPTION is the RS menu")
Option.show({ session = session, game = game })
until_(120, function() return Menu._state == "input" end)
local top = Menu._pages[1]
check(top.rows[1].id == "group.speed", "top page starts with SPEED like Emerald")
shot(game, "10-option-top")
moveTo(game, top, #top.rows + 1)
shot(game, "11-option-top-cancel")
for _, id in ipairs({ "group.battle", "group.graphics", "group.speed", "group.audio" }) do
local gi
for i, r in ipairs(top.rows) do if r.id == id then gi = i end end
if check(gi ~= nil, id .. " on the top page") then
moveTo(game, top, gi)
U.tap(game, "a")
U.wait(4)
local page = Menu._pages[#Menu._pages]
local natives = 0
for _, r in ipairs(page.rows) do if r.native then natives = natives + 1 end end
check(#Menu._pages == 2 and natives > 0, id .. " page carries native cart rows")
shot(game, "12-option-" .. id:gsub("^group%.", ""))
U.tap(game, "b")
U.wait(4)
end
end
U.tap(game, "b")
check(until_(120, function() return not Menu.isOpen() end), "B saves and closes OPTION")
end
local function startMenuExit(game, session)
local StartMenu = require("src.ui.game3.start_menu")
local Boot = require("src.ui.game3.boot")
U.wait(10)
StartMenu.show({ session = session, game = game })
U.wait(4)
for i, e in ipairs(StartMenu.ENTRIES) do if e.id == "exit" then StartMenu.cursor = i end end
shot(game, "20-start-menu-exit")
U.tap(game, "a")
U.wait(4)
check(StartMenu._confirmExit == true, "START EXIT asks to return to the main menu")
shot(game, "21-start-menu-exit-confirm")
U.tap(game, "up")
U.wait(2)
U.tap(game, "a")
check(until_(300, function() return game.phase == "boot" and game.boot and game.boot.phase == Boot.PHASE.TITLE end),
"YES returns to the title screen")
U.wait(60)
shot(game, "22-after-exit-title")
end
return function(game)
until_(900, function() return game.phase == "boot" and game.boot end)
if not check(game.boot ~= nil, "boot reached") then return finish() end
local ok, err = xpcall(function()
demoMenus(game)
realMainMenuExit(game)
local session = enterWorld(game)
options(game, session)
startMenuExit(game, session)
end, debug.traceback)
if not ok then check(false, "driver error: " .. tostring(err)) end
finish()
end
+3 -3
View File
@@ -73,7 +73,7 @@ local function phase1(game, ShaderFX, ShaderFXMenu)
end
shot(game, "01-native-first-page")
local top = Menu._pages[1]
for i = 1, 7 do check(top.rows[i].native == i, "native row " .. i .. " on page 1") end
check(top.rows[1].id == "group.speed", "page 1 opens on Emerald's SPEED group")
U.tap(game, "select")
U.wait(4)
@@ -81,12 +81,12 @@ local function phase1(game, ShaderFX, ShaderFXMenu)
moveTo(game, top, 8)
shot(game, "02-scrolled-port-rows")
moveTo(game, top, #top.rows)
moveTo(game, top, #top.rows + 1)
shot(game, "03-port-rows-bottom")
local groups = {}
for i, row in ipairs(top.rows) do
if row.port and row.port.group then groups[#groups + 1] = { i = i, id = row.port.id } end
if row.group then groups[#groups + 1] = { i = i, id = row.id } end
end
check(#groups >= 4, "RS OPTION shows " .. #groups .. " port pages")
local n = 3
+53 -34
View File
@@ -4,61 +4,80 @@
-- Pallet Town.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Oak = require("src.ui.OakSpeech")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local failed = false
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
failed = failed or not ok
return ok
end
U.wait(5)
U.tap(game, "start") -- skip intro movie
U.tap(game, "start")
U.wait(10)
U.tap(game, "a") -- title -> menu
U.wait(5)
-- with an existing save the menu is CONTINUE / NEW GAME / OPTION
local title = game.stack:top()
for _ = 1, 60 do
U.tap(game, "a")
U.wait(5)
if game.stack:top() ~= title then break end
end
local ok, saved = pcall(function()
return require("src.core.SaveData").load() ~= nil
end)
if ok and saved then U.tap(game, "down") U.wait(3) end
U.tap(game, "a") -- NEW GAME
U.tap(game, "a")
U.wait(10)
-- mash through the speech + both naming screens (preset 1)
local function top() return game.stack:top() end
local function speechState()
for _, s in ipairs(game.stack.states or {}) do
if s.shrink ~= nil or s.oakPic ~= nil then return s end
if getmetatable(s) == Oak then return s end
end
end
for _ = 1, 400 do
local s = speechState()
if s and s.step and s.step >= 9 then break end
local s
for _ = 1, 4000 do
s = speechState()
if s and s.shrink then break end
U.tap(game, "a")
U.wait(2)
end
check("shrink beat entered", s ~= nil and s.shrink ~= nil)
if not (s and s.shrink) then love.event.quit(1) return end
-- the shrink beat: captures aimed at each timeline window, with the
-- exact frame logged so the windows can be verified
local s = speechState()
local function frameNow()
return (s and s.shrink and s.shrink.frame) or -1
return (s.shrink and s.shrink.frame) or -1
end
U.log("shrink beat entered:", tostring(s ~= nil and s.shrink ~= nil),
"frame:", frameNow())
U.shot(game, DIR .. "/shrink_1_redpic.png") -- frames 1-4: RedPicFront
U.log("shot1 frame:", frameNow())
U.wait(3)
U.shot(game, DIR .. "/shrink_2_pic1.png") -- frames 5-8: ShrinkPic1
U.log("shot2 frame:", frameNow())
U.wait(8)
U.shot(game, DIR .. "/shrink_3_pic2.png") -- frames 9-28: ShrinkPic2
U.log("shot3 frame:", frameNow())
U.wait(25)
U.shot(game, DIR .. "/shrink_4_sprite.png") -- frames 29-78: walk sprite
U.log("shot4 frame:", frameNow())
U.wait(43)
U.shot(game, DIR .. "/shrink_5_fade.png") -- frames 79-102: fade
U.log("shot5 frame:", frameNow())
U.wait(15)
U.wait(30)
U.shot(game, DIR .. "/shrink_6_overworld.png")
local function at(target, name, wantPic, wantWalk)
for _ = 1, 400 do
if not s.shrink or s.shrink.frame >= target then break end
U.wait(1)
end
local f = frameNow()
local shot = U.still(game, DIR .. "/" .. name .. ".png")
U.log(name .. " frame:", f)
check(name .. " lands at frame " .. target .. " (got " .. f .. ")", f == target)
check(name .. " shows the expected beat", s.pic == wantPic and (s.walkVisible or false) == wantWalk)
check(name .. " shot reached disk", shot)
end
at(Oak.SHRINK_PIC1_AT - 1, "shrink_1_redpic", s.playerPic, false)
at(Oak.SHRINK_PIC1_AT + 2, "shrink_2_pic1", s.shrinkPic1, false)
at(Oak.SHRINK_PIC2_AT + 2, "shrink_3_pic2", s.shrinkPic2, false)
at(Oak.SHRINK_WALK_AT + 2, "shrink_4_sprite", nil, true)
at(Oak.SHRINK_FADE_AT + 2, "shrink_5_fade", nil, true)
check("shrink_5_fade is mid fade", s.fadeLevel ~= nil)
for _ = 1, 400 do
if top() == game.overworld and game.overworld then break end
U.wait(1)
end
U.wait(10)
local shot = U.shot(game, DIR .. "/shrink_6_overworld.png")
U.log("after speech: top==overworld:", tostring(top() == game.overworld),
"map:", game.overworld and game.overworld.map
and game.overworld.map.id or "?")
check("speech hands off to the overworld", game.overworld ~= nil and top() == game.overworld)
check("shrink_6_overworld shot reached disk", shot)
love.event.quit(failed and 1 or 0)
end
+153
View File
@@ -0,0 +1,153 @@
-- engine/battle/move_effects/recoil.asm:65
-- engine/battle/core.asm:3226
local U = require("tests.drivers.util")
local Battle = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Version = require("src.core.GameVersion")
return function(game)
local deadline = love.timer.getTime() + 25
local old = {}
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do old[k] = rawget(game, k) end
local keys = { "save", "saveOptions", "saveLiveOptions", "writeSlot", "writeCartSlot" }
local writers = {}
for _, k in ipairs(keys) do writers[k] = SaveData[k] end
local volume = love.audio.getVolume()
local fails = 0
local function check(label, ok)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then fails = fails + 1 end
return ok
end
local ok, err = xpcall(function()
assert(Version.generation() == 1, "Gen1 required")
local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required")
local function forbidden() error("#2653 unexpected persistent write") end
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = forbidden end
for _, k in ipairs(keys) do SaveData[k] = forbidden end
love.audio.setVolume(0)
local function wait(n)
for _ = 1, n do
assert(love.timer.getTime() < deadline, "#2653 deadline")
U.wait(1)
end
end
local function fullyTyped(b)
local row = b.shown and b.shown[#b.shown]
return b.current and row and b.codes and #row == #b.codes and b.lineIndex == #b.lines
and b.total and b.total > 0 and b.charIndex >= b.total and not b.scrollPx and not b.msgWaiting
end
local function move(id)
local def = assert(game.data.moves[id], "required imported move " .. id)
return { id = id, pp = def.pp, maxPp = def.pp }
end
game:startNewGame({ intro = false })
game.save.options.animations = false
game.save.options.textSpeed = 1
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
local function runTurn(pauseAt)
game.save.party = { Pokemon.new(game.data, "TAUROS", 40) }
game.save.party[1].moves = { move("TAKE_DOWN") }
local battle = Battle.newWild(game, "SNORLAX", 50)
battle.enemy.mon.moves = { move("SPLASH") }
battle.enemy.curMoves = battle.enemy.mon.moves
battle.rng = function(a) return a or 0 end
local r = { step = 0 }
local update = battle.update
battle.update = function(self, ...)
if r.paused then return end
if r.armed then
r.step = r.step + 1
local p, e = self.player, self.enemy
if e.shownHP ~= r.lastEnemy then
r.enemyLastMove = r.step
r.lastEnemy = e.shownHP
end
if not r.playerFirstMove and p.shownHP < r.pStart then r.playerFirstMove = r.step end
if not r.eEnd and e.mon.hp < r.eStart then r.eEnd = e.mon.hp end
if not r.midStep and r.eEnd and e.shownHP <= math.floor((r.eStart + r.eEnd) / 2) then
r.midStep = r.step
r.midPlayerHP = p.shownHP
end
local row = self.current
if row and row.text and row.text:find("recoil", 1, true) and not r.recoilStep then
r.recoilStep = r.step
r.recoilPlayerHP = p.shownHP
end
if pauseAt and r.midStep == r.step then r.paused = true end
end
update(self, ...)
end
game.stack:push(battle)
local function advanceUntil(fn, label)
for _ = 1, 3000 do
if fn() then return true end
if not r.paused and battle.current and not battle.current.auto and fullyTyped(battle) then
U.tap(game, "a")
end
wait(1)
end
error(label)
end
advanceUntil(function() return battle.phase == "menu" end, "menu did not settle")
r.pStart, r.eStart = battle.player.mon.hp, battle.enemy.mon.hp
r.lastEnemy = battle.enemy.shownHP
U.tap(game, "a")
advanceUntil(function() return battle.phase == "moveSelect" end, "move list missing")
r.armed = true
U.tap(game, "a")
advanceUntil(function() return battle.player.mon.hp < r.pStart end, "recoil never applied")
r.eEnd, r.pEnd = r.eEnd or battle.enemy.mon.hp, battle.player.mon.hp
if pauseAt then
advanceUntil(function() return r.paused end, "never reached the counted mid-drain step")
r.shotOk = U.still(game, out .. "/2653_01_target_draining_user_bar_full.png")
r.shotPlayerHP, r.shotEnemyHP = battle.player.shownHP, battle.enemy.shownHP
r.paused = false
end
advanceUntil(function()
return battle.current and battle.current.text
and battle.current.text:find("recoil", 1, true) and fullyTyped(battle)
end, "recoil text never settled")
if pauseAt then
r.textShotOk = U.still(game, out .. "/2653_02_recoil_text_after_user_bar.png")
r.textPlayerHP = battle.player.shownHP
end
advanceUntil(function() return battle.phase == "menu" end, "turn did not finish")
game.stack:pop()
return r
end
local count = runTurn(nil)
check("#2653 the hit and the recoil both landed", count.eEnd < count.eStart and count.pEnd < count.pStart)
check("#2653 user bar holds full through the target's drain",
count.playerFirstMove and count.enemyLastMove and count.playerFirstMove > count.enemyLastMove)
check("#2653 user bar still full at mid target drain", count.midStep ~= nil and count.midPlayerHP == count.pStart)
check("#2653 user bar finished before the recoil text",
count.recoilStep ~= nil and count.recoilPlayerHP == count.pEnd)
local shoot = runTurn(count.midStep ~= nil)
print(("#2653 mid target drain at logic step %s (count pass) / %s (shot pass)"):format(
tostring(count.midStep), tostring(shoot.midStep)))
check("#2653 mid-drain shot captured", shoot.shotOk == true)
check("#2653 shot frame: target mid-drain, user bar full",
shoot.shotPlayerHP == shoot.pStart and shoot.shotEnemyHP and shoot.shotEnemyHP < shoot.eStart
and shoot.shotEnemyHP > shoot.eEnd)
check("#2653 recoil text shot captured with the user bar down",
shoot.textShotOk == true and shoot.textPlayerHP == shoot.pEnd)
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
end, debug.traceback)
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = old[k] end
for _, k in ipairs(keys) do SaveData[k] = writers[k] end
love.audio.setVolume(volume)
if not ok then print("FAIL #2653 " .. tostring(err)) end
love.event.quit((ok and fails == 0) and 0 or 1)
end
@@ -12,8 +12,10 @@ return function(game)
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local failed = false
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then failed = true end
return ok
end
@@ -24,7 +26,7 @@ return function(game)
U.wait(30)
local ow = game.overworld
check("overworld is up", ow ~= nil)
if not ow then while true do coroutine.yield() end end
if not ow then love.event.quit(1) return end
local npc
for _, e in ipairs(ow.entities) do
@@ -82,7 +84,7 @@ return function(game)
U.shot(game, DIR .. "/bug1948_wipe_ogred.png"))
local ok, last = replayRecord()
local sp = ow.player.sprite
local want = SpriteRenderer.obpImage(sp.def.image, PaletteFX.ogObj())
local want = SpriteRenderer.obpImage(sp.def.image, PaletteFX.ogObjWorld())
local dmg = SpriteRenderer.obpImage(sp.def.image, PaletteFX.dmgObj())
check("the ogred wipe replay queued a redraw", ok and last ~= nil)
check("the replayed survivor is the ogObj bake, not the DMG bake",
@@ -108,7 +110,5 @@ return function(game)
U.log("green, NOT DMG gray.")
U.log("bug1948_wipe_black.png: solid black, nobody left.")
while true do
coroutine.yield()
end
love.event.quit(failed and 1 or 0)
end
+315
View File
@@ -0,0 +1,315 @@
-- engine/items/inventory.asm:59
-- engine/events/pokemart.asm:152
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 function glyphs(text)
local n = 0
for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
n = n + 1
end
return n
end
local realFont = package.loaded["src.render.Font"]
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function() end,
drawCode = function() end,
drawBox = function() end,
width = function(text) return glyphs(text) * 8 end,
split = function(text)
local out = {}
for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
out[#out + 1] = s
end
return out
end,
spansFitting = function(spans, pixels)
return math.min(#spans, math.floor(pixels / 8))
end,
encode = function(text) return { text } end,
advanceOf = function() return 8 end,
}
local RELOAD = {
"src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu",
"src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox",
}
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
local GameVersion = require("src.core.GameVersion")
local Bag = require("src.inventory.Bag")
local ShopMenu = require("src.ui.ShopMenu")
local TextBox = require("src.render.TextBox")
local SaveData = require("src.core.SaveData")
local BAG_FULL = "You can't carry\nany more items."
local BOUGHT = "Here you are!\nThank you!"
local function boxText(box)
local out = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
local function rowsOf(save, data)
local out = {}
for _, r in ipairs(Bag.rows(save, data)) do
out[#out + 1] = r.id .. "x" .. tostring(r.count)
end
return table.concat(out, ",")
end
local function newGame(money, inventory, order)
local stack = {}
local game
game = {
data = {
items = {
X_ACCURACY = { name = "X ACCURACY", price = 950, index = 46 },
POTION = { name = "POTION", price = 300, index = 20 },
},
text = {
_PokemartItemBagFullText = BAG_FULL,
_PokemartBoughtItemText = BOUGHT,
},
constants = {},
field = {},
},
save = { money = money, inventory = inventory, bagOrder = order },
input = {
wasPressed = function() return false end,
isDown = function() return false end,
},
}
game.stack = {
push = function(_, s) stack[#stack + 1] = s end,
pop = function() return table.remove(stack) end,
top = function() return stack[#stack] end,
}
return game, stack
end
local function drain(game)
for _ = 1, 600 do
local top = game.stack:top()
if not (top and top.stay and not top.stayShown) then return end
top:update(1 / 60)
end
end
local function fill(game, n)
for i = 1, n do
local id = "FILLER_" .. i
game.save.inventory[id] = 1
game.save.bagOrder[#game.save.bagOrder + 1] = id
end
end
local function buy(game, stack, id, qty)
local menu = ShopMenu.new(game, { id }, function() end)
menu.items[1].onSelect()
drain(game)
local list = stack[#stack]
list.onChoose({ value = id })
local qtyBox = stack[#stack]
qtyBox.onDone(qty)
local confirm = stack[#stack]
game.stack:pop()
confirm.choice(true)
return stack[#stack]
end
GameVersion.set("red")
do
local game = newGame(0, { X_ACCURACY = 2, POTION = 5 }, { "X_ACCURACY", "POTION" })
eq(Bag.add(game.save, "X_ACCURACY", 99, game.data), true,
"2 + 99 X ACCURACY fits with a free slot")
eq(game.save.inventory.X_ACCURACY, 101, "the total is 101")
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx2",
"the first slot maxes at 99 and the overflow lands at the end")
eq(Bag.slots(game.save, game.data), 3, "three bag slots")
Bag.remove(game.save, "X_ACCURACY", 2, game.data, 2)
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5",
"tossing the overflow row removes that slot")
Bag.add(game.save, "X_ACCURACY", 2, game.data)
Bag.remove(game.save, "X_ACCURACY", 99, game.data, 1)
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx2",
"emptying the first slot keeps the later slot where it was")
end
do
local game = newGame(0, { X_ACCURACY = 99 }, { "X_ACCURACY" })
fill(game, 19)
eq(Bag.add(game.save, "X_ACCURACY", 1, game.data), false,
"a 20-slot bag refuses the overflow slot")
eq(game.save.inventory.X_ACCURACY, 99, "and changes nothing")
eq(#game.save.bagOrder, 20, "no slot is added")
end
do
local game = newGame(0, { X_ACCURACY = 149 }, { "X_ACCURACY", "X_ACCURACY" })
fill(game, 18)
eq(Bag.add(game.save, "X_ACCURACY", 10, game.data), false,
"a full bag refuses once the maxed first slot overflows")
eq(game.save.inventory.X_ACCURACY, 149, "and changes nothing")
end
do
local game, stack = newGame(104276, { X_ACCURACY = 2 }, { "X_ACCURACY" })
local box = buy(game, stack, "X_ACCURACY", 99)
if check(getmetatable(box) == TextBox, "mart: a text box follows the YES") then
check(boxText(box):find("Here you are!", 1, true) ~= nil,
"mart: the clerk hands the items over")
check(boxText(box):find("can't carry", 1, true) == nil,
"mart: no bag-full refusal")
end
eq(game.save.money, 104276 - 94050, "mart: 94050 is paid")
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,X_ACCURACYx2",
"mart: two X ACCURACY rows")
end
do
local game, stack = newGame(3000, { X_ACCURACY = 99 }, { "X_ACCURACY" })
fill(game, 19)
local box = buy(game, stack, "X_ACCURACY", 1)
if check(getmetatable(box) == TextBox, "mart full: a text box follows the YES") then
check(boxText(box):find("can't carry", 1, true) ~= nil,
"mart full: the bag-full refusal")
end
eq(game.save.money, 3000, "mart full: no money changes hands")
end
do
local game, stack = newGame(0, { X_ACCURACY = 101, POTION = 5 },
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
local menu = ShopMenu.new(game, {}, function() end)
menu.items[2].onSelect()
drain(game)
local list = stack[#stack]
local counts = {}
for _, it in ipairs(list.items) do
if not it.cancel then counts[#counts + 1] = tostring(it.count) end
end
eq(table.concat(counts, ","), "99,5,2", "sell list shows each slot's own count")
list.index = 3
list.onChoose(list.items[3])
local qtyBox = stack[#stack]
eq(qtyBox.max, 2, "selling from the overflow row caps at that row")
qtyBox.onDone(2)
local confirm = stack[#stack]
game.stack:pop()
confirm.choice(true)
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", "the sold row is gone")
eq(game.save.money, 950, "paid half price for two")
end
do
local game = newGame(0, { X_ACCURACY = 101, POTION = 5 },
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
local back = assert(SaveData.decode(SaveData.encode(game.save)))
SaveData.validate(back, game.data)
eq(rowsOf(back, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx2",
"split slots survive a save round trip")
end
do
local pc, order = { POTION = 99 }, { "POTION" }
local data = newGame(0, {}, {}).data
eq(Bag.addTo(pc, order, "POTION", 50, data, Bag.storeSlots(pc, data), 50), true,
"PC deposit past 99 splits the box slot")
eq(table.concat(order, ","), "POTION,POTION", "two box slots")
local full, fullOrder = { POTION = 1 }, { "POTION" }
for i = 1, 49 do
full["FILLER_" .. i] = 1
fullOrder[#fullOrder + 1] = "FILLER_" .. i
end
full.POTION = 99
eq(Bag.addTo(full, fullOrder, "POTION", 1, data, Bag.storeSlots(full, data), 50), false,
"a 50-slot box refuses the overflow slot")
end
do
local game = newGame(0, { X_ACCURACY = 101, POTION = 5 },
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
Bag.remove(game.save, "X_ACCURACY", 5, game.data, 1)
eq(rowsOf(game.save, game.data), "X_ACCURACYx94,POTIONx5,X_ACCURACYx2",
"tossing from the first slot leaves 94 and 2")
eq(Bag.slots(game.save, game.data), 3, "both X ACCURACY slots stay")
local back = assert(SaveData.decode(SaveData.encode(game.save)))
SaveData.validate(back, game.data)
eq(rowsOf(back, game.data), "X_ACCURACYx94,POTIONx5,X_ACCURACYx2",
"a 94/2 split survives a save round trip")
Bag.remove(game.save, "X_ACCURACY", 1, game.data)
eq(rowsOf(game.save, game.data), "X_ACCURACYx93,POTIONx5,X_ACCURACYx2",
"a slotless remove takes from the first slot")
Bag.add(game.save, "X_ACCURACY", 10, game.data)
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx6",
"adding tops the first slot up and carries the rest to the next")
Bag.swap(game.save, 1, 3, game.data)
eq(rowsOf(game.save, game.data), "X_ACCURACYx6,POTIONx5,X_ACCURACYx99",
"SELECT on two slots of one item moves the 99 to the second pick")
Bag.swap(game.save, 1, 2, game.data)
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx6,X_ACCURACYx99",
"swapping different items keeps each slot's count")
Bag.swap(game.save, 3, 2, game.data)
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx99,X_ACCURACYx6",
"the 99 follows the second pick when the sum exceeds 99")
Bag.remove(game.save, "X_ACCURACY", 90, game.data, 2)
Bag.swap(game.save, 2, 3, game.data)
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx15",
"SELECT merges two slots that fit in one")
eq(game.save.bagStacks, nil, "a merged stack carries no per-slot list")
end
do
local game, stack = newGame(0, { X_ACCURACY = 101, POTION = 5 },
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
local menu = ShopMenu.new(game, {}, function() end)
menu.items[2].onSelect()
drain(game)
local list = stack[#stack]
local function sellRow(row, qty)
list.index = row
list.onChoose(list.items[row])
local qtyBox = stack[#stack]
qtyBox.onDone(qty)
local confirm = stack[#stack]
game.stack:pop()
confirm.choice(true)
end
list.scroll = 1
sellRow(3, 1)
eq(list.scroll, 1, "selling part of a row keeps the list scroll")
eq(list.index, 3, "and the cursor")
sellRow(3, 1)
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", "the emptied row is gone")
eq(list.scroll, 0, "an emptied row resets the sell list scroll")
eq(list.index, 1, "and puts the cursor on the first row")
end
do
GameVersion.set("gold")
local game = newGame(0, { POTION = 99 }, { "POTION" })
game.data.items.POTION.pocket = "ITEM"
eq(Bag.add(game.save, "POTION", 1, game.data), false,
"Gen 2 pockets keep one row per id")
eq(#game.save.bagOrder, 1, "no duplicate row in Gen 2")
GameVersion.set("red")
end
package.loaded["src.render.Font"] = realFont
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
require("src.ui.Screens").invalidate()
T.finish()
@@ -10,7 +10,9 @@ love = love or require("tests.love_stub")
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
package.loaded["src.core.Music"] = { playMap = function() end }
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
new = function(_, text, done, opts)
return { textBox = true, text = text, done = done, opts = opts }
end,
soundOpts = function() return nil end,
}
package.loaded["src.ui.BagMenu"] = nil
@@ -114,6 +116,75 @@ do
end
end
-- engine/menus/start_sub_menus.asm:361
local function useLeavesBox(id, label)
local game = freshGame()
local list = openOn(game, byId(id))
if not check(list ~= nil, label .. ": the bag opened on the row") then return end
pressA(game, list)
local box = game.stack:top()
if not check(getmetatable(box) == Menu, label .. ": the USE/TOSS box is up") then return end
eq(box.index, 1, label .. ": the cursor sits on USE")
game.input.pressed = "a"
box:update(1 / 60)
game.input.pressed = nil
check(game.stack:top() ~= box, label .. ": USE takes the box off the stack")
check(game.stack:top() and game.stack:top().textBox,
label .. ": the use flow prints its text")
check(list.optionBox == box, label .. ": the box stays painted under the text")
eq(box.hollowIndex, 1, label .. ": with the hollow '▷' left on USE")
local drawn = false
local realDraw = box.draw
box.draw = function(self) drawn = true end
list.drawItemBox = function() end
list:draw()
list.drawItemBox = nil
box.draw = realDraw
check(drawn, label .. ": drawing the list repaints the leftover box")
game.stack:pop()
drawn = false
box.draw = function(self) drawn = true end
list.drawItemBox = function() end
list:draw()
list.drawItemBox = nil
box.draw = realDraw
check(not drawn, label .. ": the first frame back on the list has no box")
check(list.optionBox == nil, label .. ": the redraw erases it before any update")
check(list.hollowIndex == nil, label .. ": and the list's '▶' is filled again")
list:update(1 / 60)
check(list.optionBox == nil, label .. ": the list's next update keeps it erased")
end
useLeavesBox("FIX_TM", "TM booted-up text")
useLeavesBox("FIX_POTION", "potion refusal text")
-- engine/menus/start_sub_menus.asm:416
do
local game = freshGame()
local list = openOn(game, byId("FIX_TM"))
if check(list ~= nil, "TM YES: the bag opened on the TM row") then
pressA(game, list)
local box = game.stack:top()
game.input.pressed = "a"
box:update(1 / 60)
game.input.pressed = nil
local text = game.stack:top()
check(list.optionBox == box, "TM YES: the box is painted under the prompt")
local Screens = require("src.ui.Screens")
local realPush, pushed = Screens.push, nil
Screens.push = function(g, id)
pushed = id
g.stack:push({ partyMenu = true })
end
local choice = text and text.opts and text.opts.choice
if check(choice ~= nil, "TM YES: the prompt carries the YES/NO choice") then
choice(true)
end
Screens.push = realPush
eq(pushed, "PartyMenu", "TM YES: the party menu opens")
check(list.optionBox == nil, "TM YES: the party menu wipes the leftover box")
end
end
-- CANCEL exits like B and never opens the box (home/list_menu.asm:105-110)
do
local game = freshGame()
+143
View File
@@ -0,0 +1,143 @@
-- home/text.asm:262
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local SPACE = 0x7F
local LAST = Font.encode("Z")[1]
T.check(LAST ~= SPACE, "fixture charmap encodes the 18th glyph")
local function newBattle(opts)
opts = opts or {}
local save = SaveData.newGame()
save.player.name = "RED"
save.party = { Pokemon.new(Data, "FIXMON_A", 10, function(_, b) return b end) }
if opts.gold then save.version, save.generation = "gold", 2 end
if opts.wide then save.options = save.options or {}; save.options.battleLayout = "wide" end
local pressA = false
local game = {
data = Data, save = save,
stack = { top = function() return nil end, push = function() end },
input = { wasPressed = function(_, k) return pressA and (k == "a") end,
isDown = function() return false end },
}
local battle = BattleState.newWild(game, "FIXMON_C", 10)
battle.queue, battle.nextInsert = {}, 0
battle.phase = "messages"
return battle, function(v) pressA = v end
end
local function step(battle, until_)
for _ = 1, 2000 do
if until_() then return true end
battle:updateQueue()
battle:tickTextScroll()
end
return until_()
end
local function drawnAt(battle, frame)
battle.frame = frame
local real, realBox = Font.drawCode, Font.drawBox
local hits = {}
Font.drawCode = function(code, x, y) hits[#hits + 1] = { code = code, x = x, y = y } end
Font.drawBox = function() end
local ok, err = pcall(function() battle:drawTextArea() end)
Font.drawCode, Font.drawBox = real, realBox
T.check(ok, "drawTextArea runs (" .. tostring(err) .. ")")
local at, row = {}, 0
for _, h in ipairs(hits) do
if h.y == 128 then row = row + 1 end
if h.x == 144 and h.y == 128 then at[#at + 1] = h.code end
end
return at, row
end
local TEXT = "YOUR DEX IS\nENTIRELY COMPLETEZ\vCONGRATULATIONSZ"
do
local battle, setA = newBattle()
battle.queue[1] = { text = TEXT }
T.check(step(battle, function() return battle.msgWaiting end),
"red reaches the cont wait under an 18-tile line")
T.eq(battle.shown[2] and battle.shown[2][18], LAST,
"red bottom line's 18th glyph is typed")
battle.msgPreWait = 0
local lit, row = drawnAt(battle, 0)
T.check(row >= 17, "the bottom line is drawn")
T.eq(#lit, 0, "red arrow replaces the 18th glyph while lit")
local dark, rowDark = drawnAt(battle, 30)
T.check(rowDark >= 17, "the bottom line is drawn on blink-off")
T.eq(#dark, 0, "red blink-off shows a blank cell, not the glyph")
setA(true)
battle:updateQueue()
setA(false)
T.eq(battle.msgWaiting, nil, "red A clears the cont wait")
T.eq(battle.shown[1] and battle.shown[1][18], SPACE,
"red cont leaves a space in the scrolled line")
end
do
local battle, setA = newBattle({ gold = true })
battle.queue[1] = { text = TEXT }
step(battle, function() return battle.msgWaiting end)
battle.msgPreWait = 0
setA(true)
battle:updateQueue()
setA(false)
T.eq(battle.shown[1] and battle.shown[1][18], LAST,
"gold keeps column 18 (its arrow sits on the border row)")
end
do
local battle, setA = newBattle({ wide = true })
battle.queue[1] = { text = TEXT }
step(battle, function() return battle.msgWaiting end)
battle.msgPreWait = 0
setA(true)
battle:updateQueue()
setA(false)
T.eq(battle.shown[1] and battle.shown[1][18], LAST,
"wide layout keeps column 18 (its arrow sits at column 36)")
end
do
local battle, setA = newBattle()
battle.queue[1] = { text = "ITS A TEST OF\nTHE ARROW CELLS ZZ" }
T.check(step(battle, function() return battle.msgPrompt end),
"a typed-out page raises the prompt arrow")
T.eq(battle.shown[2][18], LAST, "the prompt page's 18th glyph is typed")
local lit, row = drawnAt(battle, 0)
T.check(row >= 17, "the prompt page's bottom line is drawn")
T.eq(#lit, 0, "prompt arrow replaces the 18th glyph")
battle.msgPromptWait = 0
setA(true)
battle:updateQueue()
setA(false)
T.eq(battle.msgPrompt, nil, "A dismisses the prompt")
T.eq(battle.shown[2][18], SPACE, "PromptText writes a space over the arrow cell")
local held = drawnAt(battle, 0)
T.eq(held[1], SPACE, "the held page shows the blank, not the glyph")
end
do
local battle = newBattle()
battle.queue[1] = { text = "SHORT LINE\nSHORT TOO\vNEXT" }
step(battle, function() return battle.msgWaiting end)
T.check(#battle.shown[2] < 18, "a short bottom line has no 18th glyph")
battle.msgPreWait = 0
T.eq(#drawnAt(battle, 30), 0, "nothing drawn in the arrow cell for a short line")
end
T.finish("battle arrow cell bug 2674")
@@ -0,0 +1,160 @@
-- engine/battle/common_text.asm:10
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local SOUND_FRAMES = 40
local soundLeft = 0
local function fakeSource()
soundLeft = SOUND_FRAMES
return { isPlaying = function() return soundLeft > 0 end,
stop = function() soundLeft = 0 end }
end
local real = {}
for _, k in ipairs({ "playCry", "play", "playMove", "playMoveCry", "stopLoop" }) do
real[k] = Sound[k]
end
local realMusic = { playBattle = Music.playBattle, play = Music.play }
Sound.playCry = function() return fakeSource() end
Sound.play = function(_, name)
if name == "Trainer_Appeared" then return fakeSource() end
end
Sound.playMove = function() end
Sound.playMoveCry = function() end
Sound.stopLoop = function() end
Music.playBattle = function() end
Music.play = function() end
local function makeGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20), Pokemon.new(Data, "FIXMON_B", 5) }
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
local function snapshot(battle)
local rows, strings = 0, {}
local realRow, realDraw = battle.drawBallRow, Font.draw
battle.drawBallRow = function() rows = rows + 1 end
Font.draw = function(text) strings[#strings + 1] = tostring(text) return 0 end
local ok, err = pcall(battle.drawHUDs, battle, 0)
battle.drawBallRow, Font.draw = realRow, realDraw
local enemyHud = false
for _, s in ipairs(strings) do
if s:find(battle.enemy.name, 1, true) then enemyHud = true end
end
return ok, err, rows, enemyHud
end
local function run(label, battle)
battle.onFinish = function() end
battle:enter()
T.check(battle.introBalls ~= true, label .. ": no ball window at enter")
local frames, soundOverlap = {}, false
local ballsAt, textAt, sawRows
for f = 1, 400 do
if soundLeft > 0 then soundLeft = soundLeft - 1 end
battle:update(1 / 60)
local cur = battle.current and battle.current.text
local atText = cur == battle.introText
if atText and not textAt then textAt = f end
if battle.introBalls == true and not ballsAt then
ballsAt = f
if soundLeft > 0 then soundOverlap = true end
end
local ok, err, rows, hud = snapshot(battle)
T.check(ok, label .. ": drawHUDs runs on frame " .. f .. (ok and "" or (": " .. tostring(err))))
frames[f] = { balls = battle.introBalls == true, rows = rows, hud = hud,
landed = (battle.introSlide or 0) == 0 }
if atText and rows > 0 then sawRows = true end
if textAt then break end
end
local early, earlyRows, earlyHud = false, false, false
for f, fr in ipairs(frames) do
if textAt and f < textAt - 1 then
if fr.balls then early = true end
if fr.rows > 0 then earlyRows = true end
end
if textAt and f < textAt and fr.landed and fr.hud then earlyHud = true end
end
return { early = early, earlyRows = earlyRows, earlyHud = earlyHud,
overlap = soundOverlap, ballsAt = ballsAt, textAt = textAt,
sawRows = sawRows }
end
GameVersion.set("red")
do
local r = run("wild", BattleState.newWild(makeGame(), "FIXMON_C", 5))
T.check(r.textAt ~= nil, "wild: the intro text comes up")
T.check(not r.early, "wild: the ball window stays shut until the intro text")
T.check(not r.overlap, "wild: the ball window opens after the cry has finished")
T.eq(r.ballsAt, r.textAt and r.textAt - 1, "wild: balls go up right before the intro text")
T.check(not r.earlyRows, "wild: no ball row is drawn during the cry")
T.check(not r.earlyHud, "wild: no enemy HUD is drawn during the cry")
T.check(r.sawRows, "wild: the ball row is drawn with the intro text")
end
do
local r = run("trainer", BattleState.newTrainer(makeGame(), "OPP_FIX_YOUNGSTER", 1))
T.check(r.textAt ~= nil, "trainer: the intro text comes up")
T.check(not r.early, "trainer: the ball window stays shut through the sfx and gap")
T.check(not r.overlap, "trainer: the ball window opens after the sfx has finished")
T.eq(r.ballsAt, r.textAt and r.textAt - 1, "trainer: balls go up right before the intro text")
T.check(not r.earlyRows, "trainer: no ball rows during the sfx and gap")
T.check(r.sawRows, "trainer: the ball rows are drawn with the intro text")
end
do
local ghost = BattleState.newWild(makeGame(), "FIXMON_C", 5)
ghost:makeGhost()
local r = run("ghost", ghost)
T.check(r.ballsAt == nil and not r.sawRows, "ghost: DrawAllPokeballs never runs")
T.check(not r.earlyHud, "ghost: no enemy HUD under the intro text")
local nextText = ghost.queue[1] and ghost.queue[1].text
T.check(type(nextText) == "string" and nextText:find("ID'd", 1, true) ~= nil,
"ghost: GhostCantBeIDdText follows the intro text")
end
do
local soul = BattleState.newWild(makeGame(), "FIXMON_C", 5)
soul:makeUnveiledGhost()
local r = run("unveiled", soul)
T.check(r.ballsAt == nil and not r.sawRows, "unveiled ghost: DrawAllPokeballs never runs")
end
do
local demo = BattleState.newWild(makeGame(), "FIXMON_C", 5)
demo:makeOldManDemo()
local r = run("red demo", demo)
T.check(r.sawRows, "red old man demo: DrawAllPokeballs runs (no wBattleType check)")
end
GameVersion.set("yellow")
do
local demo = BattleState.newWild(makeGame(), "FIXMON_C", 5)
demo:makeOldManDemo()
local r = run("yellow demo", demo)
T.check(r.ballsAt == nil and not r.sawRows, "yellow old man demo: wBattleType skips DrawAllPokeballs")
end
GameVersion.set("red")
for k, v in pairs(real) do Sound[k] = v end
Music.playBattle, Music.play = realMusic.playBattle, realMusic.play
T.finish()
+179
View File
@@ -0,0 +1,179 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness").suite("Boulder dust OBP1 flash #2650")
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local Assets = require("src.render.Assets")
local Game = require("src.core.Game")
local OW = require("src.world.OverworldController")
local ImageWriter = require("src.import.ImageWriter")
local bytes = {
smoke = {0,24,26,102,4,66,11,129,86,137,26,46,76,18,56,56},
redTree = {170,0,65,20,168,106,85,124,162,198,87,103,239,255,93,127,
170,40,213,252,162,214,73,227,226,214,85,236,236,126,189,88,
172,47,66,23,170,66,86,2,165,12,90,12,166,15,81,7,
42,224,85,192,234,64,213,64,106,48,213,56,104,242,149,224},
yellowTree = {170,0,65,20,168,106,85,124,130,254,87,127,239,255,93,127,
170,40,213,252,162,254,85,255,250,254,85,252,236,126,189,88,
172,47,66,23,170,66,86,2,165,12,90,12,166,15,81,7,
42,224,85,192,234,64,213,64,106,48,213,56,104,242,149,224},
}
local ID = {}; ID.__index = ID
function ID:getDimensions() return self.w, self.h end
function ID:getWidth() return self.w end
function ID:getHeight() return self.h end
function ID:getPixel(x, y) return unpack(self.p[y * self.w + x + 1] or {0,0,0,0}) end
function ID:setPixel(x, y, ...) self.p[y * self.w + x + 1] = {...} end
function ID:mapPixel(fn)
for y = 0, self.h - 1 do
for x = 0, self.w - 1 do self:setPixel(x, y, fn(x, y, self:getPixel(x, y))) end
end
end
function ID:setFilter() end
local function imageData(w, h) return setmetatable({w = w, h = h, p = {}}, ID) end
local paths, draws, allocations, tint = {}, {}, 0, {1,1,1,1}
love.image.newImageData = function(a, b)
if type(a) == "number" then return imageData(a, b) end
local source = assert(paths[a], "missing fixture " .. tostring(a))
local out = imageData(source.w, source.h)
for y = 0, source.h - 1 do
for x = 0, source.w - 1 do out:setPixel(x, y, source:getPixel(x, y)) end
end
return out
end
love.graphics.newImage = function(a)
allocations = allocations + 1
return type(a) == "string" and love.image.newImageData(a) or a
end
love.graphics.setShader = function() end
love.graphics.setColor = function(...) tint = {...} end
love.graphics.draw = function(image, ...)
draws[#draws + 1] = {image = image, args = {...}, alpha = tint[4]}
end
local function upvalue(fn, wanted)
for i = 1, 100 do
local name, value = debug.getupvalue(fn, i)
if not name then break end
if name == wanted then return value end
end
end
local fxDust = assert(upvalue(OW.drawWorld, "fxDust"))
local fxCutTree = assert(upvalue(OW.drawWorld, "fxCutTree"))
for i = 1, 100 do
local name = debug.getupvalue(fxDust, i)
if name == "Game" then debug.setupvalue(fxDust, i, Game); break end
end
local definitions = {
smoke = {path = "assets/generated/fx/b2650_smoke.png", w = 8, h = 8},
cutTree = {path = "assets/generated/fx/b2650_tree.png", w = 16, h = 16},
}
local function fixtures(version)
for key, def in pairs(definitions) do
local raw = key == "cutTree" and bytes[version == "yellow" and "yellowTree" or "redTree"] or bytes[key]
paths[def.path] = ImageWriter.decode2bpp(raw, def.w, def.h, true)
end
end
local function state()
return setmetatable({ map = {id = "VICTORY_ROAD_1F"}, camera = {x = 0, y = 0} }, {__index = OW})
end
Game.data = {field = {overworldFx = definitions}, palettes = {palettes = {}}, maps = {}}
local FLASH = { [0] = 0, [1] = 0, [2] = 0, [3] = 2 }
local function colorOf(r) return r > .83 and 0 or r > .5 and 1 or r > .17 and 2 or 3 end
local function checkPixels(rows, key, colors, label)
local source = paths[definitions[key].path]
local wrong, opaqueZero, translucent, visible = 0, 0, 0, 0
for _, row in ipairs(rows) do
local q = type(row.args[1]) == "table" and row.args[1]
for y = q and q.y or 0, (q and q.y or 0) + (q and q.h or source.h) - 1 do
for x = q and q.x or 0, (q and q.x or 0) + (q and q.w or source.w) - 1 do
local sr, _, _, sa = source:getPixel(x, y)
local c = colorOf(sr)
local p = {row.image:getPixel(x, y)}
if sa == 0 or c == 0 then
if p[4] ~= 0 then opaqueZero = opaqueZero + 1 end
else
local want = colors[c + 1]
for i = 1, 3 do
if math.abs(p[i] - want[i] / 255) > .00001 then wrong = wrong + 1; break end
end
if p[4] ~= 1 then translucent = translucent + 1 end
visible = visible + 1
end
end
end
T.eq(row.alpha, 1, label .. " drawn opaque, no alpha stand-in")
end
T.eq(wrong, 0, label .. " OBP1 %10000000 shade remap")
T.eq(opaqueZero, 0, label .. " color 0 stays transparent")
T.eq(translucent, 0, label .. " nonzero colors fully opaque")
T.check(visible > 0, label .. " nonempty")
end
local function flashOf(colors)
return { colors[FLASH[0] + 1], colors[FLASH[1] + 1], colors[FLASH[2] + 1], colors[FLASH[3] + 1] }
end
local cases = {
{name = "boulder", key = "smoke", group = 7, draw = fxDust, count = 4,
flash = function(s) s:startDustAnim(2, 3, nil, "right"); s.dustAnim.faded = true end,
lit = function(s) s:startDustAnim(2, 3, nil, "right"); s.dustAnim.faded = false end},
{name = "cut grass", key = "smoke", group = 6, draw = fxDust, count = 4,
flash = function(s) s:startDustAnim(2, 3); s.dustAnim.frames = 31 end,
lit = function(s) s:startDustAnim(2, 3); s.dustAnim.frames = 30 end},
{name = "cut tree", key = "cutTree", group = 6, draw = fxCutTree, count = 2,
flash = function(s) s:startCutTreeAnim(2, 3); s.cutAnim.frames = 7 end,
lit = function(s) s:startCutTreeAnim(2, 3); s.cutAnim.frames = 6 end},
}
local function render(s, case, which)
s.dustAnim, s.cutAnim = nil, nil
case[which](s); draws = {}; case.draw(s, s.camera)
return draws
end
local function rawOf(s, case) return s[case.key == "smoke" and "smokeImg" or "cutTreeImg"] end
local s = state()
local seen = {}
for _, version in ipairs({"red", "blue", "yellow"}) do
GameVersion.set(version); fixtures(version)
PaletteFX.setShadeMap(nil); PaletteFX.setDarkWorld(false)
for _, case in ipairs(cases) do
for _, mode in ipairs({"og", "gbc", "ogred"}) do
PaletteFX.setMode(mode)
local label = version .. " " .. mode .. " " .. case.name
local rows = render(s, case, "flash")
T.eq(#rows, case.count, label .. " draw count")
T.check(rows[1].image ~= rawOf(s, case), label .. " flash frame is not the identity image")
checkPixels(rows, case.key, flashOf(PaletteFX.GRAYS), label .. " flash")
local img, before = rows[1].image, allocations
T.eq(render(s, case, "flash")[1].image, img, label .. " flash bake cached")
T.eq(allocations, before, label .. " repeated flash draw allocates no image")
rows = render(s, case, "lit")
T.eq(rows[1].image, rawOf(s, case), label .. " identity frame is the raw image")
T.eq(rows[1].alpha, 1, label .. " identity frame opaque")
seen[version .. mode .. case.name] = img
end
PaletteFX.setMode("redpp")
local label = version .. " redpp " .. case.name
local obj = PaletteFX.worldPack().spritePalettes[case.group]
local rows = render(s, case, "flash")
checkPixels(rows, case.key, flashOf(obj), label .. " flash")
local img, before = rows[1].image, allocations
T.eq(render(s, case, "flash")[1].image, img, label .. " flash bake cached")
T.eq(allocations, before, label .. " repeated flash draw allocates no image")
rows = render(s, case, "lit")
checkPixels(rows, case.key, obj, label .. " identity")
T.check(rows[1].image ~= img, label .. " identity and flash bakes differ")
end
end
T.check(seen["yellowogredcut tree"] ~= seen["redogredcut tree"], "yellow tree flash does not alias red bake")
PaletteFX.setMode("gbc")
T.finish()
@@ -0,0 +1,54 @@
-- scripts/ChampionsRoom.asm:30
-- home/map_objects.asm:179
-- home/overworld.asm:1844
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 story = require("data.scripts.story")
local queued = {}
local ow = {
player = { cellX = 3, cellY = 7 },
npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } },
queueScript = function(_, script, extra)
queued[#queued + 1] = { script = script, extra = extra }
end,
}
story.CHAMPIONS_ROOM.onEnter({ save = { flags = {} } }, ow)
eq(#queued, 2, "entrance walk and rival script are queued")
local walk = queued[1] and queued[1].script or {}
local rle = { { "up", 1 }, { "right", 1 }, { "up", 3 } }
local expected = {}
for i = #rle, 1, -1 do expected[#expected + 1] = rle[i] end
eq(#walk, #expected, "entrance walk has one row per RLE entry")
for i, e in ipairs(expected) do
local row = walk[i] or {}
eq(row[1], "move_player", "row " .. i .. " is move_player")
eq(row[2], e[1], "row " .. i .. " direction plays the RLE back to front")
eq(row[3], e[2], "row " .. i .. " count plays the RLE back to front")
end
local dx = { up = 0, down = 0, left = -1, right = 1 }
local dy = { up = -1, down = 1, left = 0, right = 0 }
local x, y = 3, 7
local cells = { x .. "," .. y }
local lastDir
for _, row in ipairs(walk) do
if row[1] == "move_player" then
for _ = 1, row[3] do
x, y = x + dx[row[2]], y + dy[row[2]]
cells[#cells + 1] = x .. "," .. y
end
lastDir = row[2]
end
end
eq(table.concat(cells, " "), "3,7 3,6 3,5 3,4 4,4 4,3",
"player walks up the left carpet column and sidesteps just below the rival")
eq(lastDir, "up", "player ends facing up at the rival")
T.finish("champion_entrance_walk_bug2666")
@@ -0,0 +1,76 @@
-- engine/menus/main_menu.asm:357
package.path = "./?.lua;./?/init.lua;" .. package.path
local love = _G.love or require("tests.love_stub")
_G.love = love
local S = require("tests.harness").suite("continue info layout")
local check, eq = S.check, S.eq
local Font = require("src.render.Font")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TitleState = require("src.ui.TitleState")
local savedGetInfo = love.filesystem.getInfo
local savedLoad = SaveData.load
local savedDraw, savedBox = Font.draw, Font.drawBox
local owned = {}
for i = 1, 151 do owned[i] = true end
local inventory = {}
for _, id in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE",
"EARTHBADGE" }) do
inventory[id] = 1
end
local fakeSave = {
player = { name = "RED" },
inventory = inventory,
pokedex = { owned = owned },
playTime = 47 * 3600 + 27 * 60,
}
love.filesystem.getInfo = function() return { type = "file" } end
SaveData.load = function() return fakeSave end
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local game = { data = {}, stack = stack }
local title = TitleState.new(game, {})
title.game = game
title:openMenu()
local menu = game.stack:top()
check(menu and menu.items and menu.items[1], "main menu opened with items")
menu.items[1].onSelect()
local info = game.stack:top()
check(info and info ~= menu and info.draw, "CONTINUE pushed the info window")
local drawn = {}
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text, x, y } end
Font.drawBox = function() end
local ok, err = pcall(function() info:draw() end)
Font.draw, Font.drawBox = savedDraw, savedBox
love.filesystem.getInfo = savedGetInfo
SaveData.load = savedLoad
check(ok, "ContinueInfo:draw runs: " .. tostring(err))
local function at(text)
for _, d in ipairs(drawn) do
if d[1] == text then return d[2], d[3] end
end
end
local bx, by = at(" 8")
eq(bx, 17 * 8, "badge count starts at hlcoord 17 (main_menu.asm:370)")
eq(by, 11 * 8, "badge count on row 11")
local dx, dy = at("151")
eq(dx, 16 * 8, "dex count starts at hlcoord 16 (main_menu.asm:372)")
eq(dy, 13 * 8, "dex count on row 13")
local tx, ty = at(" 47:27")
eq(tx, 13 * 8, "play time starts at hlcoord 13 (main_menu.asm:374)")
eq(ty, 15 * 8, "play time on row 15")
eq(bx + 2 * 8, tx + 6 * 8, "badge count ends flush with the play time")
eq(dx + 3 * 8, tx + 6 * 8, "dex count ends flush with the play time")
S.finish()
+103
View File
@@ -39,6 +39,109 @@ local ok, err = pcall(function()
end)
check(ok, "Diploma:draw() runs without error: " .. tostring(err))
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local SpriteRenderer = require("src.render.SpriteRenderer")
local GameVersion = require("src.core.GameVersion")
local fakePic = setmetatable({ w = 40, h = 56 }, {
__index = {
getDimensions = function(s) return s.w, s.h end,
getWidth = function(s) return s.w end,
getHeight = function(s) return s.h end,
},
})
local origPlayerPic, origObp = Sprites.playerPic, SpriteRenderer.obpImage
local origScissor, origDraw = love.graphics.setScissor, love.graphics.draw
local origMode, origIsBlue = PaletteFX.mode, GameVersion.isBlue
local bakes, scissors, picDraws = {}, {}, {}
Sprites.playerPic = function() return "fake/player.png", false end
SpriteRenderer.obpImage = function(path, ramp, group)
bakes[#bakes + 1] = { path = path, ramp = ramp, group = group }
return fakePic
end
love.graphics.setScissor = function(...)
scissors[#scissors + 1] = { ... }
end
love.graphics.draw = function(img, a, b)
if img == fakePic then picDraws[#picDraws + 1] = { a, b } end
end
local function renderIn(mode, pass)
bakes, scissors, picDraws = {}, {}, {}
PaletteFX.mode = mode
PaletteFX.clearSpriteRedraws()
PaletteFX.setPass(pass)
local okR, errR = pcall(Diploma.render, Game)
check(okR, "Diploma.render under " .. tostring(mode) .. "/"
.. tostring(pass) .. ": " .. tostring(errR))
local redraws = {}
for _, r in ipairs(PaletteFX.uiSpriteRedraws()) do redraws[#redraws + 1] = r end
PaletteFX.setPass(nil)
PaletteFX.clearSpriteRedraws()
return redraws
end
local function noNarrowScissor(label)
for _, s in ipairs(scissors) do
local x, w = s[1], s[3]
check(x == nil or x + w >= 155,
label .. ": scissor never clips the pic short of x 155")
end
end
local r = renderIn("sgb", "ui")
noNarrowScissor("sgb")
eq(#picDraws, 1, "sgb draws the player pic once")
eq(picDraws[1] and picDraws[1][1], 115, "pic x 115 (diploma.asm:44 +33)")
eq(picDraws[1] and picDraws[1][2], 80, "pic y 80")
eq(bakes[1] and bakes[1].group, "diploma", "sgb keeps the OBP0 $90 DMG bake")
eq(#r, 0, "sgb records no OBJ-ramp redraw")
GameVersion.isBlue = function() return false end
r = renderIn("ogred", "ui")
noNarrowScissor("ogred")
eq(bakes[1] and bakes[1].group, "diploma_og", "OG RED bakes the OBJ ramp")
local c = PaletteFX.OG_RED_SOFT_OBJ
local ramp = bakes[1] and bakes[1].ramp or {}
check(ramp[2] == c[1] and ramp[3] == c[2] and ramp[4] == c[3],
"OG RED OBP0 $90 maps colors 1-3 to OBJ shades 0, 1, 2")
check(r[1] and r[1].image == fakePic and r[1].x == 115 and r[1].y == 80,
"OG RED replays the pic at (115, 80) after the zone pass")
check(r[2] and r[2].clip and r[2].clip[1] == 115 and r[2].clip[2] == 80
and r[2].clip[3] == 40 and r[2].clip[4] == 56,
"OG RED replays the BG text over the pic, clipped to the pic")
local canvasSets = 0
local origSetCanvas = love.graphics.setCanvas
love.graphics.setCanvas = function(c)
if c then canvasSets = canvasSets + 1 end
return origSetCanvas(c)
end
renderIn("ogred", "ui")
eq(canvasSets, 0, "OG RED reuses the cached text overlay on a second draw")
Game.save.player.name = "RED"
renderIn("ogred", "ui")
eq(canvasSets, 1, "a changed player name rebuilds the text overlay once")
Game.save.player.name = "ASH"
love.graphics.setCanvas = origSetCanvas
GameVersion.isBlue = function() return true end
r = renderIn("ogred", "ui")
eq(bakes[1] and bakes[1].group, "diploma_og_blue",
"OG BLUE bakes under a version-distinct cache group")
check(bakes[1] and bakes[1].ramp[3] == PaletteFX.GBC_OBJ_BLUE[2],
"OG BLUE uses Blue's OBJ ramp")
GameVersion.isBlue = origIsBlue
r = renderIn("ogred", nil)
eq(bakes[1] and bakes[1].group, "diploma", "printer path keeps the DMG bake")
eq(#r, 0, "printer path records no redraw")
Sprites.playerPic, SpriteRenderer.obpImage = origPlayerPic, origObp
love.graphics.setScissor, love.graphics.draw = origScissor, origDraw
PaletteFX.mode = origMode
-- Confirm dismissal on A or B press
Input.pressed = { a = true }
diploma:update()
@@ -0,0 +1,164 @@
-- engine/pokemon/evos_moves.asm:156
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")
package.loaded["src.core.Sound"] = {
play = function() end,
playCry = function() end,
playPress = function() end,
stopLoop = function() end,
}
local Fixtures = require("tests.modkit.fixtures")
local Music = require("src.core.Music")
local Evolution = require("src.pokemon.Evolution")
local EvolutionState = require("src.ui.EvolutionState")
local Input = require("src.core.Input")
local Pokemon = require("src.pokemon.Pokemon")
local StateStack = require("src.core.StateStack")
local TextBox = require("src.render.TextBox")
local Data = Fixtures.fresh()
require("src.render.Font").load(Data)
local restores = 0
Music.restoreMap = function() restores = restores + 1 end
local A_KEY, B_KEY = "z", "x"
local function newGame(count)
local game = { data = Data }
local party = {}
for i = 1, count do party[i] = Pokemon.new(Data, "FIXMON_A", 16) end
game.save = {
party = party,
player = { name = "RED", id = 1 },
options = { textSpeed = 5 },
flags = {},
pokedex = { seen = {}, owned = {} },
}
game.stack = setmetatable({}, { __index = StateStack })
game.stack:init()
game.input = Input
Input:init()
local under = { isOpaque = true, update = function() end, draw = function() end }
game.stack:push(under)
return game, party, under
end
local function step(game, key)
if key then Input:keypressed(key) end
game.input:step()
game.stack:update(1 / 60)
if key then Input:keyreleased(key) end
end
local function indexOf(game, state)
for i, s in ipairs(game.stack.states) do
if s == state then return i end
end
end
local function leftovers(game)
local n = 0
for _, s in ipairs(game.stack.states) do
if s.evoClear or s.evoIntro or getmetatable(s) == EvolutionState then
n = n + 1
end
end
return n
end
local function runBatch(cancelFirst)
local game, party, battle = newGame(2)
restores = 0
local finished = false
local leveled = { [party[1]] = true, [party[2]] = true }
Evolution.checkParty(game, function() finished = true end, leveled)
local intros, seen = 0, {}
local secondIntro
local restoresBeforeSecond
for frame = 1, 6000 do
if finished then break end
local top = game.stack:top()
if top and top.evoIntro and not seen[top] then
seen[top] = true
intros = intros + 1
if intros == 2 then
secondIntro = {
below = game.stack.states[indexOf(game, top) - 1],
base = game.stack:visibleBase(),
battleAt = indexOf(game, battle),
}
restoresBeforeSecond = restores
end
end
local key = A_KEY
if cancelFirst and intros == 1 and getmetatable(top) == EvolutionState
and top.t > 100 and not top.done then
key = B_KEY
elseif frame % 2 == 0 then
key = nil
end
step(game, key)
end
return {
game = game, party = party, battle = battle, finished = finished,
intros = intros, second = secondIntro,
restoresBeforeSecond = restoresBeforeSecond,
}
end
for _, cancelFirst in ipairs({ false, true }) do
local label = cancelFirst and "cancelled first mon" or "two evolutions"
local r = runBatch(cancelFirst)
eq(r.intros, 2, label .. ": both mons get an IsEvolvingText box")
if check(r.second ~= nil, label .. ": the second intro box went up") then
check(r.second.below and r.second.below.evoClear and r.second.below.isOpaque,
label .. ": the second intro draws over an opaque blank screen")
check(r.second.base > r.second.battleAt,
label .. ": the battle screen is not drawn under the second intro")
eq(r.restoresBeforeSecond, 0,
label .. ": no map music between the two evolutions")
end
check(r.finished, label .. ": the batch finished")
eq(r.game.stack:top(), r.battle, label .. ": the battle is top again")
eq(leftovers(r.game), 0, label .. ": no evolution layers are left")
eq(restores, 0, label .. ": the in-battle batch never restores map music")
eq(r.party[2].species, "FIXMON_B", label .. ": the second mon evolved")
eq(r.party[1].species, cancelFirst and "FIXMON_A" or "FIXMON_B",
label .. ": the first mon's species")
end
for _, via in ipairs({ "ITEM", "TRADE" }) do
local game, party, menu = newGame(1)
restores = 0
local finished, topAtDone = false, nil
Evolution.evolve(game, party[1], "FIXMON_B", function()
finished = true
topAtDone = game.stack:top()
end, via)
local sawClear = false
local realClear = Evolution.clearScreen
Evolution.clearScreen = function(g, species)
realClear(g, species)
local top = g.stack:top()
if top and top.evoClear and top.isOpaque then sawClear = true end
end
for frame = 1, 6000 do
if finished then break end
step(game, frame % 2 == 1 and A_KEY or nil)
end
check(finished, via .. ": the single evolution finished")
eq(topAtDone, menu, via .. ": the caller's screen is top when onDone runs")
eq(leftovers(game), 0, via .. ": no evolution layers are left")
eq(restores, 1, via .. ": map music comes back once after the result box")
Evolution.clearScreen = realClear
check(sawClear, via .. ": the screen clears once the result box closes")
end
T.finish()
+20 -9
View File
@@ -127,23 +127,34 @@ do
Menu.show({ game = game, session = { version = "ruby", engineOptions = game.options } })
for _ = 1, 40 do Menu.update() end
local top = Menu._pages[1]
for i = 1, 7 do eq(top.rows[i].native, i, "RS OPTION keeps native row " .. i .. " in cart order") end
local seen = {}
for i = 8, #top.rows do seen[top.rows[i].port.id] = i end
check(seen["group.graphics"] ~= nil, "RS OPTION lists a visible GRAPHICS row below CANCEL")
check(seen["group.speed"] and seen["group.audio"] and seen["controls"] and seen["mods"],
"RS OPTION carries the Emerald port pages")
check(seen["eventTickets"] == nil and seen["textSpeed"] == nil, "RS port rows skip cart rows and Emerald-only rows")
for i, r in ipairs(top.rows) do seen[r.id] = i end
local order = {}
for _, id in ipairs(Rows.ORDER) do if seen[id] then order[#order + 1] = id end end
for i, id in ipairs(order) do eq(top.rows[i].id, id, "RS OPTION top row " .. i .. " follows Emerald's grouped order") end
check(seen["group.speed"] and seen["group.graphics"] and seen["group.audio"] and seen["group.battle"]
and seen["controls"] and seen["mods"], "RS OPTION carries the Emerald pages")
check(seen["textSpeed"] == nil and seen["sound"] == nil and seen["frameType"] == nil,
"RS cart rows move into their Emerald groups")
eq(top.rows[seen["buttonMode"]].native, 5, "BUTTON MODE stays a native cart row on the top page")
check(seen["eventTickets"] == nil, "RS skips Emerald-only rows")
Menu.handleInput(input({ "select" }))
check(not ShaderFXMenu.isOpen(), "SELECT is not a hidden SHADER FX shortcut")
for _ = 1, seen["group.graphics"] - 1 do Menu.handleInput(input({ "down" })) end
eq(top.scroll, seen["group.graphics"] - 7, "the list scrolls to reach the port rows")
Menu.handleInput(input({ "a" }))
local graphics = Menu._pages[#Menu._pages]
check(#Menu._pages == 2, "A on GRAPHICS opens its page")
local si
for i, r in ipairs(graphics.rows) do if r.id == "shaderfx" then si = i end end
local si, fi
for i, r in ipairs(graphics.rows) do
if r.id == "shaderfx" then si = i end
if r.id == "frameType" and r.native == 6 then fi = i end
end
check(fi ~= nil, "RS GRAPHICS carries the native FRAME row")
check(si ~= nil, "RS GRAPHICS lists SHADER FX")
for _ = 1, (fi or 1) - 1 do Menu.handleInput(input({ "down" })) end
Menu.handleInput(input({ "right" }))
eq(Menu._pending.frameType, 1, "RIGHT on the grouped FRAME row steps the pending frame")
graphics.index = 1
for _ = 1, (si or 1) - 1 do Menu.handleInput(input({ "down" })) end
Menu.handleInput(input({ "a" }))
check(ShaderFXMenu.isOpen(), "A on the visible SHADER FX row opens the picker")
+3 -2
View File
@@ -256,9 +256,10 @@ for _, transparent in ipairs({false, true}) do
render(s, case)
if case.key == "cutTree" then s.cutAnim.frames = 5
elseif s.dustAnim.boulder then s.dustAnim.faded = true
else s.dustAnim.frames = 28 end
else s.dustAnim.frames = 31 end
draws = {}; case.fn(s, s.camera)
T.eq(draws[1].alpha, .55, version .. " " .. case.name .. " flicker tint preserved")
T.eq(draws[1].alpha, 1, version .. " " .. case.name .. " OBP1 flash frame drawn opaque")
T.check(draws[1].image ~= render(s, case)[1].image, version .. " " .. case.name .. " OBP1 flash frame uses its own bake")
end
end
end
+133
View File
@@ -0,0 +1,133 @@
-- data/sprites/facings.asm:51
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local check, eq = T.check, T.eq
T.fixtures.fresh()
local TileRenderer = require("src.render.TileRenderer")
local PaletteFX = require("src.render.PaletteFX")
local OW = require("src.world.OverworldController")
local function upvalue(fn, name)
local i = 1
while true do
local n, v = debug.getupvalue(fn, i)
if not n then return nil end
if n == name then return v end
i = i + 1
end
end
check(type(TileRenderer.feetStrip) == "function", "TileRenderer exposes the feet strip")
local x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, nil)
eq(x0, 32, "strip starts at the sprite's left edge")
eq(y0, 52, "strip starts 4 px into the cell (bottom OAM row, sprite row 8)")
eq(x1, 48, "strip is the sprite's 16 px width")
eq(y1, 60, "strip ends 4 px above the cell bottom (sprite row 15)")
local vanilla = { frameWidth = 16, frameHeight = 16, anchorX = 8, anchorY = 16 }
x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, vanilla)
eq(y0, 52, "vanilla 16x16 frame gets the same strip")
eq(y1, 60, "vanilla 16x16 frame strip is 8 rows")
local tall = { frameWidth = 16, frameHeight = 32, anchorX = 8, anchorY = 32 }
x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, tall)
eq(y0, 52, "a 32-tall frame keeps its strip on the frame's bottom 8 rows")
eq(y1, 60, "a 32-tall frame strip is still 8 rows")
local function spans(a, b, c, d)
local out = {}
TileRenderer.eachStripSpan(a, b, c, d, function(tx, ty, x, y, ox, oy, w, h)
out[#out + 1] = { tx = tx, ty = ty, x = x, y = y, ox = ox, oy = oy, w = w, h = h }
end)
return out
end
local s = spans(32, 52, 48, 60)
eq(#s, 4, "a standing strip touches 2x2 tiles")
eq(s[1].ty, 6, "first span is the cell's top tile row")
eq(s[1].oy, 4, "top tile row is cut from its row 4")
eq(s[1].h, 4, "top tile row contributes its lower 4 rows")
eq(s[3].ty, 7, "second span is the cell's bottom tile row")
eq(s[3].oy, 0, "bottom tile row starts at its row 0")
eq(s[3].h, 4, "bottom tile row contributes its upper 4 rows")
eq(s[1].w, 8, "standing strip spans whole tile columns")
s = spans(36, 54, 52, 62)
eq(#s, 6, "a mid-step strip straddles three tile columns")
eq(s[1].ox, 4, "left column clips to the sprite's left edge")
eq(s[1].w, 4, "left column keeps only the covered pixels")
eq(s[3].w, 4, "right column clips to the sprite's right edge")
eq(s[1].oy + s[1].h, 8, "top span runs to its tile's bottom")
eq(s[4].h, 6, "bottom span covers the rest of the strip")
local image = { getDimensions = function() return 128, 128 end }
local fakeMap = {
tileset = { tilesPerRow = 16 },
tileAt = function() return 0x52 end,
}
local r = setmetatable({ map = fakeMap, image = image, quads = { [0x52] = {} } },
TileRenderer)
local drawn = {}
local realDraw = love.graphics.draw
love.graphics.draw = function(_, q, x, y)
drawn[#drawn + 1] = { q = q, x = x, y = y }
end
r:drawStripRaw(32, 52, 48, 60, 0, 0)
love.graphics.draw = realDraw
local covered = {}
for _, d in ipairs(drawn) do
for row = d.y, d.y + d.q.h - 1 do covered[row] = true end
end
for row = 48, 51 do
check(not covered[row], "sprite row " .. (row - 44) .. " stays above the grass")
end
for row = 52, 59 do
check(covered[row], "sprite row " .. (row - 44) .. " is behind the grass")
end
for row = 60, 63 do
check(not covered[row], "cell row " .. (row - 48) .. " below the sprite is not redrawn")
end
local marks = {}
local realMark = PaletteFX.markSpriteRedraw
PaletteFX.markSpriteRedraw = function(img, q, x, y)
marks[#marks + 1] = { q = q, x = x, y = y }
end
r:markStripRedraw(32, 52, 48, 60, 0, 0, {})
PaletteFX.markSpriteRedraw = realMark
local markRows = {}
for _, m in ipairs(marks) do
for row = m.y, m.y + m.q.h - 1 do markRows[row] = true end
end
check(markRows[52] and markRows[59] and not markRows[60] and not markRows[51],
"OG RED replay redraws the same strip")
local grassStrip = upvalue(OW.drawWorld, "grassStrip")
check(type(grassStrip) == "function", "drawWorld places grass by the feet strip")
if grassStrip then
local map = {
renderer = r,
isGrassCell = function(_, cx, cy) return cx == 2 and cy == 3 end,
}
local ow = { map = map }
local standing = { cellX = 2, cellY = 3, px = 32, py = 48 }
local a, b, c, d = grassStrip(ow, standing)
eq(b, 52, "entity standing in grass gets the strip from row py+4")
eq(d, 60, "entity standing in grass gets the strip to row py+12")
local entering = { cellX = 2, cellY = 2, targetX = 2, targetY = 3, px = 32, py = 40,
pose = function(self) return vanilla, self.px, self.py end }
a, b, c, d = grassStrip(ow, entering)
eq(b, 44, "stepping into grass follows the sprite mid-step")
check(grassStrip(ow, { cellX = 1, cellY = 1, px = 16, py = 16 }) == nil,
"entity outside grass gets no strip")
end
T.finish("grass feet strip")
+62
View File
@@ -0,0 +1,62 @@
-- engine/events/hidden_events/indigo_plateau_statues.asm:5
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local fakeGame = {
data = { text = {
_IndigoPlateauStatuesText1 = "PLAQUE",
_IndigoPlateauStatuesText2 = "ULTIMATE GOAL",
_IndigoPlateauStatuesText3 = "HIGHEST AUTHORITY",
} },
stack = { push = function(_, box) pushed = box end },
}
T.check(setUpvalue(OW.tryBookshelf, "TextBox", textBoxStub),
"TextBox upvalue on tryBookshelf")
T.check(setUpvalue(OW.tryBookshelf, "Game", fakeGame),
"Game upvalue on tryBookshelf")
local map = {
def = { tileset = "PLATEAU" },
inBounds = function() return true end,
cellTile = function() return 0x30 end,
}
local function read(cellX)
pushed = nil
local self_ = setmetatable({ player = { facing = "up", cellX = cellX, cellY = 6 },
map = map }, { __index = OW })
local ok = self_:tryBookshelf(cellX, 5)
return ok, pushed and pushed.text
end
for _, x in ipairs({ 9, 11 }) do
local ok, text = read(x)
T.eq(ok, true, "odd X " .. x .. " statue is consumed")
T.eq(text, "PLAQUE\fULTIMATE GOAL", "odd X " .. x .. " prints Text2")
end
for _, x in ipairs({ 8, 10 }) do
local ok, text = read(x)
T.eq(ok, true, "even X " .. x .. " statue is consumed")
T.eq(text, "PLAQUE\fHIGHEST AUTHORITY", "even X " .. x .. " prints Text3")
end
T.finish("indigo_statues_bug2683")
@@ -0,0 +1,62 @@
-- scripts/LancesRoom.asm:54
-- home/overworld.asm:1212
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 story4 = require("data.scripts.story4")
local hooks = story4.LANCES_ROOM
local function facingToward(npc, player)
local dx, dy = player.cellX - npc.cellX, player.cellY - npc.cellY
if math.abs(dx) > math.abs(dy) then
return dx < 0 and "left" or "right"
end
return dy < 0 and "up" or "down"
end
local function trigger(x, y, playerFacing)
local lance = {
def = { name = "LANCESROOM_LANCE", index = 1,
trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 },
id = "LANCES_ROOM:1",
cellX = 6, cellY = 1,
facing = "down",
}
function lance:facePlayer(player) self.facing = facingToward(self, player) end
local engaged = false
local facingAtEngage
local game = { data = {}, save = { flags = {}, defeatedTrainers = {} } }
local ow = {
npcs = { lance },
player = { cellX = x, cellY = y, facing = playerFacing },
}
function ow:trainerDefeated(npc) return game.save.defeatedTrainers[npc.id] == true end
function ow:engageTrainer(npc)
engaged = npc == lance
facingAtEngage = npc.facing
end
local handled = hooks.onStep(game, ow, x, y)
return handled, engaged, facingAtEngage, lance
end
do
local handled, engaged, atEngage, lance = trigger(5, 1, "right")
check(handled == true, "(5,1) trigger handled")
check(engaged, "(5,1) trigger engages Lance")
eq(atEngage, "down", "(5,1) Lance still faces down when his text starts")
eq(lance.facing, "down", "(5,1) Lance keeps his object_event DOWN facing")
end
do
local handled, engaged, atEngage, lance = trigger(6, 2, "up")
check(handled == true, "(6,2) trigger handled")
check(engaged, "(6,2) trigger engages Lance")
eq(atEngage, "down", "(6,2) Lance faces down when his text starts")
eq(lance.facing, "down", "(6,2) Lance keeps facing down")
end
T.finish("lance_side_trigger_facing_bug2669")
@@ -0,0 +1,45 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local played
local realPlayVictory = Music.playVictory
Music.playVictory = function(data, kind) played = kind end
local function run(kind, id, partyIndex)
local b = setmetatable({
data = {},
kind = kind,
oppClass = id,
partyIndex = partyIndex,
trainer = id and { id = id } or nil,
}, { __index = BattleState })
b.musicKind = b:computeMusicKind()
played = nil
b:playVictoryMusic()
return b.musicKind, played
end
local cases = {
{ "trainer", "OPP_LANCE", 1, "gym", "trainer" },
{ "trainer", "OPP_LORELEI", 1, "trainer", "trainer" },
{ "trainer", "OPP_AGATHA", 1, "trainer", "trainer" },
{ "trainer", "OPP_BROCK", 1, "gym", "gym" },
{ "trainer", "OPP_GIOVANNI", 3, "gym", "gym" },
{ "trainer", "OPP_GIOVANNI", 2, "trainer", "trainer" },
{ "trainer", "OPP_RIVAL3", 1, "final", "gym" },
{ "trainer", "OPP_YOUNGSTER", 1, "trainer", "trainer" },
{ "wild", nil, nil, "wild", "wild" },
}
for _, c in ipairs(cases) do
local label = tostring(c[2] or "wild") .. "#" .. tostring(c[3] or "-")
local battleKind, victoryKind = run(c[1], c[2], c[3])
T.eq(battleKind, c[4], label .. " battle theme kind")
T.eq(victoryKind, c[5], label .. " victory theme kind")
end
Music.playVictory = realPlayVictory
T.finish("lance victory music bug2659")
+88
View File
@@ -0,0 +1,88 @@
-- scripts/LancesRoom.asm:96
-- home/map_objects.asm:179
-- home/overworld.asm:1845
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 GameVersion = require("src.core.GameVersion")
local savedSound = package.loaded["src.core.Sound"]
local played = {}
package.loaded["src.core.Sound"] = {
play = function(_, name) played[#played + 1] = name end,
}
local story4 = require("data.scripts.story4")
local hooks = story4.LANCES_ROOM
local function reversed(rle)
local out = {}
for i = #rle, 1, -1 do out[#out + 1] = rle[i] end
return out
end
local function sameRoute(got, want, label)
eq(#got, #want, label .. ": segment count")
for i, w in ipairs(want) do
local g = got[i] or {}
eq(g[1], w[1], label .. ": segment " .. i .. " direction")
eq(g[2], w[2], label .. ": segment " .. i .. " count")
end
end
local redRle = { { "up", 12 }, { "left", 12 }, { "down", 7 }, { "left", 6 } }
local yellowRle = { { "up", 13 }, { "left", 12 }, { "down", 7 }, { "left", 6 } }
sameRoute(hooks.walkInRoute, reversed(redRle), "red walkInRoute")
sameRoute(hooks.walkInRouteFor(false), reversed(redRle), "red walkInRouteFor")
sameRoute(hooks.walkInRouteFor(true), reversed(yellowRle), "yellow walkInRouteFor")
eq(hooks.walkInRoute[1][1], "left", "walk-in leaves the staircase going left first")
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
local function runWalkIn(version)
local prev = GameVersion.current
GameVersion.current = version
played = {}
local blocks = {}
local moves = {}
local game = { data = {}, save = { flags = {} } }
local ow = { player = { cellX = 24, cellY = 16 } }
function ow:replaceBlock(bx, by, id) blocks[bx .. "," .. by] = id end
function ow:scriptMove(entity, dir, n, onDone)
moves[#moves + 1] = { dir, n }
local d = D[dir]
entity.cellX, entity.cellY = entity.cellX + d[1] * n, entity.cellY + d[2] * n
if onDone then onDone() end
end
hooks.onEnter(game, ow)
GameVersion.current = prev
return game, ow, moves, blocks
end
do
local game, ow, moves, blocks = runWalkIn("red")
sameRoute(moves, reversed(redRle), "red onEnter moves")
eq(ow.player.cellX, 6, "red walk-in lands at x=6")
eq(ow.player.cellY, 11, "red walk-in lands at y=11")
eq(game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR, true, "red landing locks the door")
eq(played[#played], "Go_Inside", "red landing plays SFX_GO_INSIDE")
eq(blocks["2,6"], 0x72, "red door block (2,6) closed")
eq(blocks["3,6"], 0x73, "red door block (3,6) closed")
end
do
local game, ow, moves, blocks = runWalkIn("yellow")
sameRoute(moves, reversed(yellowRle), "yellow onEnter moves")
eq(ow.player.cellX, 6, "yellow walk-in lands at x=6")
eq(ow.player.cellY, 10, "yellow walk-in lands at y=10")
check(not game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR, "yellow landing leaves the door open")
eq(#played, 0, "yellow landing plays no SFX")
eq(blocks["2,6"], 0x31, "yellow door block (2,6) open")
end
package.loaded["src.core.Sound"] = savedSound
T.finish("lance_walk_in_bug2665")
+22 -2
View File
@@ -119,18 +119,38 @@ local function buyOne(game, stack)
return menu, list, qty
end
require("src.core.GameVersion").set("red")
local function fill(game, n)
for i = 1, n do
local id = "FILLER_" .. i
game.save.inventory[id] = 1
game.save.bagOrder[#game.save.bagOrder + 1] = id
end
end
do
local game = newGame(3000, 99)
eq(Bag.add(game.save, "POKE_BALL", 1, game.data), true,
"AddItemToInventory puts a 100th POKe BALL in a new slot")
eq(game.save.inventory.POKE_BALL, 100, "the total is 100")
eq(Bag.slots(game.save, game.data), 2, "split over two slots")
end
do
local game = newGame(3000, 99)
fill(game, 19)
eq(Bag.add(game.save, "POKE_BALL", 1, game.data), false,
"AddItemToInventory refuses a 100th POKe BALL")
"a full bag refuses the overflow slot")
eq(game.save.inventory.POKE_BALL, 99, "and adds nothing")
end
for _, case in ipairs({
{ name = "bag full", money = 3000, count = 99, text = BAG_FULL },
{ name = "bag full", money = 3000, count = 99, fill = 19, text = BAG_FULL },
{ name = "not enough money", money = 100, count = nil, text = NO_MONEY },
}) do
local game, events, stack = newGame(case.money, case.count)
fill(game, case.fill or 0)
local menu, list, qty = buyOne(game, stack)
local box = stack[#stack]
if check(getmetatable(box) == TextBox, case.name .. ": a text box is pushed") then
+29 -9
View File
@@ -7,9 +7,9 @@
-- DisplayNameRaterScreen takes .playerCancelled and keeps the old nickname.
-- Nothing in the original invents a letter, so NamingScreen:confirm must hand
-- the caller "" rather than the literal "A" when nothing was typed -- both via
-- START and via the ED cell. The two fallbacks that are load bearing stay:
-- presets[1] for player/rival naming (oak_speech2.asm ChoosePlayerName never
-- accepts an empty name) and opts.default for the Name Rater cancel.
-- START and via the ED cell. Player/rival naming (oak_speech2.asm
-- ChoosePlayerName never accepts an empty name) re-opens the grid, and
-- opts.default still covers the Name Rater cancel.
-- luajit tests/engine/naming_empty_confirm_bug833.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -107,14 +107,34 @@ press(ns, game, "a")
press(ns, game, "start")
eq(res.name, "A", "a genuinely typed A still comes back as A")
-- ---------------------------------------------------------------- presets fallback (player / rival)
-- ChoosePlayerName / ChooseRivalName (engine/movie/oak_speech/oak_speech2.asm)
-- compare wStringBuffer to '@' and re-open rather than accept an empty name;
-- the port answers the same need with its presets fallback, which #833 must
-- not disturb.
-- ---------------------------------------------------------------- player / rival re-prompt
-- engine/movie/oak_speech/oak_speech2.asm:20
ns, game, res = newScreen({ title = "YOUR NAME?", maxLen = 7, presets = { "RED", "ASH" } })
ns.row, ns.col, ns.lower = 3, 4, true
press(ns, game, "start")
eq(res.name, "RED", "an empty confirm with presets still yields presets[1]")
check(not res.fired, "an empty player/rival name does not confirm")
eq(#game.stack.states, 1, "the naming screen stays up on an empty START")
eq(ns.row, 1, "re-entry puts the cursor back on row 1")
eq(ns.col, 1, "re-entry puts the cursor back on column 1")
eq(ns.lower, true, "wAlphabetCase carries over the re-entry")
check((ns.whiteout or 0) > 0, "the re-entry whites the screen out")
ns.lower = false
press(ns, game, "a")
eq(#ns.glyphs, 0, "input is ignored while the screen is white")
for _ = 1, 60 do ns:update(1 / 60) end
eq(ns.whiteout, 0, "the whiteout ends")
local edR, edC = edCell(ns)
ns.row, ns.col = edR, edC
press(ns, game, "a")
check(not res.fired, "ED with nothing typed re-prompts too")
eq(#game.stack.states, 1, "the naming screen is still up after an empty ED")
for _ = 1, 60 do ns:update(1 / 60) end
ns.row, ns.col = 1, 1
press(ns, game, "a")
press(ns, game, "start")
check(res.fired, "a typed player name confirms")
eq(res.name, "A", "the typed name comes back, never presets[1]")
eq(#game.stack.states, 0, "a typed confirm pops the naming screen")
-- ---------------------------------------------------------------- default fallback (Name Rater)
-- DisplayNameRaterScreen jumps to .playerCancelled on '@' and keeps the
+176
View File
@@ -0,0 +1,176 @@
-- engine/movie/oak_speech/oak_speech.asm:112
-- home/fade.asm:24
-- data/text/text_2.asm:1742
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local eq, check = T.eq, T.check
local OakSpeech = require("src.ui.OakSpeech")
local TextBox = require("src.render.TextBox")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Transition = require("src.render.Transition")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local Version = require("src.core.GameVersion")
local origFadeOut, origPlay, origPress = Music.fadeOut, Sound.play, Sound.playPress
Music.fadeOut = function() end
Sound.play = function() end
Sound.playPress = function() end
local function stub(fields)
local s = setmetatable(fields or {}, OakSpeech)
return s
end
do
local player, p1, p2 = { tag = "player" }, { tag = "p1" }, { tag = "p2" }
local speech = stub({ playerPic = player, shrinkPic1 = p1, shrinkPic2 = p2,
pic = player })
local finishes = 0
speech.finish = function() finishes = finishes + 1 end
speech.shrink = { frame = 0 }
local pics, walk, fade = {}, {}, {}
for f = 1, 200 do
speech:update(0)
pics[f], walk[f], fade[f] = speech.pic, speech.walkVisible, speech.fadeLevel
end
local function span(want)
local first, last
for f = 1, 200 do
if pics[f] == want then
first = first or f
last = f
end
end
return first, last
end
local _, redLast = span(player)
local p1First, p1Last = span(p1)
local p2First, p2Last = span(p2)
check(redLast and redLast >= 30,
"RedPicFront holds through ShrinkPic1's decompress (got " .. tostring(redLast) .. ")")
eq(p1First, OakSpeech.SHRINK_PIC1_AT, "ShrinkPic1 lands on its frame")
check(p1Last and p1First and p1Last - p1First + 1 >= 30,
"ShrinkPic1 holds ~40 frames, not 4")
eq(p2First, OakSpeech.SHRINK_PIC2_AT, "ShrinkPic2 lands on its frame")
check(p2Last and p2First and p2Last - p2First + 1 >= 25,
"ShrinkPic2 holds ~32 frames")
eq(pics[OakSpeech.SHRINK_WALK_AT], nil, "the pic area clears")
check(walk[OakSpeech.SHRINK_WALK_AT] and not walk[OakSpeech.SHRINK_WALK_AT - 1],
"and the walking sprite appears on the same frame")
eq(OakSpeech.SHRINK_FADE_AT - OakSpeech.SHRINK_WALK_AT, 50,
"the walking sprite stands for ld c, 50")
eq(fade[OakSpeech.SHRINK_FADE_AT - 1], nil, "no fade before GBFadeOutToWhite")
eq(fade[OakSpeech.SHRINK_FADE_AT], 1, "FadePal6 first")
eq(fade[OakSpeech.SHRINK_FADE_AT + 8], 2, "FadePal7 eight frames later")
eq(fade[OakSpeech.SHRINK_FADE_AT + 16], 3, "FadePal8 eight frames after that")
eq(OakSpeech.SHRINK_END - OakSpeech.SHRINK_FADE_AT, 24, "3 palettes x 8 frames")
eq(finishes, 1, "finish runs once")
end
do
local steps = {}
for _, step in ipairs(OakSpeech.defaultSteps({})) do steps[step.id] = step end
check(steps.legend and steps.legend.auto,
"OakSpeechText3 ends on done, so PrintText returns without a button wait")
Version.set("red")
local pressed = false
local game = { data = { text = { _OakSpeechText3 = "A\nB{DONE}" } },
save = { player = { name = "RED" }, options = { textSpeed = 1 } },
input = { wasPressed = function() return pressed end,
isDown = function() return false end } }
game.stack = { push = function(s, b) s.box = b end,
pop = function(s) s.box = nil end }
local advanced = 0
local speech = stub({ game = game })
speech.advance = function() advanced = advanced + 1 end
speech:runStep(steps.legend)
for _ = 1, 24 do speech:update(0) end
local box = game.stack.box
eq(getmetatable(box), TextBox, "the legend page is a real text box")
local sawArrow = false
for _ = 1, 300 do
if not game.stack.box then break end
box:update()
if game.stack.box and box.done and box:arrowVisible() then sawArrow = true end
end
check(not sawArrow, "no blinking arrow on the last page")
eq(game.stack.box, nil, "the box closes on its own")
eq(advanced, 1, "and the shrink starts with no A press")
end
local origObp = SpriteRenderer.obpImage
local origDraw = love.graphics.draw
local function drawWalk(mode, fadeLevel)
PaletteFX.mode = mode
PaletteFX.clearSpriteRedraws()
PaletteFX.setShadeMap(nil)
PaletteFX.setFadeObp(nil)
PaletteFX.setPass("ui")
local baked = { tag = "baked" }
local got = {}
SpriteRenderer.obpImage = function(path, colors, group)
got.path, got.colors, got.group = path, colors, group
return baked
end
local drawn = {}
love.graphics.draw = function(img) drawn[#drawn + 1] = img end
local sheet = love.graphics.newImage("missing.png")
local speech = stub({ game = {}, walkVisible = true, walkSheet = sheet,
walkPath = "red.png", walkDef = { image = "red.png" },
fadeLevel = fadeLevel })
speech:draw()
love.graphics.draw = origDraw
SpriteRenderer.obpImage = origObp
local shadeMap = PaletteFX.shadeMap()
local redraws = #PaletteFX.uiSpriteRedraws()
PaletteFX.setFadeObp(nil)
PaletteFX.setShadeMap(nil)
return got, drawn, baked, sheet, redraws, shadeMap
end
do
Version.set("red")
local got, drawn, baked, sheet, redraws = drawWalk("ogred")
eq(got.path, "red.png", "OG RED bakes the RedSprite sheet")
local want = PaletteFX.ogObjNormal()
eq(got.colors, want, "through the boot-ROM OBJ palette at rOBP0 = $D0")
check(drawn[1] == baked and drawn[1] ~= sheet, "and draws the baked sheet")
eq(redraws, 1, "replayed past the red BG zone pass")
got, drawn, baked, sheet, redraws = drawWalk("gbc")
eq(got.colors, PaletteFX.OBP0_SHADES,
"SGB keeps the sprite in DMG shades through the OBP0 lift")
eq(redraws, 0, "and lets the zone palette color it")
Version.set("blue")
got = drawWalk("ogred")
eq(got.colors, PaletteFX.ogObjNormal(), "OG BLUE bakes Blue's pink OBJ ramp")
Version.set("red")
end
do
Version.set("red")
local raw = PaletteFX.ogObjBase()
local got, drawn, _, _, redraws, shadeMap = drawWalk("ogred", 1)
eq(shadeMap, Transition.shadeMapFor(0x90), "FadePal6 rBGP")
check(got.colors and got.colors[2] == raw[1] and got.colors[3] == raw[1]
and got.colors[4] == raw[3],
"FadePal6 rOBP0 $80 lightens the baked sprite")
eq(redraws, 1, "and the faded sprite still replays")
got, drawn, _, _, redraws, shadeMap = drawWalk("ogred", 3)
eq(shadeMap, Transition.shadeMapFor(0x00), "FadePal8 rBGP is solid white")
check(got.colors and got.colors[4] == raw[1], "and so is the sprite")
end
PaletteFX.mode = "gbc"
Music.fadeOut, Sound.play, Sound.playPress = origFadeOut, origPlay, origPress
T.finish("oak_shrink_timing_2685")
@@ -0,0 +1,61 @@
-- scripts/OaksLab.asm:918
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local M = assert(loadfile("data/scripts/oaks_lab.lua"))()
local cases = {
{ "TEXT_OAKSLAB_CHARMANDER_POKE_BALL", "OAKSLAB_CHARMANDER_POKE_BALL",
"OAKSLAB_SQUIRTLE_POKE_BALL" },
{ "TEXT_OAKSLAB_SQUIRTLE_POKE_BALL", "OAKSLAB_SQUIRTLE_POKE_BALL",
"OAKSLAB_BULBASAUR_POKE_BALL" },
{ "TEXT_OAKSLAB_BULBASAUR_POKE_BALL", "OAKSLAB_BULBASAUR_POKE_BALL",
"OAKSLAB_CHARMANDER_POKE_BALL" },
}
local function find(rows, pred)
for i, row in ipairs(rows) do
if pred(row) then return i end
end
end
for _, c in ipairs(cases) do
local key, own, rival = c[1], c[2], c[3]
local rows = M.talk[key]
T.check(type(rows) == "table", key .. " rows loaded")
local jumpNo = find(rows, function(r)
return r[1] == "jump_if_false" and r[2] == "end" end)
local hideOwn = find(rows, function(r)
return r[1] == "hide_object" and r[3] == own end)
local energetic = find(rows, function(r)
return r[1] == "show_text" and r[2] == "_OaksLabMonEnergeticText" end)
local received = find(rows, function(r)
return r[1] == "show_text" and r[2] == "_OaksLabReceivedMonText" end)
local give = find(rows, function(r) return r[1] == "give_pokemon" end)
local illTake = find(rows, function(r)
return r[1] == "show_text" and r[2] == "_OaksLabRivalIllTakeThisOneText" end)
local hideRival = find(rows, function(r)
return r[1] == "hide_object" and r[3] == rival end)
local rivalReceived = find(rows, function(r)
return r[1] == "show_text" and r[2] == "_OaksLabRivalReceivedMonText" end)
T.check(jumpNo and hideOwn and energetic and received and give,
key .. ": all player-pick rows present")
if jumpNo and hideOwn and energetic and received and give then
T.eq(hideOwn, jumpNo + 1, key .. ": own ball hides right after YES")
T.check(hideOwn < energetic, key .. ": ball gone before the energetic line")
T.check(hideOwn < received, key .. ": ball gone before the received line")
T.check(hideOwn < give, key .. ": ball gone before the nickname prompt")
T.check(energetic < received and received < give,
key .. ": energetic, received, then AddPartyMon")
end
T.check(illTake and hideRival and rivalReceived,
key .. ": all rival-pick rows present")
if illTake and hideRival and rivalReceived then
T.check(illTake < hideRival and hideRival < rivalReceived,
key .. ": rival ball hides between ILL_TAKE_THIS_ONE and RECEIVED")
end
end
T.finish("oaks_lab_starter_ball_hide_2686")
@@ -18,13 +18,16 @@ for i, row in ipairs(rows) do
end
T.check(askRow ~= nil, "found the ask row")
T.eq(rows[askRow + 1][1], "jump_if_false", "the ask is followed by its jump")
T.same(rows[askRow + 2], { "show_text", "_OaksLabMonEnergeticText" },
"the energetic line is the row right after the NO jump")
T.eq(rows[askRow + 3][1], "text_sound",
T.same(rows[askRow + 2],
{ "hide_object", "OAKS_LAB", "OAKSLAB_CHARMANDER_POKE_BALL" },
"the ball hides right after the NO jump")
T.same(rows[askRow + 3], { "show_text", "_OaksLabMonEnergeticText" },
"the energetic line follows the ball hide")
T.eq(rows[askRow + 4][1], "text_sound",
"the sound cue stays on the RECEIVED line, not the energetic one")
T.eq(rows[askRow + 4][1], "show_text",
T.eq(rows[askRow + 5][1], "show_text",
"and the received-mon text follows the sound cue")
T.eq(rows[askRow + 4][2], "_OaksLabReceivedMonText",
T.eq(rows[askRow + 5][2], "_OaksLabReceivedMonText",
"specifically the received-mon text")
-- jump_if_true (row 2): the EVENT_GOT_STARTER short-circuit must land on
+24 -1
View File
@@ -149,7 +149,7 @@ T.check(tostring(ratingBox.text):find("least 50 species", 1, true) ~= nil,
T.eq(#plays, 0, "no jingle while the evaluation is printing")
ratingBox.opts.auto.sound()
T.eq(#plays, 1, "jingle fires once the rating text is printed")
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
T.eq(plays[1], "Get_Item1", "55 owned plays the Get_Item1 tier jingle")
T.check(tostring(pushed[6].text):find("Closed link", 1, true) ~= nil,
"the closing link prints at the end of the session")
@@ -166,6 +166,29 @@ T.check(tostring(lastPush().text):find("Closed link", 1, true) ~= nil,
"NO skips the rating and closes the PC")
T.eq(#plays, 0, "declining plays no jingle")
-- audio/pokedex_rating_sfx.asm:26
local tiers = {
{ 0, "Denied" }, { 9, "Denied" },
{ 10, "Pokedex_Rating" }, { 39, "Pokedex_Rating" },
{ 40, "Get_Item1" }, { 59, "Get_Item1" },
{ 60, "Caught_Mon" }, { 89, "Caught_Mon" },
{ 90, "Level_Up" }, { 119, "Level_Up" },
{ 120, "Get_Key_Item" }, { 149, "Get_Key_Item" },
{ 150, "Get_Item2" }, { 151, "Get_Item2" },
}
for _, row in ipairs(tiers) do
T.eq(OW.dexRatingSfx and OW.dexRatingSfx(row[1]), row[2],
("%d owned rates with %s"):format(row[1], row[2]))
end
reset()
local full = {}
for i = 1, 151 do full[i] = true end
fakeGame.save.pokedex = { seen = full, owned = full }
fakeSelf:dexRating(function() end)
lastPush().opts.auto.sound()
T.eq(plays[1], "Get_Item2", "a complete dex plays Get_Item2")
package.loaded["src.ui.Menu"] = realMenu
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
+119
View File
@@ -0,0 +1,119 @@
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 GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local Assets = require("src.render.Assets")
local SpriteRenderer = require("src.render.SpriteRenderer")
local prevVersion, prevMode, prevDark = GameVersion.get(), PaletteFX.mode, PaletteFX.darkWorld()
local realImageData, realImage = Assets.imageData, Assets.image
local realNewImage = love.graphics.newImage
local SHADES = { 1.0, 0.66, 0.33, 0.0 }
Assets.imageData = function(path)
local probe = { path = path }
function probe:mapPixel(fn)
self.mapped = {}
for i, v in ipairs(SHADES) do
local r, g, b, a = fn(0, 0, v, v, v, 1)
self.mapped[i] = { r, g, b, a }
end
end
return probe
end
Assets.image = function(path)
return realNewImage(path)
end
love.graphics.newImage = function(src)
local img = realNewImage(src)
if type(src) == "table" and src.mapped then img.mapped = src.mapped end
return img
end
local serial = 0
local function freshPath(tag)
serial = serial + 1
return "bug2689/" .. tag .. "_" .. serial .. ".png"
end
local function rgb(c) return string.format("%d,%d,%d", c[1], c[2], c[3]) end
local function mappedRgb(m)
return string.format("%d,%d,%d", math.floor(m[1] * 255 + 0.5),
math.floor(m[2] * 255 + 0.5), math.floor(m[3] * 255 + 0.5))
end
local function stubSprite(tag)
return setmetatable({ def = { image = freshPath(tag) } }, SpriteRenderer)
end
local function expectRamp(img, ramp, label)
local m = img and img.mapped
check(m ~= nil, label .. ": bake ran through mapPixel")
if not m then return end
eq(m[1][4], 0, label .. ": OBJ color 0 is transparent")
eq(mappedRgb(m[2]), rgb(ramp[1]), label .. ": OBJ color 1 -> rOBP0 shade")
eq(mappedRgb(m[3]), rgb(ramp[2]), label .. ": OBJ color 2 -> rOBP0 shade")
eq(mappedRgb(m[4]), rgb(ramp[3]), label .. ": OBJ color 3 -> rOBP0 shade")
end
local function lastRedrawImage()
local list = PaletteFX.spriteRedraws()
local e = list[#list]
return e and e.image
end
for _, version in ipairs({ "red", "blue" }) do
GameVersion.set(version)
PaletteFX.setMode("ogred")
PaletteFX.setDarkWorld(false)
PaletteFX.setFadeObp(nil)
local base = PaletteFX.ogObjBase()
local lit = { base[1], base[2], base[4] }
expectRamp(stubSprite(version):resolveImage(), lit,
version .. " lit resolveImage")
PaletteFX.clearSpriteRedraws()
PaletteFX.setPass("world")
local ok, err = pcall(SpriteRenderer.drawTile, stubSprite(version),
freshPath(version .. "_tile"), 0, 0, false)
PaletteFX.setPass(nil)
check(ok, version .. " drawTile runs headless" .. (ok and "" or (": " .. tostring(err))))
expectRamp(lastRedrawImage(), lit, version .. " lit drawTile redraw")
PaletteFX.clearSpriteRedraws()
PaletteFX.setFadeObp({ [0] = 0, [1] = 1, [2] = 2, [3] = 3 })
expectRamp(stubSprite(version):resolveImage(), { base[2], base[3], base[4] },
version .. " FadePal3 rOBP0 $E4 step")
PaletteFX.setFadeObp({ [0] = 0, [1] = 0, [2] = 1, [3] = 3 })
expectRamp(stubSprite(version):resolveImage(), lit,
version .. " FadePal4 rOBP0 $D0 step")
PaletteFX.setFadeObp(nil)
PaletteFX.setDarkWorld(true)
local _, group = PaletteFX.ogObjWorld()
check(tostring(group):match("dark$") ~= nil,
version .. " dark map keeps the FadePal2 OBJ shift (" .. tostring(group) .. ")")
expectRamp(stubSprite(version):resolveImage(), { base[4], base[4], base[4] },
version .. " dark map rOBP0 3,3,3,2")
PaletteFX.setDarkWorld(false)
local _, litGroup = PaletteFX.ogObjWorld()
local _, rawGroup = PaletteFX.ogObj()
check(litGroup ~= rawGroup,
version .. " overworld bake group differs from the $E4 title/battle bake")
end
love.graphics.newImage = realNewImage
Assets.imageData, Assets.image = realImageData, realImage
PaletteFX.setFadeObp(nil)
PaletteFX.setDarkWorld(prevDark)
GameVersion.set(prevVersion)
PaletteFX.setMode(prevMode)
T.finish("overworld OG rOBP0 #2689")
@@ -0,0 +1,122 @@
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 PartyMenu = require("src.ui.PartyMenu")
local function newGame(moveId, ow)
local function mk(species)
return { species = species, hp = 50, stats = { hp = 50 },
level = 30, moves = { { id = moveId, pp = 15 } } }
end
local party = { mk("MACHOP"), mk("MACHOP") }
ow.map = ow.map or { def = { tileset = "OVERWORLD" }, id = "PALLET_TOWN" }
ow.dark = ow.dark or false
ow.partyKnows = function() return party[1] end
local game = {
data = { pokemon = { MACHOP = { name = "MACHOP" } } },
save = { party = party, inventory = {}, options = {}, flags = {} },
overworld = ow,
}
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 idle(pm, n)
for _ = 1, n do pm:update(1 / 60) end
end
local function rowOf(pm, action)
for i, item in ipairs(pm.subItems or {}) do
if item.action == action then return i end
end
end
do
local game = newGame("STRENGTH", {})
local pm = PartyMenu.new(game, {})
game.stack:push(pm)
idle(pm, 7)
check(pm.blink > 0, "the cursor mon animates while the list takes input")
press(pm, "a")
check(pm.submenu, "A opens the submenu")
local frozen = pm.blink
idle(pm, 40)
eq(pm.blink, frozen, "the icon counter holds while the submenu is open")
press(pm, "b")
eq(pm.blink, 0, "closing the submenu restarts the icon on its rest frame")
idle(pm, 7)
press(pm, "down")
eq(pm.index, 2, "down moves the cursor")
eq(pm.blink, 0, "a cursor move restarts the icon on its rest frame")
idle(pm, 3)
press(pm, "up")
eq(pm.blink, 0, "up restarts the icon too")
end
do
local game = newGame("STRENGTH", {})
local pm = PartyMenu.new(game, {})
game.stack:push(pm)
idle(pm, 5)
local before = pm.blink
pm:animateTo(game.save.party[1], 10)
pm:update(1 / 60)
eq(pm.blink, before, "the icon counter holds during the HP bar fill")
end
local function fieldMove(label, moveId, action, ow, captured)
local game = newGame(moveId, ow)
local pm = PartyMenu.new(game, {})
game.stack:push(pm)
press(pm, "a")
pm.subIndex = rowOf(pm, action) or 1
press(pm, "a")
check(captured.onClose ~= nil, label .. " reaches the overworld handler")
check(not pm.submenu, label .. ": the options box is gone under the text")
check(pm.chosenHollow, label .. ": the party cursor stays hollow under the text")
end
do
local captured = {}
fieldMove("STRENGTH", "STRENGTH", "strength", {
useStrengthFieldMove = function(_, _, onClose) captured.onClose = onClose end,
}, captured)
end
do
local captured = {}
fieldMove("SURF", "SURF", "surf", {
player = { facingCell = function() return 4, 14 end },
useSurfFieldMove = function() return "ok" end,
trySurf = function(_, _, _, onClose) captured.onClose = onClose end,
}, captured)
end
do
local captured = {}
fieldMove("FLASH", "FLASH", "flash", {
dark = true,
useFlashFieldMove = function(_, onClose) captured.onClose = onClose end,
}, captured)
end
T.finish()
+179
View File
@@ -0,0 +1,179 @@
-- engine/battle/core.asm:3227
-- engine/battle/core.asm:4913
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.FIX_LEAFISH = {
id = "FIX_LEAFISH", index = 97, name = "FIX LEAF",
type = "GRASS", power = 40, accuracy = 100, pp = 25,
effect = "NO_ADDITIONAL_EFFECT",
}
Data.moves.FIX_MULTI = {
id = "FIX_MULTI", index = 98, name = "FIX MULTI",
type = "NORMAL", power = 15, accuracy = 100, pp = 10,
effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = 3,
}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local function lowRolls(lo, hi)
if lo and lo > 0 then return hi end
return 0
end
local function mkseq(vals)
local i = 0
return function(_, hi)
i = i + 1
return vals[i] ~= nil and vals[i] or hi
end
end
local function rager(rng)
local b = BattleState.newWild(game, "FIXMON_C", 40)
b.rng = rng or lowRolls
b.enemy.rageMove = { id = "RAGE", pp = 20 }
b.enemy.stages.attack = 0
return b
end
local function texts(b)
local out = {}
for _, row in ipairs(b.queue) do
if row.text then out[#out + 1] = row.text end
end
return out
end
local function find(list, needle, from)
for i = from or 1, #list do
if list[i]:find(needle, 1, true) then return i end
end
return nil
end
local function count(list, needle)
local n = 0
for _, t in ipairs(list) do
if t:find(needle, 1, true) then n = n + 1 end
end
return n
end
do
local b = rager()
b:performMove(b.player, b.enemy, { id = "FIX_LEAFISH", pp = 25 })
local t = texts(b)
local crit = find(t, "Critical hit!")
local super = find(t, "super")
local rage = find(t, "RAGE is building!")
local rose = find(t, "rose!")
T.check(crit and super and rage and rose, "crit, effectiveness, rage and rose rows all queued")
T.check(crit and super and crit < super, "crit text precedes effectiveness")
T.check(super and rage and super < rage, "rage text follows effectiveness")
T.check(rage and rose and rage < rose, "ATTACK rose! follows the rage text")
T.check(rose and t[rose]:find("ATTACK", 1, true) ~= nil, "the rose row names ATTACK")
T.eq(b.enemy.stages.attack, 1, "rage raises the attack stage once")
end
do
local b = rager()
b.enemy.mon.hp = 1
b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 })
local t = texts(b)
T.eq(b.enemy.mon.hp, 0, "the hit knocks the rager out")
T.eq(find(t, "RAGE is building!"), nil, "no rage text on the fainting hit")
T.eq(find(t, "rose!"), nil, "no stat text on the fainting hit")
T.eq(b.enemy.stages.attack, 0, "no stage gained on the fainting hit")
end
do
local b = rager()
b.enemy.stages.attack = 6
b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 })
local t = texts(b)
T.eq(find(t, "RAGE is building!"), nil, "maxed attack stays silent")
T.eq(find(t, "Nothing happened!"), nil, "maxed attack prints no fallback either")
T.eq(b.enemy.stages.attack, 6, "stage stays at +6")
end
do
local b = rager()
b.enemy.substituteHP = 200
local hp = b.enemy.mon.hp
b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 })
local t = texts(b)
T.eq(b.enemy.mon.hp, hp, "the substitute takes the hit")
T.check(find(t, "RAGE is building!") ~= nil, "rage still builds behind a substitute")
T.eq(b.enemy.stages.attack, 1, "substitute hit raises the stage")
end
do
local b = rager(mkseq({ 0, 255, 255 }))
b.enemy.mon.maxHP, b.enemy.mon.hp = 999, 999
b:performMove(b.player, b.enemy, { id = "FIX_MULTI", pp = 10 })
local t = texts(b)
T.eq(count(t, "RAGE is building!"), 3, "rage builds once per strike")
T.eq(count(t, "rose!"), 3, "a rose line per strike")
T.eq(b.enemy.stages.attack, 3, "three strikes, three stages")
local multi = find(t, "times!")
local lastRose
for i, s in ipairs(t) do if s:find("rose!", 1, true) then lastRose = i end end
T.check(multi and lastRose and lastRose < multi, "hit count text follows the last strike's rage")
end
do
local probe = rager(mkseq({ 0, 255, 255 }))
probe.enemy.mon.maxHP, probe.enemy.mon.hp = 999, 999
probe:performMove(probe.player, probe.enemy, { id = "FIX_MULTI", pp = 10 })
local perStrike = math.floor((999 - probe.enemy.mon.hp) / 3)
local b = rager(mkseq({ 0, 255, 255 }))
b.enemy.mon.hp = perStrike + 1
b:performMove(b.player, b.enemy, { id = "FIX_MULTI", pp = 10 })
local t = texts(b)
T.eq(b.enemy.mon.hp, 0, "the second strike knocks the rager out")
T.eq(count(t, "RAGE is building!"), 1, "only the first strike built rage")
T.eq(find(t, "times!"), nil, "no hit count text when a strike faints the target")
T.check(find(t, "fainted!") ~= nil, "the target faints")
end
Data.moves.FIX_BOOM = {
id = "FIX_BOOM", index = 99, name = "FIX BOOM",
type = "NORMAL", power = 170, accuracy = 100, pp = 5,
effect = "EXPLODE_EFFECT",
}
for _, arm in ipairs({
{ label = "accuracy miss", seq = { 255 } },
{ label = "invulnerable target", seq = { 0, 255, 255 }, invulnerable = true },
}) do
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local b = rager(mkseq(arm.seq))
b.enemy.invulnerable = arm.invulnerable
b:performMove(b.player, b.enemy, { id = "FIX_BOOM", pp = 5 })
local t = texts(b)
T.check(find(t, "attack missed!") ~= nil, arm.label .. ": the Explosion missed")
T.eq(b.player.mon.hp, 0, arm.label .. ": the exploder still faints")
local rage = find(t, "RAGE is building!")
local faint = find(t, "fainted!")
T.check(rage ~= nil, arm.label .. ": a missed Explosion still builds rage")
T.check(rage and faint and rage < faint, arm.label .. ": rage text precedes the exploder's faint")
T.eq(b.enemy.stages.attack, 1, arm.label .. ": stage +1 after the miss")
end
Data.moves.FIX_LEAFISH = nil
Data.moves.FIX_MULTI = nil
Data.moves.FIX_BOOM = nil
T.finish("rage building order")
+155
View File
@@ -0,0 +1,155 @@
-- engine/battle/move_effects/recoil.asm:28
-- engine/battle/move_effects/drain_hp.asm:81
-- engine/battle/core.asm:4899
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.FIX_RECOIL = {
id = "FIX_RECOIL", index = 98, name = "FIX RECOIL",
type = "NORMAL", power = 90, accuracy = 100, pp = 20,
effect = "RECOIL_EFFECT",
}
Data.moves.FIX_ABSORB = {
id = "FIX_ABSORB", index = 97, name = "FIX ABSORB",
type = "NORMAL", power = 60, accuracy = 100, pp = 20,
effect = "DRAIN_HP_EFFECT",
}
local function mkseq(vals)
local i = 0
return function(_, hi)
i = i + 1
return vals[i] ~= nil and vals[i] or hi
end
end
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = mkseq({ 0, 255, 255 })
return battle
end
local function rowsOf(battle)
local out = {}
for i, row in ipairs(battle.queue) do
local kind
if row.drain then
kind = row.battler == battle.player and "drain:player"
or row.battler == battle.enemy and "drain:enemy" or "drain"
elseif row.text then
kind = row.text:find("recoil", 1, true) and "text:recoil" or "text"
end
if kind then out[#out + 1] = { kind = kind, row = row, i = i } end
end
return out
end
local function find(rows, kind)
for _, r in ipairs(rows) do
if r.kind == kind then return r end
end
end
local function replay(battle, row)
battle.queue, battle.current = { row }, nil
local frames = 0
while battle:updateQueue() and frames < 8000 do frames = frames + 1 end
T.check(frames < 8000, "drain row settles")
end
do
local battle = newBattle()
battle.player.mon.hp = battle.player.mon.stats.hp
battle.player.shownHP = battle.player.mon.hp
local pStart = battle.player.mon.hp
local eStart = battle.enemy.mon.hp
battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 })
local dealt = eStart - battle.enemy.mon.hp
local recoil = math.max(1, math.floor(dealt / 4))
T.eq(pStart - battle.player.mon.hp, recoil, "recoil is a quarter of the damage")
local rows = rowsOf(battle)
local enemyRow, playerRow = find(rows, "drain:enemy"), find(rows, "drain:player")
local recoilText = find(rows, "text:recoil")
T.check(enemyRow and playerRow and recoilText, "target drain, user drain and recoil text all queued")
if enemyRow and playerRow and recoilText then
T.check(enemyRow.i < playerRow.i, "the target's bar drains before the user's")
T.check(playerRow.i < recoilText.i, "the user's bar drains before the recoil text")
T.eq(playerRow.row.stopAt, battle.player.mon.hp, "the user's row stops at the post-recoil HP")
replay(battle, enemyRow.row)
T.eq(battle.enemy.shownHP, battle.enemy.mon.hp, "the target bar settles on its row")
T.eq(battle.player.shownHP, pStart, "the user's bar holds still during the target's row")
replay(battle, playerRow.row)
T.eq(battle.player.shownHP, battle.player.mon.hp, "the user's bar settles on the recoil row")
end
end
do
local battle = newBattle()
battle.player.substituteHP = 10
local pStart = battle.player.mon.hp
battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 })
T.check(battle.player.mon.hp < pStart, "recoil comes off the user's HP")
T.eq(battle.player.substituteHP, 10, "the user's substitute is untouched by recoil")
for _, row in ipairs(battle.queue) do
T.check(not (row.text and row.text:find("SUBSTITUTE", 1, true)),
"no substitute text for the user's recoil")
end
end
do
local battle = newBattle()
battle.enemy.substituteHP = 1
local pStart = battle.player.mon.hp
battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 })
T.eq(battle.enemy.substituteHP, nil, "the hit breaks the substitute")
T.eq(battle.player.mon.hp, pStart, "no recoil after breaking a substitute")
T.check(not find(rowsOf(battle), "text:recoil"), "no recoil text after breaking a substitute")
end
do
local battle = newBattle()
battle.player.mon.hp = 10
battle.player.shownHP = 10
battle.player.shownPx = nil
battle:performMove(battle.player, battle.enemy, { id = "FIX_ABSORB", pp = 20 })
T.check(battle.player.mon.hp > 10, "absorb heals the user")
local rows = rowsOf(battle)
local enemyRow, playerRow = find(rows, "drain:enemy"), find(rows, "drain:player")
T.check(enemyRow and playerRow and enemyRow.i < playerRow.i,
"the user's heal has its own row after the target's")
if enemyRow then
replay(battle, enemyRow.row)
T.eq(battle.player.shownHP, 10, "the user's bar holds still during the target's row")
end
if playerRow then
replay(battle, playerRow.row)
T.eq(battle.player.shownHP, battle.player.mon.hp, "the user's bar settles on its row")
end
end
do
local battle = newBattle()
battle.player.mon.hp = 10
battle.enemy.substituteHP = 1
battle:performMove(battle.player, battle.enemy, { id = "FIX_ABSORB", pp = 20 })
T.eq(battle.player.mon.hp, 10, "no heal after breaking a substitute")
end
Data.moves.FIX_RECOIL, Data.moves.FIX_ABSORB = nil, nil
T.finish("recoil hp drain")
@@ -0,0 +1,223 @@
-- scripts/Route23.asm:195
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local played = {}
package.loaded["src.core.Sound"] = {
play = function(_, id) played[#played + 1] = id; return nil end,
}
local OW = require("src.world.OverworldController")
local RealTextBox = require("src.render.TextBox")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local OH = "You can pass here\nonly if you have\vthe {RAM:wNameBuffer}!\fOh! That is the\n{RAM:wNameBuffer}!{DONE}"
local GO = "\fOK then! Please,\ngo right ahead!{DONE}"
local NO = "You can pass here\nonly if you have\vthe {RAM:wNameBuffer}!\fYou don't have the\n{RAM:wNameBuffer} yet!{DONE}"
local NO22 = "Only truly skilled\ntrainers are\vallowed through.\fYou don't have the\nBOULDERBADGE yet!{DONE}"
local RULES = "\fThe rules are\nrules. I can't\vlet you pass.{DONE}"
local pushed
local textBoxStub = setmetatable({
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}, { __index = RealTextBox })
local fakeGame = {
data = {
text = {
_Route23OhThatIsTheBadgeText = OH,
_Route23GoRightAheadText = GO,
_Route23YouDontHaveTheBadgeYetText = NO,
_Route22GateGuardNoBoulderbadgeText = NO22,
_Route22GateGuardICantLetYouPassText = RULES,
},
items = { CASCADEBADGE = { name = "CASCADEBADGE" } },
field = { badgeGates = {
ROUTE_23 = {
passText = "Route23OhThatIsTheBadgeText",
failText = "Route23YouDontHaveTheBadgeYetText",
guards = { { y = 96, badge = "CASCADEBADGE", event = "EVENT_PASSED_CASCADEBADGE_CHECK" } },
},
ROUTE_22_GATE = {
badge = "BOULDERBADGE",
passText = "Route22GateGuardGoRightAheadText",
failText = "Route22GateGuardNoBoulderbadgeText",
coords = { { x = 4, y = 2 } },
},
} },
},
save = { inventory = { CASCADEBADGE = 1 }, flags = {} },
stack = { push = function(_, box) pushed = box end },
}
T.check(setUpvalue(OW.checkBadgeGate, "TextBox", textBoxStub),
"TextBox upvalue on checkBadgeGate")
T.check(setUpvalue(OW.checkBadgeGate, "Game", fakeGame),
"Game upvalue on checkBadgeGate")
T.check(type(OW.route23BadgeCheck) == "function", "route23BadgeCheck shared helper exists")
if OW.route23BadgeCheck then
setUpvalue(OW.route23BadgeCheck, "TextBox", textBoxStub)
setUpvalue(OW.route23BadgeCheck, "Game", fakeGame)
end
local moves = {}
local function newOw(x, y, mapId)
return setmetatable({
player = { cellX = x, cellY = y },
map = { id = mapId or "ROUTE_23" },
scriptMove = function(_, entity, dir, tiles, onDone, opts)
moves[#moves + 1] = { entity = entity, dir = dir, tiles = tiles,
onDone = onDone, collide = opts and opts.collide }
end,
}, { __index = OW })
end
local function checkPassBox(label)
local text = pushed and pushed.text or ""
local ohAt = text:find("Oh! That is the\nCASCADEBADGE!", 1, true)
local pauseAt = text:find(RealTextBox.PAUSE, 1, true)
local goAt = text:find("OK then! Please,\ngo right ahead!", 1, true)
T.check(ohAt ~= nil, label .. ": badge line present")
T.check(goAt ~= nil, label .. ": go right ahead text follows the badge text")
T.check(ohAt and pauseAt and goAt and ohAt < pauseAt and pauseAt < goAt,
label .. ": fanfare mark sits between the badge line and the go-ahead page")
T.check(not text:sub(1, (goAt or 1) - 1):find("{DONE}", 1, true),
label .. ": badge text DONE stripped before the go-ahead page")
local opts = pushed and pushed.opts or {}
T.eq(opts.pauseSounds and opts.pauseSounds[1], "Get_Item1",
label .. ": Get_Item1 rides the mid-string mark")
T.eq(opts.pauseSoundWait, true, label .. ": fanfare waits like TextCommand_SOUND")
local pages = RealTextBox.paginate((text:gsub(RealTextBox.PAUSE, "")))
T.eq(#pages, 3, label .. ": pass text is three pages")
end
local function checkFailBox(label)
local text = pushed and pushed.text or ""
T.check(text:find("You don't have the\nCASCADEBADGE yet!", 1, true) ~= nil,
label .. ": fail text shown")
T.eq(#played, 0, label .. ": no SFX_DENIED before the text")
local opts = pushed and pushed.opts or {}
T.check(opts.auto and type(opts.auto.sound) == "function",
label .. ": SFX_DENIED armed as the trailing text_asm sound")
T.eq(opts.auto and opts.auto.wait, true, label .. ": button wait after the sound")
if opts.auto and opts.auto.sound then opts.auto.sound() end
T.eq(played[1], "Denied", label .. ": the trailing sound is SFX_DENIED")
T.check(pushed and type(pushed.onDone) == "function",
label .. ": the box closes into Route23MovePlayerDownScript")
end
pushed, played, moves = nil, {}, {}
local self_ = newOw(10, 96)
T.eq(self_:checkBadgeGate(), false, "badge holder walks on")
T.check(pushed ~= nil, "pass text box pushed")
checkPassBox("step")
T.eq(fakeGame.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK ~= nil, true, "pass flag set")
pushed, played, moves = nil, {}, {}
fakeGame.save = { inventory = {}, flags = {} }
self_ = newOw(10, 96)
T.eq(self_:checkBadgeGate(), true, "no badge: the step is blocked")
T.check(pushed ~= nil, "fail text box pushed")
checkFailBox("step")
if pushed and pushed.onDone then pushed.onDone() end
T.eq(#moves, 1, "step fail: one scripted move after the box")
T.eq(moves[1] and moves[1].dir, "down", "step fail: the move is down")
T.eq(moves[1] and moves[1].tiles, 1, "step fail: one tile")
T.eq(moves[1] and moves[1].collide, true, "step fail: simulated d-pad collides")
T.eq(moves[1] and moves[1].entity, self_.player, "step fail: the player walks")
T.eq(fakeGame.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, nil, "no pass flag without the badge")
local R23 = require("data.scripts.flavor.route_23")
local talk = R23.ROUTE_23.talk
local expect = {
TEXT_ROUTE23_GUARD1 = { "EARTHBADGE", "EVENT_PASSED_EARTHBADGE_CHECK" },
TEXT_ROUTE23_GUARD2 = { "VOLCANOBADGE", "EVENT_PASSED_VOLCANOBADGE_CHECK" },
TEXT_ROUTE23_GUARD3 = { "RAINBOWBADGE", "EVENT_PASSED_RAINBOWBADGE_CHECK" },
TEXT_ROUTE23_GUARD4 = { "THUNDERBADGE", "EVENT_PASSED_THUNDERBADGE_CHECK" },
TEXT_ROUTE23_GUARD5 = { "CASCADEBADGE", "EVENT_PASSED_CASCADEBADGE_CHECK" },
TEXT_ROUTE23_SWIMMER1 = { "MARSHBADGE", "EVENT_PASSED_MARSHBADGE_CHECK" },
TEXT_ROUTE23_SWIMMER2 = { "SOULBADGE", "EVENT_PASSED_SOULBADGE_CHECK" },
}
for id, want in pairs(expect) do
local badge, flag = want[1], want[2]
T.eq(type(talk[id]), "function", id .. " is a Lua talk handler")
if type(talk[id]) == "function" then
fakeGame.data.items[badge] = { name = badge }
pushed, played, moves = nil, {}, {}
fakeGame.save = { inventory = { [badge] = 1 }, flags = {} }
local doneCalls = 0
local ow = newOw(7, 136)
talk[id](fakeGame, ow, {}, function() doneCalls = doneCalls + 1 end)
T.check(pushed ~= nil, id .. " pass: one box pushed")
local text = pushed and pushed.text or ""
T.check(text:find("Oh! That is the\n" .. badge .. "!", 1, true) ~= nil,
id .. " pass: badge line names " .. badge)
local pauseAt = text:find(RealTextBox.PAUSE, 1, true)
local goAt = text:find("OK then! Please,\ngo right ahead!", 1, true)
T.check(pauseAt and goAt and pauseAt < goAt, id .. " prints go-ahead after the badge text")
local opts = pushed and pushed.opts or {}
T.eq(opts.pauseSounds and opts.pauseSounds[1], "Get_Item1", id .. " pass: Get_Item1 on the mark")
T.eq(opts.pauseSoundWait, true, id .. " pass: fanfare waits")
T.eq(fakeGame.save.flags[flag] ~= nil, true, id .. " sets " .. flag)
T.eq(#played, 0, id .. " pass: nothing played before the box")
if pushed and pushed.onDone then pushed.onDone() end
T.eq(doneCalls, 1, id .. " pass: done fires when the box closes")
T.eq(#moves, 0, id .. " pass: no scripted move")
pushed, played, moves = nil, {}, {}
fakeGame.save = { inventory = {}, flags = {} }
doneCalls = 0
ow = newOw(7, 136)
talk[id](fakeGame, ow, {}, function() doneCalls = doneCalls + 1 end)
T.check(pushed ~= nil, id .. " fail: box pushed")
text = pushed and pushed.text or ""
T.check(text:find("You don't have the\n" .. badge .. " yet!", 1, true) ~= nil,
id .. " fail: text names " .. badge)
T.eq(#played, 0, id .. " fail: no SFX_DENIED before the text")
opts = pushed and pushed.opts or {}
T.check(opts.auto and type(opts.auto.sound) == "function",
id .. " fail: SFX_DENIED armed after the text")
if opts.auto and opts.auto.sound then opts.auto.sound() end
T.eq(played[1], "Denied", id .. " fail: trailing sound is SFX_DENIED")
T.eq(doneCalls, 0, id .. " fail: done waits for the walk")
if pushed and pushed.onDone then pushed.onDone() end
T.eq(#moves, 1, id .. " fail: Route23MovePlayerDownScript queued")
T.eq(moves[1] and moves[1].dir, "down", id .. " fail: walks down")
T.eq(moves[1] and moves[1].collide, true, id .. " fail: simulated d-pad collides")
T.check(moves[1] and type(moves[1].onDone) == "function",
id .. " fail: move carries an onDone")
if moves[1] and moves[1].onDone then moves[1].onDone() end
T.eq(doneCalls, 1, id .. " fail: done fires after the walk")
T.eq(fakeGame.save.flags[flag], nil, id .. " fail: no pass flag")
end
end
pushed, played, moves = nil, {}, {}
fakeGame.save = { inventory = {}, flags = {} }
self_ = newOw(4, 2, "ROUTE_22_GATE")
T.eq(self_:checkBadgeGate(), true, "Route 22 gate: no badge blocks the step")
T.check(pushed ~= nil, "Route 22 gate: fail box pushed")
local text22 = pushed and pushed.text or ""
local noAt = text22:find("BOULDERBADGE yet!", 1, true)
local pause22 = text22:find(RealTextBox.PAUSE, 1, true)
local rulesAt = text22:find("The rules are", 1, true)
T.check(noAt and pause22 and rulesAt and noAt < pause22 and pause22 < rulesAt,
"Route 22 gate: Denied mark sits between the two texts")
T.eq(#played, 0, "Route 22 gate: no SFX_DENIED before the text")
local opts22 = pushed and pushed.opts or {}
T.eq(opts22.pauseSounds and opts22.pauseSounds[1], "Denied", "Route 22 gate: mark plays SFX_DENIED")
T.eq(opts22.pauseSoundWait, true, "Route 22 gate: sound waits")
T.check(not text22:sub(1, (rulesAt or 1) - 1):find("{DONE}", 1, true),
"Route 22 gate: first text DONE stripped")
T.finish("route23_go_right_ahead_bug2649")
+116
View File
@@ -0,0 +1,116 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Font = require("src.render.Font")
Font.load({ font = require("tests.fixture_data.font") })
local Theme = require("src.ui.Theme")
local TextBox = require("src.render.TextBox")
local Version = require("src.core.GameVersion")
local Timing = require("src.core.Timing")
package.loaded["src.core.Sound"] = { play = function() end, playPress = function() end }
local ARROW = Theme.moreArrow or 0xEE
local SPACE = 0x7F
local LINE = "ENTIRELY COMPLETEZ"
local HEAD = "YOUR DEX IS"
local function make(text, save)
local pressed = false
local game = {
data = {}, save = save or { options = { textSpeed = 1 } },
input = { wasPressed = function(_, key) return pressed == key end,
isDown = function() return false end },
stack = { pop = function() end },
}
local box = TextBox.new(game, text, function() end)
return box, function(key)
pressed = key or false
box:update(1 / 60)
pressed = false
end
end
local function prompt(box, tick)
for _ = 1, 400 do
if box.waiting or box.done then return true end
tick()
end
return false
end
local function drawn(box, blink)
local drawCode, drawBox = Font.drawCode, Font.drawBox
local out = {}
Font.drawCode = function(code, x, y)
out[#out + 1] = { code = code, x = x, y = y }
end
Font.drawBox = function() end
local saved = box.blink
box.blink = blink
box:draw()
box.blink = saved
Font.drawCode, Font.drawBox = drawCode, drawBox
return out
end
local function at(list, x, y)
local codes = {}
for _, g in ipairs(list) do
if g.x == x and g.y == y then codes[#codes + 1] = g.code end
end
return codes
end
local bang = Font.encode("Z")[1]
local function has(codes, code)
for _, c in ipairs(codes) do
if c == code then return true end
end
return false
end
-- home/text.asm:262
for _, edition in ipairs({ "red", "blue", "yellow" }) do
Version.set(edition)
local box, tick = make(HEAD .. "\n" .. LINE .. "\vCONGRATULATIONS")
T.check(prompt(box, tick) and box.contAdvance, edition .. " reaches the cont wait")
local ax, ay = box:arrowPos()
T.eq(#box.shown[2], 18, edition .. " bottom line is 18 tiles")
local lit = at(drawn(box, 0), ax, ay)
T.same(lit, { ARROW }, edition .. " arrow replaces the 18th glyph while lit")
local dark = at(drawn(box, 30), ax, ay)
T.same(dark, {}, edition .. " the cell is blank while the arrow blinks off")
for _ = 1, Timing.TEXT_PRE_ADVANCE do tick() end
tick("a")
T.eq(box.shown[1][18], SPACE, edition .. " cont leaves a space in the scrolled line")
T.eq(box.shown[1][17], Font.encode(LINE)[17], edition .. " the 17th glyph survives")
for _ = 1, Timing.TEXT_SCROLL_PAIR do tick() end
T.check(prompt(box, tick) and box.done, edition .. " completes")
local after = drawn(box, 30)
T.check(not has(at(after, ax, box.line1Y), bang),
edition .. " scrolled line keeps no 18th glyph")
end
Version.set("red")
local plain, plainTick = make("SHORT LINE\nHERE")
T.check(prompt(plain, plainTick) and plain.done, "short page completes")
local ax, ay = plain:arrowPos()
local list = drawn(plain, 0)
T.same(at(list, ax, ay), { ARROW }, "short line still draws the arrow")
T.eq(#plain.shown[2], 4, "short line untouched")
Version.set("gold")
local gold, goldTick = make(HEAD .. "\n" .. LINE .. "\vNEXT",
{ generation = 2, options = { textSpeed = 1 } })
T.check(prompt(gold, goldTick) and gold.contAdvance, "gold reaches cont")
local gx = gold:arrowPos()
local gglyphs = at(drawn(gold, 0), gx, gold.line2Y)
T.same(gglyphs, { bang }, "gold keeps the glyph above its border-row arrow")
for _ = 1, Timing.TEXT_PRE_ADVANCE do goldTick() end
goldTick("a")
T.eq(gold.shown[#gold.shown - 1][18], bang, "gold cont keeps the glyph")
Version.set("red")
T.finish("textbox_arrow_cell_bug2674")
@@ -0,0 +1,87 @@
-- home/fade.asm:67
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local check, eq = T.check, T.eq
T.fixtures.fresh()
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local Transition = require("src.render.Transition")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local function getUpvalue(fn, name)
local i = 1
while true do
local n, v = debug.getupvalue(fn, i)
if not n then return nil end
if n == name then return v end
i = i + 1
end
end
local prevVersion, prevMode, prevDark = GameVersion.get(), PaletteFX.mode, PaletteFX.darkWorld()
local prevGame = getUpvalue(OW.warpFade, "Game")
local realSetFadeObp = PaletteFX.setFadeObp
local fade
local game = { stack = { top = function() return fade end, pop = function() end },
worldBgBattleInStack = function() return false end }
check(setUpvalue(OW.warpFade, "Game", game), "warpFade reads the Game upvalue")
local function armedAt(t)
local armed, called = nil, false
PaletteFX.setFadeObp = function(map) armed, called = map, true; realSetFadeObp(map) end
fade = Transition.new(game, nil, nil, true)
fade.t = t
local self_ = setmetatable({}, { __index = OW })
pcall(OW.drawWorld, self_)
PaletteFX.setFadeObp = realSetFadeObp
return armed, called, fade
end
for _, version in ipairs({ "red", "blue" }) do
GameVersion.set(version)
PaletteFX.setMode("ogred")
PaletteFX.setDarkWorld(false)
local base = PaletteFX.ogObjBase()
for step = 0, 3 do
local map, called, f = armedAt(step * 8)
local obp = f:obp0()
check(called, ("%s step %d drawWorld arms the fade OBP"):format(version, step))
check(map ~= nil, ("%s step %d rOBP0 $%02X arms a fade map"):format(version, step, obp))
end
local map, _, f = armedAt(8)
eq(f:obp0(), 0xE4, version .. " FadePal3 is the black-out second step")
check(map ~= nil and map[0] == 0 and map[1] == 1 and map[2] == 2 and map[3] == 3,
version .. " FadePal3 rOBP0 $E4 arms the identity map")
local colors = PaletteFX.ogObjWorld()
check(colors[2][1] == base[2][1] and colors[2][2] == base[2][2]
and colors[3][1] == base[3][1] and colors[3][2] == base[3][2],
version .. " FadePal3 step bakes the raw $E4 ramp, not the $D0 lift")
PaletteFX.setFadeObp(nil)
end
PaletteFX.setFadeObp = realSetFadeObp
PaletteFX.setFadeObp(nil)
setUpvalue(OW.warpFade, "Game", prevGame)
PaletteFX.setDarkWorld(prevDark)
GameVersion.set(prevVersion)
PaletteFX.setMode(prevMode)
T.finish("warp fade rOBP0 identity #2689")
@@ -331,4 +331,33 @@ do
Version.set(oldVersion)
end
do
local RsMenu = require("src.ui.game3.rs.main_menu")
local Pal = require("src.core.game3.pal_fade")
local T3 = RsMenu.TYPE
eq(table.concat(RsMenu.items(T3.HAS_NO_SAVED_GAME), ","), "NEW_GAME,OPTION,EXIT", "RS main menu lists EXIT under OPTION")
eq(table.concat(RsMenu.items(T3.HAS_SAVED_GAME), ","), "CONTINUE,NEW_GAME,OPTION,EXIT", "RS continue menu lists EXIT")
eq(table.concat(RsMenu.items(T3.HAS_MYSTERY_EVENT), ","), "CONTINUE,NEW_GAME,MYSTERY_EVENTS,OPTION,EXIT",
"RS mystery events menu lists EXIT last")
local items = RsMenu.items(T3.HAS_MYSTERY_EVENT)
local m = setmetatable({ menuType = T3.HAS_MYSTERY_EVENT, items = items, cursor = 1, scroll = 0, blink = 0,
pal = Pal.new(), state = "input" }, RsMenu)
for _ = 1, #items - 1 do
m:frame({ new = { down = true } })
m.state = "input"
end
eq(m.cursor, #items, "RS cursor reaches EXIT")
check(m.scroll > 0, "RS menu scrolls so EXIT stays on screen")
local w = RsMenu.windowFor(m.menuType, m.cursor)
check((w.top + w.height + 1) * 8 - m.scroll <= 160, "RS EXIT window fits after scroll")
m.state = "pressed_a"
local r = m:frame({ new = {} })
eq(type(r) == "table" and r.action, "exit", "A on RS EXIT returns to the launcher")
end
do
local Data = require("src.ui.game3.rs.start_menu_data")
check(Data.exitConfirms == true, "RS start menu EXIT offers return to title like Emerald")
end
T.finish("game3_pause_and_main_menu_exit_test")
+2
View File
@@ -3,6 +3,7 @@
-- ROM-free:
-- luajit tests/gen2_crystal_extras_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
require("src.core.GameVersion").set("crystal")
-- The same love stub tests/gen2_menus_test.lua installs; nothing here draws.
love = love or {}
@@ -569,4 +570,5 @@ do
check(ids.Gen2BuenaPassword, "Gen2BuenaPassword is one too")
end
require("src.core.GameVersion").set("red")
S.finish()
+1 -1
View File
@@ -978,7 +978,7 @@ do
local fgame = { data = {}, stack = newStack(), input = newInput(),
save = { player = {} } }
local fspeech = OakSpeech.new(fgame, nil)
fspeech.shrink = { frame = 103 } -- past the shrink timeline's end
fspeech.shrink = { frame = OakSpeech.SHRINK_END } -- past the shrink timeline's end
fgame.stack:push(fspeech)
fspeech.picReveal = nil -- skip the intro fade so update reaches shrink
local finishes = 0
+3 -3
View File
@@ -138,10 +138,10 @@ local champGame = { save = { flags = {} } }
champ.onEnter(champGame, champOw)
eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script")
local walk = champQueued[1] and champQueued[1].script
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 3
and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3,
"entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)")
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 1,
"entrance walk is RivalEntrance_RLEMovement (up 3, right 1, up 1)")
check(champQueued[2] and champQueued[2].script == rows,
"second queue is TEXT_CHAMPIONSROOM_RIVAL script rows")
check(champQueued[2] and champQueued[2].extra
+8 -6
View File
@@ -94,13 +94,14 @@ local wild = BattleState.newWild(game, "RATTATA", 2)
wild.onFinish = function() end
wild:enter()
eq(wild.introBalls, true, "the wild intro opens the DrawAllPokeballs window")
-- introBalls is a plain field, not a queue row, so it cannot displace the
-- wild cry PrintBeginningBattleText plays before PrintText (#303)
check(wild.introBalls ~= true, "the DrawAllPokeballs window is not open at enter")
-- engine/battle/common_text.asm:11
check(wild.queue[1] and wild.queue[1].fn ~= nil,
"the cry act is still the first queue row (#303)")
check(wild.queue[2] and wild.queue[2].text == wild.introText,
"the intro text is still the second queue row")
check(wild.queue[2] and wild.queue[2].fn ~= nil,
"DrawAllPokeballs is the second queue row")
check(wild.queue[3] and wild.queue[3].text == wild.introText,
"the intro text is the third queue row")
-- let the silhouette slide land so the intro box is genuinely up (the slide
-- now runs 80 frames at 2px/frame, matching the original ~2px/frame)
@@ -174,8 +175,9 @@ local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) })
local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1)
tr.onFinish = function() end
tr:enter()
check(tr.introBalls ~= true, "the trainer window is not open at enter")
for _ = 1, 140 do tr:update(1 / 60) end
eq(tr.introBalls, true, "the trainer intro opens the same window")
for _ = 1, 85 do tr:update(1 / 60) end
local ok3, err3, rows3 = snapshotHUD(tr)
check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3))
+38 -2
View File
@@ -20,18 +20,21 @@ local CASES = {
after = "_LoreleisRoomLoreleiAfterBattleText",
headerMap = "LoreleisRoom", event = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0",
npcName = "LORELEISROOM_LORELEI", class = "OPP_LORELEI",
closed = 0x24, open = 0x05,
},
{
map = "BRUNOS_ROOM", text = "TEXT_BRUNOSROOM_BRUNO",
after = "_BrunoAfterBattleText",
headerMap = "BrunosRoom", event = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0",
npcName = "BRUNOSROOM_BRUNO", class = "OPP_BRUNO",
closed = 0x24, open = 0x05,
},
{
map = "AGATHAS_ROOM", text = "TEXT_AGATHASROOM_AGATHA",
after = "_AgathaAfterBattleText",
headerMap = "AgathasRoom", event = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0",
npcName = "AGATHASROOM_AGATHA", class = "OPP_AGATHA",
closed = 0x3b, open = 0x0e,
},
}
@@ -64,23 +67,45 @@ for _, c in ipairs(CASES) do
facePlayer = function() end,
}
local engaged = false
local blocks = {}
local blocksAtPush
game.stack.push = function(_, state)
pushed[#pushed + 1] = state
blocksAtPush = blocksAtPush or #blocks
end
local ow = {
player = { cellY = 5 },
trainerDefeated = function(_, n)
return game.save.defeatedTrainers[n.id] == true
end,
engageTrainer = function(_, n, onDone)
replaceBlock = function(_, bx, by, id)
blocks[#blocks + 1] = { bx, by, id }
end,
engageTrainer = function(self, n, onDone)
engaged = n == npc
game.save.defeatedTrainers[n.id] = true
game.save.flags[c.event] = true
local hooks = mapScripts.get(c.map)
if hooks and hooks.onVictory then hooks.onVictory(game, self) end
if onDone then onDone() end
end,
}
talk(game, ow, npc, function() end)
local finished = false
talk(game, ow, npc, function() finished = true end)
check(engaged, c.map .. " engages on first talk")
check(#pushed == 1 and pushed[1].pages ~= nil,
c.map .. " win pushes after-battle TextBox")
check(#pushed[1].pages > 0, c.map .. " after-battle TextBox has pages")
eq(blocksAtPush, 0, c.map .. " exit stays shut while after-battle text is up")
check(not finished, c.map .. " talk not done before after-battle text closes")
pushed[1].onDone()
eq(#blocks, 1, c.map .. " exit block swapped once after the text")
local b = blocks[1] or {}
check(b[1] == 2 and b[2] == 0 and b[3] == c.open,
c.map .. " exit opens at block (2,0) after the text")
check(finished, c.map .. " talk done after after-battle text closes")
game.stack.push = function(_, state) pushed[#pushed + 1] = state end
-- loss: no after text
pushed, engaged = {}, false
@@ -104,6 +129,17 @@ for _, c in ipairs(CASES) do
talk(game, ow, npc, function() end)
check(#pushed == 1 and pushed[1].pages ~= nil,
c.map .. " defeated re-talk shows after text")
local hooks = mapScripts.get(c.map)
check(hooks.onVictory == nil, c.map .. " has no onVictory door swap")
blocks = {}
game.save.flags = { [c.event] = true }
hooks.onEnter(game, ow)
check(blocks[1] and blocks[1][3] == c.open, c.map .. " reload after win opens exit")
blocks = {}
game.save.flags = {}
hooks.onEnter(game, ow)
check(blocks[1] and blocks[1][3] == c.closed, c.map .. " reload before win keeps exit shut")
end
S.finish()

Some files were not shown because too many files have changed in this diff Show More