mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+57
-5
@@ -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/<mod-id>/baseroms/<file>`. 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/<mod-id>/`) 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
|
||||
|
||||
@@ -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/<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:
|
||||
|
||||
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/<id>/baseroms/<file>` 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+161
-4
@@ -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")
|
||||
-- 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")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
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
|
||||
|
||||
@@ -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/<mod-id>/ 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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`
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user