mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
CLOSES #788, CLOSES #795, CLOSES #796, CLOSES #797, CLOSES #805, CLOSES #826, CLOSES #833, CLOSES #835, CLOSES #837, CLOSES #844, CLOSES #845, CLOSES #846, CLOSES #847
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
-- Eye check: a YES/NO box over a classic battle wears the same paper as the
|
||||
-- field behind it (#822). pokered data/sgb/sgb_packets.asm BlkPacket_Battle
|
||||
-- attributes all 18 rows, and home/yes_no.asm InitYesNoTextBoxParameters puts
|
||||
-- the box at hlcoord 14,7 -- inside the player-HP-bar region, pal 0.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_choice_paper_bug822_test.lua POKEPORT_IDENTITY=bug822 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED: it scales the logic clock only, and these frames are judged as drawn.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
-- pokered data/maps/objects/Route1.asm puts the two youngsters at (5,24) and
|
||||
-- (15,13) and the sign at (9,27), so the top of the road is empty grass; the
|
||||
-- battle is pushed rather than walked into, the cell is only somewhere to stand.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 6, facing = "down" }
|
||||
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- machine-checkable preconditions ------------------------------------
|
||||
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 box's A/B click is gone and there is no")
|
||||
U.log(" way to tell a dead box from a live one you cannot hear. Set SFX to 7.")
|
||||
end
|
||||
check(("SFX volume %d, so the YES/NO box clicks when you answer it"):format(sfxVol),
|
||||
sfxVol > 0)
|
||||
check("the shade-remap shader compiled (no shader, no colorization at all)",
|
||||
PaletteFX.shader() ~= nil)
|
||||
check("PaletteFX.sendShades exists -- the raw sender the #822 fix needs",
|
||||
type(PaletteFX.sendShades) == "function")
|
||||
check("all 7 COLORS modes are on the ladder ("
|
||||
.. table.concat(PaletteFX.MODES, ", ") .. ")", #PaletteFX.MODES == 7)
|
||||
local cl = PaletteFX.CLASSIC
|
||||
check(("CLASSIC paper is the light pea green %d,%d,%d and its second shade"
|
||||
.. " the darker %d,%d,%d -- the pair #822 confused")
|
||||
:format(cl[1][1], cl[1][2], cl[1][3], cl[2][1], cl[2][2], cl[2][3]),
|
||||
cl[1][1] == 155 and cl[1][2] == 188 and cl[2][1] == 139)
|
||||
check("OG's GRAYS start at pure white, so OG is the shader identity",
|
||||
PaletteFX.GRAYS[1][1] == 255)
|
||||
local box = Theme.choiceBox
|
||||
check(("the YES/NO box sits at tile %d,%d (%dx%d) -- InitYesNoTextBoxParameters'"
|
||||
.. " hlcoord 14,7"):format(box.tx, box.ty, box.tw, box.th),
|
||||
box.tx == 14 and box.ty == 7)
|
||||
|
||||
-- ---- get into a battle with the box up ----------------------------------
|
||||
game.save.party = {}
|
||||
for _, slot in ipairs(PARTY) do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
|
||||
end
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the road: any free neighbour will do, nothing here
|
||||
-- depends on the cell beyond having somewhere legal to stand
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
|
||||
local battle = BattleState.newWild(game, "RATTATA", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 120 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu")
|
||||
|
||||
-- The same bare box BattleState pushes for the switch offer (BattleState.lua
|
||||
-- :1291): no anchor, so it sits over the battle rather than riding a dialogue
|
||||
-- box. Pushed directly because the reporter's screen is the box on top of a
|
||||
-- live battle, and how it got there changes nothing about the colorization.
|
||||
local choice = ChoiceBox.new(game, function() end)
|
||||
game.stack:push(choice)
|
||||
U.wait(10)
|
||||
check("a YES/NO box is on top of the battle", game.stack:top() == choice)
|
||||
|
||||
-- ---- measure both papers, mode by mode ----------------------------------
|
||||
-- Replays what Game:draw does for a classic battle with an overlay above it:
|
||||
-- every state paints onto the one 160x144 UI canvas, then the topmost state
|
||||
-- with sgbPalettes owns the screen. BattleState:sgbPalettes returns nil for
|
||||
-- the classic layout, so PaletteFX.ensureZones decides whether a whole-screen
|
||||
-- shade pass runs at all -- it does in OG / OG INV / CLASSIC, and does not in
|
||||
-- the colorized modes. Offscreen so the sample boxes stay in clean 160x144
|
||||
-- space whatever the window is doing; the U.shot next to each reading is the
|
||||
-- same frame as presented.
|
||||
local g = love.graphics
|
||||
local shader = PaletteFX.shader()
|
||||
|
||||
-- The empty pocket the SGB attribute map leaves at tiles 9,4 - 10,6: below
|
||||
-- the enemy HP bar (rows 0-3), right of the player mon (cols 0-8), left of
|
||||
-- the enemy mon (col 11 on). Nothing is ever drawn here, so it is the field
|
||||
-- paper and nothing else.
|
||||
local FIELD = { 74, 36, 85, 52 }
|
||||
-- Inside the YES/NO box, clear of its border tiles. The glyphs and cursor
|
||||
-- live in here too, which is why the reading is the most common color rather
|
||||
-- than one pixel.
|
||||
local BOXI = { 120, 64, 151, 87 }
|
||||
|
||||
local function modal(id, r)
|
||||
local counts, best, bestN = {}, nil, -1
|
||||
for y = r[2], r[4] do
|
||||
for x = r[1], r[3] do
|
||||
local pr, pg, pb = id:getPixel(x, y)
|
||||
local key = math.floor(pr * 255 + 0.5) .. "," .. math.floor(pg * 255 + 0.5)
|
||||
.. "," .. math.floor(pb * 255 + 0.5)
|
||||
counts[key] = (counts[key] or 0) + 1
|
||||
if counts[key] > bestN then best, bestN = key, counts[key] end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
local function sample()
|
||||
local prev = g.getCanvas()
|
||||
local a = g.newCanvas(160, 144)
|
||||
local b = g.newCanvas(160, 144)
|
||||
g.setCanvas(a)
|
||||
g.clear(1, 1, 1, 1) -- the battle letterbox is white (letterboxWhite)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
battle:draw()
|
||||
choice:draw()
|
||||
g.setCanvas(b)
|
||||
g.clear(0, 0, 0, 1)
|
||||
local zones = PaletteFX.ensureZones(nil)
|
||||
if zones and zones[1] then
|
||||
g.setShader(shader)
|
||||
PaletteFX.sendColors(shader, PaletteFX.GRAYS)
|
||||
end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(a, 0, 0)
|
||||
g.setShader()
|
||||
g.setCanvas(prev)
|
||||
local id = b:newImageData()
|
||||
return modal(id, FIELD), modal(id, BOXI), zones ~= nil and zones[1] ~= nil
|
||||
end
|
||||
|
||||
local mismatched = {}
|
||||
for _, m in ipairs(PaletteFX.MODES) do
|
||||
-- set the SAVED option too: Game:applyOptions re-reads save.options.colors,
|
||||
-- so a bare setMode gets reverted underneath the next frame
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = m
|
||||
PaletteFX.setMode(m)
|
||||
U.wait(20)
|
||||
local field, boxp, framePass = sample()
|
||||
local label = PaletteFX.modeLabel(m)
|
||||
local same = field == boxp
|
||||
local known = (m == "gbc" or m == "gbc_inv")
|
||||
U.log(("%-9s field %-13s box %-13s %s"):format(
|
||||
label, field, boxp,
|
||||
framePass and "whole-screen pass" or "no whole-screen pass"))
|
||||
if m == "classic" then
|
||||
check("CLASSIC field is the light pea green 155,188,15, not the darker"
|
||||
.. " 139,172,15 one bucket down", field == "155,188,15")
|
||||
end
|
||||
if known then
|
||||
-- the other half of #822, left open on purpose: with no frame-level pass
|
||||
-- the overlay paints raw DMG white onto a canvas the battle has already
|
||||
-- colorized, and nothing local to drawZonePass can reach it
|
||||
U.log((" %s draws its overlays raw, so a mismatch here is the known"
|
||||
.. " open half, not this fix failing"):format(label))
|
||||
else
|
||||
if not same then mismatched[#mismatched + 1] = label end
|
||||
check(label .. " box paper matches the field behind it", same)
|
||||
end
|
||||
U.shot(game, DIR .. "/bug822_" .. m .. ".png")
|
||||
end
|
||||
check("no forced-mono or ADVANCED/OG RED mode left the box a different color"
|
||||
.. " from the field", #mismatched == 0)
|
||||
if #mismatched > 0 then
|
||||
U.log(" mismatched on:", table.concat(mismatched, ", "))
|
||||
end
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
PaletteFX.setMode("classic")
|
||||
game.save.options.colors = "classic"
|
||||
U.wait(20)
|
||||
U.log("You are looking at a YES/NO box sitting on a wild RATTATA battle in")
|
||||
U.log("CLASSIC. The paper inside the box and the empty field around the mons")
|
||||
U.log("should be the one same pea green, with no seam where the box begins;")
|
||||
U.log("press 2 through the ladder and OG INV should go black-on-black the same")
|
||||
U.log("way, while OG, OG RED and ADVANCED look exactly as they always did.")
|
||||
U.log("The near miss is a box that is only slightly lighter than the field --")
|
||||
U.log("that is the old one-bucket slip, not a border. SGB and SGB INV still")
|
||||
U.log("show a white box over a tinted field; that half of #822 is open.")
|
||||
U.log("Shots: " .. DIR .. "/bug822_*.png. A or B answers the box and it goes.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,286 @@
|
||||
-- Driver for #847: slowed Cities1 (scripts/ChampionsRoom.asm:112 farcall
|
||||
-- Music_Cities1AlternateTempo, audio/alternate_tempo.asm), the scripted walk
|
||||
-- over the rival (home/overworld.asm CollisionCheckOnLand) and the back-pic
|
||||
-- sweep (engine/movie/hall_of_fame.asm HoFShowMonOrPlayer). Never add
|
||||
-- POKEPORT_SPEED here: it scales the logic clock only and audio is the test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/champion_alt_tempo_bug847_test.lua POKEPORT_IDENTITY=hof847 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Music = require("src.core.Music")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local Commands = require("src.script.Commands")
|
||||
local HallOfFame = require("src.ui.HallOfFame")
|
||||
|
||||
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
|
||||
|
||||
-- audio/music/cities1.asm: Music_Cities1_Ch1 opens `tempo 144`, the
|
||||
-- Music_Cities1_Ch1_AlternateTempo stub `tempo 232` (macros/scripts/audio.asm
|
||||
-- emits db HIGH(x), LOW(x), so these are the literal engine values).
|
||||
local NORMAL_TEMPO, ALT_TEMPO = 144, 232
|
||||
local SONG = "Music_Cities1"
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- party + options
|
||||
-- ---------------------------------------------------------------
|
||||
local SPECIES = { "CHARIZARD", "SNORLAX", "PIKACHU" }
|
||||
game.save.party = {}
|
||||
for i, name in ipairs(SPECIES) do
|
||||
game.save.party[i] = Pokemon.new(game.data, name, 100 - (i - 1) * 27)
|
||||
end
|
||||
game.save.player.name = "BRYAN"
|
||||
local options = game.save.options
|
||||
check("sfxVol is non-zero, so the cries are audible", (options.sfxVol or 0) > 0)
|
||||
check("musicVol is non-zero, so the tempo swap is audible",
|
||||
(options.musicVol or 0) > 0)
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- machine checks: the parts an ear cannot separate from a bad build
|
||||
-- ---------------------------------------------------------------
|
||||
-- read the live registry, so a mod's map_scripts contribution is inspected
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local champ = mapScripts.get("CHAMPIONS_ROOM")
|
||||
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
|
||||
check("CHAMPIONS_ROOM keeps its rival script", type(rows) == "table")
|
||||
rows = rows or {}
|
||||
|
||||
local iFade, iWait, iCue, iWalk, iWarp, cueOpts
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == "fade_music" and not iFade then
|
||||
iFade = i
|
||||
elseif row[1] == "wait" and iFade and not iWait then
|
||||
iWait = i
|
||||
elseif row[1] == "play_music" and row[2] == SONG and not iCue then
|
||||
iCue, cueOpts = i, row[3]
|
||||
elseif row[1] == "move_player" and row[2] == "up" and not iWarp then
|
||||
iWalk = i
|
||||
elseif row[1] == "warp" and row[2] == "HALL_OF_FAME" then
|
||||
iWarp = iWarp or i
|
||||
end
|
||||
end
|
||||
check("the script fades the battle theme out before Cities1 (#847)",
|
||||
iFade ~= nil and iCue ~= nil and iFade < iCue)
|
||||
check("it waits out the fade, like the ld c, 100 / call DelayFrames",
|
||||
iWait ~= nil and iWait > iFade and iWait < iCue
|
||||
and (rows[iWait or 1][2] or 0) >= 100)
|
||||
check("the Cities1 cue carries the alternate tempo " .. ALT_TEMPO,
|
||||
type(cueOpts) == "table" and cueOpts.tempo == ALT_TEMPO)
|
||||
check("it still walks the player out before the warp (#704)",
|
||||
iWalk ~= nil and iWarp ~= nil and iWalk < iWarp)
|
||||
check("Commands.fade_music exists for that first row",
|
||||
type(Commands.fade_music) == "function")
|
||||
|
||||
-- the tempo has to survive the song's own `tempo` command: without the
|
||||
-- override the body's 144 wins and Cities1 plays as the ordinary town theme
|
||||
local def = game.data.audio and game.data.audio.songs
|
||||
and game.data.audio.songs[SONG]
|
||||
check(SONG .. " is in the extracted song table", type(def) == "table")
|
||||
if type(def) == "table" then
|
||||
local slowed = {}
|
||||
for k, v in pairs(def) do slowed[k] = v end
|
||||
slowed.tempo = ALT_TEMPO
|
||||
local okAlt, alt = pcall(ChipSynth.newEngine, game.data, slowed,
|
||||
{ allowLoops = true })
|
||||
local okPlain, plain = pcall(ChipSynth.newEngine, game.data, def,
|
||||
{ allowLoops = true })
|
||||
check("an overridden song header starts locked at " .. ALT_TEMPO,
|
||||
okAlt and alt and alt.tempo == ALT_TEMPO and alt.tempoLocked == true)
|
||||
check("a plain header is left unlocked, free to take its own tempo "
|
||||
.. NORMAL_TEMPO,
|
||||
okPlain and plain and not plain.tempoLocked)
|
||||
end
|
||||
|
||||
-- HoFShowMonOrPlayer loads a back pic for every party member and for the
|
||||
-- player; a missing key would silently draw nothing during the sweep
|
||||
for _, name in ipairs(SPECIES) do
|
||||
local path = Sprites.path(game.data, name, "back", { kind = "hof" })
|
||||
check(name .. " resolves a back pic for the induction",
|
||||
type(path) == "string" and path ~= "")
|
||||
end
|
||||
local playerBack = Sprites.playerPath(game.data, "back", { kind = "hof" })
|
||||
check("the player resolves RedPicBack for the closing sweep",
|
||||
type(playerBack) == "string" and playerBack ~= "")
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- stand where ChampionsRoomPlayerEntersScript leaves the player
|
||||
-- ---------------------------------------------------------------
|
||||
-- pokered data/maps/objects/ChampionsRoom.asm: CHAMPIONSROOM_RIVAL at (4,2),
|
||||
-- both HALL_OF_FAME warps on row 0, and RivalEntrance_RLEMovement (up 1,
|
||||
-- right 1, up 3) from warp 1 lands the player at (4,3), facing the rival.
|
||||
local STAND = { x = 4, y = 3 }
|
||||
U.teleport(game, "CHAMPIONS_ROOM", STAND.x, STAND.y, "up")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
local rival
|
||||
for _, npc in ipairs(ow and ow.npcs or {}) do
|
||||
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then rival = npc end
|
||||
end
|
||||
check("the rival object is on the map", rival ~= nil)
|
||||
if rival and (ow.player.cellX ~= rival.cellX
|
||||
or ow.player.cellY ~= rival.cellY + 1) then
|
||||
-- a map edit or a mod moved the object: stand on any free walkable
|
||||
-- neighbour instead of facing a wall. {dx, dy, facing} is the offset from
|
||||
-- the rival to the stand cell plus the direction that looks back at him.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = rival.cellX + s[1], rival.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is not free; standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, "CHAMPIONS_ROOM", cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
for _, npc in ipairs(ow.npcs or {}) do
|
||||
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
|
||||
rival = npc
|
||||
end
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- run the tail of the script, from after the rival battle, so nobody has to
|
||||
-- win OPP_RIVAL3 to hear this. Only rows before that point carry jump
|
||||
-- targets, so the slice needs no reindexing -- assert that.
|
||||
local from
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == "show_text"
|
||||
and row[2] == "_ChampionsRoomRivalAfterBattleText" then
|
||||
from = i
|
||||
break
|
||||
end
|
||||
end
|
||||
check("found the post-battle row to start from", from ~= nil)
|
||||
local slice, jumpy = {}, false
|
||||
for i = from or 1, #rows do
|
||||
local row = rows[i]
|
||||
if row[1] == "jump" or row[1] == "jump_if_true"
|
||||
or row[1] == "jump_if_false" then
|
||||
jumpy = true
|
||||
end
|
||||
slice[#slice + 1] = row
|
||||
end
|
||||
check("the tail of the script has no jump targets to reindex", not jumpy)
|
||||
|
||||
if failures > 0 then
|
||||
U.log("stopping before the cutscene:", failures,
|
||||
"check(s) failed above, so what you would hear means nothing")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- watch the cues the script actually issues: on speakers a dedupe that ate
|
||||
-- the restart and a fade that never fired sound like the same nothing
|
||||
local realPlay, realFade = Music.play, Music.fadeOut
|
||||
local cues, faded = {}, false
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
cues[#cues + 1] = { song = song, tempo = ctx and ctx.tempo }
|
||||
return realPlay(data, song, loop, ctx)
|
||||
end
|
||||
Music.fadeOut = function(control)
|
||||
faded = true
|
||||
return realFade(control)
|
||||
end
|
||||
|
||||
ow:queueScript(slice, { npc = rival })
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- the walk out, over the rival's cell
|
||||
-- ---------------------------------------------------------------
|
||||
local startY = ow.player.cellY
|
||||
local minY, sharedCell, walkShot = startY, false, false
|
||||
local hof
|
||||
for i = 1, 6000 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == HallOfFame or (top and top.drawMonInfo) then
|
||||
hof = top
|
||||
break
|
||||
end
|
||||
local w = game.overworld
|
||||
if w and w.map and w.map.id == "CHAMPIONS_ROOM" and rival then
|
||||
local px, py = w.player.cellX, w.player.cellY
|
||||
if py < minY then minY = py end
|
||||
if px == rival.cellX and py == rival.cellY then
|
||||
sharedCell = true
|
||||
if not walkShot then
|
||||
walkShot = U.shot(game, DIR .. "/bug847_over_rival.png")
|
||||
end
|
||||
end
|
||||
end
|
||||
if i % 6 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
Music.play, Music.fadeOut = realPlay, realFade
|
||||
|
||||
check("the battle theme was faded, not cut", faded)
|
||||
local altCue
|
||||
for _, c in ipairs(cues) do
|
||||
if c.song == SONG and c.tempo == ALT_TEMPO then altCue = c end
|
||||
end
|
||||
check("Cities1 was restarted at the alternate tempo, not deduped away",
|
||||
altCue ~= nil)
|
||||
check("the player walked out of the room before the warp (#704)",
|
||||
minY < startY)
|
||||
-- CollisionCheckOnLand skips its checks while wSimulatedJoypadStatesIndex is
|
||||
-- non-zero, so passing through (4,2) is the original behavior, not a clip
|
||||
check("the scripted walk passed through the rival's cell (#847, not a bug)",
|
||||
sharedCell)
|
||||
check("walk-over screenshot", walkShot)
|
||||
check("the induction started", hof ~= nil)
|
||||
if not hof then
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- the back-pic sweep ahead of the first front pic
|
||||
-- ---------------------------------------------------------------
|
||||
check("the induction opens on the back pic sweep, at the right edge",
|
||||
hof.phase == "back" and (hof.scrollX or 0) > 96)
|
||||
local sweepShot = false
|
||||
for _ = 1, 400 do
|
||||
if hof.phase ~= "back" then break end
|
||||
if not sweepShot and (hof.scrollX or 0) <= 56 then
|
||||
sweepShot = U.shot(game, DIR .. "/bug847_back_sweep.png")
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check("back sweep screenshot", sweepShot)
|
||||
check("the front pic phase follows the sweep, entering from the left",
|
||||
hof.phase == "mons" and (hof.scrollX or 0) < 0)
|
||||
for _ = 1, 200 do
|
||||
if (hof.scrollX or 96) > 8 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("front scroll screenshot", U.shot(game, DIR .. "/bug847_front.png"))
|
||||
for _ = 1, 400 do
|
||||
if hof.phase == "mons" and (hof.scrollX or 0) >= 96 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the front pic settles at hlcoord (12,5)", (hof.scrollX or 0) == 96)
|
||||
|
||||
U.log(failures == 0 and "all checks passed" or ("FAILURES: " .. failures))
|
||||
U.log("input is yours now; the rest of the party and the player's own page")
|
||||
U.log("follow on their own, so just watch and listen.")
|
||||
U.log("after the rival's last line the battle music should fade out over")
|
||||
U.log("about a second, go quiet for another second and a half, and then")
|
||||
U.log("Pewter City comes back noticeably slower and heavier than it sounds")
|
||||
U.log("in town -- and it stays that slow across the warp until the hall of")
|
||||
U.log("fame theme takes over. For each mon a back sprite sweeps right to")
|
||||
U.log("left low on the screen, then the front sprite slides in from the")
|
||||
U.log("left and only cries once it stops.")
|
||||
U.log("the near miss to listen for: Cities1 at its ordinary town tempo, or")
|
||||
U.log("cutting in with no gap -- that is the old behavior, not the fix.")
|
||||
U.log("the other near miss: a cry that fires while a sprite is still moving.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -226,6 +226,18 @@ return function(game)
|
||||
-- ---------------------------------------------------------------
|
||||
-- mid scroll: .ScrollPic nudges hSCX 4px a frame, and the exemption has to
|
||||
-- travel with the pic instead of sitting at its resting column (#637)
|
||||
-- #847: HoFShowMonOrPlayer sweeps the BACK pic across the screen (low, at
|
||||
-- y=88) before the front pic scrolls in. Catch it mid-sweep, wait the
|
||||
-- sweep out, then catch the front pic partway through its own scroll.
|
||||
for _ = 1, 300 do
|
||||
if hof.phase ~= "back" or (hof.scrollX or 0) <= 56 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("back-pic sweep screenshot", U.shot(game, DIR .. "/hof847_back.png"))
|
||||
for _ = 1, 300 do
|
||||
if hof.phase ~= "back" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 200 do
|
||||
if (hof.scrollX or PIC_X) > 8 then break end
|
||||
U.wait(1)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
-- The super effective / not very effective hit sounds played at the wrong
|
||||
-- pitch, so the weak-sounding hit landed on the weakness (#826). pokered
|
||||
-- PlayApplyingAttackSound (engine/battle/animations.asm) sets
|
||||
-- wFrequencyModifier with the sound, and audio/engine_2.asm
|
||||
-- Audio2_ApplyFrequencyModifier adds it to the noise channel's polynomial
|
||||
-- counter. Ears only, so never under POKEPORT_SPEED -- the pitch is the test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/hit_sfx_bug826_test.lua POKEPORT_IDENTITY=bug826 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
-- GOLEM is ROCK/GROUND, so one attacker covers both ends of the routine
|
||||
-- with no switching: WATER_GUN is 2x on each type and EMBER is 0.5x on
|
||||
-- ROCK. SPLASH on the foe keeps the lead alive for as many replays as
|
||||
-- the listener wants.
|
||||
local FOE, FOE_LEVEL = "GOLEM", 60
|
||||
local LEAD, LEAD_LEVEL = "BULBASAUR", 25
|
||||
local WEAK_MOVE, STRONG_MOVE = "EMBER", "WATER_GUN"
|
||||
-- PlayApplyingAttackSound's wFrequencyModifier per sound, and the
|
||||
-- polynomial-counter byte of each program's first note (audio/sfx/
|
||||
-- {damage,super_effective,not_very_effective}.asm) before and after it.
|
||||
local SOUNDS = {
|
||||
{ name = "Damage", pitch = 0x20, raw = 0x44, want = 0x64 },
|
||||
{ name = "Super_Effective", pitch = 0xe0, raw = 0x34, want = 0x14 },
|
||||
{ name = "Not_Very_Effective", pitch = 0x50, raw = 0x55, want = 0xa5 },
|
||||
}
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function hex(v) return v and ("$%02x"):format(v) or "nil" end
|
||||
|
||||
-- ---- data the moment depends on ----------------------------------------
|
||||
local sfx = game.data.audio and game.data.audio.sfx or {}
|
||||
for _, row in ipairs(SOUNDS) do
|
||||
local def = sfx[row.name]
|
||||
check(row.name .. " resolves to a chip program in the generated audio",
|
||||
type(def) == "table" and def.address ~= nil and def.bank ~= nil)
|
||||
end
|
||||
local moveWeak, moveStrong = game.data.moves[WEAK_MOVE], game.data.moves[STRONG_MOVE]
|
||||
local foeDef = game.data.pokemon[FOE]
|
||||
check(WEAK_MOVE .. " and " .. STRONG_MOVE .. " resolve in the move table",
|
||||
moveWeak ~= nil and moveStrong ~= nil)
|
||||
check(FOE .. " resolves with a dual type", foeDef ~= nil and #foeDef.types == 2)
|
||||
local weakMult, strongMult
|
||||
if moveWeak and moveStrong and foeDef then
|
||||
weakMult = TypeChart.effectiveness(moveWeak.type, foeDef.types)
|
||||
strongMult = TypeChart.effectiveness(moveStrong.type, foeDef.types)
|
||||
U.log(("%s on %s is x%.1f, %s is x%.1f (the x10 scale Damage.lua uses)")
|
||||
:format(WEAK_MOVE, FOE, weakMult / 10, STRONG_MOVE, strongMult / 10))
|
||||
end
|
||||
check(WEAK_MOVE .. " is the resisted side of the pair", (weakMult or 10) < 10)
|
||||
check(STRONG_MOVE .. " is the super effective side", (strongMult or 10) > 10)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
check("sfx volume is up (" .. tostring(vol) .. "/7)", (vol or 0) > 0)
|
||||
if (vol or 0) == 0 then
|
||||
U.log("with sfxVol 0 both hits are silent and this run proves nothing;",
|
||||
"raise it in OPTION and start over")
|
||||
end
|
||||
|
||||
-- ---- the synth half: does the modifier reach the noise channel? ---------
|
||||
-- Sample each program once at offset 0 and again at its own modifier. A
|
||||
-- port that drops wFrequencyModifier reports the same byte twice, which is
|
||||
-- the whole of #826: unpitched, Super_Effective ends duller than
|
||||
-- Not_Very_Effective ends.
|
||||
local function firstNoise(header, offset)
|
||||
if not header then return nil end
|
||||
local engine = ChipSynth.newEngine(game.data, header, {
|
||||
sfx = true, allowLoops = false, frequencyOffset = offset,
|
||||
})
|
||||
for _, channel in ipairs(engine.channels) do
|
||||
channel:sample()
|
||||
local event = channel.event
|
||||
if event and event.noiseParameter then return event.noiseParameter end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
for _, row in ipairs(SOUNDS) do
|
||||
local bare = firstNoise(sfx[row.name], 0)
|
||||
local pitched = firstNoise(sfx[row.name], row.pitch)
|
||||
check(("%s reads NR43 %s unmodified, as in the asm")
|
||||
:format(row.name, hex(row.raw)), bare == row.raw)
|
||||
check(("...and %s once %s is applied"):format(hex(row.want), hex(row.pitch)),
|
||||
pitched == row.want)
|
||||
U.log(("%s: %s -> %s, shift clock %d -> %d (higher shift = duller)")
|
||||
:format(row.name, hex(bare), hex(pitched),
|
||||
math.floor((bare or 0) / 16), math.floor((pitched or 0) / 16)))
|
||||
end
|
||||
|
||||
-- ---- the battle half: what does a hit row actually carry? ---------------
|
||||
-- Offscreen scratch turn, no animation timing in the way. The row has to
|
||||
-- name the sound AND its modifier byte, and must not carry a tempo byte:
|
||||
-- Audio2_note_length skips Audio2_SetSfxTempo on CHAN8 (`cp CHAN8 / jr z,
|
||||
-- .skip`), so the hardware never retimes these three.
|
||||
-- the move is handed in whole, so the party lead keeps both its slots for
|
||||
-- the live battle below
|
||||
local function rowSfx(moveId)
|
||||
local scratch = BattleState.newWild(game, FOE, FOE_LEVEL)
|
||||
scratch.onFinish = function() end
|
||||
-- Damage.accuracyRoll is `rng(0, 255) < acc` (src/battle/Damage.lua:105),
|
||||
-- so the roll has to be pinned LOW to guarantee a hit. Pinning it high
|
||||
-- misses every time, the row never gets a .hit, and this scan reads nil.
|
||||
scratch.rng = function(lo) return lo end
|
||||
scratch:performMove(scratch.player, scratch.enemy, { id = moveId, pp = 20 })
|
||||
for _, row in ipairs(scratch.queue) do
|
||||
if row.hit and row.hit.sfx then return row.hit.sfx end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
do
|
||||
local lead = Pokemon.new(game.data, LEAD, LEAD_LEVEL)
|
||||
lead.moves = {
|
||||
{ id = WEAK_MOVE, pp = 25, maxPP = 25 },
|
||||
{ id = STRONG_MOVE, pp = 25, maxPP = 25 },
|
||||
}
|
||||
game.save.party = { lead }
|
||||
for _, case in ipairs({
|
||||
{ move = STRONG_MOVE, want = "Super_Effective", pitch = 0xe0 },
|
||||
{ move = WEAK_MOVE, want = "Not_Very_Effective", pitch = 0x50 },
|
||||
}) do
|
||||
local row = rowSfx(case.move)
|
||||
check(case.move .. " queues a hit sound with its modifier",
|
||||
type(row) == "table" and row.sound == case.want
|
||||
and row.pitch == case.pitch)
|
||||
check("...and no tempo byte, the way CHAN8 ignores one",
|
||||
type(row) == "table" and row.tempo == nil)
|
||||
U.log(("%s -> %s pitch %s"):format(case.move,
|
||||
type(row) == "table" and tostring(row.sound) or tostring(row),
|
||||
type(row) == "table" and hex(row.pitch) or "nil"))
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- reach the moment ---------------------------------------------------
|
||||
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the stand cell
|
||||
-- is read back off the loaded map, and a map edit degrades to the first
|
||||
-- free cell instead of dropping the player into a wall.
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local map = game.overworld.map
|
||||
if not map:isWalkableCell(5, 5) then
|
||||
local fx, fy
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
|
||||
end
|
||||
if fx then break end
|
||||
end
|
||||
if fx then
|
||||
U.log("cell (5, 5) is not walkable, standing on", fx, fy)
|
||||
U.teleport(game, "ROUTE_1", fx, fy, "down")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
local ow = game.overworld
|
||||
check("player stands on a walkable ROUTE_1 cell",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
-- listen in on the real playback path so the log can tell "the fix is not
|
||||
-- wired to the battle" from "the fix is wired but you did not like it"
|
||||
local heard = {}
|
||||
local realPlayMove = Sound.playMove
|
||||
Sound.playMove = function(data, anim)
|
||||
if type(anim) == "table" and anim.sound then
|
||||
for _, row in ipairs(SOUNDS) do
|
||||
if anim.sound == row.name then
|
||||
heard[#heard + 1] = { sound = anim.sound, pitch = anim.pitch }
|
||||
end
|
||||
end
|
||||
end
|
||||
return realPlayMove(data, anim)
|
||||
end
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 160 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function newFight()
|
||||
local battle = BattleState.newWild(game, FOE, FOE_LEVEL)
|
||||
battle.onFinish = function(result) ow:afterBattle(result, battle) end
|
||||
-- SPLASH so the foe's turn cannot end the run, or drown the hit under a
|
||||
-- damage sound of its own
|
||||
battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } }
|
||||
battle.enemy.curMoves = battle.enemy.mon.moves
|
||||
ow:pushBattle(battle)
|
||||
U.wait(220) -- the send-out intro plays before the menu is reachable
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
return battle
|
||||
end
|
||||
|
||||
local battle = newFight()
|
||||
check("the wild " .. FOE .. " battle reached its FIGHT menu",
|
||||
battle.phase == "menu")
|
||||
U.shot(game, DIR .. "/bug826_menu.png")
|
||||
|
||||
-- slot 1 first: the resisted hit, so the pair is heard weak then strong
|
||||
local function swing(slotDown, label)
|
||||
local before = #heard
|
||||
U.tap(game, "a") -- FIGHT
|
||||
U.wait(16)
|
||||
if slotDown then U.tap(game, "down"); U.wait(8) end
|
||||
U.tap(game, "a")
|
||||
for frame = 1, 1200 do
|
||||
if battle.phase == "menu" and #battle.queue == 0 and not battle.draining then
|
||||
break
|
||||
end
|
||||
if not battle.draining and frame % 8 == 0 then U.tap(game, "a") end
|
||||
U.wait(1)
|
||||
end
|
||||
local row = heard[before + 1]
|
||||
U.log(("%s played %s at pitch %s"):format(label,
|
||||
row and row.sound or "nothing",
|
||||
row and hex(row.pitch) or "nil"))
|
||||
return row
|
||||
end
|
||||
|
||||
local weakHeard = swing(false, WEAK_MOVE)
|
||||
U.shot(game, DIR .. "/bug826_not_very_effective.png")
|
||||
local strongHeard = swing(true, STRONG_MOVE)
|
||||
U.shot(game, DIR .. "/bug826_super_effective.png")
|
||||
check("the resisted hit reached the mixer as Not_Very_Effective $50",
|
||||
weakHeard ~= nil and weakHeard.sound == "Not_Very_Effective"
|
||||
and weakHeard.pitch == 0x50)
|
||||
check("the super effective hit reached it as Super_Effective $e0",
|
||||
strongHeard ~= nil and strongHeard.sound == "Super_Effective"
|
||||
and strongHeard.pitch == 0xe0)
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand the pad over --------------------------------------------------
|
||||
Sound.playMove = realPlayMove
|
||||
if battle.phase ~= "menu" then
|
||||
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
|
||||
end
|
||||
U.log("Both hits have already sounded once. GOLEM is still standing and")
|
||||
U.log("EMBER and WATER_GUN sit in slots 1 and 2, so play them back to back")
|
||||
U.log("as often as you like.")
|
||||
U.log("WATER_GUN, under \"It's super effective!\", should be the brighter")
|
||||
U.log("and sharper of the two -- a high crack. EMBER, under \"It's not very")
|
||||
U.log("effective...\", should be a low dull rumble underneath it.")
|
||||
U.log("The near miss to listen for: the two are close in brightness, or the")
|
||||
U.log("crack lands on EMBER and the thud on WATER_GUN. That is the modifier")
|
||||
U.log("going missing again, and it is what #826 sounded like.")
|
||||
U.log("The neutral hit changed too: any move that is neither, on any foe,")
|
||||
U.log("is now a shade duller than it used to be, and that is correct.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Manual check that a Pikachu taking the field says the short "Pika!" (#837).
|
||||
-- pokeyellow engine/battle/core.asm SendOutMon .starterPikachu (:1807-1817)
|
||||
-- voices PikachuCry11, or PikachuCry37 when IsPlayerPikachuAsleepInParty; the
|
||||
-- port called PlayCry bare and got clip 1, the long title "Pikachuuu"
|
||||
-- (engine/movie/title.asm:146). Never add POKEPORT_SPEED here: it scales the
|
||||
-- logic clock and not audio, so the cries stop lining up with what you see.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pika_entrance_cry_bug837_test.lua POKEPORT_IDENTITY=bug837 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Sound = require("src.core.Sound")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- Red and Blue carry no PCM clips at all (RomExtractor extractPikachuCries
|
||||
-- only runs on Yellow), so playPikaCry returns nil there and every Pikachu
|
||||
-- keeps its chip cry from CryData: nothing below is observable off Yellow.
|
||||
local audio = game.data.audio
|
||||
local clips = audio and audio.pikaCries
|
||||
local onYellow = check("running Yellow", GameVersion.isYellow())
|
||||
local haveClips = check("the cache carries the PCM clip set",
|
||||
type(clips) == "number")
|
||||
if not (onYellow and haveClips) then
|
||||
U.log("Import a Yellow ROM and rerun with POKEPORT_VERSION=yellow. On Red")
|
||||
U.log("and Blue there is no voiced Pikachu to get wrong.")
|
||||
idle()
|
||||
end
|
||||
-- NUM_PIKA_CRIES is 42 (pokeyellow constants/music_constants.asm), and the
|
||||
-- importer writes cry_01..cry_42.wav in PikachuCriesPointerTable order, so
|
||||
-- clip 37 existing is what makes the asleep case reachable at all.
|
||||
check("clip 37 fits inside the " .. tostring(clips) .. " clips extracted",
|
||||
clips >= 37)
|
||||
|
||||
-- resolve the two asset keys for real: a missing or unreadable wav makes
|
||||
-- playPikaCry return nil and the cry falls through to the chip cry, which
|
||||
-- is a different wrong sound from the one this issue is about. Stopped in
|
||||
-- the same frame, so neither is audible here.
|
||||
local function resolves(n)
|
||||
local src = Sound.playPikaCry(game.data, n)
|
||||
if src then src:stop() end
|
||||
return src ~= nil
|
||||
end
|
||||
check("pika_cries/cry_11.wav loads", resolves(11))
|
||||
check("pika_cries/cry_37.wav loads", resolves(37))
|
||||
|
||||
local opts = game.save.options or {}
|
||||
check("sfxVol is not muted (it reads " .. tostring(opts.sfxVol) .. ")",
|
||||
(opts.sfxVol or 0) > 0)
|
||||
check("PIKACHU VOL is not muted (it reads " .. tostring(opts.pikaVol) .. ")",
|
||||
(opts.pikaVol or 0) > 0)
|
||||
|
||||
-- Sound.playPikaCry emits "sound.played" with name = "PIKACHU_PCM_<n>";
|
||||
-- this is the same feed mods read and tests/mod_audio_tests.lua subscribes
|
||||
-- to, so the number below is the clip the engine actually asked for.
|
||||
local heardCries = {}
|
||||
local events = game.mods and game.mods.events
|
||||
if not check("the sound.played feed is live", events ~= nil and events.on ~= nil) then
|
||||
idle()
|
||||
end
|
||||
events:on("sound.played", function(p)
|
||||
if p and p.kind == "cry" then heardCries[#heardCries + 1] = p.name end
|
||||
end, nil, "bug837driver")
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "PIKACHU", 20),
|
||||
Pokemon.new(game.data, "CHARMANDER", 20),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
check("PIKACHU leads the party", game.save.party[1].species == "PIKACHU")
|
||||
|
||||
-- Route 1 so the handoff below has tall grass in reach. The route sign
|
||||
-- sits at (9, 27) (pokered data/maps/objects/Route1.asm bg_event), and the
|
||||
-- cell under it is the open path you read it from.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 9, y = 28, facing = "up" }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if not check("the overworld is up on " .. MAP, ow ~= nil) then idle() end
|
||||
|
||||
-- a map edit or a mod can wall that cell off; widen out to any free
|
||||
-- walkable neighbour rather than stranding the player inside scenery
|
||||
local function freeNear(map, x, y)
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
local cx, cy = x + dx, y + dy
|
||||
if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
return cx, cy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local cx, cy = freeNear(ow.map, STAND.x, STAND.y)
|
||||
if cx 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)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the player is standing somewhere walkable",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
-- Push the encounter rather than walking into grass: the cry under test is
|
||||
-- the player's own send-out, and a stepped encounter would put the wild
|
||||
-- rolls and a second cry ahead of it. RATTATA keeps the enemy's cry a chip
|
||||
-- cry, so it can never be confused with the PCM clip being checked.
|
||||
local function runEntrance(asleep, label, shotPath)
|
||||
for i = #heardCries, 1, -1 do heardCries[i] = nil end
|
||||
game.save.party[1].status = asleep and "SLP" or nil
|
||||
local wild = BattleState.newWild(game, "RATTATA", 3)
|
||||
wild.onFinish = function() end
|
||||
game.overworld:pushBattle(wild)
|
||||
local pcm
|
||||
for _ = 1, 500 do
|
||||
for _, name in ipairs(heardCries) do
|
||||
if name:find("PIKACHU_PCM_", 1, true) == 1 then pcm = name end
|
||||
end
|
||||
if pcm then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.log(label .. " recorded:", table.concat(heardCries, ", "))
|
||||
if shotPath then U.shot(game, shotPath) end
|
||||
return pcm, wild
|
||||
end
|
||||
|
||||
-- asleep first, so the run ends on the everyday case and what is ringing
|
||||
-- during the handoff is the clip the issue is really about
|
||||
local slept = runEntrance(true, "asleep send-out",
|
||||
DIR .. "/bug837_1_asleep.png")
|
||||
check("an asleep PIKACHU is sent out with PCM clip 37 (PikachuCry37)",
|
||||
slept == "PIKACHU_PCM_37")
|
||||
|
||||
U.teleport(game, MAP, ow.player.cellX, ow.player.cellY, STAND.facing)
|
||||
U.wait(10)
|
||||
local awake = runEntrance(false, "awake send-out",
|
||||
DIR .. "/bug837_2_awake.png")
|
||||
check("a healthy PIKACHU is sent out with PCM clip 11 (PikachuCry11)",
|
||||
awake == "PIKACHU_PCM_11")
|
||||
check("clip 1, the long title-screen cry, is not what was played",
|
||||
awake ~= "PIKACHU_PCM_1")
|
||||
|
||||
U.log("You are in the second battle, the one with PIKACHU awake. The cry")
|
||||
U.log("as it grew out of the ball should be the short bright \"Pika!\", the")
|
||||
U.log("same one you hear pressing START on the Yellow title screen. The bug")
|
||||
U.log("played the other title cry instead: the long drawn-out \"Pikachuuu\"")
|
||||
U.log("that opens the title, roughly a second and a half of it, which is")
|
||||
U.log("easy to miss as merely slow rather than wrong. Run away and walk")
|
||||
U.log("into the grass north of here for as many more send-outs as you like;")
|
||||
U.log("put PIKACHU to sleep and it turns into the sleepy clip 37 instead.")
|
||||
U.log("Screenshots of both entrances are in " .. DIR .. ".")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -0,0 +1,219 @@
|
||||
-- Animation-row SFX must obey PlaySound's channel-occupancy gate (#844).
|
||||
--
|
||||
-- Blizzard's animation is two rows -- `battle_anim BLIZZARD, ...` then
|
||||
-- `battle_anim HYDRO_PUMP, ...` (data/moves/animations.asm, BlizzardAnim) --
|
||||
-- and PlaySubanimation issues a PlaySound for every row
|
||||
-- (engine/battle/animations.asm, PlaySubanimation). The extracted data and
|
||||
-- the row timing are both faithful; what the port was missing is that the
|
||||
-- original never actually starts that second sound. Audio2_PlaySound's
|
||||
-- .playSfx/.sfxChannelLoop (audio/engine_2.asm) walks the channels the new
|
||||
-- sfx declares and, for each one already busy, does
|
||||
-- `ld a,[wSoundID] / cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`:
|
||||
-- a channel held by a LOWER sound id aborts the whole request, while an
|
||||
-- equal or lower id takes the channel over (and .playChannel resets the
|
||||
-- channel, cutting the old sound off). SFX_BATTLE_29 (BLIZZARD, CHAN5+8) is
|
||||
-- still sounding when the HYDRO_PUMP row starts, and SFX_BATTLE_2A wants
|
||||
-- CHAN5+6+8, so on hardware it is dropped outright. Unguarded, the port
|
||||
-- layered it and its watery tail outlived the animation.
|
||||
--
|
||||
-- Sound ids order by header address: `DEF \1 EQUS "((\2 - SFX_Headers_1) / 3)"`
|
||||
-- (constants/music_constants.asm, music_const), so a def's `address` is the
|
||||
-- comparable rank inside one engine bank -- which is what Sound.playMove
|
||||
-- compares and what ChipSynth.effectChannels supplies the channel set for.
|
||||
--
|
||||
-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here
|
||||
-- reads data/generated/.
|
||||
-- luajit tests/engine/move_sfx_channel_gate_bug844.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
-- ------- love.audio stub
|
||||
-- tests/love_stub carries no love.audio (headless suites never play), and
|
||||
-- the gate reads Source:isPlaying on the previously accepted row sound.
|
||||
-- These stub sources never finish on their own, which is exactly the
|
||||
-- "previous sfx is still sounding" state a mid-animation row sees.
|
||||
local sources = {}
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true; self.plays = self.plays + 1 end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:pause() self.playing = false end
|
||||
function Source:setLooping(value) self.looping = value end
|
||||
function Source:setVolume(value) self.volume = value end
|
||||
function Source:setPitch(value) self.pitch = value end
|
||||
function Source:setFilter() end
|
||||
function Source:getDuration() return 1 end
|
||||
|
||||
love.audio = {
|
||||
newSource = function(what, mode)
|
||||
local src = setmetatable({
|
||||
file = what, mode = mode, plays = 0, playing = false,
|
||||
}, Source)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end,
|
||||
}
|
||||
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
-- ------- sfx fixtures
|
||||
-- One audible note per channel. ChipAsm.sfx numbers effect channels hw+4,
|
||||
-- so hw 1/2/4 assemble as CHAN5/CHAN6/CHAN8 -- the same software channels
|
||||
-- the real sfx headers claim. `address` is the sound-id rank and `engine`
|
||||
-- names the bank the rank is comparable within.
|
||||
local function sfxDef(address, hws)
|
||||
local channels = {}
|
||||
for _, hw in ipairs(hws) do
|
||||
local program
|
||||
if hw == 4 then
|
||||
program = { { noiseNote = { len = 8, volume = 15, fade = 1,
|
||||
parameter = 0x11 } } }
|
||||
else
|
||||
program = { { squareNote = { len = 8, volume = 15, fade = 1,
|
||||
frequency = 0x600 } } }
|
||||
end
|
||||
channels[#channels + 1] = { hw = hw, program = program }
|
||||
end
|
||||
local def = ChipAsm.sfx{ channels = channels }
|
||||
def.address, def.engine = address, 2
|
||||
return def
|
||||
end
|
||||
|
||||
-- Battle_29 = CHAN5,8 at rank 16975; Battle_2A = CHAN5,6,8 at 16981. The
|
||||
-- ranks below are the same ordering, scaled small for readability.
|
||||
local defs = {
|
||||
Blizzard_Sfx = sfxDef(100, { 1, 4 }), -- SFX_BATTLE_29 shape
|
||||
HydroPump_Sfx = sfxDef(106, { 1, 2, 4 }), -- SFX_BATTLE_2A shape
|
||||
Disable_Sfx = sfxDef(100, { 4 }), -- SFX_BATTLE_1B shape: CHAN8
|
||||
Leer_Sfx = sfxDef(106, { 1, 2 }), -- SFX_BATTLE_31 shape: CHAN5,6
|
||||
Loud_Sfx = sfxDef(106, { 1, 4 }), -- a high-ranked incumbent
|
||||
Quiet_Sfx = sfxDef(100, { 1 }), -- a lower id that takes over
|
||||
Unranked_Sfx = ChipAsm.sfx{ channels = { { hw = 1, program = {
|
||||
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
|
||||
} } } }, -- a mod def: no header address
|
||||
}
|
||||
|
||||
local data = { audio = { sfx = defs, cries = {}, songs = {} } }
|
||||
|
||||
-- the gate is only observable through what actually started, so watch the
|
||||
-- Runtime event the mod SDK exposes for exactly that
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local events = require("src.mods.Events").new()
|
||||
Runtime.install(events, require("src.mods.Hooks").new())
|
||||
|
||||
local played = {}
|
||||
events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test")
|
||||
|
||||
local function reset()
|
||||
Sound.invalidate() -- also clears the tracked row sound
|
||||
for index = #sources, 1, -1 do sources[index] = nil end
|
||||
for index = #played, 1, -1 do played[index] = nil end
|
||||
end
|
||||
|
||||
local function playMove(name)
|
||||
Sound.playMove(data, { sound = name, pitch = 0, tempo = 0x80 })
|
||||
end
|
||||
|
||||
local function names()
|
||||
local out = {}
|
||||
for _, p in ipairs(played) do out[#out + 1] = p.name end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
-- ------- the channel sets the gate reads
|
||||
eq(table.concat(ChipSynth.effectChannels(data, defs.Blizzard_Sfx), ","),
|
||||
"5,8", "effectChannels reads CHAN5+8 off the Blizzard-shaped header")
|
||||
eq(table.concat(ChipSynth.effectChannels(data, defs.HydroPump_Sfx), ","),
|
||||
"5,6,8", "effectChannels reads CHAN5+6+8 off the Hydro Pump-shaped header")
|
||||
check(ChipSynth.effectChannels(data, "assets/beep.wav") == nil,
|
||||
"a file def has no knowable channel set")
|
||||
|
||||
-- ------- 1. higher id + overlapping channels is dropped (the Blizzard case)
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
check(#sources == 1 and sources[1].playing, "the Blizzard row sound starts")
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 1, "the second Blizzard row is dropped, not layered (" .. names() .. ")")
|
||||
eq(played[1] and played[1].name, "Blizzard_Sfx",
|
||||
"the sound that survives is the Blizzard row")
|
||||
eq(#sources, 1, "the dropped row never even builds a source")
|
||||
check(sources[1].playing, "the incumbent keeps sounding through the drop")
|
||||
|
||||
-- ------- 2. higher id + disjoint channels still plays (Disable/Leer)
|
||||
-- The regression guard: SFX_BATTLE_1B is CHAN8 and SFX_BATTLE_31 is
|
||||
-- CHAN5+6, so nothing is busy and both sounds are heard.
|
||||
reset()
|
||||
playMove("Disable_Sfx")
|
||||
playMove("Leer_Sfx")
|
||||
eq(#played, 2, "a higher id on disjoint channels is not gated (" .. names() .. ")")
|
||||
eq(played[2] and played[2].name, "Leer_Sfx", "the second sound is the later row")
|
||||
check(sources[1].playing and sources[2].playing,
|
||||
"neither disjoint sound cuts the other off")
|
||||
|
||||
-- ------- 3. a lower id takes the channels over
|
||||
-- .playChannel zeroes the channel state, which stops whatever held it.
|
||||
reset()
|
||||
playMove("Loud_Sfx")
|
||||
local incumbent = sources[1]
|
||||
playMove("Quiet_Sfx")
|
||||
eq(#played, 2, "a lower id is allowed to start (" .. names() .. ")")
|
||||
check(not incumbent.playing,
|
||||
"taking CHAN5 over stops the sound that held it")
|
||||
check(sources[2] and sources[2].playing, "the taking-over sound is playing")
|
||||
|
||||
-- ------- 4. an equal id restarts the sound
|
||||
-- Repeated rows of one sound (Wrap, Metronome) must not be swallowed.
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
playMove("Blizzard_Sfx")
|
||||
eq(#played, 2, "the same sound replayed is not gated against itself")
|
||||
eq(#sources, 1, "the replay reuses the cached source")
|
||||
eq(sources[1].plays, 2, "the cached source is restarted")
|
||||
check(sources[1].playing, "and is sounding afterwards")
|
||||
|
||||
-- ------- 5. an unrankable def is left exactly as it was
|
||||
-- A mod's chip sfx has no header address, so there is no comparable sound
|
||||
-- id and the gate must not invent one.
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
playMove("Unranked_Sfx")
|
||||
playMove("Unranked_Sfx")
|
||||
eq(#played, 3, "unrankable defs play regardless of what is sounding ("
|
||||
.. names() .. ")")
|
||||
|
||||
-- an unrankable def must also not become an incumbent that gates the next
|
||||
-- ranked row
|
||||
reset()
|
||||
playMove("Unranked_Sfx")
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 2, "an unrankable def gates nothing after it (" .. names() .. ")")
|
||||
|
||||
-- ------- 6. a finished sound gates nothing
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
sources[1].playing = false -- the incumbent ran out
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 2, "a row sound that already ended blocks nothing")
|
||||
|
||||
-- ------- 7. an invalidate (hot reload / cache flush) drops the tracking
|
||||
-- Sound.invalidate stops and forgets the cached sources; a stale reference
|
||||
-- would gate the next row against a dead source.
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
Sound.invalidate()
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 2, "invalidate clears the tracked row sound")
|
||||
|
||||
Runtime.install(savedEvents, savedHooks)
|
||||
|
||||
T.finish("move sfx channel gate (#844)")
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Empty confirm on the naming screen (#833). DisplayNamingScreen seeds
|
||||
-- wStringBuffer with '@' (engine/menus/naming_screen.asm), so a name the
|
||||
-- player never typed reads back as the terminator, and every caller checks
|
||||
-- that first byte: AskName falls through to .declinedNickname and copies the
|
||||
-- species name over the nick slot (vanilla's "un-nicknamed", which this port
|
||||
-- models as mon.nickname == nil, src/save_convert/GenSave.lua), while
|
||||
-- DisplayNameRaterScreen takes .playerCancelled and keeps the old nickname.
|
||||
-- Nothing in the original invents a letter, so NamingScreen:confirm must hand
|
||||
-- the caller "" rather than the literal "A" when nothing was typed -- both via
|
||||
-- START and via the ED cell. The two fallbacks that are load bearing stay:
|
||||
-- presets[1] for player/rival naming (oak_speech2.asm ChoosePlayerName never
|
||||
-- accepts an empty name) and opts.default for the Name Rater cancel.
|
||||
-- luajit tests/engine/naming_empty_confirm_bug833.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")
|
||||
|
||||
-- NamingScreen reaches for Sound at the top of the module; seeding
|
||||
-- package.loaded before it loads keeps the suite ROM-free and silent.
|
||||
package.loaded["src.core.Sound"] = { play = function() end }
|
||||
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
|
||||
-- The three things the screen touches: a stack it pops itself off, an input
|
||||
-- queue that is exactly one fixed step of edges, and a non-nil `data` for the
|
||||
-- click cue.
|
||||
local function newGame()
|
||||
local game = { data = {} }
|
||||
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 = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
-- builds a pushed screen plus a `result` table the onDone writes into
|
||||
local function newScreen(opts)
|
||||
local game = newGame()
|
||||
local result = { fired = false, name = nil }
|
||||
opts = opts or {}
|
||||
opts.onDone = function(n)
|
||||
result.fired = true
|
||||
result.name = n
|
||||
end
|
||||
local ns = NamingScreen.new(game, opts)
|
||||
game.stack:push(ns)
|
||||
return ns, game, result
|
||||
end
|
||||
|
||||
-- one fixed step with `btn` on its edge
|
||||
local function press(ns, game, btn)
|
||||
game.input.queue = { [btn] = true }
|
||||
ns:update(1 / 60)
|
||||
game.input.queue = {}
|
||||
end
|
||||
|
||||
-- the ED cell's coordinates on whatever grid the screen is showing
|
||||
local function edCell(ns)
|
||||
for r, row in ipairs(ns:grid()) do
|
||||
for c, cell in ipairs(row) do
|
||||
if cell == "ED" then return r, c end
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- START, nothing typed
|
||||
-- The nickname callers (BattleState caught-mon, Commands gift/starter) push
|
||||
-- the screen with only title/maxLen/onDone: no presets, no default.
|
||||
local ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
|
||||
press(ns, game, "start")
|
||||
check(res.fired, "START confirms an untyped name")
|
||||
eq(res.name, "", "START with nothing typed delivers the empty name")
|
||||
check(res.name ~= "A", "an untyped confirm does not invent the letter A (#833)")
|
||||
eq(#game.stack.states, 0, "confirm pops the naming screen")
|
||||
|
||||
-- the caller-shaped guard both nickname sites use
|
||||
local mon = {}
|
||||
if res.name and #res.name > 0 then mon.nickname = res.name end
|
||||
check(mon.nickname == nil,
|
||||
"an empty name leaves the mon un-nicknamed, so evolution can rename it")
|
||||
|
||||
-- ---------------------------------------------------------------- ED cell, nothing typed
|
||||
ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
|
||||
local edRow, edCol = edCell(ns)
|
||||
eq(edRow, 5, "ED sits on row 5 of the vanilla grid (data/text/alphabets.asm)")
|
||||
eq(edCol, 9, "ED is the last cell of that row")
|
||||
ns.row, ns.col = edRow, edCol
|
||||
press(ns, game, "a")
|
||||
check(res.fired, "A on the ED cell confirms")
|
||||
eq(res.name, "", "ED with nothing typed delivers the empty name too")
|
||||
|
||||
-- ---------------------------------------------------------------- typed names are untouched
|
||||
ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
|
||||
ns.row, ns.col = 1, 1 -- "A"
|
||||
press(ns, game, "a")
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "A", "a genuinely typed A still comes back as A")
|
||||
|
||||
-- ---------------------------------------------------------------- presets fallback (player / rival)
|
||||
-- ChoosePlayerName / ChooseRivalName (engine/movie/oak_speech/oak_speech2.asm)
|
||||
-- compare wStringBuffer to '@' and re-open rather than accept an empty name;
|
||||
-- the port answers the same need with its presets fallback, which #833 must
|
||||
-- not disturb.
|
||||
ns, game, res = newScreen({ title = "YOUR NAME?", maxLen = 7, presets = { "RED", "ASH" } })
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "RED", "an empty confirm with presets still yields presets[1]")
|
||||
|
||||
-- ---------------------------------------------------------------- default fallback (Name Rater)
|
||||
-- DisplayNameRaterScreen jumps to .playerCancelled on '@' and keeps the
|
||||
-- existing nickname; data/scripts/story4.lua passes it as opts.default.
|
||||
ns, game, res = newScreen({ title = "RATTATA's name?", maxLen = 10, default = "SPLASH" })
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "SPLASH", "an empty confirm with a default keeps the old nickname")
|
||||
|
||||
T.finish("naming_empty_confirm_bug833")
|
||||
@@ -0,0 +1,113 @@
|
||||
-- #828: launcher settings "reset" on Android and Steam Deck, with nothing in
|
||||
-- the log. Every options write is a WHOLE-FILE rewrite out of the caller's
|
||||
-- table (src/core/SaveData.lua saveOptions), so a filesystem that reports a
|
||||
-- successful write without the bytes surviving -- an external-storage volume
|
||||
-- that went away mid-session (conf.lua sets t.externalstorage on Android), a
|
||||
-- read-only or full save dir -- is indistinguishable from "the launcher never
|
||||
-- saved at all". saveOptions therefore reads the file back and fails loudly.
|
||||
--
|
||||
-- This suite pins that contract against injected filesystem stubs, the same
|
||||
-- { getInfo, read, write, remove } shape tests/engine/save_slots.lua and
|
||||
-- tests/engine/save_file_io_tests.lua use. It is ROM-free (T2 engine tier).
|
||||
--
|
||||
-- What it does NOT do: prove #828 is fixed. The launcher -> options.lua ->
|
||||
-- bootGame chain already round-trips correctly on desktop, so the readback is
|
||||
-- instrumentation for the two platforms that report the loss, and the real
|
||||
-- verification is a platform run (see the issue). What is testable here is
|
||||
-- that a silent no-op write is now reported instead of swallowed.
|
||||
-- luajit tests/engine/options_write_readback_bug828.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 Logger = require("src.core.Logger")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local OPTIONS = "options.lua"
|
||||
|
||||
-- An in-memory love.filesystem stub. `mode` decides what write() does with
|
||||
-- the bytes AFTER reporting success, which is the whole point of the suite:
|
||||
-- "honest" -- stores them (a working save dir)
|
||||
-- "drop" -- reports true, stores nothing (the volume vanished)
|
||||
-- "truncate" -- reports true, stores a short prefix (a full save dir)
|
||||
-- "fail" -- reports false plus an error string (the pre-existing path)
|
||||
local function memfs(mode)
|
||||
local files = {}
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content)
|
||||
if mode == "fail" then return false, "no space left on device" end
|
||||
if mode == "drop" then return true end
|
||||
if mode == "truncate" then
|
||||
files[path] = tostring(content):sub(1, 16)
|
||||
return true
|
||||
end
|
||||
files[path] = content
|
||||
return true
|
||||
end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- SaveData.persistFs hands an injected fs straight back only when it differs
|
||||
-- from love.filesystem, so the suite never touches the real save directory.
|
||||
local function logged(pattern)
|
||||
for i = #Logger.history, 1, -1 do
|
||||
if Logger.history[i]:find(pattern, 1, true) then return Logger.history[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---- the write that lands: unchanged success contract
|
||||
|
||||
local fs = memfs("honest")
|
||||
local saved = SaveData.saveOptions({ battleLayout = "wide" }, fs)
|
||||
check(saved ~= nil, "a write that lands returns the merged options table")
|
||||
eq(saved and saved.battleLayout, "wide", "the caller's key survives the merge")
|
||||
eq(saved and saved.textSpeed ~= nil, true, "defaults are filled in around it")
|
||||
check(fs.files[OPTIONS] ~= nil, "options.lua is written to the injected fs")
|
||||
|
||||
local loaded = SaveData.loadOptions(fs)
|
||||
eq(loaded and loaded.battleLayout, "wide",
|
||||
"loadOptions reads back what saveOptions wrote (the launcher -> game hop)")
|
||||
|
||||
-- ---- the write that silently does not land: the #828 failure mode
|
||||
|
||||
local dropMark = #Logger.history
|
||||
local dropped = SaveData.saveOptions({ battleLayout = "wide" }, memfs("drop"))
|
||||
eq(dropped, nil, "a write that reports success but stores nothing returns nil")
|
||||
check(logged("options save did not land"),
|
||||
"the vanished write is logged, so the next Android report can carry it")
|
||||
check(#Logger.history > dropMark, "a log line was actually emitted")
|
||||
|
||||
-- ---- a partial write is just as lost, and just as loud
|
||||
|
||||
local truncated = SaveData.saveOptions({ battleLayout = "wide" }, memfs("truncate"))
|
||||
eq(truncated, nil, "a truncated write is treated as a failed write")
|
||||
check(logged("options save did not land"), "the truncated write is logged too")
|
||||
|
||||
-- ---- the pre-existing honest failure still behaves exactly as before
|
||||
|
||||
local failMark = #Logger.history
|
||||
local failed = SaveData.saveOptions({ battleLayout = "wide" }, memfs("fail"))
|
||||
eq(failed, nil, "a write that returns false still returns nil")
|
||||
check(logged("options save failed"),
|
||||
"the false-return path keeps its own distinct log line")
|
||||
check(#Logger.history > failMark, "the false-return path still logs")
|
||||
|
||||
-- A dropped write must not be reported through the false-return message:
|
||||
-- the two are different diagnoses and the platform reports need to tell
|
||||
-- them apart.
|
||||
local last = Logger.history[#Logger.history]
|
||||
check(last and last:find("options save failed", 1, true) ~= nil,
|
||||
"the last failure logged is the false-return one, not the readback one")
|
||||
|
||||
T.finish("options_write_readback_bug828")
|
||||
@@ -0,0 +1,198 @@
|
||||
-- A RARE CANDY used from the field bag must leave the bag open (#796).
|
||||
--
|
||||
-- engine/menus/start_sub_menus.asm, StartMenu_Item / .useOrTossItem sorts the
|
||||
-- chosen item with IsInArray against UsableItems_CloseMenu first and
|
||||
-- UsableItems_PartyMenu second. RARE_CANDY is in the party-menu array
|
||||
-- (data/items/use_party.asm), so it reaches .useItem_partyMenu, which after
|
||||
-- `call UseItem` -- when wActionResultOrTookBattleTurn is not $02 --
|
||||
-- restores the screen and `jp StartMenu_Item`, i.e. re-enters the item list
|
||||
-- instead of CloseStartMenu. StartMenu_Item reloads wBagSavedMenuItem into
|
||||
-- wCurrentMenuItem before DisplayListMenuID, so the cursor comes back on the
|
||||
-- row you just used: that is what lets a stack of candies be mashed through.
|
||||
-- engine/items/item_effects.asm ItemUseVitamin .useRareCandy ends with
|
||||
-- RedrawPartyMenu / PrintStatsBox / WaitForTextScrollButtonPress /
|
||||
-- LearnMoveFromLevelUp / TryEvolvingMon and `jp RemoveUsedItem` -- it never
|
||||
-- whites out and never closes the start menu. Only .useItem_closeMenu items
|
||||
-- (UsableItems_CloseMenu: bike, escape rope, rods) jump to CloseStartMenu.
|
||||
--
|
||||
-- The port popped the bag ListMenu at the head of the leveledTo branch, which
|
||||
-- also skipped the "xN" refresh below it, so the level text played over the
|
||||
-- overworld and the player was dumped out of the menu per candy.
|
||||
-- luajit tests/engine/rare_candy_bag_open_bug796.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")
|
||||
|
||||
-- Lazily-required inside the use branches, so seeding package.loaded before
|
||||
-- the UI modules load is enough to keep the suite silent and love-free.
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function() end,
|
||||
playCry = function() end,
|
||||
}
|
||||
-- Real TextBoxes want a Font atlas. This flow only cares that a message
|
||||
-- opened, what it says, and what its onDone does.
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
}
|
||||
-- BagMenu and PartyMenu bind TextBox at require time, so they load against
|
||||
-- the stub; Screens caches its factory per id and must be told to forget.
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
package.loaded["src.ui.PartyMenu"] = nil
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
-- The fixture item table has no candy of its own; ItemEffects keys the
|
||||
-- level-up branch on the id, and BagMenu only reads name/keyItem off the def.
|
||||
Data.items.RARE_CANDY = {
|
||||
id = "RARE_CANDY", index = 90, name = "RARE CANDY", price = 4800,
|
||||
tossable = true,
|
||||
}
|
||||
|
||||
-- The mon: FIXMON_C has an empty `evolutions` and a learnset that stops at
|
||||
-- level 1, so the 5 -> 6 candy prints its line and nothing else follows it.
|
||||
-- That keeps the assertions on the bag rather than on the stat box, which
|
||||
-- would need real graphics.
|
||||
local function freshGame(candies)
|
||||
local mon = Pokemon.new(Data, "FIXMON_C", 5)
|
||||
local game = {
|
||||
data = Data,
|
||||
save = {
|
||||
party = { mon },
|
||||
player = { name = "RED", id = 1 },
|
||||
inventory = {},
|
||||
options = {},
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
}
|
||||
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,
|
||||
}
|
||||
-- one button edge per update, the way Input reports a fixed step
|
||||
game.input = { pressed = nil }
|
||||
function game.input:wasPressed(b) return self.pressed == b end
|
||||
-- FIX POTION first so the candy is never row 1: an index that silently
|
||||
-- reset to the top would otherwise pass the cursor assertion by accident
|
||||
Bag.add(game.save, "FIX_POTION", 1)
|
||||
Bag.add(game.save, "RARE_CANDY", candies)
|
||||
return game, mon
|
||||
end
|
||||
|
||||
local function isPicker(s) return getmetatable(s) == PartyMenu end
|
||||
local function isBox(s) return type(s) == "table" and s.textBox == true end
|
||||
|
||||
local function inStack(stack, pred)
|
||||
for _, s in ipairs(stack.states) do
|
||||
if pred(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function rowFor(list, id)
|
||||
for i, r in ipairs(list.items) do
|
||||
if r.value == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Open the bag, put the cursor on `id`, choose it, take USE off the
|
||||
-- USE/TOSS box, then press A on the party picker. Returns the bag list.
|
||||
local function useFromBag(game, battle, id)
|
||||
local list = BagMenu.new(game, { battle = battle })
|
||||
game.stack:push(list)
|
||||
local row = rowFor(list, id)
|
||||
if not row then return nil, "no " .. id .. " row in the bag" end
|
||||
list.index = row
|
||||
list.onChoose(list.items[row], list)
|
||||
local sub = game.stack:top()
|
||||
if not battle and sub and sub.items and sub.items[1]
|
||||
and sub.items[1].onSelect then
|
||||
game.stack:pop() -- the USE/TOSS Menu pops itself on select
|
||||
sub.items[1].onSelect()
|
||||
end
|
||||
local picker = game.stack:top()
|
||||
if not isPicker(picker) then return nil, "party picker never opened" end
|
||||
game.input.pressed = "a"
|
||||
picker:update(1 / 60)
|
||||
game.input.pressed = nil
|
||||
return list
|
||||
end
|
||||
|
||||
-- The bug: three candies in the bag, use one in the field.
|
||||
do
|
||||
local game, mon = freshGame(3)
|
||||
local list, why = useFromBag(game, nil, "RARE_CANDY")
|
||||
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
||||
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
|
||||
check(not inStack(game.stack, isPicker),
|
||||
"the pickOnly picker popped itself before onSwitch")
|
||||
check(inStack(game.stack, function(s) return s == list end),
|
||||
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
|
||||
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
|
||||
|
||||
local row = rowFor(list, "RARE_CANDY")
|
||||
if check(row ~= nil, "the RARE CANDY row survived the use") then
|
||||
eq(list.items[row].right, "x2", "and its count followed the inventory")
|
||||
eq(list.index, row, "with the cursor left on it (wBagSavedMenuItem), "
|
||||
.. "so the next candy is one A press away")
|
||||
end
|
||||
eq(game.save.inventory.RARE_CANDY, 2, "one candy was consumed")
|
||||
|
||||
local box = game.stack:top()
|
||||
if check(isBox(box), "the grew-to-level line prints over the open bag") then
|
||||
check(box.text:find("level 6", 1, true) ~= nil,
|
||||
"and it names the new level: " .. tostring(box.text))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the
|
||||
-- cursor clamps to a real row -- but the list itself still must not close.
|
||||
do
|
||||
local game = freshGame(1)
|
||||
local list = useFromBag(game, nil, "RARE_CANDY")
|
||||
if check(list ~= nil, "the bag reached the picker with a single candy") then
|
||||
check(inStack(game.stack, function(s) return s == list end),
|
||||
"the last candy does not close the bag either (#796)")
|
||||
check(rowFor(list, "RARE_CANDY") == nil, "its row was removed")
|
||||
eq(game.save.inventory.RARE_CANDY, nil, "and the slot is empty")
|
||||
check(list.index >= 1 and list.index <= #list.items,
|
||||
"the cursor clamped to a valid row (index " .. tostring(list.index)
|
||||
.. " of " .. #list.items .. ")")
|
||||
end
|
||||
end
|
||||
|
||||
-- Boundary the fix must not have moved: a candy is refused mid-battle, so the
|
||||
-- field behavior above can never be mistaken for a battle regression.
|
||||
-- item_effects.asm:800-803, ItemUseVitamin reads wIsInBattle and
|
||||
-- `jp nz, ItemUseNotTime` before it ever falls into ItemUseMedicine, which is
|
||||
-- why the port's battle-side branch is a guard rather than a live path. A
|
||||
-- bare table stands in for the battle: nothing on this route reads it.
|
||||
do
|
||||
local game, mon = freshGame(3)
|
||||
local list = useFromBag(game, { fakeBattle = true }, "RARE_CANDY")
|
||||
if check(list ~= nil, "the battle bag reached the picker") then
|
||||
eq(mon.level, 5, "a RARE CANDY mid-battle levels nothing (ItemUseVitamin "
|
||||
.. "-> ItemUseNotTime)")
|
||||
eq(game.save.inventory.RARE_CANDY, 3, "and is not consumed")
|
||||
local box = game.stack:top()
|
||||
if check(isBox(box), "the refusal prints") then
|
||||
check(box.text:find("time to use", 1, true) ~= nil,
|
||||
"with ItemUseNotTime's line: " .. tostring(box.text))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -90,7 +90,14 @@ end
|
||||
-- type-4 turn can still use it
|
||||
do
|
||||
local _, _, rows = typeOf("BUBBLEBEAM", true)
|
||||
eq(rows[1].sfx, "Damage", "the row carries the damage sound")
|
||||
-- PlayApplyingAttackSound sets wFrequencyModifier alongside the sound
|
||||
-- ($20 for SFX_DAMAGE), and the noise channel's polynomial counter IS
|
||||
-- that modifier, so the row carries both now (#826)
|
||||
eq(type(rows[1].sfx) == "table" and rows[1].sfx.sound, "Damage",
|
||||
"the row carries the damage sound")
|
||||
eq(rows[1].sfx.pitch, 0x20, "with its PlayApplyingAttackSound pitch byte")
|
||||
eq(rows[1].sfx.tempo, nil,
|
||||
"and no tempo byte: Audio2_note_length skips the sfx tempo on CHAN8")
|
||||
local _, _, plain = typeOf("TACKLE", true)
|
||||
check(plain[1].blink ~= nil, "a type-4 row carries the pic to blink")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
-- Parity test (#805): ESCAPE ROPE / DIG / TELEPORT must land on an outdoor
|
||||
-- fly-warp cell, and must re-point the LAST_MAP memory at it.
|
||||
--
|
||||
-- pret: ItemUseEscapeRope (engine/items/item_effects.asm) sets BIT_FLY_WARP
|
||||
-- and BIT_ESCAPE_WARP, and LoadSpecialWarpData's .usedFlyWarp path
|
||||
-- (engine/overworld/special_warps.asm) warps to wLastBlackoutMap with the
|
||||
-- landing cell read from FlyWarpDataPtr. wLastBlackoutMap is ALWAYS an
|
||||
-- outdoor map: SetLastBlackoutMap (engine/events/set_blackout_map.asm)
|
||||
-- copies wLastMap, and WarpFound2 (home/overworld.asm) only writes wLastMap
|
||||
-- when CheckIfInOutsideMap passes. PrepareForSpecialWarp
|
||||
-- (engine/overworld/special_warps.asm) then does `ld [wLastMap], a` with
|
||||
-- that destination for every fly/escape warp that is not a dungeon warp.
|
||||
--
|
||||
-- The port broke both halves. A .sav import stamps lastHeal from wherever
|
||||
-- the cartridge was saved (src/save_convert/SaveConvert.lua mergeDefaults,
|
||||
-- which records no outdoor), so a save made inside Seafoam Islands made
|
||||
-- ESCAPE ROPE warp the player back into that cave; and the teleport branch
|
||||
-- skipped rememberOutdoor, so the first LAST_MAP exit after the rope still
|
||||
-- resolved against the dungeon door walked in through.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_escape_rope_bug805.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
local S = require("tests.harness").suite("parity escape rope #805")
|
||||
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 Map = require("src.world.Map")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 20) }
|
||||
|
||||
-- The player is deep in a cave, having walked in from the outdoor door
|
||||
-- cell the LAST_MAP exits still point at.
|
||||
Game.stack:push(OW, "SEAFOAM_ISLANDS_B2F", 5, 5, "down")
|
||||
local ow = Game.stack:top()
|
||||
Game.overworld = ow
|
||||
|
||||
-- Capture the warp target instead of running the real Transition.
|
||||
local dest
|
||||
local realStart = ow.startWarpTo
|
||||
ow.startWarpTo = function(self, mapId, x, y, facing, onDone, opts)
|
||||
dest = { map = mapId, x = x, y = y }
|
||||
self.arriveWarp = nil
|
||||
self.transitioning = false
|
||||
end
|
||||
|
||||
local outsideTilesets = FieldDefaults.field(Data, "outsideTilesets")
|
||||
local function isOutside(mapId)
|
||||
local def = Data.maps[mapId]
|
||||
return def ~= nil and Map.isOutside(def, outsideTilesets)
|
||||
end
|
||||
|
||||
local flyWarps = Data.field.flyWarps or {}
|
||||
local bootHeal = SaveData.defaultHeal(Data.field.boot)
|
||||
|
||||
-- --------------------------------------------------------------- 1. healthy
|
||||
-- A save healed by a nurse records the outdoor town alongside the interior
|
||||
-- heal cell; the rope lands on that town's FlyWarpDataPtr cell.
|
||||
Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
|
||||
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 27 } }
|
||||
ow:rememberOutdoor("ROUTE_23", 8, 60) -- the Victory Road door walked in from
|
||||
dest = nil
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
|
||||
check(flyWarps.VIRIDIAN_CITY ~= nil, "Viridian City has a fly warp cell")
|
||||
eq(dest.map, "VIRIDIAN_CITY", "healthy heal record: rope lands on the town")
|
||||
eq(dest.x, flyWarps.VIRIDIAN_CITY.x, "rope lands on the FlyWarpDataPtr x")
|
||||
eq(dest.y, flyWarps.VIRIDIAN_CITY.y, "rope lands on the FlyWarpDataPtr y")
|
||||
|
||||
-- 3. PrepareForSpecialWarp: the destination becomes the new wLastMap, so a
|
||||
-- LAST_MAP exit taken after the rope resolves against the town just landed
|
||||
-- in, not the dungeon door from before.
|
||||
eq(Game.save.lastOutdoor.id, "VIRIDIAN_CITY",
|
||||
"teleport warp re-points wLastMap at the destination (#805)")
|
||||
eq(Game.save.lastOutdoor.x, dest.x, "wLastMap x follows the landing cell")
|
||||
eq(Game.save.lastOutdoor.y, dest.y, "wLastMap y follows the landing cell")
|
||||
|
||||
-- --------------------------------------------------------------- 2. imported
|
||||
-- Exactly what SaveConvert stamps for a cartridge save made in a cave: the
|
||||
-- player's own cell, no outdoor town. wLastBlackoutMap can never name an
|
||||
-- indoor map, so this record is unusable and falls back to the boot heal
|
||||
-- town (vanilla's zero-filled wLastBlackoutMap is map 0, Pallet Town).
|
||||
Game.save.lastHeal = { map = "SEAFOAM_ISLANDS_B2F", x = 5, y = 5 }
|
||||
ow:rememberOutdoor("ROUTE_23", 8, 60)
|
||||
dest = nil
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
|
||||
check(not isOutside("SEAFOAM_ISLANDS_B2F"),
|
||||
"Seafoam Islands B2F is not an outside map")
|
||||
check(dest.map ~= "SEAFOAM_ISLANDS_B2F",
|
||||
"imported heal record does not dump the rope back in the cave (#805)")
|
||||
check(isOutside(dest.map), "escape-warp destination is always an outside map")
|
||||
eq(dest.map, bootHeal.map, "unusable heal record falls back to the boot town")
|
||||
eq(dest.x, bootHeal.x, "boot-town fallback keeps its landing x")
|
||||
eq(dest.y, bootHeal.y, "boot-town fallback keeps its landing y")
|
||||
eq(Game.save.lastOutdoor.id, bootHeal.map,
|
||||
"fallback landing is remembered as wLastMap too")
|
||||
|
||||
-- --------------------------------------------------------------- 3. blackout
|
||||
-- A blackout (no opts) still lands on the interior heal cell and re-points
|
||||
-- LAST_MAP exits at the remembered town door: HandleBlackOut never sets
|
||||
-- BIT_FLY_WARP, so it is not a special warp destination of its own.
|
||||
Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
|
||||
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 27 } }
|
||||
ow:rememberOutdoor("ROUTE_23", 8, 60)
|
||||
dest = nil
|
||||
ow:warpToHealPoint()
|
||||
|
||||
eq(dest.map, "VIRIDIAN_POKECENTER", "blackout still lands at the heal cell")
|
||||
eq(Game.save.lastOutdoor.id, "VIRIDIAN_CITY",
|
||||
"blackout re-points wLastMap at the remembered town door")
|
||||
eq(Game.save.lastOutdoor.x, 23, "blackout keeps the recorded door x")
|
||||
eq(Game.save.lastOutdoor.y, 27, "blackout keeps the recorded door y")
|
||||
|
||||
ow.startWarpTo = realStart
|
||||
S.finish()
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Parity test: gym leader TM award respects the bag cap (#797).
|
||||
--
|
||||
-- scripts/PewterGym.asm, PewterGymScriptReceiveTM34: after
|
||||
-- SetEvent EVENT_BEAT_BROCK it runs `lb bc, TM_BIDE, 1` / `call GiveItem`
|
||||
-- / `jr nc, .BagFull`. On success it prints TEXT_PEWTERGYM_RECEIVED_TM34
|
||||
-- (and the TM34 explanation); on carry-clear it prints
|
||||
-- TEXT_PEWTERGYM_TM34_NO_ROOM instead. Both paths fall through to
|
||||
-- .gymVictory, so the badge lands either way and the TM is simply lost.
|
||||
-- The same `jr nc, .BagFull` shape is in CeruleanGym.asm, VermilionGym.asm,
|
||||
-- CeladonGym.asm, FuchsiaGym.asm, SaffronGym.asm, CinnabarGym.asm and
|
||||
-- ViridianGym.asm.
|
||||
--
|
||||
-- The port drives this through OverworldState:checkVictoryRewards over
|
||||
-- data/scripts/victories.lua (gym leaders are not def_trainers entries, so
|
||||
-- src/script/Commands.lua give_item -- which has always handled a full bag
|
||||
-- -- is never on this path). checkVictoryRewards used to write the TM
|
||||
-- straight into save.inventory, bypassing Bag.add's BAG_ITEM_CAPACITY
|
||||
-- check and producing a 21-of-20 bag while still printing "received TM".
|
||||
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 gym TM bag full #797")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local victories = require("data.scripts.victories")
|
||||
|
||||
-- === (1) data shape: every gym reward carries both branches ===
|
||||
-- A future gym edit must not silently drop the alternate line, so the
|
||||
-- table check is cheap insurance over the eight leader entries.
|
||||
do
|
||||
local n = 0
|
||||
for key, entry in pairs(victories) do
|
||||
if entry.badge then
|
||||
n = n + 1
|
||||
check(type(entry.itemDialogue) == "table" and #entry.itemDialogue > 0,
|
||||
key .. " has an itemDialogue tail (the GiveItem-succeeded text)")
|
||||
check(type(entry.bagFull) == "string",
|
||||
key .. " names a bagFull text (.BagFull branch)")
|
||||
local body = entry.bagFull and (Data.text or {})[entry.bagFull]
|
||||
check(type(body) == "string" and body ~= "",
|
||||
key .. " bagFull label resolves to extracted text")
|
||||
-- the received-TM tail must not still be baked into `dialogue`,
|
||||
-- or the full-bag path would print it anyway
|
||||
for _, label in ipairs(entry.dialogue or {}) do
|
||||
for _, tail in ipairs(entry.itemDialogue or {}) do
|
||||
check(label ~= tail,
|
||||
key .. " dialogue no longer repeats " .. tostring(tail))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
eq(n, 8, "all eight gym leader rewards checked")
|
||||
end
|
||||
|
||||
-- === (2) behavior: Brock with a full bag vs an empty one ===
|
||||
|
||||
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 OW = require("src.world.OverworldController")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
|
||||
-- concatenates the pages of the TextBox checkVictoryRewards pushed
|
||||
local function stackedDialogue()
|
||||
local top = Game.stack:top()
|
||||
if not (top and top.pages) then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages) do
|
||||
parts[#parts + 1] = table.concat(page, "\n")
|
||||
end
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
local function freshSave()
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.flags = {}
|
||||
Game.save.inventory = {}
|
||||
Game.save.bagOrder = nil
|
||||
Game.save.defeatedTrainers = {}
|
||||
end
|
||||
|
||||
-- --- full bag: the TM is refused, the NoRoom line replaces the TM text ---
|
||||
freshSave()
|
||||
local cap = Bag.capacity(Data)
|
||||
-- Bag.slots only counts non-badge ids, so distinct filler ids fill it
|
||||
for i = 1, cap do Game.save.inventory["FILLER" .. i] = 1 end
|
||||
eq(Bag.slots(Game.save), cap, "bag starts at BAG_ITEM_CAPACITY")
|
||||
|
||||
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
|
||||
local ow = Game.stack:top()
|
||||
ow:checkVictoryRewards("OPP_BROCK", 1)
|
||||
local fullText = stackedDialogue()
|
||||
|
||||
check(Game.save.inventory.TM_BIDE == nil,
|
||||
"full bag: TM_BIDE is refused (GiveItem carry clear)")
|
||||
eq(Bag.slots(Game.save), cap,
|
||||
"full bag: no 21st slot appears (the reporter's symptom)")
|
||||
check(Game.save.inventory.BOULDERBADGE == 1,
|
||||
"full bag: .gymVictory still awards BOULDERBADGE")
|
||||
check(Game.save.flags.EVENT_BEAT_BROCK,
|
||||
"full bag: EVENT_BEAT_BROCK is still set")
|
||||
check(fullText:find("room for this", 1, true) ~= nil,
|
||||
"full bag: dialogue prints _PewterGymTM34NoRoomText")
|
||||
check(fullText:find("BIDE", 1, true) == nil,
|
||||
"full bag: the TM34 explanation is skipped")
|
||||
check(fullText:find("FLASH", 1, true) ~= nil,
|
||||
"full bag: the BoulderBadge speech still runs")
|
||||
|
||||
-- --- empty bag: the success tail still appends and the TM lands ---
|
||||
freshSave()
|
||||
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
|
||||
ow = Game.stack:top()
|
||||
ow:checkVictoryRewards("OPP_BROCK", 1)
|
||||
local okText = stackedDialogue()
|
||||
|
||||
check(Game.save.inventory.TM_BIDE == 1,
|
||||
"empty bag: TM_BIDE lands in the bag")
|
||||
local order = Bag.order(Game.save)
|
||||
check(order[1] == "TM_BIDE",
|
||||
"empty bag: Bag.add kept the wBagItems order (bagOrder) honest")
|
||||
check(okText:find("BIDE", 1, true) ~= nil,
|
||||
"empty bag: itemDialogue (TM34 explanation) still appends")
|
||||
check(okText:find("room for this", 1, true) == nil,
|
||||
"empty bag: the NoRoom line is not printed")
|
||||
|
||||
-- --- one more leader, to prove the split is not Pewter-only ---
|
||||
freshSave()
|
||||
for i = 1, cap do Game.save.inventory["FILLER" .. i] = 1 end
|
||||
Game.stack:push(OW, "CERULEAN_GYM", 4, 10, "up")
|
||||
ow = Game.stack:top()
|
||||
ow:checkVictoryRewards("OPP_MISTY", 1)
|
||||
local mistyText = stackedDialogue()
|
||||
check(Game.save.inventory.TM_BUBBLEBEAM == nil,
|
||||
"Misty full bag: TM_BUBBLEBEAM is refused")
|
||||
check(Game.save.inventory.CASCADEBADGE == 1,
|
||||
"Misty full bag: CASCADEBADGE still awarded")
|
||||
eq(Bag.slots(Game.save), cap, "Misty full bag: still at capacity")
|
||||
check(mistyText:find((Data.text or {})._CeruleanGymMistyTM11NoRoomText
|
||||
:match("^[^\n]+") or "\1", 1, true) ~= nil,
|
||||
"Misty full bag: dialogue prints _CeruleanGymMistyTM11NoRoomText")
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
|
||||
S.finish()
|
||||
@@ -116,16 +116,20 @@ local HallOfFame = require("src.ui.HallOfFame")
|
||||
check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed")
|
||||
|
||||
-- Gen1 layout (issue #102): pic rests at hlcoord (12,5); mon phase starts
|
||||
-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone)
|
||||
-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone).
|
||||
-- HoFShowMonOrPlayer sweeps the BACK pic across the screen first (hSCX
|
||||
-- $c0 -> $a0) and only then scrolls the front pic in (#847), so the
|
||||
-- induction opens on the back pass.
|
||||
local hofUi = stack2:top()
|
||||
eq(hofUi.phase, "mons", "induction opens on the mon showcase phase")
|
||||
eq(hofUi.scrollX < 12 * 8, true, "front pic starts off-screen left of (12,5)")
|
||||
-- drive past the scroll so the info box is armed
|
||||
eq(hofUi.phase, "back", "induction opens on the back pic sweep (#847)")
|
||||
eq(hofUi.scrollX, 160, "back pic enters at the right edge (hSCX = $c0)")
|
||||
-- drive the back sweep and the front scroll so the info box is armed
|
||||
local scrollGuard = 0
|
||||
while hofUi.scrollX < 12 * 8 and scrollGuard < 200 do
|
||||
while (hofUi.phase == "back" or hofUi.scrollX < 12 * 8) and scrollGuard < 400 do
|
||||
scrollGuard = scrollGuard + 1
|
||||
hofUi:update(1 / 60)
|
||||
end
|
||||
eq(hofUi.phase, "mons", "the front pic phase follows the back sweep (#847)")
|
||||
eq(hofUi.scrollX, 12 * 8, "front pic settles at hlcoord (12,5)")
|
||||
eq(hofUi.showHofBanner, false, "bottom HALL OF FAME banner waits for the 80-frame hold")
|
||||
check(hofUi.timer == 80 or hofUi.timer < 80,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Parity test: getting on SURF ends the bike (#846).
|
||||
--
|
||||
-- pokered keeps walking / biking / surfing in ONE state byte,
|
||||
-- wWalkBikeSurfState. ItemUseSurfboard (engine/items/item_effects.asm)
|
||||
-- copies the old state aside, refuses when it is already 2, and on a
|
||||
-- successful mount does `ld a, 2 / ld [wWalkBikeSurfState], a ; change
|
||||
-- player state to surfing` followed by PlayDefaultMusic -- the bike state
|
||||
-- is overwritten, so no bike can survive a surf: not its 8-frame step
|
||||
-- cadence, not its theme. The port splits that byte into two independent
|
||||
-- flags (Game.save.onBike and player.surfing) and nothing used to clear
|
||||
-- the first when the second went up, so a player who surfed off the bike
|
||||
-- paddled at bike speed with Music_BikeRiding still playing.
|
||||
--
|
||||
-- The mirror direction is explicit in the same asm file: ItemUseBicycle
|
||||
-- opens `ld a, [wWalkBikeSurfState] / cp 2 ; is the player surfing? /
|
||||
-- jp z, ItemUseNotTime`, so the bag cannot re-raise the bike on water.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_surf_clears_bike_bug846.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity surf clears bike")
|
||||
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 ItemEffects = require("src.inventory.ItemEffects")
|
||||
local Music = require("src.core.Music")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.overworld = OW
|
||||
|
||||
-- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox
|
||||
-- upvalue for a stub and never puts it back, and run_tests.lua runs every
|
||||
-- parity suite from one file: point it back at the real module so trySurf
|
||||
-- pushes a real box here (same guard as parity_field_move_layering.lua).
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
while true do
|
||||
local n = debug.getupvalue(fn, i)
|
||||
if not n then return false end
|
||||
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox"))
|
||||
|
||||
local function frame(btns)
|
||||
Input.pressed = {}
|
||||
for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end
|
||||
StateStack:update(1 / 60)
|
||||
for _, b in ipairs(btns or {}) do Input.state[b] = false end
|
||||
end
|
||||
|
||||
local function popAll() while Game.stack:top() do Game.stack:pop() end end
|
||||
|
||||
local function pushOW(mapId, x, y, facing)
|
||||
popAll()
|
||||
Game.stack:push(OW, mapId, x, y, facing)
|
||||
return Game.stack:top()
|
||||
end
|
||||
|
||||
local function mkMon(species, ...)
|
||||
local m = Pokemon.new(Data, species, 20)
|
||||
m.moves = {}
|
||||
for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end
|
||||
return m
|
||||
end
|
||||
|
||||
-- Music.play no-ops on the headless audio stub, so the song the bike/surf
|
||||
-- override actually resolves to is only observable by intercepting it
|
||||
-- (the Music.playMap stub pattern in parity_seam_walk_anim.lua)
|
||||
local playedSong
|
||||
local realPlay = Music.play
|
||||
Music.play = function(_, song) playedSong = song end
|
||||
|
||||
local bikeSong = Music.special(Data, "bike")
|
||||
local surfSong = Music.special(Data, "surf")
|
||||
check(bikeSong ~= nil and surfSong ~= nil and bikeSong ~= surfSong,
|
||||
"the bike and surf themes are two distinct songs")
|
||||
|
||||
-- =====================================================================
|
||||
-- control: on land, onBike really does buy the halved step cadence, so
|
||||
-- the assertion after the mount below is not vacuous
|
||||
-- =====================================================================
|
||||
local ow = pushOW("PALLET_TOWN", 5, 6, "up")
|
||||
local p = ow.player
|
||||
eq(p.stepFrames, 16, "a walking step is 16 frames")
|
||||
eq(p.bikeStepFrames, 8, "the bicycle doubles walking speed (8 frames)")
|
||||
Game.save.onBike = true
|
||||
p.turnTimer = 0
|
||||
p.stepFramesCur = nil
|
||||
eq(p:tryMove("up", ow.map, ow.entities), "moved", "riding north out of the spawn cell")
|
||||
eq(p.stepFramesCur, p.bikeStepFrames, "onBike hands out the bike cadence on land")
|
||||
|
||||
-- =====================================================================
|
||||
-- the mount: bike state is gone the moment the got-on text closes, and
|
||||
-- the first step onto the water is a WALK-length step, not a bike one
|
||||
-- =====================================================================
|
||||
ow = pushOW("PALLET_TOWN", 4, 13, "down")
|
||||
p = ow.player
|
||||
p.surfing = false
|
||||
Game.save.onBike = true
|
||||
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
|
||||
-- HM03's badge gate (FieldDefaults hmBadges): without it partyKnows("SURF")
|
||||
-- refuses and trySurf never prints anything
|
||||
Game.save.inventory.SOULBADGE = true
|
||||
check(ow:partyKnows("SURF") ~= nil, "the party can use SURF here")
|
||||
check(ow.map:isWaterCell(4, 14), "Pallet's south shore faces water at (4,14)")
|
||||
|
||||
-- what is playing when the mount starts: the bike override over the
|
||||
-- outdoor Pallet theme
|
||||
Music.playMap(Data, "PALLET_TOWN", true, false)
|
||||
eq(playedSong, bikeSong, "the bike theme plays while riding through Pallet")
|
||||
|
||||
p.stepFramesCur = nil
|
||||
ow:trySurf(4, 14, nil)
|
||||
local box = Game.stack:top()
|
||||
check(box ~= nil and box.pages ~= nil, "SURF prints _SurfingGotOnText")
|
||||
local guard = 0
|
||||
while Game.stack:top() == box and guard < 400 do
|
||||
guard = guard + 1
|
||||
frame({ "a" })
|
||||
end
|
||||
check(Game.stack:top() ~= box, "the got-on text closes")
|
||||
|
||||
eq(p.surfing, true, "the mount raises the surf state")
|
||||
eq(Game.save.onBike, false,
|
||||
"ItemUseSurfboard writes surfing OVER the bike state, so the bike ends (#846)")
|
||||
eq(playedSong, surfSong,
|
||||
"PlayDefaultMusic after the mount picks the surf theme, not the bike theme")
|
||||
|
||||
-- the blink carries the mount forward onto the water; let that scripted
|
||||
-- step land (it is queued through scriptMove, which drives the entity
|
||||
-- directly and never touches Player:tryMove)
|
||||
guard = 0
|
||||
while (Game.stack:top() ~= ow or p.moving or #ow.scriptMoves > 0) and guard < 240 do
|
||||
guard = guard + 1
|
||||
frame({})
|
||||
end
|
||||
eq(Game.stack:top(), ow, "the mount ends back on the map")
|
||||
eq(p.cellY, 14, "the mount steps forward onto the water")
|
||||
|
||||
-- the symptom in #846: the first paddled step the player takes. It runs
|
||||
-- through Player:tryMove, which reads Game.save.onBike for its step
|
||||
-- length -- a stale bike flag paddles at 8 frames per cell.
|
||||
check(ow.map:isWaterCell(4, 15), "the next cell south is water too")
|
||||
p.turnTimer = 0
|
||||
p.stepFramesCur = nil
|
||||
eq(p:tryMove("down", ow.map, ow.entities), "moved", "paddling south from (4,14)")
|
||||
eq(p.stepFramesCur, p.stepFrames,
|
||||
"the paddled step uses the walk cadence, the exact symptom in #846")
|
||||
check(p.stepFramesCur ~= p.bikeStepFrames, "no bike cadence survives onto the water")
|
||||
|
||||
-- =====================================================================
|
||||
-- the mirror hole: the bag cannot put the bike back on under a surfer
|
||||
-- (ItemUseBicycle's `cp 2` -> jp z, ItemUseNotTime), or the bug returns
|
||||
-- by another route
|
||||
-- =====================================================================
|
||||
local save = SaveData.newGame()
|
||||
local surfingOw = { player = { surfing = true } }
|
||||
local result, msgs = ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, surfingOw)
|
||||
eq(result, "failed", "the BICYCLE is refused while surfing")
|
||||
check(result ~= "bicycle", "a surfing BICYCLE never reaches the mount path")
|
||||
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
|
||||
"the surfing BICYCLE refusal uses the OAK 'not the time' text")
|
||||
|
||||
local landOw = { player = { surfing = false } }
|
||||
eq((ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, landOw)), "bicycle",
|
||||
"the BICYCLE still mounts normally on land")
|
||||
|
||||
Music.play = realPlay
|
||||
popAll()
|
||||
S.finish()
|
||||
@@ -0,0 +1,89 @@
|
||||
-- #835: the launcher must open on the game that was played last, instead of
|
||||
-- always opening on Red. Two halves, both asserted here: RomImporter:play
|
||||
-- writes the chosen version to options.lua, and RomImporter:_applyLastVersionTab
|
||||
-- reads it back when the constructor has finished filling self.ready.
|
||||
-- Self-contained: `luajit tests/rom_importer_last_version_test.lua`; also
|
||||
-- dofile'd by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer last version")
|
||||
local eq = S.eq
|
||||
|
||||
love.mouse.isCursorSupported = function() return false end
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
-- The options round trip must not touch the developer's real save directory.
|
||||
-- SaveData.persistFs consults SaveData.portableFs() before love.filesystem
|
||||
-- (src/core/SaveData.lua:203-208), so overriding that one hook reroutes both
|
||||
-- loadOptions and saveOptions onto this in-memory volume. saveOptions reads
|
||||
-- the file back after writing (#828), so read/write have to be truthful.
|
||||
-- The override is process-global and tests/run_tests.lua dofiles every suite
|
||||
-- into one process, so it MUST be put back before this file returns: leaving
|
||||
-- it in place reroutes every later suite's save I/O into `disk` (parity_hof
|
||||
-- reads its own save back and fails 6 assertions if this leaks).
|
||||
local realPortableFs = SaveData.portableFs
|
||||
local disk = {}
|
||||
SaveData.portableFs = function()
|
||||
return {
|
||||
getInfo = function(name) return disk[name] and { type = "file" } or nil end,
|
||||
read = function(name) return disk[name] or nil, "no file: " .. name end,
|
||||
write = function(name, data) disk[name] = data return true end,
|
||||
remove = function(name) disk[name] = nil end,
|
||||
}
|
||||
end
|
||||
|
||||
local function newImporter(fields)
|
||||
local ri = setmetatable(fields, RomImporter)
|
||||
return ri
|
||||
end
|
||||
|
||||
-- ---- write side: play() records the version it hands off to boot
|
||||
|
||||
local booted = nil
|
||||
local ri = newImporter({
|
||||
android = true, -- skips the cursor restore; see #114 suite
|
||||
workState = nil,
|
||||
tab = "red",
|
||||
ready = { yellow = true },
|
||||
onComplete = function(version) booted = version end,
|
||||
})
|
||||
ri:play("yellow")
|
||||
eq(booted, "yellow", "play boots the chosen version")
|
||||
eq(SaveData.loadOptions().lastVersion, "yellow", "play remembers the version played")
|
||||
|
||||
-- ---- read side: a fresh launcher opens on that column
|
||||
|
||||
local ri2 = newImporter({ tab = "red", ready = { red = true, yellow = true } })
|
||||
ri2:_applyLastVersionTab()
|
||||
eq(ri2.tab, "yellow", "launcher opens on the last played version")
|
||||
|
||||
-- A remembered version whose cache is gone or stale must not open a column
|
||||
-- with no Play button in it.
|
||||
local ri3 = newImporter({ tab = "red", ready = { red = true, yellow = false } })
|
||||
ri3:_applyLastVersionTab()
|
||||
eq(ri3.tab, "red", "an unready remembered version leaves the tab alone")
|
||||
|
||||
-- An explicit --game shortcut (main.lua sets LaunchOptions.pendingTab) wins
|
||||
-- over the remembered version.
|
||||
LaunchOptions.pendingTab = "blue"
|
||||
local ri4 = newImporter({ tab = "blue", ready = { red = true, yellow = true } })
|
||||
ri4:_applyLastVersionTab()
|
||||
eq(ri4.tab, "blue", "an explicit --game tab beats the remembered version")
|
||||
LaunchOptions.pendingTab = nil -- module is a singleton: do not leak this
|
||||
|
||||
-- A junk value in options.lua (hand-edited file, a build that knew other
|
||||
-- versions) must not select a tab that does not exist.
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.lastVersion = "gold"
|
||||
SaveData.saveOptions(opts)
|
||||
local ri5 = newImporter({ tab = "red", ready = { red = true, yellow = true } })
|
||||
ri5:_applyLastVersionTab()
|
||||
eq(ri5.tab, "red", "an unknown remembered version leaves the tab alone")
|
||||
|
||||
SaveData.portableFs = realPortableFs
|
||||
|
||||
S.finish()
|
||||
@@ -3400,6 +3400,9 @@ runSuites({ "tests/input_hold_test.lua" })
|
||||
-- ---------------------------------------------- launcher cursor (#114)
|
||||
runSuites({ "tests/rom_importer_cursor_test.lua" })
|
||||
|
||||
-- ---------------------------------------------- launcher last played tab (#835)
|
||||
runSuites({ "tests/rom_importer_last_version_test.lua" })
|
||||
|
||||
-- ---------------------------------------------- Android second ROM pick (#167)
|
||||
runSuites({ "tests/rom_importer_android_pick_test.lua" })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user