mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 21:46:51 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -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