mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 })))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 ===
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user