From 118c45f8f6482a6fc1524c59cccfb984cfb4a0bf Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 29 Jul 2026 15:06:33 -0400 Subject: [PATCH] CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417 CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417 --- LICENSE.MD | 7 + src/core/ChipAudio.lua | 82 ++++- src/core/ChipSynth.lua | 84 ++++- src/core/chip_worker.lua | 13 +- src/pokemon/Sprites.lua | 3 +- src/world/NPC.lua | 24 +- src/world/OverworldController.lua | 122 +++++-- src/world/PikachuFollower.lua | 317 +++++++++++++++++- src/world/Player.lua | 28 +- .../pikachu_follow_distance_bug410_test.lua | 197 +++++++++++ ...pikachu_idle_center_bug411_bug417_test.lua | 313 +++++++++++++++++ .../pikachu_ledge_bug408_bug409_test.lua | 252 ++++++++++++++ tests/drivers/pikachu_talk_bug407_test.lua | 239 +++++++++++++ tests/mod_audio_tests.lua | 57 ++++ 14 files changed, 1674 insertions(+), 64 deletions(-) create mode 100644 LICENSE.MD create mode 100644 tests/drivers/pikachu_follow_distance_bug410_test.lua create mode 100644 tests/drivers/pikachu_idle_center_bug411_bug417_test.lua create mode 100644 tests/drivers/pikachu_ledge_bug408_bug409_test.lua create mode 100644 tests/drivers/pikachu_talk_bug407_test.lua diff --git a/LICENSE.MD b/LICENSE.MD new file mode 100644 index 00000000..7c093b57 --- /dev/null +++ b/LICENSE.MD @@ -0,0 +1,7 @@ +Copyright 2026 BOIS CLUB GAMES, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 17f5f3e0..e2f7b2ab 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -25,6 +25,29 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT +-- --------------------------------------------------------------------------- +-- Per-channel mix (edit these) +-- Applied on load and whenever this file hot-reloads. +-- Runtime: ChipAudio.setChannelVolume / setChannelPitch. +-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums +-- Volume: 1 = authentic, 0 = mute, >1 boosts +-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave +-- --------------------------------------------------------------------------- +local CHANNEL_VOLUME = { + [1] = 1, -- pulse 1 + [2] = 1, -- pulse 2 + [3] = 0.25, -- wave + [4] = 1, -- noise / drums +} +local CHANNEL_PITCH = { + [1] = 1, -- pulse 1 + [2] = 1, -- pulse 2 + [3] = 0.5, -- wave + [4] = 1, -- noise / drums +} +ChipSynth.setChannelVolumes(CHANNEL_VOLUME) +ChipSynth.setChannelPitches(CHANNEL_PITCH) + -- currentMusic: { source, gen, threaded, started, finished, engine } -- threaded songs stream from the worker (engine is nil here); -- the fallback path owns a local engine and fills the source itself. @@ -152,13 +175,23 @@ function ChipAudio.playMusic(data, header, allowLoops) musicGen = musicGen + 1 local gen = musicGen cmdCh:push({ cmd = "play", gen = gen, header = header, - allowLoops = allowLoops, audio = slimAudio(data) }) + allowLoops = allowLoops, audio = slimAudio(data), + channelVolumes = ChipSynth.getChannelVolumes(), + channelPitches = ChipSynth.getChannelPitches() }) currentMusic = { source = source, gen = gen, threaded = true, started = false, finished = false } -- playback starts in update() once the first buffer arrives (~1 frame) return source end +local function pushChannelMix() + if workerReady and cmdCh then + cmdCh:push({ cmd = "channelMix", + volumes = ChipSynth.getChannelVolumes(), + pitches = ChipSynth.getChannelPitches() }) + end +end + -- move finished buffers from the worker into the Source; start playback once -- the first one lands local function updateThreaded() @@ -270,6 +303,53 @@ function ChipAudio.invalidate() if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end end +-- Runtime mix for one hardware channel (1..4). Takes effect on the next +-- synthesized buffer (live music) and on any SFX/cry rendered after the call. +function ChipAudio.setChannelVolume(hw, scale) + ChipSynth.setChannelVolume(hw, scale) + pushChannelMix() +end + +function ChipAudio.getChannelVolume(hw) + return ChipSynth.getChannelVolume(hw) +end + +function ChipAudio.setChannelVolumes(volumes) + ChipSynth.setChannelVolumes(volumes) + pushChannelMix() +end + +function ChipAudio.getChannelVolumes() + return ChipSynth.getChannelVolumes() +end + +function ChipAudio.setChannelPitch(hw, scale) + ChipSynth.setChannelPitch(hw, scale) + pushChannelMix() +end + +function ChipAudio.getChannelPitch(hw) + return ChipSynth.getChannelPitch(hw) +end + +function ChipAudio.setChannelPitches(pitches) + ChipSynth.setChannelPitches(pitches) + pushChannelMix() +end + +function ChipAudio.getChannelPitches() + return ChipSynth.getChannelPitches() +end + +-- aliases for channel 4 (noise / drums) +function ChipAudio.setNoiseVolume(scale) + ChipAudio.setChannelVolume(4, scale) +end + +function ChipAudio.getNoiseVolume() + return ChipAudio.getChannelVolume(4) +end + -- a stale song must not keep sounding past the flush that replaced its -- program (20 §2 cache contract, chip music row) Assets.register(ChipAudio.invalidate) diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 8f095e68..1c62a182 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -29,6 +29,71 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT +-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise). +-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave, +-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next +-- buffer on both the sync path and the worker (via ChipAudio). +local channelVolume = { 1, 1, 1, 1 } +local channelPitch = { 1, 1, 1, 1 } + +local function clampScale(scale) + return math.max(0, tonumber(scale) or 0) +end + +local function setChannelTable(table, hw, scale) + hw = tonumber(hw) + if not hw or hw < 1 or hw > 4 then return end + table[hw] = clampScale(scale) +end + +local function setChannelTables(table, values) + if type(values) ~= "table" then return end + for hw = 1, 4 do + if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end + end +end + +function ChipSynth.setChannelVolume(hw, scale) + setChannelTable(channelVolume, hw, scale) +end + +function ChipSynth.getChannelVolume(hw) + return channelVolume[tonumber(hw) or 0] or 1 +end + +function ChipSynth.setChannelVolumes(volumes) + setChannelTables(channelVolume, volumes) +end + +function ChipSynth.getChannelVolumes() + return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] } +end + +function ChipSynth.setChannelPitch(hw, scale) + setChannelTable(channelPitch, hw, scale) +end + +function ChipSynth.getChannelPitch(hw) + return channelPitch[tonumber(hw) or 0] or 1 +end + +function ChipSynth.setChannelPitches(pitches) + setChannelTables(channelPitch, pitches) +end + +function ChipSynth.getChannelPitches() + return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] } +end + +-- aliases for the noise/drum layer +function ChipSynth.setNoiseVolume(scale) + ChipSynth.setChannelVolume(4, scale) +end + +function ChipSynth.getNoiseVolume() + return ChipSynth.getChannelVolume(4) +end + local PITCHES = { 0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, 0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA, @@ -430,7 +495,8 @@ function Channel:sampleNoise(parameter) local divisor = NOISE_DIVISORS[bit.band(parameter, 7)] local shift = bit.rshift(parameter, 4) if shift < 14 then - local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE + local pitch = channelPitch[self.hardware] or 1 + local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch local width7 = bit.band(parameter, 8) ~= 0 local remaining = cycles while remaining > 0 do @@ -500,11 +566,14 @@ function Channel:sample() event.sample = sampleIndex + 1 if event.silence then return 0 end - if event.drum then return self:sampleDrum(event, sampleIndex) end + local gain = channelVolume[self.hardware] or 1 + if event.drum then + return self:sampleDrum(event, sampleIndex) * gain + end local volume = envelopeVolume( event.volume or 0, event.fade or 0, event.elapsed) if event.noise then - return self:sampleNoise(event.noiseParameter) * volume / 15 + return self:sampleNoise(event.noiseParameter) * volume / 15 * gain end local register = event.register @@ -529,7 +598,8 @@ function Channel:sample() end end end - local frequency = 131072 / (2048 - math.min(register, 2047)) + local pitch = channelPitch[self.hardware] or 1 + local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch if event.wave then frequency = frequency * 0.5 end local phase = self.phase self.phase = (phase + frequency / SAMPLE_RATE) % 1 @@ -539,7 +609,7 @@ function Channel:sample() -- a def-local program may omit its wave table entirely if not wave then return 0 end local index = math.min(32, math.floor(phase * 32) + 1) - return wave[index] * event.waveLevel + return wave[index] * event.waveLevel * gain end local duty = event.duty if type(duty) == "table" then @@ -548,9 +618,9 @@ function Channel:sample() local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2] local step = math.floor(phase * 8) % 8 if pattern[step + 1] == 0 then - return -volume / 15 + return -volume / 15 * gain end - return volume / 15 + return volume / 15 * gain end local Engine = {} diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index 705afbae..fccba224 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -5,8 +5,10 @@ -- -- Protocol -- main thread pushes command tables onto the "chipaudio_cmd" -- channel and drains produced buffers off "chipaudio_out": --- cmd = "play" { gen, header, allowLoops, audio } start a song +-- cmd = "play" { gen, header, allowLoops, audio, +-- channelVolumes?, channelPitches? } -- cmd = "stop" halt production +-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch -- cmd = "invalidate" drop the bank cache -- cmd = "quit" end the thread -- out buffers are tagged with the play's `gen` so the main thread can @@ -45,6 +47,12 @@ local function handle(cmd) engine = nil outCh:clear() -- drop any buffers left from the previous song data = { audio = cmd.audio } + if cmd.channelVolumes ~= nil then + ChipSynth.setChannelVolumes(cmd.channelVolumes) + end + if cmd.channelPitches ~= nil then + ChipSynth.setChannelPitches(cmd.channelPitches) + end local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header, { allowLoops = cmd.allowLoops }) if ok then @@ -58,6 +66,9 @@ local function handle(cmd) engine = nil finished = false outCh:clear() + elseif cmd.cmd == "channelMix" then + if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end + if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end elseif cmd.cmd == "invalidate" then ChipSynth.invalidateBanks() elseif cmd.cmd == "quit" then diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index ee844271..6c974cdd 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -18,7 +18,8 @@ local function samePath(path) return path end -- side: "front" | "back" -- opts.mon: the live mon when available (per-instance skins) -- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade" --- | "title" | "oak" | "credits" (informational for wrappers) +-- | "title" | "oak" | "credits" | "overworld" (informational +-- for wrappers) -- Returns path, trueColor. function Sprites.path(data, species, side, opts) opts = opts or {} diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 98fd54a2..8adaf1f0 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -52,13 +52,24 @@ function NPC:facePlayer(player) end function NPC:update(map, entities) + -- self.stepFrames overrides the shared 16-frame walk for an object whose + -- step has to stay in phase with something else: Yellow's follower + -- Pikachu takes the player's own step length, halved while it is more + -- than a cell behind (FastPikachuFollow, engine/pikachu/ + -- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop + -- command: two cells of travel inside one step's frames + -- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the + -- pixel span doubles while the frame count does not. Nothing else sets + -- either field, so every other object keeps the constant (#410, #409). + local stepLen = self.stepFrames or STEP_FRAMES + local span = self.hopStep and 2 or 1 if self.moving then self.progress = self.progress + 1 -- NPC_CHANGE_FACING: animate the walk cycle in place, no translation -- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay -- pinned to the current cell while walkPhase() cycles. if self.marching then - if self.progress >= STEP_FRAMES then + if self.progress >= stepLen then self.progress = 0 self.moving = false self.marching = false @@ -67,13 +78,18 @@ function NPC:update(map, entities) return end local d = Collision.DELTA[self.facing] - self.px = self.cellX * 16 + d[1] * self.progress - self.py = self.cellY * 16 + d[2] * self.progress - if self.progress >= STEP_FRAMES then + -- 1px per frame at the default length; a shortened step scales instead, + -- so the cell still lands on a 16px boundary (Player:update does the + -- same for the bicycle) + local moved = math.floor(self.progress * 16 * span / stepLen) + self.px = self.cellX * 16 + d[1] * moved + self.py = self.cellY * 16 + d[2] * moved + if self.progress >= stepLen then self.cellX, self.cellY = self.targetX, self.targetY self.targetX, self.targetY = nil, nil self.px, self.py = self.cellX * 16, self.cellY * 16 self.moving = false + self.hopStep = nil self.stepFlip = not self.stepFlip end return diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 13315d87..fcacea3b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -795,6 +795,12 @@ function OverworldState:update(dt) if ca.onDone then ca.onDone() end end end + -- Yellow's companion hopping up onto the Poke Center counter owns the + -- world for its arc, the same way the heal machine below does (#417) + if self.pikaHop then + require("src.world.PikachuFollower").updateHop(self) + return + end if self.healAnim then local ha = self.healAnim local ev = OverworldState.stepHealAnim(ha) @@ -1497,13 +1503,18 @@ function OverworldState:interact() npc = self:npcAtCell(fx2, fy2) end if npc then - if not npc.moving then - if npc.pikachuFollower then - -- the companion answers directly (TalkToPikachu), no map text id - require("src.world.PikachuFollower").talk(Game, self, npc) - else - self:talkTo(npc) - end + if npc.pikachuFollower then + -- the companion answers directly (TalkToPikachu), no map text id -- + -- and it answers mid-step too. pikachu_follow.asm walks the follower + -- on the player's own step clock, so the original never has it + -- mid-tile while the player stands; this port's follow is a frame + -- late (the npc loop runs before Player:update lands the step), so + -- the not-moving gate used to eat the A press in the frames right + -- after landing -- exactly when you turn round to face it (#407). + -- talk() lands the follower on its cell first. + require("src.world.PikachuFollower").talk(Game, self, npc) + elseif not npc.moving then + self:talkTo(npc) end interacted(self, fx, fy, "npc", npc) return @@ -2509,41 +2520,56 @@ function OverworldState:nurseHeal(onDone, npc) hello = hello .. "\f" .. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?")) end + -- Yellow's companion has its own beat threaded through this sequence + local Follower = require("src.world.PikachuFollower") Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes) if not yes then Game.stack:push(TextBox.new(Game, bye, onDone)) return end local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.") - Game.stack:push(TextBox.new(Game, need, function() - -- the nurse turns to the machine, the map music stops, and the - -- party heals before the machine runs (predef HealParty) - if npc then npc.facing = "left" end - require("src.core.Music").stop() - local Pokemon = require("src.pokemon.Pokemon") - for _, mon in ipairs(Game.save.party) do - Pokemon.heal(mon) - end - Game.save.lastHeal = { -- SetLastBlackoutMap - map = self.map.id, x = self.player.cellX, y = self.player.cellY, - -- the town door of this interior, for LAST_MAP exits after a - -- blackout/ESCAPE ROPE warp here - outdoor = self.lastOutdoor - and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y } - or nil, - } - self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0, - visible = true, - -- map anchor: the player's cell when healing - -- began (the GB's fixed screen coords assume it - -- BG-aligned at (64,64)) - px = self.player.cellX * 16, - py = self.player.cellY * 16 } - self.healAnim.onDone = function() - if npc then npc:facePlayer(self.player) end - self:finishNurseHeal(bye, onDone) - end - end)) + -- accepting the heal sends the companion up onto the counter to Nurse + -- Joy first: pokecenter.asm runs `callfar PikachuWalksToNurseJoy` + -- between SetLastBlackoutMap and NeedYourPokemonText, and the hop has + -- to finish before the text box goes up because only the top state + -- updates. No follower (or not Yellow) calls straight through (#417). + Follower.hopToCounter(self, function() + Game.stack:push(TextBox.new(Game, need, function() + -- the nurse turns to the machine, the map music stops, and the + -- party heals before the machine runs (predef HealParty) + if npc then npc.facing = "left" end + -- DisablePikachuOverworldSpriteDrawing: Pikachu goes behind the + -- counter with the party for the machine animation + Follower.setVisible(self, false) + require("src.core.Music").stop() + local Pokemon = require("src.pokemon.Pokemon") + for _, mon in ipairs(Game.save.party) do + Pokemon.heal(mon) + end + Game.save.lastHeal = { -- SetLastBlackoutMap + map = self.map.id, x = self.player.cellX, y = self.player.cellY, + -- the town door of this interior, for LAST_MAP exits after a + -- blackout/ESCAPE ROPE warp here + outdoor = self.lastOutdoor + and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y } + or nil, + } + self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0, + visible = true, + -- map anchor: the player's cell when healing + -- began (the GB's fixed screen coords assume it + -- BG-aligned at (64,64)) + px = self.player.cellX * 16, + py = self.player.cellY * 16 } + self.healAnim.onDone = function() + -- EnablePikachuOverworldSpriteDrawing, before the fighting-fit + -- line: it comes back on the counter facing the player + Follower.setVisible(self, true) + if npc then npc:facePlayer(self.player) end + self:finishNurseHeal(bye, onDone) + end + end)) + end) end })) end @@ -4410,6 +4436,30 @@ end -- screen-space overlays: drawn to the UI canvas at normal scale function OverworldState:drawUI() + -- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm + -- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a + -- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The + -- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so + -- the front pic holds for the whole beat while the cry and any emote + -- bubble play over the world below (#407). + if self.emote and self.emote.pikaPic then + require("src.render.Font").drawBox(6, 5, 7, 7) + -- one image per path, cached: this draws every frame of the hold, and + -- a mod skin can move the path between talks + if self.pikaPicPath ~= self.emote.pikaPic then + local ok, loaded = pcall(love.graphics.newImage, self.emote.pikaPic) + self.pikaPicImg = ok and loaded or nil + self.pikaPicPath = self.emote.pikaPic + end + local img = self.pikaPicImg + if img then + love.graphics.setColor(1, 1, 1, 1) + local w, h = img:getDimensions() + love.graphics.draw(img, math.floor(56 + (40 - w) / 2), + math.floor(48 + (40 - h) / 2)) + end + end + -- poison step flicker (ChangeBGPalColor0_4Frames: dark for two -- 4-frame pulses) if self.poisonFlash and self.poisonFlash > 0 then diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 3d5c5b5c..79f7bf88 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -15,6 +15,7 @@ -- UpdatePikachuHappinessAndMood (256-step coin-flip WALKING bump, mood -- converging by 1 per step toward 128). +local Collision = require("src.world.Collision") local GameVersion = require("src.core.GameVersion") local PikachuFollower = {} @@ -140,6 +141,14 @@ local function makeFollower(game, ow, x, y, facing) npc.pikachuFollower = true npc.passable = true -- never blocks a step (Collision.occupied) npc.facing = facing or "down" + -- the idle animations below pose the walk cycle with no step under it, + -- which NPC:walkPhase (moving-only) cannot express. An instance field + -- shadows the class method, so NPC:pose keeps working unchanged (#411). + npc.walkPhase = function(self) + local idle = self.idle + if idle and idle.phase then return idle.phase % 2 end + return NPC.walkPhase(self) + end return npc end @@ -183,9 +192,157 @@ function PikachuFollower.onMapEntered(game, ow) ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY } end +-- --------------------------------------------------------------------- +-- Idle behavior (pikachu_follow.asm Func_fc803 and the Func_fc842 roll it +-- hands off to). Standing still, the follower burns down a frame +-- counter; at zero it either looks in a random direction (Random & $c, +-- another $20 frames later) or, when the buffered follow command puts it +-- two or more cells off the player (ComputePikachuFollowCommand's 5-8 +-- band), rolls one of four in-place animations: a bounce, the walk cycle +-- on the spot, a two frame shuffle, or a clockwise spin. Func_fc82e +-- drops whichever is running the moment the player takes a step. Nothing +-- here plays a bubble or a cry -- those are TalkToPikachu's alone (#411). +-- --------------------------------------------------------------------- + +local IDLE_LOOK = 0x20 -- Func_fc803's pause between random glances +local IDLE_REST = 0x10 -- Func_fc835's pause after an animation ends +local IDLE_FRAME = 8 -- frames per sprite frame in Func_fc8f8/92b/95d + +local FACINGS = { "down", "up", "left", "right" } +-- Func_fc95d .Facings, the order the spin turns through +local CLOCKWISE = { down = "left", left = "up", up = "right", right = "down" } + +-- Pointer_fc8d6, transposed to (dx, dy): the asm stores (y, x) and walks +-- the table backwards as the $11 counter runs down, so entry N here is +-- what counter N draws. A sway four pixels right then four left with the +-- body bobbing up twice, netting zero displacement. +local BOUNCE = { + { 0, 0 }, { -1, -2 }, { -2, -4 }, { -3, -2 }, { -4, 0 }, + { -3, -2 }, { -2, -4 }, { -1, -2 }, { 0, 0 }, { 1, -2 }, + { 2, -4 }, { 3, -2 }, { 4, 0 }, { 3, -2 }, { 2, -4 }, + { 1, -2 }, { 0, 0 }, +} + +local function randomInt(a, b) + local rand = love and love.math and love.math.random or math.random + return rand(a, b) +end + +-- back onto the cell's own pixels: while the follower stands, nothing else +-- writes px/py, so the bounce offset has to be undone from here +local function idleReset(npc) + npc.idle = nil + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 +end + +-- ComputePikachuFollowCommand: the command the idle state reads back is +-- 1-4 while the follower sits within a cell of the player and 5-8 once it +-- is two or more off, Y deciding whenever the rows differ. Returns the +-- facing those 5-8 encode (Func_fc862 turns that way before it bounces), +-- or nil for the near band, which only ever glances. +local function strandedFacing(ow, npc) + local p = ow.player + local dy = p.cellY - npc.cellY + if dy ~= 0 then + if dy > -2 and dy < 2 then return nil end + return dy > 0 and "down" or "up" + end + local dx = p.cellX - npc.cellX + if dx > -2 and dx < 2 then return nil end + return dx > 0 and "right" or "left" +end + +-- Func_fc842: an even roll over the four PointerTable_fc85a entries +local function startIdleAnim(npc, facing) + local roll = randomInt(0, 3) + if roll == 0 then + -- Func_fc862 turns toward the player, then asm_fc87f bounces + npc.facing = facing or npc.facing + npc.idle = { kind = "bounce", frames = 0x11 } + elseif roll == 1 then + npc.idle = { kind = "walk", frames = 0x30, tick = 0, phase = 0 } + elseif roll == 2 then + npc.idle = { kind = "shuffle", frames = 0x20, tick = 0, phase = 0 } + else + npc.idle = { kind = "spin", frames = 0x20, tick = 0 } + end +end + +local function idleTick(ow, npc) + -- Func_fc82e: a step in progress ends the idle state outright + if ow.player.moving then idleReset(npc) return end + local idle = npc.idle + if not idle then + idle = { kind = "wait", frames = IDLE_LOOK } + npc.idle = idle + end + if idle.kind == "wait" then + idle.frames = idle.frames - 1 + if idle.frames > 0 then return end + local facing = strandedFacing(ow, npc) + if facing then + startIdleAnim(npc, facing) + else + npc.facing = FACINGS[randomInt(1, 4)] + idle.frames = IDLE_LOOK + end + return + end + if idle.kind == "bounce" then + local o = BOUNCE[idle.frames] or BOUNCE[1] + npc.px = npc.cellX * 16 + o[1] + npc.py = npc.cellY * 16 + o[2] + else + idle.tick = idle.tick + 1 + if idle.tick >= IDLE_FRAME then + idle.tick = 0 + if idle.kind == "walk" then + -- Func_fc8f8 runs the anim counter through all four frames; the + -- top bit is the mirrored foot, which is our stepFlip + idle.phase = (idle.phase + 1) % 4 + npc.stepFlip = idle.phase >= 2 + elseif idle.kind == "shuffle" then + idle.phase = idle.phase == 0 and 1 or 0 -- Func_fc92b's xor $1 + else + npc.facing = CLOCKWISE[npc.facing] or "down" + end + end + end + idle.frames = idle.frames - 1 + if idle.frames <= 0 then + -- Func_fc835: a $10 frame rest, then the idle counter again + idleReset(npc) + npc.idle = { kind = "wait", frames = IDLE_REST } + end +end + +-- The cell ahead is a ledge the player just hopped (data/tilesets/ +-- ledge_tiles.asm, the same row match OverworldState:checkLedgeHop makes). +-- The follower only ever retraces cells the player stood on, so a ledge +-- tile in the trail means the player jumped it (#409). +local function ledgeStep(game, ow, cx, cy, dir) + local map = ow.map + local d = Collision.DELTA[dir] + local fx, fy = cx + d[1], cy + d[2] + local lx, ly = cx + d[1] * 2, cy + d[2] * 2 + if not (map:inBounds(fx, fy) and map:inBounds(lx, ly)) then return false end + local tileset = map.def.tileset + local standing = map:cellTile(cx, cy) + local front = map:cellTile(fx, fy) + for _, ledge in ipairs(game.data.field.ledges or {}) do + if (ledge.tileset or "OVERWORLD") == tileset + and ledge.facing == dir and ledge.input == dir + and ledge.standingTile == standing and ledge.ledgeTile == front then + return true + end + end + return false +end + -- one follow step per frame: chase the cell the player last vacated -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) + if ow.pikaHop then return end -- the counter hop owns the follower (#417) local npc = findFollower(ow) if not npc then if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end @@ -201,15 +358,31 @@ function PikachuFollower.update(game, ow) trail = { x = p.cellX, y = p.cellY } ow.pikachuTrail = trail end - -- the player left the trailing cell: it becomes Pikachu's next goal - if p.cellX ~= trail.x or p.cellY ~= trail.y then + -- The follow command is queued the frame the player COMMITS a step, not + -- the frame it lands: home/overworld.asm .noCollision sets wWalkCounter + -- and calls Func_fcc08 (pikachu_follow.asm Func_fcc42 reads the direction + -- of the step just started) before AdvancePlayerSprite, so Pikachu walks + -- into the cell the player is vacating during that same step and rests + -- exactly one cell behind. Waiting for p.cellX to change put a whole + -- extra step between them -- the two-tile gap of issue #410. targetX/Y + -- is the committed destination while a step is in flight and nil when + -- standing, so a warp or teleport still registers here (and the far > 6 + -- snap below still catches it). + local destX = p.targetX or p.cellX + local destY = p.targetY or p.cellY + if destX ~= trail.x or destY ~= trail.y then npc.goalX, npc.goalY = trail.x, trail.y - trail.x, trail.y = p.cellX, p.cellY + trail.x, trail.y = destX, destY end - if npc.moving or not npc.goalX then return end + -- standing still with nothing to chase is the idle state (Func_fc803); + -- once a step is under way NPC:update owns px/py, so only the idle + -- record is dropped here -- never the interpolated pixels + if npc.moving then npc.idle = nil return end + if not npc.goalX then idleTick(ow, npc) return end local gx, gy = npc.goalX, npc.goalY if npc.cellX == gx and npc.cellY == gy then npc.goalX, npc.goalY = nil, nil + idleTick(ow, npc) return end -- fell more than a screen behind (forced movement, warp math): snap @@ -218,8 +391,10 @@ function PikachuFollower.update(game, ow) npc.cellX, npc.cellY = gx, gy npc.px, npc.py = gx * 16, gy * 16 npc.goalX, npc.goalY = nil, nil + npc.idle = nil -- the snap already rewrote px/py return end + idleReset(npc) -- a real step overrides whatever the idle pose was local dir if npc.cellX < gx then dir = "right" elseif npc.cellX > gx then dir = "left" @@ -228,15 +403,44 @@ function PikachuFollower.update(game, ow) npc.facing = dir npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0) npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0) + -- the cell ahead is the ledge the player hopped: clear both cells in one + -- step instead of stopping on the ledge (#409). pikachu_follow.asm + -- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING + -- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over + -- one normal step's frames -- no arc and no shadow, the hop command only + -- doubles the step vector (NPC:update's hopStep span). + if ledgeStep(game, ow, npc.cellX, npc.cellY, dir) then + local d = Collision.DELTA[dir] + npc.targetX, npc.targetY = npc.cellX + d[1] * 2, npc.cellY + d[2] * 2 + npc.goalX, npc.goalY = npc.targetX, npc.targetY + npc.hopStep = true + end + -- walk at the player's own step length (the bicycle is moot: shouldSpawn + -- hides the follower on a bike, ShouldPikachuSpawn's wWalkBikeSurfState + -- check), and halve it while more than one cell behind -- that is + -- FastPikachuFollow, which pikachu_follow.asm picks whenever two or more + -- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer: + -- walk counter $4 instead of NormalPikachuFollow's $8). + local stepLen = p.stepFramesCur or p.stepFrames or 16 + if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end + npc.stepFrames = stepLen npc.moving = true npc.progress = 0 + -- this frame's npc:update loop already ran (OverworldState:update walks + -- self.npcs, then calls here), so burn the step's first frame now. + -- Without it the step costs a frame more than the player's and Pikachu + -- trails a pixel further every tile. + npc:update(ow.map, ow.entities) end -- --------------------------------------------------------------------- -- TalkToPikachu (engine/pikachu/pikachu_emotions.asm + data/pikachu/ -- pikachu_emotions.asm): pick a scripted emotion, then play its bubble --- and voiced PCM clip. The face-pic animation half of each emotion --- (pikaemotion_pikapic) has no port; the bubble + clip carry the beat. +-- and voiced PCM clip, and raise the framed Pikachu picture the original +-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm +-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The +-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted, +-- so the front pic stands in for all twenty of them (#407). -- --------------------------------------------------------------------- -- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm @@ -340,6 +544,20 @@ local function bubbleIndex(game, name) end function PikachuFollower.talk(game, ow, npc, done) + -- pikachu_follow.asm steps the follower on the player's own walk clock, + -- so it is never mid-tile while the player stands and can always be + -- addressed; this port's follow is a frame late, so land the step here + -- rather than answer from between two cells (#407). The emote hold + -- returns before the npc update loop, so a follower left mid-step would + -- freeze between cells for the whole beat. + if npc.moving then + npc.cellX, npc.cellY = npc.targetX or npc.cellX, npc.targetY or npc.cellY + npc.targetX, npc.targetY = nil, nil + npc.moving = false + npc.progress = 0 + npc.hopStep = nil + end + idleReset(npc) -- the bubble anchor reads px/py, and the hold freezes it npc:facePlayer(ow.player) ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing local save = game.save @@ -357,12 +575,97 @@ function PikachuFollower.talk(game, ow, npc, done) -- caches built before the Yellow bubble sheet only carry the three -- shared bubbles; a missing crop degrades to a silent hold local bi = e.bubble and bubbleIndex(game, e.bubble) + -- pikaemotion_pikapic: every entry in data/pikachu/pikachu_emotions.asm + -- ends with one, and its box is the only thing most of them put on + -- screen (emotion 5, the fresh-save cell, has no bubble at all). The + -- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame; + -- Sprites.path keeps a mod's replacement skin in play. The scripts' + -- 32-58 frame durations bracket the hold below, so it stays at 50. + local Sprites = require("src.pokemon.Sprites") + local pic = Sprites.path(game.data, "PIKACHU", "front", + { kind = "overworld" }) ow.emote = { - npc = npc, frames = 50, bubble = bi or false, + npc = npc, frames = 50, bubble = bi or false, pikaPic = pic, onDone = done, } end +-- --------------------------------------------------------------------- +-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by +-- engine/events/pokecenter.asm once the heal is accepted): the companion +-- looks up ($36) and hops onto the Poke Center counter. The original +-- picks one of three movement scripts by where it stands -- below the +-- player (.PikaMovementData1: walk up left, hop up right), left of it +-- (.PikaMovementData2: hop up right) or right of it (.PikaMovementData3: +-- hop up left) -- and all three land on the counter tile directly in +-- front of the player, so the port animates that one hop. Pikachu +-- already above the player yields zero movement bytes: no beat (#417). +-- --------------------------------------------------------------------- + +local HOP_FRAMES = 32 -- the port's ledge-hop arc (Player:pose hopTotal) + +function PikachuFollower.hopToCounter(ow, done) + local npc = GameVersion.isYellow() and findFollower(ow) or nil + local p = ow.player + local cx, cy = p:facingCell() + -- the nurse is talked to across a counter tile (OverworldState:interact); + -- anything else is the .pikachu_above_player no-op path + if not npc or p.facing ~= "up" or not ow.map:isCounterCell(cx, cy) then + if done then done() end + return + end + npc.goalX, npc.goalY = nil, nil + npc.targetX, npc.targetY = nil, nil + npc.moving, npc.progress, npc.hopStep = false, 0, nil + npc.idle = nil + npc.facing = "up" -- $36, look up + ow.pikaHop = { + npc = npc, frames = 0, cellX = cx, cellY = cy, onDone = done, + fromX = npc.px, fromY = npc.py, toX = cx * 16, toY = cy * 16, + } +end + +-- One frame of that hop. OverworldState:update holds the world for it the +-- way it holds for the heal machine (only the top state updates, so this +-- has to sit between the two text boxes); the arc matches Player:pose's +-- ledge hop -- a 10px sine over 32 frames. +function PikachuFollower.updateHop(ow) + local h = ow.pikaHop + if not h then return end + h.frames = h.frames + 1 + local t = math.min(1, h.frames / HOP_FRAMES) + h.npc.px = h.fromX + (h.toX - h.fromX) * t + h.npc.py = h.fromY + (h.toY - h.fromY) * t + - math.floor(10 * math.sin(t * math.pi) + 0.5) + if h.frames < HOP_FRAMES then return end + h.npc.cellX, h.npc.cellY = h.cellX, h.cellY + h.npc.px, h.npc.py = h.toX, h.toY + ow.pikaHop = nil + -- the player has not moved, so the trail restarts under his feet and the + -- follower only steps back off the counter once he walks away + ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY } + if h.onDone then h.onDone() end +end + +-- Disable/EnablePikachuOverworldSpriteDrawing around the healing machine +-- (engine/events/pokecenter.asm): Pikachu goes behind the counter with the +-- party and comes back standing on it, facing the player -- the respawn is +-- wPikachuSpawnState = 5, which is .above_player in pikachu_follow.asm, +-- followed by `lb bc, 15, 0` (sprite struct 15 is Pikachu, image index 0 +-- is facing down). ow.entities is the draw list and ow.npcs the update +-- list, so dropping it from entities alone hides it in place (#417). +function PikachuFollower.setVisible(ow, visible) + local npc = findFollower(ow) + if not npc then return end + for i, e in ipairs(ow.entities or {}) do + if e == npc then table.remove(ow.entities, i) break end + end + if visible then + npc.facing = "down" + table.insert(ow.entities, npc) + end +end + -- npc the player is facing, when it is the follower (interact hook) function PikachuFollower.at(ow, cx, cy) local npc = findFollower(ow) diff --git a/src/world/Player.lua b/src/world/Player.lua index c79c2e55..af6aeca8 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -4,6 +4,7 @@ local Collision = require("src.world.Collision") local FieldDefaults = require("src.world.FieldDefaults") +local GameVersion = require("src.core.GameVersion") local Runtime = require("src.mods.Runtime") local SpriteRenderer = require("src.render.SpriteRenderer") @@ -237,17 +238,30 @@ end function Player:draw(camX, camY) local sprite, px, py, facing, phase, flip, hopping = self:pose() - -- the shadow stays on the ground under the jumper: one 8x8 tile - -- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left - -- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM + - -- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm) + -- the shadow stays on the ground under the jumper, mirrored out of the + -- single 8x8 tile the ROM stores -- but the two engines lay it out + -- differently, and their shadow.png tiles differ to match. + -- RED/BLUE: a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left sits + -- 8px below the sprite's standing top-left (LoadHoppingShadowOAM + + -- LedgeHoppingShadowOAMBlock at "lb bc, $54, $48", + -- engine/overworld/ledges.asm); its tile is blank above the bottom + -- four rows, so the four copies make one 16x16 ellipse. + -- YELLOW: a single 16x8 row 4px lower. Its LoadHoppingShadowOAM + -- copies only two entries (LedgeHoppingShadowOAM: dbsprite 9,11 and + -- dbsprite 10,11 OAM_XFLIP, raw OAM y=88 against RED's $54=84) and + -- parks sprites 38/39 offscreen at y=$a0, because its tile is a + -- full-height half-ellipse that already fills the row. Mirroring + -- that tile downward stacked a second blob under the first (#408). if hopping and self.shadowImg then + local yellow = GameVersion.isYellow() local sx = math.floor(self.px - camX) - local sy = math.floor(self.py - camY) - 4 + 8 + local sy = math.floor(self.py - camY) - 4 + 8 + (yellow and 4 or 0) love.graphics.draw(self.shadowImg, sx, sy) love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1) - love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1) - love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1) + if not yellow then + love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1) + love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1) + end end sprite:draw(px, py, camX, camY, facing, phase, flip) end diff --git a/tests/drivers/pikachu_follow_distance_bug410_test.lua b/tests/drivers/pikachu_follow_distance_bug410_test.lua new file mode 100644 index 00000000..8eb7315e --- /dev/null +++ b/tests/drivers/pikachu_follow_distance_bug410_test.lua @@ -0,0 +1,197 @@ +-- Yellow's follower has to trail exactly one cell behind over a long walk +-- (#410). ROUTE_1 column x=0 is 36 cells of plain path (tile $2c: no +-- grass, no ledge row, no object on it, per the generated ROUTE_1 blocks +-- and pokeyellow data/maps/objects/Route1.asm), so 32 steps north measure +-- the gap with nothing else moving. Never add POKEPORT_SPEED here: it +-- scales the logic clock only, and the gap is a timing measurement. No +-- POKEPORT_IDENTITY either: the Yellow cache lives in the default save dir. +-- POKEPORT_DRIVER=tests/drivers/pikachu_follow_distance_bug410_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local PikachuFollower = require("src.world.PikachuFollower") + + local MAP = "ROUTE_1" + local START = { x = 0, y = 34 } + local STEPS = 32 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ShouldPikachuSpawn wants the lab gift and a healthy party Pikachu; the + -- level 100 lead plus a long REPEL is belt and braces, since the column + -- below carries no grass tile and cannot roll an encounter anyway + game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.onBike = false + game.save.repelSteps = 9999 + game.save.player.name = "bryan" + + U.teleport(game, MAP, START.x, START.y, "up") + U.wait(10) + + local ow = game.overworld + local map = ow.map + + -- the run has to be walkable, grass-free and warp-free the whole way; a + -- later map edit degrades to the longest column that still is, rather + -- than walking the player into a fence for 500 frames + local function runLength(cx, fromY) + local n = 0 + local y = fromY + while y >= 0 and map:isWalkableCell(cx, y) and not map:isGrassCell(cx, y) + and not map:warpAtCell(cx, y) do + n = n + 1 + y = y - 1 + end + return n + end + + if runLength(START.x, START.y) < STEPS + 1 then + local best, bestX, bestY = 0, START.x, START.y + for cx = 0, map.widthCells - 1 do + for cy = map.heightCells - 1, 0, -1 do + local n = runLength(cx, cy) + if n > best then best, bestX, bestY = n, cx, cy end + end + end + U.log(("column %d is short (%d cells); walking column %d from y=%d (%d cells)") + :format(START.x, runLength(START.x, START.y), bestX, bestY, best)) + START.x, START.y = bestX, bestY + STEPS = math.min(STEPS, best - 1) + U.teleport(game, MAP, START.x, START.y, "up") + U.wait(10) + ow = game.overworld + map = ow.map + end + check(("a straight %d step run exists at column %d"):format(STEPS, START.x), + STEPS >= 30 and runLength(START.x, START.y) >= STEPS + 1) + + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + check("the follower spawned on " .. MAP, follower() ~= nil) + + -- The far > 6 snap teleports the follower onto its goal and hides any + -- drift the walk built up, so a broken run would read as a clean one. + -- PikachuFollower.update is looked up on the module table at every call + -- site, so wrapping the field here counts snaps without touching the + -- engine: a cell that changes across the call while the follower is not + -- mid-step is the snap and nothing else (a normal step lands its cell + -- inside NPC:update, which OverworldState runs before this). + local snaps, fastCommits = 0, 0 + local realUpdate = PikachuFollower.update + PikachuFollower.update = function(g, o) + local npc = follower() + local bx, by, bmoving + if npc then bx, by, bmoving = npc.cellX, npc.cellY, npc.moving end + realUpdate(g, o) + if npc and not bmoving and not npc.moving + and (npc.cellX ~= bx or npc.cellY ~= by) then + snaps = snaps + 1 + end + if npc and not bmoving and npc.moving + and (npc.stepFrames or 16) < (o.player.stepFramesCur or 16) then + fastCommits = fastCommits + 1 + end + end + + -- The distance sampled is the settled one: while a step is in flight the + -- follower's committed cell is targetX/Y, which is where it will stand + -- when the player's own landing frame is over. Raw cell distance is + -- kept alongside it so a report of 1 cannot come from reading the wrong + -- field; it reads 2 all the way through a held walk, because both + -- sprites are then mid-step, and settles to 1 the moment input stops. + local function gap() + local p = ow.player + local npc = follower() + if not npc then return -1, -1 end + local pxc = p.targetX or p.cellX + local pyc = p.targetY or p.cellY + local nx = npc.targetX or npc.cellX + local ny = npc.targetY or npc.cellY + return math.abs(pxc - nx) + math.abs(pyc - ny), + math.abs(p.cellX - npc.cellX) + math.abs(p.cellY - npc.cellY) + end + + local series, raws = {}, {} + local prevX, prevY = ow.player.cellX, ow.player.cellY + local frames = 0 + while #series < STEPS and frames < STEPS * 40 do + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + frames = frames + 1 + coroutine.yield() + local p = ow.player + if p.cellX ~= prevX or p.cellY ~= prevY then + prevX, prevY = p.cellX, p.cellY + local g, r = gap() + series[#series + 1] = g + raws[#raws + 1] = r + end + end + game.input.state.up = false + U.wait(20) -- let the last follow step land before the final reading + + check(("all %d steps completed (%d recorded)"):format(STEPS, #series), + #series == STEPS) + + local maxGap, badSteps = 0, 0 + for _, g in ipairs(series) do + if g > maxGap then maxGap = g end + if g ~= 1 then badSteps = badSteps + 1 end + end + local function avg(from, to) + local sum, n = 0, 0 + for i = from, to do + if series[i] then sum = sum + series[i] n = n + 1 end + end + return n > 0 and sum / n or 0 + end + local head, tail = avg(1, 8), avg(#series - 7, #series) + local finalGap, finalRaw = gap() + + local function compact(list) + local out, row = {}, {} + for i, g in ipairs(list) do + row[#row + 1] = (g >= 0 and g < 10) and tostring(g) or ("[" .. g .. "]") + if i % 40 == 0 then out[#out + 1] = table.concat(row) row = {} end + end + if #row > 0 then out[#out + 1] = table.concat(row) end + return out + end + for _, row in ipairs(compact(series)) do U.log("gap per step:", row) end + for _, row in ipairs(compact(raws)) do U.log("raw cell gap: ", row) end + + check("the gap is 1 on every step", badSteps == 0 and #series > 0) + check(("max gap is 1 (saw %d)"):format(maxGap), maxGap == 1) + check(("final gap is 1 (saw %d)"):format(finalGap), finalGap == 1) + check(("Pikachu came to rest one cell behind (saw %d)"):format(finalRaw), + finalRaw == 1) + check(("no upward trend (first 8 avg %.2f, last 8 avg %.2f)") + :format(head, tail), tail <= head) + check(("the far > 6 snap never fired (%d)"):format(snaps), snaps == 0) + U.log("fast (half length) follow steps committed:", fastCommits) + + PikachuFollower.update = realUpdate + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + if U.shot(game, SHOT_DIR .. "/bug410_follow_distance.png") then + U.log("captured", SHOT_DIR .. "/bug410_follow_distance.png") + end + + U.log("Pikachu has just walked 32 cells up ROUTE_1 and should be standing") + U.log("one cell below you, close enough to touch. Walk on and it stays") + U.log("there; the bug left a visible cell of daylight between you.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_idle_center_bug411_bug417_test.lua b/tests/drivers/pikachu_idle_center_bug411_bug417_test.lua new file mode 100644 index 00000000..1fae9402 --- /dev/null +++ b/tests/drivers/pikachu_idle_center_bug411_bug417_test.lua @@ -0,0 +1,313 @@ +-- Two Yellow companion beats you have to watch rather than assert: the idle +-- animations Pikachu plays while you stand still (#411, pokeyellow +-- engine/pikachu/pikachu_follow.asm Func_fc803) and its hop onto the Poke +-- Center counter when the heal is accepted (#417, engine/pikachu/ +-- pikachu_emotions.asm PikachuWalksToNurseJoy). Do not add POKEPORT_SPEED: +-- it scales the logic clock only while audio keeps its own real-time +-- accumulator, which desynchronizes exactly the timing being judged. +-- POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/pikachu_idle_center_bug411_bug417_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local GameVersion = require("src.core.GameVersion") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local shots = {} + local ok = true + + local function check(label, pass) + if not pass then ok = false end + U.log(pass and "PASS" or "FAIL", label) + return pass + end + + local function shot(name) + local path = SHOT_DIR .. "/" .. name .. ".png" + if U.shot(game, path) then + shots[#shots + 1] = path .. " @f" .. U.frame() + return true + end + return false + end + + -- Yellow only: the follower, the counter hop and the pikapic box are all + -- behind GameVersion.isYellow(), so a Red boot would silently show none of + -- this and every look-at-it line below would be a lie. + if not check("running the Yellow cache", GameVersion.isYellow()) then + U.log("re-run with POKEPORT_VERSION=yellow; nothing else here applies.") + while true do coroutine.yield() end + end + check("SPRITE_PIKACHU is in the extracted sprite set", + game.data.sprites and game.data.sprites.SPRITE_PIKACHU ~= nil) + + -- ShouldPikachuSpawn wants the lab gift flag and a healthy party Pikachu; + -- the second mon is here so the heal has something to visibly restore. + game.save.player.name = "bryan" + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.usedPokecenter = false -- BIT_USED_POKECENTER: get the full prompt + game.save.party = { + Pokemon.new(game.data, "PIKACHU", 16), + Pokemon.new(game.data, "PIDGEY", 12), + } + -- hurt but not poisoned: a poisoned 1 HP Pikachu faints on the walk over + -- and ShouldPikachuSpawn would despawn the follower mid-driver + for _, mon in ipairs(game.save.party) do + mon.hp = math.max(1, math.floor(mon.stats.hp / 3)) + end + + local function follower() + local ow = game.overworld + for _, n in ipairs(ow and ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + -- one step in dir, reporting whether the player's cell actually changed; + -- lets the walks below route around a map edit instead of shoving at a wall + local function walk(dir) + local ow = game.overworld + local x, y = ow.player.cellX, ow.player.cellY + U.hold(game, dir, 20) + U.wait(6) + return ow.player.cellX ~= x or ow.player.cellY ~= y + end + + -- ------------------------------------------------------------ #411 idle + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + check("follower spawned on the map", follower() ~= nil) + + -- a couple of steps first: the follower has to be trailing a cell behind + -- before standing still means anything + local walked = 0 + for _, dir in ipairs({ "left", "left", "down", "right" }) do + if walk(dir) then walked = walked + 1 end + if walked >= 2 then break end + end + check("player took at least two steps before standing still", walked >= 2) + U.wait(30) + + local npc = follower() + local IDLE_FRAMES = 540 + local poses, order = {}, {} + local kinds, kindOrder = {}, {} + local facings, facingOrder = {}, {} + local moved, sawIdleRecord = false, false + local idleStart = U.frame() + local shotFrames = {} + + if npc then + for i = 1, IDLE_FRAMES do + if npc.moving then moved = true end + local idle = npc.idle + local kind = idle and idle.kind or "none" + if idle then sawIdleRecord = true end + if not kinds[kind] then + kinds[kind] = 0 + kindOrder[#kindOrder + 1] = kind + end + kinds[kind] = kinds[kind] + 1 + if not facings[npc.facing] then + facings[npc.facing] = true + facingOrder[#facingOrder + 1] = npc.facing + end + -- the pose a human can see: the countdown inside idle is deliberately + -- left out, since a frozen follower would still tick it down and make + -- "something changed" true for free + local pose = string.format("%s|%s|%d,%d|%s", kind, tostring(npc.facing), + math.floor(npc.px - npc.cellX * 16), + math.floor(npc.py - npc.cellY * 16), + tostring(npc.stepFlip)) + if not poses[pose] then + poses[pose] = true + order[#order + 1] = pose + end + -- five stills spread across the sample, far enough apart to catch a + -- glance or a bounce between them + if i % 108 == 0 then + shotFrames[#shotFrames + 1] = U.frame() + shot(string.format("bug411_idle_%d", i / 108)) + else + U.wait(1) + end + end + end + + check("follower stayed put for the whole idle sample", npc ~= nil and not moved) + check("the follower's idle state exists at all", sawIdleRecord) + check("its visible pose changed while standing still", #order > 1) + U.log(("idle sample: %d frames from f%d, %d distinct poses") + :format(IDLE_FRAMES, idleStart, #order)) + local kindLine = {} + for _, k in ipairs(kindOrder) do + kindLine[#kindLine + 1] = k .. "x" .. kinds[k] + end + U.log("idle kinds seen:", table.concat(kindLine, " ")) + U.log("facings seen:", table.concat(facingOrder, " ")) + for i = 1, math.min(#order, 8) do U.log(" pose", order[i]) end + U.log("idle shots at frames:", table.concat(shotFrames, " ")) + + -- -------------------------------------------------- #417 Poke Center hop + -- pokeyellow data/maps/objects/ViridianPokecenter.asm: the nurse is + -- object 1 at (3, 1), the counter tile is (3, 2) and the player talks to + -- her from (3, 3) facing up. Positions below are derived from the loaded + -- object rather than typed in, so a re-extract or a mod that shifts her + -- still lands the player at the counter. + local MAP = "VIRIDIAN_POKECENTER" + U.teleport(game, MAP, 3, 6, "up") + U.wait(20) + local ow = game.overworld + + local nurse + for _, n in ipairs(ow.npcs or {}) do + local d = n.def + if d and (d.sprite == "SPRITE_NURSE" or (d.name or ""):find("NURSE")) then + nurse = n + break + end + end + check("nurse object loaded on " .. MAP, nurse ~= nil) + if nurse then + local entry = game.data:textEntry(ow.map.def.label, nurse.def.text) + check("her text entry is the TX_SCRIPT nurse marker", + entry ~= nil and not not entry.nurse) + end + + -- stand two cells off the nurse with a counter tile between us: that is + -- the geometry OverworldState:interact reaches across, and the same + -- p.facing == "up" + isCounterCell test PikachuFollower.hopToCounter makes + local stand + if nurse then + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local counterX, counterY = nurse.cellX + s[1], nurse.cellY + s[2] + local sx, sy = nurse.cellX + s[1] * 2, nurse.cellY + s[2] * 2 + if ow.map:inBounds(sx, sy) and ow.map:isWalkableCell(sx, sy) + and ow.map:isCounterCell(counterX, counterY) + and not ow:npcAtCell(sx, sy) then + stand = { x = sx, y = sy, facing = s[3] } + break + end + end + end + check("found a counter cell to talk across", stand ~= nil) + + if stand then + -- walk there rather than teleport, so the follower trails in behind and + -- is standing where the hop actually starts from + local guard = 0 + while (ow.player.cellY > stand.y) and guard < 8 do + if not walk("up") then break end + guard = guard + 1 + end + while (ow.player.cellX < stand.x) and guard < 12 do + if not walk("right") then break end + guard = guard + 1 + end + while (ow.player.cellX > stand.x) and guard < 12 do + if not walk("left") then break end + guard = guard + 1 + end + while (ow.player.cellY > stand.y) and guard < 16 do + if not walk("up") then break end + guard = guard + 1 + end + if ow.player.cellX ~= stand.x or ow.player.cellY ~= stand.y then + U.log("walk did not reach the counter, teleporting to", + stand.x, stand.y) + U.teleport(game, MAP, stand.x, stand.y, stand.facing) + U.wait(20) + ow = game.overworld + end + ow.player.facing = stand.facing + U.wait(10) + end + + local fx, fy = ow.player:facingCell() + check("player is at the counter facing the nurse", + ow.map:isCounterCell(fx, fy) and ow.player.facing == "up") + check("follower survived the walk over", follower() ~= nil) + shot("bug417_at_counter") + + -- talk, say YES, and mash through the whole sequence; stop the moment the + -- overworld is back on top so the last A cannot re-open the nurse + local hurt = 0 + for _, mon in ipairs(game.save.party) do + if mon.hp < mon.stats.hp then hurt = hurt + 1 end + end + check("the party is hurt going in, so the heal check means something", + hurt == #game.save.party) + + U.tap(game, "a") + U.wait(10) + local sawHop, sawMachine, hopFrames = false, false, 0 + local shotHop, shotMachine = false, false + for _ = 1, 600 do + local cur = game.overworld + if cur.pikaHop then + sawHop = true + hopFrames = hopFrames + 1 + if not shotHop and hopFrames > 16 then + shotHop = shot("bug417_hop") + end + end + if cur.healAnim then + sawMachine = true + if not shotMachine then shotMachine = shot("bug417_machine") end + end + -- the hop deliberately runs with the overworld back on top (both text + -- boxes have popped themselves by then), so "overworld is top" alone is + -- not the end of the sequence -- wait for the party to be healed and + -- both held animations to be over + local partyUp = #game.save.party > 0 + for _, mon in ipairs(game.save.party) do + if mon.hp ~= mon.stats.hp then partyUp = false end + end + if partyUp and not cur.pikaHop and not cur.healAnim + and game.stack:top() == cur then + break + end + U.tap(game, "a") + U.wait(4) + end + U.wait(30) + + check("the heal script ran back out to the overworld", + game.stack:top() == game.overworld) + check("Pikachu hopped to the counter (#417)", sawHop) + check("the healing machine ran", sawMachine) + local healed = #game.save.party > 0 + for _, mon in ipairs(game.save.party) do + if mon.hp ~= mon.stats.hp or mon.status ~= nil then healed = false end + end + check("the party is actually healed", healed) + local pika = follower() + check("follower is back on screen after the heal", pika ~= nil) + if pika then + U.log(("follower rests at cell (%d, %d) facing %s") + :format(pika.cellX, pika.cellY, tostring(pika.facing))) + end + shot("bug417_after_heal") + + U.log(ok and "ALL PASS" or "SOME CHECKS FAILED") + for _, s in ipairs(shots) do U.log("shot", s) end + + U.log("Standing still with Pikachu right behind you, all it should do is") + U.log("glance around: pikachu_follow.asm only rolls the bounce/spin/shuffle") + U.log("animations when the follower is 2+ cells adrift, so at a normal one") + U.log("cell gap the facing changes ARE the whole idle behaviour. A sprite") + U.log("frozen one way for the whole sample is the bug. To see the livelier") + U.log("animations, strand it first -- hop a ledge or cut through a door so") + U.log("it falls behind, then stand still before it catches up.") + U.log("You are parked at the Viridian counter facing the nurse: press A and") + U.log("say YES to watch Pikachu jump up onto the counter again.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_ledge_bug408_bug409_test.lua b/tests/drivers/pikachu_ledge_bug408_bug409_test.lua new file mode 100644 index 00000000..c4920236 --- /dev/null +++ b/tests/drivers/pikachu_ledge_bug408_bug409_test.lua @@ -0,0 +1,252 @@ +-- Ledge hop with the Yellow follower: one shadow under the jumper (#408) +-- and Pikachu clearing the ledge in one motion instead of stopping on it +-- (#409). The hop cell is searched out of the loaded map's own blocks +-- against data.field.ledges (pokeyellow data/tilesets/ledge_tiles.asm), so +-- a map edit moves the test instead of breaking it. Never add +-- POKEPORT_SPEED here: it scales only the logic clock while audio keeps its +-- own real-time accumulator, which desyncs the exact 32 frames being judged. +-- POKEPORT_DRIVER=tests/drivers/pikachu_ledge_bug408_bug409_test.lua POKEPORT_IDENTITY=bug408 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + + local results = {} + local function check(label, ok) + results[#results + 1] = { label = label, ok = ok and true or false } + return ok + end + local function report() + for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end + end + + if not GameVersion.isYellow() then + check("running under POKEPORT_VERSION=yellow", false) + report() + U.log("Nothing else in this driver applies to RED/BLUE: there is no") + U.log("follower to hop, and the four-quadrant shadow is correct there.") + while true do coroutine.yield() end + end + + -- ShouldPikachuSpawn's two preconditions (engine/pikachu/ + -- pikachu_follow.asm): the lab gift happened and a healthy Pikachu is in + -- the party. Without both, ow.npcs never gets a follower and every + -- follower check below would read as the bug. + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + game.save.onBike = false + + -- ROUTE_1 (10x18 blocks) is the first outdoor map with a plain + -- south-facing ledge run; (5, 4) is the standing cell of one, read out of + -- the cached blocks rather than typed from a map screenshot. scanHop + -- re-derives it below and this only picks the starting point. + local MAP = "ROUTE_1" + local WANT = { x = 5, y = 4 } + + -- data.field.ledges rows for the loaded tileset, in the shape + -- OverworldState:checkLedgeHop matches (facing == input == the pressed + -- direction, standing tile under the player, ledge tile in front). + local function ledgeRows(map, dir) + local rows = {} + for _, l in ipairs(game.data.field.ledges or {}) do + if (l.tileset or "OVERWORLD") == map.def.tileset + and l.facing == dir and l.input == dir then + rows[#rows + 1] = l + end + end + return rows + end + + local function ledgeTileSet(map) + local set = {} + for _, l in ipairs(game.data.field.ledges or {}) do + if (l.tileset or "OVERWORLD") == map.def.tileset then + set[l.ledgeTile] = true + end + end + return set + end + + -- a cell the player can hop south from, with two walkable cells above it + -- to walk in from and a walkable landing two cells below + local function hopCellOk(map, cx, cy) + if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 2)) then + return false + end + if not map:isWalkableCell(cx, cy) then return false end + if not map:isWalkableCell(cx, cy + 2) then return false end + if not (map:isWalkableCell(cx, cy - 1) + and map:isWalkableCell(cx, cy - 2)) then + return false + end + local standing = map:cellTile(cx, cy) + local front = map:cellTile(cx, cy + 1) + for _, l in ipairs(ledgeRows(map, "down")) do + if l.standingTile == standing and l.ledgeTile == front then return true end + end + return false + end + + local function scanHop(map) + for cy = 2, map.heightCells - 3 do + for cx = 0, map.widthCells - 1 do + if hopCellOk(map, cx, cy) then return cx, cy end + end + end + return nil + end + + U.teleport(game, MAP, WANT.x, WANT.y - 2, "down") + U.wait(20) + + local ow = game.overworld + check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + if not ow then + report() + while true do coroutine.yield() end + end + + local hx, hy = WANT.x, WANT.y + if not hopCellOk(ow.map, hx, hy) then + local sx, sy = scanHop(ow.map) + if sx then + U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y), + sx, sy) + hx, hy = sx, sy + U.teleport(game, MAP, hx, hy - 2, "down") + U.wait(20) + ow = game.overworld + end + end + check(("a south ledge to hop at (%d, %d)"):format(hx, hy), + hopCellOk(ow.map, hx, hy)) + + local LEDGES = ledgeTileSet(ow.map) + + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + check("the follower spawned", follower() ~= nil) + + -- sampled every frame from here on: the two things that only exist + -- mid-motion. A follower at rest on a ledge tile is #409 exactly -- it + -- walked onto the ledge and stopped there instead of clearing it. + local maxHop, sawHopStep, restedOnLedge = 0, false, false + local function sample() + local p = ow.player + if p.hopFrames and p.hopFrames > maxHop then maxHop = p.hopFrames end + local npc = follower() + if not npc then return end + if npc.hopStep then sawHopStep = true end + if not npc.moving and LEDGES[ow.map:cellTile(npc.cellX, npc.cellY)] then + restedOnLedge = true + end + end + + local function step(n) + for _ = 1, n do + coroutine.yield() + sample() + end + end + + -- walk south into the ledge, one frame of held Down at a time, and let go + -- the moment the hop commits so the landing cannot roll straight into a + -- second hop + local walked = 0 + for _ = 1, 200 do + table.insert(game.input.pressQueue, "down") + game.input.state["down"] = true + coroutine.yield() + sample() + walked = walked + 1 + if ow.player.hopFrames and ow.player.hopFrames > 0 then break end + end + game.input.state["down"] = false + + check("the player's ledge hop actually fired", maxHop > 0) + + -- mid-arc: count the shadow quads one Player:draw puts down. Player:draw + -- issues its shadow copies back to back before the sprite, so the longest + -- run of consecutive love.graphics.draw calls carrying shadowImg is that + -- per-frame count. Yellow's LedgeHoppingShadowOAM is two entries (dbsprite + -- 9,11 and 10,11 OAM_XFLIP, engine/overworld/ledges.asm) mirrored into one + -- 16x8 ellipse; RED's four-quadrant block is what #408 was leaking in. + local shadowDraws + local img = ow.player.shadowImg + check("the hop shadow tile loaded", img ~= nil) + if img then + local real = love.graphics.draw + local run, best = 0, 0 + love.graphics.draw = function(a, ...) + if a == img then + run = run + 1 + if run > best then best = run end + else + run = 0 + end + return real(a, ...) + end + step(4) + love.graphics.draw = real + shadowDraws = best + check("exactly one mirrored pair of shadow quads per frame (2 draws)", + shadowDraws == 2) + end + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local midShot = SHOT_DIR .. "/bug408_hop_midair.png" + if ow.player.hopFrames and ow.player.hopFrames > 0 then + check("mid-arc screenshot reached disk", U.shot(game, midShot) and true) + U.log("captured", midShot, "at hopFrames", ow.player.hopFrames) + else + check("mid-arc screenshot reached disk", false) + U.log("the arc was already over before the capture; no mid-air frame") + end + + -- let the follower finish crossing (its hop is one normal step's frames + -- with a doubled step vector, pikachu_follow.asm Func_fca0a) + step(120) + + local npc = follower() + check("the follower is still on the map", npc ~= nil) + if npc then + local tile = ow.map:cellTile(npc.cellX, npc.cellY) + check("the follower came to rest on a walkable cell", + ow.map:isWalkableCell(npc.cellX, npc.cellY)) + check("that cell is not a ledge tile", not LEDGES[tile]) + check("the follower ended below the ledge row", npc.cellY > hy + 1) + check("it took the doubled hop step, not two walks", sawHopStep) + U.log(("follower rests at (%d, %d), tile %s; player at (%d, %d)") + :format(npc.cellX, npc.cellY, tostring(tile), + ow.player.cellX, ow.player.cellY)) + end + check("the follower never stood still on a ledge tile", not restedOnLedge) + + local afterShot = SHOT_DIR .. "/bug409_after_follow.png" + check("landing screenshot reached disk", U.shot(game, afterShot) and true) + U.log("captured", afterShot) + U.log("walked", walked, "frames into the ledge at", hx, hy) + + report() + + -- hand the pad back one cell short of the same ledge, so Down alone + -- re-runs the hop as many times as the reader wants + U.teleport(game, MAP, hx, hy - 1, "down") + U.wait(20) + + U.log("Hold Down to hop the ledge again from here.") + U.log("During the arc there should be one flat ellipse on the ground under") + U.log("the player, not a taller blob with a seam across its middle, and") + U.log("Pikachu should clear both cells in one motion. The near miss to") + U.log("watch for is Pikachu pausing a beat on the ledge tile itself.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_talk_bug407_test.lua b/tests/drivers/pikachu_talk_bug407_test.lua new file mode 100644 index 00000000..98838278 --- /dev/null +++ b/tests/drivers/pikachu_talk_bug407_test.lua @@ -0,0 +1,239 @@ +-- Manual check that the Yellow follower answers an A press (#407). +-- TalkToPikachu (pokeyellow engine/pikachu/pikachu_emotions.asm) picks an +-- emotion, plays its bubble + voiced clip and raises the framed pikapic; +-- the port's follower is a frame behind the player, so the old not-moving +-- gate in OverworldState:interact ate the press right after a step landed. +-- No POKEPORT_SPEED: it scales the logic clock only, and the cry runs on +-- the real-time audio accumulator, so a fast run desyncs what is judged. +-- POKEPORT_DRIVER=tests/drivers/pikachu_talk_bug407_test.lua POKEPORT_IDENTITY=bug407 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + local Sound = require("src.core.Sound") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } + + 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 + + -- the Yellow cache is a separate mount; on Red/Blue there is no follower + -- at all and every line below would fail for the wrong reason + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + U.log("Red and Blue have no follower. Re-run with POKEPORT_VERSION=yellow.") + idle() + end + + -- ShouldPikachuSpawn's three inputs (pikachu_follow.asm): the lab gift + -- happened, a healthy starter Pikachu is in the party, and the sprite + -- exists in the cache. Happiness/mood are left at their boot values + -- (90 / 128, init_player_data.asm) so the mood matrix lands on the same + -- cell a fresh save would pick. + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "bryan" + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + + -- pokeyellow data/maps/objects/PalletTown.asm: the town's objects sit at + -- (10,4), (3,8) and (11,14) and its warps at (5,5), (13,5), (12,11), so + -- the road cells around (10,8) are clear of all of them. + local MAP = "PALLET_TOWN" + local START = { x = 10, y = 8, facing = "down" } + + U.teleport(game, MAP, START.x, START.y, START.facing) + U.wait(10) + + local ow = game.overworld + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + local npc = follower() + if not check("follower is in ow.npcs with pikachuFollower set", npc ~= nil) then + U.log("EVENT_GOT_STARTER:", tostring(game.save.flags.EVENT_GOT_STARTER), + "party PIKACHU hp:", + tostring(game.save.party[1] and game.save.party[1].hp), + "SPRITE_PIKACHU:", + tostring(game.data.sprites and game.data.sprites.SPRITE_PIKACHU ~= nil)) + idle() + end + + -- one real step, so the follower trails onto the cell just vacated and + -- the press lands in exactly the window #407 used to swallow. A later + -- map edit that walls (10,9) in degrades to any free neighbour instead + -- of walking into a fence. + local p = ow.player + local DIRS = { { "down", 0, 1 }, { "up", 0, -1 }, + { "left", -1, 0 }, { "right", 1, 0 } } + local stepDir + for _, d in ipairs(DIRS) do + local cx, cy = p.cellX + d[2], p.cellY + d[3] + if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + stepDir = d[1] + break + end + end + if not check("a walkable neighbour to step into exists", stepDir ~= nil) then + idle() + end + U.hold(game, stepDir, 24) -- 16 frame step plus the turn frame and slack + U.wait(6) + + -- turn back the way we came: tryMove on a new facing only turns, so the + -- tap cannot walk back onto the follower's cell + U.tap(game, OPPOSITE[stepDir]) + U.wait(6) + + local function facingFollower() + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == npc + end + + if not facingFollower() then + -- the follower is somewhere else (a step it could not take, a mod): + -- turn toward whichever neighbouring cell it actually occupies + for _, d in ipairs(DIRS) do + if npc.cellX == ow.player.cellX + d[2] + and npc.cellY == ow.player.cellY + d[3] then + U.log("follower is", d[1], "of the player, turning that way instead") + U.tap(game, d[1]) + U.wait(6) + break + end + end + end + check("player is facing the follower's cell", facingFollower()) + U.log("player at", ow.player.cellX, ow.player.cellY, + "facing", ow.player.facing, + "| follower at", npc.cellX, npc.cellY) + + -- a muted run sounds exactly like the bug, so say so before anyone + -- listens for the clip (Sound.setVolumeLevel reads save.options.sfxVol) + local sfxVol = game.save.options and game.save.options.sfxVol + U.log("save.options.sfxVol:", tostring(sfxVol)) + if (sfxVol or 0) == 0 then + U.log("WARNING sfx volume is 0: no cry can be heard whether or not one") + U.log("WARNING plays. Raise it in OPTIONS before judging the sound half.") + end + + -- record what the talk actually asked the mixer for: PCM clip, chip + -- fallback, or nothing at all. playCry calls Sound.playPikaCry through + -- the table, so the wrapper sees the fallback too. + local cries = {} + local realPika, realChip = Sound.playPikaCry, Sound.playCry + Sound.playPikaCry = function(data, n) + local src = realPika(data, n) + cries[#cries + 1] = { kind = "pcm clip", id = n, src = src } + return src + end + Sound.playCry = function(data, species) + local src = realChip(data, species) + cries[#cries + 1] = { kind = "chip cry", id = species, src = src } + return src + end + local function restoreSound() + Sound.playPikaCry, Sound.playCry = realPika, realChip + end + + -- data.field.emotionBubbles is the sheet TalkToPikachu's bubble index + -- resolves against; an unbuilt Yellow cache carries only the three + -- shared bubbles and the talk degrades to a silent hold + local sheet = game.data.field and game.data.field.emotionBubbles + local function bubbleReport(emote) + if emote.bubble == false or emote.bubble == nil then + U.log("this emotion has NO bubble: a cry and the framed pic are all it") + U.log("puts on screen, which is correct behavior, not the bug") + return true + end + local rect = sheet and sheet.bubbles and sheet.bubbles[emote.bubble] + local ok = rect ~= nil and (sheet.path ~= nil) + check("bubble index " .. tostring(emote.bubble) .. + " resolves against the cache sheet", ok) + if ok then + U.log("bubble", rect.name or "?", "crop", + rect.x, rect.y, rect.w, rect.h, "of", sheet.path) + end + return ok + end + + local function reportCries(from) + for i = #cries, 1, -1 do + if i > from then + U.log("cry:", cries[i].kind, tostring(cries[i].id), + cries[i].src and "source created" or "NO SOURCE") + end + end + return #cries > from + end + + -- ---- press one: whatever the mood matrix picks on a boot-value save ---- + local before = #cries + U.tap(game, "a") + U.wait(6) + + local emote = ow.emote + check("the A press reached the follower (ow.emote is set)", + emote ~= nil and emote.npc == npc) + if not emote then + restoreSound() + U.log("Nothing answered the press. That is #407 exactly: no bubble, no") + U.log("cry, no framed picture, and the map keeps running underneath.") + idle() + end + check("the framed pikapic path resolved", + type(emote.pikaPic) == "string" + and love.filesystem.getInfo(emote.pikaPic) ~= nil) + check("a cry source was created", reportCries(before)) + bubbleReport(emote) + U.log("happiness", tostring(game.save.pikachuHappiness or 90), + "mood", tostring(game.save.pikachuMood or 128)) + U.log("on boot values (90 / 128) the matrix cell is emotion 5:") + U.log("PCM clip 31 and no bubble at all, so sound with no bubble is right") + if U.shot(game, SHOT_DIR .. "/bug407_talk_mood.png") then + U.log("captured", SHOT_DIR .. "/bug407_talk_mood.png") + end + + -- ---- press two: a scripted emotion that must show a bubble ---- + -- wPikachuEmotionModifier 5 is MapSpecificPikachuExpression's fifth + -- entry, emotion 25: BOLT_BUBBLE plus PCM clip 35. Forcing it takes the + -- mood roll out of the picture, so a missing bubble here is a real fault. + U.wait(70) -- the 50 frame hold, plus slack, before input is looked at again + game.save.pikachuEmotionModifier = 5 + check("still facing the follower for the second press", facingFollower()) + before = #cries + U.tap(game, "a") + U.wait(6) + + emote = ow.emote + check("emotion 25 (forced) answered the press", emote ~= nil) + if emote then + check("emotion 25 carries a bubble index", type(emote.bubble) == "number") + bubbleReport(emote) + check("emotion 25 played a cry", reportCries(before)) + if U.shot(game, SHOT_DIR .. "/bug407_talk_bolt.png") then + U.log("captured", SHOT_DIR .. "/bug407_talk_bolt.png") + end + end + restoreSound() + + U.log("Both presses have already happened; the screen shows the second.") + U.log("A framed Pikachu picture sits over the map with a lightning bubble") + U.log("above the follower and a voiced squeak plays. Face it and press A") + U.log("again for the mood-picked one: on a fresh save that emotion has a") + U.log("cry but no bubble, which is right. Nothing at all -- no box, no") + U.log("picture, no sound -- is #407 still biting.") + + idle() +end diff --git a/tests/mod_audio_tests.lua b/tests/mod_audio_tests.lua index d45ca5e4..31da1708 100644 --- a/tests/mod_audio_tests.lua +++ b/tests/mod_audio_tests.lua @@ -211,6 +211,10 @@ check(segment.startSample == 0 and segment.volume == 13 check(segment.endSample > 0, "drum segment spans samples") -- ------- ChipAudio: def-local blobs render without touching programs.bin +-- Force unity mix so amplitude/pitch checks are independent of the +-- CHANNEL_VOLUME / CHANNEL_PITCH knobs in ChipAudio.lua. +ChipAudio.setChannelVolumes({ 1, 1, 1, 1 }) +ChipAudio.setChannelPitches({ 1, 1, 1, 1 }) local blobData = { audio = {} } @@ -256,6 +260,59 @@ check(math.abs(waveTrace[1].value - 1) < 1e-9, local drumTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef) check(drumTrace[1].drumSegments == 1, "def-local drums reach the noise channel") +-- per-hardware-channel gains scale only that layer; restore to 1x after +local fullPulse = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong) +local fullDrum = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef) +local fullWave = ChipAudio._traceFirstMusicSampleForTest(blobData, waveSong) +ChipAudio.setChannelVolume(1, 0.5) +local halfPulse = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong) +ChipAudio.setChannelVolumes({ 1, 1, 0, 0.5 }) +local muteWave = ChipAudio._traceFirstMusicSampleForTest(blobData, waveSong) +local halfDrum = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef) +local pulseWhileOthers = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong) +ChipAudio.setChannelVolumes({ 1, 1, 1, 1 }) +check(math.abs(halfPulse[1].value - fullPulse[1].value * 0.5) < 1e-9, + "channel 1 volume 0.5 halves the pulse sample") +check(muteWave[1].value == 0, "channel 3 volume 0 mutes the wave sample") +check(math.abs(halfDrum[1].value - fullDrum[1].value * 0.5) < 1e-9, + "channel 4 volume 0.5 halves the drum sample") +check(math.abs(pulseWhileOthers[1].value - fullPulse[1].value) < 1e-9, + "muting wave/noise does not change pulse amplitude") +check(math.abs(fullWave[1].value) > 0, "wave sample is nonzero at unity gain") +local vols = ChipAudio.getChannelVolumes() +check(vols[1] == 1 and vols[2] == 1 and vols[3] == 1 and vols[4] == 1, + "channel volumes restore to 1") +check(ChipAudio.getNoiseVolume() == 1, "noise-volume alias tracks channel 4") + +-- per-channel pitch scales oscillator rate (zero-crossings ~double at 2x) +local function zeroCrossings(sd) + local count, prev = 0, sd:getSample(0) + for index = 1, sd:getSampleCount() - 1 do + local sample = sd:getSample(index) + if prev * sample < 0 then count = count + 1 end + prev = sample + end + return count +end +ChipAudio.setChannelVolumes({ 1, 1, 1, 1 }) +ChipAudio.setChannelPitches({ 1, 1, 1, 1 }) +local pitchBase = ChipAudio._renderMusicChannelForTest(blobData, blobSong, 0.25, 1) +ChipAudio.setChannelPitch(1, 2) +local pitchOctave = ChipAudio._renderMusicChannelForTest(blobData, blobSong, 0.25, 1) +ChipAudio.setChannelPitch(1, 0) +local pitchFrozen = ChipAudio._renderMusicChannelForTest(blobData, blobSong, 0.25, 1) +ChipAudio.setChannelPitches({ 1, 1, 1, 1 }) +local baseX = zeroCrossings(pitchBase) +local octaveX = zeroCrossings(pitchOctave) +check(baseX > 10, "unity pitch produces a tone with zero crossings") +check(octaveX > baseX * 1.7 and octaveX < baseX * 2.3, + "channel 1 pitch 2 roughly doubles zero crossings") +check(zeroCrossings(pitchFrozen) == 0, + "channel 1 pitch 0 freezes the oscillator") +local pitches = ChipAudio.getChannelPitches() +check(pitches[1] == 1 and pitches[2] == 1 and pitches[3] == 1 and pitches[4] == 1, + "channel pitches restore to 1") + -- ------- data fixtures (chip + wav only) local chipSong = ChipAsm.song{