diff --git a/docs/modding.md b/docs/modding.md index 7c724404..f68ce215 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -200,6 +200,42 @@ the adapter's own coverage table: python3 tools/modkit.py gen2check mods/my_mod ``` +## Imported version datasets + +A mod can inspect semantic content from another game the player has already +imported without switching the active game or reaching into engine cache +internals: + +```lua +local gold, reason = mod.datasets:open("gold") +if not gold then + -- reason is "unknown_version" or "not_imported" + return +end + +local chikorita = gold.content.pokemon:get("CHIKORITA") +local normalVsGhost = gold.content.type_chart:get("NORMAL>GHOST") +local spritePath = gold.assets:path(chikorita.spriteFront) + +for id, record in gold.content.pokemon:each() do + -- ids are returned in deterministic lexical order +end +``` + +`view.version` and `view.generation` identify the selected dataset. +`view.content` exposes the same registry names and generation-specific record +shapes as `mod.content`, but only `get`, `has`, and `each`; returned +records are detached copies and cannot mutate either dataset. Each open call +receives an independent facade, so one mod cannot replace another mod view method. Engine-provided +semantic records, such as `NORMAL>GHOST` type matchups, are included. + +Only a cache carrying the current engine completion marker is opened. Missing +and stale imports both return `nil, "not_imported"`; no raw ROM bytes or +generated Lua modules are exposed. `view.assets:path(relative)` and +`view.assets:info(relative)` accept only `assets/generated/...` paths and +keep them under the selected version cache prefix. The API never changes +`mod.game`, the active `Data` table, `GameVersion`, or cache mount state. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and diff --git a/docs/rfcs/0012-imported-dataset-views.md b/docs/rfcs/0012-imported-dataset-views.md new file mode 100644 index 00000000..dc854e31 --- /dev/null +++ b/docs/rfcs/0012-imported-dataset-views.md @@ -0,0 +1,113 @@ +# RFC 0012: Read-only imported dataset views + +## Status + +Proposed. + +## Motivation + +A cross-version content mod can read only the active merged dataset through +`mod.content`. Even when the player has already imported another supported +game, a mod cannot inspect that version's semantic species, moves, items, type +chart, or generated asset namespace. The available alternatives are private: +mutating `CacheFs.prefix`, mounting another cache over the active one, loading +generated Lua directly, or asking for a second raw-ROM import. They compose +poorly with the active game, expose unstable import layout, and make safe +read-only use impossible from the sandbox. + +The immediate example is an optional content pack derived from an already +verified Gold import while Red, Blue, or Yellow remains active. The capability +is generic and useful to randomizers, compatibility inspectors, dex tools, and +other cross-version content mods. + +## Decision and plan extended + +This implements **D-AT-004: optional Kanto+ content consumes an +active-independent semantic dataset view**. The consuming design is tracked in +the Adaptive Trainers implementation plan, +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 8. The delta is a generic, additive public API and contains no trainer +pools, scaling, boss identities, version-mixing rules, or Adaptive Trainers +policy. + +## Exact API delta + +Every sandboxed mod receives: + +```lua +local view, reason = mod.datasets:open("gold") +``` + +A known version with the current completed import marker returns a read-only view: + +```lua +view = { + version = "gold", + generation = 2, + content = { + pokemon = { + get = function(self, id) end, + has = function(self, id) end, + each = function(self) end, + }, + -- every public registry name and alias + }, + assets = { + path = function(self, generatedPath) end, + info = function(self, generatedPath) end, + }, +} +``` + +`get` returns a detached copy or nil. `has` reports semantic presence. +`each` returns ids in lexical order and detached values. The registries use +the selected version's generation routing and the engine's existing +`Schemas`, `Registry`, and `Builtins` normalization, so structured sources +such as type matchups retain the same public ids used by the active +`mod.content` facade. No register, patch, override, or remove verb is exposed. Each call returns an +independent facade over the cached internal dataset, so facade mutation cannot cross +mod boundaries. + +`assets:path` returns the selected cache-prefixed virtual path. +`assets:info` returns sanitized `type` and optional `size` metadata. Both +accept only relative paths below `assets/generated/`, reject control +characters, absolute paths, backslashes, and traversal, and expose no byte +reader. + +An unknown version returns `nil, "unknown_version"`. A missing or stale +completion marker returns `nil, "not_imported"`. Generated tables are parsed +under an empty environment and cached per version; the API never exposes raw +ROM bytes, generated source, host paths, or a cache mount. + +## Migration and compatibility + +Existing mods change nothing. `mod.datasets` is additive and requires no +permission. The service is allocated lazily on the first explicit +`mod.datasets:open` call. A boot with no mods, or with mods that do not call +it, performs no cross-version cache reads. + +Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active +`Data` table, PhysFS mounts, save state, or the selected game's behavior. +Red, Blue, Yellow, Gold, and Silver keep their existing active data paths. + +The completion-marker format moves to the pure `CacheFormat` helper shared by +the importer and dataset service. The literal marker and import readiness +behavior are unchanged. + +## Verification + +- `tests/modkit/cases/dataset_views.lua` loads a sandboxed fixture mod through + the public API and covers Red, Blue, Yellow, and Gold independently. +- The test proves semantic registry normalization, deterministic iteration, + detached records, read-only facades, cross-mod facade isolation, + version-prefixed generated assets, + traversal rejection, stable failure reasons, and stale-marker rejection. +- The same test proves that cross-version reads do not change the active data, + active game, or cache prefix, and that a no-mod boot performs zero + cross-version cache reads. +- The existing importer and engine/modkit suites prove unchanged marker + readiness and no-mod behavior. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/src/import/CacheFormat.lua b/src/import/CacheFormat.lua new file mode 100644 index 00000000..54d3b926 --- /dev/null +++ b/src/import/CacheFormat.lua @@ -0,0 +1,22 @@ +-- Current completion-marker contract for generated ROM caches. +-- Pure and version-aware so read-only consumers can verify an import without +-- loading the launcher or changing CacheFs.prefix. + +local GameVersion = require("src.core.GameVersion") + +local CacheFormat = {} + +CacheFormat.PREFIX = "rom-cache-v10:" + +function CacheFormat.markerFor(version) + local info = GameVersion.VERSIONS[version] + if not info then return nil end + return CacheFormat.PREFIX .. info.sha1 +end + +function CacheFormat.matches(version, marker) + local expected = CacheFormat.markerFor(version) + return expected ~= nil and marker == expected +end + +return CacheFormat diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index aa6ea4cf..9d5d2f39 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1,4 +1,5 @@ local GameVersion = require("src.core.GameVersion") +local CacheFormat = require("src.import.CacheFormat") local GamepadMap = require("src.core.GamepadMap") local Logger = require("src.core.Logger") local Strings = require("src.core.Strings") @@ -41,7 +42,6 @@ end -- the stage, which is what the Yellow markers below already do for #439/#557. -- Reach for a bump when the change spans versions or has no single file to -- point at. -local CACHE_FORMAT = "rom-cache-v10:" -- The completion marker is written under each version's cache prefix -- (red/rom-cache.complete, blue/rom-cache.complete, ...). local MARKER_PATH = "rom-cache.complete" @@ -49,7 +49,7 @@ local MARKER_PATH = "rom-cache.complete" -- The marker a finished import writes for a version: the generation tag plus -- that version's ROM hash, so both a format bump and a swapped ROM invalidate. local function markerFor(version) - return CACHE_FORMAT .. GameVersion.info(version).sha1 + return CacheFormat.markerFor(version) end local COMMUNITY_URL = "https://bois.icu" local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua new file mode 100644 index 00000000..8576df96 --- /dev/null +++ b/src/mods/DatasetViews.lua @@ -0,0 +1,228 @@ +-- Read-only views over verified, version-scoped generated datasets. +-- +-- A view reads semantic registry records from another imported version without +-- changing GameVersion, CacheFs.prefix, the active Data table, or any PhysFS +-- mount. Records are detached copies and generated assets stay under the +-- selected version's virtual cache prefix. + +local CacheFormat = require("src.import.CacheFormat") +local GameVersion = require("src.core.GameVersion") +local Builtins = require("src.mods.Builtins") +local Merge = require("src.mods.Merge") +local Registry = require("src.mods.Registry") +local Schemas = require("src.mods.Schemas") + +local DatasetViews = {} +DatasetViews.__index = DatasetViews + +local GEN1_MODULES = { + constants = "constants", maps = "maps", tilesets = "tilesets", + text = "text", text_pointers = "text_pointers", + trainer_headers = "trainer_headers", font = "font", sprites = "sprites", + pokemon = "pokemon", moves = "moves", items = "items", + type_chart = "type_chart", trainers = "trainers", + encounters = "encounters", field = "field", + battle_anims = "battle_anims", audio = "audio", + palettes = "palettes", icons = "icons", +} + +local GEN2_MODULES = { + pokemon = "pokemon", moves = "moves", items = "items", + type_chart = "type_chart", audio = "audio", font = "font", + gen2Maps = "maps", gen2Tilesets = "tilesets", gen2Text = "text", + gen2Trainers = "trainers", gen2Encounters = "encounters", + gen2Sprites = "sprites", gen2Palettes = "palettes", + gen2Icons = "icons", gen2BattleAnims = "battle_anims", + gen2Constants = "constants", gen2Landmarks = "landmarks", +} + +local function compileTable(source, chunkname) + if type(source) ~= "string" or source:byte(1) == 27 then return nil end + local env = {} + local chunk, err + if setfenv then + chunk, err = loadstring(source, chunkname) + if chunk then setfenv(chunk, env) end + else + chunk, err = load(source, chunkname, "t", env) + end + if not chunk then return nil, err end + local ok, value = pcall(chunk) + if not ok or type(value) ~= "table" then + return nil, ok and "generated module did not return a table" or value + end + return value +end + +local function resolvePath(root, suffix) + local node = root + for key in suffix:gmatch("[^.]+") do + if type(node) ~= "table" then return nil end + node = node[key] + end + return node +end + +local function assetRelative(path) + if type(path) ~= "string" or path == "" then + error("dataset asset path is required", 3) + end + if path:find("\\", 1, true) or path:sub(1, 1) == "/" + or path:find("[%z\1-\31]") then + error("dataset asset path must be a generated relative path", 3) + end + if path:sub(1, 17) ~= "assets/generated/" then + error("dataset assets are limited to assets/generated/", 3) + end + for segment in path:gmatch("[^/]+") do + if segment == "." or segment == ".." then + error("dataset asset path may not traverse directories", 3) + end + end + return path +end + +function DatasetViews.new(fs) + assert(fs and fs.read, "DatasetViews.new requires a readable filesystem") + return setmetatable({ fs = fs, datasets = {} }, DatasetViews) +end + +function DatasetViews:_module(view, root) + local moduleName = view.modules[root] + if not moduleName then return nil end + local cached = view.moduleCache[moduleName] + if cached ~= nil then return cached or nil end + local path = view.prefix .. "data/generated/" .. moduleName .. ".lua" + local source = self.fs.read(path) + local value = compileTable(source, "@" .. path) + view.moduleCache[moduleName] = value or false + return value +end + +function DatasetViews:_data(view) + if view.data then return view.data end + local data = {} + for root in pairs(view.modules) do + local value = self:_module(view, root) + if value ~= nil then data[root] = value end + end + view.data = data + return data +end + +local function registryBase(data, target) + return function() + return resolvePath(data, target) + end +end + +function DatasetViews:_registries(view) + if view.registries then return view.registries end + local data = self:_data(view) + local registries = {} + for name, catalogSpec in pairs(Schemas.REGISTRIES) do + local spec = Schemas.shapeFor(name, catalogSpec, view.generation) + local registry = Registry.new(name, spec) + if spec.target then registry.base = registryBase(data, spec.target) end + registries[name] = registry + end + Builtins.install(registries, data, view.generation) + for _, registry in pairs(registries) do registry:freeze() end + view.registries = registries + return registries +end + +function DatasetViews:_registry(view, name) + local registry = self:_registries(view)[name] + + local function valueAt(id) + local value = registry and registry:get(id) + if value == nil then return nil end + return Merge.deepCopy(value) + end + + return { + get = function(_, id) + if type(id) ~= "string" or id == "" then return nil end + return valueAt(id) + end, + has = function(_, id) + return valueAt(id) ~= nil + end, + each = function() + local ids = {} + if registry then + for id in registry:each() do + if type(id) == "string" then ids[#ids + 1] = id end + end + end + table.sort(ids) + local index = 0 + return function() + index = index + 1 + local id = ids[index] + if id == nil then return nil end + return id, valueAt(id) + end + end, + } +end + +function DatasetViews:_assets(view) + local service = self + local assets = {} + + function assets:path(path) + return view.prefix .. assetRelative(path) + end + + function assets:info(path) + local full = self:path(path) + local info = service.fs.getInfo and service.fs.getInfo(full, "file") + if not info then return nil end + local out = { type = info.type } + if info.size ~= nil then out.size = info.size end + return out + end + + return assets +end + +function DatasetViews:open(version) + if type(version) ~= "string" or not GameVersion.VERSIONS[version] then + return nil, "unknown_version" + end + local internal = self.datasets[version] + if not internal then + local prefix = GameVersion.cachePrefix(version) + local marker = self.fs.read(prefix .. "rom-cache.complete") + if not CacheFormat.matches(version, marker) then + return nil, "not_imported" + end + + local generation = GameVersion.generation(version) + internal = { + version = version, generation = generation, prefix = prefix, + modules = generation == 2 and GEN2_MODULES or GEN1_MODULES, + moduleCache = {}, + } + internal.registries = self:_registries(internal) + self.datasets[version] = internal + end + local view = { + version = version, + generation = internal.generation, + content = {}, + } + for name in pairs(Schemas.REGISTRIES) do + view.content[name] = self:_registry(internal, name) + end + for alias, canonical in pairs(Schemas.ALIASES) do + view.content[alias] = view.content[canonical] + end + view.assets = self:_assets(internal) + + return view +end + +return DatasetViews diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 84b087cd..3dbba98e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -983,6 +983,15 @@ function Loader:_api(mod) -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), content = {}, + datasets = { + open = function(_, version) + if not loader.datasetViews then + local DatasetViews = engineRequire("src.mods.DatasetViews") + loader.datasetViews = DatasetViews.new(loader.fs) + end + return loader.datasetViews:open(version) + end, + }, exports = {}, DELETE = Registry.DELETE, events = { diff --git a/tests/modkit/cases/dataset_views.lua b/tests/modkit/cases/dataset_views.lua new file mode 100644 index 00000000..59edb614 --- /dev/null +++ b/tests/modkit/cases/dataset_views.lua @@ -0,0 +1,251 @@ +-- Cross-version imported datasets through the public sandboxed mod API. +-- The view is semantic and read-only: registry-shaped records in, detached +-- copies out, with generated assets kept inside the selected version namespace. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local GameVersion = require("src.core.GameVersion") +local CacheFs = require("src.import.CacheFs") +local CacheFormat = require("src.import.CacheFormat") + +local function serialized(value) + local function encode(v) + if type(v) == "string" then return string.format("%q", v) end + if type(v) == "number" or type(v) == "boolean" then return tostring(v) end + local keys = {} + for key in pairs(v) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + local out = { "{" } + for _, key in ipairs(keys) do + out[#out + 1] = "[" .. encode(key) .. "]=" .. encode(v[key]) .. "," + end + out[#out + 1] = "}" + return table.concat(out) + end + return "return " .. encode(value) +end + +local files = { + ["mods/dataset_probe/manifest.json"] = [[{ + "id": "dataset_probe", + "name": "Dataset Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/dataset_probe/main.lua"] = [[ +local mod = ... +local out = {} +for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do + local view, reason = mod.datasets:open(version) + if not view then + out[version] = { reason = reason } + else + local registry = view.content.pokemon + local first = registry:get("FIXMON") + first.name = "MUTATED" + local ids = {} + for id in registry:each() do ids[#ids + 1] = id end + local immune = view.content.type_chart:get("NORMAL>GHOST") + local steel = view.content.type_chart:get("STEEL") + out[version] = { + version = view.version, + generation = view.generation, + name = registry:get("FIXMON").name, + has = registry:has("FIXMON"), + ids = ids, + writable = registry.register ~= nil or registry.patch ~= nil + or registry.override ~= nil or registry.remove ~= nil, + sprite = view.assets:path(registry:get("FIXMON").spriteFront), + spriteInfo = view.assets:info(registry:get("FIXMON").spriteFront), + normalGhost = immune and immune.multiplier, + steelCategory = steel and steel.category, + } + end +end +local missing, missingReason = mod.datasets:open("silver") +local unknown, unknownReason = mod.datasets:open("crystal") +out.silver = { present = missing ~= nil, reason = missingReason } +out.crystal = { present = unknown ~= nil, reason = unknownReason } +local gold = mod.datasets:open("gold") +out.assetEscape = pcall(function() gold.assets:path("../save.lua") end) +out.rawAssetRead = gold.assets.read ~= nil +mod.exports.result = out + ]], +} + +local isolationMods = { + ["mods/dataset_mutator/manifest.json"] = [[{ + "id": "dataset_mutator", + "name": "Dataset Mutator", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/dataset_mutator/main.lua"] = [[ +local mod = ... +local view = assert(mod.datasets:open("red")) +view.content.pokemon.get = function() return { name = "POISONED" } end + ]], + ["mods/dataset_observer/manifest.json"] = [[{ + "id": "dataset_observer", + "name": "Dataset Observer", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"], + "dependencies": ["dataset_mutator"] + }]], + ["mods/dataset_observer/main.lua"] = [[ +local mod = ... +local view = assert(mod.datasets:open("red")) +mod.exports.name = view.content.pokemon:get("FIXMON").name + ]], +} + +local labels = { + red = "RED SOURCE", blue = "BLUE SOURCE", + yellow = "YELLOW SOURCE", gold = "GOLD SOURCE", +} + +for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do + local prefix = GameVersion.cachePrefix(version) + files[prefix .. "rom-cache.complete"] = + "rom-cache-v10:" .. GameVersion.info(version).sha1 + files[prefix .. "data/generated/pokemon.lua"] = serialized({ + FIXMON = { + id = "FIXMON", name = labels[version], dex = version == "gold" and 252 or 152, + spriteFront = "assets/generated/battle/front/fixmon.png", + }, + ALPHA = { id = "ALPHA", name = "ALPHA", dex = 1, + spriteFront = "assets/generated/battle/front/alpha.png" }, + }) + files[prefix .. "data/generated/type_chart.lua"] = serialized({ + matchups = { { attacker = "NORMAL", defender = "GHOST", multiplier = 0 } }, + types = version == "gold" and { + STEEL = { name = "STEEL", category = "physical", index = 9 }, + } or {}, + }) + files[prefix .. "assets/generated/battle/front/fixmon.png"] = "png-" .. version +end + +-- An otherwise plausible Silver tree has no completion marker. A view must +-- not execute or expose it. +files[GameVersion.cachePrefix("silver") .. "data/generated/pokemon.lua"] = + serialized({ FIXMON = { id = "FIXMON", name = "UNVERIFIED" } }) + +T.eq(CacheFormat.markerFor("red"), + "rom-cache-v10:" .. GameVersion.info("red").sha1, + "cache marker follows the importer format and version hash") +T.eq(CacheFormat.matches("silver", "rom-cache-v9:stale"), false, + "stale cache formats are rejected") +T.eq(CacheFormat.markerFor("crystal"), nil, + "unknown versions have no valid cache marker") + +local originalVersion = GameVersion.get() +local originalPrefix = CacheFs.prefix +GameVersion.set("red") + +local function isVersionCachePath(path) + for _, version in ipairs(GameVersion.ORDER) do + local prefix = GameVersion.cachePrefix(version) + if path:sub(1, #prefix) == prefix then return true end + end + return false +end + +-- No mod means the new service is completely cold. Count only cross-version +-- cache reads so the loader's ordinary discovery/state reads do not matter. +local vanillaFiles = {} +for path, body in pairs(files) do + if not path:match("^mods/") then + vanillaFiles[path] = body + end +end +local vanillaFs = T.sdk.memfs(vanillaFiles) +local vanillaReads = 0 +local vanillaRead = vanillaFs.read +vanillaFs.read = function(path) + if isVersionCachePath(path) then + vanillaReads = vanillaReads + 1 + end + return vanillaRead(path) +end +local vanillaData = { pokemon = { ACTIVE = { id = "ACTIVE", name = "ACTIVE" } } } +local vanilla = T.sdk.loadNone({ fs = vanillaFs, data = vanillaData }) +T.eq(#vanilla.errors, 0, "no-mod loader remains clean") +T.eq(vanillaReads, 0, "no mod performs no imported-dataset reads") +T.eq(vanillaData.pokemon.ACTIVE.name, "ACTIVE", + "no mod leaves the active dataset unchanged") +vanilla.release() + +local activeData = { pokemon = { ACTIVE = { id = "ACTIVE", name = "ACTIVE" } } } +local run = T.sdk.loadMods({ "mods/dataset_probe" }, { + fs = T.sdk.memfs(files), data = activeData, generation = 1, +}) +T.eq(#run.errors, 0, + "sandboxed dataset probe loads clean: " .. tostring(run.errors[1])) +local out = run.loader.exports.dataset_probe + and run.loader.exports.dataset_probe.result or {} + +for _, version in ipairs({ "red", "blue", "yellow", "gold" }) do + local got = out[version] or {} + T.eq(got.version, version, version .. " view reports its selected version") + T.eq(got.generation, version == "gold" and 2 or 1, + version .. " view reports the selected generation") + T.eq(got.name, labels[version], + version .. " returns its own semantic species record") + T.eq(got.has, true, version .. " registry has() reads the imported view") + T.same(got.ids, { "ALPHA", "FIXMON" }, + version .. " registry each() is deterministic") + T.eq(got.writable, false, version .. " registry exposes no write verbs") + T.eq(got.sprite, + GameVersion.cachePrefix(version) .. "assets/generated/battle/front/fixmon.png", + version .. " asset path stays in its cache namespace") + T.same(got.spriteInfo, { type = "file" }, + version .. " asset info is sanitized metadata") + T.eq(got.normalGhost, 0, + version .. " exposes structured matchup ids semantically") +end +T.eq(out.gold and out.gold.steelCategory, "physical", + "Gold exposes imported Steel as a semantic type record") + +T.same(out.silver, { present = false, reason = "not_imported" }, + "an unverified cache is unavailable") +T.same(out.crystal, { present = false, reason = "unknown_version" }, + "an unknown version fails with a stable reason") +T.eq(out.assetEscape, false, "dataset asset paths reject traversal") +T.eq(out.rawAssetRead, false, "dataset assets expose no raw byte reader") +T.eq(activeData.pokemon.ACTIVE.name, "ACTIVE", + "cross-version reads do not mutate the active game dataset") +T.eq(activeData.pokemon.FIXMON, nil, + "cross-version records are not merged into active game data") +T.eq(GameVersion.get(), "red", "dataset reads do not switch the active game") +T.eq(CacheFs.prefix, originalPrefix, "dataset reads do not change cache prefix state") + +run.release() + +local isolationFiles = {} +for path, body in pairs(files) do + if not path:match("^mods/") then + isolationFiles[path] = body + end +end +for path, body in pairs(isolationMods) do isolationFiles[path] = body end +local isolationRun = T.sdk.loadMods({ + "mods/dataset_mutator", "mods/dataset_observer", +}, { fs = T.sdk.memfs(isolationFiles), + data = { pokemon = {} }, generation = 1 }) +T.eq(#isolationRun.errors, 0, "cross-mod isolation fixture loads cleanly") +T.eq(isolationRun.loader.exports.dataset_observer + and isolationRun.loader.exports.dataset_observer.name, + labels.red, "one mod cannot mutate another mod dataset facade") +isolationRun.release() + +GameVersion.set(originalVersion) +CacheFs.prefix = originalPrefix + +T.finish("dataset_views")