This commit is contained in:
bryanthaboi
2026-09-30 12:18:25 -04:00
88 changed files with 5768 additions and 1138 deletions
+19 -4
View File
@@ -12,9 +12,10 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE.
local emergencyQuitTimer = 0
-- getJoysticks() allocates a fresh table every call; this runs once (twice)
-- per frame, so cache the list and refresh it once a second instead. The 5s
-- hold requirement makes a 1s hotplug delay irrelevant.
-- getJoysticks() allocates a fresh table every call; this runs once per
-- frame (from love.run, before love.update), so cache the list and refresh
-- it once a second instead. The 5s hold requirement makes a 1s hotplug
-- delay irrelevant.
local cachedJoysticks = nil
local joystickCacheAge = 1
@@ -929,7 +930,8 @@ function love.load(args)
end
function love.update(dt)
checkEmergencyQuit(dt)
-- checkEmergencyQuit runs from love.run each frame; calling it here too
-- would double-count dt and fire the 5s hold after 2.5s.
HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
@@ -1242,8 +1244,20 @@ function love.joystickhat(joystick, hat, direction)
Game:joystickhat(joystick, hat, direction)
end
-- The joystick lists cached for polling (checkEmergencyQuit's, Input's
-- pollPads) are only refreshed on a count change or a timer otherwise, which
-- a controller swapped for another can slip past.
local function noteJoysticksChanged()
cachedJoysticks = nil
local input = Game and Game.input
if type(input) == "table" and input.joysticksChanged then
input:joysticksChanged()
end
end
function love.joystickadded(joystick)
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
noteJoysticksChanged()
if editorMode or TouchEditor or Studio then return end
if Importer then return end
if not Game then return end
@@ -1252,6 +1266,7 @@ end
function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
noteJoysticksChanged()
if editorMode or TouchEditor or Studio then return end
if Importer then return end
if not Game then return end
+62 -36
View File
@@ -722,6 +722,25 @@ local function sameColors(a, b)
return true
end
-- one OAM tile at its screen rect (rx, ry), honoring the X/Y flips
local function blitTile(g, img, quad, s, rx, ry)
g.draw(img, quad,
rx + (s.xf and 8 or 0),
ry + (s.yf and 8 or 0),
0,
s.xf and -1 or 1,
s.yf and -1 or 1)
end
-- the shade-remap uniforms; c0 is the transparent color-0 slot, so it just
-- takes color 1
local function sendTriple(shader, colors)
shader:send("c0", colors[1])
shader:send("c1", colors[1])
shader:send("c2", colors[2])
shader:send("c3", colors[3])
end
-- Draw one compiled step's OAM sprites. With colorFn, each sprite is
-- drawn through the PaletteFX shade-remap shader. The SGB colorized
-- the finished DMG picture per 8x8 screen cell (the ATTR_BLK regions
@@ -730,14 +749,22 @@ end
-- once per attribute cell the 8x8 tile touches (up to 4), and cells
-- that resolve to a different palette than the first are repainted
-- through a scissor clipped to the cell.
--
-- Per-frame cost: no closures per tile, and the uniforms are only re-sent
-- when the colors differ from the last ones this call sent (colorFn
-- memoizes, so equal colors are usually the same table). The first send
-- of every call is unconditional -- other passes share the shader.
function AnimPlayer:drawSprites(sprites, colorFn)
local g = love and love.graphics
local shader
local PaletteFX, shader
if colorFn and g and g.setShader then
shader = require("src.render.PaletteFX").shader()
PaletteFX = require("src.render.PaletteFX")
shader = PaletteFX.shader()
end
local slices = shader and g.getScissor and g.intersectScissor
and g.setScissor
local sent -- the triple the shader currently holds (this call)
local floor = math.floor
for i = 1, #sprites do
local s = sprites[i]
-- hardware hides sprites at the OAM extremes (y=0/y>=160, x=0/x>=168);
@@ -747,51 +774,48 @@ function AnimPlayer:drawSprites(sprites, colorFn)
local quad = img and self:tileQuad(s.ts, s.tile)
if quad then
local rx, ry = s.x - 8, s.y - 16 -- screen-space rect of the tile
local function blit()
g.draw(img, quad,
rx + (s.xf and 8 or 0),
ry + (s.yf and 8 or 0),
0,
s.xf and -1 or 1,
s.yf and -1 or 1)
end
-- the attribute cell holding the tile's top-left pixel
local cx = math.floor(rx / 8) * 8
local cy = math.floor(ry / 8) * 8
local cx = floor(rx / 8) * 8
local cy = floor(ry / 8) * 8
local colors = shader and colorFn(s, cx, cy)
if colors then
g.setShader(shader)
-- c0 is the transparent color-0 slot; send anything
shader:send("c0", colors[1])
shader:send("c1", colors[1])
shader:send("c2", colors[2])
shader:send("c3", colors[3])
if not (sent and sameColors(sent, colors)) then
sendTriple(shader, colors)
sent = colors
end
end
blit()
blitTile(g, img, quad, s, rx, ry)
if colors and slices and (cx ~= rx or cy ~= ry) then
-- unaligned: the tile spills into up to 3 more cells; repaint
-- the ones whose zone palette differs (opaque overdraw -- GB
-- tiles have binary alpha)
local function slice(px, py)
if px < 0 or py < 0 or px >= 160 or py >= 144 then
return -- fully off-canvas
local cx2 = floor((rx + 7) / 8) * 8
local cy2 = floor((ry + 7) / 8) * 8
for n = 1, 3 do
local px, py
if n == 1 then
if cx2 ~= cx then px, py = cx2, cy end
elseif n == 2 then
if cy2 ~= cy then px, py = cx, cy2 end
elseif cx2 ~= cx and cy2 ~= cy then
px, py = cx2, cy2
end
-- skip cells fully off-canvas
if px and px >= 0 and py >= 0 and px < 160 and py < 144 then
local cc = colorFn(s, px, py)
if cc and not sameColors(cc, colors) then
local s1, s2, s3, s4 = g.getScissor()
g.intersectScissor(px, py, 8, 8)
if not sameColors(sent, cc) then
sendTriple(shader, cc)
sent = cc
end
blitTile(g, img, quad, s, rx, ry)
if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end
end
end
local cc = colorFn(s, px, py)
if not cc or sameColors(cc, colors) then return end
local s1, s2, s3, s4 = g.getScissor()
g.intersectScissor(px, py, 8, 8)
shader:send("c0", cc[1])
shader:send("c1", cc[1])
shader:send("c2", cc[2])
shader:send("c3", cc[3])
blit()
if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end
end
local cx2 = math.floor((rx + 7) / 8) * 8
local cy2 = math.floor((ry + 7) / 8) * 8
if cx2 ~= cx then slice(cx2, cy) end
if cy2 ~= cy then slice(cx, cy2) end
if cx2 ~= cx and cy2 ~= cy then slice(cx2, cy2) end
end
if colors then
g.setShader()
@@ -799,6 +823,8 @@ function AnimPlayer:drawSprites(sprites, colorFn)
end
end
end
-- the direct sends above bypass sendColors' last-sent record
if sent then PaletteFX.forgetSent(shader) end
end
return AnimPlayer
+182 -33
View File
@@ -247,6 +247,14 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = setmetatable({}, WEAK_KEYS)
-- image -> memoized fadeImage / blackImage variants (the per-frame picImage
-- lookups). They cache what getImage returned, so invalidate() drops them
-- together with imageCache.
local function newDerivedMemo()
return { fade = setmetatable({}, WEAK_KEYS),
black = setmetatable({}, WEAK_KEYS) }
end
local derivedMemo = newDerivedMemo()
local function mattedPic(path)
return path:sub(1, 17) == "assets/generated/"
or path:sub(1, 17) == "save/mod-derived/"
@@ -326,6 +334,7 @@ end
-- are collected on their own rather than leaking.
function BattleState.invalidate()
imageCache = {}
derivedMemo = newDerivedMemo()
end
Assets.register(BattleState.invalidate)
@@ -434,12 +443,28 @@ local function fadeImage(img, bgp)
if not meta then return img end
-- a full-color pic has no DMG shades to remap
if meta.trueColor then return img end
-- picImage asks for this every drawn frame of a fade: memoize per
-- (pic, shade map) instead of rebuilding the name string + palette table
-- (meta is fixed per image; the map's four 0..3 shades are the rest of
-- getImage's key, packed into one number)
local mkey = bgp[0] * 64 + bgp[1] * 16 + bgp[2] * 4 + bgp[3]
local row = derivedMemo.fade[img]
local hit = row and row[mkey]
if hit then return hit end
local PaletteFX = require("src.render.PaletteFX")
local base = meta.pal and meta.pal.colors or PaletteFX.GRAYS
local name = (meta.pal and meta.pal.name or "GB")
.. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3]
return getImage(meta.path,
{ name = name, colors = PaletteFX.permute(base, bgp) })
local out = getImage(meta.path,
{ name = name, colors = PaletteFX.permute(base, bgp) })
if out then
if not row then
row = {}
derivedMemo.fade[img] = row
end
row[mkey] = out
end
return out
end
-- the raw DMG-gray build of a colored pic (SE_WAVY_SCREEN bakes the
@@ -463,7 +488,15 @@ local function blackImage(data, img)
local colors = PaletteFX.usesYellowCgb() and pals.BLACK
or PaletteFX.pal(data, "BLACK") or pals.BLACK
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
return getImage(meta.path, { name = name, colors = colors }) or img
-- drawn every frame of the intro slide / blackout text: reuse the last
-- bake while the resolved palette and cache name are unchanged
local memo = derivedMemo.black[img]
if memo and memo.colors == colors and memo.name == name then
return memo.out
end
local out = getImage(meta.path, { name = name, colors = colors }) or img
derivedMemo.black[img] = { colors = colors, name = name, out = out }
return out
end
-- the asset path a loaded battle image came from (nil for the headless
@@ -767,6 +800,18 @@ function BattleState:playerPartyView()
end
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
-- Queue the battle's first two cries on the chip audio worker while the
-- transition runs, so the entrance cries don't render on the frame they
-- play. A no-op without a worker; playCry renders as before on a miss.
local function prewarmCries(self)
local Sound = package.loaded["src.core.Sound"]
if not (Sound and Sound.prewarmCry) then return end
for _, battler in ipairs({ self.enemy, self.player }) do
local mon = battler and battler.mon
if mon and mon.species then pcall(Sound.prewarmCry, self.data, mon.species) end
end
end
function BattleState.newWild(game, species, level, opts)
local self = newBattle(game)
self.kind = "wild"
@@ -784,6 +829,7 @@ function BattleState.newWild(game, species, level, opts)
else
self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name)
end
prewarmCries(self)
return self
end
@@ -917,6 +963,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex, opts)
self.trainerPic = BattleState.trainerSprite(
game.data, self.trainer, oppClass, partyIndex)
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
prewarmCries(self)
return self
end
@@ -2101,9 +2148,17 @@ end
-- flag survived into the next turn, ate a move the real players saw
-- land, and from there the replay was watching a different battle.
-- LinkBattle.newSpectator calls this at the head of every turn.
local function clearFlinch(b)
if not (b.mustRecharge or b.rageMove) then b.flinched = false end
end
function BattleState:clearTurnFlinches()
for _, b in ipairs({ self.player, self.enemy }) do
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
-- ipairs({ self.player, self.enemy }) without the per-call table: the
-- enemy is only visited when the player slot is set, like ipairs did
local p = self.player
if p then
clearFlinch(p)
if self.enemy then clearFlinch(self.enemy) end
end
end
@@ -2305,16 +2360,37 @@ function BattleState:tickFx()
self:updateFx()
end
-- ScrollTextUpOneLine's pixel scroll (beginMsgLine sets scrollPx = 8):
-- 2px per logic step while the message box shows the rolling window. It
-- used to count down inside drawTextArea / WideBattle's drawMessageBox, so
-- a 144Hz display scrolled 2.4x too fast; update() now runs this once per
-- step AFTER the queue typed, which is the order the draw-side decrement
-- saw it (8 is set and the box already draws at 6 on that frame).
function BattleState:tickTextScroll()
local px = self.scrollPx
if px and px > 0 and self.phase == "messages"
and (self.current or self.animPlaying or self.msgHold) then
px = px - 2
self.scrollPx = px > 0 and px or nil
end
end
local function snapIdleBar(b)
if b.shownHP then
b.shownHP = b.mon.hp
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
-- runs every menu-phase step: no per-call { player, enemy } table (the
-- enemy is only visited when the player slot is set, like ipairs did)
function BattleState:snapIdleBars()
for _, b in ipairs({ self.player, self.enemy }) do
if b then
if b.shownHP then
b.shownHP = b.mon.hp
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
local p = self.player
if p then
snapIdleBar(p)
if self.enemy then snapIdleBar(self.enemy) end
end
end
@@ -2357,6 +2433,7 @@ function BattleState:update(dt)
self:finish()
end
end
self:tickTextScroll()
return
end
@@ -5820,6 +5897,26 @@ local HudTiles = require("src.render.HudTiles")
local hudTile = HudTiles.tile
local drawHPBar = HudTiles.drawHPBar
-- One reusable row-clip quad per battler for the faint / slide pic effects
-- (drawn every frame they run): re-aimed with setViewport instead of a new
-- Quad per frame. A quad without setViewport (headless stubs) is simply
-- rebuilt, which is what every frame used to do.
function BattleState:picQuad(battler, x, y, w, h, sw, sh)
local quads = self.picQuads
if not quads then
quads = setmetatable({}, WEAK_KEYS)
self.picQuads = quads
end
local q = quads[battler]
if q and q.setViewport then
q:setViewport(x, y, w, h, sw, sh)
return q
end
q = love.graphics.newQuad(x, y, w, h, sw, sh)
quads[battler] = q
return q
end
-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile.
-- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one
-- charmap sequence per glyph, and byte length would push it a tile left.
@@ -6084,8 +6181,8 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
end
local visible = img:getHeight() - math.floor(off / scale)
if visible > 0 then
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
img:getWidth(), img:getHeight())
local quad = self:picQuad(battler, 0, 0, img:getWidth(), visible,
img:getWidth(), img:getHeight())
love.graphics.draw(img, quad, x, y + off, 0, scale, scale)
end
return
@@ -6170,7 +6267,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
-- sink below the baseline (AnimationSlideMonDown-style row clip)
local visible = h - math.floor(oy / scale)
if visible > 0 then
local quad = love.graphics.newQuad(0, 0, w, visible, w, h)
local quad = self:picQuad(battler, 0, 0, w, visible, w, h)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end
elseif k == "slideUp" then
@@ -6181,7 +6278,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY)
local step = math.min(7, math.floor((t - 1) / 2) + 1)
local visible = math.floor(h * step / 7)
if visible > 0 then
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
local quad = self:picQuad(battler, 0, h - visible, w, visible, w, h)
love.graphics.draw(img, quad, x + ox,
y + (h - visible) * scale, 0, scale, scale)
end
@@ -6291,9 +6388,10 @@ function BattleState:sgbBattlePals()
return out
end
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
function BattleState:zoneColorsAt(x, y)
local pals = self:sgbBattlePals()
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions).
-- pals: an sgbBattlePals() result the caller already resolved this frame.
function BattleState:zoneColorsAt(x, y, pals)
pals = pals or self:sgbBattlePals()
if not pals then return nil end
local tx = math.floor(x / 8)
local ty = math.floor(y / 8)
@@ -6358,13 +6456,32 @@ function BattleState:drawZonePass(src, sx, sy)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
local shaking = sx ~= 0 or sy ~= 0
-- the six zones share four palettes (one gray ramp in mono): permute each
-- once per pass instead of once per zone
local permuted = self.zonePermuted
if not permuted then
permuted = {}
self.zonePermuted = permuted
end
permuted[0], permuted[1], permuted[2], permuted[3], permuted.gray =
nil, nil, nil, nil, nil
for _, z in ipairs(BATTLE_ZONES) do
if mono then
-- the BGP fade still runs, just in gray: the frame-level pass colors
-- whatever DMG shade this leaves behind
PaletteFX.sendShades(shader, PaletteFX.permute(PaletteFX.GRAYS, bgp))
local shades = permuted.gray
if not shades then
shades = PaletteFX.permute(PaletteFX.GRAYS, bgp)
permuted.gray = shades
end
PaletteFX.sendShades(shader, shades)
else
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
local colors = permuted[z.pal]
if not colors then
colors = PaletteFX.permute(pals[z.pal], bgp)
permuted[z.pal] = colors
end
PaletteFX.sendColors(shader, colors)
end
local zx, zy = z[1] * 8, z[2] * 8
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
@@ -6390,7 +6507,18 @@ local OBJ_SHADES = {
e4x = { 2, 1, 3 }, -- $e4 xor %00111100 = $d8
obp1 = { 3, 2, 1 }, -- $6c
}
function BattleState:animSpriteColors(s, px, py)
local function shadeTriple(P, shade)
local col = P[shade + 1]
return { col[1] / 255, col[2] / 255, col[3] / 255 }
end
-- memo (optional): a per-frame table from drawAnimLayer. It holds the
-- frame's sgbBattlePals() (memo.pals, false when there are none) so the
-- palette set is resolved once per frame instead of once per attribute
-- cell of every OAM tile, and the finished triples per (palette, obp key)
-- so tiles in the same zone share one table (AnimPlayer then sees equal
-- colors by identity).
function BattleState:animSpriteColors(s, px, py, memo)
local PaletteFX = require("src.render.PaletteFX")
local key = s.obp or "f0"
local P
@@ -6399,16 +6527,32 @@ function BattleState:animSpriteColors(s, px, py)
-- engine/battle/init_battle_variables.asm:18
P = PaletteFX.ogObjBase()
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
elseif memo then
local pals = memo.pals
if pals == nil then
pals = self:sgbBattlePals() or false
memo.pals = pals
end
P = pals and self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4),
pals) or nil
else
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
end
if not P then return nil end
local byKey = memo and memo[P]
local hit = byKey and byKey[key]
if hit then return hit end
local m = OBJ_SHADES[key] or OBJ_SHADES.f0
local function c(shade)
local col = P[shade + 1]
return { col[1] / 255, col[2] / 255, col[3] / 255 }
local out = { shadeTriple(P, m[1]), shadeTriple(P, m[2]),
shadeTriple(P, m[3]) }
if memo then
if not byKey then
byKey = {}
memo[P] = byKey
end
byKey[key] = out
end
return { c(m[1]), c(m[2]), c(m[3]) }
return out
end
-- the OAM anim layer (subanimation sprites / the resting caught ball)
@@ -6417,7 +6561,16 @@ function BattleState:drawAnimLayer(colorized)
if self.fieldCleared then return end
local colorFn
if colorized then
colorFn = function(s, px, py) return self:animSpriteColors(s, px, py) end
-- fresh per-frame memo (palette set + color triples), one long-lived
-- closure: colorFn runs up to four times per OAM tile
self.animColorMemo = {}
colorFn = self.animColorFn
if not colorFn then
colorFn = function(s, px, py)
return self:animSpriteColors(s, px, py, self.animColorMemo)
end
self.animColorFn = colorFn
end
end
if self.animPlaying and self.animPlayer then
love.graphics.setColor(1, 1, 1, 1)
@@ -6807,10 +6960,6 @@ function BattleState:drawTextArea()
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
-- view instead of drawing off-screen at y=144 (#216).
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
end
local off = self.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(self.shown or {}) do
+2 -4
View File
@@ -183,10 +183,8 @@ end
local function drawMessageBox(battle)
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
if battle.scrollPx and battle.scrollPx > 0 then
battle.scrollPx = battle.scrollPx - 2
if battle.scrollPx <= 0 then battle.scrollPx = nil end
end
-- scrollPx counts down in BattleState:tickTextScroll (logic step), not
-- here, so the scroll speed does not follow the display refresh rate
local off = battle.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(battle.shown or {}) do
+32 -12
View File
@@ -42,6 +42,7 @@ local NUM_STRUCTS = 10 -- NUM_BATTLE_ANIM_STRUCTS
-- wShadowOAM is 40 objects; BattleAnimOAMUpdate returns carry once it is full
-- and BattleAnim_UpdateOAM_All stops walking the structs.
local OAM_LIMIT = 40
local EMPTY = {}
local OAM_PRIO, OAM_YFLIP, OAM_XFLIP = 0x80, 0x40, 0x20
local OAM_FLAG_MASK = 0xe0
@@ -332,22 +333,31 @@ function Pool:updateOam(st)
local tileId = u8(buf.tileId + (set.vtile or 0))
local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0
local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0
for _, entry in ipairs(set.sprites or {}) do
if #self.oam >= OAM_LIMIT then return true end
local oam = self.oam
local pool = self.oamPool
for _, entry in ipairs(set.sprites or EMPTY) do
local n = #oam
if n >= OAM_LIMIT then return true end
-- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's;
-- OAM_PAL1 passes through from the entry, and the palette slot comes from
-- the struct.
local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK)
attr = attr + bit.band(entry.attr or 0, OAM_PAL1)
self.oam[#self.oam + 1] = {
y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)),
x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)),
-- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by
-- every sheet lookup, so the port keeps tiles in sheet-relative space.
tile = u8(tileId + (entry.tile or 0)),
attr = attr,
palette = buf.palette,
}
-- Shadow OAM slots are recycled frame to frame (playFrame), the way the
-- cart rewrites wShadowOAM in place: the 40 tables are made once.
local out = pool and pool[n + 1]
if not out then
out = {}
if pool then pool[n + 1] = out end
end
out.y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip))
out.x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip))
-- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by
-- every sheet lookup, so the port keeps tiles in sheet-relative space.
out.tile = u8(tileId + (entry.tile or 0))
out.attr = attr
out.palette = buf.palette
oam[n + 1] = out
end
return false
end
@@ -356,8 +366,18 @@ end
-- write its OAM entries. A struct that deinitialises itself inside its
-- function still draws this frame, because the ASM calls BattleAnimOAMUpdate
-- unconditionally.
--
-- The OAM list and its entries are reused from frame to frame: what a frame
-- returns is valid until the next playFrame, which is all the draw needs.
function Pool:playFrame()
self.oam = {}
local oam = self.oamList
if not oam then
oam = {}
self.oamList = oam
self.oamPool = {}
end
for i = #oam, 1, -1 do oam[i] = nil end
self.oam = oam
for slot = 1, NUM_STRUCTS do
local st = self.structs[slot]
if st.index ~= 0 then
+224 -26
View File
@@ -84,6 +84,16 @@ local lastCosted
local worker, cmdCh, outCh
local workerReady -- nil = untried, true = running, false = unavailable
-- SFX/cry prewarm state (see "one-shot effects" below): results come back on
-- their own channel so a music stop/clear never drops them
local fxCh
local fxEpoch = 0 -- bumped by invalidate: drops results from a stale def
local fxReady = {} -- key -> SoundData | false (renders to nothing)
local fxPending = {} -- key -> true while a request is in flight
local fxReadyCount = 0
local FX_READY_MAX = 64
local drainEffects, dropEffects
local function ensureWorker()
if workerReady ~= nil then return workerReady end
if not (love.thread and love.thread.newThread and love.audio) then
@@ -147,25 +157,70 @@ end
-- The queue is deep (MUSIC_BUFFER_COUNT, ~6s) for stall tolerance, but
-- synthesizing all of it on the frame a song starts renders ~6s of audio at
-- once. Cap how many buffers each fill renders; playback drains ~1 buffer
-- every ~11 frames while update() tops up a few per frame, so the deep queue
-- still ramps to full within a fraction of a second.
-- once. A (re)start renders MUSIC_FILL_INITIAL whole buffers so playback has
-- lookahead; after that each update() renders a flat slice of samples into a
-- staging buffer and queues it once all MUSIC_BUFFER_SAMPLES are in. Whole
-- 8192-sample buffers rendered on the frame a queue slot freed (every ~11
-- frames) read as a periodic stutter; ~1024 samples per 60 Hz tick
-- (playback drains ~735) keeps the per-frame cost flat while the queue still
-- creeps up to full. Below MUSIC_FILL_LOW_WATER queued buffers (a long
-- stall, or update() called well under 60 Hz) a tick renders a whole buffer's
-- worth so the queue cannot run dry. The sample stream is unchanged: every
-- buffer is still MUSIC_BUFFER_SAMPLES consecutive engine samples, and a
-- buffer begun before the song ended is completed exactly as before.
local MUSIC_FILL_INITIAL = 4
local MUSIC_FILL_PER_CALL = 3
local MUSIC_FILL_TICK_SAMPLES = 1024 -- at 44100 Hz; scaled with the rate
local MUSIC_FILL_LOW_WATER = 2
local function fillSync(limit)
local function tickSamples()
return math.max(256,
math.floor(MUSIC_FILL_TICK_SAMPLES * sampleRate() / 44100 + 0.5))
end
-- render up to `budget` samples of the fallback song, queueing each staging
-- buffer as it fills. A new buffer is only begun while the Source has a free
-- slot and the song has not finished (the old whole-buffer loop's gates).
local function renderSync(music, budget)
while budget > 0 do
if not music.staging then
if music.engine:finished() then return end
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
if not ok or type(free) ~= "number" or free <= 0 then return end
music.staging = ChipSynth.newBuffer(MUSIC_BUFFER_SAMPLES, 2)
music.stagingFill = 0
end
local count = math.min(budget, MUSIC_BUFFER_SAMPLES - music.stagingFill)
ChipSynth.renderInto(music.engine, music.staging, music.stagingFill,
count, 2)
music.stagingFill = music.stagingFill + count
budget = budget - count
if music.stagingFill >= MUSIC_BUFFER_SAMPLES then
local sd = music.staging
music.staging, music.stagingFill = nil, 0
if not pcall(music.source.queue, music.source, sd) then return end
end
end
end
-- `buffers`: render that many whole buffers now (song start / restart);
-- omitted: one per-frame tick
local function fillSync(buffers)
if suspended then return end
local music = currentMusic
if not music or not music.engine or music.engine:finished() then return end
limit = limit or MUSIC_FILL_PER_CALL
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
if not ok or type(free) ~= "number" then return end
while free > 0 and limit > 0 and not music.engine:finished() do
local sd = ChipSynth.soundData(music.engine, MUSIC_BUFFER_SAMPLES, 2)
if not pcall(music.source.queue, music.source, sd) then return end
free = free - 1
limit = limit - 1
if not music or not music.engine then return end
if not music.staging and music.engine:finished() then return end
local budget
if buffers then
budget = buffers * MUSIC_BUFFER_SAMPLES - (music.stagingFill or 0)
else
budget = tickSamples()
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
if ok and type(free) == "number"
and MUSIC_BUFFER_COUNT - free < MUSIC_FILL_LOW_WATER then
budget = MUSIC_BUFFER_SAMPLES
end
end
renderSync(music, budget)
end
local function playMusicSync(data, header, allowLoops)
@@ -360,6 +415,7 @@ end
function ChipAudio.update()
if suspended then return end
if fxCh then drainEffects() end
local m = currentMusic
if not m then return end
if m.threaded then
@@ -446,6 +502,7 @@ end
function ChipAudio.invalidate()
ChipAudio.stopMusic()
ChipSynth.invalidateBanks()
dropEffects()
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
end
@@ -458,6 +515,8 @@ function ChipAudio.shutdown()
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, outCh = nil, nil, nil
workerReady = nil
dropEffects()
fxCh = nil
end
function ChipAudio.currentSource()
@@ -500,6 +559,8 @@ function ChipAudio.setStereo(enabled)
local m = currentMusic
if m and m.engine then
ChipSynth.applyStereo(m.engine)
-- a half-rendered fallback buffer holds the previous pan mix (#1471)
m.staging, m.stagingFill = nil, 0
end
if workerReady and cmdCh then
cmdCh:push({ cmd = "channelMix",
@@ -619,31 +680,161 @@ Assets.register(ChipAudio.invalidate)
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
-- ---------------------------------------------------------------------------
-- Prewarm (#first-cry hitch): the first play of an SFX/cry synthesizes its
-- whole PCM on the main thread (a cry is ~10-25 ms of Lua synthesis, a long
-- jingle 150-350 ms). ChipAudio.prewarmSfx / prewarmCry hand that render to
-- the music worker ahead of time -- e.g. while a battle transition runs, for
-- the two species about to cry -- and the next newSfx/newCry with the same
-- def, modifiers and channel mix takes the finished SoundData instead of
-- rendering. Playback timing is unchanged: a play never waits on the worker
-- (a request still in flight is rendered synchronously as before and its
-- late result dropped), and the worker runs the same ChipSynth code with the
-- same rate/mix, so the PCM is identical. No worker (headless, love.thread
-- unavailable or dead): prewarm is a no-op and plays render as before.
local function effectKey(data, header, options)
if type(header) ~= "table" then return nil end
local volumes = ChipSynth.getChannelVolumes()
local pitches = ChipSynth.getChannelPitches()
-- table identities: a reloaded def (or dataset) is a new table
return table.concat({
tostring(data and data.audio), tostring(header.chip or header),
tostring(options.frequencyOffset), tostring(options.frameTicks),
tostring(options.plainFrames), tostring(options.cryLength),
tostring(options.maxSeconds),
sampleRate(), ChipSynth.getStereo() and 1 or 0,
volumes[1], volumes[2], volumes[3], volumes[4],
pitches[1], pitches[2], pitches[3], pitches[4], fxEpoch,
}, "|")
end
function drainEffects()
if not fxCh then return end
while true do
local result = fxCh:pop()
if not result then return end
if result.epoch == fxEpoch and fxPending[result.key] then
fxPending[result.key] = nil
if result.error then
require("src.core.Logger").warn("chip audio prewarm: %s",
tostring(result.error))
elseif fxReadyCount < FX_READY_MAX then
fxReady[result.key] = result.sd or false
fxReadyCount = fxReadyCount + 1
end
end
end
end
function dropEffects()
fxEpoch = fxEpoch + 1
fxReady, fxPending, fxReadyCount = {}, {}, 0
if fxCh then fxCh:clear() end
end
local function requestEffect(data, header, options)
if not ensureWorker() or not workerAlive() then return false end
local key = effectKey(data, header, options)
if not key then return false end
drainEffects()
if fxReady[key] ~= nil or fxPending[key] then return true end
fxCh = fxCh or love.thread.getChannel("chipaudio_fx")
local pushed = pcall(cmdCh.push, cmdCh, {
cmd = "effect", key = key, epoch = fxEpoch, header = header,
options = {
frequencyOffset = options.frequencyOffset,
frameTicks = options.frameTicks,
plainFrames = options.plainFrames,
cryLength = options.cryLength,
maxSeconds = options.maxSeconds,
},
audio = slimAudio(data),
channelVolumes = ChipSynth.getChannelVolumes(),
channelPitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo(),
sampleRate = sampleRate(),
})
if not pushed then return false end
fxPending[key] = true
return true
end
-- a finished prewarm for exactly this render, or nil
local function takeEffect(data, header, options)
if not fxCh then return nil end
local key = effectKey(data, header, options)
if not key then return nil end
drainEffects()
local sd = fxReady[key]
if sd ~= nil then
fxReady[key] = nil
fxReadyCount = fxReadyCount - 1
return sd
end
-- about to render it here; the worker's copy would arrive unused
fxPending[key] = nil
return nil
end
local function renderEffect(data, header, options)
local sd = ChipSynth.renderEffectData(data, header, options)
local sd = takeEffect(data, header, options)
if sd == nil then sd = ChipSynth.renderEffectData(data, header, options) end
if not sd then return nil end
return love.audio.newSource(sd, "static")
end
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
header = header or data.audio.sfx[name]
return renderEffect(data, header, {
local function sfxOptions(pitch, tempo, plainFrames)
return {
frequencyOffset = pitch or 0,
frameTicks = 0x80 + (tempo or 0x80),
plainFrames = plainFrames,
})
}
end
local function cryDef(data, species, resolved)
return resolved or (data.audio.cries and data.audio.cries[species])
end
local function cryOptions(cry)
return { frequencyOffset = cry.pitch, cryLength = cry.length }
end
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
header = header or data.audio.sfx[name]
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
end
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
-- out -- a derived cry borrowing another species' header with its own
-- modifiers, which no registry lookup under `species` could find
function ChipAudio.newCry(data, species, resolved)
local cry = resolved or (data.audio.cries and data.audio.cries[species])
local cry = cryDef(data, species, resolved)
if not cry then return nil end
return renderEffect(data, cry.chip and cry or cry.header, {
frequencyOffset = cry.pitch,
cryLength = cry.length,
})
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry))
end
-- Same arguments as newSfx / newCry; returns true when a render was queued
-- on the worker (or is already queued / done), false when there is no worker
-- to do it. Never renders on the calling thread.
function ChipAudio.prewarmSfx(data, name, pitch, tempo, header, plainFrames)
header = header or (data.audio and data.audio.sfx and data.audio.sfx[name])
if not header then return false end
return requestEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
end
function ChipAudio.prewarmCry(data, species, resolved)
local cry = data.audio and cryDef(data, species, resolved)
if not cry then return false end
local header = cry.chip and cry or cry.header
if not header then return false end
return requestEffect(data, header, cryOptions(cry))
end
-- test hook: prewarm bookkeeping
function ChipAudio._effectStateForTest()
local pending = 0
for _ in pairs(fxPending) do pending = pending + 1 end
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch }
end
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
@@ -659,6 +850,7 @@ function ChipAudio.newLowHealthAlarm()
local SAMPLE_RATE = sampleRate()
local samples = math.floor(SAMPLE_RATE * 60 / 60)
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
local pointer = ChipSynth._int16Pointer(data)
local phase = 0
for index = 0, samples - 1 do
local frame = math.floor(index * 60 / SAMPLE_RATE) % 30
@@ -666,8 +858,14 @@ function ChipAudio.newLowHealthAlarm()
local frequency = 131072 / (2048 - register)
phase = (phase + frequency / SAMPLE_RATE) % 1
local value = (phase < 0.5 and 1 or -1) * 0.25
data:setSample(index, 1, value)
data:setSample(index, 2, value)
if pointer then
-- same int16 setSample stores (ChipSynth bulk PCM writes)
pointer[index * 2] = value * 32767
pointer[index * 2 + 1] = value * 32767
else
data:setSample(index, 1, value)
data:setSample(index, 2, value)
end
end
return love.audio.newSource(data, "static")
end
+118 -8
View File
@@ -1407,12 +1407,68 @@ function Engine:sampleChannel(number)
return analogOut(self, selected, "hpfCap", "lpf")
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
-- is available on worker threads, so this is the hand-off unit the worker
-- produces and the main thread queues.
local function soundData(engine, samples, channels)
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
for index = 0, samples - 1 do
-- Bulk PCM writes. SoundData:setSample is a C call per sample (two per
-- stereo frame); at 44100 Hz that was a large slice of every rendered buffer.
-- Where LuaJIT's ffi and Data:getFFIPointer exist, samples go straight into
-- the SoundData's int16 storage instead. LOVE 11.5's 16-bit setSample stores
-- (int16)(value * 32767) in double precision (truncating, no clamp), which is
-- exactly what an ffi int16 store of `value * 32767` does, so the PCM is
-- bit-identical to the setSample path (tests/chip_synth_bulk_pcm_test.lua,
-- plus a real-LOVE comparison over 2M values incl. out-of-range and NaN).
-- The setSample path stays as the fallback (headless stub, no ffi).
local ffiOk, ffi = pcall(require, "ffi")
local bulkWrites = true
function ChipSynth._setBulkWritesForTest(enabled)
bulkWrites = not not enabled
end
local function int16Pointer(sd)
if not (bulkWrites and ffiOk and ffi and sd.getFFIPointer) then return nil end
if sd.getBitDepth then
local okDepth, depth = pcall(sd.getBitDepth, sd)
if not okDepth or depth ~= 16 then return nil end
end
local ok, pointer = pcall(sd.getFFIPointer, sd)
if not ok or pointer == nil then return nil end
return ffi.cast("int16_t *", pointer)
end
ChipSynth._int16Pointer = int16Pointer
-- does this runtime's SoundData expose writable int16 storage? Probed once
-- (a 1-frame buffer) so renderEffectData can pick its path before rendering.
local pointerSupport
local function bulkAvailable()
if not (bulkWrites and ffiOk and ffi) then return false end
if pointerSupport == nil then
local ok, probe = pcall(love.sound.newSoundData, 1, SAMPLE_RATE, 16, 2)
pointerSupport = (ok and probe and int16Pointer(probe)) and true or false
end
return pointerSupport
end
-- render `count` frames from `engine` into an existing SoundData starting at
-- frame `offset`. The synchronous music fallback fills one buffer a slice at
-- a time across frames (ChipAudio fillSync) so its per-frame cost is flat.
local function renderInto(engine, result, offset, count, channels)
local last = offset + count - 1
local pointer = int16Pointer(result)
if pointer then
if channels == 2 then
for index = offset, last do
local left, right = engine:sampleStereo()
pointer[index * 2] = left * 32767
pointer[index * 2 + 1] = right * 32767
end
else
for index = offset, last do
pointer[index] = engine:sample() * 32767
end
end
return result
end
for index = offset, last do
if channels == 2 then
local left, right = engine:sampleStereo()
result:setSample(index, 1, left)
@@ -1424,6 +1480,34 @@ local function soundData(engine, samples, channels)
return result
end
local function newBuffer(samples, channels)
return love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
-- is available on worker threads, so this is the hand-off unit the worker
-- produces and the main thread queues.
local function soundData(engine, samples, channels)
local result = newBuffer(samples, channels)
return renderInto(engine, result, 0, samples, channels)
end
-- reusable int16 scratch for renderEffectData's bulk path: the effect length
-- is only known once the engine finishes, so samples are converted as they
-- are produced and copied (duplicated to both channels) at the end, instead
-- of growing a Lua table of doubles per render
local scratch, scratchSize = nil, 0
local function ensureScratch(size)
if scratchSize >= size then return scratch end
local grown = math.max(size, scratchSize * 2, 4096)
local fresh = ffi.new("int16_t[?]", grown)
if scratch and scratchSize > 0 then
ffi.copy(fresh, scratch, scratchSize * 2)
end
scratch, scratchSize = fresh, grown
return scratch
end
local function renderEffectData(data, header, options)
if not header then return nil end
@@ -1433,14 +1517,38 @@ local function renderEffectData(data, header, options)
local engine = Engine.new(data, header, options)
-- audio/sfx/pokeflute.asm:20
local maximum = SAMPLE_RATE * (options.maxSeconds or 12)
local minimum = math.floor(SAMPLE_RATE / 100)
if bulkAvailable() then
local values = ensureScratch(SAMPLE_RATE)
local count = 0
while count < maximum and not engine:finished() do
if count >= scratchSize then values = ensureScratch(count + 1) end
values[count] = engine:sample() * 32767
count = count + 1
end
if count < minimum then return nil end
local result = newBuffer(count, 2)
local pointer = int16Pointer(result)
if pointer then
for index = 0, count - 1 do
local value = values[index]
pointer[index * 2] = value
pointer[index * 2 + 1] = value
end
return result
end
-- the probe said yes but this buffer has no pointer: re-render through
-- setSample below (truncated int16s cannot be turned back into floats)
engine = Engine.new(data, header, options)
end
local values = {}
local count = 0
while count < maximum and not engine:finished() do
count = count + 1
values[count] = engine:sample()
end
if count < math.floor(SAMPLE_RATE / 100) then return nil end
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2)
if count < minimum then return nil end
local result = newBuffer(count, 2)
for index = 1, count do
local value = values[index]
result:setSample(index - 1, 1, value)
@@ -1451,6 +1559,8 @@ end
ChipSynth.newEngine = Engine.new
ChipSynth.soundData = soundData
ChipSynth.renderInto = renderInto
ChipSynth.newBuffer = newBuffer
ChipSynth.renderEffectData = renderEffectData
function ChipSynth.applyStereo(engine)
+12 -1
View File
@@ -309,6 +309,10 @@ function Game:breakLink(err, source)
end
function Game:step(dt)
-- A monotonic count of fixed logic steps, for presentation counters that
-- are sampled from draw code but must advance at the 60Hz step rate
-- rather than the display refresh (Player:pose's surf bob).
Game.logicStep = (Game.logicStep or 0) + 1
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
-- Input:step promotes queued edges so a button chosen here is visible to
@@ -413,6 +417,13 @@ function Game:logicSpeed()
resolveLogicSpeedVanilla, self))
end
-- Discord Rich Presence tick, pcall'd by Game:update: a named function so
-- the per-frame call builds no closure (the require stays inside the pcall,
-- so a missing/broken module still soft-fails exactly as before)
local function discordPresenceUpdate(dt)
require("src.core.DiscordPresence").update(dt)
end
function Game:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
@@ -437,7 +448,7 @@ function Game:update(dt)
-- mod render pipelines tween on the same real-frame clock, for the same
-- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
pcall(discordPresenceUpdate, dt)
self:updateSync(dt)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every few rendered frames. The heavy GPU objects are now
+56 -10
View File
@@ -55,6 +55,36 @@ local SaveData = require("src.core.SaveData")
-- had when they required the module by hand.
local Screens = require("src.ui.Screens")
-- Already loaded by src/world/gen2/World.lua above; bound here so the
-- per-frame update/draw paths do not call require() every frame.
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
-- Resolved on first use rather than at load, keeping this file's load order.
local lazyModules = {}
local function lazy(name)
local mod = lazyModules[name]
if mod == nil then
mod = require(name)
lazyModules[name] = mod
end
return mod
end
-- DiscordPresence is optional: a require that fails once is not retried
-- every frame, and no closure is minted per frame to guard the call.
local discordPresence = nil
local function discordUpdate(dt)
if discordPresence == nil then
local ok, mod = pcall(require, "src.core.DiscordPresence")
discordPresence = (ok and type(mod) == "table") and mod or false
end
if discordPresence and discordPresence.update then
pcall(discordPresence.update, dt)
end
end
local Game2 = {}
Game2.__index = Game2
@@ -1254,6 +1284,11 @@ function Game2:load(opts)
self:softReset()
return
end
-- LoadPoisonBGPals' four-frame hold and the void-fill dissolve are spent
-- here, on the logic clock and whatever is on top (the faint text can
-- open the same frame), rather than once per render frame in World:draw.
local world = self.world
if world and world.tickFrameClocks then world:tickFrameClocks() end
-- Not the audio tick: _UpdateSound runs once per frame off VBlank
-- (audio/engine.asm:84, home/vblank.asm:141-143), never off the logic clock.
local top = self.stack:top()
@@ -1340,12 +1375,12 @@ function Game2:update(dt)
end
-- TILT eases toward its new angle in real time, not on the logic clock, so
-- fast-forward does not fling the camera over.
require("src.render.Tilt").update(dt)
Tilt.update(dt)
-- Mod render pipelines tween on the same real-frame clock, for the same
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
-- they are presentational, so fast-forward must not speed them up.
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
Pipelines.update(dt)
discordUpdate(dt)
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
-- audio runs off its own real-time accumulator, so music and sfx keep their
-- tempo at every multiplier (#1990/#1991/#1997). speedOverride is the
@@ -1488,7 +1523,6 @@ end
-- present pass has always been.
function Game2:blitZones(canvas, zones, w, h)
local G = love.graphics
local GbcPalette = require("src.render.GbcPalette")
local px, py, pw, ph = Playfield.rect(w, h)
local sx, sy = pw / 160, ph / 144
G.setColor(1, 1, 1, 1)
@@ -1599,7 +1633,7 @@ end
-- the grid itself.
function Game2:drawViewportFrame()
local G = love.graphics
local serial = require("src.core.FaithfulRes").modeSerial
local serial = lazy("src.core.FaithfulRes").modeSerial
if serial ~= (self.modeSerial or 0) then
if self.world and self.world.map then
self.world:dropBakes()
@@ -1607,9 +1641,7 @@ function Game2:drawViewportFrame()
self.modeSerial = serial
end
local w, h = GameViewport.dimensions()
local ShaderFX = require("src.render.ShaderFX")
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
local ShaderFX = lazy("src.render.ShaderFX")
-- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1
-- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed).
local shaderfx = ShaderFX.active()
@@ -1626,10 +1658,24 @@ function Game2:drawViewportFrame()
-- lighting mod written against Gen 1 tints Gold through the same seam.
local zones = nil
local classic = GbcPalette.available() and GbcPalette.presentColors() or nil
local zoneHook = ModRuntime.wantsHook("render.zones")
if classic then
zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } }
if zoneHook or ModRuntime.wantsHook("render.compose") then
-- A mod may keep or edit the list it is handed: give it a fresh one.
zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } }
else
-- Nobody else sees it, so the one list is reused frame to frame.
local zone = self._classicZone
if not zone then
zone = { x = 0, y = 0, w = 160, h = 144 }
self._classicZone = zone
self._classicZones = { zone }
end
zone.colors = classic
zones = self._classicZones
end
end
if ModRuntime.wantsHook("render.zones") then
if zoneHook then
zones = ModRuntime.call("render.zones", sameZones, self, zones)
end
local zoned = type(zones) == "table" and zones[1] ~= nil
+40 -3
View File
@@ -331,6 +331,11 @@ local function anyPadDown(pads, button)
return false
end
-- a joystick was added or removed: rebuild pollPads' list on the next poll
function Input:joysticksChanged()
self._pollPads = nil
end
-- pokefirered/src/main.c:296
function Input:pollPads()
local js = love and love.joystick
@@ -341,7 +346,24 @@ function Input:pollPads()
return
end
local pads = self._pollPads
if not pads or self._pollPadCount ~= count then
-- The count alone cannot see one controller swapped for another between
-- two polls; a cached pad that has gone away forces the rebuild too (and
-- main.lua's joystickadded/removed drop the cache via
-- Input:joysticksChanged).
local stale = not pads or self._pollPadCount ~= count
if not stale then
for k = 1, #pads do
local j = pads[k]
if j.isConnected then
local okConn, connected = pcall(j.isConnected, j)
if okConn and not connected then
stale = true
break
end
end
end
end
if stale then
pads = {}
local ok, list = pcall(js.getJoysticks)
if ok and type(list) == "table" then
@@ -390,9 +412,24 @@ function Input:pollPads()
end
end
-- step's two per-step tables are recycled rather than reallocated, but only
-- while they are still the ones step itself installed: a test or driver that
-- assigns its own `pressed` / `pressQueue` gets a fresh table afterwards,
-- exactly as before, and never sees its own table emptied.
local function ownedCleared(self, field, ownKey)
local own = self[ownKey]
if own ~= nil and self[field] == own then
for k in pairs(own) do own[k] = nil end
else
own = {}
self[ownKey] = own
end
self[field] = own
end
function Input:step()
self:pollPads()
self.pressed = {}
ownedCleared(self, "pressed", "_ownPressed")
for _, btn in ipairs(self.pressQueue) do
self.pressed[btn] = true
local sources = self.sources[btn]
@@ -409,7 +446,7 @@ function Input:step()
self.sources[btn] = nil
end
end
self.pressQueue = {}
ownedCleared(self, "pressQueue", "_ownPressQueue")
-- pokefirered/src/main.c:325
local aliases = self.aliases
local held = nil
+4 -1
View File
@@ -7,8 +7,11 @@ local ModRuntime = require("src.mods.Runtime")
local PlatformHooks = {}
-- hoisted: update runs every frame, and the vanilla step closes over nothing
local function vanillaUpdate(g, d) g:update(d) end
function PlatformHooks.update(game, dt)
return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt)
return ModRuntime.call("core.update", vanillaUpdate, game, dt)
end
function PlatformHooks.quitToLauncher(vanilla)
+39
View File
@@ -827,6 +827,45 @@ function Sound.playCry(data, species, pikaClip)
return src
end
-- Prewarm: the first play of a chip cry/SFX synthesizes it on the calling
-- frame (~10-25 ms for a cry, far more for a long jingle), which is the hitch
-- at a battle's first cry. These hand that render to the chip audio worker
-- ahead of time (ChipAudio.prewarmCry/prewarmSfx); the play that follows
-- builds its Source from the finished PCM, identical to a fresh render. Call
-- at an idle point before the sound is needed (battle creation / the
-- transition, for both species). Returns true when a render was queued or
-- the sound is already cached; never renders on the calling thread, and a
-- play that comes first simply renders as before.
function Sound.prewarmCry(data, species)
if not love.audio or deviceSuspended() then return false end
if cache["cry:" .. tostring(species)] ~= nil then return true end
local audio = data and data.audio
local def = audio and audio.cries and audio.cries[species]
if not def then return false end
-- pokeyellow/home/pokemon.asm:140 -- voiced by a PCM clip instead
if species == "PIKACHU" and audio.pikaCries then return false end
local resolved = resolveCry(data, def, 0)
if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then
return false
end
local ok, queued = pcall(
require("src.core.ChipAudio").prewarmCry, data, species, resolved)
return ok and queued or false
end
-- the plain Sound.play variant of `name` (no pitch/tempo modifiers)
function Sound.prewarmSfx(data, name)
if not love.audio or deviceSuspended() then return false end
name = Sound.resolve(data, name)
if cache[name] ~= nil then return true end
local sfx = data and data.audio and data.audio.sfx
local def = sfx and sfx[name]
if not isChipDef(def) then return false end
local ok, queued = pcall(
require("src.core.ChipAudio").prewarmSfx, data, name, nil, nil, def, nil)
return ok and queued or false
end
-- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks
-- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/
-- tempo bytes on top of the cry's species modifiers before the tempo
+43 -1
View File
@@ -11,6 +11,11 @@
-- cmd = "channelMix" { volumes, pitches, stereo?, stereoEpoch? }
-- stereoEpoch present: live SOUND toggle; drop lookahead
-- cmd = "invalidate" drop the bank cache
-- cmd = "effect" { key, epoch, header, options, audio, channelVolumes,
-- channelPitches, stereo, sampleRate }
-- prewarm one SFX/cry (ChipSynth.renderEffectData); the
-- result goes to "chipaudio_fx" as
-- { key, epoch, sd = SoundData|nil } or { key, epoch, error }
-- cmd = "quit" end the thread
-- out buffers are tagged with the play's `gen` so the main thread can
-- discard anything left over from a superseded song:
@@ -37,6 +42,7 @@ local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))()
local cmdCh = love.thread.getChannel("chipaudio_cmd")
local outCh = love.thread.getChannel("chipaudio_out")
local fxCh = love.thread.getChannel("chipaudio_fx")
local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES
local BUF_SECONDS = BUF / ChipSynth.SAMPLE_RATE
@@ -50,10 +56,37 @@ local engine = nil -- the ChipSynth engine producing the current song
local finished = false -- the current song ran out (non-looping)
local data = nil -- { audio = <slim audio tables> } for ROM bank/wave reads
local stereoEpoch = 0 -- matches ChipAudio; stale pan buffers are dropped
local effects = {} -- queued "effect" prewarm requests, oldest first
local produced = 0 -- buffers rendered for the current song so far
-- a song start outranks a prewarm: the first MUSIC_PREROLL buffers gate when
-- the song is heard, a prewarmed cry only has to be done before its play
local PREROLL = 4
local function renderEffect(req)
if req.sampleRate ~= nil then
BUF_SECONDS = BUF / ChipSynth.setSampleRate(req.sampleRate)
end
if req.channelVolumes ~= nil then
ChipSynth.setChannelVolumes(req.channelVolumes)
end
if req.channelPitches ~= nil then
ChipSynth.setChannelPitches(req.channelPitches)
end
if req.stereo ~= nil then ChipSynth.setStereo(req.stereo) end
local ok, sd = pcall(ChipSynth.renderEffectData, { audio = req.audio },
req.header, req.options or {})
if ok then
fxCh:push({ key = req.key, epoch = req.epoch, sd = sd or nil })
else
fxCh:push({ key = req.key, epoch = req.epoch, error = tostring(sd) })
end
end
local function handle(cmd)
if cmd.cmd == "play" then
gen = cmd.gen
produced = 0
finished = false
engine = nil
outCh:clear() -- drop any buffers left from the previous song
@@ -95,6 +128,9 @@ local function handle(cmd)
end
elseif cmd.cmd == "invalidate" then
ChipSynth.invalidateBanks()
effects = {}
elseif cmd.cmd == "effect" then
effects[#effects + 1] = cmd
elseif cmd.cmd == "quit" then
return true
end
@@ -113,7 +149,12 @@ while true do
end
if quit then break end
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
local musicWants = engine and not finished and gen
and outCh:getCount() < LOOKAHEAD
if #effects > 0 and not (musicWants and produced < PREROLL) then
idleWait = false
renderEffect(table.remove(effects, 1))
elseif musicWants then
idleWait = false
local activeGen = gen
local began = love.timer.getTime()
@@ -123,6 +164,7 @@ while true do
stereoEpoch = stereoEpoch, jit = jitEnabled })
finished = true
else
produced = produced + 1
outCh:push({ gen = activeGen, sd = sd, stereoEpoch = stereoEpoch,
jit = jitEnabled,
xrt = (love.timer.getTime() - began) / BUF_SECONDS })
+69 -6
View File
@@ -807,6 +807,7 @@ function Audio._seRawClear()
Audio._seRaw = {}
Audio._seRawFrames = 0
Audio._seRawTick = 0
if Audio._seSourceClear then Audio._seSourceClear() end
end
function Audio._seRawGet(id)
@@ -839,6 +840,72 @@ function Audio._seRawPut(id, loop, rawL, rawR, loopStart)
end
end
-- Static Sources for one-shot / looping SEs, keyed by everything baked into
-- their PCM (id, pan, master gain, mono, loop). A repeat SE (wall bump, ball
-- placement, menu cursor) used to rebuild its SoundData sample by sample and
-- upload a new Source on every play; now a cached Source is rewound and
-- replayed, or cloned (shares the uploaded buffer) while the cached one is
-- still live on another player slot. Entries are tied to the memoized raw
-- PCM tables, so an evicted/rebaked SE never reuses a stale Source.
Audio.SE_SOURCE_CACHE_MAX = 32
function Audio._seSourceClear()
Audio._seSrcCache = {}
Audio._seSrcCount = 0
Audio._seSrcTick = 0
end
function Audio._seSourceFor(id, rawL, rawR, master, pan, mono, loop)
if not (love and love.audio and love.audio.newSource) then return nil end
local hit = Audio._seRaw and Audio._seRaw[id]
local cacheable = hit and hit.rawL == rawL and hit.rawR == rawR
local key = cacheable and table.concat({ tostring(id), tostring(pan),
tostring(master), mono and "m" or "s", loop and "l" or "o" }, ":")
if not Audio._seSrcCache then Audio._seSourceClear() end
local entry = key and Audio._seSrcCache[key]
if entry and entry.rawL == rawL then
Audio._seSrcTick = Audio._seSrcTick + 1
entry.tick = Audio._seSrcTick
local src = entry.src
if Audio._seMeta[src] then
-- still tracked as a live SE: play a copy rather than cut it off
local ok, copy = pcall(function() return src:clone() end)
src = ok and copy or nil
end
if src then
pcall(function() src:stop() end)
if loop then pcall(function() src:setLooping(true) end) end
return src
end
key = nil -- no clone support: build a one-off, keep the cached entry
end
local sd = Audio._buildSeSoundData(rawL, rawR, master, pan, mono)
if not sd then return nil end
local src = love.audio.newSource(sd, "static")
if loop then
pcall(function() src:setLooping(true) end)
end
if key then
if not Audio._seSrcCache[key] then
Audio._seSrcCount = Audio._seSrcCount + 1
end
Audio._seSrcTick = Audio._seSrcTick + 1
Audio._seSrcCache[key] = { src = src, rawL = rawL, tick = Audio._seSrcTick }
while Audio._seSrcCount > Audio.SE_SOURCE_CACHE_MAX do
local oldKey, oldTick
for k, e in pairs(Audio._seSrcCache) do
if k ~= key and (oldTick == nil or e.tick < oldTick) then
oldKey, oldTick = k, e.tick
end
end
if oldKey == nil then break end
Audio._seSrcCache[oldKey] = nil
Audio._seSrcCount = Audio._seSrcCount - 1
end
end
return src
end
function Audio.playSe(id, opts)
opts = opts or {}
id = SE.resolve(id)
@@ -902,12 +969,8 @@ function Audio.playSe(id, opts)
if loop and loopStart and love and love.audio and love.audio.newQueueableSource then
src, body = Audio._newIntroLoopSource(rawL, rawR, loopStart, master, pan, Audio._mono)
end
local sd = not src and Audio._buildSeSoundData(rawL, rawR, master, pan, Audio._mono)
if sd and love and love.audio and love.audio.newSource then
src = love.audio.newSource(sd, "static")
if loop then
pcall(function() src:setLooping(true) end)
end
if not src then
src = Audio._seSourceFor(id, rawL, rawR, master, pan, Audio._mono, loop)
end
if src then
src:setVolume(1)
+37 -1
View File
@@ -517,12 +517,34 @@ function Bag.set(bag, id, qty)
return Bag.get(bag, id)
end
-- listPocket rows per bag and pocket, rebuilt only when the pocket's
-- (id, qty) slots or the loaded item pack change. Bag menus call listPocket
-- every frame; each row costs three ItemsData.info lookups.
local rowCache = setmetatable({}, { __mode = "k" })
local function rows_match(entry, slots, byId)
if entry.byId ~= byId then return false end
local ids, qtys, n = entry.ids, entry.qtys, entry.n
for i = 1, n do
local slot = slots[i]
if slot == nil or slot.id ~= ids[i] or slot.qty ~= qtys[i] then return false end
end
return slots[n + 1] == nil
end
--- Ordered list of { id, qty, name, info } for a pocket (bag UI).
-- The returned rows are shared between calls until the pocket changes, so
-- callers must not modify them.
function Bag.listPocket(bag, pocket)
bag = ensure(bag)
pocket = pocket or "ITEMS"
local slots = bag.pockets[pocket] or {}
local byId = ItemsData._byId
local perBag = rowCache[bag]
local entry = perBag and perBag[pocket]
if entry and byId and rows_match(entry, slots, byId) then return entry.rows end
local rows = {}
for _, slot in ipairs(bag.pockets[pocket] or {}) do
for _, slot in ipairs(slots) do
local qty = tonumber(slot.qty) or 0
if slot.id and qty > 0 then
rows[#rows + 1] = {
@@ -534,6 +556,20 @@ function Bag.listPocket(bag, pocket)
}
end
end
byId = ItemsData._byId
if byId then
local n = 0
local ids, qtys = {}, {}
for i, slot in ipairs(slots) do
n = i
ids[i], qtys[i] = slot.id, slot.qty
end
if not perBag then
perBag = {}
rowCache[bag] = perBag
end
perBag[pocket] = { byId = byId, n = n, ids = ids, qtys = qtys, rows = rows }
end
return rows
end
+12
View File
@@ -1066,4 +1066,16 @@ function Anim.drawParticles(minZ, maxZ)
if Anim._vm then Anim._vm:draw(minZ, maxZ) end
end
--- Bracket one frame's z-band drawParticles calls so the VM sorts its
--- sprites once per frame instead of once per band.
function Anim.beginParticleFrame()
local vm = Anim._vm
if vm and vm.beginDrawFrame then vm:beginDrawFrame() end
end
function Anim.endParticleFrame()
local vm = Anim._vm
if vm and vm.endDrawFrame then vm:endDrawFrame() end
end
return Anim
+9 -1
View File
@@ -17,9 +17,17 @@ AnimPal.loaded = {}
AnimPal.idxOf = setmetatable({}, { __mode = "k" })
AnimPal._pack = nil
-- Normalised tags by raw tag (strings and numbers only); tags come from a
-- fixed set, and norm runs for every sprite drawn.
local _normCache = {}
local function norm(tag)
if tag == nil then return nil end
return (tostring(tag):upper():gsub("^ANIM_TAG_", ""))
local hit = _normCache[tag]
if hit then return hit end
local out = (tostring(tag):upper():gsub("^ANIM_TAG_", ""))
local t = type(tag)
if t == "string" or t == "number" then _normCache[tag] = out end
return out
end
AnimPal.norm = norm
+8 -1
View File
@@ -745,7 +745,14 @@ P.tagInfo = tag_info
local function quad_for(img, sx, sy, w, h)
local iw, ih = img:getDimensions()
local key = sx .. ":" .. sy .. ":" .. w .. ":" .. h
-- Numeric key for whole-pixel rects (13+13+10+10 bits), string otherwise.
local key
if sx >= 0 and sx < 8192 and sy >= 0 and sy < 8192 and w >= 0 and w < 1024 and h >= 0 and h < 1024
and sx % 1 == 0 and sy % 1 == 0 and w % 1 == 0 and h % 1 == 0 then
key = ((sx * 8192 + sy) * 1024 + w) * 1024 + h
else
key = sx .. ":" .. sy .. ":" .. w .. ":" .. h
end
local c = SHEET_CACHE[img]
if not c then c = {}; SHEET_CACHE[img] = c end
local q = c[key]
+10 -1
View File
@@ -1416,13 +1416,22 @@ local function overlayResources()
return overlayRes
end
-- One quad re-pointed per draw (draw reads the viewport immediately).
local windowSpriteQuad = nil
local function drawWindowSprite(sp)
local img = sp.image
if not img or sp.invisible or not sp.active then return end
local bw = sp._baseW or sp.w or 32
local bh = sp._baseH or sp.h or 32
local iw, ih = img:getDimensions()
local q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
local q = windowSpriteQuad
if q then
q:setViewport(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
else
q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih)
windowSpriteQuad = q
end
local sx = (sp.scaleX or 1) * (sp.hFlip and -1 or 1)
local sy = (sp.scaleY or 1) * (sp.vFlip and -1 or 1)
love.graphics.draw(img, q, math.floor(sp.x + (sp.ox or 0) + 0.5), math.floor(sp.y + (sp.oy or 0) + 0.5),
+122 -43
View File
@@ -613,6 +613,26 @@ local function effective_z(vm, s)
return AnimCoords.layerZ(sub, vm._monbg, vm._bgPrio)
end
-- Last uniforms sent to the shared blend shader. Every send goes through
-- send_blend so the cache always matches what the shader holds (the anim bg
-- and sprites share one shader).
local _lastBlendShader = nil
local _lastBlendCoeff = nil
local _lastBlendR = nil
local _lastBlendG = nil
local _lastBlendB = nil
local function send_blend(sh, coeff, r, g, b)
if sh ~= _lastBlendShader or coeff ~= _lastBlendCoeff or r ~= _lastBlendR
or g ~= _lastBlendG or b ~= _lastBlendB then
_lastBlendShader = sh
_lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b
pcall(sh.send, sh, "coeff", coeff)
pcall(sh.send, sh, "target", { r, g, b })
end
end
AnimVm._sendBlend = send_blend
local function draw_anim_bg(vm)
local id = vm._animBgId
local tintBg = vm._animBgBlend
@@ -638,10 +658,7 @@ local function draw_anim_bg(vm)
local sh = tint and (tint.coeff or 0) > 0 and blend_shader()
if sh then
local c = tonumber(tint.color) or 0
pcall(function()
sh:send("coeff", tint.coeff)
sh:send("target", { band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31) })
end)
send_blend(sh, tint.coeff, band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31))
love.graphics.setShader(sh)
end
love.graphics.draw(img, vm._bgQuad, 0, 0)
@@ -657,11 +674,6 @@ local function draw_anim_bg(vm)
love.graphics.setColor(1, 1, 1, 1)
end
local _lastBlendCoeff = nil
local _lastBlendR = nil
local _lastBlendG = nil
local _lastBlendB = nil
local function particle_sort_cmp(a, b)
if a._drawZ ~= b._drawZ then return a._drawZ < b._drawZ end
return (a._poolIndex or 0) > (b._poolIndex or 0)
@@ -694,12 +706,7 @@ local function draw_sprite(self, s, a)
local sh = (not pimg) and tint and blend_shader()
if sh then
local r, g, b = band(tint.color, 31), band(rshift(tint.color, 5), 31), band(rshift(tint.color, 10), 31)
local coeff = tint.coeff
if coeff ~= _lastBlendCoeff or r ~= _lastBlendR or g ~= _lastBlendG or b ~= _lastBlendB then
_lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b
pcall(sh.send, sh, "coeff", coeff)
pcall(sh.send, sh, "target", { r, g, b })
end
send_blend(sh, tint.coeff, r, g, b)
love.graphics.setShader(sh)
end
local q = sprite_source_quad(s, bw, bh)
@@ -712,44 +719,115 @@ local function draw_sprite(self, s, a)
end
end
-- Blend mode currently set by AnimVm:draw (reset to "alpha" per call).
local _activeBlend = "alpha"
local function set_blend_mode(mode)
if mode ~= _activeBlend then
if mode == "add" then
love.graphics.setBlendMode("add", "alphamultiply")
else
love.graphics.setBlendMode("alpha", "alphamultiply")
end
_activeBlend = mode
end
end
-- Every active, visible sprite with its effective z, sorted once. The sort
-- is a strict total order (z, then pool index), so slicing this list by a z
-- band gives the same order as sorting that band on its own.
local function build_sorted_sprites(self, list)
for i = #list, 1, -1 do list[i] = nil end
AnimSprites.init()
local pool = AnimSprites._pool
local n = 0
for i = 1, AnimSprites.MAX do
local s = pool[i]
s._poolIndex = i
if s.active and s.visible ~= false then
s._drawZ = effective_z(self, s)
n = n + 1
list[n] = s
end
end
if n > 1 then table.sort(list, particle_sort_cmp) end
return list
end
local function any_task_draw()
if not (AnimTasks and AnimTasks._pool) then return false end
local pool = AnimTasks._pool
for i = 1, AnimTasks.MAX do
local t = pool[i]
if t and t.active and t.draw then return true end
end
return false
end
--- Bracket the z-band draws of one frame (battle ui). Between begin and end
--- the sorted sprite list and the task scan are built once and reused by
--- every AnimVm:draw band call; outside a bracket each call rebuilds them.
function AnimVm:beginDrawFrame()
self._inDrawFrame = true
self._frameSorted = false
self._frameTasks = nil
end
function AnimVm:endDrawFrame()
self._inDrawFrame = false
self._frameSorted = false
self._frameTasks = nil
end
function AnimVm:draw(minZ, maxZ)
if not (love and love.graphics) then return end
if (minZ or 0) <= 0 then draw_anim_bg(self) end
if AnimTasks and AnimTasks.draw then
AnimTasks.draw(minZ, maxZ, self)
local hasTasks = true
if self._inDrawFrame then
if self._frameTasks == nil then self._frameTasks = any_task_draw() end
hasTasks = self._frameTasks
end
if hasTasks then AnimTasks.draw(minZ, maxZ, self) end
end
local list = self._drawList
for i = #list, 1, -1 do list[i] = nil end
AnimSprites.init()
for i = 1, AnimSprites.MAX do
local s = AnimSprites._pool[i]
s._poolIndex = i
if s.active and s.visible ~= false then
local z = effective_z(self, s)
local sorted = self._drawList
if not (self._inDrawFrame and self._frameSorted) then
build_sorted_sprites(self, sorted)
self._frameSorted = self._inDrawFrame and true or false
end
if #sorted == 0 then
love.graphics.setColor(1, 1, 1, 1)
return
end
local list = sorted
if minZ or maxZ then
list = self._bandList
if not list then
list = {}
self._bandList = list
end
for i = #list, 1, -1 do list[i] = nil end
local n = 0
for i = 1, #sorted do
local s = sorted[i]
local z = s._drawZ
if (not minZ or z >= minZ) and (not maxZ or z <= maxZ) then
s._drawZ = z
list[#list + 1] = s
n = n + 1
list[n] = s
end
end
if n == 0 then
love.graphics.setColor(1, 1, 1, 1)
return
end
end
table.sort(list, particle_sort_cmp)
local activeBlend = "alpha"
_activeBlend = "alpha"
local bld = self.bldAlpha
local function setMode(mode)
if mode ~= activeBlend then
if mode == "add" then
love.graphics.setBlendMode("add", "alphamultiply")
else
love.graphics.setBlendMode("alpha", "alphamultiply")
end
activeBlend = mode
end
end
for _, s in ipairs(list) do
for i = 1, #list do
local s = list[i]
local a = s.alpha or 1
local eva, evb = nil, nil
if s.objBlend and bld then
@@ -758,12 +836,12 @@ function AnimVm:draw(minZ, maxZ)
end
if eva and eva + evb ~= 16 and s.image and AnimPal.indexImage(s.image) then
-- pokefirered/src/battle_anim.c:630
setMode("alpha")
set_blend_mode("alpha")
AnimPal.blackPass = true
s._drawAlpha = a * (1 - evb / 16)
draw_sprite(self, s, s._drawAlpha)
AnimPal.blackPass = nil
setMode("add")
set_blend_mode("add")
s._drawAlpha = a * eva / 16
draw_sprite(self, s, s._drawAlpha)
else
@@ -772,14 +850,15 @@ function AnimVm:draw(minZ, maxZ)
if evb >= 16 and eva < 16 then desiredBlend = "add" end
a = a * eva / 16
end
setMode(desiredBlend)
set_blend_mode(desiredBlend)
s._drawAlpha = a
draw_sprite(self, s, a)
end
end
if activeBlend ~= "alpha" then
if _activeBlend ~= "alpha" then
love.graphics.setBlendMode("alpha", "alphamultiply")
_activeBlend = "alpha"
end
love.graphics.setColor(1, 1, 1, 1)
end
+10 -1
View File
@@ -136,8 +136,17 @@ local function display_hp_nums(side, battler)
return math.floor(hp), math.floor(maxHp)
end
-- One read-only FrlgFont opts table per colour set (FrlgFont never writes
-- to opts), so per-frame healthbox text does not allocate.
local _smallOpts = setmetatable({}, { __mode = "k" })
local function small_opts(colors)
return { small = true, colors = colors or HB_TEXT }
colors = colors or HB_TEXT
local o = _smallOpts[colors]
if not o then
o = { small = true, colors = colors }
_smallOpts[colors] = o
end
return o
end
local function erase_placeholder_ink(boxX, boxY, pts)
+19 -4
View File
@@ -2130,6 +2130,19 @@ local function rse_window_colors(fgIdx, shadowIdx)
}
end
-- The default (13, 15) window colours, rebuilt only when the manifest's
-- palette table changes. Callers must not modify the returned table
-- (rse_pp_colors, which does, builds its own with rse_window_colors).
local _defaultWinColors, _defaultWinPal = nil, nil
local function rse_default_colors()
local pal = BattleChrome.manifest().windowTextPal or nil
if not _defaultWinColors or pal ~= _defaultWinPal then
_defaultWinColors = rse_window_colors()
_defaultWinPal = pal
end
return _defaultWinColors
end
-- pokeemerald/src/battle_message.c:3033
local function rse_pp_colors(pp, maxPp)
local pp2 = BattleChrome.manifest().ppTextPal or {}
@@ -2147,7 +2160,7 @@ local function rse_text(win, text, dx, opts)
local useNarrow = opts.narrow == nil and narrow or opts.narrow
F.draw(tostring(text or ""), x + (dx or 0), y, {
font = useNarrow and "narrow" or nil,
colors = opts.colors or rse_window_colors(),
colors = opts.colors or rse_default_colors(),
})
end
@@ -2161,10 +2174,10 @@ local function draw_action_menu_rse(st)
local mx, my = BattleChrome.textOrigin(W.ACTION_MENU)
local c = Ui._menuIndex - 1
local col, row = c % 2, math.floor(c / 2)
Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_window_colors() })
Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_default_colors() })
for i = 1, 4 do
local cc, rr = (i - 1) % 2, math.floor((i - 1) / 2)
battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_window_colors() })
battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_default_colors() })
end
end
@@ -2175,7 +2188,7 @@ local function draw_move_menu_rse(st)
local mon = ab and ab.mon
local c = Ui._moveIndex - 1
local _, cy = BattleChrome.textOrigin(W.MOVE_NAME_1)
Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_window_colors() })
Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_default_colors() })
for i = 1, 4 do
local mv = mon and mon.moves and mon.moves[i]
local label = "-"
@@ -2537,6 +2550,7 @@ function Ui.draw(w, h)
-- 4. Player Mon (Z: 200)
-- 5. In front of Player & Global Foreground (Z: 201 .. 999)
local dbl = st and is_double(st)
if Anim.beginParticleFrame then Anim.beginParticleFrame() end
if dbl then
draw_double_mons(st, stage, Anim, screenFxActive)
else
@@ -2556,6 +2570,7 @@ function Ui.draw(w, h)
if screenFxActive then Anim.beginScreenEffect() end
Anim.drawParticles(201, 999)
end
if Anim.endParticleFrame then Anim.endParticleFrame() end
draw_intro_ball(stage)
-- pokefirered/src/pokeball.c:770
BallOpen.draw()
+10 -4
View File
@@ -1673,15 +1673,21 @@ local function drawFieldRows(R, G, fx)
local bx0, by0 = floor(R.X0 / m), floor(R.Y0 / m)
local cw = floor((R.X1 - 1) / m) - bx0 + 1
local ch = floor((R.Y1 - 1) / m) - by0 + 1
local key = cw .. "x" .. ch
-- One canvas per transition, grown only when a level needs more room;
-- each level draws just its cw x ch corner through a quad.
local small = BattleTransition._mosaicCanvas
if not small or BattleTransition._mosaicKey ~= key then
local key = BattleTransition._mosaicKey
if not small or type(key) ~= "table" or key.w < cw or key.h < ch then
if small and small.release then pcall(small.release, small) end
small = G.newCanvas(cw, ch, { dpiscale = 1 })
local nw = math.max(cw, type(key) == "table" and small and key.w or 0)
local nh = math.max(ch, type(key) == "table" and small and key.h or 0)
small = G.newCanvas(nw, nh, { dpiscale = 1 })
small:setFilter("nearest", "nearest")
key = { w = nw, h = nh, quad = G.newQuad(0, 0, cw, ch, nw, nh) }
BattleTransition._mosaicCanvas = small
BattleTransition._mosaicKey = key
end
key.quad:setViewport(0, 0, cw, ch, key.w, key.h)
G.push("all")
G.origin()
G.setCanvas(small)
@@ -1690,7 +1696,7 @@ local function drawFieldRows(R, G, fx)
G.draw(tex, -bx0 + 0.5 - (R.gx + 0.5) / m, -by0 + 0.5 - (R.gy + 0.5) / m, 0, 1 / m, 1 / m)
G.pop()
G.setColor(1, 1, 1, 1)
G.draw(small, bx0 * m, by0 * m, 0, m, m)
G.draw(small, key.quad, bx0 * m, by0 * m, 0, m, m)
return
end
G.draw(tex, R.X0, R.Y0)
+32 -2
View File
@@ -32,9 +32,14 @@ local function log(msg)
print("[game3/collision] " .. tostring(msg))
end
local permsLoaded, permsMod
local function permissions()
local ok, P = pcall(require, "src.world.gen2.Permissions")
return ok and P or nil
if not permsLoaded then
local ok, P = pcall(require, "src.world.gen2.Permissions")
permsMod = ok and P or nil
permsLoaded = true
end
return permsMod
end
local function resolveTileset(game, mapDef)
@@ -193,6 +198,31 @@ function Collision.installWarps(mapDef)
end
end
--- Patch one cell of the bound native grid after a metatile write (what a
-- full bindMap would rebuild for it) without re-deriving the whole map.
-- Returns false when the caller must bindMap instead: another map is bound,
-- the cell is off the grid, or a warp sits on it (installWarps derives warp
-- activity and door repairs from the cell).
function Collision.patchCell(mapId, mapDef, x, y)
if not (Collision._grid and mapDef and mapDef.midLayout) then return false end
if Collision._mapId ~= mapId or Collision._mapDef ~= mapDef then return false end
local layout = mapDef.midLayout
x, y = tonumber(x), tonumber(y)
if not (x and y) or x ~= math.floor(x) or y ~= math.floor(y) then return false end
local w, h = Collision._widthCells, Collision._heightCells
if w ~= layout.width or h ~= layout.height then return false end
if x < 0 or y < 0 or x >= w or y >= h then return false end
if x >= (layout.trueWidth or w) or y >= (layout.trueHeight or h) then return false end
for _, warp in ipairs(mapDef.warps or {}) do
if tonumber(warp.x) == x and tonumber(warp.y) == y then return false end
end
-- same value LayoutNative:collArray yields for this cell
local ov = layout.overrides and layout.overrides[y * 1024 + x]
local c = layout.cells and layout.cells[y * w + x + 1]
Collision._grid[y * w + x + 1] = (ov and ov.coll) or (c and c.coll) or 0xff
return true
end
function Collision.clear()
Collision._grid = nil
Collision._mapId = nil
+6 -1
View File
@@ -92,9 +92,14 @@ local function loveCache()
}
end
-- The cache object is stateless (every read resolves CacheFs, love.filesystem
-- and Dataset.cacheRootOverride at call time), so one instance is shared.
local sharedCache = nil
--- Shared firered CacheFs-backed cache for standalone Game3 (mod.cache is nil).
function Dataset.cache()
return loveCache()
if not sharedCache then sharedCache = loveCache() end
return sharedCache
end
local dsLoadWarned = false
+5
View File
@@ -355,7 +355,12 @@ local function presentPlanes(game)
Display.mirrorFlatFrame(Renderer)
end
-- The plane path presents straight from Renderer's canvases; the flat 240x160
-- copy is only for test drivers that read Display._canvas back.
Display.mirrorForTests = false
function Display.mirrorFlatFrame(Renderer)
if not Display.mirrorForTests then return end
local canvas = Display.ensureCanvas("main")
if not canvas then return end
local world = Renderer.worldCanvas
+12 -2
View File
@@ -207,6 +207,9 @@ function Field.update(_dt)
local Ghosts = require("src.core.game3.ghosts")
Ghosts.sync()
Ghosts.update(game)
-- pre-load tileset pairs queued by a seamless world refresh, one per frame
local MapMod = package.loaded["src.core.game3.map"]
if MapMod and MapMod._warmQueue and MapMod.stepWarm then MapMod.stepWarm() end
Field.pollMapChange(game)
-- pokefirered/src/safari_zone.c:60 CB2_EndSafariBattle
@@ -2102,9 +2105,16 @@ function Field.setMetatile(x, y, metatile, isImpassable)
layout:applyOverride(x, y, mid, coll, layout:elevAt(x, y))
Field._overrideLayouts[mapId] = layout
local Collision = require("src.core.game3.collision")
Collision.bindMap(game, mapId, mapDef)
if not (Collision.patchCell and Collision.patchCell(mapId, mapDef, x, y)) then
Collision.bindMap(game, mapId, mapDef)
end
-- applyOverride already invalidated the view cell (FieldView.invalidateLayoutCell).
local FieldView = package.loaded["src.core.game3.field_view"]
if FieldView then FieldView._nativeDirty = true end
local LayoutNative = package.loaded["src.core.game3.layout_native"]
if FieldView and not (FieldView.invalidateLayoutCell and LayoutNative
and layout.applyOverride == LayoutNative.applyOverride) then
FieldView._nativeDirty = true
end
end
local world = game and (game.overworld or game.world)
if world and world.map and world.map.setBlock then
+201 -29
View File
@@ -840,14 +840,27 @@ local function washColor(bgSet)
return 0.12, 0.28, 0.22
end
local function releaseBatch(batch)
if batch and batch.release then pcall(batch.release, batch) end
end
-- Outside a full reset, visible cells hold sprite indices into the batch
-- stored under `srcPair`, so a texture swap cannot replace it in place (the
-- next releaseNativeSprite would index a smaller batch). Report the swap and
-- let drawNativeTiles rebuild everything this frame instead.
local function ensure_batch(store, srcPair, texture, capacity)
local batch = store[srcPair]
if batch and batch.getTexture and batch:getTexture() ~= texture then
if not FieldView._nativeResetting then
FieldView._nativeSwap = true
return nil
end
releaseBatch(batch)
batch = nil
end
if not batch then
batch = love.graphics.newSpriteBatch(texture, capacity)
store[srcPair] = batch
elseif batch.getTexture and batch:getTexture() ~= texture then
batch = love.graphics.newSpriteBatch(texture, capacity)
store[srcPair] = batch
end
return batch
end
@@ -864,8 +877,12 @@ local function nativeAtlas(NativeTileset, pair)
return ts
end
local function acquireNativeSprite(store, free, key, texture, capacity, quad, x, y)
-- Physical sprite slots per layer (free-list reuse does not grow them).
local nativeSlots = { under = 0, over = 0 }
local function acquireNativeSprite(store, free, key, texture, capacity, quad, x, y, layer)
local batch = ensure_batch(store, key, texture, capacity)
if not batch then return nil end
local available = free[key]
local index
if available and #available > 0 then
@@ -874,7 +891,9 @@ local function acquireNativeSprite(store, free, key, texture, capacity, quad, x,
batch:set(index, quad, x, y)
else
index = batch:add(quad, x, y)
FieldView._nativeSpriteSlots = FieldView._nativeSpriteSlots + 1
local n = nativeSlots[layer] + 1
nativeSlots[layer] = n
if n > FieldView._nativeSpriteSlots then FieldView._nativeSpriteSlots = n end
end
return { key = key, index = index, quad = quad }
end
@@ -883,6 +902,7 @@ local function releaseNativeSprite(store, free, sprite)
if not sprite then return true end
local batch = store[sprite.key]
if not (batch and batch.set) then return false end
if batch.getCount and sprite.index > batch:getCount() then return false end
batch:set(sprite.index, sprite.quad, HIDDEN_SPRITE_X, HIDDEN_SPRITE_Y)
local available = free[sprite.key]
if not available then
@@ -893,6 +913,80 @@ local function releaseNativeSprite(store, free, sprite)
return true
end
-- Clear every native batch for a full rebuild, keeping the SpriteBatch
-- objects (and their GPU buffers) for reuse. Batches that cannot be cleared
-- (test doubles) are dropped.
local function clearNativeStore(store)
for k, batch in pairs(store) do
if batch.clear then
batch:clear()
else
releaseBatch(batch)
store[k] = nil
end
end
end
-- After a rebuild, release batches no visible cell uses any more.
local function pruneNativeStore(store)
for k, batch in pairs(store) do
if batch.getCount and batch:getCount() == 0 then
releaseBatch(batch)
store[k] = nil
end
end
end
local function releaseVoidFrom(from)
if not from then return end
for _, t in ipairs({ from.under, from.over }) do
for _, batch in pairs(t or {}) do releaseBatch(batch) end
end
end
--- Mark one cell of the drawn map's layout for re-sampling on the next draw
-- (metatile writes). x, y are layout (current-map) cell coordinates. Cells
-- that are not visible need nothing: they are sampled when they scroll in.
function FieldView.invalidateCell(x, y, layout)
if FieldView._nativeDirty or type(FieldView._nativeVisibleCells) ~= "table" then return end
x, y = tonumber(x), tonumber(y)
if not (x and y) or (layout ~= nil and layout ~= FieldView._nativeLayout) then
FieldView._nativeDirty = true
return
end
local cells = FieldView._nativeDirtyCells
if not cells then
cells = {}
FieldView._nativeDirtyCells = cells
end
local n = #cells
if n >= 1024 then
FieldView._nativeDirty = true
return
end
cells[n + 1] = x
cells[n + 2] = y
end
--- Metatile write on `layout` at its own (x, y). Only the drawn map's own
-- layout maps 1:1 onto view cells; anything else (neighbour maps, a layout
-- shared with a neighbour) falls back to a full rebuild.
function FieldView.invalidateLayoutCell(layout, x, y)
if FieldView._nativeDirty then return end
if layout == nil or layout ~= FieldView._nativeLayout then
FieldView._nativeDirty = true
return
end
local Map = package.loaded["src.core.game3.map"]
for _, entry in ipairs((Map and Map.world) or {}) do
if entry.def and entry.def.midLayout == layout then
FieldView._nativeDirty = true
return
end
end
FieldView.invalidateCell(x, y, layout)
end
FieldView.VOID_FADE_FRAMES = 20
local voidKeys = setmetatable({}, { __index = function(t, pair)
local k = "void|" .. pair
@@ -969,7 +1063,8 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
end
return false
end
if not NativeTileset.get(pair) then return false end
local mainTs = nativeAtlas(NativeTileset, pair)
if not mainTs then return false end
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, canvasW, canvasH)
@@ -986,22 +1081,39 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
local voidMode = VoidFill.normalize(VoidFill.mode)
beginVoidFade(Map, mapDef, camX, camY, voidMode)
local underStore = FieldView._nativeBatches
if not underStore then
underStore = {}
FieldView._nativeBatches = underStore
end
local overStore = FieldView._nativeOverBatches
if not overStore then
overStore = {}
FieldView._nativeOverBatches = overStore
end
local mainBatch = underStore[pair]
local moved = FieldView._nativeBx ~= cx0 or FieldView._nativeBy ~= cy0
local reset = FieldView._nativeDirty
or FieldView._nativeVoid ~= voidMode
or FieldView._nativePair ~= pair
or FieldView._nativeLayout ~= layout
or FieldView._nativeViewCols ~= cols
or FieldView._nativeViewRows ~= rows
or type(FieldView._nativeVisibleCells) ~= "table"
or type(FieldView._nativeFreeUnder) ~= "table"
or type(FieldView._nativeFreeOver) ~= "table"
or (FieldView._nativeSpriteSlots or 0) > capacity * 4
-- primary atlas reloaded (tileset reinstall): cells index the old texture
or (mainBatch ~= nil and mainBatch.getTexture ~= nil and mainBatch:getTexture() ~= mainTs.image)
if not reset and moved then
local dx = math.abs(cx0 - (FieldView._nativeBx or cx0))
local dy = math.abs(cy0 - (FieldView._nativeBy or cy0))
if dx >= cols or dy >= rows then
reset = true
else
for _, store in ipairs({ FieldView._nativeBatches, FieldView._nativeOverBatches }) do
for _, batch in pairs(store or {}) do
for _, store in ipairs({ underStore, overStore }) do
for _, batch in pairs(store) do
if type(batch.set) ~= "function" then reset = true; break end
end
if reset then break end
@@ -1040,16 +1152,16 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
local slot = NativeTileset.slotFor(ts, mid)
local q = NativeTileset.quad(ts, slot)
if q then
info.under = acquireNativeSprite(FieldView._nativeBatches,
info.under = acquireNativeSprite(underStore,
FieldView._nativeFreeUnder, key, ts.image, capacity, q,
(wx - baseBx) * CELL, (wy - baseBy) * CELL)
(wx - baseBx) * CELL, (wy - baseBy) * CELL, "under")
end
if ts.layered and ts.overImage then
local oq = NativeTileset.overQuad(ts, slot)
if oq then
info.over = acquireNativeSprite(FieldView._nativeOverBatches,
info.over = acquireNativeSprite(overStore,
FieldView._nativeFreeOver, key, ts.overImage, capacity, oq,
(wx - baseBx) * CELL, (wy - baseBy) * CELL)
(wx - baseBx) * CELL, (wy - baseBy) * CELL, "over")
end
end
end
@@ -1057,12 +1169,16 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
return info
end
local function releaseCell(info)
return releaseNativeSprite(underStore, FieldView._nativeFreeUnder, info.under)
and releaseNativeSprite(overStore, FieldView._nativeFreeOver, info.over)
end
if not reset and moved then
for wy, row in pairs(visibleCells) do
for wx, info in pairs(row) do
if wx < cx0 or wx >= cx0 + cols or wy < cy0 or wy >= cy0 + rows then
if not releaseNativeSprite(FieldView._nativeBatches, FieldView._nativeFreeUnder, info.under)
or not releaseNativeSprite(FieldView._nativeOverBatches, FieldView._nativeFreeOver, info.over) then
if not releaseCell(info) then
reset = true
break
end
@@ -1074,39 +1190,89 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
end
end
if reset then
FieldView._nativeBatches, FieldView._nativeOverBatches = {}, {}
-- Metatile writes since the last draw: drop just those cells so the fill
-- loop below re-samples them (FieldView.invalidateCell).
local dirtyCells = FieldView._nativeDirtyCells
if dirtyCells then
FieldView._nativeDirtyCells = nil
if not reset then
for i = 1, #dirtyCells - 1, 2 do
local wx, wy = dirtyCells[i], dirtyCells[i + 1]
local row = visibleCells[wy]
local info = row and row[wx]
if info then
if not releaseCell(info) then
reset = true
break
end
row[wx] = nil
end
end
end
end
local function resetNative()
clearNativeStore(underStore)
clearNativeStore(overStore)
FieldView._nativeFreeUnder, FieldView._nativeFreeOver = {}, {}
visibleCells = {}
FieldView._nativeVisibleCells = visibleCells
visiblePairs = {}
FieldView._nativeVisiblePairs = visiblePairs
nativeSlots.under, nativeSlots.over = 0, 0
FieldView._nativeSpriteSlots = 0
FieldView._nativeBaseBx, FieldView._nativeBaseBy = cx0, cy0
FieldView._nativeViewCols, FieldView._nativeViewRows = cols, rows
baseBx, baseBy = cx0, cy0
end
if reset then
resetNative()
elseif moved then
for k in pairs(visiblePairs) do visiblePairs[k] = nil end
end
for wy = cy0, cy0 + rows - 1 do
local row = visibleCells[wy]
if not row then
row = {}
visibleCells[wy] = row
end
for wx = cx0, cx0 + cols - 1 do
local info = row[wx]
if not info then
info = addCell(wx, wy)
row[wx] = info
local function fill()
for wy = cy0, cy0 + rows - 1 do
local row = visibleCells[wy]
if not row then
row = {}
visibleCells[wy] = row
end
for wx = cx0, cx0 + cols - 1 do
local info = row[wx]
if not info then
info = addCell(wx, wy)
if FieldView._nativeSwap then return false end
row[wx] = info
end
if info.draw then visiblePairs[info.pair] = true end
end
if info.draw then visiblePairs[info.pair] = true end
end
return true
end
FieldView._nativeSwap = false
FieldView._nativeResetting = reset
if not fill() then
-- An atlas was reloaded while its cells were live: rebuild everything
-- this frame rather than swap the batch under their sprite indices.
FieldView._nativeSwap = false
reset = true
resetNative()
FieldView._nativeResetting = true
fill()
end
FieldView._nativeResetting = false
FieldView._nativeSwap = false
if reset then
pruneNativeStore(underStore)
pruneNativeStore(overStore)
end
FieldView._nativeBx, FieldView._nativeBy = cx0, cy0
FieldView._nativePair = pair
FieldView._nativeLayout = layout
FieldView._nativeVoid = voidMode
FieldView._nativeDirty = false
if reset or moved then
@@ -1128,7 +1294,10 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH)
drawLayer(FieldView._nativeBatches, pair, ox, oy, from and from.under, camX, camY)
if from then
from.t = from.t + 1
if from.t >= FieldView.VOID_FADE_FRAMES then FieldView._voidFrom = nil end
if from.t >= FieldView.VOID_FADE_FRAMES then
releaseVoidFrom(from)
FieldView._voidFrom = nil
end
end
FieldView._lastCamX, FieldView._lastCamY = camX, camY
@@ -1634,6 +1803,9 @@ function FieldView.invalidate()
FieldView._nativeVisiblePairs = nil
FieldView._nativeFreeUnder, FieldView._nativeFreeOver = nil, nil
FieldView._nativeSpriteSlots = 0
nativeSlots.under, nativeSlots.over = 0, 0
FieldView._nativeDirtyCells = nil
FieldView._nativeLayout = nil
FieldView._voidFrom, FieldView._voidMapDef = nil, nil
FieldView._lastCamX, FieldView._lastCamY = nil, nil
FieldView._nativeBx = nil
+73 -16
View File
@@ -69,23 +69,48 @@ Ghosts._contextFor = contextFor
local ctxCache = setmetatable({}, { __mode = "k" })
function Ghosts.sync()
local M = Map()
local placed = {}
for _, entry in ipairs(M.world or {}) do
placed[entry.id] = true
if not Ghosts._pools[entry.id] then
local defs = defsFor(entry.id, entry.def)
if defs then
Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def, entry.id)
end
end
local EMPTY = {}
local syncPlaced = {}
-- True when the last rebuild still describes Map.world and the pool table:
-- same world list, a pool for every placed map and no extra pools.
local function syncCurrent(world)
if Ghosts._syncWorld ~= world or Ghosts._syncPools ~= Ghosts._pools then return false end
local n = 0
for _, entry in ipairs(world) do
if not Ghosts._pools[entry.id] then return false end
n = n + 1
end
for id in pairs(Ghosts._pools) do
if not placed[id] and id ~= Ghosts._held then
Ghosts._pools[id] = nil
end
if not syncPlaced[id] and id ~= Ghosts._held then return false end
n = n - 1
end
return n == 0 or (Ghosts._held ~= nil and n == -1)
end
function Ghosts.sync()
local M = Map()
local world = M.world or EMPTY
if not syncCurrent(world) then
local placed = syncPlaced
for id in pairs(placed) do placed[id] = nil end
for _, entry in ipairs(world) do
placed[entry.id] = true
if not Ghosts._pools[entry.id] then
local defs = defsFor(entry.id, entry.def)
if defs then
Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def, entry.id)
end
end
end
for id in pairs(Ghosts._pools) do
if not placed[id] and id ~= Ghosts._held then
Ghosts._pools[id] = nil
end
end
Ghosts._syncWorld, Ghosts._syncPools = world, Ghosts._pools
end
local placed = syncPlaced
if Ghosts._held and not placed[Ghosts._held] then
Ghosts._heldGrace = (Ghosts._heldGrace or 0) + 1
if Ghosts._heldGrace > 2 then
@@ -98,12 +123,44 @@ function Ghosts.sync()
end
end
-- Neighbour NPCs only step while their map is near the camera (one screen of
-- margin past the view); farther pools hold still (pret only runs object
-- events spawned around the camera, TrySpawnObjectEvents). Raise
-- TICK_MARGIN_SCREENS (math.huge = tick every pool) to widen it.
Ghosts.TICK_MARGIN_SCREENS = 1
local function tickRect()
local P = package.loaded["src.core.game3.player"]
local px, py = P and tonumber(P.cellX), P and tonumber(P.cellY)
if not (px and py) then return nil end
local FieldView = package.loaded["src.core.game3.field_view"]
local Display = package.loaded["src.core.game3.display"]
local vw = (FieldView and FieldView._viewW) or (Display and Display.W) or 240
local vh = (FieldView and FieldView._viewH) or (Display and Display.H) or 160
local cols, rows = math.ceil(vw / 16), math.ceil(vh / 16)
local mx = math.ceil(cols / 2) + 1 + cols * Ghosts.TICK_MARGIN_SCREENS
local my = math.ceil(rows / 2) + 1 + rows * Ghosts.TICK_MARGIN_SCREENS
-- cull rect in current-map cells
return px - mx, py - my, px + mx, py + my
end
local function nearView(entry, x0, y0, x1, y1)
if not x0 then return true end
local layout = entry.def and entry.def.midLayout
local w = layout and layout.width
local h = layout and layout.height
if not (w and h) then return true end
local ox, oy = entry.ox or 0, entry.oy or 0
return ox + w > x0 and ox <= x1 and oy + h > y0 and oy <= y1
end
function Ghosts.update(game)
local M = Map()
local Obj = Objects()
for _, entry in ipairs(M.world or {}) do
local x0, y0, x1, y1 = tickRect()
for _, entry in ipairs(M.world or EMPTY) do
local pool = Ghosts._pools[entry.id]
if pool then
if pool and nearView(entry, x0, y0, x1, y1) then
local c = ctxCache[entry]
if not c or c.pool ~= pool or c.def ~= entry.def or c.layout ~= (entry.def and entry.def.midLayout) or c.ox ~= entry.ox or c.oy ~= entry.oy then
c = { pool = pool, def = entry.def, layout = entry.def and entry.def.midLayout, ox = entry.ox, oy = entry.oy, ctx = contextFor(entry, pool) }
+24 -2
View File
@@ -109,7 +109,11 @@ function LayoutNative:applyOverride(x, y, mid, coll, elev)
}
local FieldView = package.loaded["src.core.game3.field_view"]
if FieldView then
FieldView._nativeDirty = true
if FieldView.invalidateLayoutCell then
FieldView.invalidateLayoutCell(self, x, y)
else
FieldView._nativeDirty = true
end
end
end
@@ -120,6 +124,22 @@ local function markDirty()
end
end
-- Re-sample only the written cells when there are few of them (per-frame
-- metatile animations); bulk writes rebuild the whole view.
local MAX_CELL_INVALIDATIONS = 64
local function markCellsDirty(layout, cells, n)
local FieldView = package.loaded["src.core.game3.field_view"]
if not FieldView then return end
if not FieldView.invalidateLayoutCell or n > MAX_CELL_INVALIDATIONS then
FieldView._nativeDirty = true
return
end
for i = 1, n * 2, 2 do
FieldView.invalidateLayoutCell(layout, cells[i], cells[i + 1])
end
end
-- pokeemerald/src/battle_pyramid.c:1523
function LayoutNative:stamp(src, ox, oy)
if type(src) ~= "table" or type(src.cells) ~= "table" then return 0 end
@@ -145,6 +165,7 @@ end
-- pokeemerald/src/fieldmap.c:357
function LayoutNative:setMetatiles(rows)
local n = 0
local cells = {}
for _, r in ipairs(rows or {}) do
local x, y = tonumber(r.x) or 0, tonumber(r.y) or 0
local cur = self:cellAt(x, y)
@@ -154,8 +175,9 @@ function LayoutNative:setMetatiles(rows)
elev = r.elev ~= nil and r.elev or cur.elev or 0,
}
n = n + 1
cells[n * 2 - 1], cells[n * 2] = x, y
end
if n > 0 then markDirty() end
if n > 0 then markCellsDirty(self, cells, n) end
return n
end
+32
View File
@@ -151,10 +151,24 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
if Map._warmPairs and NativeTileset and NativeTileset.get then
Map._warmPairs = nil
Map._warmQueue = nil
for _, entry in ipairs(Map.world) do
local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair))
if pair and NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end
end
elseif NativeTileset and NativeTileset.get then
-- Seamless crossing: queue the new world's unloaded pairs and load one per
-- frame (Map.stepWarm) so a pair is ready before it scrolls into view.
local queue, seen = {}, {}
for _, entry in ipairs(Map.world) do
local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair))
if type(pair) == "string" and not seen[pair]
and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then
seen[pair] = true
queue[#queue + 1] = pair
end
end
Map._warmQueue = queue[1] and queue or nil
end
Map._worldRoot = rootId
Map._worldReachW = reachW
@@ -164,6 +178,24 @@ function Map.refreshWorld(game, reachW, reachH, rootId)
return Map.world
end
--- Load at most one queued tileset pair (see refreshWorld).
function Map.stepWarm()
local queue = Map._warmQueue
if not queue then return false end
local NativeTileset = package.loaded["src.core.game3.tileset_native"]
while queue[1] do
local pair = table.remove(queue, 1)
if NativeTileset and not (NativeTileset._pairs and NativeTileset._pairs[pair])
and NativeTileset.ready and NativeTileset.ready(pair) then
pcall(NativeTileset.get, pair)
if not queue[1] then Map._warmQueue = nil end
return true
end
end
Map._warmQueue = nil
return false
end
function Map.overscanSlices()
local slices = {}
for _, n in ipairs(Map.neighborList or {}) do
+46 -18
View File
@@ -119,6 +119,7 @@ function Oam.reset()
s.anims, s.animQuads, s.affineAnim = nil, nil, nil
end
Oam._buffer = nil
Oam._sorted = nil
Oam._clip = nil
Oam._fx = nil
Oam._blend = nil
@@ -295,7 +296,14 @@ end
--- Begin frame (pret: clear shadow OAM build).
function Oam.resetFrame()
ensure_pool()
Oam._buffer = {}
-- Empty this frame's draw list in place; the sorted order survives in
-- Oam._sorted so buildOamBuffer can skip re-sorting an unchanged scene.
local buf = Oam._buffer
if buf == nil or buf == Oam._sorted then
Oam._buffer = {}
else
for i = #buf, 1, -1 do buf[i] = nil end
end
end
Oam._layer = "ui"
@@ -504,25 +512,44 @@ function Oam.buildOamBuffer(pretOrder)
end
end
local cmp = pretOrder and sort_sprites_pret or sort_sprites
local cached = Oam._buffer
if cached and #cached == n then
local ok = true
-- Cache: last frame's sorted list. Same length, every entry still drawable
-- and still in order means it holds exactly this frame's sprites (entries
-- are distinct pool slots), so the sort can be skipped.
local sorted = Oam._sorted
local ok = false
if sorted and #sorted == n and Oam._sortedPool == Oam._sprites then
ok = true
for i = 1, n do
local s = cached[i]
local s = sorted[i]
if not (s.inUse and not s.invisible and s.image) then ok = false break end
if i < n and cmp(cached[i + 1], s) then ok = false break end
end
if ok then return cached end
end
local buf = {}
for i = 0, Oam.MAX_SPRITES - 1 do
local s = Oam._sprites[i]
if s.inUse and not s.invisible and s.image then
buf[#buf + 1] = s
local nx = sorted[i + 1]
if nx then
if cmp(nx, s) then ok = false break end
-- equal keys: keep pool order (pret's insertion sort is stable)
if not cmp(s, nx) and (s._id or 0) > (nx._id or 0) then ok = false break end
end
end
end
table.sort(buf, cmp)
Oam._buffer = buf
if not ok then
sorted = sorted or {}
for i = #sorted, 1, -1 do sorted[i] = nil end
for i = 0, Oam.MAX_SPRITES - 1 do
local s = Oam._sprites[i]
if s.inUse and not s.invisible and s.image then
sorted[#sorted + 1] = s
end
end
table.sort(sorted, cmp)
Oam._sorted = sorted
Oam._sortedPool = Oam._sprites
end
local buf = Oam._buffer
if buf == nil or buf == sorted then
buf = {}
Oam._buffer = buf
end
for i = 1, n do buf[i] = sorted[i] end
for i = #buf, n + 1, -1 do buf[i] = nil end
return buf
end
@@ -560,11 +587,12 @@ local function blit_sprite(s)
local blend = s.blend or Oam._blend
local clip = s.clip or Oam._clip
if clip and (clip.w <= 0 or clip.h <= 0) then return end
local draw = s.affineScale and function() blit_affine(s) end or function() blit_plain(s) end
local affine = s.affineScale
if not fx and not blend and not clip then
draw()
if affine then blit_affine(s) else blit_plain(s) end
return
end
local draw = affine and function() blit_affine(s) end or function() blit_plain(s) end
Fx.withClip(clip, function() Fx.draw(draw, fx, blend) end)
end
+8 -1
View File
@@ -1514,6 +1514,13 @@ local function pick(t)
return t[(idleRng.Random() % #t) + 1]
end
-- tostring(movement):upper(), memoised (idleTick runs per object per frame).
local UPPER_MOVEMENT = setmetatable({}, { __index = function(t, k)
local v = tostring(k):upper()
t[k] = v
return v
end })
local function idleTick(eo, game, ctx)
if eo.frozen or eo.scriptBusy or eo.moving or eo.hidden or not eo.visible then
return
@@ -1527,7 +1534,7 @@ local function idleTick(eo, game, ctx)
return
end
local mv = tostring(eo.movement or "STAY"):upper()
local mv = UPPER_MOVEMENT[eo.movement or "STAY"]
if mv == "STAY" then return end
if mv == "IN_PLACE" then
-- pokeemerald/src/event_object_movement.c:4422
+11 -2
View File
@@ -1502,8 +1502,17 @@ local function pic(store, kind, species, form, shiny)
if shiny and species ~= Pokemon.SPECIES_EGG then kind = kind .. "_shiny" end
local key = form > 0 and (species .. "_" .. form) or species
if kind:find("_shiny", 1, true) then key = "shiny:" .. key end
if store[key] then return store[key] end
return pic_entry(store, key, read_pic(pic_rel(kind, species, form)))
local hit = store[key]
if hit then return hit end
-- false marks a pic file known to be missing, so draw loops that probe
-- backPic then frontPic every frame do not re-read the filesystem.
if hit == false then return nil end
local rgba = read_pic(pic_rel(kind, species, form))
if not rgba then
store[key] = false
return nil
end
return pic_entry(store, key, rgba)
end
Pokemon.SPECIES_SPINDA = 308
+30
View File
@@ -99,7 +99,37 @@ function RomText.box(key, ctx)
return TextIR.toTextBox(RomText.translate(RomText.ir(key), ctx, key), ctx)
end
-- Segment types whose expansion reads nothing but the segment itself. An IR
-- made only of these expands to the same plain string every time.
local PURE_SEG = { text = true, tag = true, nl = true, para = true, scroll = true, eos = true, ext = true }
-- plain() results for pure IRs, keyed by the IR table itself (false = not
-- pure). Keying on the IR follows overrides, profile aliases and bundle
-- reloads for free: each resolves to a different table.
local plainCache = setmetatable({}, { __mode = "k" })
local function pure_ir(ir)
if type(ir) ~= "table" then return false end
for i = 1, #ir do
local seg = ir[i]
if type(seg) ~= "table" or not PURE_SEG[seg.t] then return false end
end
return true
end
function RomText.plain(key, ctx)
if ctx == nil and not Strings.active() then
local ir = RomText.ir(key)
local hit = plainCache[ir]
if hit then return hit end
if hit == nil then
local pure = pure_ir(ir)
local out = TextIR.toPlain(ir, {})
plainCache[ir] = pure and out or false
return out
end
return TextIR.toPlain(ir, {})
end
ctx = ctx or {}
return TextIR.toPlain(RomText.translate(RomText.ir(key), ctx, key), ctx)
end
+42 -14
View File
@@ -320,27 +320,45 @@ function TilesetAnim.bindPair(pair, atlas)
end
TilesetAnim._visible[pair] = true
if entry.rse then return true end
for _, kind in ipairs({ "water", "sand", "flower" }) do
TilesetAnim._applyKind(entry, kind, TilesetAnim["_" .. kind .. "Frame"])
end
TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame)
TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame)
TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame)
return true
end
function TilesetAnim.setVisiblePairs(visible)
local set = TilesetAnim._visible
for pair in pairs(set) do set[pair] = nil end
for pair in pairs(visible) do set[pair] = true end
for pair in pairs(set) do
if not visible[pair] then set[pair] = nil end
end
for pair in pairs(visible) do
if not set[pair] then
set[pair] = true
-- a General atlas coming back into view catches up before it is drawn
local entry = TilesetAnim._pairs[pair]
if entry and not entry.rse then
TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame)
TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame)
TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame)
end
end
end
end
local function get_frame_piece(bank, frame, mi, frameRgba)
local function get_frame_piece(bank, frame, mi, srcOff)
if not (love and love.image and love.image.newImageData) then return nil end
bank.pieces = bank.pieces or {}
local pieces = bank.pieces
if not pieces then
pieces = {}
bank.pieces = pieces
end
local pKey = frame * 1000 + mi
local piece = bank.pieces[pKey]
local piece = pieces[pKey]
if piece then return piece end
local ok, imgData = pcall(love.image.newImageData, 16, 16, "rgba8", frameRgba)
local ok, imgData = pcall(love.image.newImageData, 16, 16, "rgba8",
bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA))
if ok and imgData then
bank.pieces[pKey] = imgData
pieces[pKey] = imgData
return imgData
end
return nil
@@ -357,20 +375,29 @@ function TilesetAnim._applyKind(entry, kind, frame)
local nMids = #bank.mids
local cols = ts.cols or 16
local image = ts.image
-- pokeemerald-style partial upload (see rse_paste): push only the 16x16
-- metatiles this bank owns instead of re-uploading the whole atlas.
local partial = image ~= nil and image.replacePixels ~= nil
local full = false
for mi, mid in ipairs(bank.mids) do
local slot = ts.midToSlot[mid]
if slot then
local srcOff = (frame * nMids + (mi - 1)) * MID_RGBA
local ax = (slot % cols) * 16
local ay = math.floor(slot / cols) * 16
local frameRgba = bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA)
local piece = get_frame_piece(bank, frame, mi, frameRgba)
local piece = get_frame_piece(bank, frame, mi, srcOff)
local pasted = false
if piece and ts.imageData.paste then
ts.imageData:paste(piece, ax, ay)
pasted = true
if partial and not full then
local ok = pcall(image.replacePixels, image, piece, 1, 1, ax, ay, false)
if not ok then full = true end
end
end
if not pasted then
local frameRgba = bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA)
local i = 1
for y = 0, 15 do
for x = 0, 15 do
@@ -382,11 +409,12 @@ function TilesetAnim._applyKind(entry, kind, frame)
i = i + 4
end
end
full = true
end
end
end
if ts.image and ts.image.replacePixels then
ts.image:replacePixels(ts.imageData)
if partial then
if full then image:replacePixels(ts.imageData) end
elseif ts.image and love and love.graphics then
ts.image = love.graphics.newImage(ts.imageData)
if ts.image.setFilter then ts.image:setFilter("nearest", "nearest") end
+14 -2
View File
@@ -161,9 +161,18 @@ function NativeTileset.ready(pair)
and cache:exists(NATIVE .. "/" .. pair .. "/palettes.bin")
end
local animMod
local function tilesetAnim()
if animMod == nil then
local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim")
animMod = okA and TilesetAnim or false
end
return animMod or nil
end
local function bind_anim(pair, atlas)
local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim")
if okA and TilesetAnim and TilesetAnim.bindPair then
local TilesetAnim = tilesetAnim()
if TilesetAnim and TilesetAnim.bindPair then
TilesetAnim.bindPair(pair, atlas)
end
end
@@ -172,6 +181,9 @@ function NativeTileset.get(pair)
if not pair then return nil end
local cached = NativeTileset._pairs[pair]
if cached then
-- Cheap for a bound pair (no manifest read): marks it visible and
-- catches its atlas up to the current anim frames before a draw.
-- FieldView reads cached atlases directly and does not come here per frame.
bind_anim(pair, cached)
return cached
end
+8 -1
View File
@@ -78,6 +78,9 @@ function TrainerSight.getTrainerId(eo)
local scripts = Sp and Sp.bundle and Sp.bundle.scripts
local list = scripts and scripts[scriptKey]
if type(list) == "table" then
-- negative cache: this exact script list was already scanned without a
-- trainerbattle (a script swapped in under the key rescans)
if eo._trainerIdMiss == list then return nil end
for _, row in ipairs(list) do
if row.op == "trainerbattle" or row.op == "dotrainerbattle" then
local tid = tonumber(row.trainer or row[1])
@@ -88,6 +91,7 @@ function TrainerSight.getTrainerId(eo)
end
end
end
eo._trainerIdMiss = list
end
return nil
end
@@ -633,6 +637,8 @@ end
-- @param game game instance
-- @param specificTrainer optional specific EventObject to check (e.g. from spinning NPC turn update)
-- @return boolean true if an encounter was triggered
local approachFound = {}
function TrainerSight.check(game, specificTrainer)
local F = Field()
if F.locked then return false end
@@ -681,7 +687,8 @@ function TrainerSight.check(game, specificTrainer)
local order = Objs._order or {}
if twoTrainerApproach() then
-- pokeemerald/src/trainer_see.c:191
local found = {}
local found = approachFound
for i = #found, 1, -1 do found[i] = nil end
local doubles = canDouble()
for _, lid in ipairs(order) do
local eo = Objs.find(lid)
+23 -8
View File
@@ -28,9 +28,30 @@ function Hooks:wrap(name, callback, priority, owner)
for i, candidate in ipairs(chain) do
if candidate == entry then table.remove(chain, i) break end
end
-- an empty chain is dropped, as removeOwner does, so Runtime.wantsHook
-- goes back to false; only if it is still the live chain for the name
-- (a later wrap after removeOwner may have started a new one)
if #chain == 0 and self.chains[name] == chain then
self.chains[name] = nil
end
end
end
-- vanilla's pcall results, unpacked without a pack table: its values, or
-- the PASS-wrapped error
local function vanillaResult(ok, ...)
if ok then return ... end
error({ [PASS] = (...) }, 0)
end
-- the outermost pcall's results: the chain's values, or its error unwrapped
local function finish(ok, ...)
if ok then return ... end
local err = (...)
if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end
error(err, 0)
end
-- each link runs under pcall: a throwing wrapper is logged and skipped and
-- the chain continues with the current arguments, so a broken mod degrades
-- to "not installed for this call" instead of breaking the pipeline.
@@ -46,9 +67,7 @@ function Hooks:call(name, vanilla, ...)
local function run(index)
if index > #chain then
ranVanilla = true
local res = pack(pcall(vanilla, unpack(args, 1, args.n)))
if res[1] then return unpack(res, 2, res.n) end
error({ [PASS] = res[2] }, 0)
return vanillaResult(pcall(vanilla, unpack(args, 1, args.n)))
end
local entry = chain[index]
local downstream
@@ -81,11 +100,7 @@ function Hooks:call(name, vanilla, ...)
tostring(entry.owner or "?"), name, tostring(err))
return run(index + 1)
end
local res = pack(pcall(run, 1))
if res[1] then return unpack(res, 2, res.n) end
local err = res[2]
if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end
error(err, 0)
return finish(pcall(run, 1))
end
-- drops every wrap a mod made; used by entry-chunk rollback
+13
View File
@@ -258,6 +258,13 @@ function Loader:_installDevShim()
if devShim.installed then return end
devShim.installed = true
local delegate = require
-- The stock require answers an already-loaded module straight out of
-- package.loaded, so once every check below has passed the shim can do the
-- same without the pcall. Only when delegating to the stock C function:
-- a Lua wrapper in between may want to see every call.
local delegateIsStock = delegate == rawRequire
and type(delegate) == "function"
and (debug.getinfo(delegate, "S") or {}).what == "C"
_G.require = function(name, ...)
-- only the mod's own call is the mod's doing; whatever that module
-- requires in turn is the engine wiring itself up
@@ -295,6 +302,12 @@ function Loader:_installDevShim()
end
end
end
if delegateIsStock then
-- a userdata here may be require's own "loading" sentinel, whose
-- loop error the stock path has to raise
local loaded = package.loaded[name]
if loaded and type(loaded) ~= "userdata" then return loaded end
end
devShim.depth = devShim.depth + 1
local ok, result = pcall(delegate, name, ...)
devShim.depth = devShim.depth - 1
+30 -6
View File
@@ -45,9 +45,14 @@ local function head(name)
return (name:match("^([^%.]+)")) or name
end
-- nil when the require is allowed, else the message to fail it with.
function Sandbox.moduleDenial(name, permissionSet)
if type(name) ~= "string" then return nil end
-- The verdict for a name never changes (the tables above are fixed), and the
-- Loader's require shim asks for every require the engine makes while a mod
-- is enabled, so it is worked out once per name: false for allowed, the
-- denial message, or NETWORK_ONLY with the message kept alongside.
local verdicts, verdictCount = {}, 0
local VERDICT_MAX = 4096
local function verdictFor(name)
local root = head(name)
local reason = DENIED[root]
if reason then
@@ -58,10 +63,29 @@ function Sandbox.moduleDenial(name, permissionSet)
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
.. "and the engine API instead"):format(name)
end
if NETWORK[root] and not (permissionSet or {}).network then
return ("%s needs the \"network\" permission in manifest.json"):format(name)
if NETWORK[root] then
return { network = ("%s needs the \"network\" permission in manifest.json")
:format(name) }
end
return nil
return false
end
-- nil when the require is allowed, else the message to fail it with.
function Sandbox.moduleDenial(name, permissionSet)
if type(name) ~= "string" then return nil end
local verdict = verdicts[name]
if verdict == nil then
verdict = verdictFor(name)
if verdictCount >= VERDICT_MAX then verdicts, verdictCount = {}, 0 end
verdicts[name] = verdict
verdictCount = verdictCount + 1
end
if not verdict then return nil end
if type(verdict) == "table" then
if (permissionSet or {}).network then return nil end
return verdict.network
end
return verdict
end
-- ------- the love facade
+3 -1
View File
@@ -125,6 +125,9 @@ function Sprites.iconPath(data, mon, vanillaPath, opts)
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
return vanillaPath, trueColor
end
-- checked before the ctx table: party menus ask every drawn frame, and
-- an unhooked call must not allocate
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end
local species = mon and mon.species
local ctx = {
species = species,
@@ -134,7 +137,6 @@ function Sprites.iconPath(data, mon, vanillaPath, opts)
kind = "icon",
trueColor = trueColor,
}
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
trueColor = ctx.trueColor and true or false
if type(hooked) == "string" and hooked ~= "" then return hooked, trueColor end
+94 -7
View File
@@ -100,8 +100,12 @@ local function tileSet(spec)
return set
end
local resetTextCaches, resetWidthCache
function Font.load(data)
loadedFrom = data
-- a new charmap / page set / TTF: every cached encode and width is stale
if resetTextCaches then resetTextCaches() end
local def = data.font
state = { def = def, pages = {}, order = {}, byFirstByte = {} }
for id, page in pairs(pagesOf(def)) do
@@ -267,6 +271,8 @@ function Font.useBattleExtra(on)
if not state then return false end
local was = state.battleExtra or false
state.battleExtra = on and true or false
-- the swapped page may carry its own advance
if state.battleExtra ~= was and resetWidthCache then resetWidthCache() end
return was
end
@@ -278,7 +284,9 @@ end
-- Module state, not `state`: applyOptions runs before Font.load on boot
-- (src/core/Game2.lua Game2:load).
function Font.setFrame(index)
currentFrame = math.floor(tonumber(index) or 1)
local frame = math.floor(tonumber(index) or 1)
if frame ~= currentFrame and resetWidthCache then resetWidthCache() end
currentFrame = frame
end
function Font.frameIndex()
@@ -428,7 +436,7 @@ end
-- Convert a text string into a list of glyph codes. Unknown characters
-- render as space (and are reported once).
local reported = {}
function Font.encode(text)
local function encodeUncached(text)
local codes = {}
for _, span in ipairs(Font.split(text)) do
local code = span.code
@@ -445,6 +453,65 @@ function Font.encode(text)
return codes
end
-- Font.width/Font.draw run for the same few strings every frame, and
-- Font.split allocates a table per glyph. The codes only depend on the
-- charmap and TTF that Font.load builds, so cache them per string until the
-- next load. Widths also depend on which page answers a code (the frame
-- row, the battle-extra swap), so that cache is also dropped when either of
-- those changes. Both are bounded: a cache past ENCODE_CACHE_MAX entries is
-- thrown away whole rather than tracked LRU.
local ENCODE_CACHE_MAX = 4096
-- Bumped whenever a cached encode or width may have changed (Font.load, a
-- frame or battle-sheet swap), for callers that keep their own copies.
Font.revision = 0
local encodeCache, encodeCount = {}, 0
local stockSplit = Font.split
local widthCache, widthCount = {}, 0
function resetWidthCache()
widthCache, widthCount = {}, 0
-- anything a caller derived from encode/width is stale too
Font.revision = Font.revision + 1
end
function resetTextCaches()
encodeCache, encodeCount = {}, 0
resetWidthCache()
end
-- Returns the SHARED cached array: read it, never modify or keep it.
local function cachedCodes(text)
-- a replaced split (a test double) is honoured and nothing is cached
if Font.split ~= stockSplit then return encodeUncached(text) end
local codes = encodeCache[text]
if codes then return codes end
codes = encodeUncached(text)
if type(text) == "string" then
if encodeCount >= ENCODE_CACHE_MAX then
encodeCache, encodeCount = {}, 0
end
encodeCache[text] = codes
encodeCount = encodeCount + 1
end
return codes
end
-- Returns a fresh array: callers (TextBox's typewriter lines) append to
-- and keep the list, so they must never be handed the cached one.
function Font.encode(text)
local src = cachedCodes(text)
local codes = {}
for i = 1, #src do codes[i] = src[i] end
return codes
end
local stockEncode = Font.encode
-- Number of glyphs `text` draws as (#Font.split(text), without the spans).
function Font.glyphCount(text)
return #cachedCodes(text)
end
function Font.drawCode(code, x, y)
local ttf = state and state.ttf
if ttf and code >= TTF_BASE then
@@ -481,13 +548,31 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH
end
local stockAdvanceOf = Font.advanceOf
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
-- Multi-byte charmap entries like "¥" are one glyph; callers that
-- right-align with `#text * 8` mis-place them.
function Font.width(text)
local w = 0
for _, code in ipairs(Font.encode(text)) do
w = w + Font.advanceOf(code)
if Font.encode ~= stockEncode or Font.advanceOf ~= stockAdvanceOf
or Font.split ~= stockSplit then
-- a replaced encode/advanceOf/split (a test double, a mod) is still
-- what gets measured, uncached
local w = 0
for _, code in ipairs(Font.encode(text)) do w = w + Font.advanceOf(code) end
return w
end
local w = widthCache[text]
if w then return w end
w = 0
local codes = cachedCodes(text)
for i = 1, #codes do
w = w + Font.advanceOf(codes[i])
end
if type(text) == "string" then
if widthCount >= ENCODE_CACHE_MAX then resetWidthCache() end
widthCache[text] = w
widthCount = widthCount + 1
end
return w
end
@@ -495,9 +580,11 @@ end
-- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y)
local codes = Font.encode(text)
local codes = Font.encode == stockEncode and cachedCodes(text)
or Font.encode(text)
local pen = x
for _, code in ipairs(codes) do
for i = 1, #codes do
local code = codes[i]
Font.drawCode(code, pen, y)
pen = pen + Font.advanceOf(code)
end
+35 -9
View File
@@ -377,13 +377,42 @@ end
-- The same, ignoring the COLOR mode. The present pass needs it: it IS the
-- mode, so running its own palette back through resolve would flatten the
-- green ramp to grey and the mode would do nothing.
-- The pal0..pal3 uniforms each shader last received, weak-keyed so a
-- recompiled shader (see compiler) starts with no record. Nothing outside
-- this file sends pal0..pal3 to these shaders, so a uniform whose value is
-- unchanged is skipped: a send to the active shader flushes LOVE's batch.
-- The {r,g,b} arrays are scratch; shader:send copies the values.
local PAL_UNIFORMS = { [0] = "pal0", "pal1", "pal2", "pal3" }
local palScratch = { [0] = { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
local palSent = setmetatable({}, { __mode = "k" })
local function sendPalette(sh, colors)
local rec = palSent[sh]
if not rec then
rec = { valid = false }
palSent[sh] = rec
end
-- only trusted once all four sends landed (an error propagates as before)
local valid = rec.valid
rec.valid = false
for i = 0, 3 do
local r, g, b = channel(colors, i + 1)
local k = i * 3
if not (valid and rec[k + 1] == r and rec[k + 2] == g
and rec[k + 3] == b) then
local v = palScratch[i]
v[1], v[2], v[3] = r, g, b
sh:send(PAL_UNIFORMS[i], v)
rec[k + 1], rec[k + 2], rec[k + 3] = r, g, b
end
end
rec.valid = true
end
function GbcPalette.useRaw(colors)
local sh = GbcPalette.shader()
if not sh then return false end
for i = 0, 3 do
local r, g, b = channel(colors, i + 1)
sh:send("pal" .. i, { r, g, b })
end
sendPalette(sh, colors)
love.graphics.setShader(sh)
return true
end
@@ -391,11 +420,8 @@ end
function GbcPalette.useKeyed(colors)
local sh = GbcPalette.keyedShader()
if not sh then return false end
local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp)
for i = 0, 3 do
local r, g, b = channel(resolved, i + 1)
sh:send("pal" .. i, { r, g, b })
end
sendPalette(sh,
GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp))
love.graphics.setShader(sh)
return true
end
+19 -3
View File
@@ -92,7 +92,11 @@ end
local function put(t, x, y, tint)
if not t then return end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(tint or { 1, 1, 1, 1 })
if tint then
love.graphics.setColor(tint)
else
love.graphics.setColor(1, 1, 1, 1)
end
love.graphics.draw(t.img, t.quad, x, y)
love.graphics.setColor(r, g, b, a)
end
@@ -159,6 +163,19 @@ end
-- caller that is animating the bar between two HP values
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
-- thresholds do. Without it the length comes from mon.hp as before.
-- The fill tint for a bar colour, kept per colour table (the bar palettes
-- are a handful of fixed tables) and rebuilt if its values ever differ.
-- put() only reads it.
local fillTints = setmetatable({}, { __mode = "k" })
local function fillTint(c)
local t = fillTints[c]
if t and t.r == c[1] and t.g == c[2] and t.b == c[3] then return t end
t = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1, r = c[1], g = c[2], b = c[3] }
fillTints[c] = t
return t
end
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6))
@@ -182,8 +199,7 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixe
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
tint = fillTint(c)
end
end
for i = 0, segments - 1 do
+157 -20
View File
@@ -67,9 +67,37 @@ function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
-- active mode resolved, with a distinct cache group so the lit and dark bakes
-- of one sheet never collide in SpriteRenderer's obpCache.
--
-- It runs per sprite per frame in a dark cave, so the permuted palette is
-- kept per source palette (and per map, should DARK_BGP ever be swapped) and
-- the group name per group; permute's result is only ever read.
local darkPermuted = setmetatable({}, { __mode = "k" })
local darkGroupNames = {}
-- permute(colors, map), remembered in memo[colors] while `map` stays the same
local function permuteMemo(memo, colors, map)
local hit = memo[colors]
if hit and hit.map == map then return hit.colors end
local out = PaletteFX.permute(colors, map)
if type(colors) == "table" then memo[colors] = { map = map, colors = out } end
return out
end
-- tostring(group) .. suffix, remembered per group
local function suffixedGroup(names, group, suffix)
if group == nil or group ~= group then return tostring(group) .. suffix end
local name = names[group]
if not name then
name = tostring(group) .. suffix
names[group] = name
end
return name
end
function PaletteFX.darkObp(colors, group)
if not (colors and darkWorld) then return colors, group end
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
return permuteMemo(darkPermuted, colors, PaletteFX.DARK_BGP),
suffixedGroup(darkGroupNames, group, "dark")
end
-- the same shift folded into a baked 8-group world palette array (ADVANCED)
@@ -85,6 +113,9 @@ end
-- home/fade.asm:52-58
local fadeObpMap = nil
local fadeObpGroups = setmetatable({}, { __mode = "k" })
-- per fade map: the permuted palettes and suffixed group names (see darkObp)
local fadePermuted = setmetatable({}, { __mode = "k" })
local fadeGroupNames = setmetatable({}, { __mode = "k" })
function PaletteFX.setFadeObp(map)
fadeObpMap = map
@@ -98,7 +129,16 @@ function PaletteFX.fadeObp(colors, group)
.. fadeObpMap[2] .. fadeObpMap[3]
fadeObpGroups[fadeObpMap] = suffix
end
return PaletteFX.permute(colors, fadeObpMap), tostring(group) .. suffix
local memo = fadePermuted[fadeObpMap]
local names = fadeGroupNames[fadeObpMap]
if not memo then
memo = setmetatable({}, { __mode = "k" })
names = {}
fadePermuted[fadeObpMap] = memo
fadeGroupNames[fadeObpMap] = names
end
return permuteMemo(memo, colors, fadeObpMap),
suffixedGroup(names, group, suffix)
end
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
@@ -369,6 +409,8 @@ end
-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas
-- via __index so shared Red tilesets stay identical byte-for-byte.
local yellowWorld, yellowWorldBase, yellowWorldDelta
function PaletteFX.worldPack()
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
@@ -377,7 +419,13 @@ function PaletteFX.worldPack()
local y = PaletteFX.gbcYellowPack()
local yw = y and y.world
if not yw then return w end
return {
-- per sprite per frame on Yellow: build the merged view once per pair of
-- packs (a reload loads new tables, so identity is the whole key)
if yellowWorld and yellowWorldBase == w and yellowWorldDelta == yw then
return yellowWorld
end
yellowWorldBase, yellowWorldDelta = w, yw
yellowWorld = {
tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }),
groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }),
roofGroup = w.roofGroup,
@@ -387,6 +435,7 @@ function PaletteFX.worldPack()
and setmetatable(yw.spritePalettes, { __index = w.spritePalettes })
or w.spritePalettes,
}
return yellowWorld
end
function PaletteFX.usesGbcPack(mode)
@@ -775,27 +824,44 @@ end
-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent
-- here): a stable hash instead, so the same NPC instance always shows the
-- same one of the 4 SPR_PAL_* colors.
local sourceKinds = {}
local seedGroups = setmetatable({}, { __mode = "k" })
function PaletteFX.spriteObp(spriteDef, seed)
local w = PaletteFX.worldPack()
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
-- the parse depends only on the string (per sprite per frame otherwise)
local parsed = sourceKinds[src]
if not parsed then
local n = tonumber(src:match("%[(%d+)%]"))
parsed = n or (src:find("RedBikeSprite", 1, true) and "bike")
or (src:find("SurfingPikachuSprite", 1, true) and "surf") or "none"
sourceKinds[src] = parsed
end
local idx = type(parsed) == "number" and parsed or nil
-- RedBikeSprite and SurfingPikachuSprite load outside
-- SpriteSheetPointerTable, so their source has no bracketed index;
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
if not idx and src:find("RedBikeSprite", 1, true) then
if parsed == "bike" then
idx = 0
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
elseif parsed == "surf" then
idx = GameVersion.isYellow() and 60 or 0
end
local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end
if group == "random" then
local h = 0
seed = tostring(seed or "")
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
group = h % 4
local key = seed == nil and "" or seed
local h = seedGroups[key]
if not h then
h = 0
local str = tostring(seed or "")
for i = 1, #str do h = (h * 31 + str:byte(i)) % 4294967296 end
h = h % 4
if key == key then seedGroups[key] = h end
end
group = h
end
return PaletteFX.darkObp(w.spritePalettes[group], group)
end
@@ -1009,15 +1075,89 @@ function PaletteFX.effectiveColors(c)
return PaletteFX.permute(out, shadeMap)
end
local stockEffectiveColors = PaletteFX.effectiveColors
-- ------- uniform sends
--
-- sendColors runs per zone per frame and per colored sprite, and a send to
-- the active shader flushes LOVE's draw batch. Remember what each shader
-- was last given (weak-keyed, so a released or replaced shader drops its
-- record) and only send a uniform whose value changed. The {r,g,b} arrays
-- are scratch: shader:send copies the values, so they are reused.
local UNIFORMS = { "c0", "c1", "c2", "c3" }
local scratch = { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
local lastSent = setmetatable({}, { __mode = "k" })
-- A caller that sends c0..c3 itself (AnimPlayer's per-cell OBJ colors) has
-- to drop the record, or the next sendColors of the old colors would be
-- skipped. AnimPlayer:drawSprites does this after its direct sends.
function PaletteFX.forgetSent(sh)
local rec = sh and lastSent[sh]
if rec then rec.valid = false end
end
local function sendOne(sh, rec, i, col, valid)
local r, g, b = col[1] / 255, col[2] / 255, col[3] / 255
local k = i * 3
if valid and rec[k - 2] == r and rec[k - 1] == g and rec[k] == b then
return
end
local v = scratch[i]
v[1], v[2], v[3] = r, g, b
sh:send(UNIFORMS[i], v)
rec[k - 2], rec[k - 1], rec[k] = r, g, b
end
local function sendFour(sh, a, b, c, d)
local rec = lastSent[sh]
if not rec then
rec = { valid = false }
lastSent[sh] = rec
end
-- An error from send propagates as the unconditional sends' did; the
-- record only counts once all four landed, so a raise mid-way never
-- leaves a half-written palette looking current.
local valid = rec.valid
rec.valid = false
sendOne(sh, rec, 1, a, valid)
sendOne(sh, rec, 2, b, valid)
sendOne(sh, rec, 3, c, valid)
sendOne(sh, rec, 4, d, valid)
rec.valid = true
end
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
-- applying the active COLORS display mode
-- applying the active COLORS display mode. Resolves the same colors
-- effectiveColors returns, indexing through the mode and shade maps rather
-- than building the permuted tables.
function PaletteFX.sendColors(shader, c)
c = PaletteFX.effectiveColors(c)
if not c then return end
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
if PaletteFX.effectiveColors ~= stockEffectiveColors then
-- something replaced effectiveColors: honour it, as the send always did
c = PaletteFX.effectiveColors(c)
if not c then return end
return sendFour(shader, c[1], c[2], c[3], c[4])
end
local mode = PaletteFX.mode or "gbc"
local base, modeMap = c, nil
if PaletteFX.customRamp and not PaletteFX.pickerActive() then
base = PaletteFX.customRamp
elseif mode == "og" then
base = PaletteFX.GRAYS
elseif mode == "og_inv" then
base, modeMap = PaletteFX.GRAYS, INV_MAP
elseif mode == "classic" then
base = PaletteFX.CLASSIC
elseif mode == "gbc_inv" then
modeMap = INV_MAP
end
local sm = shadeMap
local i0, i1, i2, i3 = 0, 1, 2, 3
if sm then i0, i1, i2, i3 = sm[0], sm[1], sm[2], sm[3] end
if modeMap then
i0, i1, i2, i3 = modeMap[i0], modeMap[i1], modeMap[i2], modeMap[i3]
end
sendFour(shader, base[i0 + 1], base[i1 + 1], base[i2 + 1], base[i3 + 1])
end
-- The same send with NO display-mode substitution and no shade map: the four
@@ -1030,10 +1170,7 @@ function PaletteFX.sendShades(shader, c)
-- headless (no love.graphics) leaves shader() nil; sendColors reaches the
-- same no-op through effectiveColors returning nil for an absent palette
if not shader or not c then return end
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
sendFour(shader, c[1], c[2], c[3], c[4])
end
return PaletteFX
+113 -9
View File
@@ -673,6 +673,88 @@ local function sizeTable(state, i, dims, sizeUniforms, viewport, original)
return values
end
-- ------- per-pass uniform bookkeeping
--
-- runPass used to rebuild and pcall-send every uniform every frame. Each
-- pass's shader is private to its chain state (built at activate, dropped
-- with the state) and nothing else sends to it, so `cached.sent` remembers
-- what the shader last received and a uniform is only re-sent when its value
-- changed. A failed send is remembered too: it would fail the same way again
-- for the same name and value.
local textureUniformNames = {}
local function textureUniform(name)
local u = textureUniformNames[name]
if not u then
u = "LIBRA_TEXTURE_" .. name
textureUniformNames[name] = u
end
return u
end
-- Fixup.packValues over `values` with ALL_DEFAULTS layered on top (a default
-- wins, exactly as copying it into `values` did), into tables reused across
-- frames: shader:send copies, so the vec4 groups need not be fresh.
local PACK_SLOT = { x = 1, y = 2, z = 3, w = 4 }
local function packPass(cached, manifest, defaults, values)
local packed = cached.packed
if not packed then
packed = {}
cached.packed = packed
cached.groups = {}
for _, entry in ipairs(manifest) do
if entry.component and not cached.groups[entry.uniform] then
cached.groups[entry.uniform] = { 0, 0, 0, 0 }
end
end
end
for uniform, group in pairs(cached.groups) do
group[1], group[2], group[3], group[4] = 0, 0, 0, 0
packed[uniform] = group
end
for _, entry in ipairs(manifest) do
local value = defaults[entry.name]
if value == nil then value = values[entry.name] end
if entry.component then
packed[entry.uniform][PACK_SLOT[entry.component]] = value or 0
else
packed[entry.uniform] = value
end
end
return packed
end
-- Whether `value` differs from what `sent[name]` recorded. Arrays of plain
-- numbers/booleans are compared element-wise; anything else always sends.
local function changedSince(sent, name, value)
local last = sent[name]
if last == nil then return true end
if type(value) ~= "table" then return last ~= value end
if type(last) ~= "table" or last.n ~= #value then return true end
for k = 1, last.n do
local v = value[k]
local tv = type(v)
if (tv ~= "number" and tv ~= "boolean") or last[k] ~= v then return true end
end
return false
end
local function remember(sent, name, value)
if type(value) ~= "table" then
sent[name] = value
return
end
local snap = sent[name]
if type(snap) ~= "table" then
snap = {}
sent[name] = snap
end
local n = #value
for k = 1, n do snap[k] = value[k] end
for k = n + 1, (snap.n or 0) do snap[k] = nil end
snap.n = n
end
-- Runs pass `i`, drawing `srcImg` into a freshly sized canvas. `lutByName`
-- resolves a User-semantic sampler by the name the translation reported.
local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport, original, layer)
@@ -684,6 +766,11 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
local cached = assert(state.shaderCache[i],
("pass%d: shader was not built at activate"):format(i))
local shader, fragManifest = cached.shader, cached.fragManifest
local sent = cached.sent
if not sent then
sent = {}
cached.sent = sent
end
for _, s in ipairs(pass.samplers) do
local img
@@ -694,13 +781,21 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport
elseif s.semantic == "User" then img = lutByName and lutByName[s.user_name] end
assert(img, ("pass%d: no binding resolved for sampler %s (semantic=%s)")
:format(i, s.name, tostring(s.semantic)))
pcall(shader.send, shader, "LIBRA_TEXTURE_" .. s.name, img)
local uniform = textureUniform(s.name)
if sent[uniform] ~= img then
pcall(shader.send, shader, uniform, img)
sent[uniform] = img
end
end
local values = sizeTable(state, i, dims, pass.size_uniforms, viewport, original)
for name, value in pairs(state.ALL_DEFAULTS) do values[name] = value end
local packed = Fixup.packValues(fragManifest, values)
for name, value in pairs(packed) do pcall(shader.send, shader, name, value) end
local packed = packPass(cached, fragManifest, state.ALL_DEFAULTS, values)
for name, value in pairs(packed) do
if changedSince(sent, name, value) then
pcall(shader.send, shader, name, value)
remember(sent, name, value)
end
end
local srcImg = (i == 0) and frameSource or outputs[i - 1]
local srcDims = passInputDims(state, i, original)
@@ -1090,6 +1185,7 @@ end
-- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas
-- is cached module-wide and reallocated only on a real size change.
local cropCanvasCache = {}
local cropQuadCache = {}
local function gridGeometry(rect, renderScale, originX, originY)
local s = tonumber(rect.scale)
@@ -1112,16 +1208,24 @@ local function cropToGbSource(canvas, rect, srcW, srcH, renderScale, layer, geo)
-- Cheap insurance against a read-after-write hazard between this draw and
-- whatever last rendered into `canvas`.
love.graphics.flushBatch()
local outW, outH, quad
local outW, outH, qx, qy, qw, qh
if geo then
outW, outH = geo.crop.w, geo.crop.h
quad = love.graphics.newQuad(rect.x + geo.x0 + CROP_SAMPLE_BIAS,
rect.y + geo.y0 + CROP_SAMPLE_BIAS, geo.cols * geo.scale, geo.rows * geo.scale,
canvas:getPixelWidth(), canvas:getPixelHeight())
qx, qy = rect.x + geo.x0 + CROP_SAMPLE_BIAS, rect.y + geo.y0 + CROP_SAMPLE_BIAS
qw, qh = geo.cols * geo.scale, geo.rows * geo.scale
else
outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale)
quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
qx, qy, qw, qh = rect.x, rect.y, rect.w, rect.h
end
-- One quad per layer, re-aimed each frame rather than allocated. A quad
-- without setViewport (a test double) is simply rebuilt.
local quad = cropQuadCache[layer]
if quad and quad.setViewport then
quad:setViewport(qx, qy, qw, qh, canvas:getPixelWidth(), canvas:getPixelHeight())
else
quad = love.graphics.newQuad(qx, qy, qw, qh,
canvas:getPixelWidth(), canvas:getPixelHeight())
cropQuadCache[layer] = quad
end
local out
local cached = cropCanvasCache[layer]
+32 -1
View File
@@ -40,8 +40,21 @@ end
-- hardware, where sprite palette index 0 is unconditionally transparent
-- (same rule TileRenderer's getColor0KeyShader documents for tall grass).
local obpCache = {}
-- obpCache is keyed by the string `path .. "#obp" .. group`; this indexes the
-- same entries by path then group, so a draw that hits the cache (every draw
-- after the first) builds no key string. A numeric group is looked up by
-- its string form, since the concatenation cannot tell 1 from "1" either.
local obpByPath = {}
local groupStrings = {}
local function getObpImage(path, colors, group)
local byGroup = type(path) == "string" and obpByPath[path]
if byGroup then
local g = group
if type(g) == "number" then g = groupStrings[g] end
local hit = g ~= nil and byGroup[g]
if hit then return hit end
end
local key = path .. "#obp" .. group
if not obpCache[key] then
local img
@@ -59,7 +72,24 @@ local function getObpImage(path, colors, group)
end
obpCache[key] = img
end
return obpCache[key]
local img = obpCache[key]
-- only string/number groups get here: concatenating anything else raised
if group ~= group or type(path) ~= "string" then return img end -- NaN key
if type(group) == "number" then
local g = groupStrings[group]
if not g then
g = "" .. group
groupStrings[group] = g
end
group = g
end
byGroup = obpByPath[path]
if not byGroup then
byGroup = {}
obpByPath[path] = byGroup
end
byGroup[group] = img
return img
end
SpriteRenderer.obpImage = getObpImage
@@ -69,6 +99,7 @@ SpriteRenderer.obpImage = getObpImage
function SpriteRenderer.invalidate()
imageCache = {}
obpCache = {}
obpByPath = {}
end
Assets.register(SpriteRenderer.invalidate)
+28 -8
View File
@@ -437,7 +437,21 @@ function TextBox:moneyVisible()
return not self.moneyWithChoice or not not self.choicePushed
end
local step
-- One logic frame. The ScrollTextUpOneLine slide advances here, after the
-- step (so a line begun this frame is first drawn 2px into its slide, as it
-- was when draw advanced it), and not in draw, whose rate follows the
-- display's refresh rather than the 60Hz logic clock.
function TextBox:update(dt)
step(self, dt)
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
end
end
function step(self, dt)
local input = self.game.input
self.blink = (self.blink + 1) % 480
-- home/text.asm:506
@@ -722,10 +736,6 @@ function TextBox:draw()
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
love.graphics.setColor(0, 0, 0, 1)
end
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
end
-- Only the retained line carries the offset: it slides up from where it
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
@@ -757,14 +767,24 @@ function TextBox:draw()
love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8)
love.graphics.setColor(0, 0, 0, 1)
local cap = 13 * 8
for _, code in ipairs(Font.encode("MONEY")) do
if not self.moneyLabel or self.moneyRev ~= Font.revision then
self.moneyLabel, self.moneyAmount = Font.encode("MONEY"), nil
self.moneyRev = Font.revision
end
for _, code in ipairs(self.moneyLabel) do
drawGlyph(code, cap, 0)
cap = cap + Font.advanceOf(code)
end
end
local money = ("¥%d"):format(self.money() or 0)
local pen = 152 - Font.width(money)
for _, code in ipairs(Font.encode(money)) do
-- formatted and encoded only when the amount (or the font) changes
local amount = self.money() or 0
if amount ~= self.moneyAmount or self.moneyRev ~= Font.revision then
local money = ("¥%d"):format(amount)
self.moneyAmount, self.moneyRev = amount, Font.revision
self.moneyCodes, self.moneyWidth = Font.encode(money), Font.width(money)
end
local pen = 152 - self.moneyWidth
for _, code in ipairs(self.moneyCodes) do
drawGlyph(code, pen, 8)
pen = pen + Font.advanceOf(code)
end
+29 -9
View File
@@ -45,6 +45,18 @@ local function fitLabel(text, pixels)
return table.concat(out) .. "."
end
-- item -> { label, budget, out }: fitLabel's result for a row, kept
-- until the row's label or pixel budget changes (draw asks every frame).
-- Weak-keyed and outside the item so caller-owned rows are never written.
local fitCache = setmetatable({}, { __mode = "k" })
local function fitRowLabel(item, pixels)
local c = fitCache[item]
if c and c.label == item.label and c.budget == pixels then return c.out end
local out = fitLabel(item.label, pixels)
fitCache[item] = { label = item.label, budget = pixels, out = out }
return out
end
local ROWS = 7
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
@@ -258,16 +270,27 @@ function ListMenu:close()
if top == self then self.game.stack:pop() end
end
-- self.footer paginated into one flat line list, re-run only when the
-- footer text changes (both footer draws below ask every frame)
local function footerLines(self)
local text = self.footer
if self.footerFlatFor ~= text or not self.footerFlat then
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(text)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
self.footerFlatFor, self.footerFlat = text, flat
end
return self.footerFlat
end
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
local function drawMessageBox(self)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if not self.footer then return end
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local flat = footerLines(self)
local y = 112
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
@@ -362,7 +385,7 @@ function ListMenu:draw()
love.graphics.setColor(unpack(textColor))
local budget = ROW_RIGHT_MARGIN - ROW_LEFT
if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end
local label = fitLabel(item.label, budget)
local label = fitRowLabel(item, budget)
Font.draw(label, 16, y)
if item.ball then -- the Pokédex owned-ball marker tile
-- one blank glyph after the name, measured in glyph advances rather
@@ -400,10 +423,7 @@ function ListMenu:draw()
drawMessageBox(self)
elseif self.footer then
-- bare footer (bag money line, etc.)
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local flat = footerLines(self)
local y = (#flat >= 2) and 120 or 136
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
+15 -7
View File
@@ -130,16 +130,24 @@ function Menu:draw()
-- PlaceString at hlcoord 3,0 writes over the border row it was just
-- drawn on (oak_speech2.asm:162-170)
if self.title then
local title = self.title:gsub("{DONE}%s*$", "")
:gsub("{PROMPT}%s*$", "")
local lines = {}
for line in (title .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
-- the title's lines and glyph counts, rebuilt only when it changes
-- (this draws every frame the menu is up)
local lines, glyphs = self.titleLines, self.titleGlyphs
if self.titleLinesFor ~= self.title or not lines then
local title = self.title:gsub("{DONE}%s*$", "")
:gsub("{PROMPT}%s*$", "")
lines, glyphs = {}, {}
for line in (title .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
glyphs[#lines] = #Font.split(line)
end
self.titleLinesFor = self.title
self.titleLines, self.titleGlyphs = lines, glyphs
end
love.graphics.setColor(1, 1, 1, 1)
for i, line in ipairs(lines) do
for i = 1, #lines do
love.graphics.rectangle("fill", (self.tx + 3) * 8,
(self.ty + i - 1) * 8, #Font.split(line) * 8, 8)
(self.ty + i - 1) * 8, glyphs[i] * 8, 8)
end
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(lines) do
+42 -7
View File
@@ -175,6 +175,29 @@ function PartyMenu.mirrorsIcon(name)
end
local iconImages = {}
-- the OBP0-baked copies: path -> ogGroup (or "") -> image, the "#obp" half
-- of the cache, nested so a drawn frame builds no key string per icon
local obpIconImages = {}
-- image -> { half = { [frame] = quad }, full = { [frame] = quad } }: icon
-- quads never change once built, so every drawn frame reuses them (they may
-- also sit in PaletteFX's UI redraw list, which only reads them)
local iconQuads = setmetatable({}, { __mode = "k" })
local function iconQuad(img, kind, frame, w, iw, ih)
local byImg = iconQuads[img]
if not byImg then
byImg = { half = {}, full = {} }
iconQuads[img] = byImg
end
local byFrame = byImg[kind]
local q = byFrame[frame]
if not q then
q = love.graphics.newQuad(0, frame * 16, w, 16, iw, ih)
byFrame[frame] = q
end
return q
end
-- Sprites.iconPath's opts, reused (it only reads name / trueColor)
local iconPathOpts = {}
-- engine/items/town_map.asm:514
local OAM_XFLIP = { sx = -1 }
@@ -236,8 +259,10 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
iconPathOpts.name, iconPathOpts.trueColor = name, trueColor
path, trueColor = require("src.pokemon.Sprites")
.iconPath(game.data, mon, path, { name = name, trueColor = trueColor })
.iconPath(game.data, mon, path, iconPathOpts)
iconPathOpts.name, iconPathOpts.trueColor = nil, nil
if not path then return end
-- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a
-- mod's own image (an entry table rather than an icon name) is authored
@@ -261,8 +286,18 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
ogColors, ogGroup = PaletteFX.ogObjNormal()
end
end
local key = baked and (path .. "#obp" .. (ogGroup or "")) or path
if iconImages[key] == nil then
-- one cache, two halves: the plain image under its path, the baked one
-- under (path, ogGroup) -- the old path .. "#obp" .. group key, nested
local cache, key = iconImages, path
if baked then
cache = obpIconImages[path]
if not cache then
cache = {}
obpIconImages[path] = cache
end
key = ogGroup or ""
end
if cache[key] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
-- picked up the same way battle sprites are
@@ -275,9 +310,9 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
else
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
end
iconImages[key] = ok and img or false
cache[key] = ok and img or false
end
local img = iconImages[key]
local img = cache[key]
if not img then return end
local alt = forceAlt or false
if selected then
@@ -302,7 +337,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
-- reuse overworld sheets whose walk-down frame is NOT symmetric, so
-- drawing the raw 16x16 showed a tucked-back foot the hardware never
-- displays (#276, absorbing #238).
local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih)
local half = iconQuad(img, "half", frame, 8, iw, ih)
love.graphics.draw(img, half, x, y)
-- sx = -1 about the block's right edge, so the flipped copy lands on
-- x+8..x+16: the OAM_XFLIP half
@@ -312,7 +347,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp)
PaletteFX.markUiSpriteRedraw(img, half, x + 16, y, OAM_XFLIP)
end
elseif ih > 16 then
local quad = love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih)
local quad = iconQuad(img, "full", frame, 16, iw, ih)
love.graphics.draw(img, quad, x, y)
if ogColors then PaletteFX.markUiSpriteRedraw(img, quad, x, y) end
else
+8 -3
View File
@@ -285,9 +285,14 @@ local function drawClerk(menu)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if menu.footer then
local flat = {}
for _, page in ipairs(TextBox.paginate(menu.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
-- paginated once per footer text, not once per drawn frame
local flat = menu.shopFooterFlat
if menu.shopFooterFor ~= menu.footer or not flat then
flat = {}
for _, page in ipairs(TextBox.paginate(menu.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
menu.shopFooterFor, menu.shopFooterFlat = menu.footer, flat
end
local y = 112
for i = math.max(1, #flat - 1), #flat do
+23 -2
View File
@@ -17,6 +17,28 @@ local Strings = require("src.core.Strings")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
-- front pics by path: opening the summary used to decode the PNG into a new
-- Image every time. Loaded exactly as before (the raw path, no Assets
-- resolution); a failed load is retried next open, as before. Hot reload
-- (Assets.flush) drops the cache so an edited pic is picked up again.
local frontPics = {}
local function frontPic(path)
local img = frontPics[path]
if img then return img end
local ok, loaded = pcall(love.graphics.newImage, path)
if ok and loaded then
frontPics[path] = loaded
return loaded
end
return nil
end
do
local Assets = require("src.render.Assets")
if Assets.register then -- test harnesses may stub Assets without it
Assets.register(function() frontPics = {} end)
end
end
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
SummaryMenu.isOpaque = true
@@ -48,8 +70,7 @@ function SummaryMenu.new(game, mon)
local path, trueColor = Sprites.path(game.data, mon.species, "front",
{ mon = mon, kind = "summary" })
if path then
local ok, img = pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil
self.sprite = frontPic(path)
end
self.spriteTrueColor = self.sprite and trueColor or false
-- engine/pokemon/status_screen.asm:82,168-172
+39 -5
View File
@@ -328,6 +328,31 @@ local function skipAllowed()
return envSkipAllowed or _G.POKEPORT_DEV_MODE == true
end
-- sheet -> tile index -> 8x8 quad. The frame blocks redraw every frame
-- the trade machine is on screen; a tile's quad never changes, so it is
-- built once (it may also sit in PaletteFX's UI redraw list, read-only).
local tileQuads = setmetatable({}, { __mode = "k" })
local function tileQuad(sheet, tile, iw, ih)
local byTile = tileQuads[sheet]
if not byTile then
byTile = {}
tileQuads[sheet] = byTile
end
local q = byTile[tile]
if not q then
q = love.graphics.newQuad((tile % 16) * 8, math.floor(tile / 16) * 8,
8, 8, iw, ih)
byTile[tile] = q
end
return q
end
-- markUiSpriteRedraw's flip opts (it only reads sx / sy off them)
local FLIP_OPTS = {
[1] = { [1] = { sx = 1, sy = 1 }, [-1] = { sx = 1, sy = -1 } },
[-1] = { [1] = { sx = -1, sy = 1 }, [-1] = { sx = -1, sy = -1 } },
}
-- engine/movie/trade.asm:20
function TradeAnim:skipHeld()
if not skipAllowed() then return false end
@@ -343,9 +368,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y)
local P = baked and require("src.render.PaletteFX")
local iw, ih = sheet:getDimensions()
for _, tile in ipairs(block) do
local tx = (tile.tile % 16) * 8
local ty = math.floor(tile.tile / 16) * 8
local quad = love.graphics.newQuad(tx, ty, 8, 8, iw, ih)
local quad = tileQuad(sheet, tile.tile, iw, ih)
local sx = tile.xflip and -1 or 1
local sy = tile.yflip and -1 or 1
local ox = tile.xflip and 8 or 0
@@ -353,7 +376,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y)
love.graphics.draw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy)
if P then
P.markUiSpriteRedraw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy,
{ sx = sx, sy = sy })
FLIP_OPTS[sx][sy])
end
end
else
@@ -764,9 +787,20 @@ local function drawCableHoriz(self, y, x0, x1)
love.graphics.setColor(1, 1, 1, 1)
if self.img.cableHoriz then
local iw, ih = self.img.cableHoriz:getDimensions()
-- one quad per run width, kept on the instance (the last run of a
-- span is usually the only partial one)
local quads = self.cableQuads
if not quads or quads.img ~= self.img.cableHoriz then
quads = { img = self.img.cableHoriz }
self.cableQuads = quads
end
for x = x0, x1 - 1, iw do
local drawW = math.min(iw, x1 - x)
local quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
local quad = quads[drawW]
if not quad then
quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
quads[drawW] = quad
end
love.graphics.draw(self.img.cableHoriz, quad, x, y)
end
elseif self.img.cableSeg then
+12 -4
View File
@@ -528,13 +528,21 @@ end
local function elements_tile_quad(ti, sheet)
sheet = sheet or BattleChrome._elements
if not sheet or not love or not love.graphics then return nil end
local key = (sheet == BattleChrome._elementsExp and "exp_" or "elt_") .. tostring(ti)
if not BattleChrome._quads[key] then
-- Per-sheet subtables keyed by tile index (no per-tile string keys).
local subKey = sheet == BattleChrome._elementsExp and "exp_tiles" or "elt_tiles"
local sub = BattleChrome._quads[subKey]
if not sub then
sub = {}
BattleChrome._quads[subKey] = sub
end
local q = sub[ti]
if not q then
local tw = 40 -- 320/8
local tx, ty = ti % tw, math.floor(ti / tw)
BattleChrome._quads[key] = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24)
q = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24)
sub[ti] = q
end
return BattleChrome._quads[key]
return q
end
-- pokefirered/src/battle_interface.c:2050
+81 -28
View File
@@ -264,7 +264,7 @@ local function loadImage(candidates)
if okId and id then
local img = love.graphics.newImage(id)
if img and img.setFilter then img:setFilter("nearest", "nearest") end
return img, path
return img, path, id
end
elseif love and love.filesystem and love.image and love.graphics then
local okFd, fd = pcall(love.filesystem.newFileData, data, path)
@@ -273,7 +273,7 @@ local function loadImage(candidates)
if okId and id then
local img = love.graphics.newImage(id)
if img and img.setFilter then img:setFilter("nearest", "nearest") end
return img, path
return img, path, id
end
end
end
@@ -289,6 +289,42 @@ local function loadImage(candidates)
return nil, nil
end
-- Pack a sheet's fg and shadow images into one texture (fg on top, shadow
-- below a transparent gap) with matching quads. Each glyph still draws its
-- shadow then its fg, but from the same texture, so LOVE's autobatcher keeps
-- a whole string in one draw call instead of flushing on every texture swap.
-- Pixels are copied verbatim, so the output is unchanged. nil (draw from the
-- separate sheets) when either ImageData is missing or they do not match.
local ATLAS_GAP = 16
local function pack_atlas(fgData, shData, quads)
if not (fgData and shData and quads and love and love.image and love.image.newImageData
and love.graphics and love.graphics.newImage and love.graphics.newQuad) then
return nil
end
local ok, atlas = pcall(function()
local w, h = fgData:getDimensions()
local sw, shh = shData:getDimensions()
if sw ~= w or shh ~= h then return nil end
if fgData.getFormat and shData.getFormat and fgData:getFormat() ~= shData:getFormat() then return nil end
local off = h + ATLAS_GAP
local fmt = fgData.getFormat and fgData:getFormat() or "rgba8"
local data = love.image.newImageData(w, off + h, fmt)
data:paste(fgData, 0, 0, 0, 0, w, h)
data:paste(shData, 0, off, 0, 0, w, h)
local img = love.graphics.newImage(data)
if img.setFilter then img:setFilter("nearest", "nearest") end
local aw, ah = img:getDimensions()
local fq, sq = {}, {}
for id, q in pairs(quads) do
local qx, qy, qw, qh = q:getViewport()
fq[id] = love.graphics.newQuad(qx, qy, qw, qh, aw, ah)
sq[id] = love.graphics.newQuad(qx, qy + off, qw, qh, aw, ah)
end
return { image = img, fg = fq, sh = sq, src = quads }
end)
return ok and atlas or nil
end
local function loadTable(path)
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if not (okC and CacheFs and CacheFs.readActive) then return nil end
@@ -387,8 +423,8 @@ local function loadFace(spec, name)
error("FrlgFont: the active profile has no font face '" .. tostring(name) .. "'", 0)
end
local dir = spec.dir or ""
local fg, fgp = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } })
local sh = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } })
local fg, fgp, fgData = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } })
local sh, _, shData = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } })
local widths = fs.widths and loadTable(dir .. fs.widths)
local m = metricsFor(spec, fs.fontId)
if not (fg and widths and m) then
@@ -408,6 +444,7 @@ local function loadFace(spec, name)
name = name, fg = fg, sh = sh, quads = quads, widths = widths,
height = m.maxLetterHeight, pitch = m.maxLetterHeight + (m.lineSpacing or 0),
letterSpacing = m.letterSpacing or 0,
atlas = pack_atlas(fgData, shData, quads),
}
FrlgFont._faces[name] = face
log(fs.sheet .. " ready " .. tostring(fgp))
@@ -471,8 +508,8 @@ local function ensure()
end
FrlgFont._widths = widths or {}
local fg, fgp = loadImage(FG_PATHS)
local sh = loadImage(SH_PATHS)
local fg, fgp, fgData = loadImage(FG_PATHS)
local sh, _, shData = loadImage(SH_PATHS)
if not fg then
if not FrlgFont._logged then
log("latin_normal font missing")
@@ -490,6 +527,7 @@ local function ensure()
quads[id] = love.graphics.newQuad(col * 16, row * 16, 16, 16, iw, ih)
end
FrlgFont._quads = quads
FrlgFont._atlas = pack_atlas(fgData, shData, quads)
if not FrlgFont._logged then
log("latin_normal ready " .. tostring(fgp))
FrlgFont._logged = true
@@ -529,8 +567,8 @@ local function ensure_small()
if chunk then widths = chunk() end
end
end
local fg, fgp = loadImage(SMALL_FG_PATHS)
local sh = loadImage(SMALL_SH_PATHS)
local fg, fgp, fgData = loadImage(SMALL_FG_PATHS)
local sh, _, shData = loadImage(SMALL_SH_PATHS)
if not fg then return false end
local iw, ih = fg:getDimensions()
local quads = {}
@@ -545,6 +583,7 @@ local function ensure_small()
-- Sheets from extract_latin_small.py (ROM hwlat @ 0x1EAF00), CHARMAP-ordered.
FrlgFont._small = {
fg = fg, sh = sh, quads = quads, widths = widths or {}, _romBaked = true,
atlas = pack_atlas(fgData, shData, quads),
}
log("latin_small ready " .. tostring(fgp) .. " (ROM FONT_SMALL)")
return true
@@ -612,18 +651,18 @@ FrlgFont.JAPANESE_GLYPHS = japanese_glyphs()
local function load_japanese(key, fgPaths, shPaths)
if FrlgFont[key] ~= nil then return FrlgFont[key] or nil end
local fg = loadImage(fgPaths)
local fg, _, fgData = loadImage(fgPaths)
if not fg then
FrlgFont[key] = false
return nil
end
local sh = loadImage(shPaths)
local sh, _, shData = loadImage(shPaths)
local iw, ih = fg:getDimensions()
local quads = {}
for code = 0, 511 do
quads[code] = love.graphics.newQuad((code % 16) * 16, math.floor(code / 16) * 16, 16, 16, iw, ih)
end
FrlgFont[key] = { fg = fg, sh = sh, quads = quads }
FrlgFont[key] = { fg = fg, sh = sh, quads = quads, atlas = pack_atlas(fgData, shData, quads) }
return FrlgFont[key]
end
@@ -649,7 +688,7 @@ local function japanese_quad(glyphId, small)
local sheet = small and load_japanese("_jpSmall", JP_SMALL_FG_PATHS, JP_SMALL_SH_PATHS)
or load_japanese("_jpNormal", JP_FG_PATHS, JP_SH_PATHS)
if not sheet then return nil end
return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE]
return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE], sheet.atlas
end
-- The remaining single characters of the Latin block of pret
@@ -1162,6 +1201,14 @@ end
local ADVANCE_SMALL = { small = true }
local ADVANCE_NORMAL = {}
local function set_col(c)
if type(c) == "table" then
love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
else
love.graphics.setColor(1, 1, 1, 1)
end
end
--- Draw full string at pixel (x,y).
-- opts.maxWidth clips (CopyGlyphToWindow). opts.colors = COLOR.NORMAL etc.
-- opts.limitChars: only draw first N printable characters (typewriter).
@@ -1210,22 +1257,15 @@ function FrlgFont.draw(text, x, y, opts)
local drawn = 0
local pitch = opts.linePitch or (face and face.pitch)
or (useSmall and FrlgFont.SMALL_LINE_PITCH or FrlgFont.LINE_PITCH)
local fg, sh, quads
local fg, sh, quads, atlas
if face then
fg, sh, quads = face.fg, face.sh, face.quads
fg, sh, quads, atlas = face.fg, face.sh, face.quads, face.atlas
elseif useSmall then
fg, sh, quads = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads
fg, sh, quads, atlas = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads, FrlgFont._small.atlas
else
fg, sh, quads = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads
end
local function set_col(c)
if type(c) == "table" then
love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
else
love.graphics.setColor(1, 1, 1, 1)
end
fg, sh, quads, atlas = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads, FrlgFont._atlas
end
if atlas and (atlas.src ~= quads or not sh) then atlas = nil end
for ttype, val, curCol in FrlgFont.scanTokens(text, baseColors) do
if limit and drawn >= limit then break end
@@ -1262,9 +1302,12 @@ function FrlgFont.draw(text, x, y, opts)
end
if penX + adv <= maxW or penX == 0 then
local dx, dy = x + penX, y + penY
local gfg, gsh, q = fg, sh, quads[id]
local gfg, gsh, q, gat = fg, sh, quads[id], atlas
local aid = id
if id >= FrlgFont.JAPANESE_BASE then
gfg, gsh, q = japanese_quad(id, useSmall)
gfg, gsh, q, gat = japanese_quad(id, useSmall)
aid = id - FrlgFont.JAPANESE_BASE
if not gsh then gat = nil end
end
if q then
-- Draw background / highlight fill if bg is not transparent
@@ -1272,15 +1315,24 @@ function FrlgFont.draw(text, x, y, opts)
set_col(curCol.bg)
love.graphics.rectangle("fill", dx, dy, adv, pitch)
end
local aq = gat and gat.fg[aid]
-- Draw shadow
if gsh and curCol.shadow and (not curCol.shadow[4] or curCol.shadow[4] > 0) then
set_col(curCol.shadow)
love.graphics.draw(gsh, q, dx, dy)
if aq then
love.graphics.draw(gat.image, gat.sh[aid], dx, dy)
else
love.graphics.draw(gsh, q, dx, dy)
end
end
-- Draw foreground
if curCol.fg and (not curCol.fg[4] or curCol.fg[4] > 0) then
set_col(curCol.fg)
love.graphics.draw(gfg, q, dx, dy)
if aq then
love.graphics.draw(gat.image, aq, dx, dy)
else
love.graphics.draw(gfg, q, dx, dy)
end
end
end
penX = penX + japanese_step(id, adv, minW, jpn, opts, useSmall)
@@ -1381,6 +1433,7 @@ function FrlgFont.invalidate()
FrlgFont._fg = nil
FrlgFont._sh = nil
FrlgFont._quads = nil
FrlgFont._atlas = nil
FrlgFont._small = nil
FrlgFont._keypad = nil
FrlgFont._jpNormal = nil
+19 -2
View File
@@ -213,8 +213,25 @@ local function bgPal(s)
return Gfx.bgPalette(paletteSet(s))
end
-- Read-only bg palettes for per-frame draws, rebuilt when the manifest
-- changes. bgPal returns a fresh copy for callers that edit it.
local _readPals, _readPalsMan = {}, nil
local function bgPalRead(s)
local man = Gfx.manifest()
if man ~= _readPalsMan then
_readPals, _readPalsMan = {}, man
end
local set = paletteSet(s)
local pal = _readPals[set]
if not pal then
pal = Gfx.bgPalette(set)
_readPals[set] = pal
end
return pal
end
local function textColors(s, fgIdx, shIdx)
local pal = bgPal(s)
local pal = bgPalRead(s)
return { fg = Gfx.color(pal[fgIdx or 15]), shadow = Gfx.color(pal[shIdx or 3]), bg = { 0, 0, 0, 0 } }
end
@@ -2130,7 +2147,7 @@ end
local function listRowsText(s)
local colors = textColors(s, 15, 3)
local vofs = s.bg2vofs or 0
local pal = bgPal(s)
local pal = bgPalRead(s)
local ball = Gfx.sprite("caught_ball", 0, 8, 16, { pal[0], pal[1], pal[2], pal[3], pal[4], pal[5], pal[6], pal[7], pal[8],
pal[9], pal[10], pal[11], pal[12], pal[13], pal[14], pal[15] })
-- pokeemerald/src/pokedex.c:847
+19 -3
View File
@@ -28,6 +28,11 @@ local _cards = nil
local _cardEdge = {}
local _screens = nil
local _assetsTried = false
-- RSE front pics keyed by trainer pic id (false = load failed), and the
-- badge quads keyed by the badge sheet they were built for.
local _rsePics = {}
local _rseBadgeQuads = nil
local _rseBadgeQuadsImg = nil
local function read_cache_file(path)
local ok, CacheFs = pcall(require, "src.import.CacheFs")
@@ -997,7 +1002,11 @@ local function drawRse(c)
else
-- pokeemerald/src/trainer_card.c:1895
local picId = c.female and m.pics.female or m.pics.male
local pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64)
local pic = _rsePics[picId]
if pic == nil then
pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64) or false
_rsePics[picId] = pic
end
if pic then
love.graphics.draw(pic, (19 + m.picOffset[1]) * 8, (5 + m.picOffset[2]) * 8)
end
@@ -1009,10 +1018,17 @@ local function drawRse(c)
end
local badges = Kit.image(m.badges.png)
if badges then
local bw, bh = badges:getDimensions()
if _rseBadgeQuadsImg ~= badges then
local bw, bh = badges:getDimensions()
_rseBadgeQuads = {}
for i = 1, 8 do
_rseBadgeQuads[i] = love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh)
end
_rseBadgeQuadsImg = badges
end
for i = 1, 8 do
if c.badges[i] then
love.graphics.draw(badges, love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh), (4 + (i - 1) * 3) * 8, 15 * 8)
love.graphics.draw(badges, _rseBadgeQuads[i], (4 + (i - 1) * 3) * 8, 15 * 8)
end
end
end
+74 -31
View File
@@ -25,6 +25,8 @@ local Palettes = require("src.world.gen2.Palettes")
local BattleAnimView = {}
BattleAnimView.__index = BattleAnimView
local EMPTY = {}
local SCREEN_W, SCREEN_H = 160, 144
-- An OBJ at OAM (x, y) draws at (x - 8, y - 16).
@@ -64,14 +66,19 @@ function BattleAnimView:image(path)
return cached or nil
end
-- Nested by sheet then index, so a lookup builds no string.
function BattleAnimView:quad(sheetName, index, wide, image)
local key = sheetName .. ":" .. index
local quad = self.quads[key]
local bySheet = self.quads[sheetName]
if not bySheet then
bySheet = {}
self.quads[sheetName] = bySheet
end
local quad = bySheet[index]
if not quad then
local w, h = image:getDimensions()
quad = love.graphics.newQuad(
(index % wide) * 8, math.floor(index / wide) * 8, 8, 8, w, h)
self.quads[key] = quad
bySheet[index] = quad
end
return quad
end
@@ -93,30 +100,55 @@ end
-- colours; the other six are the fixed block from
-- gfx/battle_anims/battle_anims.pal, which the extractor reads into
-- palettes.battleObjects.
-- Palettes.monColors, memoized per (palettes, species, shiny): it builds five
-- tables a call and this runs per OAM object per frame. The answer is a pure
-- function of those three, and the shader only reads it.
function BattleAnimView:monColors(species, shiny)
local cache = self.monColorCache
if not cache or cache.palettes ~= self.palettes then
cache = { palettes = self.palettes, [true] = {}, [false] = {} }
self.monColorCache = cache
end
local byShiny = cache[shiny and true or false]
local colors = byShiny[species]
if colors == nil then
if species == nil then return Palettes.monColors(self.palettes, species, shiny) end
colors = Palettes.monColors(self.palettes, species, shiny) or false
byShiny[species] = colors
end
return colors or nil
end
function BattleAnimView:objPalette(name, battle)
if name == "PAL_BATTLE_OB_ENEMY" then
local enemy = battle and battle.enemy
return enemy and Palettes.monColors(self.palettes, enemy.species, enemy.shiny)
return enemy and self:monColors(enemy.species, enemy.shiny)
end
if name == "PAL_BATTLE_OB_PLAYER" then
local player = battle and battle.player
return player and Palettes.monColors(self.palettes, player.species, player.shiny)
return player and self:monColors(player.species, player.shiny)
end
local set = self.palettes and self.palettes.battleObjects
return set and set[name] or nil
end
-- One frame's OBJ layer.
-- One frame's OBJ layer. The palette shader is set once per run of objects
-- sharing a palette and the caller's shader restored once at the end: what
-- GbcPalette.with did per object, without the closure and pcall each.
function BattleAnimView:drawObjects(runner, battle)
local G = love.graphics
G.setColor(1, 1, 1, 1)
local shaded = GbcPalette.available()
local previous = shaded and G.getShader and G.getShader() or nil
local active = nil
local gfx = self.data.gfx or EMPTY
for _, obj in ipairs(runner:oam()) do
local entry, index = sheetForTile(runner, obj.tile)
-- The two battler-pic pseudo-sheets are the mons' own tiles; nothing in
-- the cache holds them as a sheet, so they are simply not drawn rather
-- than drawn from the wrong image.
if entry and not entry.battler then
local sheet = (self.data.gfx or {})[entry.gfx]
local sheet = gfx[entry.gfx]
local image = sheet and self:image(sheet.image)
if image then
local wide = sheet.wide or 8
@@ -132,19 +164,17 @@ function BattleAnimView:drawObjects(runner, battle)
local syScale = bit.band(obj.attr, OAM_YFLIP) ~= 0 and -1 or 1
local ox = sxScale < 0 and 8 or 0
local oy = syScale < 0 and 8 or 0
local colors = self:objPalette(obj.palette, battle)
local function body()
G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale)
end
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
local colors = shaded and self:objPalette(obj.palette, battle) or nil
if colors ~= active then
if colors then GbcPalette.use(colors) else G.setShader(previous) end
active = colors
end
G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale)
end
end
end
end
if active then G.setShader(previous) end
end
-- True when the BG layer needs the scanline treatment at all; a plain
@@ -269,24 +299,34 @@ end
BattleAnimView.bgpBands = bgpBands
-- engine/battle_anims/bg_effects.asm:2638
--
-- One scanline's source row and x shift, or nil when the row shows nothing.
local function scanline(bg, scy, row)
local dx, src = 0, row + scy
-- home/lcd.asm:12
local inWindow = bg.lcdc and bg.lcdc ~= "BGP"
and row > bg.lyStart and row <= bg.lyEnd
local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0
if inWindow then
local value = signed(byte)
if bg.lcdc == "SCX" then dx = -value else src = src + value end
-- home/lcd.asm:3
if byte ~= 0x90 and scy == 0 then
if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end
end
end
if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then
return src, dx
end
return nil
end
function BattleAnimView.scanlines(bg)
local lines = {}
local scy = signed(bg.scy)
for row = 0, SCREEN_H - 1 do
local dx, src = 0, row + scy
-- home/lcd.asm:12
local inWindow = bg.lcdc and bg.lcdc ~= "BGP"
and row > bg.lyStart and row <= bg.lyEnd
local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0
if inWindow then
local value = signed(byte)
if bg.lcdc == "SCX" then dx = -value else src = src + value end
-- home/lcd.asm:3
if byte ~= 0x90 and scy == 0 then
if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end
end
end
if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then
local src, dx = scanline(bg, scy, row)
if src then
lines[#lines + 1] = { src = src, dest = row, dx = dx }
end
end
@@ -339,8 +379,11 @@ function BattleAnimView:present(runner, drawBg, battle)
-- hSCX / hSCY move the whole background; the per-scanline overrides only
-- apply inside the effect's own window.
local baseX = -signed(bg.scx)
for _, line in ipairs(BattleAnimView.scanlines(bg)) do
self:blitRowAt(line.src, line.dest, baseX + line.dx)
-- BattleAnimView.scanlines row by row, without a table per scanline.
local scy = signed(bg.scy)
for row = 0, SCREEN_H - 1 do
local src, dx = scanline(bg, scy, row)
if src then self:blitRowAt(src, row, baseX + dx) end
end
-- 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.
+25 -14
View File
@@ -82,13 +82,18 @@ function BattleHud:image(key)
end
-- One 8x8 tile out of a horizontal strip, cached per (sheet, index).
-- Nested by image then index, so a lookup builds no string.
function BattleHud:quad(image, index)
local key = tostring(image) .. ":" .. index
local quad = self.quads[key]
local byImage = self.quads[image]
if not byImage then
byImage = {}
self.quads[image] = byImage
end
local quad = byImage[index]
if not quad then
local w, h = image:getDimensions()
quad = love.graphics.newQuad(index * 8, 0, 8, 8, w, h)
self.quads[key] = quad
byImage[index] = quad
end
return quad
end
@@ -120,20 +125,26 @@ function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror)
if index < 0 then return false end
local G = love.graphics
G.setColor(1, 1, 1, 1)
local function body()
if mirror then
-- Flip in place: the origin moves a tile right and x scales by -1.
G.draw(image, self:quad(image, index), tx * 8 + 8, ty * 8, 0, -1, 1)
else
G.draw(image, self:quad(image, index), tx * 8, ty * 8)
end
end
local quad = self:quad(image, index)
-- home/fade.asm:35 (RotateThreePalettesRight)
if GbcPalette.available() then
GbcPalette.with(colors or GbcPalette.DMG_SHADES, body)
-- GbcPalette.with without the per-tile closure: set, draw, restore.
local shaded = GbcPalette.available()
local previous
if shaded then
previous = G.getShader and G.getShader() or nil
GbcPalette.use(colors or GbcPalette.DMG_SHADES)
else
G.setColor(0, 0, 0, 1)
body()
end
if mirror then
-- Flip in place: the origin moves a tile right and x scales by -1.
G.draw(image, quad, tx * 8 + 8, ty * 8, 0, -1, 1)
else
G.draw(image, quad, tx * 8, ty * 8)
end
if shaded then
G.setShader(previous)
else
G.setColor(1, 1, 1, 1)
end
return true
+65 -11
View File
@@ -622,6 +622,12 @@ function BattleState.new(game, opts)
species = enemy and enemy.species,
level = enemy and enemy.level,
})
-- queue both entrance cries on the chip audio worker during the intro so
-- playCry doesn't render them on the frame they play (no-op without one)
local data = game and game.data
for _, mon in ipairs({ enemy or false, player or false }) do
if mon and mon.species then pcall(Sound.prewarmCry, data, mon.species) end
end
return self
end
@@ -750,7 +756,10 @@ function BattleState:pic(mon, back)
-- picked -- resolving the species row again would throw the form away. The
-- two extra keys are what Gen 2 genuinely carries more of: the Unown letter
-- and the shiny flag that decides the palette.
if path then
-- Unhooked, Sprites.pic hands back exactly (path, trueColor); the ctx is
-- only built when a subscriber is there to read it (drawPic asks twice a
-- frame).
if path and Runtime.wantsHook("pokemon.sprite") then
path, trueColor = Sprites.pic(path, {
species = mon.species,
side = back and "back" or "front",
@@ -937,6 +946,42 @@ function BattleState:isUnderground(side, mon)
and (self.vanishSeen and self.vanishSeen[side]) and true or false
end
-- Palettes.monColors / trainerColors, memoized per palettes table: each
-- builds five tables a call and drawPic asks twice a frame. Both are pure
-- functions of the palette data, and the shader only reads the answer.
local function colorMemo(self)
local memo = self.picColorMemo
if not memo or memo.palettes ~= self.palettes then
memo = { palettes = self.palettes, [true] = {}, [false] = {}, trainers = {} }
self.picColorMemo = memo
end
return memo
end
function BattleState:cachedMonColors(species, shiny)
if species == nil then
return Palettes.monColors(self.palettes, species, shiny)
end
local bucket = colorMemo(self)[shiny and true or false]
local colors = bucket[species]
if colors == nil then
colors = Palettes.monColors(self.palettes, species, shiny) or false
bucket[species] = colors
end
return colors or nil
end
function BattleState:cachedTrainerColors(row)
local key = row or "PLAYER"
local bucket = colorMemo(self).trainers
local colors = bucket[key]
if colors == nil then
colors = Palettes.trainerColors(self.palettes, row) or false
bucket[key] = colors
end
return colors or nil
end
function BattleState:drawPic(mon, back)
-- During the intro slide the player-side pic belongs to presentSlide's
-- backpic overlay, not to the baked bands (see BattleAnimView).
@@ -1041,17 +1086,17 @@ function BattleState:drawPic(mon, back)
-- No mon on this side at all in the catching tutorial, where the box holds
-- the DUDE's back-pic and nothing else for the whole battle.
local colors = self.palettes and mon
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
and self:cachedMonColors(mon.species, mon.shiny)
if trainerBack then
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
-- TrainerPalettes (Chris shares Cal's).
-- engine/gfx/color.asm:683-696
local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER"
colors = Palettes.trainerColors(self.palettes, row)
or Palettes.trainerColors(self.palettes, "PLAYER") or colors
colors = self:cachedTrainerColors(row)
or self:cachedTrainerColors("PLAYER") or colors
elseif enemyTrainer then
-- The opponent's class row out of the same TrainerPalettes table.
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
colors = self:cachedTrainerColors(self.enemyTrainerClass)
or colors
end
-- ../pokecrystal/engine/gfx/cgb_layouts.asm:67
@@ -4374,14 +4419,23 @@ end
-- The four labels the battle menu draws. Inside the contest the third one
-- carries the park ball count, which .PrintParkBallsRemaining writes with
-- PRINTNUM_LEADINGZEROS over two digits.
--
-- The list is refilled in place each call (the menu asks every frame); the
-- lookups themselves still run, so a catalog a mod edits live is followed.
function BattleState:menuLabels()
if not self.contest then
return { Strings(MENU[1]), Strings(MENU[2]),
Strings(MENU[3]), Strings(MENU[4]) }
local labels = self.menuLabelList
if not labels then
labels = {}
self.menuLabelList = labels
end
return { Strings(MENU[1]), Strings(MENU[2]),
Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save)),
Strings(MENU[4]) }
labels[1], labels[2], labels[4] =
Strings(MENU[1]), Strings(MENU[2]), Strings(MENU[4])
if not self.contest then
labels[3] = Strings(MENU[3])
else
labels[3] = Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save))
end
return labels
end
-- The message on the cart's own two rows: 14 and 16, with 15 blank between
+54 -36
View File
@@ -852,6 +852,39 @@ end
-- GetFrontpic's `cp EGG / jr nz, .not_egg` arm hands back EggPic, never the
-- hatchling's pic (engine/gfx/load_pics.asm:88-91); it rides menu_gfx.eggHatch,
-- with the party list's ICON_EGG standing in for a cache built before that.
-- One reusable quad per role, re-aimed per draw rather than a new Quad a
-- frame. nil when the backend cannot make one (the old pcall's degrade).
local function reusedQuad(self, slot, x, y, w, h, sw, sh)
local quads = self.reusedQuads
if not quads then
quads = {}
self.reusedQuads = quads
end
local quad = quads[slot]
if quad then
quad:setViewport(x, y, w, h, sw, sh)
return quad
end
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
if not ok then return nil end
quads[slot] = made
return made
end
-- GbcPalette.with without a closure: set `colors` (when there is a shader),
-- returning what finishShade needs to put the caller's shader back.
local function startShade(colors)
if not (colors and GbcPalette.available()) then return false end
local G = love.graphics
local previous = G.getShader and G.getShader() or nil
GbcPalette.use(colors)
return true, previous
end
local function finishShade(shaded, previous)
if shaded then love.graphics.setShader(previous) end
end
function BoxMenu:drawEggPic(mon)
local G = love.graphics
local colors = self:panelColors("EGG", mon and mon.shiny)
@@ -866,18 +899,15 @@ function BoxMenu:drawEggPic(mon)
local w = entry.width or 16
local h = math.min(entry.height or 16, image:getHeight())
if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end
local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h,
local quad = reusedQuad(self, "egg", 0, 0, w, h,
image:getWidth(), image:getHeight())
if not ok then return end
if not quad then return end
local x = PIC_X * 8 + math.floor((7 * 8 - w * 2) / 2)
local y = PIC_Y * 8 + math.floor((7 * 8 - h * 2) / 2)
G.setColor(1, 1, 1, 1)
local function body() G.draw(image, quad, x, y, 0, 2, 2) end
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
local shaded, previous = startShade(colors)
G.draw(image, quad, x, y, 0, 2, 2)
finishShade(shaded, previous)
G.setColor(1, 1, 1, 1)
end
@@ -889,18 +919,14 @@ function BoxMenu:drawHeldIcon(mon)
local gfx = (self.menuGfx or {}).billsPc
local image = self:image(gfx and gfx.icons)
if not image then return end
local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8,
image:getDimensions())
if not ok then return end
local iw, ih = image:getDimensions()
local quad = reusedQuad(self, "held", row * 8, 0, 8, 8, iw, ih)
if not quad then return end
local G = love.graphics
G.setColor(1, 1, 1, 1)
local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end
local colors = gfx and gfx.palette
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
local shaded, previous = startShade(gfx and gfx.palette)
G.draw(image, quad, ICON_X * 8, ICON_Y * 8)
finishShade(shaded, previous)
G.setColor(1, 1, 1, 1)
end
@@ -911,25 +937,17 @@ function BoxMenu:drawBoxArrows()
local image = self:image(gfx and gfx.icons)
if not image then return end
local G = love.graphics
local quads = {}
for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do
local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8,
image:getDimensions())
if not ok then return end
quads[#quads + 1] = { quad, arrow[2] }
end
local iw, ih = image:getDimensions()
local left = reusedQuad(self, "arrowLeft", ARROW_LEFT[1] * 8, 0, 8, 8, iw, ih)
if not left then return end
local right = reusedQuad(self, "arrowRight", ARROW_RIGHT[1] * 8, 0, 8, 8,
iw, ih)
if not right then return end
G.setColor(1, 1, 1, 1)
local function body()
for _, entry in ipairs(quads) do
G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8)
end
end
local colors = gfx and gfx.palette
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
local shaded, previous = startShade(gfx and gfx.palette)
G.draw(image, left, ARROW_LEFT[2] * 8, ARROW_ROW * 8)
G.draw(image, right, ARROW_RIGHT[2] * 8, ARROW_ROW * 8)
finishShade(shaded, previous)
G.setColor(1, 1, 1, 1)
end
+67 -26
View File
@@ -13,6 +13,9 @@
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local WHITE = { 255, 255, 255 }
local BLACK = { 0, 0, 0 }
local Chrome = {}
-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the
@@ -32,7 +35,11 @@ function Chrome.paletteFill(px, py, pw, ph, palette)
local G = love.graphics
G.setColor(1, 1, 1, 1)
if GbcPalette.available() then
GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end)
-- GbcPalette.with without the closure: set, draw, restore.
local previous = G.getShader and G.getShader() or nil
GbcPalette.use(palette)
G.rectangle("fill", px, py, pw, ph)
G.setShader(previous)
else
G.rectangle("fill", px, py, pw, ph)
end
@@ -160,8 +167,14 @@ Chrome.DEFAULT_BOX_PALETTE = {
function Chrome.paletteBox(tx, ty, tw, th, palette)
palette = palette or Chrome.DEFAULT_BOX_PALETTE
if GbcPalette.available() then
love.graphics.setColor(1, 1, 1, 1)
GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end)
local G = love.graphics
G.setColor(1, 1, 1, 1)
-- GbcPalette.with, restoring the caller's shader even if the box throws.
local previous = G.getShader and G.getShader() or nil
GbcPalette.use(palette)
local ok, err = pcall(Font.drawBox, tx, ty, tw, th)
G.setShader(previous)
if not ok then error(err, 0) end
else
Font.drawBox(tx, ty, tw, th, palette[1])
end
@@ -278,19 +291,56 @@ function Chrome.paletteGlyphs(palette, invert, raw)
return pal, drawGlyph, finish
end
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
if not pal then return Chrome.print(text, tx, ty) end
local width = Font.width(text)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
local pen = tx * 8
for _, code in ipairs(Font.encode(text)) do
drawGlyph(code, pen, ty * 8)
-- printThrough's resolved palette, or nil when it has to take the plain
-- degrade: paletteGlyphs' own test and fold, without its two closures.
local function glyphPalette(palette, invert, raw)
if not (palette and GbcPalette.available()) then return nil end
return raw and Chrome.rawPalette(palette, invert)
or Chrome.throughPalette(palette, invert)
end
-- The string's glyph codes and its width, off one encode: the width is the
-- sum of the same advances the pen walks, which is all Font.width is.
local function measure(text)
local codes = Font.encode(text)
local width = 0
for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end
return codes, width
end
-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and
-- finish inlined (see there for the TTF split, gen1recomp#1642).
local function drawThrough(codes, width, x, y, pal)
local G = love.graphics
local paper = pal[1] or WHITE
local ink = pal[4] or BLACK
local previous = G.getShader()
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", x, y, width, 8)
local shaded = false
local pen = x
for i = 1, #codes do
local code = codes[i]
if code >= Font.TTF_BASE then
if shaded then G.setShader(previous); shaded = false end
G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
elseif not shaded then
G.setColor(1, 1, 1, 1)
GbcPalette.useRaw(pal)
shaded = true
end
Font.drawCode(code, pen, y)
pen = pen + Font.advanceOf(code)
end
finish()
if shaded then G.setShader(previous) end
G.setColor(0, 0, 0, 1)
end
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
local pal = glyphPalette(palette, invert, raw)
if not pal then return Chrome.print(text, tx, ty) end
local codes, width = measure(text)
drawThrough(codes, width, tx * 8, ty * 8, pal)
return width
end
@@ -317,19 +367,10 @@ function Chrome.printRight(text, txEnd, ty)
end
function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw)
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
local pal = glyphPalette(palette, invert, raw)
if not pal then return Chrome.printRight(text, txEnd, ty) end
local width = Font.width(text)
local tx = txEnd * 8 - width
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx, ty * 8, width, 8)
local pen = tx
for _, code in ipairs(Font.encode(text)) do
drawGlyph(code, pen, ty * 8)
pen = pen + Font.advanceOf(code)
end
finish()
local codes, width = measure(text)
drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal)
return width
end
+12 -4
View File
@@ -501,11 +501,19 @@ function NamingScreen:drawPanel()
if self.iconImage then
G.setColor(1, 1, 1, 1)
local w, h = self.iconImage:getDimensions()
local quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h),
w, h)
-- Cut once per icon image rather than every frame.
local quad = self.iconQuad
if not quad or self.iconQuadImage ~= self.iconImage then
quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h),
w, h)
self.iconQuad, self.iconQuadImage = quad, self.iconImage
end
if self.iconColors and GbcPalette.available() then
GbcPalette.with(self.iconColors,
function() G.draw(self.iconImage, quad, 16, 16) end)
-- GbcPalette.with without the closure: set, draw, restore.
local previous = G.getShader and G.getShader() or nil
GbcPalette.use(self.iconColors)
G.draw(self.iconImage, quad, 16, 16)
G.setShader(previous)
else
G.draw(self.iconImage, quad, 16, 16)
end
+42 -24
View File
@@ -869,6 +869,24 @@ function PartyMenu:iconBob(index)
return (math.floor(self.clock / 16) % 2 == 1) and -2 or 0
end
-- One reusable quad per role, re-aimed per draw: an icon draws every frame
-- and a fresh Quad per tile would churn the GC.
local function reusedQuad(self, slot, x, y, w, h, sw, sh)
local quads = self.iconQuads
if not quads then
quads = {}
self.iconQuads = quads
end
local quad = quads[slot]
if quad then
quad:setViewport(x, y, w, h, sw, sh)
else
quad = love.graphics.newQuad(x, y, w, h, sw, sh)
quads[slot] = quad
end
return quad
end
function PartyMenu:drawIcon(mon, px, py)
local image, frame = self:iconFor(mon)
if not image then return end
@@ -876,7 +894,19 @@ function PartyMenu:drawIcon(mon, px, py)
local iw, ih = image:getDimensions()
local markerRow = PartyMenu.heldMarkerRow(mon)
local marker = markerRow and self:heldMarkerImage() or nil
local paint
G.setColor(1, 1, 1, 1)
-- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355)
-- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list,
-- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229).
local pals = self.palettes and self.palettes.partyMenu
local colors = pals and pals[1] or nil
local shaded = colors and GbcPalette.available()
local previous
if shaded then
-- GbcPalette.with without the closure: set, draw, restore.
previous = G.getShader and G.getShader() or nil
GbcPalette.use(colors)
end
if marker then
-- The _WITH_ITEM / _WITH_MAIL OAM sets (data/sprite_anims/oam.asm) are the
-- ordinary four quadrants with the `dbsprite -1, 0` entry -- the bottom
@@ -886,31 +916,19 @@ function PartyMenu:drawIcon(mon, px, py)
-- Both of the frameset's oamframes name the same marker tile, so it must
-- not be indexed by `frame`: the icon bobs, the marker does not.
local mw, mh = marker:getDimensions()
local topLeft = G.newQuad(0, frame * 16, 8, 8, iw, ih)
local topRight = G.newQuad(8, frame * 16, 8, 8, iw, ih)
local bottomRight = G.newQuad(8, frame * 16 + 8, 8, 8, iw, ih)
local held = G.newQuad(0, markerRow * 8, 8, 8, mw, mh)
paint = function()
G.draw(image, topLeft, px, py)
G.draw(image, topRight, px + 8, py)
G.draw(image, bottomRight, px + 8, py + 8)
G.draw(marker, held, px, py + 8)
end
G.draw(image, reusedQuad(self, "topLeft", 0, frame * 16, 8, 8, iw, ih),
px, py)
G.draw(image, reusedQuad(self, "topRight", 8, frame * 16, 8, 8, iw, ih),
px + 8, py)
G.draw(image, reusedQuad(self, "bottomRight", 8, frame * 16 + 8, 8, 8,
iw, ih), px + 8, py + 8)
G.draw(marker, reusedQuad(self, "held", 0, markerRow * 8, 8, 8, mw, mh),
px, py + 8)
else
local quad = G.newQuad(0, frame * 16, 16, 16, iw, ih)
paint = function() G.draw(image, quad, px, py) end
end
G.setColor(1, 1, 1, 1)
-- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355)
-- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list,
-- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229).
local pals = self.palettes and self.palettes.partyMenu
local colors = pals and pals[1] or nil
if colors and GbcPalette.available() then
GbcPalette.with(colors, paint)
else
paint()
G.draw(image, reusedQuad(self, "whole", 0, frame * 16, 16, 16, iw, ih),
px, py)
end
if shaded then G.setShader(previous) end
end
-- PlacePartyHPBar calls DrawBattleHPBar (home/pokemon.asm) with `ld d, $6`, and
+17 -2
View File
@@ -1919,6 +1919,10 @@ end
local SPACE_TILE = 0x7f
function Pokegear:tile(id, tx, ty)
-- A flat cell draws under the caller's shader, not a batched tile palette.
if (id == SPACE_TILE or id == BLANK_TILE) and self.sheet then
self.sheet:suspend()
end
if id == SPACE_TILE then
local paper = self:paperColor()
local G = love.graphics
@@ -1938,8 +1942,7 @@ function Pokegear:tile(id, tx, ty)
if self.sheet then self.sheet:draw(id, tx, ty) end
end
function Pokegear:drawTilemap(cells)
if not cells then return end
local function drawTilemapCells(self, cells)
for index = 1, SCREEN_W * SCREEN_H do
local tile = cells[index]
if tile then
@@ -1949,6 +1952,18 @@ function Pokegear:drawTilemap(cells)
end
end
-- 360 cells a frame: batched so the palette shader is set once per run of
-- same-palette tiles rather than once per tile (TileSheet:begin).
function Pokegear:drawTilemap(cells)
if not cells then return end
local sheet = self.sheet
if not sheet then return drawTilemapCells(self, cells) end
sheet:begin()
local ok, err = pcall(drawTilemapCells, self, cells)
sheet:finish()
if not ok then error(err, 0) end
end
-- Pokegear_FinishTilemap.
-- PokegearSpritesGFX, shared by the mode indicator arrow ($00) and the town
-- map cursor ($04). Loaded once and remembered as `false` when there is no
+21 -2
View File
@@ -1126,6 +1126,25 @@ function SummaryMenu:drawEggPic()
self:drawEggIconFallback(colors)
end
-- One reusable quad per role, re-aimed per draw rather than a new Quad each
-- frame (which churns the GC). nil only when the backend cannot make one.
local function reusedQuad(owner, slot, x, y, w, h, sw, sh)
local quads = owner.reusedQuads
if not quads then
quads = {}
owner.reusedQuads = quads
end
local quad = quads[slot]
if quad then
quad:setViewport(x, y, w, h, sw, sh)
return quad
end
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
if not ok then return nil end
quads[slot] = made
return made
end
-- The ICON_EGG sheet is two 16x16 frames stacked into one 16x32 image
-- (src/ui/gen2/PartyMenu.lua reads the same entry); the first frame is the
-- egg at rest, which is the one the party list shows while nothing is moving.
@@ -1141,9 +1160,9 @@ function SummaryMenu:drawEggIconFallback(colors)
local w = entry.width or 16
local h = math.min(entry.height or 16, image:getHeight())
if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end
local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h,
local quad = reusedQuad(self, "egg", 0, 0, w, h,
image:getWidth(), image:getHeight())
if not ok then return end
if not quad then return end
-- Centred in the block at 2x: a 16x16 icon inside 7x7 tiles.
local x = math.floor((7 * 8 - w * 2) / 2)
local y = math.floor((7 * 8 - h * 2) / 2)
+38 -5
View File
@@ -77,19 +77,52 @@ function TileSheet:draw(tile, tx, ty)
G.setColor(1, 1, 1, 1)
local colors = self.palette
if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end
local function body() G.draw(image, quad, tx * 8, ty * 8) end
local batch = self.batch
if colors and GbcPalette.available() then
if self.raw then
GbcPalette.withRaw(colors, body)
if batch then
-- Inside begin/finish: the shader only changes when the palette does.
if batch.active ~= colors then
if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end
batch.active = colors
end
G.draw(image, quad, tx * 8, ty * 8)
else
GbcPalette.with(colors, body)
-- GbcPalette.with / withRaw without the closure: set, draw, restore.
local previous = G.getShader and G.getShader() or nil
if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end
G.draw(image, quad, tx * 8, ty * 8)
G.setShader(previous)
end
else
body()
if batch then self:suspend() end
G.draw(image, quad, tx * 8, ty * 8)
end
return true
end
-- Batch a pass of draws: between begin and finish the palette shader is set
-- once per run of tiles sharing a palette rather than once per tile, and the
-- caller's shader comes back at finish. Anything drawn between two tiles
-- that is not a sheet tile (a flat rect, text) calls suspend first so it
-- draws under the caller's shader, as it would have outside the batch.
function TileSheet:begin()
local G = love.graphics
self.batch = { previous = G.getShader and G.getShader() or nil, active = nil }
end
function TileSheet:suspend()
local batch = self.batch
if batch and batch.active then
love.graphics.setShader(batch.previous)
batch.active = nil
end
end
function TileSheet:finish()
self:suspend()
self.batch = nil
end
-- A run of consecutive ids, left to right: the shape most of these routines
-- use for a header strip or a caption.
function TileSheet:run(first, count, tx, ty)
+7 -1
View File
@@ -831,7 +831,13 @@ function TradeAnimView:drawIcon(record, x, y)
end
if not cached then return end
local G = love.graphics
local quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions())
-- One quad per icon sheet, cut once rather than every frame.
self.iconQuads = self.iconQuads or {}
local quad = self.iconQuads[cached]
if not quad then
quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions())
self.iconQuads[cached] = quad
end
G.setColor(1, 1, 1, 1)
local colors = Palettes.monColors(self.palettes, record.species,
record.shiny)
+16 -3
View File
@@ -56,16 +56,22 @@ end
-- ../pokecrystal/home/text.asm:520 _ContTextNoPause
function Typer:start(page)
local out, total, kept = {}, 0, 0
local out, spans, total, kept = {}, {}, 0, 0
local scrolled = type(page) == "table" and page.scrolled
for i, line in ipairs(linesOf(page)) do
local text = self.expand and self.expand(line) or line
out[i] = text
local n = #spansOf(text)
-- Split once here: lines() runs every frame while the page types out.
local lineSpans = spansOf(text)
spans[i] = lineSpans
local n = #lineSpans
total = total + n
if scrolled and i == 1 then kept = n end
end
self.page = out
self.spans = spans
self.spansPage = out
self.linesOut, self.linesShown = nil, nil
self.total = total
self.shown = self.instant and total or kept
self.timer = 0
@@ -108,9 +114,16 @@ end
function Typer:lines()
if self:done() then return self.page end
-- The same partial page is asked for every frame between two letters.
if self.linesOut and self.linesShown == self.shown
and self.linesPage == self.page then
return self.linesOut
end
local cached = self.spansPage == self.page and self.spans or nil
local out, left = {}, self.shown
self.linesOut, self.linesShown, self.linesPage = out, self.shown, self.page
for i, line in ipairs(self.page) do
local spans = spansOf(line)
local spans = cached and cached[i] or spansOf(line)
if left >= #spans then
out[i] = line
elseif left <= 0 then
+21 -2
View File
@@ -438,6 +438,25 @@ function UnownPuzzle:fillCell(cell, tile)
end
end
-- One reusable quad per role, re-aimed per draw rather than a new Quad each
-- frame (which churns the GC). nil only when the backend cannot make one.
local function reusedQuad(owner, slot, x, y, w, h, sw, sh)
local quads = owner.reusedQuads
if not quads then
quads = {}
owner.reusedQuads = quads
end
local quad = quads[slot]
if quad then
quad:setViewport(x, y, w, h, sw, sh)
return quad
end
local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh)
if not ok then return nil end
quads[slot] = made
return made
end
-- .Corners is `piece -> corner tile` on a 12-tile-wide sheet; as a quad that is
-- just the panel's row and column in the 4x4 picture.
function UnownPuzzle:drawPiece(piece, tx, ty)
@@ -447,7 +466,7 @@ function UnownPuzzle:drawPiece(piece, tx, ty)
local sx = (index % UnownPuzzle.PIECES_WIDE) * size
local sy = math.floor(index / UnownPuzzle.PIECES_WIDE) * size
local w, h = self.picture:getDimensions()
local quad = love.graphics.newQuad(sx, sy, size, size, w, h)
local quad = reusedQuad(self, "piece", sx, sy, size, size, w, h)
love.graphics.setColor(1, 1, 1, 1)
-- The four pictures are drawn out of colours 1 and 2 alone, so binding BG
-- palette 0 here is what turns the panels tan and dark brown.
@@ -496,7 +515,7 @@ function UnownPuzzle:drawCursor(tx, ty)
for _, spec in ipairs(CURSOR_CELLS) do
local col, tile, row, flipX, flipY = spec[1], spec[2], spec[3], spec[4],
spec[5]
local quad = love.graphics.newQuad(tile * 8, 0, 8, 8, w, h)
local quad = reusedQuad(self, "cursor", tile * 8, 0, 8, 8, w, h)
local sx, sy = flipX and -1 or 1, flipY and -1 or 1
local ox = (tx + col) * 8 + (flipX and 8 or 0)
local oy = (ty + row) * 8 + (flipY and 8 or 0)
+396 -313
View File
@@ -63,6 +63,9 @@ OverworldState.withOverrideMirror = withOverrideMirror
local Game -- set on enter (avoids circular require at load time)
-- handleInput's d-pad poll order (a constant: it runs every frame)
local INPUT_DIRS = { "up", "down", "left", "right" }
local mapScripts -- registry of hand-ported map scripts
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
@@ -535,7 +538,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
elseif fromMapId ~= mapId then
for _, obj in ipairs(self.map.def.objects or {}) do
local npc = self.npcPool[mapId .. "_obj_" .. obj.index]
if npc then npc.pendingSpawnReset = true end
if npc then
npc.pendingSpawnReset = true
-- lets applyPendingSpawnResets skip its whole-pool scan while
-- nothing is armed (it runs every frame)
self.spawnResetsArmed = true
end
end
end
self.npcs = {}
@@ -794,6 +802,9 @@ end
-- the deferred half of the seam re-seed armed in setMap (#1755): the cart's
-- connected map has no sprites to be seen snapping -- home/overworld.asm:2133
function OverworldState:applyPendingSpawnResets()
-- setMap is the only place that arms a re-seed; nothing armed since the
-- last scan came up empty -> no pool walk this frame
if not self.spawnResetsArmed then return end
local pool = self.npcPool
if not (pool and self.camera) then return end
local due
@@ -803,7 +814,10 @@ function OverworldState:applyPendingSpawnResets()
due[npc] = true
end
end
if not due then return end
if not due then
self.spawnResetsArmed = nil
return
end
local cam = self.camera
local vw, vh = Game.renderer:worldViewSize()
local function onCamera(px, py)
@@ -1545,7 +1559,6 @@ function OverworldState:update(dt)
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive or self.spinArrive
or self.holeFall or self.holeArrive
or self.holeFall or self.holeArrive
or self.cutAnim
or (self.dustAnim and self.dustAnim.boulder)
@@ -1719,7 +1732,7 @@ function OverworldState:handleInput()
return
end
for _, dir in ipairs({ "up", "down", "left", "right" }) do
for _, dir in ipairs(INPUT_DIRS) do
if input:isDown(dir) then
if not self.player.moving and self.player.facing == dir then
if self:checkEdgeExit(dir) then return end
@@ -1785,13 +1798,25 @@ function OverworldState:handleInput()
end
-- JoypadOverworld both make -- home/overworld.asm:382
-- slopeMaps list -> { n = #list, ids = { [mapId] = true } }: update() asks
-- every frame, so the list is indexed once instead of scanned per call.
-- Keyed weakly on the list itself (a data reload brings a new one) and
-- rebuilt if its length changes.
local slopeSets = setmetatable({}, { __mode = "k" })
function OverworldState:onSlopeMap(mapId)
local fm = Game.data.field.forcedMovement
mapId = mapId or (self.map and self.map.id)
for _, m in ipairs((fm and fm.slopeMaps) or {}) do
if m == mapId then return true end
local list = fm and fm.slopeMaps
if not list or mapId == nil then return false end
local entry = slopeSets[list]
if not entry or entry.n ~= #list then
local ids = {}
for _, m in ipairs(list) do ids[m] = true end
entry = { n = #list, ids = ids }
slopeSets[list] = entry
end
return false
return entry.ids[mapId] == true
end
-- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder):
@@ -5829,7 +5854,7 @@ end
-- sprite through the tile's white gaps) over the plain one (sprites, FX
-- overlays). drawFn issues the actual draws in flat world-canvas coordinates;
-- the transform just slides them from the flat foot onto the projected anchor.
function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn, ...)
local sx, sy = Tilt.groundPoint(fx, fy, vw, vh)
local shader = colors and (keyed and PaletteFX.keyedShader()
or PaletteFX.shader()) or nil
@@ -5839,11 +5864,321 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
end
love.graphics.push()
love.graphics.translate(sx - fx, sy - fy)
drawFn()
drawFn(...) -- extra billboard args (the FX bodies take self, cam)
love.graphics.pop()
if shader then love.graphics.setShader() end
end
-- === shared FX draw bodies ==========================================
-- Each draws at flat world-canvas offsets; the tilt path wraps the
-- standing ones in an upright billboard, the flat path calls them inline
-- in their historical order. (Bodies are byte-identical to the pre-tilt
-- inline code, so the flat draw sequence is unchanged.) Module-level and
-- handed (self, cam) so drawWorld does not build six closures per frame;
-- the pipeline path wraps them for its no-argument ctx.fx contract.
-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor
-- tile over the machine's screen and one ball per healed mon in two
-- mirrored columns, all blinking during the jingle flash. The GB
-- draws it at fixed screen coords with the player's cell BG-aligned
-- at (64,64); anchoring those coords to where the player stood keeps
-- the overlay on the machine at any zoom.
local function fxHeal(self, cam)
if not self.healAnim then return end
local ha = self.healAnim
local fxDef = Game.data.field.overworldFx
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path)
self.healMachineImg = ok and img or false
end
local img = self.healMachineImg
if img then
if not self.healMachineQuads then
local w, h = img:getWidth(), img:getHeight()
self.healMachineQuads = {
love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c)
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
}
end
local shader, redrawColors = healMachineShader(ha.visible)
local mark = redrawColors and PaletteFX.spriteRedrawPassActive()
-- TileRenderer windows with -floor(cam), so the overlay must use the
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
-- the balls a pixel off the machine tiles
local ox = ha.px - 64 - math.floor(cam.x)
local oy = ha.py - 64 - math.floor(cam.y)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
if mark then
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1],
ox + 44, oy + 20, 1, redrawColors)
end
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
local b = HEAL_BALL_XY[i]
if b[3] then -- right column: OAM_XFLIP
love.graphics.draw(img, self.healMachineQuads[2],
ox + b[1] + 8, oy + b[2], 0, -1, 1)
if mark then
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
ox + b[1] + 8, oy + b[2], -1, redrawColors)
end
else
love.graphics.draw(img, self.healMachineQuads[2],
ox + b[1], oy + b[2])
if mark then
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
ox + b[1], oy + b[2], 1, redrawColors)
end
end
end
if shader then love.graphics.setShader() end
end
end
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
local function fxDust(self, cam)
if not self.dustAnim then return end
local fxDef = Game.data.field.overworldFx
local smoke = fxDef and fxDef.smoke
if smoke then
if self.smokeImg == nil then
local ok, img = pcall(love.graphics.newImage, smoke.path)
self.smokeImg = ok and img or false
end
if self.smokeImg then
local da = self.dustAnim
local dx = da.x * 16 + (da.ox or 0) - cam.x
local dy = da.y * 16 + (da.oy or 0) - cam.y
local flicker = math.floor(da.frames / 4) % 2 == 0
if da.boulder then flicker = not da.faded end
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
for i = 0, 1 do
for j = 0, 1 do
love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
end
end
-- the cut tree splitting apart (AnimCut): top half slides right,
-- bottom half slides left, 1px per frame, flickering as they go
local function fxCutTree(self, cam)
if not self.cutAnim then return end
local fxDef = Game.data.field.overworldFx
local tree = fxDef and fxDef.cutTree
if not tree then return end
if self.cutTreeImg == nil then
local ok, img = pcall(love.graphics.newImage, tree.path)
self.cutTreeImg = ok and img or false
end
local img = self.cutTreeImg
if not img then return end
if not self.cutTreeQuads then
local w, h = img:getWidth(), img:getHeight()
self.cutTreeQuads = {
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
}
end
local ca = self.cutAnim
local off = (ca.total or 8) - ca.frames
local dx = ca.x * 16 - cam.x
local dy = ca.y * 16 - cam.y
local flicker = ca.frames % 2 == 0
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
love.graphics.setColor(1, 1, 1, 1)
end
-- the "!" bubble above a trainer who spotted the player
local function fxEmote(self, cam)
if not (self.emote and self.emote.npc) then return end
-- bubble = false is a silent hold (a Pikachu emotion that plays a
-- cry with no bubble still pauses the world for its beat)
if self.emote.bubble == false then return end
local npc = self.emote.npc
-- engine/overworld/emotion_bubbles.asm:41
local ex = npc.px - cam.x
local ey = npc.py - cam.y - 20
local bubble = Game.data.field.emotionBubbles
local drawn = false
if bubble and bubble.path then
local ok, img = pcall(function()
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
return self.emoteImg
end)
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
-- picks question/happy crops instead
local bi = self.emote.bubble or 1
local rect = bubble.bubbles and bubble.bubbles[bi]
if ok and img and rect then
love.graphics.setColor(1, 1, 1, 1)
-- one Quad per bubble crop, cached: this draws every frame the "!"
-- (or the emote-command crops) is up, so a fresh Quad here churned
-- the GC. The bubble set is small and fixed, so the cache is bounded.
self.emoteQuads = self.emoteQuads or {}
local q = self.emoteQuads[bi]
if not q then
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
img:getDimensions())
self.emoteQuads[bi] = q
end
love.graphics.draw(img, q, ex, ey)
-- engine/overworld/emotion_bubbles.asm:18
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
PaletteFX.markSpriteRedraw(img, q, ex, ey, 1)
end
drawn = true
end
end
if not drawn then
local Font = require("src.render.Font")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", ex, ey, 10, 12)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12)
Font.draw("!", ex + 1, ey + 2)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- the FLY bird sweeping off with the player
local function fxBird(self, cam)
local anim = self.flyAnim or self.flyArrive
if not anim then return end
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
local SR = require("src.render.SpriteRenderer")
self.birdSprite = SR.new(Game.data.sprites[birdId])
end
if not self.birdSprite then return end
-- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
-- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
-- the flight rides any screen position, and it faces its travel
-- direction (rightward travel flips the left-drawn sheet)
local phase = anim.phase or "arrive"
if phase == "hold" then return end -- parked off screen between paths
local path, anchor, facing
if phase == "path1" then
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
elseif phase == "path2" then
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
elseif phase == "arrive" then
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
end
local step = math.floor(anim.t / 3)
local sx, sy
if path then
local pair = path[math.min(#path, step + 1)]
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
else
sx, sy = 0, 0 -- the in-place flap sits on the player
facing = "right"
end
love.graphics.setColor(1, 1, 1, 1)
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
cam.x, cam.y, facing, step % 2, false)
end
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
local function fxRod(self, cam)
if not self.fishing then return end
-- engine/overworld/player_animations.asm:424
if self.fishing.hideRod then return end
local fx = Game.data.field.overworldFx
local rod = fx and fx.fishingRod
if rod then
if self.rodImg == nil then
local ok, img = pcall(love.graphics.newImage, rod.path)
self.rodImg = ok and img or false
end
if self.rodImg then
local p = self.player
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
if not self.rodQuads then
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
local iw, ih = self.rodImg:getDimensions()
self.rodQuads = {}
for i = 0, math.floor(ih / 8) - 1 do
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
end
end
local quad = self.rodQuads[oam.tile]
-- Place the rod against the active sprite's anchored top-left. The
-- vanilla result is still (px-cam, py-cam-4), while custom larger
-- sheets keep the rod attached to their feet.
-- Fishing always uses the on-foot player sheet; read its fields
-- directly so this FX pass does not advance pose-side animation.
local sprite, px, py = p.sprite, p.px, p.py
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
local rx = sx + oam.dx
-- engine/overworld/player_animations.asm:453
local ry = sy + oam.dy + (p.fishShakeDy or 0)
love.graphics.setColor(1, 1, 1, 1)
if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
elseif quad then
love.graphics.draw(self.rodImg, quad, rx, ry)
end
end
end
end
-- drawWorld's y-sort orders, hoisted so a frame builds no comparators
local function ghostDrawOrder(a, b)
return a.npc.py + a.oy < b.npc.py + b.oy
end
local function entityDrawOrder(a, b)
if a.py ~= b.py then return a.py < b.py end
-- a fresh warp spawn parks the follower on the player's own cell
-- until it trails out; the tie must draw it under him, never on
-- top (#863)
return a.pikachuFollower == true and b.pikachuFollower ~= true
end
-- Tall-grass feet overdraw queue for the flat path: each distinct grass
-- cell an entity stands on or steps into, in first-queued order. A
-- scratch pair of coordinate arrays reused every frame (the queue holds a
-- few cells, so the duplicate check is a short linear scan) instead of a
-- "cx:cy" string key and a { cx, cy } table per cell per frame.
local function queueGrass(self, q, cx, cy)
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
return
end
local n = q.n
local xs, ys = q.x, q.y
for i = 1, n do
if xs[i] == cx and ys[i] == cy then return end
end
n = n + 1
xs[n], ys[n] = cx, cy
q.n = n
end
-- Is a neighbour map's ghost NPC wholly outside the vw x vh view? The
-- rect is the sprite's frame at its anchored origin, padded by a tile on
-- every side (hop shadows, multi-part cells), so only NPCs that could not
-- put a pixel on screen are skipped.
local GHOST_CULL_PAD = 16
local function ghostOffscreen(g, camX, camY, vw, vh)
local npc = g.npc
local sprite = npc and npc.sprite
if not (sprite and sprite.getScreenOrigin and vw and vh) then
return false
end
local sx, sy = sprite:getScreenOrigin(npc.px, npc.py, camX, camY)
local fw = sprite.frameWidth or 16
local fh = sprite.frameHeight or 16
local pad = GHOST_CULL_PAD
return sx + fw + pad < 0 or sy + fh + pad < 0
or sx - pad > vw or sy - pad > vh
end
function OverworldState:drawWorld()
-- Dark-map BG shade shift, armed for the whole frame before anything draws.
-- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the
@@ -5908,11 +6243,12 @@ function OverworldState:drawWorld()
-- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
require("src.render.TileRenderer").tick()
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick()
-- let the renderer know whether a spinner puzzle is currently sliding
-- the player, so it can flicker the arrow tiles between the blur and
-- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles)
require("src.render.TileRenderer").setSpinning(self.spinnerSliding or false)
TileRenderer.setSpinning(self.spinnerSliding or false)
local cam = self.camera
-- ShakeElevator's oscillation (engine/overworld/elevator.asm) writes
-- hSCY, which scrolls the BG layer only -- tiles bounce while OAM
@@ -5957,272 +6293,8 @@ function OverworldState:drawWorld()
local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil
-- ghost NPCs on neighbor maps, y-sorted among themselves
table.sort(self.ghosts,
function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end)
table.sort(self.entities, function(a, b)
if a.py ~= b.py then return a.py < b.py end
-- a fresh warp spawn parks the follower on the player's own cell
-- until it trails out; the tie must draw it under him, never on
-- top (#863)
return a.pikachuFollower == true and b.pikachuFollower ~= true
end)
-- === shared FX draw bodies ==========================================
-- Each draws at flat world-canvas offsets; the tilt path wraps the
-- standing ones in an upright billboard, the flat path calls them inline
-- in their historical order. (Bodies are byte-identical to the pre-tilt
-- inline code, so the flat draw sequence is unchanged.)
-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor
-- tile over the machine's screen and one ball per healed mon in two
-- mirrored columns, all blinking during the jingle flash. The GB
-- draws it at fixed screen coords with the player's cell BG-aligned
-- at (64,64); anchoring those coords to where the player stood keeps
-- the overlay on the machine at any zoom.
local function fxHeal()
if not self.healAnim then return end
local ha = self.healAnim
local fxDef = Game.data.field.overworldFx
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path)
self.healMachineImg = ok and img or false
end
local img = self.healMachineImg
if img then
if not self.healMachineQuads then
local w, h = img:getWidth(), img:getHeight()
self.healMachineQuads = {
love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c)
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
}
end
local shader, redrawColors = healMachineShader(ha.visible)
local mark = redrawColors and PaletteFX.spriteRedrawPassActive()
-- TileRenderer windows with -floor(cam), so the overlay must use the
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
-- the balls a pixel off the machine tiles
local ox = ha.px - 64 - math.floor(cam.x)
local oy = ha.py - 64 - math.floor(cam.y)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
if mark then
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1],
ox + 44, oy + 20, 1, redrawColors)
end
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
local b = HEAL_BALL_XY[i]
if b[3] then -- right column: OAM_XFLIP
love.graphics.draw(img, self.healMachineQuads[2],
ox + b[1] + 8, oy + b[2], 0, -1, 1)
if mark then
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
ox + b[1] + 8, oy + b[2], -1, redrawColors)
end
else
love.graphics.draw(img, self.healMachineQuads[2],
ox + b[1], oy + b[2])
if mark then
PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2],
ox + b[1], oy + b[2], 1, redrawColors)
end
end
end
if shader then love.graphics.setShader() end
end
end
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
local function fxDust()
if not self.dustAnim then return end
local fxDef = Game.data.field.overworldFx
local smoke = fxDef and fxDef.smoke
if smoke then
if self.smokeImg == nil then
local ok, img = pcall(love.graphics.newImage, smoke.path)
self.smokeImg = ok and img or false
end
if self.smokeImg then
local da = self.dustAnim
local dx = da.x * 16 + (da.ox or 0) - cam.x
local dy = da.y * 16 + (da.oy or 0) - cam.y
local flicker = math.floor(da.frames / 4) % 2 == 0
if da.boulder then flicker = not da.faded end
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
for i = 0, 1 do
for j = 0, 1 do
love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
end
end
-- the cut tree splitting apart (AnimCut): top half slides right,
-- bottom half slides left, 1px per frame, flickering as they go
local function fxCutTree()
if not self.cutAnim then return end
local fxDef = Game.data.field.overworldFx
local tree = fxDef and fxDef.cutTree
if not tree then return end
if self.cutTreeImg == nil then
local ok, img = pcall(love.graphics.newImage, tree.path)
self.cutTreeImg = ok and img or false
end
local img = self.cutTreeImg
if not img then return end
if not self.cutTreeQuads then
local w, h = img:getWidth(), img:getHeight()
self.cutTreeQuads = {
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
}
end
local ca = self.cutAnim
local off = (ca.total or 8) - ca.frames
local dx = ca.x * 16 - cam.x
local dy = ca.y * 16 - cam.y
local flicker = ca.frames % 2 == 0
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
love.graphics.setColor(1, 1, 1, 1)
end
-- the "!" bubble above a trainer who spotted the player
local function fxEmote()
if not (self.emote and self.emote.npc) then return end
-- bubble = false is a silent hold (a Pikachu emotion that plays a
-- cry with no bubble still pauses the world for its beat)
if self.emote.bubble == false then return end
local npc = self.emote.npc
-- engine/overworld/emotion_bubbles.asm:41
local ex = npc.px - cam.x
local ey = npc.py - cam.y - 20
local bubble = Game.data.field.emotionBubbles
local drawn = false
if bubble and bubble.path then
local ok, img = pcall(function()
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
return self.emoteImg
end)
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
-- picks question/happy crops instead
local bi = self.emote.bubble or 1
local rect = bubble.bubbles and bubble.bubbles[bi]
if ok and img and rect then
love.graphics.setColor(1, 1, 1, 1)
-- one Quad per bubble crop, cached: this draws every frame the "!"
-- (or the emote-command crops) is up, so a fresh Quad here churned
-- the GC. The bubble set is small and fixed, so the cache is bounded.
self.emoteQuads = self.emoteQuads or {}
local q = self.emoteQuads[bi]
if not q then
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
img:getDimensions())
self.emoteQuads[bi] = q
end
love.graphics.draw(img, q, ex, ey)
-- engine/overworld/emotion_bubbles.asm:18
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
PaletteFX.markSpriteRedraw(img, q, ex, ey, 1)
end
drawn = true
end
end
if not drawn then
local Font = require("src.render.Font")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", ex, ey, 10, 12)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12)
Font.draw("!", ex + 1, ey + 2)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- the FLY bird sweeping off with the player
local function fxBird()
local anim = self.flyAnim or self.flyArrive
if not anim then return end
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
local SR = require("src.render.SpriteRenderer")
self.birdSprite = SR.new(Game.data.sprites[birdId])
end
if not self.birdSprite then return end
-- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
-- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
-- the flight rides any screen position, and it faces its travel
-- direction (rightward travel flips the left-drawn sheet)
local phase = anim.phase or "arrive"
if phase == "hold" then return end -- parked off screen between paths
local path, anchor, facing
if phase == "path1" then
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
elseif phase == "path2" then
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
elseif phase == "arrive" then
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
end
local step = math.floor(anim.t / 3)
local sx, sy
if path then
local pair = path[math.min(#path, step + 1)]
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
else
sx, sy = 0, 0 -- the in-place flap sits on the player
facing = "right"
end
love.graphics.setColor(1, 1, 1, 1)
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
cam.x, cam.y, facing, step % 2, false)
end
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
local function fxRod()
if not self.fishing then return end
-- engine/overworld/player_animations.asm:424
if self.fishing.hideRod then return end
local fx = Game.data.field.overworldFx
local rod = fx and fx.fishingRod
if rod then
if self.rodImg == nil then
local ok, img = pcall(love.graphics.newImage, rod.path)
self.rodImg = ok and img or false
end
if self.rodImg then
local p = self.player
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
if not self.rodQuads then
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
local iw, ih = self.rodImg:getDimensions()
self.rodQuads = {}
for i = 0, math.floor(ih / 8) - 1 do
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
end
end
local quad = self.rodQuads[oam.tile]
-- Place the rod against the active sprite's anchored top-left. The
-- vanilla result is still (px-cam, py-cam-4), while custom larger
-- sheets keep the rod attached to their feet.
-- Fishing always uses the on-foot player sheet; read its fields
-- directly so this FX pass does not advance pose-side animation.
local sprite, px, py = p.sprite, p.px, p.py
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
local rx = sx + oam.dx
-- engine/overworld/player_animations.asm:453
local ry = sy + oam.dy + (p.fishShakeDy or 0)
love.graphics.setColor(1, 1, 1, 1)
if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
elseif quad then
love.graphics.draw(self.rodImg, quad, rx, ry)
end
end
end
end
table.sort(self.ghosts, ghostDrawOrder)
table.sort(self.entities, entityDrawOrder)
if pipelineId then
-- === PIPELINE PATH: a mod owns the world pass. ======================
@@ -6234,6 +6306,13 @@ function OverworldState:drawWorld()
-- copy of every effect: the closures above are the ones that run.
local _, _, pw, ph = Game.renderer:playfieldRect()
local pscale = Zoom.scale(Game.renderer:fitScale())
-- the mod-facing ctx.fx draw bodies take no arguments
local fxHealFn = function() return fxHeal(self, cam) end
local fxDustFn = function() return fxDust(self, cam) end
local fxCutTreeFn = function() return fxCutTree(self, cam) end
local fxEmoteFn = function() return fxEmote(self, cam) end
local fxBirdFn = function() return fxBird(self, cam) end
local fxRodFn = function() return fxRod(self, cam) end
local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
width = pw, height = ph, scale = pscale,
@@ -6247,8 +6326,8 @@ function OverworldState:drawWorld()
if PaletteFX.usesGbcPack() then return nil end
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
end,
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
emote = fxEmote, bird = fxBird, rod = fxRod },
fx = { heal = fxHealFn, dust = fxDustFn, cutTree = fxCutTreeFn,
emote = fxEmoteFn, bird = fxBirdFn, rod = fxRodFn },
}
-- Draw every active field FX into the finished scene. `project(wx, wy)`
-- maps a world point to canvas pixels (nil when it is behind the
@@ -6283,23 +6362,23 @@ function OverworldState:drawWorld()
-- ground-hugging effects sit on the cell they belong to
if self.dustAnim then
local da = self.dustAnim
at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
at(fxDustFn, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0))
end
if self.cutAnim then
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
at(fxCutTreeFn, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
end
if self.healAnim then
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
at(fxHealFn, self.healAnim.px + 8, self.healAnim.py + 16)
end
-- standing effects anchor at the foot of whoever they belong to
if self.emote and self.emote.npc then
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
at(fxEmoteFn, self.emote.npc.px + 8, self.emote.npc.py + 16)
end
if self.flyAnim then
at(fxBird, self.player.px + 8, self.player.py + 16)
at(fxBirdFn, self.player.px + 8, self.player.py + 16)
end
if self.fishing then
at(fxRod, self.player.px + 8, self.player.py + 16)
at(fxRodFn, self.player.px + 8, self.player.py + 16)
end
end)
end
@@ -6337,44 +6416,45 @@ function OverworldState:drawWorld()
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
local boulderDust = self.dustAnim and self.dustAnim.boulder
local grassCells, grassSeen = {}, {}
local function queueGrass(cx, cy)
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
return
end
local key = tostring(cx) .. ":" .. tostring(cy)
if grassSeen[key] then return end
grassSeen[key] = true
grassCells[#grassCells + 1] = { cx, cy }
local grass = self.grassScratch
if not grass then
grass = { n = 0, x = {}, y = {} }
self.grassScratch = grass
end
if boulderDust then fxDust() end
grass.n = 0
if boulderDust then fxDust(self, cam) end
for _, g in ipairs(self.ghosts) do
if self.battleOamKeep == nil then
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
local gx, gy = cam.x - g.ox, cam.y - g.oy
if not ghostOffscreen(g, gx, gy, vw, vh) then
g.npc:draw(gx, gy)
end
end
end
for _, e in ipairs(self.entities) do
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
and e == self.player) and not self:oamCulled(e) then
e:draw(cam.x, cam.y)
queueGrass(e.cellX, e.cellY)
queueGrass(e.targetX, e.targetY)
queueGrass(self, grass, e.cellX, e.cellY)
queueGrass(self, grass, e.targetX, e.targetY)
end
end
love.graphics.setColor(1, 1, 1, 1)
for _, cell in ipairs(grassCells) do
self.map.renderer:drawCellBottom(cell[1], cell[2], cam.x, bgY)
local gxs, gys = grass.x, grass.y
for i = 1, grass.n do
local cx, cy = gxs[i], gys[i]
self.map.renderer:drawCellBottom(cx, cy, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(cell[1], cell[2],
self.map.renderer:markCellBottomRedraw(cx, cy,
cam.x, bgY, grassColors)
end
end
fxHeal()
if not boulderDust then fxDust() end
fxCutTree()
fxEmote()
fxBird()
fxRod()
fxHeal(self, cam)
if not boulderDust then fxDust(self, cam) end
fxCutTree(self, cam)
fxEmote(self, cam)
fxBird(self, cam)
fxRod(self, cam)
else
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
-- standing things billboard upright over it in a separate pass. ======
@@ -6384,9 +6464,9 @@ function OverworldState:drawWorld()
-- draws them last, over the sprites, in the same canvas; here the two
-- layers are separate and composited ground-under-upright, so drawing
-- them now into the still-active ground canvas is order-equivalent.
fxHeal()
fxDust()
fxCutTree()
fxHeal(self, cam)
fxDust(self, cam)
fxCutTree(self, cam)
Game.renderer:beginUprightPass()
@@ -6451,17 +6531,20 @@ function OverworldState:drawWorld()
if self.emote and self.emote.npc then
local fx = self.emote.npc.px - cam.x + 8
local fy = self.emote.npc.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote)
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote,
self, cam)
end
if self.flyAnim or self.flyArrive then
local fx = self.player.px - cam.x + 8
local fy = self.player.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird)
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird,
self, cam)
end
if self.fishing then
local fx = self.player.px - cam.x + 8
local fy = self.player.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod,
self, cam)
end
Game.renderer:endUprightPass()
+23 -1
View File
@@ -275,6 +275,28 @@ function Player:walkPhase()
return (p >= 4 and p < 12) and 1 or 0
end
-- The surf bob is sampled by pose() (draw code) but must run at the fixed
-- logic rate: it used to tick once per pose() call, so a 144Hz display
-- bobbed 2.4x too fast and the battle-transition wipe, which draws the
-- player twice a frame, doubled it. It now advances by the number of
-- Game:step logic steps since the last pose(), i.e. exactly once per step at
-- 60Hz and never twice for one step. Without a running Game (headless
-- callers) there is no step clock and each call advances once, as before.
function Player:advanceBob()
local Game = package.loaded["src.core.Game"]
local step = type(Game) == "table" and Game.logicStep or nil
local ticks = 1
if step then
local last = self.bobStep
ticks = last and step - last or 1
self.bobStep = step
end
if ticks > 0 then
self.bobTimer = ((self.bobTimer or 0) + ticks) % 32
end
return self.bobTimer or 0
end
local SPIN_ORDER = { "down", "left", "up", "right" }
-- constants/sprite_data_constants.asm:3
local IMAGE_FACING = { [0] = "down", [1] = "up", [2] = "left", [3] = "right" }
@@ -298,7 +320,7 @@ function Player:pose()
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
hopping = true
elseif self.surfing then
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
self:advanceBob()
py = py + (self.bobTimer < 16 and 0 or 1)
end
-- engine/overworld/player_animations.asm:453
+16 -3
View File
@@ -259,17 +259,25 @@ BorderFill.CROSSFADE_FRAMES = 20
-- The bookkeeping half, love-free so it can be checked without a canvas.
-- Returns the image to draw underneath (nil on the first fill and once the
-- dissolve is over) and the alpha the incoming image draws at.
function BorderFill.crossfade(owner, image, key)
--
-- `advance` is how many 60Hz frames the dissolve moves on by (default 1, one
-- per call). An owner that keeps `borderTicks` (World counts its logic steps
-- there) has BorderFill.draw pass those instead, so the fade runs on the
-- logic clock rather than once per render frame.
function BorderFill.crossfade(owner, image, key, advance)
if not owner or key == nil then return nil, 1 end
if owner.borderKey ~= key then
-- Nothing to dissolve from on the first map of a session.
owner.borderFrom = (owner.borderKey ~= nil) and owner.borderLast or nil
owner.borderKey = key
owner.borderFade = owner.borderFrom and 0 or nil
-- Ticks banked before the swap (a stretch with the fill not drawn at all)
-- belong to no dissolve: a new one starts at its first frame.
if advance and advance > 1 then advance = 1 end
end
owner.borderLast = image
if not owner.borderFade then return nil, 1 end
owner.borderFade = owner.borderFade + 1
owner.borderFade = owner.borderFade + (advance or 1)
if owner.borderFade >= BorderFill.CROSSFADE_FRAMES then
owner.borderFade, owner.borderFrom = nil, nil
return nil, 1
@@ -291,7 +299,12 @@ function BorderFill.draw(owner, image, camX, camY, w, h, s, key)
BorderFill.SIZE, BorderFill.SIZE)
if owner then owner.borderQuad = q end
end
local from, alpha = BorderFill.crossfade(owner, image, key)
local advance = nil
if owner and owner.borderTicks ~= nil then
advance = owner.borderTicks
owner.borderTicks = 0
end
local from, alpha = BorderFill.crossfade(owner, image, key, advance)
if from then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(from, q, sx, sy, 0, s, s)
+29 -10
View File
@@ -58,15 +58,30 @@ function MapAttrGrid.cellAt(map, tileset, mx, my)
return { tileId = tileId, rawTileId = raw, attr = attr }
end
-- Full map grid keyed by "mx,my" for fast lookup during overdraw.
-- Full map grid for fast lookup during overdraw: a flat array indexed by
-- 8x8 cell (row-major, `cols` cells to a row), so a lookup is two divides and
-- an index rather than a string key built per tile per entity per frame.
-- Cells with the same raw tile id share one read-only cell table: the
-- normalization depends only on (raw id, tileset).
function MapAttrGrid.build(map, tileset)
local grid = {}
local grid = { cols = 0, rows = 0, cells = {} }
if not (map and tileset) then return grid end
local pw, ph = map.width * 32, map.height * 32
for my = 0, ph - 1, 8 do
for mx = 0, pw - 1, 8 do
local cell = MapAttrGrid.cellAt(map, tileset, mx, my)
if cell then grid[mx .. "," .. my] = cell end
local cols, rows = map.width * 4, map.height * 4
grid.cols, grid.rows = cols, rows
local cells = grid.cells
local byRaw = {}
for cy = 0, rows - 1 do
for cx = 0, cols - 1 do
local raw = MapAttrGrid.tileAt(map, tileset, cx * 8, cy * 8)
if raw ~= nil then
local cell = byRaw[raw]
if not cell then
local tileId, attr = MapAttrGrid.normalizeTile(raw, tileset)
cell = { tileId = tileId, rawTileId = raw, attr = attr }
byRaw[raw] = cell
end
cells[cy * cols + cx + 1] = cell
end
end
end
return grid
@@ -74,9 +89,13 @@ end
function MapAttrGrid.lookup(grid, mx, my)
if not grid then return nil end
local tx = math.floor(mx / 8) * 8
local ty = math.floor(my / 8) * 8
return grid[tx .. "," .. ty]
local cx = math.floor(mx / 8)
local cy = math.floor(my / 8)
local cols = grid.cols
if not cols or cx < 0 or cy < 0 or cx >= cols or cy >= grid.rows then
return nil
end
return grid.cells[cy * cols + cx + 1]
end
return MapAttrGrid
+523 -217
View File
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
-- ChipSynth bulk PCM writes (ffi int16 stores through Data:getFFIPointer)
-- must be bit-identical to the per-sample SoundData:setSample path they
-- replace, for streamed music buffers, SFX/cries (renderEffectData) and the
-- synchronous music fallback's sliced staging fill (ChipAudio fillSync).
-- luajit tests/chip_synth_bulk_pcm_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("chip synth bulk pcm")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local ffi = require("ffi")
-- ffi-backed SoundData. setSample reproduces LOVE 11.5's 16-bit store,
-- (int16)(value * 32767) in double precision with no clamp -- verified
-- against a real LOVE 11.5 SoundData over 2M values including
-- out-of-range and NaN. getFFIPointer exposes the same storage.
local FfiSoundData = {}
FfiSoundData.__index = FfiSoundData
function FfiSoundData:setSample(index, a, b)
if b == nil then
self.buf[index * self.channels] = a * 32767
else
self.buf[index * self.channels + (a - 1)] = b * 32767
end
end
function FfiSoundData:getSample(index, channel)
return self.buf[index * self.channels + ((channel or 1) - 1)] / 32767
end
function FfiSoundData:getFFIPointer() return self.buf end
function FfiSoundData:getSampleCount() return self.samples end
function FfiSoundData:getSampleRate() return self.rate end
function FfiSoundData:getBitDepth() return 16 end
function FfiSoundData:getChannelCount() return self.channels end
love.sound = love.sound or {}
love.sound.newSoundData = function(samples, rate, bits, channels)
local sd = setmetatable({
samples = samples, rate = rate, channels = channels,
buf = ffi.new("int16_t[?]", samples * channels),
}, FfiSoundData)
return sd
end
-- the conversion itself, including values the synth never produces
do
local a = love.sound.newSoundData(8, 44100, 16, 1)
local values = { 0, 1, -1, 1.5, -1.5, 0.99999, 1.5 / 32767, 0 / 0 }
local expect = { 0, 32767, -32767, -16386, 16386, 32766, 1, 0 }
for i, v in ipairs(values) do a:setSample(i - 1, v) end
for i = 1, #expect do
eq(a.buf[i - 1], expect[i], "LOVE 11.5 int16 store of " .. tostring(values[i]))
end
end
local ChipSynth = require("src.core.ChipSynth")
local ChipAsm = require("src.audio.ChipAsm")
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
ChipSynth.setChannelPitches({ 1, 1, 1, 1 })
-- the fake SoundData really takes the bulk path (and the switch disables it)
check(ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
"bulk path active on an ffi SoundData")
ChipSynth._setBulkWritesForTest(false)
check(not ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)),
"bulk switch off falls back to setSample")
ChipSynth._setBulkWritesForTest(true)
local function same(a, b)
if a.samples ~= b.samples or a.channels ~= b.channels then
return false, "shape " .. a.samples .. "x" .. a.channels
.. " vs " .. b.samples .. "x" .. b.channels
end
local n = a.samples * a.channels
for i = 0, n - 1 do
if a.buf[i] ~= b.buf[i] then
return false, ("sample %d: %d vs %d"):format(i, a.buf[i], b.buf[i])
end
end
return true
end
local function nonzero(sd)
local count = 0
for i = 0, sd.samples * sd.channels - 1 do
if sd.buf[i] ~= 0 then count = count + 1 end
end
return count
end
local data = { audio = {} }
local song = ChipAsm.song{
tempo = 0x100,
channels = {
{ hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 12, fade = 0 } },
{ octave = 4 },
{ label = "body" },
{ note = "C", len = 8 }, { note = "E", len = 8 }, { note = "G", len = 4 },
{ loop = { count = 0, to = "body" } },
} },
{ hw = 2, program = {
{ duty = 1 },
{ notetype = { speed = 12, volume = 10, fade = 2 } },
{ octave = 3 },
{ label = "body" },
{ note = "G", len = 4 }, { note = "A", len = 12 },
{ loop = { count = 0, to = "body" } },
} },
{ hw = 4, program = {
{ label = "body" },
{ drum = 3, len = 4 },
{ loop = { count = 0, to = "body" } },
} },
},
drums = { [3] = { { len = 4, volume = 13, fade = 2, parameter = 0x42 } } },
}
local function render(bulk, fn)
ChipSynth._setBulkWritesForTest(bulk)
local ok, result = pcall(fn)
ChipSynth._setBulkWritesForTest(true)
assert(ok, result)
return result
end
-- streamed music buffers (worker + sync fallback), stereo and mono
for _, channels in ipairs({ 2, 1 }) do
local function music()
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
local first = ChipSynth.soundData(engine, 8192, channels)
local second = ChipSynth.soundData(engine, 8192, channels)
return { first, second }
end
local slow, fast = render(false, music), render(true, music)
for i = 1, 2 do
local ok, why = same(slow[i], fast[i])
check(ok, ("music buffer %d (%dch) bit-identical %s")
:format(i, channels, why or ""))
end
check(nonzero(fast[1]) > 1000, "music buffer carries audio")
end
-- sliced staging fill == one whole-buffer render
do
local whole = render(false, function()
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
ChipSynth.soundData(engine, 1000, 2)
return ChipSynth.soundData(engine, 8192, 2)
end)
local sliced = render(true, function()
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
ChipSynth.soundData(engine, 1000, 2)
local sd = ChipSynth.newBuffer(8192, 2)
local fill = 0
for _, n in ipairs({ 1024, 1024, 777, 1024, 3000, 1343 }) do
ChipSynth.renderInto(engine, sd, fill, n, 2)
fill = fill + n
end
assert(fill == 8192)
return sd
end)
local ok, why = same(whole, sliced)
check(ok, "sliced staging fill bit-identical " .. (why or ""))
end
-- SFX / cries
local sfx = ChipAsm.sfx{ channels = {
{ hw = 1, program = {
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
{ squareNote = { len = 16, volume = 12, fade = 3, frequency = 0x700 } },
} },
{ hw = 4, program = {
{ noiseNote = { len = 16, volume = 14, fade = 2, parameter = 0x33 } },
} },
} }
for _, opts in ipairs({
function() return {} end,
function() return { frequencyOffset = 0x40, cryLength = 0x60 } end,
function() return { frequencyOffset = -0x20, frameTicks = 0xC0 } end,
}) do
local slow = render(false, function()
return ChipSynth.renderEffectData(data, sfx, opts())
end)
local fast = render(true, function()
return ChipSynth.renderEffectData(data, sfx, opts())
end)
check(slow and fast, "effect renders both ways")
if slow and fast then
local ok, why = same(slow, fast)
check(ok, "effect bit-identical " .. (why or ""))
check(nonzero(fast) > 100, "effect carries audio")
end
end
-- a second, longer effect after a short one: the reused scratch grows
do
local notes = {}
for i = 1, 24 do
notes[i] = { squareNote = { len = 16, volume = 15, fade = 0,
frequency = 0x600 + i * 8 } }
end
local long = ChipAsm.sfx{ channels = { { hw = 1, program = notes } } }
local slow = render(false, function()
return ChipSynth.renderEffectData(data, long, {})
end)
local fast = render(true, function()
return ChipSynth.renderEffectData(data, long, {})
end)
check(slow and fast and slow.samples > 44100,
"long effect renders past the initial scratch")
if slow and fast then
local ok, why = same(slow, fast)
check(ok, "long effect bit-identical " .. (why or ""))
end
end
-- synchronous music fallback: the sliced per-tick staging fill queues the
-- same 8192-sample buffers, in order, as whole-buffer renders did
do
love.thread = nil
local queued = {}
local source = { free = 32, playing = false }
function source:getFreeBufferCount() return self.free end
function source:queue(sd) self.free = self.free - 1; queued[#queued + 1] = sd end
function source:play() self.playing = true end
function source:stop() self.playing = false end
function source:isPlaying() return self.playing end
function source:setVolume() end
love.audio = love.audio or {}
love.audio.newQueueableSource = function() return source end
local ChipAudio = require("src.core.ChipAudio")
ChipAudio.playMusic(data, song, true)
eq(#queued, 4, "song start queues the initial buffers")
local maxTick = 0
local consumed = 0
for _ = 1, 400 do
consumed = consumed + ChipSynth.SAMPLE_RATE / 60
while consumed >= 8192 and source.free < 32 do
consumed = consumed - 8192
source.free = source.free + 1
end
local before = #queued
ChipAudio.update()
maxTick = math.max(maxTick, #queued - before)
end
check(#queued > 40, "the queue keeps filling (" .. #queued .. " buffers)")
check(maxTick <= 1, "at most one buffer completes per tick")
local engine = ChipSynth.newEngine(data, song, { allowLoops = true })
local allSame = true
for i = 1, #queued do
local ref = ChipSynth.soundData(engine, 8192, 2)
local ok = same(ref, queued[i])
if not ok then allSame = false; break end
end
check(allSame, "sliced fallback stream bit-identical to whole buffers")
-- a starved queue (below the low-water mark) renders a whole buffer's
-- worth in one tick
source.free = 31
local before = #queued
ChipAudio.update()
check(#queued - before >= 1, "low queue catches up within one tick")
ChipAudio.stopMusic()
-- no worker: prewarm declines rather than rendering on this thread
local fx = { audio = { sfx = { Beep = sfx }, cries = {} } }
check(ChipAudio.prewarmSfx(fx, "Beep") == false,
"prewarm is a no-op without a worker")
eq(ChipAudio._effectStateForTest().pending, 0, "nothing left pending")
end
S.finish()
+2
View File
@@ -92,6 +92,8 @@ local function compareBox(ours, ref, mask, diffPath)
end
return function(game)
-- nativeFrame() reads the flat 240x160 mirror of the plane path
require("src.core.game3.display").mirrorForTests = true
os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null')
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
@@ -19,6 +19,9 @@ local function finish()
end
return function(game)
-- the Fuchsia check waits on a Route 17 biker ~150 cells away wandering
-- into line; tick every ghost pool, not just those near the view
require("src.core.game3.ghosts").TICK_MARGIN_SCREENS = math.huge
for _ = 1, 600 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
@@ -0,0 +1,233 @@
-- Gen 2 overworld draw-path fixes and the caches that replaced per-frame work:
-- * Crystal ghost NPCs (neighbour-map objects) skip the grass composite,
-- as Gold/Silver already did -- their px/py are the neighbour's.
-- * bgOverQuads is one quad cache per atlas, not one shared by every atlas.
-- * the poison flash and the void-fill dissolve run on the logic clock.
-- * MapAttrGrid's numeric grid, the anim-cell index and the people cull.
-- luajit tests/engine/gen2_world_overdraw_perf_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local BorderFill = require("src.world.gen2.BorderFill")
local MapAttrGrid = require("src.world.gen2.MapAttrGrid")
local function crystalWorld(fields)
local world = setmetatable(fields, { __index = World })
function world:isCrystal() return true end
return world
end
-- ------------------------------------------------ ghost grass (Crystal)
do
local grass = 0
local world = crystalWorld({})
function world:drawGrassOver() grass = grass + 1 end
function world:drawBgPriorityOver() end
function world:drawGrassShake() end
local rows = {}
local function sprite(row) rows[#rows + 1] = row or "full" end
local entity = { px = 0, py = 0, inGrass = true }
world:drawEntityComposite(entity, 0, 0, 1, sprite, true, false)
eq(grass, 1, "an on-map object in grass gets the grass composite")
eq(rows[1], "bottom", "bottom OAM first")
grass, rows = 0, {}
world:drawEntityComposite(entity, 0, 0, 1, sprite, false, true)
eq(grass, 0, "BUG FIX: a neighbour-map ghost never samples this map's grass")
eq(#rows, 1, "the ghost draws once, whole")
eq(rows[1], "full", "as a single full sprite")
-- The old six-argument call (drawPipeline mods) keeps its behaviour.
grass = 0
world:drawEntityComposite(entity, 0, 0, 1, sprite, false)
eq(grass, 1, "no offMap argument still composites")
end
-- drawPeople passes offMap for ghosts.
do
local composites = {}
local world = crystalWorld({
player = { px = 0, py = 0, draw = function() end },
npcs = {}, camera = { x = 0, y = 0 }, viewW = 160, viewH = 144,
})
local ghostNpc = { px = 16, py = 16, inGrass = true, draw = function() end }
world.ghosts = { { npc = ghostNpc, ox = 320, oy = 0 } }
world.camera.x = 300
function world:flyHides() return false, false end
function world:drawJumpShadow() end
function world:drawEntityComposite(entity, _, _, _, _, withExtras, offMap)
composites[#composites + 1] = { entity = entity, extras = withExtras,
offMap = offMap }
end
function world:drawEmote() end
function world:drawHealAnim() end
function world:drawFlyAnim() end
world:drawPeople(1)
eq(#composites, 2, "player and ghost both drawn")
local ghost
for _, c in ipairs(composites) do
if c.entity == ghostNpc then ghost = c end
end
check(ghost ~= nil, "the ghost reached the compositor")
eq(ghost and ghost.offMap, true, "drawPeople flags the ghost as off-map")
eq(ghost and ghost.extras, false, "and without the on-map extras")
end
-- ------------------------------------------------ people cull + reuse
do
local drawn = {}
local function person(name, px, py)
return { name = name, px = px, py = py,
draw = function(self) drawn[#drawn + 1] = self.name end }
end
local world = setmetatable({
player = person("player", 5000, 5000),
npcs = { person("near", 64, 32), person("far", 2000, 32),
person("row", 80, 32) },
ghosts = {}, camera = { x = 0, y = 0 }, viewW = 160, viewH = 144,
}, { __index = World })
function world:isCrystal() return false end
function world:flyHides() return false, false end
function world:drawJumpShadow() end
function world:drawGrassShake() end
function world:drawEmote() end
function world:drawHealAnim() end
function world:drawFlyAnim() end
world:drawPeople(1)
eq(#drawn, 3, "the far NPC is culled, the player never is")
eq(drawn[1], "near", "equal rows draw in list order")
eq(drawn[2], "row", "equal rows draw in list order (second)")
eq(drawn[3], "player", "Y order holds")
local list = world._peopleList
drawn = {}
world:drawPeople(1)
eq(#drawn, 3, "a second frame draws the same people")
check(world._peopleList == list, "and reuses the draw list")
-- The billboard (TILT) path shows more than the view: no cull there.
drawn = {}
world:drawPeople(1, function(_, _, body) body() end)
eq(#drawn, 4, "the tilt billboard pass keeps everyone")
end
-- ------------------------------------------------ bgOverQuads per atlas
do
local world = crystalWorld({})
local a = love.graphics.newImage("a.png")
local b = love.graphics.newImage("b.png")
local qa = world:bgOverQuadsFor(a)
local qb = world:bgOverQuadsFor(b)
check(qa ~= qb, "BUG FIX: two atlases get two quad caches")
check(world:bgOverQuadsFor(a) == qa, "one atlas keeps its cache")
end
-- ------------------------------------------------ poison flash clock
do
local world = setmetatable({}, { __index = World })
world:poisonBGFlash()
eq(world.poisonFlash, 4, "the flash arms four frames")
for i = 3, 0, -1 do
world:tickPoisonFlash()
eq(world.poisonFlash, i, "one logic tick spends one frame")
end
world:tickPoisonFlash()
eq(world.poisonFlash, 0, "and it stops at zero")
-- Game2's fixed step drives both clocks through tickFrameClocks.
world:poisonBGFlash()
world:tickFrameClocks()
eq(world.poisonFlash, 3, "tickFrameClocks spends a poison frame")
eq(world.borderTicks, 1, "and banks one dissolve tick")
for _ = 1, 100 do world:tickFrameClocks() end
eq(world.borderTicks, BorderFill.CROSSFADE_FRAMES,
"banked ticks cap at one whole dissolve")
end
-- ------------------------------------------------ void dissolve clock
do
local owner = { borderTicks = 0 }
BorderFill.crossfade(owner, "water", "A")
local from, alpha = BorderFill.crossfade(owner, "trees", "B", 1)
eq(from, "water", "a new fill dissolves from the old one")
local a1 = alpha
from, alpha = BorderFill.crossfade(owner, "trees", "B", 0)
eq(alpha, a1, "BUG FIX: a render frame with no logic tick does not advance")
from, alpha = BorderFill.crossfade(owner, "trees", "B", 2)
check(alpha > a1, "two banked ticks advance two frames")
-- Ticks banked before a swap do not skip the new dissolve.
local o2 = {}
BorderFill.crossfade(o2, "water", "A")
local f2, al2 = BorderFill.crossfade(o2, "trees", "B", 15)
eq(f2, "water", "a swap after a long gap still dissolves")
check(al2 <= 1 / BorderFill.CROSSFADE_FRAMES + 1e-9,
"from its first frame")
end
-- ------------------------------------------------ MapAttrGrid numeric
do
local block = {}
for i = 1, 16 do block[i] = i - 1 end
block[16] = 0x85
local tileset = { blocks = { block, block } }
local map = { width = 2, height = 1, blocks = { 1, 0 }, borderBlock = 1 }
local grid = MapAttrGrid.build(map, tileset)
for my = 0, 31, 8 do
for mx = 0, 63, 8 do
local want = MapAttrGrid.cellAt(map, tileset, mx, my)
local got = MapAttrGrid.lookup(grid, mx + 3, my + 5)
eq(got and got.tileId, want.tileId, "lookup tile id at " .. mx .. "," .. my)
eq(got and got.rawTileId, want.rawTileId, "raw id at " .. mx .. "," .. my)
eq(got and got.attr.vramBank, want.attr.vramBank,
"bank at " .. mx .. "," .. my)
end
end
eq(MapAttrGrid.lookup(grid, -8, 0), nil, "left of the map is nothing")
eq(MapAttrGrid.lookup(grid, 64, 0), nil, "right of the map does not wrap")
eq(MapAttrGrid.lookup(grid, 0, 32), nil, "below the map is nothing")
eq(MapAttrGrid.lookup(nil, 0, 0), nil, "no grid is nothing")
end
-- ------------------------------------------------ anim cell index
do
local world = setmetatable({}, { __index = World })
local water = { layer = {}, tile = 1, cells = { 0, 0, 8, 0, 32, 64 } }
local flower = { layer = {}, tile = 2, cells = { 16, 8 } }
local cells = { [1] = water, [2] = flower }
eq(world:animListAt(cells, 8, 0), water, "indexed water cell")
eq(world:animListAt(cells, 32, 64), water, "indexed far water cell")
eq(world:animListAt(cells, 16, 8), flower, "indexed flower cell")
eq(world:animListAt(cells, 8, 8), nil, "a plain cell is not animated")
eq(world:animListAt(nil, 0, 0), nil, "no anim table")
eq(world:animListAt(false, 0, 0), nil, "a map with no anims")
end
-- ------------------------------------------------ memoized keys
do
local GbcPalette = require("src.render.GbcPalette")
local fake = { daytime = "DAY", flickerPhase = 1 }
local k1 = World.mapCacheKey(fake, "M")
eq(k1, "M|DAY|" .. tostring(GbcPalette.mode) .. "|nil|1", "key format")
check(World.mapCacheKey(fake, "M") == k1, "memoized")
fake.daytime = "NITE"
check(World.mapCacheKey(fake, "M") ~= k1, "a daytime change rebuilds it")
fake.daytime = "DARK"
local dark1 = World.mapCacheKey(fake, "M")
fake.flickerPhase = 2
check(World.mapCacheKey(fake, "M") ~= dark1, "a flicker change rebuilds it")
BorderFill.setVoidFill("fade")
local def = { id = "M", tileset = "TILESET_JOHTO", borderBlock = 5 }
local w = setmetatable({}, { __index = World })
eq(w:borderFillKey(def), BorderFill.fillKey(def), "fill key matches")
BorderFill.setVoidFill("water")
eq(w:borderFillKey(def), BorderFill.fillKey(def), "fill key follows mode")
BorderFill.setVoidFill("fade")
end
T.finish("gen2 world overdraw perf")
@@ -51,7 +51,11 @@ for i = 1, 4 do
"shade " .. (i - 1) .. " is not a DMG gray")
end
-- the heal overlay body is a module-level function drawWorld calls
-- (fxHeal), not an inline closure; look through it as well
local healShader = upvalue(OW.drawWorld, "healMachineShader")
or upvalue(upvalue(OW.drawWorld, "fxHeal") or function() end,
"healMachineShader")
check(type(healShader) == "function",
"the heal overlay picks its palette through healMachineShader")
+158
View File
@@ -0,0 +1,158 @@
-- Presentation counters that draw code samples must advance at the fixed
-- 60Hz logic rate, not once per rendered frame:
-- * Player:pose's surf bob (a 144Hz display bobbed 2.4x too fast, and the
-- battle-transition wipe, which draws the player twice a frame, doubled
-- it) -- now counted in Game:step logic steps (Game.logicStep);
-- * the battle text box's ScrollTextUpOneLine offset (scrollPx), which
-- BattleState:drawTextArea / WideBattle counted down per draw -- now
-- BattleState:tickTextScroll, run once per update step.
-- Also pins the per-frame caching in the battle OAM colorizer: one
-- sgbBattlePals() per frame, shared color triples, and AnimPlayer's
-- uniform sends always matching the colors of the cell being painted.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
-- ---------------------------------------------------------------- surf bob
do
local prevGame = package.loaded["src.core.Game"]
local fakeGame = { logicStep = 10 }
package.loaded["src.core.Game"] = fakeGame
local Player = require("src.world.Player")
local p = setmetatable({ surfing = true, px = 0, py = 0, facing = "down",
progress = 0, animClock = 0 }, Player)
-- the wipe's double draw (and any 120/144Hz frame without a new step)
p:pose(); p:pose(); p:pose()
T.eq(p.bobTimer, 1, "three draws inside one logic step advance the bob once")
fakeGame.logicStep = 11
p:pose(); p:pose()
T.eq(p.bobTimer, 2, "the next step advances it exactly once more")
fakeGame.logicStep = 14
p:pose()
T.eq(p.bobTimer, 5, "steps run between draws (fast-forward) all count")
-- the 32-step cycle: low for bob 0..15, one pixel down for 16..31
local lows, highs = 0, 0
for _ = 1, 32 do
fakeGame.logicStep = fakeGame.logicStep + 1
local _, _, y = p:pose()
local y2 = select(3, p:pose()) -- a second draw in the same step
T.eq(y2, y, "a repeat draw in the same step shows the same bob")
if y == 0 then lows = lows + 1 else highs = highs + 1 end
end
T.eq(lows, 16, "16 steps of the cycle sit on the baseline")
T.eq(highs, 16, "16 steps of the cycle sit a pixel down")
-- no running Game (headless callers): the old per-call tick
fakeGame.logicStep = nil
local before = p.bobTimer
p:pose(); p:pose()
T.eq(p.bobTimer, (before + 2) % 32, "without a step clock each pose ticks")
package.loaded["src.core.Game"] = prevGame
end
-- --------------------------------------------------------- battle scroll
local BattleState = require("src.battle.BattleState")
do
local b = setmetatable({ phase = "messages", current = {}, scrollPx = 8 },
{ __index = BattleState })
local seen = {}
for i = 1, 5 do
b:tickTextScroll()
seen[i] = tostring(b.scrollPx)
end
T.eq(table.concat(seen, ","), "6,4,2,nil,nil",
"2px per logic step, cleared at 0 (the draws saw 6,4,2,0 before)")
b.scrollPx = 8
b.phase = "menu"
b:tickTextScroll()
T.eq(b.scrollPx, 8, "no count-down while the message box is not shown")
b.phase, b.current, b.animPlaying, b.msgHold = "messages", nil, nil, true
b:tickTextScroll()
T.eq(b.scrollPx, 6, "msgHold keeps the box (and its scroll) live")
end
-- ------------------------------------------- battle OAM colorizer caching
do
local zoneA = { { 255, 255, 255 }, { 200, 0, 0 }, { 100, 0, 0 }, { 0, 0, 0 } }
local zoneB = { { 255, 255, 255 }, { 0, 200, 0 }, { 0, 100, 0 }, { 0, 0, 0 } }
local palsCalls = 0
local b = setmetatable({}, { __index = BattleState })
b.sgbBattlePals = function()
palsCalls = palsCalls + 1
return { [0] = zoneA, [1] = zoneB, [2] = zoneA, [3] = zoneB }
end
local PaletteFX = require("src.render.PaletteFX")
local realObp = PaletteFX.usesSpriteObp
PaletteFX.usesSpriteObp = function() return false end
local memo = {}
local c1 = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 0, 0, memo)
local c2 = b:animSpriteColors({ obp = "e4", x = 20, y = 28 }, 0, 8, memo)
local c3 = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 8, 8, memo)
T.eq(palsCalls, 1, "one sgbBattlePals per frame memo")
T.check(c1 == c2, "same zone + obp shares one triple table")
T.check(c3 ~= c1, "the enemy-HUD zone resolves its own triple")
T.eq(math.floor(c1[1][1] * 255 + 0.5), 200, "e4 color 1 is the zone's shade 1")
T.eq(math.floor(c3[1][2] * 255 + 0.5), 200, "enemy HUD wears zone 1's shade 1")
local fresh = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 0, 0)
T.eq(palsCalls, 2, "without a memo it resolves the palettes itself")
T.same(fresh, c1, "memoized and unmemoized colors agree")
PaletteFX.usesSpriteObp = realObp
-- AnimPlayer.drawSprites: every blit must see the uniforms of the cell
-- it paints, whatever sends were skipped as redundant
local AnimPlayer = require("src.battle.AnimPlayer")
local g = love.graphics
local saved = {}
for _, k in ipairs({ "draw", "setShader", "getScissor", "intersectScissor",
"setScissor" }) do saved[k] = g[k] end
local shader = { u = {} }
function shader:send(name, v) self.u[name] = v end
local realShaderFn = PaletteFX.shader
PaletteFX.shader = function() return shader end
local scissor
local blits = {}
g.draw = function()
blits[#blits + 1] = { c1 = shader.u.c1, c2 = shader.u.c2,
c3 = shader.u.c3, clip = scissor }
end
g.setShader = function() end
g.getScissor = function()
if scissor then return scissor[1], scissor[2], scissor[3], scissor[4] end
end
g.intersectScissor = function(x, y, w, h) scissor = { x, y, w, h } end
g.setScissor = function(x, y, w, h) scissor = x and { x, y, w, h } or nil end
local tri = {}
local function colorAt(px, py)
-- a vertical zone boundary at x = 16
local key = px < 16 and "L" or "R"
tri[key] = tri[key] or { { px < 16 and 1 or 0, 0, 0 }, { 0, 0, 0 },
{ 0, 0, 0 } }
return tri[key]
end
local ap = setmetatable({ sheetImage = function() return {} end,
tileQuad = function() return {} end },
{ __index = AnimPlayer })
local sprites = {
{ x = 8 + 4, y = 16, tile = 1 }, -- rx 4: fully in L
{ x = 8 + 12, y = 16, tile = 1 }, -- rx 12: straddles L|R
{ x = 8 + 20, y = 16 + 3, tile = 1 }, -- rx 20, ry 3: R only (2 rows)
{ x = 8 + 5, y = 16, tile = 1 }, -- back to L
}
ap:drawSprites(sprites, function(_, px, py) return colorAt(px, py) end)
-- expected paint order: L, L+R(slice), R, R(slice, same colors -> no
-- repaint), L
local want = { "L", "L", "R", "R", "L" }
T.eq(#blits, #want, "one blit per tile plus one per differing cell")
for i, w in ipairs(want) do
local bl = blits[i]
if bl then
T.eq(bl.c1 and bl.c1[1], w == "L" and 1 or 0,
("blit %d paints with its cell's colors"):format(i))
end
end
T.check(blits[3] and blits[3].clip ~= nil,
"the straddling tile's second cell is scissored")
for k, v in pairs(saved) do g[k] = v end
PaletteFX.shader = realShaderFn
end
T.finish("refresh-rate-independent counters")
+319
View File
@@ -0,0 +1,319 @@
-- Hot-path caches and the bugs fixed alongside them: Font's encode/width
-- memo, PaletteFX/GbcPalette last-sent uniform detection, SpriteRenderer's
-- obp bake lookup, Hooks' unwrap of the last link, Sandbox's per-name
-- verdict, Input's recycled step tables and stale-pad rebuild, and
-- TextBox's scroll slide running on the logic step instead of draw.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
package.loaded["src.core.Logger"] = {
info = function() end, warn = function() end, error = function() end,
debug = function() end,
}
-- ------------------------------------------------------------- Font
do
local Font = require("src.render.Font")
local a = Font.encode("ABC")
a[1], a[4] = -1, -2
local b = Font.encode("ABC")
eq(#b, 3, "encode hands out a copy: a caller's append does not stick")
check(b[1] ~= -1, "encode hands out a copy: a caller's write does not stick")
check(a ~= b, "each encode is a fresh table")
eq(Font.glyphCount("ABC"), #Font.split("ABC"), "glyphCount matches split")
local w = Font.width("ABC")
eq(Font.width("ABC"), w, "cached width is stable")
local rev = Font.revision
Font.setFrame(Font.frameIndex())
eq(Font.revision, rev, "re-selecting the same frame keeps the caches")
Font.setFrame(Font.frameIndex() + 1)
check(Font.revision > rev, "a frame change drops cached widths")
Font.setFrame(1)
end
-- ------------------------------------------------------------- PaletteFX
local function recordingShader()
local sh = { sends = {} }
function sh.send(self, name, value)
self.sends[#self.sends + 1] = { name = name, value = { value[1], value[2], value[3] } }
end
return sh
end
do
local PaletteFX = require("src.render.PaletteFX")
PaletteFX.pickerActive = function() return false end
local pal = { { 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 }, { 0, 0, 0 } }
local sh = recordingShader()
PaletteFX.mode = "gbc"
PaletteFX.sendColors(sh, pal)
eq(#sh.sends, 4, "first send sets all four colors")
PaletteFX.sendColors(sh, pal)
eq(#sh.sends, 4, "identical colors to the same shader are not re-sent")
local changed = { pal[1], pal[2], { 10, 20, 30 }, pal[4] }
PaletteFX.sendColors(sh, changed)
eq(#sh.sends, 5, "only the changed uniform is re-sent")
eq(sh.sends[5].name, "c2", "and it is the one that changed")
local other = recordingShader()
PaletteFX.sendColors(other, changed)
eq(#other.sends, 4, "the record is per shader")
PaletteFX.forgetSent(sh)
PaletteFX.sendColors(sh, changed)
eq(#sh.sends, 9, "forgetSent makes the next send go out in full")
PaletteFX.sendShades(sh, pal)
PaletteFX.sendColors(sh, changed)
eq(sh.sends[#sh.sends].name, "c2", "sendShades shares the record with sendColors")
-- the index arithmetic must land on exactly what effectiveColors resolves
local function lastFour(s)
local out = {}
for i = #s.sends, 1, -1 do
local e = s.sends[i]
local k = tonumber(e.name:sub(2)) + 1
if not out[k] then out[k] = e.value end
end
return out
end
for _, mode in ipairs({ "gbc", "gbc_inv", "og", "og_inv", "classic" }) do
for _, map in ipairs({ false, PaletteFX.DARK_BGP, PaletteFX.POISON_BGP }) do
PaletteFX.mode = mode
PaletteFX.setShadeMap(map or nil)
local s = recordingShader()
PaletteFX.sendColors(s, pal)
local want = PaletteFX.effectiveColors(pal)
local got = lastFour(s)
local same = true
for i = 1, 4 do
for c = 1, 3 do
if math.abs(got[i][c] - want[i][c] / 255) > 1e-12 then same = false end
end
end
check(same, ("sendColors matches effectiveColors (%s, map %s)")
:format(mode, tostring(map and true)))
end
end
PaletteFX.mode = "gbc"
PaletteFX.setShadeMap(nil)
end
-- ------------------------------------------------------------- GbcPalette
do
local GbcPalette = require("src.render.GbcPalette")
local sh = recordingShader()
love.graphics.newShader = function() return sh end
love.graphics.setShader = function() end
local pal = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 } }
check(GbcPalette.useRaw(pal), "useRaw binds")
eq(#sh.sends, 4, "useRaw sends pal0..pal3 once")
GbcPalette.useRaw(pal)
eq(#sh.sends, 4, "an unchanged palette is not re-sent")
GbcPalette.useRaw({ pal[1], { 1, 2, 3 }, pal[3], pal[4] })
eq(#sh.sends, 5, "one changed entry is one send")
eq(sh.sends[5].name, "pal1", "to the changed uniform")
end
-- ------------------------------------------------------------- SpriteRenderer
do
local Assets = require("src.render.Assets")
local loads = 0
Assets.image = function() loads = loads + 1; return { tag = loads } end
local SpriteRenderer = require("src.render.SpriteRenderer")
SpriteRenderer.invalidate()
local savedImage = love.image
love.image = nil -- headless bake path
local colors = { {}, {}, {}, {} }
local one = SpriteRenderer.obpImage("sheet.png", colors, 1)
eq(SpriteRenderer.obpImage("sheet.png", colors, 1), one, "a cache hit returns the bake")
eq(SpriteRenderer.obpImage("sheet.png", colors, "1"), one,
"number and string groups share a key, as the concatenated key did")
check(SpriteRenderer.obpImage("sheet.png", colors, "dark") ~= nil, "a new group bakes")
SpriteRenderer.invalidate()
check(SpriteRenderer.obpImage("sheet.png", colors, 1) ~= one, "invalidate drops the lookup too")
love.image = savedImage
end
-- ------------------------------------------------------------- Hooks
do
local Hooks = require("src.mods.Hooks")
local h = Hooks.new()
local unwrap = h:wrap("x", function(next, v) return next(v + 1) end)
eq(h:call("x", function(v) return v * 2 end, 1), 4, "a single link runs")
unwrap()
eq(h.chains.x, nil, "unwrapping the last link drops the chain (wantsHook false)")
eq(h:call("x", function(v) return v * 2 end, 1), 2, "and the call is plain vanilla")
local u1 = h:wrap("y", function(next, ...) return next(...) end)
local u2 = h:wrap("y", function(next, ...) return next(...) end)
u1()
check(h.chains.y ~= nil, "a chain with links left stays")
u2()
eq(h.chains.y, nil, "and goes once empty")
local stale = h:wrap("z", function(next, ...) return next(...) end)
h:removeOwner(nil)
h.chains.z = nil
local fresh = h:wrap("z", function(next, ...) return next(...) end)
stale()
check(h.chains.z ~= nil, "a stale unwrap never drops a newer chain")
fresh()
local ok, err = pcall(function()
return h:call("none", function() error("boom", 0) end)
end)
check(not ok and err == "boom", "vanilla errors propagate without a chain")
h:wrap("w", function(next, ...) return next(...) end)
ok, err = pcall(function()
return h:call("w", function() error("deep", 0) end)
end)
check(not ok and err == "deep", "vanilla errors propagate through a chain unwrapped")
local n, a, b, c = select("#", h:call("w", function() return 1, nil, 3 end)),
h:call("w", function() return 1, nil, 3 end)
eq(n, 3, "trailing results keep their count")
check(a == 1 and b == nil and c == 3, "and their values")
end
-- ------------------------------------------------------------- Sandbox
do
local Sandbox = require("src.mods.Sandbox")
for _ = 1, 2 do
check(Sandbox.moduleDenial("io") ~= nil, "io denied")
check(Sandbox.moduleDenial("love.filesystem") ~= nil, "love.* denied")
eq(Sandbox.moduleDenial("love"), nil, "bare love allowed")
check(Sandbox.moduleDenial("socket.http") ~= nil, "network needs the permission")
eq(Sandbox.moduleDenial("socket.http", { network = true }), nil, "and passes with it")
eq(Sandbox.moduleDenial("src.core.Game"), nil, "engine modules allowed")
end
end
-- ------------------------------------------------------------- Input
do
local Input = require("src.core.Input")
Input:init()
Input:keypressed("up")
Input:step()
local pressed, queue = Input.pressed, Input.pressQueue
check(Input:wasPressed("up"), "press edges")
Input:keyreleased("up")
Input:step()
eq(Input.pressed, pressed, "step recycles its own pressed table")
eq(Input.pressQueue, queue, "and its own queue")
check(not Input:wasPressed("up"), "cleared in place")
local mine = { a = true }
Input.pressed = mine
Input:step()
check(Input.pressed ~= mine and mine.a == true,
"a table a test installed is replaced, never emptied")
-- a pad swapped for another between two polls, same count
local function pad(name, connected)
return { name = name, connected = connected,
isGamepadDown = function() return false end,
isConnected = function(self) return self.connected end,
isGamepad = function() return true end,
getName = function(self) return self.name end,
getGUID = function() return "" end,
getID = function() return 1 end,
getButtonCount = function() return 12 end,
getAxisCount = function() return 4 end }
end
local p1, p2 = pad("one", true), pad("two", true)
local current = { p1 }
love.joystick = {
getJoystickCount = function() return #current end,
getJoysticks = function() return current end,
}
local GamepadMap = require("src.core.GamepadMap")
GamepadMap.ignoreRawForJoystick = function() return true end
GamepadMap.isAccelerometer = function() return false end
Input:pollPads()
eq(Input._pollPads[1], p1, "first poll caches the pad")
p1.connected = false
current = { p2 }
Input:pollPads()
eq(Input._pollPads[1], p2, "a disconnected cached pad forces a rebuild")
current = { p1 }
p1.connected = true
Input:joysticksChanged()
Input:pollPads()
eq(Input._pollPads[1], p1, "joysticksChanged drops the cache")
love.joystick = nil
end
-- ------------------------------------------------------------- TextBox
do
local TextBox = require("src.render.TextBox")
local input = {
wasPressed = function() return false end,
isDown = function() return false end,
}
local game = { data = { text = {} }, input = input,
save = { generation = 2, options = { textSpeed = 1 } } }
local box = TextBox.new(game, "a\nb\nc")
local steps = 0
while not box.scrollPx and steps < 200 do
box:update(1 / 60)
steps = steps + 1
end
eq(box.scrollPx, 6, "the frame that scrolls ends 2px into the slide")
local drew = pcall(box.draw, box)
if drew then
pcall(box.draw, box)
eq(box.scrollPx, 6, "draw no longer advances the slide")
end
box:update(1 / 60)
eq(box.scrollPx, 4, "each logic step moves it 2px")
box:update(1 / 60)
box:update(1 / 60)
eq(box.scrollPx, nil, "and it ends after four steps")
end
-- ------------------------------------------------------------- ShaderFX
do
love.filesystem.write = function() return true end
love.graphics.getRendererInfo = function() return "OpenGL", "4.1 stub", "stub", "stub" end
love.graphics.getSupported = function() return { glsl3 = true } end
love.graphics.flushBatch = function() end
love.graphics.validateShader = function() return true end
love.timer = love.timer or {}
love.timer.getDelta = love.timer.getDelta or function() return 1 / 60 end
local sends = {}
love.graphics.newShader = function(frag, vert)
local sh = { frag = frag, vert = vert }
function sh.send(self, name, value) sends[#sends + 1] = name end
return sh
end
local quads = 0
love.graphics.newQuad = function(x, y, w, h, sw, sh)
quads = quads + 1
local q = { x = x, y = y, w = w, h = h }
function q.setViewport(self, nx, ny, nw, nh) self.x, self.y, self.w, self.h = nx, ny, nw, nh end
return q
end
local ShaderFX = require("src.render.ShaderFX")
local ok = ShaderFX.activate("main", { name = "lcd3x.slangp",
fullPath = "tests/data/shaderfx/gl/lcd3x.slangp", converted = true })
eq(ok, true, "lcd3x activates under the stub")
local canvas = setmetatable({ w = 800, h = 720 }, { __index = {
getWidth = function(self) return self.w end,
getHeight = function(self) return self.h end,
getPixelWidth = function(self) return self.w end,
getPixelHeight = function(self) return self.h end,
} })
local rect = { x = 0, y = 0, w = 800, h = 720, scale = 5 }
ShaderFX.render(canvas, rect, { w = 160, h = 144 }, 1, 1)
local first = #sends
check(first > 0, "the first frame sends the pass uniforms")
ShaderFX.render(canvas, rect, { w = 160, h = 144 }, 1, 1)
eq(#sends, first, "an identical frame re-sends nothing")
eq(quads, 1, "the crop quad is re-aimed, not rebuilt")
ShaderFX.render(canvas, { x = 0, y = 0, w = 640, h = 576, scale = 4 },
{ w = 160, h = 144 }, 1, 1)
check(#sends > first, "a size change re-sends what changed")
ShaderFX.deactivate("main")
end
T.finish("render_hotpath_caches")
+205
View File
@@ -0,0 +1,205 @@
-- FieldView native tile SpriteBatches: a full reset reuses (clears) the
-- existing batches instead of allocating new ones, an atlas texture swap
-- mid-scroll rebuilds instead of indexing a replaced batch, and a single
-- metatile write re-samples one cell without a full rebuild.
package.path = "./?.lua;./?/init.lua;" .. package.path
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
end
local created, released = 0, 0
local function newBatch(texture)
created = created + 1
local b = { texture = texture, sprites = {}, released = false }
local function live(self)
if self.released then error("use of released SpriteBatch", 2) end
end
function b:add(q, x, y)
live(self)
self.sprites[#self.sprites + 1] = { q = q, x = x, y = y }
return #self.sprites
end
function b:set(i, q, x, y)
live(self)
if i < 1 or i > #self.sprites then error("Invalid sprite index: " .. tostring(i), 2) end
self.sprites[i] = { q = q, x = x, y = y }
end
function b:clear() live(self); self.sprites = {} end
function b:getCount() return #self.sprites end
function b:getTexture() return self.texture end
function b:release() released = released + 1; self.released = true end
return b
end
local noop = function() end
love = love or {}
love.graphics = love.graphics or {}
love.graphics.newSpriteBatch = function(texture) return newBatch(texture) end
love.graphics.setColor = noop
love.graphics.rectangle = noop
love.graphics.draw = noop
local Versions = require("src.import.gba.versions")
Versions.NATIVE_RENDER = true
-- Fake native atlases: quad = { pair, slot } so tests can read back mids.
local Native = { _pairs = {} }
local function atlas(pair)
local ts = { pair = pair, image = { name = pair .. "#" .. tostring(math.random()) },
layered = false, quads = {} }
Native._pairs[pair] = ts
return ts
end
function Native.ready() return true end
function Native.get(pair) return Native._pairs[pair] end
function Native.slotFor(_, mid) return mid end
function Native.hasMid() return true end
function Native.quad(ts, slot)
local q = ts.quads[slot]
if not q then q = { pair = ts.pair, slot = slot }; ts.quads[slot] = q end
return q
end
function Native.overQuad() return nil end
package.loaded["src.core.game3.tileset_native"] = Native
package.loaded["src.core.game3.tileset_anim"] = { setVisiblePairs = noop }
-- Primary map "A" (pair a) covers x >= 0; everything west of it is pair b.
local overrides = {}
local layout = { width = 64, height = 64, pair = "a" }
function layout:midAt(x, y) return overrides[y * 1024 + x] or ((x + y) % 7) end
local mapDef = { midLayout = layout, pair = "a" }
local Map = { world = {}, neighborList = {} }
function Map.worldMidAt(wx, wy, def)
if wx < 0 then return (wx * 3 + wy) % 5, "b" end
return def.midLayout:midAt(wx, wy), "a"
end
package.loaded["src.core.game3.map"] = Map
local FieldView = require("src.core.game3.field_view")
local drawNativeTiles
for i = 1, 200 do
local name, fn = debug.getupvalue(FieldView.draw, i)
if not name then break end
if name == "drawNativeTiles" then drawNativeTiles = fn break end
end
check(type(drawNativeTiles) == "function", "found drawNativeTiles")
if not drawNativeTiles then os.exit(1) end
local W, H = 240, 160
local function cellSprite(wx, wy)
local info = FieldView._nativeVisibleCells[wy] and FieldView._nativeVisibleCells[wy][wx]
local batch = info and info.under and FieldView._nativeBatches[info.under.key]
return batch and batch.sprites[info.under.index], info
end
local function allCellsValid()
for wy, row in pairs(FieldView._nativeVisibleCells) do
for wx, info in pairs(row) do
if info.under then
local batch = FieldView._nativeBatches[info.under.key]
if not batch or batch.released or info.under.index > #batch.sprites then return false end
local s = batch.sprites[info.under.index]
local mid = Map.worldMidAt(wx, wy, mapDef)
if s.q.slot ~= mid or s.x ~= (wx - FieldView._nativeBaseBx) * 16 then return false end
end
end
end
return true
end
atlas("a")
atlas("b")
check(drawNativeTiles(mapDef, 0, 0, W, H) == true, "first native draw")
local first = FieldView._nativeBatches
local batchA, batchB = first.a, first.b
local createdAfterFirst = created
check(batchA and batchB, "one batch per visible pair")
check(allCellsValid(), "first build samples every visible cell")
-- 1. full reset keeps the same SpriteBatch objects
FieldView._nativeDirty = true
drawNativeTiles(mapDef, 0, 0, W, H)
check(created == createdAfterFirst, "dirty reset allocates no new SpriteBatch")
check(FieldView._nativeBatches == first and first.a == batchA and first.b == batchB,
"dirty reset reuses the batch store and batches")
check(allCellsValid(), "reset rebuild samples every visible cell")
local cols, rows = math.ceil(W / 16) + 3, math.ceil(H / 16) + 3
check(batchA:getCount() + batchB:getCount() == cols * rows, "reset clears before refilling")
-- 2. scrolling reuses freed slots; the slot counter stays bounded
for step = 1, 40 do drawNativeTiles(mapDef, step * 4, 0, W, H) end
for step = 40, 1, -1 do drawNativeTiles(mapDef, step * 4, step, W, H) end
check(allCellsValid(), "scrolling keeps every cell's sprite valid")
check(FieldView._nativeSpriteSlots <= cols * rows * 4, "slot counter bounded by the reset heuristic")
-- 3. a pair scrolled fully out of view is pruned (released) on the next reset
local goneB = FieldView._nativeBatches.b
FieldView._nativeDirty = true
drawNativeTiles(mapDef, 400, 0, W, H)
check(goneB and FieldView._nativeBatches.b == nil and goneB.released,
"reset releases a batch no visible cell uses")
-- 4. neighbour atlas reloaded mid-scroll: rebuild, never index a new batch
drawNativeTiles(mapDef, -8, 0, W, H)
local oldB = FieldView._nativeBatches.b
check(oldB ~= nil, "pair b batch back in view")
local freshB = atlas("b")
local ok, err = pcall(drawNativeTiles, mapDef, -40, 0, W, H)
check(ok, "texture swap mid-scroll does not crash (" .. tostring(err) .. ")")
check(FieldView._nativeBatches.b ~= oldB and FieldView._nativeBatches.b:getTexture() == freshB.image,
"swapped pair gets a batch on the new texture")
check(oldB.released, "old batch released after the swap")
ok, err = pcall(function()
for step = 1, 30 do drawNativeTiles(mapDef, -40 + step * 8, step, W, H) end
end)
check(ok, "scrolling after the swap releases cells safely (" .. tostring(err) .. ")")
check(allCellsValid(), "cells valid after swap + scroll")
-- 5. primary atlas reloaded with no scroll: rebuild onto the new texture
local freshA = atlas("a")
drawNativeTiles(mapDef, 200, 40, W, H)
check(FieldView._nativeBatches.a:getTexture() == freshA.image, "primary atlas swap rebuilds")
check(allCellsValid(), "cells valid after primary swap")
-- 6. one metatile write re-samples just that cell
local clearsBefore = created
local base = FieldView._nativeBaseBx
local cx, cy = 20, 6
overrides[cy * 1024 + cx] = 99
FieldView.invalidateLayoutCell(layout, cx, cy)
check(FieldView._nativeDirty == false, "cell write does not force a full rebuild")
local countBefore = FieldView._nativeBatches.a:getCount()
drawNativeTiles(mapDef, 200, 40, W, H)
local s = cellSprite(cx, cy)
check(s and s.q.slot == 99, "written cell shows the new metatile")
check(FieldView._nativeBaseBx == base and FieldView._nativeBatches.a:getCount() == countBefore
and created == clearsBefore, "cell write reused a freed slot without a rebuild")
check(allCellsValid(), "all cells valid after cell write")
-- a layout that is not the drawn map's falls back to a full rebuild
FieldView.invalidateLayoutCell({}, 1, 1)
check(FieldView._nativeDirty == true, "foreign layout write forces a full rebuild")
drawNativeTiles(mapDef, 200, 40, W, H)
-- LayoutNative wiring: applyOverride invalidates a single cell
local LayoutNative = require("src.core.game3.layout_native")
local real = LayoutNative.fromDecoded({ width = 64, height = 64, cells = {} }, "T", "a")
local realDef = { midLayout = real, pair = "a" }
drawNativeTiles(realDef, 0, 0, W, H)
real:applyOverride(3, 4, 42, 0, 0)
check(FieldView._nativeDirty == false and FieldView._nativeDirtyCells
and FieldView._nativeDirtyCells[1] == 3 and FieldView._nativeDirtyCells[2] == 4,
"applyOverride queues one dirty cell")
Map.worldMidAt = function(wx, wy, def) return def.midLayout:midAt(wx, wy), "a" end
drawNativeTiles(realDef, 0, 0, W, H)
local info = FieldView._nativeVisibleCells[4][3]
check(info and FieldView._nativeBatches.a.sprites[info.under.index].q.slot == 42,
"applyOverride cell redrawn with the new metatile")
real:clearOverrides()
check(FieldView._nativeDirty == true, "clearOverrides still rebuilds everything")
print(failures == 0 and "PASS game3_native_batch_reuse_test" or ("FAIL game3_native_batch_reuse_test failures=" .. failures))
os.exit(failures == 0 and 0 or 1)
+180
View File
@@ -0,0 +1,180 @@
-- Per-frame caches in the game3 overworld: OAM sort reuse, ghost pool sync /
-- camera-range ticking, one-cell collision patch, trainer id negative cache
-- and seamless tileset pair warming.
package.path = "./?.lua;./?/init.lua;" .. package.path
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
end
-- OAM ---------------------------------------------------------------------
do
local Oam = require("src.core.game3.oam")
Oam.reset()
local img = {}
local function spawn(y, pri)
local id, s = Oam.createSprite({ oam = { shape = 0, size = 1, priority = pri or 0 } }, 10, y, 0)
s.image = img
return id, s
end
local _, a = spawn(40)
local _, b = spawn(20)
local _, c = spawn(30, 1)
Oam.resetFrame()
check(#Oam._buffer == 0, "resetFrame empties the frame buffer")
local buf = Oam.buildOamBuffer()
check(#buf == 3 and buf[1] == b and buf[2] == a and buf[3] == c, "sorted by priority then y")
local sorted = Oam._sorted
Oam.resetFrame()
check(Oam._buffer == buf and #buf == 0, "resetFrame clears in place")
local buf2 = Oam.buildOamBuffer()
check(buf2 == buf and Oam._sorted == sorted and buf2[1] == b and buf2[3] == c,
"unchanged scene reuses the sorted cache")
a.y = 0
Oam.resetFrame()
buf2 = Oam.buildOamBuffer()
check(buf2[1] == a and buf2[2] == b and buf2[3] == c, "moved sprite re-sorts")
a.invisible = true
Oam.resetFrame()
buf2 = Oam.buildOamBuffer()
check(#buf2 == 2 and buf2[1] == b, "hidden sprite leaves the buffer")
a.invisible = false
c.image = nil
local _, d = spawn(20)
Oam.resetFrame()
buf2 = Oam.buildOamBuffer()
local seen = {}
for _, s in ipairs(buf2) do seen[s] = true end
check(#buf2 == 3 and seen[d] and seen[a] and seen[b] and not seen[c],
"swapped sprite with equal count rebuilds the set")
Oam.reset()
check(Oam._sorted == nil, "reset drops the sort cache")
end
-- Ghosts ------------------------------------------------------------------
do
local spawned, ticked = 0, {}
local Objects = {
spawnFromDefs = function(defs, def, id)
spawned = spawned + 1
return { order = {}, byId = {}, id = id }
end,
tickPool = function(pool) ticked[pool.id] = (ticked[pool.id] or 0) + 1 end,
playerBlocks = function() return false end,
}
local Map = { world = {} }
local Player = { cellX = 5, cellY = 5 }
package.loaded["src.core.game3.objects"] = Objects
package.loaded["src.core.game3.map"] = Map
package.loaded["src.core.game3.player"] = Player
package.loaded["src.core.game3.field_view"] = { _viewW = 240, _viewH = 160 }
local Ghosts = require("src.core.game3.ghosts")
local function entry(id, ox, oy)
return { id = id, ox = ox, oy = oy,
def = { objects = { {} }, midLayout = { width = 20, height = 20 } } }
end
Map.world = { entry("NEAR", 20, 0), entry("FAR", 200, 0), entry("NORTH", 0, -20) }
Ghosts.sync()
check(spawned == 3, "sync spawns a pool per world map")
for _ = 1, 5 do Ghosts.sync() end
check(spawned == 3, "sync with an unchanged world does not respawn")
Ghosts._pools.NORTH = nil
Ghosts.sync()
check(spawned == 4 and Ghosts._pools.NORTH, "externally dropped pool is respawned")
Ghosts._pools.EXTRA = { order = {}, byId = {} }
Ghosts.sync()
check(Ghosts._pools.EXTRA == nil, "pool outside the world is dropped")
Map.world = { Map.world[1] }
Ghosts.sync()
check(Ghosts._pools.FAR == nil and Ghosts._pools.NEAR, "new world table resyncs")
Map.world = { entry("NEAR", 20, 0), entry("FAR", 200, 0) }
Ghosts.clear()
Ghosts.sync()
check(Ghosts._pools.NEAR and Ghosts._pools.FAR, "clear then sync respawns")
Ghosts.update({})
check(ticked.NEAR == 1 and ticked.FAR == nil, "only pools near the camera tick")
Player.cellX = 190
Ghosts.update({})
check(ticked.FAR == 1, "a pool ticks again once the camera is back in range")
Ghosts.clear()
package.loaded["src.core.game3.objects"] = nil
package.loaded["src.core.game3.map"] = nil
package.loaded["src.core.game3.player"] = nil
package.loaded["src.core.game3.field_view"] = nil
end
-- Collision.patchCell -----------------------------------------------------
do
local Collision = require("src.core.game3.collision")
local LayoutNative = require("src.core.game3.layout_native")
local cells = {}
for i = 1, 16 do cells[i] = { mid = 1, coll = 0, elev = 0 } end
local layout = LayoutNative.fromDecoded({ width = 4, height = 4, cells = cells }, "T", "p")
local def = { midLayout = layout, warps = { { x = 2, y = 2 } } }
Collision.bindMap(nil, "T", def)
local grid = Collision._grid
layout:applyOverride(1, 1, 5, 0x07, 0)
check(Collision.patchCell("T", def, 1, 1) and Collision._grid == grid and grid[1 * 4 + 1 + 1] == 0x07,
"patchCell updates one grid entry in place")
local full = layout:collArray()
local same = true
for i = 1, 16 do if full[i] ~= grid[i] then same = false end end
check(same, "patched grid matches a full rebuild")
check(not Collision.patchCell("T", def, 2, 2), "warp cell falls back to bindMap")
check(not Collision.patchCell("OTHER", def, 1, 1), "other bound map falls back")
check(not Collision.patchCell("T", def, 9, 1), "off-grid cell falls back")
end
-- TrainerSight negative cache ----------------------------------------------
do
local Space = require("src.core.game3.scripting.space")
local prevBundle = Space.bundle
local list = { { op = "msgbox" } }
Space.bundle = { scripts = { S = list } }
local TrainerSight = require("src.core.game3.trainer_sight")
local eo = { scriptKey = "S" }
check(TrainerSight.getTrainerId(eo) == nil and eo._trainerIdMiss == list, "first scan misses")
-- same list table: the cached miss stands without rescanning
list[2] = { op = "trainerbattle", trainer = 3 }
check(TrainerSight.getTrainerId(eo) == nil, "repeat lookups hit the negative cache")
Space.bundle.scripts.S = { { op = "trainerbattle", trainer = 7 } }
check(TrainerSight.getTrainerId(eo) == 7, "replaced script is rescanned")
Space.bundle = prevBundle
end
-- Map warm queue ----------------------------------------------------------
do
local loads = {}
local Native = { _pairs = { loaded = {} } }
function Native.ready() return true end
function Native.get(pair) loads[#loads + 1] = pair; Native._pairs[pair] = {}; return {} end
package.loaded["src.core.game3.tileset_native"] = Native
local Map = require("src.core.game3.map")
local function def(pair) return { pair = pair, midLayout = { pair = pair } } end
Map.computeWorld = function()
return { { id = "A", def = def("loaded") }, { id = "B", def = def("p1") },
{ id = "C", def = def("p2") }, { id = "D", def = def("p1") } }
end
Map.ensureMidLayout = function() end
Map._warmPairs = nil
Map._worldRoot = nil
Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT")
check(#loads == 0 and Map._warmQueue and #Map._warmQueue == 2, "seamless refresh queues unloaded pairs")
Map.stepWarm()
check(#loads == 1 and loads[1] == "p1", "stepWarm loads one pair per call")
Map.stepWarm()
check(#loads == 2 and loads[2] == "p2" and Map._warmQueue == nil, "queue drains")
Map._warmPairs = true
Map._worldRoot = nil
Native._pairs = {}
loads = {}
Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT")
check(#loads == 4 and Map._warmQueue == nil, "non-seamless load still warms synchronously")
package.loaded["src.core.game3.tileset_native"] = nil
end
print(failures == 0 and "PASS game3_overworld_perf_caches_test"
or ("FAIL game3_overworld_perf_caches_test failures=" .. failures))
os.exit(failures == 0 and 0 or 1)
+296
View File
@@ -0,0 +1,296 @@
-- tests/game3_render_caches_test.lua
-- Per-frame render caches in the Gen 3 battle system and UI: each one must
-- stay correct when what it caches changes.
-- * anim_vm: the anim bg and sprites share one blend shader; a sprite drawn
-- after the bg must re-send its own tint (the uniform cache used to miss
-- the bg's send, so the sprite drew with the bg's tint).
-- * anim_vm: band draws inside a frame bracket reuse one sorted list.
-- * pokemon.lua: a missing pic file is probed once, not every frame.
-- * trainer_card RSE: the front pic and badge quads are built once.
-- * rom_text: plain() memoises pure IRs and follows overrides.
-- * bag: listPocket rows are reused until the pocket changes.
package.path = package.path .. ";./?.lua"
local passed, failed = 0, 0
local function test(name, fn)
local ok, err = pcall(fn)
if ok then
passed = passed + 1
print(" PASS: " .. name)
else
failed = failed + 1
print(" FAIL: " .. name .. " -> " .. tostring(err))
end
end
local function eq(a, b, msg)
if a ~= b then
error(string.format("%s: expected %s, got %s", msg or "eq", tostring(b), tostring(a)), 2)
end
end
-- Minimal love.graphics that records shader uniforms and draws.
local counts = { newImageData = 0, newImage = 0, newQuad = 0, draws = 0, fsRead = 0 }
local Image = {}
Image.__index = Image
function Image:getDimensions() return self.w, self.h end
function Image:setFilter() end
function Image:setWrap() end
local Quad = {}
Quad.__index = Quad
function Quad:setViewport(x, y, w, h) self.x, self.y, self.w, self.h = x, y, w, h end
function Quad:getViewport() return self.x, self.y, self.w, self.h end
local shaders = {}
local drawLog = {}
local currentShader = nil
love = {
graphics = {
newImage = function(data)
counts.newImage = counts.newImage + 1
return setmetatable({ w = data and data.w or 64, h = data and data.h or 64 }, Image)
end,
newQuad = function(x, y, w, h)
counts.newQuad = counts.newQuad + 1
return setmetatable({ x = x, y = y, w = w, h = h }, Quad)
end,
newShader = function()
local sh = { uniforms = {}, sends = 0 }
function sh:send(name, v)
self.sends = self.sends + 1
if type(v) == "table" then v = { v[1], v[2], v[3] } end
self.uniforms[name] = v
end
shaders[#shaders + 1] = sh
return sh
end,
setShader = function(sh) currentShader = sh end,
getShader = function() return currentShader end,
draw = function(img)
counts.draws = counts.draws + 1
local sh = currentShader
drawLog[#drawLog + 1] = { img = img, coeff = sh and sh.uniforms.coeff,
target = sh and sh.uniforms.target and table.concat(sh.uniforms.target, ",") }
end,
setColor = function() end,
getColor = function() return 1, 1, 1, 1 end,
setBlendMode = function() end,
rectangle = function() end,
push = function() end,
pop = function() end,
translate = function() end,
scale = function() end,
},
image = {
newImageData = function(w, h)
counts.newImageData = counts.newImageData + 1
return { w = w, h = h }
end,
},
filesystem = {
read = function(path)
counts.fsRead = counts.fsRead + 1
if path:find("trainers/front/", 1, true) then return string.rep("\0", 64 * 64 * 4) end
return nil
end,
},
timer = { getTime = function() return 0 end },
}
-- anim_vm -----------------------------------------------------------------
local AnimVm = require("src.core.game3.battle.anim_vm")
local AnimSprites = require("src.core.game3.battle.anim_sprites")
local AnimTasks = require("src.core.game3.battle.anim_tasks")
local AnimPal = require("src.core.game3.battle.anim_pal")
local function reset_anim()
AnimSprites.reset()
AnimTasks.reset()
for i = #drawLog, 1, -1 do drawLog[i] = nil end
end
test("sprite drawn after a tinted anim bg re-sends its own blend", function()
reset_anim()
local vm = AnimVm.new()
local bgImg = setmetatable({ w = 256, h = 256, name = "bg" }, Image)
local spriteImg = setmetatable({ w = 32, h = 32, name = "sprite" }, Image)
local realBgImage, realBgColors = AnimVm.animBgImage, AnimPal.bgColors
AnimVm.animBgImage = function() return bgImg end
AnimPal.bgColors = function() return nil end
local ok, err = pcall(function()
vm._animBgId = 1
vm._animBgBlend = { coeff = 12, color = 0x001F } -- red, from the bg
local s = AnimSprites._pool[1]
s.active, s.visible, s.image = true, true, spriteImg
s.x, s.y, s.z = 100, 50, 150
s.palBlend = { coeff = 6, color = 0x7C00 } -- blue, the sprite's own
for _ = 1, 3 do vm:draw(0, 999) end
local sprites = 0
for _, d in ipairs(drawLog) do
if d.img == spriteImg then
sprites = sprites + 1
eq(d.coeff, 6, "sprite coeff")
eq(d.target, "0,0,31", "sprite target")
elseif d.img == bgImg then
eq(d.coeff, 12, "bg coeff")
eq(d.target, "31,0,0", "bg target")
end
end
eq(sprites, 3, "sprite draws")
end)
AnimVm.animBgImage, AnimPal.bgColors = realBgImage, realBgColors
vm._animBgId = nil
if not ok then error(err, 0) end
end)
test("band draws in a frame bracket slice one sorted list", function()
reset_anim()
local vm = AnimVm.new()
local imgs = {}
local zs = { 150, 50, 250, 120, 50 }
for i, z in ipairs(zs) do
local s = AnimSprites._pool[i]
imgs[i] = setmetatable({ w = 8, h = 8, id = i }, Image)
s.active, s.visible, s.image, s.x, s.y, s.z = true, true, imgs[i], 0, 0, z
end
local function order(bracket)
for i = #drawLog, 1, -1 do drawLog[i] = nil end
if bracket then vm:beginDrawFrame() end
vm:draw(0, 99)
vm:draw(101, 199)
vm:draw(201, 999)
if bracket then vm:endDrawFrame() end
local out = {}
for _, d in ipairs(drawLog) do out[#out + 1] = d.img.id end
return table.concat(out, ",")
end
local plain = order(false)
-- z ascending, ties by higher pool index first (battle_anim.c:630 order)
eq(plain, "5,2,4,1,3", "unbracketed order")
eq(order(true), plain, "bracketed order")
-- a change between frames is picked up by the next bracket
AnimSprites._pool[3].z = 10
eq(order(true), "3,5,2,4,1", "next frame sees the new z")
-- z 100 / 200 stay outside every band, as before
AnimSprites._pool[1].z = 100
eq(order(true), "3,5,2,4", "z=100 is still not drawn by the bands")
end)
-- pokemon pics --------------------------------------------------------------
local Pokemon = require("src.core.game3.pokemon")
test("missing pic is probed once and re-probed after invalidate", function()
local reads = 0
local savedCache = Pokemon._cache
Pokemon._cache = { read = function() reads = reads + 1 return nil end }
Pokemon._back = {}
Pokemon._front = {}
eq(Pokemon.backPic(25), nil, "missing back pic")
eq(Pokemon.backPic(25), nil, "missing back pic again")
eq(Pokemon.frontPic(25), nil, "missing front pic")
eq(Pokemon.frontPic(25), nil, "missing front pic again")
eq(reads, 2, "one read per missing pic")
Pokemon._front = {}
Pokemon._back = nil
eq(Pokemon.backPic(25), nil, "after reset")
eq(reads, 3, "a cache reset probes again")
Pokemon._cache = savedCache
Pokemon._front = {}
Pokemon._back = nil
end)
test("present pic is still loaded and cached", function()
local reads = 0
local savedCache = Pokemon._cache
Pokemon._cache = { read = function() reads = reads + 1 return string.rep("\0", 64 * 64 * 4) end }
Pokemon._front = {}
local a = Pokemon.frontPic(4)
local b = Pokemon.frontPic(4)
if not a then error("expected a pic entry", 0) end
eq(a, b, "same entry")
eq(reads, 1, "one read")
Pokemon._cache = savedCache
Pokemon._front = {}
end)
-- trainer card (RSE) ----------------------------------------------------------
test("RSE trainer card builds its pic and badge quads once", function()
local realKit = package.loaded["src.ui.game3.rse.scene_kit"]
local badgeImg = setmetatable({ w = 128, h = 16 }, Image)
package.loaded["src.ui.game3.rse.scene_kit"] = {
manifest = function()
return { layers = {}, pics = { male = 71, female = 72 }, picOffset = { 0, 0 },
star = { png = "star" }, badges = { png = "badges" } }
end,
image = function(name) return name == "badges" and badgeImg or nil end,
}
local TrainerCard = require("src.ui.game3.trainer_card")
local realFront = TrainerCard.frontTextsRse
TrainerCard.frontTextsRse = function() return {} end
local ok, err = pcall(function()
TrainerCard.open, TrainerCard._rse, TrainerCard.side, TrainerCard._flip = true, true, "front", nil
TrainerCard._card = { female = false, stars = 0,
badges = { true, false, true, true, false, false, false, true } }
local before = { img = counts.newImageData, quad = counts.newQuad }
for _ = 1, 5 do TrainerCard.draw() end
eq(counts.newImageData - before.img, 1, "pic decoded once")
eq(counts.newQuad - before.quad, 8, "badge quads built once")
TrainerCard._card.female = true
TrainerCard.draw()
TrainerCard.draw()
eq(counts.newImageData - before.img, 2, "the other pic decoded once")
end)
TrainerCard.frontTextsRse = realFront
TrainerCard.open, TrainerCard._rse, TrainerCard._card = false, nil, nil
package.loaded["src.ui.game3.rse.scene_kit"] = realKit
if not ok then error(err, 0) end
end)
-- rom_text ------------------------------------------------------------------
local RomText = require("src.core.game3.rom_text")
local TextIR = require("src.core.game3.scripting.text_ir")
test("plain() follows overrides and ignores cached text for impure IRs", function()
RomText.overrides.TEST_CACHE_KEY = TextIR.fromAscii("HELLO")
eq(RomText.plain("TEST_CACHE_KEY"), "HELLO", "first")
eq(RomText.plain("TEST_CACHE_KEY"), "HELLO", "memoised")
RomText.overrides.TEST_CACHE_KEY = TextIR.fromAscii("WORLD")
eq(RomText.plain("TEST_CACHE_KEY"), "WORLD", "override replaced")
local ir = { { t = "text", s = "A " }, { t = "strvar", n = 1 }, { t = "eos" } }
RomText.overrides.TEST_CACHE_KEY = ir
eq(RomText.plain("TEST_CACHE_KEY", { stringVars = { "X" } }), "A X", "with ctx")
eq(RomText.plain("TEST_CACHE_KEY", { stringVars = { "Y" } }), "A Y", "ctx still honoured")
RomText.overrides.TEST_CACHE_KEY = nil
end)
-- bag rows --------------------------------------------------------------------
local okData = pcall(function()
require("tests.game3_cache").root("items/pack.lua")
end)
local ItemsData = require("src.core.game3.items_data")
local Bag = require("src.core.game3.bag")
local haveItems = okData and pcall(ItemsData.ensureLoaded)
if haveItems then
test("listPocket reuses rows until the pocket changes", function()
local bag = Bag.new and Bag.new() or {}
Bag.add(bag, 13, 3) -- POTION
local a = Bag.listPocket(bag, "ITEMS")
local b = Bag.listPocket(bag, "ITEMS")
eq(a, b, "same rows while unchanged")
eq(#a, 1, "one row")
eq(a[1].qty, 3, "qty")
Bag.add(bag, 13, 2)
local c = Bag.listPocket(bag, "ITEMS")
if c == a then error("rows not rebuilt after add", 0) end
eq(c[1].qty, 5, "qty after add")
eq(a[1].qty, 3, "old rows untouched")
bag.pockets.ITEMS[1].qty = 7 -- direct slot edit (save load, scripts)
eq(Bag.listPocket(bag, "ITEMS")[1].qty, 7, "direct edit seen")
Bag.remove(bag, 13, 7)
eq(#Bag.listPocket(bag, "ITEMS"), 0, "empty after remove")
end)
else
print(" SKIP: listPocket (items pack not in the cache)")
end
print(string.format("game3_render_caches_test: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env luajit
-- Gen 3 Audio.playSe caches the static Source per (id, pan, gain, mono,
-- loop): a repeat SE neither re-bakes nor rebuilds SoundData / Source, the
-- player-slot bookkeeping (_seSources/_seByPlayer/_seMeta) behaves as with a
-- fresh Source, and anything baked into the PCM (pan, volume) still misses.
-- luajit tests/game3_se_source_cache_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("game3 se source cache")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local built, sources = 0, 0
local Source = {}
Source.__index = Source
function Source:play() self.playing = true; self.plays = self.plays + 1 end
function Source:stop() self.playing = false; self.stops = self.stops + 1 end
function Source:isPlaying() return self.playing end
function Source:setVolume(v) self.volume = v end
function Source:getVolume() return self.volume or 1 end
function Source:setLooping(v) self.looping = v end
function Source:clone()
sources = sources + 1
return setmetatable({ sd = self.sd, plays = 0, stops = 0, clone = true },
Source)
end
love.audio = love.audio or {}
love.audio.newSource = function(sd)
sources = sources + 1
return setmetatable({ sd = sd, plays = 0, stops = 0 }, Source)
end
local realNewSoundData = love.sound.newSoundData
love.sound.newSoundData = function(...)
built = built + 1
return realNewSoundData(...)
end
local Player = require("src.core.game3.m4a_player")
local bakes = 0
Player.songInfo = function(_, id)
return { kind = "se", player = (id == 7) and 2 or 1 }
end
Player.start = function() return true end
Player.bakeSlot = function()
bakes = bakes + 1
local L, R = {}, {}
for i = 1, 64 do L[i] = math.sin(i) * 0.5; R[i] = math.cos(i) * 0.5 end
return L, R
end
local Audio = require("src.core.game3.audio")
Audio._ready = true
Audio._pack = {}
Audio._cache = {}
Audio._seRawClear()
local function live(id)
local n = 0
for _, src in ipairs(Audio._seSources) do
local meta = Audio._seMeta[src]
if meta and meta.id == id then n = n + 1 end
end
return n
end
check(Audio.playSe(5), "first play")
local first = Audio._seByPlayer[1]
eq(bakes, 1, "first play bakes")
eq(sources, 1, "first play builds one Source")
check(first and first.playing, "first play is sounding")
check(Audio.playSe(5), "repeat play")
eq(bakes, 1, "repeat does not re-bake")
eq(sources, 1, "repeat reuses the cached Source")
check(Audio._seByPlayer[1] == first, "same Source on its player slot")
check(first.playing and first.plays == 2, "cached Source restarted")
eq(live(5), 1, "one live entry for the SE (the previous play was replaced)")
check(Audio._seMeta[first] and Audio._seMeta[first].id == 5
and Audio._seMeta[first].player == 1, "meta rebuilt for the replay")
-- a different SE on the same player replaces it, then the first comes back
local builtBefore = built
Audio.playSe(6)
check(not first.playing, "player 1 SE replaced stops the cached Source")
check(Audio._seMeta[first] == nil, "replaced Source forgotten")
Audio.playSe(5)
check(Audio._seByPlayer[1] == first and first.playing,
"cached Source reused after being replaced")
eq(built - builtBefore, 1, "only the new SE built SoundData")
-- PCM-baked parameters miss the cache
local n = sources
Audio.playSe(5, { pan = 63 })
eq(sources, n + 1, "a different pan builds its own Source")
Audio.playSe(5, { pan = 63 })
eq(sources, n + 1, "and that one is cached too")
Audio.playSe(5, { volume = 0.5 })
eq(sources, n + 2, "a different volume builds its own Source")
-- stopSe still stops and forgets it
Audio.playSe(5)
local cached = Audio._seByPlayer[1]
Audio.stopSe(5)
check(not cached.playing and Audio._seMeta[cached] == nil
and Audio._seByPlayer[1] == nil, "stopSe stops and forgets a cached Source")
-- a cached Source still tracked live (not replaced) is cloned, not cut off
Audio._seSources[#Audio._seSources + 1] = cached
Audio._seMeta[cached] = { id = 5, player = 3 }
cached.playing = true
Audio.playSe(5)
local now = Audio._seByPlayer[1]
check(now ~= cached and now.clone and now.playing,
"live cached Source is cloned for the new play")
check(cached.playing, "the live one keeps playing")
Audio.stopSe()
-- a re-install / session end drops the cache with the raw memo
Audio._seRawClear()
n = sources
Audio.playSe(5)
eq(sources, n + 1, "cache cleared with the raw memo")
eq(bakes, 3, "and the SE is baked again (5 and 6 before)")
-- non-memoable plays (explicit loop/maxSec) never touch the cache
n = sources
Audio.playSe(5, { maxSec = 1 })
Audio.playSe(5, { maxSec = 1 })
eq(sources, n + 2, "maxSec plays build fresh Sources")
S.finish()