diff --git a/src/core/Data.lua b/src/core/Data.lua index c5232296..a6ec72e8 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -209,18 +209,10 @@ local function loadModule(dir, name) -- cache explicitly when require cannot see the mounted tree. local CacheFs = require("src.import.CacheFs") local GameVersion = require("src.core.GameVersion") - local prefix = GameVersion.cachePrefix() - local path = prefix .. "data/generated/" .. name .. ".lua" - local saved = CacheFs.prefix - CacheFs.prefix = "" - local bytes = CacheFs.read(path) - CacheFs.prefix = saved - if type(bytes) ~= "string" then - -- Also try with CacheFs.prefix if the caller set it for this version. - bytes = CacheFs.read("data/generated/" .. name .. ".lua") - end + local path = "data/generated/" .. name .. ".lua" + local bytes = CacheFs.readActive(path) if type(bytes) == "string" then - local chunk, err = loadstring(bytes, "@" .. path) + local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path) if not chunk then return false, err or mod end return pcall(chunk) end diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 5dc3ec58..1f19dc66 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -294,6 +294,24 @@ function CacheFs.read(rel) return love.filesystem.read(rel) end +-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide +-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load +-- already used: active version prefix with CacheFs.prefix cleared, then +-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil. +function CacheFs.readActive(rel) + local GameVersion = require("src.core.GameVersion") + local prefix = GameVersion.cachePrefix() + local saved = CacheFs.prefix + CacheFs.prefix = "" + local bytes = CacheFs.read(prefix .. rel) + CacheFs.prefix = saved + if type(bytes) ~= "string" then + bytes = CacheFs.read(rel) + end + if type(bytes) == "string" then return bytes end + return nil +end + -- does cache-relative `rel` exist as a file? function CacheFs.exists(rel) rel = withPrefix(rel) diff --git a/src/render/Assets.lua b/src/render/Assets.lua index 5f41af88..79ee9115 100644 --- a/src/render/Assets.lua +++ b/src/render/Assets.lua @@ -43,11 +43,28 @@ function Assets.resolve(path) return loader:derivedPath(rel) or path end +-- When PhysFS hides Blue/Yellow prefixed trees (fused NX), load generated +-- asset bytes the same way Data:load does via CacheFs.readActive. +local function generatedFileData(resolved) + if type(resolved) ~= "string" or resolved:sub(1, #GENERATED) ~= GENERATED then + return nil + end + if exists(resolved) then return nil end + local bytes = require("src.import.CacheFs").readActive(resolved) + if type(bytes) ~= "string" then return nil end + return love.filesystem.newFileData(bytes, resolved) +end + function Assets.image(path) local resolved = Assets.resolve(path) local image = cache[resolved] if not image then - image = love.graphics.newImage(resolved) + local fileData = generatedFileData(resolved) + if fileData then + image = love.graphics.newImage(fileData) + else + image = love.graphics.newImage(resolved) + end cache[resolved] = image end return image @@ -56,7 +73,12 @@ 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)) + local resolved = Assets.resolve(path) + local fileData = generatedFileData(resolved) + if fileData then + return love.image.newImageData(fileData) + end + return love.image.newImageData(resolved) end function Assets.register(invalidate) diff --git a/tests/engine/assets_version_fallback_test.lua b/tests/engine/assets_version_fallback_test.lua new file mode 100644 index 00000000..b0be3f6d --- /dev/null +++ b/tests/engine/assets_version_fallback_test.lua @@ -0,0 +1,90 @@ +-- Yellow/Blue-only NX: PhysFS may hide prefixed assets/generated trees. +-- Assets must fall back through CacheFs.readActive like Data:load does. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local GameVersion = require("src.core.GameVersion") +local CacheFs = require("src.import.CacheFs") +local Assets = require("src.render.Assets") + +local PNG = "assets/generated/tilesets/reds_house.png" +local savedPrefix = CacheFs.prefix +local savedVersion = GameVersion.get() + +local function clearPath(path) + love.filesystem.remove(path) +end + +-- --- readActive: Yellow-prefixed bytes without unprefixed PhysFS visibility +GameVersion.set("yellow") +CacheFs.prefix = "" +love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes") +clearPath(PNG) +eq(CacheFs.readActive(PNG), "yellow-png-bytes", + "readActive finds yellow/ when unprefixed path is missing") + +-- --- Assets.image fallback (Yellow-only mount hole) +Assets.flush() +local img = Assets.image(PNG) +check(img ~= nil, "Assets.image loads Yellow tileset via readActive fallback") +eq(img.path, PNG, "fallback Image keeps the logical generated path name") + +-- --- Assets.imageData fallback +local id = Assets.imageData(PNG) +check(id ~= nil, "Assets.imageData loads Yellow tileset via readActive fallback") +eq(id.path, PNG, "fallback ImageData keeps the logical generated path name") + +-- --- Blue prefix +GameVersion.set("blue") +Assets.flush() +love.filesystem.write("blue/" .. PNG, "blue-png-bytes") +clearPath(PNG) +clearPath("yellow/" .. PNG) +local blueImg = Assets.image(PNG) +check(blueImg ~= nil, "Assets.image loads Blue tileset via readActive fallback") + +-- --- Red primary path (unprefixed file visible → no fallback needed) +GameVersion.set("red") +Assets.flush() +love.filesystem.write(PNG, "red-png-bytes") +clearPath("blue/" .. PNG) +local redImg = Assets.image(PNG) +check(redImg ~= nil, "Assets.image loads Red tileset from unprefixed path") +eq(love.filesystem.read(PNG), "red-png-bytes", + "Red still stores generated assets at the save-dir root") + +-- --- Missing generated file: no invented bytes +GameVersion.set("yellow") +Assets.flush() +clearPath(PNG) +clearPath("yellow/" .. PNG) +clearPath("blue/" .. PNG) +eq(CacheFs.readActive(PNG), nil, + "readActive returns nil when the file is absent in every tree") + +-- --- Non-generated paths never hit the versioned asset tree +love.filesystem.write("yellow/" .. PNG, "should-not-leak") +eq(CacheFs.readActive("assets/launcher/missing_chip.png"), nil, + "readActive does not remap unrelated paths onto yellow generated assets") + +-- --- readActive with CacheFs.prefix set (Data:load second try) +GameVersion.set("yellow") +CacheFs.prefix = "yellow/" +love.filesystem.write("yellow/data/generated/maps.lua", "return { ok = true }") +local luaBytes = CacheFs.readActive("data/generated/maps.lua") +check(type(luaBytes) == "string" and luaBytes:find("ok", 1, true), + "readActive still finds yellow/data/generated when CacheFs.prefix is set") + +CacheFs.prefix = savedPrefix +GameVersion.set(savedVersion) +Assets.flush() +clearPath(PNG) +clearPath("yellow/" .. PNG) +clearPath("blue/" .. PNG) +clearPath("yellow/data/generated/maps.lua") + +T.finish() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 61cfa6d7..7f79ba3a 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -40,8 +40,15 @@ local gstate = { shader = nil, canvas = nil, blend = "alpha", local gstack = {} stub.graphics = { - newImage = function(path) - local w, h = pngSize(path) + newImage = function(pathOrData) + local path = pathOrData + if type(pathOrData) == "table" and pathOrData._fileData then + path = pathOrData.name or "" + elseif type(pathOrData) == "table" and pathOrData.path then + path = pathOrData.path + end + local w, h = 8, 8 + if type(path) == "string" then w, h = pngSize(path) end return setmetatable({ w = w, h = h, path = path }, Image) end, newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, @@ -120,12 +127,22 @@ stub.filesystem = { write = function(name, content) files[name] = content return true end, read = function(name) return files[name] end, remove = function(name) files[name] = nil return true end, + newFileData = function(contents, name) + return { _fileData = true, contents = contents, name = name or "" } + end, + createDirectory = function() return true end, -- directories are implied by key prefixes ("mods/x/manifest.json") - getInfo = function(name) - if files[name] then return { type = "file" } end + getInfo = function(name, filter) + if files[name] then + if filter and filter ~= "file" then return nil end + return { type = "file" } + end local prefix = name .. "/" for key in pairs(files) do - if key:sub(1, #prefix) == prefix then return { type = "directory" } end + if key:sub(1, #prefix) == prefix then + if filter and filter ~= "directory" then return nil end + return { type = "directory" } + end end return nil end, @@ -215,6 +232,30 @@ stub.mouse = { stub.timer = { getTime = function() return 0 end } +-- Minimal image module so Assets.imageData can decode FileData fallbacks +-- headless (full pixel stubs live in tests/mod_graphics_tests.lua). +local ImageData = {} +ImageData.__index = ImageData +function ImageData:getWidth() return self.w end +function ImageData:getHeight() return self.h end +function ImageData:getDimensions() return self.w, self.h end +function ImageData:getPixel() return 0, 0, 0, 1 end +function ImageData:setPixel() end +function ImageData:mapPixel() end +function ImageData:encode() return { getString = function() return "" end } end + +stub.image = { + newImageData = function(a, b) + if type(a) == "table" and a._fileData then + return setmetatable({ w = 8, h = 8, path = a.name, source = a }, ImageData) + end + if type(a) == "string" then + return setmetatable({ w = 8, h = 8, path = a }, ImageData) + end + return setmetatable({ w = a or 8, h = b or 8 }, ImageData) + end, +} + -- Desktop / headless: full-window safe area (matches LÖVE's fallback). stub.window = { getSafeArea = function()