CLOSES #1231, CLOSES #1269, CLOSES #1301

This commit is contained in:
bryanthaboi
2026-08-16 10:04:54 -04:00
parent 1151c188a7
commit a910b65434
9 changed files with 390 additions and 40 deletions
+12 -10
View File
@@ -72,11 +72,11 @@ function Pool:reset()
for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end
-- wBGP / wOBP0 / wOBP1, as DMG palette bytes. -- wBGP / wOBP0 / wOBP1, as DMG palette bytes.
self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL
-- Per-battler state the CGB paths write instead of touching wBGP: a DMG -- Per-battler state the CGB paths write instead of touching wBGP: shade
-- shade byte the view remaps that battler's pic through, whether the pic is -- byte, hidden flag, lifted tile rows, and which BG square it is drawn at.
-- hidden outright, and which of the six BG squares it is drawn at.
self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL }
self.hidden = { player = false, enemy = false } self.hidden = { player = false, enemy = false }
self.liftedRows = { player = nil, enemy = nil }
self.picSize = { player = nil, enemy = nil } self.picSize = { player = nil, enemy = nil }
self.slide = { player = 0, enemy = 0 } self.slide = { player = 0, enemy = 0 }
-- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the
@@ -488,6 +488,7 @@ local function runPicResize(self, st, script)
self.picSize[side] = step self.picSize[side] = step
self.hidden[side] = false self.hidden[side] = false
end end
self.liftedRows[side] = nil
incJt(st) incJt(st)
elseif jt >= 1 and jt <= 2 then elseif jt >= 1 and jt <= 2 then
incJt(st) incJt(st)
@@ -545,7 +546,7 @@ end
-- The two battler-pic objects: the animation borrows the mon's own tiles as -- The two battler-pic objects: the animation borrows the mon's own tiles as
-- an OBJ so it can be moved without touching the tilemap. -- an OBJ so it can be moved without touching the tilemap.
local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows) local function battlerObj(self, st, objectPlayer, objectEnemy, rows)
local jt = st.jt local jt = st.jt
if jt == 0 then if jt == 0 then
if self:flyDig(st) then if self:flyDig(st) then
@@ -562,25 +563,26 @@ local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
} }
elseif jt == 1 then elseif jt == 1 then
incJt(st) incJt(st)
-- The rows the OBJ now covers are cleared out of the tilemap so the mon -- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers
-- is not drawn twice. -- come out of the tilemap, and .five never puts them back.
self.hidden[self:sideKey(st)] = clearRows self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)]
elseif jt >= 2 and jt <= 4 then elseif jt >= 2 and jt <= 4 then
incJt(st) incJt(st)
elseif jt == 5 then elseif jt == 5 then
self.hidden[self:sideKey(st)] = false
endEffect(st) endEffect(st)
end end
end end
E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st)
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW",
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true) "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
{ player = { 0, 1 }, enemy = { 6, 1 } })
end end
E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st)
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true) "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW",
{ player = { 0, 2 }, enemy = { 5, 2 } })
end end
-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler -- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler
+54 -16
View File
@@ -150,6 +150,10 @@ end
-- animation (most of them) skips the canvas entirely. -- animation (most of them) skips the canvas entirely.
local function needsCanvas(runner) local function needsCanvas(runner)
local bg = runner.bg local bg = runner.bg
-- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays
-- out of the BG until the next pic redraw, so those frames stay baked too.
local lifted = bg.liftedRows
if lifted and (lifted.player or lifted.enemy) then return true end
if bg.scx ~= 0 or bg.scy ~= 0 then return true end if bg.scx ~= 0 or bg.scy ~= 0 then return true end
if not bg.lcdc then return false end if not bg.lcdc then return false end
if bg.lyEnd <= bg.lyStart then return false end if bg.lyEnd <= bg.lyStart then return false end
@@ -229,13 +233,12 @@ end
-- times, and the grouping is by VALUE so a table that happens to repeat costs -- times, and the grouping is by VALUE so a table that happens to repeat costs
-- nothing extra. -- nothing extra.
local function bgpBands(bg) local function bgpBands(bg)
local base = bg.bgp or GbcPalette.BGP_IDENTITY
local order, bands = {}, {} local order, bands = {}, {}
for row = 0, SCREEN_H - 1 do for row = 0, SCREEN_H - 1 do
local inWindow = row >= bg.lyStart and row < bg.lyEnd local inWindow = row >= bg.lyStart and row < bg.lyEnd
-- Outside the window the register still reads whatever wBGP holds, which -- Outside the window the register still reads whatever wBGP holds.
-- for every effect that aims hLCDCPointer at rBGP is the identity. local byte = inWindow and (bg.lyBackup[row] or base) or base
local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY)
or GbcPalette.BGP_IDENTITY
local band = bands[byte] local band = bands[byte]
if not band then if not band then
band = { byte = byte, rows = {} } band = { byte = byte, rows = {} }
@@ -244,24 +247,56 @@ local function bgpBands(bg)
end end
band.rows[#band.rows + 1] = row band.rows[#band.rows + 1] = row
end end
-- Identity first so the fillBackground below it happens before any blit and -- The base band first so the fillBackground below it happens before any blit
-- the common band is the one drawn from the first bake. -- and the common band is the one drawn from the first bake.
table.sort(order, function(a, b) table.sort(order, function(a, b)
if a.byte == b.byte then return false end if a.byte == b.byte then return false end
if a.byte == GbcPalette.BGP_IDENTITY then return true end if a.byte == base then return true end
if b.byte == GbcPalette.BGP_IDENTITY then return false end if b.byte == base then return false end
return a.rows[1] < b.rows[1] return a.rows[1] < b.rows[1]
end) end)
return order return order
end end
-- Runs `drawBg` (the battle panel) and then puts it on screen through the -- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
-- animation's BG registers. Returns without a canvas when nothing is function BattleAnimView:panelPalettes(battle)
-- displacing anything, which is the common case and costs nothing. local list = {}
function BattleAnimView:present(runner, drawBg) local shades = {}
for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end
list[#list + 1] = shades
local function bracket(pair)
if not (pair and pair[1] and pair[2]) then return end
list[#list + 1] = {
{ 255, 255, 255 },
{ pair[1][1], pair[1][2], pair[1][3] },
{ pair[2][1], pair[2][2], pair[2][3] },
{ 0, 0, 0 },
}
end
for _, side in ipairs({ "player", "enemy" }) do
local mon = battle and battle[side]
local colors = mon
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
if colors then list[#list + 1] = colors end
end
local hpBar = self.palettes and self.palettes.hpBar
if hpBar then
bracket(hpBar.green)
bracket(hpBar.yellow)
bracket(hpBar.red)
end
bracket(self.palettes and self.palettes.expBar)
return list
end
-- Runs `drawBg` (the battle panel) and puts it on screen through the
-- animation's BG registers; skips the canvas when nothing needs one.
function BattleAnimView:present(runner, drawBg, battle)
if not (love and love.graphics) then return end if not (love and love.graphics) then return end
local bg = runner.bg local bg = runner.bg
if not needsCanvas(runner) then local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY
and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil
if not invert and not needsCanvas(runner) then
drawBg() drawBg()
return return
end end
@@ -292,9 +327,10 @@ function BattleAnimView:present(runner, drawBg)
self:bake(drawBg, nil) self:bake(drawBg, nil)
-- A shifted scanline exposes whatever the BG map holds beside the pic, which local remapped = invert
-- outside the two pic boxes is the blank tile. Without this the exposed and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp)
-- strip is the canvas's own transparency and every shake shows a seam. -- A shifted scanline exposes the blank tile beside the pic boxes; without
-- this the exposed strip is the canvas's own transparency.
self:fillBackground() self:fillBackground()
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
-- hSCX / hSCY move the whole background; the per-scanline overrides only -- hSCX / hSCY move the whole background; the per-scanline overrides only
@@ -314,6 +350,7 @@ function BattleAnimView:present(runner, drawBg)
self:blitRow(row, dx, dy) self:blitRow(row, dx, dy)
end end
end end
if remapped then GbcPalette.clear() end
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to -- Shaderless boot: the panel is raw grayscale, so there are no palettes to
-- permute and the entry's BRIGHTNESS is the only thing left to reproduce. -- permute and the entry's BRIGHTNESS is the only thing left to reproduce.
if bg.lcdc == "BGP" then if bg.lcdc == "BGP" then
@@ -417,5 +454,6 @@ end
BattleAnimView.SCREEN_W = SCREEN_W BattleAnimView.SCREEN_W = SCREEN_W
BattleAnimView.SCREEN_H = SCREEN_H BattleAnimView.SCREEN_H = SCREEN_H
BattleAnimView.needsCanvas = needsCanvas
return BattleAnimView return BattleAnimView
+66 -6
View File
@@ -661,7 +661,7 @@ function BattleState:drawPic(mon, back)
-- the mon drawn at this frame. -- the mon drawn at this frame.
local scale = self:picScale(path, mon, back) local scale = self:picScale(path, mon, back)
if anim then if anim then
px = px + (anim.slide or 0) if not self.liftedPass then px = px + (anim.slide or 0) end
local resized = anim.size and PIC_RESIZE_TILES[anim.size] local resized = anim.size and PIC_RESIZE_TILES[anim.size]
if resized then scale = scale * (resized / boxTiles) end if resized then scale = scale * (resized / boxTiles) end
end end
@@ -714,11 +714,39 @@ function BattleState:drawPic(mon, back)
-- A mod-supplied pic that says it is already coloured is drawn as it is: -- A mod-supplied pic that says it is already coloured is drawn as it is:
-- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands -- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands
-- back to its own draw site. -- back to its own draw site.
if colors and not trueColor and GbcPalette.available() then local function paint()
GbcPalette.with(colors, body) if colors and not trueColor and GbcPalette.available() then
else GbcPalette.with(colors, body)
body() else
body()
end
end end
local lifted = anim and anim.lifted
if not lifted then
paint()
return
end
-- engine/battle_anims/bg_effects.asm:448-465: the ClearBoxed band is off the BG.
local bandY = (back and BattleState.PLAYER_PIC_TILE_Y
or BattleState.ENEMY_PIC_TILE_Y) * 8 + lifted[1] * 8
local bandH = lifted[2] * 8
local psx, psy, psw, psh
if G.getScissor then psx, psy, psw, psh = G.getScissor() end
if self.liftedPass then
G.setScissor(0, bandY, 160, bandH)
paint()
else
if bandY > 0 then
G.setScissor(0, 0, 160, bandY)
paint()
end
local below = 144 - bandY - bandH
if below > 0 then
G.setScissor(0, bandY + bandH, 160, below)
paint()
end
end
if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end
end end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the -- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
@@ -1137,6 +1165,7 @@ function BattleState:animPicState(side)
local bg = self.anim.bg local bg = self.anim.bg
return { return {
hidden = bg.hidden[side], hidden = bg.hidden[side],
lifted = bg.liftedRows and bg.liftedRows[side] or nil,
size = bg.picSize[side], size = bg.picSize[side],
slide = bg.slide[side] or 0, slide = bg.slide[side] or 0,
shade = bg.monShade[side], shade = bg.monShade[side],
@@ -3436,6 +3465,36 @@ function BattleState:drawScene()
end end
end end
-- data/battle_anims/objects.asm:390-397: the lifted band rides at ABSOLUTE_X,
-- outside the scanline blit, so the attacker's SCX never moves it.
function BattleState:drawLiftedRows()
local battle = self.battle
if not battle then return end
local enemy = self:animPicState("enemy")
local player = self:animPicState("player")
local enemyLift = enemy and enemy.lifted
local playerLift = player and player.lifted
if not (enemyLift or playerLift) then return end
local G = love.graphics
if not self.liftCanvas then
self.liftCanvas = G.newCanvas(160, 144)
self.liftCanvas:setFilter("nearest", "nearest")
end
local previous = G.getCanvas()
G.setCanvas(self.liftCanvas)
G.clear(0, 0, 0, 0)
G.push()
G.origin()
self.liftedPass = true
if enemyLift then self:drawPic(battle.enemy, false) end
if playerLift then self:drawPic(battle.player, true) end
self.liftedPass = nil
G.pop()
G.setCanvas(previous)
G.setColor(1, 1, 1, 1)
G.draw(self.liftCanvas, 0, 0)
end
function BattleState:drawSceneBody() function BattleState:drawSceneBody()
local panel = function() self:drawPanel() end local panel = function() self:drawPanel() end
if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then
@@ -3456,7 +3515,8 @@ function BattleState:drawSceneBody()
return return
end end
if self.anim and self.animView then if self.anim and self.animView then
self.animView:present(self.anim, panel) self.animView:present(self.anim, panel, self.battle)
self:drawLiftedRows()
self.animView:drawObjects(self.anim, self.battle) self.animView:drawObjects(self.anim, self.battle)
return return
end end
+2 -2
View File
@@ -6,6 +6,7 @@ local Assets = require("src.render.Assets")
local BorderFill = require("src.world.gen2.BorderFill") local BorderFill = require("src.world.gen2.BorderFill")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
local PixelCanvas = require("src.render.PixelCanvas")
local MapPreview = {} local MapPreview = {}
@@ -96,9 +97,8 @@ function MapPreview.bake(baker, map, daytime)
local blocks = tileset.blocks local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16 local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32 local pw, ph = map.width * 32, map.height * 32
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph) local okCanvas, canvas = pcall(PixelCanvas.new, pw, ph, "nearest")
if not okCanvas or not canvas then return nil end if not okCanvas or not canvas then return nil end
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
local quads = {} local quads = {}
local function quadFor(tile) local function quadFor(tile)
local q = quads[tile] local q = quads[tile]
+6 -6
View File
@@ -47,6 +47,7 @@ local NPC = require("src.world.gen2.Npc")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local Permissions = require("src.world.gen2.Permissions") local Permissions = require("src.world.gen2.Permissions")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Player = require("src.world.gen2.Player") local Player = require("src.world.gen2.Player")
local Pokerus = require("src.core.gen2.Pokerus") local Pokerus = require("src.core.gen2.Pokerus")
local Roamers = require("src.core.gen2.Roamers") local Roamers = require("src.core.gen2.Roamers")
@@ -7612,8 +7613,9 @@ function World:bakeMapImage(map, daytime, flicker)
local blocks = tileset.blocks local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16 local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32 local pw, ph = map.width * 32, map.height * 32
local canvas = love.graphics.newCanvas(pw, ph) -- Map pixels, not the screen's: a DPI-scaled canvas bakes them non-square
canvas:setFilter("nearest", "nearest") -- (#208, see src/render/PixelCanvas.lua).
local canvas = PixelCanvas.new(pw, ph, "nearest")
local quads = {} local quads = {}
local function quadFor(tile) local function quadFor(tile)
local q = quads[tile] local q = quads[tile]
@@ -8140,12 +8142,11 @@ function World:scrollStrip(mapDef, tileset, tile, scroll)
local cached = self.scrollStrips[key] local cached = self.scrollStrips[key]
if cached ~= nil then return cached or nil end if cached ~= nil then return cached or nil end
local atlas = self:atlasFor(mapDef) local atlas = self:atlasFor(mapDef)
local ok, canvas = pcall(love.graphics.newCanvas, 8, 8 * 8) local ok, canvas = pcall(PixelCanvas.new, 8, 8 * 8, "nearest")
if not (atlas and ok and canvas) then if not (atlas and ok and canvas) then
self.scrollStrips[key] = false self.scrollStrips[key] = false
return nil return nil
end end
canvas:setFilter("nearest", "nearest")
local perRow = tileset.tilesPerRow or 16 local perRow = tileset.tilesPerRow or 16
local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
local aw, ah = atlas:getDimensions() local aw, ah = atlas:getDimensions()
@@ -9945,8 +9946,7 @@ function World:drawTilted(w, h, s, gw, gh)
if self.tiltCanvas and self.tiltCanvas.release then if self.tiltCanvas and self.tiltCanvas.release then
self.tiltCanvas:release() self.tiltCanvas:release()
end end
self.tiltCanvas = G.newCanvas(gw, gh) self.tiltCanvas = PixelCanvas.new(gw, gh, "linear")
self.tiltCanvas:setFilter("linear", "linear")
end end
local previous = G.getCanvas() local previous = G.getCanvas()
@@ -0,0 +1,68 @@
-- engine/battle_anims/bg_effects.asm:406-471 BattleBGEffect_BattlerObj_1Row
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BgEffects = require("src.battle.gen2.BgEffects")
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
do
local bg = BgEffects.new(nil, { battleTurn = 0 })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
bg:playFrame()
local spawns = bg:takeSpawns()
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
"player attacking: the enemy's feet row becomes an OBJ")
T.eq(spawns[1] and spawns[1].x, 16 * 8 + 4, "at the asm's fixed x")
T.eq(bg.liftedRows.enemy, nil, "the tilemap row is intact on frame one")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
"an intact tilemap with no scroll skips the bake canvas")
bg:playFrame()
local lifted = bg.liftedRows.enemy
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
"frame two ClearBoxes row 6 of the enemy box (hlcoord 12, 6)")
T.eq(bg.hidden.enemy, false, "the rest of the pic stays on the BG")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
"a lifted row keeps the panel on the bake canvas even with scx 0 and no"
.. " lcdc pointer, so drawPic's 160x144 scissor stays in canvas space")
for _ = 1, 4 do bg:playFrame() end
T.eq(bg:activeCount(), 0, ".five ends the effect")
lifted = bg.liftedRows.enemy
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
".five never restores the row")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
"and the wait frames after .five stay baked as well")
bg:queue("BATTLE_BG_EFFECT_SHOW_MON", 0, 0, 0)
bg:playFrame()
T.eq(bg.liftedRows.enemy, nil, "SHOW_MON's box redraw puts the row back")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
"after which the plain no-canvas path returns")
end
do
local bg = BgEffects.new(nil, { battleTurn = 1 })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", 0, 0, 0)
bg:playFrame()
local spawns = bg:takeSpawns()
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
"enemy attacking: the player's head rows become an OBJ")
T.eq(spawns[1] and spawns[1].x, 6 * 8, "at the asm's fixed x")
bg:playFrame()
local lifted = bg.liftedRows.player
T.check(lifted and lifted[1] == 0 and lifted[2] == 2,
"rows 0-1 of the player box (hlcoord 2, 6, two rows)")
T.eq(bg.liftedRows.enemy, nil, "the attacker keeps its own rows")
end
do
local bg = BgEffects.new(nil, { battleTurn = 0, flying = { enemy = true } })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
bg:playFrame()
T.eq(#bg:takeSpawns(), 0, "a flying target spawns nothing")
T.eq(bg:activeCount(), 0, "and the effect ends at once")
T.eq(bg.liftedRows.enemy, nil, "with no row lifted")
end
T.finish("gen2 battler row lift bug 1231")
+54
View File
@@ -0,0 +1,54 @@
-- Gen 2 map bakes take dpiscale 1 so map pixels stay square LCD pixels
-- (#208 #1301, constants/hardware.inc:932; see src/render/PixelCanvas.lua).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local g = love.graphics
local seen = {}
local realNewCanvas = g.newCanvas
g.newCanvas = function(w, h, settings)
seen[#seen + 1] = { w = w, h = h, dpiscale = settings and settings.dpiscale }
local c = realNewCanvas(w, h)
c.renderTo = function(_, fn) fn() end
c.setFilter = function() end
return c
end
local World = require("src.world.gen2.World")
local MapPreview = require("src.world.gen2.MapPreview")
local atlas = {
getDimensions = function() return 128, 128 end,
setFilter = function() end,
}
local tileset = { blocks = { [1] = {} }, tilesPerRow = 16 }
local map = { def = { tileset = "TILESET_JOHTO" }, width = 20, height = 18,
blocks = {}, borderBlock = 0 }
local world = setmetatable({
atlasCache = {},
atlasFor = function() return atlas, tileset end,
}, { __index = World })
World.bakeMapImage(world, map, nil, nil)
local bakeCount = #seen
T.check(bakeCount >= 1, "the map bake allocated a canvas")
World.scrollStrip(world, map.def, tileset, 3, { h = 1, v = 0 })
T.check(#seen > bakeCount, "the scroll strip allocated a canvas")
local stripCount = #seen
local origAtlasFor = MapPreview.atlasFor
MapPreview.atlasFor = function() return atlas, tileset end
MapPreview.bake({ tilesets = {}, atlasCache = {}, mapImages = {} }, map, "DAY")
MapPreview.atlasFor = origAtlasFor
T.check(#seen > stripCount, "the save-editor bake allocated a canvas")
for i, c in ipairs(seen) do
T.eq(c.dpiscale, 1,
("canvas %d (%dx%d) is allocated at dpiscale 1"):format(i, c.w, c.h))
end
g.newCanvas = realNewCanvas
T.finish("gen2 map bake dpi")
@@ -44,4 +44,46 @@ do
"the identity byte returns the palette untouched") "the identity byte returns the palette untouched")
end end
-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
do
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
local palettes = {
pokemon = {
[6] = { normal = { { 200, 100, 50 }, { 80, 40, 20 } } },
[25] = { normal = { { 230, 200, 40 }, { 150, 90, 20 } } },
},
hpBar = {
green = { { 100, 220, 100 }, { 30, 160, 30 } },
yellow = { { 230, 220, 90 }, { 180, 150, 20 } },
red = { { 230, 100, 90 }, { 180, 30, 20 } },
},
expBar = { { 120, 140, 230 }, { 40, 60, 160 } },
}
local view = BattleAnimView.new({}, palettes)
local battle = { player = { species = 6 }, enemy = { species = 25 } }
local list = view:panelPalettes(battle)
T.eq(#list, 7, "shades + two mons + three hp bars + exp bar")
local src, dst, count, ambiguous = GbcPalette.remapTable(list, 0x1b)
T.check(count > 0 and count <= GbcPalette.REMAP_MAX,
"the table fits the shader array")
T.eq(ambiguous, 0, "no colour maps two ways")
local function mapped(from)
for i = 1, count do
if src[i][1] == from[1] and src[i][2] == from[2]
and src[i][3] == from[3] then
return dst[i]
end
end
end
local black = mapped({ 255, 255, 255 })
T.check(black and black[1] == 0 and black[2] == 0 and black[3] == 0,
"white inverts to black")
local white = mapped({ 0, 0, 0 })
T.check(white and white[1] == 255 and white[2] == 255 and white[3] == 255,
"black inverts to white")
local mid = mapped({ 200, 100, 50 })
T.check(mid and mid[1] == 80 and mid[2] == 40 and mid[3] == 20,
"the mon's colour 1 shows its colour 2")
end
T.finish("gen2 shadow ball bgp bug 1269") T.finish("gen2 shadow ball bgp bug 1269")
@@ -0,0 +1,86 @@
-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
--
-- gen2_shadow_ball_bgp_bug1269.lua proves the runner lands bg.bgp and that
-- panelPalettes/remapTable would invert correctly; it never calls
-- BattleAnimView:present, which is where #1269 actually lived (the byte
-- was landed but nothing read it). This suite drives present() itself and
-- watches the shader binding around the backdrop draw.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
-- A remap shader that never touches the GPU: present() only needs
-- love.graphics.newShader to succeed so GbcPalette.remapShader() is
-- non-nil, which is the gate `present` checks before it will bake+remap.
local sentShader = { calls = {} }
function sentShader:send(name, ...) self.calls[#self.calls + 1] = name end
love.graphics.newShader = function() return sentShader end
-- The stub's stand-in Quad has no setViewport/getViewport, which blitRow
-- (the scanline blit present() drives 144 times a frame) needs; the real
-- love.graphics.Quad has both.
love.graphics.newQuad = function(x, y, w, h)
local q = { x = x, y = y, w = w, h = h }
function q:setViewport(x2, y2, w2, h2) self.x, self.y, self.w, self.h = x2, y2, w2, h2 end
function q:getViewport() return self.x, self.y, self.w, self.h end
return q
end
local T = require("tests.harness")
local GbcPalette = require("src.render.GbcPalette")
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
local shaderDuringFill = "unset"
local realRectangle = love.graphics.rectangle
love.graphics.rectangle = function(...)
if shaderDuringFill == "unset" then
shaderDuringFill = love.graphics.getShader()
end
return realRectangle(...)
end
local view = BattleAnimView.new({}, nil)
-- data/moves/animations.asm:4509 BattleAnim_ShadowBall's anim_bgp $1b, with
-- no scroll and no rBGP window queued, is exactly the frame that used to
-- fall through the old `needsCanvas`-only early-out untouched.
local runner = {
bg = {
bgp = 0x1b,
lcdc = nil,
scx = 0,
scy = 0,
lyStart = 0,
lyEnd = 0,
lyBackup = {},
},
}
T.check(love.graphics.getShader() == nil, "no shader bound before present")
view:present(runner, function() end, nil)
T.check(shaderDuringFill == sentShader,
"the panel backdrop is drawn through the remap shader, not plainly")
T.check(love.graphics.getShader() == nil,
"the shader is unbound again once present returns, so drawObjects is unaffected")
local sawRemapSend = false
for _, name in ipairs(sentShader.calls) do
if name == "remapSrc" then sawRemapSend = true end
end
T.check(sawRemapSend, "GbcPalette.useRemap actually sent a remap table, not just bound the shader")
-- The identity byte must take the plain path: no bake, no shader, ever.
shaderDuringFill = "unset"
local identityRunner = {
bg = { bgp = GbcPalette.BGP_IDENTITY, lcdc = nil, scx = 0, scy = 0,
lyStart = 0, lyEnd = 0, lyBackup = {} },
}
local plainDrawCalled = false
view:present(identityRunner, function() plainDrawCalled = true end, nil)
T.check(plainDrawCalled, "identity rBGP takes the plain drawBg() path")
T.check(shaderDuringFill == "unset",
"identity rBGP never touches the remap shader")
T.finish("gen2 shadow ball bgp view bug 1269")