CLOSES #1211, CLOSES #1228, CLOSES #1229, CLOSES #1232, CLOSES #1251, CLOSES #1265, CLOSES #1267, CLOSES #1276, CLOSES #1279, CLOSES #1282, CLOSES #1293, CLOSES #1296, CLOSES #1303, CLOSES #1329, CLOSES #1338, CLOSES #1341, CLOSES #1343, CLOSES #1344, CLOSES #1368, CLOSES #1385, CLOSES #1388, CLOSES #1389, CLOSES #1391

This commit is contained in:
bryanthaboi
2026-08-16 08:55:40 -04:00
parent 65128e13a4
commit 1151c188a7
50 changed files with 2835 additions and 276 deletions
+136 -7
View File
@@ -3,9 +3,8 @@
-- guide and SUPER_NERD2 garden nerd.
--
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- YES/NO-branched flavor text is here.
-- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
-- is below.
local M = {}
@@ -25,11 +24,138 @@ local function ask(game, s, cb)
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
local museumGuySteps = {
"up", "up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left",
"up", "up", "up",
"left",
}
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
local museumPlayerRle = {
"NO",
"up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left",
"up", "up", "up", "up", "up", "up",
}
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
local museumPreambles = {
["27,18"] = { "up", "up" },
["27,16"] = { "right", "left" },
["26,17"] = { "up", "right" },
["28,17"] = { "up", "left" },
}
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
-- pewterEscort.playerPlan in story5.lua
local function museumPlan(x, y)
local pre = museumPreambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
local function museumEscortWalk(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
if not plan then return false end
local Music = require("src.core.Music")
local t = text(game)
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
local head = plan.guyHeadStart
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
local function walkOut()
if not guy then return end
local i = 0
local function tick()
i = i + 1
if i > 4 then
guy.cellX, guy.cellY = 27, 17
guy.px, guy.py = 27 * 16, 17 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "down"
return
end
ow:scriptMove(guy, "down", 1, tick)
end
tick()
end
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
local function afterWalk()
if guy then
guy.stepFrames = nil
guy.cellX, guy.cellY = 13, 8
guy.px, guy.py = 13 * 16, 8 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "up"
end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCitySuperNerd1ItsRightHereText
or "It's right here!", walkOut)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = museumGuySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
if guy then
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
return true
end
M.PEWTER_CITY = {
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
talk = {
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you
-- checked out the museum; YES -> fossils comment, NO -> "you have
-- to go" (which in pokered also kicks off the escort script).
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
-- fossils comment, NO -> "you have to go" and the museum escort
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
local t = text(game)
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
@@ -39,7 +165,10 @@ M.PEWTER_CITY = {
or "Weren't those\nfossils from MT.\nMOON amazing?", done)
else
push(game, t._PewterCitySuperNerd1YouHaveToGoText
or "Really?\nYou absolutely\nhave to go!", done)
or "Really?\nYou absolutely\nhave to go!", function()
museumEscortWalk(game, ow)
if done then done() end
end)
end
end)
end,
+1
View File
@@ -288,6 +288,7 @@ return {
-- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = {
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
{ "face_player_dir", "up" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
+1
View File
@@ -263,6 +263,7 @@ return {
local rival = ow:npcByIndex(RIVAL)
if not rival then return false end
local rows = {
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
{ "face_player_dir", "up" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
+4
View File
@@ -140,6 +140,10 @@ M.VIRIDIAN_CITY = {
-- Daisy hands over the TOWN MAP once Oak's errand is under way
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
M.BLUES_HOUSE = {
-- scripts/BluesHouse.asm:12-16
onEnter = function(game, ow)
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
end,
talk = {
TEXT_BLUESHOUSE_DAISY_SITTING = {
{ "face_player" },
+12
View File
@@ -87,6 +87,18 @@ end
M.PALLET_TOWN = {
talk = require("data.scripts.pallet_town").talk,
escort = escort,
-- scripts/PalletTown.asm:133-144
onEnter = function(game, ow)
local f = game.save.flags
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
and not f.EVENT_DAISY_WALKING then
f.EVENT_DAISY_WALKING = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
end
end,
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
-- then a wild Pikachu battle before the lab escort (pokeyellow
-- PalletTownPikachuBattleScript).
+1 -6
View File
@@ -937,15 +937,10 @@ M.VERMILION_DOCK = {
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
-- player alone ("south of the player and won't be redrawn"), so skip
-- his own block: he must not spend the walk-out standing on water
local pbx = math.floor(ow.player.cellX / 2)
local pby = math.floor(ow.player.cellY / 2)
-- scripts/VermilionDock.asm:182-203
local rows = {}
local function setBlock(bx, by, block)
if bx < 1 or bx > 8 then return end
if bx == pbx and by == pby then return end
rows[#rows + 1] = { "replace_block", bx, by, block }
end
rows[#rows + 1] = { "wait", 120 }
+20 -10
View File
@@ -583,7 +583,8 @@ end
local function stampOT(save, mon)
save.player.id = save.player.id or math.random(0, 65535)
mon.ot = mon.ot or save.player.name
mon.otId = mon.otId or save.player.id
-- engine/battle/experience.asm:69
if not mon.traded then mon.otId = mon.otId or save.player.id end
end
BattleState.stampOT = stampOT
@@ -5923,10 +5924,16 @@ function BattleState:drawTextArea()
Font.drawCode(Font.BORDER.h, 32, 96)
Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.player.curMoves) do
-- unknown ids (mod-injected moves) print raw instead of crashing
local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
-- engine/battle/misc.asm:37
for i = 1, 4 do
local mv = self.player.curMoves[i]
if mv then
-- unknown ids (mod-injected moves) print raw instead of crashing
local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
else
Font.draw("-", 48, 96 + i * 8)
end
end
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
@@ -5953,13 +5960,16 @@ function BattleState:drawTextArea()
end
end
elseif self.phase == "mimicSelect" then
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:
-- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names
-- single-spaced from (2,8), cursor at column 1
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517):
-- 16x6 box at (0,7), names from (2,8), cursor at column 1
Font.drawBox(0, 7, 16, 6)
love.graphics.setColor(0, 0, 0, 1)
for i, m in ipairs(self.mimicMoves) do
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
-- engine/battle/misc.asm:37
for i = 1, 4 do
local m = self.mimicMoves[i]
local def = m and self.data.moves[m.id]
Font.draw(m and (def and def.name or tostring(m.id)) or "-",
16, (7 + i) * 8)
end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
+5 -5
View File
@@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
hitSfx = { sound = "Damage", pitch = 0x20 }
end
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
-- the screen vertically) for a damaging move with no added effect, and
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
-- Bubblebeam and Confusion shake instead of blinking (#354)
-- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once
-- the move has an added effect (#354)
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
-- PlayApplyingAttackAnimation runs on both arms of the wOptions check
-- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384)
local hitFx = { sfx = hitSfx,
animType = user.isPlayer and (added and 5 or 4)
or (added and 2 or 1),
blink = battle:animationsOn() and target or nil }
blink = target }
local totalDealt = 0
local landed, brokeSub = 0, false
+6 -12
View File
@@ -192,14 +192,8 @@ function Runner:loadGfx(names)
end
end
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
-- APPENDED after whatever the script already loaded rather than replacing it,
-- and they always land on the same two fixed tile ids.
--
-- (pokegold's jumptable has these two labels the other way round from the
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
-- is what a script actually writes.)
-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro
-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401)
function Runner:loadBattlerGfx(rows)
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
local slot = 1
@@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows)
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
battler = "player", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7,
battler = "enemy", rows = rows }
self.loaded[#self.loaded + 1] =
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6,
battler = "player", rows = rows }
end
--------------------------------------------------------------------------
+80 -31
View File
@@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = {
EFFECT_PARALYZE_HIT = "paralyze",
EFFECT_SLEEP_HIT = "sleep",
EFFECT_CONFUSE_HIT = "confuse",
EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696
}
local function rand(random, n)
@@ -1320,6 +1321,18 @@ function Battle:markMissed()
if self.moveEvent then self.moveEvent.missed = true end
end
-- engine/battle/effect_commands.asm:3615
Battle.AI_FAIL_STATUSES = {
sleep = true, poison = true, toxic = true, paralyze = true,
}
-- engine/battle/effect_commands.asm:3615
function Battle:aiRandomFail(attacker, defender)
if self:sideOf(attacker) ~= "enemy" then return false end
if self:volatile(defender).lockOn then return false end
return rand(self.random, 256) < 64
end
-- One attack, start to finish.
function Battle:useMove(attacker, defender, moveId)
local move = self:findMove(attacker, moveId)
@@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId)
if charge and not charging then
state.chargeMove = moveId
state.vanished = charge.vanish or nil
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table
-- keyed by effect cannot tell them apart and DIG announced itself with
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the
-- effect: `cp DIG` and then the burrow text.
-- engine/battle/effect_commands.asm:5458
if self.moveEvent then self.moveEvent.animParam = 1 end
-- BattleCommand_Charge picks the line off the MOVE, not the shared
-- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464).
local text = charge.text
if moveId == "DIG" then text = "%s dug a hole!" end
self:emit({ kind = "message", text = text:format(name) })
@@ -1811,20 +1824,20 @@ function Battle:useMove(attacker, defender, moveId)
-- Defense Curl arms Rollout as well as raising Defense.
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
-- Stat changes: the primary ones always land, the *_HIT ones roll the
-- move's effect chance after a hit that connected.
--
-- A refused primary change is a failure the cart detects BEFORE its anim
-- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` /
-- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400),
-- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER
-- `attackup` / `attackdown` in the effect list (data/moves/effects.asm,
-- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs
-- after `moveanim` (AttackDownHit), so the animation has already played.
-- A refused primary change writes wAttackMissed (effect_commands.asm:4191,
-- :4380-4400); the *_HIT twins animate first and must stay unmarked.
local change = Effects.STAT_CHANGES[def.effect]
if change then
local target = change[3] == "self" and attacker or defender
if not self:changeStageAgainstMist(attacker, target, change[1], change[2])
-- CheckMist first (effect_commands.asm:4290), then .ComputerMiss (:4318)
local misted = target ~= attacker and (change[2] or 0) < 0
and self:volatile(target).mist
if not misted and change[3] == "foe"
and def.effect ~= "EFFECT_ACCURACY_DOWN_HIT"
and self:aiRandomFail(attacker, target) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
elseif not self:changeStageAgainstMist(attacker, target, change[1], change[2])
then
self:markMissed()
end
@@ -1853,19 +1866,21 @@ function Battle:useMove(attacker, defender, moveId)
local record = Battle.moveEffectRecordFor(self.data, def.effect)
local status = record and record.kind == "primary" and record.status or nil
if status and (def.power or 0) == 0 then
-- Every status command's already-statused / immune arm ends on
-- AnimateFailedMove (BattleCommand_Poison's `.failed`,
-- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay,
-- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the
-- success path (:3752), and the effect scripts carry no moveanim of their
-- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS
-- branch below is the opposite case: that move already hit and already
-- animated, so a refused secondary must leave the event unmarked.
if not self:applyStatus(defender, status, attacker) then self:markMissed() end
-- A refused primary status is a failed move (effect_commands.asm:3748,
-- :6656); a refused secondary already animated and stays unmarked (:3752).
if Battle.AI_FAIL_STATUSES[status]
and self:aiRandomFail(attacker, defender) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
elseif not self:applyStatus(defender, status, attacker) then
self:markMissed()
end
else
local secondary = record and record.kind == "secondary"
and record.status or nil
if secondary and (defender.hp or 0) > 0 then
-- engine/battle/effect_commands.asm:6325
if secondary and (defender.hp or 0) > 0
and not self:safeguarded(defender) then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
self:applyStatus(defender, secondary, attacker)
@@ -2425,6 +2440,15 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
text = self:monName(attacker) .. "'s DEFENSE rose!" })
end
-- engine/battle/move_effects/safeguard.asm:1
Battle.MOVE_EFFECTS.EFFECT_SAFEGUARD = function(self, attacker)
local side = self.screens[self:sideOf(attacker)]
if (side.safeguard or 0) > 0 then return fail(self) end
side.safeguard = Battle.SCREEN_TURNS
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s covered by a veil!" })
end
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
-- one body. A non-Ghost user trades a stage of Speed for one each of Attack
-- and Defense, refused only when BOTH raises are already capped; a Ghost
@@ -2910,6 +2934,11 @@ function Battle.statusPenaltyFor(data, mon, stat, value)
return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
end
-- engine/battle/effect_commands.asm:6325
function Battle:safeguarded(mon)
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
function Battle:applyStatus(mon, status, source)
@@ -2917,7 +2946,14 @@ function Battle:applyStatus(mon, status, source)
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
-- in the volatile beside the major status, so a confused mon can still be
-- burned and a switch shakes the confusion off.
if status == "confuse" then return self:applyConfusion(mon) end
if status == "confuse" then return self:applyConfusion(mon, nil, source) end
-- engine/battle/effect_commands.asm:6338
if source and self:sideOf(source) ~= self:sideOf(mon)
and self:safeguarded(mon) then
self:emit({ kind = "message",
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
return false
end
-- One major status at a time.
if mon.status then
self:emit({ kind = "message",
@@ -2953,8 +2989,15 @@ end
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
Battle.BERSERK_GENE_CONFUSE_TURNS = 256
function Battle:applyConfusion(mon, turns)
function Battle:applyConfusion(mon, turns, source)
if (mon.hp or 0) <= 0 then return false end
-- engine/battle/effect_commands.asm:6338
if source and self:sideOf(source) ~= self:sideOf(mon)
and self:safeguarded(mon) then
self:emit({ kind = "message",
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
return false
end
local state = self:volatile(mon)
if (state.substitute or 0) > 0 then return false end
local held = self:heldEffect(mon, "confuse")
@@ -4434,14 +4477,20 @@ Battle.SCREEN_FALL_TEXT = {
function Battle:tickScreens()
for _, side in ipairs({ "player", "enemy" }) do
local screens = self.screens[side]
for _, field in ipairs({ "lightScreen", "reflect" }) do
for _, field in ipairs({ "lightScreen", "reflect", "safeguard" }) do
if (screens[field] or 0) > 0 then
screens[field] = screens[field] - 1
if screens[field] <= 0 then
screens[field] = nil
self:emit({ kind = "message",
text = Battle.SCREEN_SIDE_LABEL[side]
.. Battle.SCREEN_FALL_TEXT[field] })
if field == "safeguard" then
-- engine/battle/core.asm:1527
self:emit({ kind = "message",
text = self:monName(self[side]) .. "'s SAFEGUARD faded!" })
else
self:emit({ kind = "message",
text = Battle.SCREEN_SIDE_LABEL[side]
.. Battle.SCREEN_FALL_TEXT[field] })
end
end
end
end
+3 -3
View File
@@ -387,9 +387,9 @@ end
-- overworld map theme; onBike/surfing override outdoor themes with the
-- bike/surf songs and restore the map theme when they end
function Music.playMap(data, mapId, onBike, surfing, fade)
local song = data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId] or nil
function Music.playMap(data, mapId, onBike, surfing, fade, song)
song = song or (data and data.audio and data.audio.mapSongs
and mapId and data.audio.mapSongs[mapId]) or nil
state.mapSong = song
state.onBike = not not onBike
state.surfing = not not surfing
+23 -30
View File
@@ -358,42 +358,33 @@ end
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
-- (constants/script_constants.asm).
-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the
-- routine that prints it is engine code and no script bytecode points at the
-- string, so the extractor never reaches it.
-- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so
-- the extractor never reaches it.
local CONTEST_NICKNAME_PROMPT =
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123)
function Specials.askNickname(vm, mon)
nameMon(vm, mon.species)
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- InitNickname (engine/pokemon/move_mon.asm:1787)
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's blank test (home/string.asm:6-30)
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
end
H.CheckPartyFullAfterContest = function(vm)
local Breeding = require("src.core.gen2.Breeding")
local result, mon =
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
-- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the
-- mon that joined the party and the one that went to the box -- and nowhere
-- else in the contest: BugContest_SetCaughtContestMon merely holds the catch
-- in wContestMon, so this is the only place the player is ever asked.
-- GetPokemonName runs first, which is what {STRBUF} reads.
-- GiveANickname_YesNo runs on both contest arms, party and box
if mon and result ~= BugContest.NO_CATCH then
nameMon(vm, mon.species)
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is
-- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page
-- left standing.
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
if coroutine.yield({ kind = "yesorno" }) then
-- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a
-- fresh catch (the Name Rater is the one that pre-fills), and InitNickname
-- copies the species name back over an empty entry -- so a cancelled
-- keyboard is the same as answering NO.
local h = hooks(vm)
local name = h.renameMon and Specials.block(vm, function(done)
h.renameMon(mon, done, { blank = true })
end)
-- _InitString's own blank test (home/string.asm:6-30): "zero or more
-- spaces followed by a null". The keyboard's blank cells are real
-- typeable characters, so an all-space entry has to be discarded the
-- same way an empty one is, not stored as a name of spaces.
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
end
Specials.askNickname(vm, mon)
end
answer(vm, result)
end
@@ -1005,7 +996,7 @@ end
-- check is transcribed here rather than left to the screen, because its two
-- refusals are TEXT and the script has to see them before the machine opens:
-- no coins at all, or no COIN_CASE to hold them.
local COIN_CASE = 0x47 -- constants/item_constants.asm
local COIN_CASE = 0x36 -- constants/item_constants.asm:62
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
local NO_COINS_TEXT = "You have no coins."
@@ -2409,7 +2400,9 @@ local STUB_ROWS = {
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
{ "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" },
-- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to
-- WaitForLinkedFriend and lands on .FriendNotReady
{ "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" },
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
{ "TradeCenter", nil, "link cable: no trade room" },
{ "Colosseum", nil, "link cable: no battle room" },
+6 -1
View File
@@ -544,7 +544,12 @@ local function runCmd(self, cmd, op)
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
if self.givePokeFn then
self.givePokeFn(species, level, item)
local mon = self.givePokeFn(species, level, item)
-- engine/pokemon/move_mon.asm:1753-1757
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
if mon and trainer == 0 then
Specials.askNickname(self, mon)
end
end
elseif op == "checkpoke" then
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
+124 -38
View File
@@ -14,6 +14,7 @@
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
return speciesOrOpts, false
end
local function ownedFor(game, def, forceOwned)
return forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
end
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
local function descPages(game, def, forceOwned)
local e = def.dexEntry or {}
local owned = ownedFor(game, def, forceOwned)
local text = owned and e.text and game.data.text[e.text] or nil
if not text then return nil end
local pages = {}
for chunk in (text .. "\f"):gmatch("(.-)\f") do
local lines = {}
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
end
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
if #lines > 0 then pages[#pages + 1] = lines end
end
if #pages == 0 then return nil end
local last = pages[#pages]
last[#last] = last[#last] .. "."
return pages
end
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
local frameCache = {}
local function frameSheet(game)
local fx = game.data.field and game.data.field.overworldFx
local def = fx and fx.pokedexFrame
local path = def and def.path
if not path then return nil end
local hit = frameCache[path]
if hit ~= nil then return hit or nil end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then
frameCache[path] = false
return nil
end
local iw, ih = img:getDimensions()
local quads = {}
for i = 0, 17 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8, 8, 8, iw, ih)
end
frameCache[path] = { img = img, quads = quads }
return frameCache[path]
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
}
function DexEntryMenu.new(game, speciesOrOpts, onDone)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned,
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.def = game.data.pokemon[species]
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
-- pcall's second return has to survive the guard (#307)
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species)
return self
end
@@ -57,6 +115,11 @@ end
function DexEntryMenu:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
self.page = self.page + 1
return
end
self.game.stack:pop()
if self.onDone then self.onDone() end
end
@@ -64,64 +127,87 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor)
self.spriteTrueColor, self.page)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
-- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
page = page or 1
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
if frame then
local function tile(code, tx, ty)
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
end
-- engine/menus/pokedex.asm:418
for tx = 1, 18 do
tile(0x64, tx, 0)
tile(0x6f, tx, 17)
end
for ty = 1, 16 do
tile(0x66, 0, ty)
tile(0x67, 19, ty)
end
tile(0x63, 0, 0)
tile(0x65, 19, 0)
tile(0x6c, 0, 17)
tile(0x6e, 19, 17)
-- engine/menus/pokedex.asm:445
for tx = 0, 19 do
tile(DIVIDER[tx + 1], tx, 9)
end
end
if sprite then
local y = math.max(0, 60 - sprite:getHeight())
love.graphics.draw(sprite, 8, y)
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
-- it renders to its own PNG canvas, and a mark left behind there would
-- bleed into the next real frame.
-- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
local w, h = sprite:getDimensions()
local x = 8 + math.floor((8 - w / 8) / 2) * 8
local y = 8 + (7 - h / 8) * 8
love.graphics.draw(sprite, x + w, y, 0, -1, 1)
-- the unshaded pass needs the pic bounds (#350)
if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
-- engine/menus/pokedex.asm:454
Font.draw(def.name, 72, 16)
local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
-- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
Font.draw(e.kind or "?", 72, 32)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
-- engine/menus/pokedex.asm:478
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
if e.heightM then
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
else
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 48)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
end
end
local text = owned and e.text and game.data.text[e.text] or nil
local y = 72
if text then
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if y > 132 then break end
Font.draw(line, 8, y)
y = y + 10
local pages = descPages(game, def, forceOwned)
if pages then
-- engine/menus/pokedex.asm:568
local lines = pages[page] or pages[#pages]
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245
if page < #pages then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
Font.draw(Strings("Data unknown."), 8, y)
Font.draw(Strings("Data unknown."), 8, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
+112
View File
@@ -0,0 +1,112 @@
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local HallOfFame = require("src.ui.HallOfFame")
local LeaguePC = {}
LeaguePC.__index = LeaguePC
LeaguePC.isOpaque = true
-- constants/pokemon_data_constants.asm:65
local CAPACITY = 50
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
function LeaguePC:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local mon = self:currentMon()
local c = mon and P.monPal(game.data, mon.species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
end
function LeaguePC.new(game, onDone)
local self = setmetatable({}, LeaguePC)
self.game = game
self.onDone = onDone
self.teams = (game.save and game.save.hallOfFame) or {}
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
self.monIndex = 1
self.sprites = {}
self.spriteTrueColor = {}
self:loadMon()
return self
end
function LeaguePC:currentMon()
local team = self.teams[self.teamIndex]
return team and team[self.monIndex] or nil
end
function LeaguePC:loadMon()
local mon = self:currentMon()
if not mon then return end
local species = mon.species
if self.sprites[species] == nil then
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprites[species] = ok and img or false
self.spriteTrueColor[species] = (ok and img and trueColor) or false
end
require("src.core.Sound").playCry(self.game.data, species)
end
function LeaguePC:close()
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function LeaguePC:update(dt)
local input = self.game.input
if input:wasPressed("b") then
self:close()
return
end
if input:wasPressed("a") then
if not self:currentMon() then
self:close()
return
end
local team = self.teams[self.teamIndex]
if self.monIndex < #team then
self.monIndex = self.monIndex + 1
elseif self.teamIndex < #self.teams then
self.teamIndex = self.teamIndex + 1
self.monIndex = 1
else
self:close()
return
end
self:loadMon()
end
end
function LeaguePC:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local mon = self:currentMon()
if not mon then return end
local img = self.sprites[mon.species]
if img then
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
local w, h = img:getDimensions()
local x = 96 + math.floor((8 - w / 8) / 2) * 8
local y = 40 + (7 - h / 8) * 8
love.graphics.draw(img, x, y)
if self.spriteTrueColor[mon.species] then
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
-- engine/movie/hall_of_fame.asm:159
HallOfFame.drawMonInfo(self, mon)
-- engine/menus/league_pc.asm:102
Font.drawBox(0, 13, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return LeaguePC
+11 -1
View File
@@ -79,7 +79,16 @@ end
function NamingScreen:enter()
if self.presets and #self.presets > 0 then
local Menu = require("src.ui.Menu")
local items = { { label = Strings("NEW NAME") } }
-- engine/movie/oak_speech/oak_speech2.asm:1
self.choosing = true
self.isOpaque = false
local items = { {
label = Strings("NEW NAME"),
onSelect = function()
self.choosing = nil
self.isOpaque = nil
end,
} }
for _, preset in ipairs(self.presets) do
table.insert(items, {
label = preset,
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
end
function NamingScreen:draw()
if self.choosing then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
+32 -15
View File
@@ -220,6 +220,20 @@ function TownMap.new(game, opts)
-- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
-- fly destination (PALLET_TOWN), never the player's current town (#795).
@@ -345,18 +359,16 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- the player's current location blinks (slow phase). Paint it with a
-- palette-safe DARK shade (red 0), not red: this screen composites through
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
-- {165,214,255}, the exact light-blue used for the water and the town-square
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
@@ -389,11 +401,16 @@ function TownMap:draw()
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
-- palette-safe dark, same red-channel shade-remap reason as the primary
-- grid path above (#152); stale-asset builds hit this fallback square
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerSheet then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
end
if selected and self.blink % 16 < 10 then
love.graphics.setColor(0, 0, 0, 1)
+34 -38
View File
@@ -49,6 +49,9 @@ BattleState.isOpaque = true
-- the victory jingle can keep looping through the post-win prompts.
local MESSAGE_FRAMES = 48
-- engine/battle/effect_commands.asm:6661
local MOVE_DELAY_FRAMES = 40
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
@@ -239,15 +242,8 @@ function BattleState.new(game, opts)
self.menuIndex = 1
self.moveIndex = 1
self.picCache = {}
-- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_
-- ReturnMon's last row (what swallows a mon into a thrown ball) and
-- MonFaintedAnimation both clear the box and neither puts anything back: it
-- stays blank until something DRAWS a pic into it, which on the cart is only
-- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon).
-- Without this latch the pic came back the instant the animation let go of
-- the screen, so a caught mon stood there through "Gotcha!" and a fainted one
-- popped back up for its own faint line. See stepAnim for why it is latched
-- at those two moments rather than off the runner's own last frame.
-- Which side's pic box stays EMPTY until a send-out redraws it: a catch
-- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide.
self.picHidden = { player = false, enemy = false }
-- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in
-- from opposite sides before the first message.
@@ -1050,10 +1046,10 @@ function BattleState:afterAnimFor(side)
return "ANIM_PLAYER_DAMAGE"
end
function BattleState:animForMove(moveId, side)
function BattleState:animForMove(moveId, side, param)
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true,
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
})
if started then
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
@@ -1091,14 +1087,22 @@ function BattleState:animForId(idName, side, param)
})
end
-- data/moves/animations.asm:379
function BattleState:latchCaughtPic()
local anim = self.anim
if anim and anim.animId == "ANIM_THROW_POKE_BALL"
and self.ballThrow and self.ballThrow.caught then
self.picHidden.enemy = true
end
end
-- One logic frame of a running animation. B cuts it short, the way holding B
-- pages a text box.
function BattleState:stepAnim(input)
if not self.anim then return end
if input and (input:wasPressed("b") or input:wasPressed("start")) then
-- Cut short: the BG effects never reached their own last step, so the
-- tilemap is whatever they had got to and nothing is latched -- the
-- explicit latches (a catch) are the only ones that survive a skip.
-- Cut short: only the explicit latches (a caught mon) survive a skip.
self:latchCaughtPic()
self.anim = nil
-- Cart still reaches the after-anim arm after a move script ends; a skip
-- of the move should not drop the hit shake that follows it.
@@ -1106,6 +1110,7 @@ function BattleState:stepAnim(input)
return self:endSendOutAnim()
end
if not self.anim:step() then
self:latchCaughtPic()
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means
-- the OAM outlives the script, so keep the runner for drawing too.
if not self.anim.keepSprites then self.anim = nil end
@@ -1391,16 +1396,14 @@ function BattleState:advanceQueue()
end
if event.text then
self.message = event.text
-- Lines that must not hold the queue for A/B:
-- move UsedMoveText -> text_end, then moveanim
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
-- box's WaitPressAorB is the real hold
-- experience keeps the wait: _ExpPointsText ends in `prompt`
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
-- so the bar crawls under the line and A dismisses it before the battle
-- can end.
-- move/level lines do not hold for A/B (battle.asm:336-343); experience
-- keeps the wait (common_1.asm:1660-1665).
if event.kind == "move" or event.kind == "level" then
self.messageTimer = 0
-- engine/battle/effect_commands.asm:1958-1961
if event.kind == "move" and event.missed then
self.messageDelay = MOVE_DELAY_FRAMES
end
else
self.messageTimer = MESSAGE_FRAMES
end
@@ -1422,17 +1425,12 @@ function BattleState:advanceQueue()
if event.waitSfx then self.waitSfx = event.sfx end
end
end
-- The move's own animation plays over its "used X!" line, which is where
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
-- event.missed on every wAttackMissed path.
-- engine/battle/effect_commands.asm:1958: a missed move burns the delay
-- and plays nothing; the after-anim chain is animForMove / stepAnim's.
if event.kind == "move" and not event.missed then
self.afterAnimPlayed = nil
self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side) then
if not self:animForMove(event.move, event.side, event.animParam) then
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
-- (anim_commands.asm:55-72 .disabled fallthrough).
local options = self.game and self.game.options
@@ -1854,6 +1852,11 @@ function BattleState:update(_dt)
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
-- before any PromptButton wait so the bar does not sit frozen until A.
if self:stepExpAnim() then return end
-- engine/battle/effect_commands.asm:6661
if (self.messageDelay or 0) > 0 then
self.messageDelay = self.messageDelay - 1
return
end
if self.messageTimer > 0 then
if self.tutorial then
-- PromptButton waits for the button; the tutorial cannot press it, so
@@ -2463,14 +2466,6 @@ function BattleState:pushCaught(enemy, itemId)
local save = self.save
self.battle.over = true
self.battle.outcome = "caught"
-- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught
-- arm ends on the return-mon BG effect, which leaves the enemy pic box
-- cleared, and PokeBallEffect never draws a frontpic again -- there is no
-- send-out left in a battle that is already over. Latched here as well as
-- from the animation's own last step so that a throw the player skipped with
-- B (BattleAnimRunScript has no such skip; this port does) cannot put the
-- caught mon back on the field for the "Gotcha!" line.
self.picHidden.enemy = true
-- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to
-- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's
-- whole effect; its catch rate is a plain ball's. It applies on the box
@@ -2932,6 +2927,7 @@ function BattleState:useItem(itemId)
-- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything
-- pushed above is drained only once the ball has finished wobbling.
self:startBallAnim(self:ballAnimParam(itemId), itemId)
if caught and not self.anim then self.picHidden.enemy = true end
self.message = nil
self.messageTimer = 0
self.phase = "resolving"
+4 -18
View File
@@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts)
self.game = game
self.save = opts.save or (game and game.save)
local data = game and game.data or {}
-- Held, not just read into the fields below. CRY resolves its sample
-- through data.audio.cries and AREA resolves nests and landmark names
-- through data.maps / data.landmarks, and every one of those reads
-- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry
-- returned before it reached Sound, and the button did nothing at all.
-- Taken by reference so a mod's merged cry or landmark is the one used.
-- engine/pokedex/pokedex.asm:447
self.data = data
self.dex = opts.pokedex or data.gen2Pokedex
self.pokemon = opts.pokemon or data.pokemon
@@ -866,15 +861,6 @@ function PokedexMenu:drawArea()
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
local G = love.graphics
local table_ = self.data and self.data.landmarks
local byIndex = self.landmarkByIndex
if not byIndex then
byIndex = {}
for _, entry in pairs((table_ and table_.landmarks) or {}) do
if entry and entry.index then byIndex[entry.index] = entry end
end
self.landmarkByIndex = byIndex
end
if #nests == 0 then
-- A species with no grass, water or roamer entry in this region. The cart
@@ -883,11 +869,11 @@ function PokedexMenu:drawArea()
return
end
-- Blinking markers, the way the cart flashes its OBJs.
-- engine/pokegear/pokegear.asm:2427
local on = ((self.areaBlink or 0) % 32) < 20
if cells and on then
for _, index in ipairs(nests) do
local mark = byIndex[index]
local mark = Nests.landmark(self.data, index)
if mark and mark.x and mark.y then
G.setColor(0, 0, 0, 1)
G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5)
@@ -900,7 +886,7 @@ function PokedexMenu:drawArea()
-- Name the first one in words as well as on the map: the flashing dot is
-- unreadable at this size on a modern display, and the landmark name is what
-- a player actually wants off this screen.
local first = byIndex[nests[1]]
local first = Nests.landmark(self.data, nests[1])
if first and first.name then
local name = tostring(first.name):gsub("\n", " ")
self:text(name, 1, 16)
+16 -29
View File
@@ -33,6 +33,7 @@ function NPC.new(data, mapId, objDef)
self.facing = FACING_FROM_RANGE[objDef.range] or "down"
self.moving = false
self.progress = 0
self.animClock = 0
self.stepFlip = false
self.frozen = false -- scripts freeze NPCs while talking
self.wanders = objDef.movement == "WALK"
@@ -52,31 +53,15 @@ function NPC:facePlayer(player)
end
function NPC:update(map, entities)
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
-- the 1px step vector once per call (engine/overworld/movement.asm), but
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
--
-- self.stepFrames overrides the shared walk for an object whose
-- step has to stay in phase with something else: Yellow's follower
-- Pikachu takes the player's own step length, halved while it is more
-- than a cell behind (FastPikachuFollow, engine/pikachu/
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
-- command: two cells of travel inside one step's frames
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
-- pixel span doubles while the frame count does not. Nothing else sets
-- either field, so every other object keeps the constant (#410, #409).
-- engine/overworld/movement.asm:301, 32 frames per NPC cell; stepFrames is
-- the follower's own step length (#410, #409).
local stepLen = self.stepFrames or STEP_FRAMES
local span = self.hopStep and 2 or 1
if self.moving then
self.progress = self.progress + 1
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
-- pinned to the current cell while walkPhase() cycles.
self.animClock = (self.animClock or 0) + 1
-- NPC_CHANGE_FACING (movement.asm ChangeFacingDirection): walk cycle in
-- place, no translation.
if self.marching then
if self.progress >= stepLen then
self.progress = 0
@@ -122,18 +107,20 @@ end
function NPC:walkPhase()
if not self.moving then return 0 end
local stepLen = self.stepFrames or STEP_FRAMES
local p = self.progress % stepLen
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
-- engine/overworld/movement.asm:301
local p = (self.animClock or 0) % 16
return (p >= 4 and p < 12) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
-- phase this frame renders to -- so a render pipeline can pose an NPC
-- without caring which kind of entity it is. An NPC never hops, so the
-- trailing hop flag is always false.
-- Same contract as Player:pose; an NPC never hops, so the trailing hop
-- flag is always false.
function NPC:pose()
local flip = self.stepFlip
if self.moving then
flip = math.floor((self.animClock or 0) / 16) % 2 == 1
end
return self.sprite, self.px, self.py, self.facing,
self:walkPhase(), self.stepFlip, false
self:walkPhase(), flip, false
end
function NPC:draw(camX, camY)
+5 -2
View File
@@ -3090,9 +3090,12 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
end))
end
if not npc then farewell() return end
npc.frameOverride = 3
-- engine/events/pokecenter.asm:36-39; Yellow's walk-down pose when the
-- sheet has it (pokeyellow engine/events/pokecenter.asm:82-88)
local yellow = GameVersion.isYellow()
npc.frameOverride = (yellow and npc.sprite.frames[3]) and 3 or 1
-- bubble = false is the silent world hold, this port's DelayFrames
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
self.emote = { npc = npc, frames = yellow and 40 or 20, bubble = false, onDone = function()
npc.frameOverride = nil
npc:facePlayer(self.player)
farewell()
+59 -12
View File
@@ -1074,11 +1074,12 @@ function World:load()
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.seen[mon.species] = true
save.pokedex.caught[mon.species] = true
-- AddPartyMon's `.registerunowndex` runs on the same path, so a gifted
-- Unown lands in the form list too (nothing in Gold gives one, but the
-- cart's check is on the species, not on where it came from).
-- AddPartyMon's `.registerunowndex` runs on the same path, so a
-- gifted Unown lands in the form list too (move_mon.asm:347).
Unown.registerCatch(save, mon)
end
-- engine/pokemon/move_mon.asm:1632-1645
return mon
end,
giveItem = function(itemIndex, qty)
local data = self.game and self.game.data
@@ -2402,12 +2403,43 @@ function World:rollWild()
return { species = def.index, level = roll.level }
end
-- GetMapMusic (home/map.asm:2550)
function World.mapMusicLabel(audio, musicByte, rocketsMahogany, rocketsRadioTower)
if type(musicByte) ~= "number" then return nil end
local MUSIC_MAHOGANY_MART = 100 -- constants/music_constants.asm:100
local RADIO_TOWER_MUSIC = 0x80 -- constants/music_constants.asm:109
local order = audio and audio.musicOrder
local songs = audio and audio.songs
local label
if musicByte == MUSIC_MAHOGANY_MART then
label = rocketsMahogany and "Music_RocketHideout" or "Music_CherrygroveCity"
elseif musicByte >= RADIO_TOWER_MUSIC then
label = rocketsRadioTower and "Music_RocketTheme"
or (order and order[(musicByte - RADIO_TOWER_MUSIC) + 1])
else
return nil
end
if label and label ~= "Music_Nothing" and songs and songs[label] then
return label
end
return nil
end
function World:mapMusicSong(mapId)
local audio = self.game and self.game.data and self.game.data.audio
local def = self.maps and self.maps[mapId]
-- ENGINE_ROCKETS_IN_MAHOGANY / _RADIO_TOWER (data/events/engine_flags.asm:40,:36)
return World.mapMusicLabel(audio, def and def.music,
self:engineFlag(22), self:engineFlag(18))
end
function World:playMapMusic()
local data = self.game and self.game.data
if data and data.audio and data.audio.runtime and self.map then
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
end
@@ -3255,7 +3287,8 @@ function World:surfStartStep(mon)
if audio and audio.runtime and self.map then
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
if p.scriptStep then p:scriptStep(p.facing) end
self.fieldMove = { phase = "step" }
@@ -3565,7 +3598,7 @@ function World:rareWildMon()
local entry = self.encounters and self.encounters.grass
and map and self.encounters.grass[map.id]
if not entry or not entry.slots then return nil end
local key = (self.daytime == "DARK") and "NITE" or (self.daytime or "DAY")
local key = self.tod or "DAY"
local slots = entry.slots[key] or entry.slots.DAY
if not slots then return nil end
local rare = slots[4 + math.random(3)]
@@ -3958,7 +3991,7 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
-- Same guard World:rockRandom uses: a headless suite has no love global.
rng = (love and love.math and love.math.random) or math.random,
kind = kind,
daytime = self.daytime,
daytime = self.tod,
environment = map and map.def and map.def.environment,
tables = tables,
data = self.game and self.game.data,
@@ -4021,7 +4054,8 @@ function World:tryWildEncounter()
if onWater then
rate = Encounter.waterRate(tables, map.id)
else
rate = Encounter.grassRate(tables, map.id, self.daytime)
-- engine/overworld/wildmons.asm:283
rate = Encounter.grassRate(tables, map.id, self.tod)
end
-- ApplyMusicEffectOnEncounterRate runs first (wildmons.asm:213-215).
rate = World.musicEncounterRate(rate, Music.mapSong())
@@ -4315,6 +4349,14 @@ function World:rollFishing(rod)
-- the flag and nothing about the map changes. Roamers.Swarm.fishing is the
-- same store CheckSwarmFlag clears when the swarm expires.
local swarm = Roamers.Swarm.fishing(game.save)
-- engine/events/fish.asm:24-30
local groupRow = self.encounters.fishGroups
and self.encounters.fishGroups[
Encounter.fishGroupFor(self.encounters, group, swarm)]
if groupRow and groupRow.chance
and not Encounter.triggers(groupRow.chance, nil) then
return "nibble"
end
local roll
if Runtime.wantsHook("encounter.fishing") then
-- Gen 1's three arguments, in Gen 1's order: the rod, the map, and the
@@ -5100,7 +5142,7 @@ function World:sweetScentEncounter()
local tables = self:wildTables()
local onWater = FieldMoves.encounterTable(collision) == "water"
local rate = onWater and Encounter.waterRate(tables, map.id)
or Encounter.grassRate(tables, map.id, self.daytime)
or Encounter.grassRate(tables, map.id, self.tod)
if not (rate and rate > 0) then return false end
-- CheckEncounterRoamMon, the first thing ChooseWildEncounter itself does:
-- a beast REPLACES the map's own slot rather than adding to it.
@@ -5374,7 +5416,8 @@ function World:runSurf(result)
if audio and audio.runtime and self.map then
-- SpecialMapMusic (home/audio.asm:397)
Music.playMap(self.game.data, self.map.id, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(self.map.id))
end
if self.player and self.player.scriptStep then
self.player:scriptStep(self.player.facing)
@@ -5886,6 +5929,8 @@ function World:startBattle(opts, onDone)
-- World:startCatchTutorial sets it.
tutorial = opts.tutorial,
onDone = function(outcome)
-- WildBattleScript's reloadmapafterbattle (engine/overworld/events.asm:1158-1162)
self.wildCooldown = 5
self.battleActive = nil
game.stack:pop()
-- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2.
@@ -8540,7 +8585,8 @@ function World:setMap(mapId, cx, cy, facing, opts)
if audio and audio.runtime then
if not (FieldMoves.isBiking(self.playerState) and self:playBikeMusic()) then
Music.playMap(self.game.data, mapId, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(mapId))
end
end
-- Fires with the map fully built and BEFORE the map's own scene script, so a
@@ -8928,7 +8974,8 @@ function World:movePlayer(dir)
local audio = self.game and self.game.data and self.game.data.audio
if audio and audio.runtime then
Music.playMap(self.game.data, map.id, nil,
FieldMoves.isSurfing(self.playerState))
FieldMoves.isSurfing(self.playerState), nil,
self:mapMusicSong(map.id))
end
end
end
@@ -0,0 +1,102 @@
-- engine/battle/misc.asm:37 FormatMovesString .printDashLoop
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BattleState = require("src.battle.BattleState")
local Font = require("src.render.Font")
local realDraw, realDrawCode, realDrawBox = Font.draw, Font.drawCode, Font.drawBox
local drawn
local function stubFont()
drawn = {}
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = text, x = x, y = y } end
Font.drawCode = function() end
Font.drawBox = function() end
end
local function unstubFont()
Font.draw, Font.drawCode, Font.drawBox = realDraw, realDrawCode, realDrawBox
end
-- a mon with fewer than four moves: the remaining rows must be dashes, not
-- simply absent (ipairs used to stop at the last known move).
do
stubFont()
local screen = setmetatable({
phase = "moveSelect",
player = { curMoves = { { id = "TACKLE", pp = 35 } } },
data = { moves = { TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" } } },
moveIndex = 1, frame = 0,
}, { __index = BattleState })
local ok, err = pcall(function() screen:drawTextArea() end)
T.check(ok, "moveSelect draws without error (" .. tostring(err) .. ")")
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
end
T.eq(#rows, 4, "all four move rows are drawn, even the unused ones")
T.eq(rows[1], "TACKLE", "the one real move prints its name")
T.eq(rows[2], "-", "an empty slot is a dash")
T.eq(rows[3], "-", "so is the next one")
T.eq(rows[4], "-", "and the last one")
unstubFont()
end
-- a full four-move mon: no dashes anywhere.
do
stubFont()
local screen = setmetatable({
phase = "moveSelect",
player = { curMoves = {
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
} },
data = { moves = {
TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" },
GROWL = { name = "GROWL", pp = 40, type = "NORMAL" },
} },
moveIndex = 1, frame = 0,
}, { __index = BattleState })
screen:drawTextArea()
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
end
T.eq(#rows, 4, "still exactly four rows")
for i, want in ipairs({ "TACKLE", "GROWL", "TACKLE", "GROWL" }) do
T.eq(rows[i], want, "row " .. i .. " keeps its own move name")
end
unstubFont()
end
-- the Mimic menu shares FormatMovesString on the cart, so it gets the same
-- dash treatment.
do
stubFont()
local screen = setmetatable({
phase = "mimicSelect",
mimicMoves = { { id = "TACKLE" }, { id = "GROWL" } },
data = { moves = {
TACKLE = { name = "TACKLE" }, GROWL = { name = "GROWL" },
} },
mimicIndex = 1, frame = 0,
}, { __index = BattleState })
local ok = pcall(function() screen:drawTextArea() end)
T.check(ok, "mimicSelect draws without error")
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 16 and d.y >= 64 and d.y <= 88 then rows[#rows + 1] = d.text end
end
T.eq(rows[1], "TACKLE", "mimic row 1 is the enemy's first move")
T.eq(rows[2], "GROWL", "mimic row 2 is its second")
T.eq(rows[3], "-", "an enemy with fewer than four moves dashes out the rest")
T.eq(rows[4], "-", "including the last row")
unstubFont()
end
T.finish("battle move slot dashes bug 1343")
@@ -0,0 +1,80 @@
-- #1338: after the TOWN MAP, Daisy has to swap from the sitting object to
-- the walking one -- PalletTownDaisyScript, gated on both
-- EVENT_GOT_TOWN_MAP and EVENT_ENTERED_BLUES_HOUSE.
-- scripts/BluesHouse.asm:12-16; scripts/PalletTown.asm:133-144
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Story = assert(loadfile("data/scripts/story.lua"))()
local Story2 = assert(loadfile("data/scripts/story2.lua"))()
T.check(type(Story.BLUES_HOUSE.onEnter) == "function",
"M.BLUES_HOUSE.onEnter exists")
T.check(type(Story2.PALLET_TOWN.onEnter) == "function",
"M.PALLET_TOWN.onEnter exists")
-- Entering Blue's House alone must set EVENT_ENTERED_BLUES_HOUSE and touch
-- nothing else: BluesHouseDefaultScript is a plain SetEvent, no swap here.
do
local game = { save = { flags = {} } }
Story.BLUES_HOUSE.onEnter(game, {})
T.check(game.save.flags.EVENT_ENTERED_BLUES_HOUSE == true,
"onEnter sets EVENT_ENTERED_BLUES_HOUSE")
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
"and does not itself start the swap")
end
-- PALLET_TOWN's onEnter is the swap: both events must be set, and it must
-- write the toggle even though BLUES_HOUSE is not the live map (the fix's
-- own precondition, verified against src/script/Commands.lua's toggleObject
-- writing save.objectToggles before its live-NPC early return).
do
local game = {
save = {
flags = { EVENT_GOT_TOWN_MAP = true, EVENT_ENTERED_BLUES_HOUSE = true },
},
}
local ow = { map = { id = "PALLET_TOWN" } }
Story2.PALLET_TOWN.onEnter(game, ow)
T.check(game.save.flags.EVENT_DAISY_WALKING == true,
"both prerequisites set: EVENT_DAISY_WALKING fires")
local toggles = game.save.objectToggles and game.save.objectToggles.BLUES_HOUSE
T.check(toggles ~= nil, "the swap reaches BLUES_HOUSE's toggle table")
T.eq(toggles and toggles.BLUESHOUSE_DAISY1, false,
"the sitting Daisy (DAISY1) is hidden")
T.eq(toggles and toggles.BLUESHOUSE_DAISY2, true,
"the walking Daisy (DAISY2) is shown")
end
-- Only having the map, without ever entering the house, must not swap her:
-- EVENT_ENTERED_BLUES_HOUSE is a real gate, not a formality.
do
local game = {
save = { flags = { EVENT_GOT_TOWN_MAP = true } },
}
Story2.PALLET_TOWN.onEnter(game, { map = { id = "PALLET_TOWN" } })
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
"without EVENT_ENTERED_BLUES_HOUSE the swap does not fire")
end
-- Re-entering Pallet Town after she has already swapped must not re-run
-- the toggle writes (EVENT_DAISY_WALKING itself is the guard).
do
local game = {
save = {
flags = {
EVENT_GOT_TOWN_MAP = true,
EVENT_ENTERED_BLUES_HOUSE = true,
EVENT_DAISY_WALKING = true,
},
objectToggles = {},
},
}
local ow = { map = { id = "PALLET_TOWN" } }
Story2.PALLET_TOWN.onEnter(game, ow)
T.check(next(game.save.objectToggles) == nil,
"already-walking Daisy: onEnter writes no toggle a second time")
end
T.finish("blues_house_daisy_walking_bug1338")
@@ -0,0 +1,49 @@
-- engine/battle/experience.asm:69
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BattleState = require("src.battle.BattleState")
local function newSave()
return { player = { id = 12345, name = "RED" } }
end
do
local save = newSave()
local homegrown = {}
BattleState.stampOT(save, homegrown)
T.eq(homegrown.otId, 12345, "a home-grown mon is still stamped with the player id")
T.eq(homegrown.ot, "RED", "and the player's own name")
end
-- The bug: a mon that arrived traded (traded = true) but whose OT id was
-- never recorded (a legacy peer, a link mon with no otId in its packet) used
-- to get save.player.id written into otId on the very first load, which then
-- reads identically to a mon the player caught -- awardExp's OT-id compare
-- (BattleState.lua ~4009) permanently loses the 1.5x boost.
do
local save = newSave()
local tradedNoId = { traded = true }
BattleState.stampOT(save, tradedNoId)
T.eq(tradedNoId.otId, nil,
"a traded mon with no OT id is left unstamped, not silently adopted")
T.eq(tradedNoId.ot, "RED",
"the OT NAME fill still happens (cosmetic, not the boost gate)")
-- a second stampOT pass (a second save/load cycle) must not adopt it either
BattleState.stampOT(save, tradedNoId)
T.eq(tradedNoId.otId, nil, "repeated reloads do not eventually stamp it")
end
-- A mon with its own foreign OT id (the ordinary traded-in case) is untouched
-- either way; this is the arm the regression never broke.
do
local save = newSave()
local tradedWithId = { traded = true, otId = 777 }
BattleState.stampOT(save, tradedWithId)
T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten")
end
T.finish("exp traded ot survives reload bug 1265")
@@ -0,0 +1,82 @@
-- data/moves/animations.asm:379
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local UI = require("src.ui.gen2.BattleState")
local function newSelf(opts)
opts = opts or {}
return setmetatable({
anim = opts.anim,
ballThrow = opts.ballThrow,
picHidden = { player = false, enemy = false },
pendingAfterAnim = nil,
afterSendOut = nil,
}, { __index = UI })
end
-- pushCaught itself must never touch picHidden: only the animation's own
-- steps (stepAnim) are allowed to latch the enemy pic box.
do
local self1 = setmetatable({
battle = {}, tutorial = true, save = nil,
queue = {}, picHidden = { player = false, enemy = false },
}, { __index = UI })
self1:pushCaught({ species = "RATTATA" }, "POKE_BALL")
T.eq(self1.picHidden.enemy, false,
"pushCaught alone does not hide the enemy pic")
T.eq(self1.battle.outcome, "caught", "pushCaught still marks the battle caught")
end
-- stepAnim, natural end (anim:step() returns false): a caught ball throw
-- latches, everything else does not.
do
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, true, "caught ball throw latches at the natural end")
T.eq(s.anim, nil, "the finished runner is cleared")
end
do
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, false, "a break-free throw does not latch")
end
do
local otherAnim = { animId = "ANIM_HYDRO_PUMP",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = otherAnim, ballThrow = { caught = true } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, false, "an unrelated animation never latches")
end
-- stepAnim, cut short with B: the property the latch exists for -- a caught
-- mon must not reappear even if the player skips past "Gotcha!".
do
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return true end, keepSprites = false }
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, true, "a B-skipped catch still latches")
T.eq(s.anim, nil, "B cuts the runner short")
end
do
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return true end, keepSprites = false }
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, false, "a B-skipped break-free does not latch")
end
T.finish("gen2 ball throw pic latch bug 1232")
@@ -0,0 +1,58 @@
-- engine/battle_anims/anim_commands.asm:755 BattleAnimCmd_BattlerGFX_1Row
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local function findLoaded(runner, gfx)
for _, entry in ipairs(runner.loaded) do
if entry.gfx == gfx then return entry end
end
return nil
end
do
local runner = AnimRunner.new({})
runner:start(nil)
runner:loadBattlerGfx(1)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.check(head and feet, "both pseudo-sheets registered")
T.eq(head.battler, "enemy",
"GFX_PLAYERHEAD's tiles are the ENEMY's feet row")
T.eq(head.tiles, 7, "seven tiles, the enemy pic's width")
T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the asm's fixed base")
T.eq(feet.battler, "player",
"GFX_ENEMYFEET's tiles are the PLAYER's head row")
T.eq(feet.tiles, 6, "six tiles, the backpic's width")
T.eq(feet.tile, (0x80 - 6) - 49, "at the asm's fixed base")
T.eq(head.rows, 1, "one row each")
T.eq(feet.rows, 1, "on both sheets")
end
do
local runner = AnimRunner.new({})
runner:start(nil)
runner:loadBattlerGfx(2)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.eq(head.battler, "enemy", "2ROW keeps the same crossing")
T.eq(head.tiles, 14, "two enemy rows")
T.eq(feet.battler, "player", "on both sides")
T.eq(feet.tiles, 12, "two player rows")
T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "2ROW base")
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base")
end
do
local runner = AnimRunner.new({})
runner:start(nil)
AnimRunner.COMMANDS.battlergfx_1row(runner)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
T.eq(head and head.battler, "enemy", "the script command routes the same way")
end
T.finish("gen2 battler gfx row attribution bug 1231")
@@ -0,0 +1,94 @@
-- engine/battle/effect_commands.asm:5458 BattleCommand_Charge
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
GROUND = { id = "GROUND", index = 1, category = "physical" },
FLYING = { id = "FLYING", index = 2, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND",
accuracy = 100, pp = 10, effect = "EFFECT_FLY" },
FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING",
accuracy = 95, pp = 15, effect = "EFFECT_FLY" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function highRoll(n) return (n or 1) - 1 end
local function newBattle(moveId)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = { { id = moveId, pp = 20, maxPp = 20 } }
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = highRoll }), player, wild
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
do
local battle, player, wild = newBattle("DIG")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev = moveEvent(battle.events)
T.check(ev ~= nil, "the charge turn queues a move event")
T.eq(ev and ev.animParam, 1,
"DIG's charge (burrow) turn carries animParam 1, the take-cover script arm")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev2 = moveEvent(battle.events)
T.check(ev2 ~= nil, "the strike turn also queues a move event")
T.eq(ev2 and ev2.animParam, nil,
"DIG's strike turn leaves animParam nil, the hit script arm")
end
do
local battle, player, wild = newBattle("FLY")
battle.events = {}
battle:useMove(player, wild, "FLY")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, 1, "FLY's take-off turn also carries animParam 1")
end
-- a plain hit-and-run move never sets a parameter at all
do
local battle, player, wild = newBattle("DIG")
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
battle.events = {}
battle:useMove(player, wild, "TACKLE")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, nil, "a non-charge move never carries animParam")
end
T.finish("gen2 charge move anim param bug 1293")
+83
View File
@@ -0,0 +1,83 @@
-- #1251: the Game Corner's `CheckCoinsAndCoinCase` transcription must ask
-- the bag about the real COIN_CASE item id, not SILVER_WING.
-- constants/item_constants.asm:62 (COIN_CASE = $36); SILVER_WING is $47.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Vm = require("src.script.gen2.Vm")
local Specials = require("src.script.gen2.Specials")
local Events = require("src.world.gen2.Events")
local COIN_CASE = 0x36
local SILVER_WING = 0x47
-- 1) the id the handler actually queries the bag with
local seenId
local vm = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 100 end,
hasItem = function(id) seenId = id return true end,
gameCornerGame = function(_, done) done() end,
},
})
vm.showTextFn = function() end
vm.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm) end)
coroutine.resume(vm.co)
T.eq(seenId, COIN_CASE,
"SlotMachine's CheckItem call carries the real COIN_CASE id ($36)")
T.check(seenId ~= SILVER_WING,
"and specifically not SILVER_WING ($47), the pre-fix value")
-- 2) a bag holding ONLY the real coin case (not the silver wing) must be
-- enough to open both machines: this is what would still fail if the id
-- above were merely logged and not actually used to gate the machine.
local bag = { [COIN_CASE] = true }
local slotsOpened, flipOpened
local vm2 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) slotsOpened = kind done() end,
},
})
vm2.showTextFn = function() end
vm2.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm2) end)
coroutine.resume(vm2.co)
T.eq(slotsOpened, "slots",
"a bag with the real COIN CASE and coins opens the slot machine")
local vm3 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) flipOpened = kind done() end,
},
})
vm3.showTextFn = function() end
vm3.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm3) end)
coroutine.resume(vm3.co)
T.eq(flipOpened, "cardflip",
"and the same bag opens card flip too")
-- 3) the mirror case: SILVER_WING in the bag, no coin case, must still
-- refuse with _NoCoinCaseText (this is the exact symptom in #1251, and it
-- is the yield the coroutine parks on, not a call, so opening never runs
-- behind it).
local wrongBag = { [SILVER_WING] = true }
local opened = false
local vm4 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return wrongBag[id] == true end,
gameCornerGame = function() opened = true end,
},
})
vm4.showTextFn = function() end
vm4.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm4) end)
local _, refusal = coroutine.resume(vm4.co)
T.eq(refusal and refusal.text, "You don't have a\nCOIN CASE.",
"holding only SILVER_WING gets the real _NoCoinCaseText refusal")
T.check(not opened, "and the machine never opens behind it")
T.finish("gen2_coin_case_bug1251")
@@ -0,0 +1,166 @@
-- engine/battle/effect_commands.asm:1958-1961 (the 40 frame hold),
-- engine/battle/effect_commands.asm:3615 (.CheckAIRandomFail, the 25% roll)
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local UI = require("src.ui.gen2.BattleState")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
-- a controllable roll queue; falls back to a high roll (never fails an AI
-- check) once drained
local rolls
local function rng(n)
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
return (n or 1) - 1
end
local function newBattle(pmoves, emoves)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = pmoves
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = emoves
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng }), player, wild
end
local function findText(events, sub)
for _, e in ipairs(events or {}) do
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
return true
end
end
return false
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
-- ---------------------------------------------------------------- gap 2:
-- the AI's 25% "miss" on a support move, and who is exempt from the roll.
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 10 } -- accuracy roll, then the AI roll (10 < 64: fails)
battle:useMove(wild, player, "GROWL")
T.check(findText(battle.events, "But it failed!"),
"enemy GROWL fails 25% of the time with the specific line")
T.eq(moveEvent(battle.events) and moveEvent(battle.events).missed, true,
"an AI-failed move is marked missed (feeds the 40 frame hold)")
end
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 200 } -- AI roll passes (>=64): lands
battle:useMove(wild, player, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the same move lands when the AI roll passes")
end
do
local battle, player, wild = newBattle(
{ { id = "GROWL", pp = 40, maxPp = 40 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.events = {}
rolls = { 0, 10 } -- if the player rolled too, 10 would fail it
battle:useMove(player, wild, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the player's own GROWL is exempt from the AI roll")
end
-- --------------------------------------------------------------- gap 1:
-- the reported symptom -- the "used X!" line must hold before a failure.
do
local input = { wasPressed = function() return false end }
local ui = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = {
{ kind = "move", side = "enemy", move = "GROWL",
text = "Enemy MACHOP used GROWL!", missed = true },
{ kind = "message", text = "But it failed!" },
},
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
}, { __index = UI })
ui:advanceQueue()
T.eq(ui.message, "Enemy MACHOP used GROWL!", "the used-move line is shown first")
T.eq(ui.messageDelay, 40,
"a missed move arms the 40 frame delay (effect_commands.asm's MoveDelay)")
T.eq(ui.messageTimer, 0, "no separate A/B hold on the move line itself")
local frames = 0
for _ = 1, 100 do
if ui.message == "But it failed!" then break end
ui:update(1 / 60)
frames = frames + 1
end
T.eq(ui.message, "But it failed!", "the queue eventually reaches the failure line")
T.eq(frames, 41, "the used line held for exactly the 40 delay frames")
T.check(ui.messageTimer > 0, "the failure line itself still holds for A/B")
end
do
local input = { wasPressed = function() return false end }
local ui2 = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = { { kind = "move", side = "player", move = "TACKLE",
text = "MACHOP used TACKLE!" } },
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
animForMove = function() return false end,
}, { __index = UI })
ui2:advanceQueue()
T.eq(ui2.messageDelay or 0, 0, "a move that lands arms no delay at all")
end
T.finish("gen2 enemy move fail text bug 1296")
@@ -0,0 +1,92 @@
-- The fishgroup bite roll, missing entirely before #1368: .Fish rolls the
-- group's OWN chance byte before the rod's cumulative list even runs
-- (engine/events/fish.asm:24-30), so every rod bites at whatever that byte
-- says (vanilla Gold is 50 percent + 1 for every group, not 2/3 or 1/2 by
-- rod). A cache built before the extractor carried the byte has no
-- `chance` field on the group row at all and must keep fishing unconditionally.
-- luajit tests/engine/gen2_fishing_bite_gate_bug1368.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
MAGIKARP = {
name = "MAGIKARP", types = { "WATER", "WATER" },
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
specialAttack = 15, specialDefense = 20 },
levelMoves = {},
},
},
}
local COLL_FLOOR, COLL_WATER = 0x00, 0x29
local function fakeMap(waterCell)
return {
id = "TEST_MAP",
def = { fishGroup = "FISHGROUP_POND" },
cellCollision = function(_, x, y)
return (x == waterCell[1] and y == waterCell[2])
and COLL_WATER or COLL_FLOOR
end,
}
end
-- Rod lists that always hand back a species once a bite happens, so only the
-- group gate (or its absence) decides the outcome below.
local function fishGroups(chance)
return {
FISHGROUP_POND = {
chance = chance,
old = { { chance = 256, species = "MAGIKARP", level = 10 } },
good = { { chance = 256, species = "MAGIKARP", level = 20 } },
super = { { chance = 256, species = "MAGIKARP", level = 40 } },
},
}
end
local function fakeWorld(chance)
local game = { data = DATA, save = { party = {} } }
local world = World.new(game)
world.map = fakeMap({ 5, 4 })
world.maps = { TEST_MAP = world.map.def }
world.encounters = { fishGroups = fishGroups(chance) }
world.player = { cellX = 5, cellY = 5, facing = "up" }
return world
end
-- ---- chance 0: the group byte fails Random every time, always a nibble ---
-- engine/events/fish.asm:24-30
do
local world = fakeWorld(0)
for rod = 1, 3 do
local outcome = world:rollFishing(({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
eq(outcome, "nibble",
"chance 0 nibbles on " .. ({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
end
end
-- ---- chance 256: the group byte always passes, every rod finds the mon ---
do
local world = fakeWorld(256)
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
local outcome, wild = world:rollFishing(rod)
eq(outcome, "battle", "chance 256 always bites on " .. rod)
check(wild and wild.species == "MAGIKARP",
"and the rod's own list still resolves a species")
end
end
-- ---- no chance field at all: an old cache keeps fishing unconditionally --
do
local world = fakeWorld(nil)
local outcome = world:rollFishing("OLD_ROD")
eq(outcome, "battle",
"a cache with no group chance byte is not gated at all")
end
T.finish("gen2 fishing bite gate bug1368")
@@ -0,0 +1,115 @@
-- Grass encounters must key off the CLOCK (wTimeOfDay), never the palette
-- set a map header pins (wTimeOfDayPal): engine/overworld/wildmons.asm:283
-- reads wTimeOfDay for both the rate (GetMapEncounterRate) and the slot list
-- (ChooseWildEncounter). A PALETTE_DAY tower like Sprout Tower must still
-- roll its night table after dark (#1389, Gastly unobtainable), and a
-- PALETTE_NITE cave must still roll its morning/day table at noon.
-- luajit tests/engine/gen2_grass_encounter_tod_bug1389.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
RATTATA = {
name = "RATTATA", types = { "NORMAL", "NORMAL" },
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
levelMoves = {},
},
GASTLY = {
name = "GASTLY", types = { "GHOST", "POISON" },
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
specialAttack = 100, specialDefense = 35 },
levelMoves = {},
},
},
}
local function fullList(species)
local slots = {}
for i = 1, 7 do slots[i] = { species = species, level = 10 } end
return slots
end
-- data/wild/johto_grass.asm's own shape for a pinned tower: day is common
-- and worthless, night is the whole reason the room exists. A second,
-- separate map stands in for a PALETTE_NITE dungeon (Ilex Forest, Mt Moon):
-- the rates are flipped so the two fixtures cannot agree by accident.
local ENCOUNTERS = {
grass = {
SPROUT_TOWER_2F = {
rates = { MORN = 0, DAY = 0, NITE = 256 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
ILEX_FOREST = {
rates = { MORN = 256, DAY = 256, NITE = 0 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
},
}
local COLL_FLOOR = 0x00
local function fakeWorld(mapId, tod, daytime)
local game = { data = DATA,
save = { party = { { species = "RATTATA", level = 5 } } } }
local world = World.new(game)
world.map = {
id = mapId,
def = { environment = "DUNGEON" },
cellCollision = function() return COLL_FLOOR end,
}
world.maps = { [mapId] = world.map.def }
world.encounters = ENCOUNTERS
world.player = { cellX = 5, cellY = 5, facing = "down" }
-- World:applyPalettes writes both fields every load; a PALETTE_DAY tower
-- pins `daytime` to DAY no matter the hour, while `tod` keeps tracking the
-- clock (src/world/gen2/World.lua:8271-8326).
world.tod = tod
world.daytime = daytime
local battled
world.startBattle = function(_, opts)
battled = opts.wild and opts.wild.species
return true
end
return world, function() return battled end
end
-- ---- PALETTE_DAY tower, at night: the clock says NITE, the pin says DAY --
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "NITE", "DAY")
check(world:tryWildEncounter(), "the tower rolls at night despite the pin")
eq(battled(), "GASTLY",
"the night list wins because the lookup is the clock, not the pin")
end
-- ---- the same tower at actual daytime: the clock and the pin now agree ---
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "DAY", "DAY")
check(not world:tryWildEncounter(),
"DAY's rate is zero, so a daytime step in the tower rolls nothing")
eq(battled(), nil, "and nothing battled")
end
-- ---- a PALETTE_NITE dungeon at actual noon: the pin says NITE, clock DAY --
do
local world, battled = fakeWorld("ILEX_FOREST", "DAY", "NITE")
check(world:tryWildEncounter(),
"a pinned-night map still rolls its day table at the clock's noon")
eq(battled(), "RATTATA",
"the day list wins because the lookup ignores the palette pin")
end
T.finish("gen2 grass encounter tod bug1389")
@@ -0,0 +1,88 @@
-- Gold #DEX AREA page drew no nest markers or landmark name because
-- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of
-- the gen2Landmarks table Nests already resolves through (#1267).
-- engine/pokegear/pokegear.asm:2427
-- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
local Nests = require("src.core.gen2.Nests")
-- one Johto landmark (index 5), one species that nests there
local data = {
gen2Encounters = {
grass = {
ROUTE_30 = { slots = { day = { { species = "RATTATA" } } } },
},
},
gen2Maps = {
ROUTE_30 = { landmark = 5 },
},
gen2Landmarks = {
landmarks = {
LANDMARK_ROUTE_30 = { index = 5, x = 40, y = 60, name = "ROUTE 30" },
},
},
}
-- sanity: Nests.landmark itself resolves the index (never broken, per the
-- verifier) so a failure below is isolated to drawArea's own lookup
eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30",
"Nests.landmark resolves index 5 to the ROUTE 30 record")
-- capture what drawArea actually paints, without needing a real tile sheet
-- or font: fill/blank/current/monName are stubbed on the instance, which
-- Lua resolves before the PokedexMenu metatable's own methods.
local function newSelf()
local texts = {}
local rects = {}
local self = setmetatable({
game = { save = {} },
data = data,
mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet
areaRegion = "johto",
areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase
current = function() return { species = "RATTATA" } end,
monName = function() return "RATTATA" end,
fill = function() end,
blank = function() end,
text = function(_, str, tx, ty)
texts[#texts + 1] = { str = str, tx = tx, ty = ty }
end,
}, { __index = PokedexMenu })
return self, texts, rects
end
local realRect = love.graphics.rectangle
local self, texts, rects
do
self, texts, rects = newSelf()
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
end
self:drawArea()
love.graphics.rectangle = realRect
end
local function hasRect(x, y)
for _, r in ipairs(rects) do
if r.x == x and r.y == y then return true end
end
return false
end
check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2")
local function hasText(str)
for _, t in ipairs(texts) do
if t.str == str then return true end
end
return false
end
check(hasText("ROUTE 30"), "the landmark name is printed on row 16")
T.finish("gen2 pokedex area landmark bug 1267")
@@ -0,0 +1,93 @@
-- GetMapMusic (pokegold home/map.asm:2550), #1385
-- luajit tests/engine/gen2_rocket_map_music_bug1385.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
function Source:getDuration() return 1 end
local made = {}
love.audio = {
newSource = function(file, mode)
made[file] = setmetatable({ file = file, mode = mode }, Source)
return made[file]
end,
}
local World = require("src.world.gen2.World")
local Music = require("src.core.Music")
-- constants/music_constants.asm:100,:109
local MUSIC_MAHOGANY_MART = 100
local RADIO_TOWER_SENTINEL = 0x80 + 61
local order = {}
order[61 + 1] = "Music_GoldenrodCity"
local audio = {
musicOrder = order,
songs = {
Music_RocketHideout = { file = "rocket_hideout.wav" },
Music_CherrygroveCity = { file = "cherrygrove.wav" },
Music_RocketTheme = { file = "rocket_theme.wav" },
Music_GoldenrodCity = { file = "goldenrod.wav" },
},
}
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, true, false),
"Music_RocketHideout",
"MAHOGANY_MART_1F with rockets in the mart plays the hideout theme")
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, false, false),
"Music_CherrygroveCity",
"MAHOGANY_MART_1F after the hideout is cleared plays Cherrygrove")
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, true),
"Music_RocketTheme",
"RADIO_TOWER floors during the takeover play the rocket theme")
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, false),
"Music_GoldenrodCity",
"RADIO_TOWER floors otherwise fall back to the low bits of the byte")
eq(World.mapMusicLabel(audio, 72, true, true), nil,
"a plain song id stays with the mapSongs table")
eq(World.mapMusicLabel(audio, nil, true, true), nil,
"a missing music byte resolves to nothing")
local data = { audio = {
songs = {
Music_RocketHideout = { file = "rocket_hideout.wav" },
Music_Victory = { file = "victory.wav" },
},
mapSongs = {},
} }
local function playing()
for file, src in pairs(made) do
if src.playing then return file end
end
return "(silence)"
end
Music.stop()
Music.playMap(data, "MAHOGANY_MART_1F", false, false, nil,
"Music_RocketHideout")
eq(playing(), "rocket_hideout.wav",
"the resolved song overrides the empty mapSongs table")
Music.play(data, "Music_Victory", nil, { reason = "battle" })
eq(playing(), "victory.wav", "the battle result theme takes over")
Music.restoreMap(data)
eq(playing(), "rocket_hideout.wav",
"restoreMap replays the resolved song, ending the victory loop")
T.finish("gen2_rocket_map_music_bug1385")
+171
View File
@@ -0,0 +1,171 @@
-- engine/battle/move_effects/safeguard.asm:1, engine/battle/effect_commands.asm:6325
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
FIRE = { id = "FIRE", index = 2, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
SAFEGUARD = { id = "SAFEGUARD", name = "SAFEGUARD", power = 0,
type = "NORMAL", accuracy = 100, pp = 25, effect = "EFFECT_SAFEGUARD" },
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
SACRED_FIRE = { id = "SACRED_FIRE", name = "SACRED FIRE", power = 100,
type = "FIRE", accuracy = 95, pp = 5, effect = "EFFECT_SACRED_FIRE",
effectChance = 50 },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local rolls
local function rng(n)
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
return (n or 1) - 1
end
local function newBattle(pmoves, emoves)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = pmoves
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = emoves
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng }), player, wild
end
local function findText(events, sub)
for _, e in ipairs(events or {}) do
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
return true
end
end
return false
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
-- ------------------------------------------------------------- 1388a
-- Safeguard sets the USER's own side, not the target's.
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
T.eq(battle.screens.player.safeguard, 5, "Safeguard sets the CASTER's side for 5 turns")
T.check((battle.screens.enemy.safeguard or 0) == 0,
"and never touches the opposing side")
T.check(findText(battle.events, "covered by a veil"), "the veil line is emitted")
end
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
battle.events = {}
rolls = { 200 } -- force the enemy's AI roll not to fail on its own
battle:useMove(wild, player, "THUNDER_WAVE")
T.check(findText(battle.events, "protected by SAFEGUARD"),
"an incoming status move from the OTHER side is blocked, loudly")
T.eq(player.status, nil, "and the paralysis never lands")
local ev = moveEvent(battle.events)
T.eq(ev and ev.missed, true, "the blocked move is marked missed")
end
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
T.check(findText(battle.events, "But it failed!"),
"using it again while it is already up simply fails")
end
do
-- the player's OWN status move against a safeguarded enemy is blocked too:
-- the effect reads whichever side is being TARGETED, not just "the enemy".
local battle, player, wild = newBattle(
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.screens.enemy.safeguard = 5
battle.events = {}
battle:useMove(player, wild, "THUNDER_WAVE")
T.check(findText(battle.events, "protected by SAFEGUARD"),
"the player's status move is blocked by the enemy's own safeguard")
T.eq(wild.status, nil, "the enemy stays unstatused under its own screen")
end
do
local battle = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.screens.player.safeguard = 1
battle.events = {}
battle:tickScreens()
T.check(findText(battle.events, "SAFEGUARD faded"), "it fades after its five turns")
T.eq(battle.screens.player.safeguard, nil, "and clears off the side entirely")
end
-- ------------------------------------------------------------- 1388b
-- Sacred Fire's burn was never implemented at all.
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
battle.events = {}
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } -- everything rolls low: hit, no
-- crit, and the 50% secondary effect chance all pass
battle:useMove(wild, player, "SACRED_FIRE")
T.eq(player.status, "burn", "Sacred Fire can now burn its target")
end
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
battle.screens.player.safeguard = 5
battle.events = {}
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
battle:useMove(wild, player, "SACRED_FIRE")
T.eq(player.status, nil, "Safeguard blocks the burn a damaging move carries")
T.check(not findText(battle.events, "protected by SAFEGUARD"),
"the secondary block is silent (SafeCheckSafeguard, not CheckSafeguard)")
local ev = moveEvent(battle.events)
T.check(ev and not ev.missed,
"the hit itself still lands and is not marked missed")
end
T.finish("gen2 safeguard bug 1388")
@@ -0,0 +1,47 @@
-- engine/battle_anims/anim_commands.asm:603 BattleAnimCmd_BGP
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local BgEffects = require("src.battle.gen2.BgEffects")
local GbcPalette = require("src.render.GbcPalette")
-- data/moves/animations.asm:4509 BattleAnim_ShadowBall
local SHADOW_BALL = {
{ "2gfx", "BATTLE_ANIM_GFX_EGG", "BATTLE_ANIM_GFX_SMOKE" },
{ "bgp", 0x1b },
{ "sound", 6 * 4 + 2, 0 },
{ "obj", "BATTLE_ANIM_OBJ_SHADOW_BALL", 64, 92, 0x2 },
{ "wait", 32 },
}
do
local runner = AnimRunner.new({
data = { scripts = { SHADOW_BALL = SHADOW_BALL } },
})
runner:start("SHADOW_BALL")
T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL, "identity ramp before the script")
T.check(runner:step(), "the script is still running after frame one")
T.eq(runner.bg.bgp, 0x1b,
"anim_bgp $1b lands in wBGP: the inverted ramp the view must apply")
runner.bg:reset()
T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL,
"BattleAnim_RevertPals puts the identity back")
end
do
T.eq(GbcPalette.BGP_IDENTITY, 0xe4, "dc 3, 2, 1, 0")
local colors = { "c0", "c1", "c2", "c3" }
local out = GbcPalette.remap(colors, 0x1b)
T.eq(out[1], "c3", "$1b is dc 0, 1, 2, 3: colour 0 shows shade 3")
T.eq(out[2], "c2", "colour 1 shows shade 2")
T.eq(out[3], "c1", "colour 2 shows shade 1")
T.eq(out[4], "c0", "colour 3 shows shade 0")
T.check(GbcPalette.remap(colors, 0xe4) == colors,
"the identity byte returns the palette untouched")
end
T.finish("gen2 shadow ball bgp bug 1269")
@@ -0,0 +1,90 @@
-- #1228: `givepoke` with the 3-argument (untrained) form must run
-- GiveANickname_YesNo, the same as a wild catch does.
-- engine/pokemon/move_mon.asm:1632-1645, 1753-1757, 1787
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Vm = require("src.script.gen2.Vm")
local Events = require("src.world.gen2.Events")
-- Elm's `givepoke CYNDAQUIL, 5, BERRY` shape: no trainer operand, so
-- Vm.lua's arg4 fallback reads it as 0 (untrained).
local scripts = {
generation = 2,
["s:give"] = {
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 },
{ op = "end" },
},
}
local mon = { species = 155, level = 5 }
local renamed
local vm = Vm.new(scripts, {}, Events.new(), {
givePoke = function() return mon end,
yesorno = function(onChoose) onChoose(true) end,
showText = function(_, onDone) onDone() end,
specials = {
monName = function(species) return species == 155 and "CYNDAQUIL" or "?" end,
renameMon = function(m, done, opts)
renamed = { mon = m, blank = opts and opts.blank }
done("SPARKY")
end,
},
})
T.check(vm:start("s:give"), "starter script starts")
for _ = 1, 10 do vm:update() end
T.check(not vm:running(), "script finished")
T.eq(mon.nickname, "SPARKY",
"givepoke with trainer=0 runs the nickname prompt and stores the answer")
T.check(renamed ~= nil and renamed.mon == mon,
"renameMon opened on the mon givepoke just handed over")
T.check(renamed.blank == true,
"the keyboard opens blank, same as InitNickname on a fresh catch")
-- The trade-gift form (trainer ~= 0, e.g. GiftSpearowName's 8-byte
-- Route35GoldenrodGate.asm:30 shape) must never open the keyboard.
local mon2 = { species = 155, level = 5 }
local renamed2 = false
local scripts2 = {
generation = 2,
["s:give2"] = {
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 1 },
{ op = "end" },
},
}
local vm2 = Vm.new(scripts2, {}, Events.new(), {
givePoke = function() return mon2 end,
yesorno = function() error("a trainer-labelled gift must never prompt") end,
showText = function(_, onDone) onDone() end,
specials = { renameMon = function() renamed2 = true end },
})
T.check(vm2:start("s:give2"), "trainer-gift script starts")
for _ = 1, 10 do vm2:update() end
T.check(mon2.nickname == nil, "trainer arm leaves the nickname untouched")
T.check(not renamed2, "and never opens the keyboard")
-- A NO answer, and an all-spaces keyboard entry, both leave the species
-- name standing (_InitString's blank test, home/string.asm:6-30).
local mon3 = { species = 155, level = 5 }
local scripts3 = {
generation = 2,
["s:give3"] = {
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 },
{ op = "end" },
},
}
local vm3 = Vm.new(scripts3, {}, Events.new(), {
givePoke = function() return mon3 end,
yesorno = function(onChoose) onChoose(false) end,
showText = function(_, onDone) onDone() end,
specials = {
renameMon = function() error("NO must not open the keyboard") end,
},
})
T.check(vm3:start("s:give3"), "NO-answer script starts")
for _ = 1, 10 do vm3:update() end
T.check(mon3.nickname == nil, "answering NO leaves the nickname unset")
T.finish("gen2_starter_nickname_bug1228")
+94
View File
@@ -0,0 +1,94 @@
-- PKMN LEAGUE (the post-E4 Hall of Fame viewer) never had a screen backing
-- it, so a PC row wired up to open it would have had nowhere to go (#1282).
-- src/ui/LeaguePC.lua is the missing viewer; it resolves through the
-- registry's builtin fallback with no id table edit needed, because every
-- unregistered id falls through to `require("src.ui." .. id)`.
-- engine/menus/league_pc.asm:1, constants/pokemon_data_constants.asm:65 (cap 50)
-- luajit tests/engine/league_pc_bug1282.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Screens = require("src.ui.Screens")
local LeaguePC = require("src.ui.LeaguePC")
local function team(species, level)
return { { species = species, level = level, nickname = nil } }
end
local function newGame(teamCount)
local teams = {}
for i = 1, teamCount do teams[i] = team("RATTATA", i) end
local popped = false
local game = {
data = { pokemon = {}, text = {} },
save = { hallOfFame = teams },
stack = { pop = function() popped = true end },
}
return game, function() return popped end
end
-- the registry needs no LeaguePC id entry: an unregistered id falls
-- through resolve()'s builtin path straight to src/ui/LeaguePC.lua
local factory = Screens.get({ data = {} }, "LeaguePC")
check(factory == LeaguePC, "Screens.get(\"LeaguePC\") resolves to this module")
check(type(factory.new) == "function", "the resolved factory is constructible")
-- 60 recorded teams: HOF_TEAM_CAPACITY (50) means the oldest 10 are gone,
-- so the viewer opens on the OLDEST STILL-RECORDED team, index 11
do
local game = newGame(60)
local pc = LeaguePC.new(game)
eq(pc.teamIndex, 11, "60 teams over a cap of 50 starts at team 11 (60-50+1)")
eq(pc.monIndex, 1, "starts on the first mon of that team")
check(pc:currentMon() ~= nil, "a current mon is resolved")
end
-- A steps through every remaining team (49 more presses, 11 -> 60), then
-- one more A on the last team's only mon closes the whole viewer
do
local game, wasPopped = newGame(60)
local pc = LeaguePC.new(game)
local doneCalled = false
pc.onDone = function() doneCalled = true end
for _ = 1, 49 do
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
end
eq(pc.teamIndex, 60, "49 A-presses walk from team 11 to team 60")
check(not wasPopped(), "the viewer is still open on the last team")
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
check(wasPopped(), "one more A on the last team's last mon closes the viewer")
check(doneCalled, "onDone fires on close")
end
-- B always closes immediately, from any position
do
local game, wasPopped = newGame(3)
local pc = LeaguePC.new(game)
game.input = { wasPressed = function(_, b) return b == "b" end }
pc:update(0)
check(wasPopped(), "B closes the viewer")
end
-- an empty Hall of Fame (no wins recorded yet, or the extreme edge case of
-- a save with the row reachable but no completed run) must not crash: A on
-- a nil current mon closes cleanly instead of indexing into nothing
do
local game, wasPopped = newGame(0)
local pc = LeaguePC.new(game)
eq(pc.teamIndex, 1, "an empty roster clamps teamIndex to 1, not 0 or negative")
check(pc:currentMon() == nil, "there is no current mon")
local ok = pcall(function()
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
end)
check(ok, "A on an empty Hall of Fame does not raise")
check(wasPopped(), "...and closes the viewer instead")
end
T.finish("league pc bug 1282")
@@ -0,0 +1,86 @@
-- The NEW NAME / preset box drew on top of a full letter grid because
-- NamingScreen stayed isOpaque while the preset Menu was up, so the stack's
-- visibleBase never fell through to the screen underneath (#1329).
-- engine/movie/oak_speech/oak_speech2.asm:1
-- luajit tests/engine/naming_screen_opacity_bug1329.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
package.loaded["src.core.Sound"] = { play = function() end }
local StateStack = require("src.core.StateStack")
local NamingScreen = require("src.ui.NamingScreen")
local function newGame()
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local game = { data = {} }
game.stack = stack
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game, stack
end
local game, stack = newGame()
-- stand-in for OakSpeech: opaque, and the state a fixed background lives on
local backgroundDraws = 0
local background = { isOpaque = true, draw = function() backgroundDraws = backgroundDraws + 1 end }
stack:push(background)
local result = { name = nil }
local ns = NamingScreen.new(game,
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result.name = n end })
stack:push(ns) -- StateStack:push calls ns:enter(), which pushes the preset Menu
eq(ns.choosing, true, "the screen marks itself as choosing a preset")
eq(ns.isOpaque, false, "isOpaque is shadowed false while the preset box is up")
local menu = stack:top()
check(menu ~= nil and menu ~= ns, "the preset Menu is on top of the naming screen")
eq(#menu.items, 4, "NEW NAME plus the three presets")
eq(menu.items[1].label, "NEW NAME", "row 1 is NEW NAME")
eq(stack:visibleBase(), 1, "the background (index 1) is visible, not the naming screen")
backgroundDraws = 0
stack:draw()
eq(backgroundDraws, 1, "the background actually got a draw call this frame")
-- picking NEW NAME (row 1) restores the grid's normal opacity
menu.index = 1
menu.game.input.queue.a = true
menu:update(0)
menu.game.input.queue.a = false
check(stack:top() == ns, "the Menu popped itself, the naming screen is back on top")
eq(ns.choosing, nil, "choosing cleared")
eq(rawget(ns, "isOpaque"), nil, "the instance field is cleared, unshadowing the class default")
eq(ns.isOpaque, true, "isOpaque now reads true again, through the class default")
eq(stack:visibleBase(), 2, "the naming screen itself is now the opaque base")
-- a preset pick instead closes the whole naming flow with that name
local game2, stack2 = newGame()
local background2 = { isOpaque = true, draw = function() end }
stack2:push(background2)
local result2 = { name = nil }
local ns2 = NamingScreen.new(game2,
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result2.name = n end })
stack2:push(ns2)
local menu2 = stack2:top()
eq(menu2.items[4].label, "JACK", "row 4 is the third preset")
menu2.index = 4 -- "JACK"
menu2.game.input.queue.a = true
menu2:update(0)
eq(result2.name, "JACK", "selecting a preset pops the whole flow with that name")
eq(#stack2.states, 1, "only the background remains on the stack")
T.finish("naming screen opacity bug 1329")
+96
View File
@@ -0,0 +1,96 @@
-- The walking-NPC animation cadence, which the port ran at half the cart's
-- rate (#1303). UpdateSpriteInWalkingAnimation advances one animation frame
-- every 4 fixed steps regardless of how long the whole cell takes
-- (engine/overworld/movement.asm:301), so a 32-frame NPC cell must show the
-- same two-pulse cadence Player:pose already shows across its own 16.
-- luajit tests/engine/npc_walk_cadence_bug1303.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local NPC = require("src.world.NPC")
local DATA = {
sprites = {
SPRITE_TEST_NPC = { image = "fixture_npc.png", frames = 6, walker = true },
},
}
local function newNpc()
return NPC.new(DATA, "TEST_MAP", {
index = 1, x = 5, y = 5, sprite = "SPRITE_TEST_NPC", range = "ANY_DIR",
movement = "STAY",
})
end
-- ---- the pure cadence: two walk pulses across one 32-frame NPC step ------
do
local npc = newNpc()
npc.moving = true
local phases = {}
for clock = 0, 31 do
npc.animClock = clock
phases[clock] = npc:walkPhase()
end
local risingEdges = 0
for clock = 1, 31 do
if phases[clock] == 1 and phases[clock - 1] == 0 then
risingEdges = risingEdges + 1
end
end
eq(risingEdges, 2, "a 32-frame NPC cell shows two walk pulses, not one")
eq(phases[0], 0, "frame 0 stands")
eq(phases[4], 1, "frame 4 opens the first walk pulse")
eq(phases[11], 1, "frame 11 is still the first pulse")
eq(phases[12], 0, "frame 12 closes it back to standing")
eq(phases[20], 1, "frame 20 opens the second walk pulse")
eq(phases[27], 1, "frame 27 is still the second pulse")
eq(phases[28], 0, "frame 28 closes the cycle back to standing")
end
-- ---- the flip half-cycle: one flip per 16-frame half, not per whole cell -
do
local npc = newNpc()
npc.moving = true
npc.animClock = 0
local _, _, _, _, _, flip0 = npc:pose()
npc.animClock = 15
local _, _, _, _, _, flip15 = npc:pose()
npc.animClock = 16
local _, _, _, _, _, flip16 = npc:pose()
npc.animClock = 31
local _, _, _, _, _, flip31 = npc:pose()
check(not flip0, "the first half of the cell is unflipped")
check(not flip15, "still unflipped just before frame 16")
check(flip16, "frame 16 flips, matching Player:pose's own half-cycle")
check(flip31, "and stays flipped through the second half")
end
-- ---- standing keeps the externally-written flip (Pikachu idle contract) --
do
local npc = newNpc()
npc.moving = false
npc.stepFlip = true
local _, _, _, _, phase, flip = npc:pose()
eq(phase, 0, "a standing NPC has no walk phase")
check(flip, "and pose() reads stepFlip back exactly, not the moving formula")
end
-- ---- the wiring: NPC:update advances animClock alongside progress -------
do
local npc = newNpc()
npc.facing = "down"
npc.moving = true
npc.targetX, npc.targetY = npc.cellX, npc.cellY + 1
local map = {}
for i = 1, 16 do
npc:update(map, {})
eq(npc.animClock, i, "animClock ticks once per update, step " .. i)
end
check(npc.moving, "still mid-cell at 16 of the 32 ticks")
end
T.finish("npc walk cadence bug1303")
+1 -1
View File
@@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil,
pushed[1].onDone()
T.eq(#pushed, 1, "the farewell waits for the bow")
T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows")
T.eq(nurse.frameOverride, 1, "image index $14: the nurse bows")
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
local hold = fakeSelf.emote or {}
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
@@ -0,0 +1,69 @@
-- #1279: the rival must face DOWN at his own table cell before the taunt,
-- same as SetSpriteFacingDirectionAndDelay does before DisplayTextID -- not
-- just the player turning to face him.
-- scripts/OaksLab.asm:347-351 (Red/Blue); pokeyellow scripts/OaksLab.asm:311-315
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local function fakeOw(rival)
return {
npcByIndex = function(_, i) return i == 1 and rival or nil end,
map = {
inBounds = function() return true end,
isWalkableCell = function() return true end,
},
runner = { run = function(_, rows, opts) return rows, opts end },
}
end
-- capture the rows the runner was handed, since `run` is only a stub
local function captureRun(ow)
local captured
ow.runner.run = function(_, rows, opts) captured = rows return true end
return function() return captured end
end
local function baseGame()
return {
save = {
flags = {
EVENT_GOT_STARTER = true,
EVENT_BATTLED_RIVAL_IN_OAKS_LAB = false,
EVENT_CHOSE_BULBASAUR = true,
},
},
}
end
-- Red/Blue
do
local M = assert(loadfile("data/scripts/oaks_lab.lua"))()
local rival = { id = "rival" }
local ow = fakeOw(rival)
local getRows = captureRun(ow)
local ok = M.onStep(baseGame(), ow, 4, 6)
T.check(ok == true, "onStep claims the rival-challenge step")
local rows = getRows()
T.check(rows ~= nil, "the challenge rows reached the runner")
T.same(rows[1], { "face_object", 1, "down" },
"the FIRST row faces the rival down at his table (#1279)")
T.eq(rows[2][1], "face_player_dir", "the player-facing row still follows it")
T.eq(rows[2][2], "up", "and still turns the player up, unchanged")
end
-- Yellow
do
local M = assert(loadfile("data/scripts/oaks_lab_yellow.lua"))()
local rival = { id = "rival" }
local ow = fakeOw(rival)
local getRows = captureRun(ow)
local ok = M.onStep(baseGame(), ow, 4, 6)
T.check(ok == true, "yellow onStep claims the rival-challenge step")
local rows = getRows()
T.check(rows ~= nil, "the yellow challenge rows reached the runner")
T.same(rows[1], { "face_object", 1, "down" },
"yellow's first row faces the rival (object 1) down too (#1279)")
end
T.finish("oaks_lab_rival_faces_down_bug1279")
@@ -0,0 +1,73 @@
-- #1391: the Pewter museum guide's lockstep walk, exercised from all four
-- trigger cells around him, the way PewterGuys (engine/events/
-- pewter_guys.asm:1-49) builds the preamble and PewterCitySuperNerd1Shows
-- PlayerMuseumScript (scripts/PewterCity.asm:47-113) walks it out.
--
-- `museumEscort` had zero consumers and zero test coverage before this: a
-- future edit to the RLE tables or the preamble map could silently break
-- the walk and nothing would fail.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local M = assert(loadfile("data/scripts/flavor/pewter_city.lua"))()
local esc = M.PEWTER_CITY.museumEscort
T.check(type(esc) == "table", "museumEscort is exported")
T.check(type(esc.plan) == "function", "museumEscort.plan is exported")
T.check(type(esc.guySteps) == "table", "museumEscort.guySteps is exported")
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204):
-- UP 6, LEFT 13, UP 3, LEFT 1 -- 23 steps by content, not just by count.
local wantGuySteps = {}
for _ = 1, 6 do wantGuySteps[#wantGuySteps + 1] = "up" end
for _ = 1, 13 do wantGuySteps[#wantGuySteps + 1] = "left" end
for _ = 1, 3 do wantGuySteps[#wantGuySteps + 1] = "up" end
wantGuySteps[#wantGuySteps + 1] = "left"
T.same(esc.guySteps, wantGuySteps,
"guySteps is exactly UP x6, LEFT x13, UP x3, LEFT x1")
local function apply(x, y, dirs)
for _, d in ipairs(dirs) do
if d == "up" then y = y - 1
elseif d == "down" then y = y + 1
elseif d == "left" then x = x - 1
elseif d == "right" then x = x + 1 end
end
return x, y
end
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75): the four
-- cells adjacent to the guy's spawn (27,17), each with its own preamble.
local triggers = { { 27, 18 }, { 27, 16 }, { 26, 17 }, { 28, 17 } }
for _, c in ipairs(triggers) do
local plan = esc.plan(c[1], c[2])
T.check(plan ~= nil,
("(%d,%d) is a real trigger cell and must produce a plan"):format(c[1], c[2]))
T.eq(#plan.steps, 23,
("(%d,%d): 23-step walk, same length regardless of approach side")
:format(c[1], c[2]))
T.eq(plan.guyHeadStart, 0,
("(%d,%d): no NO_INPUT head padding in the museum preamble")
:format(c[1], c[2]))
local px, py = apply(c[1], c[2], plan.steps)
T.check(px == 14 and py == 8,
("(%d,%d): player's walk ends at (14,8), by the museum door")
:format(c[1], c[2]))
local guySub = {}
for i = plan.guyHeadStart + 1, plan.guyHeadStart + #plan.steps do
guySub[#guySub + 1] = esc.guySteps[i]
end
local gx, gy = apply(27, 17, guySub)
T.check(gx == 13 and gy == 8,
("(%d,%d): guy's walk ends at (13,8), beside the player")
:format(c[1], c[2]))
end
-- Any cell that is not one of the four adjacent trigger cells must not
-- start the escort at all.
T.check(esc.plan(10, 10) == nil, "a non-adjacent cell returns no plan")
T.check(esc.plan(27, 17) == nil, "the guy's own cell is not a trigger either")
T.finish("pewter_museum_escort_bug1391")
@@ -0,0 +1,95 @@
-- The Pokedex entry page laid out its fields at the port's own invented
-- coordinates instead of the cart's, ran the whole description together on
-- one page with no trailing full stop, and never let A/B advance past page
-- one (#1341).
-- engine/menus/pokedex.asm:399, home/text.asm:245 (<PAGE>), :204 (<DEXEND>)
-- luajit tests/engine/pokedex_entry_layout_bug1341.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- stub Font: DexEntryMenu draws through Font.draw/Font.drawCode only, and
-- the real Font needs loaded page images this suite has no reason to touch.
local calls = {}
package.loaded["src.render.Font"] = {
draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end,
}
local DexEntryMenu = require("src.ui.DexEntryMenu")
local function hasText(text, x, y)
for _, c in ipairs(calls) do
if c.text == text and c.x == x and c.y == y then return true end
end
return false
end
local function hasCode(code, x, y)
for _, c in ipairs(calls) do
if c.code == code and c.x == x and c.y == y then return true end
end
return false
end
local game = {
data = {
pokemon = {
BULBASAUR = {
id = "BULBASAUR",
name = "BULBASAUR",
dex = 1,
dexEntry = {
kind = "SEED POKEMON",
heightFt = 2, heightIn = 4, weight = 69,
text = "_BulbasaurDexEntry",
},
},
},
text = {
-- \f is the extractor's <PAGE> break; two pages, three lines each
_BulbasaurDexEntry = "A strange seed was\nplanted on its\nback at birth\f"
.. "The plant sprouts\nand grows with\nthis POKEMON",
},
constants = { dexDigits = 3 },
},
save = { pokedex = { owned = { BULBASAUR = true } } },
}
local ns = DexEntryMenu.new(game, "BULBASAUR")
eq(ns.pageCount, 2, "the entry has two <PAGE>-separated pages")
eq(ns.page, 1, "starts on page 1")
ns:draw()
check(hasText("BULBASAUR", 72, 16), "name at (72,16), not the port's old (72,8)")
check(hasText("SEED POKEMON", 72, 32), "kind at (72,32)")
check(hasText("No.001", 16, 64), "dex number under the pic, at (16,64)")
check(hasText("HT 2\226\128\178" .. "04\226\128\179", 72, 48), "HT at (72,48)")
check(hasText("WT 6.9lb", 72, 64), "WT at (72,64)")
check(hasText("A strange seed was", 8, 88), "page 1 line 1 at y=88 (row 11)")
check(hasText("planted on its", 8, 104), "page 1 line 2 at y=104")
check(hasText("back at birth", 8, 120), "page 1 line 3, unmodified: not the last page")
check(hasCode(0xEE, 144, 128), "the more-below arrow shows on a non-final page")
-- A on a non-final page turns it, it does not close the screen
local popped, doneCalled = false, false
game.stack = { pop = function() popped = true end }
game.input = { wasPressed = function(_, b) return b == "a" end }
ns.onDone = function() doneCalled = true end
ns:update(0)
eq(ns.page, 2, "A advances to page 2 instead of closing")
check(not popped, "the screen did not pop on a non-final page")
calls = {}
ns:draw()
check(hasText("this POKEMON.", 8, 120), "the final page's last line gets the trailing full stop")
check(not hasCode(0xEE, 144, 128), "no more-below arrow on the last page")
-- A on the final page closes the screen
ns:update(0)
check(popped, "A on the final page pops the screen")
check(doneCalled, "onDone fires once the last page closes")
T.finish("pokedex entry layout bug 1341")
@@ -0,0 +1,86 @@
-- The TOWN MAP drew a blinking black square for the player instead of their
-- walk sprite, because the marker was a placeholder rectangle instead of the
-- OAM sprite the cart draws (#1344).
-- engine/items/town_map.asm:347
-- luajit tests/engine/town_map_player_sprite_bug1344.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local TownMap = require("src.ui.TownMap")
local function newGame()
return {
data = {
field = {
townMap = { PALLET_TOWN = { x = 1, y = 2, name = "PALLET TOWN" } },
playerSprites = { walk = "SPRITE_RED" },
-- no townMap.background: forces the stale-asset fallback draw path,
-- which is the one #1344's repro screenshot came from
},
sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } },
maps = {},
},
save = {},
overworld = { map = { id = "PALLET_TOWN" } },
}
end
local game = newGame()
local tm = TownMap.new(game, {})
eq(tm.mode, "grid", "one located entry puts the screen in grid mode")
check(tm.bg == nil, "no background.map means the stale-asset fallback draws")
check(tm.playerLoc ~= nil, "the player's map resolved to a location")
check(tm.playerSheet ~= nil,
"TownMap.new resolved the walk sheet (the fix this test guards)")
-- capture what :draw() actually paints, without a real screen
local draws, rects = {}, {}
local realDraw, realRect = love.graphics.draw, love.graphics.rectangle
love.graphics.draw = function(img, quadOrX, x, y)
draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y }
end
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
end
tm.blink = 0 -- (0 < 20): the player marker is in its "on" blink phase
tm:draw()
love.graphics.draw = realDraw
love.graphics.rectangle = realRect
local function drewSprite()
for _, d in ipairs(draws) do
if d.img == tm.playerSheet and d.quad == tm.playerQuad then
return d
end
end
return nil
end
local spriteDraw = drewSprite()
check(spriteDraw ~= nil, "the player's walk sprite was drawn, not a placeholder")
if spriteDraw then
-- engine/items/town_map.asm:449 WriteTownMapSpriteOAM's -4,-3 carry quirk
eq(spriteDraw.x, tm.playerLoc.x * 8 - 4, "sprite x is markerXY - 4")
eq(spriteDraw.y, tm.playerLoc.y * 8 - 3, "sprite y is markerXY - 3")
end
local function blackDotAtPlayer()
for _, r in ipairs(rects) do
if r.mode == "fill" and r.w == 4 and r.h == 4
and r.x == tm.playerLoc.x * 8 + 2 and r.y == tm.playerLoc.y * 8 + 2 then
return true
end
end
return false
end
check(not blackDotAtPlayer(),
"the old 4x4 placeholder square is not drawn once a sprite is available")
T.finish("town map player sprite bug 1344")
+116
View File
@@ -0,0 +1,116 @@
-- The post-battle grace period, which the port never re-armed after an
-- UNSCRIPTED wild battle (#1229): World:tryWildEncounter's own
-- self:startBattle({ wild = wild }) carries no onDone, so no VM resume ever
-- ran the script's own `reloadmapafterbattle` (which is where the cart's
-- SetUpFiveStepWildEncounterCooldown lives, engine/overworld/events.asm:1158-
-- 1162), and the counter sat at zero for the very next step. What is
-- asserted here is the real World:startBattle -> onDone chain, same as
-- tests/gen2_canlose_test.lua exercises for the loss arm.
--
-- GOLD_CACHE=".../gold" luajit tests/gen2_wild_cooldown_bug1229_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 wild cooldown")
local check, eq = S.check, S.eq
local World = require("src.world.gen2.World")
local Mon = require("src.battle.gen2.Mon")
local Screens = require("src.ui.Screens")
local cache = os.getenv("GOLD_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
local probe = io.open(cache .. "/data/generated/pokemon.lua", "r")
if not probe then
check(true, "gold cache absent (SKIP)")
S.finish()
return
end
probe:close()
local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end
local pokemon = loadLua("data/generated/pokemon.lua")
local moves = loadLua("data/generated/moves.lua")
-- The battle screen as a registry fake, same shape gen2_canlose_test uses:
-- the real World:startBattle pushes it and parks its onDone for the test.
-- Screens.get caches by id process-wide, so a suite dofile'd earlier in
-- tests/run_tests.lua can leave a stale "Gen2BattleState" behind.
Screens.invalidate()
local battleDone
local registry = {
Gen2BattleState = { new = function(_g, opts)
battleDone = opts.onDone
return { screenId = "Gen2BattleState" }
end },
}
local function makeStack()
local stack = { items = {} }
function stack:push(inst) self.items[#self.items + 1] = inst end
function stack:pop()
local top = self.items[#self.items]
self.items[#self.items] = nil
return top
end
return stack
end
local function makeWorld()
battleDone = nil
local game = {
data = { audio = {}, screens = registry, pokemon = pokemon, moves = moves },
-- No roamer state and no map at all: World:pushBattleTransition needs
-- self.map to push a wipe and finds none, so pushBattle() runs straight
-- away, exactly like a headless battle would. World:restoreMapMusic and
-- World:battleMusicContext both already guard a nil self.map.
save = { player = { name = "GOLD", money = 3000 }, party = {} },
stack = makeStack(),
}
local mon = Mon.new(game.data, "CYNDAQUIL", 5)
check(mon ~= nil, "the cache can build the player's starter")
game.save.party[1] = mon
local w = World.new(game)
return w, game
end
-- ---- the unscripted path itself: World:tryWildEncounter's own call -------
do
local w, game = makeWorld()
local wild = Mon.new(game.data, "MAGIKARP", 10)
check(wild ~= nil, "the cache can build the wild mon")
-- A grace period already partway spent, so a re-arm is the only way to
-- land back on 5.
w.wildCooldown = 2
check(w:startBattle({ wild = wild }),
"the unscripted wild path starts a battle")
check(battleDone ~= nil, "the battle screen is up")
battleDone("win")
eq(w.wildCooldown, 5,
"reloadmapafterbattle's SetUpFiveStepWildEncounterCooldown re-arms " ..
"the counter (events.asm:1158-1162), even with no script waiting")
end
-- ---- the counter itself, once re-armed: four blocked steps then a roll --
do
local w = makeWorld()
w.wildCooldown = 5
for step = 1, 4 do
check(w:wildCooldownStep(),
"step " .. step .. " of the grace period is still blocked")
end
check(not w:wildCooldownStep(), "the fifth step may roll")
end
-- ---- a battle NOTHING started re-arms nothing: only startBattle's onDone -
do
local w = makeWorld()
w.wildCooldown = 0
check(w.wildCooldown == 0, "a fresh world never re-arms on its own")
end
S.finish()
+5 -13
View File
@@ -183,32 +183,24 @@ do
end
end
check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step")
-- 7 is missing because that is the block the player is stood on
check(dirsEqual(wake, { 8, 6, 5, 4, 3, 2, 1 }),
check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }),
"water closes in astern, stern column first")
-- EraseSSAnne leaves the player's own block alone ("south of the player
-- and won't be redrawn"), so he never stands on water on the way out
local pbx, pby = 7, 1
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
check(not (r[2] == pbx and r[3] == pby),
"the block under the player is never rewritten")
check(r[2] >= 1 and r[2] <= DOCK_HULL.x1,
"the slide stays inside the dock's water, off the pier column 0")
end
-- she has to end up gone: every hull block bar the player's is water by
-- the last edit that touches it
-- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship,
-- the gangway block under the player included (#1211)
local final = {}
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
final[r[2] .. "," .. r[3]] = r[4]
end
for bx = DOCK_HULL.x0, DOCK_HULL.x1 do
for by = DOCK_HULL.y0, DOCK_HULL.y1 do
if not (bx == pbx and by == pby) then
check(WATER[final[bx .. "," .. by]],
("hull block (%d,%d) ends as open water"):format(bx, by))
end
check(WATER[final[bx .. "," .. by]],
("hull block (%d,%d) ends as open water"):format(bx, by))
end
end
+4 -3
View File
@@ -178,8 +178,8 @@ do
end
check(sawSurf, "departure plays Music_Surfing")
check(ow._queued ~= nil, "departure queues the sail-away script")
-- #360: the surf override must NOT ride into Vermilion City, she has to
-- sail west block by block, and the block under the player stays dry
-- #360: the surf override must NOT ride into Vermilion City, and she
-- sails west block by block
local kept, horns, slid, underPlayer = false, 0, 0, false
for _, row in ipairs(ow._queued or {}) do
if row[1] == "play_music" and row[3] and row[3].keep then kept = true end
@@ -194,7 +194,8 @@ do
eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp")
eq(horns, 2, "the horn blows before and after she sails")
check(slid > 8, "she sails west block by block instead of vanishing")
eq(underPlayer, false, "the block under the player is never watered over")
-- scripts/VermilionDock.asm:182-203 (#1211)
eq(underPlayer, true, "the gangway block under the player is erased too")
end
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
+4 -1
View File
@@ -49,7 +49,10 @@ local dex = DexEntryMenu.new(game, "PIKACHU")
check(dex.spriteTrueColor == true,
"Pokedex keeps a Pokemon sprite's trueColor flag")
local dexRects = uiRects(function() dex:draw() end)
local dx, dy = 8, math.max(0, 60 - dex.sprite:getHeight())
-- engine/menus/pokedex.asm:503: the pic sits in the 7x7 window at (8,8)
local dw, dh = dex.sprite:getDimensions()
local dx = 8 + math.floor((8 - dw / 8) / 2) * 8
local dy = 8 + (7 - dh / 8) * 8
check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
and dexRects[1].w == dex.sprite:getWidth()
and dexRects[1].h == dex.sprite:getHeight(),
+1
View File
@@ -3559,6 +3559,7 @@ runSuites({
"tests/gen2_hof_continue_test.lua",
"tests/gen2_pokecenter_stairs_test.lua",
"tests/gen2_canlose_test.lua",
"tests/gen2_wild_cooldown_bug1229_test.lua",
"tests/gen2_pc_screens_test.lua",
"tests/gen2_badge_boosts_test.lua",
"tests/gen2_held_items_test.lua",