diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 86b2ab18..453c872a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1893,6 +1893,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return @@ -1929,6 +1930,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] if choice == "fight" and self.ghost then self:say(Strings("%s is too\nscared to move!", self.player.name)) @@ -1990,9 +1992,11 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then + require("src.core.Sound").play(self.data, "Press_AB") self.moveSwapIndex = nil self.phase = "menu" elseif input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil @@ -2031,6 +2035,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then + require("src.core.Sound").play(self.data, "Press_AB") local pick = moves[self.mimicIndex] local ctx = self.mimicCtx self.mimicMoves, self.mimicCtx = nil, nil diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 1ca31740..01f4f376 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -111,6 +111,11 @@ local NOISE_DIVISORS = { [4] = 64, [5] = 80, [6] = 96, [7] = 112, } + +local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE) +local LPF_ALPHA = 0.8 +local MIX_SCALE = 0.5 + local function snapTicks(ticks) return math.floor((ticks * 1470 + 256) / 512) end @@ -269,6 +274,7 @@ function Channel.new(engine, spec, options) phase = 0, noiseLfsr = 0x7FFF, noiseClock = 0, + drumTail = nil, timeTicks = 0, }, Channel) end @@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed) return math.min(15, volume + steps) end + +local function envelopeRingSamples(volume, fade) + if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end + return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5) +end + +local function extendDrumEnvelope(segments) + local last = segments and segments[#segments] + if not last then return segments end + local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade) + if ringEnd > last.endSample then last.endSample = ringEnd end + return segments +end + +local function drumAudioEnd(drum) + local last = drum and drum[#drum] + return last and last.endSample or 0 +end + function Channel:resetNoise() self.noiseLfsr = 0x7FFF self.noiseClock = 0 @@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter) end end end - -- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0) - return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1 + return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0 end local function sweepCalculation(register, sweep) @@ -611,21 +635,51 @@ end function Channel:sample() while not self.ended and (not self.event or self.event.sample >= self.event.samples) do + local prev = self.event self.event = self:nextEvent() self.phase = 0 - self:resetNoise() + if self.event and self.event.drum then + self.drumTail = nil + self:resetNoise() + elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then + -- ..(audio/engine_1.asm ln 197) + self.drumTail = prev + elseif not (self.event and self.event.silence and self.drumTail) then + self.drumTail = nil + self:resetNoise() + end end local event = self.event - if not event then return 0 end + local gain = channelVolume[self.hardware] or 1 + if not event then + local tail = self.drumTail + if not tail then return 0 end + local sampleIndex = tail.sample + tail.sample = sampleIndex + 1 + if sampleIndex >= drumAudioEnd(tail.drum) then + self.drumTail = nil + return 0 + end + return self:sampleDrum(tail, sampleIndex) * gain + end local sampleIndex = event.sample event.elapsed = sampleIndex / SAMPLE_RATE event.sample = sampleIndex + 1 - if event.silence then return 0 end - - local gain = channelVolume[self.hardware] or 1 + if event.silence then + local tail = self.drumTail + if not tail then return 0 end + local tailIndex = tail.sample + tail.sample = tailIndex + 1 + if tailIndex >= drumAudioEnd(tail.drum) then + self.drumTail = nil + return 0 + end + return self:sampleDrum(tail, tailIndex) * gain + end if event.drum then return self:sampleDrum(event, sampleIndex) * gain end + self.drumTail = nil local volume = envelopeVolume( event.volume or 0, event.fade or 0, event.elapsed) if event.noise then @@ -665,7 +719,8 @@ 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 * gain + local nibble = math.max(0, math.min(15, wave[index] * 8 + 8)) + return (nibble / 15) * event.waveLevel * gain end local duty = event.duty if type(duty) == "table" then @@ -674,7 +729,7 @@ 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 * gain + return 0 end return volume / 15 * gain end @@ -685,7 +740,7 @@ Engine.__index = Engine function Engine:noiseInstrument(number) -- a def-local drum wins over the ROM engine's table for that id local custom = self.customDrums and self.customDrums[number] - if custom then return custom end + if custom then return extendDrumEnvelope(custom) end local cached = self.noiseInstruments[number] if cached then return cached end @@ -718,6 +773,7 @@ function Engine:noiseInstrument(number) end end + extendDrumEnvelope(segments) self.noiseInstruments[number] = segments return segments end @@ -792,6 +848,8 @@ function Engine.new(data, header, options) customDrums = chip and chip.drums or nil, noiseInstruments = {}, channels = {}, + hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0, + lpf = 0, lpfLeft = 0, lpfRight = 0, }, Engine) -- header.tempo: the Music_*AlternateTempo override Music.play stamps onto -- a copy of the song def (audio/alternate_tempo.asm) (#847) @@ -826,10 +884,20 @@ function Engine:finished() return true end +local function analogOut(engine, input, hpfField, lpfField) + local cap = engine[hpfField] + local hp = input - cap + engine[hpfField] = input - hp * HPF_CHARGE + local prev = engine[lpfField] + local lp = prev + LPF_ALPHA * (hp - prev) + engine[lpfField] = lp + return math.max(-1, math.min(1, lp * MIX_SCALE)) +end + function Engine:sample() local value = 0 for _, channel in ipairs(self.channels) do value = value + channel:sample() end - return math.max(-1, math.min(1, value / 4)) + return analogOut(self, value, "hpfCap", "lpf") end function Engine:sampleStereo() @@ -840,8 +908,8 @@ function Engine:sampleStereo() if not event or event.panLeft ~= false then left = left + value end if not event or event.panRight ~= false then right = right + value end end - return math.max(-1, math.min(1, left / 4)), - math.max(-1, math.min(1, right / 4)) + return analogOut(self, left, "hpfCapLeft", "lpfLeft"), + analogOut(self, right, "hpfCapRight", "lpfRight") end function Engine:sampleChannel(number) @@ -850,7 +918,7 @@ function Engine:sampleChannel(number) local value = channel:sample() if channel.number == number then selected = value end end - return math.max(-1, math.min(1, selected / 4)) + return analogOut(self, selected, "hpfCap", "lpf") end -- render `samples` frames into a fresh SoundData (mono or stereo). love.sound @@ -870,22 +938,7 @@ local function soundData(engine, samples, channels) return result end --- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when --- it is too short to be audible. The caller wraps it in a static --- love.audio.Source (a playback concern, hence not done here). --- --- The synthesis is mono (one summed value per frame, unlike the music path's --- sampleStereo), but the buffer is written stereo on purpose: OpenAL only --- spatializes 1-channel Sources, and a Source left at the default (0,0,0) --- position, exactly where the listener sits, is rendered as an ambient sound --- spread over EVERY output channel the device exposes at gains that differ --- from the front pair. On an interface with more than two outputs that put --- the SFX on outputs 5+6 as well, while the 2-channel music source --- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip --- spatialization entirely and map onto the front pair, so duplicating the --- sample costs one buffer's memory and makes effects route exactly like --- music. Deliberately not sampleStereo: that honors the NR51 panning byte --- and would newly hard-pan any effect whose header issues command 0xEE. + local function renderEffectData(data, header, options) if not header then return nil end options = options or {} diff --git a/src/core/Music.lua b/src/core/Music.lua index 745d45a9..7ac621bc 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -1,12 +1,3 @@ --- Music playback supports compact ROM channel programs synthesized live by --- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The --- branch is chosen per song definition, never by a global import flag, so a --- file-backed song and a chip song coexist in one dataset. Songs with split --- files chain def.file into def.loopFile in Music.update(). --- Map themes switch on map change; battles override with the battle --- theme and restore afterwards; riding the bike overrides outdoor map --- themes with the bike song until dismount. - local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") @@ -14,20 +5,10 @@ local Music = {} local VOLUME = 0.7 --- port additions driven by OptionsMenu / save.options: musicVol scales --- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter --- low-passes the song. Each filter step keeps 40% of the previous --- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter --- applied twice/three times over. local volumeScale = 1 local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } local filterLevel = 0 --- Forward-declared here so applyVolume (below) closes over the real playback --- state rather than a nil global: the table literal is assigned further down, --- but a `local state = {}` there would leave every reference above it bound --- to the global `state`. Before this, registering the `music.volume` mod --- hook crashed applyVolume on `state.current` (a nil index). local state local function applyVolume(src) @@ -115,11 +96,6 @@ function Music.duckForFanfare(src) end end --- Overworld themes where the bike can be ridden (outdoor maps plus the --- caves/dungeons where gen-1 allows cycling). Indoor themes such as --- Pokecenter/Gym/SilphCo never get replaced by the bike theme. --- data.audio.outdoorSongs supersedes this; the copy stays as the fallback --- for caches built before the importer wrote the table. local OUTDOOR = { Music_PalletTown = true, Music_Cities1 = true, @@ -218,6 +194,7 @@ end -- the single choke point every song choice passes through, so one hook -- covers map themes, battle themes, jingles and scene music local function selectSong(song, ctx) + if ctx and ctx.selected then return song end if not Runtime.wantsHook("music.select") then return song end return Runtime.call("music.select", function(chosen) return chosen end, song, { reason = ctx and ctx.reason or "direct", @@ -234,17 +211,28 @@ end function Music.play(data, song, loop, ctx) if not song then return end if not love.audio then return end -- headless test stub + ctx = ctx or {} song = selectSong(song, ctx) - -- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm): - -- the same song restarted with channel 1 re-pointed at a stub whose only - -- difference is its `tempo`, so the same label at a different tempo is a - -- different cue and must not be deduped away (#847) + local tempo = ctx and ctx.tempo or nil -- a hook may silence the cue outright, or swap in a label the dedupe -- below has to compare against if not song or (song == state.current and tempo == state.tempo) then return end local def = songDef(data, song) if not def or state.failed[song] then return end + + if ctx.fade and state.source then + local queued = {} + for key, value in pairs(ctx) do queued[key] = value end + queued.fade, queued.selected = nil, true + local pending = { data = data, song = song, loop = loop, ctx = queued } + if state.fade then + state.fade.pending = pending + else + Music.fadeOut(ctx.fade, pending) + end + return + end if tempo then -- shallow copy: the registry def is shared, only this playback is slowed local slowed = {} @@ -317,24 +305,26 @@ function Music.reload() Music.stop() end --- Ramp the current song's volume to silence, then stop it, mirroring the --- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio + --- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in --- integer levels, one level every `control` frames, and the music stops --- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM --- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames --- to silence). Ticked once per frame from Music.update(). -function Music.fadeOut(control) - if not state.source then Music.stop() return end +function Music.fadeOut(control, pending) + if not state.source then + Music.stop() + if pending then + Music.play(pending.data, pending.song, pending.loop, pending.ctx) + end + return + end control = math.max(1, control or 10) state.fade = { control = control, counter = control, -- frames until the next volume step level = 7, -- current master-volume level (rAUDVOL nibble) from = VOLUME * volumeScale, -- level-7 (full) source volume + pending = pending, } end +Music.MAP_FADE = 10 + -- the song a map should currently play, honoring the bike/surf overrides local function effectiveMapSong(data, song) if not song or not outdoorSongs(data)[song] then return song end @@ -351,14 +341,17 @@ end -- overworld map theme; onBike/surfing override outdoor themes with the -- bike/surf songs and restore the map theme when they end -function Music.playMap(data, mapId, onBike, surfing) +function Music.playMap(data, mapId, onBike, surfing, fade) local song = data and data.audio and data.audio.mapSongs and mapId and data.audio.mapSongs[mapId] or nil state.mapSong = song state.onBike = not not onBike state.surfing = not not surfing local play = effectiveMapSong(data, song) - if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end + if play then + Music.play(data, play, nil, + { reason = "map", mapId = mapId, fade = fade }) + end end -- toggle the surf override mid-map (starting/ending a surf) @@ -475,8 +468,13 @@ function Music.update(data) f.counter = f.control f.level = f.level - 1 if f.level <= 0 then + -- ..(home/fade_audio.asm ln 36) state.fade = nil + local pending = f.pending Music.stop() + if pending then + Music.play(pending.data, pending.song, pending.loop, pending.ctx) + end return end local vol = f.from * f.level / 7 diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 15f14be2..fb66220b 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -1,29 +1,5 @@ --- Boot splash + attract movie, a faithful port of PlayIntro --- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm) --- using the real extracted art (data/generated/field.lua `intro` manifest). --- --- Three frame-counted phases: --- 1. copyright card, 180 frames (intro.asm:311-312). --- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then --- the big star streaks down-left for 40 frames while the studio logo --- sits centered in the letterbox band (the GAME FREAK logo + letter --- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228), --- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small --- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower --- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold --- (intro.asm:329-331). --- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played --- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG --- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left --- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted --- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap, --- retreat, crouch and final lunge, ending in a 24-frame fade to white --- (GBFadeOutToWhite, home/fade.asm:26-40). --- --- Any of A/B/START skips the whole movie (CheckForUserInterruption). --- Pops itself and calls onDone() when finished or skipped. All art loads --- through pcall and every missing graphic degrades to a text/rect --- fallback, so the movie stays headless-safe. +-- ..(engine/movie/intro.asm ln 8) +-- ..(engine/movie/splash.asm ln 27) local Font = require("src.render.Font") local Music = require("src.core.Music") @@ -76,15 +52,15 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209) local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331) --- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15 --- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets --- cancel) +-- ..(engine/movie/splash.asm ln 211) local LOGO_X, LOGO_Y = 72, 56 +local TEXT_X, TEXT_Y = 40, 80 + +-- ..(engine/movie/title.asm ln 390) +local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } +local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 } +local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 } --- The studio logo (assets/logo/minilogo.png) stands in for both the --- GAME FREAK logo and its letter row, so it gets the whole band between --- the letterbox bars (y 32..112) down to where the star waves spawn --- (y=88): fit it inside this box, centered, aspect preserved. local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 } -- the 4 waves of small stars: screen X positions, all spawning at y=88 @@ -155,10 +131,19 @@ function IntroMovie.new(game, onDone) self.studio = intro.studio or {} self.skipAll = intro.skip and true or false local function img(e) return tryImage(e and e.path) end - self.copyright = tryImage("assets/generated/title/copyright.png") - -- studio mark replaces the GAME FREAK logo + splash text entirely; the - -- extracted logo stays as the fallback if the asset is missing - self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png") + local titleCfg = game.data.field and game.data.field.title or {} + self.copyright = img(titleCfg.copyright) + or tryImage("assets/generated/title/copyright.png") + self.copyQuads = {} + if self.copyright then + local iw, ih = self.copyright:getDimensions() + for t = 0, 18 do + self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih) + end + end + self.gfInc = img(titleCfg.gamefreakInc) + or tryImage("assets/generated/title/gamefreak_inc.png") + self.studioLogo = tryImage(self.studio.logo) if self.studioLogo then self.studioLogo:setFilter("nearest", "nearest") local iw, ih = self.studioLogo:getDimensions() @@ -306,24 +291,12 @@ function IntroMovie:drawSplash() if self.studioLogo then love.graphics.draw(self.studioLogo, self.studioX, self.studioY, 0, self.studioScale, self.studioScale) - elseif self.logo then - love.graphics.draw(self.logo, LOGO_X, LOGO_Y) + else + if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end + if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end end love.graphics.setColor(1, 1, 1, 1) end - if t >= STAR_START and t < FLASH_START then - -- big star: from OAM (160,0) moving +4Y/-4X per frame - -- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60) - local n = t - STAR_START + 1 - local sx, sy = 152 - 4 * n, -16 + 4 * n - if self.bigStar then - love.graphics.draw(self.bigStar, sx, sy) - else - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4) - love.graphics.setColor(1, 1, 1, 1) - end - end if t >= WAVES_START then -- small stars: wave w spawns at y=88 every 24 frames, everything falls -- +1px per 3-frame substep until the wave loop ends; the lower star in @@ -351,6 +324,18 @@ function IntroMovie:drawSplash() end end drawBars() + if t >= STAR_START and t < FLASH_START then + -- ..(engine/movie/splash.asm ln 32) + local n = t - STAR_START + 1 + local sx, sy = 152 - 4 * n, -16 + 4 * n + if self.bigStar then + love.graphics.draw(self.bigStar, sx, sy) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + end + end end function IntroMovie:drawFight() @@ -379,17 +364,38 @@ function IntroMovie:drawFight() end end +function IntroMovie:drawCopyright() + if self.studio.card or self.studio.credit then + local card = self.studio.card or "" + local credit = self.studio.credit or "" + love.graphics.setColor(0, 0, 0, 1) + Font.draw(card, (160 - #card * 8) / 2, 64) + Font.draw(credit, (160 - #credit * 8) / 2, 80) + elseif self.copyright and self.gfInc then + local function row(seq, x, y) + for _, t in ipairs(seq) do + love.graphics.draw(self.copyright, self.copyQuads[t], x, y) + x = x + 8 + end + end + for _, y in ipairs({ 56, 72, 88 }) do row(COPY_PREFIX, 16, y) end + row(COPY_NINTENDO, 80, 56) + row(COPY_CREATURES, 80, 72) + love.graphics.draw(self.gfInc, 80, 88) + else + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("Nintendo"), 80, 56) + Font.draw(Strings("Creatures inc."), 80, 72) + Font.draw(Strings("GAME FREAK inc."), 16, 88) + end + love.graphics.setColor(1, 1, 1, 1) +end + function IntroMovie:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) if self.phase == 1 then - -- custom boot card (replaces the Nintendo / GAME FREAK copyright - -- card; no (c) glyph in the charmap, keep it ASCII-safe) - love.graphics.setColor(0, 0, 0, 1) - local credit = self.studio.credit or Strings("bois club") - Font.draw("2026", (160 - 4 * 8) / 2, 48) - Font.draw(credit, (160 - #credit * 8) / 2, 64) - Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) + self:drawCopyright() elseif self.phase == 2 then self:drawSplash() else diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index aec5c71a..0c366afd 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -103,7 +103,31 @@ local YELLOW_CYCLE_SPECIES = { "JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA", "GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE", } -local CYCLE_FRAMES = 240 -- the original waits ~4s between picks +-- ..(engine/movie/title.asm ln 227) +local HOLD_FRAMES = 200 +local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true } + +-- ..(engine/movie/title2.asm ln 13) +local function scrollFrames(steps, offset) + local frames = {} + for _, step in ipairs(steps) do + for _ = 1, step[2] do + frames[#frames + 1] = offset + offset = offset - step[1] + end + end + return frames +end +local OUT_FRAMES = scrollFrames( + { { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 }, + { 8, 3 }, { 9, 3 } }, 0) +local IN_FRAMES = scrollFrames( + { { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 }, + { 1, 1 } }, 120) + +-- ..(engine/movie/title2.asm ln 85) +local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 } +local BALL_REST = 100 local function tryImage(path) if not path then return nil end @@ -178,6 +202,27 @@ function TitleState.new(game, opts) or "assets/generated/title/red_version.png") self.player = tryImage(imagePath(title.player) or "assets/generated/title/player.png") + -- ..(engine/movie/title2.asm ln 85) + if self.player then + local pw, ph = self.player:getDimensions() + self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph) + self.playerQuads = { + { love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 }, + { love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 }, + { love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 }, + } + end + self.copyImg = tryImage(imagePath(title.copyright) + or "assets/generated/title/copyright.png") + self.copyQuads = {} + if self.copyImg then + local iw, ih = self.copyImg:getDimensions() + for t = 0, 18 do + self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih) + end + end + self.gfInc = tryImage(imagePath(title.gamefreakInc) + or "assets/generated/title/gamefreak_inc.png") self.blue = GameVersion.isBlue() self.yellow = GameVersion.isYellow() or title.layout == "yellow_pikachu" @@ -203,7 +248,10 @@ function TitleState.new(game, opts) self.blinkTimer = 0 self.blinkAt = nil else - self.phase = "loop" + -- ..(engine/movie/title.asm ln 28) + self.scy = 0x40 + self.phase = "drop" + self.dropStep, self.dropLeft = 1, nil self.showBubble = true end local defaultCycle = self.yellowLayout and { "PIKACHU" } @@ -216,14 +264,15 @@ function TitleState.new(game, opts) self.cycleIndex = 1 self.timer = 0 self.blink = 0 + self.scrollPhase = "hold" + self.scrollFrame = 1 + self.monOffset = 0 + self.ballY = BALL_REST return self end function TitleState:enter() - -- Yellow defers the title theme until after the logo drop and - -- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after - -- WaitForSoundToFinish on PikachuCry1) - if self.yellowLayout then return end + if self.phase ~= "loop" then return end self:startMusic() end @@ -240,6 +289,11 @@ end local DROP_STEPS = { { -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 }, } +local SETTLE_FRAMES = 36 + +-- ..(engine/movie/title.asm ln 201) +local RIBBON_FRAMES = {} +for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end -- the boot cinematic up to the interactive loop; one call per frame function TitleState:updateSequence() @@ -263,12 +317,23 @@ function TitleState:updateSequence() self.dropLeft = nil end elseif self.phase == "settle" then - -- ld c, 36 / DelayFrames, then the whoosh and the bubble self.timer = self.timer + 1 - if self.timer >= 36 then + if self.timer >= SETTLE_FRAMES then Sound.play(data, "Intro_Whoosh") self.showBubble = true - self.phase = "bubble" + self.phase = self.yellowLayout and "bubble" or "ribbon" + self.ribbonOffset = RIBBON_FRAMES[1] + self.timer = 0 + end + elseif self.phase == "ribbon" then + self.timer = self.timer + 1 + local offset = RIBBON_FRAMES[self.timer + 1] + if offset then + self.ribbonOffset = offset + else + self.ribbonOffset = nil + self:startMusic() + self.phase = "loop" self.timer = 0 end elseif self.phase == "bubble" then @@ -432,12 +497,63 @@ function TitleState:openMenu() game.stack:push(menu) end +-- ..(engine/movie/title.asm ln 271) +function TitleState:pickNewMon() + if #self.cycleSpecies < 2 then return end + local pick = self.cycleIndex + while pick == self.cycleIndex do + pick = love.math.random(1, #self.cycleSpecies) + end + self.cycleIndex = pick +end + +function TitleState:setCyclePhase(phase) + self.scrollPhase = phase + self.scrollFrame = 1 + self.timer = 0 + if phase == "in" then + self:pickNewMon() + self.monOffset = IN_FRAMES[1] + elseif phase == "out" then + self.monOffset = OUT_FRAMES[1] + elseif phase == "ball" then + self.ballY = BALL_FRAMES[1] + else + self.monOffset = 0 + end +end + +function TitleState:updateCycle() + local phase = self.scrollPhase + if phase == "hold" then + if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end + return + end + local frames = phase == "out" and OUT_FRAMES + or phase == "ball" and BALL_FRAMES or IN_FRAMES + self.scrollFrame = self.scrollFrame + 1 + local value = frames[self.scrollFrame] + if value then + if phase == "ball" then self.ballY = value else self.monOffset = value end + return + end + if phase == "out" then + -- ..(engine/movie/title.asm ln 235) + self:setCyclePhase( + STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in") + elseif phase == "ball" then + self:setCyclePhase("in") + else + self:setCyclePhase("hold") + end +end + function TitleState:update(dt) + if self.phase ~= "loop" then + self:updateSequence() + return + end if self.yellowLayout then - if self.phase ~= "loop" then - self:updateSequence() - return -- input is ignored until the cinematic lands (title.asm) - end self:updateBlink() local input = self.game.input if input:wasPressed("start") or input:wasPressed("a") then @@ -452,21 +568,7 @@ function TitleState:update(dt) end self.timer = self.timer + 1 self.blink = (self.blink + 1) % 60 - if not self.yellowLayout and self.timer >= CYCLE_FRAMES then - self.timer = 0 - -- random pick that never repeats the current one - if #self.cycleSpecies > 1 then - local pick = self.cycleIndex - while pick == self.cycleIndex do - pick = love.math.random(1, #self.cycleSpecies) - end - self.cycleIndex = pick - end - self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in - end - if self.slideIn and self.slideIn > 0 then - self.slideIn = self.slideIn - 1 - end + self:updateCycle() local input = self.game.input if input:wasPressed("start") or input:wasPressed("a") then -- the title mon cries when you leave the title (.finishedWaiting); @@ -478,15 +580,14 @@ function TitleState:update(dt) end end --- The original tilemap (engine/movie/title.asm): logo at tile (2,1), --- the version ribbon at (7,8), Red's title art as OAM at px (82,80), --- the title mon in the 7x7 box at tile (5,10), copyright on row 17. --- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu --- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM. +-- ..(engine/movie/title.asm ln 28) function TitleState:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) - local scrollY = self.yellowLayout and -(self.scy or 0) or 0 + local scrollY = -(self.scy or 0) + -- ..(engine/movie/title.asm ln 28) + local preRibbon = not self.yellowLayout + and (self.phase == "drop" or self.phase == "settle") if self.logo then love.graphics.draw(self.logo, 16, 8 + scrollY) else @@ -514,27 +615,29 @@ function TitleState:draw() -- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon -- (pokeyellow gfx/title/blue_version.png, unreferenced by title code); -- the Yellow fallback layout draws no ribbon at all. - if self.version and not self.yellow then + if self.version and not self.yellow and not preRibbon then local iw, ih = self.version:getDimensions() + local rx = self.ribbonOffset or 0 if self.versionFull then -- a continuous ribbon (versionRibbon) centers as one piece - love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64) + love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64) elseif self.blue then love.graphics.draw(self.version, - love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64) + love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64) else love.graphics.draw(self.version, - love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64) + love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64) love.graphics.draw(self.version, - love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64) + love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64) end end - local sprite, spriteTrueColor = self:currentSprite() + local sprite, spriteTrueColor + if self.scrollPhase ~= "ball" then + sprite, spriteTrueColor = self:currentSprite() + end if sprite then local w, h = sprite:getDimensions() - 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 x = 40 + math.floor((56 - w) / 2) + self.monOffset local y = 136 - h love.graphics.draw(sprite, x, y) -- a full-color mon keeps its own palette through the SGB pass, minus @@ -550,13 +653,33 @@ function TitleState:draw() end end -- Red is OAM in the original: he draws over the mon's box edge - if self.player then + if self.playerQuads then + for _, part in ipairs(self.playerQuads) do + love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3]) + end + love.graphics.draw(self.player, self.ballQuad, 82, self.ballY) + elseif self.player then love.graphics.draw(self.player, 82, 80) end end + self:drawCopyright(136 + (preRibbon and 0 or scrollY)) +end + +-- ..(engine/movie/title.asm ln 117) +local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 } + +function TitleState:drawCopyright(y) + if not self.title.copyrightText and self.copyImg and self.gfInc then + local x = 16 + for _, t in ipairs(COPY_PREFIX) do + love.graphics.draw(self.copyImg, self.copyQuads[t], x, y) + x = x + 8 + end + love.graphics.draw(self.gfInc, x, y) + return + end love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.title.copyrightText or Strings("2026 bois club games"), - 1, 136 + scrollY) + Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1ac89816..fe8ab8d1 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -451,8 +451,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts) local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce self.keepMusicOnce = nil if not keepMusic then - require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, - self.player.surfing) + -- ..(home/overworld.asm ln 2346) + local Music = require("src.core.Music") + Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, + Music.MAP_FADE) end -- forced bike/surf tiles fire the moment the player is placed on the @@ -1093,8 +1095,10 @@ function OverworldState:update(dt) local mapId = self.pendingSeamMusic self.pendingSeamMusic = nil if mapId == self.map.id then - require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, - self.player.surfing) + -- ..(home/overworld.asm ln 677) + local Music = require("src.core.Music") + Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, + Music.MAP_FADE) end end if stepped and not scripted then diff --git a/tests/drivers/title_cycle_test.lua b/tests/drivers/title_cycle_test.lua new file mode 100644 index 00000000..82f68d23 --- /dev/null +++ b/tests/drivers/title_cycle_test.lua @@ -0,0 +1,66 @@ +-- ..(engine/movie/title.asm ln 28) +-- ..(engine/movie/title2.asm ln 13) +-- POKEPORT_DRIVER=tests/drivers/title_cycle_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local shot = 0 + local function grab(tag) + shot = shot + 1 + U.shot(game, ("%s/title_%02d_%s.png"):format(DIR, shot, tag)) + end + + U.wait(30) + grab("copyright") + -- ..(engine/movie/splash.asm ln 230) + local movie = game.stack:top() + while movie.phase ~= 2 or movie.timer < 70 do U.wait(1) end + grab("star_topbar") + while movie.timer < 88 do U.wait(1) end + grab("star_middle") + while movie.timer < 100 do U.wait(1) end + grab("star_lowbar") + while movie.timer < 130 do U.wait(1) end + grab("gamefreak") + + U.tap(game, "start") + U.wait(2) + local title = game.stack:top() + U.log("top is", tostring(title and title.screenId)) + if not (title and title.scrollPhase) then + U.log("no TitleState on top; nothing below can run") + while true do coroutine.yield() end + end + + grab("drop_early") + U.wait(14) + grab("drop_late") + while title.phase == "drop" do U.wait(1) end + grab("settle") + while title.phase == "settle" do U.wait(1) end + grab("ribbon_start") + U.wait(10) + U.shot(game, DIR .. "/title_ribbon_mid.png") + while title.phase ~= "loop" do U.wait(1) end + grab("landed") + + title.cycleIndex = 1 + title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0 + title.monOffset = 0 + while title.scrollPhase == "hold" do U.wait(1) end + grab("out_a") + U.wait(6) + grab("out_b") + while title.scrollPhase == "out" do U.wait(1) end + U.log("after the scroll out the phase is", title.scrollPhase) + for _ = 1, 5 do + grab("ball") + U.wait(1) + end + while title.scrollPhase == "ball" do U.wait(1) end + grab("in") + U.wait(30) + grab("next_mon") + U.log("captured", DIR) + while true do coroutine.yield() end +end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 52a34344..33983c8a 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -65,8 +65,12 @@ function U.newGame(game) U.wait(5) U.tap(game, "start") -- skip intro movie U.wait(10) - U.tap(game, "a") -- title -> menu - U.wait(5) + local title = game.stack:top() + for _ = 1, 60 do + U.tap(game, "a") + U.wait(5) + if game.stack:top() ~= title then break end + end -- menu: CONTINUE may or may not exist; NEW GAME is first without a save U.tap(game, "a") U.wait(10) diff --git a/tests/engine/chip_analog_path.lua b/tests/engine/chip_analog_path.lua new file mode 100644 index 00000000..e974c266 --- /dev/null +++ b/tests/engine/chip_analog_path.lua @@ -0,0 +1,103 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local data = { audio = {} } + +local function pulseSong() + return ChipAsm.song{ + channels = { { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 15, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 15 }, + } } }, + } +end + +local function noiseSong() + return ChipAsm.sfx{ + channels = { { hw = 4, program = { + { noiseNote = { len = 8, volume = 15, fade = 1, parameter = 0x34 } }, + } } }, + } +end + +do + local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false }) + local sawNeg, sawPos = false, false + for _ = 1, 512 do + local v = engine.channels[1]:sample() + if v < -1e-12 then sawNeg = true end + if v > 1e-12 then sawPos = true end + end + check(sawPos and not sawNeg, + "pulse DAC is unipolar (high = volume, low = 0)") +end + +do + local engine = ChipSynth.newEngine(data, noiseSong(), { + sfx = true, allowLoops = false, + }) + local sawNeg, sawPos = false, false + for _ = 1, 2048 do + local v = engine.channels[1]:sample() + if v < -1e-12 then sawNeg = true end + if v > 1e-12 then sawPos = true end + end + check(sawPos and not sawNeg, + "noise DAC is unipolar (LFSR high = volume, low = 0)") +end + +local function crossingsAndSign(engine, frames) + local count, prev = 0, nil + local sawNeg, sawPos = false, false + for _ = 1, frames do + local sample = engine:sample() + if sample < -1e-12 then sawNeg = true end + if sample > 1e-12 then sawPos = true end + if prev and prev * sample < 0 then count = count + 1 end + prev = sample + end + return count, sawNeg, sawPos +end + +do + local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false }) + local count, sawNeg, sawPos = crossingsAndSign(engine, 4000) + check(sawNeg and sawPos, "HPF centers a unipolar pulse around analog 0") + check(count > 20, ("HPF'd pulse crosses zero (%d crossings)"):format(count)) +end + +do + local engine = ChipSynth.newEngine(data, noiseSong(), { + sfx = true, allowLoops = false, + }) + local count, sawNeg, sawPos = crossingsAndSign(engine, 8000) + check(sawNeg and sawPos, "HPF centers noise / drums around analog 0") + check(count > 50, ("HPF'd noise crosses zero (%d crossings)"):format(count)) +end + +do + local song = pulseSong() + ChipSynth.setChannelVolumes({ 1, 1, 1, 1 }) + local a = ChipSynth.newEngine(data, song, { allowLoops = false }) + local base = a.channels[1]:sample() + ChipSynth.setChannelVolume(1, 0.25) + local b = ChipSynth.newEngine(data, song, { allowLoops = false }) + local quarter = b.channels[1]:sample() + ChipSynth.setChannelVolumes({ 1, 1, 1, 1 }) + check(base > 0 and math.abs(quarter - base * 0.25) < 1e-9, + "channelVolume still quarters the unipolar DAC level") +end + +eq(type(ChipSynth.newEngine), "function", "engine factory still exported") + +T.finish("chip analog path") diff --git a/tests/engine/drum_envelope_ring.lua b/tests/engine/drum_envelope_ring.lua new file mode 100644 index 00000000..120f55f6 --- /dev/null +++ b/tests/engine/drum_envelope_ring.lua @@ -0,0 +1,82 @@ +-- ..(audio/engine_1.asm ln 197) +-- ..(audio/sfx/noise_instrument01_1.asm ln 1) +-- luajit tests/engine/drum_envelope_ring.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") + +local snare = ChipAsm.song{ + channels = { + { hw = 4, program = { + { notetype = { speed = 12 } }, + { drum = 1, len = 2 }, + { rest = 16 }, + } }, + }, + drums = { + [1] = { + { len = 1, volume = 12, fade = 1, parameter = 0x33 }, + }, + }, +} + +local engine = ChipSynth.newEngine({ audio = {} }, snare, { allowLoops = false }) +local segs = engine:noiseInstrument(1) +local last = segs[#segs] +local ringMs = (last.endSample - last.startSample) / ChipSynth.SAMPLE_RATE * 1000 +check(ringMs > 150 and ringMs < 220, + ("snare instrument rings ~188ms, not the 17ms note (%0.1fms)"):format(ringMs)) + +local energyEarly, energyLate, energyEnd = 0, 0, 0 +local total = math.floor(ChipSynth.SAMPLE_RATE * 0.25) +for i = 1, total do + local s = engine:sample() + local e = s * s + local t = i / ChipSynth.SAMPLE_RATE + if t < 0.02 then + energyEarly = energyEarly + e + elseif t > 0.05 and t < 0.12 then + energyLate = energyLate + e + elseif t > 0.20 then + energyEnd = energyEnd + e + end +end +check(energyEarly > 0, "snare attack is audible") +check(energyLate > energyEarly * 0.05, + ("snare body still sounds at 50-120ms (early=%.4f late=%.4f)") + :format(energyEarly, energyLate)) +check(energyEnd < energyLate * 0.1, + "snare has decayed by 200ms") + +local hats = ChipAsm.song{ + channels = { + { hw = 4, program = { + { notetype = { speed = 12 } }, + { drum = 1, len = 2 }, + { drum = 1, len = 2 }, + } }, + }, + drums = { + [1] = { + { len = 1, volume = 8, fade = 1, parameter = 0x10 }, + }, + }, +} +local hatEngine = ChipSynth.newEngine({ audio = {} }, hats, { allowLoops = false }) +local hits = 0 +local prev = 0 +for _ = 1, math.floor(ChipSynth.SAMPLE_RATE * 0.3) do + local s = math.abs(hatEngine:sample()) + if prev < 0.01 and s >= 0.01 then hits = hits + 1 end + prev = s +end +check(hits >= 2, ("two rapid drum_notes both trigger (%d onsets)"):format(hits)) + +T.finish("drum envelope ring") diff --git a/tests/engine/map_music_fade.lua b/tests/engine/map_music_fade.lua new file mode 100644 index 00000000..0ab6bab2 --- /dev/null +++ b/tests/engine/map_music_fade.lua @@ -0,0 +1,94 @@ +-- ..(home/audio.asm ln 9) +-- ..(home/fade_audio.asm ln 36) +-- luajit tests/engine/map_music_fade.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end +function Source:getDuration() return 1 end + +local made = {} -- file -> the last source built for it +love.audio = { + newSource = function(file, mode) + made[file] = setmetatable({ file = file, mode = mode }, Source) + return made[file] + end, +} + +local Music = require("src.core.Music") + +local data = { audio = { + songs = { + Music_Pallet = { file = "pallet.wav" }, + Music_Routes1 = { file = "routes1.wav" }, + Music_Pewter = { file = "pewter.wav" }, + }, + mapSongs = { + PALLET_TOWN = "Music_Pallet", + ROUTE_1 = "Music_Routes1", + PEWTER_CITY = "Music_Pewter", + }, +} } + +local function frames(n) + for _ = 1, n do Music.update(data) end +end + +local function playing() + for file, src in pairs(made) do + if src.playing then return file end + end + return "(silence)" +end + +local FADE = 7 * Music.MAP_FADE -- 7 volume levels x 10 frames + +Music.stop() +Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE) +eq(playing(), "pallet.wav", "the first map after boot starts at once") + +local fullVolume = made["pallet.wav"].volume + +Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE) +eq(playing(), "pallet.wav", "the new theme waits while the old one fades") +frames(FADE - 1) +eq(playing(), "pallet.wav", "still fading one frame short of silence") +check(made["pallet.wav"].volume < fullVolume, + "the old theme has been ramped down by then") +frames(1) +eq(playing(), "routes1.wav", "the queued theme takes over after 7 * 10 frames") + +eq(made["routes1.wav"].volume, fullVolume, + "the new theme starts at full volume, not where the ramp ended") + +Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE) +eq(playing(), "routes1.wav", "the same theme keeps playing") +frames(FADE) +eq(playing(), "routes1.wav", "and no fade was armed for it") + +Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE) +frames(3 * Music.MAP_FADE) +Music.playMap(data, "PEWTER_CITY", false, false, Music.MAP_FADE) +eq(playing(), "routes1.wav", "the retargeted fade keeps ramping the old theme") +frames(4 * Music.MAP_FADE) +eq(playing(), "pewter.wav", "the ramp lands on the newest map's theme") + +Music.playMap(data, "PALLET_TOWN", false, false) +eq(playing(), "pallet.wav", "a fadeless map cue swaps immediately") + +T.finish("map_music_fade") diff --git a/tests/engine/nx_yellow_boot_test.lua b/tests/engine/nx_yellow_boot_test.lua index 764198ec..61d903e7 100644 --- a/tests/engine/nx_yellow_boot_test.lua +++ b/tests/engine/nx_yellow_boot_test.lua @@ -37,7 +37,7 @@ end local Y_TITLE = { "pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png", - "player.png", "copyright.png", "yellow_version.png", + "player.png", "copyright.png", "gamefreak_inc.png", "yellow_version.png", } for _, name in ipairs(Y_TITLE) do seed("yellow/assets/generated/title/" .. name) @@ -266,6 +266,7 @@ GameVersion.set("blue") seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes") seed("blue/assets/generated/title/player.png") seed("blue/assets/generated/title/copyright.png") +seed("blue/assets/generated/title/gamefreak_inc.png") local B_INTRO = { "gf_logo.png", "gf_text.png", "big_star.png", "falling_star.png", "falling_star_blink.png", "studio_logo.png", diff --git a/tests/engine/title_mon_cycle.lua b/tests/engine/title_mon_cycle.lua new file mode 100644 index 00000000..8b9c9039 --- /dev/null +++ b/tests/engine/title_mon_cycle.lua @@ -0,0 +1,109 @@ +-- ..(engine/movie/title.asm ln 227) +-- ..(engine/movie/title2.asm ln 13) +-- luajit tests/engine/title_mon_cycle.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = require("tests.love_stub") +love.math = love.math or {} +local nextPick = 1 +love.math.random = function(lo, hi) + nextPick = nextPick % hi + 1 + return math.max(lo, nextPick) +end + +local TitleState = require("src.ui.TitleState") + +local title = TitleState.new( + { data = {}, input = { wasPressed = function() return false end } }, {}) +title.sprites = setmetatable({}, { __index = function() return false end }) + +eq(title.phase, "drop", "Red/Blue boot into the logo drop, not the loop") +local ribbonSeen = {} +for _ = 1, 400 do + if title.phase == "loop" then break end + title:update(1 / 60) + if title.phase == "ribbon" then + ribbonSeen[#ribbonSeen + 1] = title.ribbonOffset + end +end +eq(title.phase, "loop", "the cinematic lands within 400 frames") +eq(ribbonSeen[1], 112, + "the ribbon is parked off the right edge on its first drawn frame") +eq(ribbonSeen[#ribbonSeen], 4, "and walks in 4px a frame to its rest") + +title.cycleIndex = 1 -- CHARMANDER: a starter, so the ball juggle runs +title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0 +title.monOffset = 0 + +local frames = {} +for _ = 1, 260 do + title:update(1 / 60) + frames[#frames + 1] = { + phase = title.scrollPhase, offset = title.monOffset, + ball = title.ballY, mon = title.cycleSpecies[title.cycleIndex], + } +end + +local HOLD_FRAMES = 200 + +local function span(phase) + local first, count = nil, 0 + for i, f in ipairs(frames) do + if f.phase == phase then + if not first then first = i end + if first + count == i then count = count + 1 end + end + end + return first, count +end + +local holdAt, holdLen = span("hold") +local outAt, outLen = span("out") +local ballAt, ballLen = span("ball") +local inAt, inLen = span("in") +eq(holdAt, 1, "the cycle opens on the hold") +eq(holdLen, HOLD_FRAMES - 1, "ld c, 200 / CheckForUserInterruption") +eq(outAt, HOLD_FRAMES, "the scroll out begins as the 200th hold frame ends") +eq(outLen, 18, "TitleScroll_Out is 2+2+2+2+2+2+3+3 frames") +eq(ballLen, 10, "TitleScroll_WaitBall is two runs of 5") +eq(inLen, 17, "TitleScroll_In is 2+4+4+3+2+1+1 frames") +check(outAt < ballAt and ballAt < inAt, "out, then the ball, then in") + +local OUT = { 0, -1, -2, -4, -6, -9, -12, -16, -20, -25, -30, -36, -42, + -50, -58, -66, -75, -84 } +for i, want in ipairs(OUT) do + eq(frames[outAt + i - 1].offset, want, + "TitleScroll_Out offset at frame " .. i) +end + +local IN = { 120, 110, 100, 91, 82, 73, 64, 56, 48, 40, 32, 26, 20, 14, 9, + 4, 1 } +for i, want in ipairs(IN) do + eq(frames[inAt + i - 1].offset, want, "TitleScroll_In offset at frame " .. i) +end + +local BALL = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 } +for i, want in ipairs(BALL) do + eq(frames[ballAt + i - 1].ball, want, "TitleBallYTable entry " .. i) +end + +local outgoing = frames[outAt].mon +eq(outgoing, "CHARMANDER", "the starter is the one that scrolls out") +for i = outAt, inAt - 1 do + eq(frames[i].mon, outgoing, + "the pick does not change before the scroll in, at frame " .. i) +end +local incoming = frames[inAt].mon +check(incoming ~= outgoing, "TitleScreenPickNewMon never repeats the pick") +for i = inAt, inAt + inLen - 1 do + check(frames[i].offset > 0, + "the incoming mon is only ever drawn right of rest, at frame " .. i) +end +eq(frames[inAt + inLen].offset, 0, "and settles at its resting column") + +T.finish("title_mon_cycle") diff --git a/tests/parity_true_color_ui.lua b/tests/parity_true_color_ui.lua index 415f470a..0bfa5015 100644 --- a/tests/parity_true_color_ui.lua +++ b/tests/parity_true_color_ui.lua @@ -93,6 +93,7 @@ local titleGame = { field = { title = { cycleSpecies = { "PIKACHU" } } } }, } local title = TitleState.new(titleGame, {}) +title.phase, title.scy = "loop", 0 local titleSprite, titleTrueColor = title:currentSprite() check(titleSprite and titleTrueColor, "title cache keeps a Pokemon sprite's trueColor flag")