-- Central image cache plus the mod-visible asset search path. Every -- renderer that used to call love.graphics.newImage(path) straight goes -- through Assets.image, so an enabled mod shadows a generated asset with -- its own file without editing a single record, and one flush() drops -- every downstream cache for dev-mode hot reload. -- -- No loader installed means resolve() is the identity. The NX Blue/Yellow -- versioned-cache fallback lives in src/core/NxAssetOverlay.lua (installed -- once at boot on NX only), not here, so this module stays platform-free. local Assets = {} -- resolved path -> love Image local cache = {} -- downstream caches that must empty when the search path changes local invalidators = {} -- optional GPU release hooks for session end (never run on hot reload flush) local releasers = {} -- The loader bridge: overrideOrder() yields mods highest-priority-first -- and derivedPath(rel) yields an existing save/mod-derived//. -- nil until the loader installs one. Assets.loader = nil local GENERATED = "assets/generated/" local function exists(path) local fs = love and love.filesystem if not (fs and fs.getInfo) then return false end return fs.getInfo(path) ~= nil end Assets.exists = exists -- Mod overrides win; on NX, Blue/Yellow then use the prefixed save-dir file; -- otherwise the caller's unprefixed path (Red / mounted overlay). function Assets.resolve(path) if type(path) ~= "string" then return path end if path:sub(1, #GENERATED) ~= GENERATED then return path end local rel = path:sub(#GENERATED + 1) local loader = Assets.loader if loader then for _, mod in ipairs(loader:overrideOrder()) do local candidate = mod.path .. "/overrides/" .. rel if exists(candidate) then return candidate end end local derived = loader:derivedPath(rel) if derived then return derived end end -- NX Blue/Yellow: no rewrite here -- NxAssetOverlay (installed once at -- boot on NX only) covers every loader globally, so this module stays -- the mod-override choke point it always was. return path end function Assets.image(path) local resolved = Assets.resolve(path) local image = cache[resolved] if not image then image = love.graphics.newImage(resolved) cache[resolved] = image end return image end -- pixel-level reads (tile-shift variants, the spinner strip blit) resolve -- the same way but stay uncached: the caller keeps the derived product function Assets.imageData(path) return love.image.newImageData(Assets.resolve(path)) end -- Register a cache invalidator, or { invalidate = fn, release = fn } when a -- module caches LOVE Images/Canvases and can eagerly free them at session end. -- release is optional and is NOT run on flush/invalidate (HotReload safe). -- MapLoader is the canonical split-hook example: invalidateAll clears tables -- without GPU release; releaseAll evicts every resident map renderer. function Assets.register(hooks) if type(hooks) == "function" then invalidators[#invalidators + 1] = hooks return end if hooks.invalidate then invalidators[#invalidators + 1] = hooks.invalidate end if hooks.release then releasers[#releasers + 1] = hooks.release end end -- hot reload's single entry point (20-developer-tooling): drop the central -- cache and fan out to every registered downstream one. A cache whose -- invalidator throws must not strand the ones behind it in the list. function Assets.invalidate() cache = {} for _, fn in ipairs(invalidators) do pcall(fn) end end Assets.flush = Assets.invalidate -- In-process return-to-launcher / editor close: release central Images and -- run release hooks only. Does not call invalidate hooks (MapLoader must -- keep invalidateAll separate from releaseAll). function Assets.releaseSession() for _, img in pairs(cache) do if img and img.release then pcall(img.release, img) end end cache = {} for _, fn in ipairs(releasers) do pcall(fn) end end -- Loader:load hands over the live mod set once the merge is done. Load -- order is priority ascending, so the search walks it backwards: the mod -- that wins the record merge wins the asset lookup too. function Assets.installLoader(loader) if not loader then Assets.loader = nil Assets.invalidate() return end local bridge = {} function bridge:overrideOrder() local order = {} local loaded = loader.loaded or {} for i = #loaded, 1, -1 do order[#order + 1] = { id = loaded[i].manifest.id, path = loaded[i].path } end return order end function bridge:derivedPath(rel) for _, mod in ipairs(self:overrideOrder()) do local candidate = "save/mod-derived/" .. mod.id .. "/" .. rel if exists(candidate) then return candidate end end return nil end Assets.loader = bridge Assets.invalidate() end return Assets