title screen issues, audio issues, and replacing gf c

This commit is contained in:
bryanthaboi
2026-08-10 14:00:26 -04:00
parent 943ba5dcbf
commit 12c2677dc2
14 changed files with 830 additions and 181 deletions
+66
View File
@@ -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
+6 -2
View File
@@ -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)
+103
View File
@@ -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")
+82
View File
@@ -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")
+94
View File
@@ -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")
+2 -1
View File
@@ -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",
+109
View File
@@ -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")
+1
View File
@@ -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")