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:
bryanthaboi
2026-08-05 11:09:05 -04:00
parent d18824cfae
commit f6392e8932
32 changed files with 2776 additions and 114 deletions
@@ -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)
+262
View File
@@ -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