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