DramaticShapeVoxelMod archival mirror · read-only
lib/TerrainAtlas.lua

TerrainAtlas.lua

lib/TerrainAtlas.lua26.6 KB1006443ebfde69bb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648
-- Voxel world mode: the texture terrain samples.
--
-- Voxel terrain is textured from the tileset atlas, so a map's colors have
-- to live IN that atlas. Two of the three color paths already do:
--
--   RED++      TileRenderer.new bakes a fully recolored per-map atlas
--              (getGbcAtlas) and hands it over as renderer.image -- nothing
--              to do here, true GBC terrain color comes through untouched.
--   trueColor  a mod's full-color atlas is already its own colors.
--
-- The SGB modes are the gap. There the atlas is raw 4-shade grayscale and
-- the color normally arrives as a screen-space shade-remap pass over
-- rectangular zones (PaletteFX) -- which has no meaning once the ground is
-- geometry rather than a rectangle. So bake instead: one atlas copy per
-- (atlas, palette), remapped through the same cutoffs the shader uses.
--
-- A map has exactly one world palette, so this is a handful of 128x48
-- images for a whole session, built once and cached.
--
-- ANIMATED TILES (water, flowers) are the other thing this file owns. The
-- 2D path animates them by OVERDRAWING the animated cells on top of the
-- static tile layer each frame, which a single static mesh has no
-- equivalent of -- the geometry samples one texture and that is that. So
-- animate the texture: rewrite the animated tile's slot in a private copy
-- of the atlas whenever the step advances, and every instance of that tile
-- across the whole mesh moves at once. Which is what the Game Boy does in
-- the first place (home/vcopy.asm rewrites the tile's VRAM bytes); the 2D
-- overdraw is the port's workaround, not the original.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local Assets = require("src.render.Assets")
local TileRenderer = require("src.render.TileRenderer")
local PaletteFX = require("src.render.PaletteFX")

local TerrainAtlas = {}

local cache = {}
local cacheData = {}    -- the pixels behind the atlases we baked ourselves
local animated = {}     -- key -> one map's private, mutable animated atlas
                        -- false = given up on; nil = not built (or retrying)
local attempts = {}     -- key -> consecutive failures, for the retry budget

-- A failure that might not repeat -- a driver refusing one readback, an
-- asset briefly unreadable, a patch that threw once mid-reload -- must not
-- cost the animation for the rest of the session. It used to: the key was
-- condemned to `false` on the first miss and nothing ever rebuilt it, so
-- water stopped moving and stayed stopped until a hot reload.
--
-- Retry a few times, then give up for good so a genuinely broken atlas is
-- not rebuilt on every frame forever.
local MAX_ATTEMPTS = 3

local function attemptFailed(key)
  local n = (attempts[key] or 0) + 1
  attempts[key] = n
  if n >= MAX_ATTEMPTS then return false end   -- condemn it
  return nil                                    -- rebuild next frame
end

local function paletteKey(colors)
  local parts = {}
  for i = 1, 4 do
    local c = colors[i]
    parts[i] = c and (c[1] .. "," .. c[2] .. "," .. c[3]) or "-"
  end
  return table.concat(parts, ";")
end

-- The atlas image `map`'s terrain should sample, given the 4-color world
-- palette it sits under (nil to leave the atlas as-is). Falls back to the
-- renderer's own image whenever a bake is impossible -- headless, or no
-- pixel access -- which just means grayscale terrain rather than no
-- terrain.
-- The static atlas for `map` under `colors`: the answer this file gave
-- before animation existed, and the base every animated frame is patched
-- over. Returns the image and, when we baked it ourselves, its pixels.
local function staticAtlas(map, colors)
  local renderer = map.renderer
  local base = renderer and renderer.image
  if not base then return nil end
  -- already true color: RED++'s baked per-map atlas, or a mod's own art
  if not colors or renderer.gbcAtlas or map.tileset.trueColor then
    return base, false
  end
  if not (love.image and love.image.newImageData) then return base, false end

  local path = map.tileset.image
  local key = path .. "#" .. paletteKey(colors)
  if cache[key] ~= nil then return cache[key] or base, cacheData[key] end

  local data
  local ok, img = pcall(function()
    local src = Assets.imageData(path)
    local w, h = src:getDimensions()
    local out = love.image.newImageData(w, h)
    for y = 0, h - 1 do
      for x = 0, w - 1 do
        local r, g, b, a = src:getPixel(x, y)
        r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors)
        out:setPixel(x, y, r, g, b, a)
      end
    end
    local image = love.graphics.newImage(out)
    image:setFilter("nearest", "nearest")
    data = out
    return image
  end)
  cache[key] = ok and img or false
  cacheData[key] = (ok and data) or false
  return cache[key] or base, cacheData[key]
end

-- ------------------------------------------------------------ animation --

-- The four GB shades as the ORIGINAL art carries them, by the same cutoffs
-- TileRenderer.recolorSample splits on -- so a shade learned here and a
-- shade recolored there are the same shade.
local function shadeOf(r)
  if r > 0.83 then return 1 end
  if r > 0.5 then return 2 end
  if r > 0.17 then return 3 end
  return 4
end

-- How this atlas recolored one tile, learned by reading the tile's slot in
-- the raw art and in the finished atlas side by side: shade -> the colour
-- it became.
--
-- Learned rather than recomputed because the two recolour paths do not
-- share a rule -- SGB bakes one world palette over everything, RED++ picks
-- a palette group per tile GRAPHIC -- and a flower frame arrives as its own
-- little grayscale file that never went through either. Asking "what
-- happened to the tile I am replacing" gets the right answer from both
-- without this file knowing which one ran.
local function learnShades(raw, baked, tile, perRow)
  local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
  local map = {}
  for y = 0, 7 do
    for x = 0, 7 do
      local k = shadeOf(raw:getPixel(sx + x, sy + y))
      if not map[k] then
        local r, g, b, a = baked:getPixel(sx + x, sy + y)
        map[k] = { r, g, b, a }
      end
    end
  end
  return map
end

local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }

-- One animated entry's tile slot, written into `out` at step `step`.
local function patch(out, entry, spec, step)
  local perRow, tile = entry.perRow, spec.tile
  local dx, dy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
  if spec.kind == "hshift" then
    -- the water rotate (the asm's rrca/rlca run): the tile's own pixels,
    -- rolled sideways. Read from the UNANIMATED base, or each step would
    -- compound on the last one's shift.
    local o = spec.offsets[step % #spec.offsets + 1]
    for y = 0, 7 do
      for x = 0, 7 do
        local r, g, b, a = entry.base:getPixel(dx + x, dy + y)
        out:setPixel(dx + (x + o) % 8, dy + y, r, g, b, a)
      end
    end
  elseif spec.kind == "frames" then
    local path = spec.images[spec.sequence[step % #spec.sequence + 1]]
    if not path then return end
    local ok, frame = pcall(Assets.imageData, path)
    if not ok or not frame then return end
    local shades = entry.shades and entry.shades[tile]
    -- a `cut` tile is the flower billboard's slot (Structures'
    -- buildFlowers): only the frame's darkest tones AND what they
    -- enclose stay opaque -- the round-scenery hull's rule, flooding
    -- the frame border through every non-dark pixel so the pale petal
    -- insides survive with the outline. The true background is keyed
    -- to alpha; nothing else samples this slot (the ground under a
    -- flower is synthesized), and the shader's discard is what lets
    -- the billboard's silhouette change per frame under geometry that
    -- never moves.
    local mask = nil
    if entry.cut and entry.cut[tile] then
      local dark, reach, stack = {}, {}, {}
      for y = 0, 7 do
        for x = 0, 7 do
          local r, _, _, a = frame:getPixel(x, y)
          if a > 0 and shadeOf(r) >= 3 then dark[y * 8 + x] = true end
        end
      end
      for i = 0, 7 do
        for _, s in ipairs({ i, 56 + i, i * 8, i * 8 + 7 }) do
          if not dark[s] and not reach[s] then
            reach[s] = true
            stack[#stack + 1] = s
          end
        end
      end
      while #stack > 0 do
        local p = table.remove(stack)
        local px, py = p % 8, math.floor(p / 8)
        for _, d in ipairs(DIRS4) do
          local nx, ny = px + d[1], py + d[2]
          if nx >= 0 and nx < 8 and ny >= 0 and ny < 8 then
            local ni = ny * 8 + nx
            if not dark[ni] and not reach[ni] then
              reach[ni] = true
              stack[#stack + 1] = ni
            end
          end
        end
      end
      mask = {}
      for i = 0, 63 do mask[i] = dark[i] or not reach[i] end
    end
    for y = 0, 7 do
      for x = 0, 7 do
        local r, g, b, a = frame:getPixel(x, y)
        if mask and not mask[y * 8 + x] then
          out:setPixel(dx + x, dy + y, 0, 0, 0, 0)
        else
          local col = shades and shades[shadeOf(r)]
          if col and a > 0 then r, g, b = col[1], col[2], col[3] end
          out:setPixel(dx + x, dy + y, r, g, b, a)
        end
      end
    end
  end
end

-- The animation specs this file can serve: the two that rewrite a tile's
-- pixels. "toggle" (the spinner-puzzle blur) is a whole-atlas swap gated on
-- a gameplay state, and is left to the 2D path -- voxel mode does not draw
-- the spinner rooms' tile layer any differently for it.
local function specsFor(tileset)
  local declared = tileset.animatedTiles
                   or TileRenderer.defaultAnimatedTiles(tileset)
  local out = nil
  for _, spec in ipairs(declared or {}) do
    local usable = spec.tile
      and ((spec.kind == "hshift" and spec.offsets and #spec.offsets > 0)
           or (spec.kind == "frames" and spec.images and spec.sequence
               and #spec.sequence > 0))
    if usable then
      out = out or {}
      out[#out + 1] = spec
    end
  end
  return out
end

-- Pixels back off a texture the engine built on the GPU and kept no copy
-- of. LOVE 11 hands out no ImageData for an Image, so the only route is a
-- round trip: draw it 1:1 into a canvas and read that back.
--
-- This runs inside the world pass, with the pipeline's own canvas bound, so
-- the previous target is captured and put back rather than unbound -- the
-- usual setCanvas() would drop the rest of the frame on the floor. One
-- readback per map, cached with the entry it feeds; the atlas is a couple
-- of hundred pixels square, so the GPU sync costs far less than the mesh
-- build it happens alongside. Every step is guarded: a driver that refuses
-- canvas readback costs the animation and nothing else.
local function readback(image)
  if not (image and love.graphics and love.graphics.newCanvas
          and love.graphics.getCanvas) then
    return nil
  end
  local prev = love.graphics.getCanvas()
  local ok, data = pcall(function()
    local w, h = image:getDimensions()
    -- dpiscale = 1, or this is not a copy. On a highdpi surface (Android,
    -- iOS -- see conf.lua) newCanvas takes the surface's scale by default,
    -- so the atlas would be drawn into a texture 2.75x its size and read
    -- back magnified -- and every tile coordinate below, which counts in
    -- eights from the top-left, would land somewhere between two tiles.
    local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
    love.graphics.setCanvas(canvas)
    love.graphics.clear(0, 0, 0, 0)
    -- straight copy: no blending against the cleared target, no tint from
    -- whatever colour the pass left set, or the atlas comes back wrong
    love.graphics.setBlendMode("replace", "premultiplied")
    love.graphics.setColor(1, 1, 1, 1)
    love.graphics.draw(image, 0, 0)
    love.graphics.setBlendMode("alpha", "alphamultiply")
    -- LOVE refuses newImageData on the currently-active canvas, so the
    -- previous target has to come back BEFORE the read, not just after
    love.graphics.setCanvas(prev)
    local out = canvas:newImageData()
    if canvas.release then canvas:release() end
    return out
  end)
  pcall(love.graphics.setCanvas, prev)
  return ok and data or nil
end

-- RED++'s per-map atlas, rebuilt on the CPU.
--
-- This is the case that has no pixels anywhere: `getGbcAtlas` bakes one
-- ImageData per map, hands the texture to the renderer and drops the
-- pixels on the floor. Without them the animated tiles cannot be patched,
-- which is why water and flowers stood still under RED++ and nowhere else.
--
-- The readback below can recover them from the texture, but it is at the
-- mercy of whether the driver will read a canvas back, and it costs a GPU
-- sync mid-frame. Everything the engine baked FROM is public, so bake it
-- again instead: the raw art, the per-tile palette group, the group's
-- colours, and the same recolorSample cutoffs. Deterministic, no driver
-- involved, and it can be tested without a GPU.
--
-- It does mirror engine logic and could drift from getGbcAtlas if that
-- changes -- the readback stays behind it as the exact-but-fragile route.
local function gbcPixels(map)
  local renderer, tileset = map.renderer, map.tileset
  local data = renderer and renderer.data
  if not (data and tileset and love.image and love.image.newImageData) then
    return nil
  end
  local ok, out = pcall(function()
    local groupColors = PaletteFX.worldGroupColors(data, tileset.id, map.id, nil)
    if not groupColors then return nil end
    local src = Assets.imageData(tileset.image)
    local iw, ih = src:getDimensions()
    local perRow = tileset.tilesPerRow or 16
    local total = (iw / 8) * (ih / 8)
    local dst = love.image.newImageData(iw, ih)

    local function bake(from, to, colors)
      local sxo, syo = (from % perRow) * 8, math.floor(from / perRow) * 8
      local dxo, dyo = (to % perRow) * 8, math.floor(to / perRow) * 8
      for py = 0, 7 do
        for px = 0, 7 do
          local r, g, b, a = src:getPixel(sxo + px, syo + py)
          r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors)
          dst:setPixel(dxo + px, dyo + py, r, g, b, a)
        end
      end
    end

    local tileColors = {}
    for t = 0, total - 1 do
      local colors = tileColors[t]
      if colors == nil then
        local group = PaletteFX.worldGroupAt(tileset.id, map.id, t)
        colors = (group and groupColors[group + 1]) or false
        tileColors[t] = colors
      end
      bake(t, t, colors)
    end
    -- duplicate-tile aliases: the same graphic baked into a spare slot
    -- under a second palette group, so cells drawing the alias colour apart
    for _, al in ipairs(PaletteFX.TILE_ALIASES
                        and PaletteFX.TILE_ALIASES[map.id] or {}) do
      if al.alias < total then
        bake(al.tile, al.alias, groupColors[al.group + 1])
      end
    end
    return dst
  end)
  return ok and out or nil
end

-- The pixels behind the atlas texture the engine is drawing with, for the
-- frames where we did not bake one ourselves (staticAtlas returns `false`
-- for its own bake whenever the palette is absent, RED++ already baked, or
-- the tileset is trueColor).
--
-- TileRenderer.atlasImageData is the engine's own accessor for exactly this
-- and is preferred wherever the build offers it -- but like the sibling
-- clock TileRenderer.animFrame it is an OPTIONAL seam, and a build without
-- it has to cost us the animation, not the whole render pipeline. Reading
-- it unguarded is what took the pass down for the session.
--
-- Without the seam the pixels are still recoverable, by two different
-- routes. An atlas neither we nor RED++ replaced is the tileset art itself,
-- so the art on disk IS what it was built from. RED++'s per-map bake exists
-- only on the GPU -- getGbcAtlas throws its ImageData away once the texture
-- is made -- so that one has to come back off the texture (readback below).
local function rendererPixels(map)
  local renderer = map.renderer
  if not renderer then return nil end
  if TileRenderer.atlasImageData then
    local ok, data = pcall(TileRenderer.atlasImageData, renderer)
    if ok and data then return data end
  end
  if renderer.gbcAtlas then
    return gbcPixels(map) or readback(renderer.image)
  end
  local ok, data = pcall(Assets.imageData, map.tileset.image)
  return ok and data or nil
end

-- The engine's tile-animation clock: TileRenderer's 60Hz counter, by
-- whatever route this build offers.
--
-- It matters that this is the ENGINE's number and not one of our own. The
-- 2D tile layer and this texture animate the same water off the same
-- counter, so toggling voxel mode mid-cycle continues the animation instead
-- of restarting or jumping it. A clock of our own would free-run against
-- the one the flat path is drawing from.
--
--   1. TileRenderer.animFrame(), where the build exports it.
--   2. else the counter itself, off tick()'s upvalues. It is a plain local
--      in that module, so this is exact and live -- the same number, not an
--      approximation of it. Reading engine internals is what this mod's
--      "engine_internals" permission is declared for, and this one is
--      read-only and entirely optional.
--   3. else wall time in 60Hz steps. Free-running, but the water moves,
--      which beats a frozen pond. Derived from absolute time rather than
--      accumulated deltas because animate() is called once per map in the
--      neighbourhood, so a per-call accumulator would run several times
--      too fast.
local clockUpvalue = nil        -- nil = not looked for yet, false = absent

local function findClockUpvalue()
  if not (debug and debug.getupvalue) then return false end
  if type(TileRenderer.tick) ~= "function" then return false end
  for i = 1, 32 do
    local ok, name, value = pcall(debug.getupvalue, TileRenderer.tick, i)
    if not (ok and name) then break end
    if name == "animFrame" and type(value) == "number" then return i end
  end
  return false
end

local function animFrame()
  if TileRenderer.animFrame then
    local ok, f = pcall(TileRenderer.animFrame)
    if ok and type(f) == "number" then return f end
  end
  if clockUpvalue == nil then clockUpvalue = findClockUpvalue() end
  if clockUpvalue then
    local ok, _, value = pcall(debug.getupvalue, TileRenderer.tick, clockUpvalue)
    if ok and type(value) == "number" then return value end
  end
  if love.timer and love.timer.getTime then
    return math.floor(love.timer.getTime() * 60)
  end
  return 0
end

TerrainAtlas._animFrame = animFrame   -- named for the suite

-- false = this can never work and asking again is waste; nil = it did not
-- work THIS time and might next. The caller latches the first and retries
-- the second (see attemptFailed).
local function newEntry(map, base, baked)
  local tileset = map.tileset
  local specs = specsFor(tileset)
  if not specs then return false end        -- nothing on this tileset animates
  if not (love.image and love.image.newImageData
          and base.replacePixels) then
    return false                            -- no pixel access on this machine
  end
  -- the pixels the atlas texture was built from: our own SGB bake when we
  -- made one, else whatever the engine's renderer is drawing with. A
  -- readback can fail for one frame and work the next, so this is a
  -- retryable miss rather than a verdict.
  local src = baked or rendererPixels(map)
  if not src then return nil end

  local ok, entry = pcall(function()
    local w, h = src:getDimensions()
    -- A PRIVATE copy, always. The base may be the engine's own atlas, and
    -- the 2D path draws the static tile from it and overdraws the animated
    -- cell on top -- rewriting the slot underneath would animate the tile
    -- twice over there and corrupt every still frame of it.
    local data = love.image.newImageData(w, h)
    data:paste(src, 0, 0, 0, 0, w, h)
    local image = love.graphics.newImage(data)
    image:setFilter("nearest", "nearest")
    local e = { base = src, data = data, image = image, specs = specs,
                perRow = tileset.tilesPerRow or 16, step = nil }
    -- the frame files are raw grayscale; learn what the atlas did to the
    -- tile each one stands in for, so they land on the same colours
    local recolored = baked or (map.renderer and map.renderer.gbcAtlas)
    if recolored and not tileset.trueColor then
      local raw = Assets.imageData(tileset.image)
      e.shades = {}
      for _, spec in ipairs(specs) do
        if spec.kind == "frames" then
          e.shades[spec.tile] = learnShades(raw, src, spec.tile, e.perRow)
        end
      end
    end
    -- a frame-animated tile that resolved to the `flower` class stands as
    -- a 1px billboard (Structures.buildFlowers), and its slot in THIS
    -- copy carries only each frame's dark tones with the rest keyed to
    -- alpha -- see patch(). Gated on the resolved shape so a profile that
    -- pins the tile to something solid keeps a fully opaque slot, and on
    -- the tileset art being readable: without pixels Structures cannot
    -- have stood the billboard up (or synthesized the ground), and an
    -- alpha-keyed slot under an ordinary flat quad is a hole in the world.
    for _, spec in ipairs(specs) do
      if spec.kind == "frames" then
        local okCut, isFlower = pcall(function()
          local okA, art = pcall(Assets.imageData, tileset.image)
          if not (okA and art and art.getPixel) then return false end
          local sh = V.require("TileShape").forMap(map)[spec.tile]
          return sh ~= nil and sh.class == "flower"
        end)
        if okCut and isFlower then
          e.cut = e.cut or {}
          e.cut[spec.tile] = true
        end
      end
    end
    return e
  end)
  return (ok and entry) or false
end

-- The atlas for this frame: the static one when nothing on this tileset
-- animates (or anything at all goes wrong), else a private copy with the
-- animated slots rewritten to the current step. Repatched only when the
-- step actually changes -- three times a second, ~130 pixels of work.
function TerrainAtlas.animate(map, colors, base, baked)
  -- RED++ bakes a per-MAP atlas, so its animated copy is per map too and
  -- has to be evicted with the meshes (setLive below); the shared paths key
  -- on the tileset and palette alone, which is bounded by how many of those
  -- exist at all.
  local perMap = map.renderer and map.renderer.gbcAtlas and map.id or nil
  local key = map.tileset.image .. "#a#" .. paletteKey(colors or {})
    .. (perMap or "")
  local entry = animated[key]
  if entry == nil then
    entry = newEntry(map, base, baked)
    if entry then
      entry.mapId = perMap
      animated[key] = entry
    else
      -- false from newEntry is a verdict (nothing animates on this tileset,
      -- no pixel access at all); nil is a miss that may not repeat
      animated[key] = (entry == false) and false or attemptFailed(key)
    end
  end
  if not entry then return nil end

  local frame = animFrame()
  -- one number for the whole entry: every spec's own step, folded together,
  -- so a repatch happens when ANY of them turns over
  local step = 0
  for i, spec in ipairs(entry.specs) do
    local n = spec.kind == "hshift" and #spec.offsets or #spec.sequence
    step = step + (math.floor(frame / (spec.period or 20)) % n) * (16 ^ i)
  end
  if step ~= entry.step then
    entry.step = step
    local ok = pcall(function()
      for _, spec in ipairs(entry.specs) do
        local n = spec.kind == "hshift" and #spec.offsets or #spec.sequence
        patch(entry.data, entry, spec,
              math.floor(frame / (spec.period or 20)) % n)
      end
      entry.image:replacePixels(entry.data)
    end)
    if not ok then
      -- drop the entry rather than condemning the key: the next frame
      -- rebuilds and tries again, and attemptFailed gives up eventually
      animated[key] = attemptFailed(key)
      return nil
    end
  end
  -- Only a frame that got all the way here counts as healthy. Clearing the
  -- budget on a successful BUILD instead would never let it run out: an
  -- entry that builds fine and fails on upload would rebuild every frame,
  -- forever, which is worse than either animating or giving up.
  if attempts[key] then attempts[key] = nil end
  return entry.image
end

function TerrainAtlas.forMap(map, colors)
  local base, baked = staticAtlas(map, colors)
  if not base then return nil end
  return TerrainAtlas.animate(map, colors, base, baked) or base
end

-- The image a character model should texture from under an SGB palette.
--
-- In the 2D SGB modes, sprites are colorized by the screen-space
-- shade-remap shader at blit time -- the sheet itself stays grayscale. The
-- voxel canvas composites 1:1 with no shader pass, so a model textured
-- straight from the sheet renders in raw DMG grays (a black-and-gray
-- character standing in a colored room). Bake instead, exactly like the
-- terrain above: one recolored sheet per (sheet, palette), remapped
-- through the same cutoffs the shader uses, alpha preserved so OBJ color
-- 0 stays transparent. Falls back to nil (caller keeps its texture) when
-- pixels are unreachable.
function TerrainAtlas.forSprite(path, colors)
  if not (colors and love.image and love.image.newImageData) then
    return nil
  end
  local key = "spr:" .. path .. "#" .. paletteKey(colors)
  if cache[key] ~= nil then return cache[key] or nil end

  local ok, img = pcall(function()
    local src = Assets.imageData(path)
    local w, h = src:getDimensions()
    local out = love.image.newImageData(w, h)
    for y = 0, h - 1 do
      for x = 0, w - 1 do
        local r, g, b, a = src:getPixel(x, y)
        r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors)
        out:setPixel(x, y, r, g, b, a)
      end
    end
    local image = love.graphics.newImage(out)
    image:setFilter("nearest", "nearest")
    return image
  end)
  cache[key] = ok and img or false
  return cache[key] or nil
end

-- Release the animated copies of maps outside `live` (a set of map ids),
-- the same neighbourhood ChunkMesher bounds its meshes to and called from
-- the same place. Only the per-map RED++ copies are held this way; the rest
-- are keyed by tileset and palette, of which a session sees a handful.
-- Without this a cross-region trek accumulates one atlas and one texture
-- per map ever entered, and each pins the engine's own baked ImageData
-- alive behind it.
function TerrainAtlas.setLive(live)
  for key, entry in pairs(animated) do
    if entry and entry.mapId and not live[entry.mapId] then
      if entry.image and entry.image.release then
        pcall(entry.image.release, entry.image)
      end
      animated[key] = nil
    end
  end
end

function TerrainAtlas.invalidate()
  cache = {}
  cacheData = {}
  attempts = {}
  for _, entry in pairs(animated) do
    if entry and entry.image and entry.image.release then
      pcall(entry.image.release, entry.image)
    end
  end
  animated = {}
end

Assets.register(TerrainAtlas.invalidate)

return TerrainAtlas