-- 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
