Merge branch 'fix1037' into fix1038

# Conflicts:
#	docs/modding.md
This commit is contained in:
bryanthaboi
2026-08-10 14:12:21 -04:00
46 changed files with 1851 additions and 285 deletions
+4 -2
View File
@@ -195,7 +195,7 @@ return function(game)
ow:queueScript(slice, { npc = rival })
local startY = ow.player.cellY
local minY, walkShot = startY, false
local minY, walkShot, sharedCell = startY, false, false
local hof
for i = 1, 5000 do
local top = game.stack:top()
@@ -205,8 +205,9 @@ return function(game)
end
local w = game.overworld
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
local y = w.player.cellY
local x, y = w.player.cellX, w.player.cellY
if y < minY then minY = y end
if x == rival.cellX and y == rival.cellY then sharedCell = true end
if y <= 2 and not walkShot then
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
end
@@ -215,6 +216,7 @@ return function(game)
end
check("the player walked out of the room before the warp (#704)",
minY < startY)
check("the player routed around the rival", not sharedCell)
check("walk-out screenshot", walkShot)
check("the induction started", hof ~= nil)
if not hof then
+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",
+48
View File
@@ -0,0 +1,48 @@
-- Stable ListMenu identities for screen.render_visible and companion UIs.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local BoxMenu = require("src.ui.BoxMenu")
local ListMenu = require("src.ui.ListMenu")
local PlayerPC = require("src.ui.PlayerPC")
local Boxes = require("src.pokemon.Boxes")
local pushed
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, state) pushed = state end },
}
local species = T.fixtures.ids.species[1]
Boxes.ensure(game.save)[1][1] = { species = species, level = 5 }
game.save.party[1] = { species = species, level = 5 }
game.save.party[2] = { species = species, level = 6 }
game.save.pcItems = { FIX_POTION = 2 }
game.save.inventory.FIX_POTION = 2
local generic = ListMenu.new(game, "VISIBLE TITLE", {}, {})
T.eq(generic.kind, "VISIBLE TITLE", "generic lists fall back to their title")
local explicit = ListMenu.new(game, "Localized title", {}, { kind = "stable_id" })
T.eq(explicit.kind, "stable_id", "explicit list kind is preserved")
local box = BoxMenu.new(game)
for i, kind in ipairs({ "pc_box_withdraw", "pc_box_deposit",
"pc_box_release", "pc_box_change" }) do
pushed = nil
box.items[i].onSelect()
T.eq(pushed and pushed.kind, kind, kind .. " is stable")
end
local items = PlayerPC.new(game)
for i, kind in ipairs({ "pc_item_withdraw", "pc_item_deposit",
"pc_item_toss" }) do
pushed = nil
items.items[i].onSelect()
T.eq(pushed and pushed.kind, kind, kind .. " is stable")
end
T.finish("pc_list_kinds")
+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")
+21
View File
@@ -40,6 +40,27 @@ T.eq(Font.advanceOf(0x80), 8, "and the pen stays 8px monospace")
-- ------------------------------------------------- the ttf takes over
-- Font rasterizers must use the same 1x pixel grid as PixelCanvas, rather
-- than inheriting Android's window density. Keep this local fake so the
-- contract is testable without requiring a real LÖVE window.
do
local g, oldNewFont = love.graphics, love.graphics.newFont
local args
g.newFont = function(...)
args = { ... }
return oldNewFont(Font.PLAINPIXEL, Font.PLAINPIXEL_SIZE)
end
local loaded, err = pcall(Font.load, {
font = { charmap = CHARMAP, ttf = { file = "custom.ttf", size = 13 } },
})
g.newFont = oldNewFont
if not loaded then error(err, 0) end
T.eq(args[1], "custom.ttf", "rasterizer uses the configured file")
T.eq(args[2], 13, "rasterizer uses the configured size")
T.eq(args[3], "mono", "rasterizer keeps the pixel hinting mode")
T.eq(args[4], 1, "rasterizer stays on the pixel canvas scale")
end
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
T.check(Font.ttfActive(), "an empty ttf table loads the bundled font")
+43 -7
View File
@@ -5,8 +5,9 @@ local Assets = require("src.render.Assets")
local WorldAPI = require("src.world.WorldAPI")
Assets.imageData = function()
return { getPixel = function(_, x)
local shade = x < 8 and 1 or 0
return { getPixel = function(_, x, y)
local shade = x >= 8 and 0 or ({ 1, 0, 2 / 3, 1 / 3 })[
math.floor(y / 4) * 2 + math.floor(x / 4) + 1]
return shade, shade, shade, 1
end }
end
@@ -16,15 +17,34 @@ local overview, err = api:mapOverview()
T.eq(overview, nil, "map overview is unavailable outside the overworld")
T.eq(err, "no overworld", "map overview reports why it is unavailable")
local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 }
local map = {
id = "TEST_MAP", widthCells = 2, heightCells = 2,
def = {
warps = { { x = 1, y = 0 } },
objects = { { index = 1, x = 0, y = 1, item = "POTION" } },
},
}
function map:isWarpTileCell(x, y) return x == 1 and y == 0 end
function map:isWaterCell(x, y) return x == 0 and y == 1 end
function map:isWalkableCell(x, y) return x == 0 and y == 0 end
function map:tileAt(x) return x % 2 end
api = WorldAPI.new({ stack = { states = {
{ isOverworld = true, map = map },
} } }, "tester")
local save = {}
local world = {
isOverworld = true,
map = map,
objectVisible = function(s, mapId, obj)
return not (s.itemsTaken and s.itemsTaken[mapId .. "_obj_" .. obj.index])
end,
}
local game = {
save = save,
data = { field = { hiddenItems = {
TEST_MAP = { { x = 1, y = 1, item = "NUGGET" } },
} } },
stack = { states = { world } },
}
api = WorldAPI.new(game, "tester")
overview = api:mapOverview()
T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map")
T.eq(overview.width, 2, "map overview reports its width")
@@ -32,11 +52,27 @@ T.eq(overview.height, 2, "map overview reports its height")
T.eq(overview.rows[1], ".+", "walkable land and warps are distinct")
T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct")
T.eq(overview.tileRows, nil, "tile overview is optional")
T.eq(#overview.markers, 3, "active exits and untaken items are marked")
T.eq(overview.markers[1].kind, "warp", "warp marker is semantic")
T.eq(overview.markers[2].kind, "item", "visible item marker is semantic")
T.eq(overview.markers[3].kind, "hidden", "hidden item marker is semantic")
save.itemsTaken = { TEST_MAP_obj_1 = true }
save.hiddenTaken = { TEST_MAP_1_1 = true }
overview = api:mapOverview()
T.eq(#overview.markers, 1, "collected items disappear from the overview")
T.eq(overview.markers[1].kind, "warp", "exits remain after collecting items")
map.tileset = { image = "test.png", tilesPerRow = 2 }
overview = api:mapOverview()
T.eq(overview.tileWidth, 4, "tile overview reports its width")
T.eq(overview.tileHeight, 4, "tile overview reports its height")
T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading")
T.eq(overview.tileRows[1], "2323", "tile overview preserves average shading")
T.eq(overview.tileDetailWidth, 8, "detail overview reports its width")
T.eq(overview.tileDetailHeight, 8, "detail overview reports its height")
T.eq(overview.tileDetailRows[1], "03330333",
"detail overview preserves top tile quadrants")
T.eq(overview.tileDetailRows[2], "12331233",
"detail overview preserves bottom tile quadrants")
T.finish("world map overview")
+47
View File
@@ -428,16 +428,42 @@ local spriteReg = Registry.new("sprites", Schemas.REGISTRIES.sprites)
spriteReg:register("SPRITE_TITLE_LOGO",
{ image = "mods/logo/logo.png", frames = 1,
trueColor = true }, "logo_mod")
spriteReg:register("SPRITE_LARGE_ACTOR",
{ image = "mods/actor/actor.png", frames = 6,
walker = true, frameWidth = 32, frameHeight = 24,
anchorX = 16, anchorY = 24, trueColor = true },
"actor_mod")
local logoDef = spriteReg:get("SPRITE_TITLE_LOGO")
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO",
logoDef, "register"),
"a trueColor sprites record validates against the catalog schema")
check(logoDef.trueColor == true, "and keeps the flag through the merge")
local largeDef = spriteReg:get("SPRITE_LARGE_ACTOR")
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_LARGE_ACTOR",
largeDef, "register"),
"a variable-size sprites record validates against the catalog schema")
Renderer:init()
local plainSprite = SpriteRenderer.new(
{ image = "assets/generated/sprites/red.png", frames = 1 })
local litSprite = SpriteRenderer.new(logoDef)
local largeSprite = SpriteRenderer.new(largeDef)
check(plainSprite.frameWidth == 16 and plainSprite.frameHeight == 16
and plainSprite.anchorX == 8 and plainSprite.anchorY == 16,
"legacy sprite definitions keep the vanilla frame geometry")
local frameGeometry = largeSprite:getFrameGeometry(5)
check(frameGeometry.frame == 5 and frameGeometry.x == 0
and frameGeometry.y == 120 and frameGeometry.width == 32
and frameGeometry.height == 24 and frameGeometry.anchorX == 16
and frameGeometry.anchorY == 24,
"frame geometry exposes a larger sheet rectangle and anchor")
local poseGeometry = largeSprite:getPoseGeometry("right", 1, true)
check(poseGeometry.frame == 5 and poseGeometry.mirror == true
and poseGeometry.quad == largeSprite.frames[5],
"pose geometry follows walker frame selection and right mirroring")
local originX, originY = largeSprite:getScreenOrigin(32, 32, 0, 0)
check(originX == 24 and originY == 20,
"a custom anchor keeps a larger sprite grounded at its cell")
Renderer:beginFrame(true)
check(#PaletteFX.trueColorRects("ui") == 0
@@ -471,6 +497,27 @@ check(#worldDrawn == 2, "the reported zone joins the world list endFrame blits")
check(worldDrawn[1].shader and worldDrawn[2].shader == false,
"the colorized pass runs first, then the sprite's rect with no shader")
-- Larger true-color frames claim their actual extent, and fishing's top-half
-- path reserves only the bottom 8-pixel tile for the overlay.
Renderer:beginFrame(true)
Renderer:beginWorldPass()
largeSprite:draw(32, 32, 0, 0, "down", 0, false)
local largeRects = PaletteFX.trueColorRects("world")
check(#largeRects == 1 and largeRects[1].x == 24 and largeRects[1].y == 20
and largeRects[1].w == 32 and largeRects[1].h == 24,
"a larger trueColor sprite reports its full anchored extent")
Renderer:endWorldPass()
Renderer:beginFrame(true)
Renderer:beginWorldPass()
largeSprite:draw(32, 32, 0, 0, "down", 0, false, true)
local topRects = PaletteFX.trueColorRects("world")
check(#topRects == 1 and topRects[1].h == 16
and largeSprite.halfFrames[0].y == 0
and largeSprite.halfFrames[0].h == 16,
"the fishing overlay keeps a larger frame's bottom tile clear")
Renderer:endWorldPass()
-- the same path on the UI canvas, which is where a full-color title logo
-- or menu portrait lands
Renderer:beginFrame(false)
+32
View File
@@ -271,6 +271,38 @@ do
"and nothing is disabled off a failed migration")
end
-- ------- force_enable_env: an env var can override a saved disable
-- (src/mods/Loader.lua's enable-resolution block, added for a mod that
-- cannot function disabled on the one build where its env var is set --
-- e.g. a platform-launcher bridge mod).
do
local forceFiles = {
["options.lua"] = "return { mods = { forced = false } }",
["mods/forced/manifest.json"] =
[[{"id":"forced","name":"forced","version":"1.0.0","entry":"main.lua",]]
.. [["force_enable_env":"SOME_TEST_ENV"}]],
["mods/forced/main.lua"] = "return function(mod) end",
}
local realGetenv = os.getenv
os.getenv = function(name)
if name == "SOME_TEST_ENV" then return "1" end
return realGetenv(name)
end
local onLoader = Loader.new({ fs = memfs(forceFiles) })
check(onLoader:load({ pokemon = {} }) == true,
"force_enable_env: load succeeds with the env var set")
check(onLoader.mods.forced.enabled == true,
"a matching force_enable_env re-enables a mod saved as disabled")
os.getenv = realGetenv
local offLoader = Loader.new({ fs = memfs(forceFiles) })
check(offLoader:load({ pokemon = {} }) == true,
"force_enable_env: load succeeds with the env var unset")
check(offLoader.mods.forced.enabled == false,
"with the env var unset, the saved disable is left alone")
end
-- leave shared singletons the way we found them for later chained tests
local StateStack = require("src.core.StateStack")
while StateStack:top() do StateStack:pop() end
+2
View File
@@ -105,6 +105,7 @@ local full = Manifest.validate({
api = 2, profile = "overhaul", permissions = { "network" },
dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" },
options_schema = "options.lua", assets_transforms = "transforms.lua",
force_enable_env = "SOME_ENV",
}, "mods/full")
check(full.api == 2 and full.profile == "overhaul", "api and profile parse")
check(full.affects_link == true, "overhaul defaults to affecting link play")
@@ -115,6 +116,7 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range
"a bare conflict entry has no range")
check(full.options_schema == "options.lua"
and full.assets_transforms == "transforms.lua", "declared files are kept")
check(full.force_enable_env == "SOME_ENV", "force_enable_env is kept")
-- ------- github / experimental / incompatible
local gh = Manifest.validate({
+93
View File
@@ -823,6 +823,29 @@ do
and caught.enemy.mon.species == "TANGELA",
"and it is the species the authored encounter table names")
-- Trainer battles do not arm the wild-encounter cooldown.
walker.wildEncounterGraceSteps = 0
walker:afterBattle("win", { kind = "trainer" })
check(walker.wildEncounterGraceSteps == 0,
"trainer battles do not start the wild encounter grace period")
-- pokered grants three completed steps after a wild battle before the
-- next random battle can start (end_of_battle.asm + home/overworld.asm).
local finishedWild = caught
walker:afterBattle("run", finishedWild)
caught = nil
withBuses(function(_, hooks)
hooks:wrap("encounter.roll", function()
return { species = "TANGELA", level = 5 }
end, 0, "grace-period")
for step = 1, 3 do
pcall(walker.onStepComplete, walker)
check(caught == nil, "wild encounter grace period blocks step " .. step)
end
pcall(walker.onStepComplete, walker)
check(caught ~= nil, "wild encounter is eligible on step 4")
end)
-- the same walk with an encounter.roll wrapper never starts a battle
withBuses(function(_, hooks)
hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke")
@@ -893,6 +916,76 @@ do
check(value == nil and err == "no overworld", "npc() off the world")
value, err = api:queueScript({})
check(value == nil and err == "no overworld", "queueScript() off the world")
value, err = api:startWildBattle("PIDGEY", 5)
check(value == nil and err == "no overworld", "startWildBattle() off the world")
end
-- startWildBattle: what regresses is the handoff, not the battle. A mod that
-- builds a BattleState and pushes it itself still fights and still levels; it
-- silently loses onFinish -> afterBattle (evolutions, blackout-on-loss) and
-- pushBattle (entry wipe, battle theme). Shipped mods have hit exactly this.
do
-- the real dataset, not fixture(): a battle reaches for type_chart, items,
-- battle_anims and more, and this block only reads
local data = Data
local state, game = liveWorld(data)
-- the handoff runs through these three, so each needs the live game
for _, fn in ipairs({ "pushBattle", "isDungeonTransitionMap", "afterBattle" }) do
check(bindGame(OW[fn], game), fn .. " binds Game")
end
state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" })
local Pokemon = require("src.pokemon.Pokemon")
local api = WorldAPI.new(game, "tester")
local value, err = api:startWildBattle("NOT_A_MON", 5)
check(value == nil and err:find("unknown species", 1, true),
"an unknown species refuses and names it")
-- Pokemon.new writes the level through into the stat calc and the exp curve
-- verbatim, so a fraction has to be refused rather than rounded downstream
for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do
check(api:startWildBattle("PIDGEY", lv) == nil,
"level " .. tostring(lv) .. " refuses")
end
local caterpie = Pokemon.new(data, "CATERPIE", 6)
game.save.party = { caterpie }
check(api:startWildBattle("PIDGEY", 25) == true, "a wild battle starts")
-- pushBattle pushes the entry transition, which pushes the battle from its
-- own callback; awardExp is the BattleState marker (screenId would not work,
-- only Screens.push stamps that and pushBattle pushes the battle directly)
check(game.stack:top() ~= nil and game.stack:top().awardExp == nil,
"the entry transition goes on first")
-- overworld() resolves the world from UNDER the battle, so a second call
-- while one is up has to refuse rather than stack another
check(api:startWildBattle("PIDGEY", 5) == nil,
"a battle already running refuses")
local battle
for _ = 1, 400 do
local t = game.stack:top()
if t and t.awardExp then battle = t break end
if t and t.update then t:update(1 / 60) else break end
end
check(battle ~= nil, "the transition hands off to the battle")
battle.participants = { [caterpie] = true }
battle:awardExp()
check(caterpie.level >= 7, "the mon levels past its evolution threshold")
check(battle.leveledUp and battle.leveledUp[caterpie],
"awardExp records the level-up for EvolveAfterBattle")
game.stack:pop()
battle.onFinish("win")
for _ = 1, 12 do
local t = game.stack:top()
if not t or t.screenId == "EvolutionState" then break end
game.stack:pop()
if t.onDone then t.onDone() end
end
check(game.stack:top() and game.stack:top().screenId == "EvolutionState",
"the win reaches the evolution screen")
end
do
@@ -0,0 +1,93 @@
-- core.update / core.quit_to_launcher through the public mod API: a
-- platform-launcher integration can pause the simulation and veto the
-- return-to-launcher decision from a mod, with no main.lua patch.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local PlatformHooks = require("src.core.PlatformHooks")
local FIXTURE = {
["mods/fix_platform_bridge/manifest.json"] = [[{
"id": "fix_platform_bridge",
"name": "Fixture Platform Bridge",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_platform_bridge/main.lua"] = [[
local mod = ...
local paused = false
local extraPolls = 0
mod.hooks:wrap("core.update", function(nextFn, game, dt)
extraPolls = extraPolls + 1
if not paused then nextFn(game, dt) end
end)
mod.hooks:wrap("core.quit_to_launcher", function(nextFn)
if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end
return nextFn()
end)
-- test-only knobs, read back through mod.storage-free globals since
-- this fixture never leaves the process
_G.__fixturePlatformBridge = {
setPaused = function(v) paused = v end,
extraPolls = function() return extraPolls end,
}
]],
}
-- core.update: a subscriber can pause (skip vanilla) and still run every frame
do
local run = T.sdk.loadMods({ "mods/fix_platform_bridge" },
{ fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local calls = 0
local fakeGame = { update = function(self, dt) calls = calls + 1 end }
_G.__fixturePlatformBridge.setPaused(false)
PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "unpaused: vanilla Game:update runs")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 1,
"the subscriber's wrapper runs every frame")
_G.__fixturePlatformBridge.setPaused(true)
PlatformHooks.update(fakeGame, 1 / 60)
T.eq(calls, 1, "paused: vanilla Game:update is skipped")
T.eq(_G.__fixturePlatformBridge.extraPolls(), 2,
"the subscriber keeps polling every frame while paused")
run.release()
_G.__fixturePlatformBridge = nil
end
-- core.quit_to_launcher: a subscriber can veto without the vanilla
-- condition ever running, or pass it through unchanged
do
local run = T.sdk.loadMods({ "mods/fix_platform_bridge" },
{ fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local realGetenv = os.getenv
os.getenv = function(name)
if name == "FIXTURE_VETO_QUIT" then return "1" end
return realGetenv(name)
end
local vanillaCalls = 0
local vetoed = PlatformHooks.quitToLauncher(function()
vanillaCalls = vanillaCalls + 1
return true
end)
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition")
os.getenv = realGetenv
local passed = PlatformHooks.quitToLauncher(function() return true end)
T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged")
run.release()
end
T.finish("platform_lifecycle_hooks")
+11
View File
@@ -60,6 +60,17 @@ for _, r in ipairs(rows) do
end
check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame")
-- The post-battle walk takes the right-hand detour before heading north, so
-- the player does not visibly pass through the rival at (4,2).
local route = {}
for _, r in ipairs(rows) do
if r[1] == "move_player" then route[#route + 1] = r end
end
eq(route[#route - 1] and route[#route - 1][2], "right",
"walk-out route first moves right around the rival")
eq(route[#route] and route[#route][2], "up",
"walk-out route then heads north to Hall of Fame")
-- (3) Commands.face_player_dir sets the player's facing
local Commands = require("src.script.Commands")
check(type(Commands.face_player_dir) == "function", "Commands.face_player_dir is a function")
+13
View File
@@ -75,6 +75,19 @@ do
check(anyText(tb, "SUBSTITUTE"), "the failure text still prints")
end
-- Exact quarter HP is also not enough: accepting it would leave the user at
-- 0 HP with substituteHP set, so the next trainer-battle turn cannot advance.
do
local tb = freshBattle()
local cost = math.floor(tb.enemy.mon.stats.hp / 4)
tb.enemy.mon.hp = cost
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
check(tb.enemy.substituteHP == nil and tb.enemy.mon.hp == cost,
"exact quarter HP cannot create a zero-HP substitute")
check(not anyAnim(tb, "SUBSTITUTE"),
"the exact-boundary failure plays no animation")
end
-- .alreadyHasSubstitute: same, with a doll already standing
do
local tb = freshBattle()
+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")
+9 -8
View File
@@ -446,16 +446,17 @@ check(misted2.stages.attack == nil
and mistMsgs[1]:find("MIST", 1, true) ~= nil,
"primary stat drop still blocked by MIST")
-- Substitute boundary: built at exactly 1/4 max HP, leaving 0 HP
-- (substitute.asm only fails on subtraction underflow)
-- Substitute boundary: the move must fail when its quarter-HP cost would
-- consume all current HP, preventing a zero-HP user with a live substitute.
local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" }
MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser)
check(subUser.substituteHP ~= nil and subUser.mon.hp == 0,
"substitute built at exactly 1/4 max HP leaves 0 HP")
local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" }
local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2)
check(subUser2.substituteHP == nil
local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser)
check(subUser.substituteHP == nil and subUser.mon.hp == 10
and subMsgs[1]:find("weak", 1, true) ~= nil,
"substitute fails at exactly 1/4 max HP")
local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" }
local subMsgs2 = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2)
check(subUser2.substituteHP == nil and subUser2.mon.hp == 9
and subMsgs2[1]:find("weak", 1, true) ~= nil,
"substitute fails below 1/4 max HP")
-- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of