fix(mod-api): harden imported dataset views

This commit is contained in:
MaxTomahawk
2026-08-24 09:33:51 +02:00
parent b286e6584f
commit ca700c44d7
20 changed files with 1051 additions and 579 deletions
@@ -0,0 +1,22 @@
-- Dedicated Route B no-mod parity: the additive facade remains cold.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local reads = 0
local fs = T.sdk.memfs({})
local read = fs.read
fs.read = function(path)
if path:match("^[a-z]+/data/generated/")
or path:match("^[a-z]+/rom%-cache%.complete$") then
reads = reads + 1
end
return read(path)
end
local data = { pokemon = { ACTIVE = { id = "ACTIVE", nested = { n = 1 } } } }
local run = T.sdk.loadNone({ fs = fs, data = data, generation = 1 })
T.eq(#run.errors, 0, "no-mod load remains clean")
T.eq(run.loader.datasetViews, nil, "no-mod load does not allocate dataset service")
T.eq(reads, 0, "no-mod load performs no imported dataset reads")
T.eq(data.pokemon.ACTIVE.nested.n, 1, "no-mod data remains unchanged")
run.release()
T.finish("dataset_views_no_mod_parity")
@@ -0,0 +1,37 @@
-- Restricted generated-data grammar and resource limits.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local SaveSerializer = require("src.core.SaveSerializer")
local limits = {
allowArray = true, allowComments = true,
maxBytes = 256, maxDepth = 4, maxNodes = 7,
maxStringBytes = 8, maxTableEntries = 5, rootName = "generated data",
}
local value, err = SaveSerializer.decode(
'-- Generated by tools/build_data.py. DO NOT EDIT.\n'
.. 'return { rows = { "A", "B" }, [3] = true }\n', limits)
T.same(value, { rows = { "A", "B" }, [3] = true },
"LuaWriter array and keyed-table grammar decodes as data")
T.eq(err, nil, "valid generated data has no error")
local invalid = {
{ "return function() end", "function literal" },
{ "owned = true; return {}", "global assignment" },
{ "return {}; print('x')", "trailing executable syntax" },
{ string.char(27) .. "Lua", "binary chunk" },
{ "return " .. string.rep(" ", 260) .. "{}", "source-size limit" },
{ 'return { a = { b = { c = { d = { e = {} } } } } }', "depth limit" },
{ 'return { a = "123456789" }', "string limit" },
{ 'return { 1, 2, 3, 4, 5, 6 }', "table-entry limit" },
{ 'return { a = 1, b = 2, c = 3, d = 4, e = { f = 6, g = 7 } }',
"node limit" },
{ "return 7", "non-table root" },
}
for _, row in ipairs(invalid) do
local got = SaveSerializer.decode(row[1], limits)
T.eq(got, nil, row[2] .. " fails closed")
end
T.finish("generated_data_decoder")
+27 -23
View File
@@ -1,32 +1,36 @@
-- sourceTreeHasData must use each version's required-file list. Gold's
-- cache has no Gen 1 trade art / pikachu.png; validating it against
-- REQUIRED_FILES made a Gold source tree look incomplete forever.
-- Shared cache readiness uses the selected version's source-tree contract.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local CacheContract = require("src.import.CacheContract")
local GameVersion = require("src.core.GameVersion")
local f = assert(io.open("src/import/RomImporter.lua", "r"))
local src = f:read("*a")
f:close()
local present = {}
for _, path in ipairs(CacheContract.requiredFiles("gold")) do
present[GameVersion.cachePrefix("gold") .. path] = true
end
local fs = {
read = function() return nil end,
getInfo = function(path) return present[path] and { type = "file" } or nil end,
getRealDirectory = function(path) return present[path] and "/source" or nil end,
getSource = function() return "/source" end,
}
local start = src:find("local function sourceTreeHasData", 1, true)
check(start ~= nil, "sourceTreeHasData is defined")
local finish = src:find("\nfunction RomImporter.isReady", start, true)
check(finish ~= nil, "sourceTreeHasData ends before isReady")
local body = src:sub(start, finish)
local inspected = CacheContract.inspect("gold", fs, { allowSource = true })
T.eq(inspected and inspected.kind, "source",
"Gold source tree uses Gold's required-file contract")
check(body:find("requiredFilesFor", 1, true) ~= nil,
"sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)")
check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw")
present["gold/assets/generated/battle/hud/balls.png"] = nil
T.eq(CacheContract.inspect("gold", fs, { allowSource = true }), nil,
"missing Gold-only trainer HUD asset makes the source tree incomplete")
local helperStart = src:find("local function requiredFilesFor", 1, true)
check(helperStart ~= nil, "requiredFilesFor helper exists")
local helper = src:sub(helperStart, start)
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
"Gold caches require the trainer HUD ball sheet")
local required = {}
for _, path in ipairs(CacheContract.requiredFiles("gold")) do required[path] = true end
T.eq(required["data/generated/rom_text.lua"], true,
"Gold requires generated ROM text")
T.eq(required["assets/generated/pc/mail_item.png"], true,
"Gold requires PC mail art")
T.eq(required["assets/generated/trade/game_boy.png"], nil,
"Gold does not inherit the Gen 1 trade-art contract")
T.finish()
+7 -11
View File
@@ -83,16 +83,12 @@ if extractor then
"field.lua publishes tradeArt")
end
-- a cache imported before #750 has none of the art; listing one of the
-- files in REQUIRED_FILES is what makes it re-import
local importer = readFile("src/import/RomImporter.lua")
T.check(importer ~= nil, "src/import/RomImporter.lua is readable")
if importer then
local required = importer:match("local REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "REQUIRED_FILES parses")
T.check(required ~= nil and required:find(
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
"REQUIRED_FILES makes pre-#750 caches re-import the trade art")
end
-- a cache imported before #750 has none of the art; the shared readiness
-- contract is what makes it re-import.
local CacheContract = require("src.import.CacheContract")
local required = {}
for _, path in ipairs(CacheContract.requiredFiles("red")) do required[path] = true end
T.eq(required["assets/generated/trade/game_boy.png"], true,
"required-file contract makes pre-#750 caches re-import the trade art")
T.finish("trade art import")
+267 -230
View File
@@ -1,251 +1,288 @@
-- 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.
-- Sandboxed public-API coverage for imported semantic dataset views.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
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 Schemas = require("src.mods.Schemas")
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
local files = {}
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
Fixture.cache(files, version)
end
local probe = Fixture.addMod(files, "dataset_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local view, reason = mod.datasets:open(version)
local mon = view and view.content.pokemon:get("FIXMON")
if mon then mon.name = "MUTATED" end
local ids = {}
if view then
for id, value in view.content.pokemon:each() do
ids[#ids + 1] = id
if value then value.name = "EACH_MUTATED" end
end
end
out[version] = {
reason = reason, generation = view and view.generation,
name = view and view.content.pokemon:get("FIXMON").name,
has = view and view.content.pokemon:has("FIXMON"), ids = ids,
aliasScripts = view and view.content.scripts == view.content.map_scripts,
aliasUi = view and view.content.ui == view.content.screens,
readOnly = view and view.content.pokemon.register == nil
and view.content.pokemon.patch == nil and view.content.pokemon.remove == nil,
}
end
local yellow = assert(mod.datasets:open("yellow"))
out.yellowOldMan = yellow.content.field:get("oldManBattle")
out.yellowBoot = yellow.content.field:get("boot")
local red = assert(mod.datasets:open("red"))
out.redBoot = red.content.field:get("boot")
out.redConstants = red.content.constants:get("dexSize")
local gold = assert(mod.datasets:open("gold"))
out.registryCount = 0
for _, registry in pairs(gold.content) do
if type(registry) == "table" then out.registryCount = out.registryCount + 1 end
end
out.foresight = gold.content.type_chart:get("NORMAL>GHOST")
out.held = gold.content.held_items:get("LEFTOVERS")
out.continuations, out.moves, out.assets = {}, {}, {}
for _, id in ipairs({ "CROBAT", "BELLOSSOM", "POLITOED", "SLOWKING",
"STEELIX", "SCIZOR", "KINGDRA", "PORYGON2", "BLISSEY" }) do
local row = gold.content.pokemon:get(id)
out.continuations[id] = row and row.dex
out.assets[id] = row and {
gold.assets:info(row.spriteFront), gold.assets:info(row.spriteBack) }
end
for _, id in ipairs({ "IRON_TAIL", "METAL_CLAW", "STEEL_WING", "RAIN_DANCE",
"SUNNY_DAY", "SANDSTORM", "SLUDGE_BOMB", "SHADOW_BALL" }) do
out.moves[id] = gold.content.moves:has(id)
end
out.steel = gold.content.type_chart:has("STEEL")
out.executableBuiltinHidden = gold.content.statuses:get("sleep") == nil
out.assetDirectory = gold.assets:info("assets/generated/battle/front")
out.assetRejects = {}
for _, path in ipairs({ "/assets/generated/x", "assets\\generated\\x",
"assets/generated/../x", "assets/generated/./x", "other/x",
"assets/generated/x\1" }) do
out.assetRejects[#out.assetRejects + 1] = pcall(gold.assets.path, gold.assets, path)
end
local unknown, unknownReason = mod.datasets:open("crystal")
out.unknown = { unknown ~= nil, unknownReason }
mod.exports.result = out
]])
-- 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,
local active = { pokemon = { ACTIVE = { id = "ACTIVE", nested = { n = 1 } } } }
local run = T.sdk.loadMods({ probe }, {
fs = T.sdk.memfs(files), data = active, 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")
T.eq(#run.errors, 0, "dataset public probe loads clean")
local out = run.loader.exports.dataset_probe.result
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
T.eq(out[version].reason, nil, version .. " opens")
T.eq(out[version].generation,
(version == "gold" or version == "silver") and 2 or 1,
version .. " reports selected generation")
T.eq(out[version].name, "FIXMON", version .. " get/each values are detached")
T.eq(out[version].has, true, version .. " has reads without exposing a copy")
T.eq(out[version].aliasScripts, true, version .. " scripts alias is canonical")
T.eq(out[version].aliasUi, true, version .. " ui alias is canonical")
T.eq(out[version].readOnly, true, version .. " registry has no write verbs")
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")
T.eq(out.redConstants, 1, "Red derives dexSize through canonical defaults")
T.eq(out.redBoot and out.redBoot.screens and out.redBoot.screens.splash,
"IntroMovie", "Red uses canonical boot defaults")
T.eq(out.yellowOldMan and out.yellowOldMan.species, "RATTATA",
"Yellow applies canonical correction")
T.eq(out.yellowBoot and out.yellowBoot.screens and out.yellowBoot.screens.splash,
"YellowIntro",
"Yellow uses its canonical splash")
T.eq(out.foresight and out.foresight.multiplier, 0,
"Gold appends Foresight matchup rows")
T.eq(out.held and out.held.heldEffect, "HELD_LEFTOVERS", "Gold derives held_items")
for _, id in ipairs(Fixture.CONTINUATIONS) do
T.check(out.continuations[id] ~= nil, "Gold exposes " .. id)
T.same(out.assets[id], { { type = "file" }, { type = "file" } },
"Gold exposes namespaced front/back assets for " .. id)
end
for _, id in ipairs(Fixture.MOVES) do T.eq(out.moves[id], true, "Gold exposes " .. id) end
T.eq(out.steel, true, "Gold exposes Steel")
local registryCount = 0
for _ in pairs(Schemas.REGISTRIES) do registryCount = registryCount + 1 end
for _ in pairs(Schemas.ALIASES) do registryCount = registryCount + 1 end
T.eq(out.registryCount, registryCount,
"view exposes every canonical registry and alias")
T.eq(out.executableBuiltinHidden, true,
"engine records containing closures do not cross the data-only facade")
T.eq(out.assetDirectory, nil, "asset info does not expose directories")
for index, accepted in ipairs(out.assetRejects) do
T.eq(accepted, false, "asset rejection " .. index)
end
T.same(out.unknown, { false, "unknown_version" }, "unknown version fails stably")
T.eq(active.pokemon.ACTIVE.nested.n, 1, "active Data stays unchanged")
T.eq(active.pokemon.FIXMON, nil, "inactive records do not merge into active Data")
T.eq(GameVersion.get(), "red", "active GameVersion stays unchanged")
T.eq(CacheFs.prefix, originalPrefix, "CacheFs prefix stays unchanged")
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")
-- Facade replacement and nested record mutation do not cross mod boundaries.
local isolation = {}
Fixture.cache(isolation, "red")
local mutator = Fixture.addMod(isolation, "dataset_mutator", [[
local mod = ...
local view = assert(mod.datasets:open("red"))
view.content.pokemon.get = function() return { name = "POISONED" } end
local row = view.content.pokemon:get("FIXMON")
if row then row.name = "CHANGED" end
]])
local observer = Fixture.addMod(isolation, "dataset_observer", [[
local mod = ...
local view = assert(mod.datasets:open("red"))
mod.exports.name = view.content.pokemon:get("FIXMON").name
]])
isolation["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"]
}]]
local isolationRun = T.sdk.loadMods({ mutator, observer }, {
fs = T.sdk.memfs(isolation), data = { pokemon = {} }, generation = 1,
})
T.eq(isolationRun.loader.exports.dataset_observer.name, "FIXMON",
"facade and record mutation cannot cross mods")
isolationRun.release()
-- Marker-only, empty, partial, and stale-after-first-open caches fail closed.
local readiness = {}
local emptyPrefix = GameVersion.cachePrefix("red")
readiness[emptyPrefix .. "rom-cache.complete"] =
"rom-cache-v10:" .. GameVersion.info("red").sha1
Fixture.cache(readiness, "blue")
readiness[GameVersion.cachePrefix("blue") .. "data/generated/moves.lua"] = nil
Fixture.cache(readiness, "gold")
local staleReads = 0
local readinessFs = T.sdk.memfs(readiness)
local rawRead = readinessFs.read
readinessFs.read = function(path)
if path == GameVersion.cachePrefix("gold") .. "rom-cache.complete" then
staleReads = staleReads + 1
if staleReads > 1 then return "rom-cache-v9:stale" end
end
return rawRead(path)
end
local readinessMod = Fixture.addMod(readiness, "readiness_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "red", "blue" }) do
local view, reason = mod.datasets:open(version)
out[version] = { view ~= nil, reason }
end
local first, firstReason = mod.datasets:open("gold")
local second, secondReason = mod.datasets:open("gold")
out.gold = { first ~= nil, firstReason, second ~= nil, secondReason }
mod.exports.result = out
]])
local readinessRun = T.sdk.loadMods({ readinessMod }, {
fs = readinessFs, data = { pokemon = {} }, generation = 1,
})
local readyOut = readinessRun.loader.exports.readiness_probe.result
T.same(readyOut.red, { false, "not_imported" }, "marker-only empty cache fails")
T.same(readyOut.blue, { false, "not_imported" }, "partial cache fails")
T.same(readyOut.gold, { true, nil, false, "not_imported" },
"cached view is invalidated when marker becomes stale")
readinessRun.release()
-- Removal followed by a fresh import cannot resurrect the pre-removal view.
local transition = {}
Fixture.cache(transition, "red")
local transitionFs = T.sdk.memfs(transition)
local transitionRead = transitionFs.read
local transitionMarkers = 0
local reimportedPokemon = require("src.import.LuaWriter").encode({
FIXMON = { id = "FIXMON", name = "REIMPORTED", dex = 1,
spriteFront = "assets/generated/battle/front/fixmon.png",
spriteBack = "assets/generated/battle/back/fixmon.png", frontSize = 5 },
})
transitionFs.read = function(path)
if path == GameVersion.cachePrefix("red") .. "rom-cache.complete" then
transitionMarkers = transitionMarkers + 1
if transitionMarkers == 2 then return nil end
end
local body = transitionRead(path)
if transitionMarkers >= 3
and path == GameVersion.cachePrefix("red") .. "data/generated/pokemon.lua" then
return reimportedPokemon
end
return body
end
local transitionMod = Fixture.addMod(transition, "transition_probe", [[
local mod = ...
local out = {}
for index = 1, 3 do
local view, reason = mod.datasets:open("red")
out[index] = { view and view.content.pokemon:get("FIXMON").name, reason }
end
mod.exports.result = out
]])
local transitionRun = T.sdk.loadMods({ transitionMod }, {
fs = transitionFs, data = { pokemon = {} }, generation = 1,
})
T.same(transitionRun.loader.exports.transition_probe.result, {
{ "FIXMON" }, { nil, "not_imported" }, { "REIMPORTED" },
}, "remove and reimport transition rebuilds the semantic view")
transitionRun.release()
-- Every hostile source is rejected as data, with one stable public reason.
local hostile = {
{ "red", "return { BAD = function() return 1 end }" },
{ "blue", "owned = true; return {}" },
{ "yellow", "return 7" },
{ "gold", string.char(27) .. "Lua" },
{ "silver", "return {}; while true do end" },
}
local hostileFiles, hostilePaths = {}, {}
for _, row in ipairs(hostile) do
Fixture.cache(hostileFiles, row[1], { pokemon = row[2] })
end
local hostileMod = Fixture.addMod(hostileFiles, "hostile_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local view, reason = mod.datasets:open(version)
out[version] = { view ~= nil, reason }
end
mod.exports.result = out
]])
hostilePaths[1] = hostileMod
local previousHook, previousMask, previousCount
local beforeHook
if debug.gethook and debug.sethook then
previousHook, previousMask, previousCount = debug.gethook()
beforeHook = function() end
debug.sethook(beforeHook, "", 1000)
end
local hostileRun = T.sdk.loadMods(hostilePaths, {
fs = T.sdk.memfs(hostileFiles), data = { pokemon = {} }, generation = 1,
})
local hostileOut = hostileRun.loader.exports.hostile_probe.result
for _, row in ipairs(hostile) do
T.same(hostileOut[row[1]], { false, "invalid_cache" },
row[1] .. " hostile generated source fails closed")
end
T.eq(debug.gethook and debug.gethook(), beforeHook,
"dataset decoding preserves the caller debug hook")
if debug.sethook then
if previousHook then debug.sethook(previousHook, previousMask, previousCount)
else debug.sethook() end
end
hostileRun.release()
GameVersion.set(originalVersion)
CacheFs.prefix = originalPrefix
T.finish("dataset_views")
@@ -0,0 +1,21 @@
-- A generated chunk that would loop if executed must be rejected as syntax.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local files = {}
Fixture.cache(files, "red", {
pokemon = "while true do end; return {}",
})
local modPath = Fixture.addMod(files, "nontermination_probe", [[
local mod = ...
local view, reason = mod.datasets:open("red")
mod.exports.result = { view ~= nil, reason }
]])
local run = T.sdk.loadMods({ modPath }, {
fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1,
})
T.same(run.loader.exports.nontermination_probe.result,
{ false, "invalid_cache" }, "generated code is never executed")
run.release()
T.finish("dataset_views_nontermination")
+121
View File
@@ -0,0 +1,121 @@
local GameVersion = require("src.core.GameVersion")
local LuaWriter = require("src.import.LuaWriter")
local CacheContract = require("src.import.CacheContract")
local Fixture = {}
local GEN1_MODULES = {
"constants", "maps", "tilesets", "text", "text_pointers",
"trainer_headers", "font", "sprites", "pokemon", "moves", "items",
"type_chart", "trainers", "encounters", "field", "battle_anims",
}
local GEN1_OPTIONAL_MODULES = { "audio", "palettes", "icons" }
local GEN2_MODULES = {
"pokemon", "moves", "items", "type_chart", "audio", "font", "maps",
"tilesets", "text", "trainers", "encounters", "sprites", "palettes",
"icons", "battle_anims", "constants", "landmarks",
}
local CONTINUATIONS = {
"CROBAT", "BELLOSSOM", "POLITOED", "SLOWKING", "STEELIX",
"SCIZOR", "KINGDRA", "PORYGON2", "BLISSEY",
}
local MOVES = {
"IRON_TAIL", "METAL_CLAW", "STEEL_WING", "RAIN_DANCE", "SUNNY_DAY",
"SANDSTORM", "SLUDGE_BOMB", "SHADOW_BALL",
}
local function species(id, dex, generation)
local row = {
id = id, name = id, dex = dex,
spriteFront = "assets/generated/battle/front/" .. id:lower() .. ".png",
spriteBack = "assets/generated/battle/back/" .. id:lower() .. ".png",
}
if generation == 2 then row.picSize = 5 else row.frontSize = 5 end
return row
end
local function defaults(version)
local generation = GameVersion.generation(version)
local pokemon = { FIXMON = species("FIXMON", 1, generation) }
local moves = {}
local items = {}
local typeChart = {
matchups = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 } },
types = {},
}
if generation == 2 then
for index, id in ipairs(CONTINUATIONS) do
pokemon[id] = species(id, 168 + index, generation)
end
for index, id in ipairs(MOVES) do
moves[id] = { id = id, name = id, index = index, type = "STEEL",
power = 50, accuracy = 100, pp = 15, effect = "NO_ADDITIONAL_EFFECT" }
end
typeChart.types.STEEL = { name = "STEEL", category = "physical", index = 9 }
typeChart.foresightMatchups = {
{ attacker = "NORMAL", defender = "GHOST", multiplier = 0 },
}
items.LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS", price = 0,
heldEffect = "HELD_LEFTOVERS", heldParameter = 0 }
end
return {
constants = {}, maps = {}, tilesets = {}, text = {}, text_pointers = {},
trainer_headers = {}, font = {}, sprites = {}, pokemon = pokemon,
moves = moves, items = items, type_chart = typeChart, trainers = {},
encounters = {}, field = { oakSpeech = {} }, battle_anims = {},
audio = {}, palettes = {}, icons = {}, landmarks = {},
}
end
function Fixture.cache(files, version, overrides)
local prefix = GameVersion.cachePrefix(version)
local generation = GameVersion.generation(version)
local values = defaults(version)
for name, value in pairs(overrides or {}) do values[name] = value end
files[prefix .. "rom-cache.complete"] =
"rom-cache-v10:" .. GameVersion.info(version).sha1
for _, name in ipairs(generation == 2 and GEN2_MODULES or GEN1_MODULES) do
local value = values[name]
files[prefix .. "data/generated/" .. name .. ".lua"] =
type(value) == "string" and value or LuaWriter.encode(value or {})
end
if generation == 1 then
for _, name in ipairs(GEN1_OPTIONAL_MODULES) do
files[prefix .. "data/generated/" .. name .. ".lua"] =
LuaWriter.encode(values[name] or {})
end
end
for _, path in ipairs(CacheContract.requiredFiles(version)) do
if files[prefix .. path] == nil then
files[prefix .. path] = path:match("%.lua$") and LuaWriter.encode({}) or "fixture"
end
end
if type(values.pokemon) == "table" then
for _, row in pairs(values.pokemon) do
if type(row) == "table" and row.spriteFront then
files[prefix .. row.spriteFront] = "front"
files[prefix .. row.spriteBack] = "back"
end
end
end
return files
end
function Fixture.addMod(files, id, body)
files["mods/" .. id .. "/manifest.json"] = ([[{
"id": %q, "name": %q, "version": "1.0.0", "entry": "main.lua",
"api": 2, "games": ["all"]
}]]):format(id, id)
files["mods/" .. id .. "/main.lua"] = body
return "mods/" .. id
end
Fixture.GEN1_MODULES = GEN1_MODULES
Fixture.GEN2_MODULES = GEN2_MODULES
Fixture.CONTINUATIONS = CONTINUATIONS
Fixture.MOVES = MOVES
return Fixture