mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 00:48:32 +02:00
Merge pull request #1767 from MaxTomahawk/adaptive-trainers/dataset-view-api
feat(mod-api): expose imported dataset views
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
-- Imported semantic modules are opened without eager reads and are decoded
|
||||
-- once, on first use, through the same public facade mods receive.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Fixture = require("tests.modkit.dataset_view_fixture")
|
||||
local DatasetViews = require("src.mods.DatasetViews")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
|
||||
local files = {}
|
||||
Fixture.cache(files, "gold")
|
||||
local fs = T.sdk.memfs(files)
|
||||
local rawRead = fs.read
|
||||
local reads = {}
|
||||
fs.read = function(path)
|
||||
reads[path] = (reads[path] or 0) + 1
|
||||
return rawRead(path)
|
||||
end
|
||||
local decodes = 0
|
||||
local service = DatasetViews.new(fs, require, function(source, limits)
|
||||
decodes = decodes + 1
|
||||
return SaveSerializer.decode(source, limits)
|
||||
end)
|
||||
local pokemonPath = GameVersion.cachePrefix("gold")
|
||||
.. "data/generated/pokemon.lua"
|
||||
local movesPath = GameVersion.cachePrefix("gold")
|
||||
.. "data/generated/moves.lua"
|
||||
|
||||
local view, reason = service:open("gold")
|
||||
T.eq(reason, nil, "marker-valid Gold dataset opens")
|
||||
T.check(view ~= nil, "open returns the lazy view")
|
||||
T.eq(reads[pokemonPath] or 0, 0, "open does not read an unused root")
|
||||
T.eq(reads[movesPath] or 0, 0, "open does not read another unused root")
|
||||
T.eq(decodes, 0, "open decodes no semantic root")
|
||||
|
||||
local first = view and view.content.pokemon:get("FIXMON")
|
||||
T.eq(first and first.name, "FIXMON", "first access decodes a valid root")
|
||||
T.check(decodes > 0, "first root access performs bounded decoding")
|
||||
T.eq(reads[movesPath] or 0, 0, "pokemon access leaves moves unused")
|
||||
local afterFirst = decodes
|
||||
local second = view and view.content.pokemon:get("FIXMON")
|
||||
T.eq(second and second.name, "FIXMON", "repeated access remains available")
|
||||
T.eq(decodes, afterFirst, "repeated access does not re-decode cached roots")
|
||||
|
||||
T.finish("dataset_views_lazy_validation")
|
||||
@@ -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,56 @@
|
||||
-- Optional dataset absence is a handled API result, while a previously valid
|
||||
-- view disappearing and malformed cache content remain actionable warnings.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Fixture = require("tests.modkit.dataset_view_fixture")
|
||||
local DatasetViews = require("src.mods.DatasetViews")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local previousWarn = Logger.warn
|
||||
local warnings = {}
|
||||
Logger.warn = function(fmt, ...)
|
||||
warnings[#warnings + 1] = select("#", ...) > 0
|
||||
and string.format(fmt, ...) or fmt
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
local unavailable = DatasetViews.new(T.sdk.memfs({}))
|
||||
local unknown, unknownReason = unavailable:open("missing-version")
|
||||
T.eq(unknown, nil, "unknown version has no view")
|
||||
T.eq(unknownReason, "unknown_version", "unknown version returns its reason")
|
||||
local absent, absentReason = unavailable:open("gold")
|
||||
T.eq(absent, nil, "missing optional Gold dataset has no view")
|
||||
T.eq(absentReason, "not_imported", "missing optional Gold returns its reason")
|
||||
T.eq(#warnings, 0, "unknown and initially absent datasets do not warn")
|
||||
|
||||
local files = {}
|
||||
Fixture.cache(files, "gold")
|
||||
local service = DatasetViews.new(T.sdk.memfs(files))
|
||||
local view = assert(service:open("gold"))
|
||||
files[GameVersion.cachePrefix("gold") .. "rom-cache.complete"] = nil
|
||||
T.eq(view.assets:path("assets/generated/missing.png"), nil,
|
||||
"a stale view closes when its imported dataset disappears")
|
||||
T.eq(#warnings, 1, "a previously valid view disappearing still warns")
|
||||
T.check(warnings[1]:find("dataset gold unavailable", 1, true) ~= nil,
|
||||
"the stale-view warning identifies the unavailable dataset")
|
||||
|
||||
warnings = {}
|
||||
local malformedFiles = {}
|
||||
Fixture.cache(malformedFiles, "gold", { pokemon = "not generated data" })
|
||||
local malformed = assert(DatasetViews.new(T.sdk.memfs(malformedFiles)):open("gold"))
|
||||
T.eq(#warnings, 0, "lazy open does not warn before malformed data is read")
|
||||
T.eq(malformed.content.pokemon:get("FIXMON"), nil,
|
||||
"malformed generated data fails closed")
|
||||
T.eq(#warnings, 1, "malformed generated data still warns")
|
||||
T.check(warnings[1]:find("dataset gold cache rejected", 1, true) ~= nil,
|
||||
"the malformed-cache warning identifies cache rejection")
|
||||
T.check(warnings[1]:find("pokemon:", 1, true) ~= nil,
|
||||
"the malformed-cache warning keeps actionable module detail")
|
||||
end, debug.traceback)
|
||||
|
||||
Logger.warn = previousWarn
|
||||
if not ok then error(err, 0) end
|
||||
|
||||
T.finish("dataset_views_warning_policy")
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Extractor metadata hidden from record id spaces remains engine-owned: mods
|
||||
-- cannot claim, update, or remove it through the public content registries.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function goldData()
|
||||
local data = T.fixtures.fresh()
|
||||
data.pokemon.growthRates = {
|
||||
GROWTH_MEDIUM_FAST = {
|
||||
numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0,
|
||||
},
|
||||
}
|
||||
data.pokemon.tmhmMoves = { "FIX_TACKLE", "FIX_CUT" }
|
||||
data.moves.generation, data.moves.source = 2, "ROM:Moves"
|
||||
data.items.generation, data.items.source = 2, "ROM:Items"
|
||||
data.items.pockets = { "ITEM", "BALL", "KEY_ITEM", "TM_HM" }
|
||||
return data
|
||||
end
|
||||
|
||||
local function assertMetadata(data, refs, label)
|
||||
T.eq(data.pokemon.growthRates, refs.growthRates,
|
||||
label .. ": growth-rate table identity is preserved")
|
||||
T.eq(data.pokemon.tmhmMoves, refs.tmhmMoves,
|
||||
label .. ": TM/HM order identity is preserved")
|
||||
T.eq(data.moves.generation, 2, label .. ": move generation is preserved")
|
||||
T.eq(data.moves.source, "ROM:Moves", label .. ": move source is preserved")
|
||||
T.eq(data.items.generation, 2, label .. ": item generation is preserved")
|
||||
T.eq(data.items.source, "ROM:Items", label .. ": item source is preserved")
|
||||
T.eq(data.items.pockets, refs.pockets,
|
||||
label .. ": item pocket order identity is preserved")
|
||||
T.eq(Mon.experienceForLevel(
|
||||
Mon.growthFor(data, "GROWTH_MEDIUM_FAST"), 10), 1000,
|
||||
label .. ": active Gold growth behavior is preserved")
|
||||
end
|
||||
|
||||
-- A no-mod load characterizes the unchanged active-boot behavior.
|
||||
do
|
||||
local data = goldData()
|
||||
local refs = {
|
||||
growthRates = data.pokemon.growthRates,
|
||||
tmhmMoves = data.pokemon.tmhmMoves,
|
||||
pockets = data.items.pockets,
|
||||
}
|
||||
local run = T.sdk.loadNone({ data = data, generation = 2 })
|
||||
T.eq(#run.errors, 0, "no-mod Gold load remains clean")
|
||||
assertMetadata(run.data, refs, "no-mod Gold")
|
||||
run.release()
|
||||
end
|
||||
|
||||
local files, paths = {}, {}
|
||||
local attempts = {
|
||||
register_growth_rates = [[
|
||||
local mod = ...
|
||||
mod.content.pokemon:register("growthRates", {
|
||||
id = "growthRates", name = "CLAIMED", dex = 999,
|
||||
types = {},
|
||||
baseStats = { hp = 1, attack = 1, defense = 1, speed = 1,
|
||||
specialAttack = 1, specialDefense = 1 },
|
||||
catchRate = 1, baseExp = 1, growthRate = "GROWTH_MEDIUM_FAST",
|
||||
levelMoves = {}, evolutions = {},
|
||||
spriteFront = "assets/generated/battle/front/claimed.png",
|
||||
spriteBack = "assets/generated/battle/back/claimed.png", picSize = 5,
|
||||
})
|
||||
]],
|
||||
replace_growth_rates = [[
|
||||
local mod = ...
|
||||
mod.content.pokemon:register("growthRates", {
|
||||
id = "growthRates", name = "REPLACED", dex = 999,
|
||||
types = {},
|
||||
baseStats = { hp = 1, attack = 1, defense = 1, speed = 1,
|
||||
specialAttack = 1, specialDefense = 1 },
|
||||
catchRate = 1, baseExp = 1, growthRate = "GROWTH_MEDIUM_FAST",
|
||||
levelMoves = {}, evolutions = {},
|
||||
spriteFront = "assets/generated/battle/front/replaced.png",
|
||||
spriteBack = "assets/generated/battle/back/replaced.png", picSize = 5,
|
||||
}, { replace = true })
|
||||
]],
|
||||
override_move_generation = [[
|
||||
local mod = ...
|
||||
mod.content.moves:override("generation", {
|
||||
id = "generation", name = "CLAIMED", type = "NORMAL",
|
||||
power = 1, accuracy = 100, pp = 1, effect = "NO_ADDITIONAL_EFFECT",
|
||||
})
|
||||
]],
|
||||
patch_item_pockets = [[
|
||||
local mod = ...
|
||||
mod.content.items:patch("pockets", { price = 999 })
|
||||
]],
|
||||
remove_tmhm_moves = [[
|
||||
local mod = ...
|
||||
mod.content.pokemon:remove("tmhmMoves")
|
||||
]],
|
||||
}
|
||||
|
||||
for id, body in pairs(attempts) do
|
||||
files["mods/" .. id .. "/manifest.json"] = ([[{
|
||||
"id": %q, "name": %q, "version": "1.0.0", "entry": "main.lua",
|
||||
"api": 2, "gen2compat": true
|
||||
}]]):format(id, id)
|
||||
files["mods/" .. id .. "/main.lua"] = body
|
||||
paths[#paths + 1] = "mods/" .. id
|
||||
end
|
||||
table.sort(paths)
|
||||
|
||||
local data = goldData()
|
||||
local refs = {
|
||||
growthRates = data.pokemon.growthRates,
|
||||
tmhmMoves = data.pokemon.tmhmMoves,
|
||||
pockets = data.items.pockets,
|
||||
}
|
||||
local run = T.sdk.loadMods(paths, {
|
||||
fs = T.sdk.memfs(files), data = data, generation = 2,
|
||||
})
|
||||
for id in pairs(attempts) do
|
||||
local mod = run.loader.mods[id]
|
||||
T.eq(mod and mod.state, "failed", id .. " is rejected")
|
||||
T.check(mod and tostring(mod.failure):match("reserved") ~= nil,
|
||||
id .. " reports the reserved metadata boundary")
|
||||
end
|
||||
assertMetadata(run.data, refs, "rejected metadata writes")
|
||||
run.release()
|
||||
|
||||
T.finish("gen2_reserved_metadata_ids")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,394 @@
|
||||
-- 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 Schemas = require("src.mods.Schemas")
|
||||
|
||||
local originalVersion = GameVersion.get()
|
||||
local originalPrefix = CacheFs.prefix
|
||||
GameVersion.set("red")
|
||||
|
||||
local files = {}
|
||||
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) 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", "crystal" }) 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.metadataHidden = not gold.content.pokemon:has("growthRates")
|
||||
and not gold.content.pokemon:has("tmhmMoves")
|
||||
and not gold.content.moves:has("generation")
|
||||
and not gold.content.moves:has("source")
|
||||
and not gold.content.items:has("generation")
|
||||
and not gold.content.items:has("source")
|
||||
and not gold.content.items:has("pockets")
|
||||
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("missing-version")
|
||||
out.unknown = { unknown ~= nil, unknownReason }
|
||||
mod.exports.result = out
|
||||
]])
|
||||
|
||||
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, "dataset public probe loads clean")
|
||||
local out = run.loader.exports.dataset_probe.result
|
||||
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
|
||||
T.eq(out[version].reason, nil, version .. " opens")
|
||||
T.eq(out[version].generation,
|
||||
GameVersion.generation(version),
|
||||
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.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")
|
||||
T.eq(out.metadataHidden, true,
|
||||
"Gold extractor metadata stays outside record registry id spaces")
|
||||
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()
|
||||
|
||||
-- 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,
|
||||
types = {}, catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST",
|
||||
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
|
||||
special = 65 },
|
||||
level1Moves = {}, tmhm = {}, learnset = {}, evolutions = {},
|
||||
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
|
||||
-- open and the first facade read each recheck readiness; remove the cache
|
||||
-- for the next open, then make the following open the reimport.
|
||||
if transitionMarkers == 3 then return nil end
|
||||
end
|
||||
local body = transitionRead(path)
|
||||
if transitionMarkers >= 4
|
||||
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", "crystal" }) do
|
||||
local view, reason = mod.datasets:open(version)
|
||||
local value = view and view.content.pokemon:get("BAD")
|
||||
local reopened, reopenedReason = mod.datasets:open(version)
|
||||
out[version] = {
|
||||
first = view ~= nil, firstReason = reason, value = value,
|
||||
reopened = reopened ~= nil, reason = reopenedReason,
|
||||
}
|
||||
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]], {
|
||||
first = true, reopened = false, reason = "invalid_cache",
|
||||
}, row[1] .. " hostile generated source fails closed on first root access")
|
||||
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()
|
||||
|
||||
-- A syntactically valid semantic root with a primitive record must fail closed
|
||||
-- whichever public read verb encounters it first.
|
||||
local malformedFiles = {}
|
||||
for _, version in ipairs({ "red", "blue", "yellow" }) do
|
||||
Fixture.cache(malformedFiles, version, {
|
||||
pokemon = "return { FIXMON = 7 }",
|
||||
})
|
||||
end
|
||||
local malformedMod = Fixture.addMod(malformedFiles, "malformed_probe", [[
|
||||
local mod = ...
|
||||
local out = {}
|
||||
local getView = assert(mod.datasets:open("red"))
|
||||
out.get = getView.content.pokemon:get("FIXMON")
|
||||
local getAgain, getReason = mod.datasets:open("red")
|
||||
out.getAgain = { getAgain ~= nil, getReason }
|
||||
|
||||
local hasView = assert(mod.datasets:open("blue"))
|
||||
out.has = hasView.content.pokemon:has("FIXMON")
|
||||
local hasAgain, hasReason = mod.datasets:open("blue")
|
||||
out.hasAgain = { hasAgain ~= nil, hasReason }
|
||||
|
||||
local eachView = assert(mod.datasets:open("yellow"))
|
||||
for id, value in eachView.content.pokemon:each() do
|
||||
out.each = { id, value }
|
||||
break
|
||||
end
|
||||
local eachAgain, eachReason = mod.datasets:open("yellow")
|
||||
out.eachAgain = { eachAgain ~= nil, eachReason }
|
||||
mod.exports.result = out
|
||||
]])
|
||||
local malformedRun = T.sdk.loadMods({ malformedMod }, {
|
||||
fs = T.sdk.memfs(malformedFiles), data = { pokemon = {} }, generation = 1,
|
||||
})
|
||||
T.eq(#malformedRun.errors, 0, "malformed-record probe stays sandboxed")
|
||||
local malformedOut = malformedRun.loader.exports.malformed_probe.result
|
||||
T.eq(malformedOut.get, nil, "get never exposes a malformed semantic record")
|
||||
T.eq(malformedOut.has, false, "has never affirms a malformed semantic record")
|
||||
T.eq(malformedOut.each, nil, "each never exposes a malformed semantic record")
|
||||
T.same(malformedOut.getAgain, { false, "invalid_cache" },
|
||||
"get invalidates the malformed dataset")
|
||||
T.same(malformedOut.hasAgain, { false, "invalid_cache" },
|
||||
"has invalidates the malformed dataset")
|
||||
T.same(malformedOut.eachAgain, { false, "invalid_cache" },
|
||||
"each invalidates the malformed dataset")
|
||||
malformedRun.release()
|
||||
|
||||
-- The same Gold view stays independent while each Gen 1 version is the
|
||||
-- actual active runtime version and cache namespace, not just a fixture label.
|
||||
for _, activeVersion in ipairs({ "red", "blue", "yellow" }) do
|
||||
local matrixFiles = {}
|
||||
Fixture.cache(matrixFiles, "gold")
|
||||
local matrixMod = Fixture.addMod(matrixFiles,
|
||||
"active_" .. activeVersion .. "_gold_probe", [[
|
||||
local mod = ...
|
||||
local gold = assert(mod.datasets:open("gold"))
|
||||
mod.exports.result = {
|
||||
species = gold.content.pokemon:get("FIXMON").name,
|
||||
foresight = gold.content.type_chart:get("NORMAL>GHOST").multiplier,
|
||||
held = gold.content.held_items:get("LEFTOVERS").heldEffect,
|
||||
}
|
||||
]])
|
||||
GameVersion.set(activeVersion)
|
||||
CacheFs.prefix = GameVersion.cachePrefix(activeVersion)
|
||||
local activeData = {
|
||||
pokemon = { ACTIVE = { id = "ACTIVE_" .. activeVersion,
|
||||
nested = { version = activeVersion } } },
|
||||
}
|
||||
local matrixRun = T.sdk.loadMods({ matrixMod }, {
|
||||
fs = T.sdk.memfs(matrixFiles), data = activeData, generation = 1,
|
||||
})
|
||||
T.eq(#matrixRun.errors, 0, activeVersion .. "-active Gold probe loads")
|
||||
T.same(matrixRun.loader.exports["active_" .. activeVersion .. "_gold_probe"].result,
|
||||
{ species = "FIXMON", foresight = 0, held = "HELD_LEFTOVERS" },
|
||||
activeVersion .. "-active runtime sees canonical Gold semantics")
|
||||
T.eq(GameVersion.get(), activeVersion,
|
||||
activeVersion .. " remains the active GameVersion")
|
||||
T.eq(CacheFs.prefix, GameVersion.cachePrefix(activeVersion),
|
||||
activeVersion .. " remains the active cache namespace")
|
||||
T.eq(activeData.pokemon.ACTIVE.nested.version, activeVersion,
|
||||
activeVersion .. " active Data remains unchanged")
|
||||
T.eq(activeData.pokemon.FIXMON, nil,
|
||||
activeVersion .. " active Data receives no Gold record")
|
||||
matrixRun.release()
|
||||
end
|
||||
|
||||
GameVersion.set(originalVersion)
|
||||
CacheFs.prefix = originalPrefix
|
||||
T.finish("dataset_views")
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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")
|
||||
local value = view and view.content.pokemon:get("FIXMON")
|
||||
local reopened, reopenedReason = mod.datasets:open("red")
|
||||
mod.exports.result = {
|
||||
first = view ~= nil, firstReason = reason, value = value,
|
||||
reopened = reopened ~= nil, reason = reopenedReason,
|
||||
}
|
||||
]])
|
||||
local run = T.sdk.loadMods({ modPath }, {
|
||||
fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1,
|
||||
})
|
||||
T.same(run.loader.exports.nontermination_probe.result,
|
||||
{ first = true, reopened = false, reason = "invalid_cache" },
|
||||
"generated code is never executed and invalidates on first root access")
|
||||
run.release()
|
||||
T.finish("dataset_views_nontermination")
|
||||
@@ -0,0 +1,138 @@
|
||||
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,
|
||||
types = {}, catchRate = 45, baseExp = 64, growthRate = "MEDIUM_FAST",
|
||||
tmhm = {}, evolutions = {},
|
||||
spriteFront = "assets/generated/battle/front/" .. id:lower() .. ".png",
|
||||
spriteBack = "assets/generated/battle/back/" .. id:lower() .. ".png",
|
||||
}
|
||||
if generation == 2 then
|
||||
row.baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
|
||||
specialAttack = 65, specialDefense = 65 }
|
||||
row.levelMoves = {}
|
||||
row.picSize = 5
|
||||
else
|
||||
row.baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
|
||||
special = 65 }
|
||||
row.level1Moves = {}
|
||||
row.learnset = {}
|
||||
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
|
||||
pokemon.growthRates = {}
|
||||
pokemon.tmhmMoves = {}
|
||||
moves.generation, moves.source = 2, "fixture moves"
|
||||
items.generation, items.source, items.pockets = 2, "fixture items", {}
|
||||
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"] =
|
||||
CacheContract.markerFor(version)
|
||||
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