--- Mod interface module -- @author Oskar Eisemuth -- @copyright 2017-2022 -- @module commonapi.mods local mods = { } require "tableutil" local _utils = commonapi.utils local _log = commonapi.utils.logPrep("commonapi2.mods") local _syncdata = commonapi._syncdata local function createOrderedAliasTable() local t = {} local mt = { _keyorder = {}, _aliasTable = {}, __index = function(self, key) local mtl = getmetatable(self) if (mtl._aliasTable[key] ~= nil) then return self[mtl._aliasTable[key]] end return mtl[key] end, _addAlias = function(self, alias, key) local mtl = getmetatable(self) mtl._aliasTable[alias] = key end, _delete = function(self, key) if self[key] == nil then return end self[key] = nil local keyorder = self._keyorder for i, k in ipairs(keyorder) do if k == key then table.remove(keyorder, i) end end end, _getKeyIndex = function(self, key) local keyorder = self._keyorder for i, k in ipairs(keyorder) do if k == key then return i end end return nil end, __newindex = function(self, key, value) rawset(self, key, value) if (value == nil) then return end local keyorder = self._keyorder keyorder[#keyorder+1] = key end, __pairs = function(tbl) local i = 0 local function pairs_iter(self) i = i + 1 local key = self._keyorder[i] if (key) then return key, self[key] end return end return pairs_iter, tbl, nil end } setmetatable(t, mt) return t end local moddb = createOrderedAliasTable() --commonapi.utils.createOrderedTable() local moddbsync = _syncdata.SyncObject:new({ key = "mods_alias", data = moddb, serialize = function(self, data) local storage = {} for k, v in pairs(data) do storage[#storage+1] = table.copy(v) end local mt = getmetatable(data) local aliasTable = table.copy(mt._aliasTable) local newdata = { entries = storage, alias = aliasTable, } return newdata end, unserialize = function(self, indata) local newdata = createOrderedAliasTable() local idx = 1; local entries = indata.entries for i, entry in ipairs(entries) do local modid = entry.id if (modid ~= nil) then entry._idx = idx newdata[modid] = entry idx = idx + 1 end end local mt = getmetatable(newdata) mt._aliasTable = indata.alias return newdata end }) local moddb_notloaded = createOrderedAliasTable() local moddb_missing = createOrderedAliasTable() local atchar = string.byte("@", 1) local steamidchar = string.byte("*", 1) local localmodidchar = string.byte("!", 1) local stageingmodidchar = string.byte("?", 1) local function checkModDepends_getById(modid, steamid) if (modid ~= nil) then if (moddb[modid] ~= nil) then return moddb[modid] end end if (steamid ~= nil) then steamid = ""..steamid.."_1" if (moddb[steamid] ~= nil) then return moddb[steamid] end end return nil end local function checkModDepends_getByIdNotLoaded(modid, steamid) if (modid ~= nil) then if (moddb_notloaded[modid] ~= nil) then return moddb_notloaded[modid] end end if (steamid ~= nil) then steamid = ""..steamid.."_1" if (moddb_notloaded[steamid] ~= nil) then return moddb_notloaded[steamid] end end return nil end function mods._checkModDepends_createLinks(entry) local data = {} if (entry.steamId ~= nil) then data["Workshop"] = "steam://url/CommunityFilePage/"..entry.steamId end if (entry.tfnetId ~= nil) then data["Webdisk"] = "https://www.transportfever.net/filebase/index.php/Entry/"..entry.tfnetId data["TPFMM"] = "tpfmm://download/tpfnet/"..entry.tfnetId end if (entry.url ~= nil) then data["url"] = entry.url end return data end local function checkModDepends_showLinks(entry) if (entry.steamId ~= nil) then print("\tSteam Workshop: steam://url/CommunityFilePage/"..entry.steamId) end if (entry.tfnetId ~= nil) then print("\tWebdisk: https://www.transportfever.net/filebase/index.php/Entry/"..entry.tfnetId) print("\tTPFMM: tpfmm://download/tpfnet/"..entry.tfnetId) end if (entry.url ~= nil) then print("\tURL:"..entry.url) end print("") end local function checkModDepends(modid, verbose) if (moddb[modid] == nil) then return end local data = moddb[modid]["data"] if (data == nil) then return end local modid_idx = moddb[modid]._idx if (moddb[modid].errors == nil) then moddb[modid].errors = {} end local moderrors = moddb[modid].errors if (data.info == nil) then return end local requiredModsAnyLoadOrder = false if (data.info.requiredModsAnyLoadOrder == true) then requiredModsAnyLoadOrder = true end if (data.info.requiredMods ~= nil) then local requiredMods = data.info.requiredMods for m, entry in pairs( requiredMods ) do if (type(entry) ~= "table") then print("ERROR: ("..modid..") has broken requiredMods syntax, please upgrade") break end local othermod = checkModDepends_getById( entry.modId or nil, entry.steamId or nil ) if (othermod ~= nil) then local othermodnameid = "\""..othermod.name.."\", "..othermod.id if (entry.minMinorVersion ~= nil) then local othermod_version = othermod.version[2] or 0 if (othermod_version < entry.minMinorVersion) then if (verbose ~= false) then print("ERROR: ("..modid..") requires at least minorVersion ("..entry.minMinorVersion..") of ("..othermodnameid..")") print("\tConsider updating ".. othermodnameid) checkModDepends_showLinks(entry) end moderrors[#moderrors+1] = { type = "modversion", mod = othermod, data = entry, links = mods._checkModDepends_createLinks(entry) } end end if (requiredModsAnyLoadOrder == false and modid_idx < othermod._idx) then moderrors[#moderrors+1] = { type = "modorder", mod = othermod, data = entry } if (verbose ~= false) then print("ERROR: ("..modid..") should be loaded after ("..othermodnameid..")") print("\tYou need to change the mod load order (via load game menu, clicking on [+])") end end else local othermodnotloaded = checkModDepends_getByIdNotLoaded( entry.modId or nil, entry.steamId or nil ) if (othermodnotloaded ~= nil) then moderrors[#moderrors+1] = { type = "modinactive", data = entry, mod = othermodnotloaded } else moderrors[#moderrors+1] = { type = "modmissing", data = entry, links = mods._checkModDepends_createLinks(entry) } if (verbose ~= false) then print("ERROR: ("..modid..") requires mod ("..(entry.modId or "")..") steamId "..(entry.steamId or "?") ) checkModDepends_showLinks(entry) end end end end end end local function checkModDepends_nosort(modentry) if (modentry == nil) then return end local modid = modentry.id local data = modentry.data if (data == nil) then return end if (modentry.errors == nil) then modentry.errors = {} end local moderrors = modentry.errors if (data.info == nil) then return end if (data.info.requiredMods ~= nil) then local requiredMods = data.info.requiredMods for m, entry in pairs( requiredMods ) do if (type(entry) ~= "table") then print("ERROR: ("..modid..") has broken requiredMods syntax, please upgrade mod") break end local othermod = checkModDepends_getById( entry.modId or nil, entry.steamId or nil ) if (othermod == nil) then othermod = checkModDepends_getByIdNotLoaded( entry.modId or nil, entry.steamId or nil ) end if (othermod ~= nil) then if (entry.minMinorVersion ~= nil) then local othermod_version = othermod.version[2] or 0 if (othermod_version < entry.minMinorVersion) then moderrors[#moderrors+1] = { type = "modversion", mod = othermod, data = entry, links = mods._checkModDepends_createLinks(entry) } end end else moderrors[#moderrors+1] = { type = "modmissing", data = entry, links = mods._checkModDepends_createLinks(entry) } end end end end function mods._checkMods(verbose) for modid, entry in pairs( moddb ) do checkModDepends(modid, verbose) end end function mods._addModToDb(db, modidwithprefix, path, env) local modid = modidwithprefix local firstchar = modidwithprefix:byte(1) local source if (firstchar == steamidchar) then modid = modidwithprefix:sub(2) source = "*" elseif (firstchar == localmodidchar) then modid = modidwithprefix:sub(2) source = "!" elseif (firstchar == stageingmodidchar) then modid = modidwithprefix:sub(2) source = "?" end local entry = mods._createModEntry(modid, path, env) entry._internalmodid = modidwithprefix entry._installsource = source db[modid] = entry db[modid]._idx = db:_getKeyIndex(modid) -- db[modid]._internalmodid = modidwithprefix -- db[modid]._installsource = source local datainfo = entry.data.info if (datainfo.modid ~= nil) then if (modid ~= datainfo.modid) then -- _log("Added alias id", datainfo.modid, modid) db:_addAlias(datainfo.modid, modid) end end if (source == "!") then if (mods._steamCache ~= nil and mods._steamCache[modid] ~= nil) then local steammod = mods._steamCache[modid] db[modid]._updatetime = steammod.timestamp end end end function mods._createModEntry(modid, path, env) local entry = { id = modid, path = path, name = "" } local data = commonapi.utils._getFileDataIfExist(path.."mod.lua", env) if (data == nil) then data = {} else if (commonapi._native ~= nil and commonapi._native.fs ~= nil) then local nativefs = commonapi._native.fs local stat = nativefs.stat(path.."mod.lua"); entry["_updatetime"] = stat.mtime end end if (type(data.info) ~= "table") then data.info = {} end local datainfo = data.info entry["data"] = data entry["name"] = datainfo.name or "" local version = tonumber(string.match(modid, "_(%d+)$") or "1") local minorVersion = tonumber(datainfo.minorVersion or 0) entry.version = { version, minorVersion } return entry end function mods._createModEnvLoadStrings(path, realmodid) --_log("mods._createModEnvLoadStrings("..path..",".. realmodid..")") local lang = "en" if (commonapi._native ~= nil) then lang = commonapi._native.getLanguage() end local env = { data = function() return {} end, ["_"] = function(s) if (translateModStr ~= nil) then return translateModStr(realmodid, lang, s); end return s end } setmetatable(env, {__index=_G}) --[[ if (getTextRes == nil) then getTextRes = function(s) return s end end --]] local oldpackagepath = package.path package.path = path.."res/scripts/?.lua;" ..path.."config/?.lua;"..oldpackagepath local strings = commonapi.utils._getFileDataIfExist(path.."strings.lua", env) if (strings ~= nil) then setStrings(realmodid, strings) end return env end function mods._addModByPathAndId(path, realmodid, isnotloaded) -- local folder, modid = string.match(path, "([^/@]+)/([^/]+)/$") -- if (folder == "mods" or folder == "1066780" or folder == "staging_area") then local oldpackagepath = package.path -- _log("_addModByPathAndId: ", path.." "..realmodid..".", isnotloaded) local env = mods._createModEnvLoadStrings(path, realmodid) if (isnotloaded == true) then --mods._addModToDb(moddb_notloaded, modid, path, env) mods._addModToDb(moddb_notloaded, realmodid, path, env) else --mods._addModToDb(moddb, modid, path, env) mods._addModToDb(moddb, realmodid, path, env) end -- _log("_addModByPathAndId end: ", path.." "..realmodid..".") package.path = oldpackagepath -- end end function mods._isEmpty() if (next(moddb) == nil and next(moddb_notloaded) == nil) then return true else return false end end function mods._addMissingModId(modidwithprefix, maybename) local db = moddb_missing local modid = modidwithprefix local firstchar = modidwithprefix:byte(1) local source if (firstchar == steamidchar) then modid = modidwithprefix:sub(2) source = "*" elseif (firstchar == localmodidchar) then modid = modidwithprefix:sub(2) source = "!" end local entry = { id = modid, _internalmodid = modidwithprefix, _installsource = source } if (maybename ~= nil) then entry.name = maybename end db[modid] = entry db[modid]._idx = db:_getKeyIndex(modid) end function mods._prepareSteamCache() mods._steamCache = nil if (commonapi._native and commonapi._native.dampf) then local steammods = commonapi._native.dampf.getMods() if (steammods == nil) then return end mods._steamCache = {} for idx, steammod in ipairs(steammods) do steammod.idx = idx mods._steamCache[steammod.handle] = steammod end end end function mods._loadByFS() _log("Loading mods by Filesystem") mods._reset() local nativefs = commonapi._native.fs local localmods = _utils.getDirLocalMods() local gamemods = _utils.getDirGameMods() local stageingmods = _utils.getDirStageingMods() if (localmods ~= nil) then localmods = string.gsub(localmods, '\\', '/')..'/' for filename, fflags in nativefs.dir(localmods) do if (fflags == 2) then if (string.match(filename, "_(%d+)$")) then mods._addModByPathAndId(localmods..filename..'/', '!'..filename, true) end end end end if (gamemods ~= nil) then gamemods = string.gsub(gamemods, '\\', '/')..'/' for filename, fflags in nativefs.dir(gamemods) do if (fflags == 2) then if (string.match(filename, "_(%d+)$")) then mods._addModByPathAndId(gamemods..filename..'/', filename, true) end end end end mods._prepareSteamCache() if (mods._steamCache ~= nil ) then for idx, steammod in pairs(mods._steamCache) do local steammodpath = string.gsub(steammod.folder, '\\', '/')..'/' mods._addModByPathAndId(steammodpath, "*"..steammod.handle, true) end end if (stageingmods ~= nil) then stageingmods = string.gsub(stageingmods, '\\', '/')..'/' for filename, fflags in nativefs.dir(stageingmods) do if (fflags == 2) then if (string.match(filename, "_(%d+)$")) then mods._addModByPathAndId(stageingmods..filename..'/', '?'..filename, true) end end end end for modid, entry in pairs( moddb ) do checkModDepends_nosort(entry) end for modid, entry in pairs( moddb_notloaded ) do checkModDepends_nosort(entry) end end function mods._reset() moddb = createOrderedAliasTable() moddb_notloaded = createOrderedAliasTable() moddb_missing = createOrderedAliasTable() end ---- --- Mods Interface -- @section modsinterface --- Get a list of all loaded mods -- @treturn {modentry,...} A list of mod informations function mods.getLoaded() local result = {} for k, v in pairs(moddb) do result[#result+1] = table.copy(v) end return result end --- Get a specific mod by modid -- @tparam string modid Modid -- @treturn modentry A modentry function mods.getById(modid) if (moddb[modid] ~= nil) then return table.copy(moddb[modid]) end return nil end function mods._getByIdAny(modid) if (moddb[modid] ~= nil) then return table.copy(moddb[modid]) end if (moddb_notloaded[modid] ~= nil) then return table.copy(moddb_notloaded[modid]) end return nil end function mods._getLoadedNotLoaded() local result = {} for k, v in pairs(moddb) do result[#result+1] = table.copy(v) end for k, v in pairs(moddb_notloaded) do result[#result+1] = table.copy(v) end return result end function mods._getMissing() local result = {} for k, v in pairs(moddb_missing) do result[#result+1] = table.copy(v) end return result end ---- --- Mods Types -- @section modstypes ---- A mod entry. -- Table of mod information, entry returned in a list from getLoaded -- @see getLoaded -- @table modentry -- @field id modid -- @field path to mod (relative or absolute) -- @field name Name of mod (translated) function mods._shared_store() _log("mods._shared_store"); moddbsync:syncData(moddb) commonapi.repos._findModContentGlue() _log("mods._shared_store: done"); end function mods._shared_get() local newmodb = moddbsync:getData() if (newmodb ~= nil) then moddb = newmodb end end if (CommonAPILuaState ~= true) then mods._shared_get() end return mods