so many bugs i cannot even breathe

This commit is contained in:
bryanthaboi
2026-07-30 07:41:41 -04:00
parent e63fa70700
commit 0f581e2f69
78 changed files with 7729 additions and 523 deletions
+62
View File
@@ -11,8 +11,70 @@
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
-- older saves (flag set before the port hid the ball) by hiding the
-- leftover ball on the next interaction.
--
-- The room's two readables (data/events/hidden_events.asm,
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so no
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
-- the predef id in the facing byte, so neither tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
-- extractor never collects it and the pamphlet's text is inlined.
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
.. "There are 50 TMs\nin all.\f"
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
.. "SILPH CO."
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
-- a heading prints its LinkCableInfoText and returns to the menu, B or
-- STOP READING closes.
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
local function linkCableHelp(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
end
function askHeading()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, label in ipairs(LINK_HEADINGS) do
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game,
text["_LinkCableInfoText" .. i] or label, askHeading))
end }
end
items[#items + 1] = { label = "STOP READING" }
game.stack:push(TextBox.new(game,
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
askHeading))
end
return {
onInteract = function(game, ow, fx, fy)
if fy == 0 and (fx == 3 or fx == 4) then
linkCableHelp(game)
return true
end
if fx == 3 and fy == 4 then
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
return true
end
return false
end,
talk = {
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
+74 -16
View File
@@ -666,6 +666,48 @@ M.WARDENS_HOUSE = {
-- generic OPP_GIOVANNI#2 battle)
-- -------------------------------------------------------------------
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
-- theirs. Names and order are data/maps/toggleable_objects.asm's
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
local SILPH_ROCKET_OBJECTS = {
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
"SILPHCO4F_ROCKET2" } },
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
"SILPHCO6F_ROCKET2" } },
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
"SILPHCO8F_ROCKET2" } },
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
"SILPHCO9F_ROCKET2" } },
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
"SILPHCO11F_ROCKET2" } },
}
-- hide_object writes save.objectToggles, so the single pass at the win
-- covers floors the player is not standing on; onlyMap is the per-floor
-- repair path below.
local function silphRocketsLeave(game, ow, onlyMap)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
if not onlyMap or onlyMap == floor[1] then
for _, name in ipairs(floor[2]) do
Commands.hide_object(ctx, floor[1], name)
end
end
end
end
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
@@ -692,13 +734,11 @@ M.SILPH_CO_11F = {
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
silphRocketsLeave(game, ow)
end
end)
end)
@@ -706,28 +746,46 @@ M.SILPH_CO_11F = {
end,
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
silphRocketsLeave(game, ow)
end
end,
talk = {
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
-- the teleport pads reach him without passing Giovanni's trigger, and
-- afterwards he describes the ball forever. The old rows fell off the
-- end of the script on both branches, so a second talk was silence
-- (#392).
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_false", 10 }, -- 3
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
{ "jump_if_true", 10 }, -- 5
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
-- give-then-print like scripts/SilphCo11F.asm
{ "give_item", "MASTER_BALL", 1, false }, -- 7
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
{ "jump", 11 }, -- 10
{ "give_item", "MASTER_BALL", 1, false }, -- 5
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
{ "jump", "end" }, -- 8
{ "show_text",
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
},
},
}
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
-- Giovanni before the hide list existed (same shape as
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
local mapId = floor[1]
if mapId ~= "SILPH_CO_11F" then
M[mapId] = M[mapId] or {}
M[mapId].onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
silphRocketsLeave(game, ow, mapId)
end
end
end
end
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
+103 -112
View File
@@ -182,17 +182,16 @@ M.POKEMON_TOWER_6F = {
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
-- the menu waits for the player to face the panel and press A (#395).
-- Map entry only stores the car's exit warps (CeladonMartElevator
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
-- After the ride the original does NOT jump-cut to the floor: choosing a
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
-- the chosen floor's exit warp, then the player walks out of the car onto
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
-- that here: rewrite the car's exit warps, then drive a short scripted
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
-- the player walks out of the car onto that warp themselves.
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
@@ -205,46 +204,6 @@ local function elevatorSetExit(ow, floor)
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
for _, w in ipairs(m.def.warps) do
if w.x == p.cellX and w.y == p.cellY then door = w break end
end
if not door then
local best
for _, w in ipairs(m.def.warps) do
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
if not best or d < best then best, door = d, w end
end
end
-- the car interior sits on the door's walkable side; "out" is the
-- doorway direction (the map edge for Silph/Celadon, the top doorway
-- for the Rocket car). Fixed direction order keeps the step
-- deterministic when a door tile has several walkable neighbours
-- (Silph/Celadon step up into the car, the Rocket car steps down).
local into
for _, dir in ipairs(WALK_ORDER) do
local v = WALK_DIRVEC[dir]
local nx, ny = door.x + v[1], door.y + v[2]
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
end
into = into or "up"
local out = WALK_OPP[into]
local function leave()
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
end
-- step one tile into the car, then walk back through the doorway onto
-- the exit tile and take the warp: a visible walk-out on valid tiles
-- for either door orientation, instead of a jump cut
ow:scriptMove(p, into, 1, function()
ow:scriptMove(p, out, 1, leave)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
@@ -272,9 +231,9 @@ local function elevatorFloors(elevatorMapId, game)
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- Seed a walk-out destination on entry (before the panel is ever
-- read): entry floor when known, else the first listed floor (1F).
-- Choosing a floor rewrites it again; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
@@ -288,68 +247,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
-- script, never from map entry (#395)
local function panel(game, ow, npc, done)
done = done or function() end
local floors = elevatorFloors(elevatorMapId, game)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line and shows no floor menu
-- (scripts/RocketHideoutElevator.asm).
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
return
end
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
end
local ListMenu = require("src.ui.ListMenu")
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
onChoose = function(item, list)
list:close()
-- the whole ShakeElevator ride runs in place -- music stop, 100
-- collision-thud scroll bounces, the PA chime -- and only then
-- does .UpdateWarp's rewrite land, with the player still stood
-- at the panel: they walk out to the car door themselves
local ElevatorShake = require("src.world.ElevatorShake")
game.stack:push(ElevatorShake.new(game, ow, {
preFrames = preFrames,
onDone = function()
elevatorSetExit(ow, item.value)
done()
end,
}))
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded on entry)
done()
end,
}))
end
return {
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) -- #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
end
local ListMenu = require("src.ui.ListMenu")
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
onChoose = function(item, list)
list:close()
-- the map-entry Transition (startWarpTo) calls onEnter from
-- its OWN midpoint callback, so this menu was pushed on top
-- of that still-active Transition; only the top state
-- updates, so it froze mid-fade instead of finishing. Left
-- alone it would resurface after the ride and play a stray
-- fade at the wrong time -- pop it now, before it can happen.
local Transition = require("src.render.Transition")
if getmetatable(game.stack:top()) == Transition then
game.stack:pop()
end
-- the whole ShakeElevator ride runs in place -- music stop,
-- 100 collision-thud scroll bounces, the PA chime -- and only
-- then does the player walk out of the car onto the chosen
-- floor (elevatorWalkOut rewrites the car's exit warps first)
local ElevatorShake = require("src.world.ElevatorShake")
game.stack:push(ElevatorShake.new(game, ow, {
preFrames = preFrames,
onDone = function()
elevatorWalkOut(ow, item.value)
end,
}))
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
end,
}))
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
end,
talk = { [panelText] = panel },
}
end
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
"TEXT_SILPHCOELEVATOR_ELEVATOR")
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
"TEXT_CELADONMARTELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
"TEXT_ROCKETHIDEOUTELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
@@ -695,6 +654,16 @@ local DOCK_SHIP_BLOCKS = {
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
-- and the open-water ids of the rows she sits in
local DOCK_SHIP_COLUMNS = {
{ bx = 5, top = 4, bottom = 8 },
{ bx = 6, top = 5, bottom = 9 },
{ bx = 7, top = 6, bottom = 10 },
{ bx = 8, top = 7, bottom = 11 },
}
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
@@ -729,17 +698,39 @@ 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)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
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", 30 }
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
-- .shift_columns_up slides her tile columns west behind a mid-frame
-- rSCX split; with no split scroll here she sails one block per beat
-- and the water closes in astern (#360)
for step = 1, 8 do
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
setBlock(col.bx - step, 1, col.top)
setBlock(col.bx - step, 2, col.bottom)
end
setBlock(9 - step, 1, DOCK_WATER_TOP)
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
rows[#rows + 1] = { "wait", 20 }
end
-- the second horn as she clears the dock, then EraseSSAnne's 120
-- frames before the walk out
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
-- cutscene, and VERMILION_CITY's own theme has to take over as the
-- player crosses in (EnterMap's PlayDefaultMusic)
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
+20 -3
View File
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
talk = {
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
-- the label carries no leading underscore: pokered keeps this one in
-- the script bank, not the far-text bank (#393)
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
@@ -836,6 +839,19 @@ M.SILPH_CO_7F = {
end,
}
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
-- not on where the rival stopped: from (37,8) the rival stands below him on
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
-- from (36,8) he stands above him on (36,7) and takes
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
-- into the same four DOWNs, so five downs in all (#360).
local function ssAnne2FRivalExitDirs(onLeft)
if onLeft then
return { "right", "down", "down", "down", "down", "down" }
end
return { "down", "down", "down", "down" }
end
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
M.SS_ANNE_2F = {
onStep = function(game, ow, x, y)
@@ -848,11 +864,12 @@ M.SS_ANNE_2F = {
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "move_npc_to", 2, 36, 4 }, -- 9
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
}, onLeft and "up" or "left")
end,
}
+17
View File
@@ -424,6 +424,16 @@ function love.quit()
pcall(function()
require("src.core.DiscordPresence").shutdown()
end)
-- LOVE waits for every live love.thread before the process exits, and both
-- background workers idle in a loop that only a "quit" command breaks, so
-- without this the process outlived the window and the next launch re-entered
-- the dead one instead of starting fresh (#339)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.update.Check"] then
pcall(package.loaded["src.update.Check"].shutdown)
end
end
function love.filedropped(file)
@@ -461,6 +471,13 @@ function love.run()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
-- Android keeps the process and its task alive after LOVE's own
-- teardown, so the relaunched task re-enters an activity whose
-- native main already returned; end the process outright once the
-- love.quit hook has run (#339)
if love.system and love.system.getOS() == "Android" then
os.exit(a or 0)
end
return a or 0
end
end
+126 -40
View File
@@ -740,10 +740,17 @@ function BattleState:buildScreen(id, ...)
end
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
-- the queue holds until every battler's displayed HP catches up
function BattleState:drainNext()
-- the queue holds until every battler's displayed HP catches up.
-- `stopAt` pins how far that battler's bar may drain on this row. A
-- multi-hit move takes every strike off the model while the turn is still
-- being queued, so an unpinned row would drain straight to the
-- post-last-hit HP and the later strikes would animate nothing (#394);
-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the
-- wNumAttacksLeft loop (engine/battle/core.asm:4727).
function BattleState:drainNext(battler, stopAt)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { drain = true })
table.insert(self.queue, self.nextInsert,
{ drain = true, battler = battler, stopAt = stopAt })
end
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
@@ -752,14 +759,22 @@ end
function BattleState:stepHPDrain()
local busy = false
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP and b.shownHP ~= b.mon.hp then
local step = math.max(1, b.mon.stats.hp) / 96
if b.shownHP > b.mon.hp then
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
else
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
if b and b.shownHP then
-- drainFloor is the stop the running row carries (see drainNext)
local goal = b.mon.hp
if b.drainFloor and b.drainFloor > goal
and b.shownHP >= b.drainFloor then
goal = b.drainFloor
end
if b.shownHP ~= goal then
local step = math.max(1, b.mon.stats.hp) / 96
if b.shownHP > goal then
b.shownHP = math.max(goal, b.shownHP - step)
else
b.shownHP = math.min(goal, b.shownHP + step)
end
busy = busy or b.shownHP ~= goal
end
busy = busy or b.shownHP ~= b.mon.hp
end
end
return busy
@@ -837,6 +852,8 @@ function BattleState:updateQueue()
if self.draining then
if self:stepHPDrain() then return true end
self.draining = nil
if self.player then self.player.drainFloor = nil end
if self.enemy then self.enemy.drainFloor = nil end
end
-- a move animation holds the queue until it finishes; its screen
-- effects (SE_*) and per-row sounds route into the fx layer as they
@@ -875,6 +892,7 @@ function BattleState:updateQueue()
end
if item.drain then
self.draining = true
if item.battler then item.battler.drainFloor = item.stopAt end
return true
end
if item.wait then
@@ -1380,6 +1398,7 @@ function BattleState:update(dt)
for _, b in ipairs({ self.player, self.enemy }) do
if b then
if b.shownHP then b.shownHP = b.mon.hp end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
end
@@ -2154,6 +2173,31 @@ local function startPicKind(pf, kind)
pf.hidden = nil
end
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window
-- jumps right by b for 5 frames then home for 4, b counting down to 1.
-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for
-- the light one.
local function fastShakeProg(b)
local prog = {}
for i = b, 1, -1 do
prog[#prog + 1] = { dx = i, frames = 5 }
prog[#prog + 1] = { dx = 0, frames = 4 }
end
return prog
end
-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526):
-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times
-- over. Silent -- this is the non-damaging move's feedback.
local function slowShakeProg(b, c)
local prog = {}
for _ = 1, c do
for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end
for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end
end
return prog
end
-- Route one AnimPlayer event into the fx layer. Frame counts and
-- amplitudes are the routines' own (engine/battle/animations.asm;
-- shakes: engine/gfx/screen_effects.asm).
@@ -2195,14 +2239,7 @@ function BattleState:applyAnimEffect(ev)
-- ---------------------------------------------- screen shakes
elseif e == "SE_SHAKE_SCREEN" then
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
-- for 5 frames then home for 4, b counting down 8..1
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dx = b, frames = 5 }
prog[#prog + 1] = { dx = 0, frames = 4 }
end
fx.shakeProg = prog
fx.shakeProg = fastShakeProg(8)
elseif e == "SE_ROCK_SLIDE_SHAKE" then
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
@@ -2299,34 +2336,77 @@ function BattleState:applyAnimEffect(ev)
-- battler.substituteHP is set (MoveEffects raises it with the move)
end
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
-- engine/battle/animations.asm:475): the player's damaging moves blink
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
-- drops by b for 3 frames then home for 3, b counting down) -- the
-- player's pic never blinks. Damage sound with either. A hold keeps
-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
-- hit.animType is wAnimationType, 1..6:
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
-- 2 enemy damaging, added effect fast horizontal shake, b=8
-- 3 enemy non-damaging slow horizontal shake, b=6, c=2
-- 4 player damaging, no added effect BlinkEnemyMonSprite
-- 5 player damaging, added effect fast horizontal shake, b=2
-- 6 player non-damaging slow horizontal shake, b=3, c=2
-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound,
-- which is the damage sound hit.sfx already carries. Only 1 and 4 were
-- implemented, so every move with an added effect blinked (or shook
-- vertically) instead of shaking sideways and every status move showed
-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps
-- the queue still until the effect finishes.
function BattleState:applyHitFx(hit)
if hit.blink then
self.fx = self.fx or {}
if hit.blink.isPlayer then
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dy = b, frames = 3 }
prog[#prog + 1] = { dy = 0, frames = 3 }
end
self.fx.shakeProg = prog
self.waitFrames = 48 -- the predef blocks until the shake settles
else
self.fx.blink = { target = hit.blink, frames = 20 }
self.waitFrames = 20
end
end
self.fx = self.fx or {}
-- rows queued before animType existed carry only the blink target
local t = hit.animType
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
if hit.sfx then
require("src.core.Sound").play(self.data, hit.sfx)
end
if not t or not self:animationsOn() then return end
if t == 1 then
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
-- then home for 3, b counting down
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dy = b, frames = 3 }
prog[#prog + 1] = { dy = 0, frames = 3 }
end
self.fx.shakeProg = prog
self.waitFrames = 48 -- the predef blocks until the shake settles
elseif t == 2 then
self.fx.shakeProg = fastShakeProg(8)
self.waitFrames = 72
elseif t == 3 then
self.fx.shakeProg = slowShakeProg(6, 2)
self.waitFrames = 48
elseif t == 4 then
if hit.blink then
self.fx.blink = { target = hit.blink, frames = 20 }
self.waitFrames = 20
end
elseif t == 5 then
self.fx.shakeProg = fastShakeProg(2)
self.waitFrames = 18
elseif t == 6 then
self.fx.shakeProg = slowShakeProg(3, 2)
self.waitFrames = 24
end
end
-- Primary status effects whose pokered handler ends in
-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets
-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep,
-- poison, confuse, disable and the primary stat-down effects. Every other
-- primary effect goes through PlayCurrentMoveAnimation and leaves the type
-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect),
-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are
-- skipped too -- UpdateLoweredStatDone bails out for them because the
-- damaging move's own type 2/5 shake already played.
local SLOW_SHAKE_EFFECTS = {
SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true,
DISABLE_EFFECT = true,
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
ACCURACY_DOWN1_EFFECT = true,
}
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
@@ -2910,6 +2990,8 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
if primaryEffectFailed(msgs) then
self:cancelMoveAnim()
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
end
for _, m in ipairs(msgs) do
self:sayNext(m)
@@ -2963,6 +3045,10 @@ function BattleState:continueBide(user, target)
self:sayNext(Strings("But, it failed!"))
return
end
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
-- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays
-- here, after UnleashedEnergyText and before the damage (#375)
self:animNext("BIDE", user.isPlayer)
self:applyDamage(target, dmg)
if target.mon.hp <= 0 then self:onFaint(target) end
end
@@ -2987,7 +3073,7 @@ function BattleState:applyDamage(target, dmg)
end
local dealt = math.min(dmg, target.mon.hp)
target.mon.hp = target.mon.hp - dealt
if dealt > 0 then self:drainNext() end -- animate the bar down
if dealt > 0 then self:drainNext(target, target.mon.hp) end -- animate the bar down
if target.bideTurns then
target.bideDamage = (target.bideDamage or 0) + dealt
end
+8
View File
@@ -186,7 +186,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
-- 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)
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
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 }
local totalDealt = 0
+6
View File
@@ -609,10 +609,16 @@ MoveEffects.full = {
},
BIDE_EFFECT = {
-- BideEffect (effects.asm:764-789) is a ResidualEffects2 entry: the
-- storing turn plays XSTATITEM_ANIM (XSTATITEM_DUPLICATE_ANIM on the
-- enemy side) and never BIDE's own animation, which belongs to the
-- release turn in .UnleashEnergy (#375)
perform = function(ctx)
local user = ctx.user
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.battle:cancelMoveAnim()
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end,
},
+34 -3
View File
@@ -55,6 +55,14 @@ local currentMusic
local pendingBuf -- a current-gen buffer popped from the worker but not yet
-- queued because the Source was momentarily full
-- Music holds playback while a fanfare owns the music channels (#398).
-- Pausing the Source is not enough on its own: this module is what starts a
-- chip song (immediately on the sync path, on the first worker buffer on the
-- threaded one), so a song that begins during a jingle would come up
-- underneath it. Music.duckForFanfare sets the hold, Music releases it when
-- the jingle ends.
local musicHeld = false
-- ---------------------------------------------------------------------------
-- worker management
-- ---------------------------------------------------------------------------
@@ -148,7 +156,7 @@ local function playMusicSync(data, header, allowLoops)
currentMusic = { source = source, engine = engine, threaded = false,
started = true, finished = false }
fillSync(MUSIC_FILL_INITIAL)
source:play()
if not musicHeld then source:play() end
return source
end
@@ -222,7 +230,7 @@ local function updateThreaded()
end
end
end
if not m.started then
if not m.started and not musicHeld then
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
pcall(function() m.source:play() end)
m.started = true
@@ -245,7 +253,7 @@ end
-- pause/resume behavior.
function ChipAudio.ensureMusicPlaying()
local m = currentMusic
if not m or m.finished then return end
if not m or m.finished or musicHeld then return end
if m.threaded then
if not m.started then return end
local ok, playing = pcall(function() return m.source:isPlaying() end)
@@ -263,6 +271,18 @@ function ChipAudio.ensureMusicPlaying()
end
end
-- Silence the song for the length of a fanfare and start whatever was held
-- back once it ends. Held state outlives a song change: Music.play may swap
-- songs while the jingle is still sounding.
function ChipAudio.holdMusic(held)
held = not not held
if held == musicHeld then return end
musicHeld = held
if held then return end
ChipAudio.update()
ChipAudio.ensureMusicPlaying()
end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
@@ -303,6 +323,17 @@ function ChipAudio.invalidate()
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
end
-- End the worker thread. LOVE waits for every live love.thread before the
-- process exits and the worker's command loop only returns on "quit", so
-- skipping this leaves the process running after the window is gone (#339).
function ChipAudio.shutdown()
ChipAudio.stopMusic()
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, outCh = nil, nil, nil
workerReady = false
end
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
function ChipAudio.setChannelVolume(hw, scale)
+4
View File
@@ -85,6 +85,7 @@ local function fanfareActive()
local ok, playing = pcall(src.isPlaying, src)
if ok and playing then return true end
state.fanfare = nil
require("src.core.ChipAudio").holdMusic(false)
return false
end
@@ -94,6 +95,9 @@ end
function Music.duckForFanfare(src)
if not src then return end
state.fanfare = src
-- ChipAudio is what starts a chip song, so the pause below cannot hold one
-- that has not started yet (nor one Music.play swaps in mid-jingle) (#398)
require("src.core.ChipAudio").holdMusic(true)
if state.source then
local ok, playing = pcall(state.source.isPlaying, state.source)
if ok and playing then
+4
View File
@@ -111,6 +111,9 @@ local function played(kind, name, species)
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
end
-- returns the started source (nil headless, or when the def failed to load)
-- so callers that block on a fanfare like the original's
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
function Sound.play(data, name)
local sfx = data.audio and data.audio.sfx
local def = sfx and sfx[name]
@@ -120,6 +123,7 @@ function Sound.play(data, name)
require("src.core.Music").duckForFanfare(src)
end
played("sfx", name)
return src
end
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
+1 -1
View File
@@ -6,7 +6,7 @@ local RomImporter = {}
RomImporter.__index = RomImporter
-- Cache generation tag; bump to force every imported version to re-extract.
local CACHE_FORMAT = "rom-cache-v7:"
local CACHE_FORMAT = "rom-cache-v8:"
-- The completion marker is written under each version's cache prefix
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
local MARKER_PATH = "rom-cache.complete"
+54 -8
View File
@@ -35,6 +35,48 @@ PaletteFX.MODE_LABELS = {
}
PaletteFX.mode = "gbc"
-- ------- dark-cave state (wMapPalOffset)
--
-- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED
-- resolves real colour per tile and BAKES it (tileset atlas, sprite sheets),
-- so FadePal2's shift has to reach those bakes and their cache keys rather
-- than a shader (#383). OverworldState owns it: armed before the map's atlas
-- is built, cleared by FLASH.
local darkWorld = false
-- returns true when the flag actually changed, so the caller can rebuild
function PaletteFX.setDarkWorld(on)
on = on and true or false
if darkWorld == on then return false end
darkWorld = on
return true
end
function PaletteFX.darkWorld() return darkWorld end
-- cache-key suffix for anything baked under the dark shift
function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
-- FadePal2 sets rOBP0 = `dc 3,3,3,2` as well as rBGP, so EVERY OBJ colour a
-- sprite can carry lands on shade 3: the player, trainers and item balls are
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
-- active mode resolved, with a distinct cache group so the lit and dark bakes
-- of one sheet never collide in SpriteRenderer's obpCache.
function PaletteFX.darkObp(colors, group)
if not (colors and darkWorld) then return colors, group end
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
end
-- the same shift folded into a baked 8-group world palette array (ADVANCED)
local function darkGroups(groups)
if not (groups and darkWorld) then return groups end
local out = {}
for i = 1, #groups do
out[i] = PaletteFX.permute(groups[i], PaletteFX.DARK_BGP)
end
return out
end
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
PaletteFX.CLASSIC = {
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
@@ -93,9 +135,13 @@ end
-- Red bake with a Blue one and one version would show the other's colors.
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
function PaletteFX.ogObj()
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
return PaletteFX.GBC_OBJ, "gbcobj"
if GameVersion.isBlue() then
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
end
if GameVersion.isYellow() then
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
end
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
end
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
@@ -114,7 +160,7 @@ PaletteFX.OBP0_SHADES = {
}
function PaletteFX.dmgObj()
return PaletteFX.OBP0_SHADES, "obp0"
return PaletteFX.darkObp(PaletteFX.OBP0_SHADES, "obp0")
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
@@ -503,7 +549,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local w = pack and pack.world
local base = w and w.groupColors[tileset]
if not base then return nil end
if not w.roofGroup[tileset] then return base end
if not w.roofGroup[tileset] then return darkGroups(base) end
local roofMapId = mapId
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
@@ -511,7 +557,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
end
local roofMap = data and data.maps and data.maps[roofMapId]
local roof = roofMap and w.roofByMapIndex[roofMap.index]
if not roof then return base end
if not roof then return darkGroups(base) end
local out = {}
for i = 1, 8 do out[i] = base[i] end
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
@@ -521,7 +567,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
-- material's 2 middle shades are town-specific
local base4 = base[ROOF_GROUP + 1]
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
return out
return darkGroups(out)
end
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
@@ -552,7 +598,7 @@ function PaletteFX.spriteObp(spriteDef, seed)
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
group = h % 4
end
return w.spritePalettes[group], group
return PaletteFX.darkObp(w.spritePalettes[group], group)
end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
+18 -4
View File
@@ -437,12 +437,26 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
return true
end
-- clamp a scissor rect to the viewport box
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
-- Clamp a scissor rect to the viewport box, then round it outward to whole
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to
-- pixels independently, so a rect with fractional unit edges (Android's
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows
-- through as a horizontal seam at every zone boundary (#373). Rounding
-- outward makes neighbours overlap by at most one row instead -- the overlap
-- redraws the same canvas pixels one palette later, and past the canvas edge
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
-- snapped edge rather than one short of it.
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
x, y = math.max(x, ox), math.max(y, oy)
if x2 <= x or y2 <= y then return false end
love.graphics.setScissor(x, y, x2 - x, y2 - y)
dpiX, dpiY = dpiX or 1, dpiY or 1
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY,
(px2 - px1 + 0.5) / dpiX,
(py2 - py1 + 0.5) / dpiY)
return true
end
@@ -572,7 +586,7 @@ function Renderer:endFrame(zones, worldZones)
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH) then
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
+36 -1
View File
@@ -135,7 +135,10 @@ local function blitFrame(image, quad, x, y, flip, redraw)
end
end
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
-- bottom tile row of the standing frames with the fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384)
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local image = self.image
@@ -190,7 +193,39 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if topHalf then
self.halfFrames = self.halfFrames or {}
if not self.halfFrames[frame] then
local iw, ih = self.image:getDimensions()
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
end
quad = self.halfFrames[frame]
end
blitFrame(image, quad, x, y, flip, redraw)
end
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
-- palette, mirroring the mode branches in :draw above. The fishing pose row
-- overwrites the sheet's own tiles in VRAM in the original, so it has to be
-- recolored and OG-RED-redrawn exactly like the sheet rather than blitted as
-- raw DMG shades (#384).
function SpriteRenderer:drawTile(path, x, y, flip)
local image, redraw = getImage(path), false
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 8)
elseif PaletteFX.usesGbcPack() then
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then image = getObpImage(path, colors, group) end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
image, redraw = getObpImage(path, PaletteFX.ogObj()), true
else
image = getObpImage(path, PaletteFX.dmgObj())
end
local iw, ih = image:getDimensions()
self.tileQuads = self.tileQuads or {}
self.tileQuads[path] = self.tileQuads[path]
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
end
return SpriteRenderer
+13 -4
View File
@@ -402,8 +402,15 @@ end
-- the route's own default roof (Vermilion's) throughout.
local gbcAtlasCache = {}
-- Cache suffix for a map's RED++ bake. A dark cave folds FadePal2 into the
-- palette worldGroupColors hands the bake (#383), so the lit and dark bakes of
-- one map are different images and must not share a key.
local function gbcKeyFor(mapId)
return "#gbc:" .. mapId .. PaletteFX.darkKey()
end
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
local key = imagePath .. "#gbc:" .. mapId
local key = imagePath .. gbcKeyFor(mapId)
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
local img = false
if love.image and love.image.newImageData then
@@ -473,7 +480,7 @@ function TileRenderer.new(map, data)
self.gbcAtlas = true
-- also recolors the animated water/flower entries below, so they
-- match the atlas's static tiles instead of showing raw grayscale
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = gbcKeyFor(map.id),
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
-- (see getKeyedTile): same source image and palette groups, so keep the
@@ -481,6 +488,7 @@ function TileRenderer.new(map, data)
gbcCtx.imagePath = map.tileset.image
gbcCtx.perRow = map.tileset.tilesPerRow
self.gbcCtx = gbcCtx
self.gbcAtlasKey = map.tileset.image .. gbcCtx.key
self.gbcKeyed = {}
end
end
@@ -582,7 +590,7 @@ local function ensureWaterBorderFill(self)
local groupColors = PaletteFX.worldGroupColors(
self.data, map.tileset.id, map.id, nil)
colors = group and groupColors and groupColors[group + 1] or nil
gbcKey = "#gbc:" .. map.id
gbcKey = gbcKeyFor(map.id)
end
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
colors, gbcKey)
@@ -913,8 +921,9 @@ function TileRenderer:release()
self.gbcCtx = nil
end
if self.gbcAtlas and self.image then
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
local key = self.gbcAtlasKey or (self.map.tileset.image .. gbcKeyFor(self.map.id))
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
self.gbcAtlasKey = nil
safeRelease(self.image)
self.image = nil
self.gbcAtlas = nil
+58
View File
@@ -84,6 +84,15 @@ O.coins = O.mainData + 685 -- 2B BCD
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Progress bits vanilla keeps OUTSIDE wEventFlags that this port still spells
-- as save.flags entries (#396). Offsets walk forward from wTownVisitedFlag
-- over the same ram/wram.asm declaration run the 60-byte gap above sums:
-- +29 wStatusFlags1, +35 wStatusFlags4, +41 wElite4Flags,
-- +44 wCompletedInGameTradeFlags.
O.statusFlags1 = O.townVisited + 29 -- 1B
O.statusFlags4 = O.townVisited + 35 -- 1B
O.elite4Flags = O.townVisited + 41 -- 1B
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
@@ -314,6 +323,34 @@ local BADGE_BY_BIT = {
local BADGE_BY_BIT_SET = {}
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
-- save.flags names whose vanilla home is NOT wEventFlags (#396: exporting a
-- save and importing it back made the Saffron gate guards thirsty again,
-- because BIT_GAVE_SAFFRON_GUARDS_DRINK is a wStatusFlags1 bit and nothing
-- carried it). Bit numbers are constants/ram_constants.asm; the trade bits
-- are wWhichTrade, which engine/events/in_game_trades.asm uses to index
-- wCompletedInGameTradeFlags, i.e. the data/events/trades.asm row order the
-- port's `trade` command takes 1-based.
local EXTRA_FLAG_BITS = {
EVENT_GOT_OLD_ROD = { O.statusFlags1, 3 },
EVENT_GOT_GOOD_ROD = { O.statusFlags1, 4 },
EVENT_GOT_SUPER_ROD = { O.statusFlags1, 5 },
EVENT_GAVE_GUARDS_DRINK = { O.statusFlags1, 6 },
EVENT_GOT_LAPRAS = { O.statusFlags4, 0 },
EVENT_STARTED_ELITE_4 = { O.elite4Flags, 1 },
EVENT_TRADED_NIDORINO_FOR_NIDORINA = { O.tradeFlags, 0 },
EVENT_TRADED_ABRA_FOR_MR_MIME = { O.tradeFlags, 1 },
EVENT_TRADED_PONYTA_FOR_SEEL = { O.tradeFlags, 3 },
EVENT_TRADED_SPEAROW_FOR_FARFETCHD = { O.tradeFlags, 4 },
EVENT_TRADED_SLOWBRO_FOR_LICKITUNG = { O.tradeFlags, 5 },
EVENT_TRADED_POLIWHIRL_FOR_JYNX = { O.tradeFlags, 6 },
EVENT_TRADED_RAICHU_FOR_ELECTRODE = { O.tradeFlags, 7 },
EVENT_TRADED_VENONAT_FOR_TANGELA = { O.tradeFlags, 8 },
EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F = { O.tradeFlags, 9 },
}
-- port-local name -> the wEventFlags name it means (#396)
local FLAG_ALIAS = { EVENT_RECEIVED_BIKE_VOUCHER = "EVENT_GOT_BIKE_VOUCHER" }
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
@@ -671,6 +708,14 @@ function GenSave.decode(bytes, data, opts)
end
end
-- the same progress under names that are not wEventFlags bits (#396)
for name, spec in pairs(EXTRA_FLAG_BITS) do
if bitGet(bytes, spec[1], spec[2]) then save.flags[name] = true end
end
for portName, vanillaName in pairs(FLAG_ALIAS) do
if save.flags[vanillaName] then save.flags[portName] = true end
end
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
-- into de and rotates it right one bit per iteration with b counting up
@@ -794,6 +839,19 @@ function GenSave.encode(save, data, template)
local bitIdx = events.byName[name]
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
end
for portName, vanillaName in pairs(FLAG_ALIAS) do
local bitIdx = events.byName[vanillaName]
if bitIdx and save.flags[portName] then bitSet(buf, O.eventFlags, bitIdx, true) end
end
end
-- Non-wEventFlags progress, written both ways: this port's save is the only
-- authority for these names, so a flag it does not hold must clear the
-- template's bit rather than survive in the export (#396).
if save.flags then
for name, spec in pairs(EXTRA_FLAG_BITS) do
bitSet(buf, spec[1], spec[2], save.flags[name] and true or false)
end
end
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
+14 -2
View File
@@ -225,11 +225,23 @@ function Commands.give_item(ctx, itemId, count, gotText)
ctx.game.stringBuffer = def and def.name or itemId
-- the jingle rides the box -- Sound.play routes fanfares through
-- Music.duckForFanfare, like PlaySoundWaitForCurrent
require("src.core.Sound").play(ctx.game.data,
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
local Sound = require("src.core.Sound")
local jingle = (def and def.keyItem) and "Get_Key_Item" or "Get_Item1"
if gotText ~= false then
-- the gift texts carry the jingle as a trailing text command
-- (sound_get_item_1 / sound_get_key_item -> home/text.asm
-- TextCommand_SOUND), so it only fires once the last page has typed
-- out, blocks on WaitForSoundToFinish, and AfterDisplayingTextID's
-- button wait runs after it (#374)
ctx.textOpts = ctx.textOpts or {}
ctx.textOpts.auto = {
sound = function() return Sound.play(ctx.game.data, jingle) end,
wait = true,
}
Commands.show_text(ctx, gotText
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
else
Sound.play(ctx.game.data, jingle)
end
end
+5 -3
View File
@@ -305,9 +305,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
-- HP medicine: fill the bar in the still-open picker first, then print
-- and close, the order item_effects.asm .doneHealing runs in
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
-- picker is nil for every other item and for in-battle use, which keeps
-- the pop-then-print path below. #252
if picker and extra and extra.healedFrom and target then
-- Only a keepOpen picker is still on the stack to animate: every other
-- item, and every in-battle use, popped it in PartyMenu before onSwitch,
-- and takes the pop-then-print path below -- which is the path that
-- spends the battle turn. #252, #379
if picker and picker.keepOpen and extra and extra.healedFrom and target then
picker:animateTo(target, extra.healedFrom, function()
showMessages(game, payload, closePicker)
end)
+4 -1
View File
@@ -486,7 +486,10 @@ end
function OakSpeech:advance()
self.step = self.step + 1
self.picFlip = false
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
-- and 2B over one flipped NIDORINO with no redraw between them
-- (oak_speech.asm:80-83), so a pic-less step must not un-mirror what is
-- still on screen; only applyPic and the demo step may change it (#397)
local steps = self.steps
if not steps then
-- enter() builds steps; keep a path for callers that advance early
+24 -15
View File
@@ -351,18 +351,23 @@ function PartyMenu:update(dt)
end })
return
elseif action == "flash" then -- FLASH lights dark tunnels
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs
-- with the party menu still on screen, and only then
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads
-- over the menu, and the cave is lit when the blink hands the
-- screen back, never under the text (#385).
local ow = self.game.overworld
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
self.game.stack:pop()
ow.dark = false
self.game.save.flashLit = true
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
self.game.stack:push(Transition.whiteFlash(self.game))
self:close()
-- setDark, not a bare field write: ADVANCED carries the darkness
-- in a baked atlas, so lighting the cave rebuilds it (#383)
self.game.stack:push(Transition.whiteFlash(self.game, nil,
function() ow:setDark(false) end))
end))
return
elseif action == "surf" then
@@ -377,9 +382,11 @@ function PartyMenu:update(dt)
local reason = ow:useSurfFieldMove()
local Transition = require("src.render.Transition")
if reason == "ok" then
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
-- UseItem prints _SurfingGotOnText with the party menu still up;
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
-- trySurf closes this menu when its text does (#385)
local fx, fy = ow.player:facingCell()
ow:trySurf(fx, fy)
ow:trySurf(fx, fy, function() self:close() end)
return
end
if reason == "dismount" then
@@ -410,9 +417,10 @@ function PartyMenu:update(dt)
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
-- never zeroes wActionResultOrTookBattleTurn, so unlike the
-- other refusals the menu still closes afterwards
-- (GBPalWhiteOutWithDelay3 + .goBackToMap)
self.game.stack:pop()
-- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints
-- over the still-open menu like every other .loop refusal (#385)
self.game.stack:push(TextBox.new(self.game, txt, function()
self:close()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
return
@@ -454,18 +462,19 @@ function PartyMenu:update(dt)
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
local name = mon.nickname or def.name
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
ow.strengthActive = true
local t1 = (self.game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
local t2 = (self.game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
-- like surf (#320): the blink belongs UNDER the texts, not as a
-- flashbang on the empty map after them; the stack only updates
-- the top state, so the flash holds until the texts close
self.game.stack:push(Transition.whiteFlash(self.game))
-- like surf (#320, #385): both texts print with the party menu
-- still on screen, and the blink IS the menu closing afterwards,
-- not a flashbang on the empty map
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2))
self.game.stack:push(TextBox.new(self.game, t2, function()
self:close()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
+14 -9
View File
@@ -22,13 +22,17 @@ TitleState.isOpaque = true
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
--
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
local function withPureWhite(pal)
-- Every title SuperPal shares color 0 on hardware (sgb_palettes.asm: LOGO1,
-- LOGO2 and MEWMON all start RGB 31,29,31), so the ribbon band's white has to
-- be the neighbouring zones' white. Under RED++, LOGO2/MEWMON come from the
-- GBC pack (pure white) while Blue's LOGO1 stays on the ROM pack (#128) and
-- the row reads as a pink band; taking LOGO2's white fixes that without
-- forcing pure white in SGB, where a brighter band drew two faint lines
-- across the title (#373). Ink colors (Blue/Red "Version" text) stay intact.
local function withWhiteOf(pal, ref)
if not pal then return nil end
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
if not (ref and ref[1]) then return pal end
return { ref[1], pal[2], pal[3], pal[4] }
end
function TitleState:sgbPalettes(game)
@@ -46,9 +50,10 @@ function TitleState:sgbPalettes(game)
P.zone(logoPal, 9, 8, 10, 8),
}
else
local logoPal = P.pal(game.data, "LOGO2")
z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
P.zone(logoPal, 0, 0, 19, 7),
P.zone(withWhiteOf(P.pal(game.data, "LOGO1"), logoPal), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
end
@@ -427,7 +432,7 @@ end
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
-- (4,8) 12x9 no version ribbon, no cycling mon, no Red OAM.
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
+10
View File
@@ -174,4 +174,14 @@ function Check.download()
cmdCh:push({ cmd = "download" })
end
-- End the worker thread. Its command loop sits in Channel:demand(), which
-- never returns on its own, and LOVE waits for every live love.thread before
-- the process exits (#339).
function Check.shutdown()
if cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, stateCh = nil, nil, nil
workerReady = false
end
return Check
+17
View File
@@ -128,6 +128,23 @@ FieldDefaults.FIELD = {
-- the one-shot flag the gate's pass text is gated on; a gate a mod adds
-- gets "PASSED_<mapId>" instead of this pre-v2 spelling
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
-- the Rocket Hideout lift gates the floor callbacks stamp shut
-- (scripts/RocketHideoutB1F.asm / RocketHideoutB4F.asm
-- ...DoorCallbackScript); seeds caches imported before the manifest
-- carried them, which left both gates standing open (#372)
cardKeyDoors = {
closedDoors = {
ROCKET_HIDEOUT_B1F = {
{ block = 0x54, bx = 12, by = 8, open = 0x0e,
event = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" },
},
ROCKET_HIDEOUT_B4F = {
{ block = 0x2d, bx = 12, by = 5, open = 0x0e,
events = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" } },
},
},
},
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
hiddenExtras = {
-- PrintTrashText bins (#188); seeds stale caches missing the key
+166 -144
View File
@@ -65,6 +65,16 @@ local ROD_OAM = {
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
}
-- field.darkMaps (home/overworld.asm's dark-map check): the floors that run
-- with wMapPalOffset = 6 until FLASH
local function isDarkMap(mapId)
local darkDef = Game.data.field.darkMaps
for _, m in ipairs(darkDef and darkDef.maps or {}) do
if m == mapId then return true end
end
return false
end
-- object_event spawn filter (toggleable_objects, items taken, beaten
-- static encounters), shared by the current map's real NPCs and the
-- visual-only ghosts on connected neighbor maps
@@ -188,8 +198,50 @@ function OverworldState:enter(mapId, x, y, facing)
-- survives save/load: a loaded game may start inside a building whose
-- exit mat is a LAST_MAP warp
self.lastOutdoor = Game.save.lastOutdoor
self.justWarped = false
self:setMap(mapId, x, y, facing, { via = "boot" })
-- boot/load: derive the flag from the tile the save left us standing on,
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
-- door mat can still walk straight back out (issue #378)
self:refreshStandingOnWarp()
end
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
-- layouts ship with the doorways open; each floor's map script stamps
-- the closed door block on load until its unlock event is set
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
-- the lift doorway). A door opens on its single `event`, or on `events`
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
-- needs both guard trainers beaten -- CheckBothEventsSet). The callbacks
-- run whenever BIT_CUR_MAP_LOADED_1 is set, which is map load AND the end
-- of a battle on that map (home/trainers.asm EndTrainerBattle), so the
-- gate opens with SFX_GO_INSIDE the moment the last guard falls (#372).
function OverworldState:stampClosedDoors()
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
local floorDoors = self.map and closedDoors and closedDoors[self.map.id]
if not floorDoors then return end
local stamped, unlocked = false, false
for _, door in ipairs(floorDoors) do
local open
if door.events then
open = true
for _, ev in ipairs(door.events) do
if not Game.save.flags[ev] then open = false break end
end
else
open = Game.save.flags[door.event]
end
local want = open and door.open or door.block
if self.map:blockAt(door.bx, door.by) ~= want then
self.map:setBlock(door.bx, door.by, want)
stamped = true
if open then unlocked = true end
end
end
if stamped then self.map.renderer:rebuild() end
if unlocked then require("src.core.Sound").play(Game.data, "Go_Inside") end
end
function OverworldState:setMap(mapId, x, y, facing, opts)
@@ -222,6 +274,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation
self.tileAnimOverride = nil
end
-- ADVANCED bakes colour into the tileset atlas, so the dark-cave shift has
-- to be armed before that atlas is built for this map (#383); self.dark is
-- settled below, once the map record is in hand.
if PaletteFX.setDarkWorld(isDarkMap(mapId) and not Game.save.flashLit)
and PaletteFX.usesGbcPack() then
MapLoader.invalidateAll()
end
self.map = MapLoader.load(Game.data, mapId)
-- STRENGTH deactivates on every real map load (home/overworld.asm
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
@@ -241,38 +300,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.map.renderer:rebuild()
self.cutBlocks[mapId] = nil
end
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
-- layouts ship with the doorways open; each floor's map script stamps
-- the closed door block on load until its unlock event is set
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
-- the lift doorway). A door opens on its single `event`, or on `events`
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
-- needs both guard trainers beaten -- CheckBothEventsSet).
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
local floorDoors = closedDoors and closedDoors[mapId]
if floorDoors then
local stamped = false
for _, door in ipairs(floorDoors) do
local open
if door.events then
open = true
for _, ev in ipairs(door.events) do
if not Game.save.flags[ev] then open = false break end
end
else
open = Game.save.flags[door.event]
end
local want = open and door.open or door.block
if self.map:blockAt(door.bx, door.by) ~= want then
self.map:setBlock(door.bx, door.by, want)
stamped = true
end
end
if stamped then self.map.renderer:rebuild() end
end
self:stampClosedDoors()
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
@@ -294,18 +322,11 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark
-- until FLASH is used; the light persists between the tunnel floors
-- and resets once outside
local darkDef = Game.data.field.darkMaps
self.dark = false
if darkDef then
local isDark = false
for _, m in ipairs(darkDef.maps) do
if m == mapId then isDark = true break end
end
if isDark then
self.dark = not Game.save.flashLit
else
Game.save.flashLit = nil
end
if isDarkMap(mapId) then
self:setDark(not Game.save.flashLit)
else
Game.save.flashLit = nil
self:setDark(false)
end
if Game.data.field.flyWarps[mapId] then
Game.save.visited = Game.save.visited or {}
@@ -576,17 +597,19 @@ function OverworldState:sgbWorldZones()
return zones
end
-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld)
-- can actually reach this frame's world pass. It cannot in RED++: that mode
-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns
-- an EMPTY zone list above, so the world blits with no shade-remap shader at
-- all and there is no palette left to permute. Only then does fxDark
-- composite the darkness by hand (#322).
function OverworldState:darkNeedsOverlay()
if not self.dark then return false end
local renderer = self.map and self.map.renderer
return PaletteFX.usesGbcPack() and renderer ~= nil
and renderer.gbcAtlas ~= nil
-- wMapPalOffset, the one piece of state both halves of the darkness read:
-- drawWorld arms PaletteFX.DARK_BGP off self.dark for the shade-remapped
-- modes, and PaletteFX.setDarkWorld feeds the bakes ADVANCED does instead of
-- shading (tileset atlas, sprite sheets) plus their cache keys. A bake cannot
-- be re-shaded in place, so a change there rebuilds every resident map --
-- every dark floor, not just this one, since FLASH lights them all (#383).
function OverworldState:setDark(on)
on = on and true or false
self.dark = on
if PaletteFX.setDarkWorld(on) and PaletteFX.usesGbcPack() and self.map then
MapLoader.invalidateAll()
self:reloadMap(self.map.id, "dark")
end
end
function OverworldState:npcByIndex(index)
@@ -795,6 +818,15 @@ function OverworldState:update(dt)
if ca.onDone then ca.onDone() end
end
end
-- fishing pose tail: the rod is already gone, the pose holds for the
-- frames the original spends unwinding the item menu (#384)
if self.fishPose then
self.fishPose = self.fishPose - 1
if self.fishPose <= 0 then
self.fishPose = nil
self.player.fishing = nil
end
end
-- Yellow's companion hopping up onto the Poke Center counter owns the
-- world for its arc, the same way the heal machine below does (#417)
if self.pikaHop then
@@ -961,19 +993,33 @@ function OverworldState:dirHeld()
or input:isDown("left") or input:isDown("right")
end
-- The warp cell the player WARPED IN on is inert until they physically step
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
-- floors every input frame (issue #230).
function OverworldState:onWarpArrivalCell()
if self.justWarped then return true end
local entry = self.warpEntryCell
return entry ~= nil and self.player.cellX == entry.x
and self.player.cellY == entry.y
-- BIT_STANDING_ON_WARP (wMovementFlags): the warp under the player's feet may
-- only fire from a collision -- the blocked-step warp (handleInput) and the
-- map-edge exit (checkEdgeExit) -- while this flag is set. pokered clears it
-- on every completed step, sets it again when that step lands on a warp
-- square, then clears it once more when the square is a warp-activating tile
-- that is not also a door tile (CheckWarpsNoCollisionLoop ->
-- IsPlayerStandingOnDoorTileOrWarpTile, engine/overworld/player_state.asm);
-- onStepComplete maintains it. ClearVariablesOnEnterMap does not clear
-- wMovementFlags, so the flag rides through the warp itself: a house door
-- tile ($1B) leaves it set, so you land on the interior mat still able to
-- walk back out on that same tile (issue #378), while a staircase tile
-- ($1A/$1C) clears it and cannot bounce you between floors (issue #230).
function OverworldState:canCollisionWarp()
return self.standingOnWarp == true
end
-- Re-derive the flag from the tile under the player, the way a completed step
-- does (and the way MapEntryAfterBattle's IsPlayerStandingOnWarp does after a
-- battle): a door tile keeps it, a stair/ladder warp tile clears it.
function OverworldState:refreshStandingOnWarp()
local p = self.player
self.standingOnWarp = false
if self.map:warpAtCell(p.cellX, p.cellY)
and not (self.map:isWarpTileCell(p.cellX, p.cellY)
and not self.map:isDoorTileCell(p.cellX, p.cellY)) then
self.standingOnWarp = true
end
end
function OverworldState:handleInput()
@@ -1017,9 +1063,9 @@ function OverworldState:handleInput()
local result, why = self.player:tryMove(dir, self.map, self.entities)
-- a collision while standing on a warp square fires the warp when the
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
-- entrances, ...) -- but never on the inert cell we just warped in on
-- (issue #230), which the completed-step path guards the same way.
if result == "blocked" and not self:onWarpArrivalCell() then
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
-- #230), which the map-edge path guards the same way.
if result == "blocked" and self:canCollisionWarp() then
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
self.player.cellX, self.player.cellY, dir)
if w then
@@ -1175,11 +1221,11 @@ function OverworldState:checkEdgeExit(dir)
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
if w then
-- ...but not while still standing on the warp cell we just arrived on
-- (issue #230): fall through so pushing into the edge bonks (SFX +
-- walk-in-place) instead of instantly re-warping. A real step onto an
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
if self:onWarpArrivalCell() then return false end
-- ...but only with BIT_STANDING_ON_WARP set: a staircase tile clears it,
-- so pushing into the edge beside one bonks (SFX + walk-in-place) instead
-- of bouncing floors (issue #230), while the door mat you warped in on
-- keeps it and exits on that same tile (issue #378).
if not self:canCollisionWarp() then return false end
self:takeWarp(w.def)
return true
end
@@ -1405,6 +1451,7 @@ function OverworldState:goFishing(rod)
-- the bobber waits a beat before the verdict (the original's
-- FishingInit dot animation); the rod pose draws in the meantime
self.fishing = { facing = self.player.facing }
self.player.fishing = true
Game.stack:push(TextBox.new(Game, ". . .", function()
-- FishingAnim (engine/overworld/player_animations.asm) holds
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
@@ -1412,12 +1459,20 @@ function OverworldState:goFishing(rod)
-- must NOT vanish with the dots box (#321).
if not enc then
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
-- the rod OAM goes out with the verdict box (res BIT_LEDGE_OR_FISHING
-- straight after PrintText) but the player keeps the patched tiles
-- until the overworld reloads them a few frames later
-- (RestoreScreenTilesAndReloadTilePatterns, home/palettes.asm ->
-- ReloadMapSpriteTilePatterns, home/reload_sprites.asm) -- #384
self.fishing = nil
self.fishPose = 10
end))
return
end
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
-- the bite goes straight into battle, which reloads the sprite tiles
self.fishing = nil
self.player.fishing = nil
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
@@ -2098,25 +2153,26 @@ end
-- Gen 1 has no confirmation prompt: using SURF gets straight on
-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party
-- menu's SURF action (via useSurfFieldMove) once the facing tile has been
-- confirmed to be water -- there is no overworld A-press hook.
function OverworldState:trySurf(fx, fy)
-- confirmed to be water -- there is no overworld A-press hook. onClose is
-- that menu's own close, called when the got-on text ends (see below).
function OverworldState:trySurf(fx, fy, onClose)
local mon = self:partyKnows("SURF")
if not mon then return end
local name = mon.nickname or Game.data.pokemon[mon.species].name
local p = self.player
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
:gsub("{RAM:wNameBuffer}", name)
-- GBPalWhiteOutWithDelay3 runs while the got-on text is still up
-- (start_sub_menus.asm .surf), so the blink reads as a text flash
-- instead of a flashbang on the empty map (#320). The flash sits
-- under the textbox on the stack: only the top state updates, so it
-- holds its frames until the text closes. surfing (and the sprite
-- swap) only applies when the step happens -- no paddling on land.
Game.stack:push(require("src.render.Transition").whiteFlash(Game))
-- UseItem prints the got-on text with the party menu still on screen and
-- GBPalWhiteOutWithDelay3 + .goBackToMap only run after it
-- (start_sub_menus.asm .surf), so the text reads over the menu and the
-- blink is the menu closing, not a flashbang on the empty map (#320,
-- #385). The mount rides the blink, so nothing paddles on land.
Game.stack:push(TextBox.new(Game, text, function()
if onClose then onClose() end
p.surfing = true
require("src.core.Music").setSurfing(Game.data, true)
self:stepForwardOrCrossEdge(p.facing)
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
function() self:stepForwardOrCrossEdge(p.facing) end))
end))
end
@@ -3050,18 +3106,16 @@ function OverworldState:onStepComplete()
entry = nil
end
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
-- test. justWarped only records that an arrival happened (it still
-- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a
-- completed step with it swallowed the warp under the player's feet,
-- which is why a second ladder one cell from the first did nothing
-- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265.
-- pokered has no such counter: every completed step runs
-- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP,
-- the flag the bonk path needs, is only set by
-- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from
-- MapEntryAfterBattle, never on a plain warp arrival -- which is
-- exactly what warpEntryCell reproduces.
self.justWarped = false
-- test. Consuming a completed step with a one-shot "just warped" counter
-- instead swallowed the warp under the player's feet, which is why a
-- second ladder one cell from the first did nothing (Seafoam B3F has warp
-- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter:
-- every completed step runs CheckWarpsNoCollision (home/overworld.asm).
-- That same step is where BIT_STANDING_ON_WARP is maintained: cleared
-- before the check (home/overworld.asm:324), set again while standing on a
-- warp square, then cleared once more when the square is a warp-activating
-- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile).
self:refreshStandingOnWarp()
if entry then
-- still standing on the warp we arrived through; do not re-trigger it
else
@@ -3499,6 +3553,10 @@ function OverworldState:afterBattle(result, battle)
{ save = Game.save, healTarget = self:healPoint() })
self:warpToHealPoint(evolutions)
else
-- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which
-- re-runs the floor's door callback: beating the last Rocket Hideout guard
-- opens the lift gate without leaving the map (#372)
self:stampClosedDoors()
-- throwing the last SAFARI BALL ends the game
if Game.save.safari and Game.save.safari.balls <= 0 then
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
@@ -3632,12 +3690,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.arriveWarp = nil
Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
self.justWarped = true
-- The warp we land ON stays inert until we physically step off it, so a
-- warp whose destination cell is itself a warp cannot bounce us straight
-- back (elevator cars, stacked stair/door mats). This generalizes the
-- one-step justWarped guard, which only skipped the very next frame's
-- check and so let a mon walked back onto the pad re-trigger it.
-- The warp we land ON stays inert for the completed-step check until we
-- physically step off it, so a warp whose destination cell is itself a
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
-- mats). BIT_STANDING_ON_WARP is deliberately NOT touched here:
-- ClearVariablesOnEnterMap leaves wMovementFlags alone, so the flag the
-- departing tile set rides through the warp (issue #378).
self.warpEntryCell = { x = x, y = y }
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
@@ -3663,8 +3721,8 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
-- warp that lands on a door tile auto-steps south once, indoor or
-- outdoor. Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
-- (warpEntryCell) is unnecessary -- and would let you stand on the
-- door without re-entering if you hold back into it.
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
-- not a forced move, so it obeys collision: on a landing with a
-- solid cell south of the door (the mansion stair landings back
@@ -3673,7 +3731,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
if Collision.canMove(self.map, self.entities, self.player, "down") then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1)
else
self.player.facing = "down"
@@ -4116,23 +4173,6 @@ function OverworldState:drawWorld()
end
end
-- Rock Tunnel darkness. The original never cuts a window of light around
-- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset
-- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts
-- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped
-- mode, armed at the top of drawWorld. RED++ is the one mode with no
-- palette left to shift -- TileRenderer bakes true colour into the tileset
-- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader
-- runs over the world at all -- so there the darkness is composited instead:
-- a flat veil over the whole world view (surveying still cannot peek past
-- it) at the 85/255 brightness FadePal2 leaves DMG white on.
local function fxDark()
if not self:darkNeedsOverlay() then return end
love.graphics.setColor(0, 0, 0, 1 - 85 / 255)
love.graphics.rectangle("fill", 0, 0, vw, vh)
love.graphics.setColor(1, 1, 1, 1)
end
-- the FLY bird sweeping off with the player
local function fxBird()
if not self.flyAnim then return end
@@ -4210,7 +4250,7 @@ function OverworldState:drawWorld()
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
emote = fxEmote, bird = fxBird, rod = fxRod },
}
-- Draw every active field FX into the finished scene. `project(wx, wy)`
-- maps a world point to canvas pixels (nil when it is behind the
@@ -4261,17 +4301,6 @@ function OverworldState:drawWorld()
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
end
-- Rock Tunnel darkness is a screen-space veil, not a ground object:
-- draw it flat over the finished scene like the tilt path. It fills
-- the view in world-pixel units, so it only needs the scale, and it is
-- only needed at all in the mode whose palette cannot carry the
-- darkening itself (see fxDark).
if self:darkNeedsOverlay() then
love.graphics.push()
love.graphics.scale(scale, scale)
fxDark()
love.graphics.pop()
end
end
override = Pipelines.drawWorld(pipelineId, ctx)
-- world post-processes (a miniature-diorama blur, a colour grade) fold
@@ -4334,7 +4363,6 @@ function OverworldState:drawWorld()
fxDust()
fxCutTree()
fxEmote()
fxDark()
fxBird()
fxRod()
else
@@ -4423,12 +4451,6 @@ function OverworldState:drawWorld()
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
end
-- Rock Tunnel darkness is a screen-space veil, not a ground object --
-- draw it flat into the upright canvas so it darkens the final
-- composited scene uniformly. It no-ops unless this mode needs the
-- composited fallback (see fxDark).
fxDark()
Game.renderer:endUprightPass()
end
+29 -1
View File
@@ -42,6 +42,20 @@ function Player.new(data, cx, cy, facing)
local ok, img = pcall(love.graphics.newImage, fx.shadow.path)
self.shadowImg = ok and img or nil
end
-- FishingAnim (engine/overworld/player_animations.asm) patches tiles
-- $02/$06/$0a -- the bottom tile row of each standing frame -- with
-- RedFishingTiles before it parks the rod OAM, so the rod stroke meets a
-- pair of hands instead of ending in mid air (#384)
if fx then
local function posePath(name)
local def = fx[name]
return def and def.path or nil
end
local pose = { down = posePath("redFishFront"), up = posePath("redFishBack") }
pose.left = posePath("redFishSide")
pose.right = pose.left -- the side pose mirrors like the sprite (OAM_XFLIP)
if pose.down or pose.up or pose.left then self.fishTiles = pose end
end
self.cellX, self.cellY = cx, cy
self.px, self.py = cx * 16, cy * 16
self.facing = facing or "down"
@@ -231,7 +245,10 @@ function Player:pose()
py = py - math.floor((total - self.spinFrames) * 24 / total)
end
end
local sprite = (self.surfing and self.surfSprite)
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
-- across FishingAnim, so casting from the water shows the on-foot sheet
local sprite = (self.fishing and self.sprite)
or (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
return sprite, self.px, py, facing, phase, flip, hopping
end
@@ -263,6 +280,17 @@ function Player:draw(camX, camY)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
end
-- Fishing pose: the standing frame with its bottom tile row swapped for
-- RedFishingTiles, which is where the hands and the near half of the rod
-- live; the far half is the rod OAM OverworldState draws (FishingRodOAM,
-- engine/overworld/player_animations.asm) -- #384
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
if fishTile then
sprite:draw(px, py, camX, camY, facing, 0, false, true)
sprite:drawTile(fishTile, math.floor(px - camX),
math.floor(py - camY) - 4 + 8, facing == "right")
return
end
sprite:draw(px, py, camX, camY, facing, phase, flip)
end
@@ -0,0 +1,295 @@
-- Driver: a POTION used in battle prints, moves the HUD bar and spends the
-- turn (#379). pokered engine/items/item_effects.asm:1-3 leaves
-- wActionResultOrTookBattleTurn at its success value, and ItemUseMedicine only
-- clears it on .healingItemNoEffect (:1241), so a medicine that lands costs
-- the turn; the party-menu bar fill is the field case (#252). Data half:
-- tests/parity_battle_item_turn.lua. Never under POKEPORT_SPEED: fast-forward
-- desynchronizes SFX_HEAL_HP from the bar.
-- POKEPORT_DRIVER=tests/drivers/battle_item_turn_bug379_test.lua POKEPORT_IDENTITY=bug379 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local BattleState = require("src.battle.BattleState")
local ItemEffects = require("src.inventory.ItemEffects")
local PartyMenu = require("src.ui.PartyMenu")
local TextBox = require("src.render.TextBox")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function top() return game.stack:top() end
local function isPicker(s)
return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu)
end
local function isBox(s) return getmetatable(s) == TextBox end
local function isBag(s) return s ~= nil and s.screenId == "BagMenu" end
local function inStack(pred)
for _, s in ipairs(game.stack.states or {}) do
if pred(s) then return true end
end
return false
end
local function boxText(s)
local out = {}
for _, page in ipairs(s.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- ---- preconditions -----------------------------------------------------
U.log("#379 in-battle medicine: machine checks")
check("POTION takes the HP-medicine path", ItemEffects.healsHP("POTION") == true)
check("ANTIDOTE does not (status cure, always spent the turn)",
ItemEffects.healsHP("ANTIDOTE") ~= true)
for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do
check(id .. " resolves in the item table", game.data.items[id] ~= nil)
end
check("SFX_HEAL_HP is in the generated audio",
game.data.audio and game.data.audio.sfx
and game.data.audio.sfx.Heal_HP ~= nil)
check("BattleState:itemUsed exists (queues the foe's turn)",
type(BattleState.itemUsed) == "function")
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the heal chime will be SILENT, raise it in OPTION first")
else
U.log("sfxVol", tostring(vol), "-- expect the heal chime under the bar drain")
end
-- use() must hand back the pre-heal HP; without it neither bar has a start
do
local scratch = Pokemon.new(game.data, "BULBASAUR", 20)
scratch.hp = 4
local scratchSave = { party = { scratch }, inventory = {}, flags = {},
pokedex = { seen = {}, owned = {} } }
local result, msgs, extra = ItemEffects.use(game.data, scratchSave,
"POTION", scratch)
check("POTION on a hurt mon reports consumed", result == "consumed")
check("...and hands back the pre-heal HP",
type(extra) == "table" and extra.healedFrom == 4)
check("...with the restored-HP message",
type(msgs) == "table" and type(msgs[1]) == "string"
and msgs[1]:find("restored", 1, true) ~= nil)
end
-- ---- fixture -----------------------------------------------------------
local lead = Pokemon.new(game.data, "CHARIZARD", 50)
local second = Pokemon.new(game.data, "PIKACHU", 30)
lead.hp = 12
game.save.party = { lead, second }
game.save.player.name = "RED"
for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do
Bag.add(game.save, id, 9)
end
-- ROUTE_1 is open field with tall grass either side of the path
-- (data/generated/maps.lua ROUTE_1); the cell comes off the loaded map so a
-- map edit degrades to somewhere else in the grass instead of a wall.
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
local ow = game.overworld
local map = ow.map
local gx, gy
if not map:isWalkableCell(5, 5) then
for cy = 0, map.heightCells - 1 do
for cx = 0, map.widthCells - 1 do
if map:isGrassCell(cx, cy) and map:isWalkableCell(cx, cy) then
gx, gy = cx, cy
break
end
end
if gx then break end
end
if gx then
U.teleport(game, "ROUTE_1", gx, gy, "down")
U.wait(10)
ow = game.overworld
end
end
check("player is standing on a walkable ROUTE_1 cell",
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
-- ---- navigation --------------------------------------------------------
local function mashUntil(cond, max)
for _ = 1, max or 120 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return cond()
end
-- FIGHT PKMN / ITEM RUN (BattleState.menuIndex): down from FIGHT is ITEM
local function openBagFrom(battle)
for _ = 1, 30 do
if battle.menuIndex == 3 then break end
U.tap(game, battle.menuIndex > 2 and "up" or "down")
U.wait(4)
end
if battle.menuIndex ~= 3 then return nil, "ITEM cell unreachable" end
U.tap(game, "a")
U.wait(20)
local bag = top()
if not isBag(bag) then return nil, "bag never opened" end
return bag
end
local function cursorTo(menu, want)
for _ = 1, 40 do
if not menu or menu.index == want then return menu and menu.index == want end
U.tap(game, menu.index < want and "down" or "up")
U.wait(3)
end
return menu.index == want
end
local function chooseItem(bag, id)
local row
for i, r in ipairs(bag.items or {}) do
if r.value == id then row = i break end
end
if not row or not cursorTo(bag, row) then return false end
U.tap(game, "a") -- in battle A uses it straight away, no USE / TOSS
U.wait(16)
return true
end
local function newFight()
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
mashUntil(function() return battle.phase == "menu" end)
return battle
end
-- ======== scripted run: POTION on the hurt lead ==========================
U.log("#379 scripted run: POTION on a 12 HP CHARIZARD, mid-battle")
local battle = newFight()
check("the wild battle reached its FIGHT menu", battle.phase == "menu")
local turns, ends = 0, 0
local realAction, realEnd = battle.executeAction, battle.endOfTurn
battle.executeAction = function(self, ...)
turns = turns + 1
return realAction(self, ...)
end
battle.endOfTurn = function(self, ...)
ends = ends + 1
return realEnd(self, ...)
end
local bag, why = openBagFrom(battle)
check("ITEM opened the bag" .. (why and (" (" .. why .. ")") or ""), bag ~= nil)
if bag then
U.shot(game, DIR .. "/bug379_bag_in_battle.png")
check("POTION chosen from the bag", chooseItem(bag, "POTION"))
local picker = top()
check("the party picker opened to pick a target", isPicker(picker))
if isPicker(picker) then
check("keepOpen is off in battle (the fill is the field case, #252)",
picker.keepOpen ~= true)
cursorTo(picker, 1)
U.tap(game, "a")
U.wait(10)
check("the mon was healed", lead.hp > 12)
check("no party-menu bar fill was started in battle", picker.heal == nil)
check("the picker popped itself", not inStack(isPicker))
check("the bag list closed underneath it", not inStack(isBag))
-- THE DEFECT: the animate branch swallowed this message, and with it the
-- itemUsed tail that spends the turn
for _ = 1, 60 do
if isBox(top()) then break end
U.wait(1)
end
local box = top()
check("the restored-HP message printed (#379)", isBox(box))
if isBox(box) then
U.wait(50)
local said = boxText(box)
U.log("box reads:", said)
check("...and it is the restored-HP line",
said:find("restored", 1, true) ~= nil)
U.shot(game, DIR .. "/bug379_message_in_battle.png")
end
-- the turn: drain, the foe's move, end-of-turn, back to the menu
for _ = 1, 400 do
if battle.phase == "menu" and #battle.queue == 0 then break end
U.tap(game, "a")
U.wait(4)
end
check("the foe took its turn after the item (#379)", turns >= 1)
check("end-of-turn effects ran (#379)", ends >= 1)
check("and the FIGHT menu came back", battle.phase == "menu")
U.shot(game, DIR .. "/bug379_back_on_menu.png")
end
end
-- ======== control: a refused POTION is free =============================
U.log("#379 control: a POTION on a full-HP mon is refused, turn NOT spent")
lead.hp = lead.stats.hp
local freeTurns = 0
battle.executeAction = function(self, ...)
freeTurns = freeTurns + 1
return realAction(self, ...)
end
local held = game.save.inventory.POTION or 0
local bag2 = openBagFrom(battle)
if check("the bag reopened for the refusal", bag2 ~= nil) then
chooseItem(bag2, "POTION")
local picker = top()
if isPicker(picker) then
cursorTo(picker, 1)
U.tap(game, "a")
U.wait(12)
end
for _ = 1, 60 do
if isBox(top()) then break end
U.wait(1)
end
local box = top()
if check("the refusal printed", isBox(box)) then
U.wait(40)
check("...as \"It won't have any effect.\"",
boxText(box):find("effect", 1, true) ~= nil)
U.shot(game, DIR .. "/bug379_refused.png")
end
check("a refused item costs no turn (item_effects.asm:1241)", freeTurns == 0)
check("and the POTION was not consumed",
(game.save.inventory.POTION or 0) == held)
end
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- re-arm and hand the pad over --------------------------------------
for _ = 1, 200 do
if battle.phase == "menu" and #battle.queue == 0 then break end
U.tap(game, "a")
U.wait(4)
end
lead.hp = 12
local handoff = openBagFrom(battle)
if handoff then
for i, r in ipairs(handoff.items or {}) do
if r.value == "POTION" then cursorTo(handoff, i) break end
end
end
U.log("The ITEMS list is open mid-battle with the cursor on POTION. Press A,")
U.log("pick CHARIZARD: the picker and the list both close, \"CHARIZARD's HP was")
U.log("restored!\" prints with the heal chime, the HUD bar climbs, then PIDGEY")
U.log("attacks before FIGHT comes back. #379 was no message and a free heal.")
while true do
coroutine.yield()
end
end
+273
View File
@@ -0,0 +1,273 @@
-- Driver: the applying-attack shake pokered plays after a move's own
-- animation (#354). PlayApplyingAttackAnimation dispatches wAnimationType
-- 1..6 through AnimationTypePointerTable (engine/battle/animations.asm:475);
-- only 1 (vertical shake) and 4 (blink the enemy pic) were wired, so an
-- added-effect move like BUBBLEBEAM blinked instead of shaking sideways and a
-- status move showed nothing. Wiring half: tests/parity_applying_attack_anim.lua.
-- Never under POKEPORT_SPEED: fast-forward desynchronizes the damage sound.
-- POKEPORT_DRIVER=tests/drivers/battle_shake_bug354_test.lua POKEPORT_IDENTITY=bug354 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- pokered core.asm:3159 / effects.asm:1448: a player damaging move with an
-- added effect is type 5, without one type 4, and a primary status effect
-- that ends in PlayCurrentMoveAnimation2 is type 6. `frames` counts only
-- the frames the screen sits off-centre: b=2 out for 5 frames twice, and
-- the slow creep's five off-centre steps of 2 frames, twice over.
local MOVES = {
{ slot = 1, id = "BUBBLEBEAM", want = 5, peak = 2, frames = 10 },
{ slot = 4, id = "TACKLE", want = 4 },
{ slot = 3, id = "GROWL", want = 6, peak = 3, frames = 20 },
{ slot = 2, id = "HYPNOSIS", want = 6, peak = 3, frames = 20 },
}
U.log("#354 applying-attack shake: machine checks")
for _, m in ipairs(MOVES) do
check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil)
end
local anims = game.data.battle_anims and game.data.battle_anims.moveAnims
check("battle_anims carries BUBBLEBEAM's own animation",
anims ~= nil and anims.BUBBLEBEAM ~= nil)
check("...and HYPNOSIS's", anims ~= nil and anims.HYPNOSIS ~= nil)
check("BattleState:applyHitFx exists", type(BattleState.applyHitFx) == "function")
check("animations are on in OPTIONS (a shake is gated on them)",
game.save.options.animations ~= false)
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the damage thud under the shake will be SILENT,")
U.log("raise it in OPTION before judging the sound")
else
U.log("sfxVol", tostring(vol), "-- the damage thud opens types 1, 2, 4 and 5;")
U.log("types 3 and 6 (the slow creeps) are silent on purpose")
end
-- ---- fixture -----------------------------------------------------------
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 30)
squirtle.moves = {}
for _, m in ipairs(MOVES) do
squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp }
end
game.save.party = { squirtle }
game.save.player.name = "RED"
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground, and the
-- battle is pushed straight in rather than encountered
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
local ow = game.overworld
if not ow.map:isWalkableCell(5, 5) then
-- a map edit moved the path: take the nearest walkable cell instead
local sx, sy
for r = 1, 6 do
for dy = -r, r do
for dx = -r, r do
if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then
sx, sy = 5 + dx, 5 + dy
end
end
end
if sx then break end
end
if sx then
U.log("(5, 5) is blocked, standing on", sx, sy)
U.teleport(game, "ROUTE_1", sx, sy, "down")
U.wait(10)
ow = game.overworld
end
end
check("the overworld is up on ROUTE_1", ow ~= nil)
local battle = BattleState.newWild(game, "PIDGEY", 20)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
-- a foe that survives four turns: every type needs its own turn to show
battle.enemy.mon.stats.hp = 400
battle.enemy.mon.hp = 400
battle.enemy.shownHP = 400
-- ---- watch the fx layer ------------------------------------------------
-- one entry per applying-attack row, labelled with the move that queued it
local rows, cur, using = {}, nil, nil
local realPerform, realFx = battle.performMove, battle.applyHitFx
battle.performMove = function(self, user, target, moveInst, ...)
using = { id = moveInst and moveInst.id, isPlayer = user.isPlayer }
return realPerform(self, user, target, moveInst, ...)
end
battle.applyHitFx = function(self, hit)
cur = { move = using and using.id, isPlayer = using and using.isPlayer,
animType = hit.animType, frames = 0, peakX = 0, peakY = 0 }
rows[#rows + 1] = cur
realFx(self, hit)
-- a spent blink table stays on fx with frames = 0, so freshness matters
local bl = self.fx and self.fx.blink
cur.blinked = bl ~= nil and bl.frames > 0
end
local function sample()
local fx = battle.fx
if not (cur and fx) then return end
local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0)
if dx > 0 or dy > 0 then
cur.frames = cur.frames + 1
cur.peakX = math.max(cur.peakX, dx)
cur.peakY = math.max(cur.peakY, dy)
end
end
-- step n frames, sampling every one, pressing A every `mash` frames
local function pump(n, mash, stop)
for i = 1, n do
if mash and i % mash == 0 then
table.insert(game.input.pressQueue, "a")
end
U.wait(1)
game.input.state.a = false
sample()
if stop and stop() then return end
end
end
local function toMenu()
pump(400, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
return battle.phase == "menu"
end
-- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where up/down
-- walk the slots. A press that lands on a frame the battle is not reading
-- input is simply lost, so every step retries instead of assuming.
local function useMove(slot)
for _ = 1, 40 do
if battle.phase == "moveSelect" then break end
if battle.phase == "menu" then
if battle.menuIndex ~= 1 then
U.tap(game, battle.menuIndex > 2 and "up" or "left")
else
U.tap(game, "a")
end
end
U.wait(4)
end
if battle.phase ~= "moveSelect" then return false end
for _ = 1, 30 do
if battle.moveIndex == slot then break end
U.tap(game, battle.moveIndex < slot and "down" or "up")
U.wait(3)
end
if battle.moveIndex ~= slot then return false end
for _ = 1, 20 do
if battle.phase ~= "moveSelect" then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
local function lastRowFor(id)
for i = #rows, 1, -1 do
if rows[i].move == id and rows[i].isPlayer then return rows[i] end
end
return nil
end
local SHOTS = { BUBBLEBEAM = "bug354_bubblebeam_shake.png",
HYPNOSIS = "bug354_hypnosis_creep.png",
TACKLE = "bug354_tackle_blink.png" }
check("the battle reached its FIGHT menu", toMenu())
for _, m in ipairs(MOVES) do
local sent = useMove(m.slot)
check("chose " .. m.id .. " from the move menu", sent)
if sent then
-- catch the shake mid-flight for the screenshot: the offset is live for
-- only a couple of dozen frames
local shotAt
-- A every 8 frames: the text box between the announcement and the
-- animation waits on the button like any other
pump(400, 8, function()
local live = battle.fx and (m.peak and (battle.fx.shakeX or 0) ~= 0
or (not m.peak and battle.fx.blink
and battle.fx.blink.frames > 0))
if not shotAt and live and SHOTS[m.id] then
shotAt = true
U.shot(game, DIR .. "/" .. SHOTS[m.id])
end
local r = lastRowFor(m.id)
return r ~= nil and battle.fx.shakeProg == nil
and (r.frames > 0 or r.blinked)
end)
toMenu()
local r = lastRowFor(m.id)
check(m.id .. " queued an applying-attack row", r ~= nil)
if r then
check(("%s is animation type %d (got %s)")
:format(m.id, m.want, tostring(r.animType)),
r.animType == m.want)
U.log((" %s: %d frames off-centre, peak %dpx across / %dpx down, blink %s")
:format(m.id, r.frames, r.peakX, r.peakY, tostring(r.blinked)))
if m.peak then
-- the exact frame count is pinned in the parity suite; sampling from
-- a driver misses a frame whenever the logic step outruns the render
check(("%s moved the screen %dpx sideways"):format(m.id, m.peak),
r.peakX == m.peak)
check(("%s held it off-centre (%d of the routine's %d frames)")
:format(m.id, r.frames, m.frames), r.frames > 0)
check(m.id .. " did not blink the enemy pic instead (#354)",
r.blinked == false)
else
check(m.id .. " still blinks the enemy pic and holds still",
r.blinked == true and r.frames == 0)
end
end
if SHOTS[m.id] and not shotAt and m.peak then
U.log(" no off-centre frame to capture for " .. m.id)
end
end
end
-- the foe's own turns: a plain damaging move is type 1, one with an added
-- effect type 2, SAND-ATTACK type 3
local foe = {}
for _, r in ipairs(rows) do
if not r.isPlayer then
foe[#foe + 1] = ("%s type %s (%d frames, %dpx across, %dpx down)")
:format(tostring(r.move), tostring(r.animType),
r.frames, r.peakX, r.peakY)
end
end
U.log("the foe's rows so far: " .. (#foe > 0 and table.concat(foe, "; ")
or "none yet"))
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- hand off ----------------------------------------------------------
U.log("The pad is yours at the FIGHT menu. Slot 1 BUBBLEBEAM: after the")
U.log("bubbles and their white flashes the whole screen -- field, HUD, text")
U.log("box -- snaps 2px right and home four times, with the damage thud.")
U.log("Slot 2 HYPNOSIS and slot 3 GROWL creep it 1px at a time out to 3px")
U.log("and back, twice, in silence. Slot 4 TACKLE is the control: the foe's")
U.log("pic blinks and nothing moves. Let the PIDGEY hit back too -- a plain")
U.log("move drops the screen 8px vertically, SAND-ATTACK creeps it 6px across.")
U.log("Reference: https://youtu.be/4aBT7rjZoIE at 1:19.")
U.log("Screenshots: " .. DIR .. "/bug354_*.png")
while true do
coroutine.yield()
end
end
+205
View File
@@ -0,0 +1,205 @@
-- Eye/ear check on which animation BIDE plays when (#375). BideEffect ends on
-- `add XSTATITEM_ANIM / jp PlayBattleAnimation2` (engine/battle/effects.asm:
-- 764-789), so the storing turn is the silent X-item spiral; BIDE's own row
-- (sound Battle_18) belongs to .UnleashEnergy (engine/battle/core.asm:3501-3529).
-- POKEPORT_DRIVER=tests/drivers/bide_anim_bug375_test.lua POKEPORT_IDENTITY=bug375 POKEPORT_TOUCH=0 love .
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local ChipAudio = require("src.core.ChipAudio")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- data/generated/maps.lua ROUTE_1: the open path south of the first grass
-- patch, and pokered data/maps/objects/Route1.asm parks its youngsters at
-- (5, 24) and (15, 13), so nobody is standing here or watching.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 5, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear and eye cannot check ---------------------------------
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: the release hit plays no sound at all, so")
U.log(" the half of this that is audible cannot be judged. Set SFX")
U.log(" to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
if opts.animations == false then
U.log("FAIL OPTION has animations off, which skips every queued anim row")
U.log(" (BattleState:animationsOn). Turn ANIMATION on first.")
end
check("battle animations are on", opts.animations ~= false)
local moveDef = game.data.moves.BIDE
check("BIDE is in the move table", moveDef ~= nil)
local anims = (game.data.battle_anims or {}).moveAnims or {}
check("BIDE has an animation", anims.BIDE ~= nil)
check("XSTATITEM_ANIM has an animation", anims.XSTATITEM_ANIM ~= nil)
check("XSTATITEM_DUPLICATE_ANIM has an animation",
anims.XSTATITEM_DUPLICATE_ANIM ~= nil)
-- the row's sound byte is a move id; playAnimSound resolves it through
-- moves.lua, which is where the actual sfx program name lives
local sfxName = moveDef and moveDef.anim and moveDef.anim.sound
check("BIDE's animation names a sound (" .. tostring(sfxName) .. ")",
sfxName ~= nil)
if sfxName then
local src = ((game.data.audio or {}).sfx or {})[sfxName]
and ChipAudio.newSfx(game.data, sfxName) or nil
local secs = src and src:getDuration() or 0
check(("%s synthesizes (%.2fs)"):format(sfxName, secs), secs > 0)
end
-- ---- a BIDE user, parked on Route 1 ------------------------------------
local lead = Pokemon.new(game.data, "CHARMANDER", 20)
lead.moves = { { id = "BIDE", pp = 10 } }
game.save.party = { lead }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(15)
local ow = game.overworld
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit or a mod blocked the cell: take any free neighbour
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(15)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow.map.id == MAP)
-- Every anim row this battle queues, in order. Polling the queue catches
-- the announcement-time row performMove inserts directly as well as the
-- ones animNext adds, which is the whole difference the fix makes.
local function watchAnims(battle)
local seen, mark = {}, {}
return seen, function()
for _, row in ipairs(battle.queue) do
if row.anim and not mark[row] then
mark[row] = true
seen[#seen + 1] = row.anim
end
end
end
end
local function listOf(seen)
return #seen > 0 and table.concat(seen, ", ") or "(none)"
end
local function has(seen, name)
for _, n in ipairs(seen) do
if n == name then return true end
end
return false
end
-- Mash A while polling, so a row that comes and goes inside the wait is
-- still recorded.
local function mash(battle, poll, taps, gap)
for _ = 1, taps do
U.tap(battle.game, "a")
for _ = 1, gap do poll() U.wait(1) end
end
end
-- the wipe and the send-out chain hold the battle in "intro" for a while,
-- and the send-out text needs the A presses to advance
local function waitPhase(battle, phase, tries, poll)
for _ = 1, tries do
if battle.phase == phase then return true end
mash(battle, poll, 1, 6)
end
return battle.phase == phase
end
-- FIGHT then the only move in the list
local function pickBide(battle, poll)
U.tap(battle.game, "a")
if not waitPhase(battle, "moveSelect", 10, poll) then return false end
U.tap(battle.game, "a")
return true
end
-- ---- player side -------------------------------------------------------
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local seen, poll = watchAnims(battle)
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
check("BIDE is the move on the list", pickBide(battle, poll))
for _ = 1, 20 do poll() U.wait(1) end
U.shot(game, DIR .. "/bug375_store_spiral.png")
for _ = 1, 30 do poll() U.wait(1) end
U.shot(game, DIR .. "/bug375_store_text.png")
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
battle.player.bideTurns ~= nil)
-- ride out the locked turns: the storing text needs A presses and the menu
-- never comes back until BIDE unleashes
local released = false
for _ = 1, 120 do
U.tap(game, "a")
for _ = 1, 6 do poll() U.wait(1) end
if has(seen, "BIDE") then
released = true
break
end
if game.stack:top() ~= battle or battle.player.mon.hp <= 0 then break end
end
U.shot(game, DIR .. "/bug375_release.png")
check("the release queued BIDE's own animation: " .. listOf(seen), released)
for _ = 1, 60 do poll() U.wait(1) end
-- ---- enemy side --------------------------------------------------------
-- PlayBattleAnimation2 adds hWhoseTurn, so the foe's storing turn is
-- XSTATITEM_DUPLICATE_ANIM (AttackAnimationPointers[174]), never the
-- player row.
for _ = 1, 8 do
if game.stack:top() == ow then break end
game.stack:pop()
end
lead.hp = lead.stats.hp
local foeBattle = BattleState.newWild(game, "RATTATA", 8)
foeBattle.onFinish = function() end
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
ow:pushBattle(foeBattle)
local foeSeen, foePoll = watchAnims(foeBattle)
check("the foe's battle reached the menu",
waitPhase(foeBattle, "menu", 60, foePoll))
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
for _ = 1, 60 do
if has(foeSeen, "XSTATITEM_DUPLICATE_ANIM") then break end
U.tap(game, "a")
for _ = 1, 6 do foePoll() U.wait(1) end
end
U.shot(game, DIR .. "/bug375_enemy_store.png")
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
U.log("The pad is yours in a battle where both sides know only BIDE, so")
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
U.log("white and balls spiral inward, silently, and then it says storing")
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
U.log("unleashes, where the thud and its animation come after the text and")
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,169 @@
-- Manual check on the Celadon Mansion roof house blackboard and pamphlet (#391).
-- pokered data/events/hidden_events.asm CELADON_MANSION_ROOF_HOUSE declares both
-- with hidden_text_predef (LinkCableHelp, TMNotebook), which the extractor never
-- parsed, so both A presses were dead. The data half is in
-- tests/parity_celadon_roof_readables.lua.
-- POKEPORT_DRIVER=tests/drivers/celadon_roof_readables_bug391_test.lua POKEPORT_IDENTITY=bug391 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . (no POKEPORT_SPEED: fast-forward desyncs audio)
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local Menu = require("src.ui.Menu")
-- data/scripts/init.lua, not src/script/MapScripts.lua: OverworldController
-- requires it lazily on the first map load, so the registrations it makes
-- are not in place yet when the driver starts
local MapScripts = require("data.scripts.init")
local MAP = "CELADON_MANSION_ROOF_HOUSE"
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- hidden_events.asm: hidden_text_predef 3, 4 (pamphlet on the table) and
-- 3, 0 / 4, 0 (blackboard in the north wall). Both target cells are solid,
-- so the stand cell is the walkable one below, facing up.
local PAMPHLET = { x = 3, y = 4 }
local BLACKBOARD = { x = 3, y = 0 }
local BLACKBOARD_ALT = { x = 4, y = 0 }
local BALL = { x = 4, y = 3 }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local hooks = MapScripts.get(MAP)
check(MAP .. " registers an onInteract hook",
hooks ~= nil and type(hooks.onInteract) == "function")
check("the Eevee ball talk entry is still registered",
hooks ~= nil and hooks.talk ~= nil
and hooks.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL ~= nil)
for _, key in ipairs({ "_LinkCableHelpText1", "_LinkCableHelpText2",
"_LinkCableInfoText1", "_LinkCableInfoText2", "_LinkCableInfoText3" }) do
local s = game.data.text[key]
check(key .. " resolves to a string", type(s) == "string" and s ~= "")
end
local opts = game.save.options or {}
U.log("audio device present:", love.audio ~= nil,
" SFX VOL (0-7):", tostring(opts.sfxVol))
if not love.audio or opts.sfxVol == 0 then
U.log("WARNING: sfx output is off, so the menu beeps and the wall bonk will",
"not be audible; raise SFX VOL in OPTION first")
end
-- reach the pamphlet: warps land at (2,7)/(3,7), so start below the table
U.teleport(game, MAP, PAMPHLET.x, PAMPHLET.y + 2, "up")
U.wait(10)
local ow = game.overworld
check("map " .. MAP .. " loaded", ow ~= nil and ow.map ~= nil)
check("the pamphlet cell is solid, as the table it sits on",
not ow.map:isWalkableCell(PAMPHLET.x, PAMPHLET.y))
check("the blackboard cell is solid, as the wall it hangs on",
not ow.map:isWalkableCell(BLACKBOARD.x, BLACKBOARD.y))
U.hold(game, "up", 20)
U.wait(10)
-- a map edit or a mod could move the table: any walkable neighbour will do,
-- {dx, dy, facing} is the offset from the readable plus the look back at it
local SIDES = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
-- alt is the blackboard's second cell: hidden_text_predef declares 3,0 and
-- 4,0 with the same handler, so either one facing up is the right spot
local function faceCell(cell, alt)
local p = game.overworld.player
local fx, fy = p:facingCell()
if fx == cell.x and fy == cell.y then return true end
if alt and fx == alt.x and fy == alt.y then return true end
for _, s in ipairs(SIDES) do
local cx, cy = cell.x + s[1], cell.y + s[2]
if game.overworld.map:isWalkableCell(cx, cy)
and not game.overworld:npcAtCell(cx, cy) then
U.log(("(%d,%d) was not faced from where the walk ended, standing on")
:format(cell.x, cell.y), cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
return true
end
end
return false
end
local function boxText(box)
local out = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " / ")
end
check("standing where the pamphlet is faced", faceCell(PAMPHLET))
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
local isBox = getmetatable(top) == TextBox
check("A on the pamphlet opened a text box", isBox)
if isBox then
U.log("box reads:", boxText(top))
check("it is the TM pamphlet, not the generic bookshelf line",
boxText(top):find("pamphlet", 1, true) ~= nil)
U.shot(game, SHOT_DIR .. "/bug391_pamphlet.png")
end
-- clear the box: five pages of TMNotebookText, then back to the overworld
for _ = 1, 40 do
if game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(8)
end
check("the pamphlet closed and gave the overworld back",
game.stack:top() == game.overworld)
-- reach the blackboard: the table blocks column 3 between the two, so restart
-- from the row below the wall rather than walking the long way around it
U.teleport(game, MAP, BLACKBOARD.x, BLACKBOARD.y + 2, "up")
U.hold(game, "up", 20) -- one step onto row 1, then a bonk on the north wall
U.wait(10)
U.log("walked to", game.overworld.player.cellX, game.overworld.player.cellY,
"facing", game.overworld.player.facing)
check("standing where the blackboard is faced",
faceCell(BLACKBOARD, BLACKBOARD_ALT))
U.tap(game, "a")
U.wait(30)
top = game.stack:top()
isBox = getmetatable(top) == TextBox
check("A on the blackboard opened a text box", isBox)
if isBox then
U.log("box reads:", boxText(top))
check("it is _LinkCableHelpText1",
boxText(top):find("TRAINER TIPS", 1, true) ~= nil)
end
-- through the intro and the prompt into the heading menu
local menu
for _ = 1, 40 do
local t = game.stack:top()
if getmetatable(t) == Menu then menu = t break end
U.tap(game, "a")
U.wait(8)
end
check("the prompt opened the heading menu", menu ~= nil)
if menu then
local labels = {}
for i, item in ipairs(menu.items or {}) do labels[i] = item.label end
U.log("menu rows:", table.concat(labels, " / "))
check("four rows in HowToLinkText order",
table.concat(labels, "/")
== "HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING")
U.shot(game, SHOT_DIR .. "/bug391_blackboard_menu.png")
U.log("captured", SHOT_DIR .. "/bug391_blackboard_menu.png")
end
U.log("The heading menu on screen is the moment to eyeball: a 15x10 box in")
U.log("the top-left corner, four rows, nothing clipped. Pick a heading and")
U.log("its blurb prints, then the prompt and this menu should come back.")
U.log("B or STOP READING drops you back outside and you can walk again.")
U.log(("Then stand at (%d,%d) facing down and press A: the ball on the table")
:format(BALL.x, BALL.y - 1))
U.log("still hands over EEVEE, since the same script file changed.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,139 @@
-- Manual check that the elevator floor menu waits for the panel (#395).
-- pokered data/maps/objects/CeladonMartElevator.asm has it as `bg_event 3, 0,
-- TEXT_CELADONMARTELEVATOR`, and only that text reaches
-- DisplayElevatorFloorMenu (engine/events/elevator.asm), which rewrites the
-- car's warps and returns without moving the player. No POKEPORT_SPEED here:
-- fast-forward desynchronizes the ride's audio.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/elevator_panel_bug395_test.lua POKEPORT_IDENTITY=bug395 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local ListMenu = require("src.ui.ListMenu")
local mapScripts = require("data.scripts.init")
-- data/generated/maps.lua CELADON_MART_ELEVATOR: sign (3,0) is the panel,
-- warp (1,3) is the arrival tile, row 0 is wall, so the panel is read from
-- (3,1) facing up. 2F is the floor picked below.
local MAP = "CELADON_MART_ELEVATOR"
local TEXT = "TEXT_CELADONMARTELEVATOR"
local ARRIVE = { x = 1, y = 3 }
local PANEL = { x = 3, y = 0 }
local STAND = { x = 3, y = 1, facing = "up" }
local PICK = "CELADON_MART_2F"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local def = game.data.maps[MAP]
local sign
for _, s in ipairs(def.signs or {}) do
if s.text == TEXT then sign = s end
end
check(TEXT .. " is a sign in the map data", sign ~= nil)
if sign then
check(("the panel sits at (%d,%d)"):format(PANEL.x, PANEL.y),
sign.x == PANEL.x and sign.y == PANEL.y)
end
check("the panel text runs a script", type(mapScripts.talkScript(MAP, TEXT)) == "function")
local vol = game.save.options and game.save.options.sfxVol
if (vol or 0) == 0 then
U.log("sfxVol is 0: the ride will be silent, raise it in OPTION first")
else
U.log("sfxVol", tostring(vol), "-- 100 collision thuds then the PA chime")
end
U.teleport(game, MAP, ARRIVE.x, ARRIVE.y, "up")
U.wait(10)
local ow = game.overworld
check("no menu opened on arrival", getmetatable(game.stack:top()) ~= ListMenu)
U.shot(game, DIR .. "/elev395_0_entered.png")
local function facingThePanel()
local fx, fy = ow.player:facingCell()
return ow.map:signAtCell(fx, fy) ~= nil
end
-- walk over to the panel, one cell per hold
U.hold(game, "right", 24)
U.hold(game, "right", 24)
U.hold(game, "up", 24)
U.hold(game, "up", 24)
U.wait(10)
U.log("standing at", ow.player.cellX, ow.player.cellY, ow.player.facing)
if not facingThePanel() and sign then
-- a map edit moved the panel: stand on any walkable neighbour of it.
-- {dx, dy, facing} is the offset from the panel plus the way back at it.
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = sign.x + s[1], sign.y + s[2]
if ow.map:isWalkableCell(cx, cy) then
U.log("panel moved; standing on", cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
ow = game.overworld
break
end
end
end
check("the player is facing the panel", facingThePanel())
U.tap(game, "a")
U.wait(20)
local menu = getmetatable(game.stack:top()) == ListMenu
check("A on the panel opens the floor list", menu)
if menu then
local labels = {}
for _, item in ipairs(game.stack:top().items or {}) do
labels[#labels + 1] = item.label
end
U.log("floors listed:", table.concat(labels, " "))
end
U.shot(game, DIR .. "/elev395_1_menu.png")
U.tap(game, "down") -- 1F -> 2F
U.wait(2)
U.tap(game, "a")
-- 9 zero frames (Celadon farjps into ShakeElevator with no extra Delay3),
-- then -1,-1,+1,+1 in 2-frame steps
local trace = {}
for _ = 1, 24 do
trace[#trace + 1] = tostring(ow.bgShakeY or 0)
U.wait(1)
end
U.log("bgShakeY after the pick:", table.concat(trace, ","))
U.shot(game, DIR .. "/elev395_2_shake_a.png")
U.shot(game, DIR .. "/elev395_3_shake_b.png") -- the other phase of the bounce
local ElevatorShake = require("src.world.ElevatorShake")
for _ = 1, 1200 do
U.wait(1)
if getmetatable(game.stack:top()) ~= ElevatorShake then break end
end
U.wait(20)
check("the ride left the player in the car", ow.map.id == MAP)
check("and standing where they read the panel",
ow.player.cellX == STAND.x and ow.player.cellY == STAND.y)
local dests = {}
for _, w in ipairs(ow.map.def.warps) do dests[#dests + 1] = tostring(w.destMap) end
U.log("car exit warps now point at:", table.concat(dests, " "))
check("the exits were rewritten to " .. PICK, dests[1] == PICK)
U.shot(game, DIR .. "/elev395_4_rode.png")
U.log("The ride is over and the pad is yours: walk down and left onto the")
U.log("door tile at (2,3) and you should come out on CELADON MART 2F. The")
U.log("panel is behind you -- A on it should reopen WHICH FLOOR?, and B out")
U.log("of that list should leave you in the car with 2F still the exit.")
U.log("Worth checking by hand: Silph Co (same panel cell, 11 floors) and the")
U.log("Rocket car, whose panel is at (1,1) so you face LEFT from (2,1) and")
U.log("with no LIFT KEY get \"It appears to need a key.\" and no list.")
while true do
coroutine.yield()
end
end
-47
View File
@@ -1,47 +0,0 @@
-- Driver: elevator ride (ShakeElevator, engine/overworld/elevator.asm).
-- Teleports onto the Celadon Mart elevator's exit tile (1,3) -- the real
-- arrival cell, where the floor menu opens on entry -- picks 2F, traces
-- the bgShakeY scroll offset through the 9-frame lead-in and first shake
-- cycles, screenshots both phases of the oscillation, then confirms the
-- post-ride walk-out onto the arrival floor (the car's exit warps are
-- rewritten and the player walks out, per scripts/CeladonMartElevator.asm,
-- instead of a jump-cut warp).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local ListMenu = require("src.ui.ListMenu")
U.teleport(game, "CELADON_MART_ELEVATOR", 1, 3, "up")
U.wait(5)
local ow = game.overworld
U.log("map:", ow.map.id,
"menu open:", tostring(getmetatable(game.stack:top()) == ListMenu))
U.shot(game, DIR .. "/elev_0_menu.png")
U.tap(game, "down") -- cursor 1F -> 2F
U.wait(2)
U.tap(game, "a") -- choose 2F: the ElevatorShake state pushes
-- expect 9 zero frames (Celadon farjps into ShakeElevator, no extra
-- Delay3), then -1,-1,+1,+1,... in 2-frame steps
local trace = {}
for _ = 1, 24 do
trace[#trace + 1] = tostring(ow.bgShakeY or 0)
U.wait(1)
end
U.log("bgShakeY after A:", table.concat(trace, ","))
U.shot(game, DIR .. "/elev_1_shake_a.png")
U.shot(game, DIR .. "/elev_2_shake_b.png") -- 3 frames later: other phase
-- ride out: rest of the 200 shake frames, the PA chime, then the
-- scripted walk-out; wait until the walk-out has warped onto the floor
for _ = 1, 1200 do
U.wait(1)
if ow.map.id ~= "CELADON_MART_ELEVATOR" and not ow.transitioning
and #ow.scriptMoves == 0 then
break
end
end
U.wait(10)
U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"bgShakeY:", tostring(ow.bgShakeY or 0))
U.shot(game, DIR .. "/elev_3_arrived.png")
end
+123
View File
@@ -0,0 +1,123 @@
-- Manual check that the exit mat you warp in on answers the first press of
-- DOWN (#378). The house door outside is a door tile, so it leaves
-- BIT_STANDING_ON_WARP set and ClearVariablesOnEnterMap never clears it
-- (home/overworld.asm:247, engine/overworld/player_state.asm:190).
-- POKEPORT_DRIVER=tests/drivers/exit_mat_bug378_test.lua POKEPORT_IDENTITY=bug378 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the audio clock.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Warp = require("src.world.Warp")
local HOUSE = "REDS_HOUSE_1F"
local TOWN = "PALLET_TOWN"
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
local ow
local function settle(mapId)
for _ = 1, 300 do
ow = game.overworld
if ow and ow.map.id == mapId and not ow.transitioning
and #ow.scriptMoves == 0 and not ow.player.moving then
break
end
U.wait(1)
end
U.wait(4)
ow = game.overworld
end
-- data/generated/maps.lua warps, extracted from pokered
-- data/maps/objects/PalletTown.asm and RedsHouse1F.asm: the town warp whose
-- destination is the house is the door tile, and the house's LAST_MAP warps
-- are the two mat cells on its bottom row.
local function warpTo(mapId, destMap)
for _, w in ipairs(game.data.maps[mapId].warps or {}) do
if w.destMap == destMap then return w end
end
return nil
end
local door = warpTo(TOWN, HOUSE)
local mat = warpTo(HOUSE, "LAST_MAP")
expect(door ~= nil, "Pallet Town has a warp into " .. HOUSE)
expect(mat ~= nil, HOUSE .. " has a LAST_MAP exit warp")
if not (door and mat) then error("map data has no door/mat pair to test") end
U.log(("door at (%d, %d), mat at (%d, %d)"):format(door.x, door.y,
mat.x, mat.y))
-- Approach the door from the south, which is the only side the walk-in works
-- from; if a map edit blocked that cell, take any walkable neighbour.
U.teleport(game, TOWN, door.x, door.y + 1, "up")
settle(TOWN)
local stand = { x = door.x, y = door.y + 1, facing = "up" }
if not ow.map:isWalkableCell(stand.x, stand.y) then
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = door.x + s[1], door.y + s[2]
if ow.map:isWalkableCell(cx, cy) then
U.log(("cell (%d, %d) is blocked, approaching from"):format(
stand.x, stand.y), cx, cy, "facing", s[3])
stand = { x = cx, y = cy, facing = s[3] }
U.teleport(game, TOWN, cx, cy, s[3])
settle(TOWN)
break
end
end
end
-- Walk in for real: teleporting straight onto the mat would never set the
-- flag the door sets, so the bug could not show up. The direction is
-- released the instant the warp lands, because a still-held d-pad walks the
-- player off the mat and stepping back onto it is ExtraWarpCheck's own
-- trigger, which would exit the house before the human sees anything.
for _ = 1, 120 do
if game.overworld and game.overworld.map.id == HOUSE then break end
table.insert(game.input.pressQueue, stand.facing)
game.input.state[stand.facing] = true
coroutine.yield()
end
game.input.state[stand.facing] = false
settle(HOUSE)
expect(ow.map.id == HOUSE, "walked in through the door, map:", ow.map.id)
local p = ow.player
expect(p.cellX == mat.x and p.cellY == mat.y,
("landed on the mat (%d, %d), got:"):format(mat.x, mat.y),
p.cellX, p.cellY)
expect(ow.map:warpAtCell(p.cellX, p.cellY) ~= nil,
"the cell under the player carries a warp entry")
expect(not ow.map:isWarpTileCell(p.cellX, p.cellY),
("the mat tile ($%02X) is no warp-activating tile, so nothing cleared "
.. "the flag"):format(ow.map:cellTile(p.cellX, p.cellY)))
expect(ow:canCollisionWarp(),
"BIT_STANDING_ON_WARP survived the warp, so DOWN can exit (#378)")
expect(Warp.onEdge(ow.map, p.cellX, p.cellY, "down") ~= nil,
"and DOWN off the south edge resolves to the exit warp")
expect(ow.warpEntryCell ~= nil,
"the completed-step arrival record is set, as #265 wants it")
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: a wrong bonk would be SILENT, raise it in OPTION first")
else
U.log("sfxVol", tostring(vol), "-- there should be no collision thud at all")
end
U.log("You are on the mat inside the house, already facing down. Press DOWN.")
U.log("Right: it fades out on the first press and you are back in Pallet")
U.log("Town on the door, which auto-steps you one cell south. Wrong: a")
U.log("collision thud and Red walks in place until you step off the mat.")
U.log("The stairs at the top right must still bonk, not bounce floors (#230).")
if fails > 0 then U.log(fails .. " check(s) failed before the handoff") end
while true do
coroutine.yield()
end
end
+158
View File
@@ -0,0 +1,158 @@
-- Ear check: a song cued while a jingle sounds must wait for it (#398).
-- A fanfare's sfx header claims the music channels and .playMusic rewrites
-- only the music-channel state, so a mid-jingle song stays muted (pokered
-- audio/engine_1.asm:39-56, :1343-1357).
-- POKEPORT_DRIVER=tests/drivers/fanfare_hold_bug398_test.lua POKEPORT_IDENTITY=bug398 POKEPORT_TOUCH=0 love .
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local ChipAudio = require("src.core.ChipAudio")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
-- data/generated/maps.lua ROUTE_1: the northern grass patch spans cells
-- (10-17, 6-8), and pokered data/maps/objects/Route1.asm keeps its
-- youngsters at (5, 24) and (15, 13), well south of it.
local MAP = "ROUTE_1"
local STAND = { x = 12, y = 7, facing = "down" }
local FANFARES = {
"Level_Up", "Caught_Mon", "Get_Item1", "Get_Item2",
"Get_Key_Item", "Pokedex_Rating", "Dex_Page_Added", "Pokeflute",
}
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
local musicVol = opts.musicVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: no jingle will sound at all, so nothing")
U.log(" below can be judged. Set SFX to 7 in OPTION first.")
end
if musicVol == 0 then
U.log("FAIL music volume is 0: the song that must stay held is muted")
U.log(" anyway. Set MUSIC to 7 in OPTION first.")
end
check(("sfx volume %d, music volume %d"):format(sfxVol, musicVol),
sfxVol > 0 and musicVol > 0)
for _, name in ipairs(FANFARES) do
local def = (game.data.audio.sfx or {})[name]
local src = def and ChipAudio.newSfx(game.data, name) or nil
local secs = src and src:getDuration() or 0
check(("%s synthesizes (%.2fs)"):format(name, secs), secs > 0.3)
end
local fanfares = game.data.audio.fanfares
check("Caught_Mon counts as a fanfare",
fanfares == nil or fanfares.Caught_Mon == true)
-- Music.lua:100 reaches into ChipAudio for the hold: a chip song is started
-- by ChipAudio, not by Music, so pausing Music's own source cannot cover it
check("ChipAudio.holdMusic exists for Music to call",
type(ChipAudio.holdMusic) == "function")
local routeSong = (game.data.audio.mapSongs or {})[MAP]
check(MAP .. " has a map theme to hold (" .. tostring(routeSong) .. ")",
routeSong ~= nil)
-- ---- park in the grass -------------------------------------------------
game.save.party = {
Pokemon.new(game.data, "PIDGEY", 4),
Pokemon.new(game.data, "CHARIZARD", 50),
}
game.save.inventory = { POKE_BALL = 20, POTION = 5 }
game.save.bagOrder = { "POKE_BALL", "POTION" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit or a mod blocked the cell: take any free neighbour
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow.map.id == MAP)
-- ---- the hold, measured ------------------------------------------------
-- Music hands its chip sources to ChipAudio, so the source the restore
-- built is only reachable through playMusic's return value.
local song
local realPlayMusic = ChipAudio.playMusic
ChipAudio.playMusic = function(...)
local src, err = realPlayMusic(...)
song = src or song
return src, err
end
local jingle = Sound.play(game.data, "Caught_Mon")
song = nil
-- BattleState:finish restores the map theme this way, and restoreMap clears
-- the current label so even the same theme is rebuilt and restarted
Music.restoreMap(game.data)
U.wait(45) -- past the threaded first-buffer window, still inside the jingle
local stillSounding = jingle ~= nil and jingle:isPlaying()
local songUp = song ~= nil and song:isPlaying()
if not stillSounding then
U.log("FAIL the jingle ended before the restore could be judged; rerun")
else
check("the map theme stays silent under the jingle", not songUp)
end
for _ = 1, 400 do
if not (jingle and jingle:isPlaying()) then break end
U.wait(1)
end
local back = false
for _ = 1, 120 do
if song and song:isPlaying() then back = true break end
U.wait(1)
end
check("the held theme comes back when the jingle ends", back)
ChipAudio.playMusic = realPlayMusic
-- ---- the real path, so the same beat can be heard in a battle ----------
local battle = BattleState.newWild(game, "PIDGEY", 3)
battle.rng = function(a, b) return a end -- clean capture, no wobbles
ow:pushBattle(battle)
for _ = 1, 14 do U.tap(game, "a") U.wait(6) end
battle.phase = "messages"
battle.afterQueue = "menu"
battle:throwBall("POKE_BALL")
local ChoiceBox = require("src.ui.ChoiceBox")
for _ = 1, 120 do
if game.stack:top() == ow then break end
if getmetatable(game.stack:top()) == ChoiceBox then
U.tap(game, "down") -- decline the nickname, or naming eats the mash
U.wait(2)
end
U.tap(game, "a")
U.wait(4)
end
if not check("the catch ran through to the overworld", game.stack:top() == ow) then
-- hand the pad over in the grass whatever the mash got stuck on
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
end
U.wait(60)
U.log("That was a capture mashed straight out of the battle, which is the")
U.log("tightest case: the caught jingle should sound alone start to finish")
U.log("and the route theme should only come in after its last note.")
U.log("You have 20 balls and a level 4 PIDGEY, so walk the grass and catch")
U.log("something, or let it level up, and listen for the same thing.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,187 @@
-- Manual check that STRENGTH / SURF / FLASH print their message over the
-- party menu and only then blink it away (#385). pokered
-- engine/menus/start_sub_menus.asm .flash/.strength/.surf all run PrintText
-- -> GBPalWhiteOutWithDelay3 -> jp .goBackToMap, so the menu is the backdrop
-- of the text and the blink is the menu closing. No POKEPORT_SPEED here:
-- POKEPORT_DRIVER=tests/drivers/field_move_layer_bug385_test.lua POKEPORT_IDENTITY=bug385 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local PartyMenu = require("src.ui.PartyMenu")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local fails = 0
local function check(ok, msg)
U.log(ok and "PASS" or "FAIL", msg)
if not ok then fails = fails + 1 end
return ok
end
U.newGame(game)
game.save.options = game.save.options or {}
game.save.options.textSpeed = 5 -- SLOW: the shot lands while it types
local vol = game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the STRENGTH cry will be silent, turn SFX up in OPTION")
else
U.log("sfxVol =", tostring(vol), "-- STRENGTH plays the mon's cry over the text")
end
-- every badge the three field moves are gated on at list time
game.save.inventory.BOULDERBADGE = true
game.save.inventory.SOULBADGE = true
game.save.inventory.RAINBOWBADGE = true
for _, key in ipairs({ "_UsedStrengthText", "_CanMoveBouldersText",
"_SurfingGotOnText", "_FlashLightsAreaText" }) do
local t = game.data.text[key]
check(type(t) == "string" and t ~= "", key .. " extracted from the ROM")
end
local function giveMon(species, move)
local mon = Pokemon.new(game.data, species, 30)
mon.moves = { { id = move, pp = 15 } }
game.save.party = { mon }
return mon
end
-- park on `cell` if it is free, else on any walkable neighbour of the
-- target that looks back at it (a map edit or a mod moves objects)
local function stand(mapId, cell, target)
U.teleport(game, mapId, cell.x, cell.y, cell.facing)
local ow = game.overworld
if ow.map:isWalkableCell(cell.x, cell.y) and not ow:npcAtCell(cell.x, cell.y) then
return ow
end
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = target.x + s[1], target.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(cell.x, cell.y),
cx, cy, "facing", s[3])
U.teleport(game, mapId, cx, cy, s[3])
return game.overworld
end
end
return ow
end
-- START -> POKéMON -> the mon -> the field-move row, by looking the rows
-- up rather than counting presses (a mod can insert either list)
local function openFieldMove(action)
U.tap(game, "start")
U.wait(12)
local menu = game.stack:top()
local row
for i, it in ipairs(menu and menu.items or {}) do
if tostring(it.label):upper():find("MON", 1, true) then row = i break end
end
if not check(row ~= nil, "START menu lists POKéMON") then return nil end
for _ = 2, row do U.tap(game, "down"); U.wait(2) end
U.tap(game, "a")
U.wait(12)
local pm = game.stack:top()
if not check(getmetatable(pm) == PartyMenu, "POKéMON opens the party menu") then
return nil
end
U.tap(game, "a") -- A on the mon builds the field-move submenu
U.wait(8)
local sub
for i, it in ipairs(pm.subItems or {}) do
if it.action == action then sub = i break end
end
if not check(sub ~= nil, action:upper() .. " is listed in the submenu") then
return nil
end
for _ = 2, sub do U.tap(game, "down"); U.wait(2) end
U.tap(game, "a")
return pm
end
-- the state the compositor starts from (StateStack:draw walks up from the
-- highest opaque state): what is actually behind the message
local function backdrop()
return game.stack.states[game.stack:visibleBase()]
end
local function textIsUp()
local top = game.stack:top()
return top ~= nil and top.pages ~= nil
end
local function drainText()
for _ = 1, 400 do
if not textIsUp() then break end
U.tap(game, "a")
U.wait(3)
end
end
-- ---------------------------------------------------------------- STRENGTH
-- pokered data/maps/objects/SeafoamIslands1F.asm: BOULDER1 at (18, 10),
-- so the floor to its west is the approach cell
giveMon("MACHOP", "STRENGTH")
local ow = stand("SEAFOAM_ISLANDS_1F", { x = 17, y = 10, facing = "right" },
{ x = 18, y = 10 })
check(ow:npcAtCell(18, 10) ~= nil, "the Seafoam boulder is loaded at (18,10)")
local pmStr = openFieldMove("strength")
U.wait(6)
check(textIsUp(), "STRENGTH puts a message up")
check(backdrop() == pmStr, "the party menu is the backdrop of the STRENGTH text")
U.shot(game, DIR .. "/bug385_strength.png")
drainText()
U.wait(60)
-- ---------------------------------------------------------------- SURF
-- pokered data/maps/objects/PalletTown.asm has no object on the south
-- shore; (4, 13) is the land cell above the water at (4, 14)
giveMon("SQUIRTLE", "SURF")
ow = stand("PALLET_TOWN", { x = 4, y = 13, facing = "down" }, { x = 4, y = 13 })
ow.player.surfing = false
check(ow:useSurfFieldMove() == "ok", "(4,13) faces surfable water")
local pmSurf = openFieldMove("surf")
U.wait(6)
check(textIsUp(), "SURF puts the got-on message up")
check(backdrop() == pmSurf, "the party menu is the backdrop of the got-on text")
U.shot(game, DIR .. "/bug385_surf.png")
drainText()
U.wait(60)
U.shot(game, DIR .. "/bug385_surf_after.png")
ow.player.surfing = false
-- ---------------------------------------------------------------- FLASH
-- pokered data/maps/objects/RockTunnel1F.asm: warp 1 is (15, 3), so
-- (15, 4) is a real walkable cell just inside the entrance
game.save.flashLit = false
giveMon("PIKACHU", "FLASH")
ow = stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 })
check(ow.dark == true, "ROCK_TUNNEL_1F is dark before FLASH")
local pmFlash = openFieldMove("flash")
U.wait(6)
check(textIsUp(), "FLASH puts a message up")
check(backdrop() == pmFlash, "the party menu is the backdrop of the FLASH text")
check(ow.dark == true, "the tunnel is still dark while the FLASH text is up")
U.shot(game, DIR .. "/bug385_flash_text.png")
drainText()
U.wait(40)
check(ow.dark == false, "the tunnel is lit once the blink hands the map back")
U.shot(game, DIR .. "/bug385_flash_after.png")
U.log(fails == 0 and "all machine checks passed" or (fails .. " machine checks failed"))
U.log("shots in", DIR)
-- hand the pad back on a dark tunnel with FLASH still unused
game.save.flashLit = false
giveMon("PIKACHU", "FLASH")
stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 })
U.log("You are in the dark tunnel with an unused FLASH: START, POKéMON, A, FLASH.")
U.log("The message should read over the six-row party list, not a white screen,")
U.log("and the tunnel should only be lit after the blink closes the menu.")
U.log("Same for SURF and STRENGTH in the shots: party list behind the text,")
U.log("one short blink, then the map.")
while true do
coroutine.yield()
end
end
+219
View File
@@ -0,0 +1,219 @@
-- Driver: the fishing POSE -- the standing frame's bottom tile row swapped for
-- RedFishingTiles so the rod ends in hands -- and its two-stage teardown, rod
-- OAM out at once, pose ~10 frames later (#384). FishingAnim / RedFishingTiles
-- / res BIT_LEDGE_OR_FISHING: player_animations.asm:378, :485, :449.
-- No POKEPORT_SPEED: fast-forward outruns the rendered frames and the audio
-- clock, so the ten-frame tail can neither be sampled nor watched.
-- POKEPORT_DRIVER=tests/drivers/fishing_pose_bug384_test.lua POKEPORT_IDENTITY=bug384 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox")
local SpriteRenderer = require("src.render.SpriteRenderer")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Same pond as tests/drivers/fishing_rod_bug321_test.lua: the Viridian City
-- water block (x=8..13, y=24..27, data/generated/maps.lua VIRIDIAN_CITY) is
-- the one place all four shores sit a few steps apart. shoreFor() re-derives
-- each stand cell from the map so a layout edit cannot park us at a wall.
local MAP = "VIRIDIAN_CITY"
local SPOTS = {
{ x = 10, y = 23, facing = "down", note = "north shore" },
{ x = 8, y = 28, facing = "up", note = "south shore" },
{ x = 14, y = 24, facing = "left", note = "east shore" },
{ x = 7, y = 24, facing = "right", note = "west shore" },
}
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
local ROD = "OLD_ROD"
-- ---- the halves of the drawing, before anything is on screen ------------
-- A missing sheet, a sheet of the wrong size and a Player that never picks
-- up fishTiles all look the same: a rod ending in mid air, which is the
-- shipped bug.
local fx = game.data.field.overworldFx
local POSE = {
{ key = "redFishFront", facing = "down", asm = "RedFishingTilesFront, tile $02" },
{ key = "redFishBack", facing = "up", asm = "RedFishingTilesBack, tile $06" },
{ key = "redFishSide", facing = "left", asm = "RedFishingTilesSide, tile $0a" },
}
for _, p in ipairs(POSE) do
local def = fx and fx[p.key]
if check(("field.overworldFx.%s resolves (%s)"):format(p.key, p.asm),
def ~= nil and type(def.path) == "string") then
local ok, img = pcall(love.graphics.newImage, def.path)
if check(("%s loads: %s"):format(p.key, def.path), ok and img ~= nil) then
local w, h = img:getDimensions()
check(("%s is 16x8, one 16-wide tile row"):format(p.key), w == 16 and h == 8)
end
end
end
check("SpriteRenderer:drawTile exists (the pose row wears the sprite's OBP)",
type(SpriteRenderer.drawTile) == "function")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.inventory = { [ROD] = 1 }
game.save.bagOrder = { ROD }
check("OLD ROD is a real item and the only thing in the bag",
game.data.items[ROD] ~= nil and (game.save.inventory[ROD] or 0) > 0)
local ItemEffects = require("src.inventory.ItemEffects")
check("using it routes to the fishing branch",
ItemEffects.use(game.data, game.save, ROD, nil, nil, nil, game.overworld) == "fish")
local function useRod()
Screens.push(game, "BagMenu")
U.wait(20)
U.tap(game, "a") -- OLD ROD -> USE/TOSS
U.wait(20)
U.tap(game, "a") -- USE
U.wait(30)
end
local function topBox()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local shown = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
return top, table.concat(shown, " / ")
end
local function shoreFor(map, spot)
local d = DELTA[spot.facing]
if map:inBounds(spot.x + d[1], spot.y + d[2])
and map:isWaterCell(spot.x + d[1], spot.y + d[2])
and map:isWalkableCell(spot.x, spot.y) then
return spot.x, spot.y
end
for y = 0, map.def.height * 2 - 1 do
for x = 0, map.def.width * 2 - 1 do
if map:isWalkableCell(x, y) and not map:isWaterCell(x, y)
and map:inBounds(x + d[1], y + d[2])
and map:isWaterCell(x + d[1], y + d[2]) then
U.log(("(%d,%d) no longer faces water; using (%d,%d) for %s")
:format(spot.x, spot.y, x, y, spot.facing))
return x, y
end
end
end
return spot.x, spot.y
end
-- park on a shore, cast, and stop on the dots box
local function castFrom(spot)
U.teleport(game, MAP, spot.x, spot.y, spot.facing)
U.wait(15)
local sx, sy = shoreFor(game.overworld.map, spot)
if sx ~= spot.x or sy ~= spot.y then
U.teleport(game, MAP, sx, sy, spot.facing)
U.wait(15)
end
local ow = game.overworld
local fcx, fcy = ow.player:facingCell()
check(("facing %s from (%d,%d), the %s: water in front")
:format(spot.facing, sx, sy, spot.note),
ow.map:inBounds(fcx, fcy) and ow.map:isWaterCell(fcx, fcy))
useRod()
return ow
end
-- ---- pose up, one facing at a time -------------------------------------
-- Every facing is left on its verdict box; the next teleport pops it, so no
-- bite ever reaches a battle here.
for _, spot in ipairs(SPOTS) do
local ow = castFrom(spot)
local dots = topBox()
check(("%s: USE opened the fishing box"):format(spot.facing), dots ~= nil)
check(("%s: the rod OAM is up (overworld.fishing)"):format(spot.facing),
ow.fishing ~= nil and ow.fishing.facing == spot.facing)
check(("%s: the pose is up (player.fishing)"):format(spot.facing),
ow.player.fishing == true)
check(("%s: a pose row exists for this facing"):format(spot.facing),
ow.player.fishTiles ~= nil and ow.player.fishTiles[spot.facing] ~= nil)
if spot.facing == "right" then
check("right reuses the left pose row, x-flipped like the sprite",
ow.player.fishTiles.right == ow.player.fishTiles.left)
end
-- RodResponse (engine/items/item_effects.asm:1878) zeroes wWalkBikeSurfState
-- across FishingAnim, so the sheet under the pose is always the walking one
local sheet = ow.player:pose()
check(("%s: the walking sheet is what draws (not surf/bike)"):format(spot.facing),
sheet == ow.player.sprite)
if not U.shot(game, ("%s/bug384_pose_%s.png"):format(SHOT_DIR, spot.facing)) then
check(("%s: screenshot reached disk"):format(spot.facing), false)
end
end
-- ---- the retract, sampled frame by frame --------------------------------
-- The rod OAM dies with the verdict box (res BIT_LEDGE_OR_FISHING right
-- after PrintText, player_animations.asm:449) but the patched player tiles
-- live until ReloadMapSpriteTilePatterns (home/reload_sprites.asm) a few
-- frames later. Only the no-bite verdict shows that gap: a bite goes
-- straight into a battle, which reloads the tiles itself, so drop OLD ROD's
-- `always` hook for the rest of this run.
local rodFishing = (game.data.field.fishing or {})[ROD]
check("field.fishing.OLD_ROD exists", rodFishing ~= nil)
if rodFishing then rodFishing.always = nil end
local ow = castFrom(SPOTS[4])
local dots = topBox()
if dots then
for _ = 1, 12 do
U.tap(game, "a")
U.wait(12)
if game.stack:top() ~= dots then break end
end
end
local verdict, verdictText = topBox()
check("the no-bite verdict box opened", verdict ~= nil)
if verdictText then U.log("box reads:", verdictText) end
local rodGone, poseGone
for i = 1, 180 do
if game.stack:top() == verdict then U.tap(game, "a") else U.wait(1) end
if not rodGone and ow.fishing == nil then rodGone = i end
if not poseGone and ow.player.fishing == nil then poseGone = i end
if poseGone then break end
end
check("the rod OAM came down", rodGone ~= nil)
check("the pose came down too", poseGone ~= nil)
if rodGone and poseGone then
U.log(("rod out on sample frame %d, pose out on %d"):format(rodGone, poseGone))
check("the pose OUTLIVES the rod (two stages, not one)", poseGone > rodGone)
check("the tail is around ten frames", poseGone - rodGone >= 5)
end
-- ---- hand off ----------------------------------------------------------
local live = castFrom(SPOTS[4])
if live.fishing then
local box = topBox()
if box then
for _ = 1, 12 do
U.tap(game, "a")
U.wait(12)
if game.stack:top() ~= box then break end
end
end
end
U.log("West shore of the Viridian pond, mid-cast, verdict box open. OLD ROD")
U.log("was set to never bite for this cast, so dismissing it stays on the map.")
U.log("The rod should read as one unbroken stroke: hands and near end in the")
U.log("player's lower body, far end in the loose tile, touching on the same")
U.log("scanline, and the lower row the same shade as the torso above it.")
U.log("Dismiss the box: the loose tile goes at once, the hands hold about ten")
U.log("frames, then the sprite snaps back. Both going together is the bug.")
U.log("Shots: " .. SHOT_DIR .. "/bug384_pose_<facing>.png")
while true do
coroutine.yield()
end
end
+2 -1
View File
@@ -181,7 +181,8 @@ return function(game)
U.log("Correct: one 8x8 rod stroke touching the player's hands, a mirror")
U.log("image left vs right, still up for the whole verdict box (#321).")
U.log("Shots: " .. SHOT_DIR .. "/bug321_rod_<facing>_{dots,verdict}.png")
U.log("Still unported: the fishing pose, the sprite shake, the ! bubble.")
U.log("The pose swaps the sprite's bottom tile row (#384); the sprite shake")
U.log("and the ! bubble on a bite are still unported.")
while true do
coroutine.yield()
+118
View File
@@ -0,0 +1,118 @@
-- Driver: the Rocket Hideout B4F lift gate stays barred until both guards fall,
-- then opens on the spot (#372). scripts/RocketHideoutB4F.asm ...DoorCallback-
-- Script stamps $2d at lb bc, 5, 12 until CheckBothEventsSet trainer_0/_1, then
-- SFX_GO_INSIDE and $e, and re-runs after a battle (home/trainers.asm
-- EndTrainerBattle). Data half: tests/parity_hideout_gate.lua. Never under
-- POKEPORT_SPEED: fast-forward desynchronizes the door sound from the stamp.
-- POKEPORT_DRIVER=tests/drivers/hideout_gate_bug372_test.lua POKEPORT_IDENTITY=bug372 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local FieldDefaults = require("src.world.FieldDefaults")
local MAP = "ROCKET_HIDEOUT_B4F"
local MAP_LABEL = "RocketHideoutB4F"
local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0"
local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
-- data/generated/maps.lua ROCKET_HIDEOUT_B4F: the gate is block (12,5),
-- i.e. cells (24-25, 10-11), the only gap in that wall row; the guards
-- (data/maps/objects/RocketHideoutB4F.asm) sit at (23,12) and (26,12) and
-- Giovanni at (25,3) with the SILPH SCOPE ball at (25,2) behind the gate.
local DOOR = { bx = 12, by = 5 }
local GATE_CELLS = { { 24, 11 }, { 25, 11 } }
local STAND = { x = 24, y = 13, facing = "up" }
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 40),
Pokemon.new(game.data, "NIDOKING", 38),
}
game.save.player.name = "RED"
-- a save that has been here before would already carry the guard events
game.save.flags[GUARD_0] = nil
game.save.flags[GUARD_1] = nil
game.save.defeatedTrainers = {}
local doors = FieldDefaults.fieldValue(game.data, "cardKeyDoors", "closedDoors")
local row = doors and doors[MAP] and doors[MAP][1]
check("the B4F gate has a closedDoors row (stale caches had none)", row ~= nil)
if row then
check("it stamps $2d over block (12,5)",
row.block == 0x2d and row.bx == DOOR.bx and row.by == DOOR.by)
check("it opens to $e on both guard events",
row.open == 0x0e and row.events ~= nil and #row.events == 2)
end
check("B1F carries its own row ($54 at (12,8))",
doors and doors.ROCKET_HIDEOUT_B1F ~= nil
and doors.ROCKET_HIDEOUT_B1F[1].block == 0x54)
check("the guards set the events the gate watches",
game.data:trainerHeader(MAP_LABEL, 2).event == GUARD_0
and game.data:trainerHeader(MAP_LABEL, 3).event == GUARD_1)
check("Go_Inside is in the generated audio",
game.data.audio and game.data.audio.sfx
and game.data.audio.sfx.Go_Inside ~= nil)
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the door sound will be SILENT, raise it in OPTION first")
else
U.log("sfxVol", tostring(vol), "-- expect one door sound as the gate opens")
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
-- a map edit or a mod moved the doorway: stand on any walkable cell
-- touching the gate instead of the cell below it
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
for _, cell in ipairs(GATE_CELLS) do
for _, off in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
{ 1, 0, "left" }, { -1, 0, "right" } }) do
local cx, cy = cell[1] + off[1], cell[2] + off[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d,%d) is not walkable, standing on"):format(STAND.x, STAND.y),
cx, cy, "facing", off[3])
U.teleport(game, MAP, cx, cy, off[3])
U.wait(10)
ow = game.overworld
break
end
end
end
end
check("the doorway is stamped shut on arrival",
ow.map:blockAt(DOOR.bx, DOOR.by) == 0x2d)
local blocked = true
for _, cell in ipairs(GATE_CELLS) do
if ow.map:isWalkableCell(cell[1], cell[2]) then blocked = false end
end
check("neither doorway cell can be stepped into", blocked)
local guards = 0
for _, n in ipairs(ow.npcs or {}) do
local name = n.def and n.def.name
if name == "ROCKETHIDEOUTB4F_ROCKET1" or name == "ROCKETHIDEOUTB4F_ROCKET2" then
guards = guards + 1
end
end
check("both guards are on the floor", guards == 2)
U.log(("machine checks: %d pass, %d fail"):format(pass, fail))
U.shot(game, (os.getenv("SHOT_DIR") or "/tmp/shots") .. "/bug372_gate_shut.png")
U.log("You are one cell south of the lift gate: barred, and walking up bumps.")
U.log("Beat one grunt and it is still barred; beat the second and the bars turn")
U.log("to floor as the fade ends, one door sound, then north to Giovanni.")
while true do
coroutine.yield()
end
end
+184
View File
@@ -0,0 +1,184 @@
-- Driver: a multi-hit move must step the enemy HP bar down once per strike
-- (#394), not empty it on hit 1 and freeze for the rest. pokered
-- ApplyDamageToEnemyPokemon (engine/battle/core.asm:4684-4727) subtracts
-- wDamage then runs UpdateHPBar2 inside the wNumAttacksLeft loop. Machine
-- half: tests/engine/multihit_hp_drain.lua. Never under POKEPORT_SPEED:
-- fast-forward desynchronizes the per-strike damage sound from the bar.
-- POKEPORT_DRIVER=tests/drivers/multihit_bug394_test.lua POKEPORT_IDENTITY=bug394 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local MOVE = "DOUBLESLAP"
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local moveDef = game.data.moves[MOVE]
check(MOVE .. " resolves in the move table", moveDef ~= nil)
check("...as a 2-to-5 strike move",
moveDef ~= nil and moveDef.effect == "TWO_TO_FIVE_ATTACKS_EFFECT")
check("SNORLAX resolves (a big HP pool, so each strike is visible)",
game.data.pokemon.SNORLAX ~= nil)
check("the damage sound is in the generated audio",
game.data.audio and game.data.audio.sfx
and game.data.audio.sfx.Damage ~= nil)
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the per-strike damage hits will be SILENT,",
"raise it in OPTION first")
else
U.log("sfxVol", tostring(vol), "-- expect one damage hit per strike")
end
-- :L50 so a SNORLAX counterattack cannot end the run before the handoff
local lead = Pokemon.new(game.data, "BULBASAUR", 50)
lead.moves = { { id = MOVE, pp = 10, maxPP = 10 } } -- one slot, no mis-pick
game.save.party = { lead }
-- offscreen scratch battle: the queue a multi-hit turn builds, with no
-- animation timing in the way. Each drain row has to name its target and
-- carry the HP left after its own strike; unpinned rows were the bug.
do
local seqIndex = 0
local scratch = BattleState.newWild(game, "SNORLAX", 30)
scratch.onFinish = function() end
scratch.rng = function(_, hi) -- 5 hits, hit, no crit, max damage roll
seqIndex = seqIndex + 1
local scripted = ({ 7, 0, 255, 255 })[seqIndex]
return scripted ~= nil and scripted or hi
end
local startHP = scratch.enemy.mon.hp
scratch:performMove(scratch.player, scratch.enemy, { id = MOVE, pp = 10 })
local stops, anims = {}, 0
for _, row in ipairs(scratch.queue) do
if row.drain then stops[#stops + 1] = row
elseif row.anim == MOVE then anims = anims + 1 end
end
check(("%d strikes queued %d animations"):format(#stops, anims),
#stops > 1 and anims == #stops)
local stepped, named = true, true
local prev = startHP
local shownStops = {}
for _, row in ipairs(stops) do
named = named and row.battler == scratch.enemy
stepped = stepped and type(row.stopAt) == "number" and row.stopAt < prev
prev = row.stopAt or prev
shownStops[#shownStops + 1] = tostring(row.stopAt)
end
check("every drain row names the enemy", named)
check("and stops on its own strike's HP, one step at a time", stepped)
check("the last stop is the post-turn HP", prev == scratch.enemy.mon.hp)
U.log(("enemy HP %d -> stops: %s"):format(startHP,
table.concat(shownStops, ", ")))
end
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the cell is read
-- off the loaded map so a map edit degrades to another walkable cell
-- instead of a wall.
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
local map = game.overworld.map
if not map:isWalkableCell(5, 5) then
local fx, fy
for cy = 0, map.heightCells - 1 do
for cx = 0, map.widthCells - 1 do
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
end
if fx then break end
end
if fx then
U.teleport(game, "ROUTE_1", fx, fy, "down")
U.wait(10)
end
end
local ow = game.overworld
check("player stands on a walkable ROUTE_1 cell",
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
local function mashUntil(cond, max)
for _ = 1, max or 120 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return cond()
end
local function newFight()
local battle = BattleState.newWild(game, "SNORLAX", 30)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(220) -- the send-out intro plays before the menu is reachable
mashUntil(function() return battle.phase == "menu" end)
return battle
end
-- ---- scripted run: watch the bar across the strikes ---------------------
local battle = newFight()
check("the wild battle reached its FIGHT menu", battle.phase == "menu")
U.shot(game, DIR .. "/bug394_menu.png")
U.tap(game, "a") -- FIGHT
U.wait(16)
U.tap(game, "a") -- the only move slot
U.wait(8)
-- sample on the falling edge of battle.draining, a frame at a time: a
-- coarser poll can miss a strike's drain entirely and read the next one's
-- rest twice. Rows that leave the enemy bar where it was (the foe's own
-- turn drains the player bar) and the full-bar reset a finished battle
-- does are skipped. A is tapped only every eighth frame, to walk the
-- queue's text rows without eating a whole drain.
local seen, wasDraining = {}, false
local lastShown = battle.enemy.shownHP or battle.enemy.mon.hp
for frame = 1, 2400 do
local draining = battle.draining ~= nil
if wasDraining and not draining then
local shown = battle.enemy.shownHP or battle.enemy.mon.hp
if shown < lastShown - 0.5 then
lastShown = shown
seen[#seen + 1] = shown
U.shot(game, DIR .. ("/bug394_hit%d.png"):format(#seen))
U.log(("after strike %d the enemy bar rests on %.0f HP, model HP %d")
:format(#seen, shown, battle.enemy.mon.hp))
end
end
wasDraining = draining
if not draining and battle.phase == "menu" and #battle.queue == 0 then break end
if not draining and frame % 8 == 0 then U.tap(game, "a") end
U.wait(1)
end
local rests = {}
for i, hp in ipairs(seen) do rests[i] = ("%.0f"):format(hp) end
U.log(("enemy bar rests: %s"):format(table.concat(rests, ", ")))
check(("more than one strike moved the enemy bar (%d)"):format(#seen), #seen > 1)
check("the first strike did not drain the whole turn's damage (#394)",
#seen > 1 and seen[1] > seen[#seen])
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- re-arm and hand the pad over --------------------------------------
lead.hp = lead.stats.hp
lead.status = nil
lead.moves[1].pp = lead.moves[1].maxPP
local handoff = newFight()
U.log("A fresh SNORLAX is waiting on FIGHT with DOUBLESLAP in the only slot.")
U.log("Press A twice and watch the enemy bar: it should tick down a bit on")
U.log("every strike, in step with the replayed slap and its damage hit, with")
U.log("the HP number dropping each time. Before #394 the whole bar emptied on")
U.log("slap one and then sat frozen for the rest.")
if handoff.phase ~= "menu" then
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
end
while true do
coroutine.yield()
end
end
@@ -0,0 +1,149 @@
-- Manual check that Oak's NIDORINO keeps its mirrored front sprite across the
-- _OakSpeechText2A -> _OakSpeechText2B page break (#397). OakSpeechText2 is one
-- PrintText over one flipped pic (pokered engine/movie/oak_speech/oak_speech.asm
-- :80-85, :172-177); advance() used to clear picFlip on the pic-less next step.
-- SHOT_DIR=/tmp/bug397 POKEPORT_DRIVER=tests/drivers/oak_nidorino_flip_bug397_test.lua POKEPORT_IDENTITY=bug397 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the cry from the wipe.
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function top() return game.stack:top() end
local function isBox(s) return getmetatable(s) == TextBox end
local function speechState()
for _, s in ipairs(game.stack.states or {}) do
if s.demoPic ~= nil or (s.steps and s.answers) then return s end
end
end
local function boxText(s)
if not isBox(s) then return "" end
local out = {}
for _, page in ipairs(s.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " / ")
end
local function stepId(speech)
local cur = speech.steps and speech.steps[speech.step]
return cur and (cur.id or cur.kind) or "?"
end
-- boot flow: attract movie -> title -> menu. With a save on disk NEW GAME is
-- the second row, so land on it the same way silly_oak_intro_test does.
U.wait(5)
U.tap(game, "start")
U.wait(15)
U.tap(game, "a")
U.wait(8)
local ok, saved = pcall(function()
return require("src.core.SaveData").load() ~= nil
end)
if ok and saved then
U.tap(game, "down")
U.wait(5)
end
U.tap(game, "a")
U.wait(20)
local speech
for _ = 1, 90 do
speech = speechState()
if speech then break end
U.wait(1)
end
if not check("NEW GAME entered Oak's speech", speech ~= nil) then
U.log("top is", tostring(top()), "-- CONTINUE was picked instead")
while true do coroutine.yield() end
end
local demoIdx
for i, s in ipairs(speech.steps or {}) do
if (s.kind or "say") == "demo" then demoIdx = i break end
end
check("the step list still has a demo beat", demoIdx ~= nil)
check("the show-off front sprite loaded (" ..
tostring(speech.demoSpecies) .. ")", speech.demoPic ~= nil)
local vol = game.save and game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the NIDORINO cry will be silent, raise it in OPTION")
else
U.log("sfxVol", tostring(vol), "-- the cry plays as the sprite wipes in")
end
if not demoIdx then
while true do coroutine.yield() end
end
-- mash only up to the demo beat, then let the wipe finish on its own
for _ = 1, 300 do
if speech.step >= demoIdx then break end
U.tap(game, "a")
U.wait(3)
end
for _ = 1, 180 do
if speech.step == demoIdx and not speech.picReveal and isBox(top()) then
break
end
U.wait(1)
end
check("_OakSpeechText2A is up on the demo beat",
speech.step == demoIdx and isBox(top()))
local textA = boxText(top())
U.log("page A reads:", textA)
check("the mirrored pic is on screen (picFlip set, demo pic)",
speech.picFlip == true and speech.pic == speech.demoPic)
U.shot(game, DIR .. "/oak_nido_2a.png")
-- one A per page of 2A; picFlip must survive every one of them
local held = true
for _ = 1, 8 do
U.tap(game, "a")
U.wait(10)
if speech.picFlip ~= true or speech.pic ~= speech.demoPic then
held = false
break
end
if speech.step > demoIdx then break end
end
check("picFlip and pic unchanged after the page break", held)
check("the speech moved on to the pic-less step (" .. stepId(speech) .. ")",
speech.step > demoIdx)
local textB = boxText(top())
U.log("page B reads:", textB)
check("a second page is up and it is not page A",
isBox(top()) and textB ~= "" and textB ~= textA)
U.shot(game, DIR .. "/oak_nido_2b.png")
U.log("shots in", DIR)
-- put the beat back so the transition can be watched live: pop the box, rewind
-- the step counter and re-run the demo beat (wipe, cry, page A)
for _ = 1, 8 do
if top() == speech then break end
game.stack:pop()
end
if top() == speech then
speech.picReveal = nil
speech.step = demoIdx
speech:runStep(speech.steps[demoIdx])
U.wait(60)
end
U.log("NIDORINO wipes in from the right facing LEFT, horn toward screen-left.")
U.log("Press A to turn the page: the sprite must not move or mirror, it stays")
U.log("facing left until the screen fades to white for the naming beat.")
while true do
coroutine.yield()
end
end
+102
View File
@@ -0,0 +1,102 @@
-- Ear check on the Viridian Mart parcel jingle (#374): the key-item fanfare
-- used to fire when the box opened, two pages early. pokered carries it as a
-- text command inside the gift text (scripts/ViridianMart.asm
-- sound_get_key_item, home/text.asm TextCommand_SOUND), so it lands after the
-- last character. Do not set POKEPORT_SPEED: fast-forward scales the logic
-- clock only, so audio and text desynchronize and the check means nothing.
-- POKEPORT_DRIVER=tests/drivers/parcel_jingle_bug374_test.lua POKEPORT_IDENTITY=bug374 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Flags = require("src.script.Flags")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "VIRIDIAN_MART"
local QUEST = "_ViridianMartClerkParcelQuestText"
-- pokered data/maps/objects/ViridianMart.asm: warp_event 3, 7 is the door;
-- ViridianMartDefaultScript's .PlayerMovement walks left 1 / up 2 from it to
-- the counter, so the driver only has to stand on the mat.
local STAND = { x = 3, y = 7, facing = "up" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
Flags.set(game.save, "EVENT_GOT_STARTER")
game.save.flags.EVENT_GOT_OAKS_PARCEL = nil
game.save.flags.EVENT_OAK_GOT_PARCEL = nil
check("starter flag set, both parcel flags clear",
Flags.get(game.save, "EVENT_GOT_STARTER")
and not Flags.get(game.save, "EVENT_GOT_OAKS_PARCEL")
and not Flags.get(game.save, "EVENT_OAK_GOT_PARCEL"))
-- the whole point of the bug: the gift text is the three-page quest, so the
-- jingle has two pages of typing to be early over
local text = game.data.text[QUEST]
check(QUEST .. " resolves", type(text) == "string" and text ~= "")
local pages = 1
for _ in tostring(text):gmatch("\f") do pages = pages + 1 end
check("the quest text is three pages", pages == 3)
local last = tostring(text):match("([^\f]*)$") or ""
check("its last page is the got-parcel line",
last:find("PARCEL", 1, true) ~= nil)
local sfx = game.data.audio and game.data.audio.sfx
check("Get_Key_Item is in the sfx table", sfx ~= nil and sfx.Get_Key_Item ~= nil)
local parcel = game.data.items.OAKS_PARCEL
check("OAKS_PARCEL is a key item (picks Get_Key_Item over Get_Item1)",
parcel ~= nil and parcel.keyItem == true)
local story = require("data.scripts.story")
local gift
for _, row in ipairs(story.VIRIDIAN_MART.talk.TEXT_VIRIDIANMART_CLERK) do
if row[1] == "give_item" and row[2] == "OAKS_PARCEL" then gift = row end
end
check("the clerk's give_item row passes the quest text",
gift ~= nil and gift[4] == QUEST)
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("SFX VOLUME IS 0. Nothing will be audible. Raise it in OPTION and rerun.")
else
check("sfx volume is audible (" .. tostring(vol) .. ")", vol ~= 0)
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit or a mod moved the mat: any free walkable neighbour of the
-- door still lands inside the shop, and the clerk's script drives the walk
local sides = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
for _, s in ipairs(sides) do
local cx, cy = STAND.x + s[1], STAND.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("door cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
ow = game.overworld
U.wait(10)
break
end
end
end
check("standing in " .. MAP, ow ~= nil and ow.map.id == MAP)
U.wait(50)
U.tap(game, "a") U.wait(40) -- the clerk's "you came from PALLET" page
U.tap(game, "a") U.wait(90) -- simulated walk to the counter, quest page 1
U.tap(game, "a") U.wait(60) -- page 2
U.tap(game, "a") U.wait(180) -- last page types out, the jingle belongs here
U.shot(game, DIR .. "/374_parcel.png")
U.log("captured", DIR .. "/374_parcel.png")
U.log("The fanfare should start only after \"got / OAK's PARCEL!\" has")
U.log("finished typing, the music ducks under it, and A is ignored until it")
U.log("ends; then the arrow blinks and A closes the box. The pad is yours:")
U.log("clear both parcel flags and walk back in the door to hear it again.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,91 @@
-- Manual check that EXIT GAME actually ends the process (#339): the two
-- background workers are now told to quit from love.quit and Android exits
-- outright, so the app reopens without swiping it out of Recents.
-- Port lifecycle only, no pokered analogue (the GB has no process).
-- POKEPORT_DRIVER=tests/drivers/quit_process_bug339_test.lua POKEPORT_IDENTITY=bug339 POKEPORT_TOUCH=0 love .
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
return function(game)
local U = dofile("tests/drivers/util.lua")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- No map setup: the quit path is the title's EXIT GAME row
-- (src/ui/TitleState.lua:368), reachable straight from boot. The START
-- menu QUIT is not a process quit, it power-cycles to the title
-- (src/core/Game.lua:165), so it cannot verify this.
U.wait(5)
U.tap(game, "start") -- past the copyright splash / attract movie
U.wait(30)
local title = game.stack:top()
check("the title screen is on top",
title ~= nil and title.screenId == "TitleState")
-- love.quit reaches both workers through package.loaded, so what matters is
-- that the module this session loaded is the one carrying shutdown
local chip = package.loaded["src.core.ChipAudio"]
check("ChipAudio is loaded in this session", chip ~= nil)
check("ChipAudio.shutdown exists",
chip ~= nil and type(chip.shutdown) == "function")
check("love.thread is available, so the chip worker is the live one",
love.thread ~= nil and love.thread.newThread ~= nil)
-- src/update/Check's worker is started by the launcher
-- (src/import/RomImporter.lua:594), not by a driver run
local upd = package.loaded["src.update.Check"]
if upd then
check("Check.shutdown exists", type(upd.shutdown) == "function")
else
local ok, mod = pcall(require, "src.update.Check")
check("Check.shutdown exists (module not started this run)",
ok and type(mod.shutdown) == "function")
end
check("love.event.quit is reachable",
love.event ~= nil and love.event.quit ~= nil)
local opts = game.save.options
if (opts and opts.sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the menu presses will make no sound.")
end
if (opts and opts.musicVol or 0) == 0 then
U.log("WARNING music volume is 0: with the title theme silent the chip")
U.log("worker may never have started, which is the thread this fix joins.")
end
-- park the cursor on EXIT GAME and stop there; pressing it is the human's
-- job, since the press ends the process
U.tap(game, "a")
U.wait(20)
local menu = game.stack:top()
local wanted, labels = nil, {}
for i, item in ipairs(menu and menu.items or {}) do
labels[#labels + 1] = tostring(item.label)
if tostring(item.label):find("EXIT", 1, true) then wanted = i end
end
check("the title menu is open", menu ~= nil and menu.items ~= nil)
check("it has an EXIT GAME row", wanted ~= nil)
U.log("menu rows:", table.concat(labels, ", "))
if wanted then
for _ = 1, #menu.items do
if menu.index == wanted then break end
U.tap(game, "down")
U.wait(6)
end
check("the cursor is parked on EXIT GAME", menu.index == wanted)
end
U.log("The cursor sits on EXIT GAME; press A and the window should close.")
U.log("A few seconds later `pgrep -fl love` must print nothing at all: before")
U.log("this fix a headless love process stayed resident burning a core.")
U.log("On Android, reopening from the launcher without swiping the app out of")
U.log("Recents should cold boot normally instead of flashing black.")
while true do
coroutine.yield()
end
end
@@ -45,8 +45,9 @@ return function(game)
ow = game.overworld
end
-- 1) Arrive on the 2F stairs via a REAL warp so warpEntryCell/justWarped
-- are set. Teleporting straight onto (7,1) bypasses takeWarp and would
-- 1) Arrive on the 2F stairs via a REAL warp so the arrival state is real
-- (warpEntryCell set, BIT_STANDING_ON_WARP cleared by the stair tile).
-- Teleporting straight onto (7,1) bypasses takeWarp and would
-- never set the arrival-inert state, so the bug could not reproduce --
-- we must walk up onto the 1F stairs and let the warp carry us.
U.teleport(game, "REDS_HOUSE_1F", 7, 3, "up")
@@ -0,0 +1,357 @@
-- Driver: in a dark cave FadePal2 writes rOBP0 as well as rBGP, so the player
-- is a black silhouette, and ADVANCED bakes the shift into its atlas instead
-- of veiling the world (#383). home/fade.asm:3-19,66 LoadGBPal indexes
-- FadePal4 - wMapPalOffset; home/overworld.asm:500,535,790 sets the offset.
-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_dark_bake_bug383_test.lua \
-- POKEPORT_IDENTITY=bug383 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots383 love .
-- No POKEPORT_SPEED: fast-forward desynchronizes audio.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Probe = dofile("tests/drivers/shot_probe.lua")
local PaletteFX = require("src.render.PaletteFX")
local Pokemon = require("src.pokemon.Pokemon")
local Zoom = require("src.render.Zoom")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots383"
local fails = 0
local function check(ok, msg)
U.log(ok and "PASS" or "FAIL", msg)
if not ok then fails = fails + 1 end
return ok
end
local function rgb(c)
return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil"
end
local function ramp(p)
if not p then return "nil" end
local s = {}
for i = 1, 4 do s[i] = rgb(p[i]) end
return table.concat(s, " ")
end
local function sameCol(a, b)
return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3]
end
local function samePal(a, b)
if not a or not b then return false end
for i = 1, 4 do
if not sameCol(a[i], b[i]) then return false end
end
return true
end
game.save.flags.EVENT_GOT_STARTER = true
game.save.inventory.BOULDERBADGE = true
local mon = Pokemon.new(game.data, "PIKACHU", 30)
mon.moves = { { id = "FLASH", pp = 15 } }
game.save.party = { mon }
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local vol = game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the FLASH blink and menu beeps are silent, turn SFX up",
"in OPTION before judging anything by ear")
else
U.log("sfxVol =", tostring(vol))
end
-- ---- the arithmetic, before anything is on screen ----------------------
local darkDef = game.data.field.darkMaps
local listed = false
for _, m in ipairs(darkDef and darkDef.maps or {}) do
if m == "ROCK_TUNNEL_1F" then listed = true end
end
check(listed, "ROCK_TUNNEL_1F is in field.darkMaps.maps")
local BGP = PaletteFX.DARK_BGP
check(BGP[0] == 2 and BGP[1] == 3 and BGP[2] == 3 and BGP[3] == 3,
"DARK_BGP is FadePal2's `dc 3,3,3,2`")
local litObp, litGroup = PaletteFX.dmgObj()
check(litObp == PaletteFX.OBP0_SHADES,
"with nothing armed dmgObj still hands back the lit OBP0 ramp by identity")
check(PaletteFX.setDarkWorld(true) == true,
"setDarkWorld reports the change, so the caller knows to rebake")
check(PaletteFX.setDarkWorld(true) == false,
"and reports nothing on a repeat, so walking a dark floor never rebuilds")
check(PaletteFX.darkKey() == "#dark",
"the bake cache key carries the flag")
local darkObp, darkGroup = PaletteFX.dmgObj()
U.log("OBP0 lit:", ramp(litObp))
U.log("OBP0 dark:", ramp(darkObp))
check(samePal(darkObp, { litObp[3], litObp[4], litObp[4], litObp[4] }),
"rOBP0 `dc 3,3,3,2` collapses every OBJ colour a sprite draws to shade 3")
check(darkGroup ~= litGroup,
"and its bake sits in its own cache group, not over the lit one")
local ogDark = PaletteFX.ogObj()
check(samePal(ogDark, { PaletteFX.GBC_OBJ[3], PaletteFX.GBC_OBJ[4],
PaletteFX.GBC_OBJ[4], PaletteFX.GBC_OBJ[4] }),
"OG RED's boot-ROM greens go with it (colours 1/2/3 all black)")
PaletteFX.setDarkWorld(false)
check(PaletteFX.dmgObj() == PaletteFX.OBP0_SHADES and PaletteFX.darkKey() == "",
"clearing the flag hands the lit ramp and the lit cache key back")
check(game.overworld == nil or game.overworld.darkNeedsOverlay == nil,
"the screen-space veil is gone: no darkNeedsOverlay left to ask")
-- ---- reach the moment ---------------------------------------------------
-- pokered data/maps/objects/RockTunnel1F.asm: the Route 10 entrance is
-- warp_event 15, 3, so two cells south of it is floor that cannot
-- re-trigger the warp, with the ladder and the sign both on screen.
local MAP, STAND = "ROCK_TUNNEL_1F", { x = 15, y = 5, facing = "down" }
local function enter(flashLit)
game.save.flashLit = flashLit or nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(12)
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
local found
for r = 1, 8 do
for dy = -r, r do
for dx = -r, r do
local cx, cy = STAND.x + dx, STAND.y + dy
if not found and ow.map:isWalkableCell(cx, cy)
and not ow.map:warpAtCell(cx, cy) then
found = { x = cx, y = cy }
end
end
end
if found then break end
end
if found then
U.log(("(%d,%d) is not floor any more -- standing on"):format(
STAND.x, STAND.y), found.x, found.y)
STAND.x, STAND.y = found.x, found.y
game.save.flashLit = flashLit or nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(12)
ow = game.overworld
end
end
return ow
end
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
local ow = enter(nil)
check(ow ~= nil and ow.map and ow.map.id == MAP, "player is inside " .. MAP)
check(ow ~= nil and ow.dark == true and PaletteFX.darkWorld() == true,
"the floor is dark with no FLASH used, and the bake flag is armed with it")
-- The cavern floor tiles carry no shade-0 pixels, so `dc 3,3,3,2` leaves them
-- solid shade 3 -- "the floor is completely black" -- while wall, sign and
-- ladder tiles keep shade-0 pixels that land on shade 2 and stay legible.
-- That is the reporter's reference image, and no uniform veil can produce it.
local FLOOR_TILES = { 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x2f, 0x34,
0x3d, 0x3e, 0x3f }
local ts = ow and ow.map and ow.map.tileset
if ts and love.image and love.image.newImageData then
local okImg, art = pcall(love.image.newImageData, ts.image)
if okImg and art then
local perRow = ts.tilesPerRow or 16
local lightest, worst = 0, nil
for _, t in ipairs(FLOOR_TILES) do
local bx, by = (t % perRow) * 8, math.floor(t / perRow) * 8
for y = by, by + 7 do
for x = bx, bx + 7 do
local r = math.floor(select(1, art:getPixel(x, y)) * 255 + 0.5)
if r > lightest then lightest, worst = r, t end
end
end
end
U.log(("cavern floor tiles: lightest pixel is %d (tile 0x%02x)")
:format(lightest, worst or 0))
check(lightest < 255,
"no cavern floor tile has a shade-0 pixel, so the shift blacks it out")
else
U.log("WARN could not read", tostring(ts.image), "-- floor art unchecked")
end
end
-- Camera:follow parks the sprite at (vw/2 - 16, vh/2 - 12) in world-canvas
-- pixels and endFrame blits that canvas centred at Zoom.scale(fitScale), so
-- the player's torso is a fixed box in the captured frame.
local function playerRect(shot)
local wc = game.renderer and game.renderer.worldCanvas
if not (shot and wc) then return nil end
local W, H = shot:getDimensions()
local vw, vh = wc:getWidth(), wc:getHeight()
local sp = Zoom.scale(game.renderer:fitScale())
local ox = math.floor((W - vw * sp) / 2)
local oy = math.floor((H - vh * sp) / 2)
local x0, y0 = vw / 2 - 16, vh / 2 - 12
return { (ox + (x0 + 4) * sp) / (W - 1), (oy + (y0 + 6) * sp) / (H - 1),
(ox + (x0 + 12) * sp) / (W - 1), (oy + (y0 + 14) * sp) / (H - 1) }
end
-- Count the colours the player would still be wearing if only rBGP had been
-- ported, inside his own 16x16 -- his cell is floor, so none of them can
-- come from the ground under him.
local function spriteProbe(label, shot, telltale)
local rect = playerRect(shot)
if not (shot and rect) then
U.log("WARN no pixel probe for", label, "-- judge the player by eye")
return
end
local counts, total = Probe.count(shot, telltale, 1, rect)
local parts, leaked = {}, 0
for name, n in pairs(counts) do
parts[#parts + 1] = ("%s=%d"):format(name, n)
leaked = leaked + n
end
table.sort(parts)
U.log(("probe[%s player %d px] %s"):format(label, total,
table.concat(parts, " ")))
U.log(" tones on him:", Probe.fmt(Probe.top(shot, 3, 1, rect)))
check(leaked == 0, label .. ": the player is black, not lit or half-lit")
end
local CAVE = PaletteFX.pal(game.data, "CAVE")
U.log("CAVE palette:", ramp(CAVE))
-- ---- ADVANCED / RED++, the mode in the report --------------------------
local pack = PaletteFX.gbcPack()
if not pack then
U.log("WARN data/palettes_gbc is absent; the ADVANCED half cannot be shown")
else
game.save.options.colors = "redpp"
PaletteFX.setMode("redpp")
ow = enter(nil)
U.wait(30)
U.shot(game, DIR .. "/bug383_1_dark_redpp.png")
local tsId = ow.map.tileset.id
local lit = pack.world.groupColors[tsId]
local shifted = PaletteFX.worldGroupColors(game.data, tsId, ow.map.id, nil)
local allShifted = lit ~= nil and shifted ~= nil
for i = 1, 8 do
if not (lit and shifted and samePal(shifted[i],
{ lit[i][3], lit[i][4], lit[i][4], lit[i][4] })) then
allShifted = false
end
end
check(allShifted,
"every ADVANCED tile group bakes FadePal2-shifted, not veiled")
local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
U.log("atlas key:", tostring(key))
check(type(key) == "string" and key:find("#dark", 1, true) ~= nil,
"and the atlas it baked is keyed apart from the lit one")
local shot = Probe.grab()
if shot then
local top, total = Probe.top(shot, 4)
U.log(("probe[ADVANCED dark] %d px: %s"):format(total, Probe.fmt(top)))
local floorIsShifted = false
for i = 1, 8 do
for j = 1, 4 do
if shifted and top[1] and sameCol(top[1], shifted[i][j]) then
floorIsShifted = true
end
end
end
check(floorIsShifted and top[1].share > 0.3,
"the screen is mostly one exact shifted colour -- flat black floor, "
.. "no veil arithmetic and no speckle")
local litSeen = 0
for i = 1, 8 do
local c = Probe.count(shot, { a = lit[i][1], b = lit[i][2] }, 3)
litSeen = litSeen + c.a + c.b
end
check(litSeen == 0, "and none of the lit atlas's own two top colours "
.. "survives anywhere on it")
PaletteFX.setDarkWorld(false)
local litSprite = PaletteFX.spriteObp(ow.player.sprite.def,
ow.player.sprite.seed)
PaletteFX.setDarkWorld(true)
U.log("player OBP lit:", ramp(litSprite))
spriteProbe("ADVANCED", shot,
{ obj1 = litSprite[2], obj2 = litSprite[3] })
end
end
-- ---- OG RED: the reporter's reference capture --------------------------
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
ow = enter(nil)
U.wait(30)
U.shot(game, DIR .. "/bug383_2_dark_ogred.png")
local shot = Probe.grab()
if shot then
local top, total = Probe.top(shot, 5)
U.log(("probe[OG RED dark] %d px: %s"):format(total, Probe.fmt(top)))
spriteProbe("OG RED", shot,
{ green = PaletteFX.GBC_OBJ[2], darkGreen = PaletteFX.GBC_OBJ[3] })
end
-- ---- the shade-remapped modes, each in its own ramp --------------------
-- Their sprites bake OBP0 and are coloured by the zone shader, so the tone
-- to look for is the one DARK_BGP sends DMG white to: palette entry 3, which
-- the floor under him cannot supply.
for _, m in ipairs({ { "gbc", "SGB", CAVE }, { "og", "plain DMG",
{ { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 },
{ 0, 0, 0 } } },
{ "classic", "CLASSIC", PaletteFX.CLASSIC } }) do
game.save.options.colors = m[1]
PaletteFX.setMode(m[1])
ow = enter(nil)
U.wait(30)
U.shot(game, DIR .. "/bug383_3_dark_" .. m[1] .. ".png")
local s = Probe.grab()
if s then
U.log(("probe[%s dark] %s"):format(m[2], Probe.fmt(Probe.top(s, 4))))
spriteProbe(m[2], s, { shade2 = m[3][3] })
end
end
-- ---- FLASH lights it, sprites and atlas together -----------------------
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
ow = enter(true)
check(ow.dark == false and PaletteFX.darkWorld() == false,
"after FLASH neither half of the darkness is armed")
U.wait(30)
U.shot(game, DIR .. "/bug383_4_flash_lit_sgb.png")
local litShot = Probe.grab()
if litShot then
local rect = playerRect(litShot)
if rect then
-- the same box that had to be featureless in the dark: CAVE[1] is the
-- sprite's own shade-0 white and the floor around him has none, so this
-- is also the proof the box really covers the player
local c = Probe.count(litShot, { paper = CAVE[1] }, 1, rect)
U.log("lit player box tones:", Probe.fmt(Probe.top(litShot, 3, 1, rect)))
check(c.paper > 0,
"lit again, the player fills that same box with CAVE's white")
end
end
if pack then
game.save.options.colors = "redpp"
PaletteFX.setMode("redpp")
ow = enter(true)
U.wait(30)
U.shot(game, DIR .. "/bug383_5_flash_lit_redpp.png")
local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
U.log("atlas key after FLASH:", tostring(key))
check(type(key) == "string" and key:find("#dark", 1, true) == nil,
"ADVANCED rebaked its atlas lit rather than keeping the dark one")
end
U.log(fails == 0 and "all #383 machine checks passed"
or (fails .. " #383 machine check(s) FAILED -- read up"))
U.log("shots in", DIR)
-- ---- hand the pad over, dark, in the reporter's mode -------------------
if pack then
game.save.options.colors = "redpp"
PaletteFX.setMode("redpp")
end
enter(nil)
U.log("You are in ROCK TUNNEL 1F in ADVANCED colours, no FLASH used yet.")
U.log("The floor should be flat black with no speckle, the rock outlines,")
U.log("ladder and sign dim but legible, and the player gone: black on black.")
U.log("START, POKeMON, A, FLASH lights it in one frame with no reload hitch,")
U.log("him back in colour; the ladder to 2F is lit too, Route 10 and back is dark.")
while true do
coroutine.yield()
end
end
+21 -16
View File
@@ -145,8 +145,8 @@ return function(game)
"the map reports itself dark with no FLASH used")
check(ow ~= nil and ow:paletteNameFor(ow.map) == "CAVE",
"and resolves the CAVE palette (tileset CAVERN)")
check(ow ~= nil and ow:darkNeedsOverlay() == false,
"SGB needs NO composited veil -- its palette carries the darkening")
check(ow ~= nil and ow.darkNeedsOverlay == nil,
"the composited veil path is gone: SGB darkens via its palette")
local function probe(label, wanted, rect)
local shot = Probe.grab()
@@ -222,16 +222,13 @@ return function(game)
local corner = Probe.count(shot, { red = { 148, 58, 58 } }, 3, FAR_CORNER)
check(corner.red > 0, "and the far corner still carries that red, not a void")
-- Not a FAIL: OG RED replays its OBP-baked sprites on top of the finished
-- zone pass (PaletteFX.markSpriteRedraw) and that replay is unshaded, so
-- the player keeps his green where FadePal2's OBP0 would darken him too.
local obj = PaletteFX.ogObj()
local sprite, spriteTotal = Probe.count(shot,
{ green = obj[2], darkGreen = obj[3] })
local share = (sprite.green + sprite.darkGreen) * 100
/ math.max(1, spriteTotal)
U.log(("known gap: OG RED's player keeps his green in the dark "
.. "(%.2f%% of the frame); hardware darkens OBP0 too"):format(share))
-- FadePal2 writes rOBP0 as well as rBGP (`dc 3,3,3,2`), so every OBJ
-- colour lands on shade 3 and the player is a black silhouette: none of
-- the boot-ROM green may survive (#383).
local sprite = Probe.count(shot,
{ green = PaletteFX.GBC_OBJ[2], darkGreen = PaletteFX.GBC_OBJ[3] })
check(sprite.green == 0 and sprite.darkGreen == 0,
"OG RED's player is black in the dark, not green")
end
-- ---- the modes that darken in their own ramps --------------------------
@@ -259,13 +256,21 @@ return function(game)
m[2] .. ": the far corner still has two tones (no light window)")
end
if m[1] == "redpp" then
-- RED++ has no palette left to shift: TileRenderer bakes true colour
-- into the atlas, so the darkness has to be composited by hand.
-- RED++ has no palette left for the frame shader to shift, so the shift
-- goes into the palette its atlas bakes from instead of a veil (#383).
local o = game.overworld
U.log("RED++ gbcAtlas present:",
tostring(o and o.map and o.map.renderer and o.map.renderer.gbcAtlas ~= nil))
check(o ~= nil and o:darkNeedsOverlay() == true,
"RED++ is the ONE mode that still composites a flat veil")
check(PaletteFX.darkWorld() == true,
"RED++ arms the dark-world flag, so its atlas bakes dark")
local pack = PaletteFX.gbcPack()
local tsId = o and o.map and o.map.tileset and o.map.tileset.id
local lit = pack and tsId and pack.world.groupColors[tsId]
local shifted = tsId and PaletteFX.worldGroupColors(game.data, tsId,
o.map.id, nil)
check(lit ~= nil and shifted ~= nil and samePal(shifted[1],
{ lit[1][3], lit[1][4], lit[1][4], lit[1][4] }),
"and every baked group is FadePal2-shifted, not veiled")
end
end
+33 -23
View File
@@ -6325,18 +6325,35 @@ function MANUAL.evolveNidorino() return true end
-- SILPH_CO_ELEVATOR at 140) gate real geography: the mart's upper floors
-- and most of Silph are only reachable through them.
--
-- The floor menu opens from the map's own onEnter (data/scripts/story3.lua
-- `elevator`), so it is already up by the time we get here -- there is
-- nothing to interact with. Rows are the short floor tokens pokered prints
-- ("5F", "B2F"), which is exactly the tail of the destination map id, so
-- the next segment names the button to press.
-- The floor menu belongs to the car's panel bg_event, not to map entry
-- (#395): walk to the panel and press A. Rows are the short floor tokens
-- pokered prints ("5F", "B2F"), which is exactly the tail of the
-- destination map id, so the next segment names the button to press.
-- Ride the elevator to `wantMap` (defaults to the next segment's floor).
--
-- The floor menu opens from the elevator's own onEnter, so it is already up
-- when we arrive. If it is NOT up -- we bounced in on a stray warp and the
-- menu was dismissed, or we are on the default 1F-exit oscillation -- step
-- back onto the car's warp to re-open it before giving up.
local function rideElevator(where, wantMap)
-- Stand beside the car's panel bg_event and press A to open the floor
-- menu (data/maps/objects/CeladonMartElevator.asm `bg_event 3, 0`).
local function pressPanel()
local m = ow().map
local sign = (m.def.signs or {})[1]
if not sign then return false end
-- offset from the panel cell, then the direction that faces it back
local SIDES = { { 0, 1, "up" }, { 0, -1, "down" },
{ 1, 0, "left" }, { -1, 0, "right" } }
for _, s in ipairs(SIDES) do
local sx, sy = sign.x + s[1], sign.y + s[2]
if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy) then
ops.goto_({ x = sx, y = sy })
local p = ow().player
if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then
faceDir(s[3])
press("a")
if waitFor(isList, 60) then return true end
end
end
end
return false
end
local want = tostring(wantMap or nextMapWanted or "")
local token = want:match("_([^_]+)$")
if not token then
@@ -6345,14 +6362,7 @@ local function rideElevator(where, wantMap)
return false
end
local from = ow().map.id
if not waitFor(isList, 20) then
-- Re-open the floor menu: the car's exit warp re-enters the elevator,
-- firing onEnter again. This is what breaks the un-ridden bounce.
local car = findWarpTo("ELEVATOR") or findWarpTo("")
if car then walkOntoWarp(car.x, car.y) end
from = ow().map.id
end
if not waitFor(isList, 60) then
if not isList() and not pressPanel() then
note("elevator: no floor menu", where)
say(("elevator on %s: the WHICH FLOOR? menu never opened"):format(from))
return false
@@ -6388,11 +6398,11 @@ local function rideElevator(where, wantMap)
if not cursorTo("index", idx) then backOut() return false end
press("a")
-- ShakeElevator runs the whole ride in place -- music stop, 100 scroll
-- bounces, the PA chime -- and only then walks us out onto the floor.
for _ = 1, 600 do
if ow().map.id ~= from then break end
if idle() then U.wait(4) else mashUntilIdle() end
end
-- bounces, the PA chime -- and then hands control back inside the car:
-- the rewritten exit warp is what we walk out onto (#395).
mashUntilIdle()
local car = findWarpTo(want) or ow().map.def.warps[1]
if car then walkOntoWarp(car.x, car.y) end
local landed = ow().map.id
if landed == want then
say(("rode the elevator to %s"):format(token))
+232
View File
@@ -0,0 +1,232 @@
-- Manual check that Team Rocket leaves Silph Co and the president keeps talking
-- (#392). pokered scripts/SilphCo11F.asm: the Giovanni win runs
-- SilphCo11FTeamRocketLeavesScript (HideObject over TOGGLE_SILPH_CO_2F_2..11F_3),
-- and SilphCo11FSilphPresidentText prints .MasterBallDescriptionText on every
-- later talk. No POKEPORT_SPEED: fast-forward desynchronizes the jingle.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_rockets_bug392_test.lua POKEPORT_IDENTITY=bug392 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local ScriptRunner = require("src.script.ScriptRunner")
local mapScripts = require("data.scripts.init")
-- pokered data/maps/objects/SilphCo11F.asm: president (7,5) STAY DOWN, so
-- he is read from (6,5) facing right; GIOVANNI (6,9), ROCKET1 (3,16),
-- ROCKET2 (15,9). SilphCo11FDefaultScript.PlayerCoordsArray is (6,13) and
-- (7,12), each stepped onto from the cell below it (teleport ignores
-- collision, so the stand cell only has to be south of the trigger).
local PRESIDENT = { x = 7, y = 5 }
local READ = { x = 6, y = 5, facing = "right" }
local TRIGGERS = { { stand = { 7, 13 }, cell = { 7, 12 } },
{ stand = { 6, 14 }, cell = { 6, 13 } } }
local ELEVENTH = { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
"SILPHCO11F_ROCKET2" }
-- one floor per shape: 3F is rocket + scientist + an item ball, 5F adds the
-- rocket-aligned ROCKER, 7F keeps the rival out of the hide list
local FLOORS = {
{ "SILPH_CO_3F", { 22, 7 },
gone = { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" },
stays = { "SILPHCO3F_HYPER_POTION", "SILPHCO3F_SILPH_WORKER_M" } },
{ "SILPH_CO_5F", { 13, 10 },
gone = { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" },
stays = { "SILPHCO5F_CARD_KEY", "SILPHCO5F_SILPH_WORKER_M" } },
{ "SILPH_CO_7F", { 10, 9 },
gone = { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" },
stays = { "SILPHCO7F_TM_SWORDS_DANCE", "SILPHCO7F_SILPH_WORKER_M3" } },
}
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function spawned(name)
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
local rows = mapScripts.get("SILPH_CO_11F").talk
.TEXT_SILPHCO11F_SILPH_PRESIDENT
check("the president has a hand-ported talk script", type(rows) == "table")
if type(rows) == "table" then
local problems = ScriptRunner.validate(rows)
check("his script validates: " .. (problems[1] or "no problems"),
#problems == 0)
end
for _, key in ipairs({ "_SilphCo11FSilphPresidentText",
"_SilphCo11FSilphPresidentReceivedMasterBallText",
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }) do
local body = game.data.text[key]
check(key .. " is extracted", type(body) == "string" and body ~= "")
end
local vol = game.save.options and game.save.options.sfxVol
if (vol or 0) == 0 then
U.log("sfxVol is 0: the key-item jingle will be silent, raise it in OPTION")
else
U.log("sfxVol", tostring(vol), "-- the ball arrives on the key-item jingle")
end
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 70),
Pokemon.new(game.data, "SNORLAX", 70),
Pokemon.new(game.data, "LAPRAS", 70),
}
game.save.player.name = "bryan"
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil
game.save.flags.EVENT_GOT_MASTER_BALL = nil
game.save.objectToggles = {}
U.teleport(game, "SILPH_CO_11F", READ.x, READ.y, READ.facing)
U.wait(10)
local ow = game.overworld
for _, name in ipairs(ELEVENTH) do
check(name .. " is on the floor before the fight", spawned(name) ~= nil)
end
U.shot(game, DIR .. "/silph392_0_before.png")
local function facingThePresident()
local o = game.overworld
local fx, fy = o.player:facingCell()
return o:npcAtCell(fx, fy) == spawned("SILPHCO11F_SILPH_PRESIDENT")
end
if not facingThePresident() then
-- a map edit moved him: stand on any free walkable neighbour.
-- {dx, dy, facing} is the offset from the president plus the way back.
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = PRESIDENT.x + s[1], PRESIDENT.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("president moved; standing on", cx, cy, "facing", s[3])
U.teleport(game, "SILPH_CO_11F", cx, cy, s[3])
U.wait(10)
ow = game.overworld
break
end
end
end
check("the player is facing the president", facingThePresident())
local function boxText(top)
local lines = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return table.concat(lines, " ")
end
-- one whole talk: A to open, then A through every box until the world is back
local function talk()
local seen = {}
U.tap(game, "a")
U.wait(20)
for _ = 1, 120 do
local top = game.stack:top()
if getmetatable(top) == TextBox then
local txt = boxText(top)
if txt ~= "" and seen[#seen] ~= txt then seen[#seen + 1] = txt end
elseif top == game.overworld then
break
end
U.tap(game, "a")
U.wait(10)
end
return table.concat(seen, " / ")
end
local first = talk()
U.log("first talk reads:", first)
check("the first talk prints his thank-you", first ~= "")
check("and hands over the MASTER BALL",
(game.save.inventory.MASTER_BALL or 0) > 0)
check("EVENT_GOT_MASTER_BALL is set", game.save.flags.EVENT_GOT_MASTER_BALL == true)
U.wait(20)
local second = talk()
U.log("second talk reads:", second)
check("the second talk is not silence", second ~= "")
check("it is the MASTER BALL description",
second:find("prototype", 1, true) ~= nil)
check("and no second ball is handed out",
(game.save.inventory.MASTER_BALL or 0) == 1)
U.shot(game, DIR .. "/silph392_1_president.png")
-- the hide pass, floor by floor: this is what a save that beat Giovanni sees
-- on its next visit, and what the win itself does in one go
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = true
for i, floor in ipairs(FLOORS) do
local mapId, at = floor[1], floor[2]
U.teleport(game, mapId, at[1], at[2], "down")
U.wait(10)
for _, name in ipairs(floor.gone) do
check(name .. " left " .. mapId, spawned(name) == nil)
end
for _, name in ipairs(floor.stays) do
check(name .. " is still there", spawned(name) ~= nil)
end
local toggles = game.save.objectToggles[mapId] or {}
check(mapId .. " toggles were written to the save",
toggles[floor.gone[1]] == false)
U.shot(game, ("%s/silph392_2_%s.png"):format(DIR, mapId:lower()))
if i == #FLOORS then
check("the 7F rival keeps his toggle",
toggles.SILPHCO7F_RIVAL ~= false)
end
end
-- put the fight back on the table: clear the event, the toggles and the
-- battle records so the coordinate trigger re-arms
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil
game.save.objectToggles = {}
game.save.defeatedTrainers = {}
game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = nil
game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_1 = nil
local fired, cell
for i, t in ipairs(TRIGGERS) do
U.teleport(game, "SILPH_CO_11F", t.stand[1], t.stand[2], "up")
U.wait(10)
ow = game.overworld
if i == 1 then
for _, name in ipairs(ELEVENTH) do
check(name .. " is back on the floor", spawned(name) ~= nil)
end
U.shot(game, DIR .. "/silph392_3_rearmed.png")
end
U.hold(game, "up", 24)
for _ = 1, 300 do
U.wait(1)
if getmetatable(game.stack:top()) == BattleState then break end
end
if getmetatable(game.stack:top()) == BattleState
or (ow.player.cellX == t.cell[1] and ow.player.cellY == t.cell[2]) then
fired, cell = true, t.cell
break
end
U.log(("the step up from (%d,%d) missed the trigger; trying the other pad")
:format(t.stand[1], t.stand[2]))
end
cell = cell or TRIGGERS[1].cell
check(("stepping onto (%d,%d) started GIOVANNI"):format(cell[1], cell[2]),
fired == true)
U.shot(game, DIR .. "/silph392_4_giovanni.png")
U.log("Win the fight: after the fade GIOVANNI and both ROCKETs, (3,16) and")
U.log("(15,9), should be gone, and every grunt and lab-coat trainer on")
U.log("3F/5F/7F with them. The president at (7,5) has the ball already, so")
U.log("A on him should describe it again, never an empty beat.")
while true do
coroutine.yield()
end
end
+158
View File
@@ -0,0 +1,158 @@
-- Manual check that the Silph Co 2F worker begs before handing over TM36 (#393).
-- pokered scripts/SilphCo2F.asm prints .PleaseTakeThisText (text/SilphCo2F.asm:1,
-- "Eeek!" / "No! Stop! Help!" ... "please take this!") before GiveItem; the port
-- had no such string in the cache, so the talk opened on "got TM36!". Re-import
-- first (CACHE_FORMAT bump). No POKEPORT_SPEED here: fast-forward
-- desynchronizes the item jingle from the box it belongs to.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_tm36_bug393_test.lua POKEPORT_IDENTITY=bug393 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local Bag = require("src.inventory.Bag")
local mapScripts = require("data.scripts.init")
-- pokered data/maps/objects/SilphCo2F.asm: SILPHCO2F_SILPH_WORKER_F stands at
-- (10, 1) facing UP with row 0 walled off, so she is talked to from below.
local MAP = "SILPH_CO_2F"
local TEXT = "TEXT_SILPHCO2F_SILPH_WORKER_F"
local WORKER = "SILPHCO2F_SILPH_WORKER_F"
local PRE = "SilphCo2FSilphWorkerFPleaseTakeThisText"
local STAND = { x = 10, y = 2, facing = "up" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- the whole bug was a missing cache string, so say which half is wrong when
-- the boxes come out empty
local pre = game.data.text[PRE]
check(PRE .. " is in the text cache", type(pre) == "string" and pre ~= "")
if type(pre) == "string" then
check("it is the scared line", pre:find("Eeek!", 1, true) ~= nil
and pre:find("please take this!", 1, true) ~= nil)
U.log("pre text reads:", (pre:gsub("[\n\011\012]", " / ")))
end
check(MAP .. "/" .. TEXT .. " runs a hand-ported script",
type(mapScripts.talkScript(MAP, TEXT)) == "function")
check("TM_SELFDESTRUCT is a known item",
game.data.items.TM_SELFDESTRUCT ~= nil)
-- a save that already has the TM takes the explanation-only branch
if game.save.flags.EVENT_GOT_TM36 then
game.save.flags.EVENT_GOT_TM36 = nil
Bag.remove(game.save, "TM_SELFDESTRUCT", 1)
U.log("EVENT_GOT_TM36 was already set; cleared it to replay the gift")
end
local vol = game.save.options and game.save.options.sfxVol
if (vol or 0) == 0 then
U.log("sfxVol is 0: the item jingle will be silent, raise it in OPTION first")
else
U.log("sfxVol", tostring(vol), "-- one Get_Item1 jingle, after the pleading")
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local function workerIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == WORKER then return n end
end
return nil
end
-- re-reads game.overworld: the fallback below teleports again, which rebuilds
-- the state and its npc list
local function facingTheWorker()
local ow = game.overworld
local her = ow and workerIn(ow)
if not her then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == her
end
local ow = game.overworld
local her = workerIn(ow)
check("the worker is loaded on " .. MAP, her ~= nil)
if her and not facingTheWorker() then
-- a map edit or a mod moved her: stand on any free walkable neighbour.
-- {dx, dy, facing} is the offset from her cell plus the way back at her.
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = her.cellX + s[1], her.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
ow = game.overworld
break
end
end
end
check("the player is facing her", facingTheWorker())
local function boxText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local out = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " / ")
end
local function hasTm()
return (game.save.inventory or {}).TM_SELFDESTRUCT ~= nil
end
U.tap(game, "a")
U.wait(30)
local first = boxText()
check("A on her opens a text box", first ~= nil)
if first then U.log("box 1 reads:", first) end
check("the first box is the scared line, not the TM",
first ~= nil and first:find("Eeek!", 1, true) ~= nil)
check("and the TM is still hers while it is up", not hasTm())
U.shot(game, DIR .. "/bug393_1_scared.png")
-- read the rest of the conversation the way a player does
for i = 2, 8 do
U.tap(game, "a")
U.wait(40)
local t = boxText()
if not t then break end
U.log(("box %d reads:"):format(i), t)
if t:find("TM36", 1, true) then
U.shot(game, DIR .. "/bug393_2_tm36.png")
end
end
check("TM36 ended up in the bag", hasTm())
check("EVENT_GOT_TM36 is set", game.save.flags.EVENT_GOT_TM36 ~= nil)
U.log("bag holds", tostring(Bag.slots(game.save)), "item kinds now")
-- hand it back unclaimed so the pleading can be watched and heard live
game.save.flags.EVENT_GOT_TM36 = nil
Bag.remove(game.save, "TM_SELFDESTRUCT", 1)
while getmetatable(game.stack:top()) == TextBox do
U.tap(game, "a")
U.wait(20)
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
U.log("the gift was rolled back; the pad is yours, press A on her")
U.log("She should panic first: \"Eeek!/No! Stop! Help!\", then work out you")
U.log("are not a Rocket and offer the TM, and only then the jingle plays and")
U.log("the box says <PLAYER> got TM36!, followed by the SELFDESTRUCT warning.")
U.log("Press A on her once more after that: straight to the warning, no panic.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,180 @@
-- Eye/ear check on the S.S. Anne leaving Vermilion Dock and the rival's
-- goodbye on 2F (#360): she used to blink to water in one frame, the surf
-- music rode into the city, and the rival walked back to his spawn without
-- the CUT-master line. scripts/VermilionDock.asm and scripts/SSAnne2F.asm.
-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so
-- the horns and the music switch desynchronize from what you see.
-- POKEPORT_DRIVER=tests/drivers/ss_anne_departure_bug360_test.lua POKEPORT_IDENTITY=bug360 POKEPORT_DEV=1 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local story3 = require("data.scripts.story3")
local story5 = require("data.scripts.story5")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- pokered data/maps/objects/VermilionDock.asm: warp_event 14, 2 is the
-- gangway off the ship, which is block (7,1); SSAnne2F.asm's
-- .PlayerCoordinatesArray is 36,8 / 37,8 with the rival spawned on (36,4).
local DOCK, DOCK_CELL = "VERMILION_DOCK", { x = 14, y = 2 }
local ANNE2F, TRIGGER = "SS_ANNE_2F", { x = 37, y = 8 }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function rowsOfKind(rows, kind)
local out = {}
for _, r in ipairs(rows or {}) do
if r[1] == kind then out[#out + 1] = r end
end
return out
end
-- capture what a scene queues without letting it touch the live save or
-- start the encounter sting
local function capture(run)
local realPlay = Music.play
Music.play = function() end
local rows
local ow = {
runner = {
isRunning = function() return false end,
run = function(_, r) rows = r end,
},
player = { facing = "down", cellX = DOCK_CELL.x, cellY = DOCK_CELL.y },
startDustAnim = function(_, _, _, done) if done then done() end end,
queueScript = function(_, r) rows = r end,
}
run(ow)
Music.play = realPlay
return rows
end
-- ---------------------------------------------------------- rival exit
local CUT = "_SSAnne2FRivalCutMasterText"
local cut = game.data.text[CUT]
check(CUT .. " resolves", type(cut) == "string" and cut ~= "")
if type(cut) == "string" then
check("it is the CUT-master line", cut:find("CUT", 1, true) ~= nil)
U.log("rival goodbye reads:", (cut:gsub("\n", " / ")))
end
for _, side in ipairs({ { 37, 4 }, { 36, 6 } }) do
local x, steps = side[1], side[2]
local rows = capture(function(ow)
story5[ANNE2F].onStep({ save = { flags = {} }, data = game.data }, ow, x, 8)
end)
local said = rowsOfKind(rows, "show_text")
check(("x=%d the goodbye is the last thing he says"):format(x),
said[#said] ~= nil and said[#said][2] == CUT)
local walk = rowsOfKind(rows, "walk_npc")[1]
check(("x=%d exit walk is %d steps, ending downward"):format(x, steps),
walk ~= nil and #walk[3] == steps and walk[3][#walk[3]] == "down")
check(("x=%d he does not retreat to the spawn (36,4)"):format(x), (function()
for _, r in ipairs(rowsOfKind(rows, "move_npc_to")) do
if r[3] == 36 and r[4] == 4 then return false end
end
return true
end)())
end
-- ------------------------------------------------------------ the ship
local dockRows = capture(function(ow)
story3[DOCK].onEnter({ save = { flags = { EVENT_GOT_HM01 = true } },
data = game.data }, ow)
end)
local horns = 0
for _, r in ipairs(rowsOfKind(dockRows, "play_sound")) do
if r[2] == "SS_Anne_Horn" then horns = horns + 1 end
end
check("the departure blows the horn twice", horns == 2)
local slide = 0
for _, w in ipairs(rowsOfKind(dockRows, "wait")) do
if w[2] == 20 then slide = slide + 1 end
end
check("she sails in eight beats, not one frame", slide == 8)
check("no keepMusic override into the city", (function()
for _, r in ipairs(rowsOfKind(dockRows, "play_music")) do
if r[3] and r[3].keep then return false end
end
return true
end)())
local sfx = game.data.audio and game.data.audio.sfx
check("SS_Anne_Horn is in the sfx table", sfx ~= nil and sfx.SS_Anne_Horn ~= nil)
local mapSongs = game.data.audio and game.data.audio.mapSongs
check("VERMILION_CITY still owns Music_Vermilion",
mapSongs ~= nil and mapSongs.VERMILION_CITY == "Music_Vermilion")
local opts = game.save.options or {}
if opts.sfxVol == 0 or opts.musicVol == 0 then
U.log("SFX OR MUSIC VOLUME IS 0. The horns and the music switch are the")
U.log("whole check. Raise them in OPTION and rerun.")
else
check("sfx and music are audible (" .. tostring(opts.sfxVol) .. "/"
.. tostring(opts.musicVol) .. ")", true)
end
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "PIKACHU", 30),
Pokemon.new(game.data, "SNORLAX", 77),
}
game.save.flags.EVENT_GOT_POKEDEX = true
game.save.flags.EVENT_GOT_HM01 = true
game.save.flags.EVENT_SS_ANNE_LEFT = nil
game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL = nil
check("HM01 in hand, ship still docked, rival unbeaten",
game.save.flags.EVENT_GOT_HM01 == true
and game.save.flags.EVENT_SS_ANNE_LEFT == nil
and game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL == nil)
U.log("Watch for: she idles a couple of seconds with smoke off the funnel,")
U.log("one horn, then travels WEST a block at a time with the water closing")
U.log("in behind her, a second horn once she is gone, and the surf loop")
U.log("gives way to the Vermilion theme the moment you cross into town.")
U.teleport(game, DOCK, DOCK_CELL.x, DOCK_CELL.y, "up")
local ow = game.overworld
if ow and ow.map.id ~= DOCK then
check("standing on " .. DOCK, false)
elseif ow and not ow.map:isWalkableCell(DOCK_CELL.x, DOCK_CELL.y) then
-- a map edit moved the gangway: any walkable neighbour of it still sits
-- on the deck row the departure is keyed on
for _, s in ipairs({ { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 0 } }) do
local cx, cy = DOCK_CELL.x + s[1], DOCK_CELL.y + s[2]
if ow.map:isWalkableCell(cx, cy) then
U.log(("gangway cell (%d,%d) is blocked, standing on")
:format(DOCK_CELL.x, DOCK_CELL.y), cx, cy)
U.teleport(game, DOCK, cx, cy, "up")
break
end
end
end
-- filmstrip across the slide: 120 frames of idling, then a shot every 40
U.wait(110)
U.shot(game, DIR .. "/360_dock_0.png")
for i = 1, 5 do
U.wait(40)
U.shot(game, DIR .. "/360_dock_" .. i .. ".png")
end
U.log("captured", DIR .. "/360_dock_0.png", "through 5")
U.wait(240)
U.shot(game, DIR .. "/360_dock_gone.png")
-- park him one step short of the 2F trigger so the pad picks it up
U.wait(120)
U.teleport(game, ANNE2F, TRIGGER.x, TRIGGER.y + 1, "up")
U.log("The pad is yours. One tap UP walks onto (37,8) and the rival comes")
U.log("down from the captain's door: beat him, and after the goodbye he must")
U.log("walk four cells DOWN out of the room, never back up to (36,4). For")
U.log("the other branch, console: game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL")
U.log("= nil, then step onto (36,8) -- there he goes RIGHT around you first.")
while true do
coroutine.yield()
end
end
+108
View File
@@ -0,0 +1,108 @@
-- Manual check for the two faint horizontal lines across the title (#373):
-- black gaps between the SGB zone clips plus a too-bright ribbon band.
-- Zones are pokered data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows
-- 0-7 / 8-9 / 10-17), whose whites all match (data/sgb/sgb_palettes.asm).
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug373_test.lua POKEPORT_IDENTITY=bug373 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local Renderer = require("src.render.Renderer")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- past the copyright splash / attract movie (engine/movie/splash.asm)
U.wait(5)
U.tap(game, "start")
U.wait(30)
local title = game.stack:top()
check("the title screen is on top",
title ~= nil and title.screenId == "TitleState")
if not (title and title.sgbPalettes) then
U.log("No title state to look at; nothing below can run.")
while true do coroutine.yield() end
end
local opts = game.save.options
local mode = opts and opts.colors or PaletteFX.mode
check("COLORS is SGB (the default this bug shows in)", mode == "gbc")
if (opts and opts.musicVol or 0) == 0 then
U.log("WARNING music volume is 0: the title theme will be silent.")
end
if (opts and opts.sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the START press will make no sound.")
end
-- the three palette names the zones are built from
for _, name in ipairs({ "LOGO1", "LOGO2", "MEWMON" }) do
local pal = PaletteFX.pal(game.data, name)
check(name .. " resolves to a four-color palette",
type(pal) == "table" and #pal == 4)
end
local function report(z)
if not z then return end
for i = 1, #z do
local c = z[i].colors
U.log(("zone %d rows %d-%d, white %s"):format(
i, z[i].y, z[i].y + z[i].h - 1,
c and c[1] and table.concat(c[1], ",") or "none"))
end
end
local z = title:sgbPalettes(game)
check("the title builds three zones", z ~= nil and #z == 3)
if z and #z == 3 then
check("the ribbon band starts at tile row 8 (y=64)", z[2].y == 64)
check("the player/mon zone starts at tile row 10 (y=80)", z[3].y == 80)
check("logo and ribbon share a boundary", z[1].y + z[1].h == z[2].y)
check("ribbon and mon zone share a boundary", z[2].y + z[2].h == z[3].y)
local w1, w2, w3 = z[1].colors[1], z[2].colors[1], z[3].colors[1]
local function sameColor(a, b)
return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3]
end
check("all three zones share color 0", sameColor(w1, w2) and sameColor(w1, w3))
report(z)
end
-- The black-gap half only exists where the framebuffer is not an integer
-- multiple of the window, i.e. Android's truncated density (#208).
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpiX, dpiY = pw / ww, ph / wh
U.log(("surface %dx%d units, %dx%d pixels, dpi %.4f/%.4f, fit scale %d")
:format(ww, wh, pw, ph, dpiX, dpiY, Renderer:fitScale()))
if dpiX % 1 == 0 and dpiY % 1 == 0 then
U.log("Integer DPI here, so the black-gap half cannot show on this")
U.log("screen at all; only the Android build reproduces it.")
end
U.shot(game, SHOT_DIR .. "/bug373_title_gbc.png")
U.log("captured", SHOT_DIR .. "/bug373_title_gbc.png")
PaletteFX.setMode("redpp")
U.wait(20)
U.shot(game, SHOT_DIR .. "/bug373_title_redpp.png")
U.log("captured", SHOT_DIR .. "/bug373_title_redpp.png")
PaletteFX.setMode(mode)
U.wait(10)
U.log("The title is on screen and the pad is yours; START replays it.")
U.log("In SGB the whole background is one off-white: sample left-edge")
U.log("background above, inside and below the \"Version\" band and all three")
U.log("read the same, no brighter strip and no dark hairline at rows 64/80.")
U.log("The redpp shot is pure white throughout with the ink still red on")
U.log("Red and blue on Blue; run POKEPORT_VERSION=blue to confirm that half.")
while true do
coroutine.yield()
end
end
+176
View File
@@ -0,0 +1,176 @@
-- A song cued while a fanfare is sounding stays silent until the jingle
-- ends (#398), on both chip playback paths. On the Game Boy a fanfare's
-- sfx header claims the music channels and Audio1_PlaySound's .playMusic
-- only rewrites the NUM_MUSIC_CHANS state, so the new song is muted until
-- the jingle finishes (audio/engine_1.asm:39-56 Audio1_ApplyMusicAffects,
-- :1343-1357 .playMusic). ChipAudio is what calls Source:play for a chip
-- song, so Music pausing its own Source cannot cover a song that starts
-- mid-jingle: the hold lives in ChipAudio and Music releases it.
-- ROM-free: ChipAsm blobs, no data/generated/.
-- luajit tests/engine/fanfare_music_hold.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
-- ------- audio stub: records which sources exist and which are sounding
local sources = {}
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(v) self.looping = v end
function Source:setVolume(v) self.volume = v end
function Source:setPitch(v) self.pitch = v end
function Source:setFilter() end
function Source:getDuration() return 1 end
function Source:getFreeBufferCount() return self.free end
function Source:queue() self.free = math.max(0, self.free - 1) end
local ChipSynth = require("src.core.ChipSynth")
local BUFFERS = ChipSynth.MUSIC_BUFFER_COUNT
local function track(src)
sources[#sources + 1] = src
return src
end
love.audio = {
newSource = function(what, mode)
if what ~= "assets/beep.wav" then error("could not open " .. tostring(what), 0) end
return track(setmetatable({ file = what, mode = mode, free = 0 }, Source))
end,
newQueueableSource = function()
return track(setmetatable({ queueable = true, free = BUFFERS }, Source))
end,
}
-- ------- worker stub for the threaded path
-- The real worker synthesizes on another thread; here the test hands the
-- buffers over itself so it controls the frame the first one lands on.
local channels = {}
local lastGen = 0
local Channel = {}
Channel.__index = Channel
function Channel:push(msg)
if type(msg) == "table" and msg.cmd == "play" then lastGen = msg.gen end
self.queue[#self.queue + 1] = msg
end
function Channel:pop() return table.remove(self.queue, 1) end
function Channel:clear() self.queue = {} end
local function channel(name)
channels[name] = channels[name] or setmetatable({ queue = {} }, Channel)
return channels[name]
end
local threadStub = {
newThread = function()
return {
start = function() end,
getError = function() return nil end,
wait = function() end,
}
end,
getChannel = channel,
}
local function deliverBuffer()
channel("chipaudio_out"):push({ gen = lastGen, sd = true })
end
-- ------- fixture dataset
local ChipAsm = require("src.audio.ChipAsm")
local function chipSong(octave)
return ChipAsm.song{
channels = { { hw = 1, program = {
{ notetype = { speed = 12, volume = 12, fade = 0 } },
{ octave = octave },
{ note = "C", len = 8 },
{ loop = { count = 0, to = 1 } },
} } },
}
end
local function fixtureData()
return {
audio = {
songs = { Music_PalletTown = chipSong(4), Music_Routes1 = chipSong(5) },
-- Level_Up rides Sound.lua's FANFARES fallback, which is what runs
-- when a cache carries no data.audio.fanfares table
sfx = { Level_Up = "assets/beep.wav" },
cries = {},
mapSongs = { PALLET_TOWN = "Music_PalletTown" },
},
}
end
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
-- ChipAudio decides sync vs threaded once per process (workerReady), so each
-- path gets its own copy of the module; Music resolves it through require on
-- every call and picks the new one up.
local function freshChipAudio(threaded)
love.thread = threaded and threadStub or nil
package.loaded["src.core.ChipAudio"] = nil
return require("src.core.ChipAudio")
end
local function lastSource() return sources[#sources] end
local function scenario(label, threaded)
local ChipAudio = freshChipAudio(threaded)
local data = fixtureData()
Sound.invalidate()
Music.reload()
for i = #sources, 1, -1 do sources[i] = nil end
Music.playMap(data, "PALLET_TOWN", false, false)
local mapSrc = lastSource()
if threaded then deliverBuffer() ChipAudio.update() end
check(mapSrc and mapSrc.queueable, label .. ": map theme streams through ChipAudio")
check(mapSrc.playing, label .. ": map theme is sounding before the jingle")
local fanfare = Sound.play(data, "Level_Up")
check(fanfare ~= nil and fanfare.playing, label .. ": Level_Up started")
check(not mapSrc.playing, label .. ": the playing song ducked under the jingle")
-- BattleState:finish -> Music.restoreMap while the jingle still sounds;
-- restoreMap clears state.current, so even the same theme rebuilds
Music.restoreMap(data)
local held = lastSource()
check(held ~= mapSrc, label .. ": restoreMap built a new source mid-jingle")
if threaded then deliverBuffer() ChipAudio.update() end
ChipAudio.ensureMusicPlaying()
Music.update(data)
check(not held.playing, label .. ": a song cued during the jingle stays silent")
check(fanfare.playing, label .. ": the jingle is still the only thing sounding")
fanfare:stop()
Music.update(data)
if threaded then ChipAudio.update() end
check(held.playing, label .. ": the held song starts once the jingle ends")
-- a later song change is unaffected: the hold is released, not sticky
Music.play(data, "Music_Routes1")
local next_ = lastSource()
if threaded then deliverBuffer() ChipAudio.update() end
check(next_ ~= held and next_.playing, label .. ": later songs start normally")
end
scenario("sync", false)
scenario("threaded", true)
T.finish("fanfare music hold")
+170
View File
@@ -0,0 +1,170 @@
-- Headless regression: a gift jingle rides its received-item text instead
-- of firing when the box opens (#374). pokered's GiveItem (home/give.asm)
-- plays nothing; the sound is a trailing text command in the gift text
-- (scripts/ViridianMart.asm sound_get_key_item), and home/text.asm:506
-- TextCommand_SOUND runs only after the last character is placed, then
-- WaitForSoundToFinish, then home/text_script.asm AfterDisplayingTextID's
-- button wait. ROM-free: fixture items plus a stub audio source.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local Commands = require("src.script.Commands")
local Sound = require("src.core.Sound")
local SaveData = require("src.core.SaveData")
local TextBox = require("src.render.TextBox")
-- a key item to gift: the fixture set has none, and keyItem is what picks
-- Get_Key_Item over Get_Item1
Data.items.FIX_PARCEL = {
id = "FIX_PARCEL", index = 90, name = "FIX PARCEL", price = 0,
keyItem = true,
}
-- one stub source per sfx file, with a playing flag the case drives: Sound.
-- play has to hand the source back for the box to block on it. The A-press
-- beep goes through the same path, so plays records which sound it was.
local plays = {}
local sources = {}
local function newSource(file)
local src = sources[file]
if src then return src end
src = {
playing = false,
setVolume = function() end,
setPitch = function() end,
stop = function() end,
isPlaying = function(self) return self.playing end,
play = function(self)
plays[#plays + 1] = file
self.playing = true
end,
}
sources[file] = src
return src
end
love.audio = { newSource = newSource }
local function jingles()
local n = 0
for _, file in ipairs(plays) do
if file ~= "ab.wav" then n = n + 1 end
end
return n
end
Data.audio = {
sfx = { Get_Key_Item = "key.wav", Get_Item1 = "item.wav", Press_AB = "ab.wav" },
fanfares = {},
}
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop()
local t = self.states[#self.states]
self.states[#self.states] = nil
return t
end
function stack:top() return self.states[#self.states] end
local pressed = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = stack,
input = {
wasPressed = function(_, key) return pressed[key] or false end,
isDown = function() return false end,
},
}
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local resumed = 0
local ctx = {
game = game,
save = game.save,
runner = { yield = function() end, resume = function() resumed = resumed + 1 end },
}
local function step(btn)
pressed = btn and { [btn] = true } or {}
local top = stack:top()
if top then top:update(1 / 60) end
pressed = {}
end
-- ---------------------------------------------------------------- gift text
-- the Viridian Mart shape: the gift text is the whole quest, three pages,
-- and only the last one is "<PLAYER> got / <item>!"
-- uppercase: the fixture font has no lowercase glyphs
local QUEST = "YOU KNOW PROF\nOAK RIGHT\fHE ASKED ME TO\nDELIVER THIS\f"
.. "{PLAYER} GOT\n{RAM:wStringBuffer}!"
Commands.give_item(ctx, "FIX_PARCEL", 1, QUEST)
T.eq(game.save.inventory.FIX_PARCEL, 1, "the parcel reached the bag")
local box = stack:top()
T.check(getmetatable(box) == TextBox, "the received-item box is up")
T.eq(#box.pages, 3, "the gift text is three pages")
T.eq(jingles(), 0, "no jingle when the box opens")
-- type the whole text out, answering the page breaks; the jingle must stay
-- silent for every frame of it
local pagesSeen = 1
for _ = 1, 2000 do
if box.done then break end
step(box.waiting and "a" or nil)
if box.pageIndex > pagesSeen then
pagesSeen = box.pageIndex
T.eq(jingles(), 0, "still silent on page " .. pagesSeen)
end
end
T.check(box.done, "the last page finished typing")
T.eq(jingles(), 0, "silent until the last character is placed")
step()
T.eq(jingles(), 1, "the jingle fires once the text is out")
T.eq(plays[#plays], "key.wav", "and it is the key-item jingle")
T.eq(box.autoSrc, sources["key.wav"],
"the box holds the source Sound.play returned")
-- WaitForSoundToFinish: A is dead while the fanfare sounds
step("a")
T.eq(stack:top(), box, "A does not close the box during the jingle")
T.eq(resumed, 0, "the script has not resumed yet")
T.eq(jingles(), 1, "and the jingle is not retriggered")
sources["key.wav"].playing = false
step()
T.check(box.auto == nil, "the sound gate drops when the fanfare ends")
T.eq(stack:top(), box, "the box stays up for the button wait")
step("a")
T.eq(stack:top(), nil, "A closes the box after the jingle")
T.eq(resumed, 1, "the script resumed once")
-- ------------------------------------------------------------- plain gift
-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE
-- BALLs): nothing to hang the sound on, so it plays on the spot
plays = {}
Commands.give_item(ctx, "FIX_POTION", 1, false)
T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately")
T.eq(plays[1], "item.wav", "with the plain-item sound")
T.eq(stack:top(), nil, "and pushes no box of its own")
-- ------------------------------------------------------------ script data
-- both Viridian Mart paths must hand give_item the quest text, since that
-- is what routes the jingle through the box
local story = require("data.scripts.story")
local mart = story.VIRIDIAN_MART
local rows = mart.talk.TEXT_VIRIDIANMART_CLERK
local gift
for _, row in ipairs(rows) do
if row[1] == "give_item" and row[2] == "OAKS_PARCEL" then gift = row end
end
T.check(gift ~= nil, "the clerk talk path gifts OAKS_PARCEL")
T.eq(gift and gift[4], "_ViridianMartClerkParcelQuestText",
"and passes the quest text, not the default got-item line")
T.finish("give_item_jingle")
+96
View File
@@ -0,0 +1,96 @@
-- A multi-hit move must step the target's HP bar down once per strike
-- (#394). ApplyDamageToEnemyPokemon (engine/battle/core.asm:4684-4727)
-- subtracts wDamage and runs UpdateHPBar2 inside the wNumAttacksLeft loop,
-- so the bar animates one hit's worth per pass; the port takes every
-- strike off the model while the turn is still being queued, so each drain
-- row has to carry its own stop.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local HITS = 3
Data.moves.FIX_MULTI = {
id = "FIX_MULTI", index = 99, name = "FIX MULTI",
type = "NORMAL", power = 15, accuracy = 100, pp = 10,
effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = HITS,
}
local function mkseq(vals) -- scripted rng: pops vals, then max rolls
local i = 0
return function(_, hi)
i = i + 1
return vals[i] ~= nil and vals[i] or hi
end
end
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = mkseq({ 0, 255, 255 }) -- hit, no crit, max damage roll
local startHP = battle.enemy.mon.hp
battle:performMove(battle.player, battle.enemy, { id = "FIX_MULTI", pp = 10 })
local drains = {}
for _, row in ipairs(battle.queue) do
if row.drain then drains[#drains + 1] = row end
end
T.eq(#drains, HITS, "a drain row per strike")
local perHit = (startHP - battle.enemy.mon.hp) / HITS
T.check(perHit > 1, "the strikes take more than a pixel of bar each")
for h, row in ipairs(drains) do
T.eq(row.battler, battle.enemy, "drain row " .. h .. " names the target")
T.eq(row.stopAt, startHP - perHit * h,
"drain row " .. h .. " stops at the HP left after strike " .. h)
end
-- replay the rows the way updateQueue consumes them: the row's stopAt
-- becomes the battler's drainFloor, the drain runs to a stop, the floor is
-- cleared. Before the fix every row read the live post-last-hit HP, so
-- row 1 emptied the whole total and rows 2..n moved nothing.
local settled = {}
for _, row in ipairs(drains) do
local before = battle.enemy.shownHP
battle.enemy.drainFloor = row.stopAt
local frames = 0
while battle:stepHPDrain() and frames < 4000 do frames = frames + 1 end
battle.enemy.drainFloor = nil
T.check(battle.enemy.shownHP < before, "the bar moved on this strike")
settled[#settled + 1] = battle.enemy.shownHP
end
for h, hp in ipairs(settled) do
T.eq(hp, startHP - perHit * h, "the bar rests on strike " .. h .. "'s HP")
end
T.eq(settled[#settled], battle.enemy.mon.hp,
"the last strike leaves the bar on the true HP")
-- a single-hit drain still runs to the live HP with no stop pinned
local single = BattleState.newWild(game, "FIXMON_C", 40)
single.rng = mkseq({ 0, 255, 255 })
single:performMove(single.player, single.enemy, { id = "FIX_TACKLE", pp = 35 })
local rows = 0
for _, row in ipairs(single.queue) do
if row.drain then
rows = rows + 1
T.eq(row.stopAt, single.enemy.mon.hp, "the single drain stops at the new HP")
end
end
T.eq(rows, 1, "one strike queues one drain")
local frames = 0
single.enemy.drainFloor = single.enemy.mon.hp
while single:stepHPDrain() and frames < 4000 do frames = frames + 1 end
T.eq(single.enemy.shownHP, single.enemy.mon.hp, "the single drain settles")
Data.moves.FIX_MULTI = nil
T.finish("multihit hp drain")
+209
View File
@@ -0,0 +1,209 @@
-- Quitting has to end the process (#339). Both background love.thread
-- workers idle in loops that only a { cmd = "quit" } command breaks
-- (src/core/chip_worker.lua, src/update/check_worker.lua) and LOVE joins
-- every live thread before the process exits, so with no shutdown the window
-- closed while the process kept spinning: on Android the relaunched task
-- re-entered an activity whose native main had already returned. Purely a
-- port lifecycle concern, so no pokered citation.
-- ROM-free: a ChipAsm blob plus a fake love.thread.
-- luajit tests/engine/quit_thread_shutdown.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")
-- ------- audio + thread stubs
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:getFreeBufferCount() return self.free end
function Source:queue() self.free = math.max(0, self.free - 1) end
local ChipSynth = require("src.core.ChipSynth")
love.audio = {
newQueueableSource = function()
return setmetatable({ free = ChipSynth.MUSIC_BUFFER_COUNT }, Source)
end,
}
local channels, threads = {}, {}
local Channel = {}
Channel.__index = Channel
function Channel:push(msg) self.log[#self.log + 1] = msg end
function Channel:pop() return table.remove(self.log, 1) end
function Channel:clear() self.log = {} end
function Channel:getCount() return #self.log end
-- the command channels are read back after shutdown, so keep every command
-- pushed on them rather than letting pop/clear consume the history
local Recorder = setmetatable({}, { __index = Channel })
Recorder.__index = Recorder
function Recorder:pop() return nil end
function Recorder:clear() end
local function channel(name)
if not channels[name] then
local mt = name:find("cmd", 1, true) and Recorder or Channel
channels[name] = setmetatable({ log = {} }, mt)
end
return channels[name]
end
local function commands(name)
local out = {}
for _, msg in ipairs(channel(name).log) do
out[#out + 1] = type(msg) == "table" and msg.cmd or tostring(msg)
end
return out
end
local function counted(list, want)
local n = 0
for _, cmd in ipairs(list) do
if cmd == want then n = n + 1 end
end
return n
end
love.thread = {
newThread = function(path)
local th = { path = path, waited = 0, started = false }
th.start = function() th.started = true end
th.getError = function() return nil end
th.wait = function() th.waited = th.waited + 1 end
threads[#threads + 1] = th
return th
end,
getChannel = channel,
}
local function threadFor(path)
for _, th in ipairs(threads) do
if th.path == path then return th end
end
return nil
end
-- ------- chip audio worker
local ChipAsm = require("src.audio.ChipAsm")
local song = ChipAsm.song{
channels = { { hw = 1, program = {
{ notetype = { speed = 12, volume = 12, fade = 0 } },
{ octave = 4 },
{ note = "C", len = 8 },
{ loop = { count = 0, to = 1 } },
} } },
}
local data = { audio = { songs = { Music_PalletTown = song } } }
-- absent before the fix; called through this so the rest of the report still
-- runs instead of erroring out on the first missing entry point
local function shutdown(mod)
if type(mod.shutdown) == "function" then mod.shutdown() end
end
local ChipAudio = require("src.core.ChipAudio")
check(type(ChipAudio.shutdown) == "function", "ChipAudio exposes shutdown")
check(ChipAudio.playMusic(data, song, true) ~= nil,
"threaded playMusic starts the chip worker")
local chipThread = threadFor("src/core/chip_worker.lua")
check(chipThread ~= nil and chipThread.started, "the chip worker is running")
eq(counted(commands("chipaudio_cmd"), "quit"), 0,
"nothing tells the chip worker to quit during play")
shutdown(ChipAudio)
local chipCmds = commands("chipaudio_cmd")
eq(chipCmds[#chipCmds], "quit", "shutdown pushes the chip worker's quit command")
eq(chipThread.waited, 1, "shutdown joins the chip worker instead of leaving it live")
-- a second call must not push onto a channel whose worker is already gone,
-- and neither must the normal playback calls that survive teardown
shutdown(ChipAudio)
ChipAudio.stopMusic()
ChipAudio.invalidate()
eq(counted(commands("chipaudio_cmd"), "quit"), 1,
"shutdown is idempotent and post-shutdown calls stay quiet")
eq(chipThread.waited, 1, "the joined worker is not waited on twice")
-- ------- update check worker
local Check = require("src.update.Check")
check(type(Check.shutdown) == "function", "Check exposes shutdown")
Check.start()
local checkThread = threadFor("src/update/check_worker.lua")
check(checkThread ~= nil and checkThread.started, "the update worker is running")
eq(commands("update_check_cmd")[1], "check", "start pushes the check command")
shutdown(Check)
local upCmds = commands("update_check_cmd")
eq(upCmds[#upCmds], "quit", "shutdown pushes the update worker's quit command")
eq(checkThread.waited, 1, "shutdown joins the update worker")
shutdown(Check)
Check.download()
eq(counted(commands("update_check_cmd"), "quit"), 1,
"shutdown is idempotent and post-shutdown calls stay quiet")
eq(Check.state().status ~= nil, true, "state() still answers after shutdown")
-- ------- the worker loops still break on that command
local function source(path)
local f = io.open(path, "rb")
check(f ~= nil, path .. " is readable")
local text = f and f:read("*a") or ""
if f then f:close() end
return text
end
local chipSrc = source("src/core/chip_worker.lua")
check(chipSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*return true') ~= nil,
"chip_worker leaves its command loop on quit")
local checkSrc = source("src/update/check_worker.lua")
check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil,
"check_worker leaves its demand loop on quit")
-- ------- main.lua wiring
-- love.quit is the established teardown hook (DiscordPresence already rides
-- it) and reaches the modules through package.loaded, so a session that never
-- touched audio or the launcher pays nothing.
local mainSrc = source("main.lua")
local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n")
check(quitHook ~= nil, "love.quit is still a single top-level function")
quitHook = quitHook or ""
check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil,
"love.quit shuts the chip worker down")
check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil,
"love.quit shuts the update worker down")
-- The Android half: LOVE keeps the JVM process after the native main returns,
-- so the quit event exits the process outright. It has to sit after the
-- love.quit() veto test, or the editor's abort-quit path would die on a quit
-- it refused.
local quitBranch = mainSrc:match('if name == "quit" then(.-)\n%s*end\n')
check(quitBranch ~= nil, "love.run still handles the quit event")
quitBranch = quitBranch or ""
local vetoAt = quitBranch:find("not love%.quit()")
local osAt = quitBranch:find("os%.exit")
local androidAt = quitBranch:find('getOS() == "Android"', 1, true)
check(vetoAt ~= nil and osAt ~= nil and vetoAt < osAt,
"the process exit runs only after love.quit declined to veto")
check(androidAt ~= nil and androidAt < osAt,
"the process exit is gated on Android")
local exits = 0
for _ in mainSrc:gmatch("os%.exit") do exits = exits + 1 end
eq(exits, 1, "os.exit appears once, at the quit event")
T.finish("quit thread shutdown")
+199
View File
@@ -0,0 +1,199 @@
-- Gen1 save codec (src/save_convert/GenSave.lua) for the save.flags names whose
-- vanilla home is NOT wEventFlags: exporting a slot and importing it back used
-- to drop them, so the Saffron gate guards were thirsty again (#396). Offsets
-- are re-derived here byte by byte from ram/wram.asm rather than read out of
-- GenSave.OFFSETS, and the bit numbers come from constants/ram_constants.asm
-- (BIT_GOT_OLD_ROD 3, BIT_GAVE_SAFFRON_GUARDS_DRINK 6, BIT_GOT_LAPRAS 0,
-- BIT_STARTED_ELITE_4 1). The trade bits are wWhichTrade, which
-- engine/events/in_game_trades.asm uses to index wCompletedInGameTradeFlags,
-- so they are checked against the 1-based rows the port's scripts pass.
-- luajit tests/engine/save_convert_extra_flags.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 bit = require("bit")
local GenSave = require("src.save_convert.GenSave")
local SaveData = require("src.core.SaveData")
-- the codec crosswalks need the real dataset; CI has no ROM
local loadPokemon = loadfile("data/generated/pokemon.lua")
if not loadPokemon then
print("save_convert_extra_flags skipped (needs data/generated/ for the Gen1 save codec)")
os.exit(0)
end
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local events = loadfile("src/save_convert/data/event_flags.lua")()
local data = {
pokemon = loadPokemon(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = events,
}
-- ------------------------------------------------------------------
-- offsets, walked forward from wTownVisitedFlag over ram/wram.asm's own
-- declaration run: 2 wTownVisitedFlag, 2 wSafariSteps, wFossilItem,
-- wFossilMon, ds 2, wEnemyMonOrTrainerClass, wPlayerJumpingYScreenCoordsIndex,
-- wRivalStarter, ds 1, wPlayerStarter, wBoulderSpriteIndex, wLastBlackoutMap,
-- wDestinationMap, wUnusedPlayerDataByte, wTileInFrontOfBoulder...,
-- wDungeonWarpDestinationMap, wWhichDungeonWarp, wUnusedCardKeyGateID, ds 8
-- ------------------------------------------------------------------
local OFF = GenSave.OFFSETS
local TOWN_VISITED = OFF.townVisited
local WRAM = {
statusFlags1 = TOWN_VISITED + 29,
statusFlags4 = TOWN_VISITED + 35, -- +30 ds 1, wBeatGymFlags, ds 1, 2/3
elite4Flags = TOWN_VISITED + 41, -- +36 ds 1, 5, ds 1, 6, 7
tradeFlags = TOWN_VISITED + 44, -- +42 ds 1, wMovementFlags
eventFlags = TOWN_VISITED + 60, -- the run's own end, pinned already
}
eq(WRAM.eventFlags, OFF.eventFlags,
"the wram walk lands on wEventFlags where the codec already pins it")
eq(OFF.statusFlags1, WRAM.statusFlags1, "wStatusFlags1 is wTownVisitedFlag + 29")
eq(OFF.statusFlags4, WRAM.statusFlags4, "wStatusFlags4 is wTownVisitedFlag + 35")
eq(OFF.elite4Flags, WRAM.elite4Flags, "wElite4Flags is wTownVisitedFlag + 41")
eq(OFF.tradeFlags, WRAM.tradeFlags, "wCompletedInGameTradeFlags is wTownVisitedFlag + 44")
-- independent flag_array read (byte = index / 8, bit = index % 8), so nothing
-- below trusts the writer it is checking
local function flagGet(bytes, base, index)
local byte = bytes:byte(base + math.floor(index / 8) + 1)
return bit.band(bit.rshift(byte, index % 8), 1) == 1
end
-- ------------------------------------------------------------------
-- the names under test, and the proof they cannot ride wEventFlags
-- ------------------------------------------------------------------
local EXTRA = {
{ "EVENT_GOT_OLD_ROD", "statusFlags1", 3 },
{ "EVENT_GOT_GOOD_ROD", "statusFlags1", 4 },
{ "EVENT_GOT_SUPER_ROD", "statusFlags1", 5 },
{ "EVENT_GAVE_GUARDS_DRINK", "statusFlags1", 6 },
{ "EVENT_GOT_LAPRAS", "statusFlags4", 0 },
{ "EVENT_STARTED_ELITE_4", "elite4Flags", 1 },
}
local named = 0
for _ in pairs(events.byName) do named = named + 1 end
eq(named, 507, "event_flags.lua carries every EVENT_* constant and no more")
for _, row in ipairs(EXTRA) do
eq(events.byName[row[1]], nil, row[1] .. " has no wEventFlags bit to ride")
end
-- ------------------------------------------------------------------
-- trade rows as the port's scripts actually pass them: `{ "trade", N, FLAG }`
-- with N the 1-based data/events/trades.asm row, so the bit is N - 1
-- ------------------------------------------------------------------
local TRADE_ROWS = {}
local scripts = io.popen("ls data/scripts/*.lua")
for path in scripts:lines() do
local f = io.open(path, "r")
local src = f:read("*a")
f:close()
for index, flag in src:gmatch('"trade",%s*(%d+),%s*"(EVENT_[A-Z0-9_]+)"') do
TRADE_ROWS[#TRADE_ROWS + 1] = { flag = flag, index = tonumber(index) }
end
end
scripts:close()
check(#TRADE_ROWS == 7, "the nine-row trade table's seven reachable trades are scripted")
-- ------------------------------------------------------------------
-- round trips
-- ------------------------------------------------------------------
local function seedSave()
local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
save.party = { {
species = "SQUIRTLE", level = 6, exp = 200,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
hp = 22,
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "SQ", ot = "RED", otId = save.player.id, catchRate = 45,
} }
return save
end
local set = seedSave()
for _, row in ipairs(EXTRA) do set.flags[row[1]] = true end
for _, row in ipairs(TRADE_ROWS) do set.flags[row.flag] = true end
set.flags.EVENT_RECEIVED_BIKE_VOUCHER = true
local setBytes = GenSave.encode(set, data, nil)
eq(#setBytes, GenSave.SAVE_SIZE, "the export is a 32768-byte save")
for _, row in ipairs(EXTRA) do
check(flagGet(setBytes, WRAM[row[2]], row[3]),
row[1] .. " reaches the .sav as its " .. row[2] .. " bit " .. row[3])
end
for _, row in ipairs(TRADE_ROWS) do
check(flagGet(setBytes, WRAM.tradeFlags, row.index - 1),
row.flag .. " reaches wCompletedInGameTradeFlags bit " .. (row.index - 1))
end
-- the port spells bit 337 EVENT_RECEIVED_BIKE_VOUCHER; vanilla calls it
-- EVENT_GOT_BIKE_VOUCHER (constants/event_constants.asm)
eq(events.byName.EVENT_GOT_BIKE_VOUCHER, 337, "the bike voucher is event bit 337")
check(flagGet(setBytes, WRAM.eventFlags, 337),
"EVENT_RECEIVED_BIKE_VOUCHER reaches the real bike voucher event bit")
-- no spill into the neighbouring bits of a shared byte
eq(bit.band(setBytes:byte(WRAM.statusFlags1 + 1), 0x87), 0,
"wStatusFlags1 keeps its other bits (0-2, 7) clear")
eq(bit.band(setBytes:byte(WRAM.tradeFlags + 1), 0x04), 0,
"the unused CHIKUCHIKU trade bit stays clear")
local back = GenSave.decode(setBytes, data)
eq(#(back.warnings or {}), 0, "the export decodes with no warnings")
local reflags = back.flags
for _, row in ipairs(EXTRA) do
eq(reflags[row[1]], true, row[1] .. " survives export -> import")
end
for _, row in ipairs(TRADE_ROWS) do
eq(reflags[row.flag], true, row.flag .. " survives export -> import")
end
eq(reflags.EVENT_RECEIVED_BIKE_VOUCHER, true,
"EVENT_RECEIVED_BIKE_VOUCHER comes back under the port's own spelling")
eq(reflags.EVENT_GOT_BIKE_VOUCHER, true, "and under the vanilla spelling too")
-- ------------------------------------------------------------------
-- the port's save is the only authority for these bits, so a save that does
-- NOT hold one must clear it out of the template rather than inherit it
-- ------------------------------------------------------------------
local clear = seedSave()
local clearBytes = GenSave.encode(clear, data, setBytes)
for _, row in ipairs(EXTRA) do
check(not flagGet(clearBytes, WRAM[row[2]], row[3]),
row[1] .. " is cleared, not inherited from the template")
end
for _, row in ipairs(TRADE_ROWS) do
check(not flagGet(clearBytes, WRAM.tradeFlags, row.index - 1),
row.flag .. " is cleared, not inherited from the template")
end
local reclear = GenSave.decode(clearBytes, data)
eq(reclear.flags.EVENT_GAVE_GUARDS_DRINK, nil,
"a save that never watered the guards imports back thirsty")
-- ------------------------------------------------------------------
-- cross-file pin: the four Saffron gates read this exact spelling
-- (data/scripts/story2.lua, pokered scripts/Route5Gate.asm and its twins)
-- ------------------------------------------------------------------
local sf = io.open("data/scripts/story2.lua", "r")
local story2 = sf:read("*a")
sf:close()
check(story2:find("flags.EVENT_GAVE_GUARDS_DRINK", 1, true) ~= nil,
"the gate scripts still spell the drink flag EVENT_GAVE_GUARDS_DRINK")
T.finish("save_convert_extra_flags")
+222
View File
@@ -0,0 +1,222 @@
-- #373: the two faint horizontal lines across the title screen. Both
-- halves are asserted here: Renderer's zone scissor must hand LOVE rects
-- whose framebuffer pixels stay contiguous across a zone boundary at
-- Android's non-integer DPI, and TitleState's three title zones must share
-- color 0 the way the hardware SuperPals do (pokered data/sgb/
-- sgb_palettes.asm: PAL_LOGO1 / PAL_LOGO2 / PAL_MEWMON all start RGB
-- 31,29,31).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq, same = T.check, T.eq, T.same
-- ---------------------------------------------------------------- scissor
-- scissorClamped is a local in Renderer.lua and the only caller is inside
-- endFrame's blit closure, which needs canvases and a compiled shader. The
-- source is loaded directly so the rect arithmetic can be exercised with no
-- GPU, the same way parity_picker_pointer_grab reads RomImporter (#254).
local scissorClamped, captured
do
local f = io.open("src/render/Renderer.lua", "rb")
check(f ~= nil, "Renderer source is readable")
local src = f and f:read("*a") or ""
if f then f:close() end
local body = src:match("\nlocal function scissorClamped.-\nend\n")
check(body ~= nil, "scissorClamped is still a single local function")
local fakeLove = { graphics = { setScissor = function(x, y, w, h)
captured = { x = x, y = y, w = w, h = h }
end } }
local chunk = assert(loadstring("local love = ...\n" .. (body or "")
.. "\nreturn scissorClamped"))
scissorClamped = chunk(fakeLove)
check(type(scissorClamped) == "function", "scissorClamped loads standalone")
end
-- The title's three SGB zones in canvas pixels (PaletteFX.zone turns the
-- ATTR_BLK tile rects rows 0-7 / 8-9 / 10-17 into these).
local ZONES = {
{ x = 0, y = 0, w = 160, h = 64 },
{ x = 0, y = 64, w = 160, h = 16 },
{ x = 0, y = 80, w = 160, h = 64 },
}
-- Mirrors Renderer:endFrame's letterbox geometry and its UI blit call
-- (blit(canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)).
local function viewport(pw, ph, dpiX, dpiY)
local uiw, uih = 160, 144
local Sp = math.max(1, math.floor(math.min(pw / uiw, ph / uih)))
return {
Sp = Sp, Sx = Sp / dpiX, Sy = Sp / dpiY,
ox = math.floor((pw - uiw * Sp) / 2) / dpiX,
oy = math.floor((ph - uih * Sp) / 2) / dpiY,
vpw = uiw * (Sp / dpiX), vph = uih * (Sp / dpiY),
}
end
local function zoneRects(v, dpiX, dpiY)
local out = {}
for i, z in ipairs(ZONES) do
captured = nil
local drew = scissorClamped(v.ox + z.x * v.Sx, v.oy + z.y * v.Sy,
z.w * v.Sx, z.h * v.Sy,
v.ox, v.oy, v.vpw, v.vph, dpiX, dpiY)
out[i] = drew and captured or nil
end
return out
end
-- What the pre-fix code emitted: the clamp alone, fractional edges and all.
local function clampOnly(v)
local out = {}
for i, z in ipairs(ZONES) do
local x, y = v.ox + z.x * v.Sx, v.oy + z.y * v.Sy
local x2 = math.min(x + z.w * v.Sx, v.ox + v.vpw)
local y2 = math.min(y + z.h * v.Sy, v.oy + v.vph)
x, y = math.max(x, v.ox), math.max(y, v.oy)
out[i] = { x = x, y = y, w = x2 - x, h = y2 - y }
end
return out
end
-- love.graphics.setScissor scales a rect by the DPI scalar and truncates
-- x, y, w and h to whole framebuffer pixels independently, so a rect's
-- covered rows are [floor(y*d), floor(y*d) + floor(h*d)).
local function rowSpan(r, d)
local top = math.floor(r.y * d)
return top, top + math.floor(r.h * d)
end
local function colSpan(r, d)
local left = math.floor(r.x * d)
return left, left + math.floor(r.w * d)
end
local function seams(rects, d)
local n = 0
for k = 1, #rects - 1 do
local _, bottom = rowSpan(rects[k], d)
local top = rowSpan(rects[k + 1], d)
if bottom < top then n = n + 1 end
end
return n
end
-- Desktop: dpi 1, integer origin and scale. Every edge is already whole, so
-- the rounding must land on exactly the pixels the plain clamp did (that path
-- was never broken) and neighbours must abut with no overlap at all.
do
local v = viewport(1920, 1080, 1, 1)
local fixed, plain = zoneRects(v, 1, 1), clampOnly(v)
for i = 1, #ZONES do
local top, bottom = rowSpan(fixed[i], 1)
local ptop, pbottom = rowSpan(plain[i], 1)
eq(top, ptop, "desktop zone " .. i .. " starts on the same row")
eq(bottom, pbottom, "desktop zone " .. i .. " ends on the same row")
end
for k = 1, #fixed - 1 do
local _, bottom = rowSpan(fixed[k], 1)
eq(bottom, rowSpan(fixed[k + 1], 1),
"desktop zone " .. k .. " abuts its neighbour exactly")
end
end
-- The reporter's 1920x1080 Android screen, plus DPI values around it. d is
-- the scalar LOVE applies; it equals the axis ratio on a square-DPI surface
-- and can differ when the window's two ratios do not match.
local CASES = {
{ dpiX = 2.625, dpiY = 2.625 },
{ dpiX = 2.75, dpiY = 2.75 },
{ dpiX = 3.5, dpiY = 3.5 },
{ dpiX = 2.0625, dpiY = 2.0625 },
{ dpiX = 2.625, dpiY = 2.6, d = 2.625 },
{ dpiX = 2.75, dpiY = 2.8, d = 2.75 },
}
local brokenCases = 0
for _, c in ipairs(CASES) do
local tag = ("dpi %s/%s"):format(c.dpiX, c.dpiY)
local v = viewport(1920, 1080, c.dpiX, c.dpiY)
local dy = c.d or c.dpiY
local dx = c.d or c.dpiX
local rects = zoneRects(v, c.dpiX, c.dpiY)
eq(#rects, 3, tag .. ": all three title zones draw")
eq(seams(rects, dy), 0, tag .. ": no black row between adjacent zones")
if seams(clampOnly(v), dy) > 0 then brokenCases = brokenCases + 1 end
for k = 1, #rects - 1 do
local _, bottom = rowSpan(rects[k], dy)
local top = rowSpan(rects[k + 1], dy)
check(bottom - top <= 1,
tag .. ": zone " .. k .. " overlaps its neighbour by at most one row")
end
-- The same truncation was shaving the bottom copyright row and the right
-- edge of the picture, so the outer edges have to reach the viewport too.
local firstTop = rowSpan(rects[1], dy)
local _, lastBottom = rowSpan(rects[#rects], dy)
check(firstTop <= math.floor(v.oy * dy), tag .. ": top edge is covered")
check(lastBottom >= math.floor((v.oy + v.vph) * dy),
tag .. ": the bottom copyright row is covered")
local left, right = colSpan(rects[#rects], dx)
check(left <= math.floor(v.ox * dx), tag .. ": left edge is covered")
check(right >= math.floor((v.ox + v.vpw) * dx),
tag .. ": the right edge of the picture is covered")
end
check(brokenCases > 0,
"the plain clamped rects still gap on a fractional-DPI surface, which is"
.. " the seam the rounding removes")
-- ---------------------------------------------------------------- palettes
local PaletteFX = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local TitleState = require("src.ui.TitleState")
local OFF_WHITE = { 255, 239, 255 }
local function romPack(logo1)
return { palettes = { palettes = {
LOGO1 = logo1,
LOGO2 = { OFF_WHITE, { 247, 247, 140 }, { 148, 156, 148 }, { 57, 57, 132 } },
MEWMON = { OFF_WHITE, { 247, 181, 140 }, { 132, 115, 156 }, { 24, 16, 16 } },
} } }
end
local RED_LOGO1 = { OFF_WHITE, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 } }
local BLUE_LOGO1 = { OFF_WHITE, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 } }
local savedMode, savedVersion = PaletteFX.mode, GameVersion.get()
local title = setmetatable({}, { __index = TitleState })
-- SGB (the default): the ribbon band's white is the neighbouring zones'
-- white, so the two boundaries at y=64 and y=80 draw no brighter strip.
do
PaletteFX.mode = "gbc"
GameVersion.set("red")
local z = title:sgbPalettes({ data = romPack(RED_LOGO1) })
eq(z and #z, 3, "the title builds three SGB zones")
eq(z[1].y, 0, "logo zone starts at row 0")
eq(z[2].y, 64, "the version ribbon starts at tile row 8")
eq(z[3].y, 80, "the player/mon zone starts at tile row 10")
eq(z[1].y + z[1].h, z[2].y, "logo and ribbon share a boundary")
eq(z[2].y + z[2].h, z[3].y, "ribbon and mon zone share a boundary")
same(z[2].colors[1], z[1].colors[1], "the ribbon band's white is the logo's")
same(z[3].colors[1], z[1].colors[1], "the mon zone's white matches too")
same(z[2].colors[4], RED_LOGO1[4], "Red's \"Version\" ink survives")
end
-- RED++ (#128): LOGO2/MEWMON come from the GBC pack, Blue's LOGO1 from the
-- ROM pack. Taking LOGO2's white must still whiten the ribbon there and
-- must not touch the blue ink.
do
PaletteFX.mode = "redpp"
GameVersion.set("blue")
local z = title:sgbPalettes({ data = romPack(BLUE_LOGO1) })
eq(z and #z, 3, "RED++ builds three title zones")
same(z[1].colors[1], { 255, 255, 255 }, "the GBC logo pal is pure white")
same(z[2].colors[1], { 255, 255, 255 }, "the ribbon band is pure white")
same(z[3].colors[1], { 255, 255, 255 }, "the mon zone is pure white")
same(z[2].colors[4], BLUE_LOGO1[4], "Blue's \"Version\" ink stays blue")
same(z[2].colors[3], BLUE_LOGO1[3], "Blue LOGO1's other inks stay put")
end
PaletteFX.mode = savedMode
GameVersion.set(savedVersion)
T.finish("title zone seams")
+1 -1
View File
@@ -402,7 +402,7 @@ check(ChipAudio.awaitingFirstBuffer(),
clearAwait()
-- sfx shape dispatch
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
check(Sound.play(data, "Beep") ~= nil, "Sound.play returns the source it started")
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
resetSources()
Sound.play(data, "Chip_Sfx")
+3 -4
View File
@@ -538,7 +538,7 @@ press(fpm, "a")
check(not fpm.submenu and forced == fgame.save.party[1],
"forceSwitch still picks immediately (ChooseNextMon / SHIFT)")
-- ------- issue #320: the STRENGTH blink sits under its texts
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
do
local owStub = { strengthActive = false,
map = { def = { tileset = "OVERWORLD" } }, dark = false }
@@ -556,9 +556,8 @@ do
pm.subIndex = #pm.subItems
press(pm, "a") -- run STRENGTH
local states = sgame.stack.states
check(#states == 2 and states[1].frames ~= nil
and states[2].pages ~= nil,
"the blink sits under the strength texts")
check(#states == 2 and states[1] == pm and states[2].pages ~= nil,
"the strength text sits over the still-open party menu")
check(owStub.strengthActive == true, "strength still activates")
end
+10 -5
View File
@@ -628,14 +628,19 @@ do
local ow = setmetatable({ player = { facing = "down" } }, { __index = OW })
ow.stepForwardOrCrossEdge = function(_, dir) ow.stepped = dir end
ow:trySurf(10, 10)
local bottom, top = stack.states[1], stack.states[2]
check(bottom and bottom.frames ~= nil and top and top.pages ~= nil,
"the blink sits under the surf text on the stack")
local closed = false
ow:trySurf(10, 10, function() closed = true end)
local top = stack.states[1]
check(#stack.states == 1 and top and top.pages ~= nil,
"the surf text prints alone, over the party menu (#385)")
check(not ow.player.surfing, "no surfing sprite while the text is up")
top.onDone()
check(ow.player.surfing == true, "surfing applies with the step")
check(closed, "the party menu closes when the text ends")
check(ow.player.surfing == true, "surfing applies with the blink")
local blink = stack.states[#stack.states]
check(blink and blink.frames ~= nil, "the blink follows the text")
blink.onDone()
check(ow.stepped == "down", "the step onto the water fires")
end
+32 -29
View File
@@ -26,11 +26,9 @@ local Sound = require("src.core.Sound")
local Map = require("src.world.Map")
local Warp = require("src.world.Warp")
-- synchronous scriptMove/takeWarp for the walk-out: elevatorWalkOut
-- (data/scripts/story3.lua) rewrites the car's exit warps then walks the
-- player out through the doorway and takes the rewritten warp, instead of
-- the old jump-cut startWarpTo.
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- synchronous takeWarp: after the ride the car's exit warps are rewritten
-- and the player walks out onto one under their own control, so the test
-- takes that warp itself instead of expecting a scripted walk-out.
-- the Rocket Hideout keyGate path pushes a TextBox, which needs the font
-- loaded (like tests/run_tests.lua does before any TextBox use)
@@ -71,10 +69,19 @@ local function restoreWarps(mapId, snap)
end
end
-- drives one elevator map's onEnter and returns the pushed state (either a
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox).
-- fromMapId is the floor the player entered from (OverworldState:setMap
-- passes it), used to seed a cancel-safe walk-out destination.
-- the car's panel bg_event (data/maps/objects/CeladonMartElevator.asm
-- etc.): the TEXT_ constant whose talk script opens the floor menu
local PANEL_TEXT = {
SILPH_CO_ELEVATOR = "TEXT_SILPHCOELEVATOR_ELEVATOR",
CELADON_MART_ELEVATOR = "TEXT_CELADONMARTELEVATOR",
ROCKET_HIDEOUT_ELEVATOR = "TEXT_ROCKETHIDEOUTELEVATOR",
}
-- drives one elevator map's onEnter and then its panel talk script, and
-- returns the pushed state (either a ListMenu or, for the keyGated Rocket
-- Hideout without the key, a TextBox). fromMapId is the floor the player
-- entered from (OverworldState:setMap passes it), used to seed a
-- cancel-safe exit.
local function openElevator(mapId, inventory, fromMapId)
local script = mapScripts.get(mapId)
check(script ~= nil, mapId .. " script registered")
@@ -84,26 +91,14 @@ local function openElevator(mapId, inventory, fromMapId)
local ow = {}
-- the real elevator car map (built without the tile renderer -- Map.new
-- is pure data), plus the player standing on the exit tile they warped
-- in onto (the true arrival cell), so the post-ride walk-out has a
-- door and geometry to work with
-- in onto (the true arrival cell)
local carDef = Data.maps[mapId]
ow.map = Map.new(carDef, Data.tilesets[carDef.tileset])
local firstWarp = carDef.warps[1]
ow.player = { cellX = firstWarp.x, cellY = firstWarp.y, facing = "up" }
ow.scriptMoves = {}
ow.walkSteps = {}
function ow:startWarpTo(map, x, y, facing)
warpCalls[#warpCalls + 1] = { map = map, x = x, y = y, facing = facing }
end
-- synchronous: advance the entity one step per tile and fire onDone
function ow:scriptMove(entity, dir, tiles, onDone)
local d = DIRVEC[dir]
entity.cellX = entity.cellX + d[1] * tiles
entity.cellY = entity.cellY + d[2] * tiles
entity.facing = dir
self.walkSteps[#self.walkSteps + 1] = dir
if onDone then onDone() end
end
-- resolve the (rewritten) warp entry like OverworldState:takeWarp does
function ow:takeWarp(warpDef)
local destMap, x, y = Warp.destination(Data, warpDef, self.lastOutdoor)
@@ -117,6 +112,12 @@ local function openElevator(mapId, inventory, fromMapId)
}
sfxCalls = {}
script.onEnter(game, ow, fromMapId)
-- #395: entry only seeds the car's exit warps; the floor menu belongs
-- to the panel bg_event, so nothing may be pushed yet
eq(#items, 0, mapId .. " onEnter opens no menu")
local panel = script.talk and script.talk[PANEL_TEXT[mapId]]
check(panel ~= nil, mapId .. " panel bg_event has a talk script")
if panel then panel(game, ow, nil, function() end) end
return items[#items], warpCalls, stack, ow
end
@@ -202,7 +203,6 @@ do
-- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp.
clear(warpCalls)
sfxCalls = {}
ow.walkSteps = {}
local chosen = menu.items[5] -- "5F"
menu.onChoose(chosen, menu)
eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)")
@@ -222,8 +222,9 @@ do
-- floor, then the player walks out onto that warp (no jump cut)
eq(ow.map.def.warps[1].destMap, chosen.value.map,
"the car's exit warp is rewritten to the chosen floor's map")
check(#ow.walkSteps >= 1, "the player walks out of the car (scriptMove, not a jump-cut)")
eq(#warpCalls, 1, "the rewritten warp fires exactly once, after the walk-out")
eq(#warpCalls, 0, "the ride never warps: the player walks out of the car themselves")
ow:takeWarp(ow.map.def.warps[1])
eq(#warpCalls, 1, "walking out takes the rewritten warp")
if warpCalls[1] then
eq(warpCalls[1].map, chosen.value.map, "walk-out lands on the chosen floor's map")
eq(warpCalls[1].x, chosen.value.x, "walk-out lands on the chosen floor's x")
@@ -266,8 +267,9 @@ do
eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Celadon Mart ride ends on the PA chime")
eq(ow.map.def.warps[1].destMap, chosen.value.map,
"Celadon Mart car exit warp rewritten to the chosen floor")
check(#ow.walkSteps >= 1, "Celadon Mart player walks out (scriptMove, not a jump-cut)")
eq(#warpCalls, 1, "Celadon Mart rewritten warp fires once, after the walk-out")
eq(#warpCalls, 0, "Celadon Mart ride never warps on its own")
ow:takeWarp(ow.map.def.warps[1])
eq(#warpCalls, 1, "Celadon Mart walking out takes the rewritten warp")
if warpCalls[1] then
eq(warpCalls[1].map, chosen.value.map, "Celadon Mart walk-out lands on the chosen floor map")
eq(warpCalls[1].x, chosen.value.x, "Celadon Mart walk-out lands on the chosen floor x")
@@ -326,8 +328,9 @@ do
eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Rocket Hideout ride ends on the PA chime")
eq(ow.map.def.warps[1].destMap, chosen.value.map,
"Rocket Hideout car exit warp rewritten to the chosen floor")
check(#ow.walkSteps >= 1, "Rocket Hideout player walks out (scriptMove, not a jump-cut)")
eq(#warpCalls, 1, "Rocket Hideout rewritten warp fires once, after the walk-out")
eq(#warpCalls, 0, "Rocket Hideout ride never warps on its own")
ow:takeWarp(ow.map.def.warps[1])
eq(#warpCalls, 1, "Rocket Hideout walking out takes the rewritten warp")
if warpCalls[1] then
eq(warpCalls[1].map, chosen.value.map, "Rocket Hideout walk-out lands on the chosen floor map")
eq(warpCalls[1].x, chosen.value.x, "Rocket Hideout walk-out lands on the chosen floor x")
+9 -6
View File
@@ -119,10 +119,11 @@ clearCaptured()
local pmStr = PartyMenu.new(Game)
selectSubItem(pmStr, 3)
eq(Game.overworld.strengthActive, true, "party-menu STRENGTH sets strengthActive")
check(not onStack(pmStr), "party menu closes after STRENGTH")
check(onStack(pmStr), "party menu stays under the STRENGTH texts (#385)")
check(sawText("used") and sawText("STRENGTH"), "_UsedStrengthText shown")
drainText()
check(sawText("move boulders"), "_CanMoveBouldersText shown after it")
check(not onStack(pmStr), "party menu closes with the blink after the texts")
-- now the same two bumps push the boulder (gate passes -> arm -> move)
eq(ow:checkBoulderPush("right"), false, "first bump arms the push after activation")
@@ -166,9 +167,10 @@ ow.player.facing = "down"; ow.player.surfing = false
clearCaptured()
local pmSurf = PartyMenu.new(Game)
selectSubItem(pmSurf, 3)
-- the blink sits under the got-on text (#320); dismissing the text is
-- what mounts and steps
Game.stack:top().onDone()
-- the got-on text prints over the menu (#385); dismissing it closes the
-- menu and mounts, and the blink that follows carries the step
check(onStack(pmSurf), "party menu stays under the got-on text")
Game.stack:pop().onDone() -- a TextBox pops itself before firing onDone
eq(ow.player.surfing, true, "SURF from the party menu sets player.surfing")
check(not onStack(pmSurf), "party menu closes after a successful SURF")
check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF")
@@ -375,9 +377,10 @@ clearCaptured()
local pmNoOff = PartyMenu.new(Game)
selectSubItem(pmNoOff, 3)
check(sawText("There's no place\nto get off!"), "_SurfingNoPlaceToGetOffText verbatim")
check(not onStack(pmNoOff), "the menu closes after the message (result stays 1)")
check(onStack(pmNoOff), "the menu stays under the message (#385)")
eq(ow.player.surfing, true, "still surfing after a blocked dismount")
drainText()
check(not onStack(pmNoOff), "the menu closes after the message (result stays 1)")
ow.player.surfing = false
popToOW()
@@ -501,7 +504,7 @@ clearCaptured()
-- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon)
local pmFaintSurf = PartyMenu.new(Game)
selectSubItem(pmFaintSurf, 6)
Game.stack:top().onDone() -- dismiss the text: mount + step (#320)
Game.stack:pop().onDone() -- dismiss the text: menu closes, mount (#320, #385)
eq(ow.player.surfing, true, "fainted mon can SURF from the party menu")
check(not onStack(pmFaintSurf), "party menu closes after fainted SURF")
+219
View File
@@ -0,0 +1,219 @@
-- Parity test: wAnimationType, the applying-attack animation pokered plays
-- after every move's own animation (#354). PlayApplyingAttackAnimation
-- (engine/battle/animations.asm:475) dispatches through
-- AnimationTypePointerTable (:490-497) on six types: 1 enemy damaging plain
-- (vertical shake b=8), 2 enemy damaging with an added effect (fast
-- horizontal shake b=8), 3 enemy non-damaging (slow creep b=6 c=2), 4 player
-- damaging plain (blink the enemy pic), 5 player damaging with an added
-- effect (fast horizontal shake b=2), 6 player non-damaging (slow creep b=3
-- c=2). GetPlayerAnimationType / GetEnemyAnimationType (core.asm:3159 /
-- :5555) pick 4/5 and 1/2 off wPlayerMoveEffect; the primary status handlers
-- that end in PlayCurrentMoveAnimation2 (effects.asm:1448) set 6/3. Only 1
-- and 4 were wired, so Bubblebeam blinked instead of shaking and Hypnosis
-- showed nothing. The shake generators themselves are
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm) and
-- AnimationShakeScreenHorizontallySlow (animations.asm:526-547).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.moves and Data.moves.BUBBLEBEAM) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity applying attack anim")
local check, eq = S.check, S.eq
-- rng floor: accuracyRoll compares rng(0, 255) against the scaled accuracy,
-- so the lowest roll lands HYPNOSIS (60%) every run
local function freshBattle()
Game.save.options.animations = true
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 30) }
local tb = BattleState.newWild(Game, "PIDGEY", 10)
tb.queue, tb.nextInsert = {}, 0
tb.rng = function(a) return a end
return tb
end
-- the hit rows a turn queued, in order (each carries sfx + animType)
local function hitRows(tb)
local out = {}
for _, row in ipairs(tb.queue) do
if row.hit then out[#out + 1] = row.hit end
end
return out
end
local function typeOf(moveId, isPlayer)
local tb = freshBattle()
local user = isPlayer and tb.player or tb.enemy
local target = isPlayer and tb.enemy or tb.player
tb:performMove(user, target, { id = moveId, pp = 10 }, false)
local rows = hitRows(tb)
return rows[1] and rows[1].animType, #rows, rows
end
-- ------------------------------------------------- damaging moves, both sides
-- TACKLE is NO_ADDITIONAL_EFFECT; BUBBLEBEAM is SPEED_DOWN_SIDE_EFFECT and
-- CONFUSION is CONFUSION_SIDE_EFFECT, the two moves named in the report
do
eq(Data.moves.TACKLE.effect, "NO_ADDITIONAL_EFFECT",
"TACKLE has no added effect")
eq(Data.moves.BUBBLEBEAM.effect, "SPEED_DOWN_SIDE_EFFECT",
"BUBBLEBEAM drops SPEED as a side effect")
eq(Data.moves.CONFUSION.effect, "CONFUSION_SIDE_EFFECT",
"CONFUSION confuses as a side effect")
eq(typeOf("TACKLE", true), 4, "player TACKLE blinks the enemy pic (type 4)")
eq(typeOf("TACKLE", false), 1, "enemy TACKLE shakes vertically (type 1)")
eq(typeOf("BUBBLEBEAM", true), 5,
"player BUBBLEBEAM shakes horizontally light (type 5, #354)")
eq(typeOf("BUBBLEBEAM", false), 2,
"enemy BUBBLEBEAM shakes horizontally heavy (type 2)")
eq(typeOf("CONFUSION", true), 5, "player CONFUSION is type 5 too (#354)")
eq(typeOf("CONFUSION", false), 2, "and type 2 from the foe")
eq(select(2, typeOf("BUBBLEBEAM", true)), 1,
"one applying-attack row per hit, not one per stat drop")
end
-- the damage sound still rides the row (PlayApplyingAttackSound off
-- wDamageMultipliers), and every damaging type keeps a blink target so a
-- type-4 turn can still use it
do
local _, _, rows = typeOf("BUBBLEBEAM", true)
eq(rows[1].sfx, "Damage", "the row carries the damage sound")
local _, _, plain = typeOf("TACKLE", true)
check(plain[1].blink ~= nil, "a type-4 row carries the pic to blink")
end
-- ------------------------------------------------- primary status moves
-- PlayCurrentMoveAnimation2 call sites: SleepEffect (effects.asm:64),
-- PoisonEffect (:150), ConfusionSideEffectSuccess's primary branch (:1150),
-- DisableEffect (:1352), UpdateLoweredStatDone (:685)
do
eq(typeOf("HYPNOSIS", true), 6, "HYPNOSIS creeps the screen (type 6, #354)")
eq(typeOf("HYPNOSIS", false), 3, "and type 3 from the foe")
eq(typeOf("GROWL", true), 6, "GROWL is a primary stat drop: type 6")
eq(typeOf("TAIL_WHIP", true), 6, "so is TAIL_WHIP")
eq(typeOf("SAND_ATTACK", false), 3, "the foe's SAND-ATTACK is type 3")
eq(typeOf("POISONPOWDER", true), 6, "POISONPOWDER is type 6")
eq(typeOf("CONFUSE_RAY", true), 6, "CONFUSE RAY is type 6")
eq(typeOf("DISABLE", true), 6, "DISABLE is type 6")
end
-- effects whose handler uses PlayCurrentMoveAnimation, which zeroes
-- wAnimationType: no applying animation at all
do
eq(typeOf("THUNDER_WAVE", true), nil,
"FreezeBurnParalyzeEffect zeroes the type (effects.asm:196)")
eq(typeOf("LEECH_SEED", true), nil, "LeechSeedEffect leaves it at 0")
eq(typeOf("SWORDS_DANCE", true), nil,
"StatModifierUpEffect leaves it at 0 (effects.asm:485)")
eq(typeOf("HARDEN", true), nil, "a stat-up move stays animation-free")
end
-- a failed status effect prints AlreadyAsleep / NothingHappened with no
-- animation, so the row is peeled and nothing shakes
do
local tb = freshBattle()
tb.enemy.mon.status = "SLP"
tb.enemy.mon.sleepTurns = 3
tb:performMove(tb.player, tb.enemy, { id = "HYPNOSIS", pp = 10 }, false)
eq(#hitRows(tb), 0, "a refused sleep queues no applying animation")
end
-- ------------------------------------------------- what each type looks like
-- one entry per type: the pokered routine's own frame budget and amplitude.
-- 4*b*c for the slow creeps (2-frame delay up and down), 9*b for the fast
-- ones (5 frames out, 4 home, b counting down). stepProgram spends one
-- extra frame settling the tail, which SE_SHAKE_SCREEN already does.
local SHAPES = {
{ t = 1, axis = "y", peak = 8, frames = 49, wait = 48,
what = "ShakeScreenVertically, PredefShakeScreenVertically b=8" },
{ t = 2, axis = "x", peak = 8, frames = 73, wait = 72,
what = "ShakeScreenHorizontallyHeavy, b=8" },
{ t = 3, axis = "x", peak = 6, frames = 49, wait = 48,
what = "ShakeScreenHorizontallySlow, b=6 c=2" },
{ t = 5, axis = "x", peak = 2, frames = 19, wait = 18,
what = "ShakeScreenHorizontallyLight, b=2" },
{ t = 6, axis = "x", peak = 3, frames = 25, wait = 24,
what = "ShakeScreenHorizontallySlow2, b=3 c=2" },
}
-- run the armed program to exhaustion; returns how many frames it held the
-- screen off-centre and the largest offset on each axis
local function runShake(tb)
local frames, peakX, peakY = 0, 0, 0
for _ = 1, 400 do
if not (tb.fx and tb.fx.shakeProg) then break end
tb:updateFx()
frames = frames + 1
peakX = math.max(peakX, math.abs(tb.fx.shakeX or 0))
peakY = math.max(peakY, math.abs(tb.fx.shakeY or 0))
end
return frames, peakX, peakY
end
for _, s in ipairs(SHAPES) do
local tb = freshBattle()
tb:applyHitFx({ animType = s.t, sfx = "Damage" })
check(tb.fx and tb.fx.shakeProg ~= nil,
("type %d arms a shake program (%s)"):format(s.t, s.what))
eq(tb.fx.blink, nil, ("type %d does not blink a pic"):format(s.t))
eq(tb.waitFrames, s.wait,
("type %d holds the queue %d frames"):format(s.t, s.wait))
local frames, peakX, peakY = runShake(tb)
eq(frames, s.frames, ("type %d moves the screen for %d frames")
:format(s.t, s.frames))
if s.axis == "x" then
eq(peakX, s.peak, ("type %d peaks at %dpx sideways"):format(s.t, s.peak))
eq(peakY, 0, ("type %d never moves vertically"):format(s.t))
else
eq(peakY, s.peak, ("type %d peaks at %dpx down"):format(s.t, s.peak))
eq(peakX, 0, ("type %d never moves sideways"):format(s.t))
end
end
-- type 4 is the odd one out: the enemy pic blinks and the screen holds still
do
local tb = freshBattle()
tb:applyHitFx({ animType = 4, sfx = "Damage", blink = tb.enemy })
eq(tb.fx.shakeProg, nil, "type 4 arms no shake (#354 must not regress it)")
check(tb.fx.blink ~= nil and tb.fx.blink.target == tb.enemy,
"type 4 blinks the enemy pic")
eq(tb.waitFrames, 20, "for the 20 frames AnimationBlinkEnemyMon takes")
end
-- the OPTIONS animation toggle still gates the whole thing; the sound does not
do
local tb = freshBattle()
Game.save.options.animations = false
tb:applyHitFx({ animType = 5, sfx = "Damage" })
eq(tb.fx.shakeProg, nil, "animations off arms no shake")
eq(tb.fx.blink, nil, "and no blink")
Game.save.options.animations = true
end
-- rows queued without an animType (older save-state queues, mods) still get
-- the pre-#354 behavior off blink.isPlayer
do
local tb = freshBattle()
tb:applyHitFx({ blink = tb.enemy })
check(tb.fx.blink ~= nil, "a bare enemy blink row still blinks")
local tb2 = freshBattle()
tb2:applyHitFx({ blink = tb2.player })
check(tb2.fx.shakeProg ~= nil, "a bare player blink row still shakes")
local _, _, peakY = runShake(tb2)
eq(peakY, 8, "vertically, by 8px")
end
S.finish()
+259
View File
@@ -0,0 +1,259 @@
-- Parity test: using an item in battle spends the turn, medicine included
-- (#379). pokered engine/items/item_effects.asm:1-3 zeroes
-- wActionResultOrTookBattleTurn to the success value before UseItem, and
-- ItemUseMedicine only clears it on its failure paths (:826 empty party,
-- :1241 .healingItemNoEffect), so a POTION that lands costs the turn the same
-- way a status cure does. The party HP bar fill (.doneHealing / UpdateHPBar2
-- with the menu still up) is the field-only case (#252).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local ItemEffects = require("src.inventory.ItemEffects")
local S = require("tests.harness").suite("parity battle item turn")
local check, eq = S.check, S.eq
-- Real TextBoxes want a Font atlas, and the flow under test only cares that a
-- message opened and what its onDone does. Restored at the bottom for the
-- suites run_tests.lua chains after this file.
local realTextBox = package.loaded["src.render.TextBox"]
local realBag = package.loaded["src.ui.BagMenu"]
local realParty = package.loaded["src.ui.PartyMenu"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
}
-- BagMenu and PartyMenu bind TextBox at require time, so they are reloaded
-- against the stub here and dropped again at the bottom
package.loaded["src.ui.BagMenu"] = nil
package.loaded["src.ui.PartyMenu"] = nil
local BagMenu = require("src.ui.BagMenu")
local PartyMenu = require("src.ui.PartyMenu")
-- src.ui.Screens caches its factory per id, so a suite that already opened a
-- PartyMenu would hand BagMenu the pre-reload class and the picker identity
-- check below would never match
require("src.ui.Screens").invalidate()
-- A stack that behaves like StateStack for the two things this flow reads:
-- top() identity (PartyMenu:close) and push/pop ordering.
local function newStack()
local stack = { states = {} }
function stack:push(s) self.states[#self.states + 1] = s end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return stack
end
-- One button per call: PartyMenu:update reads game.input once per fixed step.
local function newInput()
local input = { pressed = nil }
function input:wasPressed(b) return self.pressed == b end
return input
end
local function freshGame(monHP, status)
local lead = Pokemon.new(Data, "CHARIZARD", 50)
lead.hp = monHP or 10
lead.status = status
local game = {
data = Data,
stack = newStack(),
input = newInput(),
save = {
party = { lead },
player = { name = "RED" },
inventory = {},
options = { battleStyle = "set", battleAnim = "on" },
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
}
local Bag = require("src.inventory.Bag")
for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do
Bag.add(game.save, id, 3)
end
return game, lead
end
local function isPicker(s) return getmetatable(s) == PartyMenu end
local function isBox(s) return type(s) == "table" and s.textBox == true end
local function inStack(stack, pred)
for _, s in ipairs(stack.states) do
if pred(s) then return true end
end
return false
end
-- TextBox pops itself BEFORE firing onDone, which is what PartyMenu:close's
-- identity check depends on.
local function dismiss(stack, box)
if stack:top() == box then stack:pop() end
if box.done then box.done() end
end
local function rowFor(list, id)
for i, r in ipairs(list.items) do
if r.value == id then return i end
end
return nil
end
-- Open the bag on `id`, choose it, then press A on the party picker. Returns
-- the picker (nil when none opened) so the caller can inspect the fill.
local function useFromBag(game, battle, id)
local list = BagMenu.new(game, { battle = battle })
game.stack:push(list)
local row = rowFor(list, id)
if not row then return nil, "no " .. id .. " row in the bag" end
list.index = row
list.onChoose(list.items[row], list)
-- out of battle the bag offers USE / TOSS first (start_sub_menus.asm)
local sub = game.stack:top()
if not battle and sub and sub.items and sub.items[1]
and sub.items[1].onSelect then
game.stack:pop()
sub.items[1].onSelect()
end
local picker = game.stack:top()
if not isPicker(picker) then return nil, "party picker never opened" end
game.input.pressed = "a"
picker:update(1 / 60)
game.input.pressed = nil
return picker
end
-- Pump the picker until its bar fill lands (it blocks input while it runs).
local function runFill(picker)
for _ = 1, 400 do
if not picker.heal then return true end
picker:update(1 / 60)
end
return false
end
-- Count the turn: BattleState:itemUsed is the only path that queues the foe's
-- action plus end-of-turn, so wrapping it is the flag check.
local function watchTurn(battle)
local rec = { itemUsed = 0, messages = {} }
local real = battle.itemUsed
battle.itemUsed = function(self, messages)
rec.itemUsed = rec.itemUsed + 1
return real(self, messages)
end
return rec
end
do
check(ItemEffects.healsHP("POTION"), "POTION is an HP medicine")
check(not ItemEffects.healsHP("ANTIDOTE"), "ANTIDOTE is a status cure")
end
-- The report: a POTION mid-battle healed for free. BagMenu gated its
-- animate-the-bar branch on the picker existing, and in battle the picker is
-- non-nil (PartyMenu hands itself to onSwitch after popping), so the branch
-- swallowed the message, the itemUsed tail, and the turn.
do
local game, lead = freshGame(10)
local battle = BattleState.newWild(game, "PIDGEY", 8)
local rec = watchTurn(battle)
local picker, why = useFromBag(game, battle, "POTION")
check(picker ~= nil, "the picker opened for a POTION in battle: " .. tostring(why))
if picker then
eq(lead.hp, 30, "the POTION restored 20 HP")
check(picker.heal == nil, "no party-menu bar fill in battle (#252 is field-only)")
check(not inStack(game.stack, isPicker), "the picker is gone")
local box = game.stack:top()
check(isBox(box), "the restored-HP message opened (#379)")
if isBox(box) then
check(box.text:find("was restored", 1, true) ~= nil,
"and it is the restored-HP line: " .. tostring(box.text))
dismiss(game.stack, box)
end
eq(rec.itemUsed, 1, "a POTION in battle costs the turn (#379)")
check(#battle.queue > 0, "and the foe's action is queued behind it")
end
end
-- Same for the tier above it: the gate is per-item, so a SUPER POTION taking
-- the animate branch while a POTION does not would be the bug half-fixed.
do
local game = freshGame(10)
local battle = BattleState.newWild(game, "PIDGEY", 8)
local rec = watchTurn(battle)
local picker = useFromBag(game, battle, "SUPER_POTION")
if check(picker ~= nil, "the picker opened for a SUPER POTION") then
local box = game.stack:top()
check(isBox(box), "a SUPER POTION prints its message too")
if isBox(box) then dismiss(game.stack, box) end
eq(rec.itemUsed, 1, "a SUPER POTION in battle costs the turn (#379)")
end
end
-- Control: status cures always spent the turn, and still must.
do
local game, lead = freshGame(40, "PSN")
local battle = BattleState.newWild(game, "PIDGEY", 8)
local rec = watchTurn(battle)
local picker = useFromBag(game, battle, "ANTIDOTE")
if check(picker ~= nil, "the picker opened for an ANTIDOTE") then
check(lead.status == nil, "PSN was cured")
local box = game.stack:top()
if isBox(box) then dismiss(game.stack, box) end
eq(rec.itemUsed, 1, "a status cure still costs the turn")
end
end
-- .healingItemNoEffect (item_effects.asm:1241) clears the flag: a POTION on a
-- full-HP mon is refused and the turn is NOT spent.
do
local game, lead = freshGame(nil)
lead.hp = lead.stats.hp
local battle = BattleState.newWild(game, "PIDGEY", 8)
local rec = watchTurn(battle)
local picker = useFromBag(game, battle, "POTION")
if check(picker ~= nil, "the picker opened for the refused POTION") then
local box = game.stack:top()
check(isBox(box) and box.text:find("won't have", 1, true) ~= nil,
"the refusal prints \"It won't have any effect.\"")
if isBox(box) then dismiss(game.stack, box) end
eq(rec.itemUsed, 0, "a refused item does not cost the turn")
eq(game.save.inventory.POTION, 3, "and the POTION is not consumed")
end
end
-- Field regression guard for #252: out of battle the picker stays up and
-- animates, which is the behavior the #379 gate must not have taken away.
do
local game, lead = freshGame(10)
local picker = useFromBag(game, nil, "POTION")
if check(picker ~= nil, "the field picker opened for a POTION") then
check(picker.keepOpen == true, "keepOpen is set out of battle (#252)")
check(inStack(game.stack, isPicker),
"the party menu is still on the stack after the pick (#252)")
check(type(picker.heal) == "table" and picker.heal.from == 10,
"the bar fill started from the pre-heal HP")
check(runFill(picker), "the fill lands")
local box = game.stack:top()
check(isBox(box), "then the message prints over the still-drawn menu")
check(inStack(game.stack, isPicker), "...with the picker underneath it")
if isBox(box) then
dismiss(game.stack, box)
check(not inStack(game.stack, isPicker),
"dismissing the message closes the picker")
end
eq(lead.hp, 30, "and the field POTION healed the same 20 HP")
end
end
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.ui.BagMenu"] = realBag
package.loaded["src.ui.PartyMenu"] = realParty
require("src.ui.Screens").invalidate()
S.finish()
+180
View File
@@ -0,0 +1,180 @@
-- Parity test: which animation BIDE plays on which turn (#375). BIDE is a
-- ResidualEffects2 entry (data/battle/residual_effects_2.asm:18), so the
-- ordinary PlayMoveAnimation is skipped on the storing turn and BideEffect
-- (engine/battle/effects.asm:764-789) ends on
-- `ldh a, [hWhoseTurn] / add XSTATITEM_ANIM / jp PlayBattleAnimation2`:
-- the X-item spiral, duplicated for the enemy side. BIDE's own hit
-- animation belongs to .UnleashEnergy (engine/battle/core.asm:3501-3529),
-- which re-points wPlayerMoveNum at BIDE and rejoins
-- HandleIfPlayerMoveMissed, i.e. after UnleashedEnergyText and before the
-- HP bar drains. The accuracy half of the release is in tests/parity_J.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.moves and Data.moves.BIDE) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity bide anim")
local check, eq = S.check, S.eq
local function freshBattle()
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local tb = BattleState.newWild(Game, "PIDGEY", 10)
tb.queue, tb.nextInsert = {}, 0
return tb
end
-- the rows this suite cares about, in queue order: text lines and anim names
local function trace(tb)
local out = {}
for _, row in ipairs(tb.queue) do
if row.text then
out[#out + 1] = { text = (row.text:gsub("\n", " ")) }
elseif row.anim then
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer }
elseif row.drain then
out[#out + 1] = { drain = true }
end
end
return out
end
local function indexOfAnim(rows, name)
for i, r in ipairs(rows) do
if r.anim == name then return i end
end
return nil
end
local function indexOfText(rows, needle)
for i, r in ipairs(rows) do
if r.text and r.text:find(needle, 1, true) then return i end
end
return nil
end
local function anyAnim(rows, name)
return indexOfAnim(rows, name) ~= nil
end
-- the three animations this behavior needs must actually be in the cache, or
-- AnimPlayer:start warns and plays nothing
do
local anims = Data.battle_anims and Data.battle_anims.moveAnims
check(anims ~= nil, "battle_anims carries moveAnims")
if anims then
check(anims.BIDE ~= nil, "BIDE has an animation")
check(anims.XSTATITEM_ANIM ~= nil, "XSTATITEM_ANIM has an animation")
check(anims.XSTATITEM_DUPLICATE_ANIM ~= nil,
"XSTATITEM_DUPLICATE_ANIM has an animation")
end
end
-- storing turn, player side: used-BIDE line, X-item spiral, storing line.
-- The bug queued BIDE's hit row (sound = "BIDE", subanim 4) here instead.
do
local tb = freshBattle()
tb:performMove(tb.player, tb.enemy, { id = "BIDE", pp = 10 }, false)
local rows = trace(tb)
eq(tb.player.bideTurns ~= nil, true, "the storing turn locks BIDE in")
eq(tb.player.bideDamage, 0, "and zeroes the accumulator")
check(not anyAnim(rows, "BIDE"),
"BIDE's own animation does not play on the storing turn (#375)")
local spiral = indexOfAnim(rows, "XSTATITEM_ANIM")
check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM")
eq(rows[spiral] and rows[spiral].isPlayer, true,
"on the player's side of the field")
local used = indexOfText(rows, "used BIDE!")
local storing = indexOfText(rows, "storing energy!")
check(used and spiral and used < spiral,
"the spiral comes after the used-BIDE line")
check(spiral and storing and spiral < storing,
"and before the storing-energy line")
check(tb.moveAnimRow == nil, "the peeled move-anim row is not left dangling")
check(not anyAnim(rows, "XSTATITEM_DUPLICATE_ANIM"),
"the player never gets the enemy-side duplicate")
end
-- enemy side: PlayBattleAnimation2 adds hWhoseTurn, so the foe gets
-- XSTATITEM_DUPLICATE_ANIM (AttackAnimationPointers[174])
do
local tb = freshBattle()
tb:performMove(tb.enemy, tb.player, { id = "BIDE", pp = 10 }, false)
local rows = trace(tb)
check(not anyAnim(rows, "BIDE"),
"the foe's storing turn is animation-free of BIDE too")
check(not anyAnim(rows, "XSTATITEM_ANIM"),
"and does not borrow the player row")
local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM")
check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM")
check(dup and not rows[dup].isPlayer, "attributed to the enemy side")
end
-- locked turns: .BideCheck decrements with no text of its own in pokered and
-- no animation either; the port prints the storing line, and that is all
do
local tb = freshBattle()
tb.player.bideTurns = 3
tb.player.bideDamage = 0
tb:continueBide(tb.player, tb.enemy)
local rows = trace(tb)
eq(tb.player.bideTurns, 2, "a locked turn just counts down")
check(indexOfText(rows, "storing energy!") ~= nil, "and reprints the line")
for _, r in ipairs(rows) do
check(r.anim == nil, "no animation on a locked turn: " .. tostring(r.anim))
end
end
-- release turn: text, then BIDE's animation, then the bar drains
do
local tb = freshBattle()
tb.enemy.mon.stats.hp = 200
tb.enemy.mon.hp = 200
tb.player.bideTurns = 1
tb.player.bideDamage = 40
tb:continueBide(tb.player, tb.enemy)
local rows = trace(tb)
eq(tb.enemy.mon.hp, 120, "the release deals bideDamage*2")
local unleashed = indexOfText(rows, "unleashed energy!")
local hit = indexOfAnim(rows, "BIDE")
check(unleashed ~= nil, "the release prints UnleashedEnergyText")
check(hit ~= nil, "the release plays BIDE's own animation (#375)")
eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side")
check(unleashed and hit and unleashed < hit,
"the animation follows the text")
local drainAt
for i, r in ipairs(rows) do
if r.drain then drainAt = i; break end
end
check(drainAt ~= nil, "the HP bar drain is queued")
check(hit and drainAt and hit < drainAt,
"and the animation runs before the bar moves")
check(not anyAnim(rows, "XSTATITEM_ANIM"),
"the release is not the X-item spiral")
end
-- zero stored damage sets wMoveMissed, so the failure path stays anim-free
do
local tb = freshBattle()
local hpBefore = tb.enemy.mon.hp
tb.player.bideTurns = 1
tb.player.bideDamage = 0
tb:continueBide(tb.player, tb.enemy)
local rows = trace(tb)
eq(tb.enemy.mon.hp, hpBefore, "nothing stored means no damage")
check(indexOfText(rows, "But, it failed!") ~= nil, "it prints the failure")
check(not anyAnim(rows, "BIDE"), "and plays no animation")
end
S.finish()
+135
View File
@@ -0,0 +1,135 @@
-- Parity test: the Celadon Mansion roof house blackboard and pamphlet (#391).
--
-- pokered data/events/hidden_events.asm, hidden_events_for
-- CELADON_MANSION_ROOF_HOUSE:
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so neither tile
-- reaches data/generated/field.lua and data/scripts/celadon_eevee.lua carries
-- them as an onInteract hook instead. LinkCableHelp
-- (engine/events/hidden_events/school_blackboard.asm) prints
-- _LinkCableHelpText1, then loops prompt + 15x10 heading box until B or the
-- fourth row; TMNotebook (school_notebooks.asm) is one plain text.
--
-- Self-contained; run via `luajit tests/parity_celadon_roof_readables.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.CELADON_MANSION_ROOF_HOUSE) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
local TextBox = require("src.render.TextBox")
local Menu = require("src.ui.Menu")
local S = require("tests.harness").suite("parity celadon roof readables")
local check, eq = S.check, S.eq
local MAP = "CELADON_MANSION_ROOF_HOUSE"
for _, key in ipairs({ "_LinkCableHelpText1", "_LinkCableHelpText2",
"_LinkCableInfoText1", "_LinkCableInfoText2", "_LinkCableInfoText3" }) do
check(type(Data.text[key]) == "string" and Data.text[key] ~= "",
key .. " is extracted")
end
local hooks = MapScripts.get(MAP)
check(hooks and type(hooks.onInteract) == "function",
MAP .. " registers onInteract for the two readables")
check(hooks and hooks.talk
and hooks.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL ~= nil,
"the Eevee ball talk entry survives alongside the hook")
-- stack stub: the blackboard loop pushes and pops, so keep the whole stack
local stack = {}
local game = {
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = {
push = function(_, state) stack[#stack + 1] = state end,
pop = function() return table.remove(stack) end,
top = function() return stack[#stack] end,
},
}
local ow = { player = { facing = "up" } }
local function pages(box)
local out = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, "\n")
end
-- the pamphlet: (3,4) only, the table's other three cells stay silent
stack = {}
eq(hooks.onInteract(game, ow, 3, 4), true, "the pamphlet at (3,4) is claimed")
local box = stack[#stack]
check(getmetatable(box) == TextBox, "the pamphlet pushes a TextBox")
local pamphlet = pages(box)
for _, want in ipairs({ "pamphlet", "50 TMs", "HMs", "SILPH CO." }) do
check(pamphlet:find(want, 1, true) ~= nil,
"the pamphlet text has " .. want)
end
for _, cell in ipairs({ { 4, 4 }, { 3, 3 }, { 4, 3 }, { 3, 5 } }) do
eq(hooks.onInteract(game, ow, cell[1], cell[2]), false,
("(%d,%d) is not one of the readables"):format(cell[1], cell[2]))
end
-- the blackboard: both top-row cells, and facing is irrelevant because
-- hidden_text_predef spends the facing byte on the predef id
for _, fx in ipairs({ 3, 4 }) do
stack = {}
ow.player.facing = fx == 3 and "up" or "left"
eq(hooks.onInteract(game, ow, fx, 0), true,
("the blackboard at (%d,0) is claimed"):format(fx))
check(pages(stack[#stack]):find("TRAINER TIPS", 1, true) ~= nil,
("(%d,0) opens with _LinkCableHelpText1"):format(fx))
end
eq(hooks.onInteract(game, ow, 2, 0), false,
"the wall left of the blackboard stays silent")
-- walk the loop: intro -> prompt -> heading box -> blurb -> prompt again
stack = {}
hooks.onInteract(game, ow, 3, 0)
local intro = stack[#stack]
check(type(intro.onDone) == "function", "the intro text has a continuation")
intro.onDone()
local prompt = stack[#stack]
check(getmetatable(prompt) == TextBox
and pages(prompt):find("heading", 1, true) ~= nil,
"the intro leads into the which-heading prompt")
prompt.onDone()
local menu = stack[#stack]
check(getmetatable(menu) == Menu, "the prompt opens the heading menu")
eq(#menu.items, 4, "four headings, as in HowToLinkText")
local labels = {}
for i, item in ipairs(menu.items) do labels[i] = item.label end
eq(table.concat(labels, "/"),
"HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING",
"the headings read in HowToLinkText order")
eq(menu.items[4].onSelect, nil, "STOP READING just closes the menu")
check(menu.tw == 15 and menu.th == 10 and menu.tx == 0 and menu.ty == 0,
"the box is the asm's 15x10 at the top left")
for i = 1, 3 do
stack = {}
menu.items[i].onSelect()
local blurb = stack[#stack]
check(getmetatable(blurb) == TextBox,
labels[i] .. " prints a text box")
local want = Data.text["_LinkCableInfoText" .. i]:match("^[^\n\011\012]+")
check(pages(blurb):find(want, 1, true) ~= nil,
labels[i] .. " prints _LinkCableInfoText" .. i)
check(type(blurb.onDone) == "function",
labels[i] .. " returns to the prompt instead of dropping out")
blurb.onDone()
check(getmetatable(stack[#stack]) == TextBox,
"the prompt comes back after " .. labels[i])
stack[#stack].onDone()
check(getmetatable(stack[#stack]) == Menu,
"the menu comes back after " .. labels[i])
end
S.finish()
+207
View File
@@ -0,0 +1,207 @@
-- Parity: the elevator floor menu belongs to the panel, not to map entry (#395).
--
-- Oracle: data/maps/objects/CeladonMartElevator.asm declares
-- `bg_event 3, 0, TEXT_CELADONMARTELEVATOR` (SilphCoElevator.asm the same
-- cell, RocketHideoutElevator.asm `bg_event 1, 1`), and only
-- CeladonMartElevatorText reaches `predef DisplayElevatorFloorMenu`
-- (scripts/CeladonMartElevator.asm). Map entry runs
-- CeladonMartElevatorStoreWarpEntriesScript alone, so warping into the car
-- must open nothing. DisplayElevatorFloorMenu (engine/events/elevator.asm)
-- rewrites wWarpEntries through .UpdateWarp and returns: it never moves the
-- player, so nothing may walk them out of the car after the ride.
--
-- Self-contained: `luajit tests/parity_elevator_panel.lua`; also dofile'd by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.CELADON_MART_ELEVATOR) then Data:load() end
local MapLoader = require("src.world.MapLoader")
local mapScripts = require("data.scripts.init")
local S = require("tests.harness").suite("parity elevator panel")
local check, eq = S.check, S.eq
-- restored at the bottom so the suites after this file see the real states
local realList = package.loaded["src.ui.ListMenu"]
local realShake = package.loaded["src.world.ElevatorShake"]
local realTextBox = package.loaded["src.render.TextBox"]
package.loaded["src.ui.ListMenu"] = {
new = function(_, title, items, opts)
return { kind = "list", title = title, items = items, opts = opts,
close = function() end }
end,
}
package.loaded["src.world.ElevatorShake"] = {
new = function(_, _, opts) return { kind = "shake", opts = opts } end,
}
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { kind = "text", text = text, done = done } end,
}
-- panel cell / reading cell from the bg_event coordinates above; facing is
-- the direction that looks at the panel from the reading cell
local CARS = {
{ map = "CELADON_MART_ELEVATOR", text = "TEXT_CELADONMARTELEVATOR",
panelX = 3, panelY = 0, readX = 3, readY = 1, facing = "up",
from = "CELADON_MART_3F", pick = "CELADON_MART_2F", floors = 5 },
{ map = "SILPH_CO_ELEVATOR", text = "TEXT_SILPHCOELEVATOR_ELEVATOR",
panelX = 3, panelY = 0, readX = 3, readY = 1, facing = "up",
from = "SILPH_CO_5F", pick = "SILPH_CO_11F", floors = 11 },
{ map = "ROCKET_HIDEOUT_ELEVATOR", text = "TEXT_ROCKETHIDEOUTELEVATOR",
panelX = 1, panelY = 1, readX = 2, readY = 1, facing = "left",
from = "ROCKET_HIDEOUT_B2F", pick = "ROCKET_HIDEOUT_B4F", floors = 3,
key = "LIFT_KEY", keyText = "_RocketHideoutElevatorAppearsToNeedKeyText" },
}
-- ow stub: the car's warps are copied out of Data so a rewrite here cannot
-- leak into the suites that read the same map defs later. scriptMove /
-- takeWarp are the walk-out primitives the fix deleted; leaving them here as
-- tripwires is what makes "nothing moves the player" an assertion.
local function fakeCar(car)
local warps = {}
for i, w in ipairs(Data.maps[car.map].warps) do
warps[i] = { x = w.x, y = w.y, destMap = w.destMap, destWarp = w.destWarp }
end
local moves = 0
return {
map = { id = car.map, def = { warps = warps, label = Data.maps[car.map].label } },
player = { cellX = car.readX, cellY = car.readY, facing = car.facing },
npcs = {}, entities = {}, scriptMoves = {},
scriptMove = function() moves = moves + 1 end,
takeWarp = function() moves = moves + 1 end,
moveCount = function() return moves end,
}
end
local function fakeGame(inventory)
local pushed = {}
return {
data = Data,
save = { flags = {}, inventory = inventory or {} },
stack = {
pushed = pushed,
push = function(_, state) pushed[#pushed + 1] = state end,
pop = function() pushed[#pushed] = nil end,
top = function() return pushed[#pushed] end,
},
}, pushed
end
local function destinations(ow)
local maps = {}
for _, w in ipairs(ow.map.def.warps) do maps[w.destMap] = true end
local list = {}
for m in pairs(maps) do list[#list + 1] = m end
table.sort(list)
return table.concat(list, ",")
end
for _, car in ipairs(CARS) do
local tag = car.map
local entry = mapScripts.get(car.map)
check(type(entry) == "table" and type(entry.onEnter) == "function",
tag .. ": map entry still stores the car's warps")
-- the panel is map data, and it is unreachable except by facing it
local map = MapLoader.load(Data, car.map)
local sign = map:signAtCell(car.panelX, car.panelY)
check(sign ~= nil,
("%s: bg_event at (%d,%d) is extracted as a sign")
:format(tag, car.panelX, car.panelY))
eq(sign and sign.text, car.text, tag .. ": that sign carries the panel text")
check(not map:isWalkableCell(car.panelX, car.panelY),
tag .. ": the panel cell is wall, so it can only be read by facing it")
check(map:isWalkableCell(car.readX, car.readY),
("%s: (%d,%d) is the walkable cell the panel is read from")
:format(tag, car.readX, car.readY))
local panel = mapScripts.talkScript(car.map, car.text)
check(type(panel) == "function",
tag .. ": the panel text runs a hand-ported handler")
-- warping in: warps seeded to the floor just left, nothing on the stack
local game, pushed = fakeGame()
local ow = fakeCar(car)
entry.onEnter(game, ow, car.from)
eq(#pushed, 0, tag .. ": entering the car pushes no menu and no text")
eq(destinations(ow), car.from,
tag .. ": entry only points the car's exit warps back at " .. car.from)
local seeded = destinations(ow)
if car.key then
-- RocketHideoutElevatorText with no LIFT_KEY: the line, and no menu
local box = nil
local done = false
panel(game, ow, nil, function() done = true end)
box = pushed[#pushed]
eq(#pushed, 1, tag .. ": the keyless panel pushes exactly one state")
eq(box and box.kind, "text", tag .. ": and that state is the text box")
eq(box and box.text, Data.text[car.keyText],
tag .. ": it is the appears-to-need-a-key line")
if box and box.done then box.done() end
check(done, tag .. ": the keyless panel hands control back")
eq(destinations(ow), seeded,
tag .. ": a keyless read leaves the exit warps alone")
pushed[1] = nil
game.save.inventory[car.key] = 1
end
-- reading the panel with a key (or with no gate): WHICH FLOOR?
local done = false
panel(game, ow, nil, function() done = true end)
local list = pushed[#pushed]
eq(list and list.kind, "list", tag .. ": reading the panel opens a list menu")
eq(list and list.title, "WHICH FLOOR?", tag .. ": titled WHICH FLOOR?")
eq(list and #list.items, car.floors,
tag .. ": every floor that warps into this car is listed")
eq(destinations(ow), seeded,
tag .. ": opening the menu has not rewritten anything yet")
-- `ret c` on B: no warp rewrite, no ride, player still in the car
local cancelGame, cancelPushed = fakeGame(car.key and { [car.key] = 1 } or nil)
local cancelOw = fakeCar(car)
entry.onEnter(cancelGame, cancelOw, car.from)
local cancelDone = false
panel(cancelGame, cancelOw, nil, function() cancelDone = true end)
local cancelList = cancelPushed[#cancelPushed]
cancelList.opts.onCancel()
check(cancelDone, tag .. ": B closes the panel")
eq(destinations(cancelOw), car.from,
tag .. ": B leaves the exit warps on the floor entered from")
eq(cancelOw.moveCount(), 0, tag .. ": B moves nobody")
-- choosing a floor: the ride first, the .UpdateWarp rewrite when it ends
local chosen
for _, item in ipairs(list.items) do
if item.value.map == car.pick then chosen = item end
end
check(chosen ~= nil, tag .. ": " .. car.pick .. " is on the floor list")
list.opts.onChoose(chosen, list)
local shake = pushed[#pushed]
eq(shake and shake.kind, "shake", tag .. ": choosing a floor starts the ride")
eq(destinations(ow), seeded,
tag .. ": the warps are still the entry floor's while the car shakes")
shake.opts.onDone()
check(done, tag .. ": the ride hands control back to the player")
eq(destinations(ow), car.pick,
tag .. ": the finished ride points the car's exits at " .. car.pick)
eq(ow.moveCount(), 0,
tag .. ": no scripted walk-out -- the player leaves the car themselves")
eq(ow.player.cellX .. "," .. ow.player.cellY,
car.readX .. "," .. car.readY,
tag .. ": they are still standing at the panel")
-- the rewrite has to land on the floor's own warp back into the car, the
-- reciprocal pair wElevatorWarpMaps holds
for _, w in ipairs(ow.map.def.warps) do
local back = Data.maps[car.pick].warps[w.destWarp]
check(back ~= nil and back.destMap == car.map,
tag .. ": rewritten exit lands on " .. car.pick .. "'s elevator door")
end
end
package.loaded["src.ui.ListMenu"] = realList
package.loaded["src.world.ElevatorShake"] = realShake
package.loaded["src.render.TextBox"] = realTextBox
S.finish()
+184
View File
@@ -0,0 +1,184 @@
-- Parity test: the party-menu field moves print their text with the party
-- menu still on screen, and the GBPalWhiteOutWithDelay3 blink IS the menu
-- closing afterwards (engine/menus/start_sub_menus.asm .flash / .strength /
-- .surf, each PrintText -> GBPalWhiteOutWithDelay3 -> jp .goBackToMap,
-- whose RestoreScreenTilesAndReloadTilePatterns + CloseTextDisplay tear the
-- menu down only after the white-out). Popping the menu first left the
-- opaque WhiteFlash as the only state under the message, i.e. the white
-- screen in #385, and lit a dark cave under the FLASH text.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity field move layering")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local PartyMenu = require("src.ui.PartyMenu")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
-- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox
-- upvalue for a stub and never puts it back, and run_tests.lua dofiles every
-- parity suite into one process: point it back at the real module so trySurf
-- pushes a real box here.
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox"))
local function frame(btns)
Input.pressed = {}
for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end
StateStack:update(1 / 60)
for _, b in ipairs(btns or {}) do Input.state[b] = false end
end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function pushOW(mapId, x, y, facing)
popAll()
Game.stack:push(OW, mapId, x, y, facing)
return Game.stack:top()
end
local function mkMon(species, ...)
local m = Pokemon.new(Data, species, 20)
m.moves = {}
for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end
return m
end
local function selectSubItem(pm, idx)
Game.stack:push(pm)
frame({ "a" })
for _ = 2, idx do frame({ "down" }) end
frame({ "a" })
end
local function isText(s) return s ~= nil and s.pages ~= nil end
-- Transition.whiteFlash keeps its remaining frame budget and draws a full
-- opaque rect; the overworld and the menu have no `frames`
local function isBlink(s)
return s ~= nil and s.pages == nil and s.isOpaque == true
and type(s.frames) == "number"
end
-- the state the compositor starts from: StateStack:draw walks up from the
-- highest opaque state, so this is literally what the player sees behind
-- the message
local function backdrop()
return Game.stack.states[Game.stack:visibleBase()]
end
-- dismiss exactly one box (an auto page advances on its own timer, a
-- prompt page on A), leaving whatever it pushed on top
local function drainOne()
local box = Game.stack:top()
local guard = 0
while Game.stack:top() == box and guard < 400 do
guard = guard + 1
frame({ "a" })
end
end
-- run frames until the stack is back on the map (the blink pops itself and
-- fires its onDone)
local function settle(ow)
local guard = 0
while Game.stack:top() ~= ow and guard < 240 do
guard = guard + 1
frame({})
end
end
check(PartyMenu.isOpaque == true, "PartyMenu is opaque, so it can be the backdrop")
-- =====================================================================
-- .strength: PrintStrengthText's two pages, then the blink
-- =====================================================================
Game.save.party = { mkMon("MACHOP", "STRENGTH") }
Game.save.inventory = { RAINBOWBADGE = true }
local ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
local pmStr = PartyMenu.new(Game)
selectSubItem(pmStr, 3)
check(isText(Game.stack:top()), "STRENGTH opens _UsedStrengthText")
eq(backdrop(), pmStr, "the party menu is the backdrop of _UsedStrengthText")
drainOne()
check(isText(Game.stack:top()), "_CanMoveBouldersText follows")
eq(backdrop(), pmStr, "the party menu is still the backdrop of the second page")
drainOne()
check(isBlink(Game.stack:top()), "the blink comes after both pages")
eq(backdrop(), Game.stack:top(), "the blink is the top state, never under a message")
settle(ow)
eq(Game.stack:top(), ow, "STRENGTH ends on the map")
-- =====================================================================
-- .surf: ItemUseSurfboard's got-on text, then the blink that carries the
-- mount forward (#320 put the blink under the text, which is what left
-- the white rect as the backdrop)
-- =====================================================================
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
Game.save.inventory = { SOULBADGE = true }
ow = pushOW("PALLET_TOWN", 4, 13, "down")
ow.player.surfing = false
local pmSurf = PartyMenu.new(Game)
selectSubItem(pmSurf, 3)
check(isText(Game.stack:top()), "SURF opens _SurfingGotOnText")
eq(backdrop(), pmSurf, "the party menu is the backdrop of _SurfingGotOnText")
drainOne()
check(isBlink(Game.stack:top()), "the blink follows the got-on text")
eq(ow.player.surfing, true, "the mount happens with the blink, not before the text")
settle(ow)
eq(Game.stack:top(), ow, "SURF ends on the map")
-- =====================================================================
-- .cannotStopSurfing: prints and still closes, same layering
-- =====================================================================
ow = pushOW("PALLET_TOWN", 4, 15, "down")
ow.player.surfing = true
local pmNoOff = PartyMenu.new(Game)
selectSubItem(pmNoOff, 3)
check(isText(Game.stack:top()), "a blocked dismount opens _SurfingNoPlaceToGetOffText")
eq(backdrop(), pmNoOff, "the party menu is the backdrop of the no-place message")
drainOne()
check(isBlink(Game.stack:top()), "the blink follows the no-place message")
settle(ow)
eq(Game.stack:top(), ow, "the blocked dismount ends on the map")
ow.player.surfing = false
-- =====================================================================
-- .flash: `xor a / ld [wMapPalOffset], a` is undone before PrintText in
-- the asm, but the map is not on screen then -- the party menu is -- so
-- the lit cave may only appear once the blink hands the screen back
-- =====================================================================
Game.save.flashLit = false
Game.save.party = { mkMon("PIKACHU", "FLASH") }
Game.save.inventory = { BOULDERBADGE = true }
ow = pushOW("ROCK_TUNNEL_1F", 15, 4, "down")
eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH")
local pmFlash = PartyMenu.new(Game)
selectSubItem(pmFlash, 3)
check(isText(Game.stack:top()), "FLASH opens _FlashLightsAreaText")
eq(backdrop(), pmFlash, "the party menu is the backdrop of _FlashLightsAreaText")
eq(ow.dark, true, "the tunnel is still dark while the message is up")
drainOne()
check(isBlink(Game.stack:top()), "the blink follows the FLASH message")
eq(ow.dark, true, "the tunnel is still dark when the blink starts")
settle(ow)
eq(Game.stack:top(), ow, "FLASH ends on the map")
eq(ow.dark, false, "the tunnel is lit once the blink hands the map back")
eq(Game.save.flashLit, true, "FLASH is recorded on the save")
popAll()
S.finish()
+182
View File
@@ -0,0 +1,182 @@
-- Parity: the Rocket Hideout lift gates (#372).
--
-- Oracle: scripts/RocketHideoutB4F.asm RocketHideoutB4FDoorCallbackScript
-- stamps $2d over the lift doorway (lb bc, 5, 12) until CheckBothEventsSet
-- EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 / _1, then plays SFX_GO_INSIDE and
-- writes $e; scripts/RocketHideoutB1F.asm is the same shape with $54 at
-- lb bc, 8, 12 over EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4. The callback
-- runs whenever BIT_CUR_MAP_LOADED_1 is set, which home/trainers.asm
-- EndTrainerBattle does, so the gate opens the moment the last guard falls
-- rather than on the next map load.
--
-- The .blk layouts ship both doorways open, so a cache whose closedDoors
-- predates the two rows left the gates open all game: FieldDefaults has to
-- seed them the way it seeds the PrintTrashText bins.
--
-- Self-contained: `luajit tests/parity_hideout_gate.lua`; also globbed by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.ROCKET_HIDEOUT_B4F) then Data:load() end
local S = require("tests.harness").suite("parity hideout gate")
local check, eq = S.check, S.eq
local FieldDefaults = require("src.world.FieldDefaults")
local B4F, B1F = "ROCKET_HIDEOUT_B4F", "ROCKET_HIDEOUT_B1F"
local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0"
local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
local B1F_GUARD = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4"
-- ---- the shipped layout ---------------------------------------------------
-- ReplaceTileBlock only ever writes; nothing in the .blk closes these, which
-- is why a missing closedDoors row reads as a permanently open gate.
local function shippedBlock(mapId, bx, by)
local def = Data.maps[mapId]
return def.blocks[by * def.width + bx + 1]
end
eq(shippedBlock(B4F, 12, 5), 0x0e, "B4F ships the lift doorway open (block $e)")
eq(shippedBlock(B1F, 12, 8), 0x0e, "B1F ships its doorway open (block $e)")
-- ---- the door rows -------------------------------------------------------
local doors = FieldDefaults.fieldValue(Data, "cardKeyDoors", "closedDoors")
check(type(doors) == "table", "field.cardKeyDoors.closedDoors resolves")
local b4 = doors[B4F] and doors[B4F][1]
check(b4 ~= nil, "B4F has a closed-door row")
if b4 then
eq(b4.bx, 12, "B4F door x (lb bc, 5, 12)")
eq(b4.by, 5, "B4F door y (lb bc, 5, 12)")
eq(b4.block, 0x2d, "B4F closed block is $2d")
eq(b4.open, 0x0e, "B4F open block is $e")
eq(b4.event, nil, "B4F is not gated on a single event")
eq(b4.events and b4.events[1], GUARD_0, "CheckBothEventsSet arg 1")
eq(b4.events and b4.events[2], GUARD_1, "CheckBothEventsSet arg 2")
end
local b1 = doors[B1F] and doors[B1F][1]
check(b1 ~= nil, "B1F has a closed-door row")
if b1 then
eq(b1.bx, 12, "B1F door x (lb bc, 8, 12)")
eq(b1.by, 8, "B1F door y (lb bc, 8, 12)")
eq(b1.block, 0x54, "B1F closed block is $54")
eq(b1.open, 0x0e, "B1F open block is $e")
eq(b1.event, B1F_GUARD, "B1F waits on the fifth grunt")
end
-- the events the rows name have to be the ones the guards actually set:
-- interact() writes trainerHeader().event on a win
eq(Data:trainerHeader("RocketHideoutB4F", 2).event, GUARD_0,
"the (23,12) guard sets trainer_0")
eq(Data:trainerHeader("RocketHideoutB4F", 3).event, GUARD_1,
"the (26,12) guard sets trainer_1")
eq(Data:trainerHeader("RocketHideoutB1F", 5).event, B1F_GUARD,
"B1F's fifth grunt sets trainer_4")
-- ---- the stale cache the reporters have ----------------------------------
-- closedDoors exists but holds only the Silph rows, so fieldValue's per-path
-- fallback resolves the cache's table and finds no hideout floors. seed()
-- fills map keys in place, which is what heals it.
do
local silph = { { block = 0x54, bx = 2, by = 2, open = 0x0e,
event = "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" } }
local stale = { field = { cardKeyDoors = { doorTiles = { 24 },
closedDoors = { SILPH_CO_2F = silph } } } }
check(FieldDefaults.fieldValue(stale, "cardKeyDoors", "closedDoors")[B4F] == nil,
"the stale cache really is missing the B4F row")
FieldDefaults.seed(stale)
local healed = FieldDefaults.fieldValue(stale, "cardKeyDoors", "closedDoors")
eq(healed[B4F] and healed[B4F][1].block, 0x2d, "seed fills the B4F gate")
eq(healed[B1F] and healed[B1F][1].block, 0x54, "seed fills the B1F gate")
eq(healed.SILPH_CO_2F, silph, "and leaves the cache's own rows alone")
eq(stale.field.cardKeyDoors.doorTiles[1], 24, "including the card-key tiles")
end
-- ---- the live floor ------------------------------------------------------
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "CHARMANDER", 20) }
local sfx = {}
local realPlay = Sound.play
Sound.play = function(_, id) sfx[#sfx + 1] = id end
local function goInsides()
local n = 0
for _, id in ipairs(sfx) do if id == "Go_Inside" then n = n + 1 end end
return n
end
-- Map:setBlock writes through to the shared Data record every later suite in
-- this process reads, so both gates are put back at the bottom of the file
local restore = {}
local function remember(mapId, bx, by)
restore[#restore + 1] = { mapId, bx, by, shippedBlock(mapId, bx, by) }
end
remember(B4F, 12, 5)
remember(B1F, 12, 8)
local function arrive(mapId, x, y)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, mapId, x, y, "up")
return Game.stack:top()
end
-- one cell south of the gate, the way a player walks up to it
local ow = arrive(B4F, 24, 13)
eq(ow.map:blockAt(12, 5), 0x2d, "arriving with both guards alive bars the gate")
check(not ow.map:isWalkableCell(24, 11) and not ow.map:isWalkableCell(25, 11),
"and neither doorway cell can be stepped into")
sfx = {}
Game.save.flags[GUARD_0] = true
ow:afterBattle("win", {})
eq(ow.map:blockAt(12, 5), 0x2d, "one guard down leaves the gate barred")
eq(goInsides(), 0, "and plays no door sound")
Game.save.flags[GUARD_1] = true
ow:afterBattle("win", {})
eq(ow.map:blockAt(12, 5), 0x0e,
"the second guard opens the gate without leaving the floor")
eq(goInsides(), 1, "with one SFX_GO_INSIDE")
check(ow.map:isWalkableCell(24, 11) and ow.map:isWalkableCell(25, 11),
"the doorway cells are floor now")
-- EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED: the stamp is a transition, not a
-- per-load sound
ow:afterBattle("win", {})
eq(goInsides(), 1, "a later battle on the open floor is silent")
ow = arrive(B4F, 24, 13)
eq(ow.map:blockAt(12, 5), 0x0e, "re-entering the floor keeps it open")
eq(goInsides(), 1, "and does not re-play the door sound")
-- ---- B1F, the same seed row ---------------------------------------------
sfx = {}
ow = arrive(B1F, 24, 17)
eq(ow.map:blockAt(12, 8), 0x54, "B1F is barred until the fifth grunt")
Game.save.flags[B1F_GUARD] = true
ow:afterBattle("win", {})
eq(ow.map:blockAt(12, 8), 0x0e, "beating him opens it on the spot")
eq(goInsides(), 1, "with its own door sound")
for _, r in ipairs(restore) do
local def = Data.maps[r[1]]
def.blocks[r[3] * def.width + r[2] + 1] = r[4]
end
Sound.play = realPlay
while Game.stack:top() do Game.stack:pop() end
S.finish()
+217
View File
@@ -0,0 +1,217 @@
-- Parity: Team Rocket leaves Silph Co, and the president keeps talking (#392).
--
-- Oracle: scripts/SilphCo11F.asm SilphCo11FTeamRocketLeavesScript walks
-- .HideToggleableObjectIDs (TOGGLE_SILPH_CO_2F_2..11F_3 plus the Saffron
-- street set, which M.SAFFRON_CITY handles) through predef HideObject just
-- before SetEvent EVENT_BEAT_SILPH_CO_GIOVANNI. The ordinals come from
-- constants/toggle_constants.asm and name the objects listed per floor in
-- data/maps/toggleable_objects.asm, so the item balls, the rescued 2F/10F
-- workers and the 7F rival keep their sprites. HideObject writes
-- wMissableObjectFlags, which the port keeps in save.objectToggles.
--
-- Oracle: scripts/SilphCo11F.asm SilphCo11FSilphPresidentText branches on
-- EVENT_GOT_MASTER_BALL alone -- nz jumps to .got_item, which prints
-- _SilphCo11FSilphPresidentMasterBallDescriptionText -- so every later talk
-- prints something.
--
-- Self-contained: `luajit tests/parity_silph_rockets.lua`; also globbed by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity silph rockets")
local check, eq = S.check, S.eq
local MapScripts = require("src.script.MapScripts")
require("data.scripts.init")
local Commands = require("src.script.Commands")
local ScriptRunner = require("src.script.ScriptRunner")
local OverworldState = require("src.world.OverworldController")
-- .HideToggleableObjectIDs, floor by floor, in the order the asm lists them
local HIDDEN = {
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
"SILPHCO4F_ROCKET2" } },
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
"SILPHCO6F_ROCKET2" } },
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
"SILPHCO8F_ROCKET2" } },
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
"SILPHCO9F_ROCKET2" } },
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
"SILPHCO11F_ROCKET2" } },
}
-- toggleable objects the hide list deliberately skips
local KEPT = {
{ "SILPH_CO_2F", "SILPHCO2F_SILPH_WORKER_F" },
{ "SILPH_CO_3F", "SILPHCO3F_HYPER_POTION" },
{ "SILPH_CO_5F", "SILPHCO5F_CARD_KEY" },
{ "SILPH_CO_7F", "SILPHCO7F_RIVAL" },
{ "SILPH_CO_7F", "SILPHCO7F_TM_SWORDS_DANCE" },
{ "SILPH_CO_10F", "SILPHCO10F_SILPH_WORKER_F" },
{ "SILPH_CO_11F", "SILPHCO11F_BEAUTY" },
}
local function objOf(mapId, name)
for _, o in ipairs(Data.maps[mapId].objects) do
if o.name == name then return o end
end
end
local function newSave(beat)
return {
flags = beat and { EVENT_BEAT_SILPH_CO_GIOVANNI = true } or {},
inventory = {}, objectToggles = {}, itemsTaken = {},
defeatedTrainers = {},
}
end
-- every name in both lists has to be a real object_event, or the toggle
-- write lands on a key nothing reads
for _, floor in ipairs(HIDDEN) do
for _, name in ipairs(floor[2]) do
check(objOf(floor[1], name) ~= nil,
name .. " is an object_event on " .. floor[1])
end
end
for _, kept in ipairs(KEPT) do
check(objOf(kept[1], kept[2]) ~= nil,
kept[2] .. " is an object_event on " .. kept[1])
end
-- ---------------------------------------------------------------- hide pass
-- 11F's onEnter is the whole pass: it runs on the post-battle callback's
-- floor and repairs saves that beat Giovanni before the list existed
local function enter(mapId, save)
local view = MapScripts.get(mapId)
check(view and type(view.onEnter) == "function",
mapId .. " has an onEnter hook")
if view and view.onEnter then view.onEnter({ save = save }, nil) end
end
do
local save = newSave(true)
enter("SILPH_CO_11F", save)
local hidden = 0
for _, floor in ipairs(HIDDEN) do
for _, name in ipairs(floor[2]) do
eq(save.objectToggles[floor[1]] and save.objectToggles[floor[1]][name],
false, name .. " hidden by the 11F pass")
check(not OverworldState.objectVisible(save, floor[1],
objOf(floor[1], name)),
name .. " no longer spawns")
hidden = hidden + 1
end
end
eq(hidden, 31, "the asm hides 31 Silph objects on 2F-11F")
for _, kept in ipairs(KEPT) do
eq(save.objectToggles[kept[1]] and save.objectToggles[kept[1]][kept[2]],
nil, kept[2] .. " is not in .HideToggleableObjectIDs")
check(OverworldState.objectVisible(save, kept[1], objOf(kept[1], kept[2])),
kept[2] .. " still spawns")
end
end
-- each floor repairs itself on entry and touches no other floor, so a save
-- that walks back in one elevator ride at a time still clears
for _, floor in ipairs(HIDDEN) do
local save = newSave(true)
enter(floor[1], save)
for _, name in ipairs(floor[2]) do
eq(save.objectToggles[floor[1]][name], false,
name .. " hidden on entering " .. floor[1])
end
if floor[1] ~= "SILPH_CO_11F" then
local others = 0
for mapId in pairs(save.objectToggles) do
if mapId ~= floor[1] then others = others + 1 end
end
eq(others, 0, floor[1] .. " onEnter writes only its own floor")
end
end
-- before the win nothing is hidden: the grunts are still battleable
for _, floor in ipairs(HIDDEN) do
local save = newSave(false)
enter(floor[1], save)
eq(next(save.objectToggles), nil,
floor[1] .. " hides nothing while the event is unset")
for _, name in ipairs(floor[2]) do
check(OverworldState.objectVisible(save, floor[1], objOf(floor[1], name)),
name .. " still spawns before Giovanni falls")
end
end
-- ---------------------------------------------------------------- president
local rows = MapScripts.get("SILPH_CO_11F").talk.TEXT_SILPHCO11F_SILPH_PRESIDENT
check(type(rows) == "table", "the president has a hand-ported talk script")
-- the shipped bug: the last row jumped past the end of the script, which
-- validate reports and every branch walked into
local problems = ScriptRunner.validate(rows)
eq(#problems, 0, "president script validates: " .. table.concat(problems, "; "))
for _, row in ipairs(rows) do
check(row[2] ~= "EVENT_BEAT_SILPH_CO_GIOVANNI",
"no Giovanni gate: the teleport pads reach him without the trigger")
end
for _, key in ipairs({ "_SilphCo11FSilphPresidentText",
"_SilphCo11FSilphPresidentReceivedMasterBallText",
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }) do
local body = Data.text[key]
check(type(body) == "string" and body ~= "", key .. " is extracted")
end
-- run the rows through the real interpreter with only the leaf commands
-- stubbed, so the branch arithmetic is what is under test
local shown, given
Commands.face_player = function() end
Commands.show_text = function(_, key) shown[#shown + 1] = key end
Commands.give_item = function(_, item, count) given = { item, count } end
local function talk(save)
shown, given = {}, nil
local runner = ScriptRunner.new({ data = Data, save = save }, nil)
runner:exec(rows, runner:makeContext({}))
return shown, given
end
do
local save = newSave(true)
local first = talk(save)
eq(first[1], "_SilphCo11FSilphPresidentText", "first talk thanks the player")
eq(given and given[1], "MASTER_BALL", "first talk hands over a MASTER BALL")
eq(given and given[2], 1, "one ball, as in lb bc, MASTER_BALL, 1")
eq(first[2], "_SilphCo11FSilphPresidentReceivedMasterBallText",
"the got-item line prints after the give (sound_get_key_item rides it)")
eq(#first, 2, "the description is not printed on the same talk")
eq(save.flags.EVENT_GOT_MASTER_BALL, true, "SetEvent EVENT_GOT_MASTER_BALL")
local second, secondGive = talk(save)
eq(second[1], "_SilphCo11FSilphPresidentMasterBallDescriptionText",
"second talk describes the ball instead of going silent")
eq(#second, 1, "and prints nothing else")
eq(secondGive, nil, "a second ball is not handed out")
end
-- the ball does not depend on Giovanni's coordinate trigger having fired
do
local fresh = talk(newSave(false))
eq(fresh[1], "_SilphCo11FSilphPresidentText",
"an unbeaten-Giovanni save still gets the thank-you and the ball")
end
S.finish()
+110
View File
@@ -0,0 +1,110 @@
-- Parity test: the Silph Co 2F worker begs before she hands over TM36.
--
-- scripts/SilphCo2F.asm SilphCo2FSilphWorkerFText prints .PleaseTakeThisText
-- (text/SilphCo2F.asm:1, "Eeek!" / "No! Stop! Help!" ... "please take this!")
-- and only then calls GiveItem, so the scared line always comes first and the
-- second visit never repeats it. The port skipped it because that label
-- carries no leading underscore, so the manifest harvester dropped the string
-- and the gift() row had no `pre` to print (#393).
--
-- Self-contained; run via `luajit tests/parity_silph_tm36_pre.lua`, and also
-- dofile'd by tests/run_tests.lua's parity aggregator.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.SILPH_CO_2F) then Data:load() end
require("src.render.Font").load(Data)
local S = require("tests.harness").suite("parity silph tm36 pre text")
local check, eq = S.check, S.eq
local SaveData = require("src.core.SaveData")
local TextBox = require("src.render.TextBox")
local PRE = "SilphCo2FSilphWorkerFPleaseTakeThisText"
local pre = Data.text[PRE]
check(type(pre) == "string" and pre ~= "", PRE .. " is extracted")
if type(pre) == "string" then
check(pre:find("Eeek!", 1, true) ~= nil, "it opens on \"Eeek!\"")
check(pre:find("with TEAM ROCKET.", 1, true) ~= nil,
"it works out the player is not a Rocket")
check(pre:find("please take this!", 1, true) ~= nil,
"it ends on \"please take this!\"")
end
local handler = require("data.scripts.story5")
.SILPH_CO_2F.talk.TEXT_SILPHCO2F_SILPH_WORKER_F
check(type(handler) == "function", "SILPH_CO_2F wires the worker's talk entry")
local stack = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = {
push = function(_, state) stack[#stack + 1] = state end,
pop = function() return table.remove(stack) end,
top = function() return stack[#stack] end,
},
}
game.save.player.name = "RED"
local function pages(box)
local out = {}
for _, page in ipairs(box.pages or {}) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " / ")
end
-- runs one conversation to its end, returning the boxes in the order the
-- player reads them; `onBox` sees each box before its A press lands, which is
-- how the bag is inspected while the first box is still up
local function converse(onBox)
stack = {}
local read, done = {}, false
handler(game, nil, nil, function() done = true end)
for _ = 1, 8 do
local box = stack[#stack]
if not box then break end
check(getmetatable(box) == TextBox, "the worker pushes a TextBox")
read[#read + 1] = pages(box)
if onBox then onBox(#read, box) end
if done or not box.onDone then break end
stack[#stack] = nil
box.onDone()
end
check(done, "the conversation runs to its callback")
return read
end
local function bagHasTm()
return (game.save.inventory or {}).TM_SELFDESTRUCT ~= nil
end
local first = converse(function(n)
if n == 1 then
check(not bagHasTm(),
"the TM is still hers while the first box is up")
check(not game.save.flags.EVENT_GOT_TM36,
"EVENT_GOT_TM36 is still unset while the first box is up")
end
end)
check((first[1] or ""):find("Eeek!", 1, true) ~= nil,
"the first box the player reads is the scared line")
local joined = table.concat(first, " | ")
check(joined:find("TM36", 1, true) ~= nil, "the run reaches the TM36 boxes")
check(joined:find("SELFDESTRUCT!", 1, true) ~= nil,
"and the SELFDESTRUCT explanation")
check(bagHasTm(), "TM36 is in the bag afterwards")
check(game.save.flags.EVENT_GOT_TM36, "EVENT_GOT_TM36 is set afterwards")
-- second visit: SilphCo2F.asm jumps straight to the explanation
local again = converse()
eq(#again, 1, "talking again is one box")
check((again[1] or ""):find("SELFDESTRUCT!", 1, true) ~= nil,
"and it is the TM36 explanation")
check((again[1] or ""):find("Eeek!", 1, true) == nil,
"the scared line does not play twice")
S.finish()
+275
View File
@@ -0,0 +1,275 @@
-- Parity: the S.S. Anne rival goodbye and her departure from the dock
-- (#360). scripts/SSAnne2F.asm SSAnne2FRivalAfterBattleScript prints
-- TEXT_SSANNE2F_RIVAL_CUT_MASTER and walks him out DOWNWARD, keyed on the
-- player's X; scripts/VermilionDock.asm VermilionDockSSAnneLeavesScript
-- delays 120, blows SFX_SS_ANNE_HORN, shifts her eight columns west, then
-- VermilionDock_EraseSSAnne blows the horn again and delays 120 more.
--
-- luajit tests/parity_ss_anne_departure.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity ss anne departure")
local check, eq = S.check, S.eq
-- restored at the bottom for the suites run after this file
local realMusic = package.loaded["src.core.Music"]
local realTextBox = package.loaded["src.render.TextBox"]
local realPicBox = package.loaded["src.ui.PicBox"]
local music = { played = {} }
package.loaded["src.core.Music"] = {
play = function(_, id) music.played[#music.played + 1] = id end,
playOnce = function() return true end,
stop = function() music.played[#music.played + 1] = "stop" end,
}
package.loaded["src.render.TextBox"] = {
new = function(_, text) return { text = text } end,
}
package.loaded["src.ui.PicBox"] = { new = function() return {} end }
local story3 = dofile("data/scripts/story3.lua")
local story5 = dofile("data/scripts/story5.lua")
local text = dofile("data/generated/text.lua")
local audio = dofile("data/generated/audio.lua")
local maps = dofile("data/generated/maps.lua")
local function dirsEqual(a, b)
if type(a) ~= "table" or #a ~= #b then return false end
for i = 1, #b do if a[i] ~= b[i] then return false end end
return true
end
local function rowsOfKind(rows, kind)
local out = {}
for _, r in ipairs(rows) do
if r[1] == kind then out[#out + 1] = r end
end
return out
end
-- ------------------------------------------------------------- rival exit
-- SSAnne2F.asm .RivalDownFourMovement (player on 37) and
-- .RivalWalkAroundPlayerMovement, which falls through into those same four
-- DOWNs (player on 36).
local RIGHT_OF_HIM = { "down", "down", "down", "down" }
local AROUND_HIM = { "right", "down", "down", "down", "down", "down" }
local function ambush(x)
local rows
local ow = {
runner = {
isRunning = function() return false end,
run = function(_, r) rows = r end,
},
player = { facing = "down" },
}
local game = { save = { flags = {} }, data = {} }
check(story5.SS_ANNE_2F.onStep(game, ow, x, 8),
("SS Anne 2F ambush fires at (%d,8)"):format(x))
check(rows ~= nil, "the ambush queued rows")
return rows
end
do
check(type(text._SSAnne2FRivalCutMasterText) == "string",
"_SSAnne2FRivalCutMasterText is in the cache")
check(text._SSAnne2FRivalCutMasterText:find("CUT", 1, true) ~= nil,
"the goodbye line is the CUT master one")
for _, case in ipairs({ { 37, RIGHT_OF_HIM }, { 36, AROUND_HIM } }) do
local x, dirs = case[1], case[2]
local rows = ambush(x)
local said = {}
for _, r in ipairs(rowsOfKind(rows, "show_text")) do
said[#said + 1] = r[2]
end
local order = {
"_SSAnne2FRivalText",
"_SSAnne2FRivalDefeatedText",
"_SSAnne2FRivalCutMasterText",
}
local pi = 1
for _, id in ipairs(said) do
if pi <= #order and id == order[pi] then pi = pi + 1 end
end
eq(pi, #order + 1,
("x=%d text order: greeting, defeated, CUT master"):format(x))
local walk = rowsOfKind(rows, "walk_npc")[1]
check(walk ~= nil, ("x=%d exit is a walk_npc list"):format(x))
check(dirsEqual(walk and walk[3], dirs),
("x=%d exit walk matches the pokered movement data"):format(x))
-- the port used to send him back to his (36,4) spawn by the
-- captain's-room stairs instead of out of the room
for _, r in ipairs(rowsOfKind(rows, "move_npc_to")) do
check(not (r[3] == 36 and r[4] == 4),
("x=%d rival must not retreat to the spawn (36,4)"):format(x))
end
-- jump_if_false on a lost battle has to clear the whole tail
local jump = rowsOfKind(rows, "jump_if_false")[1]
eq(jump and jump[2], #rows, "a lost battle jumps past the exit walk")
end
end
-- --------------------------------------------------------------- the ship
-- data/maps/objects/VermilionDock.asm: the SS_ANNE_1F gangway warp is
-- (14,2), i.e. block (7,1), and hlowcoord 5, 2 is the hull's own block box.
local DOCK_HULL = { x0 = 5, x1 = 8, y0 = 1, y1 = 2 }
local WATER = { [1] = true, [13] = true }
local function dockBlock(bx, by)
local def = maps.VERMILION_DOCK
return def.blocks[by * def.width + bx + 1]
end
local function sail(cellX, cellY)
local rows, puffs = nil, 0
local ow = {
player = { cellX = cellX, cellY = cellY },
startDustAnim = function(_, _, _, done)
puffs = puffs + 1
if done then done() end
end,
queueScript = function(_, r) rows = r end,
}
local game = {
save = { flags = { EVENT_GOT_HM01 = true } },
data = { text = text },
}
story3.VERMILION_DOCK.onEnter(game, ow)
check(rows ~= nil, "stepping off the gangway queues the departure")
check(game.save.flags.EVENT_SS_ANNE_LEFT == true, "EVENT_SS_ANNE_LEFT set")
eq(puffs, 3, "three funnel smoke puffs (LoadSmokeTileFourTimes)")
return rows
end
do
-- the hull ids the slide reuses are the map's own blocks, so a data
-- rebuild that renumbered the tileset would be caught here
eq(dockBlock(DOCK_HULL.x0, 1), 4, "bow upper-half block id")
eq(dockBlock(DOCK_HULL.x1, 2), 11, "stern lower-half block id")
check(WATER[dockBlock(2, 1)] and WATER[dockBlock(2, 2)],
"the water she sails into is blocks 1 (upper) and 13 (lower)")
eq(audio.mapSongs.VERMILION_CITY, "Music_Vermilion",
"the city has its own theme for PlayDefaultMusic to switch to")
check(audio.songs.Music_Vermilion ~= nil, "and that song is in the cache")
local rows = sail(14, 2)
local horns = 0
for _, r in ipairs(rowsOfKind(rows, "play_sound")) do
if r[2] == "SS_Anne_Horn" then horns = horns + 1 end
end
eq(horns, 2, "the horn blows twice: leaving, then once she is gone")
local waits = rowsOfKind(rows, "wait")
eq(waits[1][2], 120, "120 frames before the first horn")
eq(waits[#waits][2], 120, "EraseSSAnne's 120 frames before the walk out")
local slide = 0
for _, w in ipairs(waits) do
if w[2] == 20 then slide = slide + 1 end
end
eq(slide, 8, "eight column shifts, .shift_columns_up's ld e, $8")
-- the bug was the whole hull blinking to water in a single frame with no
-- travel at all: her bow block has to be written one column further west
-- each step, and the water has to close in astern behind her
local bow, wake = {}, {}
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
if r[3] == 1 and r[4] == dockBlock(DOCK_HULL.x0, 1) then
bow[#bow + 1] = r[2]
elseif r[3] == 1 and r[4] == 1 then
wake[#wake + 1] = r[2]
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 }),
"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
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
end
end
-- and she has to have travelled: the westmost water column of the map
-- carried hull blocks partway through
local sawWest = false
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
if r[2] == 1 and not WATER[r[4]] then sawWest = true end
end
check(sawWest, "the hull reaches the west edge of the water before it goes")
end
do
-- fix 3: opts.keep on play_music sets keepMusicOnce, which
-- OverworldController:setMap consumes to SKIP the destination map's
-- theme -- the dock's Music_Surfing must not ride into Vermilion City
local rows = sail(14, 2)
for _, r in ipairs(rowsOfKind(rows, "play_music")) do
check(not (r[3] and r[3].keep),
"no keepMusicOnce override on the way into the city")
end
local warp = rowsOfKind(rows, "warp")[1]
check(warp ~= nil and warp[2] == "VERMILION_CITY", "the cutscene warps into town")
check(not (warp[6] and warp[6].keepMusic), "the warp itself keeps no music")
eq(music.played[#music.played], "Music_Surfing", "the sail-away plays surf")
end
do
-- coming back later: no ghost hull, just the sailor's line and a bounce
-- back into the city
local set, rebuilt, pushed, warped = {}, false, nil, nil
local ow = {
player = { cellX = 14, cellY = 2 },
map = {
setBlock = function(_, bx, by, block) set[bx .. "," .. by] = block end,
renderer = { rebuild = function() rebuilt = true end },
},
startWarpTo = function(_, m) warped = m end,
}
local game = {
save = { flags = { EVENT_SS_ANNE_LEFT = true } },
data = { text = text },
stack = { push = function(_, s) pushed = s end },
}
story3.VERMILION_DOCK.onEnter(game, ow)
for bx = DOCK_HULL.x0, DOCK_HULL.x1 do
for by = DOCK_HULL.y0, DOCK_HULL.y1 do
check(WATER[set[bx .. "," .. by]],
("re-entry: (%d,%d) is water, not a ghost hull"):format(bx, by))
end
end
check(rebuilt, "re-entry rebuilds the tile renderer")
check(pushed ~= nil and type(pushed.text) == "string",
"re-entry shows the ship-set-sail line")
pushed.text = pushed.text or ""
eq(pushed.text, text._VermilionCitySailor1ShipSetSailText,
"and it is the sailor's own line")
end
package.loaded["src.core.Music"] = realMusic
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.ui.PicBox"] = realPicBox
S.finish()
+14 -9
View File
@@ -81,7 +81,7 @@ end
local function owWith(moved)
return {
player = { facing = "down", cellY = 2 },
player = { facing = "down", cellX = 14, cellY = 2 },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
queueScript = function(self, rows) self._queued = rows end,
startDustAnim = function() end,
@@ -178,18 +178,23 @@ do
end
check(sawSurf, "departure plays Music_Surfing")
check(ow._queued ~= nil, "departure queues the sail-away script")
local kept, horn = false, false
-- #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
local kept, horns, slid, underPlayer = false, 0, 0, false
for _, row in ipairs(ow._queued or {}) do
if row[1] == "play_music" and row[2] == "Music_Surfing"
and row[3] and row[3].keep then
kept = true
end
if row[1] == "play_music" and row[3] and row[3].keep then kept = true end
if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then
horn = true
horns = horns + 1
end
if row[1] == "replace_block" then
slid = slid + 1
if row[2] == 7 and row[3] == 1 then underPlayer = true end
end
end
check(kept, "departure keeps Music_Surfing across the city warp")
check(horn, "departure queues SS_Anne_Horn")
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")
end
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
+36 -12
View File
@@ -2,8 +2,10 @@
-- under the player's feet (#265). home/overworld.asm:391 CheckWarpsNoCollision
-- runs on EVERY completed step with no first-step-after-a-warp counter, so the
-- arrival disable is POSITIONAL (the cell you came in on is inert until you
-- leave it), which is what warpEntryCell models. #230's stand-still bonk guard,
-- still backed by justWarped, is asserted here too.
-- leave it), which is what warpEntryCell models. The two STAND-STILL triggers
-- answer to BIT_STANDING_ON_WARP instead, which the departing tile decides and
-- the warp carries over: cleared by a staircase (#230), kept by a door tile so
-- an exit mat works on the tile you land on (#378). Both are asserted here.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
@@ -48,15 +50,18 @@ Game.overworld = OW
-- these two fields once setMap has placed the player (OverworldController.lua,
-- inside the Transition callback). takeWarp is stubbed per instance so the
-- assertion is which warp the engine decided to take, with no Transition to pump.
local function arriveOn(mapId, x, y, facing)
local function arriveOn(mapId, x, y, facing, standingOnWarp)
Input:reset()
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, mapId, x, y, facing or "down")
local ow = Game.stack:top()
ow.player.moving = false
ow.player.turnTimer = 0
ow.justWarped = true
ow.warpEntryCell = { x = x, y = y }
-- BIT_STANDING_ON_WARP is whatever the tile we LEFT made it: a door tile
-- leaves it set, a stair/ladder tile clears it, and the map change does not
-- touch wMovementFlags.
ow.standingOnWarp = standingOnWarp and true or false
ow.taken = nil
ow.takeWarp = function(self, def) self.taken = def end
return ow
@@ -77,16 +82,16 @@ end
-- onto the adjacent ladder at (25,4).
do
local ow = arriveOn("SEAFOAM_ISLANDS_B3F", 25, 3, "down")
check(ow:onWarpArrivalCell(), "the cell just arrived on is inert")
check(ow.warpEntryCell ~= nil, "the cell just arrived on is inert")
stepTo(ow, 25, 4, "down")
check(ow.taken ~= nil, "the step onto (25,4) fires a warp")
eq(ow.taken and ow.taken.destMap, "SEAFOAM_ISLANDS_B4F",
"and it is the ladder down to B4F")
check(ow.justWarped == false, "the arrival record is cleared by that step")
check(ow.warpEntryCell == nil, "the arrival record is cleared by that step")
end
-- The arrival cell stays inert while you stand on it, even if a scripted nudge
-- re-runs the step handler there. That is warpEntryCell's job, not justWarped's.
-- re-runs the step handler there. That is warpEntryCell's job.
do
local ow = arriveOn("SEAFOAM_ISLANDS_B3F", 25, 3, "down")
stepTo(ow, 25, 3, "down")
@@ -106,12 +111,14 @@ do
eq(ow.taken and ow.taken.destMap, "SEAFOAM_ISLANDS_B2F", "back up to B2F")
end
-- #230 must not come back: justWarped still guards the two STAND-STILL warp
-- triggers, where no step ever completes. Red's house 2F staircase is on the
-- map's east edge, so pushing into that edge has to bonk, not bounce floors.
-- #230 must not come back: the staircase tile ($1A/$1C, a warp tile that is
-- not a door tile) clears BIT_STANDING_ON_WARP on the step that takes it, so
-- the two STAND-STILL triggers stay shut on arrival. Red's house 2F staircase
-- is on the map's east edge, so pushing into that edge has to bonk, not bounce.
do
local ow = arriveOn("REDS_HOUSE_2F", 7, 1, "down")
check(ow:onWarpArrivalCell(), "the staircase arrival cell reports inert")
local ow = arriveOn("REDS_HOUSE_2F", 7, 1, "down", false)
check(ow:canCollisionWarp() == false,
"the staircase arrival leaves BIT_STANDING_ON_WARP clear")
check(ow:checkEdgeExit("right") == false,
"pushing east off the map edge from it does not warp (#230)")
Input.state.right = true
@@ -138,4 +145,21 @@ do
Input:reset()
end
-- #378: the exit mat you warped IN on is NOT inert for the collision paths.
-- REDS_HOUSE_1F's mat (2,7) is tile $14 -- neither a door tile nor a warp tile,
-- so nothing clears the flag that Pallet Town's door tile ($1B, a door tile)
-- set on the way in, and the first press of DOWN walks straight back outside.
do
local reds1 = MapLoader.load(Data, "REDS_HOUSE_1F")
eq(reds1:cellTile(2, 7), 0x14, "REDS_HOUSE_1F (2,7) is the exit mat tile")
check(not reds1:isWarpTileCell(2, 7), "the mat is not a warp-activating tile")
eq(reds1.def.height * 2, 8,
"REDS_HOUSE_1F is 8 cells tall, so y=7 is the south edge")
local ow = arriveOn("REDS_HOUSE_1F", 2, 7, "down", true)
check(ow:checkEdgeExit("down") == true,
"pressing DOWN on the mat just arrived on exits the house (#378)")
eq(ow.taken and ow.taken.destMap, "LAST_MAP", "back out the way we came")
end
S.finish()
+142
View File
@@ -0,0 +1,142 @@
-- Regression: BIT_STANDING_ON_WARP is decided by the tile you LEAVE and is
-- carried across the warp, so the exit mat you land on works on that same tile
-- (#378). ClearVariablesOnEnterMap (engine/overworld/clear_variables.asm)
-- never touches wMovementFlags, and IsPlayerStandingOnDoorTileOrWarpTile
-- (engine/overworld/player_state.asm) only clears the bit for a
-- warp-activating tile that is NOT a door tile -- which is why a house door
-- ($1B) leaves it set for the mat inside and a staircase ($1A/$1C) clears it
-- (#230). This file pins the flag itself: how it is derived, that a map
-- change preserves it, and what each of the two collision paths then does.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.REDS_HOUSE_1F) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local MapLoader = require("src.world.MapLoader")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity warp standing flag")
local check, eq = S.check, S.eq
-- ---- ground truth: the three tiles that decide the flag -------------------
-- data/tilesets/door_tile_ids.asm .OverworldDoorTileIDs lists $1B; REDS_HOUSE_1
-- has no door entry and .RedsHouse1WarpTileIDs is $1A/$1C; POKECENTER has
-- neither list. pokered data/maps/objects/{PalletTown,RedsHouse1F,
-- ViridianPokecenter}.asm give the warp cells.
local pallet = MapLoader.load(Data, "PALLET_TOWN")
eq(pallet:cellTile(5, 5), 0x1B, "Red's front door in Pallet Town is tile $1B")
check(pallet:isDoorTileCell(5, 5), "$1B is a door tile, so the flag stays set")
local doorWarp = pallet:warpAtCell(5, 5)
eq(doorWarp and doorWarp.def.destMap, "REDS_HOUSE_1F", "and it leads inside")
local reds1 = MapLoader.load(Data, "REDS_HOUSE_1F")
eq(reds1:cellTile(2, 7), 0x14, "the exit mat inside is tile $14")
check(reds1:warpAtCell(2, 7) ~= nil, "the mat cell carries a warp entry")
check(not reds1:isWarpTileCell(2, 7),
"but it is neither a door nor a warp-activating tile, so nothing clears")
eq(reds1.heightCells, 8, "the map is 8 cells tall, so the mat row is the edge")
check(reds1:isWarpTileCell(7, 1) and not reds1:isDoorTileCell(7, 1),
"the staircase at (7,1) is a warp tile and not a door tile")
local center = MapLoader.load(Data, "VIRIDIAN_POKECENTER")
eq(center:cellTile(3, 7), 0x1C, "the Poke Center mat is tile $1C")
check(center:warpAtCell(3, 7) ~= nil, "the mat cell carries a warp entry")
check(not center:isWarpTileCell(3, 7),
"POKECENTER lists no warp tiles, so $1C does not clear the flag here")
eq(center.heightCells, 8, "and its mat row is the south edge too")
-- ---- live engine ---------------------------------------------------------
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
-- Boot the overworld somewhere with no fade to pump. takeWarp is stubbed per
-- boot so the assertion is which warp the engine decided to take.
local function bootOn(mapId, x, y, facing)
Input:reset()
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, mapId, x, y, facing or "down")
local ow = Game.stack:top()
ow.player.moving = false
ow.player.turnTimer = 0
ow.taken = nil
ow.takeWarp = function(self, def) self.taken = def end
return ow
end
-- A loaded save standing on the mat can still walk out: enter() re-derives the
-- flag the way MapEntryAfterBattle's IsPlayerStandingOnWarp does.
do
local ow = bootOn("REDS_HOUSE_1F", 2, 7, "down")
check(ow:canCollisionWarp(), "a save loaded on the mat has the flag set")
check(ow:checkEdgeExit("down"), "so DOWN on the mat leaves the house")
eq(ow.taken and ow.taken.destMap, "LAST_MAP", "out to the map we came from")
end
-- The same derivation on a staircase clears it, so a save loaded upstairs
-- cannot be bounced back down by pushing into the east edge (#230).
do
local ow = bootOn("REDS_HOUSE_2F", 7, 1, "down")
check(ow:canCollisionWarp() == false,
"a save loaded on the staircase has the flag clear")
check(ow:checkEdgeExit("right") == false, "pushing east off the edge bonks")
check(ow.taken == nil, "and takes no warp")
end
-- The whole of #378: walk onto the door tile outside, warp in, and the flag the
-- door set is still there on the mat. setMap is the map change startWarpTo
-- performs, and it must leave the flag alone.
do
local ow = bootOn("PALLET_TOWN", 5, 6, "up")
check(ow:canCollisionWarp() == false, "the grass south of the door is inert")
local p = ow.player
p.cellX, p.cellY, p.facing = 5, 5, "up"
p.px, p.py = 5 * 16, 5 * 16
ow:onStepComplete()
check(ow:canCollisionWarp(), "the completed step onto the door sets the flag")
eq(ow.taken and ow.taken.destMap, "REDS_HOUSE_1F", "and takes us inside")
ow.taken = nil
ow:setMap("REDS_HOUSE_1F", 2, 7, "down", { via = "warp" })
ow.warpEntryCell = { x = 2, y = 7 }
check(ow:canCollisionWarp(), "the map change does not clear the flag")
check(ow:checkEdgeExit("down"),
"so the first press of DOWN on the arrival mat exits (#378)")
eq(ow.taken and ow.taken.destMap, "LAST_MAP", "straight back to Pallet Town")
end
-- A Poke Center mat is the same shape with a different tile: its $1C is not in
-- any warp-tile list, so the door outside decides and the exit works on arrival.
do
local ow = bootOn("VIRIDIAN_POKECENTER", 3, 7, "down")
check(ow:canCollisionWarp(), "the Center mat keeps the flag the door set")
check(ow:checkEdgeExit("down"), "DOWN on it walks back out to Viridian")
eq(ow.taken and ow.taken.destMap, "LAST_MAP", "by the LAST_MAP warp")
end
-- The stand-still collision path reads the same flag: north of the staircase
-- cell is solid wall, and bonking it must never fire the stairs.
do
local ow = bootOn("REDS_HOUSE_2F", 7, 1, "down")
Input.state.up = true
Input.pressed = {}
for _ = 1, 30 do
ow.player.turnTimer = 0
ow:handleInput()
ow.player:update()
end
check(ow.taken == nil, "the blocked step into the wall warps nowhere")
eq(ow.player.cellX, 7, "and the player has not moved")
eq(ow.player.cellY, 1, "in either axis")
Input:reset()
end
S.finish()
+4 -1
View File
@@ -315,7 +315,10 @@ def text_metadata(pokered):
for path in paths:
for _, line in util.read_asm(path):
stripped = line.strip()
match = re.match(r"(_\w+)::?\s*$", stripped)
# not every string label carries the far-text underscore
# (SilphCo2FSilphWorkerFPleaseTakeThisText and friends live in
# the script bank), and dropping those lost their strings (#393)
match = re.match(r"(\w+)::?\s*$", stripped)
if match:
current = match.group(1)
labels.append(current)
+5
View File
@@ -21930,6 +21930,10 @@
23,
21570
],
"SilphCo2FSilphWorkerFPleaseTakeThisText": [
32,
25684
],
"SilphCo2F_h": [
22,
23781
@@ -34047,6 +34051,7 @@
]
},
"labels": [
"SilphCo2FSilphWorkerFPleaseTakeThisText",
"_AIBattleUseItemText",
"_AIBattleWithdrawText",
"_AbandonLearningText",
+5
View File
@@ -21907,6 +21907,10 @@
23,
21570
],
"SilphCo2FSilphWorkerFPleaseTakeThisText": [
32,
25684
],
"SilphCo2F_h": [
22,
23781
@@ -34024,6 +34028,7 @@
]
},
"labels": [
"SilphCo2FSilphWorkerFPleaseTakeThisText",
"_AIBattleUseItemText",
"_AIBattleWithdrawText",
"_AbandonLearningText",
+1
View File
@@ -39058,6 +39058,7 @@
]
},
"labels": [
"SilphCo2FSilphWorkerFPleaseTakeThisText",
"_AIBattleUseItemText",
"_AIBattleWithdrawText",
"_AbandonLearningText",