Compare commits

...

13 Commits

Author SHA1 Message Date
bryanthaboi 8c877a64f0 Merge pull request #421 from bryanthaboi/dev
yellow fixes and othe random shit
2026-07-29 15:11:17 -04:00
bryanthaboi 8fdfd5c843 Merge pull request #416 from johnjohto/fix-true-color-dex-intro
fix true color UI Pokemon sprites

Two conflicts against dev, both structural rather than behavioural:

DexEntryMenu.render became a static function on dev so the printer could
share the entry page; the branch still marked true color from the method
form.  Threaded the flag through as a render() parameter.  The printer
caller leaves it nil on purpose -- it renders to its own PNG canvas, and a
mark left there would bleed into the next real frame.

TitleState moved the cycling mon and Red's OAM into the non-Yellow branch
on dev (title_yellow.asm has neither); the branch still drew them at top
level.  Kept dev's structure and folded the true color marking into it.
2026-07-29 15:10:03 -04:00
bryanthaboi 579c1744bd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-29 15:06:37 -04:00
bryanthaboi 118c45f8f6 CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417
CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417
2026-07-29 15:06:33 -04:00
bryanthaboi beaa502ff9 Merge pull request #419 from johnjohto/fix-exported-save-layout
keep imported save cache on export
2026-07-29 14:20:05 -04:00
bryanthaboi 730b7b842c Add video update section to README
Added a section to watch the latest update video.
2026-07-29 14:16:56 -04:00
bryanthaboi 784832ca95 Revise README for support and announcements
Updated support section with new announcements and logos.
2026-07-29 14:09:27 -04:00
johnjohto bbe894fb4f keep imported save cache on export
Red restores the current-map cache from SRAM before Continue. Dropping the import template made a later export boot with zeroed map data.
2026-07-29 14:01:19 -04:00
johnjohto 4c17e606fd fix true color UI Pokemon sprites
The UI drew sprite paths but discarded their trueColor flag, so the SGB palette pass recolored full-color Pokemon.
2026-07-29 13:31:50 -04:00
bryanthaboi 73d81a72b8 Merge pull request #412 from johnjohto/fix-wide-option-battle-screens
Keep wide layout through battle menus
2026-07-29 13:26:54 -04:00
johnjohto 8b0f770c57 Keep wide layout through battle menus 2026-07-29 13:14:56 -04:00
bryanthaboi a89d43f315 Merge pull request #405 from bryanthaboi/dev
Update build.sh
2026-07-29 12:17:11 -04:00
bryanthaboi 96a64d439e Update build.sh 2026-07-29 12:15:56 -04:00
26 changed files with 1980 additions and 125 deletions
+7
View File
@@ -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.
+13 -2
View File
@@ -4,7 +4,16 @@ A native LÖVE2D recreation of Poke Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a> </p>
### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/TbHdJIrKJJU/maxresdefault.jpg)](https://www.youtube.com/watch?v=TbHdJIrKJJU)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
@@ -185,4 +194,6 @@ request with real detail is one that can actually get built.
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
[pokered](https://github.com/pret/pokered) disassembly.
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
+9 -3
View File
@@ -66,17 +66,23 @@ rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
# The editor is only reachable if its entry point and both module directories
# made it in; a silent miss would ship a launcher whose Edit button crashes.
# made it in, and every version's import manifest has to ship or that game's
# ROM import fails in the built app (dev reads them off the source tree, so
# the miss only ever shows up in a build -- the Yellow manifest shipped this
# way once).
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua; do
tools/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json; do
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|| fail "game.love is missing $required (save editor would not load)"
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
+9 -8
View File
@@ -38,15 +38,16 @@ BattleState.letterboxWhite = true
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
-- differs; every battler, queue and animation below is shared. The wide
-- layout is live only while this battle is the state being drawn on top --
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
-- goes back with it and the battle underneath is not drawn at all.
function BattleState:wideLayout()
-- differs; every battler, queue and animation below is shared. Menus and
-- prompts pushed during a wide battle keep its wide canvas, while drawing
-- their classic 160px UI centred within it (Game:draw).
function BattleState:isWideBattleLayout()
local options = self.game and self.game.save and self.game.save.options
if not options or options.battleLayout ~= "wide" then return false end
local stack = self.game.stack
return (stack and stack.top and stack:top()) == self
return options and options.battleLayout == "wide" or false
end
function BattleState:wideLayout()
return self:isWideBattleLayout()
end
-- Renderer:setUISize asks the top state for its surface before anything draws
+81 -1
View File
@@ -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)
+77 -7
View File
@@ -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 = {}
+65 -7
View File
@@ -244,35 +244,93 @@ end
-- exactly as the owning state computed it
local function sameZones(_, zones) return zones end
-- A wide battle owns the surface until it leaves the stack. The party,
-- bag, choice and text states it opens still draw their original 160px UI,
-- but the canvas must not snap to 160px between those states.
function Game.wideBattleInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
return state
end
end
return nil
end
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content.
local function centerClassicZones(zones, offset)
if not zones or offset == 0 then return zones end
local shifted = {}
for i, zone in ipairs(zones) do
local copy = {}
for key, value in pairs(zone) do copy[key] = value end
if copy.x == 0 and copy.w == Renderer.WIDTH then
copy.w = copy.w + offset * 2
else
copy.x = (copy.x or 0) + offset
end
shifted[i] = copy
end
return shifted
end
function Game:draw()
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
-- The UI surface is resolved once, before any state draws: the top state
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
-- asks for 304x144). Anything else keeps the classic surface, so a menu
-- pushed over a wide battle brings the screen straight back to 160x144.
-- A wide battle holds its 304px surface through every menu or prompt it
-- opens. States that do not draw the wide battle composition are centred
-- in that surface below, so their classic coordinates and hit testing stay
-- unchanged. Outside a battle, including the title screen, the option is
-- intentionally inactive because it is a battle-layout setting.
local top = self.stack:top()
if top and top.uiSize then
local wideBattle = Game.wideBattleInStack(self.stack)
local classicOffset = 0
if wideBattle and wideBattle.uiSize then
Renderer:setUISize(wideBattle:uiSize())
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
elseif top and top.uiSize then
Renderer:setUISize(top:uiSize())
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
Renderer:beginFrame(worldBelow)
self.stack:draw()
for i = self.stack:visibleBase(), #self.stack.states do
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
and state:isWideBattleLayout()
if state and state.draw then
if classicOffset ~= 0 and not wideState then
love.graphics.push()
love.graphics.translate(classicOffset, 0)
state:draw()
love.graphics.pop()
else
state:draw()
end
end
end
-- SGB colorization: the topmost state that knows its palette owns the
-- screen (overlays like text boxes inherit from what's beneath them);
-- the overworld's world pass colors each visible map area separately
local zones, worldZones
local zones, worldZones, zoneOwner
for i = #self.stack.states, 1, -1 do
local s = self.stack.states[i]
if s.sgbPalettes then
zones = s:sgbPalettes(self)
zoneOwner = s
break
end
end
if classicOffset ~= 0 and zoneOwner
and not (zoneOwner.isWideBattleLayout
and zoneOwner:isWideBattleLayout()) then
zones = centerClassicZones(zones, classicOffset)
end
-- 14's render.zones: weather/lighting overlays and custom colorization
-- recolor or add zones before the blit
if ModRuntime.wantsHook("render.zones") then
+12 -1
View File
@@ -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
+2 -1
View File
@@ -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 {}
+4 -5
View File
@@ -116,13 +116,12 @@ local function defaultsSave()
end
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
-- way convert.lua did, then stamp the requested version. The 32768-byte
-- import template GenSave stashes as `rawImport` and the decode `warnings`
-- are dropped here: neither belongs in a serialized slot file (a fresh
-- export always starts zero-filled -- see GenSave.lua's header).
-- way convert.lua did, then stamp the requested version. Keep the imported
-- SRAM image with the slot: Pokémon Red restores its saved current-map cache
-- before Continue, and an export needs that unmodeled data to remain bootable.
-- Decode warnings are only import diagnostics and do not belong in the slot.
local function mergeDefaults(decoded, version)
decoded.warnings = nil
decoded.rawImport = nil
local save = defaultsSave()
for k, v in pairs(decoded) do save[k] = v end
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
+15 -5
View File
@@ -35,14 +35,15 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species]
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
require("src.core.Sound").playCry(game.data, species)
return self
end
@@ -55,17 +56,26 @@ function DexEntryMenu:update(dt)
end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned)
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if sprite then
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
local y = math.max(0, 60 - sprite:getHeight())
love.graphics.draw(sprite, 8, y)
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
-- it renders to its own PNG canvas, and a mark left behind there would
-- bleed into the next real frame.
if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
end
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
+34 -19
View File
@@ -61,7 +61,7 @@ local function tryImage(path)
return ok and img or nil
end
-- Resolve a pic descriptor to (image, flip).
-- Resolve a pic descriptor to (image, flip, trueColor).
-- Descriptors:
-- "oak" | "rival" | "player" shorthand
-- { type = "trainer", id = "OPP_PROF_OAK" }
@@ -70,7 +70,7 @@ end
-- { type = "image", path = "..." }
-- { type = "sprite", id = "SPRITE_RED" }
function OakSpeech.resolvePic(game, desc, speech)
if desc == nil then return nil, false end
if desc == nil then return nil, false, false end
if type(desc) == "string" then
if desc == "oak" then
desc = { type = "trainer", id = "OPP_PROF_OAK" }
@@ -86,35 +86,37 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false
return speech.oakPic, false, false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false
return speech.rivalPic, false, false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false
return tryImage(tr and tr.pic), false, false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
end
local path = require("src.pokemon.Sprites").path(
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, desc.id, "front", { kind = "oak" })
return tryImage(path), desc.flip and true or false
return tryImage(path), desc.flip and true or false, trueColor
elseif t == "player" then
if speech and speech.playerPic and not desc.path then
return speech.playerPic, false
return speech.playerPic, false, speech.playerTrueColor
end
if desc.path then return tryImage(desc.path), false end
return tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })), false
if desc.path then return tryImage(desc.path), false, false end
local path, trueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })
return tryImage(path), false, trueColor
elseif t == "image" then
return tryImage(desc.path), desc.flip and true or false
return tryImage(desc.path), desc.flip and true or false, false
elseif t == "sprite" then
local sp = game.data.sprites and game.data.sprites[desc.id]
return tryImage(sp and sp.image), desc.flip and true or false
return tryImage(sp and sp.image), desc.flip and true or false,
sp and sp.trueColor or false
end
return nil, false
return nil, false, false
end
-- Vanilla step list. Ids are the stable anchors mods insert around.
@@ -219,15 +221,18 @@ function OakSpeech.new(game, onDone)
-- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
local demoPath = require("src.pokemon.Sprites").path(
local demoPath, demoTrueColor = require("src.pokemon.Sprites").path(
game.data, self.demoSpecies, "front", { kind = "oak" })
self.demoPic = tryImage(demoPath)
self.demoTrueColor = self.demoPic and demoTrueColor or false
local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" }))
local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })
self.playerPic = tryImage(playerPath)
self.playerTrueColor = self.playerPic and playerTrueColor or false
self.shrinkPic1 = tryImage(oakGfx.shrink1
or "assets/generated/intro/shrink1.png")
self.shrinkPic2 = tryImage(oakGfx.shrink2
@@ -278,14 +283,17 @@ end
function OakSpeech:applyPic(step)
if step.pic == nil then return end
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
local img, flip, trueColor = OakSpeech.resolvePic(self.game, step.pic, self)
if img then
self.pic = img
self.picFlip = flip or false
self.picTrueColor = trueColor or false
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
-- mirror the old fallback: player pic missing → oak
self.pic = self.playerPic or self.oakPic
self.picFlip = false
self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor
or false
end
end
@@ -342,6 +350,7 @@ function OakSpeech:runStep(step)
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
self.pic = self.demoPic
self.picFlip = true
self.picTrueColor = self.demoTrueColor
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies)
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
@@ -536,8 +545,10 @@ function OakSpeech:update(dt)
s.frame = s.frame + 1
if s.frame == 5 then
self.pic = self.shrinkPic1 or self.pic
self.picTrueColor = false
elseif s.frame == 9 then
self.pic = self.shrinkPic2 or self.pic
self.picTrueColor = false
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
@@ -545,6 +556,7 @@ function OakSpeech:update(dt)
Music.fadeOut(10)
elseif s.frame == 29 then
self.pic = nil
self.picTrueColor = false
self.walkVisible = true
elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
@@ -584,6 +596,9 @@ function OakSpeech:draw()
else
love.graphics.draw(self.pic, x + off, y)
end
if self.picTrueColor then
require("src.render.PaletteFX").markTrueColor(x + off, y, w, h)
end
love.graphics.setColor(1, 1, 1, 1)
end
if self.walkVisible and self.walkSheet then
+47 -8
View File
@@ -98,6 +98,30 @@ local function imagePath(entry)
return entry
end
-- PaletteFX redraws a true-color rectangle after the palette pass. Red's
-- title art is drawn on top of the title mon, so leave its bounds out of the
-- rectangle rather than redrawing that art without its title palette.
local function markVisibleTrueColor(x, y, w, h, cover)
local P = require("src.render.PaletteFX")
if not cover then
P.markTrueColor(x, y, w, h)
return
end
local cx, cy, cw, ch = cover[1], cover[2], cover[3], cover[4]
local right, bottom = x + w, y + h
local cright, cbottom = cx + cw, cy + ch
local ix1, iy1 = math.max(x, cx), math.max(y, cy)
local ix2, iy2 = math.min(right, cright), math.min(bottom, cbottom)
if ix1 >= ix2 or iy1 >= iy2 then
P.markTrueColor(x, y, w, h)
return
end
if y < iy1 then P.markTrueColor(x, y, w, iy1 - y) end
if iy2 < bottom then P.markTrueColor(x, iy2, w, bottom - iy2) end
if x < ix1 then P.markTrueColor(x, iy1, ix1 - x, iy2 - iy1) end
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
end
function TitleState.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TitleState)
@@ -148,7 +172,7 @@ function TitleState.new(game, opts)
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or defaultCycle
self.sprites = {} -- species -> image or false (load failed)
self.sprites = {} -- species -> { image, trueColor } or false (load failed)
self.cycleIndex = 1
self.timer = 0
self.blink = 0
@@ -252,12 +276,13 @@ function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species]
if cached == nil then
local path = require("src.pokemon.Sprites").path(
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "title" })
cached = tryImage(path) or false
local image = tryImage(path)
cached = image and { image = image, trueColor = trueColor } or false
self.sprites[species] = cached
end
return cached or nil
return cached and cached.image or nil, cached and cached.trueColor or false
end
local function hasSave()
@@ -446,13 +471,27 @@ function TitleState:draw()
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end
end
local sprite = self:currentSprite()
local sprite, spriteTrueColor = self:currentSprite()
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
136 - h)
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
local x = 40 + math.floor((56 - w) / 2) + slide
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
-- the strip Red's OAM covers (#350). Yellow never reaches here: its
-- layout has no cycling mon and no Red art (title_yellow.asm).
if spriteTrueColor then
local cover
if self.player then
local pw, ph = self.player:getDimensions()
cover = { 82, 80, pw, ph }
end
markVisibleTrueColor(x, y, w, h, cover)
end
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
love.graphics.draw(self.player, 82, 80)
end
+20 -4
View File
@@ -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
+86 -36
View File
@@ -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
+310 -7
View File
@@ -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)
+21 -7
View File
@@ -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
@@ -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
@@ -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
@@ -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
+239
View File
@@ -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
+10 -1
View File
@@ -96,7 +96,14 @@ local function syntheticSave(name)
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
} }
return GenSave.encode(seed, data, nil)
-- The current-map view pointer is part of wMainData's map cache, not a
-- modeled save field. Pokémon Red restores that cache before Continue,
-- so it is a useful canary for the import -> slot -> export path.
local raw = GenSave.encode(seed, data, nil)
local cacheOff = OFF.mainData + 104
local cacheTemplate = raw:sub(1, cacheOff) .. string.char(0xA5)
.. raw:sub(cacheOff + 2)
return GenSave.encode(seed, data, cacheTemplate)
end
-- ---------------------------------------------- importToSlot -> listSlots
@@ -149,6 +156,8 @@ do
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"the export carries a valid main-data checksum")
eq(outBytes and outBytes:byte(OFF.mainData + 105), 0xA5,
"the export keeps the saved current-map cache")
-- the export re-imports to an equivalent save
local re = SaveConvert.importSav(outBytes, "red")
+8
View File
@@ -6,12 +6,20 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local WideBattle = require("src.battle.WideBattle")
local Renderer = require("src.render.Renderer")
local Game = require("src.core.Game")
T.eq(WideBattle.WIDTH, 304, "the wide layout runs on a 304px native surface")
T.eq(WideBattle.HEIGHT, 144, "the wide surface keeps the native height")
T.eq(WideBattle.FIELD_BOTTOM, 104,
"the lower 40 rows are the message / command windows")
local wide = { isWideBattleLayout = function() return true end }
local normal = { isWideBattleLayout = function() return false end }
T.eq(Game.wideBattleInStack({ states = { normal, wide, normal } }), wide,
"a wide battle remains the surface owner under a classic overlay")
T.eq(Game.wideBattleInStack({ states = { normal } }), nil,
"a classic stack keeps the normal surface")
-- move grid: slots are laid out 1 2 / 3 4
T.eq(WideBattle.moveGridIndex(1, 4, "right"), 2, "RIGHT crosses the row")
T.eq(WideBattle.moveGridIndex(2, 4, "left"), 1, "LEFT crosses the row")
+57
View File
@@ -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{
+88
View File
@@ -0,0 +1,88 @@
-- True-color Pokemon art is drawn on the UI canvas in addition to battle.
-- The Pokedex, title screen, and Oak's intro need to report the exact
-- rectangle they draw so PaletteFX can put the unshaded copy over the SGB
-- palette pass. Run with `luajit tests/parity_true_color_ui.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity true-color ui")
local check = S.check
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
require("src.render.Font").load(Data)
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local DexEntryMenu = require("src.ui.DexEntryMenu")
local TitleState = require("src.ui.TitleState")
local OakSpeech = require("src.ui.OakSpeech")
local savedCry = Sound.playCry
Sound.playCry = function() end
local function uiRects(draw)
PaletteFX.clearTrueColor()
PaletteFX.setPass("ui")
draw()
local rects = PaletteFX.trueColorRects("ui")
PaletteFX.setPass(nil)
return rects
end
local def = Data.pokemon.PIKACHU
local savedTrueColor = def.trueColor
def.trueColor = true
local game = {
data = Data,
save = { pokedex = { owned = { PIKACHU = true } } },
stack = { pop = function() end },
}
local dex = DexEntryMenu.new(game, "PIKACHU")
check(dex.spriteTrueColor == true,
"Pokedex keeps a Pokemon sprite's trueColor flag")
local dexRects = uiRects(function() dex:draw() end)
local dx, dy = 8, math.max(0, 60 - dex.sprite:getHeight())
check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
and dexRects[1].w == dex.sprite:getWidth()
and dexRects[1].h == dex.sprite:getHeight(),
"Pokedex reports the true-color sprite rectangle")
local titleGame = {
data = { pokemon = Data.pokemon,
field = { title = { cycleSpecies = { "PIKACHU" } } } },
}
local title = TitleState.new(titleGame, {})
local titleSprite, titleTrueColor = title:currentSprite()
check(titleSprite and titleTrueColor,
"title cache keeps a Pokemon sprite's trueColor flag")
local titleRects = uiRects(function() title:draw() end)
local tx = 40 + math.floor((56 - titleSprite:getWidth()) / 2)
check(#titleRects == 1 and titleRects[1].x == tx
and titleRects[1].y == 136 - titleSprite:getHeight()
and titleRects[1].w == 82 - tx
and titleRects[1].h == titleSprite:getHeight(),
"title reports the visible true-color sprite rectangle")
local oak = OakSpeech.new({
data = { pokemon = Data.pokemon, trainers = {},
field = { oakSpeech = { demoSpecies = "PIKACHU" } } },
}, nil)
oak.pic, oak.picFlip = oak.demoPic, true
oak.picTrueColor = oak.demoTrueColor
check(oak.demoTrueColor == true,
"Oak intro keeps the demo Pokemon's trueColor flag")
local oakRects = uiRects(function() oak:draw() end)
local ow, oh = oak.demoPic:getDimensions()
local ox = 48 + math.floor((8 - ow / 8) / 2) * 8
local oy = 32 + (7 - oh / 8) * 8
check(#oakRects == 1 and oakRects[1].x == ox and oakRects[1].y == oy
and oakRects[1].w == ow and oakRects[1].h == oh,
"Oak intro reports the true-color sprite rectangle")
def.trueColor = savedTrueColor
Sound.playCry = savedCry
PaletteFX.clearTrueColor()
S.finish()
+4 -3
View File
@@ -297,9 +297,10 @@ check(scSave and scSave.lastHeal and scSave.lastHeal.map == scSave.player.map,
"SaveConvert.importSav: lastHeal derives from the decoded position")
check(scSave and scSave.lastOutdoor and scSave.lastOutdoor.id ~= nil,
"SaveConvert.importSav: lastOutdoor is set")
-- the import template + decode warnings never leak into the slot table
check(scSave and scSave.rawImport == nil and scSave.warnings == nil,
"SaveConvert.importSav: rawImport/warnings stripped from the returned table")
-- The original SRAM image carries the current-map cache that Red restores on
-- Continue, while decode warnings are only import diagnostics.
check(scSave and type(scSave.rawImport) == "string" and scSave.warnings == nil,
"SaveConvert.importSav: keeps the SRAM template but drops warnings")
-- size / type validation
local badSize, badSizeErr = SaveConvert.importSav("too short", 2)