6.0 KiB
RFC 0008 — Streamed mod imports and installation-scoped generated cache
Motivation
required_imports/optional_imports can now describe files up to 2 GiB, but
the existing launcher and public mod API still assume imported bytes are small:
- the Windows desktop picker stages a selected required import through a fixed
%TEMP%/pokeport_required_import.binpath before validation; - the fallback import path materializes the selected file as one Lua string;
- after validation a mod can only use
mod:read("baseroms/..."), which also materializes the whole file; mod.storageis intentionally scoped to one Pokémon playthrough, so it is not an appropriate home for a one-time generated asset cache shared by every save using the same installed mod.
This makes optical-disc-sized user sources impractical even though the manifest schema already accepts them. A failed temporary staging copy can also turn a valid large source into a smaller temporary file and produce a misleading "wrong file size" rejection.
A mod should be able to consume its own already-validated source incrementally and compile derived runtime data once, without receiving a host path or general filesystem access.
Decision being extended
This extends the same legal/sandbox direction as D11 asset transforms
(src/mods/AssetTransform.lua): mods distribute recipes and derive bytes from
user-owned sources rather than shipping ROM-derived data. It also follows the
D14 parity-gate contract referenced by tests/harness.lua and
tests/engine/gate_meta_coverage.lua (the 21-testing-and-ci plan): additive
extension points ship public-API coverage, no-mod parity coverage, and docs in
the same change.
The historical D11 plan document is referenced by source comments but is not present in the current repository tree; this RFC is the checked-in design record for the new surface.
Exact API delta
No manifest field changes. Existing required_imports and optional_imports
remain the declaration/validation authority.
Two additive facades are added to the mod object.
mod.imports
local info, err = mod.imports:info("source_id")
local bytes, err = mod.imports:read("source_id", offset, length)
source_idmust name an import declared by the calling mod.- the import is rechecked through
RequiredImports.validateStoredbefore it is exposed, so missing, replaced, or invalid optional imports are not readable; offsetandlengthare zero-based byte coordinates;- one read is capped at 8 MiB;
- no host path or file handle is returned;
- production reads seek into the engine-owned stored copy instead of reading the whole source.
info() returns declaration metadata plus stored size. It does not expose a
host path.
mod.cache
mod.cache:write("extract/v1/model.bin", bytes)
local bytes = mod.cache:read("extract/v1/model.bin")
local info = mod.cache:info("extract/v1/model.bin")
mod.cache:delete("extract/v1/model.bin")
The cache is rooted at mod_cache/<mod-id>/, follows the engine persistence
backend, and is independent of game version, launcher slot, and playthrough.
Paths are checked with SafePath; .., absolute paths, drive paths, and other
escapes remain unavailable. A single cache write is capped at 64 MiB so large
generated datasets are naturally split into independently replaceable files.
The engine does not interpret cache bytes. Mods own generated-format versioning, fingerprints, transactional completion markers, and rebuild policy.
Launcher/import transport delta
For large raw required imports:
- desktop pickers return the original selected path instead of staging it through a fixed temporary file;
- the engine opens that source itself;
- bytes are copied directly to the existing engine-owned
mods/<id>/baseroms/<file>destination in 4 MiB chunks; - MD5 is updated incrementally during the copy;
- the normal size/MD5 validation receipt is written only after the complete destination passes validation;
- partial destinations are removed on short reads, write failure, size mismatch, or digest mismatch.
N64 imports stay on the existing canonicalization path because byte-order and copier-header normalization require transformation rather than a raw copy.
If a validation receipt for an already-stored large raw import is missing, the engine rebuilds it with streaming MD5 rather than a whole-file read.
Backward compatibility / migration
Existing mods do nothing. This is additive.
- manifest v1/v2 fields are unchanged;
mod:read,mod.storage, registries, events, hooks, and legacy compatibility retain their existing behavior;- small required imports retain the existing in-memory validation path;
- N64 imports retain canonicalization and existing accepted byte orders;
- a mod that never touches
mod.importsormod.cachecreates no new cache files and observes no new behavior.
The mod API integer is not bumped because no existing member changes meaning or shape.
Security and legal posture
The launcher remains the authority that validates user-supplied bytes. The new
facade narrows access rather than widening it: a mod can read only ids declared
in its own manifest, only after validation, and only in bounded ranges. It does
not receive host paths, io, or a raw filesystem handle.
mod.cache is writable only beneath the calling mod's generated-cache root.
Nothing in this RFC permits packaged ROM-derived bytes; modkit lint/pack
continue to enforce the existing legal posture.
Parity guarantee
The change ships with:
- a no-mod/API-v1 parity test proving an empty load and an existing v1-style
mod:readload do not create cache data or change the old surface; - a public mod-API test that reaches
mod.importsandmod.cachethrough a realLoaderload, including bounded reads, undeclared/missing imports, cache isolation, and traversal rejection; - incremental MD5 vectors and a large-import streaming regression test;
- the existing engine suite, required-import suite, and mod lint gates.