mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
so many bugs i cannot even breathe
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -21930,6 +21930,10 @@
|
||||
23,
|
||||
21570
|
||||
],
|
||||
"SilphCo2FSilphWorkerFPleaseTakeThisText": [
|
||||
32,
|
||||
25684
|
||||
],
|
||||
"SilphCo2F_h": [
|
||||
22,
|
||||
23781
|
||||
@@ -34047,6 +34051,7 @@
|
||||
]
|
||||
},
|
||||
"labels": [
|
||||
"SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
"_AIBattleUseItemText",
|
||||
"_AIBattleWithdrawText",
|
||||
"_AbandonLearningText",
|
||||
|
||||
@@ -21907,6 +21907,10 @@
|
||||
23,
|
||||
21570
|
||||
],
|
||||
"SilphCo2FSilphWorkerFPleaseTakeThisText": [
|
||||
32,
|
||||
25684
|
||||
],
|
||||
"SilphCo2F_h": [
|
||||
22,
|
||||
23781
|
||||
@@ -34024,6 +34028,7 @@
|
||||
]
|
||||
},
|
||||
"labels": [
|
||||
"SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
"_AIBattleUseItemText",
|
||||
"_AIBattleWithdrawText",
|
||||
"_AbandonLearningText",
|
||||
|
||||
@@ -39058,6 +39058,7 @@
|
||||
]
|
||||
},
|
||||
"labels": [
|
||||
"SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
"_AIBattleUseItemText",
|
||||
"_AIBattleWithdrawText",
|
||||
"_AbandonLearningText",
|
||||
|
||||
Reference in New Issue
Block a user