diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 40147fae..3030138f 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"` when you mean everywhere. `docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold -and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1, +and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their writes with a report, and which hooks and events are still to come. `docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 0e7e7ffd..896b9187 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -24,7 +24,7 @@ The short version, for an author deciding what to write: merged.** The write is taken, dropped, and named once per mod in the same error feed the mod manager shows -- in both directions, so a Red boot writing to `decorations` is told exactly as a Gold boot writing to `map_scripts` is. -- **40 event names and 43 hook names have a call site in both generations**, so +- **40 event names and 44 hook names have a call site in both generations**, so one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua` reads those names back out of the source and fails if a site is renamed or deleted on either side, and fails again if a new shared site appears without @@ -539,7 +539,8 @@ gains a field instead of the name gaining a prefix. `battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`, `battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`, `pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`, - `battle.crit`, `battle.accuracy`, `battle.turn_order`, + `battle.crit`, `battle.accuracy`, `battle.charge_required`, + `battle.turn_order`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, `battle.catch_exp`, `battle.bottom_ui_visible`, diff --git a/docs/modding.md b/docs/modding.md index 4016e1f1..7c724404 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file` digests. `format` is either `"raw"` (the default) or `"n64"`. An optional `description` gives players dump or region guidance in the import panel. `size` declares the exact canonical byte length; `max_size` declares a smaller -per-import ceiling when an exact size is not appropriate. Every import also -has an engine-enforced 128 MiB ceiling and is rejected before hashing when its -filesystem reports an invalid size. +per-import ceiling when an exact size is not appropriate. The engine hard limit +is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and +use the launcher's streaming large-file path rather than being materialized as +one Lua string. For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders, strips a recognized 512-byte copier header, converts the bytes to canonical big-endian `.z64` order, and then checks MD5. The canonical bytes are written to `mods//baseroms/`. Each selection is a private grant to that mod: the launcher never scans or copies another mod's imported files merely -because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for -example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem +because its manifest names the same digest. Small sources can still be read +with the existing scoped `mod:read` API, for example +`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded +`mod.imports` facade described below; no host path or new general filesystem permission is exposed. Missing `required_imports` block the mod before its entry chunk runs; missing `optional_imports` remain visible in the same launcher panel but do not block loading. +#### Bounded access to validated imports + +A loaded mod can address only ids declared by its own `required_imports` or +`optional_imports` arrays: + +```lua +local info, err = mod.imports:info("stadium2") +local header, err = mod.imports:read("stadium2", 0, 4096) +``` + +`read` uses zero-based offsets and is capped at 8 MiB per call. The engine +rechecks the stored import before exposing it, seeks into the engine-owned +copy, and never gives the mod a host path or file handle. This is intended for +large source formats whose table/index can be parsed with small reads before +selectively reading the payloads a transform actually needs. + MD5 here identifies a known dump because ROM databases commonly publish it; it is not a security or authenticity guarantee. Do not paste the SHA-1 used by Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives @@ -441,6 +460,28 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Installation-scoped generated cache + +Generated data derived from a validated user source often belongs to the mod +installation rather than to one Pokémon save. `mod.cache` is that namespace: + +```lua +local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena) +local bytes, err = mod.cache:read("extract/v1/arena.bin") +local info = mod.cache:info("extract/v1/arena.bin") +mod.cache:delete("extract/v1/arena.bin") +``` + +The physical root is engine-owned (`mod_cache//`) and never exposed to +the mod. Keys are safe relative paths and a single write is capped at 64 MiB. +The cache does not rewind with checkpoints and is not scoped to game version, +slot, or playthrough. The mod owns its generated format, fingerprints, rebuild +policy, and completion marker; the engine treats the bytes as opaque data. + +Use `mod.storage` instead when the data belongs to one playthrough. Use +`mod.cache` when it is a reproducible installation artifact that can be rebuilt +from a declared user source. + ## Durable tool storage and runtime checkpoints `mod.save` remains the right place for state that should travel with the next @@ -633,6 +674,17 @@ the selected indices. Mods remain responsible for selection policy and should use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact contract and compatibility guarantees. +Both battle engines expose the guarded `battle.charge_required` hook when a +charge-capable move is selected for its initial turn and the active ruleset +would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is +`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to +skip only that initial charge and continue through the ordinary move pipeline; +call `next(ctx)` to keep it. The hook does not run for the release turn or when +the active ruleset already skips charging (for example, Gold Solarbeam in +sun). PP use, accuracy, damage, animation, and secondary effects remain owned +by the engine. With no subscriber, the vanilla decision runs without building +the hook context. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md b/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md new file mode 100644 index 00000000..22240416 --- /dev/null +++ b/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md @@ -0,0 +1,141 @@ +# 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.bin` path 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.storage` is 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` + +```lua +local info, err = mod.imports:info("source_id") +local bytes, err = mod.imports:read("source_id", offset, length) +``` + +* `source_id` must name an import declared by the calling mod. +* the import is rechecked through `RequiredImports.validateStored` before it is + exposed, so missing, replaced, or invalid optional imports are not readable; +* `offset` and `length` are 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` + +```lua +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//`, 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: + +1. desktop pickers return the original selected path instead of staging it + through a fixed temporary file; +2. the engine opens that source itself; +3. bytes are copied directly to the existing engine-owned + `mods//baseroms/` destination in 4 MiB chunks; +4. MD5 is updated incrementally during the copy; +5. the normal size/MD5 validation receipt is written only after the complete + destination passes validation; +6. 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.imports` or `mod.cache` creates 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:read` load do not create cache data or change the old surface; +* a public mod-API test that reaches `mod.imports` and `mod.cache` through a + real `Loader` load, 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. diff --git a/docs/rfcs/0011-battle-charge-required.md b/docs/rfcs/0011-battle-charge-required.md new file mode 100644 index 00000000..310bec00 --- /dev/null +++ b/docs/rfcs/0011-battle-charge-required.md @@ -0,0 +1,92 @@ +# RFC 0011: Charge-required battle hook + +## Status + +Proposed. + +## Motivation + +A battle-mechanics mod can change damage through `battle.damage` and register +move effects, but it cannot conditionally skip the first turn of an existing +charge move. In Gen 1, the engine decides and stores the charge continuation +before any public effect callback can run. Reaching into `user.charging`, +`user.chargeReady`, or generation-specific volatile state is private, +checkpoint-fragile, and would require a mod to duplicate move-pipeline policy. + +Weather is the immediate example: a portable sun rule needs Solarbeam to +resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation, +and secondary effects to the engine. The capability is generic and useful to +other ruleset and move-mechanics mods. + +## Decision and plan extended + +This implements **D-AT-002: charge-stage policy remains mod authority through a +generic guarded engine decision seam**. 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 follows the additive, guarded hook convention documented by +Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or +Adaptive Trainers policy. + +## Exact API delta + +Both the Gen 1 and Gen 2 battle engines add this guarded hook: + +```lua +mod.hooks:wrap("battle.charge_required", function(next, ctx) + -- ctx = { + -- battle = live battle controller, + -- user = attacking battler, + -- target = defending battler, + -- move = merged move record, + -- charge = true, + -- isCalled = false, + -- } + if should_resolve_now(ctx) then return false end + return next(ctx) +end) +``` + +The call site is the initial-use charge decision, after announcement and PP +handling but before charge state, invulnerability, charge animation, or charge +text is created. It runs only when the active engine rules would otherwise +require a charge. It does not run on the release turn. Returning exactly +`false` skips that initial charge and continues through the engine-owned move +pipeline. Any other downstream return preserves the charge. `isCalled` is true +when Metronome or Mirror Move selected the move. + +Gold keeps its native sun decision first, so Solarbeam in native sun already +requires no charge and does not invoke the hook. Gen 1 link battles use the +shared Gen 1 move pipeline and therefore receive the same seam; normal link +mod-compatibility rules continue to govern deterministic peers. + +The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no +subscriber, no hook payload table is allocated and the existing branch runs +unchanged. + +## Migration and compatibility + +Existing mods change nothing. The hook name and payload are additive. With no +wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous +charge state, PP use, text, animation, accuracy, damage, and native weather +behavior. Existing charge-move data and effect records require no migration. + +A mod adopting the seam should call `next(ctx)` unless it deliberately wants to +skip this charge. It should not mutate private charge fields or re-run the move. + +## Verification + +- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2 + engines through a sandboxed public mod, including false-to-skip, next-to-keep, + release-turn behavior, called-move PP semantics, shared payload shape, and + native Gold sun behavior. +- The same test proves no-mod charge/release parity and replaces + `Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard. +- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty + chains preserve vanilla values and allocation behavior. +- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both + generations and keeps the compatibility reference list complete. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4a61ef0a..3a005361 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3980,7 +3980,17 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- record (chargeText) and the invulnerability from semiInvulnerable, -- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- ChargeEffect sets INVULNERABLE for both) - if record and record.charge and not releasing then + local chargeRequired = record and record.charge ~= nil and not releasing + if chargeRequired and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) + return c.charge + end, { + battle = self, user = user, target = target, move = move, + charge = true, isCalled = isCalled or false, + }) + chargeRequired = required ~= false + end + if chargeRequired then self:cancelMoveAnim() user.charging = moveInst user.chargeReady = true diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 06d57035..9346f9dd 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1494,6 +1494,15 @@ function Battle:useMove(attacker, defender, moveId) if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then charge = nil end + if charge and not charging and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) + return c.charge + end, { + battle = self, user = attacker, target = defender, move = def, + charge = true, isCalled = (self.copyDepth or 0) > 0, + }) + if required == false then charge = nil end + end if charge and not charging then state.chargeMove = moveId state.vanished = charge.vanish or nil diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index a3bfd889..00ef8e99 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -287,6 +287,43 @@ function CacheFs.write(rel, data) return love.filesystem.write(rel, data) end +-- Open a cache-relative file for streaming replacement. The returned handle +-- has write(bytes) and close() methods and follows the same portable/save-dir +-- routing as CacheFs.write without forcing the caller to hold the whole file +-- in one Lua string. +function CacheFs.openWrite(rel) + rel = withPrefix(rel) + local root = CacheFs.root() + if root then + ensureParents(root, rel) + local f, err = io.open(realPath(root, rel), "wb") + if not f then return nil, err end + return { + write = function(_, data) + local ok, writeErr = f:write(data) + if not ok then return nil, writeErr end + return true + end, + close = function() f:close() end, + } + end + if not (love and love.filesystem and love.filesystem.newFile) then + return nil, "streaming cache writes are unavailable" + end + local parent = rel:match("^(.*)/[^/]+$") + if parent and not love.filesystem.createDirectory(parent) then + local info = love.filesystem.getInfo(parent) + local reason = info and ("a " .. info.type .. " already exists there") + or "unknown reason" + return nil, "could not create " .. parent .. ": " .. reason + end + local file, makeErr = love.filesystem.newFile(rel) + if not file then return nil, makeErr or "could not create cache file" end + local ok, openErr = file:open("w") + if not ok then return nil, openErr or "could not open cache file" end + return file +end + -- read cache-relative `rel`; returns the bytes or nil function CacheFs.read(rel) rel = withPrefix(rel) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 63221a14..f1dff9c9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -388,6 +388,142 @@ local function externalFileSize(path) return size end +local function openImportSource(path) + -- Desktop picker paths live outside LÖVE's virtual filesystem. Prefer the + -- native file handle so a 1.46 GiB disc is never copied to a temp file or + -- read into one Lua string before validation. + local native = io.open(path, "rb") + if native then + local size, sizeErr = native:seek("end") + if size == nil or size == false then + native:close() + return nil, sizeErr or "could not determine source file size" + end + local reset, resetErr = native:seek("set", 0) + if reset == nil or reset == false then + native:close() + return nil, resetErr or "could not rewind source file" + end + return { + size = size, + read = function(_, n) return native:read(n) end, + close = function() native:close() end, + } + end + if love and love.filesystem and love.filesystem.newFile then + local file, makeErr = love.filesystem.newFile(path) + if not file then return nil, makeErr or "could not open source file" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open source file" end + local size = file.getSize and file:getSize() or nil + return { + size = size, + read = function(_, n) return file:read(n) end, + close = function() file:close() end, + } + end + return nil, "streaming source access is unavailable" +end +local function streamRequiredImport(manifest, importId, source) + local RequiredImports = require("src.mods.RequiredImports") + local spec = RequiredImports.spec(manifest, importId) + if not spec then return nil, "Import declaration was not found." end + if spec.format == "n64" then + return nil, "streaming canonicalization is unavailable for N64 imports" + end + local input, openErr = openImportSource(source) + if not input then return nil, openErr end + local sizeErr = RequiredImports.sizeError(spec, input.size, false) + if sizeErr then input:close(); return nil, sizeErr end + + local CacheFs = require("src.import.CacheFs") + local destination = RequiredImports.path(manifest, spec) + local savedPrefix = CacheFs.prefix + local output + local inputClosed, outputClosed = false, false + + local function closeInput() + if inputClosed then return end + inputClosed = true + pcall(function() input:close() end) + end + + local function closeOutput() + if outputClosed or not output then return end + outputClosed = true + pcall(function() output:close() end) + end + + local resultOk, resultDetail + CacheFs.prefix = "" + local ran, thrown = xpcall(function() + CacheFs.remove(RequiredImports.receiptPath(manifest, spec)) + CacheFs.remove(destination) + local makeErr + output, makeErr = CacheFs.openWrite(destination) + if not output then + resultDetail = makeErr or "could not create imported file" + return + end + + local MD5 = require("src.mods.StreamMD5") + local md5 = MD5.new() + local total, chunkBytes = 0, 4 * 1024 * 1024 + while true do + local chunk = input:read(chunkBytes) + if not chunk or #chunk == 0 then break end + md5:update(chunk) + local wrote, writeErr = output:write(chunk) + if wrote == false or wrote == nil then + resultDetail = "could not copy import: " +.. tostring(writeErr or "write failed") + return + end + total = total + #chunk + if #chunk < chunkBytes then break end + end + + closeInput() + closeOutput() + if input.size and total ~= input.size then + resultDetail = ("source read ended early (expected %d bytes, copied %d)") + :format(input.size, total) + return + end + local storedSizeErr = RequiredImports.sizeError(spec, total, true) + if storedSizeErr then resultDetail = storedSizeErr; return end + local digest = md5:final() + + -- acceptStoredDigest uses the normal engine path/receipt rules, so + -- restore the caller's prefix before handing control to it. + CacheFs.prefix = savedPrefix + local accepted, detail = RequiredImports.acceptStoredDigest( + manifest, importId, digest, love.filesystem) + if not accepted then resultDetail = detail; return end + resultOk, resultDetail = true, detail + end, function(err) + if debug and debug.traceback then + return debug.traceback(tostring(err), 2) + end + return tostring(err) + end) + + -- finally: resource handles and the process-global CacheFs prefix must + -- be restored even when a read/hash/write helper raises a Lua error. + closeInput() + closeOutput() + CacheFs.prefix = "" + if not ran or not resultOk then + pcall(function() CacheFs.remove(destination) end) + end + CacheFs.prefix = savedPrefix + + if not ran then + return nil, "could not copy import: " .. tostring(thrown) + end + if not resultOk then return nil, resultDetail end + return true, resultDetail +end local function readDroppedFile(file) local ok, openError = file:open("r") if not ok then return nil, openError end @@ -1234,11 +1370,13 @@ local function chooseRequiredFile() "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. prompt .. "';", "$d.Filter='All files (*.*)|*.*';", + -- Required imports can be multi-gigabyte optical-disc images. Do NOT + -- stage them through %TEMP%: that doubles free-space requirements and a + -- failed Copy-Item can leave a plausible-looking truncated temp file. + -- Stream the selected source directly into the mod-owned destination. "if($d.ShowDialog() -eq 'OK'){", - "$t=Join-Path $env:TEMP 'pokeport_required_import.bin';", - "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", - "[Console]::Write($t)}", + "[Console]::Write($d.FileName)}", }) return commandOutput( 'powershell -NoProfile -STA -Command "' .. script .. '"') @@ -2078,8 +2216,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed) return nil end local RequiredImports = require("src.mods.RequiredImports") - local info = love.filesystem.getInfo(source, "file") - local size = info and info.size or externalFileSize(source) + -- A desktop picker returns a host path. Ask the host file handle first; + -- love.filesystem.getInfo is only authoritative for virtual/save paths. + local size = externalFileSize(source) + if not size then + local info = love.filesystem.getInfo(source, "file") + size = info and info.size or nil + end local sizeErr = RequiredImports.sizeError(spec, size, false) if sizeErr then requiredImportNotice(self, modId, importId, sizeErr) @@ -2102,6 +2245,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed) } return nil end + if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES + and spec.format ~= "n64" then + local ok, result = streamRequiredImport(manifest, importId, source) + if ok then + self.requiredImportNotice = nil + self.modNotice = { ok = true, text = "Imported " .. tostring(importId) + .. " for " .. tostring(manifest.name or manifest.id) .. "." } + self:_refreshMods() + return true + end + requiredImportNotice(self, modId, importId, result) + self.modNotice = nil + return nil + end local data = love.filesystem.read(source) if not data then data = readExternalPath(source) end if not data then diff --git a/src/mods/ImportAccess.lua b/src/mods/ImportAccess.lua new file mode 100644 index 00000000..11f32380 --- /dev/null +++ b/src/mods/ImportAccess.lua @@ -0,0 +1,174 @@ +-- Scoped access to a mod's launcher-validated required/optional imports and +-- to installation-wide generated cache data. +-- +-- This intentionally does not expose host paths or raw filesystem handles. +-- Import reads are bounded and can only address ids declared by the calling +-- mod's manifest. Cache paths are confined to mod_cache// and are not +-- tied to a Pokémon playthrough. + +local RequiredImports = require("src.mods.RequiredImports") +local SafePath = require("src.mods.SafePath") +local SaveData = require("src.core.SaveData") + +local ImportAccess = {} + +ImportAccess.MAX_READ_BYTES = 8 * 1024 * 1024 +ImportAccess.MAX_CACHE_WRITE_BYTES = 64 * 1024 * 1024 + +local function specMap(manifest) + local out = {} + for _, spec in ipairs(RequiredImports.specs(manifest)) do out[spec.id] = spec end + return out +end + +local function parentOf(path) + return path:match("^(.*)/[^/]+$") +end + +local function copyInfo(info) + if not info then return nil end + return { type = info.type, size = info.size, modtime = info.modtime } +end + +local function fsReadRange(fs, path, offset, length) + if fs and type(fs.readRange) == "function" then + return fs.readRange(path, offset, length) + end + local newFile = fs and fs.newFile + if newFile then + local file, makeErr = newFile(path) + if not file then return nil, makeErr or "could not open import" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open import" end + local seekOk, seekErr = file:seek(offset) + if seekOk == nil or seekOk == false then + file:close() + return nil, seekErr or "could not seek import" + end + local data, readErr = file:read(length) + file:close() + return data, readErr + end + -- Injectable headless filesystems may expose only read(). Production + -- love.filesystem has newFile(), so large imports are never materialized + -- into one Lua string by this fallback. + if fs and fs.read then + local data = fs.read(path) + if type(data) ~= "string" then return nil, "could not read import" end + return data:sub(offset + 1, offset + length) + end + return nil, "random-access import reads are unavailable" +end + +local function validatedInfo(manifest, spec, fs) + local ok, detail = RequiredImports.validateStored(manifest, spec, fs) + if not ok then return nil, detail or "import is not validated" end + local path = RequiredImports.path(manifest, spec) + local info = fs.getInfo and fs.getInfo(path, "file") or nil + if not info then return nil, "import is missing" end + return info, detail +end + +local function makeCache(modId, fs) + local root = "mod_cache/" .. modId + local function pathFor(rel, what) + rel = SafePath.require(rel, what or "mod.cache path") + return root .. "/" .. rel + end + local cache = {} + + function cache:write(rel, bytes) + if type(bytes) ~= "string" then + return nil, "mod.cache:write expects a byte string" + end + if #bytes > ImportAccess.MAX_CACHE_WRITE_BYTES then + return nil, "mod.cache:write payload exceeds 64 MiB; split generated data into smaller files" + end + local path = pathFor(rel, "mod.cache:write") + local parent = parentOf(path) + if parent and fs.createDirectory then + local ok = fs.createDirectory(parent) + if ok == false then return nil, "could not create cache directory" end + end + if not fs.write then return nil, "cache writes are unavailable" end + return fs.write(path, bytes) + end + + function cache:read(rel) + local path = pathFor(rel, "mod.cache:read") + if not fs.read then return nil, "cache reads are unavailable" end + return fs.read(path) + end + + function cache:info(rel) + local path = pathFor(rel, "mod.cache:info") + if not fs.getInfo then return nil end + return copyInfo(fs.getInfo(path)) + end + + function cache:exists(rel) + local info = self:info(rel) + return info ~= nil and info.type == "file" + end + + + function cache:delete(rel) + local path = pathFor(rel, "mod.cache:delete") + if not fs.remove then return nil, "cache deletion is unavailable" end + return fs.remove(path) + end + + return cache +end + +function ImportAccess.new(manifest, fs) + local specs = specMap(manifest) + local cacheFs = SaveData.persistenceFs(fs) or fs + local imports = {} + + function imports:info(id) + local spec = specs[id] + if not spec then return nil, "undeclared import: " .. tostring(id) end + local info, digestOrErr = validatedInfo(manifest, spec, fs) + if not info then return nil, digestOrErr end + return { + id = spec.id, + name = spec.name, + file = spec.file, + size = info.size, + md5 = digestOrErr, + required = spec.required ~= false, + } + end + + function imports:read(id, offset, length) + local spec = specs[id] + if not spec then return nil, "undeclared import: " .. tostring(id) end + offset, length = tonumber(offset), tonumber(length) + if not offset or offset < 0 or offset % 1 ~= 0 then + return nil, "offset must be a non-negative integer" + end + if not length or length < 0 or length % 1 ~= 0 then + return nil, "length must be a non-negative integer" + end + if length > ImportAccess.MAX_READ_BYTES then + return nil, "single import read exceeds 8 MiB" + end + + local info, err = validatedInfo(manifest, spec, fs) + if not info then return nil, err end + local size = tonumber(info.size) or tonumber(spec.size) + if size and offset + length > size then return nil, "import read is out of bounds" end + if length == 0 then return "" end + + local path = RequiredImports.path(manifest, spec) + local data, readErr = fsReadRange(fs, path, offset, length) + if not data then return nil, readErr end + if #data ~= length then return nil, "short import read" end + return data + end + + return imports, makeCache(manifest.id, cacheFs) +end + +return ImportAccess diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 8ec4767d..ddfc3f12 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -962,6 +962,8 @@ function Loader:_api(mod) local Storage = engineRequire("src.mods.Storage") local storage = Storage and Storage.new(modId, loader.fs) local Checkpoint = engineRequire("src.core.Checkpoint") + local ImportAccess = engineRequire("src.mods.ImportAccess") + local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs) local api = { id = modId, version = mod.manifest.version, @@ -1161,6 +1163,12 @@ function Loader:_api(mod) -- checkpoint. The -- engine binds version/playthrough/mod scope and portable persistence; -- callers never receive paths or a raw filesystem handle. + -- Read-only bounded access to this mod's manifest-declared, launcher-validated + -- imports. No host path is exposed; large sources are read in bounded ranges. + imports = importApi, + -- Installation-scoped generated data, independent from Pokémon save slots. + -- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped. + cache = installCache, storage = { context = function(_, game) return storage:context(game) end, selected = function(_, game) return storage:selected(game) end, diff --git a/src/mods/RequiredImports.lua b/src/mods/RequiredImports.lua index 806e916e..cfdb9b42 100644 --- a/src/mods/RequiredImports.lua +++ b/src/mods/RequiredImports.lua @@ -120,6 +120,36 @@ local function accepts(spec, digest) return false end +local function specById(manifest, importId) + for _, candidate in ipairs(allSpecs(manifest)) do + if candidate.id == importId then return candidate end + end + return nil +end + +RequiredImports.spec = specById + +local function streamDigest(fs, path, chunkBytes) + if not (fs and fs.newFile) then return nil, "streaming file access is unavailable" end + local file, makeErr = fs.newFile(path) + if not file then return nil, makeErr or "could not open stored import" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open stored import" end + local MD5 = require("src.mods.StreamMD5") + local ctx = MD5.new() + chunkBytes = chunkBytes or (4 * 1024 * 1024) + while true do + local data, readErr = file:read(chunkBytes) + if data and #data > 0 then ctx:update(data) end + if not data or #data < chunkBytes then + if readErr then file:close(); return nil, readErr end + break + end + end + file:close() + return ctx:final() +end + function RequiredImports.path(manifest, spec) return manifest.path .. "/baseroms/" .. spec.file end @@ -180,6 +210,47 @@ local function removeReceipt(manifest, spec, fs) end end +-- Finalize a caller-streamed import after the destination bytes have already +-- been copied into the engine-owned baseroms path. This keeps large imports +-- out of a single Lua string while preserving the same size/MD5 receipt rules. +function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs) + fs = fs or (love and love.filesystem) + local spec = specById(manifest, importId) + if not spec then return nil, "unknown required import: " .. tostring(importId) end + digest = tostring(digest or ""):lower() + if not accepts(spec, digest) then + return nil, ("MD5 mismatch (got %s)"):format(digest ~= "" and digest or "unavailable") + end + local path = RequiredImports.path(manifest, spec) + local info = fs and fs.getInfo and fs.getInfo(path, "file") or nil + if not info then return nil, "copied import is missing" end + local sizeErr = RequiredImports.sizeError(spec, info.size, true) + if sizeErr then return nil, sizeErr end + if love and fs == love.filesystem then + local savedPrefix = CacheFs.prefix + local ok, prefixErr = xpcall(function() + CacheFs.prefix = "" + CacheFs.remove(removedMarker(manifest, spec)) + CacheFs.prefix = savedPrefix + -- writeReceipt has its own temporary CacheFs prefix switch. Keep it + -- inside this guard too so a write error cannot leak global state. + writeReceipt(manifest, spec, digest, info, fs) + end, function(err) + return tostring(err) + end) + CacheFs.prefix = savedPrefix + if not ok then + return nil, "could not finalize import receipt: " .. tostring(prefixErr) + end + return true, digest + elseif fs and fs.remove then + fs.remove(removedMarker(manifest, spec)) + end + writeReceipt(manifest, spec, digest, info, fs) + return true, digest +end + + -- Validate bytes against a declaration. The returned data is canonicalized -- (notably for N64 byte order/header variants) and is what must be stored. function RequiredImports.validateData(spec, data, hashFn) @@ -217,6 +288,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn) local cached = cachedDigest(manifest, spec, fs, info) if cached then return true, cached, true end removeReceipt(manifest, spec, fs) + -- Large raw imports (GameCube discs, future optical images, etc.) must never + -- be materialized into one Lua string merely because their validation + -- receipt was lost. Stream the MD5 directly from the installed file. N64 + -- sources stay on the canonicalization path because byte-order/header + -- normalization is part of their validation contract. + if info.size and info.size > RequiredImports.LARGE_WARN_BYTES + and spec.format ~= "n64" and fs.newFile then + local digest, hashErr = streamDigest(fs, path) + if not digest then return nil, hashErr end + if not accepts(spec, digest) then + return nil, ("MD5 mismatch (got %s)"):format(digest) + end + info = fs.getInfo(path, "file") or info + writeReceipt(manifest, spec, digest, info, fs) + return true, digest, false + end if not fs.read then return nil, "file could not be read" end local data = fs.read(path) local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn) diff --git a/src/mods/StreamMD5.lua b/src/mods/StreamMD5.lua new file mode 100644 index 00000000..1990e8ec --- /dev/null +++ b/src/mods/StreamMD5.lua @@ -0,0 +1,101 @@ +local bitlib = rawget(_G, "bit") or rawget(_G, "bit32") +if not bitlib then error("StreamMD5 requires bit or bit32") end + +local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot +local lshift, rshift = bitlib.lshift, bitlib.rshift +local rol = bitlib.rol or bitlib.lrotate + +local K = { + 0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501, + 0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821, + 0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8, + 0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a, + 0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70, + 0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665, + 0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1, + 0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391, +} +local S = { + 7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22, + 5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20, + 4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23, + 6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21, +} + +local function add32(a,b,c,d) + local n = (a or 0) + (b or 0) + (c or 0) + (d or 0) + return band(n, 0xffffffff) +end + +local function le32_from(s, i) + local b1,b2,b3,b4 = s:byte(i, i+3) + return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24)) +end + +local function le32_bytes(x) + return string.char( + band(x,0xff), band(rshift(x,8),0xff), + band(rshift(x,16),0xff), band(rshift(x,24),0xff)) +end + +local M = {} +local StreamMD5 = {} +StreamMD5.__index = StreamMD5 + +function StreamMD5.new() + return setmetatable({ + a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476, + bytes=0, buffer="", done=false, + }, StreamMD5) +end + +function StreamMD5:_block(block) + for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end + local a,b,c,d = self.a,self.b,self.c,self.d + for i=0,63 do + local f,g + if i < 16 then + f = bor(band(b,c), band(bnot(b),d)); g=i + elseif i < 32 then + f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16 + elseif i < 48 then + f = bxor(b,c,d); g=(3*i+5)%16 + else + f = bxor(c, bor(b,bnot(d))); g=(7*i)%16 + end + local tmp=d + d=c + c=b + b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1])) + a=tmp + end + self.a=add32(self.a,a); self.b=add32(self.b,b) + self.c=add32(self.c,c); self.d=add32(self.d,d) +end + +function StreamMD5:update(data) + assert(not self.done, "StreamMD5 context already finalized") + assert(type(data)=="string", "StreamMD5:update expects a string") + self.bytes = self.bytes + #data + local s = self.buffer .. data + local full = #s - (#s % 64) + for i=1,full,64 do self:_block(s:sub(i,i+63)) end + self.buffer = s:sub(full+1) + return self +end + +function StreamMD5:final() + assert(not self.done, "StreamMD5 context already finalized") + local originalBytes = self.bytes + local padLen = (56 - ((originalBytes + 1) % 64)) % 64 + local bits = originalBytes * 8 + local lo = bits % 4294967296 + local hi = math.floor(bits / 4294967296) % 4294967296 + self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi)) + assert(#self.buffer == 0, "MD5 finalization left a partial block") + self.done=true + local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d) + return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end)) +end + +return StreamMD5 diff --git a/tests/engine/battle_charge_required.lua b/tests/engine/battle_charge_required.lua new file mode 100644 index 00000000..c046639a --- /dev/null +++ b/tests/engine/battle_charge_required.lua @@ -0,0 +1,345 @@ +-- Public, shared battle.charge_required hook. +-- +-- A mod that adds weather to Gen 1 needs to let SolarBeam resolve on the +-- turn it is selected without replacing the move, mutating private battle +-- state, or reimplementing the damage pipeline. This case loads a real +-- sandboxed mod through the public SDK and drives the real Gen 1 and Gold +-- battle engines. It also pins each generation's empty-chain decision. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Font = require("src.render.Font") +local Gen2Battle = require("src.battle.gen2.Battle") +local Gen2Mon = require("src.battle.gen2.Mon") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") + +local function lowRoll(a) + return a or 0 +end + +local function queueHasText(battle, fragment) + for _, row in ipairs(battle.queue or {}) do + if row.text and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function eventHasText(battle, fragment) + for _, row in ipairs(battle.events or {}) do + if row.kind == "message" and row.text + and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function gen1Data() + local data = T.fixtures.fresh() + data.moves.SOLARBEAM = { + id = "SOLARBEAM", index = 80, name = "SOLARBEAM", type = "GRASS", + power = 120, accuracy = 100, pp = 10, effect = "CHARGE_EFFECT", + } + data.moves.FLY = { + id = "FLY", index = 81, name = "FLY", type = "FLYING", + power = 70, accuracy = 95, pp = 15, effect = "FLY_EFFECT", + } + data.moves.DIG = { + id = "DIG", index = 82, name = "DIG", type = "GROUND", + power = 100, accuracy = 100, pp = 10, effect = "FLY_EFFECT", + } + Font.load(data) + TypeChart.load(data) + return data +end + +local function gen1Battle(data, moveId) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local move = { id = moveId, pp = 10, maxPp = 10 } + save.party[1].moves = { move } + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + local game = { data = data, save = save, stack = stack, + input = { wasPressed = function() return false end, + isDown = function() return false end } } + local battle = BattleState.newWild(game, "FIXMON_B", 20) + battle.rng = lowRoll + return battle, battle.player, battle.enemy, move +end + +local G2_TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, +} + +local G2_MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 100, pp = 35, + effect = "EFFECT_NORMAL_HIT" }, + SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120, + type = "GRASS", accuracy = 100, pp = 10, + effect = "EFFECT_SOLARBEAM" }, + FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING", + accuracy = 95, pp = 15, effect = "EFFECT_FLY" }, + DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND", + accuracy = 100, pp = 10, effect = "EFFECT_FLY" }, +} + +local G2_DATA = { + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + MACHOP = { + id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = {}, evolutions = {}, + }, + }, + moves = G2_MOVES, + type_chart = { types = G2_TYPES, matchups = {} }, + items = {}, +} + +local G2_DVS = { attack = 15, defense = 15, speed = 15, special = 15 } +G2_DVS.hp = Gen2Mon.hpDV(G2_DVS) + +local function gen2Battle(moveId, weather) + local player = Gen2Mon.new(G2_DATA, "MACHOP", 30, { dvs = G2_DVS }) + local move = { id = moveId, pp = 10, maxPp = 10 } + player.moves = { move } + local wild = Gen2Mon.new(G2_DATA, "MACHOP", 20, { dvs = G2_DVS }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Gen2Battle.new({ data = G2_DATA, party = { player }, + wild = wild, random = function(n) return math.max(0, (n or 1) - 1) end }) + battle.weather = weather + return battle, player, wild, move +end + +-- No-mod parity: Red always charges a charge-capable move on initial use, +-- spends PP only on that initial use, and resolves on the continuation. +do + local run = T.sdk.loadNone() + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.eq(enemy.mon.hp, hp, "Gen 1 no-mod initial SolarBeam only charges") + T.eq(player.charging, move, "Gen 1 no-mod stores the selected move") + T.eq(move.pp, 9, "Gen 1 no-mod charge spends one PP") + T.check(queueHasText(battle, "took in sunlight"), + "Gen 1 no-mod keeps the charge text") + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, "Gen 1 no-mod continuation resolves damage") + T.eq(move.pp, 9, "Gen 1 no-mod continuation spends no second PP") + run.release() +end + +-- No-mod parity: Gold's native answer remains weather-sensitive. Solarbeam +-- charges without sun and skips charge under sun. +do + local run = T.sdk.loadNone({ generation = 2 }) + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.eq(wild.hp, hp, "Gold no-mod initial Solarbeam charges without sun") + T.eq(player.volatile.chargeMove, "SOLARBEAM", + "Gold no-mod stores Solarbeam without sun") + T.eq(move.pp, 9, "Gold no-mod charge spends one PP") + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, "Gold no-mod continuation resolves damage") + T.eq(player.volatile.chargeMove, nil, + "Gold no-mod continuation clears stored charge state") + T.eq(move.pp, 9, "Gold no-mod continuation spends no second PP") + + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, "Gold no-mod sun skips Solarbeam charge") + T.eq(player.volatile.chargeMove, nil, + "Gold no-mod sun stores no charge continuation") + T.eq(move.pp, 9, "Gold no-mod sun still spends exactly one PP") + run.release() +end + +local MOD = { + ["mods/charge_probe/manifest.json"] = [[{ + "id": "charge_probe", + "name": "Charge Required Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/charge_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.charge_required", function(nextFn, ctx) + mod.exports.calls = (mod.exports.calls or 0) + 1 + mod.exports.last = { + battle = ctx.battle ~= nil, + user = ctx.user ~= nil, + target = ctx.target ~= nil, + move = ctx.move and ctx.move.id, + charge = ctx.charge, + isCalled = ctx.isCalled, + } + if ctx.move.id == "SOLARBEAM" then return false end + return nextFn(ctx) + end) + ]], +} + +-- Public mod API, Gen 1: one conditional false resolves through the ordinary +-- damage pipeline on the first turn. Fly and Dig keep their vanilla charge +-- state, invulnerability, PP, and text; the release does not call the hook. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), + }) + T.eq(#run.errors, 0, + "the public charge hook mod loads clean (" .. tostring(run.errors[1]) .. ")") + local data = gen1Data() + local battle, player, enemy, move = gen1Battle(data, "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, + "a public Gen 1 hook can resolve SolarBeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gen 1 resolution spends one PP") + T.eq(player.charging, nil, "the bypass creates no Gen 1 continuation") + T.check(not queueHasText(battle, "took in sunlight"), + "the bypass emits no Gen 1 charge text") + + local out = run.loader.exports.charge_probe or {} + T.eq(out.calls, 1, "the public Gen 1 hook fires once on initial use") + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "the public Gen 1 hook receives the generation-neutral context") + + for _, id in ipairs({ "FLY", "DIG" }) do + battle, player, enemy, move = gen1Battle(data, id) + local beforeCalls = out.calls or 0 + battle:performMove(player, enemy, move) + T.eq(player.charging, move, id .. " still charges through next(ctx)") + T.eq(player.invulnerable, true, id .. " still becomes invulnerable") + T.eq(move.pp, 9, id .. " still spends one PP on its charge turn") + T.check(queueHasText(battle, id == "FLY" and "flew up" or "dug a hole"), + id .. " still emits its charge text") + T.eq(out.calls, beforeCalls + 1, id .. " calls the hook on initial use") + battle:performMove(player, enemy, move) + T.eq(out.calls, beforeCalls + 1, + id .. " release does not call the initial-use hook again") + T.eq(move.pp, 9, id .. " release spends no second PP") + end + + -- Called charge-capable moves get the same initial-use seam and say so. + battle, player, enemy, move = gen1Battle(data, "FLY") + battle:performMove(player, enemy, move, true) + out = run.loader.exports.charge_probe or {} + T.eq(out.last and out.last.isCalled, true, + "the Gen 1 context marks a called charge move") + T.eq(move.pp, 10, "a called Gen 1 charge move keeps called-move PP semantics") + run.release() +end + +-- Public mod API, Gold: the same wrapper bypasses the ordinary no-sun charge +-- branch, preserves one PP spend, and emits no charge text. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), generation = 2, + }) + T.eq(#run.errors, 0, + "the shared charge hook mod loads clean on Gold") + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "the public Gold hook resolves Solarbeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gold resolution spends one PP") + T.eq(player.volatile.chargeMove, nil, + "the bypass creates no Gold charge continuation") + T.check(not eventHasText(battle, "took in sunlight"), + "the bypass emits no Gold charge text") + local out = run.loader.exports.charge_probe or {} + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "Gold receives the same generation-neutral hook context") + + local calls = out.calls + battle, player, wild, move = gen2Battle("DIG") + hp = wild.hp + battle:useMove(player, wild, "DIG") + T.eq(wild.hp, hp, "Gold next(ctx) keeps the initial charge turn") + T.eq(player.volatile.chargeMove, "DIG", + "Gold next(ctx) stores the selected charge move") + T.eq(player.volatile.vanished, true, + "Gold next(ctx) preserves semi-invulnerability") + T.eq(move.pp, 9, "Gold next(ctx) spends one PP on the charge turn") + T.eq(out.calls, calls + 1, + "Gold next(ctx) invokes the hook once on initial use") + battle:useMove(player, wild, "DIG") + T.check(wild.hp < hp, "Gold next(ctx) release resolves damage") + T.eq(out.calls, calls + 1, + "Gold release does not invoke the charge hook again") + T.eq(move.pp, 9, "Gold release spends no second PP") + + calls = out.calls + battle, player, wild, move = gen2Battle("TACKLE") + battle.copyDepth = 1 + battle:useMove(player, wild, "FLY") + T.eq(out.calls, calls + 1, "a called Gold charge move invokes the hook") + T.eq(out.last and out.last.isCalled, true, + "the Gold context marks a called charge move") + T.eq(move.pp, 10, "a called Gold move spends no known-move PP") + T.eq(player.volatile.chargeMove, "FLY", + "a called Gold charge move can keep its charge through next(ctx)") + + calls = out.calls + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "Gold native sun still resolves before the public charge decision") + T.eq(out.calls, calls, + "the hook does not run when the active rules already skip charge") + run.release() +end + +-- Guard parity: with no subscriber, neither generation reaches Runtime.call; +-- both take their vanilla decision without constructing/dispatching a ctx. +do + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local battle2, player2, enemy2 = gen2Battle("SOLARBEAM") + local oldWants, oldCall = Runtime.wantsHook, Runtime.call + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gen 1 guard checks the hook name") + return false + end + Runtime.call = function() + error("unsubscribed charge hot path dispatched", 0) + end + local ok, err = pcall(battle.performMove, battle, player, enemy, move) + T.check(ok, "the guarded Gen 1 hot path does not dispatch: " .. tostring(err)) + + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gold guard checks the hook name") + return false + end + ok, err = pcall(battle2.useMove, battle2, player2, enemy2, "SOLARBEAM") + T.check(ok, "the guarded Gold hot path does not dispatch: " .. tostring(err)) + Runtime.wantsHook, Runtime.call = oldWants, oldCall +end + +T.finish("battle charge required") diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index beabb51c..01558e97 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -402,7 +402,8 @@ local GEN2_HOOKS = { "ui.pc.items", "ui.list_menu", "transition.style", -- battle - "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", + "battle.damage", "battle.crit", "battle.accuracy", + "battle.charge_required", "battle.turn_order", "battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain", "catch.rate", "trainer.party", -- one wrap cancels or forces an evolution in either game: Gold passes `data` diff --git a/tests/engine/mod_import_access_parity_test.lua b/tests/engine/mod_import_access_parity_test.lua new file mode 100644 index 00000000..fc2f7c73 --- /dev/null +++ b/tests/engine/mod_import_access_parity_test.lua @@ -0,0 +1,42 @@ +-- No-mod and API-v1 parity coverage for additive mod.imports/mod.cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- No mod installed: no generated cache namespace is touched. +local emptyFiles = {} +local none = T.sdk.loadNone({ fs = T.sdk.memfs(emptyFiles) }) +T.eq(#none.errors, 0, "zero-mod load remains clean") +for path in pairs(emptyFiles) do + T.check(path:sub(1, 10) ~= "mod_cache/", + "zero-mod load creates no installation cache data") +end +none.release() + +-- Existing API-v1 style file access still behaves exactly as before. The new +-- facades are additive members; mod:read remains the same scoped read path. +local files = { + ["mods/v1_read_probe/manifest.json"] = [[{ + "id": "v1_read_probe", + "name": "V1 Read Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 1 + }]], + ["mods/v1_read_probe/main.lua"] = [[ + local mod = ... + mod.exports.payload = mod:read("payload.txt") + mod.exports.hasImports = type(mod.imports) == "table" + mod.exports.hasCache = type(mod.cache) == "table" + ]], + ["mods/v1_read_probe/payload.txt"] = "unchanged-v1-read", +} +local run = T.sdk.loadMods({ "mods/v1_read_probe" }, { fs = T.sdk.memfs(files) }) +T.eq(#run.errors, 0, "API-v1 probe still loads") +local out = run.loader.exports.v1_read_probe +T.eq(out.payload, "unchanged-v1-read", "mod:read keeps its v1 behavior") +T.eq(out.hasImports, true, "new import facade is additive") +T.eq(out.hasCache, true, "new cache facade is additive") +run.release() + +T.finish("mod_import_access_parity") diff --git a/tests/engine/mod_import_access_test.lua b/tests/engine/mod_import_access_test.lua new file mode 100644 index 00000000..f861e73f --- /dev/null +++ b/tests/engine/mod_import_access_test.lua @@ -0,0 +1,105 @@ +-- Public mod-API coverage for bounded validated imports + installation cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local digest = "00000000000000000000000000000000" +local files = { + ["mods/import_api_probe/manifest.json"] = [[{ + "id": "import_api_probe", + "name": "Import API Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "required_imports": [{ + "id": "disc", + "name": "Disc", + "file": "source.iso", + "md5": ["00000000000000000000000000000000"], + "size": 16 + }], + "optional_imports": [{ + "id": "optional", + "name": "Optional", + "file": "optional.bin", + "md5": ["11111111111111111111111111111111"], + "size": 4, + "required": false + }] + }]], + ["mods/import_api_probe/main.lua"] = [[ + local mod = ... + local info, infoErr = mod.imports:info("disc") + local slice, sliceErr = mod.imports:read("disc", 4, 6) + local oob, oobErr = mod.imports:read("disc", 15, 2) + local missing, missingErr = mod.imports:info("optional") + local undeclared, undeclaredErr = mod.imports:read("not_declared", 0, 1) + + local wrote, writeErr = mod.cache:write("extract/v1/probe.bin", "abc") + local cached, readErr = mod.cache:read("extract/v1/probe.bin") + local cacheInfo = mod.cache:info("extract/v1/probe.bin") + local escaped = pcall(function() mod.cache:write("../escape.bin", "x") end) + + mod.exports.result = { + info = info, infoErr = infoErr, + slice = slice, sliceErr = sliceErr, + oob = oob, oobErr = oobErr, + missing = missing, missingErr = missingErr, + undeclared = undeclared, undeclaredErr = undeclaredErr, + wrote = wrote, writeErr = writeErr, + cached = cached, readErr = readErr, + cacheInfo = cacheInfo, + escaped = escaped, + } + ]], + ["mods/import_api_probe/baseroms/source.iso"] = "0123456789abcdef", + ["mods/import_api_probe/baseroms/.required-import-disc.validated"] = + "v1\n" .. digest .. "\n16\n1\n", +} + +local baseFs = T.sdk.memfs(files) +local oldInfo = baseFs.getInfo +function baseFs.getInfo(path) + local info = oldInfo(path) + if info and info.type == "file" then + return { type = "file", size = #(files[path] or ""), modtime = 1 } + end + return info +end +function baseFs.readRange(path, offset, length) + local body = files[path] + if not body then return nil end + return body:sub(offset + 1, offset + length) +end +function baseFs.createDirectory() return true end +function baseFs.remove(path) files[path] = nil return true end + +local run = T.sdk.loadMods({ "mods/import_api_probe" }, { fs = baseFs }) +T.eq(#run.errors, 0, + "the import/cache probe loads through the production Loader") + +local out = run.loader.exports.import_api_probe.result +T.check(type(out.info) == "table", "declared validated import has info") +T.eq(out.info.size, 16, "import info reports stored size") +T.eq(out.slice, "456789", "bounded read seeks into the validated import") +T.eq(out.sliceErr, nil, "bounded read has no error") +T.eq(out.oob, nil, "out-of-bounds read is refused") +T.check(type(out.oobErr) == "string" and out.oobErr:find("out of bounds", 1, true), + "out-of-bounds read explains the refusal") +T.eq(out.missing, nil, "missing optional import is not exposed") +T.check(type(out.missingErr) == "string", "missing optional import returns an error") +T.eq(out.undeclared, nil, "undeclared import id is refused") +T.check(type(out.undeclaredErr) == "string" + and out.undeclaredErr:find("undeclared", 1, true), + "undeclared import refusal is explicit") + +T.check(out.wrote == true, "installation cache write succeeds") +T.eq(out.cached, "abc", "installation cache read returns exact bytes") +T.eq(out.cacheInfo and out.cacheInfo.size, 3, "installation cache info is scoped") +T.eq(out.escaped, false, "cache traversal is rejected by the public facade") +T.eq(files["mod_cache/import_api_probe/extract/v1/probe.bin"], "abc", + "cache bytes live under the calling mod id") +T.eq(files["escape.bin"], nil, "cache traversal created nothing outside its root") + +run.release() +T.finish("mod_import_access") diff --git a/tests/engine/required_import_streaming_test.lua b/tests/engine/required_import_streaming_test.lua new file mode 100644 index 00000000..1de44ddf --- /dev/null +++ b/tests/engine/required_import_streaming_test.lua @@ -0,0 +1,183 @@ +-- Regression for large raw required imports: direct source -> engine-owned +-- baseroms destination, incremental MD5, no whole-file staging requirement. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local RomImporter = require("src.import.RomImporter") +local RequiredImports = require("src.mods.RequiredImports") + +-- The stock headless filesystem intentionally omits love.filesystem.newFile. +-- Add the smallest streaming-file facade this transport path needs so the +-- regression drives the same seek/read/write API production LÖVE provides. +local oldNewFile = love.filesystem.newFile +local oldGetInfo = love.filesystem.getInfo +love.filesystem.newFile = function(path) + local body, pos, mode = love.filesystem.read(path) or "", 1, nil + local file = {} + function file:open(m) + mode = m + if m == "w" then body, pos = "", 1 else pos = 1 end + return true + end + function file:read(n) + local out = body:sub(pos, pos + n - 1) + pos = pos + #out + if out == "" then return nil end + return out + end + function file:write(bytes) + if mode ~= "w" then return nil, "not open for write" end + body = body:sub(1, pos - 1) .. bytes .. body:sub(pos + #bytes) + pos = pos + #bytes + love.filesystem.write(path, body) + return true + end + function file:seek(offset) pos = offset + 1 return offset end + function file:getSize() return #body end + function file:close() + if mode == "w" then love.filesystem.write(path, body) end + mode = nil + return true + end + return file +end +love.filesystem.getInfo = function(path, filter) + local info = oldGetInfo(path, filter) + if info and info.type == "file" then + local bytes = love.filesystem.read(path) or "" + return { type = "file", size = #bytes, modtime = 1 } + end + return info +end + +local source = "required_import_streaming_source.tmp" +local f = assert(io.open(source, "wb")) +f:write("abc") +f:close() + +local manifest = { + id = "stream_probe", + name = "Stream Probe", + path = "mods/stream_probe", + required_imports = { + { id = "source", name = "Source", file = "source.bin", format = "raw", + size = 3, md5 = { "900150983cd24fb0d6963f7d28e17f72" } }, + }, + optional_imports = {}, +} + +local importer = setmetatable({ + mods = { { id = "stream_probe", manifest = manifest } }, + requiredImportNotice = nil, + modNotice = nil, + _refreshMods = function(self) self._refreshed = true end, +}, RomImporter) + +-- Reproduce the Windows failure seen with a 1.46 GiB optical-disc image: +-- after the user accepts the "Large import" confirmation, the old path calls +-- love.filesystem.read(source) and attempts to materialize the entire source as +-- one Lua string. A large raw source must go straight to the streaming branch. +local ordinaryLoveRead = love.filesystem.read +local forbiddenWholeSourceReads = 0 +love.filesystem.read = function(path, ...) + if path == source then + forbiddenWholeSourceReads = forbiddenWholeSourceReads + 1 + error("large external required import used whole-file love.filesystem.read") + end + return ordinaryLoveRead(path, ...) +end + +-- Exercise the exact large-file branch with tiny fixture bytes by lowering the +-- threshold for this test. Production keeps the 128 MiB confirmation/streaming +-- threshold; the copy/hash algorithm is identical. +local oldWarn = RequiredImports.LARGE_WARN_BYTES +RequiredImports.LARGE_WARN_BYTES = 2 +local ok = importer:_importRequiredSource("stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +love.filesystem.read = ordinaryLoveRead + +T.eq(ok, true, "large raw required import streams successfully") +T.eq(forbiddenWholeSourceReads, 0, + "post-confirm large import never materializes the external source as one Lua string") +T.eq(love.filesystem.read("mods/stream_probe/baseroms/source.bin"), "abc", + "streamed destination preserves exact source bytes") +T.eq(importer.requiredImportNotice, nil, "successful stream leaves no import error") +T.eq(importer._refreshed, true, "successful stream refreshes the mod list") + +local CacheFs = require("src.import.CacheFs") + +-- A thrown writer error must not leak the temporary empty CacheFs prefix. +local workingNewFile = love.filesystem.newFile +local workingPrefix = CacheFs.prefix +CacheFs.prefix = "sentinel/" +love.filesystem.newFile = function(path) + local file = workingNewFile(path) + if path == "mods/stream_probe/baseroms/source.bin" then + function file:write() + error("forced writer failure") + end + end + return file +end +importer.requiredImportNotice = nil +RequiredImports.LARGE_WARN_BYTES = 2 +local failedWrite = importer:_importRequiredSource( + "stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +T.eq(failedWrite, nil, "thrown streaming writer error is contained") +T.eq(CacheFs.prefix, "sentinel/", + "streaming writer error restores CacheFs.prefix") +love.filesystem.newFile = workingNewFile +CacheFs.prefix = workingPrefix + +-- acceptStoredDigest has its own prefix switch for marker/receipt I/O. +-- Even an unexpected CacheFs failure must restore the caller's prefix. +love.filesystem.write("mods/stream_probe/baseroms/source.bin", "abc") +local workingRemove = CacheFs.remove +CacheFs.prefix = "sentinel/" +CacheFs.remove = function() + error("forced marker removal failure") +end +local accepted = RequiredImports.acceptStoredDigest( + manifest, "source", "900150983cd24fb0d6963f7d28e17f72", love.filesystem) +T.eq(accepted, nil, "acceptStoredDigest contains CacheFs failure") +T.eq(CacheFs.prefix, "sentinel/", + "acceptStoredDigest failure restores CacheFs.prefix") +CacheFs.remove = workingRemove +CacheFs.prefix = workingPrefix + +-- Native sources are rejected cleanly if seek-to-start fails after the +-- size probe; never return a handle left sitting at EOF. +local realIoOpen = io.open +local fakeCloses = 0 +io.open = function(path, mode) + if path ~= source then return realIoOpen(path, mode) end + local calls = 0 + return { + seek = function(_, whence) + calls = calls + 1 + if whence == "end" then return 3 end + if whence == "set" then return nil, "forced rewind failure" end + return nil, "unexpected seek" + end, + close = function() fakeCloses = fakeCloses + 1 end, + } +end +importer.requiredImportNotice = nil +RequiredImports.LARGE_WARN_BYTES = 2 +local failedSeek = importer:_importRequiredSource( + "stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +io.open = realIoOpen +T.eq(failedSeek, nil, "failed native rewind rejects import source") +T.eq(fakeCloses >= 2, true, + "failed native rewind closes probed source handles") + +love.filesystem.remove("mods/stream_probe/baseroms/source.bin") +love.filesystem.remove("mods/stream_probe/baseroms/source.iso") +os.remove(source) +love.filesystem.newFile = oldNewFile +love.filesystem.getInfo = oldGetInfo + +T.finish("required_import_streaming") diff --git a/tests/engine/stream_md5_test.lua b/tests/engine/stream_md5_test.lua new file mode 100644 index 00000000..c75879b3 --- /dev/null +++ b/tests/engine/stream_md5_test.lua @@ -0,0 +1,33 @@ +-- Incremental MD5 vectors used by large required-import streaming. +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- Plain Lua runners may expose bit32 while production LuaJIT exposes bit. +if not rawget(_G, "bit") and not rawget(_G, "bit32") then + local ok, bit32 = pcall(require, "bit32") + if ok then _G.bit32 = bit32 end +end + +local T = require("tests.modkit") +local MD5 = require("src.mods.StreamMD5") + +local vectors = { + { "", "d41d8cd98f00b204e9800998ecf8427e" }, + { "a", "0cc175b9c0f1b6a831c399e269772661" }, + { "abc", "900150983cd24fb0d6963f7d28e17f72" }, + { "message digest", "f96b697d7cb7938d525a2f31aaf161d0" }, + { "abcdefghijklmnopqrstuvwxyz", "c3fcd3d76192e4007dfb496cca67e13b" }, +} + +for _, row in ipairs(vectors) do + local ctx = MD5.new() + for i = 1, #row[1], 3 do ctx:update(row[1]:sub(i, i + 2)) end + T.eq(ctx:final(), row[2], "incremental MD5 vector: " .. row[1]) +end + +-- Cross a large number of block boundaries without building one giant string. +local million = MD5.new() +for _ = 1, 1000 do million:update(string.rep("a", 1000)) end +T.eq(million:final(), "7707d6ae4e027c70eea2a935c2296f21", + "RFC 1321 million-a vector") + +T.finish("stream_md5")