Merge pull request #2851 from bryanthaboi/dev

orcs v human
This commit is contained in:
bryanthaboi
2026-10-10 14:13:20 -04:00
committed by GitHub
23 changed files with 925 additions and 25 deletions
+7 -4
View File
@@ -309,11 +309,14 @@ M.PALLET_TOWN = {
game.stack:push(TextBox.new(game,
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
-- .HeyWaitDontGoOutText turns the player to face down (toward
-- the approaching Oak) before the exclamation bubble
function()
ow.emote = nil
oakAppearsAndWalks()
end, { auto = { delay = 10, overlap = 60, onOverlap = function()
-- scripts/PalletTown.asm:186
ow.player.facing = "down"
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
-- scripts/PalletTown.asm:185
ow.emote = { npc = ow.player, frames = 60 }
end } }))
return true
end,
+1 -3
View File
@@ -214,9 +214,7 @@ local function applySavedAudioMode()
end)
if not ok or type(savedOptions) ~= "table" then savedOptions = {} end
local mode = savedOptions.audioMode or "both"
if love and love.audio and love.audio.setMixWithSystem then
pcall(love.audio.setMixWithSystem, mode ~= "game_only")
end
require("src.audio.AudioMix").set(mode ~= "game_only")
end
local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch
+23
View File
@@ -0,0 +1,23 @@
local AudioMix = {}
local sent
function AudioMix.set(mix)
mix = mix and true or false
if sent == mix then return false end
if not (love and love.audio and love.audio.setMixWithSystem) then
return false
end
if pcall(love.audio.setMixWithSystem, mix) then sent = mix end
return true
end
function AudioMix.current()
return sent
end
function AudioMix._resetForTest()
sent = nil
end
return AudioMix
+6 -4
View File
@@ -573,9 +573,7 @@ end
function Music.setAudioMode(mode)
currentAudioMode = mode or "both"
if love and love.audio and love.audio.setMixWithSystem then
pcall(love.audio.setMixWithSystem, currentAudioMode ~= "game_only")
end
require("src.audio.AudioMix").set(currentAudioMode ~= "game_only")
Music.setVolumeLevel(storedVolumeLevel)
end
@@ -634,7 +632,11 @@ function Music.onDeviceReset()
local src = require("src.core.ChipAudio").currentSource()
if not src then return end
state.source = src
applyVolume(src)
if state.fade then
fadeSetLevel(state.fade, state.fade.level)
else
applyVolume(src)
end
applyFilter(src)
return
end
+1 -5
View File
@@ -370,9 +370,7 @@ function Audio.applyEngineOptions(opts)
if type(opts) ~= "table" then return end
local mode = opts.audioMode or "both"
Audio._audioMode = mode
if love and love.audio and love.audio.setMixWithSystem then
pcall(love.audio.setMixWithSystem, mode ~= "game_only")
end
require("src.audio.AudioMix").set(mode ~= "game_only")
if mode == "external_only" then
Audio._bgmVolume = 0
else
@@ -467,7 +465,6 @@ function Audio.playSong(id, opts)
}
Audio._mapSong = Audio._mapSong or id
-- A new song owns the bus — cancel stale fades and fanfares (oak exit fade was killing lab BGM).
Audio._fadeOut = nil
Audio._fadeIn = nil
Audio._fanfareActive = false
@@ -1743,7 +1740,6 @@ function Audio.onFocusGained()
end
end
--- Device reset: QueueableSource may be dead — rebuild then refill.
function Audio.rebuildPlayback()
Audio._suspended = false
if not (love and love.audio and love.audio.newQueueableSource) then return false end
+7 -3
View File
@@ -118,6 +118,7 @@ function ListMenu.new(game, title, items, opts)
self.onChoose = opts.onChoose
self.onCancel = opts.onCancel
self.footer = opts.footer
self.listPrompt = opts.footer
self.pageJump = opts.pageJump -- Left/Right move a page at a time
self.wrap = opts.wrap -- Up on first / Down on last wraps
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
@@ -312,6 +313,10 @@ end
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
function ListMenu:drawItemBox()
local hasBox = self.messageBox or self.footer
-- engine/menus/players_pc.asm:151-162
local boxUnder = hasBox and self.footer == self.listPrompt
if boxUnder then drawMessageBox(self) end
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th)
love.graphics.setColor(0, 0, 0, 1)
@@ -356,9 +361,8 @@ function ListMenu:drawItemBox()
and (self.arrowBlink or 0) < ARROW_BLINK_ON then
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
end
-- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID,
-- so the bottom box sits under the list from the first frame
if self.messageBox or self.footer then drawMessageBox(self) end
-- engine/menus/players_pc.asm:173
if hasBox and not boxUnder then drawMessageBox(self) end
love.graphics.setColor(1, 1, 1, 1)
end
+1 -1
View File
@@ -159,7 +159,7 @@ local function markerSheet(def, seed)
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObjLit() -- engine/items/town_map.asm:325
colors, group = PaletteFX.ogObjNormal() -- home/palettes.asm:24
else
colors, group = PaletteFX.dmgObjLit()
end
+12
View File
@@ -204,9 +204,21 @@ local function makeFollower(game, ow, x, y, facing)
if idle and idle.phase then return idle.phase % 2 end
return NPC.walkPhase(self)
end
npc.draw = function(self, camX, camY)
if PikachuFollower.underPlayer(ow, self) then return end
return NPC.draw(self, camX, camY)
end
return npc
end
-- engine/pikachu/pikachu_follow.asm:1030
function PikachuFollower.underPlayer(ow, npc)
local p = ow and ow.player
if not (p and npc) or npc.moving then return false end
return npc.cellX == (p.targetX or p.cellX)
and npc.cellY == (p.targetY or p.cellY)
end
local function findFollower(ow)
for i, npc in ipairs(ow.npcs or {}) do
if npc.pikachuFollower then return npc, i end
+15 -2
View File
@@ -7067,7 +7067,8 @@ function World:startBattle(opts, onDone)
-- whatever stood there.
if whiteout then
self:healParty()
if not BugContest.isActive(game.save) then
local inContest = BugContest.isActive(game.save)
if not inContest then
CallAsm.run(self, "HalveMoney")
CallAsm.run(self, "GetWhiteoutSpawn")
end
@@ -7078,6 +7079,12 @@ function World:startBattle(opts, onDone)
Runtime.emit("world.blacked_out",
{ save = game.save, healTarget = self:healPoint() })
end
if inContest then
self:restoreMapMusic()
-- engine/events/whiteout.asm:24-25
self:bugContestResults()
return
end
-- engine/events/whiteout.asm:19-20
self:runMapSetup(MAPSETUP.WARP, function()
self:warpToSpawn()
@@ -11078,9 +11085,15 @@ end
function World:whiteOut()
self:showText(
Strings("You have no more\nPOKéMON that can\011fight!"), function()
self:healParty()
-- engine/events/whiteout.asm:15-16
if BugContest.isActive(self.game and self.game.save) then
-- engine/events/whiteout.asm:24-25
self:bugContestResults()
return
end
CallAsm.run(self, "HalveMoney")
CallAsm.run(self, "GetWhiteoutSpawn")
self:healParty()
-- Guarded because healPoint walks the spawn table to answer: with nobody
-- listening the blackout must not pay for a lookup warpToSpawn is about to
-- make again anyway.
+69
View File
@@ -0,0 +1,69 @@
-- scripts/PalletTown.asm:177-188
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local ok = true
local function check(label, cond)
if not cond then ok = false end
U.log(cond and "PASS" or "FAIL", label)
end
local function done()
love.event.quit(ok and 0 or 1)
end
local f = game.save.flags
f.EVENT_FOLLOWED_OAK_INTO_LAB = nil
f.EVENT_GOT_STARTER = nil
U.teleport(game, "PALLET_TOWN", 10, 3, "up")
local ow = game.stack:top()
local box
for _ = 1, 120 do
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
U.wait(1)
local top = game.stack:top()
if getmetatable(top) == TextBox then box = top break end
end
game.input.state.up = false
check("hey wait text box opened", box ~= nil)
if not box then return done() end
local bubbleStart
for _ = 1, 600 do
if ow.emote and ow.emote.npc == ow.player then bubbleStart = true break end
U.wait(1)
end
check("bubble appears while text box is up", bubbleStart ~= nil and game.stack:top() == box)
if not bubbleStart then return done() end
U.wait(30)
check("text box still up mid-bubble", game.stack:top() == box)
U.still(game, DIR .. "/2843_01_bubble_with_textbox.png")
local boxGone, bubbleGone
for _ = 1, 200 do
U.wait(1)
if not boxGone and game.stack:top() ~= box then boxGone = U.frame() end
if not bubbleGone and not (ow.emote and ow.emote.npc == ow.player) then bubbleGone = U.frame() end
if boxGone and bubbleGone then break end
end
local held = box.autoTimer and box.auto and (box.autoTimer - box.auto.delay)
U.log("bubble frames under box", tostring(held))
check("text box and bubble close on the same frame",
boxGone ~= nil and boxGone == bubbleGone)
check("bubble lasts 60 frames under the box", held == 60)
U.still(game, DIR .. "/2843_02_box_and_bubble_gone.png")
local oakShown = false
for _ = 1, 60 do
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "PALLETTOWN_OAK" then oakShown = true end
end
if oakShown then break end
U.wait(1)
end
check("oak appears after the bubble", oakShown)
done()
end
@@ -0,0 +1,120 @@
-- engine/menus/main_menu.asm:106-112, home/fade_audio.asm:12-45
return function(game)
local U = dofile("tests/drivers/util.lua")
local SaveData = require("src.core.SaveData")
local Music = require("src.core.Music")
local ChipAudio = require("src.core.ChipAudio")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots/2844"
local fails = 0
local function check(label, ok, detail)
print((ok and "PASS " or "FAIL ") .. label
.. (detail ~= nil and (" " .. tostring(detail)) or ""))
if not ok then fails = fails + 1 end
return ok
end
local function finish()
print(fails == 0 and "PASS continue_fade_no_jump_bug2844"
or "FAIL continue_fade_no_jump_bug2844")
love.event.quit(fails == 0 and 0 or 1)
while true do coroutine.yield() end
end
local function waitFor(pred, limit)
for _ = 1, limit or 1800 do
if pred() then return true end
U.wait(1)
end
return false
end
local mixCalls = 0
local realMix = love.audio.setMixWithSystem
love.audio.setMixWithSystem = function(mix)
mixCalls = mixCalls + 1
love.event.push("audioreset")
if realMix then return realMix(mix) end
return true
end
if not check("new game reached the overworld", U.newGame(game)) then
return finish()
end
local ow = game.overworld
game.save.player.map = ow.map.id
game.save.player.x, game.save.player.y = ow.player.cellX, ow.player.cellY
check("save written", SaveData.save(game.save) ~= false)
local mapSong = game.data.audio.mapSongs[ow.map.id]
game:returnToTitle()
local title = game.stack:top()
for _ = 1, 120 do
title = game.stack:top()
if title and title.screenId == "TitleState" and title.phase == "loop" then
break
end
U.tap(game, "start")
U.wait(10)
end
check("title reached its loop", title and title.phase == "loop")
local titleSong
check("title music playing", waitFor(function()
titleSong = Music.current()
return titleSong ~= nil and titleSong ~= mapSong
and ChipAudio.currentSource() ~= nil
end, 600), titleSong)
U.tap(game, "start")
check("main menu open", waitFor(function()
return title.menuOpen and game.stack:top() ~= title
end, 600))
U.wait(4)
local menu = game.stack:top()
U.tap(game, "a")
check("CONTINUE info window open", waitFor(function()
local top = game.stack:top()
return top ~= menu and top and top.titleUiBox and top.titleUiBox[1] == 4
end, 300))
U.wait(4)
local titleSource = ChipAudio.currentSource()
mixCalls = 0
U.tap(game, "a")
local fadeFrames, swapped, rose, lastVol = 0, false, false, nil
local switchFrame, shot = nil, false
for f = 1, 300 do
local top = game.stack:top()
if top and top.screenId == "QuarantineReport" then
table.insert(game.input.pressQueue, "a")
end
local cur = Music.current()
local level, _, vol
if Music.fadeLevel then level, _, vol = Music.fadeLevel() end
if level and cur == titleSong then
fadeFrames = fadeFrames + 1
if ChipAudio.currentSource() ~= titleSource then swapped = true end
local live = ChipAudio.currentSource()
local ok, v = pcall(function() return live:getVolume() end)
v = ok and v or vol
if lastVol and v and v > lastVol + 1e-6 then rose = true end
lastVol = v or lastVol
if fadeFrames == 30 and not shot then
shot = true
U.still(game, SHOT_DIR .. "/2844_01_overworld_title_fading.png")
end
end
if not switchFrame and cur == mapSong then switchFrame = f end
if switchFrame and f > switchFrame + 5 then break end
U.wait(1)
end
check("CONTINUE sends no setMixWithSystem (no audioreset)", mixCalls == 0,
mixCalls)
check("title fade ran", fadeFrames >= 80, fadeFrames)
check("title keeps its queued stream through the fade (no position jump)",
not swapped)
check("source volume never rises during the fade", not rose)
check("map song takes over after the fade", switchFrame ~= nil, switchFrame)
love.audio.setMixWithSystem = realMix
return finish()
end
@@ -0,0 +1,82 @@
-- ../pokecrystal/engine/events/whiteout.asm:9-25
-- ../pokecrystal/engine/events/std_scripts.asm:308-315
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local BugContest = require("src.core.gen2.BugContest")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-contest-whiteout-2845"
return function(game)
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
local fails = 0
local function ok(cond, line)
if not cond then fails = fails + 1 end
print("[2845] " .. (cond and "PASS " or "FAIL ") .. line)
return cond
end
local function finish()
print("[2845] " .. (fails == 0 and "all claims passed" or (fails .. " claims failed")))
love.event.quit(fails == 0 and 0 or 1)
while true do U.wait(60) end
end
local function mapId(world)
return world.map and world.map.def and world.map.def.id
end
U.wait(45)
local world, save = game.world, game.save
if not ok(world and world.map and world.vm and save, "the world booted") then finish() end
local lead = Mon.new(game.data, "SENTRET", 3)
lead.hp = 1
save.party = { lead }
save.player = save.player or {}
save.player.money = 3000
BugContest.start(save)
world:warpToMapId("NATIONAL_PARK", 10, 20, "down")
U.wait(45)
ok(mapId(world) == "NATIONAL_PARK", "standing in the park mid-contest")
ok(BugContest.isActive(save), "the contest is running")
local wild = Mon.new(game.data, "SCYTHER", 50)
wild.moves = { { id = "QUICK_ATTACK", pp = 30, maxPp = 30 } }
ok(world:startBattle({ wild = wild, contest = true }), "a contest battle started")
local screen
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
if not ok(screen ~= nil and screen.contest, "the contest battle screen came up") then finish() end
for _ = 1, 3000 do
if game.stack:top() ~= screen then break end
U.tap(game, "a")
U.wait(3)
end
ok(screen.battle and screen.battle.outcome == "lose", "the battle ended in a loss")
local atGate = false
for _ = 1, 600 do
if mapId(world) == "ROUTE_36_NATIONAL_PARK_GATE" then atGate = true break end
U.wait(2)
end
ok(atGate, "the wipe warped to the national park gate, not a Pokecenter (map "
.. tostring(mapId(world)) .. ")")
U.wait(30)
U.still(game, SHOT_DIR .. "/2845_01_gate_after_wipe.png")
local deadline = love.timer.getTime() + 15
while world.vm:running() and love.timer.getTime() < deadline do
U.tap(game, "a")
U.wait(3)
end
ok(not world.vm:running(), "the judging script ran to completion")
ok(not BugContest.isActive(save), "the contest is over")
ok(mapId(world) == "ROUTE_36_NATIONAL_PARK_GATE", "still at the gate after judging")
ok(save.player.money == 3000, "the wallet was not halved")
ok((save.party[1] and save.party[1].hp or 0) > 0, "the party was healed")
U.wait(10)
U.still(game, SHOT_DIR .. "/2845_02_judged_at_gate.png")
finish()
end
+69
View File
@@ -0,0 +1,69 @@
-- engine/menus/players_pc.asm:151-173
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local Menu = require("src.ui.Menu")
local Font = require("src.render.Font")
local ok = true
local function check(label, cond)
if not cond then ok = false end
U.log(cond and "PASS" or "FAIL", label)
end
local function hasLabel(menu, needle)
if getmetatable(menu) ~= Menu then return false end
for _, it in ipairs(menu.items or {}) do
if type(it.label) == "string" and it.label:find(needle, 1, true) then return true end
end
return false
end
local function mash(cond)
for _ = 1, 200 do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
local function boxOrder(state)
local seen = {}
local real = Font.drawBox
Font.drawBox = function(tx, ty, ...)
seen[#seen + 1] = tx .. "," .. ty
return real(tx, ty, ...)
end
local good, err = pcall(state.draw, state)
Font.drawBox = real
if not good then error(err, 0) end
return table.concat(seen, " ")
end
local function done()
love.event.quit(ok and 0 or 1)
end
game.save.pcItems = { POTION = 3 }
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_POKEDEX = true
U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up")
U.tap(game, "a")
check("pc main menu opens", mash(function() return hasLabel(game.stack:top(), "LOG OFF") end))
U.wait(8)
U.tap(game, "down")
U.wait(3)
U.tap(game, "a")
check("player pc opens", mash(function() return hasLabel(game.stack:top(), "WITHDRAW ITEM") end))
U.wait(8)
U.tap(game, "a")
U.wait(10)
local list = game.stack:top()
check("withdraw list open", list and list.kind == "pc_item_withdraw")
if not (list and list.kind == "pc_item_withdraw") then return done() end
check("prompt box under the list", boxOrder(list):find("0,12 4,2", 1, true) ~= nil)
U.still(game, DIR .. "/2840_01_withdraw_list_over_prompt.png")
U.tap(game, "a")
U.wait(10)
check("How many? box over the list", boxOrder(list):find("4,2 0,12", 1, true) ~= nil)
U.still(game, DIR .. "/2840_02_how_many_over_list.png")
done()
end
@@ -0,0 +1,82 @@
-- engine/pikachu/pikachu_follow.asm:1030
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local GameVersion = require("src.core.GameVersion")
local NPC = require("src.world.NPC")
local PF = require("src.world.PikachuFollower")
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local failures = 0
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
if not ok then failures = failures + 1 end
return ok
end
check("running as Yellow", GameVersion.isYellow())
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
game.save.pikachuInBall = false
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
game.save.onBike = false
local pikaDraws = 0
local realDraw = NPC.draw
NPC.draw = function(self, ...)
if self.pikachuFollower then pikaDraws = pikaDraws + 1 end
return realDraw(self, ...)
end
local function plain(map, x, y)
return map:inBounds(x, y) and map:isWalkableCell(x, y)
and not map:isGrassCell(x, y)
end
U.teleport(game, "ROUTE_1", 10, 20, "down")
U.wait(5)
local ow = game.overworld
local sx, sy
for y = 4, 30 do
for x = 2, 16 do
if not sx and plain(ow.map, x, y) and plain(ow.map, x, y + 1)
and plain(ow.map, x, y + 2) and plain(ow.map, x - 1, y)
and plain(ow.map, x + 1, y) then
sx, sy = x, y
end
end
end
check("found an open Route 1 cell", sx ~= nil)
sx, sy = sx or 10, sy or 20
U.teleport(game, "ROUTE_1", sx, sy, "down")
U.wait(30)
ow = game.overworld
local npc = PF.current(ow)
local p = ow.player
check("follower spawned on the Route 1 map load", npc ~= nil)
check("spawn state 0 parks Pikachu on the player's cell",
npc and npc.cellX == p.cellX and npc.cellY == p.cellY)
pikaDraws = 0
local shotOk = U.still(game, SHOT_DIR .. "/2846_01_route1_before_step.png")
check("Pikachu is not drawn before the first step (draws=" .. pikaDraws .. ")",
shotOk and pikaDraws == 0)
U.hold(game, "down", 20)
U.wait(20)
npc = PF.current(ow)
p = ow.player
check("after one step Pikachu trails one cell behind",
npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY - 1)
pikaDraws = 0
shotOk = U.still(game, SHOT_DIR .. "/2846_02_route1_after_step.png")
check("Pikachu is drawn after the first step (draws=" .. pikaDraws .. ")",
shotOk and pikaDraws > 0)
NPC.draw = realDraw
U.log(failures == 0 and "PASS all" or ("FAIL " .. failures .. " check(s)"))
love.event.quit(failures == 0 and 0 or 1)
end
@@ -41,7 +41,7 @@ return function(game)
local red = sprites.SPRITE_RED
local colors, group
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
colors, group = PaletteFX.ogObjNormal()
else
colors, group = PaletteFX.dmgObj()
end
+38
View File
@@ -0,0 +1,38 @@
-- engine/items/town_map.asm:325
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
local ok = true
local function check(label, cond)
if not cond then ok = false end
U.log(cond and "PASS" or "FAIL", label)
end
game.save.visited = { PALLET_TOWN = true, VIRIDIAN_CITY = true }
U.teleport(game, "VIRIDIAN_CITY", 20, 20, "down")
U.wait(10)
game.save.options = game.save.options or {}
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
U.wait(4)
Screens.push(game, "TownMap")
U.wait(20)
local top = game.stack:top()
check("town map is up", top and top.playerQuad ~= nil)
local red = (game.data.sprites or {}).SPRITE_RED
local colors, group = PaletteFX.ogObjNormal()
local want = red and SpriteRenderer.obpImage(red.image, colors, group)
check("marker is the rOBP0 $D0 bake", top and want ~= nil and top.playerSheet == want)
local base = PaletteFX.ogObjBase()
check("OBJ color 1 is white", table.concat(colors[2], ",") == table.concat(base[1], ","))
for _ = 1, 60 do
if top.blink < 16 then break end
coroutine.yield()
end
U.shot(game, DIR .. "/2841_01_town_map_ogred_marker.png")
love.event.quit(ok and 0 or 1)
end
+7 -2
View File
@@ -36,10 +36,15 @@ Music.applyOptions({ audioMode = "both", musicVol = 7 })
eq(Music.audioMode(), "both", "Music mode set to 'both'")
eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'both' enables mixWithSystem")
mixWithSystemCalls = {}
Music.applyOptions({ audioMode = "both", musicVol = 7 })
eq(#mixWithSystemCalls, 0, "re-applying unchanged options sends no setMixWithSystem (#2844)")
mixWithSystemCalls = {}
Music.applyOptions({ audioMode = "external_only", musicVol = 7 })
eq(Music.audioMode(), "external_only", "Music mode set to 'external_only'")
eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'external_only' enables mixWithSystem")
eq(#mixWithSystemCalls, 0, "'both' -> 'external_only' keeps mixWithSystem on without a resend")
eq(require("src.audio.AudioMix").current(), true, "audioMode 'external_only' enables mixWithSystem")
mixWithSystemCalls = {}
Music.applyOptions({ audioMode = "game_only", musicVol = 7 })
@@ -60,7 +65,7 @@ Game3Audio.applyEngineOptions({ audioMode = "external_only", musicVol = 7, sfxVo
eq(Game3Audio._audioMode, "external_only", "Game3 audioMode set to 'external_only'")
eq(Game3Audio._bgmVolume, 0, "Game3 'external_only' mutes in-game BGM")
check(Game3Audio._sfxVolume > 0, "Game3 'external_only' keeps SFX active")
eq(mixWithSystemCalls[#mixWithSystemCalls], true, "Game3 'external_only' enables mixWithSystem")
eq(require("src.audio.AudioMix").current(), true, "Game3 'external_only' enables mixWithSystem")
mixWithSystemCalls = {}
Game3Audio.applyEngineOptions({ audioMode = "game_only", musicVol = 7, sfxVol = 7 })
@@ -0,0 +1,91 @@
-- home/fade_audio.asm:12-35
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
love = require("tests.love_stub")
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
local current
local resets = 0
local fakeChip = {
playMusic = function()
current = setmetatable({ playing = true }, Source)
return current
end,
currentSource = function() return current end,
rebuildPlayback = function()
current = setmetatable({ playing = true }, Source)
resets = resets + 1
return true
end,
stopMusic = function() current = nil end,
update = function() end,
ensureMusicPlaying = function() end,
holdMusic = function() end,
awaitingFirstBuffer = function() return false end,
applyOptions = function() return false end,
setStereo = function() end,
}
package.loaded["src.core.ChipAudio"] = fakeChip
local Music = require("src.core.Music")
local mixCalls = 0
love.audio = {
newSource = function() return setmetatable({}, Source) end,
setMixWithSystem = function()
mixCalls = mixCalls + 1
fakeChip.rebuildPlayback()
Music.onDeviceReset()
return true
end,
}
local data = { audio = {
songs = {
Music_TitleScreen = { chip = true },
Music_PalletTown = { chip = true },
},
mapSongs = { PALLET_TOWN = "Music_PalletTown" },
} }
local opts = { audioMode = "both", musicVol = 7, musicFilter = 0 }
Music.applyOptions(opts)
Music.play(data, "Music_TitleScreen")
local title = current
eq(Music.current(), "Music_TitleScreen", "title theme playing")
mixCalls, resets = 0, 0
Music.applyOptions(opts)
eq(mixCalls, 0, "CONTINUE re-applying the same options sends no setMixWithSystem")
eq(resets, 0, "so the playing title stream is not rebuilt mid-song")
check(current == title, "the title keeps its queued stream")
local full = current.volume
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
eq(Music.current(), "Music_TitleScreen", "title fades before the map theme")
for _ = 1, 3 * (Music.MAP_FADE + 1) do Music.update(data) end
local faded = current.volume
check(faded < full, "fade has lowered the title volume")
fakeChip.rebuildPlayback()
Music.onDeviceReset()
check(math.abs(current.volume - faded) < 1e-9,
"a device reset mid-fade keeps the faded level")
T.finish("continue_fade_audio_reset_bug2844")
@@ -0,0 +1,48 @@
-- engine/menus/players_pc.asm:151-173
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local eq = T.eq
love = love or require("tests.love_stub")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local game = { data = { items = {} }, save = {}, input = { wasPressed = function() return false end } }
local realBox, realDraw, realCode = Font.drawBox, Font.draw, Font.drawCode
local boxes
Font.drawBox = function(tx, ty) boxes[#boxes + 1] = tx .. "," .. ty end
Font.draw = function() end
Font.drawCode = function() end
local list = ListMenu.new(game, nil, { { label = "POTION", count = 1 }, { label = "CANCEL", cancel = true } }, {
messageBox = true,
footer = "What do you want\nto withdraw?",
})
local function order()
boxes = {}
list:draw()
return table.concat(boxes, " ")
end
eq(order(), "0,12 4,2", "prompt box drawn first, list border on top")
list.footer = "How many?"
eq(order(), "4,2 0,12", "How many? box drawn over the list")
list.footer = "Withdrew\nPOTION."
eq(order(), "4,2 0,12", "withdrew box drawn over the list")
list.footer = "What do you want\nto withdraw?"
eq(order(), "0,12 4,2", "back at the prompt the list is on top again")
local bag = ListMenu.new(game, "ITEMS", { { label = "CANCEL", cancel = true } }, { itemBox = true })
boxes = {}
bag:draw()
eq(table.concat(boxes, " "), "4,2", "bag list draws no bottom box")
bag.footer = "The party is full!"
boxes = {}
bag:draw()
eq(table.concat(boxes, " "), "4,2 0,12", "late message box goes over the list")
Font.drawBox, Font.draw, Font.drawCode = realBox, realDraw, realCode
T.finish("pc item box order bug 2840")
+64
View File
@@ -0,0 +1,64 @@
-- engine/items/town_map.asm:325
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 GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local TownMap = require("src.ui.TownMap")
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
local realObp = SpriteRenderer.obpImage
local baked = {}
SpriteRenderer.obpImage = function(path, colors, group)
baked[#baked + 1] = { path = path, colors = colors, group = group }
return nil
end
local function game()
return {
data = {
field = {
townMap = { PALLET_TOWN = { x = 1, y = 2, name = "PALLET TOWN" } },
playerSprites = { walk = "SPRITE_RED" },
},
sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } },
maps = {},
},
save = {},
overworld = { map = { id = "PALLET_TOWN" } },
}
end
for _, version in ipairs({ "red", "blue" }) do
GameVersion.set(version)
PaletteFX.setMode("ogred")
baked = {}
TownMap.new(game(), {})
local bake
for _, b in ipairs(baked) do
if b.path == "assets/generated/sprites/red_walk.png" then bake = b end
end
check(bake ~= nil, version .. ": player marker baked")
if bake then
local want, group = PaletteFX.ogObjNormal()
local base = PaletteFX.ogObjBase()
eq(bake.group, group, version .. ": marker uses the rOBP0 $D0 bake")
for i = 2, 4 do
eq(table.concat(bake.colors[i], ","), table.concat(want[i], ","),
version .. ": OBJ color " .. (i - 1))
end
eq(table.concat(bake.colors[2], ","), table.concat(base[1], ","),
version .. ": OBJ color 1 is the white shade")
end
end
SpriteRenderer.obpImage = realObp
GameVersion.set(prevVersion)
PaletteFX.setMode(prevMode)
T.finish("town map OG rOBP0 bug 2841")
+119
View File
@@ -0,0 +1,119 @@
-- engine/events/whiteout.asm:9-25, engine/events/std_scripts.asm:308-315
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 contest whiteout")
local check, eq = S.check, S.eq
local World = require("src.world.gen2.World")
local Screens = require("src.ui.Screens")
local Events = require("src.world.gen2.Events")
local Save = require("src.core.gen2.Save")
local BugContest = require("src.core.gen2.BugContest")
local DATA = {
pokemon = {
CATERPIE = { id = "CATERPIE", name = "CATERPIE", index = 10, baseExp = 53,
growthRate = "MEDIUM_FAST", stats = { hp = 45, attack = 30,
defense = 35, speed = 45, specialAttack = 20, specialDefense = 20 },
types = { "BUG" } },
RATTATA = { id = "RATTATA", name = "RATTATA", index = 19, baseExp = 57,
growthRate = "MEDIUM_FAST", stats = { hp = 30, attack = 56,
defense = 35, speed = 72, specialAttack = 25, specialDefense = 35 },
types = { "NORMAL" } },
},
moves = {},
}
local function mon(species, level, hp)
return { species = species, name = species, nickname = species,
level = level, hp = hp, maxHp = 30,
stats = { hp = 30, attack = 10, defense = 10, speed = 10,
specialAttack = 10, specialDefense = 10 },
moves = {}, experience = 0, statExp = {}, dvs = {} }
end
local function contestSave()
local save = Save.normalize({ party = { mon("RATTATA", 10, 0) },
player = { name = "GOLD", money = 3000 } })
BugContest.start(save)
return save
end
local function contestWorld(save)
local captured, log = {}, {}
Screens.invalidate()
local game = {
data = {
pokemon = DATA.pokemon, moves = DATA.moves,
screens = {
Gen2BattleState = function(_, opts)
captured.opts = opts
return { screenId = "Gen2BattleState" }
end,
},
},
save = save,
stack = { push = function() end, pop = function() end },
}
local world = World.new(game)
game.world = world
world.map = { def = { id = "NATIONAL_PARK" } }
world.maps = { NATIONAL_PARK = world.map.def }
world.events = Events.new()
world.playBattleMusic = function() end
world.battleMusicContext = function() return nil end
world.pushBattleTransition = function() return nil end
world.restoreMapMusic = function() end
world.roamMonsAfterBattle = function() end
world.healParty = function() log[#log + 1] = "heal" end
world.runMapSetup = function(_, _, fn) log[#log + 1] = "mapsetup" fn() end
world.warpToSpawn = function() log[#log + 1] = "spawn" end
world.bugContestResults = function() log[#log + 1] = "results" return true end
world.bugContestOver = function() log[#log + 1] = "over" return true end
world.showText = function(_, _, onDone) if onDone then onDone() end end
return world, captured, log
end
local function has(log, entry)
for _, e in ipairs(log) do if e == entry then return true end end
return false
end
do
local save = contestSave()
local world, captured, log = contestWorld(save)
world:startBattle({ wild = mon("CATERPIE", 7, 20), contest = true })
check(captured.opts ~= nil, "a contest encounter pushes the battle screen")
captured.opts.onDone("lose")
check(has(log, "heal"), "the party is healed")
check(has(log, "results"),
"a contest wipe runs BugContestResultsWarpScript")
check(not has(log, "spawn"), "and does not warp to the last Pokecenter")
check(not has(log, "over"), "and does not also run the out-of-balls script")
eq(save.player.money, 3000, "nor halve the wallet")
end
do
local save = Save.normalize({ party = { mon("RATTATA", 10, 0) },
player = { name = "GOLD", money = 3000 } })
local world, captured, log = contestWorld(save)
world:startBattle({ wild = mon("CATERPIE", 7, 20) })
captured.opts.onDone("lose")
check(has(log, "spawn"), "outside the contest a wipe still warps home")
check(not has(log, "results"), "and never runs the contest results")
eq(save.player.money, 1500, "and halves the wallet")
end
do
local save = contestSave()
local world, _, log = contestWorld(save)
world:whiteOut()
check(has(log, "results"),
"a poison wipe in the park also goes to the contest results")
check(not has(log, "spawn"), "rather than to the last Pokecenter")
eq(save.player.money, 3000, "and keeps the wallet whole")
end
Screens.invalidate()
return S.finish()
@@ -0,0 +1,60 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity Yellow Pikachu under player")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local NPC = require("src.world.NPC")
local Follower = require("src.world.PikachuFollower")
local spriteDef = Data.sprites and (Data.sprites.SPRITE_PIKACHU or Data.sprites.SPRITE_RED)
if not spriteDef then
for _, def in pairs(Data.sprites or {}) do spriteDef = def break end
end
if not spriteDef then
print("[skip] parity Yellow Pikachu under player: no sprite table")
S.finish()
return
end
local game = { data = { sprites = { SPRITE_PIKACHU = spriteDef }, field = {} },
save = { party = {} } }
local walkable = function() return true end
local ow = {
map = { id = "ROUTE_1", inBounds = walkable, isWalkableCell = walkable },
player = { cellX = 5, cellY = 9, facing = "down" },
npcs = {}, entities = {},
}
local restore = Follower.setShouldSpawn(function() return true end)
local draws = 0
local realDraw = NPC.draw
NPC.draw = function() draws = draws + 1 end
Follower.onMapEntered(game, ow, nil, true)
local pika = Follower.current(ow)
check(pika, "a map load spawns the follower")
eq(pika and pika.cellX, 5, "spawn state 0 puts Pikachu on the player's column")
eq(pika and pika.cellY, 9, "spawn state 0 puts Pikachu on the player's row")
check(pika and Follower.underPlayer(ow, pika), "the follower counts as under the player")
pika:draw(0, 0)
eq(draws, 0, "Pikachu on the player's cell draws nothing before the first step")
ow.player.targetX, ow.player.targetY = 5, 10
pika:draw(0, 0)
eq(draws, 1, "once the player commits a step off the cell Pikachu is drawn")
ow.player.cellX, ow.player.cellY = 5, 10
ow.player.targetX, ow.player.targetY = nil, nil
pika:draw(0, 0)
eq(draws, 2, "and stays drawn one cell behind")
Follower.onMapEntered(game, ow, { pikachuSpawn = 1 }, true)
pika = Follower.current(ow)
check(pika and not Follower.underPlayer(ow, pika), "a warp spawn beside the player is not hidden")
pika:draw(0, 0)
eq(draws, 3, "and draws")
NPC.draw = realDraw
Follower.setShouldSpawn(restore)
S.finish()
+2
View File
@@ -897,3 +897,5 @@ tests/engine/save_editor_toast_s6.lua
tests/engine/launcher_save_toast_s6.lua
tests/engine/save_editor_map_point_buttons_s8.lua
tests/engine/love_run_shell_matrix.lua
tests/engine/pc_item_box_order_bug2840.lua
tests/engine/town_map_og_obp0_bug2841.lua