diff --git a/docs/new-features.md b/docs/new-features.md index 6155d80f..2bb38dfc 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -68,19 +68,31 @@ Game Boy equivalent: ## Colors mode -The `2` key (and the Options menu COLORS row) cycles the global shade-remap -display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**: +The `2` key (and the Options menu COLORS row) cycles the display mode +through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**. +The first three are the real colorizations; the rest are DMG-shade novelties: -- **GBC** (default): current SGB / GBC zone palettes. +- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global + red BG palette + one green OBJ palette, every map, no per-map variation + (Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally). + The player/NPCs stay green over the red terrain via the OBP bake + + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`). +- **SGB** (default): the per-map Super Game Boy region palettes + (`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on + real SGB. (This is the mode formerly mislabeled "GBC".) +- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus + per-species mon colors (`data/palettes_gbc.lua`). - **OG**: force the four DMG grays (colorization off). - **OG INV**: inverted DMG grays. -- **GBC INV**: each SGB zone palette with shade order reversed. +- **SGB INV**: each SGB zone palette with shade order reversed. - **CLASSIC**: original Game Boy pea-soup greens (`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`). -The transform is applied centrally in `PaletteFX.sendColors`, so it covers -overworld, menus, battles, and tilt upright billboards. Persisted as -`save.options.colors`. +The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so +it covers overworld, menus, battles, and tilt upright billboards. OG RED's +global BG palette is supplied by `OverworldState:overworldBgColors` (per-map +override in the overworld pass). Persisted as `save.options.colors`; the +`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels. ## GBC FX @@ -132,7 +144,8 @@ migrated once into `options.lua` on load. - Music / SFX volume - Music Filter - OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules) -- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2` - (RED++ uses pokered-gbc SuperPalettes + per-species mon colors) +- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also + hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc + SuperPalettes + per-species mon colors) - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` \ No newline at end of file diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index b98f2590..ef2b9b00 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -144,6 +144,25 @@ function AnimPlayer.new(data) }, AnimPlayer) end +-- Release the tilesheet images and quads this player built. They live in +-- per-instance caches (a fresh AnimPlayer is made per battle), so unlike a +-- shared module cache they are dead the moment the battle ends -- freeing +-- them here instead of waiting on a GC finalizer keeps grinding battles +-- from piling orphaned VRAM up faster than the (Lua-heap-triggered) GC +-- reclaims it. +function AnimPlayer:release() + for _, img in pairs(self.images) do + if img and img.release then pcall(img.release, img) end + end + self.images = {} + for _, byTile in pairs(self.quads) do + for _, q in pairs(byTile) do + if q and q.release then pcall(q.release, q) end + end + end + self.quads = {} +end + function AnimPlayer:warnOnce(key, fmt, ...) if not self.warned[key] then self.warned[key] = true diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e4e3500f..87edf7e8 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -934,6 +934,20 @@ end -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) function BattleState:exit() require("src.core.Sound").stopLoop("Low_Health_Alarm") + -- Free this battle's own GPU objects now rather than waiting on a GC + -- finalizer: the two full-screen wavy-effect canvases (colorMode) and + -- the AnimPlayer's per-instance tilesheet images/quads. The shared + -- module caches (imageCache/imagePadBottom, keyed by path+palette) are + -- reused by the next battle, so they are deliberately left alone -- only + -- the per-instance objects, which are dead once this battle is popped, + -- are released here. + local function rel(o) if o and o.release then pcall(o.release, o) end end + rel(self.bgCanvas); self.bgCanvas = nil + rel(self.waveCanvas); self.waveCanvas = nil + self.colorFxReady = nil + if self.animPlayer and self.animPlayer.release then + self.animPlayer:release() + end end -- An action the battler is locked into (bypasses the menu), or nil. @@ -3551,12 +3565,28 @@ function BattleState:sgbBattlePals() if placeholder or not b then return pals.MEWMON or pals.GREENBAR end return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON end - return { + local out = { [0] = bar(self.player), [1] = bar(self.enemy), [2] = mon(self.player, self.showPlayerBack or self.safari or self.demo), [3] = mon(self.enemy, self.showEnemyTrainer), } + -- OG RED: the Game Boy Color drew the whole battle from one BG palette -- + -- white paper, black ink -- so every zone shares the same background and + -- outline; only the two mid shades differ per element (green HP bar, red + -- mon pic). The bar/base zones otherwise carry the SGB off-white + -- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a + -- true white, which is what drew a white box around each pic on the pink + -- field. Snap every zone's color 0/3 to the global GBC white/black; the + -- mid shades (and the green bar the user prefers) stay untouched. + if PaletteFX.mode == "ogred" then + local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4] + for i = 0, 3 do + local c = out[i] + out[i] = { white, c[2], c[3], black } + end + end + return out end -- the SGB palette covering a screen pixel (BlkPacket_Battle regions) diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index b4644da3..e5628072 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -708,14 +708,27 @@ local function soundData(engine, samples, channels) return result end -local function fillMusic() +-- Amortized queue fill. The queue is deep (MUSIC_BUFFER_COUNT buffers, ~6s) +-- for stall tolerance, but synthesizing all of it at once -- which is what a +-- song change did -- renders ~6 seconds of Game Boy audio in a single frame: +-- that was the map-switch stutter (a new map's theme starts a new song). So +-- 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 and keeps its headroom -- it just gets +-- there gradually instead of all on the frame the song starts. +local MUSIC_FILL_INITIAL = 4 -- buffers rendered when a song first starts +local MUSIC_FILL_PER_CALL = 3 -- buffers rendered per update()/recovery tick + +local function fillMusic(limit) local music = currentMusic if not music or music.engine:finished() then return end + limit = limit or MUSIC_FILL_PER_CALL local free = music.source:getFreeBufferCount() - while free > 0 and not music.engine:finished() do + while free > 0 and limit > 0 and not music.engine:finished() do music.source:queue(soundData( music.engine, MUSIC_BUFFER_SAMPLES, 2)) free = free - 1 + limit = limit - 1 end end @@ -728,7 +741,9 @@ function ChipAudio.playMusic(data, header, allowLoops) if not ok then return nil, source end ChipAudio.stopMusic() currentMusic = { source = source, engine = engine } - fillMusic() + -- only a small starting cushion here; update() ramps the deep queue to full + -- over the next frames so the song-start frame never renders the whole queue + fillMusic(MUSIC_FILL_INITIAL) source:play() return source end @@ -741,7 +756,7 @@ function ChipAudio.ensureMusicPlaying() if not music or music.engine:finished() then return end local ok, playing = pcall(music.source.isPlaying, music.source) if ok and not playing then - fillMusic() + fillMusic(MUSIC_FILL_INITIAL) pcall(music.source.play, music.source) end end diff --git a/src/core/Game.lua b/src/core/Game.lua index 13057e0a..bc07d211 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -194,6 +194,13 @@ function Game:update(dt) -- frame dt (not the fixed logic step) for a smooth ~0.25s glide. require("src.render.Tilt").update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) + -- Steady-state memory backstop: advance the incremental collector one + -- small step every rendered frame. The heavy GPU objects are now freed + -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this + -- only has to keep ordinary Lua-heap garbage (per-frame tables/closures) + -- from drifting upward over a long session, and to spread collection out + -- so the default lazy schedule never batches it into a visible pause. + if collectgarbage then collectgarbage("step", 1) end end -- render.zones' identity default: unhooked, the zone list reaches the blit diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 64d4d560..e9ef4ee9 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -15,11 +15,16 @@ local PaletteFX = {} local shader -- false = unavailable (headless / no shader support) local gbcPack -- false = missing; nil = not loaded yet --- Cycle order matches OptionsMenu / hotkey 2 -PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" } +-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations +-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ = +-- pokered-gbc per-tile), then the DMG-shade novelty modes. +PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" } +-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are +-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC" +-- label was a misnomer -- it never was the real Game Boy Color palette). PaletteFX.MODE_LABELS = { - gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV", - gbc_inv = "GBC INV", classic = "CLASSIC", + ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG", + og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC", } PaletteFX.mode = "gbc" @@ -28,6 +33,20 @@ PaletteFX.CLASSIC = { { 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }, } +-- OG RED: the Game Boy Color boot-ROM auto-palette for Pokemon Red. Pokemon +-- Red ships no CGB code (pokered's wOnCGB is hardwired 0), so on a Game Boy +-- Color the boot ROM colorizes it with ONE global palette pair -- a red +-- background and green objects -- applied to the whole game with no per-map +-- variation (that variety was the Super Game Boy's doing, i.e. SGB mode). +-- Lightest shade first, matching the SGB palette tables. Values verified +-- against hardware captures of Pallet Town and Oak's Lab. +PaletteFX.GBC_BG = { + { 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 }, +} +PaletteFX.GBC_OBJ = { + { 255, 255, 255 }, { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 }, +} + local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 } function PaletteFX.shader() @@ -141,16 +160,18 @@ function PaletteFX.usesGbcPack(mode) return mode == "redpp" end --- Per-object overworld sprite coloring (ColorOverworldSprite) applies in --- plain GBC mode too, not only under the RED++ pack: without it the --- whole-map zone shader paints characters with whatever two mid shades --- the terrain palette defines. RED++ handles sprites through the baked --- usesGbcPack() path in SpriteRenderer; this names the modes where the --- OBP bake plus the post-zone redraw (below) stand in for real OBJ --- palettes over a shader-colorized background. +-- Whether the active mode bakes a per-OBJ palette onto overworld sprites +-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the +-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and +-- NPCs stay green over the red background, exactly like Pokemon Red on a +-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no +-- palette of its own, so the characters tint with the whole-map region +-- palette along with the terrain (the Super Game Boy never colored Pokemon +-- Red's sprites separately -- baking a per-sprite palette there was the +-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through +-- the usesGbcPack() path in SpriteRenderer instead. function PaletteFX.usesSpriteObp(mode) - mode = mode or PaletteFX.mode - return mode == "gbc" + return (mode or PaletteFX.mode) == "ogred" end -- ------- post-zone sprite redraw (GBC mode) @@ -201,7 +222,13 @@ end -- named palette from the active pack (nil on stale builds / missing name). -- RED++ falls back to the ROM pack for names the gbc table omits (rare). +-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette +-- (the hardware had a single BGP for the whole game), so terrain zones, +-- battle HP bars / text, and menu boxes all come out red -- everything a +-- background tile drew. Objects do not come through here (they bake +-- GBC_OBJ green), so this stays a BG-only hook. function PaletteFX.pal(data, name) + if PaletteFX.mode == "ogred" then return PaletteFX.GBC_BG end local p = PaletteFX.pack(data) local c = p and p.palettes[name] if c then return c end @@ -217,6 +244,11 @@ end -- Transformed mon's pic is tinted gray, not the copied species' own -- SGB color). RED++ uses per-species pals from mon_palettes.asm. function PaletteFX.monPal(data, species, transformed) + -- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into + -- the tilemap, colored by BGP), so it wears the global red BG palette, not + -- a per-species one -- matching the hardware capture where both mons are + -- red/pink on the white field. + if PaletteFX.mode == "ogred" then return PaletteFX.GBC_BG end local p = PaletteFX.pack(data) if not p then return nil end if transformed then diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4120efdc..bcb4c253 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -79,6 +79,10 @@ function Renderer:beginWorldPass() local vw, vh = self:worldViewSize() if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw or self.worldCanvas:getHeight() ~= vh then + -- free the old canvas before replacing it: a zoom/tilt tween changes + -- the view size every frame, so without this the superseded canvases + -- pile up in VRAM until a GC finalizer happens to run + if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end self.worldCanvas = love.graphics.newCanvas(vw, vh) self.worldCanvas:setFilter("nearest", "nearest") end @@ -107,6 +111,7 @@ function Renderer:beginUprightPass() local cw, ch = vw + 2 * M, vh + 2 * M if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw or self.uprightCanvas:getHeight() ~= ch then + if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end self.uprightCanvas = love.graphics.newCanvas(cw, ch) self.uprightCanvas:setFilter("nearest", "nearest") end @@ -200,6 +205,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target) -- 2x for extra crispness. if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw or self.tiltCanvas:getHeight() ~= wvh then + if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end self.tiltCanvas = love.graphics.newCanvas(wvw, wvh) self.tiltCanvas:setFilter("linear", "linear") end diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 9a3443c1..3b94a728 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -114,14 +114,12 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) image = getObpImage(self.def.image, colors, group) end elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then - -- plain GBC: the terrain zone shader still runs over the world canvas, - -- so the baked sprite is also queued for a post-zone redraw - -- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top - local colors, group = PaletteFX.spriteObp(self.def, self.seed) - if colors then - image = getObpImage(self.def.image, colors, group) - redraw = true - end + -- OG RED (GBC boot-ROM look): every OBJ wears the one global green + -- object palette. The red BG zone shader still runs over the world + -- canvas, so the baked sprite is queued for a post-zone redraw + -- (PaletteFX.markSpriteRedraw) that restores its green pixels on top. + image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj") + redraw = true end -- single-frame sprites (item balls, fossils...) have one fixed pose; -- still 3-frame sprites turn to face (the nurse at her machine, diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index d2a923a0..8cf5c115 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -90,8 +90,8 @@ function TileRenderer.setSpinning(active) end -- true while the spinner arrow tiles should show the 'blur' graphic; false --- means draw nothing extra (the static mapBatch/ringBatch tile shows --- through, matching the asm's restore-to-original behavior). The 8-tick +-- means draw nothing extra (the static window tile shows through, +-- matching the asm's restore-to-original behavior). The 8-tick -- half-period approximates one GB movement step (2px/frame); this is a -- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not -- a cycle-accurate replication -- the port's tweened scriptMove has no @@ -426,22 +426,24 @@ function TileRenderer.new(map, data) end local def = map.def - local wB, hB = def.width, def.height - -- two batches: the border-block ring around the map, and the map body. - -- Connected-map strips draw body-only on top of this map's ring. - local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16 - self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static") - self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static") - -- animated tiles overdraw the static batches each frame. Entry order - -- decides which one claims a tile listed twice, so the vanilla defaults - -- keep the old water-then-flower-then-spinner precedence. + -- The map body measured in 8px tiles (each block is 4x4 tiles). The tile + -- layer is drawn windowed to the camera (see :ensureWindow) instead of + -- baked into a whole-map SpriteBatch, so a map becoming visible -- a warp, + -- a connection seam -- costs nothing to "build": there is no per-map batch + -- construction that scales with map size, which is what stuttered. + self.bodyTilesW = def.width * 4 + self.bodyTilesH = def.height * 4 + -- Animated tiles overdraw the static window each frame. Only the per-entry + -- render spec (textures/sequence/gate) is kept here; the animated cells are + -- gathered per camera window in :ensureWindow, so nothing here scales with + -- map size either. Entry order decides which entry claims a tile listed + -- twice (the vanilla water-then-flower-then-spinner precedence). local anims, claimedBy = {}, {} local declared = map.tileset.animatedTiles or TileRenderer.defaultAnimatedTiles(map.tileset) for _, spec in ipairs(declared) do local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx) if anim then - anim.cells = {} anims[#anims + 1] = anim for _, tile in ipairs(anim.tiles) do if claimedBy[tile] == nil then claimedBy[tile] = anim end @@ -460,66 +462,9 @@ function TileRenderer.new(map, data) aliasMap[al.block] = cells end end - - for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do - for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do - local inside = bx >= 0 and by >= 0 and bx < wB and by < hB - local batch = inside and self.mapBatch or self.ringBatch - -- beyond-edge ring cells use the same override drawBorderFill does, - -- so the ring and the far background fill agree (OVERWORLD maps - -- whose raw border_block is water still ring with the tree wall) - local blockId = inside and map:blockAt(bx, by) or borderBlockFor(map) - local block = map.tileset.blocks[blockId + 1] - if not block then - -- a tileset without the tree-wall block keeps its own border - blockId = map:blockAt(bx, by) - block = map.tileset.blocks[blockId + 1] - end - local remap = aliasMap and aliasMap[blockId] - for ty = 0, 3 do - for tx = 0, 3 do - local ci = ty * 4 + tx - local tile = block[ci + 1] - if remap and remap[ci] then tile = remap[ci] end - local quad = self.quads[tile] - if quad then - batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8) - end - local anim = claimedBy[tile] - if anim then - local cells = anim.cells - cells[#cells + 1] = { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile } - end - end - end - end - end - - -- animated overdraw batches: the full set (ring + body) for the - -- current map, and a body-only set for connected-map drawing -- - -- a neighbor's water ring must never overdraw this map's tiles. - -- `quadFor`, when given, looks up a per-entry quad (used by toggle - -- entries, whose texture is a full tileset-atlas clone rather than a - -- single-tile image like the hshift/frames variants). - local function animBatches(entries, image, quadFor) - if #entries == 0 then return nil, nil end - local all = love.graphics.newSpriteBatch(image, #entries, "static") - local body - for _, c in ipairs(entries) do - if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end - if c[3] then - body = body or love.graphics.newSpriteBatch(image, #entries, "static") - if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end - end - end - return all, body - end - for _, anim in ipairs(anims) do - anim.batch, anim.bodyBatch = - animBatches(anim.cells, anim.textures[1], anim.quadFor) - anim.cells = nil - end + self.aliasMap = aliasMap self.anims = anims + self.claimedBy = claimedBy -- a repeating 32x32 image of the border block, tiled behind -- everything the 3-block ring doesn't cover (the survey zoom sees @@ -554,8 +499,16 @@ function TileRenderer:drawBorderFill(camX, camY, vw, vh) if not self.borderFill then return end if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end local x, y = math.floor(camX), math.floor(camY) - local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32) - love.graphics.draw(self.borderFill, quad, 0, 0) + -- one reused Quad per renderer, mutated in place: this runs every + -- overworld frame, so allocating a fresh Quad here churned the GC + local q = self.borderQuad + if q then + q:setViewport(x, y, vw, vh, 32, 32) + else + q = love.graphics.newQuad(x, y, vw, vh, 32, 32) + self.borderQuad = q + end + love.graphics.draw(self.borderFill, q, 0, 0) end -- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under @@ -616,19 +569,98 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors) end end --- animated overdraw at the current step; bodyOnly skips the ring --- positions (connected maps draw body-only) -function TileRenderer:drawAnimated(camX, camY, bodyOnly) +-- Window cover for the static tile layer. Refill the reusable window batch +-- (and the per-entry animated batches) only when the camera has scrolled past +-- what they already cover; a small margin keeps small scrolls free. Cost +-- scales with the view, never the map -- crossing a seam or warping in builds +-- nothing. The beyond-body area (what the old 3-block ring drew) is painted +-- by :drawBorderFill, whose world-aligned border-block tiling is identical +-- there, so only body tiles are gathered here. +local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills + +function TileRenderer:ensureWindow(camX, camY, vw, vh) + local W, H = self.bodyTilesW, self.bodyTilesH + vw = vw or W * 8 -- a nil view (headless draw) means the whole body + vh = vh or H * 8 + -- visible body-tile range (8px tiles), clamped to the map body + local tx0 = math.min(W, math.max(0, math.floor(camX / 8))) + local ty0 = math.min(H, math.max(0, math.floor(camY / 8))) + local tx1 = math.max(0, math.min(W, math.floor((camX + vw) / 8) + 1)) + local ty1 = math.max(0, math.min(H, math.floor((camY + vh) / 8) + 1)) + local win = self.win + if win and tx0 >= win.tx0 and ty0 >= win.ty0 + and tx1 <= win.tx1 and ty1 <= win.ty1 then + return -- still inside the last fill + end + -- refill with margin so the next few scrolled pixels stay covered + tx0 = math.max(0, tx0 - WINDOW_MARGIN) + ty0 = math.max(0, ty0 - WINDOW_MARGIN) + tx1 = math.min(W, tx1 + WINDOW_MARGIN) + ty1 = math.min(H, ty1 + WINDOW_MARGIN) + if not self.winBatch then + self.winBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic") + end + self.winBatch:clear() + local anims = self.anims + for _, anim in ipairs(anims) do + if not anim.batch then + anim.batch = love.graphics.newSpriteBatch(anim.textures[1], 256, "dynamic") + end + anim.batch:clear() + end + local map, quads = self.map, self.quads + local claimedBy, aliasMap = self.claimedBy, self.aliasMap + for ty = ty0, ty1 - 1 do + local by = math.floor(ty / 4) + local ty4 = ty % 4 + for tx = tx0, tx1 - 1 do + local blockId = map:blockAt(math.floor(tx / 4), by) + local block = map.tileset.blocks[blockId + 1] + if block then + local ci = ty4 * 4 + (tx % 4) + local tile = block[ci + 1] + local remap = aliasMap and aliasMap[blockId] + if remap and remap[ci] then tile = remap[ci] end + local wx, wy = tx * 8, ty * 8 + local quad = quads[tile] + if quad then self.winBatch:add(quad, wx, wy) end + local anim = claimedBy[tile] + if anim then + if anim.quadFor then + anim.batch:add(anim.quadFor(tile), wx, wy) + else + anim.batch:add(wx, wy) + end + end + end + end + end + self.win = { tx0 = tx0, ty0 = ty0, tx1 = tx1, ty1 = ty1 } +end + +-- draw the static tile window, then its animated overdraw, at the camera offset +function TileRenderer:drawWindow(camX, camY, vw, vh) + self:ensureWindow(camX, camY, vw, vh) + if self.winBatch then + love.graphics.draw(self.winBatch, -math.floor(camX), -math.floor(camY)) + end + self:drawAnimated(camX, camY) +end + +-- animated overdraw at the current step, over the static window batch. The +-- cells were gathered for the current camera window by :ensureWindow, so this +-- only ever touches on-screen animated tiles. +function TileRenderer:drawAnimated(camX, camY) local anims = self.anims if not anims then return end local x, y = -math.floor(camX), -math.floor(camY) for _, anim in ipairs(anims) do - local batch = bodyOnly and anim.bodyBatch or anim.batch + local batch = anim.batch if batch then if anim.gate then -- a gated entry has only the two frames the asm has (patch / -- restore-to-static); when the gate is shut draw nothing so the - -- already-static mapBatch/ringBatch tile shows through unchanged + -- already-static window tile shows through unchanged if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end else local step = math.floor(animFrame / anim.period) % #anim.sequence + 1 @@ -649,30 +681,68 @@ function TileRenderer:markTrueColor(camX, camY, blocks) (def.height + 2 * blocks) * 32) end -function TileRenderer:draw(camX, camY) +function TileRenderer:draw(camX, camY, vw, vh) if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end - love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY)) - love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) - self:drawAnimated(camX, camY) + self:drawWindow(camX, camY, vw, vh) end --- body only, for connected-map strips -function TileRenderer:drawMapOnly(camX, camY) +-- body only, for connected-map strips. Identical to :draw now that the +-- border ring is served by :drawBorderFill for the current map too -- the +-- only remaining difference is the trueColor mark extent. +function TileRenderer:drawMapOnly(camX, camY, vw, vh) if self.trueColor then self:markTrueColor(camX, camY, 0) end - love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) - self:drawAnimated(camX, camY, true) + self:drawWindow(camX, camY, vw, vh) end --- rebuild after a block change (Cut trees) +local function safeRelease(o) + if o and o.release then pcall(o.release, o) end +end + +-- Release only the GPU objects this instance built and uniquely owns: the +-- two SpriteBatches, the border-fill image and its quad, the per-tile +-- quads, and the animated-overdraw batches. Deliberately leaves the +-- tileset atlas (self.image, shared through Assets/imageCache) and the +-- animation textures (shared module caches) alone -- other maps still use +-- them. Used by :rebuild before it swaps in fresh batches, and by +-- :release on eviction. +function TileRenderer:releaseBatches() + safeRelease(self.winBatch); self.winBatch = nil + safeRelease(self.borderFill); self.borderFill = nil + safeRelease(self.borderQuad); self.borderQuad = nil + self.win = nil + if self.quads then + for _, q in pairs(self.quads) do safeRelease(q) end + self.quads = nil + end + if self.anims then + for _, a in ipairs(self.anims) do + safeRelease(a.batch); a.batch = nil + -- a.textures are shared, module-cached: never released here + end + end +end + +-- Full teardown for eviction (MapLoader.evict): the owned batches, plus +-- the RED++ per-map recolored atlas, which -- unlike the plain tileset +-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id). +function TileRenderer:release() + self:releaseBatches() + if self.gbcAtlas and self.image then + local key = self.map.tileset.image .. "#gbc:" .. self.map.id + if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end + safeRelease(self.image) + self.image = nil + self.gbcAtlas = nil + end + self.anims = nil +end + +-- rebuild after a block change (Cut trees, card-key doors). The tile layer +-- is read live from the map on every window fill, so a block swap only needs +-- the cached window dropped -- the next draw re-reads the changed blocks. No +-- SpriteBatch is reconstructed; that is the whole point of the windowed draw. function TileRenderer:rebuild() - local fresh = TileRenderer.new(self.map, self.data) - self.image = fresh.image - self.gbcAtlas = fresh.gbcAtlas - self.quads = fresh.quads - self.ringBatch = fresh.ringBatch - self.mapBatch = fresh.mapBatch - self.anims = fresh.anims - self.borderFill = fresh.borderFill + self.win = nil end -- drop every atlas and every derived animation texture so the next diff --git a/src/world/MapLoader.lua b/src/world/MapLoader.lua index 043e3ca3..0452fec0 100644 --- a/src/world/MapLoader.lua +++ b/src/world/MapLoader.lua @@ -2,6 +2,13 @@ -- data, cached by map id. The cache is keyed so a mod that patches one -- map record after boot (or the dev-mode hot reload) can drop just that -- entry instead of every map's SpriteBatches. +-- +-- The resident set is trimmed LRU (trim) so exploring a large world never +-- holds every visited map's GPU objects at once. Maps are built on demand +-- and cheaply: a map's tile layer draws windowed to the camera (see +-- TileRenderer), so there is no per-map batch to construct up front and thus +-- nothing to stream -- OverworldState:rebuildNeighbors just loads each +-- neighbor directly. local Assets = require("src.render.Assets") local Map = require("src.world.Map") @@ -10,9 +17,20 @@ local TileRenderer = require("src.render.TileRenderer") local MapLoader = {} local cache = {} +-- mapId -> monotonic access stamp, for LRU eviction +local lru = {} +local accessSeq = 0 +-- max map renderers kept resident. Comfortably above the largest +-- current+neighbor set (~15 in dense overworld), so protected maps are +-- never the ones evicted; this only caps the lingering trail behind you. +local RESIDENT_CAP = 32 -function MapLoader.load(data, mapId) - if cache[mapId] then return cache[mapId] end +local function touch(mapId) + accessSeq = accessSeq + 1 + lru[mapId] = accessSeq +end + +local function build(data, mapId) local def = data.maps[mapId] assert(def, "unknown map: " .. tostring(mapId) .. " (not in the maps registry)") @@ -25,26 +43,75 @@ function MapLoader.load(data, mapId) local map = Map.new(def, tilesetDef) map.renderer = TileRenderer.new(map, data) cache[mapId] = map + touch(mapId) return map end +function MapLoader.load(data, mapId) + local m = cache[mapId] + if m then touch(mapId); return m end + return build(data, mapId) +end + -- the live instance for a map id, or nil when it has not been loaded; -- callers that must not build a map (invalidation, tests) use this function MapLoader.cached(mapId) - return cache[mapId] + local m = cache[mapId] + if m then touch(mapId) end + return m +end + +-- evict one resident map, releasing its renderer's GPU objects. Callers +-- must ensure the map is not the current map and not drawn as a connected +-- strip (nothing live may hold its renderer) -- MapLoader.trim guarantees +-- this via its `protected` set. +function MapLoader.evict(mapId) + local m = cache[mapId] + if not m then return false end + cache[mapId] = nil + lru[mapId] = nil + local r = m.renderer + if r and r.release then pcall(r.release, r) end + return true +end + +-- keep the resident renderer set bounded. `protected` (mapId -> true) is +-- never evicted (the current map and everything drawn as a connected +-- strip); the rest is trimmed least-recently-used down to RESIDENT_CAP. +function MapLoader.trim(protected) + local n = 0 + for _ in pairs(cache) do n = n + 1 end + if n <= RESIDENT_CAP then return end + local ids = {} + for id in pairs(cache) do + if not (protected and protected[id]) then ids[#ids + 1] = id end + end + table.sort(ids, function(a, b) return (lru[a] or 0) < (lru[b] or 0) end) + local over = n - RESIDENT_CAP + for _, id in ipairs(ids) do + if over <= 0 then break end + MapLoader.evict(id) + over = over - 1 + end end -- drop one map so the next load re-reads its record and rebuilds its --- renderer. Callers holding the old instance keep it -- OverworldState --- re-points self.map itself (WorldAPI:invalidateMap). +-- renderer. Deliberately does NOT release the old renderer: callers +-- holding the old instance keep drawing it until they re-point themselves +-- (OverworldState re-points self.map / self.neighbors via setMap / +-- rebuildNeighbors), so releasing here would free a batch still in use. +-- The orphaned instance is reclaimed by GC; MapLoader.evict is the path +-- that releases eagerly, and it only runs on maps nothing live holds. function MapLoader.invalidate(mapId) local had = cache[mapId] ~= nil cache[mapId] = nil + lru[mapId] = nil return had end function MapLoader.invalidateAll() cache = {} + lru = {} end -- kept as the pre-v2 name diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 82e6ccda..d2b4f070 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -357,20 +357,32 @@ end -- widened to everything the current view size can show so a full -- zoom-out never runs past the rendered set. Re-run whenever the view -- grows (zoom/resize), not only on setMap. +-- +-- Neighbors are built eagerly here. A TileRenderer is now a light object -- +-- the tile layer draws windowed to the camera, so nothing per-map is +-- constructed up front (see TileRenderer) -- so there is no build cost to +-- amortize and no prefetch race to lose at a seam. That is what the old +-- one-per-frame streaming queue existed to hide, and it is gone. function OverworldState:rebuildNeighbors() local mapId = self.map.id self.neighbors = {} local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS local vw, vh = Game.renderer:worldViewSize() self.neighborViewW, self.neighborViewH = vw, vh + -- resident set the eviction pass must never touch: the current map plus + -- every drawn neighbor + local keep = { [mapId] = true } for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId, hops, math.floor(vw / 2) + 64, math.floor(vh / 2) + 64)) do - table.insert(self.neighbors, - { map = MapLoader.load(Game.data, n.id), - ox = n.ox, oy = n.oy }) + keep[n.id] = true + local m = MapLoader.load(Game.data, n.id) + table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy }) end + -- bound resident memory: drop maps behind us that are neither current nor + -- a drawn neighbor, releasing their window batch / border image / atlas + MapLoader.trim(keep) -- visual-only NPCs on connected maps (survey zoom): same spawn filter -- as a real map entry, but they never join self.entities -- no sight @@ -434,7 +446,9 @@ function OverworldState:paletteNameFor(map) return Runtime.call("map.palette", samePalette, name, map) end --- UI-pass palette (text boxes and menus tint with the current map) +-- UI-pass palette (text boxes and menus tint with the current map). OG RED +-- resolves every name to the one global red BG palette inside PaletteFX.pal, +-- so this needs no mode-specific branch. function OverworldState:sgbPalettes() local PaletteFX = require("src.render.PaletteFX") return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map)) @@ -3346,9 +3360,9 @@ function OverworldState:drawWorld() -- reorder (no draws), so they run once for both paths. local tilt = Tilt.active() self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) - self.map.renderer:draw(cam.x, bgY) + self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do - nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy) + nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -3492,11 +3506,21 @@ function OverworldState:drawWorld() end) -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command -- picks question/happy crops instead - local rect = bubble.bubbles and bubble.bubbles[self.emote.bubble or 1] + 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) - love.graphics.draw(img, love.graphics.newQuad(rect.x, rect.y, - rect.w, rect.h, img:getDimensions()), ex, ey) + -- 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) drawn = true end end diff --git a/tests/drivers/ogred_battle_test.lua b/tests/drivers/ogred_battle_test.lua new file mode 100644 index 00000000..b9fdd019 --- /dev/null +++ b/tests/drivers/ogred_battle_test.lua @@ -0,0 +1,48 @@ +-- Visual test: SQUIRTLE (player) vs BULBASAUR (enemy) in OG RED, to match +-- the Game Boy Color hardware capture. Run with a display: +-- +-- SHOT_DIR=/tmp/ogred POKEPORT_IDENTITY=pokeport-ogred-shot \ +-- POKEPORT_DRIVER=tests/drivers/ogred_battle_test.lua love . +-- +-- Captures ogred_0{1..5}_*.png into SHOT_DIR. OG RED is a global palette: +-- red BG (terrain, mon pics, HUD, text) + green OBJ (overworld characters, +-- battle effects). Battle mon pics are BG tiles, so they come out red/pink +-- on the near-white field -- see PaletteFX.GBC_BG / monPal. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "." + local PaletteFX = require("src.render.PaletteFX") + + -- Set the SAVED option, not just the live mode: Game:applyOptions re-reads + -- save.options.colors, so a bare setMode would get reverted to the default. + game.save.options = game.save.options or {} + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "SQUIRTLE", 5) } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "BULBASAUR", 5) + battle.onFinish = function() end + ow:pushBattle(battle) + + U.wait(220) + U.shot(game, DIR .. "/ogred_01_intro.png") + + for _ = 1, 24 do U.tap(game, "a"); U.wait(6) end + U.shot(game, DIR .. "/ogred_02_menu.png") + + U.tap(game, "a"); U.wait(12) -- FIGHT -> move list + U.shot(game, DIR .. "/ogred_03_moves.png") + + U.tap(game, "down"); U.wait(6) -- TACKLE -> TAIL WHIP + U.tap(game, "a"); U.wait(30) + U.shot(game, DIR .. "/ogred_04_tailwhip.png") + U.wait(40) + U.shot(game, DIR .. "/ogred_05_after.png") + U.wait(4) +end diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 2a7db360..e643af6c 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -40,6 +40,8 @@ stub.graphics = { newSpriteBatch = function(image, size) local batch = { image = image, sprites = {} } function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end + function batch:clear() self.sprites = {} end + function batch:setTexture(tex) self.texture = tex end return batch end, draw = noop, rectangle = noop, setColor = noop, clear = noop, diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index f672469e..4bc65370 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -113,6 +113,7 @@ love.graphics = { newSpriteBatch = function(image, size) local batch = { image = image, sprites = {} } function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end + function batch:clear() self.sprites = {} end function batch:setTexture(tex) self.texture = tex end return batch end, @@ -287,6 +288,8 @@ local renderer = TileRenderer.new(map) check(#renderer.anims == 1, "a declared animatedTiles entry builds one anim") check(renderer.anims[1].period == 12, "the declared period is honored") check(#renderer.anims[1].textures == 3, "the declared frame images load") +-- the camera-window fill gathers the on-screen animated cells into anims[].batch +renderer:draw(0, 0) check(renderer.anims[1].batch ~= nil, "the animated tile collected cells") -- the vanilla water cycle, driven through the same data path: eight @@ -307,6 +310,8 @@ local sea = TileRenderer.new({ check(#sea.anims == 1, "an OVERWORLD tileset animates its water with no record edit") check(#sea.anims[1].textures == 8, "the water entry builds 8 shifted variants") +-- fill the camera window so the water cells are gathered into the batch +sea:draw(0, 0) local seen = {} for step = 1, 8 do sea:drawAnimated(0, 0)