mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 00:48:32 +02:00
fix(mod-api): harden imported dataset views
This commit is contained in:
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user