diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..2d4109584 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -100,6 +100,13 @@ ModCompat.ALIASES = { ["src.render.GbcPalette"] = "src.render.GBCFX", } +-- Extend the data vocabulary without changing loader permissions or public +-- module objects. Existing aliases retain their native identities. +local Compatibility = require("src.mods.Gen1Compat") +for name, target in pairs(Compatibility.ALIASES) do + if ModCompat.ALIASES[name] == nil then ModCompat.ALIASES[name] = target end +end + local seen = {} local installed = false @@ -107,6 +114,8 @@ local installed = false -- matters because "the target is missing too" is a build problem, not a mod -- problem, and should not be reported as an unknown module. function ModCompat.resolve(name) + local missing = Compatibility.unavailable(name) + if missing then return nil, missing end local target = ModCompat.ALIASES[name] if not target then return nil, nil end local ok, value = pcall(require, target) @@ -128,7 +137,23 @@ function ModCompat.install() if installed then return false end local loaders = package.loaders or package.searchers if type(loaders) ~= "table" then return false end - loaders[#loaders + 1] = function(name) + local searcher + searcher = function(name) + local missing = Compatibility.unavailable(name) + if missing then + -- A later host/plugin provider still outranks an unsupported descriptor. + local following = false + for _, candidate in ipairs(loaders) do + if candidate == searcher then following = true + elseif following then + local provider, origin = candidate(name) + if type(provider) == "function" then return provider, origin end + end + end + -- Raise before require installs its in-progress sentinel. Retrying the + -- import must retain the diagnostic instead of reporting a fake loop. + error(missing, 0) + end if not ModCompat.ALIASES[name] then -- the standard "not found" contract: a string the require error joins return "\n\tno mod-compat alias for '" .. tostring(name) .. "'" @@ -142,6 +167,7 @@ function ModCompat.install() return value end end + loaders[#loaders + 1] = searcher installed = true return true end diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua new file mode 100644 index 000000000..fd427e7db --- /dev/null +++ b/src/mods/Gen1Compat.lua @@ -0,0 +1,33 @@ +-- Compatibility spellings, kept separate from the loader's execution policy. +-- Only interchangeable native objects belong in ALIASES. Unsupported modules +-- fail at import with an actionable diagnostic instead of a misleading facade. +local C = {} +C.ALIASES = { + ['src.world.gen2.WorldAPI'] = 'src.world.WorldAPI', + ['src.world.gen2.Follower'] = 'src.world.OptionalFollower', +} +C.UNAVAILABLE = { + ['src.import.pmd.Sprite'] = { 'mystery_dungeon_follower' }, + ['src.world.game3.Follower'] = { 'mystery_dungeon_follower' }, + ['src.core.gen2.Unown'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.pokemon'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.player'] = { 'mystery_dungeon_follower' }, +} +-- Concrete host modules and registered providers always take precedence. +function C.hasProvider(name) + if type(name) ~= 'string' then return false end + if type(package.loaded[name]) == 'table' or type(package.preload[name]) == 'function' then return true end + if package.searchpath then + if package.searchpath(name, package.path or '') or package.searchpath(name, package.cpath or '') then return true end + end + local path = name:gsub('%.', '/') .. '.lua' + return love and love.filesystem and love.filesystem.getInfo + and love.filesystem.getInfo(path, 'file') ~= nil or false +end +function C.unavailable(name) + local consumers = C.UNAVAILABLE[name] + if not consumers or C.hasProvider(name) then return nil end + return ('Compatibility module %s is unavailable in this engine; affected mods: %s') + :format(name, table.concat(consumers, ', ')) +end +return C diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua new file mode 100644 index 000000000..583ba2234 --- /dev/null +++ b/tests/engine/compat_module_aliases.lua @@ -0,0 +1,39 @@ +local T=require('tests.harness') +love=require('tests.love_stub') +local C=require('src.core.ModCompat') +C.install() +T.eq(require('src.world.gen2.WorldAPI'),require('src.world.WorldAPI'),'world API alias preserves object identity') +if loadfile('src/world/OptionalFollower.lua') then + T.eq(require('src.world.gen2.Follower'),require('src.world.OptionalFollower'),'Gen2 follower alias preserves object identity') +else + T.raises(function()require('src.world.gen2.Follower')end,'src.world.OptionalFollower','alias identifies an unavailable native follower provider') +end +local names=require('src.mods.Gen1Compat') +for name in pairs(names.UNAVAILABLE)do + if names.hasProvider(name)then + local ok,module=pcall(require,name) + T.check(ok,'concrete host provider loads '..name) + T.eq(type(module),'table','concrete host provider remains a module '..name) + T.eq(names.unavailable(name),nil,'existing provider is not marked unavailable '..name) + T.eq(require(name),module,'existing provider retains require identity '..name) + T.check(names.hasProvider(name),'provider remains available '..name) + else + local ok,reason=pcall(require,name) + T.check(not ok,'unsupported import fails '..name) + T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) + T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) + T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) + local again,detail=pcall(require,name) + T.check(not again and reason==detail,'repeat import retains the exact diagnostic '..name) + end +end + +local providers=package.loaders or package.searchers +local provided={marker='native-late-provider'} +local late=function(name)if name=='src.import.pmd.Sprite'then return function()return provided end end end +providers[#providers+1]=late +T.eq(require('src.import.pmd.Sprite'),provided,'late concrete provider outranks the missing-module descriptor') +T.check(names.hasProvider('src.import.pmd.Sprite'),'loaded provider is recognized') +T.eq(names.unavailable('src.import.pmd.Sprite'),nil,'loaded provider has no missing-module diagnostic') +table.remove(providers);package.loaded['src.import.pmd.Sprite']=nil +T.finish()