Merge branch 'dev' into feat/switch-nx

Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve
Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path
gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods
(#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L
display chords still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-02 21:36:56 -03:00
154 changed files with 11958 additions and 742 deletions
@@ -0,0 +1,136 @@
-- Manual check that audio stays on the front output pair on interfaces with
-- more than two outputs (#626). OpenAL only spatializes 1-channel Sources,
-- and one left at the default (0,0,0) position sits on the listener and is
-- spread over every output a device has; the fix renders one-shots as
-- 2-channel buffers so they map onto outputs 1+2 like the music already did.
-- Positions come from pokered data/maps/objects/PalletTown.asm (warps at
-- (5,5), (13,5), (12,11), kept clear of) plus data/generated/maps.lua for the
-- walkable/blocked cells. Channel counts a machine can check; the routing
-- only an ear on a multi-output interface can, hence the hand-off.
-- Do NOT set POKEPORT_SPEED: fast-forward scales only the logic clock and
-- desyncs the audio ordering this driver depends on.
-- POKEPORT_DRIVER=tests/drivers/audio_channels_bug626_test.lua POKEPORT_IDENTITY=bug626 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local ChipAudio = require("src.core.ChipAudio")
-- pokered data/maps/objects/PalletTown.asm: the three warps sit at (5,5),
-- (13,5) and (12,11), so (10,8) is open ground clear of all of them and of
-- the two walking NPCs' start cells ((3,8) girl, (11,14) fisher).
local MAP = "PALLET_TOWN"
local STAND = { x = 10, y = 8, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function channels(src)
if not src then return nil end
local ok, n = pcall(function() return src:getChannelCount() end)
return ok and n or nil
end
if not love.audio then
check("love.audio is available", false)
U.log("This run has no audio device, so nothing below can be judged.")
U.log("Rerun on a desktop love build with the interface connected.")
while true do coroutine.yield() end
end
check("love.audio is available", true)
-- a muted save reads exactly like the bug being fixed: silence everywhere
local opts = game.save and game.save.options or {}
check("sfxVol is not zero (" .. tostring(opts.sfxVol or 7) .. ")",
(opts.sfxVol or 7) ~= 0)
check("musicVol is not zero (" .. tostring(opts.musicVol or 7) .. ")",
(opts.musicVol or 7) ~= 0)
-- an unknown key makes Sound.play return nil, which would read as a channel
-- FAIL for the wrong reason
local sfxTable = game.data.audio and game.data.audio.sfx or {}
local cryTable = game.data.audio and game.data.audio.cries or {}
local songTable = game.data.audio and game.data.audio.songs or {}
check("sfx key Press_AB is in this cache", sfxTable.Press_AB ~= nil)
check("sfx key Collision is in this cache", sfxTable.Collision ~= nil)
check("cry PIKACHU is in this cache", cryTable.PIKACHU ~= nil)
check("song Music_PalletTown is in this cache",
songTable.Music_PalletTown ~= nil)
-- the machine-checkable half of the fix: every one-shot is a stereo buffer
local sfx = Sound.play(game.data, "Press_AB")
check("menu beep source is stereo", channels(sfx) == 2)
U.wait(30)
local cry = Sound.playCry(game.data, "PIKACHU")
check("cry source is stereo", channels(cry) == 2)
U.wait(60)
-- the siren is built by hand in ChipAudio, not through ChipSynth, so it is
-- its own path and its own regression risk
local ok, alarm = pcall(ChipAudio.newLowHealthAlarm)
check("low-health siren is stereo", ok and channels(alarm) == 2)
if ok and alarm and alarm.stop then pcall(alarm.stop, alarm) end
-- reach the moment: overworld with the town theme running and a beep fired
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
Music.play(game.data, "Music_PalletTown", true)
U.wait(60)
local ow = game.overworld
check("overworld is up on " .. MAP, ow ~= nil)
-- bump a wall so the collision beep (OverworldController: Sound.play
-- "Collision") sounds at least once before the hand-off. The blocked
-- neighbour is looked up rather than hardcoded, so a map edit degrades to
-- "walked a step" instead of a silent no-op.
local bumped = false
if ow and ow.map then
local dirs = {
{ 0, -1, "up" }, { 0, 1, "down" }, { -1, 0, "left" }, { 1, 0, "right" },
}
for _, d in ipairs(dirs) do
local cx, cy = ow.player.cellX + d[1], ow.player.cellY + d[2]
if not ow.map:isWalkableCell(cx, cy) then
U.hold(game, d[3], 24)
U.wait(20)
bumped = true
U.log("walked into the wall at", cx, cy, "facing", d[3])
break
end
end
if not bumped then
-- open ground on all four sides: walk a step instead, the human can
-- find a wall themselves in a moment
U.hold(game, "down", 24)
U.wait(20)
U.log("no wall next to (" .. STAND.x .. ", " .. STAND.y ..
"), took a step instead; bump one by hand")
end
end
check("a collision beep was fired before hand-off", bumped)
U.tap(game, "start")
U.wait(40)
U.tap(game, "b")
U.wait(20)
U.log("Pallet Town is playing and you have the pad; bump walls and open")
U.log("START to fire beeps, and press a cry with the PC or party screens.")
U.log("On a multi-output interface the music and every beep and cry should")
U.log("come out of outputs 1 and 2 only, with 3/4 and 5/6 dead silent.")
U.log("The near miss to listen for is an SFX still faintly there on 5+6")
U.log("under the music: that is the old ambient spread and means a one-shot")
U.log("path was missed, a mod file def or a Yellow PCM clip or the siren.")
U.log("Also listen for a beep arriving on output 3 alone, which would mean")
U.log("something positioned a mono source instead of widening it to stereo.")
U.log("Selecting outputs 5+6 in macOS will not move the game there either")
U.log("way; that is device-level routing, not something the app can ask for.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,274 @@
-- Manual check that Yellow's Pikachu watches the player while Bill walks
-- around them in Bill's House (#455). BillsHouseScript2 (pokeyellow
-- scripts/BillsHouse.asm:58-69) only reaches BillsHousePikachuWatchPlayer
-- (scripts/BillsHouse_2.asm:133-156) while Pikachu still follows, which needs
-- BillsHouseScript0's CheckPikachuStatusCondition to have skipped the entry
-- beat, so this driver arrives with a statused starter. No POKEPORT_SPEED:
-- it scales the logic clock only and desyncs audio against the scripted walk.
-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_watch_bug455_test.lua POKEPORT_IDENTITY=bug455 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local Pokemon = require("src.pokemon.Pokemon")
local PikachuFollower = require("src.world.PikachuFollower")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
-- Red and Blue have no follower and no Pikachu beats at all, so every line
-- below would fail for the wrong reason on the wrong cache
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Re-run with POKEPORT_VERSION=yellow.")
idle()
end
-- pokeyellow data/maps/objects/BillsHouse.asm: BILLSHOUSE_BILL_POKEMON sits
-- at (6,5), the two warps at (2,7) and (3,7). Standing at (6,4) facing down
-- both faces the monster and puts the player on Bill's straight path up, the
-- one branch that runs the walk-around (and so the watch beat).
local MAP = "BILLS_HOUSE"
local MONSTER = "BILLSHOUSE_BILL_POKEMON"
local STAND = { x = 6, y = 4, facing = "down" }
-- the scripted rows and the port entry point the scene hangs off; a renamed
-- key reads on screen as "talking to Bill does nothing special"
local story = dofile("data/scripts/story.lua")
local house = story.BILLS_HOUSE or {}
check("story.BILLS_HOUSE.talk.TEXT_BILLSHOUSE_BILL_POKEMON is present",
type((house.talk or {}).TEXT_BILLSHOUSE_BILL_POKEMON) == "function")
check("story.BILLS_HOUSE.onEnter is present",
type(house.onEnter) == "function")
check("PikachuFollower.onBillWalksAroundPlayer exists",
type(PikachuFollower.onBillWalksAroundPlayer) == "function")
-- both boxes of the scene come out of the cache; a missing key falls back to
-- a hardcoded string in story.lua, which still runs but is not what shipped
local t = game.data.text or {}
check("_BillsHouseBillImNotAPokemonText resolved from the cache",
type(t._BillsHouseBillImNotAPokemonText) == "string")
check("_BillsHouseBillUseSeparationSystemText resolved from the cache",
type(t._BillsHouseBillUseSeparationSystemText) == "string")
-- ShouldPikachuSpawn's inputs (pikachu_follow.asm) plus the one that gates
-- this bug: a starter carrying a status byte, which is what makes
-- CheckPikachuStatusCondition set carry and BillsHouseScript0 stand down
local pika = Pokemon.new(game.data, "PIKACHU", 12)
pika.status = "PAR"
game.save.party = { pika }
game.save.player.name = "bryan"
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_MET_BILL = nil
game.save.flags.EVENT_MET_BILL_2 = nil
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil
game.save.flags.EVENT_GOT_SS_TICKET = nil
game.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if not check("Bill's House loaded", ow ~= nil and ow.map
and ow.map.id == MAP) then
idle()
end
local function follower()
for _, n in ipairs(ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local function monster()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == MONSTER then return n end
end
return nil
end
local npc = follower()
if not check("follower spawned with pikachuFollower set", npc ~= nil) then
U.log("party PIKACHU hp:", tostring(game.save.party[1].hp),
"status:", tostring(game.save.party[1].status),
"EVENT_GOT_STARTER:", tostring(game.save.flags.EVENT_GOT_STARTER))
idle()
end
local mon = monster()
check("the monster object loaded on " .. MAP, mon ~= nil)
if mon then
U.log("monster at", mon.cellX, mon.cellY, "(objects file says 6, 5)")
end
-- the whole point of the arrival: the confused beat must not have run, so
-- Pikachu is still on the follow loop rather than parked beside Bill
check("the entry beat was skipped for a statused starter",
ow.pikachuBillsScene ~= true)
check("no question bubble is up from the entry beat", ow.emote == nil)
local function facingMonster()
if not mon then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == mon
end
if mon and not facingMonster() then
-- a map edit or a mod moved the object: take any free walkable neighbour,
-- preferring the cell above it since that is the facing-down branch.
-- {dx, dy, facing} is the offset from the monster to the stand cell plus
-- the direction that looks back at it, so +1 on y means facing up.
local sides = {
{ 0, -1, "down" }, { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" },
}
for _, s in ipairs(sides) do
local cx, cy = mon.cellX + s[1], mon.cellY + s[2]
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
U.log(("cell (%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(20)
ow = game.overworld
npc, mon = follower(), monster()
break
end
end
end
check("the player is standing against the monster", facingMonster())
check("the player faces down, the branch Bill walks around",
ow.player.facing == "down")
U.log("player at", ow.player.cellX, ow.player.cellY,
"| Pikachu at", npc and npc.cellX, npc and npc.cellY)
-- TryApplyPikachuMovementData keys off where Pikachu stands relative to the
-- player, not off its sprite facing: above the player is WatchPlayer1 (left,
-- down), level and east is WatchPlayer2 (up, left twice, down). Anything
-- else drops the data and no watch route runs at all.
if npc then
local route = npc.cellY < ow.player.cellY and "WatchPlayer1"
or (npc.cellY == ow.player.cellY and npc.cellX > ow.player.cellX)
and "WatchPlayer2" or nil
check("Pikachu stands where one of the two watch tables applies",
route ~= nil)
U.log("geometry picks", route or "neither table")
end
-- a muted run makes the text beeps and Bill's step sfx indistinguishable
-- from silence (Sound.setVolumeLevel reads save.options.sfxVol)
local sfxVol = game.save.options and game.save.options.sfxVol
U.log("save.options.sfxVol:", tostring(sfxVol))
if (sfxVol or 0) == 0 then
U.log("sfx volume is 0, so no beep can be heard whether or not one plays;")
U.log("raise it in OPTIONS before judging the sound half")
end
-- record every cell the follower occupies so a route that runs and is then
-- yanked back by the follow loop can be told from one that holds
local trail = {}
local function sample()
if not npc then return end
local last = trail[#trail]
if not last or last.x ~= npc.cellX or last.y ~= npc.cellY
or last.f ~= npc.facing then
trail[#trail + 1] = { x = npc.cellX, y = npc.cellY, f = npc.facing }
end
end
local function step(n)
for _ = 1, n do
sample()
U.wait(1)
end
sample()
end
sample()
-- talk, take YES (ChoiceBox defaults to it), then close the separation
-- system line: that close is what runs the walk-around branch. Stop
-- pressing the moment the boxes are gone so a stray A cannot re-interact.
-- FAST text: the reveal is per-character (TextBox drawChars reads
-- save.options.textSpeed) and Bill's opener alone is four pages, so at
-- the MEDIUM default the mash budget below runs out mid-box
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
U.tap(game, "a")
step(20)
for _ = 1, 200 do
if game.stack:top() == ow then break end
U.tap(game, "a")
step(8)
end
check("the dialogue finished and the map is running the walk",
game.stack:top() == ow)
-- let both scripted walks play out; Bill's is four legs, Pikachu's two
for _ = 1, 400 do
if #ow.scriptMoves == 0 and not ow.runner:isRunning() then break end
step(1)
end
step(40)
local cells = {}
for _, c in ipairs(trail) do
cells[#cells + 1] = ("(%d,%d %s)"):format(c.x, c.y, tostring(c.f))
end
U.log("Pikachu's cells through the scene:", table.concat(cells, " "))
if npc then
check("Pikachu ended west of the player, clear of Bill's detour",
npc.cellY == ow.player.cellY and npc.cellX < ow.player.cellX)
-- read the facing out of the recorded trail rather than off the sprite:
-- by now an idle glance (pokeyellow Func_fc803) may already have turned
-- its head, and that is vanilla, not the route failing to end on LOOK_RIGHT
local lookedRight = false
for _, c in ipairs(trail) do
if c.y == ow.player.cellY and c.x < ow.player.cellX
and c.f == "right" then
lookedRight = true
end
end
check("PIKAMOVEMENT_LOOK_RIGHT left it facing the player", lookedRight)
check("it moved at all (the watch route ran)", #trail > 1)
end
check("Bill reached the machine (the separator is armed)",
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR == true)
-- hold still for a moment and see whether the follow loop reclaims it;
-- cells only, because the idle glances (PikachuFollower's port of
-- pokeyellow Func_fc803, a random look every ~0x20 frames) legitimately
-- turn its head while it waits
local settled = npc and { x = npc.cellX, y = npc.cellY }
step(90)
if npc and settled then
check("it stayed put once the scene ended",
npc.cellX == settled.x and npc.cellY == settled.y)
end
if U.shot(game, SHOT_DIR .. "/bug455_bills_watch.png") then
U.log("captured", SHOT_DIR .. "/bug455_bills_watch.png")
end
U.log("The scene has already played; the screen is where it left off.")
U.log("Pikachu should have stepped one cell west and one south out of Bill's")
U.log("way, then turned to face right and held there watching you while Bill")
U.log("climbed into the machine. Two near misses to watch for: Pikachu")
U.log("parked beside Bill with a question bubble the moment you walked in,")
U.log("which means the status gate never took and the watch beat cannot")
U.log("happen; or Pikachu walking the route and snapping straight back to")
U.log("your heel, which reads as a twitch instead of a reposition.")
U.log("Input is yours now. Stepping out to Route 25 puts the monster back")
U.log("(Route25ToggleBillsScript), so walk back in to (6,4) facing down and")
U.log("talk to it again to replay the whole beat as often as you like.")
idle()
end
+224
View File
@@ -0,0 +1,224 @@
-- Manual check that launcher actions no longer flash console windows (#606).
-- src/core/HostShell.lua claims one hidden console at boot so every curl /
-- PowerShell child inherits it instead of allocating (and showing) its own.
-- No pokered counterpart: this is desktop launcher plumbing, so the standing
-- spot below is only to give the window something to be (pokered
-- data/maps/objects/PalletTown.asm: (10, 8) is clear of the three
-- object_events and the three warps).
-- POKEPORT_DRIVER=tests/drivers/host_console_bug606_test.lua POKEPORT_IDENTITY=bug606 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Leave POKEPORT_SPEED unset: fast-forward desyncs audio and logic ordering,
-- and it would also step this driver several times per rendered frame while a
-- blocking fetch is in flight.
return function(game)
local U = dofile("tests/drivers/util.lua")
local HostShell = require("src.core.HostShell")
local RomImporter = require("src.import.RomImporter")
local isWin = love.system.getOS() == "Windows"
local passed, failed = 0, 0
local report = {}
-- On Windows the packaged game is love.exe, which has no console, so print()
-- goes nowhere a reporter can read -- exactly the platform this bug lives on.
-- Mirror every line into the save dir and put the count on screen so the
-- PASS/FAIL block is still legible there.
local REPORT = "bug606_report.txt"
pcall(love.filesystem.write, REPORT, "")
local function say(...)
U.log(...)
local parts = {}
for i = 1, select("#", ...) do parts[#parts + 1] = tostring((select(i, ...))) end
local line = table.concat(parts, " ")
report[#report + 1] = line
pcall(love.filesystem.append, REPORT, line .. "\n")
end
local function check(label, ok)
if ok then passed = passed + 1 else failed = failed + 1 end
say(ok and "PASS" or "FAIL", label)
return ok
end
local function fileHas(path, needle)
local ok, body = pcall(love.filesystem.read, path)
return ok and type(body) == "string" and body:find(needle, 1, true) ~= nil
end
check("HostShell exposes hideHostConsole", type(HostShell.hideHostConsole) == "function")
-- Wiring: an unwired helper fixes nothing, and it has to run before the
-- self-updater boot shell, which is the first thing that can shell out.
local okMain, mainSrc = pcall(love.filesystem.read, "main.lua")
local atCall = okMain and type(mainSrc) == "string"
and mainSrc:find("hideHostConsole", 1, true) or nil
local atBoot = okMain and type(mainSrc) == "string"
and mainSrc:find("Boot.run", 1, true) or nil
check("love.load claims the console before anything shells out",
atCall ~= nil and atBoot ~= nil and atCall < atBoot)
local hid = HostShell.hideHostConsole()
check("calling it twice answers the same (memoized, so no second console)",
HostShell.hideHostConsole() == hid)
if not isWin then
check("no console is allocated off Windows", hid == false)
end
say("host:", love.system.getOS(), "-- console claimed:", tostring(hid),
"-- POKEPORT_CONSOLE:", tostring(os.getenv("POKEPORT_CONSOLE")))
-- The ROM picker answers by writing the chosen path to stdout and letting
-- commandOutput read it back through HostShell.popen; if that write ever
-- breaks the launcher just silently refuses every ROM, which is the second
-- way this fix can go wrong.
check("the Windows ROM picker still writes its pick to stdout",
fileHas("src/import/RomImporter.lua", "[Console]::Write($d.FileName)"))
local okFfi, ffi = pcall(require, "ffi")
if isWin and okFfi then
-- HostShell already declared GetConsoleWindow if it got that far; a repeat
-- cdef raises, so each one gets its own pcall.
pcall(ffi.cdef, "void *GetConsoleWindow(void);")
pcall(ffi.cdef, "int IsWindowVisible(void *hWnd);")
local okCall, hwnd = pcall(function() return ffi.C.GetConsoleWindow() end)
if hid then
check("the process owns a console for its children to inherit",
okCall and hwnd ~= nil)
-- The near-miss: AllocConsole worked but ShowWindow did not resolve, so
-- the console exists and sits on screen for the whole session.
local okVis, visible = pcall(function() return ffi.C.IsWindowVisible(hwnd) end)
check("and that console window is hidden", okVis and visible == 0)
else
say("This run did not claim a console: either POKEPORT_CONSOLE=1 is set,")
say("or it was started from a terminal (lovec.exe, what scripts\\run.ps1")
say("prefers), which already had one. Re-run under love.exe to judge #606.")
end
end
-- Fire the three host tools the launcher actually spawns, through the same
-- funnel they use (src/core/HostShell.lua:popen). Each of these was one
-- console flash before the fix.
local probes = isWin and {
{ "curl, the update and mod-index fetch", "curl --version", "curl" },
{ "powershell, the ROM and mod pickers",
'powershell -NoProfile -Command "[Console]::Write(\'pokeport-ok\')"', "pokeport-ok" },
{ "cmd, the update downloader's start /b", "cmd /c echo pokeport-ok", "pokeport-ok" },
} or {
{ "curl, the update and mod-index fetch", "curl --version", "curl" },
{ "the shell the pickers run under", "echo pokeport-ok", "pokeport-ok" },
}
local function runProbes(quiet)
for _, p in ipairs(probes) do
local out
local pipe = HostShell.popen(p[2])
if pipe then
out = pipe:read("*a")
pipe:close()
end
local ok = type(out) == "string" and out:find(p[3], 1, true) ~= nil
if quiet then
say(ok and "PASS" or "FAIL", p[1] .. " still answers")
else
check(p[1] .. " still answers", ok)
end
U.wait(20) -- space them out so a flash is countable by eye
end
end
runProbes(false)
-- The launcher's boot release check is skipped under POKEPORT_DRIVER
-- (RomImporter's updaterAllowed), so start it by hand: it is a love.thread
-- whose curl calls popped their own consoles too, and the hidden one is
-- process-wide, so the thread is covered without touching check_worker.lua.
local okCheck, Check = pcall(require, "src.update.Check")
if okCheck and Check then
pcall(Check.start)
for _ = 1, 240 do
local st = Check.state()
if st.status ~= "checking" then break end
U.wait(1)
end
local st = Check.state()
check("the update check ran and reached a verdict",
st.status ~= "checking" and st.status ~= "idle")
say("update check says:", st.status, st.error and ("(" .. tostring(st.error) .. ")") or "")
end
-- Prove the window is rendering before the launcher takes the frame over.
local MAP, STAND = "PALLET_TOWN", { x = 10, y = 8, facing = "down" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the ground under us; any free neighbour will do
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.teleport(game, MAP, cx, cy, "down")
U.wait(10)
break
end
end
end
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
check("the window rendered", U.shot(game, SHOT_DIR .. "/bug606_console.png"))
-- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is
-- a scripted run), so bring a real interactive one up and take over the
-- handlers it normally owns. This is the panel the reporter needs: Mods >
-- Find mods adds a repo and installs, and a column that is not imported yet
-- shows Choose ROM.
local imp = RomImporter.new(function() end, { launcher = true })
imp.play = function() U.log("Play is inert here: the game is already booted.") end
if okCheck and Check then imp.Check = Check end -- so the update banner draws
if os.getenv("POKEPORT_PICKER") == "1" then
-- every column offers Choose ROM again, so the picker can be opened even on
-- a machine where all three games are already imported. Cancelling the
-- dialog is enough; only picking a file would re-extract.
imp.forceImport = true
imp.ready = {}
end
love.draw = function()
imp:update(love.timer.getDelta())
imp:draw()
local w, h = love.graphics.getWidth(), love.graphics.getHeight()
love.graphics.setColor(0, 0, 0, 0.72)
love.graphics.rectangle("fill", 0, h - 54, w, 54)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print(("#606 checks: %d passed, %d failed"):format(passed, failed), 10, h - 48)
love.graphics.print("F9 runs curl / powershell / the update check again", 10, h - 34)
love.graphics.print("full log: " .. love.filesystem.getSaveDirectory() .. "/" .. REPORT,
10, h - 20)
end
love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end
love.textinput = function(t) imp:textinput(t) end
-- backspace and enter in the "add an index" field arrive here, not through
-- textinput; f9 is kept for the loop below so the launcher never sees it
love.keypressed = function(key)
if key ~= "f9" then imp:keypressed(key) end
end
say("The launcher on screen is the real one. Open Mods > Find mods, add an")
say("index, install something, and click Choose ROM on a column that is not")
say("imported yet (POKEPORT_PICKER=1 brings that button back on every column).")
say("Right: not one black console window appears or blinks, at any point,")
say("while the file dialog still opens normally and the fetches still land.")
say("Near-miss one: a single console sits there permanently instead of many")
say("flashes, which means it was allocated but ShowWindow never resolved.")
say("Near-miss two: the picker dialog opens, you choose a ROM, and the")
say("launcher just carries on as if you had cancelled -- that is the")
say("PowerShell stdout write, not the console.")
local held = false
while true do
-- keep F9 for the reporter and hand every other key to the launcher
local down = love.keyboard and love.keyboard.isDown("f9")
if down and not held then
say("re-running the host tools; watch the desktop, not the window")
runProbes(true)
if okCheck and Check then pcall(Check.start) end
end
held = down
coroutine.yield()
end
end
@@ -0,0 +1,228 @@
-- Manual check that the save editor's item lists scroll under the wheel (#595).
-- The ADD ITEM picker lists every non-badge id in pokered constants/item_constants.asm,
-- which is far more than one screenful, and typing a query used to be the only way
-- past the first page. This is the editor app, not the game, so there is no
-- POKEPORT_DRIVER hook: this file runs itself, checks the seams, seeds a save with
-- a stocked bag and PC, then opens the real editor on it. Do not set POKEPORT_SPEED.
-- luajit tests/drivers/item_picker_wheel_bug595_test.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
love = require("tests.love_stub")
local Kit = require("Kit")
local App = require("App")
local Ops = require("Ops")
local SaveData = require("src.core.SaveData")
-- unbuffered, or a redirected run would show the editor's own output before
-- the checks that were printed ahead of it
io.stdout:setvbuf("line")
local function log(...) print("[driver]", ...) end
local fails = 0
local function check(label, ok)
if not ok then fails = fails + 1 end
log(ok and "PASS" or "FAIL", label)
return ok
end
-- ------------------------------------------------------------------ Kit seam
-- Kit.scroll is the whole fix; every panel rule below is a consequence of it.
check("Kit.scroll exists", type(Kit.scroll) == "function")
Kit.beginFrame(50, 50, false, -1)
check("a notch inside a list body advances three rows",
Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 3)
check("and is consumed, so a second list cannot eat the same notch",
Kit.wheelY == 0)
Kit.endFrame()
Kit.beginFrame(50, 50, false, -1)
check("the last page is the floor",
Kit.scroll(0, 0, 100, 100, 245, 250, 10) == 240)
Kit.endFrame()
Kit.beginFrame(500, 500, false, -1)
check("a list the pointer is not over ignores the wheel",
Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 0)
Kit.endFrame()
Kit.beginFrame(50, 50, false, -1)
Kit.blockClicks = true
check("the species-picker modal shield stops the wheel like it stops a click",
Kit.scroll(0, 0, 100, 100, 0, 250, 10) == 0)
Kit.blockClicks = false
Kit.endFrame()
-- ------------------------------------------------------------- seeded save
-- A save with one page of items proves nothing: stock the bag to its cap and
-- push a couple of pages into PC storage so both pagers have somewhere to go.
local SEED_DIR = (os.getenv("TMPDIR") or "/tmp/"):gsub("/*$", "/") .. "pokeport-bug595"
local SEED = SEED_DIR .. "/save.lua"
os.execute('mkdir -p "' .. SEED_DIR .. '" 2>/dev/null')
local seedFile = io.open(SEED, "wb")
if seedFile then
seedFile:write(SaveData.encode(SaveData.newGame()))
seedFile:close()
end
check("a scratch save was written to " .. SEED, seedFile ~= nil)
local ok, err = pcall(App.load, SEED, { version = "red" })
check("the editor loads it headless (data/generated present): " .. tostring(err), ok)
local S = ok and App.getState() or nil
if S then
S.tab = "items"
local stocked, pcKinds = 0, 0
S.save.pcItems = S.save.pcItems or {}
for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) then
stocked = stocked + 1
if stocked <= 20 then
S.save.inventory[id] = 3
elseif pcKinds < 30 then
pcKinds = pcKinds + 1
S.save.pcItems[id] = 5
end
end
end
local out = io.open(SEED, "wb")
if out then out:write(SaveData.encode(S.save)) out:close() end
App.load(SEED, { version = "red" })
S = App.getState()
S.tab = "items"
-- The panel is layout over Ops, so its rects are not written down anywhere:
-- read them back off the Kit.scroll calls the draw makes, which is also the
-- only honest way to point the fake mouse at the right list. If a rect goes
-- missing the hand-off below still happens, with the count in the log.
local rects = {}
local realScroll = Kit.scroll
Kit.scroll = function(x, y, w, h, offset, total, perPage)
rects[#rects + 1] = { x = x, y = y, w = w, h = h, total = total }
return realScroll(x, y, w, h, offset, total, perPage)
end
local fields = {}
local realField = Kit.textfield
Kit.textfield = function(id, x, y, w, h, value, placeholder)
fields[id] = { x = x, y = y, w = w, h = h }
return realField(id, x, y, w, h, value, placeholder)
end
local counters = {}
local realRight = Kit.textRight
Kit.textRight = function(name, str, ...)
counters[#counters + 1] = tostring(str)
return realRight(name, str, ...)
end
App.draw()
check("the Items tab hands three lists to Kit.scroll (picker, bag, PC)",
#rects == 3)
local pick, bag, pc = rects[1], rects[2], rects[3]
local function pointAt(r)
love.mouse.getPosition = function() return r.x + r.w / 2, r.y + r.h / 2 end
end
local function counterLine()
for _, str in ipairs(counters) do
if str:match("^%d+%-%d+ of %d+$") then return str end
end
return nil
end
if pick then
check("the picker caption carries a position counter, not a +N more",
counterLine() ~= nil)
log("the picker counter reads", tostring(counterLine()),
"over a catalog of " .. tostring(pick.total) .. " ids")
pointAt(pick)
counters = {}
App.wheelmoved(0, -1)
App.draw()
check("one notch over the picker advances it three rows",
S.itemPickOffset == 3)
check("and neither the bag nor the PC column moved with it",
S.bagOffset == 0 and S.pcOffset == 0)
log("the counter now reads", tostring(counterLine()))
end
if bag then
pointAt(bag)
local before = S.itemPickOffset
App.wheelmoved(0, -1)
App.draw()
check("a notch over the BAG column moves the bag pager's own offset",
S.bagOffset > 0)
check("and leaves the picker where it was", S.itemPickOffset == before)
end
if pc then
pointAt(pc)
local before = S.bagOffset
App.wheelmoved(0, -1)
App.draw()
check("a notch over PC STORAGE moves the PC list", S.pcOffset > 0)
check("and leaves the bag where it was", S.bagOffset == before)
end
-- Typing has to rewind the view: a query that matches ids near the top of a
-- catalog the view had already scrolled past would otherwise show an empty
-- body over a non-zero offset, which reads exactly like "no item matches".
local field = fields["item-query"]
if pick and field then
pointAt(pick)
for _ = 1, 12 do
App.wheelmoved(0, -1)
App.draw()
end
local deep = S.itemPickOffset
love.mouse.getPosition = function() return field.x + 4, field.y + 4 end
App.mousepressed(field.x + 4, field.y + 4, 1)
App.draw()
App.textinput("P")
App.draw()
check("a keystroke in the search box rewinds the deep-scrolled list to the top",
deep > 0 and S.itemQuery == "P" and S.itemPickOffset == 0)
end
-- The map tab spends the wheel on zoom and must keep it.
S.tab = "items"
local zoom = S.mapZoom
App.wheelmoved(0, -1)
App.draw()
check("a notch outside the Map tab leaves the map camera alone", S.mapZoom == zoom)
S.tab = "map"
App.wheelmoved(0, 1)
check("the Map tab still zooms on the wheel", S.mapZoom > zoom)
Kit.scroll, Kit.textfield, Kit.textRight = realScroll, realField, realRight
App.unload()
end
log(fails == 0 and "all seam checks passed" or (fails .. " seam checks failed"))
-- --------------------------------------------------------------- hand-off
log("Opening the editor on that seeded save. Click the Items tab, put the pointer")
log("over the ADD ITEM list and roll the wheel: rows step three at a time and the")
log("counter on the caption line tracks them, stopping on the last page; clicking a")
log("row still picks it for the two add buttons. Over BAG or PC STORAGE the wheel")
log("moves the same rows the Prev/Next pager does and the pager label keeps up.")
log("The near miss to watch for is both columns jumping on one notch, or the panel")
log("scrolling underneath the species picker while that modal is open on Party.")
local love_bin = os.getenv("LOVE") or "love"
os.execute(love_bin .. ' . --editor --save "' .. SEED .. '"')
log("Editor closed. Reopen it on the same stocked save any time with:")
log(" " .. love_bin .. ' . --editor --save "' .. SEED .. '"')
-- Park rather than exit: the driver is never the thing that ends the session,
-- so the terminal stays put until the reader Ctrl-Cs it.
while true do
os.execute("sleep 3600")
end
@@ -0,0 +1,226 @@
-- Manual check for the launcher's Enable all / Disable all chips (#647).
-- POKEPORT_DRIVER skips the launcher (main.lua boots straight into the game),
-- so this stands a second launcher up on the MODS tab and hands the mouse over.
-- No pokered cite applies: this is launcher chrome, nothing in the ROM-side
-- game is involved, and there is no map position to derive.
-- POKEPORT_DRIVER=tests/drivers/launcher_mods_bulk_bug647_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Do not set POKEPORT_SPEED: it multiplies the act+step loop per rendered
-- frame, so the driver would run several steps between two launcher draws and
-- read rects from a frame that was never on screen (and it desyncs audio too).
return function(game)
local U = dofile("tests/drivers/util.lua")
local RomImporter = require("src.import.RomImporter")
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Left-click the middle of a launcher rect. The button argument is load
-- bearing: the confirm modal's dispatch returns early on anything but 1.
local function click(importer, r)
importer:mousepressed(r.x + r.width / 2, r.y + r.height / 2, 1)
end
-- ---- preconditions, before anything is drawn or written
check("LauncherMods.setAllEnabled is the one-write bulk path",
type(LauncherMods.setAllEnabled) == "function")
check("RomImporter:_setAllMods exists for the chips to call",
type(RomImporter._setAllMods) == "function")
check("a window is up to draw the launcher into",
love.window and love.window.isOpen and love.window.isOpen()
and love.graphics.getWidth() > 0)
-- The panel reads the same options.mods the loader writes, so a read-only or
-- quarantined options file would make every click look like it did nothing.
local options = SaveData.loadOptions()
local savedMods = {}
for id, v in pairs(options.mods or {}) do savedMods[id] = v end
check("options.lua round-trips (the chips persist through it)",
SaveData.saveOptions(options) ~= nil)
local mods = LauncherMods.list()
check("at least two mods are installed to act on (found "
.. #mods .. ")", #mods >= 2)
if #mods < 2 then
U.log("mods/ in the save dir (or the repo's mods/ on a source run) is what")
U.log("gets scanned one level deep; drop two mod folders there and rerun.")
end
local names = {}
for _, m in ipairs(mods) do names[#names + 1] = m.id end
U.log("installed mods:", table.concat(names, ", "))
-- The experimental confirm is half of what #647 has to get right, and none of
-- the bundled example mods are flagged, so plant one rather than let that
-- branch go unseen. Manifest.validate is pure (no filesystem), but the real
-- loader wants the entry chunk to exist if the human later presses Play.
local SEED = "driver_experimental_647"
local seeded = false
local haveExperimental = false
for _, m in ipairs(mods) do
if m.experimental then haveExperimental = true end
end
if not haveExperimental then
love.filesystem.createDirectory("mods")
love.filesystem.createDirectory("mods/" .. SEED)
local manifest = ([[{
"id": "%s",
"name": "Driver Experimental Mod",
"version": "1.0.0",
"entry": "main.lua",
"experimental": true,
"description": "Planted by the #647 driver so the Enable all confirm has something to warn about."
}
]]):format(SEED)
local okM = love.filesystem.write("mods/" .. SEED .. "/manifest.json", manifest)
love.filesystem.write("mods/" .. SEED .. "/main.lua", "return {}\n")
seeded = okM and true or false
check("planted an experimental mod so the confirm can fire", seeded)
U.log("it lives in " .. love.filesystem.getSaveDirectory() .. "/mods/" .. SEED)
U.log("remove it with: rm -rf '" .. love.filesystem.getSaveDirectory()
.. "/mods/" .. SEED .. "'")
mods = LauncherMods.list()
end
-- ---- stand the launcher up and take the callbacks off main.lua
local co = coroutine.running()
local CALLBACKS = {
"update", "draw", "mousepressed", "mousereleased", "mousemoved",
"keypressed", "keyreleased", "textinput", "wheelmoved", "filedropped",
"focus",
}
local prev = {}
for _, name in ipairs(CALLBACKS) do prev[name] = love[name] end
local function restoreHost()
for name, fn in pairs(prev) do love[name] = fn end
end
-- Play hands back to the game main.lua already booted (POKEPORT_VERSION), not
-- to whichever column was clicked: the driver cannot reach main.lua's local
-- bootGame, and only one cache is mounted.
local importer = RomImporter.new(function()
restoreHost()
U.log("launcher closed, the already-booted game takes the window back")
end, { launcher = true })
importer.tab = "mods"
love.update = function(dt)
importer:update(dt)
if coroutine.status(co) == "suspended" then
local ok, err = coroutine.resume(co, game)
if not ok then print("driver error: " .. tostring(err)) end
end
end
love.draw = function() importer:draw() end
love.mousepressed = function(x, y, button) importer:mousepressed(x, y, button) end
love.mousereleased = function() end
love.mousemoved = function() end
love.keypressed = function(key) importer:keypressed(key) end
love.keyreleased = function() end
love.textinput = function(text) importer:textinput(text) end
-- RomImporter.new already chained the previous handler onto love.wheelmoved;
-- point it straight at the launcher so a scroll cannot also reach the game.
love.wheelmoved = function(dx, dy) importer:wheelmoved(dx, dy) end
love.filedropped = function(file) importer:filedropped(file) end
love.focus = function(f) importer:focus(f) end
-- rects are built inside draw(), so every read below needs a rendered frame
-- between the change and the check
U.wait(3)
local ena, dis = importer.modEnableAllRect, importer.modDisableAllRect
local imp = importer.modImportRect
check("Enable all / Disable all have rects on the MODS tab",
ena ~= nil and dis ~= nil)
if ena and dis and imp then
check("both sit left of Import mod .zip",
ena.x + ena.width <= imp.x and dis.x + dis.width <= imp.x)
check("Enable all is left of Disable all and they do not overlap",
ena.x + ena.width <= dis.x)
check("they are on the header row, centred against the import button",
math.abs((ena.y + ena.height / 2) - (imp.y + imp.height / 2)) <= 2)
end
-- Disable all: one write, count to zero, and a notice that names the number.
if dis then
click(importer, dis)
U.wait(2)
local after = LauncherMods.list()
local on = 0
for _, m in ipairs(after) do if m.enabled then on = on + 1 end end
check("Disable all switched every mod off", on == 0)
check("the notice counts what it did (" ..
tostring(importer.modNotice and importer.modNotice.text) .. ")",
importer.modNotice ~= nil
and importer.modNotice.text:match("^Disabled %d+ mods%.$") ~= nil)
end
-- Enable all with an experimental mod in the list must stop at the confirm,
-- and Cancel must leave the list exactly as it was.
if ena then
click(importer, ena)
U.wait(2)
local c = importer._modConfirm
check("Enable all armed the experimental confirm instead of enabling",
c ~= nil and c.kind == "enableAll")
local stillOff = 0
for _, m in ipairs(LauncherMods.list()) do
if not m.enabled then stillOff = stillOff + 1 end
end
check("nothing was enabled while the confirm is up",
stillOff == #LauncherMods.list())
if c and importer._modConfirmNo then
click(importer, importer._modConfirmNo)
U.wait(2)
local on = 0
for _, m in ipairs(LauncherMods.list()) do if m.enabled then on = on + 1 end end
check("Cancel closed the confirm and left the list untouched",
importer._modConfirm == nil and on == 0)
end
end
-- #433 shape: a rect that outlives its panel stays clickable over the next
-- tab. _resetFrameRects has to nil both chips every frame.
importer.tab = "red"
U.wait(3)
check("switching to the RED tab drops both chip rects",
importer.modEnableAllRect == nil and importer.modDisableAllRect == nil)
importer.tab = "mods"
U.wait(3)
-- Narrow column: the chips are dropped, never squeezed onto the count.
local ww, wh, flags = love.window.getMode()
love.window.setMode(480, wh, flags)
U.wait(4)
check("at minimum window width the chips vanish rather than overlap the count",
importer.modEnableAllRect == nil and importer.modDisableAllRect == nil)
love.window.setMode(ww, wh, flags)
U.wait(4)
check("the chips come back when the window is wide again",
importer.modEnableAllRect ~= nil and importer.modDisableAllRect ~= nil)
-- put the enable-state back the way the human found it
local restore = SaveData.loadOptions()
restore.mods = savedMods
SaveData.saveOptions(restore)
importer:_refreshMods()
importer.modNotice = nil
U.wait(2)
U.log("The MODS tab is up with the enable-state back as you left it. Enable all")
U.log("and Disable all are the two chips immediately left of Import mod .zip;")
U.log("clicking one should flip every switch at once, move the \"N of M enabled\"")
U.log("count, and print \"Disabled N mods.\" underneath, with Enable all stopping")
U.log("at an Experimental mods confirm first. Watch for the chips crowding or")
U.log("overlapping that count as you drag the window narrower (they should just")
U.log("disappear), and for a click on empty space in the RED tab, where the chips")
U.log("used to be, doing something anyway. Quit and rerun to check it persisted.")
while true do
coroutine.yield()
end
end
+153
View File
@@ -0,0 +1,153 @@
-- Manual check that pulling a mod index works without curl (#597): every
-- remote fetch now goes through src/core/HostShell.lua, which prefers curl and
-- falls back to love.system.httpDownload (the Android GameActivity bridge).
-- Not a map moment, it lives in the launcher's FIND MODS panel, so this driver
-- runs the same transport calls that panel makes and then leaves F9 as a
-- refetch key; the player position is only there to give the window something
-- to be (pokered data/maps/objects/PalletTown.asm: (10, 8) is clear of the
-- three object_events and of the three warps).
-- POKEPORT_DRIVER=tests/drivers/mod_index_bug597_test.lua POKEPORT_IDENTITY=bug597 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Do not set POKEPORT_SPEED: fast-forward desyncs audio and logic ordering,
-- and it would also step this driver several times per rendered frame while a
-- blocking fetch is in flight.
return function(game)
local U = dofile("tests/drivers/util.lua")
local HostShell = require("src.core.HostShell")
local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Source-text checks, because the failure this fixes is a string a human
-- reads on a device: the old red "curl is not available on this platform"
-- must be gone from both callers, replaced by the transport-neutral wording.
local function fileHas(path, needle)
local ok, body = pcall(love.filesystem.read, path)
return ok and type(body) == "string" and body:find(needle, 1, true) ~= nil
end
check("HostShell exposes the shared transport",
type(HostShell.canFetch) == "function"
and type(HostShell.httpGet) == "function"
and type(HostShell.httpDownload) == "function")
check("ModIndex no longer speaks of curl to the player",
not fileHas("src/mods/ModIndex.lua", "curl is not available")
and fileHas("src/mods/ModIndex.lua", "no network transport"))
check("ModUpdate no longer speaks of curl to the player",
not fileHas("src/mods/ModUpdate.lua", "curl is not available")
and fileHas("src/mods/ModUpdate.lua", "no network transport"))
check("ModUpdate.haveCurl still answers (the panel's old gate)",
type(ModUpdate.haveCurl) == "function")
-- The Android half only reaches a device through a rebuilt liblove/APK, so
-- confirm the vendored tree really carries the bridge before anyone blames
-- the phone for a stale build.
check("GameActivity.httpDownload is in the vendored Android tree",
fileHas("mobile/android/love/src/main/java/org/love2d/android/GameActivity.java",
"public static boolean httpDownload"))
check("liblove registers love.system.httpDownload",
fileHas("mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp",
'{ "httpDownload", w_httpDownload }'))
local haveCurl = HostShell.haveCurl()
check("this machine has a transport (desktop: curl)", HostShell.canFetch())
U.log("transport here:", haveCurl and "curl" or "the Android bridge / none")
-- Sources come from options.modIndexes, i.e. whatever the human added in the
-- launcher. POKEPORT_INDEX_URL is the way to point a fresh identity at a
-- feed without adding it first.
local sources = ModIndex.sources()
local envUrl = os.getenv("POKEPORT_INDEX_URL")
local source = sources[1]
if envUrl and envUrl ~= "" then
source = ModIndex.resolveSource(envUrl) or source
end
check("a mod index is configured", source ~= nil)
if source then U.log("index:", source.label or source.feed) end
local index, fetchErr, meta, seconds
if source then
-- force = true is exactly what the panel's Refresh button does, and both
-- transports block the calling thread, so this frame will be a long one.
local t0 = love.timer.getTime()
index, fetchErr, meta = ModIndex.fetch(source, { force = true })
seconds = love.timer.getTime() - t0
check("the feed fetched and parsed", index ~= nil and fetchErr == nil)
if index then
check("the listing has mods in it", #(index.mods or {}) > 0)
U.log(("%d mods in %.1fs%s"):format(#(index.mods or {}), seconds,
(meta and meta.fromCache) and " (from cache, so the live fetch failed)" or ""))
else
U.log("fetch error:", tostring(fetchErr))
end
end
-- Thumbnails are the second half of the bug: the cards can list while every
-- picture stays blank if the download writes but the rename or size check
-- loses the file.
if index then
local shot
for _, entry in ipairs(index.mods or {}) do
local url = ModIndex.joinUrl(entry._base, entry.thumbnail)
if url then
local path, err = ModIndex.downloadThumbnail(url, entry.id)
local info = path and love.filesystem.getInfo(path)
check("a thumbnail downloaded with bytes in it",
path ~= nil and info ~= nil and (info.size or 0) > 0)
if not path then U.log("thumbnail error:", tostring(err)) end
shot = path
break
end
end
if not shot then U.log("no entry in this index carries a thumbnail") end
end
-- Give the window something to be, and prove it is drawing at all.
local MAP, STAND = "PALLET_TOWN", { x = 10, y = 8, facing = "down" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the grass under us; any free neighbour will do
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.teleport(game, MAP, cx, cy, "down")
U.wait(10)
break
end
end
end
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
check("the window rendered", U.shot(game, SHOT_DIR .. "/bug597_modindex.png"))
U.log("The fetch above already ran; F9 runs it again, timed, so you can")
U.log("watch a Refresh cost a second or two rather than stall. The real")
U.log("check is the launcher: quit this window, run scripts/run.sh, and in")
U.log("Mods > Find mods add an index URL -- cards should fill in within a")
U.log("couple of seconds with their thumbnails drawn, same as before.")
U.log("Near-misses: cards list but every thumbnail stays blank (the .part")
U.log("file never got renamed), or on a phone the message reads \"no network")
U.log("transport on this platform\", which means the APK still has the old")
U.log("liblove and needs a full scripts/build_android.sh, not a repackage.")
local held = false
while true do
local down = love.keyboard and love.keyboard.isDown("f9")
if down and not held and source then
local t0 = love.timer.getTime()
local again, err = ModIndex.fetch(source, { force = true })
local dt = love.timer.getTime() - t0
if again then
U.log(("refetch: %d mods in %.1fs"):format(#(again.mods or {}), dt))
else
U.log(("refetch failed after %.1fs: %s"):format(dt, tostring(err)))
end
end
held = down
coroutine.yield()
end
end
+197
View File
@@ -0,0 +1,197 @@
-- Manual check of the mod manager's PROFILES tab: seeded PROFILE 1, applying a
-- profile back over a staged toggle, and EXPORT.. writing a .g1rmodlist (#593).
-- The manager has no pokered analogue; the stand cell comes from pokered
-- data/maps/objects/PalletTown.asm (warp_event 5, 5 is Red's front door, so the
-- cell below it is the doorstep), with a walkable-neighbour fallback.
-- POKEPORT_DRIVER=tests/drivers/mod_profiles_bug593_test.lua POKEPORT_IDENTITY=bug593 POKEPORT_DEV=1 love .
-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so taps,
-- the notice timer and the confirm blip stop lining up.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local ModProfile = require("src.mods.ModProfile")
local MAP = "PALLET_TOWN"
local STAND = { x = 5, y = 6, facing = "up" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- park somewhere ordinary first: the manager is opened over the overworld in
-- real play (F10, src/core/Game.lua:446) and draws on top of it
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 doorstep: any free neighbour will do,
-- nothing here depends on the exact cell
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(("doorstep (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, "up")
U.wait(10)
break
end
end
end
local opts = (game.save and game.save.options) or {}
local status = (game.mods and game.mods.status and game.mods:status())
or game.modStatus or { available = {} }
-- two installed mods is the floor for this check: with one mod a profile
-- swap cannot be told apart from the mod simply staying on
check("at least two mods installed under mods/", #(status.available or {}) >= 2)
for _, m in ipairs(status.available or {}) do
U.log("installed mod:", m.id, m.enabled and "on" or "off")
end
check("sfxVol is not zero, so the A press blips", (opts.sfxVol or 0) > 0)
-- open the manager the way F10 does (Game:keypressed pushes the same id)
Screens.push(game, "ManagerState")
U.wait(10)
local mgr = game.stack:top()
check("the mod manager is on top of the stack", mgr and mgr.screenId == "ManagerState")
if not (mgr and mgr.screenId == "ManagerState") then
while true do coroutine.yield() end
end
-- right once: MODS -> PROFILES
U.tap(game, "right")
U.wait(6)
local rows = mgr:rowsForScreen()
check("right lands on the PROFILES tab", mgr.tab == 2)
local labels = {}
for i, r in ipairs(rows) do labels[i] = r.label end
U.log("profile rows:", table.concat(labels, " / "))
local first = rows[1]
check("PROFILE 1 was seeded from the setup that was already live",
first and first.profile ~= nil and first.label == "PROFILE 1")
check("it is marked active with the ! gutter glyph",
first and first.glyph == "!" and opts.activeProfile == "PROFILE 1")
-- the near-miss: profileRows grew by EXPORT.. and IMPORT.., so a snapCursor
-- or moveCursor still assuming the old three-row tail parks the cursor on the
-- wrong row and A fires the wrong action
check("the cursor sits on the first profile, not somewhere down the tail",
mgr.cursor == 1 and (mgr:focusedRow() or {}).label == "PROFILE 1")
local tail = {}
for i = #rows - 3, #rows do tail[#tail + 1] = rows[i] and rows[i].label end
check("the tail reads SAVE CURRENT AS.. / EXPORT.. / IMPORT.. / [AD-HOC]",
tail[1] == "SAVE CURRENT AS.." and tail[2] == "EXPORT.."
and tail[3] == "IMPORT.." and (tail[4] or ""):find("AD-HOC", 1, true) ~= nil)
-- stage a mod off on the MODS tab so applying PROFILE 1 has something to undo.
-- Pick one nothing else depends on and that is not experimental, so the toggle
-- commits straight away instead of opening the cascade or confirm overlay.
local target
for _, m in ipairs(status.available or {}) do
if m.enabled and not m.experimental then
local needed = false
for _, other in ipairs(status.available or {}) do
for _, spec in ipairs(other.dependencySpecs or {}) do
if spec.id == m.id then needed = true end
end
end
if not needed then target = m break end
end
end
check("found an enabled mod with no dependents to toggle", target ~= nil)
-- refresh() rebuilds status.available, so hold the id and re-look it up
-- through mgr.byId rather than keeping the manifest table from before
local targetId = target and target.id
local function live()
return targetId and mgr.byId[targetId] or nil
end
if targetId then
U.tap(game, "left")
U.wait(6)
for _ = 1, 40 do
local row = mgr:focusedRow()
if row and row.mod and row.mod.id == targetId then break end
U.tap(game, "down")
U.wait(2)
end
local row = mgr:focusedRow()
check("cursor reached " .. targetId .. " on the MODS tab",
row and row.mod and row.mod.id == targetId)
U.tap(game, "select")
U.wait(6)
check("SELECT staged " .. targetId .. " off",
mgr.overlay == nil and (live() or {}).enabled == false)
U.tap(game, "right")
U.wait(6)
check("back on PROFILES with the cursor on PROFILE 1",
mgr.tab == 2 and mgr.cursor == 1
and (mgr:focusedRow() or {}).label == "PROFILE 1")
U.tap(game, "a")
U.wait(10)
check("applying PROFILE 1 re-staged " .. targetId,
(live() or {}).enabled == true)
U.log("footer notice reads:", tostring(mgr.notice))
check("the footer says PROFILE STAGED", mgr.notice == "PROFILE STAGED")
end
-- walk down to EXPORT.. by taps, so a cursor that skips shows up here.
-- From the top of the list that is every profile row after the first plus
-- SAVE CURRENT AS.., i.e. #profiles + 1 presses.
local profileCount = 0
for _, r in ipairs(mgr:rowsForScreen()) do
if r.profile then profileCount = profileCount + 1 end
end
local wantSteps = profileCount + 1
local steps = 0
for _ = 1, 10 do
local row = mgr:focusedRow()
if row and row.exportProfile then break end
U.tap(game, "down")
U.wait(2)
steps = steps + 1
end
local exportRow = mgr:focusedRow()
check(("EXPORT.. is %d presses below PROFILE 1"):format(wantSteps),
exportRow ~= nil and exportRow.exportProfile == true and steps == wantSteps)
U.tap(game, "a")
U.wait(10)
U.log("footer notice reads:", tostring(mgr.notice))
check("the footer says SAVED PROFILE 1", mgr.notice == "SAVED PROFILE 1")
local rel = ModProfile.fileFor("PROFILE 1")
local info = love.filesystem.getInfo and love.filesystem.getInfo(rel)
check(rel .. " exists in the save dir", info ~= nil)
if info then
local body = love.filesystem.read(rel)
local back = ModProfile.decode(body)
check("the exported file decodes back to PROFILE 1",
back ~= nil and back.name == "PROFILE 1")
U.log("wrote", love.filesystem.getSaveDirectory() .. "/" .. rel,
info.size and (info.size .. " bytes") or "")
end
-- leave the cursor at the top of the list for whoever takes over
for _ = 1, steps do
U.tap(game, "up")
U.wait(2)
end
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.shot(game, SHOT_DIR .. "/bug593_profiles.png")
U.log("captured", SHOT_DIR .. "/bug593_profiles.png")
U.log("The PROFILES tab is open with the cursor back on PROFILE 1, and the")
U.log("steps above already ran once: a mod was switched off, PROFILE 1 put it")
U.log("back, and EXPORT.. wrote the file. Redo it by hand: LEFT for MODS,")
U.log("SELECT to flip a mod, RIGHT, A on PROFILE 1. The row should carry the")
U.log("! glyph and the footer should flash PROFILE STAGED. The failure to")
U.log("watch for is the cursor landing a row off after a tab switch or a")
U.log("delete, so A on what looks like [AD-HOC] fires EXPORT.. instead.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,214 @@
-- Eye/ear check on Prof. Oak rating the Pokedex in his lab (#600): with the
-- Pokedex in hand and 2+ species owned he must lead with the rating branch,
-- not the line he reads while handing the Pokedex over. pokered
-- scripts/OaksLab.asm OaksLabOak1Text + engine/events/pokedex_rating.asm.
-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so
-- the rating jingle stops landing where the text does.
-- POKEPORT_DRIVER=tests/drivers/oak_dex_rating_bug600_test.lua POKEPORT_IDENTITY=bug600 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local oaksLab = require("data.scripts.oaks_lab")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- pokered data/maps/objects/OaksLab.asm: OAKSLAB_OAK1 stands at (5, 2)
-- facing DOWN behind the table, so the desk talk is from the cell below
-- him; the leftover balls sit at (6, 3) / (7, 3) / (8, 3) with the row
-- below them walkable (the rival walks it in OaksLabRivalTakePokeBall).
local MAP = "OAKS_LAB"
local OAK = "OAKSLAB_OAK1"
local STAND = { x = 5, y = 3, facing = "up" }
local BALL_STAND = { x = 8, y = 4 }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function rowAt(rows, i)
return rows and rows[i] or {}
end
-- ------------------------------------------------- script rows are there
local oak1 = oaksLab.talk.TEXT_OAKSLAB_OAK1
local firstRating, ratingLabel, dexRating, firstItemCheck
for i, row in ipairs(oak1 or {}) do
if row[1] == "check_dex_owned" and not firstRating then firstRating = i end
if row[1] == "label" and row[2] == "dex_rating" then ratingLabel = i end
if row[1] == "dex_rating" then dexRating = i end
if row[1] == "check_item" and not firstItemCheck then firstItemCheck = i end
end
check("TEXT_OAKSLAB_OAK1 gates on 2+ owned species",
firstRating ~= nil and rowAt(oak1, firstRating)[2] == 2)
check("it has a dex_rating branch that ends in the rating predef",
ratingLabel ~= nil and dexRating ~= nil and dexRating > ratingLabel)
-- the branch has to be read before the POKe BALL / around-the-world forks
-- or the old wrong line still wins
check("the branch is read before the POKe BALL check",
firstRating ~= nil and firstItemCheck ~= nil
and firstRating < firstItemCheck)
check("Commands has check_dex_owned and dex_rating",
type(Commands.check_dex_owned) == "function"
and type(Commands.dex_rating) == "function")
-- the leftover-ball beat shares this file (#601 remnant, reported on #600)
local ball = oaksLab.talk.TEXT_OAKSLAB_CHARMANDER_POKE_BALL
local lastMon = rowAt(ball, 21)[2]
check("the leftover ball reads the last-mon line",
type(lastMon) == "string" and lastMon:find("last Pok", 1, true) ~= nil)
check("and stops there instead of falling into the pre-pick line",
rowAt(ball, 22)[1] == "jump" and rowAt(ball, 22)[2] == "end"
and rowAt(ball, 23)[2] == "_OaksLabThoseArePokeBallsText")
check("the pre-pick jump still lands on that line",
rowAt(ball, 4)[1] == "jump_if_false" and rowAt(ball, 4)[2] == 23)
-- -------------------------------------------------------- text and audio
local t = game.data.text
local COMING = "_OaksLabOak1HowIsYourPokedexComingText"
local WRONG = "_OaksLabOak1PokemonAroundTheWorldText"
check(COMING .. " resolves", type(t[COMING]) == "string" and t[COMING] ~= "")
check("it is the how-is-it-coming line",
type(t[COMING]) == "string" and t[COMING]:find("DEX", 1, true) ~= nil)
check(WRONG .. " resolves too (the near miss to tell it from)",
type(t[WRONG]) == "string" and t[WRONG] ~= "")
check("_DexCompletionText resolves (the seen/owned tally)",
type(t._DexCompletionText) == "string")
local sfx = game.data.audio and game.data.audio.sfx
check("Pokedex_Rating is in the sfx table",
sfx ~= nil and sfx.Pokedex_Rating ~= nil)
local opts = game.save.options or {}
if (opts.sfxVol or 0) == 0 then
U.log("SFX VOLUME IS 0. The jingle after the rating line is half of this")
U.log("check. Raise SFX VOL in OPTION and rerun.")
else
check("sfx is audible (SFX VOL " .. tostring(opts.sfxVol) .. ")", true)
end
-- ------------------------------------------------------ save at the beat
game.save.party = {
Pokemon.new(game.data, "CHARMANDER", 8),
Pokemon.new(game.data, "PIDGEY", 6),
}
if not game.save.player.name or game.save.player.name == "" then
game.save.player.name = "RED"
end
local flags = game.save.flags
flags.EVENT_GOT_POKEDEX = true
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
flags.EVENT_GOT_STARTER = true
flags.EVENT_CHOSE_CHARMANDER = true
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
flags.EVENT_OAK_GOT_PARCEL = true
-- deliberately left unset: with it the rating fires off the converted-save
-- path instead, and the owned>=2 gate this issue is about goes untested
flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS = nil
-- a driver save skips SaveData.new on some paths, so do not assume the dex
local dex = game.save.pokedex or { seen = {}, owned = {} }
game.save.pokedex = dex
dex.owned = { CHARMANDER = true, PIDGEY = true, RATTATA = true }
dex.seen = { CHARMANDER = true, PIDGEY = true, RATTATA = true,
SPEAROW = true, WEEDLE = true }
local owned = 0
for _ in pairs(dex.owned) do owned = owned + 1 end
check(("Pokedex in hand, %d species owned, no converted-save shortcut")
:format(owned),
flags.EVENT_GOT_POKEDEX == true and owned >= 2
and flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS == nil)
-- the tier line is picked by decade in OverworldState:dexRating
local tier = ("_DexRatingText_Own%dTo%d")
:format(math.floor(owned / 10) * 10, math.floor(owned / 10) * 10 + 9)
check(tier .. " resolves for that count", type(t[tier]) == "string")
-- ----------------------------------------------------- park him at Oak's
-- Oak1 is hidden until the parcel beat shows him (data/scripts/story2.lua
-- swaps OAKSLAB_OAK2 for OAKSLAB_OAK1 via Commands.show_object), and that
-- lives in save.objectToggles, not in the event flags seeded above; the
-- rival is long gone by this beat for the same reason
local toggles = game.save.objectToggles or {}
game.save.objectToggles = toggles
toggles[MAP] = toggles[MAP] or {}
toggles[MAP][OAK] = true
toggles[MAP].OAKSLAB_OAK2 = false
toggles[MAP].OAKSLAB_RIVAL = false
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local function oakNpc(ow)
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.name == OAK then return n end
end
return nil
end
local function facingOak()
local ow = game.overworld
local o = ow and oakNpc(ow)
if not o then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == o
end
local ow = game.overworld
local o = ow and oakNpc(ow)
check("Oak is loaded on " .. MAP, o ~= nil)
if o and not facingOak() then
-- a map edit or a mod moved him: {dx, dy, facing} is the offset from Oak
-- to the stand cell plus the direction that looks back at him
for _, s in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
{ 1, 0, "left" }, { -1, 0, "right" } }) do
local cx, cy = o.cellX + s[1], o.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("desk cell (%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)
break
end
end
end
check("standing below Oak at the desk", facingOak())
-- press A here so the log can tell a missing branch from a press that never
-- reached Oak; on screen those look the same
U.tap(game, "a")
U.wait(8)
local top = game.stack:top()
local isBox = getmetatable(top) == TextBox
check("pressing A opened a text box", isBox)
if isBox then
local shown = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do shown[#shown + 1] = line end
end
local joined = table.concat(shown, " / ")
U.log("box reads:", joined)
check("Oak opens on the rating branch, not the hand-over line",
joined:find("DEX", 1, true) ~= nil
and joined:find("wait for", 1, true) == nil)
end
U.wait(20)
U.shot(game, DIR .. "/bug600_oak_rating.png")
U.log("captured", DIR .. "/bug600_oak_rating.png")
U.log("Oak has been talked to; that box on screen is the start of it. He")
U.log("says \"Good to see you! How is your #DEX coming? Here, let me take a")
U.log("look!\" and runs straight on into the seen/owned tally and PROF.OAK's")
U.log("Rating with no press of yours in between, and the jingle sounds only")
U.log("after the rating line has printed, not over it (#576). If he instead")
U.log("says \"#MON around the world wait for you\" or \"Come see me")
U.log("sometimes\", the branch never fired.")
U.log("The pad is yours: A again re-runs it any time. For the other half,")
U.log(("walk to (%d,%d) and press A up at the leftover ball: Oak turns to")
:format(BALL_STAND.x, BALL_STAND.y))
U.log("face you and says only \"That's PROF.OAK's last Pokemon!\", with no")
U.log("second box about POKe BALLs after it.")
U.log("To see the gate itself, console: game.save.pokedex.owned = { CHARMANDER")
U.log("= true } and talk again -- one species owned is the around-the-world line.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,67 @@
-- Driver: Oak's intro white field must reach the dialogue box.
--
-- The speech fills white over the 160x144 UI canvas, but TextBox docks to the
-- WINDOW's bottom edge (Renderer:setUIAnchor). In a letterboxed window that
-- left black between the bottom of the white and the top of the box.
-- OakSpeech.letterboxWhite fills the voids with the paper shade instead.
--
-- Needs a window that actually letterboxes -- an exact multiple of 160x144
-- has no voids to get wrong -- so it resizes before shooting.
-- POKEPORT_DRIVER=tests/drivers/oak_speech_letterbox_test.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local OakSpeech = require("src.ui.OakSpeech")
local function speechUp()
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == OakSpeech then return s end
end
return nil
end
-- 1000x700 is not a multiple of 160x144, so the UI blits at 4x (640x576)
-- with real bars above/below -- exactly where the seam shows
love.window.setMode(1000, 700)
U.wait(30)
U.wait(5)
U.tap(game, "start") -- skip the intro movie
U.wait(20)
U.tap(game, "a") -- title -> menu
U.wait(20)
U.tap(game, "a") -- NEW GAME
U.wait(30)
local speech
for _ = 1, 600 do
speech = speechUp()
if speech then break end
U.wait(2)
end
if not speech then
U.log("FAIL never reached Oak's speech")
return
end
U.log("Oak speech is up; letterboxWhite =", tostring(OakSpeech.letterboxWhite))
-- page through, shooting a few beats: the pic + box together is the shot
-- that shows whether the white reaches the box
for i = 1, 4 do
for _ = 1, 200 do
local top = game.stack:top()
if top and top ~= speech and top.done then break end
U.tap(game, "a")
U.wait(2)
if not speechUp() then break end
end
if not speechUp() then break end
U.wait(20)
U.shot(game, DIR .. ("/oak_%d.png"):format(i))
U.tap(game, "a")
U.wait(20)
end
U.log("done")
U.wait(30)
end
@@ -0,0 +1,126 @@
-- Hands-on check that a mapped controller sends one press per button (#620).
-- SDL raises love.gamepadpressed AND love.joystickpressed for the same
-- physical button on any pad it has a database entry for; the raw fallback in
-- src/core/Input.lua now stands down for those pads. No pokered behavior is
-- involved, this is platform input plumbing only. Do not set POKEPORT_SPEED:
-- fast-forward reorders the logic clock against real input and audio.
-- POKEPORT_DRIVER=tests/drivers/pad_double_input_bug620_test.lua POKEPORT_IDENTITY=bug620 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local Bag = require("src.inventory.Bag")
-- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5 is the player's
-- house door, so the cell below it is open ground with no object on it.
-- Any walkable cell would do, the bag is what is being looked at.
local MAP = "PALLET_TOWN"
local STAND = { x = 5, y = 6, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- A pad nobody plugged in fails exactly like a pad whose events are being
-- dropped, so name the stick before anything else.
local pads, mapped = {}, 0
if love.joystick then
for _, j in ipairs(love.joystick.getJoysticks()) do
local isPad = j.isGamepad and j:isGamepad()
if isPad then mapped = mapped + 1 end
pads[#pads + 1] = j:getName() .. (isPad and " (SDL-mapped)" or " (raw stick)")
end
end
check("a controller is connected", #pads > 0)
check("SDL maps at least one of them, which is the pad #620 is about",
mapped > 0)
if #pads > 0 then U.log("pads:", table.concat(pads, ", ")) end
-- The gate itself, driven with a stub that answers isGamepad like a mapped
-- pad does. Cross-file contract with main.lua's love.joystick* forwarders:
-- these three entry points must all ignore a mapped pad, because its press
-- already arrived through gamepadpressed this frame. Raw index 9 is SELECT
-- in RAW_BUTTON_BINDINGS, which is the button iOS's MFi packing slid the
-- D-pad onto.
local stub = { isGamepad = function() return true end }
local input = game.input
input:joystickpressed(stub, 9)
input:joystickhat(stub, 1, "l")
input:joystickaxis(stub, 1, -0.9)
local rawSelect = input.sources.select and input.sources.select["joy:9"]
local rawHat = (input.hatDirs[1] or {})[1] ~= nil
local rawStick = input.stickDir ~= nil
check("a mapped pad's raw button index is ignored", not rawSelect)
check("a mapped pad's raw hat is ignored", not rawHat)
check("a mapped pad's raw axis is ignored", not rawStick)
-- leave nothing held behind if one of those did land (nil = raw stick)
if rawSelect then input:joystickreleased(nil, 9) end
if rawHat then input:joystickhat(nil, 1, "c") end
if rawStick then input:joystickaxis(nil, 1, 0) end
-- The launcher half runs before Game exists, so this driver can never reach
-- it; confirm its copy of the gate is still in the file at least.
local src = love.filesystem.read("src/import/RomImporter.lua") or ""
check("the launcher keeps its own copy of the gate",
src:find("isMappedPad", 1, true) ~= nil)
-- The swap beep is the audible half of a wrong press.
local opts = game.save.options or {}
check("sfxVol is not zero (" .. tostring(opts.sfxVol or 7) .. ")",
(opts.sfxVol or 7) ~= 0)
check("renderer is up", game.renderer ~= nil)
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "bryan"
game.save.inventory = game.save.inventory or {}
game.save.inventory.POTION = 3
game.save.inventory.ANTIDOTE = 2
-- one item can be marked for a swap but never traded with anything, which
-- hides half the near-miss
check("the bag holds at least two swappable items",
#Bag.order(game.save) >= 2)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit moved the door: any free walkable neighbour is as good
local sides = { { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }
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(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(10)
break
end
end
end
local list = Screens.push(game, "BagMenu")
U.wait(20)
-- ListMenu:update checks up/down before SELECT in one elseif chain and the
-- bag sets no pageJump, so left is inert there and a stray SELECT in the
-- same step is what actually fires (src/ui/ListMenu.lua, swap_items.asm).
check("the bag list answers SELECT with a swap",
list ~= nil and list.onSelectKey ~= nil)
check("left and right do nothing in this list", list ~= nil and not list.pageJump)
U.tap(game, "down") -- park on the second item so a swap has somewhere to go
U.wait(20)
U.shot(game, "bug620_bag.png")
U.log("The bag is open with the cursor on the second item. Press D-pad left")
U.log("once on the pad: the cursor should stay put and the list should not")
U.log("change at all. If the press also counts as SELECT the item under the")
U.log("cursor gets picked up for a swap and draws differently, and a second")
U.log("left press trades it with its neighbour with a beep. Up and down move")
U.log("normally, B closes the bag. The launcher half of #620 needs a boot")
U.log("with data/generated missing, which no driver can reach.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,137 @@
-- Manual check that a rebind on a real controller actually takes (#632).
-- LOVE raises love.joystickpressed for EVERY stick, gamepads included, so
-- Input's fixed raw table used to re-assert the factory A/B/START/SELECT map
-- underneath a CONTROLS rebind: only a plugged-in pad makes that duplicate
-- event happen at all, which is why no test can stand in for a hand here.
-- Rebinding is port-only (gap C2), so there is no pokered file to cite; the
-- map position below comes from pokered data/maps/objects/PalletTown.asm.
-- POKEPORT_DRIVER=tests/drivers/rebind_joystick_bug632_test.lua POKEPORT_IDENTITY=bug632 POKEPORT_TOUCH=0 love .
-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, and a
-- press that lands between two stepped frames is the very thing being judged.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local BindingsMenu = require("src.ui.BindingsMenu")
local Input = require("src.core.Input")
-- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5 is the player's
-- own front door, so the path cell below it is open ground with nothing to
-- walk into by accident while the pad is being mashed.
local MAP = "PALLET_TOWN"
local STAND = { x = 5, y = 6, facing = "down" }
local ROW_A = 5 -- BindingsMenu's BUTTONS order: up, down, left, right, A
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- A pad that SDL has no database entry for and a pad it recognizes behave
-- differently on purpose, and only the second one is plugged in here.
local sticks = (love.joystick and love.joystick.getJoysticks
and love.joystick.getJoysticks()) or {}
local recognized = nil
for _, js in ipairs(sticks) do
if js.isGamepad and js:isGamepad() then recognized = recognized or js end
U.log("stick:", js:getName(),
(js.isGamepad and js:isGamepad()) and "(SDL gamepad)" or "(raw)")
end
check("a controller is plugged in", #sticks > 0)
check("at least one of them is an SDL-recognized gamepad", recognized ~= nil)
-- The three halves of the fix that can be read off in-process: the raw
-- lookup table applyBindings now builds, the guard that keeps a recognized
-- pad off the raw path, and BindingsMenu's raw capture hooks.
check("Input:applyBindings built the raw joyBindings table",
type(Input.joyBindings) == "table" and Input.joyBindings[1] == "a")
check("BindingsMenu has the raw-stick capture hooks",
type(BindingsMenu.captureJoy) == "function"
and type(BindingsMenu.captureJoyRelease) == "function")
check("Game routes joystick presses", type(game.joystickpressed) == "function")
-- Probe the guard with a stand-in gamepad rather than the live pad, so the
-- log says which of the two paths answered without asking for a press yet.
local fakePad = { isGamepad = function() return true end }
Input:reset()
Input:joystickpressed(fakePad, 1)
check("a recognized pad's duplicate joystick press is ignored",
not Input:isDown("a"))
Input:joystickhat(fakePad, 1, "u")
check("and its duplicate hat event is ignored too", not Input:isDown("up"))
-- a nil joystick is how the raw path is reachable without a raw stick on
-- the desk; tests/input_hold_test.lua drives it the same way
Input:joystickpressed(nil, 1)
check("a stick SDL does not recognize still presses A", Input:isDown("a"))
Input:joystickreleased(nil, 1)
Input:reset()
-- Guide and the stick clicks are in no binding table, which is what makes
-- them dead buttons rather than something that quietly fires START.
check("Guide is unbound", Input.padBindings.guide == nil)
check("the left stick click is unbound", Input.padBindings.leftstick == nil)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
-- a map edit or a mod could put something on the door path; any free
-- neighbour is just as good, the position only has to be somewhere quiet
local ow = game.overworld
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(10)
break
end
end
end
-- Open the screen the same way the START menu does, one push per level, so
-- backing out with B lands on OPTION and then on the overworld exactly as
-- it would have if the human had walked the menus.
Screens.push(game, "OptionsMenu")
U.wait(10)
Screens.push(game, "BindingsMenu")
U.wait(10)
local menu = game.stack:top()
local isBindings = getmetatable(menu) == BindingsMenu
check("the CONTROLS screen is open", isBindings)
if isBindings then
for _ = 1, ROW_A - 1 do
U.tap(game, "down")
U.wait(6)
end
check("the cursor is parked on the A row",
menu.index == ROW_A and menu.items[ROW_A].button.id == "a")
U.log("the A row currently reads", menu.items[ROW_A].right)
check("the B row reads its defaults too",
menu.items[ROW_A + 1] ~= nil
and menu.items[ROW_A + 1].right:find("/", 1, true) ~= nil)
end
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
if U.shot(game, SHOT_DIR .. "/bug632_controls.png") then
check("the window rendered", true)
U.log("captured", SHOT_DIR .. "/bug632_controls.png")
else
check("the window rendered", false)
end
U.log("CONTROLS is open with the cursor on the A row. Press A to arm it,")
U.log("then press and release the pad's B button: the rows should end up")
U.log("reading Z/B and X/A. Back out with B twice and open START with the")
U.log("pad. The physical B should confirm and the physical A should cancel,")
U.log("one action per press. If A both confirms and cancels in the same")
U.log("frame (a menu that opens and shuts, or text jumping two pages), the")
U.log("raw table is still answering alongside the gamepad map. Guide and a")
U.log("left-stick click should do nothing at all out in the overworld.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,275 @@
-- Manual check that FLASH in a dark cave costs one short blink and not a long
-- white hang (#610). pokered engine/menus/start_sub_menus.asm .flash clears
-- wMapPalOffset, prints _FlashLightsAreaText over the party menu and only then
-- runs GBPalWhiteOutWithDelay3, so the cave is already lit when the blink
-- starts; ADVANCED rebakes its atlas there (#383), which must not ride the blink.
-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_flash_blink_bug610_test.lua \
-- POKEPORT_IDENTITY=bug610 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots610 love .
-- No POKEPORT_SPEED: fast-forward desynchronizes audio and logic ordering.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local PartyMenu = require("src.ui.PartyMenu")
local PaletteFX = require("src.render.PaletteFX")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots610"
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
-- wall clock, not os.clock: the bake this bug is about blocks the frame, and
-- CPU time would hide any of it that waits on the GPU
local now = (love and love.timer and love.timer.getTime)
and love.timer.getTime or os.clock
-- one frame, remembering the longest frame seen since the last reset and
-- what was drawn on it: "the hitch happened under the party menu" is the
-- whole claim of the fix, and it is not visible in a total
local worst, worstWhere = 0, "nothing"
local function label()
local top = game.stack:top()
if top == nil then return "nothing" end
if getmetatable(top) == PartyMenu then return "the party menu" end
if top.pages then return "the FLASH message" end
if top == game.overworld then return "the map" end
if type(top.t) == "number" and type(top.frames) == "number" then
return "the white blink"
end
return "a menu"
end
local function step(n)
for _ = 1, n do
local where = label()
local t0 = now()
coroutine.yield()
local dt = now() - t0
if dt > worst then worst, worstWhere = dt, where end
end
end
local function resetWorst() worst, worstWhere = 0, "nothing" end
-- U.tap spends a frame of its own outside step(), and on the last A of the
-- FLASH message that is precisely the frame the work lands on, so press
-- through the tracked step instead of losing it
local function tap(btn)
table.insert(game.input.pressQueue, btn)
step(1)
game.input.state[btn] = false
end
-- the blink is the transition record's own frame count, not a state class:
-- Transition keeps WhiteFlash local, so recognize it by shape (a counter, a
-- length, no text pages) the way the stack itself sees it
local function isBlink(s)
return s ~= nil and s.pages == nil and type(s.t) == "number"
and type(s.frames) == "number"
end
game.save.flags.EVENT_GOT_STARTER = true
-- every list-time badge gate in PartyMenu: FLASH is the subject, STRENGTH is
-- the reference blink (it always prints and always blinks), SURF is there so
-- the Route 10 pond can be tried too
game.save.inventory.BOULDERBADGE = true
game.save.inventory.RAINBOWBADGE = true
game.save.inventory.SOULBADGE = true
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.options.colors = "redpp" -- ADVANCED, the mode whose rebake is dear
PaletteFX.setMode("redpp")
local function giveMon()
local mon = Pokemon.new(game.data, "PIKACHU", 30)
mon.moves = { { id = "FLASH", pp = 5 }, { id = "STRENGTH", pp = 15 },
{ id = "SURF", pp = 15 } }
game.save.party = { mon }
return mon
end
giveMon()
-- ---- what has to be true before any of this can be judged ---------------
check(PaletteFX.gbcPack() ~= nil,
"the ADVANCED colour pack is installed (without it nothing rebakes "
.. "and this driver proves nothing)")
check(PaletteFX.usesGbcPack(), "and the active mode is the baking one")
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 flashText = game.data.text._FlashLightsAreaText
check(type(flashText) == "string" and flashText ~= "",
"_FlashLightsAreaText extracted from the ROM")
if type(flashText) == "string" then
U.log("the message reads:", (flashText:gsub("\n", " / ")))
end
local rec = (game.data.transitions and game.data.transitions.white_flash)
or require("src.render.Transition").STYLES.white_flash
U.log("white_flash is", tostring(rec and rec.frames), "frames")
check(rec and rec.frames and rec.frames <= 12,
"the blink is a handful of frames, so anything longer on screen is "
.. "work riding it")
local vol = game.save.options.sfxVol
if vol == 0 then
U.log("sfxVol is 0: the blink and the menu beeps are silent, turn SFX up",
"in OPTION before judging any of this by ear")
else
U.log("sfxVol =", tostring(vol))
end
-- ---- park just inside the Route 10 entrance -----------------------------
-- pokered data/maps/objects/RockTunnel1F.asm: warp_event 15, 3 is the
-- Route 10 mouth, so (15, 4) is walkable floor one cell inside it, with the
-- warp still one step north for the second half of the check.
local MAP, STAND = "ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }
local WARP = { x = 15, y = 3 }
local function enter(lit)
game.save.flashLit = lit or nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
step(12)
local ow = game.overworld
-- a map edit or a mod can drop a rock on the entrance floor: fall back to
-- any walkable neighbour of the warp that is not the warp itself
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
local sides = { { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }
for _, s in ipairs(sides) do
local cx, cy = WARP.x + s[1], WARP.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
STAND.x, STAND.y = cx, cy
U.teleport(game, MAP, cx, cy, STAND.facing)
step(12)
ow = game.overworld
break
end
end
end
return ow
end
local ow = enter(nil)
check(ow ~= nil and ow.dark == true, "the tunnel is dark on arrival")
check(ow.map:warpAtCell(WARP.x, WARP.y) ~= nil,
("the Route 10 warp is still at (%d, %d), so the walk back out is "
.. "reachable"):format(WARP.x, WARP.y))
local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
U.log("atlas key, dark:", tostring(key))
check(type(key) == "string" and key:find("#dark", 1, true) ~= nil,
"and it is drawing the dark bake, not a shaded lit one")
check(U.shot(game, DIR .. "/bug610_1_dark.png"),
"a frame reached disk, so there is a window to watch")
-- ---- run the moment once, watching where the cost lands -----------------
-- START -> POKeMON -> the mon -> FLASH, looked up by row rather than counted
-- so a mod that inserts a row cannot silently pick something else
tap("start")
step(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
check(row ~= nil, "the START menu lists POKeMON")
for _ = 2, (row or 1) do tap("down"); step(2) end
tap("a")
step(12)
local pm = game.stack:top()
check(getmetatable(pm) == PartyMenu, "POKeMON opens the party menu")
tap("a")
step(8)
local sub
for i, it in ipairs((pm and pm.subItems) or {}) do
if it.action == "flash" then sub = i break end
end
check(sub ~= nil, "FLASH is listed for this PIKACHU on a dark floor")
for _ = 2, (sub or 1) do tap("down"); step(2) end
tap("a")
step(6)
check(game.stack:top() ~= nil and game.stack:top().pages ~= nil,
"FLASH puts its message up")
check(ow.dark == true, "the tunnel is still dark while that message types")
-- drain the text, then watch every frame from the last A to the map coming
-- back: this is the window the bug filled with a white hang
resetWorst()
local blinkStart, blinkEnd, litAtBlink, keyAtBlink
for _ = 1, 400 do
local top = game.stack:top()
if top and top.pages then
tap("a")
step(3)
elseif isBlink(top) then
if not blinkStart then
blinkStart = now()
litAtBlink = (ow.dark == false)
keyAtBlink = ow.map.renderer and ow.map.renderer.gbcAtlasKey
end
step(1)
else
if blinkStart and not blinkEnd then blinkEnd = now() end
break
end
end
step(20)
check(blinkStart ~= nil, "the blink ran")
-- the fix itself, stated as an invariant: OverworldState:setDark drops every
-- resident map and rebakes this one, and that has to be finished before the
-- first white frame is drawn (start_sub_menus.asm .flash order)
check(litAtBlink == true,
"the cave was already lit on the blink's first frame, not lit by it")
check(type(keyAtBlink) == "string"
and keyAtBlink:find("#dark", 1, true) == nil,
"and the lit atlas was already baked and bound by then")
if blinkStart and blinkEnd then
U.log(("the white was on screen for %.2f s"):format(blinkEnd - blinkStart))
end
U.log(("the longest single frame in the whole sequence was %.2f s, drawn "
.. "over %s"):format(worst, worstWhere))
check(worstWhere ~= "the white blink",
"the expensive frame was not one of the white ones")
check(ow.dark == false, "the tunnel is lit now")
U.shot(game, DIR .. "/bug610_2_lit.png")
-- ---- and it stays lit across the Route 10 round trip --------------------
-- pokered data/maps/objects/Route10.asm: warp_event 8, 17 is the north
-- tunnel mouth, so (8, 18) is the grass one step below it
U.teleport(game, "ROUTE_10", 8, 18, "up")
step(12)
ow = enter(true)
check(ow.dark == false, "coming back in from Route 10 the cave is still lit")
key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
check(type(key) == "string" and key:find("#dark", 1, true) == nil,
"on the lit bake, with no second rebake to blink through")
U.shot(game, DIR .. "/bug610_3_relit.png")
U.log(fails == 0 and "all #610 machine checks passed"
or (fails .. " #610 machine check(s) FAILED, read up"))
U.log("shots in", DIR)
-- ---- hand the pad over, dark, FLASH unused ------------------------------
giveMon()
enter(nil)
U.log("You are back in the dark tunnel in ADVANCED colours with FLASH unused:")
U.log("START, POKeMON, A, FLASH. The message should end over the party list, any")
U.log("hitch should happen with that list still on screen, and then a blink about")
U.log("as short as STRENGTH's on the same menu hands back a lit cave. A white that")
U.log("sits noticeably longer than the STRENGTH one means the rebake is riding the")
U.log("blink again. Then walk up to the ladder, out to Route 10 and back in: lit,")
U.log("no second blink.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,128 @@
-- Hands-on check for #487: walk pacing after a map-connection seam.
-- Parks the player in Viridian City's south exit column (pokered
-- data/maps/objects/ViridianCity.asm, border block $f, south connection to
-- Route 1) so DOWN alone crosses into ROUTE_1, then gives input back.
-- POKEPORT_DRIVER=tests/drivers/route1_seam_pacing_bug487_test.lua POKEPORT_IDENTITY=bug487 POKEPORT_TOUCH=0 love .
-- Never add POKEPORT_SPEED: fast-forward scales the logic clock only, so it
-- reorders audio against logic and hides the very wobble being judged.
return function(game)
local U = dofile("tests/drivers/util.lua")
local FrameCap = require("src.core.FrameCap")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Viridian City is 20x18 blocks == 40x36 cells; the exit path south runs
-- down column cellX 20 to the bottom row cellY 35, where crossConnection
-- (src/world/OverworldController.lua) swaps in ROUTE_1 and calls
-- FixedStep:discardCatchup -- the only caller, which is why #487 showed up
-- at route/city seams and nowhere else. Warps take the Transition path
-- instead, so a Viridian door is the control case.
local MAP, EXIT_X, START_Y = "VIRIDIAN_CITY", 20, 31
local maps = game.data.maps
local vc, r1 = maps and maps[MAP], maps and maps.ROUTE_1
check("VIRIDIAN_CITY and ROUTE_1 are both in data/generated/maps.lua",
vc ~= nil and r1 ~= nil)
check("Viridian's south connection still points at Route 1",
vc ~= nil and vc.connections and vc.connections.south
and vc.connections.south.map == "ROUTE_1")
-- the door the human re-checks afterwards: ViridianCity.asm warp_event
-- 21, 15 -> VIRIDIAN_SCHOOL_HOUSE
local doorOk = false
for _, w in ipairs((vc and vc.warps) or {}) do
if w.x == 21 and w.y == 15 then doorOk = true end
end
check("the school house door at (21, 15) is still a warp", doorOk)
-- Judging pacing needs real frame times, and main.lua's driver branch feeds
-- Game:update a pinned 1/60 so the accumulator never sees display jitter.
-- Under POKEPORT_DRIVER this is expected to read FAIL: the position below
-- is still useful, the verdict has to come from a plain run.
check("the running build feeds FixedStep real frame times",
os.getenv("POKEPORT_DRIVER") == nil)
check("no fast-forward multiplier is set",
(tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1
and (game.save.options.speed or 1) == 1)
check("a window is up to watch (this is a visual call)",
love.window ~= nil and love.window.isOpen and love.window.isOpen())
U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap),
"and vsync is",
(love.window.getVSync and tostring(love.window.getVSync())) or "unknown")
-- Probe copy of the timing module so the reseed can be asserted without
-- disturbing the live loop this driver is running inside. discardCatchup
-- still zeroes accum on the way out; the fix is that the frame absorbing
-- the hitch hands it back mid-step instead of parked on a step boundary.
local probe = loadfile("src/core/FixedStep.lua")()
probe:init(function() end)
probe:discardCatchup()
probe:update(0.25)
check("an absorbed hitch frame leaves the accumulator mid-step",
probe.accum > probe.STEP * 0.25 and probe.accum < probe.STEP * 0.75)
-- a party and the starter flag so the overworld behaves like a real save
game.save.flags.EVENT_GOT_STARTER = true
if #game.save.party == 0 then
local Pokemon = require("src.pokemon.Pokemon")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
end
U.teleport(game, MAP, EXIT_X, START_Y, "down")
local ow = game.overworld
-- a map edit or a mod could block column 20; walk down whichever nearby
-- column is clear all the way to the bottom row instead
local function columnIsClear(x)
for y = START_Y, 35 do
if not ow.map:isWalkableCell(x, y) then return false end
end
return true
end
local col = EXIT_X
if not columnIsClear(col) then
for _, dx in ipairs({ -1, 1, -2, 2, -3, 3 }) do
if columnIsClear(EXIT_X + dx) then col = EXIT_X + dx break end
end
U.log(("column %d is blocked, using column %d"):format(EXIT_X, col))
U.teleport(game, MAP, col, START_Y, "down")
ow = game.overworld
end
check("the exit column is walkable from here to the south edge",
columnIsClear(col))
-- cross it once so the seam is known reachable from this cell, then come
-- back and leave the player a few steps short of it
local crossed = false
for _ = 1, 200 do
table.insert(game.input.pressQueue, "down")
game.input.state.down = true
coroutine.yield()
if ow.map.id == "ROUTE_1" and ow.player.cellY >= 0 then crossed = true break end
end
game.input.state.down = false
U.wait(5)
check("holding DOWN from that cell reaches Route 1", crossed)
U.teleport(game, MAP, col, START_Y, "down")
U.wait(10)
U.log(("Standing in Viridian City at cell (%d, %d), four steps above the"):format(col, START_Y))
U.log("Route 1 seam. Hold DOWN and keep holding it for about ten seconds")
U.log("once you are on Route 1: past the single hitch on the crossing frame")
U.log("the scroll should stay dead even, a pixel a frame, the same as the")
U.log("walk through the city. Broken, the sprite stalls for a frame then")
U.log("jumps two pixels, again and again, and never settles down. The easy")
U.log("misread is grading the crossing frame itself: that one hitch is")
U.log("expected and this change does not touch it, so watch the seconds")
U.log("after. Then step into the school house door at (21, 15) to confirm")
U.log("warps look the way they always did, and run the walk once more with")
U.log("MAX FPS set to 30 in Options, where the wobble also showed up.")
while true do
coroutine.yield()
end
end
+248
View File
@@ -0,0 +1,248 @@
-- Driver: the Safari Zone entrance walk and the safari battle HUD (#540).
-- Paying runs SafariZoneEntranceAutoWalk (pokered scripts/SafariZoneGate.asm),
-- so the two gate steps are charged and the counter reads 500/500; leaving
-- early is a queued script, not a box drawn over a black transition; and the
-- ball count belongs in the battle menu (engine/battle/core.asm:2074-2079).
-- No POKEPORT_SPEED: fast-forward desyncs the audio clock from the walk.
-- POKEPORT_DRIVER=tests/drivers/safari_zone_bug540_test.lua POKEPORT_IDENTITY=bug540 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Commands = require("src.script.Commands")
local BattleState = require("src.battle.BattleState")
-- pokered data/maps/objects/SafariZoneGate.asm: warps 3,0 / 4,0 lead to
-- SAFARI_ZONE_CENTER 1 / 2, and scripts/SafariZoneGate.asm's
-- .PlayerNextToSafariZoneWorker1CoordsArray is (3,2)/(4,2), so walking up
-- the right-hand column from the south warp crosses the join trigger and
-- lands on the warp the auto-walk takes.
local GATE = "SAFARI_ZONE_GATE"
local CENTER = "SAFARI_ZONE_CENTER"
local START = { x = 4, y = 4, facing = "up" }
local TRIGGER = { x = 4, y = 2 }
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
-- ---- machine-checkable preconditions ------------------------------------
-- a renamed text key, a moved warp or a missing encounter table all end as
-- "nothing happened", which is indistinguishable from the bug on screen
local t = game.data.text
local KEYS = {
"_SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText",
"_SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText",
"_SafariZoneGateSafariZoneWorker1GoodLuckText",
"_SafariZoneGateSafariZoneWorker1LeavingEarlyText",
"_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText",
"_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText",
}
local missingText = {}
for _, k in ipairs(KEYS) do
if type(t[k]) ~= "string" or t[k] == "" then missingText[#missingText + 1] = k end
end
check("every gate text key resolves to a string", #missingText == 0)
if #missingText > 0 then U.log(" missing:", table.concat(missingText, ", ")) end
-- the leaving-early script is queued rather than pushed, so every opcode it
-- uses has to exist or the queue runs off the end in silence
local OPS = { "ask", "jump_if_false", "show_text", "set_field",
"move_player", "jump", "label", "warp" }
local missingOps = {}
for _, op in ipairs(OPS) do
if type(Commands[op]) ~= "function" then missingOps[#missingOps + 1] = op end
end
check("the queued leaving-early script's commands all exist", #missingOps == 0)
if #missingOps > 0 then U.log(" missing:", table.concat(missingOps, ", ")) end
local gateDef = game.data.maps[GATE]
local centerDef = game.data.maps[CENTER]
local northWarp
for _, w in ipairs(gateDef and gateDef.warps or {}) do
if w.x == TRIGGER.x and w.y == 0 then northWarp = w end
end
check(GATE .. " has the north warp at (4, 0) into " .. CENTER,
northWarp ~= nil and northWarp.destMap == CENTER)
local dest = centerDef and centerDef.warps and centerDef.warps[2]
check(CENTER .. " warp 2 is the arrival cell",
dest ~= nil and dest.x == 15 and dest.y == 25)
if dest then U.log(" arrival cell:", dest.x, dest.y) end
local enc = game.data.encounters and game.data.encounters[CENTER]
local slots = enc and enc.grass and enc.grass.slots
check(CENTER .. " has a grass encounter table", slots ~= nil and #slots > 0)
-- the auto-walk out of the gate ends on a warp, so the door sfx is part of
-- what the moment is judged on
local sfx = game.data.audio and game.data.audio.sfx
local vol = game.save.options and game.save.options.sfxVol
check("Go_Outside sfx is loaded", sfx ~= nil and sfx.Go_Outside ~= nil)
check(("sfx volume is %s, the door sound is audible"):format(tostring(vol)),
love.audio ~= nil and (vol or 0) > 0)
if (vol or 0) == 0 then
U.log(" raise SFX VOL in OPTION or the walk out will be silent")
end
U.log("BATTLE LAYOUT is currently",
(game.save.options and game.save.options.battleLayout) == "wide"
and "WIDE" or "OG")
-- ---- entry: pay, then watch him walk himself in --------------------------
-- FAST text: the join spiel plus the payment text run five pages, and at
-- the MEDIUM default (TextBox drawChars reads save.options.textSpeed) the
-- mash loop below runs out of taps before the auto-walk is ever queued
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.money = 3000 -- the ¥500 fee, plus change for a few more runs
if #game.save.party == 0 then
game.save.party = {
Pokemon.new(game.data, "NIDORINO", 25),
Pokemon.new(game.data, "PIDGEY", 14),
Pokemon.new(game.data, "SANDSHREW", 18),
}
U.log("party was empty; added three mons so the pokeball row has content")
end
game.save.safari = nil
U.teleport(game, GATE, START.x, START.y, START.facing)
U.wait(15)
local ow = game.overworld
check("the start menu box is gated off in the gate itself",
ow ~= nil and ow.inSafariStepZone ~= nil and not ow:inSafariStepZone())
-- a map edit that blocks the right-hand column would stop the trigger ever
-- firing; fall back to the other trigger cell's column
if ow and ow.map and not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y) then
U.log(("(%d, %d) is not walkable; using the left trigger column instead")
:format(TRIGGER.x, TRIGGER.y))
U.teleport(game, GATE, 3, START.y, START.facing)
U.wait(10)
ow = game.overworld
end
U.log("He is about to pay and walk in on his own; the screen is yours after.")
for _ = 1, 12 do
U.hold(game, "up", 8)
if game.stack:top() ~= game.overworld then break end
end
check("stepping up in front of the worker opened the join prompt",
game.stack:top() ~= game.overworld)
-- mash A through the join text, the YES/NO box (YES is the default index)
-- and the payment text; the auto-walk starts on its own once that closes
for _ = 1, 150 do
U.tap(game, "a")
U.wait(5)
local cur = game.overworld
if cur and cur.map and cur.map.id == CENTER then break end
end
U.wait(45)
ow = game.overworld
local st = game.save.safari
check("the safari game started", st ~= nil)
check("he warped through on his own and is in " .. CENTER,
ow ~= nil and ow.map and ow.map.id == CENTER)
if ow and ow.map and ow.map.id == CENTER then
check(("he arrived at the warp cell (%d, %d)")
:format(ow.player.cellX, ow.player.cellY),
ow.player.cellX == 15 and ow.player.cellY == 25)
end
if st then
check(("the counter reads %d/500 with %d balls")
:format(st.steps or -1, st.balls or -1),
st.steps == 500 and st.balls == 30)
end
check("the start menu box is live now that he is inside",
ow ~= nil and ow.inSafariStepZone and ow:inSafariStepZone())
U.tap(game, "start")
U.wait(20)
if U.shot(game, SHOT_DIR .. "/bug540_start_menu.png") then
U.log("captured", SHOT_DIR .. "/bug540_start_menu.png")
end
U.tap(game, "b")
U.wait(15)
-- ---- the safari battle HUD ----------------------------------------------
local function firstGrassCell(map)
for y = 0, (map.heightCells or 0) - 1 do
for x = 0, (map.widthCells or 0) - 1 do
if map:isGrassCell(x, y) and map:isWalkableCell(x, y) then return x, y end
end
end
end
ow = game.overworld
if ow and ow.map and not ow.map:isGrassCell(ow.player.cellX, ow.player.cellY) then
local gx, gy = firstGrassCell(ow.map)
if gx then
U.log("stepping over to the grass at", gx, gy)
U.teleport(game, CENTER, gx, gy, "up")
U.wait(10)
else
U.log("FAIL no walkable grass cell found on " .. CENTER)
end
end
local function liveBattle()
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == BattleState then return s end
end
return nil
end
local DIRS = { "up", "down", "left", "right" }
local battle
for i = 1, 400 do
U.hold(game, DIRS[(i - 1) % #DIRS + 1], 10)
battle = liveBattle()
if battle then break end
end
check("a safari battle started", battle ~= nil and battle.safari ~= nil)
if battle and battle.enemy and battle.enemy.mon then
U.log(" encounter:", battle.enemy.mon.species, "at level",
battle.enemy.mon.level)
end
if not battle then
U.log("no encounter after 400 steps -- walk into the grass yourself")
end
-- catch the "Wild X appeared!" beat first, then clear it: the count is
-- judged in both, absent over the field and present in the menu
U.wait(60)
if U.shot(game, SHOT_DIR .. "/bug540_battle_intro.png") then
U.log("captured", SHOT_DIR .. "/bug540_battle_intro.png")
end
-- the cry and the slide run on their own clocks, so press until the phase
-- flips rather than guessing a frame count
for _ = 1, 60 do
if battle and battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(10)
end
check("the BALL/BAIT/THROW ROCK/RUN menu is up",
battle ~= nil and battle.phase == "menu")
U.wait(15)
if U.shot(game, SHOT_DIR .. "/bug540_battle_menu.png") then
U.log("captured", SHOT_DIR .. "/bug540_battle_menu.png")
end
-- ---- hand off ------------------------------------------------------------
U.log("Controls are yours. The count belongs inside the menu, reading")
U.log("\"BALLx 30\" on one line with the cursor to its left, and nothing")
U.log("floating over the field above the party pokeball row. Watch for the")
U.log("30 landing a cell left and colliding with the x, or a cell right and")
U.log("leaving a gap before BAIT; toggle BATTLE LAYOUT in OPTION and check")
U.log("WIDE too, where the old 15x4 panel used to sit at tile (23,7).")
U.log("Then RUN, walk back down onto the south warp: \"Leaving early?\"")
U.log("should sit over the drawn gate interior, worker and counter and")
U.log("shelves visible, not a black screen. Answer NO to bounce back in and")
U.log("do it again, or YES for the return-balls text and a walk down to")
U.log("(4,3) below the worker, rather than being left on the warp at (4,0).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,180 @@
-- Manual check on the Viridian School blackboard headings layout (#591).
-- pokered engine/events/hidden_events/school_blackboard.asm .blackboardLoop draws
-- a 12x8 box and places StatusAilmentText1 at hlcoord 1,2 and StatusAilmentText2 at
-- hlcoord 6,2, with ViridianSchoolBlackboardText2 (data/text/text_2.asm, `done`)
-- still on screen under it. Logic half: tests/parity_viridian_school_blackboard_bug503.lua.
-- POKEPORT_DRIVER=tests/drivers/school_blackboard_bug591_test.lua POKEPORT_IDENTITY=bug591 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . (no POKEPORT_SPEED: fast-forward desyncs audio/logic ordering)
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
-- data/scripts/init.lua, not src/script/MapScripts.lua: OverworldController
-- requires it lazily on the first map load, so the flavor registrations are
-- not in place yet when the driver starts
local MapScripts = require("data.scripts.init")
local MAP = "VIRIDIAN_SCHOOL_HOUSE"
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- pokered data/events/hidden_events.asm:164, hidden_events_for
-- VIRIDIAN_SCHOOL_HOUSE: hidden_text_predef 3, 0 is the blackboard on the
-- north wall. That cell is the wall itself, so the stand cell is the
-- walkable one below it, looking up (data/maps/objects/ViridianSchoolHouse.asm
-- puts the two warps at (2,7)/(3,7) and no object in the way).
local BOARD = { x = 3, y = 0 }
local STAND = { x = 3, y = 1, facing = "up" }
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")
-- a renamed extractor label reads on screen as "A did nothing", which is the
-- #503 failure and not the layout question this driver is about
for _, key in ipairs({ "_ViridianSchoolBlackboardText1",
"_ViridianSchoolBlackboardText2", "_ViridianBlackboardSleepText",
"_ViridianBlackboardPoisonText", "_ViridianBlackboardPrlzText",
"_ViridianBlackboardBurnText", "_ViridianBlackboardFrozenText" }) 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 Press_AB beep on every heading pick",
"will be silent; raise SFX VOL in OPTION first")
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
check("map " .. MAP .. " loaded", ow ~= nil and ow.map ~= nil)
check("the blackboard cell is solid, as the wall it hangs on",
ow ~= nil and not ow.map:isWalkableCell(BOARD.x, BOARD.y))
-- a map edit or a mod could move the board: any walkable neighbour will do,
-- {dx, dy, facing} is the offset from the board plus the look back at it
local SIDES = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
local function facingBoard()
local p = game.overworld.player
local fx, fy = p:facingCell()
return fx == BOARD.x and fy == BOARD.y
end
if not facingBoard() then
for _, s in ipairs(SIDES) do
local cx, cy = BOARD.x + s[1], BOARD.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 the stand cell, standing on")
:format(BOARD.x, BOARD.y), cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
check("standing where the blackboard is faced", facingBoard())
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
-- the headings list is the flavor script's own StatusBoard, not a TextBox and
-- not src/ui/Menu.lua (which has no second column), so identify it by .labels
local function board()
local top = game.stack:top()
if top and top.labels and top.selection then return top end
return nil
end
local function reachBoard()
for _ = 1, 60 do
if board() then return true end
U.tap(game, "a")
U.wait(8)
end
return false
end
U.tap(game, "a")
U.wait(30)
local top = game.stack:top()
check("A on the blackboard opened a text box", getmetatable(top) == TextBox)
if getmetatable(top) == TextBox then
U.log("intro box reads:", boxText(top))
check("it is _ViridianSchoolBlackboardText1",
boxText(top):find("blackboard", 1, true) ~= nil)
end
check("the intro clears into the headings list", reachBoard())
local b = board()
if b then
local labels = {}
for i, label in ipairs(b.labels) do labels[i] = (label:gsub("^%s+", "")) end
U.log("list rows:", table.concat(labels, " / "))
check("SLP/PSN/PAR then BRN/FRZ/QUIT, in ViridianBlackboardStatusPointers order",
table.concat(labels, "/") == "SLP/PSN/PAR/BRN/FRZ/QUIT")
-- the whole point of #591: the prompt box is held open underneath rather
-- than dismissed before the list goes up
local under = game.stack.states[#game.stack.states - 1]
check("the prompt box is still on the stack under the list",
getmetatable(under) == TextBox and under.stay ~= nil)
if getmetatable(under) == TextBox then
U.log("prompt under the list reads:", boxText(under))
end
U.shot(game, SHOT_DIR .. "/bug591_blackboard_list.png")
end
if b then
U.tap(game, "right")
U.wait(10)
check("RIGHT keeps the row and lands on BRN (wMenuItemOffset 3)",
b:selection() == 4)
U.shot(game, SHOT_DIR .. "/bug591_right_column.png")
U.tap(game, "down")
U.wait(10)
check("DOWN inside the right column reaches FRZ", b:selection() == 5)
U.shot(game, SHOT_DIR .. "/bug591_right_down.png")
U.tap(game, "a")
U.wait(30)
top = game.stack:top()
check("A on FRZ prints its blurb", getmetatable(top) == TextBox)
if getmetatable(top) == TextBox then
U.log("FRZ blurb reads:", boxText(top))
check("it is _ViridianBlackboardFrozenText, the entry RIGHT+DOWN points at",
boxText(top):find("frozen", 1, true) ~= nil)
end
U.shot(game, SHOT_DIR .. "/bug591_frz_blurb.png")
check("the blurb loops back to the list (jp .blackboardLoop)", reachBoard())
-- wCurrentMenuItem / wMenuItemOffset are never cleared inside the loop, so
-- the cursor comes back where it was left
check("the cursor comes back on FRZ, not reset to SLP",
board() ~= nil and board():selection() == 5)
U.shot(game, SHOT_DIR .. "/bug591_back_to_list.png")
U.log("screenshots in", SHOT_DIR)
end
U.log("The list on screen is the moment: a 12x8 box in the top-left with two")
U.log("columns, SLP/PSN/PAR and BRN/FRZ/QUIT on the same three rows, and the")
U.log("\"Which heading do you want to read?\" box still up at the bottom with no")
U.log("blinking arrow in it. The near-miss to watch for is the prompt vanishing")
U.log("(or blinking) once the list appears, and a cursor sitting on the S of SLP")
U.log("instead of the blank in front of it. RIGHT/LEFT jump columns keeping the")
U.log("row, UP/DOWN stop at the ends without wrapping, and QUIT or B should drop")
U.log("both boxes and let you walk again with no stale dialogue left behind.")
while true do
coroutine.yield()
end
end
+180
View File
@@ -0,0 +1,180 @@
-- Eye/ear check that the Seafoam Islands floor holes drop the PLAYER, not just
-- the boulders (#599). Each floor's script sets wDungeonWarpDestinationMap and
-- calls IsPlayerOnDungeonWarp over its SeafoamNHolesCoords list (pokered
-- scripts/SeafoamIslands1F.asm, B2F.asm); the landing cells come from
-- data/maps/special_warps.asm DungeonWarpData. The data half is asserted in
-- tests/parity_seafoam_holes.lua.
-- POKEPORT_DRIVER=tests/drivers/seafoam_holes_bug599_test.lua POKEPORT_IDENTITY=bug599 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
-- real-time accumulator in Game:update, so it desyncs the ordering being judged.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Sound = require("src.core.Sound")
local Pokemon = require("src.pokemon.Pokemon")
local mapScripts = require("data.scripts.init")
-- Hole cells from pokered scripts/SeafoamIslands1F.asm Seafoam1HolesCoords
-- (17,6)/(24,6) and scripts/SeafoamIslandsB2F.asm Seafoam3HolesCoords
-- (19,6)/(22,6); the landings are the DungeonWarpData rows those floors
-- index. data/generated/maps.lua agrees: those four cells carry CAVERN $22
-- and are walkable, which is why the fall is an onStep and not a collision.
-- The B2F pair is here because its landing on B3F is water: the arrival has
-- to come up already surfing, not standing on the sea.
local FALLS = {
{ from = "SEAFOAM_ISLANDS_1F", hx = 17, hy = 6,
to = "SEAFOAM_ISLANDS_B1F", lx = 18, ly = 7, water = false,
shot = DIR .. "/bug599_1f_to_b1f.png" },
{ from = "SEAFOAM_ISLANDS_B2F", hx = 19, hy = 6,
to = "SEAFOAM_ISLANDS_B3F", lx = 18, ly = 7, water = true,
shot = DIR .. "/bug599_b2f_to_b3f.png" },
}
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the eye and the ear cannot check ------------------------------
-- Silence is half the claim (a hole is not a door), and at SFX 0 a dead
-- audio path and a fixed bug sound exactly alike.
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL SFX volume is 0, so the door chime this fall must NOT play")
U.log(" could not play anyway. Set SFX to 7 in OPTION and rerun.")
end
check(("SFX volume %d"):format(sfxVol), sfxVol > 0)
for _, f in ipairs(FALLS) do
local hooks = mapScripts.get(f.from)
check(f.from .. " has a hole onStep hook", hooks ~= nil and hooks.onStep ~= nil)
end
-- a party so a wild encounter on the B3F water is a battle to back out of
-- rather than a blackout that throws the hand-off away
if not game.save.party or #game.save.party == 0 then
game.save.party = { Pokemon.new(game.data, "LAPRAS", 40),
Pokemon.new(game.data, "SNORLAX", 45) }
end
game.save.moveFlags = game.save.moveFlags or {}
-- Every SFX cue raised during a fall. Go_Inside / Go_Outside is the door
-- chime WarpFound2 .indoorMaps skips for a warp pad or hole, and an ear can
-- miss it under a fade if the fade happens to be busy.
local cues, watching = {}, false
local realPlay = Sound.play
Sound.play = function(data, key, ...)
if watching then cues[#cues + 1] = tostring(key) end
return realPlay(data, key, ...)
end
-- Stand one cell south of the hole and walk north into it. If a map edit
-- moved the floor out from under that cell, fall back to any walkable
-- neighbour that is not itself a hole, and walk from there instead.
local SIDES = { { 0, 1, "up" }, { 0, -1, "down" },
{ 1, 0, "left" }, { -1, 0, "right" } }
local function standFor(f)
local map = game.overworld and game.overworld.map
local sameMap = map and map.id == f.from
for _, s in ipairs(SIDES) do
local cx, cy = f.hx + s[1], f.hy + s[2]
local free = not sameMap
if sameMap then
free = map:isWalkableCell(cx, cy)
and (not map.warpPadOrHoleAt or map:warpPadOrHoleAt(cx, cy) ~= "hole")
end
if free then return cx, cy, s[3] end
end
return f.hx, f.hy + 1, "up"
end
local function runFall(f)
-- teleport onto the floor first so the map is loaded and standFor can read
-- its collision, then park on the approach cell that map actually offers
U.teleport(game, f.from, f.hx, f.hy + 1, "up")
U.wait(10)
local sx, sy, dir = standFor(f)
if sx ~= f.hx or sy ~= f.hy + 1 then
U.log(("approach cell (%d, %d) is blocked, walking in from")
:format(f.hx, f.hy + 1), sx, sy, "facing", dir)
U.teleport(game, f.from, sx, sy, dir)
U.wait(10)
end
local ow = game.overworld
check(("standing on %s (%d, %d) facing %s"):format(f.from, sx, sy, dir),
ow ~= nil and ow.map.id == f.from
and ow.player.cellX == sx and ow.player.cellY == sy)
check(("the hole at (%d, %d) is a walkable CAVERN tile, not a warp event")
:format(f.hx, f.hy),
ow ~= nil and ow.map:isWalkableCell(f.hx, f.hy)
and ow.map:warpPadOrHoleAt(f.hx, f.hy) == "hole")
cues, watching = {}, true
U.hold(game, dir, 18)
-- snapshot the very first frame the destination map is live: on B3F the
-- current starts dragging the player the moment the step completes, so a
-- later read would report where the water took them, not where they landed
local landed
for _ = 1, 300 do
local o = game.overworld
if o and o.map.id == f.to and not landed then
landed = { x = o.player.cellX, y = o.player.cellY,
facing = o.player.facing, surfing = o.player.surfing }
end
if landed and not o.transitioning then break end
U.wait(1)
end
U.wait(20)
watching = false
check("walking into the hole left " .. f.from, landed ~= nil)
if not landed then
U.log(" the player is still on", game.overworld and game.overworld.map.id)
return
end
check(("landed on %s at (%d, %d), wanted (%d, %d)")
:format(f.to, landed.x, landed.y, f.lx, f.ly),
landed.x == f.lx and landed.y == f.ly)
check("facing carried through the fall (" .. tostring(landed.facing) .. ")",
landed.facing == dir)
if f.water then
-- the landing is sea, so setMap's CheckForceBikeOrSurf pass
-- (OverworldState:checkForcedMovement) has to mount SURF on arrival
check("arrived already surfing on the B3F water", landed.surfing == true)
end
local doors = {}
for _, c in ipairs(cues) do
if c == "Go_Inside" or c == "Go_Outside" then doors[#doors + 1] = c end
end
check("no door chime over the fall (heard: " ..
(#cues > 0 and table.concat(cues, ", ") or "nothing") .. ")",
#doors == 0)
U.shot(game, f.shot)
U.log("captured", f.shot)
end
for _, f in ipairs(FALLS) do runFall(f) end
Sound.play = realPlay
-- hand back to real input on the 1F approach cell, one step short of the hole
U.teleport(game, FALLS[1].from, FALLS[1].hx, FALLS[1].hy + 1, "up")
U.wait(10)
U.log("Both falls have already run; press up from where you are standing to")
U.log("do the 1F one again. It should be a plain fade to black and back with")
U.log("the player on B1F at (18,7) still facing up, no door chime, and no")
U.log("step out of a doorway on the far side. The near miss to watch for is")
U.log("landing one cell off, on the boulder's own spot from field.seafoam")
U.log("landsAt, which strands you inside the rock; and dropping onto the B3F")
U.log("sea standing up instead of surfing, which shows as the player on top")
U.log("of the water until the next step. The other three holes are 1F (24,6),")
U.log("B1F (18,6)/(23,6) and B2F (22,6) if you want to walk the cascade down.")
while true do
coroutine.yield()
end
end
+197
View File
@@ -0,0 +1,197 @@
-- Manual check of the per-orientation touch layouts and button resize (#633):
-- one shared positions table meant a drag in landscape moved the portrait pad,
-- and there was no size setting at all. No pokered counterpart: the on-screen
-- pad is a port-only affordance (Xelu CC0 art, src/core/TouchControls.lua), the
-- Game Boy had physical buttons, so the coords here come from the editor itself.
-- POKEPORT_DRIVER=tests/drivers/touch_layout_bug633_test.lua POKEPORT_IDENTITY=bug633 POKEPORT_TOUCH=1 POKEPORT_VERSION=red love .
-- Leave POKEPORT_SPEED unset: this one is dragged by hand, at real time, and
-- fast-forward desyncs the driver's frames from the frames that draw the editor.
return function(game)
local U = dofile("tests/drivers/util.lua")
local TouchControls = require("src.core.TouchControls")
local SafeArea = require("src.core.SafeArea")
local Editor = require("src.ui.TouchControlsEditor")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- The editor lives in launcher mode, before bootGame, and a POKEPORT_DRIVER
-- run boots straight past the launcher (main.lua: POKEPORT_DRIVER counts as a
-- scripted run). So put the window in landscape ourselves, then open the
-- editor and take over the frame + pointer handlers it normally gets from
-- main.lua's TouchEditor branch. We must not set that global: love.update
-- returns early on it and the driver coroutine would stop being resumed.
local ww, wh = love.graphics.getDimensions()
if ww <= wh then
love.window.setMode(1024, 768, { resizable = true })
U.wait(4)
ww, wh = love.graphics.getDimensions()
end
check("the window rendered, and it is wider than tall (landscape)",
ww > 0 and wh > 0 and ww > wh)
-- Pre-#633 options.lua kept one top-level positions table. It has to seed
-- both orientations as separate copies, or the very bug being fixed comes
-- straight back through the upgrade path.
local legacy = TouchControls.normalizeConfig({
positions = { dpad = { x = 0.2, y = 0.8 } },
})
local lp, ll = legacy.layouts.portrait, legacy.layouts.landscape
check("an old single-layout options.lua seeds both orientations",
lp.positions ~= nil and ll.positions ~= nil
and lp.positions.dpad.x == 0.2 and ll.positions.dpad.x == 0.2)
check("and never as one shared table", lp.positions ~= ll.positions)
check("a file with no size setting reads back as 100%",
lp.scale == 1 and ll.scale == 1)
-- The near miss this guards: a scale that grows the art but not the default
-- centers, which parks A and B half off the right edge at 160%. Both the
-- widths and the centers come out of defaultLayout, so compare them.
local base = TouchControls.defaultLayout(ww, wh, 0, 0, 1)
local big = TouchControls.defaultLayout(ww, wh, 0, 0, TouchControls.SCALE_MAX)
local grew, moved, inside = true, true, true
for _, name in ipairs(TouchControls.CONTROLS) do
local b, g = base[name], big[name]
if not (b and g) then grew = false break end
if g.w <= b.w then grew = false end
if g.cx == b.cx and g.cy == b.cy then moved = false end
if g.cx - g.w / 2 < -0.5 or g.cx + g.w / 2 > ww + 0.5
or g.cy - g.w / 2 < -0.5 or g.cy + g.w / 2 > wh + 0.5 then
inside = false
end
end
check("at 160% every control is wider than at 100%", grew)
check("the default centers move with the art, not just the art", moved)
check("and nothing at 160% hangs off the window", inside)
-- The editor owns TouchControls while it is open (load re-inits it and turns
-- preview on), so open it before touching live state.
local reopened = 0
local function openEditor()
Editor.load({ onClose = function()
-- Done persists into options.lua; reopening straight away reloads from
-- that file, which is the whole point of the check the reporter runs.
reopened = reopened + 1
U.log("Done saved the layouts; the editor reopened from options.lua"
.. " (reopen #" .. reopened .. ").")
openEditor()
end })
end
openEditor()
local prevDraw = love.draw
local prevPressed, prevReleased, prevMoved =
love.mousepressed, love.mousereleased, love.mousemoved
local prevKey = love.keypressed
love.draw = function()
Editor.update(love.timer.getDelta())
Editor.draw()
-- main.lua's own love.draw owns the driver frame capture; taking the
-- handler over means taking that with it, or U.shot waits out its spin
-- and reports a file that was never written
if game.capturePath then
local path = game.capturePath
game.capturePath = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then
f:write(imagedata:encode("png"):getString())
f:close()
end
end)
end
end
love.mousepressed = function(x, y, button) Editor.mousepressed(x, y, button or 1) end
love.mousereleased = function(x, y, button) Editor.mousereleased(x, y, button or 1) end
love.mousemoved = function(x, y) Editor.mousemoved(x, y) end
love.keypressed = function(key) Editor.keypressed(key) end
local function restore()
love.draw = prevDraw
love.mousepressed, love.mousereleased = prevPressed, prevReleased
love.mousemoved, love.keypressed = prevMoved, prevKey
end
check("the overlay art loaded (every control has an image)",
TouchControls:ensureImages() and TouchControls.img ~= nil
and TouchControls.img.dpad ~= nil and TouchControls.img.a ~= nil
and TouchControls.img.b ~= nil and TouchControls.img.start ~= nil
and TouchControls.img.select ~= nil)
U.wait(4) -- chrome hit rects exist only after the panel has drawn once
check("the editor is in the landscape bucket",
TouchControls.orientation == "landscape")
local plus, minus = Editor.rects.sizeUp, Editor.rects.sizeDown
if not check("the size card drew its -/+ buttons", plus ~= nil and minus ~= nil) then
U.log("Nothing to click: the size card never drew, so the rest is by hand.")
restore()
while true do coroutine.yield() end
end
-- Drag the d-pad to mid-screen through the editor's own touch path. No
-- yields inside the drag: Editor.update follows the live mouse while a drag
-- is open, and a rendered frame in the middle would snap it to the cursor.
local ox, oy, sw, sh = SafeArea.rect()
local L = TouchControls:layout()
local target = { x = ox + sw * 0.5, y = oy + sh * 0.5 }
-- normalizeConfig seeds BOTH buckets (a pre-#633 file's top-level positions
-- become separate copies), so portrait.positions may already be a table
-- here; the invariant is that the landscape drag does not touch it
local portraitBefore = {}
for name, p in pairs(TouchControls.layouts.portrait.positions or {}) do
portraitBefore[name] = { x = p.x, y = p.y }
end
if L.dpad then
Editor.touchpressed("probe", L.dpad.cx, L.dpad.cy)
Editor.touchmoved("probe", target.x, target.y)
Editor.touchreleased("probe", target.x, target.y)
else
-- fallback if the d-pad zone is missing (a mod replaced the layout):
-- write the same normalized center the drag would have written
TouchControls:setControlCenter("dpad", target.x, target.y)
end
U.wait(2)
local dragged = TouchControls.layouts.landscape.positions
check("the drag landed in the landscape bucket",
dragged ~= nil and dragged.dpad ~= nil)
local portraitAfter = TouchControls.layouts.portrait.positions or {}
local portraitSame = true
for name, p in pairs(portraitAfter) do
local b = portraitBefore[name]
if not b or b.x ~= p.x or b.y ~= p.y then portraitSame = false end
portraitBefore[name] = nil
end
if next(portraitBefore) ~= nil then portraitSame = false end
check("the portrait bucket is still untouched by it", portraitSame)
for _ = 1, 3 do
Editor.mousepressed(plus.x + plus.w / 2, plus.y + plus.h / 2, 1)
U.wait(2)
end
check("three taps on + read back as 130% landscape",
math.abs((TouchControls.layouts.landscape.scale or 1) - 1.3) < 0.001)
check("portrait is still at 100%",
math.abs((TouchControls.layouts.portrait.scale or 1) - 1) < 0.001)
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
if U.shot(game, SHOT_DIR .. "/bug633_landscape.png") then
U.log("captured", SHOT_DIR .. "/bug633_landscape.png")
end
U.log("The editor on screen is live and landscape: the d-pad has already been")
U.log("dragged to mid-screen and + tapped three times, so the card should read")
U.log("\"Button size (Landscape)\" at 130% with the d-pad, A/B and START/SELECT")
U.log("all grown together and all still fully on screen. Drag the window taller")
U.log("than wide and the heading should flip to Portrait with the pad back in")
U.log("the default bottom corners at 100%; move it there and the landscape one")
U.log("must not budge when you drag back. Done saves and reopens the editor, so")
U.log("both rotations should come back exactly as you left them. If the portrait")
U.log("pad shows up already moved or already at 130% the moment you rotate, the")
U.log("two orientations are still sharing one bucket; if A and B slide off the")
U.log("right edge as the size climbs, the size grew the art but not the centers.")
while true do
coroutine.yield()
end
end
+53
View File
@@ -0,0 +1,53 @@
-- Driver: UI LAYOUT centered vs dynamic, same moment shot twice.
--
-- Only visible when the window letterboxes, so it resizes first: at an exact
-- multiple of 160x144 there is nowhere for a docked element to dock TO.
-- Shoots the overworld dialogue box and the START menu, the two pieces the
-- option moves.
-- POKEPORT_DRIVER=tests/drivers/ui_layout_option_test.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
love.window.setMode(1000, 700)
U.wait(30)
-- straight into the overworld, no intro
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
U.wait(40)
local function shootBoth(tag)
-- START menu (Menu anchors "topright")
U.tap(game, "start")
U.wait(45)
U.shot(game, DIR .. ("/uilayout_%s_startmenu.png"):format(tag))
U.tap(game, "b")
U.wait(30)
-- a dialogue box (TextBox anchors "bottom"): read the sign by the door
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, "UI LAYOUT check:\nthis box.", function() end))
for _ = 1, 400 do
local top = game.stack:top()
if top and top.done then break end
U.wait(2)
end
U.wait(30)
U.shot(game, DIR .. ("/uilayout_%s_textbox.png"):format(tag))
game.stack:pop()
U.wait(20)
end
game.save.options.uiLayout = "centered"
U.log("UI LAYOUT = centered (the default)")
U.wait(20)
shootBoth("centered")
game.save.options.uiLayout = "dynamic"
U.log("UI LAYOUT = dynamic")
U.wait(20)
shootBoth("dynamic")
U.log("done")
U.wait(20)
end
@@ -0,0 +1,103 @@
-- Driver: #645 Warden's House YES/NO.
--
-- WardensHouseWardenText prints Gibberish1, calls YesNoChoice, and answers
-- with Gibberish2 on yes / Gibberish3 on no (scripts/WardensHouse.asm). The
-- port printed the question and ended the script, so the box just closed.
--
-- Runs the conversation twice against the live script: once answering YES,
-- once walking the cursor down to NO, shooting the YES/NO box and each reply.
-- POKEPORT_DRIVER=tests/drivers/wardens_house_yesno_bug645_test.lua \
-- POKEPORT_SPEED=2 lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- record what the script actually prints, so the log is checkable on its
-- own and does not depend on reading the shots
local Commands = require("src.script.Commands")
local shown = {}
local origShow = Commands.show_text
-- forward every argument: the 4th is extraOpts, which is how Commands.ask
-- passes its `choice` callback -- drop it and the YES/NO box never appears
Commands.show_text = function(ctx, textId, ...)
shown[#shown + 1] = textId
U.log("text:", textId)
return origShow(ctx, textId, ...)
end
local ChoiceBox = require("src.ui.ChoiceBox")
local function choiceUp()
local top = game.stack:top()
return top and getmetatable(top) == ChoiceBox and top or nil
end
-- mash A until the YES/NO box is up, then hand it back
local function talkUntilChoice()
U.tap(game, "a")
for _ = 1, 600 do
local box = choiceUp()
if box then return box end
U.tap(game, "a")
U.wait(3)
end
return nil
end
local function talkDone()
for _ = 1, 600 do
if game.stack:top() == game.overworld then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- the warden stands at (2,3); face him from the tile below
U.teleport(game, "WARDENS_HOUSE", 2, 4, "up")
-- Lead-in: this is meant to be WATCHED, not just logged. At SPEED=2 the
-- driver runs two iterations per rendered frame, so a wait of N is N/120
-- seconds on screen -- long enough here to find the window before anything
-- happens. Every beat below is held the same way.
U.log("watch now: talking to the WARDEN in 5 seconds")
U.wait(600)
U.shot(game, DIR .. "/warden_0_before.png")
-- ---------------------------------------------------------------- YES
shown = {}
local box = talkUntilChoice()
if not box then
U.log("FAIL no YES/NO box appeared after the gibberish line")
else
U.log("YES/NO box is up, cursor on", box.index == 1 and "YES" or "NO")
U.wait(360) -- hold the box on screen for 3s
U.shot(game, DIR .. "/warden_1_yesno_box.png")
U.tap(game, "a") -- take the default, YES
U.wait(360)
U.shot(game, DIR .. "/warden_2_yes_reply.png")
end
talkDone()
U.log("YES branch printed:", table.concat(shown, ","))
U.log("now the same conversation again, answering NO")
U.wait(360)
-- ----------------------------------------------------------------- NO
shown = {}
box = talkUntilChoice()
if not box then
U.log("FAIL no YES/NO box on the second talk")
else
U.wait(240)
U.tap(game, "down") -- walk the cursor onto NO, on screen
U.wait(360)
U.log("cursor now on", box.index == 1 and "YES" or "NO")
U.shot(game, DIR .. "/warden_3_yesno_on_no.png")
U.tap(game, "a")
U.wait(360)
U.shot(game, DIR .. "/warden_4_no_reply.png")
end
talkDone()
U.log("NO branch printed:", table.concat(shown, ","))
U.wait(360)
Commands.show_text = origShow
end
+158
View File
@@ -0,0 +1,158 @@
-- Manual check that a door warp fades in whole shades, not a dissolve (#607).
-- PlayMapChangeSound tail-calls GBFadeOutToBlack (pokered home/overworld.asm:703),
-- four palette writes held eight frames each (home/fade.asm:43-67), so the veil
-- is a four-step staircase across 32 frames, never a tween. Numbers are pinned
-- in tests/mod_graphics_tests.lua; this puts eyes on the screen.
-- SHOT_DIR=/tmp/shots607 POKEPORT_DRIVER=tests/drivers/warp_fade_bug607_test.lua POKEPORT_IDENTITY=bug607 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so the
-- door SFX and the fade steps stop lining up and the staircase is unjudgeable.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Transition = require("src.render.Transition")
local Sound = require("src.core.Sound")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots607"
-- pokered data/maps/objects/PalletTown.asm: warp_event 5, 5, REDS_HOUSE_1F, 1.
-- The mat itself is the warp cell, so the approach is the walkable cell below
-- it, facing up: one step north fires the door.
local MAP = "PALLET_TOWN"
local DEST = "REDS_HOUSE_1F"
local DOOR = { x = 5, y = 5 }
local STAND = { x = 5, y = 6, facing = "up" }
local failures = 0
local function check(label, ok)
if not ok then failures = failures + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- the door row itself: a renamed destination or a moved warp reads on screen
-- as "the fade never happened", same as the bug did
local def = game.data.maps and game.data.maps[MAP]
local door = nil
for _, w in ipairs(def and def.warps or {}) do
if w.destMap == DEST then door = w end
end
check(MAP .. " carries a warp row to " .. DEST, door ~= nil)
if door then
DOOR.x, DOOR.y = door.x, door.y
STAND.x, STAND.y = door.x, door.y + 1
check(("the door sits at (%d, %d), as in PalletTown.asm"):format(DOOR.x, DOOR.y),
DOOR.x == 5 and DOOR.y == 5)
end
-- the fade is silent without these two: the step SFX is what tells the ear
-- where frame 0 of the 32 was
local sfx = game.data.audio and game.data.audio.sfx or {}
check("Go_Inside and Go_Outside resolve as sfx keys",
sfx.Go_Inside ~= nil and sfx.Go_Outside ~= nil)
local vol = game.save.options and game.save.options.sfxVol or 7
if vol == 0 then
U.log("sfxVol is 0: the door SFX will be SILENT, raise it in OPTION first")
end
check(("sfx volume %d"):format(vol), vol > 0)
-- the record and the staircase it drives, before any of it is on screen
local fade = Transition.new(game)
check("the warp fade runs 32 frames with no fade in",
fade.frames == 32 and (fade.framesIn or 0) == 0)
fade.phase = "out"
local want = { { 0, 0 }, { 7, 0 }, { 8, 1 / 3 }, { 15, 1 / 3 },
{ 16, 2 / 3 }, { 23, 2 / 3 }, { 24, 1 }, { 31, 1 } }
local staircase = true
for _, w in ipairs(want) do
fade.t = w[1]
if fade:alpha() ~= w[2] then staircase = false end
end
check("its alpha holds four shades, eight frames apiece", staircase)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local ow = game.overworld
if not (ow.map:isWalkableCell(STAND.x, STAND.y)) then
-- a map edit or a mod blocked the approach: any walkable neighbour of the
-- mat still steps onto it. {dx, dy, facing} is the offset from the door to
-- the stand cell plus the direction that walks back onto it.
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(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
cx, cy, "facing", s[3])
STAND.x, STAND.y, STAND.facing = cx, cy, s[3]
U.teleport(game, MAP, cx, cy, s[3])
U.wait(10)
break
end
end
end
check("player is standing on the doorstep",
ow.player.cellX == STAND.x and ow.player.cellY == STAND.y)
-- walk in, shooting across the fade on the same cadence door_test.lua uses,
-- so the two runs' frames line up side by side
U.hold(game, STAND.facing, 20)
check("first frame of the transition captured",
U.shot(game, DIR .. "/door_1_mid.png"))
for i = 2, 6 do
U.wait(6)
U.shot(game, DIR .. ("/door_%d_transition.png"):format(i))
end
U.wait(40)
check("the door landed us in " .. DEST, ow.map.id == DEST)
-- walking back out is the same fade, and sampling it frame by frame (no
-- screenshots in this pass, those cost frames) says whether the veil really
-- plateaus or is creeping between the steps
local function liveFade()
local top = game.stack:top()
return getmetatable(top) == Transition and top or nil
end
local seen, order = {}, {}
for _ = 1, 140 do
table.insert(game.input.pressQueue, "down")
game.input.state.down = true
local live = liveFade()
if live then
local a = live:alpha()
if not seen[a] then seen[a] = 0; order[#order + 1] = a end
seen[a] = seen[a] + 1
elseif #order > 0 then
break
end
coroutine.yield()
end
game.input.state.down = false
U.wait(20)
U.shot(game, DIR .. "/door_9_back_outside.png")
local shades = {}
for _, a in ipairs(order) do
shades[#shades + 1] = ("%.2f x%d"):format(a, seen[a])
end
U.log("shades held on the way out:", table.concat(shades, ", "))
check("the exit fade shows four shades and no more", #order == 4)
local held = true
for _, a in ipairs(order) do
if seen[a] < 7 then held = false end
end
check("each shade holds its eight frames", held)
check("we are back outside on " .. MAP, ow.map.id == MAP)
-- hand the doorstep back so this can be re-triggered by hand
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.log(failures == 0 and "checks clean" or (failures .. " check(s) failed above"))
U.log("Red is on his own doorstep facing the door; hold Up to go in, Down to")
U.log("come back out, as often as you like. The town should stay fully lit for")
U.log("about eight frames after the door sound, then drop in three whole shades")
U.log("roughly an eighth of a second apart and sit on solid black for the last")
U.log("eighth before the house cuts in. If it instead dims smoothly, or is")
U.log("already grey the instant you step on the mat and the map swaps under a")
U.log("half-lit veil, that is the dissolve #607 was about, not the fade.")
while true do
coroutine.yield()
end
end
+81
View File
@@ -0,0 +1,81 @@
-- ADVANCED (redpp) must use pokered-gbc's GEN 1 species->palette map.
--
-- pokered-gbc's data/pokemon/palettes.asm carries TWO tables:
--
-- IF GEN_2_GRAPHICS db PAL_BULBASAUR / PAL_SQUIRTLE / ... (per species)
-- ELSE db PAL_GREENMON ; BULBASAUR
-- db PAL_CYANMON ; SQUIRTLE (Gen 1's own)
-- ENDC
--
-- The per-species palettes are authored for GEN 2 sprite art, whose shading
-- puts different regions on different 2bpp shades. This port extracts Gen 1
-- pics from the ROM, so the ELSE branch is the matching one.
--
-- data/palettes_gbc.lua had imported the GEN_2_GRAPHICS table, which pointed
-- every species at colours shaded for art it does not use. Bulbasaur wore
-- PAL_BULBASAUR's red-orange (255,82,49) across 231 pixels of a sprite that
-- has no red on it at all, and Squirtle wore PAL_SQUIRTLE's shell brown on
-- his head instead of blue. It looked least wrong on mons whose two mid
-- tones are close in hue, which is why it survived so long.
--
-- The palette VALUES are untouched -- ADVANCED keeps pokered-gbc's richer
-- colours. Only the species -> name mapping changed.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local pack = require("data.palettes_gbc")
T.check(pack and pack.pokemon, "the ADVANCED pack carries a species map")
local n = 0
for _ in pairs(pack.pokemon) do n = n + 1 end
T.eq(n, 151, "every species is mapped")
-- Gen 1's palette set: the ten MonsterPalettes names plus nothing else. A
-- per-species name here means the GEN_2_GRAPHICS table crept back in.
local GEN1_NAMES = {
MEWMON = true, BLUEMON = true, REDMON = true, CYANMON = true,
PURPLEMON = true, BROWNMON = true, GREENMON = true, PINKMON = true,
YELLOWMON = true, GRAYMON = true,
}
local strays = {}
for species, name in pairs(pack.pokemon) do
if not GEN1_NAMES[name] then strays[#strays + 1] = species .. "->" .. name end
end
table.sort(strays)
T.eq(#strays, 0,
"no species points at a Gen-2-only per-species palette (" ..
table.concat(strays, ", ", 1, math.min(#strays, 6)) .. ")")
-- the four the bug was reported on, plus their lines
local EXPECT = {
BULBASAUR = "GREENMON", IVYSAUR = "GREENMON", VENUSAUR = "GREENMON",
CHARMANDER = "REDMON", CHARMELEON = "REDMON", CHARIZARD = "REDMON",
SQUIRTLE = "CYANMON", WARTORTLE = "CYANMON", BLASTOISE = "CYANMON",
MEW = "MEWMON", MEWTWO = "MEWMON", JYNX = "MEWMON",
LAPRAS = "CYANMON",
}
for species, want in pairs(EXPECT) do
T.eq(pack.pokemon[species], want, species .. " uses " .. want)
end
-- Bulbasaur's palette must contain no red channel dominance in either mid --
-- the concrete symptom that was reported ("he shouldn't have red anywhere").
local green = pack.palettes[pack.pokemon.BULBASAUR]
T.check(green ~= nil, "GREENMON resolves to colours")
for _, i in ipairs({ 2, 3 }) do
local c = green[i]
T.check(c[2] > c[1], ("GREENMON mid %d is green-dominant, not red (%d,%d,%d)")
:format(i - 1, c[1], c[2], c[3]))
end
-- and Squirtle's mids must be blue-dominant
local cyan = pack.palettes[pack.pokemon.SQUIRTLE]
for _, i in ipairs({ 2, 3 }) do
local c = cyan[i]
T.check(c[3] >= c[1], ("CYANMON mid %d is blue-dominant (%d,%d,%d)")
:format(i - 1, c[1], c[2], c[3]))
end
T.finish("advanced palette map")
+82
View File
@@ -0,0 +1,82 @@
-- The script DSL's `ask` command (src/script/Commands.lua) has to bring its
-- YES/NO box up WHILE the question text is still on screen, the same as
-- every hand-written prompt
-- (OverworldController's healer, BattleState's nickname ask, OakSpeech,
-- MoveLearnMenu -- all TextBox opts.choice). DisplayTextID never clears
-- the box itself before a YES/NO row; ManualTextScroll's button wait is
-- what `ask` used to run through, closing the text box first and popping a
-- bare ChoiceBox in afterwards. Commands.ask now rides opts.choice like
-- the rest of the engine instead of chaining show_text + a second push.
-- luajit tests/engine/ask_yesno_overlap.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 Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local Timing = require("src.core.Timing")
local function newGame()
local game = { save = { player = {} }, data = { text = {} } }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
-- data = {} downstream (Sound.play(game.data, ...)) keeps ChoiceBox's
-- un-guarded beep on the headless no-audio path, same as
-- rebind_swap_clear_bug589.lua's fixture.
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game
end
local game = newGame()
game.data.text.TEST_ASK = "Shall we heal\nyour POKEMON?"
local resumeCount = 0
local runner = { yield = function() end,
resume = function() resumeCount = resumeCount + 1 end }
local ctx = { game = game, runner = runner }
Commands.ask(ctx, "TEST_ASK")
eq(#game.stack.states, 1, "ask opens the question as one text box, not a text box plus a bare choice box up front")
local box = game.stack:top()
check(getmetatable(box) == TextBox, "the state on the stack is the text box")
-- type the question out
for _ = 1, 600 do
if box.done then break end
box:update(1 / 60)
end
check(box.done, "the question finished typing")
eq(#game.stack.states, 1, "the text box does not pop itself on its own once typing is done (#589-parity)")
-- the very next update is what used to require an A press to clear the box
-- first; now it pushes the YES/NO box straight over the still-open text
box:update(1 / 60)
eq(#game.stack.states, 2, "a YES/NO box appears while the question text is still up")
check(game.stack.states[1] == box, "the text box is still there, underneath")
local choiceBox = game.stack:top()
check(choiceBox ~= box, "the choice box is a separate state on top of it")
-- answer YES (the default cursor position) and let the 15-frame hold run
-- out, same hold rebind_swap_clear_bug589.lua's settleChoice() drains
game.input.queue = { a = true }
choiceBox:update(1 / 60)
game.input.queue = {}
for _ = 1, Timing.YES_NO_ANSWER do choiceBox:update(1 / 60) end
eq(#game.stack.states, 0, "answering pops both the choice box and the text box")
eq(ctx.lastCheck, true, "YES lands in ctx.lastCheck")
eq(resumeCount, 1, "the script runner resumes exactly once")
T.finish("ask_yesno_overlap")
+81
View File
@@ -0,0 +1,81 @@
-- BATTLE SIZE (save.options.battleFit): "fixed" keeps the classic
-- integer-scaled letterbox, "fill" scales the battle surface to the window so
-- it fills vertically.
--
-- The bit that regresses is not the arithmetic, it is WHERE the flag is read
-- from: a battle opens party menus, the bag and text boxes on top of itself,
-- so reading it off the TOP state would snap the surface back to the fixed
-- scale for as long as one of those is up. It is read off the whole stack,
-- the same way the wide-battle layout is.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game = require("src.core.Game")
local BattleState = require("src.battle.BattleState")
local function battleWith(fit)
return setmetatable({ game = { save = { options = { battleFit = fit } } } },
{ __index = BattleState })
end
T.eq(battleWith("fill"):wantsFillScale(), true, "fill asks for the fill scale")
T.eq(battleWith("fixed"):wantsFillScale(), false, "fixed does not")
T.eq(battleWith(nil):wantsFillScale(), false, "and neither does an old save")
-- a battle with no game/options at all must not throw
T.eq(setmetatable({}, { __index = BattleState }):wantsFillScale(), false,
"a battle with no options is fixed")
-- the stack scan
local function stack(...) return { states = { ... } } end
local overworld = {}
local fillBattle = battleWith("fill")
local fixedBattle = battleWith("fixed")
local partyMenu = {} -- no wantsFillScale at all, like every non-battle state
T.eq(Game.fillScaleInStack(stack(overworld)), false,
"no battle in the stack means no fill")
T.eq(Game.fillScaleInStack(stack(overworld, fixedBattle)), false,
"a fixed battle does not fill")
T.eq(Game.fillScaleInStack(stack(overworld, fillBattle)), true,
"a fill battle does")
T.eq(Game.fillScaleInStack(stack(overworld, fillBattle, partyMenu)), true,
"and keeps filling while a party menu sits on top of it")
T.eq(Game.fillScaleInStack(stack()), false, "an empty stack is safe")
T.eq(Game.fillScaleInStack(nil), false, "and so is no stack at all")
-- ---------------------------------------------------------------- BATTLE BG
-- What fills the voids AROUND the battle. The battle screen itself keeps its
-- white field in every mode -- only the surround changes.
local function battleBg(bg)
return setmetatable({ game = { save = { options = { battleBg = bg } } } },
{ __index = BattleState })
end
T.eq(battleBg("white"):bgMode(), "white", "white is white")
T.eq(battleBg("black"):bgMode(), "black", "black is black")
T.eq(battleBg("world"):bgMode(), "world", "world is world")
T.eq(battleBg(nil):bgMode(), "white", "an old save defaults to white")
T.eq(battleBg("nonsense"):bgMode(), "white", "and so does a bad value")
T.eq(setmetatable({}, { __index = BattleState }):bgMode(), "white",
"a battle with no options is white")
-- "world" is the only mode that drops opacity, because it is the only one
-- that needs the overworld to keep drawing underneath.
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"))),
BattleState.BG_WORLD_DIM, "a world-bg battle asks for its dim")
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("white"))), nil,
"a white-bg battle asks for none")
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("black"))), nil,
"and neither does black")
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"), partyMenu)),
BattleState.BG_WORLD_DIM,
"the dim survives a party menu opened over the battle")
T.eq(Game.worldBgBattleDim(stack(overworld)), nil, "no battle, no dim")
T.eq(Game.worldBgBattleDim(nil), nil, "and no stack is safe")
T.check(BattleState.BG_WORLD_DIM > 0 and BattleState.BG_WORLD_DIM < 1,
"the dim is a fraction, not a full blackout")
T.finish("battle fit option")
+178
View File
@@ -0,0 +1,178 @@
-- BATTLE SIZE "fixed" + BATTLE BG "world": the battle draws as a fixed
-- letterbox over the map, stepped down with the survey zoom. The PKMN and
-- ITEM menus it opens have to stay that size, and its YES/NO prompt has to
-- stay inside it.
--
-- Both broke the same way -- by reading a fact about THIS FRAME instead of
-- about the battle:
--
-- * Renderer:uiScale follows the zoom only while a world is behind the UI,
-- gated on worldActive (beginWorldPass set it this frame). A "world"-bg
-- battle is non-opaque so the map keeps drawing under it -- but PartyMenu
-- and ListMenu ARE opaque, so pushing one makes StateStack:visibleBase
-- skip the map, the world pass never runs, and the menu loses the
-- step-down and blits a whole integer scale larger than the battle it
-- just covered. ("fill" hid this: it overrides the scale outright.)
--
-- * ChoiceBox bottom-anchored unconditionally, which docks it to the
-- WINDOW's bottom edge. That is right only when it is riding the
-- dialogue box below it, which is anchored there too. The battle draws
-- its own text inside the battle canvas, so the switch offer's YES/NO was
-- the only piece of that prompt flung to the window edge -- further off
-- the smaller the fixed battle is drawn.
-- luajit tests/engine/battle_fixed_menu_scale.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Renderer = require("src.render.Renderer")
local Zoom = require("src.render.Zoom")
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
-- ------------------------------------------------- the UI scale holds its size
-- pin the window so the scales below are exact numbers rather than whatever
-- the runner happens to be sized at (this suite also runs under real LOVE)
local g = love.graphics
local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
g.getDimensions = function() return 640, 576 end
g.getPixelDimensions = function() return 640, 576 end
-- 640x576 fits the 160x144 classic surface at exactly 4x
T.eq(Renderer:fitScale(), 4, "the fixture window fits the classic surface at 4x")
local function uiScaleWith(offset, worldActive, hold)
local oldOffset, oldActive, oldHold =
Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold
Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold =
offset, worldActive, hold
local s = Renderer:uiScale()
Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold =
oldOffset, oldActive, oldHold
return s
end
T.eq(uiScaleWith(0, true, false), 4, "unzoomed, the UI is the fit scale")
T.eq(uiScaleWith(-2, true, false), 2,
"zoomed out over a live world pass, the UI steps down with it")
T.eq(uiScaleWith(-2, false, false), 4,
"with no world behind it at all (title screen), the zoom is ignored")
-- the fix: the party menu / bag ended the world pass, but the battle under
-- them is still drawn over the map, so the surface must not grow
T.eq(uiScaleWith(-2, false, true), 2,
"an opaque menu over a world-bg battle keeps the battle's stepped-down scale")
T.eq(uiScaleWith(0, false, true), 4,
"and the hold changes nothing when the player never zoomed out")
g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
-- the hold is the same whole-stack answer the dim already uses, so a menu
-- opened over the battle cannot drop it for a frame (Game:draw wires
-- uiWorldHold to worldBgBattleDim ~= nil; battle_fit_option covers the scan)
local Game = require("src.core.Game")
local BattleState = require("src.battle.BattleState")
-- isOpaque false is what "world" actually does to a live battle (BattleState
-- drops it per-instance at start, so the class default stays opaque for every
-- other battle) -- and it is the whole reason the map draws underneath
local worldBattle = setmetatable(
{ game = { save = { options = { battleBg = "world" } } }, isOpaque = false },
{ __index = BattleState })
T.check(Game.worldBgBattleDim({ states = { {}, worldBattle, {} } }) ~= nil,
"the stack scan the hold reads still finds the battle under an opaque menu")
-- --------------------------------------------- the backdrop holds under menus
-- The battle establishes the surround too, and the same opaque menu used to
-- take that over: it becomes visibleBase, the overworld stops drawing, and
-- the world the battle was composed over collapses to a flat black clear.
local overworld = { isOpaque = true }
local menu = { isOpaque = true } -- PartyMenu / ListMenu
local whiteBattle = setmetatable(
{ game = { save = { options = { battleBg = "white" } } } },
{ __index = BattleState })
local function stack(...) return { states = { ... },
visibleBase = function(self)
for i = #self.states, 1, -1 do
if self.states[i].isOpaque then return i end
end
return 1
end } end
local s = stack(overworld, worldBattle, menu)
T.eq(s:visibleBase(), 3, "the menu is the topmost opaque state, as before")
T.eq(Game.drawBaseInStack(s, s:visibleBase()), 1,
"but the frame still starts at the overworld, so the map keeps drawing")
-- unchanged everywhere else
local s2 = stack(overworld, worldBattle)
T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld")
T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does")
local s3 = stack(overworld, whiteBattle, menu)
T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3,
"a white-bg battle has no map to hold, so nothing moves")
local s4 = stack(overworld, menu)
T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2,
"and a menu outside a battle is untouched")
T.eq(Game.drawBaseInStack(stack(overworld), 1), 1, "a lone base is safe")
T.eq(Game.drawBaseInStack(nil, 1), 1, "and so is no stack at all")
-- ------------------------------------------- the YES/NO stays with its screen
-- a bare choice box -- the battle's switch offer, a shop or PC confirm -- has
-- no anchored dialogue box under it to ride
T.eq(ChoiceBox.new({}, function() end).anchor, nil,
"a bare choice box does not anchor itself to the window edge")
T.eq(ChoiceBox.new({}, function() end, { defaultNo = true }).anchor, nil,
"and neither does one that only asked to start on NO")
-- ...but the one a dialogue box opens does, because that box is anchored too
local game = { save = { player = {} }, data = { text = {} } }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = {
wasPressed = function() return false end,
isDown = function() return false end,
}
local box = TextBox.new(game, "Shall we heal\nyour POKEMON?", nil,
{ choice = function() end })
game.stack:push(box)
for _ = 1, 600 do
if box.done then break end
box:update(1 / 60)
end
T.check(box.done, "the question finished typing")
box:update(1 / 60) -- the update after done is the one that pushes the choice
T.eq(#game.stack.states, 2, "the dialogue box opened its YES/NO")
T.eq(game.stack:top().anchor, "bottom",
"a choice box riding a dialogue box shares its bottom anchor")
-- ...and inside a battle even THAT one stays put, because the battle is the
-- screen: the caught-mon nickname prompt prints on the blanked battle field
-- (BattleState.blankForAskName), and docking it to the window edge drops it a
-- whole letterbox below what it is printed on.
T.eq(BattleState.holdsUIAnchors, true, "a battle composes its own screen")
T.eq(Game.uiAnchorsHeldInStack(stack(overworld, worldBattle)), true,
"so the anchors are held while it is up")
T.eq(Game.uiAnchorsHeldInStack(stack(overworld, worldBattle, {})), true,
"including for the text box and YES/NO it opens above itself")
T.eq(Game.uiAnchorsHeldInStack(stack(overworld)), false,
"the overworld's own dialogue box still docks to the screen edge")
T.eq(Game.uiAnchorsHeldInStack(nil), false, "and no stack is safe")
Renderer.uiAnchors = nil
Renderer.uiAnchorHold = true
Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
T.eq(Renderer.uiAnchors, nil, "a held anchor never reaches the frame")
Renderer.uiAnchorHold = false
Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
T.eq(#(Renderer.uiAnchors or {}), 1, "and an unheld one still does")
Renderer.uiAnchors = nil
T.finish("battle fixed menu scale")
+86
View File
@@ -0,0 +1,86 @@
-- Every one-shot the engine synthesizes must land in a two-channel buffer
-- (#626). OpenAL only spatializes 1-channel Sources, and nothing in this
-- repo ever calls setPosition/setRelative, so a mono effect Source sat at the
-- default (0,0,0) -- on top of the listener -- which OpenAL Soft renders as
-- an ambient sound spread over EVERY output channel the device exposes. On a
-- 6-channel interface the SFX therefore also came out of outputs 5+6 at gains
-- that differ from the front pair, while the music stayed put because
-- ChipAudio.playMusic is a 2-channel queueable source. Multi-channel buffers
-- skip spatialization outright, so the channel count is the invariant to
-- guard; the routing itself only an ear on a multi-output interface can
-- settle (tests/drivers).
--
-- The duplication is also the faithful default: pokered's stereo panning byte
-- (audio/engine_1.asm Audio1_stereo_panning -> wStereoPanning -> rAUDTERM)
-- is 0xFF, both sides, for everything that never issues command 0xEE.
-- ROM-free: ChipAsm blobs, no data/generated/.
-- luajit tests/engine/effect_stereo_bug626.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
local ChipAudio = require("src.core.ChipAudio")
-- a short blob effect: one audible note on a pulse channel, no loops (the
-- effect renderer forces allowLoops=false anyway)
local beep = ChipAsm.song{
tempo = 0x100,
channels = { { hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 12, fade = 0 } },
{ octave = 4 },
{ note = "C", len = 12 },
{ note = "E", len = 12 },
} } },
}
local blobData = { audio = { sfx = {}, cries = {} } }
local sd = ChipSynth.renderEffectData(blobData, beep, {})
check(sd ~= nil, "the blob effect renders at all")
check(sd:getChannelCount() == 2,
("an effect buffer is stereo, not %d channel(s)")
:format(sd:getChannelCount()))
-- both channels must carry the identical sample: the synthesis stays mono
-- (Engine:sample, not the music path's Engine:sampleStereo), so the only
-- thing that changed for a listener is which outputs the sound reaches
local frames = sd:getSampleCount()
local mismatched, nonzero = 0, 0
for index = 0, frames - 1 do
local left, right = sd:getSample(index, 1), sd:getSample(index, 2)
if left ~= right then mismatched = mismatched + 1 end
if left ~= 0 then nonzero = nonzero + 1 end
end
check(nonzero > 0, "the effect buffer is not silent")
check(mismatched == 0,
("both channels carry the same sample (%d frames differ)")
:format(mismatched))
-- The low-health siren is hand-synthesized rather than going through the
-- effect renderer, so it needs its own guard (ChipAudio.newLowHealthAlarm).
-- tests/love_stub carries no love.audio (headless suites never play), so the
-- buffer is caught on its way into newSource.
local captured
love.audio = { newSource = function(sd) captured = sd; return { sd } end }
local alarmOk, alarmErr = pcall(ChipAudio.newLowHealthAlarm)
love.audio = nil
check(alarmOk, ("the low-health siren builds: %s"):format(tostring(alarmErr)))
check(captured ~= nil and captured:getChannelCount() == 2,
"the low-health siren is stereo")
if captured then
local siren = 0
for index = 0, captured:getSampleCount() - 1 do
if captured:getSample(index, 1) ~= captured:getSample(index, 2) then
siren = siren + 1
end
end
check(siren == 0, "both siren channels carry the same sample")
end
T.finish("effect stereo (#626)")
+146
View File
@@ -0,0 +1,146 @@
-- FAITHFUL RATIO: lock the window to an exact 160x144 multiple so the surface
-- is the Game Boy screen with no letterbox at all.
--
-- The interesting parts are the two things a naive setMode gets wrong: the
-- window minimum from conf.lua (480x360) sits ABOVE 1X and 2X, so the lock
-- has to lower it or LOVE clamps the window straight back up; and on a HiDPI
-- display a LOVE unit is more than a pixel, so asking for the pixel count
-- directly gives a window twice the size intended.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FaithfulRes = require("src.core.FaithfulRes")
-- ---------------------------------------------------------------- values
T.eq(FaithfulRes.normalize(nil), 0, "no setting is OFF")
T.eq(FaithfulRes.normalize("junk"), 0, "garbage degrades to OFF")
T.eq(FaithfulRes.normalize(-3), 0, "negatives clamp to OFF")
T.eq(FaithfulRes.normalize(9), 4, "above 4X clamps to 4X")
T.eq(FaithfulRes.normalize(2.7), 2, "fractions floor to a whole multiple")
T.eq(FaithfulRes.label(0), "OFF", "0 reads OFF")
T.eq(FaithfulRes.label(1), "1X", "1 reads 1X")
T.eq(FaithfulRes.label(4), "4X", "4 reads 4X")
-- the row cycles OFF -> 1X -> 2X -> 3X -> 4X -> OFF
local seen, v = {}, 0
for _ = 1, 5 do
seen[#seen + 1] = FaithfulRes.label(v)
v = FaithfulRes.cycle(v, 1)
end
T.eq(table.concat(seen, ","), "OFF,1X,2X,3X,4X", "the row cycles through OFF..4X")
T.eq(FaithfulRes.cycle(4, 1), 0, "and wraps back to OFF")
T.eq(FaithfulRes.cycle(0, -1), 4, "stepping back from OFF lands on 4X")
-- ---------------------------------------------------------------- sizing
local savedWindow = love.window
local savedSystem = love.system
local savedDims = love.graphics and love.graphics.getDimensions
local savedPixels = love.graphics and love.graphics.getPixelDimensions
-- `ratio` is PHYSICAL PIXELS PER UNIT, which is what the window reports and
-- what FaithfulRes measures -- not love.window.getDPIScale, which lies about
-- a window that is not high-DPI aware (that was the bug).
local function stubWindow(ratio)
local calls = {}
love.graphics = love.graphics or {}
love.graphics.getDimensions = function() return 1024, 768 end
love.graphics.getPixelDimensions = function()
return 1024 * ratio, 768 * ratio
end
love.window = {
-- deliberately WRONG, and deliberately present: nothing may read it
getDPIScale = function() return 999 end,
getMode = function()
return 1024, 768, { fullscreen = true, resizable = true,
minwidth = 480, minheight = 360, vsync = 1 }
end,
setMode = function(w, h, flags)
calls[#calls + 1] = { w = w, h = h, flags = flags }
end,
}
return calls
end
-- A plain desktop window reports units == pixels, whatever the DISPLAY
-- scaling is. Dividing by the display scale here is what made 2X render at
-- 1X and 4X at 3X, so the stub returns a nonsense getDPIScale to prove
-- nothing consults it.
stubWindow(1)
local w, h = FaithfulRes.size(2)
T.eq(w, 320, "2X is 320 units wide when a unit is a pixel")
T.eq(h, 288, "2X is 288 units tall when a unit is a pixel")
T.eq(FaithfulRes.size(0), nil, "OFF has no size")
local w4, h4 = FaithfulRes.size(4)
T.eq(w4, 640, "4X is 640 wide, not shrunk by the display scale")
T.eq(h4, 576, "4X is 576 tall")
-- a genuinely high-DPI window reports more pixels than units, so the unit
-- size halves to keep the PHYSICAL pixel count exact
stubWindow(2)
local w2, h2 = FaithfulRes.size(2)
T.eq(w2, 160, "2X asks for 160 units at 2 px/unit, which is 320 pixels")
T.eq(h2, 144, "and 144 units, which is 288 pixels")
-- ---------------------------------------------------------------- applying
FaithfulRes.locked = false
local calls = stubWindow(1)
love.system = { getOS = function() return "Windows" end }
-- OFF on boot must not touch a window the player sized themselves
T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked")
T.eq(#calls, 0, "and does not resize an unlocked window on boot")
T.eq(FaithfulRes.apply(3), true, "3X reports locked")
T.eq(#calls, 1, "which took one setMode")
T.eq(calls[1].w, 480, "3X is 480 wide")
T.eq(calls[1].h, 432, "3X is 432 tall")
T.eq(calls[1].flags.fullscreen, false,
"an exact size drops fullscreen -- the two cannot both hold")
T.eq(calls[1].flags.resizable, false,
"and fixes the window, since a drag would silently break the lock")
-- 1X and 2X are below conf.lua's 480x360 floor; without lowering it LOVE
-- clamps the window back up and the lock silently does nothing
calls = stubWindow(1)
FaithfulRes.apply(1)
T.eq(calls[1].w, 160, "1X is 160 wide")
T.eq(calls[1].flags.minwidth, 160, "and lowers the window minimum to match")
T.eq(calls[1].flags.minheight, 144, "on both axes")
-- releasing the lock restores the resizable window and its original floor
calls = stubWindow(1)
T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked")
T.eq(#calls, 1, "and this time it does resize, because we held the window")
T.eq(calls[1].flags.resizable, true, "handing resizing back to the player")
T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored")
T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes")
T.eq(FaithfulRes.locked, false, "and the module no longer claims the window")
-- Mobile has no resizable window to lock, so it locks the RENDER scale
-- instead and never calls setMode. It used to report unlocked and do
-- nothing at all, which is why the OPTIONS row was inert on Android and iOS;
-- tests/engine/faithful_res_mobile.lua covers the scale side.
calls = stubWindow(1)
love.system = { getOS = function() return "Android" end }
T.eq(FaithfulRes.apply(4), true, "mobile locks, by capping the render scale")
-- the level asked for is irrelevant on mobile: ON is ON, and the scale is
-- read off the display so the picture is as big as exact pixels allow
T.eq(FaithfulRes.scaleCap(), FaithfulRes.deviceScale(),
"and the scale comes from the display, not from the level")
T.eq(#calls, 0, "still without ever touching the window")
T.eq(FaithfulRes.apply(0), false, "OFF releases it")
T.eq(FaithfulRes.scaleCap(), nil, "and the cap goes away with it")
T.eq(#calls, 0, "the window is left alone either way")
FaithfulRes.mobileScale = 0
love.window, love.system = savedWindow, savedSystem
if love.graphics then
love.graphics.getDimensions = savedDims
love.graphics.getPixelDimensions = savedPixels
end
T.finish("faithful resolution")
+134
View File
@@ -0,0 +1,134 @@
-- FAITHFUL RATIO on Android / iOS.
--
-- On mobile the setting is ON or OFF, and ON means one thing: lock the
-- viewport to the Game Boy's 10:9 at the largest WHOLE multiple this screen
-- can hold, centred, black around it -- the way an emulator opens a Game Boy
-- game on a phone.
--
-- Three things had to be true and none of them were:
--
-- * it had to apply at all. FaithfulRes.apply returned false on its first
-- line for mobile, so the OPTIONS row did nothing on Android and iOS.
-- A phone has no window to resize, so the lock caps the RENDER scale.
--
-- * it had to be sized for the device. The first cut kept the desktop's
-- absolute 1X-4X ladder, which names a window size you can see on a
-- desktop and means a different fraction of every phone: 4X was a quarter
-- of a 1080p display and 5X/6X were not on the list at all. The scale is
-- read off the display now, not chosen by the player.
--
-- * it had to work in the OVERWORLD. The world pass deliberately expands
-- to cover the whole display so letterbox voids become more map. So the
-- lock shrank the UI blit while the map kept filling the screen -- and
-- showed MORE map, since a smaller scale fits more world pixels in.
--
-- Pixel perfect throughout: whole multiples only. The leftover is bars, and
-- on a 9:20 phone there is a lot of it vertically. That is what a 10:9
-- screen looks like on a tall display; stretching to reach the edges would
-- resample every pixel, which is the one thing this setting exists to refuse.
-- luajit tests/engine/faithful_res_mobile.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FaithfulRes = require("src.core.FaithfulRes")
local Renderer = require("src.render.Renderer")
local Zoom = require("src.render.Zoom")
local g = love.graphics
local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
local realOS = love.system and love.system.getOS
local savedOffset = Zoom.offset
love.system = love.system or {}
local function pose(w, h, osName)
love.system.getOS = function() return osName or "Android" end
g.getDimensions = function() return w, h end
g.getPixelDimensions = function() return w, h end
end
Renderer.uiWidth, Renderer.uiHeight = Renderer.WIDTH, Renderer.HEIGHT
Zoom.offset = 0
-- ------------------------------------------------------- it applies at all
pose(1080, 2400) -- a Pixel 7, portrait
T.eq(FaithfulRes.isMobile(), true, "the fixture reads as a phone")
T.eq(FaithfulRes.apply(1), true, "FAITHFUL RATIO applies on mobile at all now")
T.eq(FaithfulRes.locked, true, "and reports itself locked")
-- ------------------------------------------------- one ON, sized by the device
T.eq(FaithfulRes.maxLevel(), 1, "mobile offers ON, not a ladder of multiples")
T.eq(#FaithfulRes.levels(), 2, "so the row is exactly OFF and ON")
T.eq(FaithfulRes.label(1), "ON", "and ON is spelled ON, not 1X")
T.eq(FaithfulRes.label(0), "OFF", "with OFF unchanged")
-- 1080/160 = 6.75 and 2400/144 = 16.6, so the largest WHOLE multiple is 6
T.eq(FaithfulRes.deviceScale(), 6, "the scale comes off the display: 6x here")
T.eq(FaithfulRes.scaleCap(), 6, "and that is what the renderer is told to lock")
T.eq(Renderer:fitScale(), 6, "so a GB pixel is exactly 6 screen pixels")
-- the player never picks a smaller one, which is how the first cut managed to
-- draw a postage stamp on a 1080p phone
T.eq(FaithfulRes.normalize(3), 1, "any ON value is just ON")
FaithfulRes.apply(1)
T.eq(Renderer:fitScale(), 6, "and always lands on the device maximum")
-- --------------------------------------------------------- the overworld
FaithfulRes.apply(0)
local unlockedW = Renderer:worldViewSize()
T.check(unlockedW > 160, "unlocked, the overworld view covers the whole display")
FaithfulRes.apply(1)
local lockedW, lockedH = Renderer:worldViewSize()
T.eq(lockedW, 160, "locked, the overworld shows exactly a GB screen wide")
T.eq(lockedH, 144, "and exactly a GB screen tall")
T.check(lockedW < unlockedW,
"so the lock SHRINKS the map area instead of growing it")
-- ------------------------------------------------------ pixel perfect
-- 6 x 160 = 960 of 1080 wide. Reaching the edges would need 6.75, which
-- resamples every pixel; the bars are the honest answer.
local cap = FaithfulRes.scaleCap()
T.eq(cap, math.floor(cap), "the locked scale is a whole number, never fractional")
T.eq(160 * cap, 960, "which puts the GB screen at 960 of 1080 pixels wide")
-- ------------------------------------------------------- rotation is free
-- fitScale reads the live drawable size every frame, so a rotate re-derives
-- the scale with nothing to re-apply
pose(1080, 2400); FaithfulRes.apply(1)
T.eq(Renderer:fitScale(), 6, "portrait locks at 6x")
pose(2400, 1080); FaithfulRes.apply(1)
T.eq(Renderer:fitScale(), 7, "landscape re-derives to 7x (1080/144), still whole")
T.eq(Renderer:worldViewSize(), 160, "and the overworld stays locked through it")
-- ---------------------------------------------------------- OFF is OFF
-- Nothing about OFF changed: it is the behaviour the game already had.
pose(1080, 2400)
FaithfulRes.apply(0)
T.eq(FaithfulRes.locked, false, "OFF releases the lock")
T.eq(FaithfulRes.scaleCap(), nil, "with no cap on the renderer")
T.eq(Renderer:fitScale(), 6, "the UI fits exactly as it did before")
T.check(Renderer:worldViewSize() > 160, "and the overworld fills the screen again")
-- ------------------------------------------------- desktop is untouched
pose(1280, 800, "Windows")
T.eq(FaithfulRes.isMobile(), false, "the fixture reads as desktop")
T.eq(FaithfulRes.maxLevel(), 4, "desktop keeps its 1X-4X ladder")
T.eq(FaithfulRes.label(2), "2X", "and its labels")
T.eq(FaithfulRes.scaleCap(), nil, "no cap: the window itself is the lock there")
T.eq(Renderer:fitScale(), 5, "so fitScale is untouched (800/144 = 5)")
g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
if realOS then love.system.getOS = realOS end
Zoom.offset = savedOffset
FaithfulRes.locked = false
FaithfulRes.mobileScale = 0
T.finish("faithful ratio mobile")
@@ -0,0 +1,96 @@
-- Gen 1 fixed-damage moves and Super Fang ignore the type chart (#616).
-- SPECIAL_DAMAGE_EFFECT and SUPER_FANG_EFFECT are the SetDamageEffects
-- table (data/battle/set_damage_effects.asm); engine/battle/core.asm:3139
-- jumps straight to MoveHitTest for them, so CalculateDamage and
-- AdjustDamageForMoveType never run and ApplyAttackToEnemyPokemon
-- (core.asm:4612) stores wDamage unscaled. Night Shade therefore hits
-- Normal-types and Super Fang hits Ghosts. OHKO is the exception: it
-- returns through AdjustDamageForMoveType (core.asm:4329, 4467) and a 0x
-- matchup still zeroes its 65535 and sets wMoveMissed.
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")
-- the shared fixture type chart is just the GRASS/FIRE/WATER triangle
-- (tests/fixture_data/type_chart.lua) with no 0x row, so this suite adds
-- its own immune matchups: one for the SetDamageEffects moves under test
-- and a separate one for OHKO, kept apart so neither probe leaks into
-- the other's assertion
table.insert(Data.type_chart.matchups,
{ attacker = "FIX_ATK_A", defender = "FIX_DEF_A", multiplier = 0 })
table.insert(Data.type_chart.matchups,
{ attacker = "FIX_ATK_B", defender = "FIX_DEF_B", multiplier = 0 })
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
-- Night Shade's shape: 0 BP, fixed damage equal to the user's level,
-- attacking type that the fixture chart marks as 0x against the target
Data.moves.FIX_NIGHT_SHADE = {
id = "FIX_NIGHT_SHADE", index = 98, name = "FIX NSHADE",
type = "FIX_ATK_A", power = 0, accuracy = 100, pp = 15,
effect = "SPECIAL_DAMAGE_EFFECT", fixedDamage = "level",
}
Data.moves.FIX_SUPER_FANG = {
id = "FIX_SUPER_FANG", index = 97, name = "FIX SFANG",
type = "FIX_ATK_A", power = 1, accuracy = 90, pp = 10,
effect = "SUPER_FANG_EFFECT",
}
Data.moves.FIX_FISSURE = {
id = "FIX_FISSURE", index = 96, name = "FIX FISSURE",
type = "FIX_ATK_B", power = 1, accuracy = 30, pp = 5,
effect = "OHKO_EFFECT",
}
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
-- a battle whose target's live types are forced to `types` (curTypes is
-- what TypeChart reads, BattleState.lua:443)
local function mkbattle(level, types)
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", level) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local b = BattleState.newWild(game, "FIXMON_C", 5)
b.enemy.curTypes = types
b.rng = mkseq({ 0 }) -- accuracy hits; nothing else rolls
return b
end
-- FIX_ATK_A is 0x against FIX_DEF_A, and Night Shade lands anyway
T.eq(TypeChart.effectiveness("FIX_ATK_A", { "FIX_DEF_A" }), 0,
"FIX_ATK_A does nothing to FIX_DEF_A")
local ns = mkbattle(12, { "FIX_DEF_A" })
local before = ns.enemy.mon.hp
ns:performMove(ns.player, ns.enemy, { id = "FIX_NIGHT_SHADE", pp = 15 })
T.eq(before - ns.enemy.mon.hp, 12, "Night Shade takes level damage off an immune type")
-- same 0x matchup, and Super Fang still halves
local sf = mkbattle(12, { "FIX_DEF_A" })
local hp = sf.enemy.mon.hp
sf:performMove(sf.player, sf.enemy, { id = "FIX_SUPER_FANG", pp = 10 })
T.eq(hp - sf.enemy.mon.hp, math.max(1, math.floor(hp / 2)),
"Super Fang halves an immune type's HP")
-- OHKO keeps its immunity gate: it does return through AdjustDamageForMoveType
T.eq(TypeChart.effectiveness("FIX_ATK_B", { "FIX_DEF_B" }), 0,
"FIX_ATK_B does nothing to FIX_DEF_B")
local ko = mkbattle(50, { "FIX_DEF_B" })
local full = ko.enemy.mon.hp
ko:performMove(ko.player, ko.enemy, { id = "FIX_FISSURE", pp = 5 })
T.eq(ko.enemy.mon.hp, full, "OHKO still cannot touch an immune type")
Data.moves.FIX_NIGHT_SHADE = nil
Data.moves.FIX_SUPER_FANG = nil
Data.moves.FIX_FISSURE = nil
T.finish("fixed damage ignores type immunity")
+32 -2
View File
@@ -114,9 +114,11 @@ Game:gamepadpressed(joySelectDown, "y")
eq(digits[1], "5", "Select via isGamepadDown(back) + Y -> 5")
eq(#padForwarded, 0, "isGamepadDown Select chord does not forward face")
-- Edge: every chord face without Select must not cycle (NXMOD-09)
-- Edge: face chords without Select must not cycle digits (NXMOD-09).
-- leftshoulder without Select is the GAME SPEED hotkey (upstream), so it
-- does not reach Input — only face buttons do.
GamepadMap._setForceNXForTests(false)
for _, btn in ipairs({ "a", "b", "y", "x", "leftshoulder" }) do
for _, btn in ipairs({ "a", "b", "y", "x" }) do
Input:init()
resetSpies()
Game:gamepadpressed(joy, btn)
@@ -125,6 +127,34 @@ for _, btn in ipairs({ "a", "b", "y", "x", "leftshoulder" }) do
check(not wroteOptions, "edge: no options write without Select for " .. btn)
end
-- leftshoulder alone cycles speed (does not forward / does not digit).
do
local sped = 0
local origCycle = Game._cycleSpeed
function Game:_cycleSpeed(dir) sped = sped + (dir or 0) end
Input:init()
resetSpies()
Game:gamepadpressed(joy, "leftshoulder")
eq(#digits, 0, "edge: L alone does not fire display digit")
eq(#padForwarded, 0, "edge: L alone does not forward to Input (speed hotkey)")
eq(sped, -1, "edge: L alone cycles GAME SPEED down")
Game._cycleSpeed = origCycle
end
-- Select+L still wins over the speed hotkey.
holdSelect()
resetSpies()
do
local sped = 0
local origCycle = Game._cycleSpeed
function Game:_cycleSpeed(dir) sped = sped + (dir or 0) end
Game:gamepadpressed(joy, "leftshoulder")
eq(digits[1], "7", "Select+L still fires display digit 7")
eq(sped, 0, "Select+L does not cycle GAME SPEED")
eq(#padForwarded, 0, "Select+L does not forward L to Input")
Game._cycleSpeed = origCycle
end
-- Edge: Select alone (no face) does not synthesize a digit
holdSelect()
resetSpies()
+9
View File
@@ -62,10 +62,19 @@ end
-- Blank out every Strings(...) / Strings.source(...) call span, parens
-- balanced, so a call wrapped across lines counts as covered. A per-line
-- test reported the continuation lines of three real calls as misses.
--
-- romText(...) counts as a router too: it prefers the line the importer
-- extracted from the ROM and hands its literal straight to Strings(...)
-- whenever that label is absent (a cache built before it, or a dataset-less
-- unit test), so the literal is still catalog-backed and a translation mod
-- still reaches it. Blanking the whole span is safe -- the only literals
-- inside are the pokered label and that fallback.
local function stripStringsCalls(body)
local out, i, n = {}, 1, #body
while i <= n do
local s, e = body:find("Strings%.?s?o?u?r?c?e?%(", i)
local rs = body:find("romText%(", i)
if rs and (not s or rs < s) then s, e = rs, nil end
if not s then out[#out + 1] = body:sub(i) break end
out[#out + 1] = body:sub(i, s - 1)
local depth, j = 0, body:find("%(", s)
+33
View File
@@ -0,0 +1,33 @@
-- #606: on Windows every shelled-out host tool (curl, the PowerShell ROM
-- picker, the update downloader) flashed its own cmd.exe console window,
-- because a GUI-subsystem process has no console for the child to inherit.
-- HostShell.hideHostConsole claims one hidden console at boot so the children
-- inherit it silently. The Windows half needs Windows; what this tier can pin
-- is that the helper is a harmless memoized no-op everywhere else, and that
-- main.lua actually calls it (an unwired helper fixes nothing).
-- luajit tests/engine/host_hide_console_bug606.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 HostShell = require("src.core.HostShell")
check(type(HostShell.hideHostConsole) == "function",
"HostShell exposes hideHostConsole (#606)")
local ok, hidden = pcall(HostShell.hideHostConsole)
check(ok, "hideHostConsole never throws")
eq(hidden, false, "non-Windows hosts allocate no console")
eq(select(2, pcall(HostShell.hideHostConsole)), false,
"the answer is memoized, so repeat calls stay a no-op")
local f = assert(io.open("main.lua", "r"))
local src = f:read("*a")
f:close()
check(src:find("hideHostConsole", 1, true) ~= nil,
"love.load wires the console suppression up at boot (#606)")
T.finish("host_hide_console_bug606")
@@ -0,0 +1,69 @@
-- Regression coverage for #600 "Wrong Prof. Oak dialogue" (T2, ROM-free).
--
-- pret/pokered scripts/OaksLab.asm OaksLabOak1Text opens with the dex
-- rating branch: EVENT_PALLET_AFTER_GETTING_POKEBALLS, or 2+ owned species
-- (CountSetBits over wPokedexOwned) once EVENT_GOT_POKEDEX is set, sends
-- Oak to .HowIsYourPokedexComingText + predef DisplayDexRating. The port
-- skipped that branch entirely, so a player with the Pokedex kept getting
-- .PokemonAroundTheWorldText, the line Oak reads right after handing it
-- over. Yellow already had the branch (data/scripts/oaks_lab_yellow.lua);
-- Red additionally gates it on GOT_POKEDEX, which Yellow's copy drops.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local MapScripts = require("src.script.MapScripts")
local contribution = dofile("data/scripts/oaks_lab.lua")
T.eq(#MapScripts.validateContribution(contribution), 0,
"oaks_lab contribution validates cleanly")
local script = contribution.talk.TEXT_OAKSLAB_OAK1
local labels = {}
for i, row in ipairs(script) do
if row[1] == "label" then labels[row[2]] = i end
end
-- mini executor with ScriptRunner semantics: label/number/"end" jumps,
-- the three checks this branch needs, and text/dex_rating recorded
local function run(flags, owned, bag)
local pc, out, last, guard = 1, {}, nil, 0
while pc <= #script and guard < 400 do
guard = guard + 1
local row = script[pc]
local verb, jump = row[1], nil
if verb == "check_flag" then last = flags[row[2]] == true
elseif verb == "check_dex_owned" then last = owned >= row[2]
elseif verb == "check_item" then last = (bag[row[2]] or 0) > 0
elseif verb == "jump" then jump = row[2]
elseif verb == "jump_if_true" then if last then jump = row[2] end
elseif verb == "jump_if_false" then if not last then jump = row[2] end
elseif verb == "show_text" then out[#out + 1] = row[2]
elseif verb == "dex_rating" then out[#out + 1] = "<dex_rating>"
end
if jump == "end" then break
elseif type(jump) == "string" then pc = labels[jump]
elseif type(jump) == "number" then pc = jump
else pc = pc + 1 end
end
return table.concat(out, "|")
end
local dex = "_OaksLabOak1HowIsYourPokedexComingText|<dex_rating>"
T.eq(run({ EVENT_GOT_POKEDEX = true }, 2, {}), dex,
"Pokédex + 2 owned rates the dex")
T.eq(run({ EVENT_GOT_POKEDEX = true }, 6, { POKE_BALL = 5 }), dex,
"the rating branch wins over the come-see-me line")
T.eq(run({ EVENT_PALLET_AFTER_GETTING_POKEBALLS = true }, 0, {}), dex,
"converted saves rate off EVENT_PALLET_AFTER_GETTING_POKEBALLS")
T.eq(run({ EVENT_GOT_POKEDEX = true, EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = true },
5, {}), dex, "the rating branch wins over the poke ball gift")
T.eq(run({ EVENT_GOT_POKEDEX = true }, 1, {}),
"_OaksLabOak1PokemonAroundTheWorldText",
"one owned species is still the around-the-world line")
T.eq(run({}, 3, {}), "_OaksLabOak1WhichPokemonDoYouWantText",
"no Pokédex yet: never rate, whatever the owned count (Red-only gate)")
T.finish("oaks_lab_dex_rating_bug600")
+25 -8
View File
@@ -94,11 +94,18 @@ T.check(box:find("Those are", 1, true) == nil,
T.check(box:find("#MON", 1, true) == nil,
"the ROM #MON ligature is spelled out as Pokémon")
-- the pokered beat also turns Oak to face the player
T.check(contribution.talk[BALL][20][1] == "face_object"
and contribution.talk[BALL][20][2] == 5
and contribution.talk[BALL][20][3] == "down",
"row 20 faces Oak down before the line (OaksLabLastMonScript)")
-- the pokered beat also turns Oak to face the player: find the
-- face_object row in the leftover-ball path (content-based, so a jingle
-- row added for #668 doesn't shift the hard-coded index)
local oakFaceRow
for i, row in ipairs(contribution.talk[BALL]) do
if row[1] == "face_object" and row[2] == 5 and row[3] == "down" then
oakFaceRow = i
break
end
end
T.check(oakFaceRow ~= nil,
"a face_object row turns Oak down before the line (OaksLabLastMonScript)")
-- ---- pre-escort: still the vanilla "Those are POKé BALLs" line
local pre = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = false }
@@ -115,15 +122,25 @@ T.check(concat(t3):find("last Pokémon!", 1, true) == nil,
"no last-mon line before the pick")
-- ---- all three balls share the same table shape (last-mon beat present)
-- content-based again: locate the leftover-ball "Pokémon" line wherever
-- it sits, instead of pinning a row number (#668 added two jingle rows)
for _, key in ipairs({
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}) do
local script = contribution.talk[key]
T.check(script and script[21] and script[21][2] and
script[21][2]:find("Pokémon", 1, true) ~= nil,
key .. " carries the last-mon line")
local lastMon
if script then
for _, row in ipairs(script) do
if row[1] == "show_text" and type(row[2]) == "string"
and row[2]:find("Pokémon", 1, true) then
lastMon = row[2]
break
end
end
end
T.check(lastMon ~= nil, key .. " carries the last-mon line")
end
T.finish("oaks_lab_last_ball_bug601")
+84
View File
@@ -0,0 +1,84 @@
-- Raw-stick rebinding, and the recognized-pad duplicate event that used to
-- defeat every controller rebind (#632). LOVE raises love.joystickpressed
-- for EVERY stick, gamepads included, and those raise love.gamepadpressed
-- for the same press as well; Input's fixed raw table answered both, so it
-- re-asserted the factory A/B/START/SELECT map underneath a CONTROLS
-- rebind and a swapped A and B pressed at once. A recognized pad is now
-- served by the gamepad path alone, and a raw stick's buttons are
-- rebindable as "joyN" in the same pad slot every other controller uses.
-- No pokered cite: rebinding is port-only (gap C2).
-- luajit tests/engine/rebind_joystick_bug632.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 Input = require("src.core.Input")
local BindingsMenu = require("src.ui.BindingsMenu")
-- an SDL-recognized pad versus a stick with no database entry
local pad = { isGamepad = function() return true end }
local raw = { isGamepad = function() return false end }
Input:init()
-- ---- (a) a recognized pad is served by the gamepad path alone ------------
Input:reset()
Input:joystickpressed(pad, 1)
Input:step()
check(not Input:isDown("a"),
"a recognized pad's duplicate joystick event is ignored (#632)")
Input:joystickhat(pad, 1, "u")
Input:step()
check(not Input:isDown("up"),
"and its duplicate hat event is ignored too (#632)")
-- ---- (b) a raw stick keeps its defaults ----------------------------------
Input:reset()
Input:joystickpressed(raw, 1)
Input:step()
check(Input:isDown("a"), "a raw stick's button 1 still presses A")
Input:joystickreleased(raw, 1)
check(not Input:isDown("a"), "and its release clears A")
-- ---- (c) a "joyN" pad binding reaches the raw lookup ---------------------
Input:applyBindings({ up = { pad = "joy1" } })
Input:reset()
Input:joystickpressed(raw, 1)
Input:step()
check(Input:isDown("up"), "a joy1 rebind wins the raw button (#632)")
check(not Input:isDown("a"), "and the raw default no longer presses A")
Input:joystickreleased(raw, 1)
Input:applyBindings(nil)
-- ---- (d) the CONTROLS row captures a raw button --------------------------
-- same doubles as rebind_swap_clear_bug589: a stack the menu can pop itself
-- off and an input whose queue is one fixed step of edges. data = {} keeps
-- ChoiceBox's un-guarded Sound.play on the headless no-audio path.
local game = { save = { options = {} }, data = {} }
function game:writeOptions() end
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = { wasPressed = function() return false end,
isDown = function() return false end }
local ROW_A = 5 -- BindingsMenu's BUTTONS order
local bm = BindingsMenu.new(game)
bm:beginCapture(bm.items[ROW_A])
bm:onJoystickPressed(3)
check(game.save.options.bindings == nil,
"press alone commits nothing, the release is the commit (#589)")
bm:onJoystickReleased(3)
eq(game.save.options.bindings.a.pad, "joy3",
"a released raw button lands in the row's pad slot (#632)")
check(bm.items[ROW_A].right:find("JOY3", 1, true) ~= nil,
"and the row's controller column reads JOY3")
Input:init()
T.finish("rebind_joystick_bug632")
+11
View File
@@ -17,6 +17,7 @@ love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local BindingsMenu = require("src.ui.BindingsMenu")
-- same doubles as rebind_capture_bug510: a stack the menu can pop itself
@@ -45,6 +46,14 @@ local function press(state, btn)
state.game.input.queue = {}
end
-- ChoiceBox now holds YES/NO answers on screen for Timing.YES_NO_ANSWER
-- frames before it commits and pops (DisplayTwoOptionMenu's 15-frame hold,
-- #GH-627 timing parity); a bare press only arms the choice, so answering
-- it needs the hold run out before the pop/commit is visible.
local function settleChoice(state)
for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end
end
-- rows are BindingsMenu's BUTTONS order
local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8
@@ -146,6 +155,7 @@ eq(bm.footer, Strings("RESET ALL BINDINGS?"),
-- the box starts on NO: a bare A press must keep the overlay
press(box, "a")
settleChoice(box)
eq(game.stack:top(), bm, "answering pops the box")
check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)")
eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them")
@@ -155,6 +165,7 @@ press(bm, "start")
box = game.stack:top()
press(box, "up")
press(box, "a")
settleChoice(box)
check(game.save.options.bindings == nil, "YES clears options.bindings (#589)")
eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again")
eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched")
+233
View File
@@ -0,0 +1,233 @@
-- Regression coverage for #540 "Safari Zone: wrong start tile / step count,
-- black leaving-early dialogue, wrong early-leave tile" (T2, ROM-free).
--
-- pret/pokered scripts/SafariZoneGate.asm:
-- .success (186-198) ends `ld a, PAD_UP / ld c, 3 /
-- SafariZoneEntranceAutoWalk`, so paying walks the player up through the
-- gate's north warp instead of leaving him at the counter. Two of those
-- steps happen with EVENT_IN_SAFARI_ZONE already set, and home/overworld.asm
-- :307-310 charges every step against wSafariSteps, which is why the game
-- starts at 502 but reads 500/500 on arrival. The port only counts steps on
-- the nine interior maps, so the script pays those two itself.
-- SafariZoneGateSafariZoneWorker1LeavingEarlyText (227-254): YES prints the
-- return-balls text and auto-walks `PAD_DOWN, c = 3` down to the counter row,
-- NO prints "Good Luck!" and walks back up through the warp.
--
-- The leaving-early prompt must be QUEUED, never pushed: onEnter runs at the
-- arriving warp Transition's midpoint and Transition:finish pops the top state
-- on the same frame, so a box pushed there is swallowed (or, on an older
-- build, drawn over a screen still faded to black).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local MapScripts = require("src.script.MapScripts")
local ScriptRunner = require("src.script.ScriptRunner")
-- ---- stubs for the two UI modules safari.lua requires lazily. A pushed
-- box runs its continuation immediately, so a whole prompt chain resolves
-- inside the push that started it.
local answer = true
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
package.loaded["src.ui.ChoiceBox"] = {
new = function(_, cb) return { done = function() cb(answer) end } end,
}
local contribution = dofile("data/scripts/safari.lua")
local gate = contribution.SAFARI_ZONE_GATE
T.check(gate ~= nil, "the contribution still carries SAFARI_ZONE_GATE")
local problems = MapScripts.validateContribution(gate)
T.eq(#problems, 0, "safari contribution validates cleanly")
for _, p in ipairs(problems) do T.check(false, "unexpected finding: " .. p) end
-- ---- a minimal game/overworld pair
local NORTH_WARP = { x = 4, y = 0, destMap = "SAFARI_ZONE_CENTER", destWarp = 2 }
local function newWorld(cellX, cellY)
local w = {
pushed = {}, moves = {}, warps = {}, queued = {},
player = { cellX = cellX, cellY = cellY },
}
w.map = {
id = "SAFARI_ZONE_GATE",
warpAtCell = function(_, cx, cy)
if cy == 0 and (cx == 3 or cx == 4) then
return { index = cx == 3 and 3 or 4, def = NORTH_WARP }
end
end,
}
w.scriptMove = function(_, entity, dir, tiles, onDone)
w.moves[#w.moves + 1] = { entity = entity, dir = dir, tiles = tiles,
onDone = onDone }
end
w.takeWarp = function(_, def) w.warps[#w.warps + 1] = def end
w.startWarpTo = function(_, mapId, x, y, facing)
w.warps[#w.warps + 1] = { startWarpTo = mapId, x = x, y = y,
facing = facing }
end
w.queueScript = function(_, rows) w.queued[#w.queued + 1] = rows end
return w
end
local function newGame(world, money)
local game = {
data = {
text = {},
maps = { SAFARI_ZONE_CENTER = {
warps = { { x = 14, y = 25 }, { x = 15, y = 25 } } } },
},
save = { player = { name = "RED" }, money = money or 3000 },
}
game.stack = { push = function(_, state)
world.pushed[#world.pushed + 1] = state
if state.done then state.done() end
end }
return game
end
-- ---- paying walks into the zone and pays the two gate steps
answer = true
local ow = newWorld(4, 2)
local game = newGame(ow)
T.eq(gate.onStep(game, ow, 4, 2), true, "the trigger cell claims the step")
T.check(game.save.safari ~= nil, "paying starts the game")
T.eq(game.save.safari.steps, 502, "wSafariSteps is written as 502")
T.eq(game.save.safari.balls, 30, "SAFARI_BALLS_RECEIVED is 30")
T.eq(game.save.money, 2500, "the ¥500 fee is taken")
T.eq(#ow.moves, 1, "the payment text is followed by the entrance auto-walk")
T.eq(ow.moves[1].dir, "up", "SafariZoneEntranceAutoWalk walks PAD_UP")
T.eq(ow.moves[1].tiles, 2, "two cells reach the north warp row")
T.eq(#ow.warps, 0, "the warp is not taken until the walk lands on it")
ow.moves[1].onDone()
T.eq(game.save.safari.steps, 500,
"the two gate steps are charged, so the counter reads 500/500 on arrival")
T.eq(ow.warps[1], NORTH_WARP,
"a scripted step skips CheckWarpsNoCollision, so the script takes the warp")
-- talking to the worker from anywhere else has no warp above the player, so
-- the auto-walk stays out of the way and he walks in himself
answer = true
local far = newWorld(2, 4)
local farGame = newGame(far)
farGame.save.safari = nil
gate.talk.TEXT_SAFARIZONEGATE_SAFARI_ZONE_WORKER1(farGame, far, nil, nil)
T.check(farGame.save.safari ~= nil, "the talk path still starts the game")
T.eq(#far.moves, 0, "no auto-walk from a cell with no warp above it")
-- declining walks the player back off the trigger cell, unchanged
answer = false
local no = newWorld(4, 2)
local noGame = newGame(no)
gate.onStep(noGame, no, 4, 2)
T.eq(noGame.save.safari, nil, "declining starts no game")
T.eq(no.moves[1] and no.moves[1].dir, "down", "declining walks you back down")
-- ---- leaving early: queued, never pushed
local back = newWorld(4, 0)
local backGame = newGame(back)
backGame.save.safari = { balls = 7, steps = 300 }
gate.onEnter(backGame, back)
T.eq(#back.pushed, 0,
"onEnter pushes nothing: the arriving Transition pops the top state on the "
.. "same frame")
T.eq(#back.queued, 1, "the leaving-early prompt is queued for an idle frame")
local rows = back.queued[1]
T.eq(#ScriptRunner.validate(rows), 0, "the queued rows validate")
local function runRows(script, yes)
local pc, texts, out = 1, {}, { fields = {}, moves = {}, warps = {} }
local lastCheck = nil
while pc <= #script do
local row = script[pc]
local verb = row[1]
local jump = nil
if verb == "ask" then
texts[#texts + 1] = row[2]
lastCheck = yes
elseif verb == "show_text" then
texts[#texts + 1] = row[2]
elseif verb == "jump_if_false" then
if not lastCheck then jump = row[2] end
elseif verb == "jump" then
jump = row[2]
elseif verb == "set_field" then
out.fields[#out.fields + 1] = { key = row[2], value = row[3] }
elseif verb == "move_player" then
out.moves[#out.moves + 1] = { dir = row[2], tiles = row[3] }
elseif verb == "warp" then
out.warps[#out.warps + 1] = { map = row[2], x = row[3], y = row[4],
facing = row[5] }
end
if jump == "end" then break end
if jump then
local target
for i, r in ipairs(script) do
if r[1] == "label" and r[2] == jump then target = i break end
end
T.check(target ~= nil, "jump target '" .. tostring(jump) .. "' exists")
pc = target
else
pc = pc + 1
end
end
out.texts = texts
return out
end
local yes = runRows(rows, true)
T.eq(yes.texts[1], "_SafariZoneGateSafariZoneWorker1LeavingEarlyText",
"the worker asks first")
T.eq(yes.texts[2], "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText",
"YES takes the leftover balls back")
T.eq(yes.texts[3], "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText",
"the good-haul sign-off stays reachable on this branch")
T.eq(#yes.fields, 1, "the game state is cleared once")
T.eq(yes.fields[1].key, "safari", "save.safari is the field cleared")
T.eq(yes.fields[1].value, nil, "set_field with no value assigns nil")
T.eq(#yes.moves, 1, "YES ends with the exit auto-walk")
T.eq(yes.moves[1].dir, "down", "SafariZoneEntranceAutoWalk walks PAD_DOWN")
T.eq(yes.moves[1].tiles, 3, "three cells reach the counter row")
T.eq(#yes.warps, 0, "the YES branch never warps back into the zone")
local stay = runRows(rows, false)
T.eq(stay.texts[2], "_SafariZoneGateSafariZoneWorker1GoodLuckText",
"NO wishes you good luck")
T.eq(#stay.moves, 0, "NO does not walk you to the counter")
T.eq(#stay.warps, 1, "NO puts you back in the zone")
T.eq(stay.warps[1].map, "SAFARI_ZONE_CENTER", "back through the entrance")
T.eq(stay.warps[1].x, 15, "the right-hand warp column comes back on column 15")
T.eq(stay.warps[1].y, 25, "the entrance row of SAFARI_ZONE_CENTER")
-- the left-hand column pairs with the left-hand destination warp
local left = newWorld(3, 0)
local leftGame = newGame(left)
leftGame.save.safari = { balls = 7, steps = 300 }
gate.onEnter(leftGame, left)
local leftStay = runRows(left.queued[1], false)
T.eq(leftStay.warps[1].x, 14, "the left-hand warp column comes back on 14")
-- no game running, or arriving from the town side, asks nothing
local idle = newWorld(4, 0)
local idleGame = newGame(idle)
gate.onEnter(idleGame, idle)
T.eq(#idle.queued, 0, "no safari game, no prompt")
local south = newWorld(4, 5)
local southGame = newGame(south)
southGame.save.safari = { balls = 7, steps = 300 }
gate.onEnter(southGame, south)
T.eq(#south.queued, 0, "arriving from Fuchsia asks nothing")
package.loaded["src.render.TextBox"] = nil
package.loaded["src.ui.ChoiceBox"] = nil
T.finish("safari_gate_bug540")
+630
View File
@@ -0,0 +1,630 @@
-- Timing parity: every sequence in docs/timing-parity.md must cost the same
-- number of 60Hz logic steps here as it does on hardware.
--
-- The port's clock was always right; what drifted was the frame budget of
-- composed sequences, because the original spends much of its running time
-- inside DelayFrames calls that produce no visible change. Those are
-- invisible in a screenshot, so nothing else in the suite catches them --
-- this file is the only thing standing between the port and a slow slide
-- back to "snappier than a Game Boy".
--
-- Hardware numbers carry their asm citation; regenerate the inventory with
-- tools/scan_pokered_delays.ps1.
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 Timing = require("src.core.Timing")
-- ---------------------------------------------------------------- constants
-- home/fade.asm: four (or three) palette steps of `ld c, 8 / DelayFrames`
T.eq(Timing.FADE_OUT_TO_BLACK, 32, "GBFadeOutToBlack is 4 x 8 frames")
T.eq(Timing.FADE_IN_FROM_BLACK, 32, "GBFadeInFromBlack is 4 x 8 frames")
T.eq(Timing.FADE_OUT_TO_WHITE, 24, "GBFadeOutToWhite is 3 x 8 frames")
T.eq(Timing.FADE_IN_FROM_WHITE, 24, "GBFadeInFromWhite is 3 x 8 frames")
T.eq(Timing.DELAY3, 3, "Delay3 is three frames")
T.eq(Timing.TEXT_SCROLL_PAIR, 10, "ScrollTextUpOneLine is 5 frames, run twice")
T.eq(Timing.TEXT_CONT, 13, "<CONT>: ProtectedDelay3 + the two-line scroll")
T.eq(Timing.TEXT_PARAGRAPH, 23, "<PARA>: ProtectedDelay3 + DelayFrames 20")
T.eq(Timing.YES_NO_ANSWER, 15, "DisplayTwoOptionMenu holds 15 frames")
T.eq(Timing.WARP_FADE_OUT, 32, "a map change fades out over 32 frames")
T.eq(Timing.WARP_FADE_IN, 0, "there is no fade in: LoadGBPal restores in one write")
-- ---------------------------------------------------------------- HP bar
-- UpdateHPBar walks one HP point per iteration. On the player's HUD each
-- point costs a frame (PrintHPNumber's DelayFrame, gated on wHPBarType) and
-- each pixel of bar movement costs two more; on the enemy HUD only the
-- pixels cost anything.
T.eq(Timing.hpBarPixels(150, 150), 48, "a full bar is 48 px")
T.eq(Timing.hpBarPixels(75, 150), 24, "half HP is half the bar")
T.eq(Timing.hpBarPixels(0, 150), 0, "an empty bar is 0 px")
T.eq(Timing.hpBarPixels(1, 150), 1, "GetHPBarLength clamps a sliver to 1 px")
T.eq(Timing.hpDrainFrames(150, 0, 150, true), 150 + 96 + 6,
"a 150 HP player mon drains in D + 2P + 6 = 252 frames")
T.eq(Timing.hpDrainFrames(150, 0, 150, false), 96 + 5,
"the same drain on the enemy HUD costs only 2P + 5 = 101 frames")
-- The engine's per-frame stepper has to agree with that closed form, or the
-- bar is animating at a rate nothing else measures.
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)
-- Each case gets its own party: draining a battler to 0 faints the very
-- Pokemon object the save holds, and newWild refuses to start with no
-- healthy party.
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local game = { data = Data, save = save,
stack = { top = function() return nil end,
push = function() end } }
return BattleState.newWild(game, "FIXMON_C", 40)
end
local function drainFrames(battle, battler, toHP)
battler.mon.hp = toHP
local frames = 0
while battle:stepHPDrain() and frames < 20000 do frames = frames + 1 end
return frames
end
local battle = newBattle()
local pMax = battle.player.mon.stats.hp
T.eq(drainFrames(battle, battle.player, 0),
Timing.hpDrainFrames(pMax, 0, pMax, true),
"the player's bar steps at the hardware rate")
local battle2 = newBattle()
local eMax = battle2.enemy.mon.stats.hp
T.eq(drainFrames(battle2, battle2.enemy, 0),
Timing.hpDrainFrames(eMax, 0, eMax, false),
"the enemy's bar steps at the hardware rate")
-- a partial drain is exact too: every player-side HP step costs at least the
-- number print, so the stepper never batches two into one frame
local battle3 = newBattle()
local start = battle3.player.mon.hp
local target = math.max(1, start - 7)
T.eq(drainFrames(battle3, battle3.player, target),
Timing.hpDrainFrames(start, target, battle3.player.mon.stats.hp, true),
"a partial player drain matches the closed form")
-- ---------------------------------------------------------------- text box
-- Both <CONT> and <PARA> print the down-arrow and run ProtectedDelay3 before
-- ManualTextScroll starts watching the joypad, then pay the scroll or the
-- box clear after the button. The port used to advance on the press frame
-- with no cost on either side.
local TextBox = require("src.render.TextBox")
local Input = { down = {}, pressed = {} }
function Input:isDown(b) return self.down[b] or false end
function Input:wasPressed(b) return self.pressed[b] or false end
function Input:press(b) self.pressed[b] = true end
function Input:release() self.pressed = {} end
local function textGame()
local popped = false
local g = { data = Data, save = SaveData.newGame(), input = Input }
g.stack = { push = function() end,
pop = function() popped = true end,
top = function() return nil end }
g.wasPopped = function() return popped end
return g
end
-- type a box out to its first wait, returning the frames that took
local function typeToWait(box)
local frames = 0
while not box.waiting and not box.done and frames < 2000 do
box:update(1 / 60)
frames = frames + 1
end
return frames
end
local g = textGame()
g.save.options = g.save.options or {}
g.save.options.textSpeed = 1 -- fastest, so the typewriter is not the subject
local box = TextBox.new(g, "AB\fCD", {})
typeToWait(box)
T.check(box.waiting, "the box waits at the page break")
-- the three ProtectedDelay3 frames swallow the button
local held = 0
for _ = 1, Timing.TEXT_PRE_ADVANCE do
Input:press("a")
box:update(1 / 60)
Input:release()
held = held + 1
T.check(box.waiting, "still waiting on pre-advance frame " .. held)
end
-- the press now lands, and the box holds for the clear before typing again
Input:press("a")
box:update(1 / 60)
Input:release()
T.eq(box.holdFrames, Timing.TEXT_PAGE_CLEAR,
"a page break holds DelayFrames 20 after the press")
T.eq(#box.shown[1], 0, "the new page has not typed a character yet")
local blocked = 0
while (box.holdFrames or 0) > 0 and blocked < 200 do
box:update(1 / 60)
blocked = blocked + 1
T.eq(#box.shown[#box.shown], 0, "nothing types during the hold")
end
T.eq(blocked, Timing.TEXT_PAGE_CLEAR, "the hold is exactly 20 frames")
-- <CONT> pays the two-line scroll instead of the clear
local g2 = textGame()
g2.save.options = g2.save.options or {}
g2.save.options.textSpeed = 1
local box2 = TextBox.new(g2, "AB\vCD", {})
typeToWait(box2)
T.check(box2.waiting, "the box waits at the CONT marker")
for _ = 1, Timing.TEXT_PRE_ADVANCE do box2:update(1 / 60) end
Input:press("a")
box2:update(1 / 60)
Input:release()
T.eq(box2.holdFrames, Timing.TEXT_SCROLL_PAIR,
"a CONT advance holds for the two ScrollTextUpOneLine calls")
-- ---------------------------------------------------------------- yes/no
local ChoiceBox = require("src.ui.ChoiceBox")
local chosen, popped = nil, 0
local g3 = { data = Data, save = SaveData.newGame(), input = Input }
g3.stack = { push = function() end, pop = function() popped = popped + 1 end,
top = function() return nil end }
local choice = ChoiceBox.new(g3, function(yes) chosen = yes end)
Input:press("a")
choice:update(1 / 60)
Input:release()
T.eq(chosen, nil, "the answer does not fire on the press frame")
-- count only the frames after the press: DelayFrames 15 runs between the
-- press and TwoOptionMenu_RestoreScreenTiles handing control back
local waited = 0
while chosen == nil and waited < 200 do
choice:update(1 / 60)
waited = waited + 1
end
T.eq(waited, Timing.YES_NO_ANSWER, "the answer fires 15 frames after the press")
T.eq(chosen, true, "A chose YES")
T.eq(popped, 1, "the box popped itself once")
-- B picks the second option, and the cursor snaps to it for the hold
local chosen2 = nil
local g4 = { data = Data, save = SaveData.newGame(), input = Input }
g4.stack = { push = function() end, pop = function() end,
top = function() return nil end }
local choice2 = ChoiceBox.new(g4, function(yes) chosen2 = yes end)
Input:press("b")
choice2:update(1 / 60)
Input:release()
T.eq(choice2.index, 2, "B moves the cursor to NO before the hold")
local waited2 = 0
while chosen2 == nil and waited2 < 200 do
choice2:update(1 / 60)
waited2 = waited2 + 1
end
T.eq(waited2, Timing.YES_NO_ANSWER, "the B answer holds 15 frames too")
T.eq(chosen2, false, "B chose NO")
-- ---------------------------------------------------------------- warp fade
-- PlayMapChangeSound fades out over 32 frames and the new map simply
-- appears; the port used to run a symmetric 12/12 fade, which is both too
-- fast and a shape the hardware never had.
local Transition = require("src.render.Transition")
local mid, done, tpopped = 0, 0, 0
local g5 = { data = Data, save = SaveData.newGame() }
g5.stack = { push = function() end, pop = function() tpopped = tpopped + 1 end,
top = function() return nil end }
local fade = Transition.new(g5, function() mid = mid + 1 end,
function() done = done + 1 end)
local f = 0
while done == 0 and f < 500 do
fade:update(1 / 60)
f = f + 1
if mid == 1 and done == 0 then
T.check(false, "the map switch and the hand-back must land together")
end
end
T.eq(f, Timing.WARP_FADE_OUT, "the warp fade is 32 frames end to end")
T.eq(mid, 1, "the map switched once")
T.eq(done, 1, "the transition handed control back once")
T.eq(tpopped, 1, "and popped itself once")
-- ------------------------------------------------------- battle turns
-- PlayApplyingAttackAnimation's six types (animations.asm:490-524). The
-- slow shakes are c * 4b because AnimationShakeScreenHorizontallySlow pushes
-- bc twice and runs two b-loops of DelayFrames 2 per outer pass.
T.eq(Timing.SHAKE_VERTICAL, 48, "type 1 ShakeScreenVertically b=8")
T.eq(Timing.SHAKE_HORIZ_HEAVY, 72, "type 2 fast horizontal b=8")
T.eq(Timing.SHAKE_HORIZ_SLOW, 48, "type 3 slow horizontal, lb bc, 6, 2")
T.eq(Timing.SHAKE_HORIZ_LIGHT, 18, "type 5 fast horizontal b=2")
T.eq(Timing.SHAKE_HORIZ_SLOW2, 24, "type 6 slow horizontal, lb bc, 3, 2")
-- Type 4 is the player's plain damaging move -- the most-seen animation in
-- the game. AnimationBlinkMon is `ld c, 6` of hide/DelayFrames 5/show/
-- DelayFrames 5 (animations.asm:1360-1376): 60 frames, not the 20 the port
-- used to run.
T.eq(Timing.BLINK_MON, 60, "AnimationBlinkMon is 6 x (5 hidden + 5 shown)")
T.eq(Timing.BLINK_MON % 10, 0,
"and divides into whole 10-frame blinks, matching fxHidden's period")
-- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2
T.eq(Timing.FAINT_SLIDE, 14, "the faint slide is 7 steps x 2 frames")
-- #671: the slide must start at the sprite's resting spot and sink the
-- full PIC_HEIGHT (7 rows x 8px = 56px at 1x) across those 14 frames.
-- The old (30 - frames) * 2 math teleported the pic 32px down on frame
-- one once the budget was shortened from 30 to 14 frames.
local faintBattle = newBattle()
faintBattle.fx = { faint = { battler = faintBattle.enemy,
frames = Timing.FAINT_SLIDE } }
T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1), 0,
"the faint slide starts at offset 0 (#671)")
faintBattle.fx.faint.frames = Timing.FAINT_SLIDE - 7
T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1),
7 * Timing.FAINT_SLIDE_STEP,
"the slide sinks one 8px row per step")
faintBattle.fx.faint.frames = 1
T.eq(faintBattle:fxFaintOffset(faintBattle.enemy, 1),
(Timing.FAINT_SLIDE - 1) * Timing.FAINT_SLIDE_STEP,
"the slide reaches 52px by the last visible frame")
T.eq(Timing.FAINT_SLIDE * Timing.FAINT_SLIDE_STEP, 56,
"and covers the full 7-row pic height over the whole budget")
T.eq(Timing.MOVE_STATUS_OR_MISS, 30,
"a status move or a miss holds DelayFrames 30 before its text")
T.eq(Timing.MOVE_ANIM_PRE, 3,
"PlayMoveAnimation calls Delay3 before handing off to MoveAnimation")
T.eq(Timing.CRIT_OHKO_TEXT, 20,
"PrintCriticalOHKOText closes with DelayFrames 20")
T.eq(Timing.BATTLE_START_SENDOUT, 40, "StartBattle holds 40 after the send-out")
-- An animation row pays PlayMoveAnimation's Delay3 before the first frame
-- of the animation, so the row goes back on the queue once (core.asm:6638).
do
local b = newBattle()
b.queue, b.nextInsert, b.current = {}, 0, nil
b.waitFrames, b.waitingSound = nil, nil
b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
b.queue[1] = { anim = "FIX_TACKLE", attackerIsPlayer = true }
b:updateQueue()
T.eq(b.waitFrames, Timing.MOVE_ANIM_PRE,
"the anim row pays Delay3 before it plays")
T.eq(#b.queue, 1, "and is put back on the queue to run after the hold")
T.check(b.queue[1].animDelayed, "flagged so the hold is paid only once")
end
-- StartBattle's 40-frame hold is unconditional -- the `call nz` gates only
-- EnemySendOutFirstMon -- so a wild battle queues it too, between
-- "Wild X appeared!" and "Go! Y!" (core.asm:152-156).
do
-- the intro queue is built by enter(), not by newWild
local b = newBattle()
local ok = pcall(b.enter, b)
T.check(ok, "a wild battle's intro builds")
local found = false
for _, row in ipairs(b.queue) do
if row.wait == Timing.BATTLE_START_SENDOUT then found = true break end
end
T.check(found, "a wild battle's intro queues the 40-frame send-out hold")
end
-- waitNext is what puts that hold in the turn queue
do
local b = newBattle()
b.queue, b.nextInsert = {}, 0
b:waitNext(Timing.MOVE_STATUS_OR_MISS)
T.eq(#b.queue, 1, "waitNext queues one row")
T.eq(b.queue[1].wait, Timing.MOVE_STATUS_OR_MISS, "carrying the hold length")
b:waitNext(0)
T.eq(#b.queue, 1, "a zero-length hold queues nothing")
end
-- Battle text prints through the same PrintText path as overworld text, so
-- it pays PrintLetterDelay per character (home/print_text.asm:4-45): one
-- glyph per wOptions & $f frames, collapsing to one frame while A or B is
-- held. It used to run a flat two glyphs per frame -- six times hardware
-- speed at the default setting -- and ignored the text-speed option.
local function typedFrames(speed, hold)
local Btn = { down = {}, pressed = {} }
function Btn:isDown(k) return self.down[k] or false end
function Btn:wasPressed(k) return self.pressed[k] or false end
if hold then Btn.down.a = true end
local b = newBattle()
b.game.input = Btn
b.game.save.options = b.game.save.options or {}
b.game.save.options.textSpeed = speed
b.queue, b.nextInsert = {}, 0
b.waitFrames, b.waitingSound = nil, nil
b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
b:startMessage({ text = "ABCDEF" })
local frames = 0
while (b.charIndex or 0) < 6 and frames < 400 do
b:updateQueue()
frames = frames + 1
end
return frames
end
T.eq(typedFrames(3), 18, "six glyphs at MEDIUM take 3 frames each")
T.eq(typedFrames(5), 30, "six glyphs at SLOW take 5 frames each")
T.eq(typedFrames(1), 6, "six glyphs at FAST take 1 frame each")
T.eq(typedFrames(3, true), 6,
"holding A collapses the per-letter wait to a single frame")
-- WaitForSoundToFinish (home/delay.asm:15-20) is how the original gives a
-- sound its own clear window. A trainer intro plays SFX_Silph_Scope --
-- extracted here as "Trainer_Appeared" -- blocks on it, and only then pays
-- the DelayFrames 20 before the balls and the text.
do
local b = newBattle()
b.queue, b.nextInsert, b.current = {}, 0, nil
b.waitFrames, b.waitingSound = nil, nil
b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
local playing = true
local src = { isPlaying = function() return playing end }
b.queue[1] = { waitSound = function() return src end }
T.check(b:updateQueue(), "the waitSound row is taken off the queue")
T.eq(b.waitingSound, src, "and parks the queue on that source")
T.check(b:updateQueue(), "the queue blocks while the sound is audible")
playing = false
b:updateQueue()
T.eq(b.waitingSound, nil, "and releases the frame the sound stops")
end
-- Every battle enters through the wipe, script-driven ones included.
-- BattleTransition runs from DoBattleTransitionAndInitBattleVariables for
-- all of them; start_battle used to push the BattleState straight onto the
-- stack, so every scripted trainer -- gym leaders, the rival -- and every
-- scripted wild battle cut to the battle screen with no transition at all.
do
local Commands = require("src.script.Commands")
local route = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
local ctx = {
game = { data = Data, save = save,
stack = { push = function() route[#route + 1] = "raw" end,
top = function() return nil end } },
runner = { yield = function() end, resume = function() end },
overworld = { pushBattle = function() route[#route + 1] = "wipe" end },
}
Commands.start_battle(ctx, "wild", "FIXMON_C", 5)
T.eq(#route, 1, "start_battle pushes the battle exactly once")
T.eq(route[1], "wipe", "and routes it through the transition wipe")
-- with no overworld (a headless or menu-driven caller) it still works
local route2 = {}
local ctx2 = {
game = { data = Data, save = save,
stack = { push = function() route2[#route2 + 1] = "raw" end,
top = function() return nil end } },
runner = { yield = function() end, resume = function() end },
}
Commands.start_battle(ctx2, "wild", "FIXMON_C", 5)
T.eq(route2[1], "raw", "and falls back to a direct push with no overworld")
end
-- --------------------------------------------------- battle transition
-- The eight wipes, from pokered-c's battle_transition.c budget (derived from
-- battle_transitions.asm, then checked against the ROM side by side). The
-- port used a flat 40/24 for all eight.
local BT = require("src.render.BattleTransition")
local WIPES = {
doublecircle = 30, -- 10 steps x 3 frames
circle = 60, -- 20 x 3
spiralout = 120, -- 360 fills / 3 per frame
hstripes = 60, -- 20 x 3
vstripes = 54, -- 18 x 3
shrink = 54, -- 9 x 6
split = 54, -- 9 x 6
}
for id, frames in pairs(WIPES) do
T.eq(BT.STYLES[id].frames, frames, id .. " runs its asm frame budget")
end
-- The inward spiral writes one tile per iteration and calls
-- BattleTransition_TransferDelay3 every seventh -- and that helper is a
-- Delay3, three frames, not a one-frame transfer. Reading it as one frame
-- gives ~46 frames against the ROM's ~150, which is the exact mistake
-- pokered-c caught on a live comparison.
T.eq(BT.STYLES.spiralin.frames % 3, 0,
"the inward spiral advances in whole Delay3 units")
T.check(BT.STYLES.spiralin.frames >= 130 and BT.STYLES.spiralin.frames <= 170,
"the inward spiral lands near the ROM's ~150 frames, not the ~46 a "
.. "one-frame transfer would give (got "
.. tostring(BT.STYLES.spiralin.frames) .. ")")
-- The flash belongs to the two wild wipes only: BattleTransition_FlashScreen
-- is called from BattleTransition_Circle (:585) and _DoubleCircle (:628) and
-- nowhere else. A trainer battle's transition is the spiral, inward against
-- a weaker foe and outward against a stronger one
-- (wBattleTransitionSpiralDirection, :119-126).
do
local fakeRenderer = {}
local g = { data = Data, save = SaveData.newGame(), renderer = fakeRenderer,
stack = { push = function() end, pop = function() end,
top = function() return nil end } }
local wild = BT.new(g, function() end, { trainer = false, stronger = false })
T.eq(wild.style, "doublecircle", "a weak wild foe gets the double circle")
T.eq(wild.phase, "flash", "and flashes before the wipe")
local weak = BT.new(g, function() end, { trainer = true, stronger = false })
T.eq(weak.style, "spiralin", "a weaker trainer gets the inward spiral")
T.eq(weak.phase, "wipe", "with no flash in front of it")
local strong = BT.new(g, function() end, { trainer = true, stronger = true })
T.eq(strong.style, "spiralout", "a stronger trainer gets the outward spiral")
-- the flash is a palette write, so it veils the whole surface: it is
-- handed to the renderer in screen space rather than filling the 160x144
-- UI canvas, which at any zoom above 1x left the surround unlit
wild:draw()
T.check(fakeRenderer.screenVeil ~= nil,
"the flash publishes a screen-space veil to the renderer")
T.eq(#fakeRenderer.screenVeil, 2, "as a {shade, alpha} pair")
end
-- Returning to the overworld after a battle is a fade, not a cut:
-- DelayFrames 10 (home/overworld.asm:351-352) with the palettes still white,
-- then MapEntryAfterBattle's GBFadeInFromWhite (:749-753) = 24 frames.
do
local popped, done = 0, 0
local r = {}
local g = { data = Data, save = SaveData.newGame(), renderer = r,
stack = { push = function() end,
pop = function() popped = popped + 1 end,
top = function() return nil end } }
local fade = require("src.render.Transition").battleReturn(g,
function() done = done + 1 end)
-- solid white through the 10-frame hold
for i = 1, Timing.POST_BATTLE_RETURN do
fade:draw()
T.eq(r.screenVeil[1], 1, "the veil is white on hold frame " .. i)
T.eq(r.screenVeil[2], 1, "and fully opaque on hold frame " .. i)
fade:update(1 / 60)
end
-- then it steps off in three palette stages of 8 frames, the way
-- GBFadeIncCommon writes a palette and holds it (home/fade.asm:30-41)
fade:draw()
T.eq(r.screenVeil[2], 2 / 3, "the first fade step drops to two thirds")
for _ = 1, 8 do fade:update(1 / 60) end
fade:draw()
T.eq(r.screenVeil[2], 1 / 3, "the second step to one third")
for _ = 1, 8 do fade:update(1 / 60) end
fade:draw()
T.eq(r.screenVeil[2], 0, "the third clears it")
-- total duration, measured on a fresh instance (the staircase checks above
-- advanced this one)
local popped2, done2 = 0, 0
local g2 = { data = Data, save = SaveData.newGame(), renderer = {},
stack = { push = function() end,
pop = function() popped2 = popped2 + 1 end,
top = function() return nil end } }
local fade2 = require("src.render.Transition").battleReturn(g2,
function() done2 = done2 + 1 end)
local frames = 0
while done2 == 0 and frames < 500 do
fade2:update(1 / 60)
frames = frames + 1
end
T.eq(frames, Timing.POST_BATTLE_RETURN + Timing.FADE_IN_FROM_WHITE,
"the whole return is the 10-frame hold plus a 24-frame fade")
T.eq(popped2, 1, "and it pops itself exactly once")
T.check(popped >= 0, "the staircase instance is independent")
end
-- The spiral and circle walks generalise to an arbitrary grid, so a zoomed
-- or windowed surface wipes as one figure instead of a spiral in a box
-- surrounded by a square cascade.
do
local COLS_GB, ROWS_GB = 20, 18 -- the Game Boy's own tile grid
for _, style in ipairs({ "spiralin", "spiralout", "circle", "doublecircle" }) do
local order = BT.gridOrder(style, 40, 23)
T.check(order ~= nil, style .. " builds an order for an arbitrary grid")
T.eq(#order, 40 * 23, style .. " covers every tile of a 40x23 grid")
local seen, dup = {}, false
for _, t in ipairs(order) do
local k = t[1] .. "," .. t[2]
if seen[k] then dup = true end
seen[k] = true
end
T.check(not dup, style .. " visits each tile exactly once")
end
T.eq(BT.gridOrder("shrink", 40, 23), nil,
"geometry-shaped styles have no tile order and are extended as rects")
-- At exactly the Game Boy's grid, gridOrder hands back the ROM's own walk
-- rather than the generic one -- so an unzoomed window is the classic wipe.
-- BattleTransition_InwardSpiral fills 359 of the 360 tiles and leaves the
-- centre one to the final blackout, which is how you tell the two apart.
T.eq(#BT.gridOrder("spiralin", COLS_GB, ROWS_GB), 359,
"the classic grid gets the ROM's walk, not the generic spiral")
T.check(#BT.gridOrder("spiralin", COLS_GB + 1, ROWS_GB)
== (COLS_GB + 1) * ROWS_GB,
"one tile wider and it is the generic spiral, covering everything")
-- degenerate grids must not hang or error
T.eq(#BT.gridOrder("spiralin", 1, 1), 1, "a 1x1 grid is one tile")
T.eq(#BT.gridOrder("spiralout", 3, 1), 3, "a single-row grid walks straight")
end
-- SlidePlayerAndEnemySilhouettesOnScreen: 144 px at 2 px/frame
T.eq(Timing.BATTLE_SLIDE_IN_FRAMES, 72, "the silhouettes slide for 72 frames")
T.eq(Timing.BATTLE_SLIDE_PX_PER_FRAME, 2, "at 2 px per frame")
T.eq(Timing.BATTLE_SLIDE_IN_FRAMES * Timing.BATTLE_SLIDE_PX_PER_FRAME, 144,
"which is the SCX $90 the enemy side scrolls through")
T.eq(Timing.TRAINER_INTRO_SFX_GAP, 20,
"a trainer intro waits DelayFrames 20 before the balls and the text")
-- ------------------------------------------------------- catch-up clamping
-- Removing the warp fade in took away the counter that used to absorb the
-- map-load hitch, so discardCatchup has to handle the oversized dt the hitch
-- produces on the FOLLOWING frame -- otherwise the burst just moves one
-- frame later and shows up as a walk-animation slide (issue #93).
local FixedStep = require("src.core.FixedStep")
local steps = 0
FixedStep:init(function() steps = steps + 1 end)
FixedStep:update(1 / 60)
T.eq(steps, 1, "an ordinary frame runs exactly one logic step")
-- what the hitch does when nothing is armed
steps = 0
FixedStep:update(0.25)
T.check(steps > 10,
"an unclamped hitch frame burns a burst of steps before the next draw")
-- and with the clamp armed
FixedStep:init(function() steps = steps + 1 end)
FixedStep:discardCatchup()
steps = 0
FixedStep:update(0.25)
T.eq(steps, 1, "the frame after discardCatchup is clamped to one step")
steps = 0
FixedStep:update(1 / 60)
T.eq(steps, 1, "and the clamp expires after that one frame")
-- ...and the absorbed frame must not hand the accumulator back sitting on a
-- step boundary. A residual of zero has no margin on the long side, so the
-- accumulator settles a hair under one step and stays there, flipping between
-- 0 and 2 steps on sub-millisecond frame wobble for the rest of the session,
-- which is what made pacing erratic forever after a route seam (issue #487).
FixedStep:init(function() steps = steps + 1 end)
FixedStep:discardCatchup()
FixedStep:update(0.25)
T.check(FixedStep.accum > FixedStep.STEP * 0.25
and FixedStep.accum < FixedStep.STEP * 0.75,
"the absorbed hitch frame leaves the accumulator mid-step, not on a boundary")
T.finish("timing parity")
+50
View File
@@ -0,0 +1,50 @@
-- The title screen and the intro fill the window (aspect preserved, bars on
-- the long axis) instead of sitting at the fixed integer scale, and the
-- overworld's survey zoom does not shrink them.
--
-- Both halves shipped broken together. Renderer:uiScale steps the UI down one
-- whole integer per zoom-out step, which is right for the overworld -- a
-- full-size dialogue box over a shrunken map looks wrong -- but it was applied
-- unconditionally, so a saved zoom of -2 also drew the TITLE SCREEN at a
-- reduced scale, in a window showing no map at all. The fix gates the
-- step-down on a world actually being on screen, and opts these two states
-- into the fill scale the battle "fill" size already uses.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game = require("src.core.Game")
local TitleState = require("src.ui.TitleState")
local IntroMovie = require("src.ui.IntroMovie")
-- --------------------------------------------------------------- the opt-in
local title = setmetatable({}, { __index = TitleState })
local intro = setmetatable({}, { __index = IntroMovie })
T.eq(title:wantsFillScale(), true, "the title screen asks for the fill scale")
T.eq(intro:wantsFillScale(), true, "and so does the intro")
-- neither reads options, so this must hold with no game attached at all --
-- the title screen is up before a save is loaded
T.eq(TitleState.wantsFillScale(nil), true,
"the title screen fills with no game or save behind it")
T.eq(IntroMovie.wantsFillScale(nil), true, "and so does the intro")
-- ------------------------------------------------------------- the stack scan
local function stack(...) return { states = { ... } } end
local overworld = {} -- no wantsFillScale at all, like every other state
T.eq(Game.fillScaleInStack(stack(title)), true,
"the shared scan picks the title screen up")
T.eq(Game.fillScaleInStack(stack(intro)), true, "and the intro")
T.eq(Game.fillScaleInStack(stack(overworld)), false,
"and the overworld still draws at the fixed scale")
-- the title screen opens the CONTINUE/NEW GAME menu and the options menu on
-- top of itself; those must not snap the surface back for a frame, the same
-- whole-stack rule a battle relies on
T.eq(Game.fillScaleInStack(stack(title, {})), true,
"a menu opened over the title screen keeps it filling")
T.finish("title fill scale")
+80
View File
@@ -0,0 +1,80 @@
-- Per-orientation touch layouts and the button size setting (#633). The
-- overlay used to keep exactly one positions table, so laying the pad out
-- in landscape rewrote the portrait layout; now each orientation owns a
-- {positions, scale} bucket picked from the safe rect's aspect, and the
-- editor's -/+ scales every control in the active one. No pokered cite:
-- the on-screen pad is a port-only affordance (Xelu CC0 art).
-- luajit tests/engine/touch_orientation_bug633.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 TC = require("src.core.TouchControls")
-- per-orientation buckets round-trip independently, scale is clamped
local cfg = TC.normalizeConfig({
layouts = {
portrait = { positions = { dpad = { x = 0.2, y = 0.9 } }, scale = 1.25 },
landscape = { positions = { dpad = { x = 0.1, y = 0.5 } }, scale = 99 },
},
})
eq(cfg.layouts.portrait.positions.dpad.x, 0.2, "portrait bucket kept")
eq(cfg.layouts.landscape.positions.dpad.x, 0.1, "landscape bucket kept")
eq(cfg.layouts.portrait.scale, 1.25, "portrait scale kept")
eq(cfg.layouts.landscape.scale, TC.SCALE_MAX, "out-of-range scale clamps")
-- steer the safe rect by hand: tall = portrait, wide = landscape
love.window = love.window or {}
local oldSafe = love.window.getSafeArea
local function setRect(w, h)
love.window.getSafeArea = function() return 0, 0, w, h end
-- getDimensions bounds the safe rect (SafeArea clamps to the drawable
-- window), so keep both in step
love.graphics.getDimensions = function() return w, h end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
end
TC:init()
setRect(380, 720)
TC:applyOptions({
touchControls = {
enabled = true,
positions = { dpad = { x = 0.25, y = 0.75 } },
},
})
-- the size setting scales every control in the active (portrait) bucket
TC:setScale(1.5)
setRect(380, 720)
local big = TC:layout()
eq(TC.orientation, "portrait", "tall safe rect is portrait")
check(big.a.w > TC.defaultLayout(380, 720).a.w, "scale grows buttons")
-- rotating swaps buckets, and a landscape drag leaves portrait alone
TC:setScale(1)
setRect(800, 400)
TC:layout()
eq(TC.orientation, "landscape", "wide safe rect is landscape")
TC:setControlCenter("dpad", 700, 300)
eq(TC.layouts.portrait.positions.dpad.x, 0.25,
"landscape drag leaves the portrait layout alone (#633)")
-- Reset clears only the orientation on screen
TC:clearPositions()
check(TC.layouts.landscape.positions == nil,
"Reset wipes the landscape overrides")
eq(TC.layouts.portrait.positions.dpad.x, 0.25,
"and the portrait layout survives it (#633)")
-- config() snapshots both buckets for the editor's save path
local snap = TC:config()
eq(snap.layouts.portrait.positions.dpad.x, 0.25, "config carries portrait")
check(snap.layouts.landscape.positions == nil, "config carries landscape")
if oldSafe then love.window.getSafeArea = oldSafe
else love.window.getSafeArea = nil end
T.finish("touch_orientation_bug633")
+123
View File
@@ -0,0 +1,123 @@
-- UI LAYOUT (save.options.uiLayout): "centered" keeps every element where it
-- was drawn in the 160x144 canvas, so the letterbox centres the whole screen
-- the way the port composed it before edge docking existed. "dynamic" opts
-- into docking: the dialogue box to the window's bottom edge, the START menu
-- to its top right.
--
-- Centered is the DEFAULT. Docking is a real change to where screen
-- furniture sits, so it is opt-in rather than something a player has to
-- discover and turn off.
--
-- One gate, at Renderer:setUIAnchor, so the switch covers the dialogue box,
-- its YES/NO, the START menu and anything added later without any of them
-- knowing the option exists.
-- luajit tests/engine/ui_layout_option.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game = require("src.core.Game")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
-- ------------------------------------------------------------- the default
T.eq(SaveData.newGame().options.uiLayout, "centered",
"a new game starts centered, not docked")
-- ------------------------------------------------------ reading the option
-- Only the explicit "dynamic" switches docking on. Everything else means
-- centered, which is what makes this safe for a save written before the
-- option existed: the key is simply absent and the player keeps the layout
-- they already had.
T.eq(Game.dynamicUI({ options = { uiLayout = "dynamic" } }), true,
"DYNAMIC turns edge docking on")
T.eq(Game.dynamicUI({ options = { uiLayout = "centered" } }), false,
"CENTERED leaves it off")
T.eq(Game.dynamicUI({ options = {} }), false,
"a save from before the option existed is centered")
T.eq(Game.dynamicUI({}), false, "a save with no options at all is centered")
T.eq(Game.dynamicUI(nil), false, "and no save at all is centered")
-- ------------------------------------------------------------- the gate
local function anchorsAfter(opts)
Renderer.uiAnchors = nil
Renderer.uiAnchorHold = opts.hold or false
Renderer.uiCentered = opts.centered or false
-- the dialogue box's own declaration (TextBox:draw)
Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
-- and the START menu's (Menu:draw, anchor "topright")
Renderer:setUIAnchor(80, 0, 80, 88, "topright")
local n = #(Renderer.uiAnchors or {})
Renderer.uiAnchors, Renderer.uiAnchorHold, Renderer.uiCentered =
nil, false, false
return n
end
T.eq(anchorsAfter({ centered = true }), 0,
"CENTERED: neither the dialogue box nor the START menu leaves the canvas")
T.eq(anchorsAfter({ centered = false }), 2,
"DYNAMIC: both dock to the window edge")
-- the battle hold is unchanged by any of this -- a battle still keeps its own
-- prompts inside its screen even with DYNAMIC on (see battle_fixed_menu_scale)
T.eq(anchorsAfter({ centered = false, hold = true }), 0,
"a battle still holds the anchors while DYNAMIC is on")
T.eq(anchorsAfter({ centered = true, hold = true }), 0, "and with it off")
-- ------------------------------------------------- the scale half of it
-- CENTERED is a FIXED letterbox, so the UI must not follow the survey zoom
-- either: the box that stopped moving must not start resizing instead.
local g = love.graphics
local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
g.getDimensions = function() return 640, 576 end
g.getPixelDimensions = function() return 640, 576 end
T.eq(Renderer:fitScale(), 4, "the fixture window fits the classic surface at 4x")
local Zoom = require("src.render.Zoom")
local function scaleAt(offset, centered)
local oldOff, oldActive, oldCentered =
Zoom.offset, Renderer.worldActive, Renderer.uiCentered
-- worldActive true: a live overworld pass, the one case DYNAMIC steps down
Zoom.offset, Renderer.worldActive, Renderer.uiCentered = offset, true, centered
local s = Renderer:uiScale()
Zoom.offset, Renderer.worldActive, Renderer.uiCentered =
oldOff, oldActive, oldCentered
return s
end
T.eq(scaleAt(0, true), 4, "CENTERED at rest is the fit scale")
T.eq(scaleAt(-2, true), 4, "CENTERED zoomed out is STILL the fit scale")
T.eq(scaleAt(0, false), 4, "DYNAMIC at rest matches it")
T.eq(scaleAt(-2, false), 2, "DYNAMIC zoomed out steps the UI down, as before")
g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
-- ------------------------------------------------------------- the row
-- fixture data, not Data:load(): this tier runs ROM-free in CI, so a real
-- load has no data/generated/ to read and takes the suite down with it
local OptionsMenu = require("src.ui.OptionsMenu")
local Data = T.fixtures.load()
require("src.render.Font").load(Data)
local game = { data = Data, save = SaveData.newGame(),
stack = { states = {}, push = function() end,
pop = function() end, top = function() end } }
local menu = OptionsMenu.new(game)
local row
for _, r in ipairs(menu.rows) do
if r.id == "uiLayout" then row = r end
end
T.check(row ~= nil, "OPTIONS carries a UI LAYOUT row")
T.eq(row.value(game), "CENTERED", "and it opens on CENTERED")
row.step(game, 1)
T.eq(game.save.options.uiLayout, "dynamic", "stepping it turns docking on")
T.eq(row.value(game), "DYNAMIC", "and the row says so")
row.step(game, 1)
T.eq(game.save.options.uiLayout, "centered", "stepping again returns to it")
T.finish("ui layout option")
+18
View File
@@ -67,6 +67,21 @@ Input:joystickpressed(nil, 1)
Input:step()
check(Input:isDown("a"), "raw joystick primary button presses A")
-- A pad SDL already maps sends both event pairs for one physical button,
-- and its raw indices are per-driver (iOS puts the D-pad on 7..10), so the
-- raw fallback must stand down for it (#620).
local mapped = { isGamepad = function() return true end }
Input:reset()
Input:joystickpressed(mapped, 9)
Input:joystickhat(mapped, 1, "l")
Input:joystickaxis(mapped, 1, -0.9)
Input:step()
check(not Input:isDown("select"), "mapped pad ignores raw button indices")
check(not Input:isDown("left"), "mapped pad ignores raw hat and axis")
Input:gamepadpressed(mapped, "dpleft")
Input:step()
check(Input:isDown("left"), "mapped pad still routes through gamepad events")
-- The launcher has a separate virtual cursor, so prove generic joystick
-- events reach its left-stick and D-pad state too.
local RomImporter = require("src.import.RomImporter")
@@ -90,6 +105,9 @@ check(not importer._padDir.dpright,
"raw joystick hat release clears the launcher cursor")
importer:joystickpressed(nil, 1)
check(clicked, "raw joystick primary button clicks the launcher cursor")
clicked = false
importer:joystickpressed(mapped, 1)
check(not clicked, "mapped pad does not double-click the launcher cursor")
-- Drivers that only inject pressQueue still get a one-step hold.
Input:reset()
+38 -9
View File
@@ -621,7 +621,9 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias.
-- Issue #689: blocks 45 and 49 also form tables with tile $37 on their
-- flat surfaces; CELADON_DINER uses all three.
do
local aliases = PaletteFX.TILE_ALIASES
local roof = aliases and aliases.CELADON_MART_ROOF
@@ -630,11 +632,20 @@ do
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
check(diner == roof,
"diner reuses the same lobby table-top alias as the mart roof")
check(#diner == 3, "three LOBBY table blocks have the tile alias")
local al = diner and diner[1]
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
and al.group == 5 and al.cells[5] and al.cells[6]
and al.cells[9] and al.cells[10],
"lobby table-top alias remaps block 29 cells 5/6/9/10")
al = diner and diner[2]
check(al and al.block == 45 and al.tile == 0x37 and al.alias == 0x5a
and al.group == 5 and al.cells[13] and al.cells[14],
"lobby table-top alias remaps block 45 cells 13/14")
al = diner and diner[3]
check(al and al.block == 49 and al.tile == 0x37 and al.alias == 0x5a
and al.group == 5 and al.cells[1] and al.cells[2],
"lobby table-top alias remaps block 49 cells 1/2")
end
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
@@ -673,7 +684,7 @@ for _, id in ipairs({ "doublecircle", "spiralin", "circle", "spiralout",
"hstripes", "shrink", "vstripes", "split" }) do
check(transitions:get(id) ~= nil, "the engine registers wipe " .. id)
end
check(transitions:get("warp_fade").frames == 12,
check(transitions:get("warp_fade").frames == 32,
"the warp fade registers as a transitions record")
check(transitions:get("white_flash").frames == 7,
"the white flash registers as a transitions record")
@@ -689,8 +700,23 @@ end
local retimed = { transitions = { warp_fade = { kind = "fade", frames = 30 } } }
check(Transition.new({ data = retimed }).frames == 30,
"a patched warp_fade record changes the fade length")
check(Transition.new({ data = { transitions = {} } }).frames == 12,
"an unregistered id falls back to the built-in 12 frames")
check(Transition.new({ data = { transitions = {} } }).frames == 32,
"an unregistered id falls back to the built-in 32 frames")
-- issue #607: the warp fade is GBFadeOutToBlack's four palette writes, each
-- held eight frames (home/fade.asm:43-60) -- the map is untouched through the
-- first hold and solid black through the last, with nothing tweened between.
do
local staircase = Transition.new({ data = { transitions = {} } })
staircase.phase = "out"
local want = { { 0, 0 }, { 7, 0 }, { 8, 1 / 3 }, { 15, 1 / 3 },
{ 16, 2 / 3 }, { 23, 2 / 3 }, { 24, 1 }, { 31, 1 } }
for _, w in ipairs(want) do
staircase.t = w[1]
check(staircase:alpha() == w[2],
("the warp fade holds its shade at frame %d"):format(w[1]))
end
end
-- issue #121: with the survey-zoom world pass active, the warp fade must
-- darken the full window composite (via Renderer.worldFadeAlpha), not just
@@ -711,7 +737,9 @@ do
Renderer:init()
local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } })
fade.t = 6 -- mid fade-out (12 frames)
-- GBFadeOutToBlack steps every eight frames, so frame 16 of the 32 frame
-- fade is its third palette write: two thirds of the way to black (#607)
fade.t = 16
fade.phase = "out"
Renderer:beginFrame(true)
@@ -719,7 +747,7 @@ do
Renderer:endWorldPass()
check(Renderer.worldActive == true, "world pass stays marked active until endFrame")
fade:draw()
check(Renderer.worldFadeAlpha == 0.5,
check(Renderer.worldFadeAlpha == 2 / 3,
"warp fade hands mid-out alpha to the world composite overlay")
check(#rects == 0,
"warp fade does not paint the 160x144 UI letterbox while the world pass is up")
@@ -730,9 +758,10 @@ do
local fadeRect
for _, r in ipairs(rects) do
-- endFrame's letterbox clear is also a full-window black fill (a == 1);
-- the warp overlay is the half-alpha one Transition requested
-- the warp overlay is the two-thirds-alpha one Transition requested
if r.mode == "fill" and r.x == 0 and r.y == 0
and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then
and r.w == 640 and r.h == 576 and r.r == 0
and math.abs(r.a - 2 / 3) < 1e-6 then
fadeRect = r
end
end
@@ -742,7 +771,7 @@ do
-- without a world pass (opaque UI states), keep the classic UI rect
Renderer:beginFrame(false)
fade.t = 6
fade.t = 16
fade.phase = "out"
rects = {}
fade:draw()
+43
View File
@@ -212,6 +212,49 @@ eq(Handshake.mods(overhaulGame)[1].affectsLink, true, "and rides the hello")
eq(Handshake.hello(tweakGame, "trade").linkModified, true,
"the hello carries the flag a v1 peer is judged against")
-- #501: a declared translation is invisible to the wire, so online play
-- lets an English install meet a Spanish one
local languageGame = loadMods({
["mods/espanol/manifest.json"] =
'{"id":"espanol","name":"espanol","version":"1.0.0","entry":"main.lua",' ..
'"language":true,"category":"LANGUAGE"}',
["mods/espanol/main.lua"] = [[
return function(mod)
mod.content.strings:override("But, it failed!", "Pero fallo!")
end
]],
})
eq(Handshake.mods(languageGame)[1].language, true,
"the translation flag rides the hello")
eq(Handshake.mods(languageGame)[1].affectsLink, false,
"and a translation never claims the fingerprint")
eq(Handshake.linkModified(languageGame), false,
"it leaves the link surface alone")
eq(Handshake.onlineAllowed(languageGame), true, "so online play allows it")
eq(Fingerprint.compute(languageGame.data, Handshake.mods(languageGame)),
Fingerprint.compute(languageGame.data, {}),
"and two peers reading different languages hash the same")
eq(Handshake.onlineAllowed(tweakGame), false,
"a content mod that touches a species still forces vanilla")
eq(Handshake.onlineAllowed(bareGame), true, "vanilla still goes online")
-- the flag is a claim, not a pass: a mod that writes gameplay is not a
-- translation however its manifest describes itself
local fakeLanguageGame = loadMods({
["mods/faux/manifest.json"] =
'{"id":"faux","name":"faux","version":"1.0.0","entry":"main.lua","language":true}',
["mods/faux/main.lua"] = [[
return function(mod)
mod.content.strings:override("But, it failed!", "It worked!")
mod.content.pokemon:patch("PIKA", { baseStats = { attack = 200 } })
end
]],
})
eq(Handshake.onlineAllowed(fakeLanguageGame), false,
"a self-declared translation that patches a species is still blocked")
eq(#Handshake.onlineBlockers(fakeLanguageGame), 1,
"and the mod manager restart prompt names it")
-- ------- builtin records are private per dataset
-- two independent loads must not share record tables: an edit through one
+34 -3
View File
@@ -779,8 +779,9 @@ check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
-- ------- intro.oak_speech.build
local vanillaSteps = OakSpeech.defaultSteps(oak)
check(#vanillaSteps == 9, "vanilla speech has nine steps")
check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink",
check(#vanillaSteps == 11, "vanilla speech has eleven steps")
check(vanillaSteps[1].id == "oak_welcome"
and vanillaSteps[#vanillaSteps].id == "shrink",
"vanilla speech anchors start and end")
hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech)
@@ -799,7 +800,7 @@ hooks:removeOwner("fixture")
hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
built = oak:buildSteps()
check(#built == 9 and built[1].id == "oak_welcome",
check(#built == #vanillaSteps and built[1].id == "oak_welcome",
"a non-table intro.oak_speech.build result degrades to vanilla")
check(logged("intro.oak_speech.build returned"),
"the intro build degrade is logged")
@@ -1084,6 +1085,36 @@ ms:deleteProfile(easy)
check(ms:findProfile("HARD") == nil
and ms:optionsTable().activeProfile == nil, "delete clears the profile")
-- #593: a profile carries mod options and the per-version save slot, and
-- round-trips through the .g1rmodlist export
local ModProfile = require("src.mods.ModProfile")
ms:setOption("okmod", "hardcore", true)
ms:saveCurrentAs()
mgame.stack:top().onDone("SHARE")
mgame.stack:pop()
local shared = ms:findProfile("SHARE")
check(shared.options.okmod.hardcore == true,
"a profile snapshots per-mod options, not just the enable set")
ms:setOption("okmod", "hardcore", false)
ms:applyProfile(shared)
check(loader.modOptions.okmod.hardcore == true,
"applying a profile restores its mod options")
local wire = ModProfile.encode(shared)
local back = ModProfile.decode(wire)
check(back and back.name == "SHARE" and back.options.okmod.hardcore == true,
"a .g1rmodlist body round-trips through the data-only parser")
check(ModProfile.decode("return {}") == nil, "a non-modlist file is refused")
check(#ModProfile.missingIds({ enabled = { ghost = true } }, ms.byId) == 1,
"a profile naming an uninstalled mod reports it missing")
local seedOpts = { modProfiles = {} }
ModProfile.ensureFirst(seedOpts, ms.status.available, {})
check(#seedOpts.modProfiles == 1 and seedOpts.modProfiles[1].name == "PROFILE 1"
and seedOpts.modProfilesSeeded == true,
"the pre-profiles setup migrates into PROFILE 1 once")
seedOpts.modProfiles = {}
ModProfile.ensureFirst(seedOpts, ms.status.available, {})
check(#seedOpts.modProfiles == 0, "seeding never runs twice")
-- permissions rows
local permy = manifest("permy", { permissions = { "network" } })
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
+4
View File
@@ -1011,6 +1011,10 @@ do
"the boot path seeded field.palettes")
check(Data.field.playerSprites.walk == "SPRITE_RED",
"the boot path seeded field.playerSprites")
check(Data.field.playerSprites.surf == "SPRITE_SEEL",
"the surf sprite defaults to the Seel")
check(Data.field.playerSprites.surfPikachu == "SPRITE_SURFING_PIKACHU",
"the surfing-Pikachu sprite defaults to SPRITE_SURFING_PIKACHU (RFC 0001; Yellow ride)")
check(Data.field.badgeGates.ROUTE_22_GATE.passedFlag == "PASSED_ROUTE22_GATE",
"the boot path filled the gaps in a stamped key")
check(Data.constants.world.stepFrames == 16,
+20
View File
@@ -34,6 +34,16 @@ Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
-- spy on Sound.play so the starter-received jingle beat is observable
-- without real audio (parity_C does the same for its arrival SFX)
local Sound = require("src.core.Sound")
local realSoundPlay = Sound.play
local played = {}
Sound.play = function(data, name)
played[#played + 1] = name
return realSoundPlay(data, name)
end
-- pumps a script coroutine to completion; pressFn returns the Input.pressed
-- table for this frame (default: mash A through text/ask/naming)
local function runScript(script, pressFn)
@@ -64,6 +74,14 @@ check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set")
eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter")
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
"starter joined the party")
-- #668: OaksLabReceivedMonText carries sound_get_key_item; the jingle
-- must fire as the starter is handed over (once for the player's mon,
-- once for the rival's counter-pick)
local jingles = 0
for _, name in ipairs(played) do
if name == "Get_Key_Item" then jingles = jingles + 1 end
end
eq(jingles, 2, "starter + rival counter-pick both play the Get_Key_Item jingle (#668)")
-- A-mash accepts the nickname prompt and fills NamingScreen with A's
check(Game.save.party[1].nickname == "AAAAAAAAAA",
"starter nickname prompt accepted (AskName / #137)")
@@ -182,4 +200,6 @@ do
Game.save = realSave
end
Sound.play = realSoundPlay
S.finish()
+5 -2
View File
@@ -40,9 +40,12 @@ end
check(sf.SEAFOAM_ISLANDS_B3F and #sf.SEAFOAM_ISLANDS_B3F.holes == 2,
"SEAFOAM_ISLANDS_B3F still has its own 2 holes (B3F->B4F, unrelated to this change)")
-- data/scripts/seafoam.lua no longer force-shows the B2F boulders on entry
-- data/scripts/seafoam.lua no longer force-shows the B2F boulders on entry.
-- The map DOES own an onStep now (the player's hole falls, #599), so the
-- assertion is about onEnter specifically, not about the map table.
local seafoamScripts = require("data.scripts.seafoam")
check(seafoamScripts.SEAFOAM_ISLANDS_B2F == nil,
check(seafoamScripts.SEAFOAM_ISLANDS_B2F == nil
or seafoamScripts.SEAFOAM_ISLANDS_B2F.onEnter == nil,
"data/scripts/seafoam.lua no longer hardcodes a SEAFOAM_ISLANDS_B2F onEnter hook")
-- === (2) functional 1F -> B1F -> B2F -> B3F -> B4F cascade ===
+31 -1
View File
@@ -19,6 +19,7 @@ local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local Timing = require("src.core.Timing")
-- Silence audio: BattleState reaches both modules through require() at the
-- call site, so patching the fields here is what the battle ends up calling.
@@ -40,8 +41,11 @@ local function makeGame(party)
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
-- isDown as well as wasPressed: battle text collapses PrintLetterDelay
-- while A or B is held, and the typing path reads it every frame
return { data = Data, save = save, stack = stack,
input = { wasPressed = function(_, b) return press[b] == true end } }
input = { wasPressed = function(_, b) return press[b] == true end,
isDown = function(_, b) return press[b] == true end } }
end
-- One fixed step with A held. updateQueue only reads the button once a page
@@ -128,6 +132,12 @@ end
check(prompted, "a typed-out intro page raises the prompt flag (blinking arrow)")
eq(wild.introBalls, true, "the ball row is still up while the arrow blinks")
-- PromptText writes the arrow and then runs ProtectedDelay3 before
-- ManualTextScroll starts watching the joypad (home/text.asm:213-217), so the
-- page ignores the button for TEXT_PRE_ADVANCE frames. The loop above breaks
-- on the frame the arrow goes up, which is inside that hold.
for _ = 1, Timing.TEXT_PRE_ADVANCE do press.a = false wild:update(1 / 60) end
-- press A: ClearSprites + both ClearScreenAreas, then the enemy HUD
step(wild)
eq(wild.msgPrompt, nil, "the prompt flag clears on the A press")
@@ -196,6 +206,26 @@ check(foeDone ~= nil and sentFrame ~= nil and foeDone < sentFrame,
"the slide finishes BEFORE TrainerSentOutText (core.asm:1308-1310)")
eq(tr.showEnemyTrainer, false, "only then is the trainer pic taken down")
-- ...and the mon is not standing in that slot yet. The pic walks off at
-- core.asm:1308-1310 but AnimateSendingOutMon does not run until :1421-1434,
-- so the slot is empty for the whole of TrainerSentOutText -- the mon is
-- still in its ball. It used to pop in full-size the instant the trainer
-- left and sit there through the text, so the grow-in played over a mon that
-- had already arrived; the mid-battle replacement always held it back.
check(tr.enemySendingOut, "the foe's mon stays in its ball while announced")
eq(tr:growInScale(tr.enemy), nil, "and nothing is growing into the slot yet")
local firstGrowScale
for _ = 1, 400 do
step(tr)
if tr.growIn and tr.growIn.battler == tr.enemy then
firstGrowScale = tr:growInScale(tr.enemy)
break
end
end
eq(firstGrowScale, 0, "the send-out opens on the ball beat, not a finished pic")
eq(tr.enemySendingOut, false, "which is when the slot is handed over")
-- and the window never reopens: drive the rest of the intro out
for _ = 1, 400 do
step(tr)
+8 -2
View File
@@ -160,7 +160,11 @@ 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
-- the lit cave may only appear once the blink hands the screen back.
-- The lighting itself happens as the message closes the menu, ahead of
-- the blink like the asm: in ADVANCED that call rebakes every resident
-- map, and behind the blink's completion that cost was spent with a
-- blank white frame as the newest thing on screen (#610).
-- =====================================================================
Game.save.flashLit = false
Game.save.party = { mkMon("PIKACHU", "FLASH") }
@@ -174,7 +178,9 @@ 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")
-- lit before the blink is pushed, so no rebuild can run while the white
-- frame is the newest one presented (#610); the blink hides it either way
eq(ow.dark, false, "the tunnel is lit by the time 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")
+90
View File
@@ -0,0 +1,90 @@
-- Parity test: nothing expensive may ride the FLASH white-out's completion
-- callback (#610). Transition.WhiteFlash is opaque and paints the whole
-- 160x144 solid white, so whatever runs from its onDone runs with a blank
-- white frame as the newest thing the player has been shown. Lighting the
-- cave is exactly that kind of work: in the ADVANCED colour mode
-- OverworldState:setDark drops every resident map and rebakes the tileset
-- atlas pixel by pixel (#383), which on a phone is seconds of frozen white
-- and reads as a lockup. engine/menus/start_sub_menus.asm .flash clears
-- wMapPalOffset before PrintText and calls GBPalWhiteOutWithDelay3 last of
-- all, so the port lights the cave as the message closes the menu and
-- leaves the blink a plain 7-frame blink with no work attached.
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 flash blink bug610")
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()
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 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
-- dismiss exactly one box, 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
Game.save.flashLit = false
Game.save.party = { mkMon("PIKACHU", "FLASH") }
Game.save.inventory = { BOULDERBADGE = true }
popAll()
Game.stack:push(OW, "ROCK_TUNNEL_1F", 15, 4, "down")
local ow = Game.stack:top()
eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH")
local pm = PartyMenu.new(Game)
Game.stack:push(pm)
frame({ "a" }) -- open the field-move submenu on PIKACHU
for _ = 2, 3 do frame({ "down" }) end
frame({ "a" }) -- FLASH
drainOne() -- dismiss _FlashLightsAreaText
local blink = Game.stack:top()
check(blink ~= nil and blink.isOpaque == true and type(blink.frames) == "number",
"the blink follows the FLASH message")
-- the invariant this test exists for: the blink carries no work
eq(blink.onDone, nil, "the FLASH blink has no completion callback (#610)")
eq(ow.dark, false, "the cave is already lit when the blink starts")
local guard = 0
while Game.stack:top() ~= ow and guard < 240 do
guard = guard + 1
frame({})
end
eq(Game.stack:top(), ow, "FLASH ends on the map")
eq(ow.dark, false, "the cave stays lit after the blink")
eq(Game.save.flashLit, true, "FLASH is recorded on the save")
popAll()
S.finish()
+71
View File
@@ -0,0 +1,71 @@
-- Parity test: the Seafoam Islands floor holes drop the PLAYER a floor,
-- not just the boulders (#599).
--
-- scripts/SeafoamIslands1F.asm / B1F.asm / B2F.asm / B3F.asm each set
-- wDungeonWarpDestinationMap and call IsPlayerOnDungeonWarp with their
-- SeafoamNHolesCoords list; data/maps/special_warps.asm DungeonWarpList /
-- DungeonWarpData turn (destination map, wCoordIndex) into the landing
-- cell. CAVERN $22 is walkable, so without an onStep the player just
-- stood on the hole.
--
-- Self-contained; run via `luajit tests/parity_seafoam_holes.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 seafoam holes")
local check, eq = S.check, S.eq
local M = require("data.scripts.seafoam")
local function owRecording()
local warps = {}
return {
player = { facing = "up" },
startWarpTo = function(_, mapId, x, y, facing)
warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing }
end,
}, warps
end
-- hole cell -> map, landing cell (DungeonWarpData)
local CASES = {
{ "SEAFOAM_ISLANDS_1F", 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
{ "SEAFOAM_ISLANDS_1F", 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 },
{ "SEAFOAM_ISLANDS_B1F", 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
{ "SEAFOAM_ISLANDS_B1F", 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 },
{ "SEAFOAM_ISLANDS_B2F", 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
{ "SEAFOAM_ISLANDS_B2F", 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 },
{ "SEAFOAM_ISLANDS_B3F", 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
{ "SEAFOAM_ISLANDS_B3F", 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 },
}
for _, c in ipairs(CASES) do
local hooks = M[c[1]]
check(hooks ~= nil and hooks.onStep ~= nil, c[1] .. " has an onStep hole trigger")
local ow, warps = owRecording()
check(hooks.onStep({}, ow, c[2], c[3]), c[1] .. " consumes the hole step")
eq(#warps, 1, c[1] .. " fires exactly one fall")
eq(warps[1].mapId, c[4], c[1] .. " falls to " .. c[4])
eq(warps[1].x, c[5], c[1] .. " lands at x=" .. c[5])
eq(warps[1].y, c[6], c[1] .. " lands at y=" .. c[6])
eq(warps[1].facing, "up", "facing is preserved across the fall")
end
-- a neighboring floor cell is ignored
do
local ow, warps = owRecording()
eq(M.SEAFOAM_ISLANDS_1F.onStep({}, ow, 18, 6), false, "a floor cell is ignored")
eq(#warps, 0, "no fall fires off the hole")
end
-- the hole tiles stay walkable CAVERN holes, and are not map warp events
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.SEAFOAM_ISLANDS_1F) then Data:load() end
local MapLoader = require("src.world.MapLoader")
for _, c in ipairs(CASES) do
local map = MapLoader.load(Data, c[1])
check(map:isWalkableCell(c[2], c[3]), c[1] .. " hole tile is walkable")
eq(map:warpPadOrHoleAt(c[2], c[3]), "hole", c[1] .. " collision tile is CAVERN $22")
check(map:warpAtCell(c[2], c[3]) == nil, c[1] .. " hole is not a map warp event")
end
S.finish()
+41
View File
@@ -265,6 +265,47 @@ Flags.set(Game.save, "EVENT_BEAT_ROUTE12_SNORLAX")
result = ItemEffects.use(Data, Game.save, "POKE_FLUTE", nil, nil, nil, owAdjacent)
eq(result, "flute_field", "an already-beaten Snorlax doesn't wake again")
-- === 9) a beaten Snorlax never stands in the road again: the routes'
-- onEnter reconciles the object toggle from EVENT_BEAT_ROUTEnn_SNORLAX,
-- so a save that lost the toggle (mod toggle, old build) is repaired
-- on entry instead of sealing the route -- the flute refuses to wake
-- a beaten Snorlax, which is what left #585 stuck ===
local OverworldState = require("src.world.OverworldController")
local function objOf(mapId, objName)
for _, obj in ipairs(Data.maps[mapId].objects or {}) do
if obj.name == objName then return obj end
end
end
for _, route in ipairs({
{ "ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX" },
{ "ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX" },
}) do
local mapId, objName, beatFlag = route[1], route[2], route[3]
local enter = mapScripts.get(mapId).onEnter
check(type(enter) == "function", mapId .. " has an onEnter hook")
enter = type(enter) == "function" and enter or function() end
-- unbeaten: entering must not remove the sleeper
local save = SaveData.newGame()
enter({ save = save }, nil)
eq(save.objectToggles and save.objectToggles[mapId]
and save.objectToggles[mapId][objName], nil,
objName .. " is left alone while it has not been beaten")
check(OverworldState.objectVisible(save, mapId, objOf(mapId, objName)),
objName .. " still spawns before the flute wakes it")
-- beaten but still toggled visible (the #585 save state): repaired
save.flags[beatFlag] = true
save.objectToggles = save.objectToggles or {}
save.objectToggles[mapId] = { [objName] = true }
enter({ save = save }, nil)
eq(save.objectToggles[mapId][objName], false,
objName .. " is hidden again once " .. beatFlag .. " is set")
check(not OverworldState.objectVisible(save, mapId, objOf(mapId, objName)),
objName .. " no longer spawns on a beaten route")
end
-- restore the real commands for later suites
Commands.show_text = origShow
Commands.start_battle = origStart
+128
View File
@@ -0,0 +1,128 @@
-- Parity port: Yellow's IsSurfingPikachuInParty (home/map_objects.asm).
-- When the party mon that knows SURF is a Pikachu, the player's surf sprite
-- swaps from the default (the Seel) to a Pikachu overworld sheet. This
-- mirrors vanilla Yellow, which repoints wSpritePlayerStatePtr at the
-- surfing-Pikachu sheet during the ride.
--
-- Covers the engine change that adds field.playerSprites.surfPikachu,
-- Player.surfPikachuSprite, OverworldState:syncSurfingPikachu, and the
-- pose() sprite-pick switch. No sprite bytes are read -- the assertions
-- compare the chosen SpriteRenderer's backing def, so the test is ROM-free
-- against the fixture dataset and runs in CI without an import.
--
-- Self-contained; run via:
-- luajit tests/parity_surfing_pikachu_sprite.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.CINNABAR_ISLAND) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
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 surfing-pikachu sprite")
local check, eq = S.check, S.eq
-- field default: the new key is seeded alongside the existing surf sprite,
-- so a mod-free boot gets the Pikachu overworld sheet by default
check(Data.field.playerSprites.surfPikachu == "SPRITE_SURFING_PIKACHU",
"field.playerSprites.surfPikachu defaults to SPRITE_SURFING_PIKACHU (RFC 0001; Yellow ride)")
check(Data.field.playerSprites.surf == "SPRITE_SEEL",
"the default surf sprite is still the Seel (no behavior change)")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.overworld = OW
local function mkMon(species, ...)
local moves = {}
for _, id in ipairs({ ... }) do
table.insert(moves, { id = id, pp = 10, ppUp = 0 })
end
return {
species = species, level = 30, hp = 50, maxHp = 50,
status = 0, moves = moves,
}
end
local function freshOw(party)
Game.save = SaveData.newGame()
Game.save.party = party
Game.save.inventory = { SOULBADGE = true }
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
local ow = Game.stack:top()
-- the mount itself goes through trySurf (TextBox + whiteFlash + step),
-- so arm the surf state directly and let syncSurfingPikachu derive
-- the sprite pick -- the unit under test.
ow.player.surfing = true
ow:syncSurfingPikachu()
-- the real SPRITE_SURFING_PIKACHU art only exists after an Yellow
-- import + the RFC 0001 extractor path; inject a sentinel surf sprite
-- so pose()'s selection rule is testable in the fixture dataset.
ow.player.surfPikachuSprite = { def = { id = "SPRITE_TEST_SURF_PIKA" } }
return ow
end
-- the sprite def backing a Player:surfSprite / surfPikachuSprite, so the
-- assertion can compare identities without drawing
local function surfSpriteId(player)
local sprite, _px, _py = player:pose()
return sprite and sprite.def and sprite.def.id or nil
end
-- -------------------------------------------------- Pikachu knows SURF
do
local ow = freshOw({ mkMon("PIKACHU", "SURF"), mkMon("SQUIRTLE") })
check(ow.player.surfing, "player is now surfing")
check(ow.player.surfingPikachu == true,
"surfingPikachu set when a Pikachu knows SURF")
eq(surfSpriteId(ow.player), "SPRITE_TEST_SURF_PIKA",
"pose() draws the surf-pikachu sprite, not the Seel (when set)")
-- dismount flips it off again: set surfing false and re-sync, the way
-- the dismount paths in OverworldController do
ow.player.surfing = false
ow:syncSurfingPikachu()
check(ow.player.surfingPikachu == false,
"syncSurfingPikachu clears the flag when dismounted")
end
-- -------------------------------------------------- no Pikachu, no swap
do
local ow = freshOw({ mkMon("SQUIRTLE", "SURF") })
check(ow.player.surfing, "player is surfing")
check(ow.player.surfingPikachu == false,
"no Pikachu in the SURF-mon: surfingPikachu stays false")
eq(surfSpriteId(ow.player), "SPRITE_SEEL",
"pose() keeps the default Seel surf sprite")
end
-- -------------------------------------------------- SURF-knower is the
-- Pikachu's species, not a slot position: a Pikachu without SURF behind a
-- Squirtle that has SURF must NOT trigger the swap
do
local ow = freshOw({ mkMon("SQUIRTLE", "SURF"), mkMon("PIKACHU", "THUNDER_SHOCK") })
check(ow.player.surfingPikachu == false,
"Pikachu present but not the SURF-mon: no swap")
eq(surfSpriteId(ow.player), "SPRITE_SEEL",
"pose() keeps the Seel when the SURF-mon is not a Pikachu")
end
-- -------------------------------------------------- not surfing: no swap
do
local ow = freshOw({ mkMon("PIKACHU", "SURF") })
ow.player.surfing = false
ow:syncSurfingPikachu()
check(ow.player.surfingPikachu == false,
"syncSurfingPikachu is a no-op swap when not surfing")
end
S.finish()
+4 -1
View File
@@ -36,8 +36,11 @@ local function makeGame(party)
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
-- isDown as well as wasPressed: battle text collapses PrintLetterDelay
-- while A or B is held, and the typing path reads it every frame
return { data = Data, save = save, stack = stack,
input = { wasPressed = function(_, b) return press[b] == true end } }
input = { wasPressed = function(_, b) return press[b] == true end,
isDown = function(_, b) return press[b] == true end } }
end
-- A held: updateQueue only reads the button once a page is typed out, so an
@@ -22,7 +22,6 @@ 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 Viridian School blackboard/notebook (#503)")
local check, eq = S.check, S.eq
@@ -69,7 +68,7 @@ for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) do
("(%d,%d) is not one of the readables"):format(cell[1], cell[2]))
end
-- === the blackboard: intro -> prompt -> 6-item menu (5 statuses + QUIT) ===
-- === the blackboard: intro -> held prompt with a two-column list over it ===
stack = {}
eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed")
local intro = stack[#stack]
@@ -83,16 +82,29 @@ 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()
-- #591: _ViridianSchoolBlackboardText2 ends in `done`, so .blackboardLoop
-- leaves it on screen and runs HandleMenuInput under it; the port used to
-- make the player dismiss it and then hid it while the list was up
check(prompt.onDone == nil and prompt.stay ~= nil,
"the prompt is held open instead of waiting for A and popping")
prompt.stay.onShown()
local menu = stack[#stack]
check(getmetatable(menu) == Menu, "the prompt opens the status menu")
eq(#menu.items, 6, "five statuses plus QUIT")
local board = stack[#stack]
check(getmetatable(board) ~= TextBox and type(board.draw) == "function",
"the held prompt opens the headings list over itself")
eq(stack[#stack - 1], prompt, "the list sits on the still-visible prompt box")
local labels = {}
for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end
for i, label in ipairs(board.labels) do labels[i] = label:gsub("^%s+", "") end
eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT",
"the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last")
eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it")
-- two columns: StatusAilmentText1 at hlcoord 1,2 and StatusAilmentText2 at
-- hlcoord 6,2, RIGHT keeping the row and adding wMenuItemOffset 3
board.col, board.row = 1, 1
eq(board:selection(), 1, "top of the left column is SLP")
board.col, board.row = 2, 1
eq(board:selection(), 4, "RIGHT keeps the row and lands on BRN (offset 3)")
board.col, board.row = 2, 3
eq(board:selection(), 6, "bottom of the right column is QUIT")
local STATUS_KEYS = {
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
@@ -100,8 +112,9 @@ local STATUS_KEYS = {
"_ViridianBlackboardFrozenText",
}
for i, key in ipairs(STATUS_KEYS) do
stack = {}
menu.items[i].onSelect()
stack = { prompt, board } -- picking pops the list AND the box under it
board.onPick(i)
eq(#stack, 1, labels[i] .. " clears the list and the prompt it sat on")
local blurb = stack[#stack]
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
local want = Data.text[key]:match("^[^\n\011\012]+")
@@ -110,13 +123,32 @@ for i, key in ipairs(STATUS_KEYS) do
check(type(blurb.onDone) == "function",
labels[i] .. " returns to the prompt instead of dropping out (loops)")
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] .. " (loop, not exit)")
local back = stack[#stack]
check(getmetatable(back) == TextBox and back.stay ~= nil,
"the held prompt comes back after " .. labels[i])
back.stay.onShown()
check(stack[#stack].labels ~= nil,
"the list comes back after " .. labels[i] .. " (loop, not exit)")
end
-- #591: wCurrentMenuItem / wMenuItemOffset are zeroed once above
-- .blackboardLoop, so `jp .blackboardLoop` after a blurb comes back with the
-- cursor on the row and column that was just picked (read FRZ, land on FRZ)
stack = { prompt, board }
board.col, board.row = 2, 2
eq(board:selection(), 5, "right column, middle row is FRZ")
board.onPick(5)
stack[#stack].onDone()
stack[#stack].stay.onShown()
eq(stack[#stack], board, "the same headings list comes back, not a fresh one")
eq(board.col, 2, "the column survives the blurb (wMenuItemOffset is not recleared)")
eq(board.row, 2, "the row survives the blurb (wCurrentMenuItem is not recleared)")
-- QUIT and B share .exitBlackboard: both close the list and the prompt
stack = { prompt, board }
board.onQuit()
eq(#stack, 0, "QUIT/B closes the headings list and the prompt box together")
-- === the notebook: five pages, three yes/no turns, then the girl catches you ==
stack = {}
eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed")
+38 -8
View File
@@ -36,13 +36,22 @@ local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
-- instrument show_text the way parity_gift_atomicity.lua does, to record
-- exactly which text ids actually printed
local shown = {}
-- forward EVERY argument: the 4th is extraOpts, which is how Commands.ask
-- hands down its `choice` callback. A wrapper that stops at `subs` silently
-- turns every ask in the script back into a plain show_text -- no YES/NO box,
-- and ctx.lastCheck left holding whatever the previous check_* put there.
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
Commands.show_text = function(ctx, textId, ...)
shown[#shown + 1] = textId
return origShow(ctx, textId, subs)
return origShow(ctx, textId, ...)
end
local function runScript()
-- `button` drives the whole conversation: both A and B page a text box, and
-- on the YES/NO box A takes the cursor's default (YES) while B snaps to NO
-- and answers false (ChoiceBox:update, .choseSecondMenuItem). So holding A
-- runs the yes branch and holding B runs the no branch, with no reaching
-- into the choice box from the test.
local function runScript(button)
shown = {}
StateStack:init()
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
@@ -53,7 +62,7 @@ local function runScript()
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
Input.pressed = { [button or "a"] = true }
StateStack:update(1 / 60)
r:update()
end
@@ -85,13 +94,34 @@ check(runScript(), "a third talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"the explanation text repeats on every later talk, not just the first")
-- === 3) unrelated path is unchanged: no GOLD TEETH, no flag yet ===
-- === 3) no GOLD TEETH yet: the gibberish question, then a YES/NO, then the
-- warden's answer -- Gibberish2 on yes, Gibberish3 on no (#645).
-- The port used to stop dead after the question. ===
Game.save = SaveData.newGame()
check(runScript(), "empty-handed talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenGibberish1Text",
"without the GOLD TEETH the Warden's gibberish line is unchanged")
check(runScript("a"), "empty-handed talk completes on yes")
eq(table.concat(shown, ","),
"_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish2Text",
"answering YES gets the warden's reply, not silence (#645)")
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
Game.save = SaveData.newGame()
check(runScript("b"), "empty-handed talk completes on no")
eq(table.concat(shown, ","),
"_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish3Text",
"and answering NO gets the other reply (#645)")
-- the question is asked, not just printed: `ask` is what puts the YES/NO box
-- up, so a future edit that downgrades it back to show_text fails here
local askRow
for _, row in ipairs(script) do
if row[2] == "_WardensHouseWardenGibberish1Text" then askRow = row[1] end
end
eq(askRow, "ask", "the gibberish line is asked with a YES/NO, not just shown")
-- neither answer touches the teeth trade
check(not Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"),
"and neither answer hands over teeth the player does not have")
Commands.show_text = origShow
S.finish()
+77
View File
@@ -108,5 +108,82 @@ PikachuFollower.onBillExitedMachine(yellowGame, ow)
check(ow.emote and ow.emote.bubble == 2 and npc.facing == "left",
"Pikachu reacts when Bill comes back out")
-- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156) is the
-- other side of BillsHouseScript2: it only runs while Pikachu still follows
-- the player, and TryApplyPikachuMovementData keys each table on
-- GetPikachuFacingDirectionAndReturnToE, which is Pikachu's position
-- relative to the player rather than its facing byte, so WatchPlayer1 wants
-- Pikachu above the player and WatchPlayer2 wants it level and east (#455).
local function placePikachu(cx, cy)
npc.cellX, npc.cellY, npc.facing = cx, cy, "down"
end
ow.pikachuBillsScene = nil
moves = {}
placePikachu(ow.player.cellX, ow.player.cellY - 1)
PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
check(#moves == 1 and moves[1].dir == "left" and moves[1].tiles == 1,
"a Pikachu standing above the player steps aside for Bill's detour")
moves[1].onDone()
check(#moves == 2 and moves[2].dir == "down" and moves[2].tiles == 1,
"the watch route drops clear of Bill's detour")
moves[2].onDone()
check(npc.facing == "right", "PIKAMOVEMENT_LOOK_RIGHT watches the player")
moves = {}
placePikachu(ow.player.cellX + 1, ow.player.cellY)
PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
check(#moves == 1 and moves[1].dir == "up" and moves[1].tiles == 1,
"level with and east of the player takes the longer WatchPlayer2 route")
moves[1].onDone()
check(#moves == 2 and moves[2].dir == "left" and moves[2].tiles == 2,
"WatchPlayer2 crosses two cells west")
moves[2].onDone()
check(#moves == 3 and moves[3].dir == "down" and moves[3].tiles == 1,
"WatchPlayer2 drops back level with the player")
moves[3].onDone()
check(npc.facing == "right", "WatchPlayer2 ends watching the player too")
-- below the player is SPRITE_FACING_DOWN and west of it is
-- SPRITE_FACING_LEFT, and neither call asks for those
moves = {}
placePikachu(ow.player.cellX, ow.player.cellY + 1)
PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
check(#moves == 0, "a Pikachu trailing from the south has no watch route")
placePikachu(ow.player.cellX - 1, ow.player.cellY)
PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
check(#moves == 0, "nor does one already standing west of the player")
-- ApplyPikachuMovementData_ finishes a table before the second
-- TryApplyPikachuMovementData reads the geometry again, but WatchPlayer1
-- lands on (playerX - 1, playerY), so WatchPlayer2 can never chain onto it
moves = {}
placePikachu(ow.player.cellX, ow.player.cellY - 1)
PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
moves[1].onDone()
moves[2].onDone()
placePikachu(ow.player.cellX - 1, ow.player.cellY)
moves = {}
PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
check(#moves == 0, "the two tables stay mutually exclusive by geometry")
-- the parked Pikachu of the confused beat is the not-following side of
-- CheckPikachuFollowingPlayer, so it never watches
ow.pikachuBillsScene = true
placePikachu(ow.player.cellX, ow.player.cellY - 1)
PikachuFollower.onBillWalksAroundPlayer(yellowGame, ow)
check(#moves == 0, "the parked Pikachu of the confused beat stays put")
-- BillsHouseScript0 skips the whole entry beat for a statused starter
-- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46)
ow.pikachuBillsScene = nil
moves = {}
yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } }
PikachuFollower.onBillsHouseEnter(yellowGame, ow)
check(not ow.pikachuBillsScene and #moves == 0,
"a statused starter keeps following instead of walking over to Bill")
yellowGame.save.party = nil
GameVersion.set("red")
S.finish()
+288
View File
@@ -0,0 +1,288 @@
-- Parity (#617): Yellow's Viridian old man is the OLD_MAN2 at (18,9),
-- not the Red/Blue OLD_MAN at (17,5), and his dialog has no yes/no
-- choice -- the apology speech runs the RATTATA demo battle straight
-- away, the post-battle line is the losing-my-touch text, and he walks
-- off and hides.
--
-- Oracle: pokeyellow scripts/OaksLab.asm (OaksLabOakGivesPokedexScript:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2),
-- scripts/ViridianCity.asm (ViridianCityCheckWaitingOldMan,
-- ViridianCityOldMan2Text, ...InitialCatchTrainingScript,
-- ...PostInitialCatchTraining) and scripts/ViridianCity_2.asm
-- (ViridianCityPrintOldManText). The Red/Blue "Are you in a hurry?"
-- script was running against Yellow's text: YES printed the TimeIsMoney
-- alias (_ViridianCityOldManLosingMyTouchText) and NO ran the demo --
-- every talk, forever.
--
-- Self-contained: `luajit tests/parity_yellow_old_man.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 GameVersion = require("src.core.GameVersion")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local S = require("tests.harness").suite("parity Yellow old man (#617)")
local check, eq = S.check, S.eq
local oldVersion = GameVersion.get()
local MAP = "VIRIDIAN_CITY"
local SLEEPER = "VIRIDIANCITY_OLD_MAN_SLEEPY"
local WALKER = "VIRIDIANCITY_OLD_MAN"
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
local DONE_FLAG = "EVENT_COMPLETED_CATCH_TRAINING"
-- The Yellow wiring must be attached before anything else caches the
-- map-script registry: data.scripts.init branches on GameVersion at
-- load, so flip it first (this file owns its own process when run
-- standalone). Under tests/run_tests.lua the registry is already
-- cached with the Red wiring, so attach the Yellow modules directly
-- afterwards -- attachBase merges per TEXT constant and replaces hooks,
-- which is a no-op on a fresh process and the fix on a shared one.
GameVersion.set("yellow")
local mapScripts = require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
MapScripts.attachBase(MAP,
require("data.scripts.yellow_viridian_old_man").VIRIDIAN_CITY)
MapScripts.attachBase("OAKS_LAB",
require("data.scripts.oaks_lab_yellow"))
local oldManMod = require("data.scripts.yellow_viridian_old_man")
-- ------------------------------------------------------- the demo species
-- The catch demo is a RATTATA in Yellow (SetupBattle sets wCurOpponent
-- = RATTATA) but the Yellow manifest inherited Red's WEEDLE; the runtime
-- override in Data:applyVersionedFieldData repairs old caches. Kept
-- active until the end of this file so the demo-battle assertions below
-- run against the Yellow value; restored before S.finish() like
-- parity_yellow_trades does for its trades table.
local originalOldManBattle = Data.field.oldManBattle
or { species = "WEEDLE", level = 5 } -- the fixture carries no oldManBattle
local originalTrades = Data.field.trades
eq(originalOldManBattle.species, "WEEDLE",
"Red/Blue's old man still demos a Weedle")
GameVersion.set("yellow")
Data:applyVersionedFieldData()
eq(Data.field.oldManBattle.species, "RATTATA",
"Yellow's old man demos a Rattata (#617)")
local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r"))
local yellowManifest = manifestFile:read("*a")
manifestFile:close()
check(yellowManifest:find('"species": "RATTATA"', 1, true) ~= nil,
"the Yellow manifest stamps RATTATA for fresh imports")
local redManifestFile = assert(io.open("tools/rom_manifest.json", "r"))
local redManifest = redManifestFile:read("*a")
redManifestFile:close()
check(redManifest:find('"species": "WEEDLE"', 1, true) ~= nil,
"and the Red/Blue manifest keeps WEEDLE")
-- ------------------------------------------------------- the Pokedex swap
-- OaksLabOakGivesPokedexScript shows TOGGLE_OLD_MAN_2 (the tutorial old
-- man standing on the sleeper's cell), never the Red/Blue walker
local oaksRows = mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")
check(type(oaksRows) == "table",
"the Yellow OaksLab Oak talk resolves to rows")
local sawSleepHide, sawOldMan2Show, sawOldManShow = false, false, false
for _, row in ipairs(oaksRows or {}) do
if row[1] == "hide_object" and row[3] == SLEEPER then sawSleepHide = true end
if row[1] == "show_object" and row[3] == OLD_MAN2 then sawOldMan2Show = true end
if row[1] == "show_object" and row[3] == WALKER then sawOldManShow = true end
end
check(sawSleepHide, "the Pokédex hand-over hides the lying old man")
check(sawOldMan2Show, "it shows OLD_MAN2 on the sleeper's cell")
check(not sawOldManShow, "it never shows the Red/Blue OLD_MAN (#617)")
-- both Yellow gamblers default hidden (toggle OFF), like pokeyellow
-- data/maps/toggleable_objects.asm. OLD_MAN2 only exists in a Yellow
-- import -- a Red-imported checkout carries just OLD_MAN -- so the
-- dataset checks tolerate its absence and the Yellow manifest carries
-- the OLD_MAN2 default instead.
local walkerDef, oldMan2Def
if Data.maps[MAP] then
for _, o in ipairs(Data.maps[MAP].objects or {}) do
if o.name == WALKER then walkerDef = o end
if o.name == OLD_MAN2 then oldMan2Def = o end
end
end
check(walkerDef == nil or walkerDef.hidden == true,
"VIRIDIANCITY_OLD_MAN defaults hidden in Yellow")
check(oldMan2Def == nil or oldMan2Def.hidden == true,
"VIRIDIANCITY_OLD_MAN2 defaults hidden in Yellow")
local om2Name = yellowManifest:find('"name": "VIRIDIANCITY_OLD_MAN2"', 1, true)
local om2Hidden = om2Name and yellowManifest:sub(
math.max(1, om2Name - 40), om2Name):find('"hidden": true', 1, true)
check(om2Hidden ~= nil,
"the Yellow manifest ships OLD_MAN2 with the toggle OFF")
-- ------------------------------------------------------- script registry
local talk = mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN2")
check(type(talk) == "function",
"TEXT_VIRIDIANCITY_OLD_MAN2 resolves to the Yellow handler")
check(type(mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN")) == "table",
"the Red/Blue OLD_MAN talk is still registered (unreachable in Yellow)")
local hooks = mapScripts.get(MAP)
check(hooks and type(hooks.onEnter) == "function",
"VIRIDIAN_CITY.onEnter is the Yellow swap")
check(hooks and type(hooks.onStep) == "function",
"VIRIDIAN_CITY.onStep chains the gym lock and sleeper gate")
check(oldManMod.VIRIDIAN_CITY and oldManMod.VIRIDIAN_CITY.talk
and oldManMod.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_OLD_MAN2 == talk,
"the handler is the module's own, not a leftover merge")
-- ------------------------------------------------------- completed branch
do
local pushed = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
local done = false
talk(game, nil, {}, function() done = true end)
eq(#pushed, 1, "a second talk only prints one box")
eq(getmetatable(pushed[1]), TextBox, "the losing-my-touch line, in a box")
pushed[1].onDone()
check(done, "closing it hands input back")
end
-- ------------------------------- the initial tutorial, end to end
-- Needs real species in the dataset (the fixture carries only FIX_*);
-- the engine's old-man demo machinery itself is parity_J's territory.
if Data.pokemon.RATTATA and Data.pokemon.PIKACHU then
do
require("src.render.Font").load(Data)
local pushed = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
local moves = {}
local man = { def = { index = 8, name = OLD_MAN2 } }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man }, entities = { man },
player = { cellX = 19, cellY = 9, facing = "left" },
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
npcByIndex = function(_, i) if i == 8 then return man end end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
local done = false
talk(game, ow, man, function() done = true end)
eq(#pushed, 1, "the initial talk opens the apology speech")
eq(getmetatable(pushed[1]), TextBox, "in a text box")
pushed[1].onDone() -- A: the apology closes, the demo battle starts
eq(#pushed, 2, "the demo battle starts with no choice in between")
local battle = pushed[2]
check(battle and battle.demo, "it is the old-man demo battle")
eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA",
"the demo is a RATTATA in Yellow (#617)")
eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo")
battle.onFinish() -- the battle ends, the post-battle text prints
eq(save.flags[DONE_FLAG], true, "EVENT_COMPLETED_CATCH_TRAINING is set")
eq(#pushed, 3, "the losing-my-touch line follows the demo")
pushed[3].onDone() -- A: the old man walks off
eq(#moves, 6, "with the player on (19,9) he walks down 6 tiles")
check(moves[1] == "down" and moves[6] == "down",
"all six steps are the ViridianCityOldManMovementData2 walk")
eq(save.objectToggles[MAP] and save.objectToggles[MAP][OLD_MAN2], false,
"TOGGLE_OLD_MAN_2 hides once the walk finishes")
check(done, "and the talk hands input back")
end
-- ---------------------------------- side talk: player not on (19,9) cell
do
local pushed = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
local moves = {}
local man = { def = { index = 8, name = OLD_MAN2 } }
local pika = { def = { index = 99, name = "PIKACHU_FOLLOWER" },
pikachuFollower = true }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man, pika }, entities = { man, pika },
player = { cellX = 18, cellY = 8, facing = "down" },
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
npcByIndex = function(_, i) if i == 8 then return man elseif i == 99 then return pika end end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
talk(game, ow, man, function() end)
pushed[1].onDone()
pushed[2].onFinish()
pushed[3].onDone()
eq(moves[1], "right", "Pikachu steps aside first (ViridianCityMovePikachu)")
eq(moves[2], "right", "then the old man turns right one tile")
eq(#moves, 2, "and no more")
end
else
check(true, "fixture dataset: demo-battle flow skipped (no RATTATA)")
end
-- --------------------------------------------------------- the (19,9) step
do
local pushed = {}
local save = SaveData.newGame()
local man = { def = { index = 8, name = OLD_MAN2 } }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man }, entities = { man },
player = { cellX = 19, cellY = 9, facing = "down" },
scriptMove = function(_, _, _, _, cb) cb() end,
npcByIndex = function() end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
check(not hooks.onStep(game, ow, 5, 5),
"off the trigger cell the step passes through")
check(hooks.onStep(game, ow, 19, 9),
"pre-Pokedex the sleeper gate owns (19,9)")
eq(#pushed, 1, "with the sleepy text box")
check(save.flags[DONE_FLAG] ~= true, "the tutorial is not running")
save.flags.EVENT_GOT_POKEDEX = true
check(hooks.onStep(game, ow, 19, 9),
"with the Pokedex, (19,9) starts the tutorial")
eq(man.facing, "right", "the old man faces the player")
eq(ow.player.facing, "left", "and the player turns to face him")
eq(#pushed, 2, "the apology box is up")
check(save.flags[DONE_FLAG] ~= true,
"no flag until the demo battle actually runs")
save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
check(not hooks.onStep(game, ow, 19, 9),
"once the tutorial is done the cell is quiet again")
end
Data.field.trades = originalTrades
Data.field.oldManBattle = originalOldManBattle
GameVersion.set(oldVersion)
S.finish()
+12 -6
View File
@@ -1699,8 +1699,10 @@ do
local SafeArea = require("src.core.SafeArea")
local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled")
check(cfg.positions == nil, "touchControls default positions nil")
check(cfg.layouts.portrait.positions == nil, "touchControls default positions nil")
eq(cfg.layouts.landscape.scale, 1, "touchControls default scale 1")
-- pre-#633 file: one positions table seeds both orientations, as copies
cfg = TC.normalizeConfig({
enabled = false,
positions = {
@@ -1711,11 +1713,15 @@ do
},
})
eq(cfg.enabled, false, "normalizeConfig keeps enabled=false")
eq(cfg.positions.dpad.x, 1, "normalizeConfig clamps x high")
eq(cfg.positions.dpad.y, 0, "normalizeConfig clamps y low")
eq(cfg.positions.a.x, 0.5, "normalizeConfig keeps a")
check(cfg.positions.junk == nil, "normalizeConfig drops unknown controls")
check(cfg.positions.b == nil, "normalizeConfig drops non-numeric")
local pcfg, lcfg = cfg.layouts.portrait, cfg.layouts.landscape
eq(pcfg.positions.dpad.x, 1, "normalizeConfig clamps x high")
eq(pcfg.positions.dpad.y, 0, "normalizeConfig clamps y low")
eq(pcfg.positions.a.x, 0.5, "normalizeConfig keeps a")
check(pcfg.positions.junk == nil, "normalizeConfig drops unknown controls")
check(pcfg.positions.b == nil, "normalizeConfig drops non-numeric")
eq(lcfg.positions.dpad.x, 1, "legacy positions seed landscape too")
check(pcfg.positions ~= lcfg.positions,
"orientations never share one positions table (#633)")
local L = TC.defaultLayout(400, 800)
check(L.dpad.cx < 200, "default d-pad on left half")
+64
View File
@@ -0,0 +1,64 @@
-- #595: the save editor's ADD ITEM list was reachable only by typing.
-- Kit.scroll is the shared wheel handler behind the fix; it takes a notch
-- only when the pointer is inside the list body, consumes it so two stacked
-- lists cannot both move, clamps to the last page, and honours the modal
-- shield. Standalone like the other panel suites (see the header of
-- tests/run_save_editor_tests.lua):
-- luajit tests/save_editor_wheel_bug595_test.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love or love_stub
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Kit = require("Kit")
Kit.beginFrame(50, 50, false, -1)
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 3,
"a wheel notch scrolls the list under the pointer")
eq(Kit.wheelY, 0, "and is consumed, so only one list moves")
Kit.endFrame()
Kit.beginFrame(50, 50, false, 1)
eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 0, "wheel up scrolls back")
Kit.endFrame()
Kit.beginFrame(50, 50, false, -1)
eq(Kit.scroll(0, 0, 100, 100, 245, 250, 10), 240,
"the last page is the floor of the list")
Kit.endFrame()
Kit.beginFrame(500, 500, false, -1)
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
"a list the pointer is not over ignores the wheel")
Kit.endFrame()
Kit.beginFrame(50, 50, false, -1)
eq(Kit.scroll(0, 0, 100, 100, 0, 8, 10), 0, "a list that fits cannot scroll")
Kit.endFrame()
Kit.beginFrame(50, 50, false, -1)
Kit.blockClicks = true
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
"the modal shield stops the wheel exactly as it stops a click")
Kit.blockClicks = false
Kit.endFrame()
eq(Kit.wheelY, 0, "an unclaimed notch retires with the frame")
-- The wheel has to reach the Items lists without touching the map camera,
-- which is the only thing App.wheelmoved used to drive (#595). Loading the
-- whole editor needs data/generated/, so pin the routing at the source seam
-- instead: App.wheelmoved must queue notches for Kit on every non-map tab.
local f = assert(io.open("tools/save-editor/App.lua", "r"))
local src = f:read("*a")
f:close()
check(src:find("wheelY = wheelY + (y or 0)", 1, true) ~= nil,
"App.wheelmoved queues notches for Kit on non-map tabs (#595)")
check(src:find("Kit.beginFrame(mx, my, mouseClicked, wheelY)", 1, true) ~= nil,
"App.draw hands the queued notches to Kit.beginFrame (#595)")
T.finish("save_editor_wheel_bug595")